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,
} from './lib/space-detection'
export { baseMaterial, glassMaterial } from './materials'
export {
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getMaterialPresetByRef,
getMaterialsForTarget,
LIBRARY_MATERIAL_REF_PREFIX,
MATERIAL_CATALOG,
type MaterialCatalogItem,
toLibraryMaterialRef,
} from './material-library'
export * from './schema'
export {
type ControlValue,
@@ -59,9 +69,24 @@ export {
getWallPlanFootprint,
getWallThickness,
} from './systems/wall/wall-footprint'
export {
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallCurveFrameAt,
getWallCurveLength,
getWallMidpointHandlePoint,
getWallStraightSnapOffset,
getWallSurfacePolygon,
isCurvedWall,
normalizeWallCurveOffset,
sampleWallCenterline,
} from './systems/wall/wall-curve'
export {
calculateLevelMiters,
getWallMiterBoundaryPoints,
type Point2D,
type WallMiterBoundaryPoints,
pointToKey,
type WallMiterData,
} from './systems/wall/wall-mitering'
+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
export {
DEFAULT_MATERIALS,
MaterialMapPropertiesSchema,
MaterialMapsSchema,
MaterialPreset,
MaterialPresetPayloadSchema,
MaterialProperties,
MaterialSchema,
MaterialTarget,
TextureWrapMode,
resolveMaterial,
} from './material'
export type {
MaterialMapProperties,
MaterialMaps,
MaterialPresetPayload,
MaterialTarget as MaterialTargetValue,
TextureWrapMode as TextureWrapModeValue,
} from './material'
export { BuildingNode } from './nodes/building'
export { CeilingNode } from './nodes/ceiling'
export { DoorNode, DoorSegment } from './nodes/door'
+62
View File
@@ -25,6 +25,7 @@ export const MaterialProperties = z.object({
export type MaterialProperties = z.infer<typeof MaterialProperties>
export const MaterialSchema = z.object({
id: z.string().optional(),
preset: MaterialPreset.optional(),
properties: MaterialProperties.optional(),
texture: z
@@ -37,6 +38,67 @@ export const MaterialSchema = z.object({
})
export type MaterialSchema = z.infer<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> = {
white: {
color: '#ffffff',
@@ -9,6 +9,7 @@ export const CeilingNode = BaseNode.extend({
type: nodeType('ceiling'),
children: z.array(ItemNode.shape.id).default([]),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
height: z.number().default(2.5), // Height in meters
+3
View File
@@ -1,6 +1,7 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
export const FenceStyle = z.enum(['slat', 'rail', 'privacy'])
export const FenceBaseStyle = z.enum(['floating', 'grounded'])
@@ -8,6 +9,8 @@ export const FenceBaseStyle = z.enum(['floating', 'grounded'])
export const FenceNode = BaseNode.extend({
id: objectId('fence'),
type: nodeType('fence'),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]),
height: z.number().default(1.8),
@@ -11,6 +11,7 @@ export const RoofSegmentNode = BaseNode.extend({
id: objectId('rseg'),
type: nodeType('roof-segment'),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
+1
View File
@@ -8,6 +8,7 @@ export const RoofNode = BaseNode.extend({
id: objectId('roof'),
type: nodeType('roof'),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
+1
View File
@@ -7,6 +7,7 @@ export const SlabNode = BaseNode.extend({
id: objectId('slab'),
type: nodeType('slab'),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
elevation: z.number().default(0.05), // Elevation in meters
@@ -15,6 +15,7 @@ export const StairSegmentNode = BaseNode.extend({
id: objectId('sseg'),
type: nodeType('stair-segment'),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
+1
View File
@@ -16,6 +16,7 @@ export const StairNode = BaseNode.extend({
id: objectId('stair'),
type: nodeType('stair'),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
+3
View File
@@ -13,8 +13,10 @@ export const WallNode = BaseNode.extend({
.array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id]))
.default([]),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
thickness: z.number().optional(),
height: z.number().optional(),
curveOffset: z.number().optional(),
// e.g., start/end points for path
start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]),
@@ -26,6 +28,7 @@ export const WallNode = BaseNode.extend({
Wall node - used to represent a wall in the building
- thickness: thickness in meters
- height: height in meters
- curveOffset: midpoint sagitta offset used to bend the wall into an arc
- start: start point of the wall in level coordinate system
- end: end point of the wall in level coordinate system
- size: size of the wall in grid units
@@ -4,6 +4,13 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNodeId, CeilingNode } from '../../schema'
import useScene from '../../store/use-scene'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
if (!uv) return
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
}
// ============================================================================
// CEILING SYSTEM
// ============================================================================
@@ -100,6 +107,7 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG
// Rotate so the shape lies flat in X-Z plane
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
ensureUv2Attribute(geometry)
return geometry
}
@@ -10,6 +10,66 @@ type FencePart = {
scale: [number, number, number]
}
function applyFenceUVs(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
if (!(position && normal)) return
const uvs = new Float32Array(position.count * 2)
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index)
const py = position.getY(index)
const pz = position.getZ(index)
minX = Math.min(minX, px)
minY = Math.min(minY, py)
minZ = Math.min(minZ, pz)
maxX = Math.max(maxX, px)
maxY = Math.max(maxY, py)
maxZ = Math.max(maxZ, pz)
}
const width = Math.max(maxX - minX, 0.001)
const height = Math.max(maxY - minY, 0.001)
const depth = Math.max(maxZ - minZ, 0.001)
for (let index = 0; index < position.count; index += 1) {
const px = position.getX(index)
const py = position.getY(index)
const pz = position.getZ(index)
const nx = Math.abs(normal.getX(index))
const ny = Math.abs(normal.getY(index))
const nz = Math.abs(normal.getZ(index))
let u = 0
let v = 0
if (ny >= nx && ny >= nz) {
u = (px - minX) / width
v = (pz - minZ) / depth
} else if (nx >= nz) {
u = (pz - minZ) / depth
v = (py - minY) / height
} else {
u = (px - minX) / width
v = (py - minY) / height
}
uvs[index * 2] = u
uvs[index * 2 + 1] = v
}
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2))
}
function getStyleDefaults(style: FenceNode['style']) {
if (style === 'privacy') {
return { spacingFactor: 0.42, postFactor: 1.35, baseFactor: 1.2, topFactor: 1.2 }
@@ -103,6 +163,7 @@ function generateFenceGeometry(fence: FenceNode) {
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
geometries.forEach((geometry) => geometry.dispose())
applyFenceUVs(merged)
merged.computeVertexNormals()
return merged
}
+46 -6
View File
@@ -4,6 +4,13 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNodeId, SlabNode } from '../../schema'
import useScene from '../../store/use-scene'
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
if (!uv) return
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
}
// ============================================================================
// SLAB SYSTEM
// ============================================================================
@@ -39,6 +46,7 @@ export const SlabSystem = () => {
*/
function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {
const newGeo = generateSlabGeometry(node)
ensureUv2Attribute(newGeo)
mesh.geometry.dispose()
mesh.geometry = newGeo
@@ -157,16 +165,46 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
if (polygon.length < 3) return new THREE.BufferGeometry()
const positions: number[] = []
const uvs: number[] = []
const indices: number[] = []
const n = polygon.length
const bounds = new THREE.Box2()
for (const [x, z] of polygon) {
bounds.expandByPoint(new THREE.Vector2(x, z))
}
for (const hole of slabNode.holes ?? []) {
for (const [x, z] of hole) {
bounds.expandByPoint(new THREE.Vector2(x, z))
}
}
const floorWidth = Math.max(bounds.max.x - bounds.min.x, 0.001)
const floorHeight = Math.max(bounds.max.y - bounds.min.y, 0.001)
const pushFloorVertex = (x: number, y: number, z: number) => {
positions.push(x, y, z)
uvs.push((x - bounds.min.x) / floorWidth, (z - bounds.min.y) / floorHeight)
}
const pushWallVertex = (
x: number,
y: number,
z: number,
u: number,
v: number,
) => {
positions.push(x, y, z)
uvs.push(u, v)
}
// --- Floor at Y=0 ---
for (const [x, z] of polygon) positions.push(x!, 0, z!)
for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!)
const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!))
const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
for (const hole of slabNode.holes ?? []) {
for (const [x, z] of hole) positions.push(x!, 0, z!)
for (const [x, z] of hole) pushFloorVertex(x!, 0, z!)
}
const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d)
@@ -182,11 +220,12 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const [x0, z0] = polygon[i]!
const [x1, z1] = polygon[j]!
const vBase = positions.length / 3
const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001)
positions.push(x0!, 0, z0!) // v0 — floor level
positions.push(x1!, 0, z1!) // v1 — floor level
positions.push(x1!, depth, z1!) // v2 — ground level
positions.push(x0!, depth, z0!) // v3 — ground level
pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level
pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level
pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level
pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level
indices.push(vBase, vBase + 1, vBase + 2)
indices.push(vBase, vBase + 2, vBase + 3)
@@ -194,6 +233,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geo.setIndex(indices)
geo.computeVertexNormals()
return geo
@@ -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 Point2D, pointToKey, type WallMiterData } from './wall-mitering'
import { getWallSurfacePolygon, isCurvedWall } from './wall-curve'
import {
getWallMiterBoundaryPoints,
type Point2D,
pointToKey,
type WallMiterData,
} from './wall-mitering'
export const DEFAULT_WALL_THICKNESS = 0.1
export const DEFAULT_WALL_HEIGHT = 2.5
const CURVED_WALL_SURFACE_SEGMENTS = 24
export function getWallThickness(wallNode: WallNode): number {
return wallNode.thickness ?? DEFAULT_WALL_THICKNESS
@@ -10,25 +17,38 @@ export function getWallThickness(wallNode: WallNode): number {
export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterData): Point2D[] {
const { junctionData } = miterData
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
const thickness = getWallThickness(wallNode)
const halfT = thickness / 2
const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y }
const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) {
return []
}
const nUnit = { x: -v.y / L, y: v.x / L }
const keyStart = pointToKey(wallStart)
const keyEnd = pointToKey(wallEnd)
const startJunction = junctionData.get(keyStart)?.get(wallNode.id)
const endJunction = junctionData.get(keyEnd)?.get(wallNode.id)
if (isCurvedWall(wallNode)) {
const boundaryPoints = getWallMiterBoundaryPoints(wallNode, miterData)
if (!boundaryPoints) {
return []
}
const { startLeft, startRight, endLeft, endRight } = boundaryPoints
return getWallSurfacePolygon(wallNode, CURVED_WALL_SURFACE_SEGMENTS, {
endLeft,
endRight,
startLeft,
startRight,
})
}
const pStartLeft: Point2D = startJunction?.left || {
x: wallStart.x + nUnit.x * halfT,
y: wallStart.y + nUnit.y * halfT,
@@ -37,8 +57,6 @@ export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterDat
x: wallStart.x - nUnit.x * halfT,
y: wallStart.y - nUnit.y * halfT,
}
// Junction offsets are stored relative to the outgoing direction.
const pEndLeft: Point2D = endJunction?.right || {
x: wallEnd.x + nUnit.x * halfT,
y: wallEnd.y + nUnit.y * halfT,
+104 -4
View File
@@ -1,4 +1,5 @@
import type { WallNode } from '../../schema'
import { getWallCurveFrameAt, isCurvedWall } from './wall-curve'
// ============================================================================
// TYPES
@@ -9,6 +10,13 @@ export interface Point2D {
y: number
}
export interface WallMiterBoundaryPoints {
startLeft: Point2D
startRight: Point2D
endLeft: Point2D
endRight: Point2D
}
interface LineEquation {
a: number
b: number
@@ -127,6 +135,70 @@ function findJunctions(walls: WallNode[]): Map<string, Junction> {
return actualJunctions
}
function getWallDirectionFromJunction(
wall: WallNode,
endType: 'start' | 'end' | 'passthrough',
) {
if (endType === 'passthrough') {
return {
x: wall.end[0] - wall.start[0],
y: wall.end[1] - wall.start[1],
}
}
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return endType === 'start'
? frame.tangent
: { x: -frame.tangent.x, y: -frame.tangent.y }
}
return endType === 'start'
? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
}
function getWallBoundaryFrame(
wall: WallNode,
endType: 'start' | 'end',
) {
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1)
return {
point: frame.point,
tangent:
endType === 'start'
? frame.tangent
: { x: -frame.tangent.x, y: -frame.tangent.y },
normal: frame.normal,
}
}
const point =
endType === 'start'
? { x: wall.start[0], y: wall.start[1] }
: { x: wall.end[0], y: wall.end[1] }
const vector =
endType === 'start'
? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
const length = Math.hypot(vector.x, vector.y)
if (length < 1e-9) {
return {
point,
tangent: { x: 1, y: 0 },
normal: { x: 0, y: 1 },
}
}
return {
point,
tangent: { x: vector.x / length, y: vector.y / length },
normal: { x: -vector.y / length, y: vector.x / length },
}
}
// ============================================================================
// MITER CALCULATION (exactly like demo)
// ============================================================================
@@ -171,10 +243,7 @@ function calculateJunctionIntersections(
}
} else {
// Normal wall endpoint (start or end)
const v =
endType === 'start'
? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
const v = getWallDirectionFromJunction(wall, endType)
const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) continue
@@ -264,6 +333,37 @@ export function calculateLevelMiters(walls: WallNode[]): WallMiterData {
return { junctionData, junctions }
}
export function getWallMiterBoundaryPoints(
wall: WallNode,
miterData: WallMiterData,
): WallMiterBoundaryPoints | null {
const thickness = wall.thickness ?? 0.1
const halfThickness = thickness / 2
const startFrame = getWallBoundaryFrame(wall, 'start')
const endFrame = getWallBoundaryFrame(wall, 'end')
const startJunction = miterData.junctionData.get(pointToKey(startFrame.point))?.get(wall.id)
const endJunction = miterData.junctionData.get(pointToKey(endFrame.point))?.get(wall.id)
return {
startLeft: startJunction?.left ?? {
x: startFrame.point.x + startFrame.normal.x * halfThickness,
y: startFrame.point.y + startFrame.normal.y * halfThickness,
},
startRight: startJunction?.right ?? {
x: startFrame.point.x - startFrame.normal.x * halfThickness,
y: startFrame.point.y - startFrame.normal.y * halfThickness,
},
endLeft: endJunction?.right ?? {
x: endFrame.point.x + endFrame.normal.x * halfThickness,
y: endFrame.point.y + endFrame.normal.y * halfThickness,
},
endRight: endJunction?.left ?? {
x: endFrame.point.x - endFrame.normal.x * halfThickness,
y: endFrame.point.y - endFrame.normal.y * halfThickness,
},
}
}
/**
* Gets wall IDs that share junctions with the given walls
*/
+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 useScene from '../../store/use-scene'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
import {
calculateLevelMiters,
getAdjacentWallIds,
getWallMiterBoundaryPoints,
type Point2D,
type WallMiterData,
pointToKey,
} from './wall-mitering'
// Reusable CSG evaluator for better performance
const csgEvaluator = new Evaluator()
const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv')
if (!uv) return
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
}
function insetCurvedWallBoundaryPointsFor3D(
wall: WallNode,
boundaryPoints: ReturnType<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
@@ -163,7 +223,14 @@ export function generateExtrudedWall(
if (L < 1e-9) {
return new THREE.BufferGeometry()
}
const polyPoints = getWallPlanFootprint(wallNode, miterData)
const boundaryPoints = getWallMiterBoundaryPoints(wallNode, miterData)
const polyPoints = isCurvedWall(wallNode)
? getWallSurfacePolygon(
wallNode,
24,
insetCurvedWallBoundaryPointsFor3D(wallNode, boundaryPoints, miterData) ?? undefined,
)
: getWallPlanFootprint(wallNode, miterData)
if (polyPoints.length < 3) {
return new THREE.BufferGeometry()
}
@@ -205,6 +272,7 @@ export function generateExtrudedWall(
// Rotate so extrusion direction (Z) becomes height direction (Y)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
ensureUv2Attribute(geometry)
// Apply CSG subtraction for cutouts (doors/windows)
const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness)
@@ -239,6 +307,7 @@ export function generateExtrudedWall(
const resultGeometry = resultBrush.geometry
resultGeometry.computeVertexNormals()
ensureUv2Attribute(resultGeometry)
return resultGeometry
}
@@ -38,7 +38,7 @@ const ALLOWED_TYPES = [
'slab',
'ceiling',
]
const DELETE_ONLY_TYPES = ['wall']
const DELETE_ONLY_TYPES: string[] = []
const HOLE_TYPES = ['slab', 'ceiling']
export function FloatingActionMenu() {
@@ -48,6 +48,7 @@ export function FloatingActionMenu() {
const mode = useEditor((s) => s.mode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const setSelection = useViewer((s) => s.setSelection)
const setEditingHole = useEditor((s) => s.setEditingHole)
@@ -57,6 +58,18 @@ export function FloatingActionMenu() {
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const node = selectedId ? nodes[selectedId as AnyNodeId] : null
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
const canCurveSelectedWall =
node?.type === 'wall' &&
!(node.children ?? []).some((childId) => {
const child = nodes[childId as AnyNodeId]
if (!child) return false
if (child.type === 'door' || child.type === 'window') return true
if (child.type === 'item') {
const attachTo = child.asset?.attachTo
return attachTo === 'wall' || attachTo === 'wall-side'
}
return false
})
useFrame(() => {
if (!(selectedId && isValidType && groupRef.current)) return
@@ -84,7 +97,10 @@ export function FloatingActionMenu() {
node.type === 'item' ||
node.type === 'window' ||
node.type === 'door' ||
node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' ||
node.type === 'roof-segment' ||
node.type === 'stair' ||
@@ -96,6 +112,16 @@ export function FloatingActionMenu() {
},
[node, setMovingNode, setSelection],
)
const handleCurve = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!canCurveSelectedWall || !node || node.type !== 'wall') return
sfxEmitter.emit('sfx:item-pick')
setCurvingWall(node)
setSelection({ selectedIds: [] })
},
[canCurveSelectedWall, node, setCurvingWall, setSelection],
)
const handleDuplicate = useCallback(
(e: React.MouseEvent) => {
@@ -263,10 +289,15 @@ export function FloatingActionMenu() {
(e: React.MouseEvent) => {
e.stopPropagation()
if (!selectedId) return
if (node?.type === 'item') {
sfxEmitter.emit('sfx:item-delete')
} else {
sfxEmitter.emit('sfx:structure-delete')
}
setSelection({ selectedIds: [] })
useScene.getState().deleteNode(selectedId as AnyNodeId)
},
[selectedId, setSelection],
[node?.type, selectedId, setSelection],
)
if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
@@ -283,17 +314,14 @@ export function FloatingActionMenu() {
>
<NodeActionMenu
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={canCurveSelectedWall ? handleCurve : undefined}
onDelete={handleDelete}
onDuplicate={
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
? handleDuplicate
: undefined
}
onMove={
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
? handleMove
: undefined
}
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
/>
@@ -6,11 +6,17 @@ import {
type AnyNodeId,
type BuildingNode,
calculateLevelMiters,
type CeilingNode,
DoorNode,
emitter,
type GridEvent,
type GuideNode,
getWallChordFrame,
isCurvedWall,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
getScaledDimensions,
getWallCurveLength,
getWallPlanFootprint,
type ItemNode,
ItemNode as ItemNodeSchema,
@@ -235,6 +241,12 @@ type WallEndpointDragState = {
currentPoint: WallPlanPoint
}
type WallCurveDragState = {
pointerId: number
wallId: WallNode['id']
currentCurveOffset: number
}
const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const
type GuideCorner = (typeof GUIDE_CORNERS)[number]
@@ -276,6 +288,11 @@ type WallEndpointDraft = {
end: WallPlanPoint
}
type WallCurveDraft = {
wallId: WallNode['id']
curveOffset: number
}
type SlabBoundaryDraft = {
slabId: SlabNode['id']
polygon: WallPlanPoint[]
@@ -2176,7 +2193,7 @@ function getWallMeasurementOverlay(
): WallMeasurementOverlay | null {
const dx = wall.end[0] - wall.start[0]
const dz = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dz)
const length = getWallCurveLength(wall)
if (length < 0.1) {
return null
@@ -2425,13 +2442,19 @@ function buildGridPath(
function findClosestWallPoint(
point: WallPlanPoint,
walls: WallNode[],
maxDistance = 0.5,
options?: {
maxDistance?: number
canUseWall?: (wall: WallNode) => boolean
},
): {
wall: WallNode
point: WallPlanPoint
t: number
normal: [number, number, number]
} | null {
const maxDistance = options?.maxDistance ?? 0.5
const canUseWall = options?.canUseWall
let best: {
wall: WallNode
point: WallPlanPoint
@@ -2441,6 +2464,10 @@ function findClosestWallPoint(
let bestDistSq = maxDistance * maxDistance
for (const wall of walls) {
if (canUseWall && !canUseWall(wall)) {
continue
}
const [x1, z1] = wall.start
const [x2, z2] = wall.end
const dx = x2 - x1
@@ -4351,6 +4378,97 @@ const FloorplanWallEndpointLayer = memo(function FloorplanWallEndpointLayer({
)
})
const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLayer({
curveHandles,
hoveredHandleId,
onHandleHoverChange,
onWallCurvePointerDown,
palette,
}: {
curveHandles: Array<{
wall: WallNode
point: WallPlanPoint
isActive: boolean
}>
hoveredHandleId: string | null
onHandleHoverChange: (handleId: string | null) => void
onWallCurvePointerDown: (wall: WallNode, event: ReactPointerEvent<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({
hoveredHandleId,
midpointHandles,
@@ -4544,6 +4662,7 @@ export function FloorplanPanel() {
const guideInteractionRef = useRef<GuideInteractionState | null>(null)
const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)
const wallCurveDragRef = useRef<WallCurveDragState | null>(null)
const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null)
const slabBoundaryDraftRef = useRef<SlabBoundaryDraft | null>(null)
const zoneBoundaryDraftRef = useRef<ZoneBoundaryDraft | null>(null)
@@ -4576,6 +4695,7 @@ export function FloorplanPanel() {
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
const setMode = useEditor((state) => state.setMode)
const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
const setPhase = useEditor((state) => state.setPhase)
@@ -4680,6 +4800,22 @@ export function FloorplanPanel() {
.filter((node): node is SlabNode => node?.type === 'slab')
}),
)
const ceilings = useScene(
useShallow((state) => {
if (!levelId) {
return [] as CeilingNode[]
}
const nextLevelNode = state.nodes[levelId]
if (!nextLevelNode || nextLevelNode.type !== 'level') {
return [] as CeilingNode[]
}
return nextLevelNode.children
.map((childId) => state.nodes[childId])
.filter((node): node is CeilingNode => node?.type === 'ceiling')
}),
)
const levelGuides = useScene(
useShallow((state) => {
if (!levelId) {
@@ -4741,6 +4877,7 @@ export function FloorplanPanel() {
const [cursorPoint, setCursorPoint] = useState<WallPlanPoint | null>(null)
const [floorplanCursorPosition, setFloorplanCursorPosition] = useState<SvgPoint | 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 [hoveredWallId, setHoveredWallId] = useState<WallNode['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 [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState<string | null>(null)
const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState<string | null>(null)
const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null)
const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null)
@@ -4922,29 +5060,42 @@ export function FloorplanPanel() {
[floorplanWalls],
)
const displayWallById = useMemo(() => {
if (!wallEndpointDraft) {
return wallById
}
const wall = wallById.get(wallEndpointDraft.wallId)
if (!wall) {
if (!wallEndpointDraft && !wallCurveDraft) {
return wallById
}
const nextWallById = new Map(wallById)
if (wallEndpointDraft) {
const wall = nextWallById.get(wallEndpointDraft.wallId)
if (wall) {
nextWallById.set(
wall.id,
buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end),
)
}
}
if (wallCurveDraft) {
const wall = nextWallById.get(wallCurveDraft.wallId)
if (wall) {
nextWallById.set(wall.id, { ...wall, curveOffset: wallCurveDraft.curveOffset })
}
}
return nextWallById
}, [wallById, wallEndpointDraft])
}, [wallById, wallCurveDraft, wallEndpointDraft])
const displayFloorplanWallById = useMemo(() => {
if (!wallEndpointDraft) {
if (!wallEndpointDraft && !wallCurveDraft) {
return floorplanWallById
}
const previewWall = displayWallById.get(wallEndpointDraft.wallId)
const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId
if (!previewWallId) {
return floorplanWallById
}
const previewWall = displayWallById.get(previewWallId)
if (!previewWall) {
return floorplanWallById
}
@@ -4952,7 +5103,7 @@ export function FloorplanPanel() {
const nextFloorplanWallById = new Map(floorplanWallById)
nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall))
return nextFloorplanWallById
}, [displayWallById, floorplanWallById, wallEndpointDraft])
}, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
const wallPolygons = useMemo(
() =>
walls.map((wall) => {
@@ -4967,11 +5118,16 @@ export function FloorplanPanel() {
[floorplanWallById, wallMiterData, walls],
)
const displayWallPolygons = useMemo(() => {
if (!wallEndpointDraft) {
if (!wallEndpointDraft && !wallCurveDraft) {
return wallPolygons
}
const previewWall = displayWallById.get(wallEndpointDraft.wallId)
const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId
if (!previewWallId) {
return wallPolygons
}
const previewWall = displayWallById.get(previewWallId)
if (!previewWall) {
return wallPolygons
}
@@ -4990,7 +5146,7 @@ export function FloorplanPanel() {
}
: entry,
)
}, [displayWallById, wallEndpointDraft, wallPolygons])
}, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
const openingsPolygons = useMemo(
() =>
@@ -5046,6 +5202,29 @@ export function FloorplanPanel() {
: entry,
)
}, [slabBoundaryDraft, slabPolygons])
const ceilingPolygons = useMemo(
() =>
ceilings.flatMap((ceiling) => {
const polygon = toFloorplanPolygon(ceiling.polygon)
if (polygon.length < 3) {
return []
}
const holes = (ceiling.holes ?? [])
.map((hole) => toFloorplanPolygon(hole))
.filter((hole) => hole.length >= 3)
return [
{
ceiling,
polygon,
holes,
path: formatPolygonPath(polygon, holes),
},
]
}),
[ceilings],
)
const zonePolygons = useMemo(
() =>
zones.flatMap((zone) => {
@@ -5176,6 +5355,13 @@ export function FloorplanPanel() {
return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null
}, [floorplanItemEntries, selectedIds])
const selectedWallEntry = useMemo(() => {
if (selectedIds.length !== 1) {
return null
}
return displayWallPolygons.find(({ wall }) => wall.id === selectedIds[0]) ?? null
}, [displayWallPolygons, selectedIds])
const selectedStairEntry = useMemo(() => {
if (selectedIds.length !== 1) {
return null
@@ -5192,6 +5378,13 @@ export function FloorplanPanel() {
return displaySlabPolygons.find(({ slab }) => slab.id === selectedIds[0]) ?? null
}, [displaySlabPolygons, selectedIds])
const selectedCeilingEntry = useMemo(() => {
if (selectedIds.length !== 1) {
return null
}
return ceilingPolygons.find(({ ceiling }) => ceiling.id === selectedIds[0]) ?? null
}, [ceilingPolygons, selectedIds])
const selectedZoneEntry = useMemo(() => {
if (!selectedZoneId) {
return null
@@ -5212,12 +5405,23 @@ export function FloorplanPanel() {
const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive
const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair'
const isStairMoveActive = movingNode?.type === 'stair'
const isSlabMoveActive = movingNode?.type === 'slab'
const isCeilingMoveActive = movingNode?.type === 'ceiling'
const isWallMoveActive = movingNode?.type === 'wall'
const isWallCurveActive = curvingWall?.type === 'wall'
const isItemPlacementPreviewActive =
(mode === 'build' && tool === 'item') || movingNode?.type === 'item'
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo
const isFloorplanGridInteractionActive =
isStairBuildActive || isStairMoveActive || isFloorItemBuildActive || isFloorItemMoveActive
isStairBuildActive ||
isStairMoveActive ||
isSlabMoveActive ||
isCeilingMoveActive ||
isWallMoveActive ||
isWallCurveActive ||
isFloorItemBuildActive ||
isFloorItemMoveActive
const floorplanPreviewStairSegment = useMemo(
() =>
StairSegmentNodeSchema.parse({
@@ -5399,6 +5603,56 @@ export function FloorplanPanel() {
shouldShowPersistentWallEndpointHandles,
wallEndpointDraft,
])
const wallCurveHandles = useMemo(() => {
if (
isOpeningPlacementActive ||
movingNode ||
mode !== 'select' ||
floorplanSelectionTool !== 'click' ||
!selectedWallEntry
) {
return []
}
const hasWallChildrenBlockingCurve = (selectedWallEntry.wall.children ?? []).some((childId) => {
const childNode = levelDescendantNodeById.get(childId as AnyNodeId)
if (!childNode) {
return false
}
if (childNode.type === 'door' || childNode.type === 'window') {
return true
}
if (childNode.type === 'item') {
const attachTo = childNode.asset?.attachTo
return attachTo === 'wall' || attachTo === 'wall-side'
}
return false
})
if (hasWallChildrenBlockingCurve) {
return []
}
const centerPoint = getWallMidpointHandlePoint(selectedWallEntry.wall)
return [
{
wall: selectedWallEntry.wall,
point: [centerPoint.x, centerPoint.y] as WallPlanPoint,
isActive: wallCurveDraft?.wallId === selectedWallEntry.wall.id,
},
]
}, [
floorplanSelectionTool,
isOpeningPlacementActive,
mode,
movingNode,
levelDescendantNodeById,
selectedWallEntry,
wallCurveDraft,
])
const slabVertexHandles = useMemo(() => {
if (!shouldShowSlabBoundaryHandles) {
return []
@@ -5717,6 +5971,27 @@ export function FloorplanPanel() {
: null,
[selectedItemEntry, surfaceSize, viewBox],
)
const selectedSlabActionMenuPosition = useMemo(
() =>
selectedSlabEntry
? getFloorplanActionMenuPosition(selectedSlabEntry.polygon, viewBox, surfaceSize)
: null,
[selectedSlabEntry, surfaceSize, viewBox],
)
const selectedCeilingActionMenuPosition = useMemo(
() =>
selectedCeilingEntry
? getFloorplanActionMenuPosition(selectedCeilingEntry.polygon, viewBox, surfaceSize)
: null,
[selectedCeilingEntry, surfaceSize, viewBox],
)
const selectedWallActionMenuPosition = useMemo(
() =>
selectedWallEntry
? getFloorplanActionMenuPosition(selectedWallEntry.polygon, viewBox, surfaceSize)
: null,
[selectedWallEntry, surfaceSize, viewBox],
)
const selectedStairActionMenuPosition = useMemo(
() =>
selectedStairEntry
@@ -6198,6 +6473,11 @@ export function FloorplanPanel() {
setWallEndpointDraft(null)
setHoveredEndpointId(null)
}, [])
const clearWallCurveDrag = useCallback(() => {
wallCurveDragRef.current = null
setWallCurveDraft(null)
setHoveredWallCurveHandleId(null)
}, [])
const clearSiteBoundaryInteraction = useCallback(() => {
setSiteVertexDragState(null)
setSiteBoundaryDraft(null)
@@ -6219,11 +6499,13 @@ export function FloorplanPanel() {
clearSlabPlacementDraft()
clearZonePlacementDraft()
clearWallEndpointDrag()
clearWallCurveDrag()
clearSiteBoundaryInteraction()
clearSlabBoundaryInteraction()
clearZoneBoundaryInteraction()
setCursorPoint(null)
}, [
clearWallCurveDrag,
clearSiteBoundaryInteraction,
clearSlabBoundaryInteraction,
clearSlabPlacementDraft,
@@ -6430,10 +6712,7 @@ export function FloorplanPanel() {
}
const dragState = wallEndpointDragRef.current
if (!dragState || event.pointerId !== dragState.pointerId) {
return
}
if (dragState && event.pointerId === dragState.pointerId) {
event.preventDefault()
const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
@@ -6475,6 +6754,44 @@ export function FloorplanPanel() {
return nextDraft
})
return
}
const curveDragState = wallCurveDragRef.current
if (!curveDragState || event.pointerId !== curveDragState.pointerId) {
return
}
event.preventDefault()
const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
const wall = wallById.get(curveDragState.wallId)
if (!(planPoint && wall)) {
return
}
const chord = getWallChordFrame(wall)
const snappedPoint: WallPlanPoint = shiftPressed
? planPoint
: [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
const rawCurveOffset =
-(
(snappedPoint[0] - chord.midpoint.x) * chord.normal.x +
(snappedPoint[1] - chord.midpoint.y) * chord.normal.y
)
const nextCurveOffset = normalizeWallCurveOffset(
wall,
shiftPressed ? rawCurveOffset : snapToHalf(rawCurveOffset),
)
if (curveDragState.currentCurveOffset === nextCurveOffset) {
return
}
curveDragState.currentCurveOffset = nextCurveOffset
setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset })
setCursorPoint(snappedPoint)
sfxEmitter.emit('sfx:grid-snap')
}
const commitGuideInteraction = (event: PointerEvent) => {
@@ -6559,6 +6876,26 @@ export function FloorplanPanel() {
setCursorPoint(null)
}
const commitWallCurveDrag = (event: PointerEvent) => {
const dragState = wallCurveDragRef.current
if (!dragState || event.pointerId !== dragState.pointerId) {
return
}
const wall = wallById.get(dragState.wallId)
if (wall) {
const nextCurveOffset = normalizeWallCurveOffset(wall, dragState.currentCurveOffset)
const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0)
if (nextCurveOffset !== currentCurveOffset) {
updateNode(wall.id, { curveOffset: nextCurveOffset })
sfxEmitter.emit('sfx:structure-build')
}
}
clearWallCurveDrag()
setCursorPoint(null)
}
const cancelWallEndpointDrag = (event: PointerEvent) => {
const dragState = wallEndpointDragRef.current
if (!dragState || event.pointerId !== dragState.pointerId) {
@@ -6569,11 +6906,23 @@ export function FloorplanPanel() {
setCursorPoint(null)
}
const cancelWallCurveDrag = (event: PointerEvent) => {
const dragState = wallCurveDragRef.current
if (!dragState || event.pointerId !== dragState.pointerId) {
return
}
clearWallCurveDrag()
setCursorPoint(null)
}
window.addEventListener('pointermove', handleWindowPointerMove)
window.addEventListener('pointerup', commitGuideInteraction)
window.addEventListener('pointercancel', cancelGuideInteraction)
window.addEventListener('pointerup', commitWallEndpointDrag)
window.addEventListener('pointercancel', cancelWallEndpointDrag)
window.addEventListener('pointerup', commitWallCurveDrag)
window.addEventListener('pointercancel', cancelWallCurveDrag)
return () => {
window.removeEventListener('pointermove', handleWindowPointerMove)
@@ -6581,8 +6930,11 @@ export function FloorplanPanel() {
window.removeEventListener('pointercancel', cancelGuideInteraction)
window.removeEventListener('pointerup', commitWallEndpointDrag)
window.removeEventListener('pointercancel', cancelWallEndpointDrag)
window.removeEventListener('pointerup', commitWallCurveDrag)
window.removeEventListener('pointercancel', cancelWallCurveDrag)
}
}, [
clearWallCurveDrag,
clearGuideInteraction,
clearWallEndpointDrag,
getSvgPointFromClientPoint,
@@ -6596,7 +6948,8 @@ export function FloorplanPanel() {
useEffect(() => {
clearWallEndpointDrag()
}, [clearWallEndpointDrag, levelId])
clearWallCurveDrag()
}, [clearWallCurveDrag, clearWallEndpointDrag, levelId])
useEffect(() => {
if (shouldShowSiteBoundaryHandles) {
@@ -7066,7 +7419,9 @@ export function FloorplanPanel() {
}
if (isOpeningPlacementActive) {
const closest = findClosestWallPoint(planPoint, walls)
const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall),
})
if (closest) {
const dx = closest.wall.end[0] - closest.wall.start[0]
const dz = closest.wall.end[1] - closest.wall.start[1]
@@ -7286,7 +7641,9 @@ export function FloorplanPanel() {
}
if (isOpeningPlacementActive) {
const closest = findClosestWallPoint(planPoint, walls)
const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall),
})
if (closest) {
const dx = closest.wall.end[0] - closest.wall.start[0]
const dz = closest.wall.end[1] - closest.wall.start[1]
@@ -7373,7 +7730,9 @@ export function FloorplanPanel() {
isOpeningPlacementActive,
isPolygonBuildActive,
isWallBuildActive,
isWindowBuildActive,
isZoneBuildActive,
movingOpeningType,
setSelectedReferenceId,
setSelection,
shiftPressed,
@@ -8095,6 +8454,96 @@ export function FloorplanPanel() {
},
[deleteNode, selectedItemEntry, setSelection],
)
const handleSelectedWallMove = useCallback(
(event: ReactMouseEvent<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(
(stair: StairNode, event: ReactMouseEvent<SVGElement>) => {
emitFloorplanNodeClick(stair.id, 'double-click', event)
@@ -8304,6 +8753,45 @@ export function FloorplanPanel() {
},
[clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode],
)
const handleWallCurvePointerDown = useCallback(
(wall: WallNode, event: ReactPointerEvent<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(
(slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {
if (event.button !== 0) {
@@ -8662,12 +9150,21 @@ export function FloorplanPanel() {
!zoneVertexDragState
) {
const rect = event.currentTarget.getBoundingClientRect()
setFloorplanCursorPosition({
const nextPosition = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
})
}
setFloorplanCursorPosition((currentPosition) =>
currentPosition &&
currentPosition.x === nextPosition.x &&
currentPosition.y === nextPosition.y
? currentPosition
: nextPosition,
)
} else {
setFloorplanCursorPosition(null)
setFloorplanCursorPosition((currentPosition) =>
currentPosition === null ? currentPosition : null,
)
}
handlePointerMove(event)
@@ -9230,7 +9727,7 @@ export function FloorplanPanel() {
rotationModifierPressed={rotationModifierPressed}
/>
)}
{selectedItemActionMenuPosition && isFloorplanHovered && !movingNode && (
{selectedItemActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div
className="absolute z-30"
style={{
@@ -9248,7 +9745,58 @@ export function FloorplanPanel() {
/>
</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
className="absolute z-30"
style={{
@@ -9266,7 +9814,7 @@ export function FloorplanPanel() {
/>
</div>
)}
{selectedStairActionMenuPosition && isFloorplanHovered && !movingNode && (
{selectedStairActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div
className="absolute z-30"
style={{
@@ -9543,6 +10091,13 @@ export function FloorplanPanel() {
onWallEndpointPointerDown={handleWallEndpointPointerDown}
palette={palette}
/>
<FloorplanWallCurveHandleLayer
curveHandles={wallCurveHandles}
hoveredHandleId={hoveredWallCurveHandleId}
onHandleHoverChange={setHoveredWallCurveHandleId}
onWallCurvePointerDown={handleWallCurvePointerDown}
palette={palette}
/>
<FloorplanPolygonHandleLayer
hoveredHandleId={hoveredSlabHandleId}
@@ -1,7 +1,7 @@
'use client'
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'
type NodeActionMenuProps = {
@@ -9,6 +9,7 @@ type NodeActionMenuProps = {
onDelete?: MouseEventHandler<HTMLButtonElement>
onDuplicate?: MouseEventHandler<HTMLButtonElement>
onMove?: MouseEventHandler<HTMLButtonElement>
onCurve?: MouseEventHandler<HTMLButtonElement>
onPointerDown?: PointerEventHandler<HTMLDivElement>
onPointerUp?: PointerEventHandler<HTMLDivElement>
onPointerEnter?: PointerEventHandler<HTMLDivElement>
@@ -20,6 +21,7 @@ export function NodeActionMenu({
onDelete,
onDuplicate,
onMove,
onCurve,
onPointerDown,
onPointerUp,
onPointerEnter,
@@ -44,6 +46,17 @@ export function NodeActionMenu({
<Move className="h-4 w-4" />
</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 && (
<button
aria-label="Duplicate"
@@ -346,6 +346,7 @@ export const SelectionManager = () => {
const clickHandledRef = useRef(false)
const movingNode = useEditor((s) => s.movingNode)
const curvingWall = useEditor((s) => s.curvingWall)
useEffect(() => {
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
@@ -384,7 +385,7 @@ export const SelectionManager = () => {
useEffect(() => {
if (mode !== 'select') return
if (movingNode) return
if (movingNode || curvingWall) return
const onClick = (event: NodeEvent) => {
// Skip if box-select just completed (drag ended over a node)
@@ -485,12 +486,12 @@ export const SelectionManager = () => {
})
emitter.off('grid:click', onGridClick)
}
}, [mode, movingNode])
}, [curvingWall, mode, movingNode])
// Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => {
if (mode !== 'select') return
if (movingNode) return
if (movingNode || curvingWall) return
const onEnter = (event: NodeEvent) => {
const node = event.node
@@ -619,7 +620,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
})
}
}, [mode, movingNode])
}, [curvingWall, mode, movingNode])
// Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => {
@@ -4,9 +4,14 @@ import {
type AnyNodeId,
calculateLevelMiters,
DEFAULT_WALL_HEIGHT,
getWallCurveLength,
getWallMiterBoundaryPoints,
getWallPlanFootprint,
getWallSurfacePolygon,
isCurvedWall,
type Point2D,
pointToKey,
sampleWallCenterline,
sceneRegistry,
useScene,
type WallMiterData,
@@ -15,7 +20,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import * as THREE from 'three'
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 MeasurementGuide = {
guideStart: Vec3
guideEnd: Vec3
guidePath: Vec3[]
extStartStart: Vec3
extStartEnd: Vec3
extEndStart: Vec3
@@ -56,18 +60,19 @@ export function WallMeasurementLabel() {
const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
const wall = selectedNode?.type === 'wall' ? selectedNode : null
const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null)
useEffect(() => {
setWallObject(null)
}, [selectedId])
const [wallObjectState, setWallObjectState] = useState<{
id: WallNode['id']
object: THREE.Object3D
} | null>(null)
const wallObject =
selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null
useFrame(() => {
if (!selectedId || wallObject) return
const nextWallObject = sceneRegistry.nodes.get(selectedId)
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]
}
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(
wall: WallNode,
nodes: Record<string, WallNode | { type: string; children?: string[] }>,
@@ -143,31 +176,66 @@ function buildMeasurementGuide(
const height = wall.height ?? DEFAULT_WALL_HEIGHT
const startLocal = worldPointToWallLocal(wall, middlePoints.start)
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]]
const guideEnd: Vec3 = [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]]
return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
})
: [
[startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]],
[endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]],
]
const dirX = guideEnd[0] - guideStart[0]
const dirZ = guideEnd[2] - guideStart[2]
const dirLength = Math.hypot(dirX, dirZ)
if (guidePath.length < 2) return null
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
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 {
guideStart,
guideEnd,
extStartStart: [startLocal[0], height, startLocal[2]],
extStartEnd: [startLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, startLocal[2]],
extEndStart: [endLocal[0], height, endLocal[2]],
extEndEnd: [endLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, endLocal[2]],
labelPosition: [
(guideStart[0] + guideEnd[0]) / 2,
guideStart[1] + LABEL_LIFT,
(guideStart[2] + guideEnd[2]) / 2,
guidePath,
extStartStart: [extensionStartBase[0], height, extensionStartBase[2]],
extStartEnd: [
extensionStartBase[0],
height + GUIDE_Y_OFFSET + extOvershoot,
extensionStartBase[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 }) {
const nodes = useScene((state) => state.nodes)
const theme = useViewer((state) => state.theme)
@@ -216,10 +294,6 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
const color = isNight ? '#ffffff' : '#111111'
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(
() =>
buildMeasurementGuide(
@@ -228,12 +302,26 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
),
[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
return (
<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.extEndEnd} start={guide.extEndStart} />
@@ -36,6 +36,7 @@ export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId,
return (
<PolygonEditor
allowPolygonMove
color="#ef4444"
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
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,
DoorNode,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useScene,
@@ -84,6 +85,11 @@ export const DoorTool: React.FC = () => {
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
const levelId = getLevelId()
if (!levelId) return
if (event.node.parentId !== levelId) return
@@ -130,6 +136,11 @@ export const DoorTool: React.FC = () => {
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
@@ -190,6 +201,7 @@ export const DoorTool: React.FC = () => {
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
@@ -2,6 +2,7 @@ import {
type AnyNodeId,
DoorNode,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useScene,
@@ -98,6 +99,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
@@ -151,6 +156,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
@@ -213,6 +222,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
const onWallClick = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal)
@@ -1,21 +1,27 @@
import type {
BuildingNode,
CeilingNode,
DoorNode,
FenceNode,
ItemNode,
RoofNode,
RoofSegmentNode,
SlabNode,
StairNode,
StairSegmentNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { MoveBuildingContent } from '../building/move-building-tool'
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
import { MoveDoorTool } from '../door/move-door-tool'
import { MoveFenceTool } from '../fence/move-fence-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 type { PlacementState } from './placement-types'
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 === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
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')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
@@ -9,8 +9,10 @@ const Y_OFFSET = 0.02
type DragState = {
isDragging: boolean
vertexIndex: number
mode: 'vertex' | 'polygon'
vertexIndex: number | null
initialPosition: [number, number]
initialPolygon: Array<[number, number]>
pointerId: number
}
@@ -23,6 +25,8 @@ export interface PolygonEditorProps {
levelId?: string
/** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
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,
levelId,
surfaceHeight = 0,
allowPolygonMove = false,
}) => {
// Get level node from registry if levelId is provided
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)
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
const midpoints = useMemo(() => {
if (displayPolygon.length < 2) return []
@@ -158,7 +174,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// Update vertex position during drag
if (dragState?.isDragging) {
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 */}
{displayPolygon.map(([x, z], index) => {
const isHovered = hoveredVertex === index
const isDragging = dragState?.vertexIndex === index
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
const radius = 0.1
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
@@ -282,8 +306,10 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
e.stopPropagation()
setDragState({
isDragging: true,
mode: 'vertex',
vertexIndex: index,
initialPosition: [x!, z!],
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
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) */}
{!dragState &&
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 (
<PolygonEditor
allowPolygonMove
color="#ef4444"
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
minVertices={3}
@@ -20,6 +20,7 @@ import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabHoleEditor } from './slab/slab-hole-editor'
import { SlabTool } from './slab/slab-tool'
import { StairTool } from './stair/stair-tool'
import { CurveWallTool } from './wall/curve-wall-tool'
import { WallTool } from './wall/wall-tool'
import { WindowTool } from './window/window-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
@@ -51,6 +52,7 @@ export const ToolManager: React.FC = () => {
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const buildingId = useViewer((state) => state.selection.buildingId)
@@ -140,6 +142,7 @@ export const ToolManager: React.FC = () => {
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)}
{curvingWall && <CurveWallTool node={curvingWall} />}
{movingNode && movingNode.type !== 'building' && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
</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 {
type AnyNodeId,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useScene,
@@ -112,6 +113,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
@@ -168,6 +173,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
@@ -233,6 +242,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
const onWallClick = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
@@ -1,6 +1,7 @@
import {
type AnyNodeId,
emitter,
isCurvedWall,
sceneRegistry,
spatialGridManager,
useScene,
@@ -86,6 +87,11 @@ export const WindowTool: React.FC = () => {
const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
const levelId = getLevelId()
if (!levelId) return
// Only interact with walls on the current level
@@ -135,6 +141,11 @@ export const WindowTool: React.FC = () => {
const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
@@ -198,6 +209,7 @@ export const WindowTool: React.FC = () => {
const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
// Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return
@@ -1,51 +1,50 @@
'use client'
import { DEFAULT_MATERIALS, type MaterialPreset, type MaterialSchema } from '@pascal-app/core'
import {
getMaterialsForTarget,
toLibraryMaterialRef,
type MaterialSchema,
type MaterialTarget,
} from '@pascal-app/core'
import { useState } from 'react'
const PRESET_COLORS: Record<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 = {
nodeType?: MaterialTarget
value?: MaterialSchema
onChange: (material: MaterialSchema) => void
selectedMaterialPreset?: string
onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void
}
export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState<boolean>(
value?.preset === 'custom' || !!value?.properties,
)
export function MaterialPicker({
nodeType,
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 || DEFAULT_MATERIALS[currentPreset]
const currentProps = value?.properties || {
color: '#ffffff',
roughness: 0.5,
metalness: 0,
opacity: 1,
transparent: false,
side: 'front' as const,
}
const selectedCatalogId =
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
const handlePresetChange = (preset: MaterialPreset) => {
if (preset === 'custom') {
const handleCatalogSelect = (materialId: string) => {
setShowCustom(false)
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
}
const handleCustomOpen = () => {
setShowCustom(true)
onChange({
onChange?.({
preset: 'custom',
properties: {
color: value?.properties?.color || '#ffffff',
@@ -56,18 +55,14 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
side: value?.properties?.side ?? 'front',
},
})
} else {
setShowCustom(false)
onChange({ preset })
}
}
const handlePropertyChange = (
prop: keyof typeof currentProps,
val: (typeof currentProps)[keyof typeof currentProps],
) => {
onChange({
preset: showCustom ? 'custom' : currentPreset,
onChange?.({
preset: 'custom',
properties: {
...currentProps,
[prop]: val,
@@ -77,31 +72,56 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
return (
<div className="space-y-3">
<div className="grid grid-cols-5 gap-1.5">
{(Object.keys(PRESET_COLORS) as MaterialPreset[]).map((preset) => (
{(catalogItems.length > 0 || onChange) && (
<div className="space-y-2">
{catalogItems.length > 0 ? (
<div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div>
) : null}
<div className="flex flex-wrap gap-1.5">
{catalogItems.map((item) => (
<button
className={`h-8 w-8 rounded border-2 transition-all ${
currentPreset === preset
className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
selectedCatalogId === toLibraryMaterialRef(item.id)
? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-gray-300 hover:border-gray-400'
}`}
key={preset}
onClick={() => handlePresetChange(preset)}
style={{
backgroundColor: PRESET_COLORS[preset],
backgroundImage:
preset === 'glass'
? '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)'
: undefined,
backgroundSize: preset === 'glass' ? '8px 8px' : undefined,
}}
title={PRESET_LABELS[preset]}
key={item.id}
onClick={() => handleCatalogSelect(item.id)}
title={item.label}
type="button"
>
{item.previewThumbnailUrl ? (
<img
alt={item.label}
className="h-full w-full object-cover"
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="flex items-center gap-2">
<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 { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2 } from 'lucide-react'
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton } from '../controls/action-button'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
@@ -18,6 +19,7 @@ export function CeilingPanel() {
const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
@@ -34,7 +36,14 @@ export function CeilingPanel() {
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
@@ -102,6 +111,13 @@ export function CeilingPanel() {
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
)
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
const calculateArea = (polygon: Array<[number, number]>): number => {
@@ -223,8 +239,17 @@ export function CeilingPanel() {
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="ceiling"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup>
</PanelWrapper>
)
}
@@ -1,8 +1,17 @@
'use client'
import { type AnyNode, type AnyNodeId, type FenceBaseStyle, type FenceNode, type FenceStyle, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
type FenceBaseStyle,
type FenceNode,
type FenceStyle,
type MaterialSchema,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
@@ -62,6 +71,20 @@ export function FencePanel() {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
if (!node || node.type !== 'fence' || selectedIds.length !== 1) return null
const dx = node.end[0] - node.start[0]
@@ -179,6 +202,16 @@ export function FencePanel() {
value={node.edgeInset}
/>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="fence"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -17,7 +17,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
@@ -43,7 +42,14 @@ export function RoofPanel() {
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
@@ -166,7 +172,7 @@ export function RoofPanel() {
</PanelSection>
<PanelSection title="Position">
<MetricControl
<SliderControl
label="X"
max={50}
min={-50}
@@ -180,7 +186,7 @@ export function RoofPanel() {
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<MetricControl
<SliderControl
label="Y"
max={50}
min={-50}
@@ -194,7 +200,7 @@ export function RoofPanel() {
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<MetricControl
<SliderControl
label="Z"
max={50}
min={-50}
@@ -255,7 +261,13 @@ export function RoofPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="roof"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
@@ -16,7 +16,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
@@ -57,7 +56,14 @@ export function RoofSegmentPanel() {
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
@@ -230,7 +236,7 @@ export function RoofSegmentPanel() {
</PanelSection>
<PanelSection title="Position">
<MetricControl
<SliderControl
label="X"
max={50}
min={-50}
@@ -244,7 +250,7 @@ export function RoofSegmentPanel() {
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<MetricControl
<SliderControl
label="Y"
max={50}
min={-50}
@@ -258,7 +264,7 @@ export function RoofSegmentPanel() {
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<MetricControl
<SliderControl
label="Z"
max={50}
min={-50}
@@ -319,7 +325,13 @@ export function RoofSegmentPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="roof-segment"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
@@ -2,8 +2,9 @@
import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2 } from 'lucide-react'
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
@@ -18,6 +19,7 @@ export function SlabPanel() {
const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined
@@ -30,9 +32,16 @@ export function SlabPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
@@ -100,6 +109,13 @@ export function SlabPanel() {
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
)
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
const calculateArea = (polygon: Array<[number, number]>): number => {
@@ -221,8 +237,17 @@ export function SlabPanel() {
</div>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="slab"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup>
</PanelWrapper>
)
}
@@ -21,7 +21,6 @@ import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
@@ -70,7 +69,14 @@ export function StairPanel() {
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
@@ -361,7 +367,7 @@ export function StairPanel() {
)}
<PanelSection title="Position">
<MetricControl
<SliderControl
label="X"
max={50}
min={-50}
@@ -375,7 +381,7 @@ export function StairPanel() {
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<MetricControl
<SliderControl
label="Y"
max={50}
min={-50}
@@ -389,7 +395,7 @@ export function StairPanel() {
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<MetricControl
<SliderControl
label="Z"
max={50}
min={-50}
@@ -470,7 +476,13 @@ export function StairPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="stair"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
@@ -17,7 +17,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
@@ -65,7 +64,14 @@ export function StairSegmentPanel() {
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
@@ -243,7 +249,7 @@ export function StairSegmentPanel() {
</PanelSection>
<PanelSection title="Position">
<MetricControl
<SliderControl
label="X"
max={50}
min={-50}
@@ -257,7 +263,7 @@ export function StairSegmentPanel() {
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<MetricControl
<SliderControl
label="Y"
max={50}
min={-50}
@@ -271,7 +277,7 @@ export function StairSegmentPanel() {
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
<MetricControl
<SliderControl
label="Z"
max={50}
min={-50}
@@ -332,7 +338,13 @@ export function StairSegmentPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="stair-segment"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
@@ -3,12 +3,20 @@
import {
type AnyNode,
type AnyNodeId,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallCurveLength,
normalizeWallCurveOffset,
type MaterialSchema,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
@@ -19,6 +27,8 @@ export function WallPanel() {
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined
@@ -55,9 +65,16 @@ export function WallPanel() {
[node, handleUpdate],
)
const handleMaterialChange = useCallback(
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
@@ -66,14 +83,40 @@ export function WallPanel() {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleCurve = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setCurvingWall(node)
setSelection({ selectedIds: [] })
}, [node, setCurvingWall, setSelection])
if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const length = Math.sqrt(dx * dx + dz * dz)
const length = getWallCurveLength(node)
const height = node.height ?? 2.5
const thickness = node.thickness ?? 0.1
const curveOffset = getClampedWallCurveOffset(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
const hasWallChildrenBlockingCurve = (node.children ?? []).some((childId) => {
const child = nodes[childId as AnyNodeId]
if (!child) return false
if (child.type === 'door' || child.type === 'window') return true
if (child.type === 'item') {
const attachTo = child.asset?.attachTo
return attachTo === 'wall' || attachTo === 'wall-side'
}
return false
})
return (
<PanelWrapper
@@ -113,11 +156,40 @@ export function WallPanel() {
unit="m"
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 title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</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>
)
}
+13
View File
@@ -3,16 +3,19 @@
import type { AssetInput } from '@pascal-app/core'
import {
type BuildingNode,
type CeilingNode,
type DoorNode,
type FenceNode,
type ItemNode,
type LevelNode,
type RoofNode,
type RoofSegmentNode,
type SlabNode,
type Space,
type StairNode,
type StairSegmentNode,
useScene,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
@@ -88,6 +91,9 @@ type EditorState = {
| WindowNode
| DoorNode
| FenceNode
| CeilingNode
| SlabNode
| WallNode
| RoofNode
| RoofSegmentNode
| StairNode
@@ -100,6 +106,9 @@ type EditorState = {
| WindowNode
| DoorNode
| FenceNode
| CeilingNode
| SlabNode
| WallNode
| RoofNode
| RoofSegmentNode
| StairNode
@@ -107,6 +116,8 @@ type EditorState = {
| BuildingNode
| null,
) => void
curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode
@@ -437,6 +448,8 @@ const useEditor = create<EditorState>()(
| BuildingNode
| null,
setMovingNode: (node) => set({ movingNode: node }),
curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
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 { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
@@ -39,10 +39,11 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const handlers = useNodeEvents(node, 'ceiling')
const materials = useMemo(() => {
const props = resolveMaterial(node.material)
const preset = getMaterialPresetByRef(node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material)
const color = props.color || '#999999'
return createCeilingMaterials(color)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return (
<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 type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials'
export const FenceRenderer = ({ node }: { node: FenceNode }) => {
const ref = useRef<Mesh>(null!)
const handlers = useNodeEvents(node, 'fence')
const material = useMemo(() => DEFAULT_STAIR_MATERIAL, [])
const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
])
useRegistry(node.id, 'fence', ref)
useLayoutEffect(() => {
@@ -1,24 +1,43 @@
import { type RoofSegmentNode, useRegistry } from '@pascal-app/core'
import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import type * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial } from '../../../lib/materials'
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer'
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const ref = useRef<THREE.Mesh>(null!)
const nodes = useScene((state) => state.nodes)
useRegistry(node.id, 'roof-segment', ref)
const handlers = useNodeEvents(node, 'roof-segment')
const debugColors = useViewer((s) => s.debugColors)
const parentNode =
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined
const customMaterial = useMemo(() => {
const mat = node.material
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
const effectiveMaterial = node.material ?? parentNode?.material
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset)
if (presetMaterial) return presetMaterial
const mat = effectiveMaterial
if (!mat) return null
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
parentNode?.materialPreset,
parentNode?.material,
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
@@ -2,7 +2,7 @@ import { type RoofNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import type * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial } from '../../../lib/materials'
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer'
import { roofDebugMaterials, roofMaterials } from './roof-materials'
@@ -16,10 +16,12 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const debugColors = useViewer((s) => s.debugColors)
const customMaterial = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return null
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
@@ -1,8 +1,13 @@
import { type SlabNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_SLAB_MATERIAL } from '../../../lib/materials'
import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_SLAB_MATERIAL,
} from '../../../lib/materials'
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const ref = useRef<Mesh>(null!)
@@ -12,10 +17,34 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const handlers = useNodeEvents(node, 'slab')
const material = useMemo(() => {
const mat = node.material
if (!mat) return DEFAULT_SLAB_MATERIAL
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
const sourceMaterial = presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL)
const slabMaterial = sourceMaterial.clone()
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
// pipeline variants that can fail when geometry is regenerated while a
// transparent/custom material is attached.
slabMaterial.transparent = false
slabMaterial.opacity = 1
slabMaterial.alphaMap = null
slabMaterial.side = THREE.DoubleSide
slabMaterial.depthWrite = true
slabMaterial.needsUpdate = true
return slabMaterial
}, [
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
node.materialPreset,
])
useEffect(() => {
return () => {
material.dispose()
}
}, [material])
return (
<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 type * as THREE from 'three'
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 }) => {
const ref = useRef<THREE.Mesh>(null!)
const nodes = useScene((state) => state.nodes)
useRegistry(node.id, 'stair-segment', ref)
@@ -14,12 +15,30 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
}, [node.id])
const handlers = useNodeEvents(node, 'stair-segment')
const parentNode =
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
const material = useMemo(() => {
const mat = node.material
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset
const effectiveMaterial = node.material ?? parentNode?.material
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset)
if (presetMaterial) return presetMaterial
const mat = effectiveMaterial
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
parentNode?.materialPreset,
parentNode?.material,
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
])
return (
<mesh
@@ -2,7 +2,7 @@ import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, use
import { useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
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'
type SegmentTransform = {
@@ -47,10 +47,12 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
const handlers = useNodeEvents(node, 'stair')
const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return (
<group
@@ -2,7 +2,11 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
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'
export const WallRenderer = ({ node }: { node: WallNode }) => {
@@ -17,10 +21,18 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
const handlers = useNodeEvents(node, 'wall')
const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return DEFAULT_WALL_MATERIAL
return createMaterial(mat)
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
}, [
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
node.materialPreset,
])
return (
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
@@ -281,6 +281,10 @@ export const SelectionManager = () => {
if (!strategy) return
if (strategy.isValid(event.node)) {
event.stopPropagation()
if (event.node.type === 'slab') {
useViewer.setState({ hoveredId: null })
return
}
useViewer.setState({ hoveredId: event.node.id })
}
}
@@ -388,11 +392,14 @@ const OutlinerSync = () => {
const selection = useViewer((s) => s.selection)
const hoveredId = useViewer((s) => s.hoveredId)
const outliner = useViewer((s) => s.outliner)
const nodes = useScene((s) => s.nodes)
useEffect(() => {
// Sync selected objects
outliner.selectedObjects.length = 0
for (const id of selection.selectedIds) {
const node = nodes[id as AnyNodeId]
if (node?.type === 'slab') continue
const obj = sceneRegistry.nodes.get(id)
if (obj) outliner.selectedObjects.push(obj)
}
@@ -400,10 +407,12 @@ const OutlinerSync = () => {
// Sync hovered objects
outliner.hoveredObjects.length = 0
if (hoveredId) {
const hoveredNode = nodes[hoveredId as AnyNodeId]
if (hoveredNode?.type === 'slab') return
const obj = sceneRegistry.nodes.get(hoveredId)
if (obj) outliner.hoveredObjects.push(obj)
}
}, [selection, hoveredId, outliner])
}, [selection, hoveredId, outliner, nodes])
return null
}
+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'
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 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 {
return `${props.color}-${props.roughness}-${props.metalness}-${props.opacity}-${props.transparent}-${props.side}`
}
export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMaterial {
const props = resolveMaterial(material)
const cacheKey = getCacheKey(props)
function getTextureKey(material?: MaterialSchema): string {
const texture = material?.texture
if (!texture) return 'none'
const repeat = texture.repeat?.join('x') ?? 'default'
const scale = texture.scale ?? 'default'
return `${texture.url}-${repeat}-${scale}`
}
function getTexture(material?: MaterialSchema): THREE.Texture | undefined {
const textureConfig = material?.texture
if (!textureConfig?.url) return undefined
const cacheKey = getTextureKey(material)
const cached = textureCache.get(cacheKey)
if (cached) return cached
const texture = textureLoader.load(textureConfig.url)
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
const repeatX = textureConfig.repeat?.[0] ?? textureConfig.scale ?? 1
const repeatY = textureConfig.repeat?.[1] ?? textureConfig.scale ?? 1
texture.repeat.set(repeatX, repeatY)
texture.colorSpace = THREE.SRGBColorSpace
textureCache.set(cacheKey, texture)
return texture
}
function isStandardMaterial(material: THREE.Material): material is StandardMaterial {
return (
material instanceof THREE.MeshStandardMaterial ||
material instanceof THREE.MeshPhysicalMaterial
)
}
function applyTextureProperties(
texture: THREE.Texture,
props: MaterialMapProperties,
slot?: TextureSlot,
): THREE.Texture {
texture.wrapS = wrapMap[props.wrapS]
texture.wrapT = wrapMap[props.wrapT]
texture.repeat.set(props.repeatX, props.repeatY)
texture.rotation = props.rotation
texture.flipY = props.flipY
texture.colorSpace = SRGB_TEXTURE_SLOTS.includes(slot ?? 'map')
? THREE.SRGBColorSpace
: THREE.NoColorSpace
texture.needsUpdate = true
return texture
}
function getPresetTextureCacheKey(path: string, props: MaterialMapProperties, slot?: TextureSlot): string {
return `${path}-${props.repeatX}-${props.repeatY}-${props.rotation}-${props.wrapS}-${props.wrapT}-${props.flipY}-${slot ?? 'map'}`
}
function getPresetTexture(path: string, props: MaterialMapProperties, slot?: TextureSlot): THREE.Texture {
const cacheKey = getPresetTextureCacheKey(path, props, slot)
const cached = textureCache.get(cacheKey)
if (cached) return cached
const texture = textureLoader.load(path)
applyTextureProperties(texture, props, slot)
textureCache.set(cacheKey, texture)
return texture
}
async function loadPresetTexture(
path: string,
props: MaterialMapProperties,
slot?: TextureSlot,
): Promise<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)) {
return materialCache.get(cacheKey)!
}
const material = new THREE.MeshStandardMaterial()
applyMaterialPresetToMaterials(material, preset)
materialCache.set(cacheKey, material)
return material
}
export function createMaterialFromPresetRef(materialPreset?: string): THREE.MeshStandardMaterial | null {
const preset = getMaterialPresetByRef(materialPreset)
if (!preset) return null
return createMaterialFromPreset(preset)
}
export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMaterial {
const props = resolveMaterial(material)
const cacheKey = `${getCacheKey(props)}-${getTextureKey(material)}`
if (materialCache.has(cacheKey)) {
return materialCache.get(cacheKey)!
}
const map = getTexture(material)
const threeMaterial = new THREE.MeshStandardMaterial({
color: props.color,
roughness: props.roughness,
@@ -28,6 +263,7 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
opacity: props.opacity,
transparent: props.transparent,
side: sideMap[props.side],
map,
})
materialCache.set(cacheKey, threeMaterial)
@@ -70,4 +306,10 @@ export function clearMaterialCache(): void {
material.dispose()
}
materialCache.clear()
for (const texture of textureCache.values()) {
texture.dispose()
}
textureCache.clear()
textureLoadPromises.clear()
}
@@ -2,6 +2,7 @@ import {
type AnyNodeId,
baseMaterial,
emitter,
getMaterialPresetByRef,
sceneRegistry,
useScene,
type WallNode,
@@ -13,6 +14,7 @@ import { Color } from 'three'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
import useViewer from '../../store/use-viewer'
import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials'
const tmpVec = new Vector3()
const u = new Vector3()
@@ -21,12 +23,14 @@ const DEFAULT_WALL_COLOR = '#f2f0ed'
const WALL_HIGHLIGHT_PROFILES = {
delete: {
color: new Color('#dc2626'),
blend: 0.78,
blend: 0.76,
emissiveBlend: 0.92,
emissiveIntensity: 0.46,
},
selection: {
color: new Color('#818cf8'),
blend: 0.32,
emissiveBlend: 0.7,
emissiveIntensity: 0.42,
},
} as const
@@ -51,11 +55,11 @@ const dotPattern = Fn(() => {
})
interface WallMaterials {
visible: MeshStandardNodeMaterial
visible: Material
invisible: MeshStandardNodeMaterial
deleteVisible: MeshStandardNodeMaterial
deleteVisible: Material
deleteInvisible: MeshStandardNodeMaterial
highlightedVisible: MeshStandardNodeMaterial
highlightedVisible: Material
highlightedInvisible: MeshStandardNodeMaterial
materialHash: string
}
@@ -75,6 +79,7 @@ const presetColors = {
} as const
function getMaterialHash(wallNode: WallNode): string {
if (wallNode.materialPreset) return `preset-ref-${wallNode.materialPreset}`
if (!wallNode.material) return 'none'
const mat = wallNode.material
if (mat.preset && mat.preset !== 'custom') {
@@ -90,27 +95,54 @@ function getPresetColor(preset: string): string {
return presetColors[preset as keyof typeof presetColors] ?? '#ffffff'
}
function getHighlightedColor(color: string, kind: WallHighlightKind): Color {
function getHighlightedColor(color: Color, kind: WallHighlightKind): Color {
const profile = WALL_HIGHLIGHT_PROFILES[kind]
return new Color(color).lerp(profile.color, profile.blend)
return color.clone().lerp(profile.color, profile.blend)
}
function createHighlightedWallMaterial(
material: MeshStandardNodeMaterial,
baseColor: string,
material: Material,
kind: WallHighlightKind,
): MeshStandardNodeMaterial {
const highlightedMaterial = material.clone()
const highlightedColor = getHighlightedColor(baseColor, kind)
): Material {
const highlightedMaterial = material.clone() as Material & {
color?: Color
emissive?: Color
emissiveIntensity?: number
needsUpdate?: boolean
}
const profile = WALL_HIGHLIGHT_PROFILES[kind]
highlightedMaterial.color = highlightedColor
highlightedMaterial.emissive = highlightedColor.clone()
highlightedMaterial.emissiveIntensity = profile.emissiveIntensity
if ('color' in highlightedMaterial && highlightedMaterial.color) {
highlightedMaterial.color = getHighlightedColor(highlightedMaterial.color, kind)
}
if ('emissive' in highlightedMaterial && highlightedMaterial.emissive) {
highlightedMaterial.emissive = highlightedMaterial.emissive
.clone()
.lerp(profile.color, profile.emissiveBlend)
}
if ('emissiveIntensity' in highlightedMaterial) {
highlightedMaterial.emissiveIntensity = Math.max(
highlightedMaterial.emissiveIntensity ?? 0,
profile.emissiveIntensity,
)
}
highlightedMaterial.needsUpdate = true
return highlightedMaterial
}
function createBaseVisibleWallMaterial(wallNode: WallNode): Material {
if (wallNode.materialPreset) {
return createMaterialFromPresetRef(wallNode.materialPreset) ?? baseMaterial
}
if (wallNode.material) {
return createMaterial(wallNode.material)
}
return baseMaterial
}
function getMaterialsForWall(wallNode: WallNode): WallMaterials {
const cacheKey = wallNode.id
const materialHash = getMaterialHash(wallNode)
@@ -130,19 +162,16 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
}
let userColor = DEFAULT_WALL_COLOR
if (wallNode.material?.properties?.color) {
const preset = getMaterialPresetByRef(wallNode.materialPreset)
if (preset?.mapProperties?.color) {
userColor = preset.mapProperties.color
} else if (wallNode.material?.properties?.color) {
userColor = wallNode.material.properties.color
} else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') {
userColor = getPresetColor(wallNode.material.preset)
}
const visibleMat = wallNode.material
? new MeshStandardNodeMaterial({
color: userColor,
roughness: 1,
metalness: 0,
})
: (baseMaterial.clone() as MeshStandardNodeMaterial)
const visibleMat = createBaseVisibleWallMaterial(wallNode)
const invisibleMat = new MeshStandardNodeMaterial({
transparent: true,
@@ -152,10 +181,13 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
emissive: userColor,
})
const highlightedVisible = createHighlightedWallMaterial(visibleMat, userColor, 'selection')
const highlightedInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'selection')
const deleteVisible = createHighlightedWallMaterial(visibleMat, userColor, 'delete')
const deleteInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'delete')
const highlightedVisible = createHighlightedWallMaterial(visibleMat, 'selection')
const highlightedInvisible = createHighlightedWallMaterial(
invisibleMat,
'selection',
) as MeshStandardNodeMaterial
const deleteVisible = createHighlightedWallMaterial(visibleMat, 'delete')
const deleteInvisible = createHighlightedWallMaterial(invisibleMat, 'delete') as MeshStandardNodeMaterial
const result: WallMaterials = {
visible: visibleMat,
@@ -170,6 +202,10 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
return result
}
function getVisibleWallMaterial(wallNode: WallNode): Material {
return createBaseVisibleWallMaterial(wallNode)
}
function getWallHideState(
wallNode: WallNode,
wallMesh: Mesh,
@@ -265,9 +301,7 @@ export const WallCutout = () => {
? materials.deleteVisible
: isSelectionHighlighted
? materials.highlightedVisible
: wallNode.material
? materials.visible
: baseMaterial
: getVisibleWallMaterial(wallNode)
}
})
lastWallMode.current = wallMode
@@ -289,7 +323,7 @@ export const WallCutout = () => {
const current = wallMesh.material as Material
snapshot.set(wallMesh, current)
if (current === mats.highlightedVisible || current === mats.deleteVisible) {
wallMesh.material = mats.visible
wallMesh.material = getVisibleWallMaterial(wallNode)
} else if (current === mats.highlightedInvisible || current === mats.deleteInvisible) {
wallMesh.material = mats.invisible
}