Refine wall move previews and expose auto-slab planning

This commit is contained in:
sudhir
2026-05-12 12:15:45 +05:30
parent f3444d4275
commit 70c85d7760
8 changed files with 240 additions and 210 deletions
+2
View File
@@ -45,6 +45,8 @@ export { getRenderableSlabPolygon } from './lib/slab-polygon'
export { export {
detectSpacesForLevel, detectSpacesForLevel,
initSpaceDetectionSync, initSpaceDetectionSync,
planAutoSlabsForLevel,
type AutoSlabSyncPlan,
type Space, type Space,
wallTouchesOthers, wallTouchesOthers,
} from './lib/space-detection' } from './lib/space-detection'
+29 -10
View File
@@ -41,6 +41,12 @@ type DetectedRoom = {
bbox: ReturnType<typeof bboxOf> bbox: ReturnType<typeof bboxOf>
} }
export type AutoSlabSyncPlan = {
create: SlabNodeType[]
update: Array<{ id: SlabNodeType['id']; data: Partial<SlabNodeType> }>
delete: Array<SlabNodeType['id']>
}
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
const ROOM_CURVE_TOLERANCE = 0.04 const ROOM_CURVE_TOLERANCE = 0.04
@@ -488,12 +494,10 @@ function buildSpace(levelId: string, polygon: Point2D[]): Space {
} }
} }
function syncAutoSlabsForLevel( export function planAutoSlabsForLevel(
levelId: string,
roomPolygons: Point2D[][], roomPolygons: Point2D[][],
existingSlabs: SlabNodeType[], existingSlabs: SlabNodeType[],
sceneStore: any, ): AutoSlabSyncPlan {
) {
const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls) const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls)
const manualSignatures = new Set( const manualSignatures = new Set(
manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))), manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))),
@@ -618,16 +622,31 @@ function syncAutoSlabsForLevel(
) )
} }
if (slabsToDelete.length > 0) { return {
sceneStore.getState().deleteNodes(slabsToDelete) create: slabsToCreate,
update: slabsToUpdate,
delete: slabsToDelete,
}
} }
if (slabsToUpdate.length > 0) { function syncAutoSlabsForLevel(
sceneStore.getState().updateNodes(slabsToUpdate) levelId: string,
roomPolygons: Point2D[][],
existingSlabs: SlabNodeType[],
sceneStore: any,
) {
const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs)
if (plan.delete.length > 0) {
sceneStore.getState().deleteNodes(plan.delete)
} }
if (slabsToCreate.length > 0) { if (plan.update.length > 0) {
sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId }))) sceneStore.getState().updateNodes(plan.update)
}
if (plan.create.length > 0) {
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
} }
} }
@@ -16,6 +16,7 @@ export type FloorplanActionMenuEntry = {
onDelete: FloorplanActionMenuHandler onDelete: FloorplanActionMenuHandler
onMove: FloorplanActionMenuHandler onMove: FloorplanActionMenuHandler
onAddHole?: FloorplanActionMenuHandler onAddHole?: FloorplanActionMenuHandler
onCurve?: FloorplanActionMenuHandler
onDuplicate?: FloorplanActionMenuHandler onDuplicate?: FloorplanActionMenuHandler
} }
@@ -81,6 +82,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
> >
<NodeActionMenu <NodeActionMenu
onAddHole={entry.onAddHole} onAddHole={entry.onAddHole}
onCurve={entry.onCurve}
onDelete={entry.onDelete} onDelete={entry.onDelete}
onDuplicate={entry.onDuplicate} onDuplicate={entry.onDuplicate}
onMove={entry.onMove} onMove={entry.onMove}
@@ -39,6 +39,7 @@ import {
sceneRegistry, sceneRegistry,
useLiveTransforms, useLiveTransforms,
useScene, useScene,
WallNode as WallNodeSchema,
type WallNode, type WallNode,
WindowNode, WindowNode,
ZoneNode as ZoneNodeSchema, ZoneNode as ZoneNodeSchema,
@@ -7554,6 +7555,7 @@ export function FloorplanPanel() {
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint) const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
const setMovingNode = useEditor((state) => state.setMovingNode) const setMovingNode = useEditor((state) => state.setMovingNode)
const setCurvingWall = useEditor((state) => state.setCurvingWall)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const structureLayer = useEditor((state) => state.structureLayer) const structureLayer = useEditor((state) => state.structureLayer)
const setStructureLayer = useEditor((state) => state.setStructureLayer) const setStructureLayer = useEditor((state) => state.setStructureLayer)
@@ -9324,6 +9326,7 @@ export function FloorplanPanel() {
selectedWallEntry, selectedWallEntry,
wallCurveDraft, wallCurveDraft,
]) ])
const canCurveSelectedWall = wallCurveHandles.length > 0
const slabVertexHandles = useMemo(() => { const slabVertexHandles = useMemo(() => {
if (!shouldShowSlabBoundaryHandles) { if (!shouldShowSlabBoundaryHandles) {
return [] return []
@@ -14066,6 +14069,57 @@ export function FloorplanPanel() {
}, },
[selectedWallEntry, setMovingNode, setSelection], [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<string, unknown>
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<HTMLButtonElement>) => {
event.stopPropagation()
duplicateSelectedWall()
},
[duplicateSelectedWall],
)
const handleSelectedWallCurve = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
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( const handleSelectedWallDelete = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => { (event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation() event.stopPropagation()
@@ -16105,9 +16159,17 @@ export function FloorplanPanel() {
site, site,
]) ])
const hasDuplicatableFloorplanSelection = Boolean( const hasDuplicatableFloorplanSelection = Boolean(
selectedItemEntry || selectedOpeningEntry || selectedStairEntry || selectedRoofEntry, selectedItemEntry ||
selectedOpeningEntry ||
selectedStairEntry ||
selectedRoofEntry ||
selectedWallEntry,
) )
const handleDuplicateFloorplanSelection = useCallback(() => { const handleDuplicateFloorplanSelection = useCallback(() => {
if (selectedWallEntry) {
duplicateSelectedWall()
return
}
if (selectedOpeningEntry) { if (selectedOpeningEntry) {
duplicateSelectedOpening() duplicateSelectedOpening()
return return
@@ -16124,6 +16186,7 @@ export function FloorplanPanel() {
duplicateSelectedRoof() duplicateSelectedRoof()
} }
}, [ }, [
duplicateSelectedWall,
duplicateSelectedItem, duplicateSelectedItem,
duplicateSelectedOpening, duplicateSelectedOpening,
duplicateSelectedRoof, duplicateSelectedRoof,
@@ -16132,6 +16195,7 @@ export function FloorplanPanel() {
selectedOpeningEntry, selectedOpeningEntry,
selectedRoofEntry, selectedRoofEntry,
selectedStairEntry, selectedStairEntry,
selectedWallEntry,
]) ])
const activeDraftAnchorPoint = const activeDraftAnchorPoint =
referenceScaleDraft?.start ?? referenceScaleDraft?.start ??
@@ -16258,7 +16322,9 @@ export function FloorplanPanel() {
}} }}
wall={{ wall={{
position: selectedWallActionMenuPosition, position: selectedWallActionMenuPosition,
onCurve: canCurveSelectedWall ? handleSelectedWallCurve : undefined,
onDelete: handleSelectedWallDelete, onDelete: handleSelectedWallDelete,
onDuplicate: handleSelectedWallDuplicate,
onMove: handleSelectedWallMove, onMove: handleSelectedWallMove,
}} }}
/> />
@@ -9,13 +9,12 @@ import {
type GridEvent, type GridEvent,
getMaterialPresetByRef, getMaterialPresetByRef,
getPerpendicularWallMoveAxis, getPerpendicularWallMoveAxis,
getRenderableSlabPolygon,
pauseSceneHistory, pauseSceneHistory,
planAutoSlabsForLevel,
planWallMoveJunctions, planWallMoveJunctions,
resolveMaterial, resolveMaterial,
resumeSceneHistory, resumeSceneHistory,
type SlabNode, type SlabNode,
SlabNode as SlabSchema,
useScene, useScene,
type WallMoveAxis, type WallMoveAxis,
type WallMoveBridgePlan, type WallMoveBridgePlan,
@@ -25,7 +24,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -33,9 +32,6 @@ import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting' 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] { function rotateVector([x, z]: [number, number], angle: number): [number, number] {
const cos = Math.cos(angle) const cos = Math.cos(angle)
const sin = Math.sin(angle) const sin = Math.sin(angle)
@@ -70,11 +66,6 @@ type GhostWallPreview = {
height: number height: number
} }
type GhostSlabPreview = {
id: string
polygon: Array<[number, number]>
}
function getLinkedWallSnapshots(args: { function getLinkedWallSnapshots(args: {
wallId: WallNode['id'] wallId: WallNode['id']
wallParentId: string | null wallParentId: string | null
@@ -222,27 +213,6 @@ function getWallGhostColor(wall: WallNode) {
return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color 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( function getWallsAfterUpdates(
nodes: ReturnType<typeof useScene.getState>['nodes'], nodes: ReturnType<typeof useScene.getState>['nodes'],
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>, updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
@@ -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<typeof useScene.getState>['nodes']) {
return Object.values(nodes).filter(
(entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId,
)
}
function getLevelAutoSlabs(
levelId: string,
nodes: ReturnType<typeof useScene.getState>['nodes'],
) {
return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls)
}
function getLevelAutoSlabSnapshots(levelId: string) {
return getLevelAutoSlabs(levelId, useScene.getState().nodes).map(cloneSlabSnapshot)
}
function buildBridgeWallCreates(args: { function buildBridgeWallCreates(args: {
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>> bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
nextStart: [number, number] nextStart: [number, number]
@@ -343,68 +339,6 @@ function buildBridgeWallPreviews(args: {
return previews 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<string>()
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( function setPreviewGeometryAttributes(
geometry: BufferGeometry, geometry: BufferGeometry,
positions: number[], positions: number[],
@@ -430,48 +364,6 @@ function createWallPreviewGeometry(length: number, height: number) {
return geometry 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 }) { function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) {
const dx = preview.end[0] - preview.start[0] const dx = preview.end[0] - preview.start[0]
const dz = preview.end[1] - preview.start[1] 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 (
<mesh
frustumCulled={false}
layers={EDITOR_LAYER}
position={[0, AUTO_SLAB_PREVIEW_Y, 0]}
renderOrder={1}
>
<primitive attach="geometry" object={geometry} />
<meshBasicMaterial
color="#38bdf8"
depthTest={false}
depthWrite={false}
opacity={0.2}
side={DoubleSide}
transparent
/>
</mesh>
)
}
export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const meta = const meta =
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) 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, originalEnd: node.end,
}), }),
) )
const originalAutoSlabsRef = useRef<SlabNode[]>(
node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [],
)
const dragAnchorRef = useRef<[number, number] | null>(null) const dragAnchorRef = useRef<[number, number] | null>(null)
const nodeIdRef = useRef(node.id) const nodeIdRef = useRef(node.id)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) 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] return [centerX, 0, centerZ]
}) })
const [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([]) const [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([])
const [ghostSlabPreviews, setGhostSlabPreviews] = useState<GhostSlabPreview[]>([])
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
@@ -588,9 +453,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const originalEnd = originalEndRef.current const originalEnd = originalEndRef.current
const originalCenter = originalCenterRef.current const originalCenter = originalCenterRef.current
const originalHalfVector = originalHalfVectorRef.current const originalHalfVector = originalHalfVectorRef.current
const levelId = node.parentId ?? null
const originalAutoSlabs = originalAutoSlabsRef.current
pauseSceneHistory(useScene) pauseSceneHistory(useScene)
let wasCommitted = false let shouldRestoreOnCleanup = true
const applyNodePreview = ( const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>, 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 buildWallFromCenter = (center: [number, number]) => {
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current) const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]] 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 nextGhostWalls = bridgePreviews.map((preview) => preview.ghost)
const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall) const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall)
const sceneNodes = useScene.getState().nodes
const levelId = node.parentId ?? null
setGhostWallPreviews(nextGhostWalls) 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) applyNodePreview(previewUpdates)
applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls])
} }
const restoreOriginal = () => { const restoreOriginal = () => {
setGhostWallPreviews([]) setGhostWallPreviews([])
setGhostSlabPreviews([])
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd }, { id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current, ...linkedOriginalsRef.current,
]) ])
restoreAutoSlabPreview()
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
@@ -738,16 +658,16 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const preview = previewRef.current ?? { start: originalStart, end: originalEnd } const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true shouldRestoreOnCleanup = false
// Restore original baseline while paused so the next resume+update // Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original). // registers as a single tracked change (undo reverts to original).
setGhostWallPreviews([]) setGhostWallPreviews([])
setGhostSlabPreviews([])
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd }, { id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current, ...linkedOriginalsRef.current,
]) ])
restoreAutoSlabPreview()
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
const commitPlan = getMovePlan(preview.start, preview.end) const commitPlan = getMovePlan(preview.start, preview.end)
@@ -848,6 +768,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
} }
const onCancel = () => { const onCancel = () => {
shouldRestoreOnCleanup = false
restoreOriginal() restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene) resumeSceneHistory(useScene)
@@ -862,7 +783,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
window.addEventListener('keyup', onKeyUp) window.addEventListener('keyup', onKeyUp)
return () => { return () => {
if (!wasCommitted) { if (shouldRestoreOnCleanup) {
restoreOriginal() restoreOriginal()
} }
shiftPressedRef.current = false shiftPressedRef.current = false
@@ -873,14 +794,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
} }
}, [exitMoveMode, isNew, node.metadata]) }, [exitMoveMode, isNew, node.metadata, node.parentId])
return ( return (
<group> <group>
<CursorSphere position={cursorLocalPos} showTooltip={false} /> <CursorSphere position={cursorLocalPos} showTooltip={false} />
{ghostSlabPreviews.map((preview) => (
<GhostSlabPreviewMesh key={preview.id} preview={preview} />
))}
{ghostWallPreviews.map((preview) => ( {ghostWallPreviews.map((preview) => (
<GhostWallPreviewMesh key={preview.id} preview={preview} /> <GhostWallPreviewMesh key={preview.id} preview={preview} />
))} ))}
@@ -4,12 +4,19 @@ import {
resolveMaterial, resolveMaterial,
useRegistry, useRegistry,
} from '@pascal-app/core' } 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 { float, mix, positionWorld, smoothstep } from 'three/tsl'
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
function createEmptyGeometry() {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
return geometry
}
const gridScale = 5 const gridScale = 5
const gridX = positionWorld.x.mul(gridScale).fract() const gridX = positionWorld.x.mul(gridScale).fract()
const gridY = positionWorld.z.mul(gridScale).fract() const gridY = positionWorld.z.mul(gridScale).fract()
@@ -51,10 +58,20 @@ function getCeilingMaterials(color = '#999999') {
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
const placeholderGeometry = useMemo(createEmptyGeometry, [])
const gridPlaceholderGeometry = useMemo(createEmptyGeometry, [])
useRegistry(node.id, 'ceiling', ref) useRegistry(node.id, 'ceiling', ref)
const handlers = useNodeEvents(node, 'ceiling') const handlers = useNodeEvents(node, 'ceiling')
useEffect(
() => () => {
placeholderGeometry.dispose()
gridPlaceholderGeometry.dispose()
},
[gridPlaceholderGeometry, placeholderGeometry],
)
const materials = useMemo(() => { const materials = useMemo(() => {
const preset = getMaterialPresetByRef(node.materialPreset) const preset = getMaterialPresetByRef(node.materialPreset)
const props = preset?.mapProperties ?? resolveMaterial(node.material) const props = preset?.mapProperties ?? resolveMaterial(node.material)
@@ -69,17 +86,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
]) ])
return ( return (
<mesh material={materials.bottomMaterial} ref={ref}> <mesh geometry={placeholderGeometry} material={materials.bottomMaterial} ref={ref}>
<boxGeometry args={[0, 0, 0]} />
<mesh <mesh
geometry={gridPlaceholderGeometry}
material={materials.topMaterial} material={materials.topMaterial}
name="ceiling-grid" name="ceiling-grid"
{...handlers} {...handlers}
scale={0} scale={0}
visible={false} visible={false}
> />
<boxGeometry args={[0, 0, 0]} />
</mesh>
{node.children.map((childId) => ( {node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
))} ))}
@@ -11,6 +11,12 @@ import {
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>() const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
function createEmptyGeometry() {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
return geometry
}
function getSlabMaterial( function getSlabMaterial(
cacheKey: string, cacheKey: string,
params: { material?: SlabNode['material']; materialPreset?: string }, params: { material?: SlabNode['material']; materialPreset?: string },
@@ -47,11 +53,14 @@ function getSlabMaterial(
export const SlabRenderer = ({ node }: { node: SlabNode }) => { export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
const placeholderGeometry = useMemo(createEmptyGeometry, [])
useRegistry(node.id, 'slab', ref) useRegistry(node.id, 'slab', ref)
const handlers = useNodeEvents(node, 'slab') const handlers = useNodeEvents(node, 'slab')
useEffect(() => () => placeholderGeometry.dispose(), [placeholderGeometry])
const material = useMemo(() => { const material = useMemo(() => {
const resolvedMaterial = node.material const resolvedMaterial = node.material
const resolvedMaterialPreset = node.materialPreset const resolvedMaterialPreset = node.materialPreset
@@ -75,13 +84,12 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
return ( return (
<mesh <mesh
castShadow castShadow
geometry={placeholderGeometry}
receiveShadow receiveShadow
ref={ref} ref={ref}
{...handlers} {...handlers}
material={material} material={material}
visible={node.visible} visible={node.visible}
> />
<boxGeometry args={[0, 0, 0]} />
</mesh>
) )
} }
@@ -50,7 +50,7 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) {
const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh
if (gridMesh) { if (gridMesh) {
gridMesh.geometry.dispose() gridMesh.geometry.dispose()
gridMesh.geometry = newGeo gridMesh.geometry = newGeo.clone()
} }
// Position at the ceiling height // Position at the ceiling height