diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e147e18c..bca40c4d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -45,6 +45,8 @@ export { getRenderableSlabPolygon } from './lib/slab-polygon' export { detectSpacesForLevel, initSpaceDetectionSync, + planAutoSlabsForLevel, + type AutoSlabSyncPlan, type Space, wallTouchesOthers, } from './lib/space-detection' diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index f9fda76e..94a6e47b 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -41,6 +41,12 @@ type DetectedRoom = { bbox: ReturnType } +export type AutoSlabSyncPlan = { + create: SlabNodeType[] + update: Array<{ id: SlabNodeType['id']; data: Partial }> + delete: Array +} + const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 const ROOM_CURVE_TOLERANCE = 0.04 @@ -488,12 +494,10 @@ function buildSpace(levelId: string, polygon: Point2D[]): Space { } } -function syncAutoSlabsForLevel( - levelId: string, +export function planAutoSlabsForLevel( roomPolygons: Point2D[][], existingSlabs: SlabNodeType[], - sceneStore: any, -) { +): AutoSlabSyncPlan { const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls) const manualSignatures = new Set( manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))), @@ -618,16 +622,31 @@ function syncAutoSlabsForLevel( ) } - if (slabsToDelete.length > 0) { - sceneStore.getState().deleteNodes(slabsToDelete) + return { + create: slabsToCreate, + update: slabsToUpdate, + delete: slabsToDelete, + } +} + +function syncAutoSlabsForLevel( + levelId: string, + roomPolygons: Point2D[][], + existingSlabs: SlabNodeType[], + sceneStore: any, +) { + const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs) + + if (plan.delete.length > 0) { + sceneStore.getState().deleteNodes(plan.delete) } - if (slabsToUpdate.length > 0) { - sceneStore.getState().updateNodes(slabsToUpdate) + if (plan.update.length > 0) { + sceneStore.getState().updateNodes(plan.update) } - if (slabsToCreate.length > 0) { - sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId }))) + if (plan.create.length > 0) { + sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId }))) } } diff --git a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx index fefaf949..6ad90876 100644 --- a/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-action-menu-layer.tsx @@ -16,6 +16,7 @@ export type FloorplanActionMenuEntry = { onDelete: FloorplanActionMenuHandler onMove: FloorplanActionMenuHandler onAddHole?: FloorplanActionMenuHandler + onCurve?: FloorplanActionMenuHandler onDuplicate?: FloorplanActionMenuHandler } @@ -81,6 +82,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ > state.setPhase) const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint) const setMovingNode = useEditor((state) => state.setMovingNode) + const setCurvingWall = useEditor((state) => state.setCurvingWall) const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const structureLayer = useEditor((state) => state.structureLayer) const setStructureLayer = useEditor((state) => state.setStructureLayer) @@ -9324,6 +9326,7 @@ export function FloorplanPanel() { selectedWallEntry, wallCurveDraft, ]) + const canCurveSelectedWall = wallCurveHandles.length > 0 const slabVertexHandles = useMemo(() => { if (!shouldShowSlabBoundaryHandles) { return [] @@ -14066,6 +14069,57 @@ export function FloorplanPanel() { }, [selectedWallEntry, setMovingNode, setSelection], ) + const duplicateSelectedWall = useCallback(() => { + const wall = selectedWallEntry?.wall + if (!wall?.parentId) { + return + } + + sfxEmitter.emit('sfx:item-pick') + + const cloned = structuredClone(wall) as Record + delete cloned.id + cloned.children = [] + cloned.metadata = { + ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), + isNew: true, + } + + const temporal = useScene.temporal.getState() + temporal.pause() + try { + const duplicate = WallNodeSchema.parse(cloned) + useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) + setMovingNode(duplicate) + setSelection({ selectedIds: [] }) + } catch (error) { + console.error('Failed to duplicate wall', error) + } finally { + temporal.resume() + } + }, [selectedWallEntry, setMovingNode, setSelection]) + const handleSelectedWallDuplicate = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + duplicateSelectedWall() + }, + [duplicateSelectedWall], + ) + const handleSelectedWallCurve = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const wall = selectedWallEntry?.wall + if (!(wall && canCurveSelectedWall)) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setCurvingWall(wall) + setSelection({ selectedIds: [] }) + }, + [canCurveSelectedWall, selectedWallEntry, setCurvingWall, setSelection], + ) const handleSelectedWallDelete = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() @@ -16105,9 +16159,17 @@ export function FloorplanPanel() { site, ]) const hasDuplicatableFloorplanSelection = Boolean( - selectedItemEntry || selectedOpeningEntry || selectedStairEntry || selectedRoofEntry, + selectedItemEntry || + selectedOpeningEntry || + selectedStairEntry || + selectedRoofEntry || + selectedWallEntry, ) const handleDuplicateFloorplanSelection = useCallback(() => { + if (selectedWallEntry) { + duplicateSelectedWall() + return + } if (selectedOpeningEntry) { duplicateSelectedOpening() return @@ -16124,6 +16186,7 @@ export function FloorplanPanel() { duplicateSelectedRoof() } }, [ + duplicateSelectedWall, duplicateSelectedItem, duplicateSelectedOpening, duplicateSelectedRoof, @@ -16132,6 +16195,7 @@ export function FloorplanPanel() { selectedOpeningEntry, selectedRoofEntry, selectedStairEntry, + selectedWallEntry, ]) const activeDraftAnchorPoint = referenceScaleDraft?.start ?? @@ -16258,7 +16322,9 @@ export function FloorplanPanel() { }} wall={{ position: selectedWallActionMenuPosition, + onCurve: canCurveSelectedWall ? handleSelectedWallCurve : undefined, onDelete: handleSelectedWallDelete, + onDuplicate: handleSelectedWallDuplicate, onMove: handleSelectedWallMove, }} /> diff --git a/packages/editor/src/components/tools/wall/move-wall-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-tool.tsx index a52b2b5c..36cc061b 100644 --- a/packages/editor/src/components/tools/wall/move-wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/move-wall-tool.tsx @@ -9,13 +9,12 @@ import { type GridEvent, getMaterialPresetByRef, getPerpendicularWallMoveAxis, - getRenderableSlabPolygon, pauseSceneHistory, + planAutoSlabsForLevel, planWallMoveJunctions, resolveMaterial, resumeSceneHistory, type SlabNode, - SlabNode as SlabSchema, useScene, type WallMoveAxis, type WallMoveBridgePlan, @@ -25,7 +24,7 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { BufferGeometry, DoubleSide, Float32BufferAttribute, ShapeUtils, Vector2 } from 'three' +import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -33,9 +32,6 @@ import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting' -const AUTO_SLAB_PREVIEW_ELEVATION = 0.05 -const AUTO_SLAB_PREVIEW_Y = AUTO_SLAB_PREVIEW_ELEVATION + 0.025 - function rotateVector([x, z]: [number, number], angle: number): [number, number] { const cos = Math.cos(angle) const sin = Math.sin(angle) @@ -70,11 +66,6 @@ type GhostWallPreview = { height: number } -type GhostSlabPreview = { - id: string - polygon: Array<[number, number]> -} - function getLinkedWallSnapshots(args: { wallId: WallNode['id'] wallParentId: string | null @@ -222,27 +213,6 @@ function getWallGhostColor(wall: WallNode) { return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color } -function getMinRotatedKey(values: string[]) { - if (values.length === 0) return '' - - let best = '' - for (let index = 0; index < values.length; index += 1) { - const value = [...values.slice(index), ...values.slice(0, index)].join('|') - if (!best || value < best) { - best = value - } - } - - return best -} - -function getPolygonPreviewKey(polygon: Array<[number, number]>) { - const values = polygon.map(([x, z]) => `${x.toFixed(3)}:${z.toFixed(3)}`) - const forward = getMinRotatedKey(values) - const reversed = getMinRotatedKey([...values].reverse()) - return forward < reversed ? forward : reversed -} - function getWallsAfterUpdates( nodes: ReturnType['nodes'], updates: Array<{ id: AnyNodeId; data: Partial }>, @@ -257,6 +227,32 @@ function getWallsAfterUpdates( }) } +function cloneSlabSnapshot(slab: SlabNode): SlabNode { + return { + ...slab, + polygon: slab.polygon.map(([x, z]) => [x, z] as [number, number]), + holes: slab.holes.map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), + holeMetadata: slab.holeMetadata.map((metadata) => ({ ...metadata })), + } +} + +function getLevelSlabs(levelId: string, nodes: ReturnType['nodes']) { + return Object.values(nodes).filter( + (entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId, + ) +} + +function getLevelAutoSlabs( + levelId: string, + nodes: ReturnType['nodes'], +) { + return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls) +} + +function getLevelAutoSlabSnapshots(levelId: string) { + return getLevelAutoSlabs(levelId, useScene.getState().nodes).map(cloneSlabSnapshot) +} + function buildBridgeWallCreates(args: { bridgePlans: Array> nextStart: [number, number] @@ -343,68 +339,6 @@ function buildBridgeWallPreviews(args: { return previews } -function buildAutoSlabGhostPreviews(args: { - levelId: string - walls: WallNode[] - existingSlabs: SlabNode[] -}): GhostSlabPreview[] { - const { levelId, walls, existingSlabs } = args - const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId) - - if (levelWalls.length < 3) { - return [] - } - - const manualSlabKeys = new Set( - existingSlabs - .filter((slab) => !slab.autoFromWalls) - .map((slab) => getPolygonPreviewKey(slab.polygon)), - ) - const existingAutoKeys = new Set( - existingSlabs - .filter((slab) => slab.autoFromWalls) - .map((slab) => getPolygonPreviewKey(getRenderableSlabPolygon(slab))), - ) - const seenPreviewKeys = new Set() - const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls) - const previews: GhostSlabPreview[] = [] - - for (let index = 0; index < roomPolygons.length; index += 1) { - const polygon = roomPolygons[index] - if (!polygon || polygon.length < 3) continue - - const rawPolygon = polygon.map((point) => [point.x, point.y] as [number, number]) - if (manualSlabKeys.has(getPolygonPreviewKey(rawPolygon))) { - continue - } - - const previewSlab = SlabSchema.parse({ - polygon: rawPolygon, - holes: [], - elevation: AUTO_SLAB_PREVIEW_ELEVATION, - autoFromWalls: true, - }) - const renderablePolygon = getRenderableSlabPolygon(previewSlab) - const previewKey = getPolygonPreviewKey(renderablePolygon) - - if ( - renderablePolygon.length < 3 || - existingAutoKeys.has(previewKey) || - seenPreviewKeys.has(previewKey) - ) { - continue - } - - seenPreviewKeys.add(previewKey) - previews.push({ - id: `auto-slab:${index}:${previewKey}`, - polygon: renderablePolygon, - }) - } - - return previews -} - function setPreviewGeometryAttributes( geometry: BufferGeometry, positions: number[], @@ -430,48 +364,6 @@ function createWallPreviewGeometry(length: number, height: number) { return geometry } -function createSlabPreviewGeometry(polygon: Array<[number, number]>) { - if (polygon.length < 3) { - return null - } - - const contour = polygon.map(([x, z]) => new Vector2(x, z)) - const triangles = ShapeUtils.triangulateShape(contour, []) - if (triangles.length === 0) { - return null - } - - let minX = Number.POSITIVE_INFINITY - let minZ = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let maxZ = Number.NEGATIVE_INFINITY - - for (const [x, z] of polygon) { - minX = Math.min(minX, x) - minZ = Math.min(minZ, z) - maxX = Math.max(maxX, x) - maxZ = Math.max(maxZ, z) - } - - const width = Math.max(maxX - minX, 0.001) - const depth = Math.max(maxZ - minZ, 0.001) - const positions: number[] = [] - const normals: number[] = [] - const uvs: number[] = [] - - for (const [x, z] of polygon) { - positions.push(x, 0, z) - normals.push(0, 1, 0) - uvs.push((x - minX) / width, (z - minZ) / depth) - } - - const geometry = new BufferGeometry() - setPreviewGeometryAttributes(geometry, positions, normals, uvs) - geometry.setIndex(triangles.flat()) - geometry.computeBoundingSphere() - return geometry -} - function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) { const dx = preview.end[0] - preview.start[0] const dz = preview.end[1] - preview.start[1] @@ -504,35 +396,6 @@ function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) { ) } -function GhostSlabPreviewMesh({ preview }: { preview: GhostSlabPreview }) { - const geometry = useMemo(() => createSlabPreviewGeometry(preview.polygon), [preview.polygon]) - - useEffect(() => () => geometry?.dispose(), [geometry]) - - if (!geometry) { - return null - } - - return ( - - - - - ) -} - export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const meta = typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) @@ -564,6 +427,9 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { originalEnd: node.end, }), ) + const originalAutoSlabsRef = useRef( + node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [], + ) const dragAnchorRef = useRef<[number, number] | null>(null) const nodeIdRef = useRef(node.id) const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) @@ -576,7 +442,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { return [centerX, 0, centerZ] }) const [ghostWallPreviews, setGhostWallPreviews] = useState([]) - const [ghostSlabPreviews, setGhostSlabPreviews] = useState([]) const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) @@ -588,9 +453,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const originalEnd = originalEndRef.current const originalCenter = originalCenterRef.current const originalHalfVector = originalHalfVectorRef.current + const levelId = node.parentId ?? null + const originalAutoSlabs = originalAutoSlabsRef.current pauseSceneHistory(useScene) - let wasCommitted = false + let shouldRestoreOnCleanup = true const applyNodePreview = ( updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>, @@ -606,6 +473,72 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } } + const applyLiveAutoSlabPreview = (walls: WallNode[]) => { + if (!levelId) { + return + } + + const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId) + const sceneState = useScene.getState() + const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls) + const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes)) + + if ( + slabPlan.create.length === 0 && + slabPlan.update.length === 0 && + slabPlan.delete.length === 0 + ) { + return + } + + sceneState.applyNodeChanges({ + update: slabPlan.update.map((entry) => ({ + id: entry.id as AnyNodeId, + data: entry.data, + })), + create: slabPlan.create.map((slab) => ({ + node: slab, + parentId: levelId as AnyNodeId, + })), + delete: slabPlan.delete.map((id) => id as AnyNodeId), + }) + } + + const restoreAutoSlabPreview = () => { + if (!levelId) { + return + } + + const sceneState = useScene.getState() + const originalIds = new Set(originalAutoSlabs.map((slab) => slab.id)) + const currentAutoSlabs = getLevelAutoSlabs(levelId, sceneState.nodes) + const update = originalAutoSlabs + .filter((slab) => sceneState.nodes[slab.id as AnyNodeId]) + .map((slab) => ({ + id: slab.id as AnyNodeId, + data: cloneSlabSnapshot(slab), + })) + const create = originalAutoSlabs + .filter((slab) => !sceneState.nodes[slab.id as AnyNodeId]) + .map((slab) => ({ + node: cloneSlabSnapshot(slab), + parentId: levelId as AnyNodeId, + })) + const deleteIds = currentAutoSlabs + .filter((slab) => !originalIds.has(slab.id)) + .map((slab) => slab.id as AnyNodeId) + + if (update.length === 0 && create.length === 0 && deleteIds.length === 0) { + return + } + + sceneState.applyNodeChanges({ + update, + create, + delete: deleteIds, + }) + } + const buildWallFromCenter = (center: [number, number]) => { const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current) const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]] @@ -672,31 +605,18 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { }) const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost) const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall) - const sceneNodes = useScene.getState().nodes - const levelId = node.parentId ?? null setGhostWallPreviews(nextGhostWalls) - setGhostSlabPreviews( - levelId - ? buildAutoSlabGhostPreviews({ - levelId, - walls: [...previewSceneWalls, ...virtualBridgeWalls], - existingSlabs: Object.values(sceneNodes).filter( - (entry): entry is SlabNode => - entry?.type === 'slab' && (entry.parentId ?? null) === levelId, - ), - }) - : [], - ) applyNodePreview(previewUpdates) + applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls]) } const restoreOriginal = () => { setGhostWallPreviews([]) - setGhostSlabPreviews([]) applyNodePreview([ { id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current, ]) + restoreAutoSlabPreview() } const onGridMove = (event: GridEvent) => { @@ -738,16 +658,16 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const preview = previewRef.current ?? { start: originalStart, end: originalEnd } - wasCommitted = true + shouldRestoreOnCleanup = false // Restore original baseline while paused so the next resume+update // registers as a single tracked change (undo reverts to original). setGhostWallPreviews([]) - setGhostSlabPreviews([]) applyNodePreview([ { id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current, ]) + restoreAutoSlabPreview() resumeSceneHistory(useScene) const commitPlan = getMovePlan(preview.start, preview.end) @@ -848,6 +768,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } const onCancel = () => { + shouldRestoreOnCleanup = false restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) resumeSceneHistory(useScene) @@ -862,7 +783,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { window.addEventListener('keyup', onKeyUp) return () => { - if (!wasCommitted) { + if (shouldRestoreOnCleanup) { restoreOriginal() } shiftPressedRef.current = false @@ -873,14 +794,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } - }, [exitMoveMode, isNew, node.metadata]) + }, [exitMoveMode, isNew, node.metadata, node.parentId]) return ( - {ghostSlabPreviews.map((preview) => ( - - ))} {ghostWallPreviews.map((preview) => ( ))} diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 91e7487c..2bf712e0 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -4,12 +4,19 @@ import { resolveMaterial, useRegistry, } from '@pascal-app/core' -import { useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' +import { BufferGeometry, Float32BufferAttribute } from 'three' import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' import { NodeRenderer } from '../node-renderer' +function createEmptyGeometry() { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([], 3)) + return geometry +} + const gridScale = 5 const gridX = positionWorld.x.mul(gridScale).fract() const gridY = positionWorld.z.mul(gridScale).fract() @@ -51,10 +58,20 @@ function getCeilingMaterials(color = '#999999') { export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const ref = useRef(null!) + const placeholderGeometry = useMemo(createEmptyGeometry, []) + const gridPlaceholderGeometry = useMemo(createEmptyGeometry, []) useRegistry(node.id, 'ceiling', ref) const handlers = useNodeEvents(node, 'ceiling') + useEffect( + () => () => { + placeholderGeometry.dispose() + gridPlaceholderGeometry.dispose() + }, + [gridPlaceholderGeometry, placeholderGeometry], + ) + const materials = useMemo(() => { const preset = getMaterialPresetByRef(node.materialPreset) const props = preset?.mapProperties ?? resolveMaterial(node.material) @@ -69,17 +86,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { ]) return ( - - + - - + /> {node.children.map((childId) => ( ))} diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index f59431e6..330c5d1b 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -11,6 +11,12 @@ import { const slabMaterialCache = new Map() +function createEmptyGeometry() { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + return geometry +} + function getSlabMaterial( cacheKey: string, params: { material?: SlabNode['material']; materialPreset?: string }, @@ -47,11 +53,14 @@ function getSlabMaterial( export const SlabRenderer = ({ node }: { node: SlabNode }) => { const ref = useRef(null!) + const placeholderGeometry = useMemo(createEmptyGeometry, []) useRegistry(node.id, 'slab', ref) const handlers = useNodeEvents(node, 'slab') + useEffect(() => () => placeholderGeometry.dispose(), [placeholderGeometry]) + const material = useMemo(() => { const resolvedMaterial = node.material const resolvedMaterialPreset = node.materialPreset @@ -75,13 +84,12 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { return ( - - + /> ) } diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index 82f06e79..c15d1525 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -50,7 +50,7 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) { const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh if (gridMesh) { gridMesh.geometry.dispose() - gridMesh.geometry = newGeo + gridMesh.geometry = newGeo.clone() } // Position at the ceiling height