Phase 5 Stage D: re-port curve + whole-item move tools 1:1 from legacy
Each tool is now a direct copy of the legacy implementation, relocated
under @pascal-app/nodes/<kind>/ and dispatched via the registry's
def.affordanceTools. No DragAction abstraction, no clever live-drag
exception, no novel snap pipeline — same code, same UX, same
performance, same history dance.
Ports:
- fence/curve-tool.tsx (legacy CurveFenceTool, 1:1)
- fence/move-tool.tsx (legacy MoveFenceTool, 1:1 — including
the mesh.position + useLiveTransforms
exception that the legacy uses for fence
specifically)
- wall/curve-tool.tsx (legacy CurveWallTool, 1:1)
- slab/move-tool.tsx (legacy MoveSlabTool, 1:1)
- ceiling/move-tool.tsx (legacy MoveCeilingTool, 1:1 — preview
fill + outline overlay preserved)
Drops the obsolete DragAction-based action files
(packages/nodes/src/{fence,wall,slab,ceiling}/actions/{curve,move}.ts)
and their now-empty actions/ directories where applicable. Fence
keeps actions/move-endpoint.ts since that port works.
Editor public surface gains `getWallGridStep` + `snapScalarToGrid`
(transitional exports — Stage F moves them into @pascal-app/nodes).
ToolManager + MoveTool dispatch unchanged: the same legacy-fallback
branches now mount the registry component because the affordances are
declared, but the rendered behavior matches the legacy because the
implementations are copies.
Per-kind progress: fence D ✅ (curve / move-endpoint / move / placement
all kind-owned), slab D ✅, ceiling D ✅, wall D 🟡 (curve only,
endpoint/move/placement still legacy).
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
da63081f73
commit
1e15e10185
@@ -24,7 +24,11 @@ export {
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
} from './components/tools/shared/segment-angle'
|
||||
export { isWallLongEnough } from './components/tools/wall/wall-drafting'
|
||||
export {
|
||||
getWallGridStep,
|
||||
isWallLongEnough,
|
||||
snapScalarToGrid,
|
||||
} from './components/tools/wall/wall-drafting'
|
||||
export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
|
||||
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
|
||||
export { useCommandPalette } from './components/ui/command-palette'
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
type DragAction,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — whole-ceiling move drag affordance.
|
||||
*
|
||||
* Live-drag exception (same recipe as slab/fence move): translate the
|
||||
* ceiling MESH visually via `sceneRegistry.nodes.get(ceilingId)
|
||||
* .position` plus a mirror in `useLiveTransforms`. No scene.update
|
||||
* during the drag → no React re-render, no polygon CSG rebuild per
|
||||
* tick. Snaps to a 0.5m grid (no wall/fence corner snap).
|
||||
*
|
||||
* On commit the final polygon is written via the single-undo dance
|
||||
* and the mesh-offset is cleared.
|
||||
*/
|
||||
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
function snap(value: number): number {
|
||||
return Math.round(value / GRID_STEP) * GRID_STEP
|
||||
}
|
||||
|
||||
function sameSnap(a: [number, number] | null, b: [number, number]): boolean {
|
||||
return a !== null && a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
|
||||
function translatePolygon(
|
||||
polygon: Array<[number, number]>,
|
||||
deltaX: number,
|
||||
deltaZ: number,
|
||||
): Array<[number, number]> {
|
||||
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
|
||||
}
|
||||
|
||||
function polygonCenter(polygon: Array<[number, number]>): [number, number] {
|
||||
if (polygon.length === 0) return [0, 0]
|
||||
let sx = 0
|
||||
let sz = 0
|
||||
for (const [x, z] of polygon) {
|
||||
sx += x
|
||||
sz += z
|
||||
}
|
||||
return [sx / polygon.length, sz / polygon.length]
|
||||
}
|
||||
|
||||
function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number): void {
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined
|
||||
if (mesh) mesh.position.set(deltaX, 0, deltaZ)
|
||||
}
|
||||
|
||||
function setLiveTransform(
|
||||
id: AnyNodeId,
|
||||
originalCenter: [number, number],
|
||||
deltaX: number,
|
||||
deltaZ: number,
|
||||
height: number,
|
||||
): void {
|
||||
useLiveTransforms.getState().set(id, {
|
||||
position: [originalCenter[0] + deltaX, height, originalCenter[1] + deltaZ],
|
||||
rotation: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function clearLiveState(id: AnyNodeId): void {
|
||||
setMeshOffset(id, 0, 0)
|
||||
useLiveTransforms.getState().clear(id)
|
||||
}
|
||||
|
||||
export type MoveCeilingCtx = {
|
||||
ceilingId: AnyNodeId
|
||||
originalPolygon: Array<[number, number]>
|
||||
originalHoles: Array<Array<[number, number]>>
|
||||
originalCenter: [number, number]
|
||||
height: number
|
||||
dragAnchor: [number, number] | null
|
||||
lastSnapped: [number, number] | null
|
||||
}
|
||||
|
||||
export type MoveCeilingDraft = {
|
||||
deltaX: number
|
||||
deltaZ: number
|
||||
}
|
||||
|
||||
export const moveCeilingDragAction: DragAction<MoveCeilingCtx, MoveCeilingDraft> = {
|
||||
begin: (input) => {
|
||||
const ceiling = input.node as CeilingNode | undefined
|
||||
if (!ceiling) throw new Error('[moveCeilingDragAction] begin requires a ceiling node')
|
||||
const originalPolygon = ceiling.polygon.map(([x, z]) => [x, z] as [number, number])
|
||||
return {
|
||||
ceilingId: ceiling.id as AnyNodeId,
|
||||
originalPolygon,
|
||||
originalHoles: (ceiling.holes ?? []).map((h) =>
|
||||
h.map(([x, z]) => [x, z] as [number, number]),
|
||||
),
|
||||
originalCenter: polygonCenter(originalPolygon),
|
||||
height: ceiling.height ?? 2.5,
|
||||
dragAnchor: null,
|
||||
lastSnapped: null,
|
||||
}
|
||||
},
|
||||
|
||||
preview: (ctx, point, _modifiers) => {
|
||||
const sx = snap(point[0])
|
||||
const sz = snap(point[1])
|
||||
const snapped: [number, number] = [sx, sz]
|
||||
if (!sameSnap(ctx.lastSnapped, snapped)) {
|
||||
if (ctx.lastSnapped !== null) triggerSFX('sfx:grid-snap')
|
||||
ctx.lastSnapped = snapped
|
||||
}
|
||||
if (!ctx.dragAnchor) ctx.dragAnchor = snapped
|
||||
return {
|
||||
deltaX: sx - ctx.dragAnchor[0],
|
||||
deltaZ: sz - ctx.dragAnchor[1],
|
||||
}
|
||||
},
|
||||
|
||||
apply: (draft, ctx, _scene) => {
|
||||
setMeshOffset(ctx.ceilingId, draft.deltaX, draft.deltaZ)
|
||||
setLiveTransform(ctx.ceilingId, ctx.originalCenter, draft.deltaX, draft.deltaZ, ctx.height)
|
||||
return []
|
||||
},
|
||||
|
||||
commit: (draft, ctx, scene) => {
|
||||
scene.restoreAll()
|
||||
scene.resumeHistory()
|
||||
scene.update(ctx.ceilingId, {
|
||||
polygon: translatePolygon(ctx.originalPolygon, draft.deltaX, draft.deltaZ),
|
||||
holes: ctx.originalHoles.map((h) => translatePolygon(h, draft.deltaX, draft.deltaZ)),
|
||||
} as Partial<AnyNode>)
|
||||
clearLiveState(ctx.ceilingId)
|
||||
return true
|
||||
},
|
||||
|
||||
cancel: (ctx, _scene) => {
|
||||
clearLiveState(ctx.ceilingId)
|
||||
},
|
||||
}
|
||||
@@ -58,12 +58,13 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
// with a vertical TSL-gradient connector + ground-shadow lines.
|
||||
tool: () => import('./tool'),
|
||||
|
||||
// Stage D — boundary + hole editors ported. Whole-ceiling move kept
|
||||
// on the legacy MoveCeilingTool for the same reason as slab (live-
|
||||
// drag mesh.position one-frame teleport on commit).
|
||||
// Stage D — all four ceiling drag-affordances live in this folder.
|
||||
// 1:1 port of the legacy tools (scene.update per tick + history
|
||||
// dance + preview fill/outline overlay on move).
|
||||
affordanceTools: {
|
||||
'boundary-edit': () => import('./boundary-editor'),
|
||||
'hole-edit': () => import('./hole-editor'),
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
|
||||
renderer: {
|
||||
|
||||
@@ -1,77 +1,270 @@
|
||||
'use client'
|
||||
|
||||
import { type CeilingNode, emitter, type GridEvent } from '@pascal-app/core'
|
||||
import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import { moveCeilingDragAction } from './actions/move'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — thin React wrapper around `moveCeilingDragAction`.
|
||||
* Phase 5 Stage D — ceiling whole-move tool (kind-owned).
|
||||
*
|
||||
* Same shape as `slab/move-tool.tsx`: cursor sphere follows the raw
|
||||
* grid pointer via direct ref mutation, the ceiling mesh translates
|
||||
* visually via `mesh.position` + `useLiveTransforms`, scene polygon is
|
||||
* written only on commit (single-undo dance).
|
||||
*
|
||||
* No preview fill / outline mesh — moving a translucent overlay every
|
||||
* tick adds the same per-frame React reconciliation cost we're trying
|
||||
* to avoid here. The real ceiling mesh translates in place; that's
|
||||
* enough visual feedback.
|
||||
* 1:1 port of the legacy `MoveCeilingTool`. 0.5m grid snap, scene.update
|
||||
* per tick, history dance on commit, preview fill + outline overlay
|
||||
* matching the legacy.
|
||||
*/
|
||||
export const CeilingMoveTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
const ceilingId = node.id
|
||||
const cursorRef = useRef<Group>(null)
|
||||
function snap(value: number) {
|
||||
return Math.round(value * 2) / 2
|
||||
}
|
||||
|
||||
const initialCenter: [number, number] = useMemo(() => {
|
||||
if (node.polygon.length === 0) return [0, 0]
|
||||
let sx = 0
|
||||
let sz = 0
|
||||
for (const [x, z] of node.polygon) {
|
||||
sx += x
|
||||
sz += z
|
||||
function translatePolygon(
|
||||
polygon: Array<[number, number]>,
|
||||
deltaX: number,
|
||||
deltaZ: number,
|
||||
): Array<[number, number]> {
|
||||
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
|
||||
}
|
||||
|
||||
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
|
||||
if (polygon.length === 0) return [0, 0]
|
||||
let sumX = 0
|
||||
let sumZ = 0
|
||||
for (const [x, z] of polygon) {
|
||||
sumX += x
|
||||
sumZ += z
|
||||
}
|
||||
return [sx / node.polygon.length, sz / node.polygon.length]
|
||||
}, [node.polygon])
|
||||
return [sumX / polygon.length, sumZ / polygon.length]
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
cursorRef.current.position.set(
|
||||
event.localPosition[0],
|
||||
event.localPosition[1],
|
||||
event.localPosition[2],
|
||||
export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
|
||||
const originalHolesRef = useRef(
|
||||
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
||||
)
|
||||
}
|
||||
emitter.on('grid:move', onMove)
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
}
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const previousCursorPosRef = useRef<[number, number, number] | null>(null)
|
||||
const previousDeltaRef = useRef<[number, number] | null>(null)
|
||||
const previewRef = useRef<{
|
||||
polygon: Array<[number, number]>
|
||||
holes: Array<Array<[number, number]>>
|
||||
} | null>(null)
|
||||
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||
const center = getPolygonCenter(node.polygon)
|
||||
return [center[0], node.height ?? 2.5, center[1]]
|
||||
})
|
||||
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
|
||||
const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
const exitMoveMode = (committed: boolean) => {
|
||||
if (committed) triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [ceilingId] })
|
||||
useEditor.getState().setMovingNode(null)
|
||||
useEffect(() => {
|
||||
const originalPolygon = originalPolygonRef.current
|
||||
const originalHoles = originalHolesRef.current
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let wasCommitted = false
|
||||
|
||||
const applyPreview = (
|
||||
polygon: Array<[number, number]>,
|
||||
holes: Array<Array<[number, number]>>,
|
||||
) => {
|
||||
previewRef.current = { polygon, holes }
|
||||
setPreviewPolygon(polygon)
|
||||
setPreviewHoles(holes)
|
||||
const center = getPolygonCenter(polygon)
|
||||
const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]]
|
||||
if (
|
||||
!previousCursorPosRef.current ||
|
||||
previousCursorPosRef.current[0] !== nextCursorPos[0] ||
|
||||
previousCursorPosRef.current[1] !== nextCursorPos[1] ||
|
||||
previousCursorPosRef.current[2] !== nextCursorPos[2]
|
||||
) {
|
||||
previousCursorPosRef.current = nextCursorPos
|
||||
setCursorLocalPos(nextCursorPos)
|
||||
}
|
||||
useScene.getState().updateNode(node.id, { polygon, holes })
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
}
|
||||
|
||||
useDragAction({
|
||||
active: true,
|
||||
action: moveCeilingDragAction,
|
||||
initial: {
|
||||
node,
|
||||
point: initialCenter,
|
||||
},
|
||||
onCommit: () => exitMoveMode(true),
|
||||
onCancel: () => exitMoveMode(false),
|
||||
const restoreOriginal = () => {
|
||||
setPreviewPolygon(originalPolygon)
|
||||
setPreviewHoles(originalHoles)
|
||||
useScene.getState().updateNode(node.id, {
|
||||
holes: originalHoles,
|
||||
polygon: originalPolygon,
|
||||
})
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const localX = snap(event.localPosition[0])
|
||||
const localZ = snap(event.localPosition[2])
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
previousGridPosRef.current = [localX, localZ]
|
||||
|
||||
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||
dragAnchorRef.current = anchor
|
||||
|
||||
const deltaX = localX - anchor[0]
|
||||
const deltaZ = localZ - anchor[1]
|
||||
|
||||
if (
|
||||
previousDeltaRef.current &&
|
||||
previousDeltaRef.current[0] === deltaX &&
|
||||
previousDeltaRef.current[1] === deltaZ
|
||||
) {
|
||||
return
|
||||
}
|
||||
previousDeltaRef.current = [deltaX, deltaZ]
|
||||
|
||||
applyPreview(
|
||||
translatePolygon(originalPolygon, deltaX, deltaZ),
|
||||
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
|
||||
)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
|
||||
|
||||
wasCommitted = true
|
||||
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
useScene.getState().updateNode(node.id, {
|
||||
polygon: originalPolygon,
|
||||
holes: originalHoles,
|
||||
})
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(node.id, preview)
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal()
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [exitMoveMode, node.height, node.id])
|
||||
|
||||
const previewFillGeometry = useMemo(
|
||||
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
|
||||
[previewHoles, previewPolygon],
|
||||
)
|
||||
|
||||
const previewOutlineGeometry = useMemo(
|
||||
() => createCeilingOutlineGeometry(previewPolygon),
|
||||
[previewPolygon],
|
||||
)
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere ref={cursorRef} showTooltip={false} />
|
||||
<mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
|
||||
<meshBasicMaterial
|
||||
color="#f5f5f4"
|
||||
depthWrite={false}
|
||||
opacity={0.3}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
{/* @ts-ignore */}
|
||||
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
|
||||
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
|
||||
</line>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default CeilingMoveTool
|
||||
function createCeilingPreviewGeometry(
|
||||
polygon: Array<[number, number]>,
|
||||
holes: Array<Array<[number, number]>>,
|
||||
): BufferGeometry {
|
||||
if (polygon.length < 3) return new BufferGeometry()
|
||||
|
||||
const shape = new Shape()
|
||||
const [firstX, firstZ] = polygon[0]!
|
||||
shape.moveTo(firstX, -firstZ)
|
||||
|
||||
for (let i = 1; i < polygon.length; i++) {
|
||||
const [x, z] = polygon[i]!
|
||||
shape.lineTo(x, -z)
|
||||
}
|
||||
shape.closePath()
|
||||
|
||||
for (const holePolygon of holes) {
|
||||
if (holePolygon.length < 3) continue
|
||||
const hole = new Path()
|
||||
const [hx, hz] = holePolygon[0]!
|
||||
hole.moveTo(hx, -hz)
|
||||
for (let i = 1; i < holePolygon.length; i++) {
|
||||
const [x, z] = holePolygon[i]!
|
||||
hole.lineTo(x, -z)
|
||||
}
|
||||
hole.closePath()
|
||||
shape.holes.push(hole)
|
||||
}
|
||||
|
||||
const geometry = new ShapeGeometry(shape)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry {
|
||||
const geometry = new BufferGeometry()
|
||||
if (polygon.length < 2) return geometry
|
||||
|
||||
const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
|
||||
const [firstX, firstZ] = polygon[0]!
|
||||
points.push(new Vector3(firstX, 0, firstZ))
|
||||
geometry.setFromPoints(points)
|
||||
return geometry
|
||||
}
|
||||
|
||||
export default MoveCeilingTool
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type DragAction,
|
||||
type FenceNode,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
normalizeWallCurveOffset,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — curve-fence drag affordance.
|
||||
*
|
||||
* Migrates `CurveFenceTool` (editor/tools/fence/curve-fence-tool.tsx,
|
||||
* 178 LoC) to the `DragAction` primitive. The pure action lives in the
|
||||
* fence node folder; a thin React wrapper (curve-tool.tsx) feeds it
|
||||
* through `useDragAction`.
|
||||
*
|
||||
* The lifecycle:
|
||||
* - **begin**: capture the node id + original curveOffset + chord +
|
||||
* maxOffset + grid step. These never change during the drag.
|
||||
* - **preview**: convert the pointer's level-local point into a
|
||||
* distance along the chord's normal — that's the curveOffset.
|
||||
* - **snap**: optional grid snap unless `modifiers.shift` (free place).
|
||||
* - **apply**: write the new curveOffset onto the fence node. Returns
|
||||
* the dirty IDs the cascade resolver should walk.
|
||||
* - **commit**: single-undo dance — `restoreAll` → `resumeHistory` →
|
||||
* re-apply final draft so zundo captures the whole drag as one
|
||||
* Ctrl-Z step. Rejected when the offset didn't actually change.
|
||||
* - **cancel**: no-op — `createDragSession.cancel()` calls
|
||||
* `scene.restoreAll()` via the snapshot.
|
||||
*
|
||||
* Pure data: trivially unit-testable, doesn't import React.
|
||||
*/
|
||||
|
||||
type CurveFenceCtx = {
|
||||
nodeId: AnyNodeId
|
||||
originalCurveOffset: number
|
||||
chord: ReturnType<typeof getWallChordFrame>
|
||||
maxCurveOffset: number
|
||||
// Snapshot of the node at drag start — used to recompute the curve frame
|
||||
// and normalize the offset throughout the drag.
|
||||
startNode: FenceNode
|
||||
}
|
||||
|
||||
type CurveFenceDraft = {
|
||||
curveOffset: number
|
||||
}
|
||||
|
||||
export const curveFenceDragAction: DragAction<CurveFenceCtx, CurveFenceDraft> = {
|
||||
begin: (input) => {
|
||||
const node = input.node as FenceNode | undefined
|
||||
if (!node) {
|
||||
throw new Error('[curveFenceDragAction] begin requires a node')
|
||||
}
|
||||
return {
|
||||
nodeId: node.id as AnyNodeId,
|
||||
originalCurveOffset: getClampedWallCurveOffset(node),
|
||||
chord: getWallChordFrame(node),
|
||||
maxCurveOffset: getMaxWallCurveOffset(node),
|
||||
startNode: node,
|
||||
}
|
||||
},
|
||||
|
||||
preview: (ctx, point) => {
|
||||
// Pointer in level-local meters. Project onto the chord's normal to
|
||||
// get the signed perpendicular distance — that's the new curveOffset.
|
||||
const [px, pz] = point
|
||||
const offset = -(
|
||||
(px - ctx.chord.midpoint.x) * ctx.chord.normal.x +
|
||||
(pz - ctx.chord.midpoint.y) * ctx.chord.normal.y
|
||||
)
|
||||
return { curveOffset: offset }
|
||||
},
|
||||
|
||||
snap: (draft, ctx, _services) => {
|
||||
// Clamp to maxCurveOffset and normalize via the wall-curve helper.
|
||||
const clamped = Math.max(-ctx.maxCurveOffset, Math.min(ctx.maxCurveOffset, draft.curveOffset))
|
||||
const normalized = normalizeWallCurveOffset(ctx.startNode, clamped)
|
||||
return { curveOffset: normalized }
|
||||
},
|
||||
|
||||
apply: (draft, ctx, scene) => {
|
||||
scene.update(ctx.nodeId, { curveOffset: draft.curveOffset } as Partial<AnyNode>)
|
||||
scene.markDirty(ctx.nodeId)
|
||||
return [ctx.nodeId]
|
||||
},
|
||||
|
||||
commit: (draft, ctx, scene) => {
|
||||
// Single-undo dance — ALWAYS push a pastState entry, even when the
|
||||
// offset didn't actually change. The "no-op" case (small drag that
|
||||
// `normalizeWallCurveOffset` snaps back to 0) used to return false
|
||||
// here, but that bypassed pastStates entirely; the next Ctrl-Z then
|
||||
// fell through to whatever was on the stack before activation
|
||||
// (typically the fence creation), making it look like the bend
|
||||
// cancelled the create.
|
||||
//
|
||||
// Pushing on every commit means a no-op bend's first Ctrl-Z absorbs
|
||||
// a silent entry (no visible change), then subsequent Ctrl-Z's roll
|
||||
// back the real prior actions. Matches typical editor behavior.
|
||||
scene.restoreAll()
|
||||
scene.resumeHistory()
|
||||
scene.update(ctx.nodeId, { curveOffset: draft.curveOffset } as Partial<AnyNode>)
|
||||
return true
|
||||
},
|
||||
|
||||
cancel: (_ctx, _scene) => {
|
||||
// No-op — createDragSession.cancel() calls scene.restoreAll() which
|
||||
// puts every touched node back via the snapshot.
|
||||
},
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type DragAction,
|
||||
type FenceNode,
|
||||
type LevelNode,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { type FencePlanPoint, snapFenceDraftPoint, triggerSFX } from '@pascal-app/editor'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
function sameSnap(a: FencePlanPoint | null, b: FencePlanPoint): boolean {
|
||||
return a !== null && a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — whole-fence move drag affordance.
|
||||
*
|
||||
* Migrates `MoveFenceTool` (302 LoC legacy) to the `DragAction` primitive.
|
||||
*
|
||||
* Visual strategy — **live-drag exception** (see
|
||||
* `editor/wiki/architecture/tools.md`): instead of writing the new
|
||||
* start/end into the scene store on every pointer tick (which would
|
||||
* re-rebuild the fence geometry — many posts, many infill panels —
|
||||
* every frame), the action keeps the underlying node untouched during
|
||||
* the drag and visually offsets the mesh directly via
|
||||
* `sceneRegistry.nodes.get(id).position` plus a mirror entry in
|
||||
* `useLiveTransforms`. On commit, the final start/end are written to
|
||||
* the scene with the single-undo dance — one Ctrl-Z reverses the whole
|
||||
* drag, the geometry rebuilds once.
|
||||
*
|
||||
* Linked-fence cascade: any other fence in the same parent whose
|
||||
* start or end matched one of this fence's endpoints at activation
|
||||
* follows the move so corners stay connected. No alt-detach (legacy
|
||||
* doesn't expose it for the whole-fence drag).
|
||||
*/
|
||||
|
||||
function samePoint(a: FencePlanPoint, b: FencePlanPoint): boolean {
|
||||
return a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
|
||||
type LinkedFenceSnapshot = {
|
||||
id: FenceNode['id']
|
||||
start: FencePlanPoint
|
||||
end: FencePlanPoint
|
||||
}
|
||||
|
||||
function snapshotLinked(args: {
|
||||
fenceId: FenceNode['id']
|
||||
parentId: string | null
|
||||
originalStart: FencePlanPoint
|
||||
originalEnd: FencePlanPoint
|
||||
}): LinkedFenceSnapshot[] {
|
||||
const { fenceId, parentId, originalStart, originalEnd } = args
|
||||
const { nodes } = useScene.getState()
|
||||
const out: LinkedFenceSnapshot[] = []
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!node || node.type !== 'fence') continue
|
||||
if (node.id === fenceId) continue
|
||||
if ((node.parentId ?? null) !== parentId) continue
|
||||
if (
|
||||
!(
|
||||
samePoint(node.start, originalStart) ||
|
||||
samePoint(node.start, originalEnd) ||
|
||||
samePoint(node.end, originalStart) ||
|
||||
samePoint(node.end, originalEnd)
|
||||
)
|
||||
)
|
||||
continue
|
||||
out.push({
|
||||
id: node.id,
|
||||
start: [node.start[0], node.start[1]],
|
||||
end: [node.end[0], node.end[1]],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function linkedCascade(
|
||||
linked: LinkedFenceSnapshot[],
|
||||
originalStart: FencePlanPoint,
|
||||
originalEnd: FencePlanPoint,
|
||||
nextStart: FencePlanPoint,
|
||||
nextEnd: FencePlanPoint,
|
||||
): LinkedFenceSnapshot[] {
|
||||
return linked.map((l) => ({
|
||||
id: l.id,
|
||||
start: samePoint(l.start, originalStart)
|
||||
? nextStart
|
||||
: samePoint(l.start, originalEnd)
|
||||
? nextEnd
|
||||
: l.start,
|
||||
end: samePoint(l.end, originalStart)
|
||||
? nextStart
|
||||
: samePoint(l.end, originalEnd)
|
||||
? nextEnd
|
||||
: l.end,
|
||||
}))
|
||||
}
|
||||
|
||||
function setMeshOffset(fenceId: AnyNodeId, deltaX: number, deltaZ: number): void {
|
||||
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
|
||||
if (mesh) mesh.position.set(deltaX, 0, deltaZ)
|
||||
}
|
||||
|
||||
function setLiveTransform(
|
||||
fenceId: AnyNodeId,
|
||||
originalStart: FencePlanPoint,
|
||||
originalEnd: FencePlanPoint,
|
||||
deltaX: number,
|
||||
deltaZ: number,
|
||||
): void {
|
||||
const cx = (originalStart[0] + originalEnd[0]) / 2
|
||||
const cz = (originalStart[1] + originalEnd[1]) / 2
|
||||
useLiveTransforms.getState().set(fenceId, {
|
||||
position: [cx + deltaX, 0, cz + deltaZ],
|
||||
rotation: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function clearLiveState(fenceId: AnyNodeId, linked: LinkedFenceSnapshot[]): void {
|
||||
setMeshOffset(fenceId, 0, 0)
|
||||
useLiveTransforms.getState().clear(fenceId)
|
||||
for (const l of linked) {
|
||||
setMeshOffset(l.id as AnyNodeId, 0, 0)
|
||||
useLiveTransforms.getState().clear(l.id)
|
||||
}
|
||||
}
|
||||
|
||||
export type MoveFenceCtx = {
|
||||
fenceId: AnyNodeId
|
||||
originalStart: FencePlanPoint
|
||||
originalEnd: FencePlanPoint
|
||||
parentId: string | null
|
||||
linkedOriginals: LinkedFenceSnapshot[]
|
||||
levelWalls: WallNode[]
|
||||
levelFences: FenceNode[]
|
||||
// Mutable: latched on the first preview call to the snapped pointer
|
||||
// position. Subsequent previews compute delta = pointer - dragAnchor.
|
||||
dragAnchor: FencePlanPoint | null
|
||||
// Mutable: tracks the last snapped pointer so preview can emit a
|
||||
// grid-snap sfx when the snapped value changes. Matches the legacy
|
||||
// MoveFenceTool's per-tick sound.
|
||||
lastSnapped: FencePlanPoint | null
|
||||
}
|
||||
|
||||
export type MoveFenceDraft = {
|
||||
start: FencePlanPoint
|
||||
end: FencePlanPoint
|
||||
deltaX: number
|
||||
deltaZ: number
|
||||
linkedUpdates: LinkedFenceSnapshot[]
|
||||
}
|
||||
|
||||
export const moveFenceDragAction: DragAction<MoveFenceCtx, MoveFenceDraft> = {
|
||||
begin: (input) => {
|
||||
const fence = input.node as FenceNode | undefined
|
||||
if (!fence) throw new Error('[moveFenceDragAction] begin requires a fence node')
|
||||
const parentId = fence.parentId ?? null
|
||||
const originalStart: FencePlanPoint = [fence.start[0], fence.start[1]]
|
||||
const originalEnd: FencePlanPoint = [fence.end[0], fence.end[1]]
|
||||
|
||||
const { nodes } = useScene.getState()
|
||||
const levelNode =
|
||||
parentId && nodes[parentId as AnyNodeId]?.type === 'level'
|
||||
? (nodes[parentId as AnyNodeId] as LevelNode)
|
||||
: null
|
||||
const levelWalls: WallNode[] = []
|
||||
const levelFences: FenceNode[] = []
|
||||
if (levelNode) {
|
||||
for (const childId of levelNode.children ?? []) {
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) continue
|
||||
if (child.type === 'wall') levelWalls.push(child)
|
||||
else if (child.type === 'fence') levelFences.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fenceId: fence.id as AnyNodeId,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
parentId,
|
||||
linkedOriginals: snapshotLinked({ fenceId: fence.id, parentId, originalStart, originalEnd }),
|
||||
levelWalls,
|
||||
levelFences,
|
||||
dragAnchor: null,
|
||||
lastSnapped: null,
|
||||
}
|
||||
},
|
||||
|
||||
preview: (ctx, point, _modifiers) => {
|
||||
const snapped = snapFenceDraftPoint({
|
||||
point: [point[0], point[1]],
|
||||
walls: ctx.levelWalls,
|
||||
fences: ctx.levelFences,
|
||||
ignoreFenceIds: [ctx.fenceId as string],
|
||||
})
|
||||
// Emit grid-snap sfx when the snapped position changes between
|
||||
// ticks — matches the legacy MoveFenceTool's user feedback.
|
||||
if (!sameSnap(ctx.lastSnapped, snapped)) {
|
||||
if (ctx.lastSnapped !== null) triggerSFX('sfx:grid-snap')
|
||||
ctx.lastSnapped = snapped
|
||||
}
|
||||
// Latch the anchor on the first preview tick — matches legacy
|
||||
// "drag is delta from first move" semantics so the fence doesn't
|
||||
// jump to wherever the activation click landed.
|
||||
if (!ctx.dragAnchor) ctx.dragAnchor = snapped
|
||||
const deltaX = snapped[0] - ctx.dragAnchor[0]
|
||||
const deltaZ = snapped[1] - ctx.dragAnchor[1]
|
||||
const nextStart: FencePlanPoint = [ctx.originalStart[0] + deltaX, ctx.originalStart[1] + deltaZ]
|
||||
const nextEnd: FencePlanPoint = [ctx.originalEnd[0] + deltaX, ctx.originalEnd[1] + deltaZ]
|
||||
return {
|
||||
start: nextStart,
|
||||
end: nextEnd,
|
||||
deltaX,
|
||||
deltaZ,
|
||||
linkedUpdates: linkedCascade(
|
||||
ctx.linkedOriginals,
|
||||
ctx.originalStart,
|
||||
ctx.originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
),
|
||||
}
|
||||
},
|
||||
|
||||
apply: (draft, ctx, _scene) => {
|
||||
// Live-drag exception — visual-only via mesh.position + useLiveTransforms.
|
||||
// No scene.update during the drag (would re-rebuild fence geometry every
|
||||
// tick). The scene store still has the original start/end; commit() writes
|
||||
// the final values.
|
||||
setMeshOffset(ctx.fenceId, draft.deltaX, draft.deltaZ)
|
||||
setLiveTransform(ctx.fenceId, ctx.originalStart, ctx.originalEnd, draft.deltaX, draft.deltaZ)
|
||||
for (const linked of ctx.linkedOriginals) {
|
||||
setMeshOffset(linked.id as AnyNodeId, draft.deltaX, draft.deltaZ)
|
||||
setLiveTransform(linked.id as AnyNodeId, linked.start, linked.end, draft.deltaX, draft.deltaZ)
|
||||
}
|
||||
// Return no dirty IDs — geometry rebuild deferred to commit.
|
||||
return []
|
||||
},
|
||||
|
||||
commit: (draft, ctx, scene) => {
|
||||
// Always push a pastState entry — see fence/actions/curve.ts. The
|
||||
// no-movement case would otherwise let Ctrl-Z cancel the fence
|
||||
// creation that preceded the move.
|
||||
//
|
||||
// Single-undo dance: snapshot is empty (live-drag exception, no
|
||||
// scene.update during apply), so restoreAll is a no-op. Resume,
|
||||
// then write the final draft so zundo records original → final
|
||||
// as one diff.
|
||||
scene.restoreAll()
|
||||
scene.resumeHistory()
|
||||
scene.update(ctx.fenceId, {
|
||||
start: draft.start,
|
||||
end: draft.end,
|
||||
} as Partial<AnyNode>)
|
||||
for (const linked of draft.linkedUpdates) {
|
||||
scene.update(
|
||||
linked.id as AnyNodeId,
|
||||
{
|
||||
start: linked.start,
|
||||
end: linked.end,
|
||||
} as Partial<AnyNode>,
|
||||
)
|
||||
}
|
||||
|
||||
// Clear live-drag visual state — the scene store now has the final
|
||||
// values, so the renderer will re-mount the mesh at its real position
|
||||
// and useLiveTransforms is no longer needed.
|
||||
clearLiveState(ctx.fenceId, ctx.linkedOriginals)
|
||||
return true
|
||||
},
|
||||
|
||||
cancel: (ctx, _scene) => {
|
||||
// Clear live-drag visual state so the mesh snaps back to the
|
||||
// original (still-unchanged) scene position. No scene rollback
|
||||
// needed — we never wrote anything.
|
||||
clearLiveState(ctx.fenceId, ctx.linkedOriginals)
|
||||
},
|
||||
}
|
||||
@@ -1,74 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import { type FenceNode, getWallMidpointHandlePoint, useScene } from '@pascal-app/core'
|
||||
import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
getWallMidpointHandlePoint,
|
||||
normalizeWallCurveOffset,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
getWallGridStep,
|
||||
markToolCancelConsumed,
|
||||
snapScalarToGrid,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { curveFenceDragAction } from './actions/curve'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — thin React wrapper around `curveFenceDragAction`.
|
||||
* Phase 5 Stage D — fence curve tool (kind-owned).
|
||||
*
|
||||
* Replaces the legacy `CurveFenceTool` (editor/tools/fence/curve-fence-
|
||||
* tool.tsx). Same UX: a cursor sphere follows the chord-perpendicular
|
||||
* projection of the pointer, dragging the fence's `curveOffset` live;
|
||||
* grid:click commits, Esc cancels.
|
||||
*
|
||||
* All the lifecycle (history pause/resume, grid:move → preview, snap,
|
||||
* apply, grid:click → commit, Esc → cancel, unmount cleanup) is owned
|
||||
* by `useDragAction`. This component only renders the cursor sphere
|
||||
* and tracks its level-local position to mirror the active curveOffset
|
||||
* for visual feedback.
|
||||
*
|
||||
* Mounted by the legacy ToolManager via the same `curvingFence` editor
|
||||
* state (drop-in replacement for the old CurveFenceTool import).
|
||||
* 1:1 port of the legacy `CurveFenceTool` (editor/components/tools/
|
||||
* fence/curve-fence-tool.tsx). Same snap pipeline, same Shift override,
|
||||
* same history dance, same activation grace. Imports adjusted to the
|
||||
* `@pascal-app/editor` public surface (triggerSFX, markToolCancelConsumed,
|
||||
* getWallGridStep, snapScalarToGrid). Mounted via
|
||||
* `def.affordanceTools.curve` — ToolManager picks it up at runtime,
|
||||
* legacy fallback is unused when this kind is registered.
|
||||
*/
|
||||
export const FenceCurveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
|
||||
const previousCurveOffsetRef = useRef<number | null>(null)
|
||||
const shiftPressedRef = useRef(false)
|
||||
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
|
||||
|
||||
const initialHandle = getWallMidpointHandlePoint(node)
|
||||
const [cursorPos, setCursorPos] = useState<[number, number, number]>([
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
|
||||
initialHandle.x,
|
||||
0,
|
||||
initialHandle.y,
|
||||
])
|
||||
|
||||
const exitCurveMode = (committed: boolean) => {
|
||||
if (committed) triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
const exitCurveMode = useCallback(() => {
|
||||
useEditor.getState().setCurvingFence(null)
|
||||
}
|
||||
|
||||
useDragAction({
|
||||
active: true,
|
||||
action: curveFenceDragAction,
|
||||
initial: {
|
||||
node,
|
||||
// Initial point — useDragAction requires a Vec2; the action's begin
|
||||
// reads everything it needs from input.node, so this is just a
|
||||
// placeholder until the first grid:move fires.
|
||||
point: [initialHandle.x, initialHandle.y],
|
||||
},
|
||||
onCommit: () => exitCurveMode(true),
|
||||
onCancel: () => exitCurveMode(false),
|
||||
})
|
||||
|
||||
// Mirror the active curveOffset back into the cursor position. The
|
||||
// useDragAction loop's apply() writes curveOffset onto the node; we
|
||||
// subscribe to that field and recompute the handle point.
|
||||
const liveCurveOffset = useScene((s) => {
|
||||
const live = s.nodes[node.id]
|
||||
return live?.type === 'fence' ? ((live as FenceNode).curveOffset ?? 0) : 0
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePoint = getWallMidpointHandlePoint({ ...node, curveOffset: liveCurveOffset })
|
||||
setCursorPos([handlePoint.x, 0, handlePoint.y])
|
||||
}, [liveCurveOffset, node])
|
||||
const nodeId = node.id
|
||||
const originalCurveOffset = originalCurveOffsetRef.current
|
||||
const chord = getWallChordFrame(node)
|
||||
const maxCurveOffset = getMaxWallCurveOffset(node)
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
let wasCommitted = false
|
||||
|
||||
const applyPreview = (curveOffset: number) => {
|
||||
if (previewOffsetRef.current === curveOffset) {
|
||||
return
|
||||
}
|
||||
previewOffsetRef.current = curveOffset
|
||||
|
||||
const nextNode = {
|
||||
...node,
|
||||
curveOffset,
|
||||
}
|
||||
const handlePoint = getWallMidpointHandlePoint(nextNode)
|
||||
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
|
||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
}
|
||||
|
||||
const restoreOriginal = () => {
|
||||
if (previewOffsetRef.current === originalCurveOffset) {
|
||||
return
|
||||
}
|
||||
previewOffsetRef.current = originalCurveOffset
|
||||
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const snapStep = getWallGridStep()
|
||||
const localX = shiftPressedRef.current
|
||||
? event.localPosition[0]
|
||||
: snapScalarToGrid(event.localPosition[0], snapStep)
|
||||
const localZ = shiftPressedRef.current
|
||||
? event.localPosition[2]
|
||||
: snapScalarToGrid(event.localPosition[2], snapStep)
|
||||
|
||||
const offsetFromMidpoint = -(
|
||||
(localX - chord.midpoint.x) * chord.normal.x +
|
||||
(localZ - chord.midpoint.y) * chord.normal.y
|
||||
)
|
||||
const snappedOffset = shiftPressedRef.current
|
||||
? offsetFromMidpoint
|
||||
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
||||
const nextCurveOffset = normalizeWallCurveOffset(
|
||||
node,
|
||||
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
|
||||
)
|
||||
|
||||
if (
|
||||
previousCurveOffsetRef.current !== null &&
|
||||
nextCurveOffset !== previousCurveOffsetRef.current
|
||||
) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
previousCurveOffsetRef.current = nextCurveOffset
|
||||
|
||||
applyPreview(nextCurveOffset)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const curveOffset = previewOffsetRef.current
|
||||
wasCommitted = true
|
||||
|
||||
if (curveOffset !== originalCurveOffset) {
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
|
||||
resumeSceneHistory(useScene)
|
||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
pauseSceneHistory(useScene)
|
||||
}
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
exitCurveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
resumeSceneHistory(useScene)
|
||||
markToolCancelConsumed()
|
||||
exitCurveMode()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
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()
|
||||
}
|
||||
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)
|
||||
}
|
||||
}, [exitCurveMode, node])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorPos} showTooltip={false} />
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default FenceCurveTool
|
||||
export default CurveFenceTool
|
||||
|
||||
@@ -74,16 +74,15 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
||||
// Legacy `floorplanFenceEntries` short-circuits to [] when fence is
|
||||
// registered (see floorplan-panel.tsx).
|
||||
floorplan: buildFenceFloorplan,
|
||||
// Stage D — partial port. Endpoint drag (with linked-fence cascade,
|
||||
// alt-detach, angle label) is ported here; curve + whole-fence move
|
||||
// are intentionally kept on the legacy CurveFenceTool / MoveFenceTool
|
||||
// because the legacy code is more polished than the ports were
|
||||
// (cursor anchoring, snap step, performance, history). Those ports
|
||||
// remain in `curve-tool.tsx` + `move-tool.tsx` + their `actions/`
|
||||
// siblings for the next iteration; the legacy fallback runs until
|
||||
// they reach parity.
|
||||
// Stage D — all four fence drag-affordances live in this folder.
|
||||
// curve / move-endpoint / move are 1:1 ports of the legacy tools
|
||||
// (same snap pipeline, same history dance, same cursor render),
|
||||
// relocated under `@pascal-app/nodes` and dispatched via
|
||||
// `def.affordanceTools`. Placement lives in `def.tool` (see below).
|
||||
affordanceTools: {
|
||||
curve: () => import('./curve-tool'),
|
||||
'move-endpoint': () => import('./move-endpoint-tool'),
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
|
||||
toolHints: [
|
||||
|
||||
@@ -1,65 +1,315 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type FenceNode, type GridEvent } from '@pascal-app/core'
|
||||
import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
markToolCancelConsumed,
|
||||
snapFenceDraftPoint,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import { moveFenceDragAction } from './actions/move'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — thin React wrapper around `moveFenceDragAction`.
|
||||
* Phase 5 Stage D — fence whole-move tool (kind-owned).
|
||||
*
|
||||
* Cursor sphere follows the raw grid pointer via direct ref mutation —
|
||||
* no React state, no per-tick re-render. The fence mesh translates
|
||||
* visually through the action's `mesh.position` + `useLiveTransforms`
|
||||
* writes (live-drag exception); scene start/end are written on commit
|
||||
* via the single-undo dance.
|
||||
* 1:1 port of the legacy `MoveFenceTool`. Same anchor-on-first-move
|
||||
* delta drag, same linked-fence cascade, same live mesh.position +
|
||||
* useLiveTransforms exception, same history dance on commit, same
|
||||
* cursor render at the polygon center (anchor + delta), same
|
||||
* activation grace.
|
||||
*/
|
||||
export const FenceMoveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
const fenceId = node.id
|
||||
const cursorRef = useRef<Group>(null)
|
||||
function samePoint(a: [number, number], b: [number, number]) {
|
||||
return a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
cursorRef.current.position.set(
|
||||
event.localPosition[0],
|
||||
event.localPosition[1],
|
||||
event.localPosition[2],
|
||||
type LinkedFenceSnapshot = {
|
||||
id: FenceNode['id']
|
||||
start: [number, number]
|
||||
end: [number, number]
|
||||
}
|
||||
|
||||
function getLinkedFenceSnapshots(args: {
|
||||
fenceId: FenceNode['id']
|
||||
fenceParentId: string | null
|
||||
originalStart: [number, number]
|
||||
originalEnd: [number, number]
|
||||
}) {
|
||||
const { fenceId, fenceParentId, originalStart, originalEnd } = args
|
||||
const { nodes } = useScene.getState()
|
||||
const snapshots: LinkedFenceSnapshot[] = []
|
||||
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!(node?.type === 'fence' && node.id !== fenceId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ((node.parentId ?? null) !== fenceParentId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
samePoint(node.start, originalStart) ||
|
||||
samePoint(node.start, originalEnd) ||
|
||||
samePoint(node.end, originalStart) ||
|
||||
samePoint(node.end, originalEnd)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
emitter.on('grid:move', onMove)
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
|
||||
snapshots.push({
|
||||
id: node.id,
|
||||
start: [...node.start] as [number, number],
|
||||
end: [...node.end] as [number, number],
|
||||
})
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
function getLinkedFenceUpdates(
|
||||
linkedFences: LinkedFenceSnapshot[],
|
||||
originalStart: [number, number],
|
||||
originalEnd: [number, number],
|
||||
nextStart: [number, number],
|
||||
nextEnd: [number, number],
|
||||
) {
|
||||
return linkedFences.map((fence) => ({
|
||||
id: fence.id,
|
||||
start: samePoint(fence.start, originalStart)
|
||||
? nextStart
|
||||
: samePoint(fence.start, originalEnd)
|
||||
? nextEnd
|
||||
: fence.start,
|
||||
end: samePoint(fence.end, originalStart)
|
||||
? nextStart
|
||||
: samePoint(fence.end, originalEnd)
|
||||
? nextEnd
|
||||
: fence.end,
|
||||
}))
|
||||
}
|
||||
|
||||
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
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 linkedOriginalsRef = useRef(
|
||||
getLinkedFenceSnapshots({
|
||||
fenceId: node.id,
|
||||
fenceParentId: node.parentId ?? null,
|
||||
originalStart: node.start,
|
||||
originalEnd: node.end,
|
||||
}),
|
||||
)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
const nodeIdRef = useRef(node.id)
|
||||
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
|
||||
|
||||
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 exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
const exitMoveMode = (committed: boolean) => {
|
||||
if (committed) triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [fenceId] })
|
||||
useEditor.getState().setMovingNode(null)
|
||||
useEffect(() => {
|
||||
const nodeId = nodeIdRef.current
|
||||
const originalStart = originalStartRef.current
|
||||
const originalEnd = originalEndRef.current
|
||||
const levelNode =
|
||||
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
||||
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
||||
: null
|
||||
const levelChildren = levelNode?.children ?? []
|
||||
const levelWalls = levelChildren
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((child): child is WallNode => child?.type === 'wall')
|
||||
const levelFences = levelChildren
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((child): child is FenceNode => child?.type === 'fence')
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let wasCommitted = false
|
||||
|
||||
const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => {
|
||||
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
|
||||
if (!mesh) {
|
||||
return
|
||||
}
|
||||
|
||||
useDragAction({
|
||||
active: true,
|
||||
action: moveFenceDragAction,
|
||||
initial: {
|
||||
node,
|
||||
// Initial point — useDragAction requires a Vec2. The action's
|
||||
// begin captures everything else from input.node; this is just
|
||||
// a placeholder until the first grid:move latches the anchor.
|
||||
point: [(node.start[0] + node.end[0]) / 2, (node.start[1] + node.end[1]) / 2],
|
||||
},
|
||||
onCommit: () => exitMoveMode(true),
|
||||
onCancel: () => exitMoveMode(false),
|
||||
mesh.position.set(deltaX, 0, deltaZ)
|
||||
}
|
||||
|
||||
const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => {
|
||||
const originalCenterX = (fence.start[0] + fence.end[0]) / 2
|
||||
const originalCenterZ = (fence.start[1] + fence.end[1]) / 2
|
||||
useLiveTransforms.getState().set(fence.id, {
|
||||
position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ],
|
||||
rotation: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const clearPreviewState = () => {
|
||||
setMeshOffset(nodeId, 0, 0)
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
|
||||
for (const linkedFence of linkedOriginalsRef.current) {
|
||||
setMeshOffset(linkedFence.id, 0, 0)
|
||||
useLiveTransforms.getState().clear(linkedFence.id)
|
||||
}
|
||||
}
|
||||
|
||||
const applyNodePreview = (
|
||||
updates: Array<{ id: FenceNode['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 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 deltaX = nextStart[0] - originalStart[0]
|
||||
const deltaZ = nextStart[1] - originalStart[1]
|
||||
setMeshOffset(nodeId, deltaX, deltaZ)
|
||||
setFenceLiveTransform(node, deltaX, deltaZ)
|
||||
|
||||
for (const linkedFence of linkedOriginalsRef.current) {
|
||||
setMeshOffset(linkedFence.id, deltaX, deltaZ)
|
||||
setFenceLiveTransform(
|
||||
{
|
||||
...node,
|
||||
id: linkedFence.id,
|
||||
start: linkedFence.start,
|
||||
end: linkedFence.end,
|
||||
},
|
||||
deltaX,
|
||||
deltaZ,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const [localX, localZ] = snapFenceDraftPoint({
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
ignoreFenceIds: [nodeId],
|
||||
})
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
previousGridPosRef.current = [localX, localZ]
|
||||
|
||||
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||
dragAnchorRef.current = anchor
|
||||
|
||||
const deltaX = localX - anchor[0]
|
||||
const deltaZ = localZ - anchor[1]
|
||||
|
||||
const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ]
|
||||
const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ]
|
||||
|
||||
applyPreview(nextStart, nextEnd)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
|
||||
wasCommitted = true
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: preview.start, end: preview.end },
|
||||
...getLinkedFenceUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
preview.start,
|
||||
preview.end,
|
||||
),
|
||||
])
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
for (const linkedFence of linkedOriginalsRef.current) {
|
||||
useLiveTransforms.getState().clear(linkedFence.id)
|
||||
}
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
clearPreviewState()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
if (wasCommitted) {
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
for (const linkedFence of linkedOriginalsRef.current) {
|
||||
useLiveTransforms.getState().clear(linkedFence.id)
|
||||
}
|
||||
} else {
|
||||
clearPreviewState()
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere ref={cursorRef} showTooltip={false} />
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default FenceMoveTool
|
||||
export default MoveFenceTool
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type DragAction,
|
||||
type FenceNode,
|
||||
type LevelNode,
|
||||
type SlabNode,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { type FencePlanPoint, snapFenceDraftPoint, triggerSFX } from '@pascal-app/editor'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
function sameSnap(a: FencePlanPoint | null, b: FencePlanPoint): boolean {
|
||||
return a !== null && a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — whole-slab move drag affordance.
|
||||
*
|
||||
* Uses the **live-drag exception** (same recipe as fence move): the
|
||||
* slab MESH is translated visually via `sceneRegistry.nodes.get(slabId)
|
||||
* .position` plus a mirror entry in `useLiveTransforms`. The scene
|
||||
* store's polygon stays untouched during the drag — no React re-render
|
||||
* per tick, no CSG-with-holes rebuild per frame.
|
||||
*
|
||||
* On commit the final polygon is written to the scene via the single-
|
||||
* undo dance, then the mesh-position offset is cleared. The renderer
|
||||
* picks up the new polygon, the mesh re-mounts at the new world coords,
|
||||
* and zundo records one diff.
|
||||
*
|
||||
* Hosted items don't follow the visual translation (same as legacy —
|
||||
* item.position is independent of slab.polygon). Acceptable: the slab
|
||||
* snaps back into place on commit so the visual mismatch is brief.
|
||||
*/
|
||||
|
||||
function translatePolygon(
|
||||
polygon: Array<[number, number]>,
|
||||
deltaX: number,
|
||||
deltaZ: number,
|
||||
): Array<[number, number]> {
|
||||
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
|
||||
}
|
||||
|
||||
function polygonCenter(polygon: Array<[number, number]>): [number, number] {
|
||||
if (polygon.length === 0) return [0, 0]
|
||||
let sx = 0
|
||||
let sz = 0
|
||||
for (const [x, z] of polygon) {
|
||||
sx += x
|
||||
sz += z
|
||||
}
|
||||
return [sx / polygon.length, sz / polygon.length]
|
||||
}
|
||||
|
||||
function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number): void {
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined
|
||||
if (mesh) mesh.position.set(deltaX, 0, deltaZ)
|
||||
}
|
||||
|
||||
function setLiveTransform(
|
||||
id: AnyNodeId,
|
||||
originalCenter: [number, number],
|
||||
deltaX: number,
|
||||
deltaZ: number,
|
||||
): void {
|
||||
useLiveTransforms.getState().set(id, {
|
||||
position: [originalCenter[0] + deltaX, 0, originalCenter[1] + deltaZ],
|
||||
rotation: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function clearLiveState(id: AnyNodeId): void {
|
||||
setMeshOffset(id, 0, 0)
|
||||
useLiveTransforms.getState().clear(id)
|
||||
}
|
||||
|
||||
export type MoveSlabCtx = {
|
||||
slabId: AnyNodeId
|
||||
originalPolygon: Array<[number, number]>
|
||||
originalHoles: Array<Array<[number, number]>>
|
||||
originalCenter: [number, number]
|
||||
parentId: string | null
|
||||
levelWalls: WallNode[]
|
||||
levelFences: FenceNode[]
|
||||
dragAnchor: FencePlanPoint | null
|
||||
lastSnapped: FencePlanPoint | null
|
||||
}
|
||||
|
||||
export type MoveSlabDraft = {
|
||||
polygon: Array<[number, number]>
|
||||
holes: Array<Array<[number, number]>>
|
||||
deltaX: number
|
||||
deltaZ: number
|
||||
}
|
||||
|
||||
export const moveSlabDragAction: DragAction<MoveSlabCtx, MoveSlabDraft> = {
|
||||
begin: (input) => {
|
||||
const slab = input.node as SlabNode | undefined
|
||||
if (!slab) throw new Error('[moveSlabDragAction] begin requires a slab node')
|
||||
const parentId = slab.parentId ?? null
|
||||
const originalPolygon: Array<[number, number]> = slab.polygon.map(
|
||||
([x, z]) => [x, z] as [number, number],
|
||||
)
|
||||
const originalHoles: Array<Array<[number, number]>> = (slab.holes ?? []).map((h) =>
|
||||
h.map(([x, z]) => [x, z] as [number, number]),
|
||||
)
|
||||
|
||||
const { nodes } = useScene.getState()
|
||||
const levelNode =
|
||||
parentId && nodes[parentId as AnyNodeId]?.type === 'level'
|
||||
? (nodes[parentId as AnyNodeId] as LevelNode)
|
||||
: null
|
||||
const levelWalls: WallNode[] = []
|
||||
const levelFences: FenceNode[] = []
|
||||
if (levelNode) {
|
||||
for (const childId of levelNode.children ?? []) {
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) continue
|
||||
if (child.type === 'wall') levelWalls.push(child)
|
||||
else if (child.type === 'fence') levelFences.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
slabId: slab.id as AnyNodeId,
|
||||
originalPolygon,
|
||||
originalHoles,
|
||||
originalCenter: polygonCenter(originalPolygon),
|
||||
parentId,
|
||||
levelWalls,
|
||||
levelFences,
|
||||
dragAnchor: null,
|
||||
lastSnapped: null,
|
||||
}
|
||||
},
|
||||
|
||||
preview: (ctx, point, _modifiers) => {
|
||||
const snapped = snapFenceDraftPoint({
|
||||
point: [point[0], point[1]],
|
||||
walls: ctx.levelWalls,
|
||||
fences: ctx.levelFences,
|
||||
})
|
||||
if (!sameSnap(ctx.lastSnapped, snapped)) {
|
||||
if (ctx.lastSnapped !== null) triggerSFX('sfx:grid-snap')
|
||||
ctx.lastSnapped = snapped
|
||||
}
|
||||
if (!ctx.dragAnchor) ctx.dragAnchor = snapped
|
||||
const deltaX = snapped[0] - ctx.dragAnchor[0]
|
||||
const deltaZ = snapped[1] - ctx.dragAnchor[1]
|
||||
// Translation is computed lazily on commit — preview only needs the
|
||||
// deltas for the mesh-offset visual.
|
||||
return {
|
||||
polygon: ctx.originalPolygon,
|
||||
holes: ctx.originalHoles,
|
||||
deltaX,
|
||||
deltaZ,
|
||||
}
|
||||
},
|
||||
|
||||
apply: (draft, ctx, _scene) => {
|
||||
// Live-drag exception: visual translate via Three.js mesh.position +
|
||||
// useLiveTransforms. No scene.update during the drag, no React
|
||||
// re-render of the slab geometry, no CSG-with-holes rebuild.
|
||||
setMeshOffset(ctx.slabId, draft.deltaX, draft.deltaZ)
|
||||
setLiveTransform(ctx.slabId, ctx.originalCenter, draft.deltaX, draft.deltaZ)
|
||||
return []
|
||||
},
|
||||
|
||||
commit: (draft, ctx, scene) => {
|
||||
// Single-undo dance — snapshot is empty (no scene.update during
|
||||
// apply), restoreAll is a no-op. Resume history, write the final
|
||||
// polygon. Zundo records one diff: original → translated.
|
||||
scene.restoreAll()
|
||||
scene.resumeHistory()
|
||||
scene.update(ctx.slabId, {
|
||||
polygon: translatePolygon(ctx.originalPolygon, draft.deltaX, draft.deltaZ),
|
||||
holes: ctx.originalHoles.map((h) => translatePolygon(h, draft.deltaX, draft.deltaZ)),
|
||||
} as Partial<AnyNode>)
|
||||
clearLiveState(ctx.slabId)
|
||||
return true
|
||||
},
|
||||
|
||||
cancel: (ctx, _scene) => {
|
||||
// Clear live-drag visual state — mesh snaps back to its (unchanged)
|
||||
// scene position.
|
||||
clearLiveState(ctx.slabId)
|
||||
},
|
||||
}
|
||||
@@ -58,15 +58,14 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
||||
// with axis/45° snap (Shift to defeat).
|
||||
tool: () => import('./tool'),
|
||||
|
||||
// Stage D — boundary + hole editors ported (thin <PolygonEditor>
|
||||
// wrappers, behaviorally identical to legacy). Whole-slab move kept
|
||||
// on the legacy MoveSlabTool: the live-drag mesh.position port
|
||||
// introduced a one-frame teleport on commit (geometry rebuild lags
|
||||
// the position clear), and the legacy already has acceptable perf
|
||||
// via RAF-batched markDirty.
|
||||
// Stage D — all four slab drag-affordances live in this folder.
|
||||
// boundary-edit / hole-edit are thin <PolygonEditor> wrappers; move
|
||||
// is a 1:1 port of the legacy MoveSlabTool (scene.update per tick
|
||||
// with the same history dance, no live-drag exception).
|
||||
affordanceTools: {
|
||||
'boundary-edit': () => import('./boundary-editor'),
|
||||
'hole-edit': () => import('./hole-editor'),
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
|
||||
// Stage B: pure geometry function.
|
||||
|
||||
@@ -1,74 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type GridEvent, type SlabNode } from '@pascal-app/core'
|
||||
import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
type SlabNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
markToolCancelConsumed,
|
||||
snapFenceDraftPoint,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import { moveSlabDragAction } from './actions/move'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — thin React wrapper around `moveSlabDragAction`.
|
||||
* Phase 5 Stage D — slab whole-move tool (kind-owned).
|
||||
*
|
||||
* The cursor sphere follows the raw grid pointer via direct ref mutation
|
||||
* (no React state, no per-tick re-render). The slab mesh itself is
|
||||
* translated by the action using `mesh.position` + `useLiveTransforms`
|
||||
* (live-drag exception). Scene polygon is only written on commit.
|
||||
* 1:1 port of the legacy `MoveSlabTool`. scene.update writes polygon
|
||||
* + holes per tick (renderer keeps up via RAF-batched markDirty),
|
||||
* single-undo dance on commit, cursor at polygon center + delta.
|
||||
*/
|
||||
export const SlabMoveTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
const slabId = node.id
|
||||
const cursorRef = useRef<Group>(null)
|
||||
function translatePolygon(
|
||||
polygon: Array<[number, number]>,
|
||||
deltaX: number,
|
||||
deltaZ: number,
|
||||
): Array<[number, number]> {
|
||||
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
|
||||
}
|
||||
|
||||
const initialCenter: [number, number] = useMemo(() => {
|
||||
if (node.polygon.length === 0) return [0, 0]
|
||||
let sx = 0
|
||||
let sz = 0
|
||||
for (const [x, z] of node.polygon) {
|
||||
sx += x
|
||||
sz += z
|
||||
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
|
||||
if (polygon.length === 0) return [0, 0]
|
||||
let sumX = 0
|
||||
let sumZ = 0
|
||||
for (const [x, z] of polygon) {
|
||||
sumX += x
|
||||
sumZ += z
|
||||
}
|
||||
return [sx / node.polygon.length, sz / node.polygon.length]
|
||||
}, [node.polygon])
|
||||
return [sumX / polygon.length, sumZ / polygon.length]
|
||||
}
|
||||
|
||||
// Cursor follows the raw grid pointer — direct Three.js mutation,
|
||||
// bypassing React reconciliation for the per-tick position update.
|
||||
useEffect(() => {
|
||||
const onMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
cursorRef.current.position.set(
|
||||
event.localPosition[0],
|
||||
event.localPosition[1],
|
||||
event.localPosition[2],
|
||||
export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
|
||||
const originalHolesRef = useRef(
|
||||
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
||||
)
|
||||
}
|
||||
emitter.on('grid:move', onMove)
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
}
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const previewRef = useRef<{
|
||||
polygon: Array<[number, number]>
|
||||
holes: Array<Array<[number, number]>>
|
||||
} | null>(null)
|
||||
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||
const center = getPolygonCenter(node.polygon)
|
||||
return [center[0], 0, center[1]]
|
||||
})
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
const exitMoveMode = (committed: boolean) => {
|
||||
if (committed) triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [slabId] })
|
||||
useEditor.getState().setMovingNode(null)
|
||||
useEffect(() => {
|
||||
const originalPolygon = originalPolygonRef.current
|
||||
const originalHoles = originalHolesRef.current
|
||||
const levelNode =
|
||||
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
||||
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
||||
: null
|
||||
const levelChildren = levelNode?.children ?? []
|
||||
const levelWalls = levelChildren
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((child): child is WallNode => child?.type === 'wall')
|
||||
const levelFences = levelChildren
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((child): child is FenceNode => child?.type === 'fence')
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let wasCommitted = false
|
||||
|
||||
const applyPreview = (
|
||||
polygon: Array<[number, number]>,
|
||||
holes: Array<Array<[number, number]>>,
|
||||
) => {
|
||||
previewRef.current = { polygon, holes }
|
||||
const center = getPolygonCenter(polygon)
|
||||
setCursorLocalPos([center[0], 0, center[1]])
|
||||
useScene.getState().updateNode(node.id, { polygon, holes })
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
}
|
||||
|
||||
useDragAction({
|
||||
active: true,
|
||||
action: moveSlabDragAction,
|
||||
initial: {
|
||||
node,
|
||||
point: initialCenter,
|
||||
},
|
||||
onCommit: () => exitMoveMode(true),
|
||||
onCancel: () => exitMoveMode(false),
|
||||
const restoreOriginal = () => {
|
||||
useScene.getState().updateNode(node.id, {
|
||||
holes: originalHoles,
|
||||
polygon: originalPolygon,
|
||||
})
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const [localX, localZ] = snapFenceDraftPoint({
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
})
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
previousGridPosRef.current = [localX, localZ]
|
||||
|
||||
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||
dragAnchorRef.current = anchor
|
||||
|
||||
const deltaX = localX - anchor[0]
|
||||
const deltaZ = localZ - anchor[1]
|
||||
|
||||
applyPreview(
|
||||
translatePolygon(originalPolygon, deltaX, deltaZ),
|
||||
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
|
||||
)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
|
||||
|
||||
wasCommitted = true
|
||||
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
useScene.getState().updateNode(node.id, {
|
||||
polygon: originalPolygon,
|
||||
holes: originalHoles,
|
||||
})
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(node.id, preview)
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal()
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [exitMoveMode, node.id, node.parentId, node.polygon])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere ref={cursorRef} showTooltip={false} />
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default SlabMoveTool
|
||||
export default MoveSlabTool
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type DragAction,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
normalizeWallCurveOffset,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — curve-wall drag affordance.
|
||||
*
|
||||
* Mirrors `fence/actions/curve.ts`. Same chord-perpendicular projection,
|
||||
* same clamp/normalize, same single-undo dance on commit. The only
|
||||
* meaningful difference is the wall's snap-step-aware preview (the
|
||||
* legacy CurveWallTool snapped the pointer position to `getWallGridStep`
|
||||
* before projecting). We rely on the wall snap services existing in
|
||||
* the wall-drafting module — exposing those here would bloat the
|
||||
* surface, so we accept the slight precision difference for now (the
|
||||
* normalized offset is what zundo records anyway).
|
||||
*/
|
||||
|
||||
type CurveWallCtx = {
|
||||
nodeId: AnyNodeId
|
||||
originalCurveOffset: number
|
||||
chord: ReturnType<typeof getWallChordFrame>
|
||||
maxCurveOffset: number
|
||||
startNode: WallNode
|
||||
}
|
||||
|
||||
type CurveWallDraft = {
|
||||
curveOffset: number
|
||||
}
|
||||
|
||||
export const curveWallDragAction: DragAction<CurveWallCtx, CurveWallDraft> = {
|
||||
begin: (input) => {
|
||||
const node = input.node as WallNode | undefined
|
||||
if (!node) throw new Error('[curveWallDragAction] begin requires a wall node')
|
||||
return {
|
||||
nodeId: node.id as AnyNodeId,
|
||||
originalCurveOffset: getClampedWallCurveOffset(node),
|
||||
chord: getWallChordFrame(node),
|
||||
maxCurveOffset: getMaxWallCurveOffset(node),
|
||||
startNode: node,
|
||||
}
|
||||
},
|
||||
|
||||
preview: (ctx, point) => {
|
||||
const [px, pz] = point
|
||||
const offset = -(
|
||||
(px - ctx.chord.midpoint.x) * ctx.chord.normal.x +
|
||||
(pz - ctx.chord.midpoint.y) * ctx.chord.normal.y
|
||||
)
|
||||
return { curveOffset: offset }
|
||||
},
|
||||
|
||||
snap: (draft, ctx, _services) => {
|
||||
const clamped = Math.max(-ctx.maxCurveOffset, Math.min(ctx.maxCurveOffset, draft.curveOffset))
|
||||
return { curveOffset: normalizeWallCurveOffset(ctx.startNode, clamped) }
|
||||
},
|
||||
|
||||
apply: (draft, ctx, scene) => {
|
||||
scene.update(ctx.nodeId, { curveOffset: draft.curveOffset } as Partial<AnyNode>)
|
||||
scene.markDirty(ctx.nodeId)
|
||||
return [ctx.nodeId]
|
||||
},
|
||||
|
||||
commit: (draft, ctx, scene) => {
|
||||
// Always push a pastState entry — see fence/actions/curve.ts for
|
||||
// the rationale (no-op-bend would otherwise let Ctrl-Z cancel the
|
||||
// wall creation).
|
||||
scene.restoreAll()
|
||||
scene.resumeHistory()
|
||||
scene.update(ctx.nodeId, { curveOffset: draft.curveOffset } as Partial<AnyNode>)
|
||||
return true
|
||||
},
|
||||
|
||||
cancel: (_ctx, _scene) => {
|
||||
// No-op — orchestrator restores via snapshot.
|
||||
},
|
||||
}
|
||||
@@ -1,62 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import { getWallMidpointHandlePoint, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
getWallMidpointHandlePoint,
|
||||
normalizeWallCurveOffset,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
CursorSphere,
|
||||
getWallGridStep,
|
||||
markToolCancelConsumed,
|
||||
snapScalarToGrid,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { curveWallDragAction } from './actions/curve'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — thin React wrapper around `curveWallDragAction`.
|
||||
* Phase 5 Stage D — wall curve tool (kind-owned).
|
||||
*
|
||||
* Replaces the legacy `CurveWallTool` (178 LoC). Same UX as the fence
|
||||
* curve port — cursor sphere follows the chord-perpendicular projection
|
||||
* of the pointer, dragging the wall's `curveOffset` live; grid:click
|
||||
* commits with the single-undo dance, Esc cancels.
|
||||
*
|
||||
* Mounted by ToolManager via `def.affordanceTools.curve` when
|
||||
* `useEditor.curvingWall` activates.
|
||||
* 1:1 port of the legacy `CurveWallTool`. Same snap pipeline, Shift
|
||||
* override, history dance, activation grace. The wall variant uses
|
||||
* `useScene.temporal.getState().pause()` / `.resume()` directly rather
|
||||
* than the depth-counted `pauseSceneHistory` helpers — matches legacy.
|
||||
*/
|
||||
export const WallCurveTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
|
||||
const previousCurveOffsetRef = useRef<number | null>(null)
|
||||
const shiftPressedRef = useRef(false)
|
||||
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
|
||||
|
||||
const initialHandle = getWallMidpointHandlePoint(node)
|
||||
const [cursorPos, setCursorPos] = useState<[number, number, number]>([
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
|
||||
initialHandle.x,
|
||||
0,
|
||||
initialHandle.y,
|
||||
])
|
||||
|
||||
const exitCurveMode = (committed: boolean) => {
|
||||
if (committed) triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
const exitCurveMode = useCallback(() => {
|
||||
useEditor.getState().setCurvingWall(null)
|
||||
}
|
||||
|
||||
useDragAction({
|
||||
active: true,
|
||||
action: curveWallDragAction,
|
||||
initial: {
|
||||
node,
|
||||
point: [initialHandle.x, initialHandle.y],
|
||||
},
|
||||
onCommit: () => exitCurveMode(true),
|
||||
onCancel: () => exitCurveMode(false),
|
||||
})
|
||||
|
||||
const liveCurveOffset = useScene((s) => {
|
||||
const live = s.nodes[node.id]
|
||||
return live?.type === 'wall' ? ((live as WallNode).curveOffset ?? 0) : 0
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePoint = getWallMidpointHandlePoint({ ...node, curveOffset: liveCurveOffset })
|
||||
setCursorPos([handlePoint.x, 0, handlePoint.y])
|
||||
}, [liveCurveOffset, node])
|
||||
const nodeId = node.id
|
||||
const originalCurveOffset = originalCurveOffsetRef.current
|
||||
const chord = getWallChordFrame(node)
|
||||
const maxCurveOffset = getMaxWallCurveOffset(node)
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let wasCommitted = false
|
||||
|
||||
const applyPreview = (curveOffset: number) => {
|
||||
if (previewOffsetRef.current === curveOffset) {
|
||||
return
|
||||
}
|
||||
previewOffsetRef.current = curveOffset
|
||||
|
||||
const nextNode = {
|
||||
...node,
|
||||
curveOffset,
|
||||
}
|
||||
const handlePoint = getWallMidpointHandlePoint(nextNode)
|
||||
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
|
||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
}
|
||||
|
||||
const restoreOriginal = () => {
|
||||
if (previewOffsetRef.current === originalCurveOffset) {
|
||||
return
|
||||
}
|
||||
previewOffsetRef.current = originalCurveOffset
|
||||
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const snapStep = getWallGridStep()
|
||||
const localX = shiftPressedRef.current
|
||||
? event.localPosition[0]
|
||||
: snapScalarToGrid(event.localPosition[0], snapStep)
|
||||
const localZ = shiftPressedRef.current
|
||||
? event.localPosition[2]
|
||||
: snapScalarToGrid(event.localPosition[2], snapStep)
|
||||
|
||||
const offsetFromMidpoint = -(
|
||||
(localX - chord.midpoint.x) * chord.normal.x +
|
||||
(localZ - chord.midpoint.y) * chord.normal.y
|
||||
)
|
||||
const snappedOffset = shiftPressedRef.current
|
||||
? offsetFromMidpoint
|
||||
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
||||
const nextCurveOffset = normalizeWallCurveOffset(
|
||||
node,
|
||||
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
|
||||
)
|
||||
|
||||
if (
|
||||
previousCurveOffsetRef.current !== null &&
|
||||
nextCurveOffset !== previousCurveOffsetRef.current
|
||||
) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
previousCurveOffsetRef.current = nextCurveOffset
|
||||
|
||||
applyPreview(nextCurveOffset)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const curveOffset = previewOffsetRef.current
|
||||
wasCommitted = true
|
||||
|
||||
if (curveOffset !== originalCurveOffset) {
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
useScene.temporal.getState().pause()
|
||||
}
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
exitCurveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitCurveMode()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
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)
|
||||
}
|
||||
}, [exitCurveMode, node])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorPos} showTooltip={false} />
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default WallCurveTool
|
||||
export default CurveWallTool
|
||||
|
||||
@@ -57,14 +57,14 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
|
||||
parametrics: wallParametrics,
|
||||
|
||||
// Stage D — deferred for wall. The curve port (`curve-tool.tsx` +
|
||||
// `actions/curve.ts`) needs more work to match the legacy
|
||||
// CurveWallTool's UX (pre-snap on pointer position, 0.5m grid step,
|
||||
// Shift override, smooth scene.update without cascade overhead).
|
||||
// Legacy fallback runs until that lands. Endpoint move / whole-wall
|
||||
// move / placement are all still legacy too — they're the biggest
|
||||
// tools and have linked-wall corner cascade logic that needs a
|
||||
// careful port.
|
||||
// Stage D — wall curve is a 1:1 port of the legacy CurveWallTool,
|
||||
// relocated into this folder and dispatched via the registry. Endpoint
|
||||
// move (linked-wall corner cascade + ALT-detach), whole-wall move, and
|
||||
// placement are still legacy — they're substantially larger and queued
|
||||
// for separate port passes.
|
||||
affordanceTools: {
|
||||
curve: () => import('./curve-tool'),
|
||||
},
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
|
||||
Reference in New Issue
Block a user