diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 0f4cdbc8..071dda03 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -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(null) + const startEndpointGroupRef = useRef(null) + const endEndpointGroupRef = useRef(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 ( - - - e.stopPropagation()} - onPointerUp={(e) => e.stopPropagation()} - /> - + + + + e.stopPropagation()} + onPointerUp={(e) => e.stopPropagation()} + /> + + + {node?.type === 'wall' && ( + <> + + + + + + + + + + + + )} ) } diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index cf36a502..9dacd1f8 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -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 && ( )} + {movingWallEndpoint && } {curvingWall && } {movingNode && movingNode.type !== 'building' && } {!movingNode && BuildToolComponent && } diff --git a/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx b/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx new file mode 100644 index 00000000..292f6478 --- /dev/null +++ b/packages/editor/src/components/tools/wall/move-wall-endpoint-tool.tsx @@ -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(Date.now()) + const previousGridPosRef = useRef(null) + const shiftPressedRef = useRef(false) + const nodeIdRef = useRef(target.wall.id) + const originalStartRef = useRef([...target.wall.start] as WallPlanPoint) + const originalEndRef = useRef([...target.wall.end] as WallPlanPoint) + const fixedPointRef = useRef( + 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 ( + + + + ) +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 9f624144..c648451e 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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()( | ItemNode | WindowNode | DoorNode + | FenceNode + | CeilingNode + | SlabNode + | WallNode | RoofNode | RoofSegmentNode | StairNode @@ -474,6 +485,8 @@ const useEditor = create()( | BuildingNode | null, setMovingNode: (node) => set({ movingNode: node }), + movingWallEndpoint: null, + setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }), curvingWall: null, setCurvingWall: (wall) => set({ curvingWall: wall }), selectedReferenceId: null,