Phase 5 Stage D wall: port endpoint move, whole-move, placement (1:1 legacy)
Three remaining wall affordances ported into nodes/src/wall/ as
direct copies of the legacy implementations. Wall D is now complete.
- move-endpoint-tool.tsx (426 LoC) — linked-wall corner cascade +
Alt-detach + angle label. Mounted via `affordanceTools.move-endpoint`.
- move-tool.tsx (804 LoC, the most complex tool in the editor) —
center-drag with axis lock, linked-wall corner cascade via
`planWallMoveJunctions`, bridge wall ghost previews, auto-slab
live preview via `planAutoSlabsForLevel`, R/T rotation in 45°
steps, Shift to bypass grid snap, isNew metadata strip on first
commit. Mounted via `affordanceTools.move`.
- tool.tsx (332 LoC) — two-click placement with length/angle HUD,
Shift to bypass angle snap. Mounted via `def.tool`.
Editor public surface gains:
- createWallOnCurrentLevel, snapWallDraftPoint, WallPlanPoint
- MovingWallEndpoint type
ToolManager dispatch for `movingWallEndpoint` routes through the
registry with the legacy fallback (same shape as the fence
move-endpoint dispatch).
Wall is now A ✅ C ✅ D ✅. Stage B still pending (geometry depends on
level-batch miter data, blocked on `ctx.levelData` design decision).
Stage E pending (drop WallPanel — has slider drags + actions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
6a1d853dd8
commit
56e022a093
@@ -229,7 +229,20 @@ export const ToolManager: React.FC = () => {
|
|||||||
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
{movingWallEndpoint &&
|
||||||
|
(() => {
|
||||||
|
const RegistryAffordance = getRegistryAffordanceTool(
|
||||||
|
movingWallEndpoint.wall.type,
|
||||||
|
'move-endpoint',
|
||||||
|
)
|
||||||
|
return RegistryAffordance ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<RegistryAffordance target={movingWallEndpoint} />
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<MoveWallEndpointTool target={movingWallEndpoint} />
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{movingFenceEndpoint &&
|
{movingFenceEndpoint &&
|
||||||
(() => {
|
(() => {
|
||||||
const RegistryAffordance = getRegistryAffordanceTool(
|
const RegistryAffordance = getRegistryAffordanceTool(
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ export {
|
|||||||
getSegmentAngleReferenceAtPoint,
|
getSegmentAngleReferenceAtPoint,
|
||||||
} from './components/tools/shared/segment-angle'
|
} from './components/tools/shared/segment-angle'
|
||||||
export {
|
export {
|
||||||
|
createWallOnCurrentLevel,
|
||||||
getWallGridStep,
|
getWallGridStep,
|
||||||
isWallLongEnough,
|
isWallLongEnough,
|
||||||
snapScalarToGrid,
|
snapScalarToGrid,
|
||||||
|
snapWallDraftPoint,
|
||||||
|
type WallPlanPoint,
|
||||||
} from './components/tools/wall/wall-drafting'
|
} from './components/tools/wall/wall-drafting'
|
||||||
export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
|
export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
|
||||||
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
|
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
|
||||||
@@ -74,6 +77,7 @@ export { type CommandAction, useCommandRegistry } from './store/use-command-regi
|
|||||||
export type {
|
export type {
|
||||||
FloorplanSelectionTool,
|
FloorplanSelectionTool,
|
||||||
MovingFenceEndpoint,
|
MovingFenceEndpoint,
|
||||||
|
MovingWallEndpoint,
|
||||||
SplitOrientation,
|
SplitOrientation,
|
||||||
ViewMode,
|
ViewMode,
|
||||||
} from './store/use-editor'
|
} from './store/use-editor'
|
||||||
|
|||||||
@@ -57,13 +57,17 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
|||||||
|
|
||||||
parametrics: wallParametrics,
|
parametrics: wallParametrics,
|
||||||
|
|
||||||
// Stage D — wall curve is a 1:1 port of the legacy CurveWallTool,
|
// Stage D — all four wall drag affordances live in this folder.
|
||||||
// relocated into this folder and dispatched via the registry. Endpoint
|
// curve / move-endpoint / move are 1:1 ports of the legacy tools
|
||||||
// move (linked-wall corner cascade + ALT-detach), whole-wall move, and
|
// (same snap pipelines, linked-wall corner cascade with
|
||||||
// placement are still legacy — they're substantially larger and queued
|
// `planWallMoveJunctions`, ALT-detach, bridge wall previews,
|
||||||
// for separate port passes.
|
// auto-slab live preview, history dances). Placement is wired via
|
||||||
|
// `def.tool`.
|
||||||
|
tool: () => import('./tool'),
|
||||||
affordanceTools: {
|
affordanceTools: {
|
||||||
curve: () => import('./curve-tool'),
|
curve: () => import('./curve-tool'),
|
||||||
|
'move-endpoint': () => import('./move-endpoint-tool'),
|
||||||
|
move: () => import('./move-tool'),
|
||||||
},
|
},
|
||||||
|
|
||||||
renderer: {
|
renderer: {
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
|
pauseSceneHistory,
|
||||||
|
resumeSceneHistory,
|
||||||
|
useScene,
|
||||||
|
type WallNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
CursorSphere,
|
||||||
|
formatAngleRadians,
|
||||||
|
getAngleToSegmentReference,
|
||||||
|
getSegmentAngleReferenceAtPoint,
|
||||||
|
isWallLongEnough,
|
||||||
|
type MovingWallEndpoint,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
snapWallDraftPoint,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
type WallPlanPoint,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 5 Stage D — wall endpoint move tool (kind-owned).
|
||||||
|
*
|
||||||
|
* 1:1 port of the legacy `MoveWallEndpointTool` (426 LoC, the largest
|
||||||
|
* single tool in wall/). Same drag pipeline (snap → preview → apply
|
||||||
|
* with linked-wall corner cascade), same Alt-detach modifier, same
|
||||||
|
* angle label between the dragged segment and any neighbour sharing
|
||||||
|
* the endpoint, same single-undo dance on commit, same activation
|
||||||
|
* grace window.
|
||||||
|
*
|
||||||
|
* Mounted via `def.affordanceTools['move-endpoint']` from
|
||||||
|
* `wall/definition.ts`. Editor state trigger is
|
||||||
|
* `useEditor.movingWallEndpoint`.
|
||||||
|
*/
|
||||||
|
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
|
||||||
|
return a[0] === b[0] && a[1] === b[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
type WallSegmentLike = {
|
||||||
|
id: WallNode['id']
|
||||||
|
start: WallPlanPoint
|
||||||
|
end: WallPlanPoint
|
||||||
|
curveOffset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type AngleLabelState = {
|
||||||
|
label: string
|
||||||
|
position: [number, number, number]
|
||||||
|
} | null
|
||||||
|
|
||||||
|
function getEndpointAngleLabel(args: {
|
||||||
|
preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number }
|
||||||
|
walls: WallSegmentLike[]
|
||||||
|
nodeId: WallNode['id']
|
||||||
|
}): AngleLabelState {
|
||||||
|
const { preview, walls, nodeId } = args
|
||||||
|
const endpoints = [{ point: preview.start }, { point: preview.end }]
|
||||||
|
const targetSegment: WallSegmentLike = {
|
||||||
|
id: nodeId,
|
||||||
|
start: preview.start,
|
||||||
|
end: preview.end,
|
||||||
|
curveOffset: preview.curveOffset,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
|
||||||
|
if (!targetReference) continue
|
||||||
|
|
||||||
|
const connectedWall = walls.find(
|
||||||
|
(wall) =>
|
||||||
|
wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
|
||||||
|
)
|
||||||
|
if (!connectedWall) continue
|
||||||
|
|
||||||
|
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
|
||||||
|
if (!connectedReference) continue
|
||||||
|
|
||||||
|
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
|
||||||
|
if (angle === null) continue
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: formatAngleRadians(angle),
|
||||||
|
position: [endpoint.point[0], 0.34, endpoint.point[1]],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkedWallSnapshot = {
|
||||||
|
id: WallNode['id']
|
||||||
|
start: WallPlanPoint
|
||||||
|
end: WallPlanPoint
|
||||||
|
curveOffset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
curveOffset: node.curveOffset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkedWallUpdates(
|
||||||
|
linkedWalls: LinkedWallSnapshot[],
|
||||||
|
originalStart: WallPlanPoint,
|
||||||
|
originalEnd: WallPlanPoint,
|
||||||
|
nextStart: WallPlanPoint,
|
||||||
|
nextEnd: WallPlanPoint,
|
||||||
|
) {
|
||||||
|
return linkedWalls.map((wall) => ({
|
||||||
|
id: wall.id,
|
||||||
|
curveOffset: wall.curveOffset,
|
||||||
|
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 altPressedRef = 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 [angleLabel, setAngleLabel] = useState<AngleLabelState>(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 [altPressed, setAltPressed] = useState(false)
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
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, detachLinkedWalls = false) => {
|
||||||
|
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
||||||
|
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
||||||
|
const linkedUpdates = detachLinkedWalls
|
||||||
|
? []
|
||||||
|
: getLinkedWallUpdates(
|
||||||
|
linkedOriginalsRef.current,
|
||||||
|
originalStart,
|
||||||
|
originalEnd,
|
||||||
|
nextStart,
|
||||||
|
nextEnd,
|
||||||
|
)
|
||||||
|
previewRef.current = { start: nextStart, end: nextEnd }
|
||||||
|
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||||
|
setAngleLabel(
|
||||||
|
getEndpointAngleLabel({
|
||||||
|
preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset },
|
||||||
|
walls: [
|
||||||
|
...levelWalls.map((wall) => ({
|
||||||
|
id: wall.id,
|
||||||
|
start: wall.start,
|
||||||
|
end: wall.end,
|
||||||
|
curveOffset: wall.curveOffset,
|
||||||
|
})),
|
||||||
|
...linkedUpdates,
|
||||||
|
],
|
||||||
|
nodeId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreOriginal = (clearAngleLabel = true) => {
|
||||||
|
applyNodePreview([
|
||||||
|
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||||
|
...linkedOriginalsRef.current,
|
||||||
|
])
|
||||||
|
if (clearAngleLabel) {
|
||||||
|
setAngleLabel(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
) {
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
previousGridPosRef.current = snappedPoint
|
||||||
|
|
||||||
|
applyPreview(snappedPoint, event.nativeEvent.altKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Restore original baseline while paused so the next resume+update
|
||||||
|
// registers as a single tracked change (undo reverts to original).
|
||||||
|
applyNodePreview([
|
||||||
|
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||||
|
...linkedOriginalsRef.current,
|
||||||
|
])
|
||||||
|
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
applyNodePreview([
|
||||||
|
{ id: nodeId, start: preview.start, end: preview.end },
|
||||||
|
...(altPressedRef.current
|
||||||
|
? []
|
||||||
|
: getLinkedWallUpdates(
|
||||||
|
linkedOriginalsRef.current,
|
||||||
|
originalStart,
|
||||||
|
originalEnd,
|
||||||
|
preview.start,
|
||||||
|
preview.end,
|
||||||
|
)),
|
||||||
|
])
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
}
|
||||||
|
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
|
setAngleLabel(null)
|
||||||
|
exitMoveMode()
|
||||||
|
event.nativeEvent?.stopPropagation?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
restoreOriginal()
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
setAngleLabel(null)
|
||||||
|
markToolCancelConsumed()
|
||||||
|
exitMoveMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
shiftPressedRef.current = true
|
||||||
|
}
|
||||||
|
if (event.key === 'Alt') {
|
||||||
|
altPressedRef.current = true
|
||||||
|
setAltPressed(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
shiftPressedRef.current = false
|
||||||
|
}
|
||||||
|
if (event.key === 'Alt') {
|
||||||
|
altPressedRef.current = false
|
||||||
|
setAltPressed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWindowBlur = () => {
|
||||||
|
shiftPressedRef.current = false
|
||||||
|
altPressedRef.current = false
|
||||||
|
setAltPressed(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('grid:move', onGridMove)
|
||||||
|
emitter.on('grid:click', onGridClick)
|
||||||
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('keyup', onKeyUp)
|
||||||
|
window.addEventListener('blur', onWindowBlur)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!wasCommitted) {
|
||||||
|
restoreOriginal(false)
|
||||||
|
}
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
emitter.off('grid:move', onGridMove)
|
||||||
|
emitter.off('grid:click', onGridClick)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
|
window.removeEventListener('blur', onWindowBlur)
|
||||||
|
}
|
||||||
|
}, [exitMoveMode, target])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||||
|
<Html
|
||||||
|
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
|
||||||
|
style={{ pointerEvents: 'none', touchAction: 'none' }}
|
||||||
|
zIndexRange={[100, 0]}
|
||||||
|
>
|
||||||
|
<div className="translate-y-10">
|
||||||
|
<div
|
||||||
|
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
|
||||||
|
altPressed
|
||||||
|
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
|
||||||
|
: 'border-border bg-background/95 text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{altPressed ? 'Detaching corner' : 'Alt to detach'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EndpointAngleLabel({
|
||||||
|
label,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
position: [number, number, number]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
||||||
|
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MoveWallEndpointTool
|
||||||
@@ -0,0 +1,833 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
constrainWallMoveDeltaToAxis,
|
||||||
|
DEFAULT_WALL_HEIGHT,
|
||||||
|
detectSpacesForLevel,
|
||||||
|
emitter,
|
||||||
|
type GridEvent,
|
||||||
|
getMaterialPresetByRef,
|
||||||
|
getPerpendicularWallMoveAxis,
|
||||||
|
pauseSceneHistory,
|
||||||
|
planAutoSlabsForLevel,
|
||||||
|
planWallMoveJunctions,
|
||||||
|
resolveMaterial,
|
||||||
|
resumeSceneHistory,
|
||||||
|
type SlabNode,
|
||||||
|
useScene,
|
||||||
|
type WallMoveAxis,
|
||||||
|
type WallMoveBridgePlan,
|
||||||
|
type WallMoveJunctionPlan,
|
||||||
|
type WallNode,
|
||||||
|
WallNode as WallSchema,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
CursorSphere,
|
||||||
|
EDITOR_LAYER,
|
||||||
|
getWallGridStep,
|
||||||
|
isWallLongEnough,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
snapScalarToGrid,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 5 Stage D — wall whole-move tool (kind-owned).
|
||||||
|
*
|
||||||
|
* 1:1 port of the legacy `MoveWallTool` (804 LoC, the most complex
|
||||||
|
* single tool in the editor). Preserves every behavior:
|
||||||
|
*
|
||||||
|
* - **Center-drag with axis lock** — wall stays perpendicular to its
|
||||||
|
* move axis unless rotated via R/T (45° steps).
|
||||||
|
* - **Linked-wall corner cascade** — neighbours sharing endpoints
|
||||||
|
* move with the dragged wall via `planWallMoveJunctions`.
|
||||||
|
* - **Bridge wall ghost previews** — when a corner separates, a
|
||||||
|
* translucent ghost shows the new wall that would be inserted.
|
||||||
|
* - **Auto-slab live preview** — `planAutoSlabsForLevel` runs every
|
||||||
|
* tick so room slabs adapt to the new wall layout in real time.
|
||||||
|
* - **Single-undo dance** — paused history during drag, restore +
|
||||||
|
* resume + reapply on commit so one Ctrl-Z rolls back the whole
|
||||||
|
* operation.
|
||||||
|
* - **`isNew` metadata strip** — first commit after a fresh wall
|
||||||
|
* placement clears the placement marker.
|
||||||
|
* - **Activation grace** (150ms) + Shift to bypass grid snap.
|
||||||
|
*
|
||||||
|
* Mounted via `def.affordanceTools.move` from `wall/definition.ts`.
|
||||||
|
*/
|
||||||
|
function rotateVector([x, z]: [number, number], angle: number): [number, number] {
|
||||||
|
const cos = Math.cos(angle)
|
||||||
|
const sin = Math.sin(angle)
|
||||||
|
return [x * cos - z * sin, x * sin + z * cos]
|
||||||
|
}
|
||||||
|
|
||||||
|
function samePoint(a: [number, number], b: [number, number]) {
|
||||||
|
return a[0] === b[0] && a[1] === b[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointKey(point: [number, number]) {
|
||||||
|
return `${point[0]}:${point[1]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
|
||||||
|
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
|
||||||
|
delete nextMeta.isNew
|
||||||
|
return nextMeta as WallNode['metadata']
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkedWallSnapshot = WallNode
|
||||||
|
|
||||||
|
type GhostWallPreview = {
|
||||||
|
id: string
|
||||||
|
start: [number, number]
|
||||||
|
end: [number, number]
|
||||||
|
color: string
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkedWallSnapshots(args: {
|
||||||
|
wallId: WallNode['id']
|
||||||
|
wallParentId: string | null
|
||||||
|
originalStart: [number, number]
|
||||||
|
originalEnd: [number, number]
|
||||||
|
}) {
|
||||||
|
const { wallId, wallParentId, originalStart, originalEnd } = args
|
||||||
|
const { nodes } = useScene.getState()
|
||||||
|
const walls = Object.values(nodes).filter(
|
||||||
|
(node): node is WallNode =>
|
||||||
|
node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId,
|
||||||
|
)
|
||||||
|
const directlyLinkedWalls = walls.filter(
|
||||||
|
(wall) =>
|
||||||
|
samePoint(wall.start, originalStart) ||
|
||||||
|
samePoint(wall.start, originalEnd) ||
|
||||||
|
samePoint(wall.end, originalStart) ||
|
||||||
|
samePoint(wall.end, originalEnd),
|
||||||
|
)
|
||||||
|
const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)])
|
||||||
|
|
||||||
|
for (const wall of directlyLinkedWalls) {
|
||||||
|
contextPoints.add(pointKey(wall.start))
|
||||||
|
contextPoints.add(pointKey(wall.end))
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshots: LinkedWallSnapshot[] = []
|
||||||
|
const seenWallIds = new Set<WallNode['id']>()
|
||||||
|
|
||||||
|
for (const node of walls) {
|
||||||
|
if (!contextPoints.has(pointKey(node.start)) && !contextPoints.has(pointKey(node.end))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenWallIds.has(node.id)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenWallIds.add(node.id)
|
||||||
|
|
||||||
|
snapshots.push({
|
||||||
|
...node,
|
||||||
|
start: [...node.start] as [number, number],
|
||||||
|
end: [...node.end] as [number, number],
|
||||||
|
children: [...(node.children ?? [])],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkedWallUpdates(
|
||||||
|
linkedWalls: Array<{
|
||||||
|
wall: LinkedWallSnapshot
|
||||||
|
matchPoint?: [number, number]
|
||||||
|
targetPoint?: [number, number]
|
||||||
|
}>,
|
||||||
|
originalStart: [number, number],
|
||||||
|
originalEnd: [number, number],
|
||||||
|
nextStart: [number, number],
|
||||||
|
nextEnd: [number, number],
|
||||||
|
) {
|
||||||
|
return linkedWalls.map(({ wall, matchPoint, targetPoint }) => {
|
||||||
|
if (matchPoint && targetPoint) {
|
||||||
|
return {
|
||||||
|
id: wall.id,
|
||||||
|
start: samePoint(wall.start, matchPoint) ? targetPoint : wall.start,
|
||||||
|
end: samePoint(wall.end, matchPoint) ? targetPoint : wall.end,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStart = targetPoint ?? nextStart
|
||||||
|
const targetEnd = targetPoint ?? nextEnd
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: wall.id,
|
||||||
|
start: samePoint(wall.start, originalStart)
|
||||||
|
? targetStart
|
||||||
|
: samePoint(wall.start, originalEnd)
|
||||||
|
? targetEnd
|
||||||
|
: wall.start,
|
||||||
|
end: samePoint(wall.end, originalStart)
|
||||||
|
? targetStart
|
||||||
|
: samePoint(wall.end, originalEnd)
|
||||||
|
? targetEnd
|
||||||
|
: wall.end,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlannedLinkedWallUpdates(
|
||||||
|
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
|
||||||
|
originalStart: [number, number],
|
||||||
|
originalEnd: [number, number],
|
||||||
|
nextStart: [number, number],
|
||||||
|
nextEnd: [number, number],
|
||||||
|
) {
|
||||||
|
const movePlans = new Map<
|
||||||
|
WallNode['id'],
|
||||||
|
{ wall: LinkedWallSnapshot; matchPoint?: [number, number]; targetPoint?: [number, number] }
|
||||||
|
>()
|
||||||
|
|
||||||
|
for (const wall of plan.linkedWallsToMove) {
|
||||||
|
movePlans.set(wall.id, { wall })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const targetPlan of plan.linkedWallTargetPlans) {
|
||||||
|
movePlans.set(targetPlan.wall.id, {
|
||||||
|
wall: targetPlan.wall,
|
||||||
|
matchPoint: targetPlan.originalPoint,
|
||||||
|
targetPoint: targetPlan.targetPoint,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return getLinkedWallUpdates(
|
||||||
|
Array.from(movePlans.values()),
|
||||||
|
originalStart,
|
||||||
|
originalEnd,
|
||||||
|
nextStart,
|
||||||
|
nextEnd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function wallSegmentExists(
|
||||||
|
walls: Array<Pick<WallNode, 'start' | 'end'>>,
|
||||||
|
start: [number, number],
|
||||||
|
end: [number, number],
|
||||||
|
) {
|
||||||
|
return walls.some(
|
||||||
|
(wall) =>
|
||||||
|
(samePoint(wall.start, start) && samePoint(wall.end, end)) ||
|
||||||
|
(samePoint(wall.start, end) && samePoint(wall.end, start)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWallGhostColor(wall: WallNode) {
|
||||||
|
const presetColor =
|
||||||
|
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
|
||||||
|
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
|
||||||
|
getMaterialPresetByRef(wall.exteriorMaterialPreset)?.mapProperties.color
|
||||||
|
|
||||||
|
if (presetColor) {
|
||||||
|
return presetColor
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWallsAfterUpdates(
|
||||||
|
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
||||||
|
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
|
||||||
|
) {
|
||||||
|
const updateById = new Map(updates.map((update) => [update.id, update.data]))
|
||||||
|
|
||||||
|
return Object.values(nodes)
|
||||||
|
.filter((node): node is WallNode => node?.type === 'wall')
|
||||||
|
.map((wall) => {
|
||||||
|
const update = updateById.get(wall.id as AnyNodeId)
|
||||||
|
return update ? ({ ...wall, ...update } as WallNode) : wall
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
nextEnd: [number, number]
|
||||||
|
existingWalls: WallNode[]
|
||||||
|
wallCount: number
|
||||||
|
}): Array<{ node: WallNode; parentId?: AnyNodeId }> {
|
||||||
|
const { bridgePlans, nextStart, nextEnd, existingWalls, wallCount } = args
|
||||||
|
const wallsForDuplicateCheck = [...existingWalls]
|
||||||
|
const creates: Array<{ node: WallNode; parentId?: AnyNodeId }> = []
|
||||||
|
|
||||||
|
for (const plan of bridgePlans) {
|
||||||
|
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
|
||||||
|
|
||||||
|
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: _id, parentId: _parentId, children: _children, ...sourceWall } = plan.wall
|
||||||
|
const bridgeWall = WallSchema.parse({
|
||||||
|
...sourceWall,
|
||||||
|
name: `Wall ${wallCount + creates.length + 1}`,
|
||||||
|
start: plan.originalPoint,
|
||||||
|
end: nextPoint,
|
||||||
|
children: [],
|
||||||
|
metadata: stripWallIsNewMetadata(plan.wall.metadata),
|
||||||
|
})
|
||||||
|
|
||||||
|
creates.push({
|
||||||
|
node: bridgeWall,
|
||||||
|
parentId: (plan.wall.parentId ?? undefined) as AnyNodeId | undefined,
|
||||||
|
})
|
||||||
|
wallsForDuplicateCheck.push(bridgeWall)
|
||||||
|
}
|
||||||
|
|
||||||
|
return creates
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBridgeWallPreviews(args: {
|
||||||
|
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
|
||||||
|
nextStart: [number, number]
|
||||||
|
nextEnd: [number, number]
|
||||||
|
existingWalls: WallNode[]
|
||||||
|
}): Array<{ ghost: GhostWallPreview; wall: WallNode }> {
|
||||||
|
const { bridgePlans, nextStart, nextEnd, existingWalls } = args
|
||||||
|
const wallsForDuplicateCheck: Array<Pick<WallNode, 'start' | 'end'>> = [...existingWalls]
|
||||||
|
const previews: Array<{ ghost: GhostWallPreview; wall: WallNode }> = []
|
||||||
|
|
||||||
|
for (const plan of bridgePlans) {
|
||||||
|
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
|
||||||
|
|
||||||
|
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: _id, children: _children, ...sourceWall } = plan.wall
|
||||||
|
const wall = WallSchema.parse({
|
||||||
|
...sourceWall,
|
||||||
|
name: 'Wall Preview',
|
||||||
|
start: plan.originalPoint,
|
||||||
|
end: nextPoint,
|
||||||
|
children: [],
|
||||||
|
metadata: stripWallIsNewMetadata(plan.wall.metadata),
|
||||||
|
})
|
||||||
|
const ghost = {
|
||||||
|
id: `${plan.wall.id}:${plan.movedEndpoint}:${previews.length}`,
|
||||||
|
start: [...plan.originalPoint] as [number, number],
|
||||||
|
end: [...nextPoint] as [number, number],
|
||||||
|
color: getWallGhostColor(plan.wall),
|
||||||
|
height: plan.wall.height ?? DEFAULT_WALL_HEIGHT,
|
||||||
|
}
|
||||||
|
previews.push({ ghost, wall })
|
||||||
|
wallsForDuplicateCheck.push(wall)
|
||||||
|
}
|
||||||
|
|
||||||
|
return previews
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPreviewGeometryAttributes(
|
||||||
|
geometry: BufferGeometry,
|
||||||
|
positions: number[],
|
||||||
|
normals: number[],
|
||||||
|
uvs: number[],
|
||||||
|
) {
|
||||||
|
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
||||||
|
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
|
||||||
|
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
|
||||||
|
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWallPreviewGeometry(length: number, height: number) {
|
||||||
|
const geometry = new BufferGeometry()
|
||||||
|
setPreviewGeometryAttributes(
|
||||||
|
geometry,
|
||||||
|
[0, 0, 0, length, 0, 0, length, height, 0, 0, height, 0],
|
||||||
|
[0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
|
||||||
|
[0, 0, 1, 0, 1, 1, 0, 1],
|
||||||
|
)
|
||||||
|
geometry.setIndex([0, 1, 2, 0, 2, 3])
|
||||||
|
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]
|
||||||
|
const length = Math.hypot(dx, dz)
|
||||||
|
const angle = -Math.atan2(dz, dx)
|
||||||
|
const geometry = useMemo(() => {
|
||||||
|
return length < 0.01 ? null : createWallPreviewGeometry(length, preview.height)
|
||||||
|
}, [length, preview.height])
|
||||||
|
|
||||||
|
useEffect(() => () => geometry?.dispose(), [geometry])
|
||||||
|
|
||||||
|
if (!geometry) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={[preview.start[0], 0.02, preview.start[1]]} rotation={[0, angle, 0]}>
|
||||||
|
<mesh frustumCulled={false} layers={EDITOR_LAYER} renderOrder={2}>
|
||||||
|
<primitive attach="geometry" object={geometry} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color={preview.color}
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
opacity={0.32}
|
||||||
|
side={DoubleSide}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||||
|
const meta =
|
||||||
|
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
|
||||||
|
? (node.metadata as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
const isNew = !!meta.isNew
|
||||||
|
const activatedAtRef = useRef<number>(Date.now())
|
||||||
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||||
|
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
|
||||||
|
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
|
||||||
|
const originalCenterRef = useRef<[number, number]>([
|
||||||
|
(node.start[0] + node.end[0]) / 2,
|
||||||
|
(node.start[1] + node.end[1]) / 2,
|
||||||
|
])
|
||||||
|
const originalHalfVectorRef = useRef<[number, number]>([
|
||||||
|
(node.end[0] - node.start[0]) / 2,
|
||||||
|
(node.end[1] - node.start[1]) / 2,
|
||||||
|
])
|
||||||
|
const moveAxisRef = useRef<WallMoveAxis | null>(
|
||||||
|
getPerpendicularWallMoveAxis(node.start, node.end),
|
||||||
|
)
|
||||||
|
const linkedOriginalsRef = useRef<LinkedWallSnapshot[]>(
|
||||||
|
isNew
|
||||||
|
? []
|
||||||
|
: getLinkedWallSnapshots({
|
||||||
|
wallId: node.id,
|
||||||
|
wallParentId: node.parentId ?? null,
|
||||||
|
originalStart: node.start,
|
||||||
|
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)
|
||||||
|
const pendingRotationRef = useRef(0)
|
||||||
|
const shiftPressedRef = useRef(false)
|
||||||
|
|
||||||
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||||
|
const centerX = (node.start[0] + node.end[0]) / 2
|
||||||
|
const centerZ = (node.start[1] + node.end[1]) / 2
|
||||||
|
return [centerX, 0, centerZ]
|
||||||
|
})
|
||||||
|
const [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([])
|
||||||
|
|
||||||
|
const exitMoveMode = useCallback(() => {
|
||||||
|
useEditor.getState().setMovingNode(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nodeId = nodeIdRef.current
|
||||||
|
const originalStart = originalStartRef.current
|
||||||
|
const originalEnd = originalEndRef.current
|
||||||
|
const originalCenter = originalCenterRef.current
|
||||||
|
const originalHalfVector = originalHalfVectorRef.current
|
||||||
|
const levelId = node.parentId ?? null
|
||||||
|
const originalAutoSlabs = originalAutoSlabsRef.current
|
||||||
|
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
let shouldRestoreOnCleanup = true
|
||||||
|
|
||||||
|
const applyNodePreview = (
|
||||||
|
updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
|
||||||
|
) => {
|
||||||
|
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 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]]
|
||||||
|
const nextEnd: [number, number] = [center[0] + rotatedHalf[0], center[1] + rotatedHalf[1]]
|
||||||
|
return { start: nextStart, end: nextEnd }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) =>
|
||||||
|
planWallMoveJunctions(
|
||||||
|
linkedOriginalsRef.current,
|
||||||
|
originalStart,
|
||||||
|
originalEnd,
|
||||||
|
nextStart,
|
||||||
|
nextEnd,
|
||||||
|
)
|
||||||
|
|
||||||
|
const getLinkedPreviewUpdates = (
|
||||||
|
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
|
||||||
|
nextStart: [number, number],
|
||||||
|
nextEnd: [number, number],
|
||||||
|
) => {
|
||||||
|
const movedUpdates = getPlannedLinkedWallUpdates(
|
||||||
|
plan,
|
||||||
|
originalStart,
|
||||||
|
originalEnd,
|
||||||
|
nextStart,
|
||||||
|
nextEnd,
|
||||||
|
)
|
||||||
|
const movedById = new Map(movedUpdates.map((entry) => [entry.id, entry]))
|
||||||
|
|
||||||
|
return linkedOriginalsRef.current.map(
|
||||||
|
(wall) => movedById.get(wall.id) ?? { id: wall.id, start: wall.start, end: wall.end },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
|
||||||
|
previewRef.current = { start: nextStart, end: nextEnd }
|
||||||
|
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
||||||
|
const centerZ = (nextStart[1] + nextEnd[1]) / 2
|
||||||
|
setCursorLocalPos([centerX, 0, centerZ])
|
||||||
|
const previewPlan = getMovePlan(nextStart, nextEnd)
|
||||||
|
const previewUpdates = [
|
||||||
|
{ id: nodeId, start: nextStart, end: nextEnd },
|
||||||
|
...getLinkedPreviewUpdates(previewPlan, nextStart, nextEnd),
|
||||||
|
]
|
||||||
|
const previewCollapsedWallIds = new Set([
|
||||||
|
...previewUpdates
|
||||||
|
.filter((entry) => entry.id !== nodeId && !isWallLongEnough(entry.start, entry.end))
|
||||||
|
.map((entry) => entry.id as AnyNodeId),
|
||||||
|
...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
|
||||||
|
])
|
||||||
|
const previewSceneWalls = getWallsAfterUpdates(
|
||||||
|
useScene.getState().nodes,
|
||||||
|
previewUpdates.map((entry) => ({
|
||||||
|
id: entry.id as AnyNodeId,
|
||||||
|
data: { start: entry.start, end: entry.end },
|
||||||
|
})),
|
||||||
|
).filter((wall) => !previewCollapsedWallIds.has(wall.id as AnyNodeId))
|
||||||
|
const bridgePreviews = buildBridgeWallPreviews({
|
||||||
|
bridgePlans: previewPlan.bridgePlans,
|
||||||
|
nextStart,
|
||||||
|
nextEnd,
|
||||||
|
existingWalls: previewSceneWalls,
|
||||||
|
})
|
||||||
|
const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost)
|
||||||
|
const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall)
|
||||||
|
setGhostWallPreviews(nextGhostWalls)
|
||||||
|
applyNodePreview(previewUpdates)
|
||||||
|
applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls])
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreOriginal = () => {
|
||||||
|
setGhostWallPreviews([])
|
||||||
|
applyNodePreview([
|
||||||
|
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||||
|
...linkedOriginalsRef.current,
|
||||||
|
])
|
||||||
|
restoreAutoSlabPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridMove = (event: GridEvent) => {
|
||||||
|
const rawX = event.localPosition[0]
|
||||||
|
const rawZ = event.localPosition[2]
|
||||||
|
const snapStep = getWallGridStep()
|
||||||
|
const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep)
|
||||||
|
const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep)
|
||||||
|
|
||||||
|
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||||
|
dragAnchorRef.current = anchor
|
||||||
|
|
||||||
|
const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis(
|
||||||
|
localX - anchor[0],
|
||||||
|
localZ - anchor[1],
|
||||||
|
moveAxisRef.current,
|
||||||
|
)
|
||||||
|
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
|
||||||
|
|
||||||
|
if (
|
||||||
|
previousGridPosRef.current &&
|
||||||
|
(constrainedGridPos[0] !== previousGridPosRef.current[0] ||
|
||||||
|
constrainedGridPos[1] !== previousGridPosRef.current[1])
|
||||||
|
) {
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
previousGridPosRef.current = constrainedGridPos
|
||||||
|
|
||||||
|
const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
|
||||||
|
const nextWall = buildWallFromCenter(nextCenter)
|
||||||
|
applyPreview(nextWall.start, nextWall.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (Date.now() - activatedAtRef.current < 150) {
|
||||||
|
event.nativeEvent?.stopPropagation?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||||
|
|
||||||
|
shouldRestoreOnCleanup = false
|
||||||
|
|
||||||
|
// Restore original baseline while paused so the next resume+update
|
||||||
|
// registers as a single tracked change (undo reverts to original).
|
||||||
|
setGhostWallPreviews([])
|
||||||
|
applyNodePreview([
|
||||||
|
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||||
|
...linkedOriginalsRef.current,
|
||||||
|
])
|
||||||
|
restoreAutoSlabPreview()
|
||||||
|
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
const commitPlan = getMovePlan(preview.start, preview.end)
|
||||||
|
const linkedWallUpdates = getPlannedLinkedWallUpdates(
|
||||||
|
commitPlan,
|
||||||
|
originalStart,
|
||||||
|
originalEnd,
|
||||||
|
preview.start,
|
||||||
|
preview.end,
|
||||||
|
)
|
||||||
|
const collapsedLinkedWallIds = new Set([
|
||||||
|
...linkedWallUpdates
|
||||||
|
.filter((entry) => !isWallLongEnough(entry.start, entry.end))
|
||||||
|
.map((entry) => entry.id as AnyNodeId),
|
||||||
|
...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
|
||||||
|
])
|
||||||
|
|
||||||
|
const commitUpdates = [
|
||||||
|
{
|
||||||
|
id: nodeId as AnyNodeId,
|
||||||
|
data: isNew
|
||||||
|
? {
|
||||||
|
start: preview.start,
|
||||||
|
end: preview.end,
|
||||||
|
metadata: stripWallIsNewMetadata(node.metadata),
|
||||||
|
}
|
||||||
|
: { start: preview.start, end: preview.end },
|
||||||
|
},
|
||||||
|
...linkedWallUpdates
|
||||||
|
.filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId))
|
||||||
|
.map((entry) => ({
|
||||||
|
id: entry.id as AnyNodeId,
|
||||||
|
data: { start: entry.start, end: entry.end },
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
const sceneState = useScene.getState()
|
||||||
|
const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter(
|
||||||
|
(wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId),
|
||||||
|
)
|
||||||
|
const bridgeCreates = buildBridgeWallCreates({
|
||||||
|
bridgePlans: commitPlan.bridgePlans,
|
||||||
|
nextStart: preview.start,
|
||||||
|
nextEnd: preview.end,
|
||||||
|
existingWalls,
|
||||||
|
wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length,
|
||||||
|
})
|
||||||
|
sceneState.applyNodeChanges({
|
||||||
|
update: commitUpdates,
|
||||||
|
create: bridgeCreates,
|
||||||
|
delete: Array.from(collapsedLinkedWallIds),
|
||||||
|
})
|
||||||
|
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
|
||||||
|
triggerSFX('sfx:item-place')
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
|
exitMoveMode()
|
||||||
|
event.nativeEvent?.stopPropagation?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
shiftPressedRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROTATION_STEP = Math.PI / 4
|
||||||
|
let rotationDelta = 0
|
||||||
|
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
||||||
|
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
||||||
|
|
||||||
|
if (rotationDelta === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
pendingRotationRef.current += rotationDelta
|
||||||
|
triggerSFX('sfx:item-rotate')
|
||||||
|
|
||||||
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||||
|
const currentCenter: [number, number] = [
|
||||||
|
(preview.start[0] + preview.end[0]) / 2,
|
||||||
|
(preview.start[1] + preview.end[1]) / 2,
|
||||||
|
]
|
||||||
|
const nextWall = buildWallFromCenter(currentCenter)
|
||||||
|
moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end)
|
||||||
|
applyPreview(nextWall.start, nextWall.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
shiftPressedRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
shouldRestoreOnCleanup = false
|
||||||
|
restoreOriginal()
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
markToolCancelConsumed()
|
||||||
|
exitMoveMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (shouldRestoreOnCleanup) {
|
||||||
|
restoreOriginal()
|
||||||
|
}
|
||||||
|
shiftPressedRef.current = false
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
emitter.off('grid:move', onGridMove)
|
||||||
|
emitter.off('grid:click', onGridClick)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
|
}
|
||||||
|
}, [exitMoveMode, isNew, node.metadata, node.parentId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||||
|
{ghostWallPreviews.map((preview) => (
|
||||||
|
<GhostWallPreviewMesh key={preview.id} preview={preview} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MoveWallTool
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
CursorSphere,
|
||||||
|
createWallOnCurrentLevel,
|
||||||
|
EDITOR_LAYER,
|
||||||
|
formatAngleRadians,
|
||||||
|
getAngleToSegmentReference,
|
||||||
|
getSegmentAngleReferenceAtPoint,
|
||||||
|
markToolCancelConsumed,
|
||||||
|
snapWallDraftPoint,
|
||||||
|
triggerSFX,
|
||||||
|
type WallPlanPoint,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 5 Stage D — wall placement tool (kind-owned).
|
||||||
|
*
|
||||||
|
* 1:1 port of the legacy `WallTool`. Two-click flow: click 1 sets the
|
||||||
|
* start, click 2 creates the wall. Between clicks a vertical preview
|
||||||
|
* rectangle + length/angle measurement HUD follow the pointer. Shift
|
||||||
|
* bypasses the angle snap; Esc cancels.
|
||||||
|
*
|
||||||
|
* Not a `DragAction` — same reasoning as fence/slab/ceiling placement:
|
||||||
|
* stateful sequence of grid:click events, not a single drag-up.
|
||||||
|
*
|
||||||
|
* Mounted via `def.tool` from `wall/definition.ts`.
|
||||||
|
*/
|
||||||
|
const WALL_HEIGHT = 2.5
|
||||||
|
const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
|
||||||
|
const DRAFT_ANGLE_LABEL_Y = 0.28
|
||||||
|
|
||||||
|
type DraftAngleLabel = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
position: [number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftMeasurementState = {
|
||||||
|
lengthLabel: string
|
||||||
|
lengthPosition: [number, number, number]
|
||||||
|
angleLabels: DraftAngleLabel[]
|
||||||
|
} | null
|
||||||
|
|
||||||
|
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
||||||
|
if (unit === 'imperial') {
|
||||||
|
const feet = value * 3.280_84
|
||||||
|
const wholeFeet = Math.floor(feet)
|
||||||
|
const inches = Math.round((feet - wholeFeet) * 12)
|
||||||
|
if (inches === 12) return `${wholeFeet + 1}'0"`
|
||||||
|
return `${wholeFeet}'${inches}"`
|
||||||
|
}
|
||||||
|
return `${Number.parseFloat(value.toFixed(2))}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDraftAngleLabels(
|
||||||
|
start: WallPlanPoint,
|
||||||
|
end: WallPlanPoint,
|
||||||
|
walls: WallNode[],
|
||||||
|
): DraftAngleLabel[] {
|
||||||
|
const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
|
||||||
|
const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
|
||||||
|
const endpoints = [
|
||||||
|
{ id: 'start', point: start, draftVector: draftFromStart },
|
||||||
|
{ id: 'end', point: end, draftVector: draftFromEnd },
|
||||||
|
]
|
||||||
|
const labels: DraftAngleLabel[] = []
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
const connectedWall = walls.find((wall) =>
|
||||||
|
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
|
||||||
|
)
|
||||||
|
if (!connectedWall) continue
|
||||||
|
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
|
||||||
|
if (!connectedReference) continue
|
||||||
|
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
|
||||||
|
if (angle === null) continue
|
||||||
|
labels.push({
|
||||||
|
id: endpoint.id,
|
||||||
|
label: formatAngleRadians(angle),
|
||||||
|
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDraftMeasurementState(
|
||||||
|
start: WallPlanPoint,
|
||||||
|
end: WallPlanPoint,
|
||||||
|
walls: WallNode[],
|
||||||
|
unit: 'metric' | 'imperial',
|
||||||
|
): DraftMeasurementState {
|
||||||
|
const dx = end[0] - start[0]
|
||||||
|
const dz = end[1] - start[1]
|
||||||
|
const length = Math.hypot(dx, dz)
|
||||||
|
if (length < 0.01) return null
|
||||||
|
return {
|
||||||
|
lengthLabel: formatMeasurement(length, unit),
|
||||||
|
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
|
||||||
|
angleLabels: getDraftAngleLabels(start, end, walls),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWallPreview(mesh: Mesh, start: Vector3, end: Vector3) {
|
||||||
|
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
|
||||||
|
const length = direction.length()
|
||||||
|
if (length < 0.01) {
|
||||||
|
mesh.visible = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mesh.visible = true
|
||||||
|
direction.normalize()
|
||||||
|
|
||||||
|
const shape = new Shape()
|
||||||
|
shape.moveTo(0, 0)
|
||||||
|
shape.lineTo(length, 0)
|
||||||
|
shape.lineTo(length, WALL_HEIGHT)
|
||||||
|
shape.lineTo(0, WALL_HEIGHT)
|
||||||
|
shape.closePath()
|
||||||
|
|
||||||
|
const geometry = new ShapeGeometry(shape)
|
||||||
|
const angle = -Math.atan2(direction.z, direction.x)
|
||||||
|
|
||||||
|
mesh.position.set(start.x, start.y, start.z)
|
||||||
|
mesh.rotation.y = angle
|
||||||
|
|
||||||
|
if (mesh.geometry) mesh.geometry.dispose()
|
||||||
|
mesh.geometry = geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentLevelWalls(): WallNode[] {
|
||||||
|
const currentLevelId = useViewer.getState().selection.levelId
|
||||||
|
const { nodes } = useScene.getState()
|
||||||
|
if (!currentLevelId) return []
|
||||||
|
const levelNode = nodes[currentLevelId]
|
||||||
|
if (!levelNode || levelNode.type !== 'level') return []
|
||||||
|
return (levelNode as LevelNode).children
|
||||||
|
.map((childId) => nodes[childId])
|
||||||
|
.filter((node): node is WallNode => node?.type === 'wall')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WallTool: React.FC = () => {
|
||||||
|
const unit = useViewer((state) => state.unit)
|
||||||
|
const cursorRef = useRef<Group>(null)
|
||||||
|
const wallPreviewRef = useRef<Mesh>(null!)
|
||||||
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
|
const buildingState = useRef(0)
|
||||||
|
const shiftPressed = useRef(false)
|
||||||
|
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let gridPosition: WallPlanPoint = [0, 0]
|
||||||
|
let previousWallEnd: [number, number] | null = null
|
||||||
|
|
||||||
|
const onGridMove = (event: GridEvent) => {
|
||||||
|
if (!(cursorRef.current && wallPreviewRef.current)) return
|
||||||
|
|
||||||
|
const walls = getCurrentLevelWalls()
|
||||||
|
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||||
|
gridPosition = snapWallDraftPoint({ point: localPoint, walls })
|
||||||
|
|
||||||
|
if (buildingState.current === 1) {
|
||||||
|
const snappedLocal = snapWallDraftPoint({
|
||||||
|
point: localPoint,
|
||||||
|
walls,
|
||||||
|
start: [startingPoint.current.x, startingPoint.current.z],
|
||||||
|
angleSnap: !shiftPressed.current,
|
||||||
|
})
|
||||||
|
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
||||||
|
cursorRef.current.position.copy(endingPoint.current)
|
||||||
|
|
||||||
|
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
|
||||||
|
if (
|
||||||
|
previousWallEnd &&
|
||||||
|
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
|
||||||
|
) {
|
||||||
|
triggerSFX('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
previousWallEnd = currentWallEnd
|
||||||
|
|
||||||
|
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
|
||||||
|
setDraftMeasurement(
|
||||||
|
getDraftMeasurementState(
|
||||||
|
[startingPoint.current.x, startingPoint.current.z],
|
||||||
|
snappedLocal,
|
||||||
|
walls,
|
||||||
|
unit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
|
||||||
|
setDraftMeasurement(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
const walls = getCurrentLevelWalls()
|
||||||
|
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||||
|
|
||||||
|
if (buildingState.current === 0) {
|
||||||
|
const snappedStart = snapWallDraftPoint({ point: localClick, walls })
|
||||||
|
gridPosition = snappedStart
|
||||||
|
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||||
|
endingPoint.current.copy(startingPoint.current)
|
||||||
|
buildingState.current = 1
|
||||||
|
wallPreviewRef.current.visible = true
|
||||||
|
setDraftMeasurement(null)
|
||||||
|
} else if (buildingState.current === 1) {
|
||||||
|
const snappedEnd = snapWallDraftPoint({
|
||||||
|
point: localClick,
|
||||||
|
walls,
|
||||||
|
start: [startingPoint.current.x, startingPoint.current.z],
|
||||||
|
angleSnap: !shiftPressed.current,
|
||||||
|
})
|
||||||
|
const dx = snappedEnd[0] - startingPoint.current.x
|
||||||
|
const dz = snappedEnd[1] - startingPoint.current.z
|
||||||
|
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
||||||
|
createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
||||||
|
wallPreviewRef.current.visible = false
|
||||||
|
buildingState.current = 0
|
||||||
|
setDraftMeasurement(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Shift') shiftPressed.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Shift') shiftPressed.current = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
if (buildingState.current === 1) {
|
||||||
|
markToolCancelConsumed()
|
||||||
|
buildingState.current = 0
|
||||||
|
wallPreviewRef.current.visible = false
|
||||||
|
setDraftMeasurement(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('grid:move', onGridMove)
|
||||||
|
emitter.on('grid:click', onGridClick)
|
||||||
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('keyup', onKeyUp)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
emitter.off('grid:move', onGridMove)
|
||||||
|
emitter.off('grid:click', onGridClick)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
|
}
|
||||||
|
}, [unit])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<CursorSphere ref={cursorRef} />
|
||||||
|
<mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
|
||||||
|
<shapeGeometry />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#818cf8"
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
opacity={0.5}
|
||||||
|
side={DoubleSide}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{draftMeasurement && (
|
||||||
|
<>
|
||||||
|
<DraftMeasurementLabel
|
||||||
|
label={draftMeasurement.lengthLabel}
|
||||||
|
position={draftMeasurement.lengthPosition}
|
||||||
|
/>
|
||||||
|
{draftMeasurement.angleLabels.map((angleLabel) => (
|
||||||
|
<DraftMeasurementLabel
|
||||||
|
key={angleLabel.id}
|
||||||
|
label={angleLabel.label}
|
||||||
|
position={angleLabel.position}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraftMeasurementLabel({
|
||||||
|
label,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
position: [number, number, number]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
||||||
|
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WallTool
|
||||||
Reference in New Issue
Block a user