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
@@ -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({
>
<NodeActionMenu
onAddHole={entry.onAddHole}
onCurve={entry.onCurve}
onDelete={entry.onDelete}
onDuplicate={entry.onDuplicate}
onMove={entry.onMove}
@@ -39,6 +39,7 @@ import {
sceneRegistry,
useLiveTransforms,
useScene,
WallNode as WallNodeSchema,
type WallNode,
WindowNode,
ZoneNode as ZoneNodeSchema,
@@ -7554,6 +7555,7 @@ export function FloorplanPanel() {
const setPhase = useEditor((state) => 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<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(
(event: ReactMouseEvent<HTMLButtonElement>) => {
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,
}}
/>
@@ -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<typeof useScene.getState>['nodes'],
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: {
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
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<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(
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 (
<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 }) => {
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<SlabNode[]>(
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<GhostWallPreview[]>([])
const [ghostSlabPreviews, setGhostSlabPreviews] = useState<GhostSlabPreview[]>([])
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 (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
{ghostSlabPreviews.map((preview) => (
<GhostSlabPreviewMesh key={preview.id} preview={preview} />
))}
{ghostWallPreviews.map((preview) => (
<GhostWallPreviewMesh key={preview.id} preview={preview} />
))}