Phase 5 Stage D fence: port MoveFenceTool to DragAction (whole-fence move)
Third Stage D affordance port (302 LoC legacy → action + thin wrapper). The action (`actions/move.ts`) uses the live-drag exception documented in editor/wiki/architecture/tools.md: visual-only updates via `sceneRegistry.nodes.get(id).position` + `useLiveTransforms` during the drag, no scene mutations. Avoids re-rebuilding the fence geometry (many posts + infill panels) every pointer tick. Commit performs the single-undo dance — writes final start/end to scene, geometry rebuilds once, Ctrl-Z reverses the whole drag. Linked-fence cascade follows the same shape as MoveFenceEndpoint — any fence in the same parent that shared an endpoint at activation moves with the drag. `getRegistryAffordanceTool` extracted to `tools/shared/affordance-dispatch.ts` so move-tool.tsx and tool-manager.tsx share the lazy-load helper (no duplicate caches). MoveTool dispatch gains a generic `affordanceTools.move` check after the capability-driven `movable` shortcut — fence routes through here; wall / slab / etc. fall through to their legacy per-kind chain until their D ports land. 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
36c48b7fc9
commit
c16878c876
@@ -17,6 +17,7 @@ import type {
|
|||||||
WindowNode,
|
WindowNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { nodeRegistry } from '@pascal-app/core'
|
import { nodeRegistry } from '@pascal-app/core'
|
||||||
|
import { Suspense } from 'react'
|
||||||
import { Vector3 } from 'three'
|
import { Vector3 } from 'three'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
@@ -28,6 +29,7 @@ import { MoveElevatorTool } from '../elevator/move-elevator-tool'
|
|||||||
import { MoveFenceTool } from '../fence/move-fence-tool'
|
import { MoveFenceTool } from '../fence/move-fence-tool'
|
||||||
import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
|
import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
|
||||||
import { MoveRoofTool } from '../roof/move-roof-tool'
|
import { MoveRoofTool } from '../roof/move-roof-tool'
|
||||||
|
import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
|
||||||
import { MoveSlabTool } from '../slab/move-slab-tool'
|
import { MoveSlabTool } from '../slab/move-slab-tool'
|
||||||
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
|
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
|
||||||
import { MoveWallTool } from '../wall/move-wall-tool'
|
import { MoveWallTool } from '../wall/move-wall-tool'
|
||||||
@@ -122,6 +124,18 @@ export const MoveTool: React.FC<{
|
|||||||
return <MoveRegistryNodeTool node={movingNode} />
|
return <MoveRegistryNodeTool node={movingNode} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 5 Stage D: registry-driven move affordance (kind-owned
|
||||||
|
// `DragAction` with bespoke semantics). Falls through to the legacy
|
||||||
|
// per-kind chain below when the kind hasn't ported its move tool.
|
||||||
|
const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
|
||||||
|
if (RegistryMove) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<RegistryMove node={movingNode} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (movingNode.type === 'building')
|
if (movingNode.type === 'building')
|
||||||
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
||||||
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { nodeRegistry } from '@pascal-app/core'
|
||||||
|
import { type ComponentType, lazy } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 5 Stage D — runtime lazy-load of a kind's affordance tool.
|
||||||
|
*
|
||||||
|
* The editor can't statically import from `@pascal-app/nodes` (the
|
||||||
|
* nodes package depends on editor — static imports would cycle). The
|
||||||
|
* kind declares its drag-affordance components in
|
||||||
|
* `def.affordanceTools[<key>]: () => import('./<name>-tool')`; this
|
||||||
|
* helper resolves that to a `React.lazy` component at the call site.
|
||||||
|
*
|
||||||
|
* Returns null when the kind doesn't declare the affordance — callers
|
||||||
|
* mount the legacy fallback in that case.
|
||||||
|
*/
|
||||||
|
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
|
||||||
|
|
||||||
|
export function getRegistryAffordanceTool(
|
||||||
|
kind: string,
|
||||||
|
affordance: string,
|
||||||
|
): ComponentType<any> | null {
|
||||||
|
const def = nodeRegistry.get(kind)
|
||||||
|
const loader = def?.affordanceTools?.[affordance]
|
||||||
|
if (!loader) return null
|
||||||
|
const cached = lazyToolCache.get(loader)
|
||||||
|
if (cached) return cached
|
||||||
|
const Comp = lazy(loader as () => Promise<{ default: ComponentType<any> }>)
|
||||||
|
lazyToolCache.set(loader, Comp as unknown as ComponentType)
|
||||||
|
return Comp as unknown as ComponentType<any>
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
|
|||||||
import { ItemTool } from './item/item-tool'
|
import { ItemTool } from './item/item-tool'
|
||||||
import { MoveTool } from './item/move-tool'
|
import { MoveTool } from './item/move-tool'
|
||||||
import { RoofTool } from './roof/roof-tool'
|
import { RoofTool } from './roof/roof-tool'
|
||||||
|
import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
|
||||||
import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
||||||
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
||||||
import { SlabHoleEditor } from './slab/slab-hole-editor'
|
import { SlabHoleEditor } from './slab/slab-hole-editor'
|
||||||
@@ -49,26 +50,6 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
|
|||||||
return Comp
|
return Comp
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazy-loads the kind's drag-affordance component from
|
|
||||||
* `def.affordanceTools[name]`. Lets editor consume kind-owned tools
|
|
||||||
* from `@pascal-app/nodes` without a static import (which would create
|
|
||||||
* a circular dep: nodes already depends on editor).
|
|
||||||
*
|
|
||||||
* Returns null when the kind doesn't declare the affordance — caller
|
|
||||||
* renders the legacy fallback in that case.
|
|
||||||
*/
|
|
||||||
function getRegistryAffordanceTool(kind: string, affordance: string): ComponentType<any> | null {
|
|
||||||
const def = nodeRegistry.get(kind)
|
|
||||||
const loader = def?.affordanceTools?.[affordance]
|
|
||||||
if (!loader) return null
|
|
||||||
const cached = lazyToolCache.get(loader)
|
|
||||||
if (cached) return cached
|
|
||||||
const Comp = lazy(loader as () => Promise<{ default: ComponentType<any> }>)
|
|
||||||
lazyToolCache.set(loader, Comp as unknown as ComponentType)
|
|
||||||
return Comp as unknown as ComponentType<any>
|
|
||||||
}
|
|
||||||
|
|
||||||
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
||||||
site: {
|
site: {
|
||||||
'property-line': SiteBoundaryEditor,
|
'property-line': SiteBoundaryEditor,
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type DragAction,
|
||||||
|
type FenceNode,
|
||||||
|
type LevelNode,
|
||||||
|
sceneRegistry,
|
||||||
|
useLiveTransforms,
|
||||||
|
useScene,
|
||||||
|
type WallNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { type FencePlanPoint, snapFenceDraftPoint } from '@pascal-app/editor'
|
||||||
|
import type * as THREE from 'three'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
preview: (ctx, point, _modifiers) => {
|
||||||
|
const snapped = snapFenceDraftPoint({
|
||||||
|
point: [point[0], point[1]],
|
||||||
|
walls: ctx.levelWalls,
|
||||||
|
fences: ctx.levelFences,
|
||||||
|
ignoreFenceIds: [ctx.fenceId as string],
|
||||||
|
})
|
||||||
|
// 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) => {
|
||||||
|
// Reject when nothing moved — falls through to action.cancel which
|
||||||
|
// clears the live-drag visual state.
|
||||||
|
if (draft.deltaX === 0 && draft.deltaZ === 0) return false
|
||||||
|
|
||||||
|
// Single-undo dance — paused history during drag means nothing
|
||||||
|
// was recorded. We didn't scene.update during apply either (live-
|
||||||
|
// drag exception), so the snapshot is empty and restoreAll is a
|
||||||
|
// no-op. Resume history, then write the final draft. Zundo
|
||||||
|
// captures one diff: original → final.
|
||||||
|
scene.restoreAll() // no-op (no scene updates during apply)
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -80,6 +80,13 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
|||||||
// actions/move-endpoint.ts (linked-fence cascade, alt-detach,
|
// actions/move-endpoint.ts (linked-fence cascade, alt-detach,
|
||||||
// single-undo dance). Wrapper owns the angle label + detach badge.
|
// single-undo dance). Wrapper owns the angle label + detach badge.
|
||||||
'move-endpoint': () => import('./move-endpoint-tool'),
|
'move-endpoint': () => import('./move-endpoint-tool'),
|
||||||
|
// Triggered by useEditor.movingNode when the moving node is a
|
||||||
|
// fence. Whole-fence rigid translation with linked-fence cascade.
|
||||||
|
// Pure logic in actions/move.ts uses the live-drag exception
|
||||||
|
// (mesh.position + useLiveTransforms) to avoid rebuilding fence
|
||||||
|
// geometry every pointer tick; commits the final start/end with
|
||||||
|
// the single-undo dance.
|
||||||
|
move: () => import('./move-tool'),
|
||||||
},
|
},
|
||||||
|
|
||||||
toolHints: [
|
toolHints: [
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type FenceNode, useLiveTransforms } from '@pascal-app/core'
|
||||||
|
import { CursorSphere, triggerSFX, useDragAction, useEditor } from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { moveFenceDragAction } from './actions/move'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 5 Stage D — thin React wrapper around `moveFenceDragAction`.
|
||||||
|
*
|
||||||
|
* Replaces the legacy `MoveFenceTool` (302 LoC). The action owns all
|
||||||
|
* the math (snap + linked cascade + live-drag mesh offsets +
|
||||||
|
* single-undo dance on commit). The wrapper just renders the cursor
|
||||||
|
* sphere and tracks its position from the live-transform store.
|
||||||
|
*
|
||||||
|
* Mounted by ToolManager when `useEditor.movingNode` is a fence
|
||||||
|
* (capability-driven dispatch — fence has no `movable` capability, so
|
||||||
|
* the legacy MoveTool's per-kind branch chain falls through to here
|
||||||
|
* via the new affordance dispatch).
|
||||||
|
*/
|
||||||
|
export const FenceMoveTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||||
|
const fenceId = node.id
|
||||||
|
const originalCenter: [number, number, number] = [
|
||||||
|
(node.start[0] + node.end[0]) / 2,
|
||||||
|
0,
|
||||||
|
(node.start[1] + node.end[1]) / 2,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Live position from the live-transforms store — the action writes
|
||||||
|
// here every preview tick (live-drag exception). Falls back to the
|
||||||
|
// original center until the first move.
|
||||||
|
const liveCenter = useLiveTransforms((s) => {
|
||||||
|
const t = s.get(fenceId)
|
||||||
|
return t?.position ?? originalCenter
|
||||||
|
})
|
||||||
|
|
||||||
|
const exitMoveMode = (committed: boolean) => {
|
||||||
|
if (committed) triggerSFX('sfx:item-place')
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [fenceId] })
|
||||||
|
useEditor.getState().setMovingNode(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
useDragAction({
|
||||||
|
active: true,
|
||||||
|
action: moveFenceDragAction,
|
||||||
|
initial: {
|
||||||
|
node,
|
||||||
|
point: [originalCenter[0], originalCenter[2]],
|
||||||
|
},
|
||||||
|
onCommit: () => exitMoveMode(true),
|
||||||
|
onCancel: () => exitMoveMode(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<CursorSphere position={liveCenter as [number, number, number]} showTooltip={false} />
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FenceMoveTool
|
||||||
Reference in New Issue
Block a user