Add 3D wall endpoint move controls

This commit is contained in:
sudhir
2026-04-16 11:50:40 +05:30
parent f563dd9763
commit 1cd71630bc
4 changed files with 381 additions and 24 deletions
@@ -14,11 +14,13 @@ import {
StairSegmentNode,
sceneRegistry,
useScene,
type WallNode,
WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { Move } from 'lucide-react'
import { useCallback, useRef } from 'react'
import * as THREE from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
@@ -47,12 +49,16 @@ export function FloatingActionMenu() {
const updateNode = useScene((s) => s.updateNode)
const mode = useEditor((s) => s.mode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const setSelection = useViewer((s) => s.setSelection)
const setEditingHole = useEditor((s) => s.setEditingHole)
const groupRef = useRef<THREE.Group>(null)
const startEndpointGroupRef = useRef<THREE.Group>(null)
const endEndpointGroupRef = useRef<THREE.Group>(null)
// Only show for single selection of specific types
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
@@ -85,6 +91,29 @@ export function FloatingActionMenu() {
const yOffset = isStructural ? 0.8 : 0.3
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
}
if (node?.type === 'wall') {
const wall = node as WallNode
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
const endpointYOffset = 0.35
const startWorld = obj.localToWorld(new THREE.Vector3(0, 0, 0))
const endWorld = obj.localToWorld(new THREE.Vector3(wallLength, 0, 0))
if (startEndpointGroupRef.current) {
startEndpointGroupRef.current.position.set(
startWorld.x,
startWorld.y + endpointYOffset,
startWorld.z,
)
}
if (endEndpointGroupRef.current) {
endEndpointGroupRef.current.position.set(
endWorld.x,
endWorld.y + endpointYOffset,
endWorld.z,
)
}
}
}
})
@@ -122,6 +151,16 @@ export function FloatingActionMenu() {
},
[canCurveSelectedWall, node, setCurvingWall, setSelection],
)
const handleEndpointMove = useCallback(
(endpoint: 'start' | 'end', e: React.MouseEvent) => {
e.stopPropagation()
if (!(node && node.type === 'wall')) return
sfxEmitter.emit('sfx:item-pick')
setMovingWallEndpoint({ wall: node, endpoint })
setSelection({ selectedIds: [] })
},
[node, setMovingWallEndpoint, setSelection],
)
const handleDuplicate = useCallback(
(e: React.MouseEvent) => {
@@ -307,32 +346,68 @@ export function FloatingActionMenu() {
[node?.type, selectedId, setSelection],
)
if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
if (
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
movingWallEndpoint
)
return null
return (
<group ref={groupRef}>
<Html
center
style={{
pointerEvents: 'auto',
touchAction: 'none',
}}
zIndexRange={[100, 0]}
>
<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) ? handleMove : undefined}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
/>
</Html>
<group>
<group ref={groupRef}>
<Html
center
style={{
pointerEvents: 'auto',
touchAction: 'none',
}}
zIndexRange={[100, 0]}
>
<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) ? handleMove : undefined}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
/>
</Html>
</group>
{node?.type === 'wall' && (
<>
<group ref={startEndpointGroupRef}>
<Html center style={{ pointerEvents: 'auto', touchAction: 'none' }} zIndexRange={[100, 0]}>
<button
aria-label="Move wall start"
className="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-muted-foreground shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-foreground"
onClick={(e) => handleEndpointMove('start', e)}
onPointerDown={(e) => e.stopPropagation()}
type="button"
>
<Move className="h-4 w-4" />
</button>
</Html>
</group>
<group ref={endEndpointGroupRef}>
<Html center style={{ pointerEvents: 'auto', touchAction: 'none' }} zIndexRange={[100, 0]}>
<button
aria-label="Move wall end"
className="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-muted-foreground shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-foreground"
onClick={(e) => handleEndpointMove('end', e)}
onPointerDown={(e) => e.stopPropagation()}
type="button"
>
<Move className="h-4 w-4" />
</button>
</Html>
</group>
</>
)}
</group>
)
}
@@ -21,6 +21,7 @@ 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 { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
import { WallTool } from './wall/wall-tool'
import { WindowTool } from './window/window-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
@@ -52,6 +53,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 movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
@@ -142,6 +144,7 @@ export const ToolManager: React.FC = () => {
{showCeilingHoleEditor && selectedCeilingId && editingHole && (
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)}
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
{curvingWall && <CurveWallTool node={curvingWall} />}
{movingNode && movingNode.type !== 'building' && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
@@ -0,0 +1,266 @@
'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, { type MovingWallEndpoint } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import {
isWallLongEnough,
snapWallDraftPoint,
type WallPlanPoint,
} from './wall-drafting'
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
return a[0] === b[0] && a[1] === b[1]
}
type LinkedWallSnapshot = {
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
}
function getLinkedWallSnapshots(args: {
wallId: WallNode['id']
wallParentId: string | null
originalStart: WallPlanPoint
originalEnd: WallPlanPoint
}) {
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 WallPlanPoint,
end: [...node.end] as WallPlanPoint,
})
}
return snapshots
}
function getLinkedWallUpdates(
linkedWalls: LinkedWallSnapshot[],
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
nextStart: WallPlanPoint,
nextEnd: WallPlanPoint,
) {
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 MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<WallPlanPoint | null>(null)
const shiftPressedRef = useRef(false)
const nodeIdRef = useRef(target.wall.id)
const originalStartRef = useRef<WallPlanPoint>([...target.wall.start] as WallPlanPoint)
const originalEndRef = useRef<WallPlanPoint>([...target.wall.end] as WallPlanPoint)
const fixedPointRef = useRef<WallPlanPoint>(
target.endpoint === 'start'
? ([...target.wall.end] as WallPlanPoint)
: ([...target.wall.start] as WallPlanPoint),
)
const linkedOriginalsRef = useRef(
getLinkedWallSnapshots({
wallId: target.wall.id,
wallParentId: target.wall.parentId ?? null,
originalStart: target.wall.start,
originalEnd: target.wall.end,
}),
)
const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
return [point[0], 0, point[1]]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingWallEndpoint(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const fixedPoint = fixedPointRef.current
const levelWalls = Object.values(useScene.getState().nodes).filter(
(node): node is WallNode =>
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
)
useScene.temporal.getState().pause()
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>,
) => {
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 applyPreview = (movingPoint: WallPlanPoint) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
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 planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
const snappedPoint = snapWallDraftPoint({
point: planPoint,
walls: levelWalls,
start: fixedPoint,
angleSnap: !shiftPressedRef.current,
ignoreWallIds: [nodeId],
})
if (
previousGridPosRef.current &&
(snappedPoint[0] !== previousGridPosRef.current[0] ||
snappedPoint[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = snappedPoint
applyPreview(snappedPoint)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged =
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
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 onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
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)
}
}, [exitMoveMode, target])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
+13
View File
@@ -74,6 +74,11 @@ export type FloorplanSelectionTool = 'click' | 'marquee'
// Combined tool type
export type Tool = SiteTool | StructureTool | FurnishTool
export type MovingWallEndpoint = {
wall: WallNode
endpoint: 'start' | 'end'
}
type EditorState = {
phase: Phase
setPhase: (phase: Phase) => void
@@ -117,6 +122,8 @@ type EditorState = {
| BuildingNode
| null,
) => void
movingWallEndpoint: MovingWallEndpoint | null
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void
selectedReferenceId: string | null
@@ -467,6 +474,10 @@ const useEditor = create<EditorState>()(
| ItemNode
| WindowNode
| DoorNode
| FenceNode
| CeilingNode
| SlabNode
| WallNode
| RoofNode
| RoofSegmentNode
| StairNode
@@ -474,6 +485,8 @@ const useEditor = create<EditorState>()(
| BuildingNode
| null,
setMovingNode: (node) => set({ movingNode: node }),
movingWallEndpoint: null,
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedReferenceId: null,