diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 1eaffb0c..03da9e27 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -17,6 +17,7 @@ import type { WindowNode, } from '@pascal-app/core' import { nodeRegistry } from '@pascal-app/core' +import { Suspense } from 'react' import { Vector3 } from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' 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 { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRoofTool } from '../roof/move-roof-tool' +import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' import { MoveSlabTool } from '../slab/move-slab-tool' import { MoveSpawnTool } from '../spawn/move-spawn-tool' import { MoveWallTool } from '../wall/move-wall-tool' @@ -122,6 +124,18 @@ export const MoveTool: React.FC<{ return } + // 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 ( + + + + ) + } + if (movingNode.type === 'building') return if (movingNode.type === 'door') return diff --git a/packages/editor/src/components/tools/shared/affordance-dispatch.ts b/packages/editor/src/components/tools/shared/affordance-dispatch.ts new file mode 100644 index 00000000..0fc35c6a --- /dev/null +++ b/packages/editor/src/components/tools/shared/affordance-dispatch.ts @@ -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[]: () => import('./-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, ComponentType>() + +export function getRegistryAffordanceTool( + kind: string, + affordance: string, +): ComponentType | 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 }>) + lazyToolCache.set(loader, Comp as unknown as ComponentType) + return Comp as unknown as ComponentType +} diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index a231d2f8..b4d51407 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -21,6 +21,7 @@ import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool' import { ItemTool } from './item/item-tool' import { MoveTool } from './item/move-tool' import { RoofTool } from './roof/roof-tool' +import { getRegistryAffordanceTool } from './shared/affordance-dispatch' import { SiteBoundaryEditor } from './site/site-boundary-editor' import { SlabBoundaryEditor } from './slab/slab-boundary-editor' import { SlabHoleEditor } from './slab/slab-hole-editor' @@ -49,26 +50,6 @@ function getRegistryTool(tool: Tool | null): ComponentType | null { 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 | 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 }>) - lazyToolCache.set(loader, Comp as unknown as ComponentType) - return Comp as unknown as ComponentType -} - const tools: Record>> = { site: { 'property-line': SiteBoundaryEditor, diff --git a/packages/nodes/src/fence/actions/move.ts b/packages/nodes/src/fence/actions/move.ts new file mode 100644 index 00000000..d9f7616e --- /dev/null +++ b/packages/nodes/src/fence/actions/move.ts @@ -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 = { + 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) + for (const linked of draft.linkedUpdates) { + scene.update( + linked.id as AnyNodeId, + { + start: linked.start, + end: linked.end, + } as Partial, + ) + } + + // 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) + }, +} diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index c7b1adee..869e0519 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -80,6 +80,13 @@ export const fenceDefinition: NodeDefinition = { // actions/move-endpoint.ts (linked-fence cascade, alt-detach, // single-undo dance). Wrapper owns the angle label + detach badge. '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: [ diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx new file mode 100644 index 00000000..c307216a --- /dev/null +++ b/packages/nodes/src/fence/move-tool.tsx @@ -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 ( + + + + ) +} + +export default FenceMoveTool