Phase 5 Stage D fence: port MoveFenceEndpointTool to DragAction
Second Stage D affordance port (425 LoC legacy → ~430 split across
action + wrapper). All math (snap, linked-fence cascade, alt-detach,
min-length gate, single-undo dance) lives in the pure
`moveFenceEndpointDragAction`. The React wrapper handles the UI
overlays (cursor sphere, Drag/Detach badge, angle label) and the live
state subscriptions.
The action introduces the single-undo dance pattern for multi-write
commits: `commit()` calls `scene.restoreAll()` → `resumeHistory()` →
re-applies the final draft so zundo records the entire drag as one
undo step. Reusable shape for slab/wall/door endpoint ports.
Transitional exports added to `@pascal-app/editor`'s public surface
(`snapFenceDraftPoint`, `isWallLongEnough`, the segment-angle helpers,
`MovingFenceEndpoint`). Stage F cleanup moves these into
`@pascal-app/nodes` once every consumer is registry-driven.
`getRegistryAffordanceTool` is now generic (`ComponentType<any>`) so
affordances with different prop shapes (`{ node }`, `{ target }`, …)
all dispatch through the same helper.
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
ee309ece6f
commit
05efa25edb
@@ -58,18 +58,15 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
|
|||||||
* Returns null when the kind doesn't declare the affordance — caller
|
* Returns null when the kind doesn't declare the affordance — caller
|
||||||
* renders the legacy fallback in that case.
|
* renders the legacy fallback in that case.
|
||||||
*/
|
*/
|
||||||
function getRegistryAffordanceTool(
|
function getRegistryAffordanceTool(kind: string, affordance: string): ComponentType<any> | null {
|
||||||
kind: string,
|
|
||||||
affordance: string,
|
|
||||||
): ComponentType<{ node: any }> | null {
|
|
||||||
const def = nodeRegistry.get(kind)
|
const def = nodeRegistry.get(kind)
|
||||||
const loader = def?.affordanceTools?.[affordance]
|
const loader = def?.affordanceTools?.[affordance]
|
||||||
if (!loader) return null
|
if (!loader) return null
|
||||||
const cached = lazyToolCache.get(loader)
|
const cached = lazyToolCache.get(loader)
|
||||||
if (cached) return cached as ComponentType<{ node: any }>
|
if (cached) return cached
|
||||||
const Comp = lazy(loader as () => Promise<{ default: ComponentType<{ node: any }> }>)
|
const Comp = lazy(loader as () => Promise<{ default: ComponentType<any> }>)
|
||||||
lazyToolCache.set(loader, Comp as unknown as ComponentType)
|
lazyToolCache.set(loader, Comp as unknown as ComponentType)
|
||||||
return Comp
|
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>>> = {
|
||||||
@@ -212,7 +209,20 @@ export const ToolManager: React.FC = () => {
|
|||||||
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
||||||
)}
|
)}
|
||||||
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
||||||
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
|
{movingFenceEndpoint &&
|
||||||
|
(() => {
|
||||||
|
const RegistryAffordance = getRegistryAffordanceTool(
|
||||||
|
movingFenceEndpoint.fence.type,
|
||||||
|
'move-endpoint',
|
||||||
|
)
|
||||||
|
return RegistryAffordance ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<RegistryAffordance target={movingFenceEndpoint} />
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<MoveFenceEndpointTool target={movingFenceEndpoint} />
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{curvingWall && <CurveWallTool node={curvingWall} />}
|
{curvingWall && <CurveWallTool node={curvingWall} />}
|
||||||
{curvingFence &&
|
{curvingFence &&
|
||||||
(() => {
|
(() => {
|
||||||
|
|||||||
@@ -4,7 +4,18 @@ export {
|
|||||||
type SnapshotCameraData,
|
type SnapshotCameraData,
|
||||||
ThumbnailGenerator,
|
ThumbnailGenerator,
|
||||||
} from './components/editor/thumbnail-generator'
|
} from './components/editor/thumbnail-generator'
|
||||||
|
// Phase 5 Stage D transitional exports — pure drafting / angle helpers
|
||||||
|
// consumed by kind-owned drag actions in @pascal-app/nodes. Stage F
|
||||||
|
// cleanup moves these into @pascal-app/nodes (fence/drafting.ts +
|
||||||
|
// shared/segment-angle.ts) once every Stage D port is in.
|
||||||
|
export { type FencePlanPoint, snapFenceDraftPoint } from './components/tools/fence/fence-drafting'
|
||||||
export { CursorSphere } from './components/tools/shared/cursor-sphere'
|
export { CursorSphere } from './components/tools/shared/cursor-sphere'
|
||||||
|
export {
|
||||||
|
formatAngleRadians,
|
||||||
|
getAngleToSegmentReference,
|
||||||
|
getSegmentAngleReferenceAtPoint,
|
||||||
|
} from './components/tools/shared/segment-angle'
|
||||||
|
export { isWallLongEnough } from './components/tools/wall/wall-drafting'
|
||||||
export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
|
export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
|
||||||
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
|
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
|
||||||
export { useCommandPalette } from './components/ui/command-palette'
|
export { useCommandPalette } from './components/ui/command-palette'
|
||||||
@@ -36,7 +47,12 @@ export { applySceneGraphToEditor } from './lib/scene'
|
|||||||
export { triggerSFX } from './lib/sfx-bus'
|
export { triggerSFX } from './lib/sfx-bus'
|
||||||
export { default as useAudio } from './store/use-audio'
|
export { default as useAudio } from './store/use-audio'
|
||||||
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
||||||
export type { FloorplanSelectionTool, SplitOrientation, ViewMode } from './store/use-editor'
|
export type {
|
||||||
|
FloorplanSelectionTool,
|
||||||
|
MovingFenceEndpoint,
|
||||||
|
SplitOrientation,
|
||||||
|
ViewMode,
|
||||||
|
} from './store/use-editor'
|
||||||
export { default as useEditor } from './store/use-editor'
|
export { default as useEditor } from './store/use-editor'
|
||||||
export {
|
export {
|
||||||
type PaletteView,
|
type PaletteView,
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type DragAction,
|
||||||
|
type FenceNode,
|
||||||
|
useScene,
|
||||||
|
type WallNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { type FencePlanPoint, isWallLongEnough, snapFenceDraftPoint } from '@pascal-app/editor'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 5 Stage D — move-fence-endpoint drag affordance.
|
||||||
|
*
|
||||||
|
* Pure orchestration of an endpoint drag:
|
||||||
|
* - **begin**: snapshot originals (start / end / moving point / fixed
|
||||||
|
* point), look up linked fences (any other fence in the same parent
|
||||||
|
* whose start or end matches the moving point at activation time),
|
||||||
|
* cache walls + fences at the level for snap targets.
|
||||||
|
* - **preview**: snap the pointer (grid + 45° angle + wall/fence corner
|
||||||
|
* + span snaps via `snapFenceDraftPoint`), compute next start / end
|
||||||
|
* and the cascade of linked fence endpoint updates. Alt-key detaches
|
||||||
|
* the cascade for this tick (legacy "detach endpoint" semantics).
|
||||||
|
* - **apply**: writes the fence + linked fence endpoints into the
|
||||||
|
* scene. Drag-session paused history captures originals; cascade
|
||||||
|
* resolver fans dirty marks through `endpoint-match`.
|
||||||
|
* - **commit**: requires `hasChanged` && `isWallLongEnough(next)`.
|
||||||
|
* Performs the single-undo dance — revert to originals (snapshot),
|
||||||
|
* resume history, re-apply final draft — so the entire drag is one
|
||||||
|
* `Ctrl-Z` step. Returns false to reject; `createDragSession.cancel`
|
||||||
|
* restores all touched nodes.
|
||||||
|
* - **cancel**: nothing to do — `createDragSession.cancel`'s built-in
|
||||||
|
* `scene.restoreAll()` puts every touched node back.
|
||||||
|
*
|
||||||
|
* Pure data — no React, no DOM. Tests drive it through
|
||||||
|
* `createDragSession` with a stub `SceneApi` + a `useScene` fixture
|
||||||
|
* pre-populated by the test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
|
||||||
|
|
||||||
|
function samePoint(a: FencePlanPoint, b: FencePlanPoint): boolean {
|
||||||
|
return (
|
||||||
|
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
|
||||||
|
Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkedFenceSnapshot = {
|
||||||
|
id: FenceNode['id']
|
||||||
|
start: FencePlanPoint
|
||||||
|
end: FencePlanPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MoveFenceEndpointCtx = {
|
||||||
|
fenceId: AnyNodeId
|
||||||
|
endpoint: 'start' | 'end'
|
||||||
|
originalStart: FencePlanPoint
|
||||||
|
originalEnd: FencePlanPoint
|
||||||
|
originalMovingPoint: FencePlanPoint
|
||||||
|
fixedPoint: FencePlanPoint
|
||||||
|
parentId: string | null
|
||||||
|
linkedOriginals: LinkedFenceSnapshot[]
|
||||||
|
levelWalls: WallNode[]
|
||||||
|
levelFences: FenceNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MoveFenceEndpointDraft = {
|
||||||
|
movingPoint: FencePlanPoint
|
||||||
|
start: FencePlanPoint
|
||||||
|
end: FencePlanPoint
|
||||||
|
linkedUpdates: LinkedFenceSnapshot[]
|
||||||
|
detached: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotLinked(
|
||||||
|
fenceId: FenceNode['id'],
|
||||||
|
parentId: string | null,
|
||||||
|
linkedPoint: FencePlanPoint,
|
||||||
|
): LinkedFenceSnapshot[] {
|
||||||
|
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, linkedPoint) && !samePoint(node.end, linkedPoint)) 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[],
|
||||||
|
origin: FencePlanPoint,
|
||||||
|
next: FencePlanPoint,
|
||||||
|
): LinkedFenceSnapshot[] {
|
||||||
|
return linked.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
start: samePoint(l.start, origin) ? next : l.start,
|
||||||
|
end: samePoint(l.end, origin) ? next : l.end,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moveFenceEndpointDragAction: DragAction<MoveFenceEndpointCtx, MoveFenceEndpointDraft> =
|
||||||
|
{
|
||||||
|
begin: (input) => {
|
||||||
|
const fence = input.node as FenceNode | undefined
|
||||||
|
if (!fence) throw new Error('[moveFenceEndpointDragAction] begin requires a fence node')
|
||||||
|
const endpoint = (input.handleId ?? 'end') as 'start' | 'end'
|
||||||
|
const parentId = fence.parentId ?? null
|
||||||
|
const originalStart: FencePlanPoint = [fence.start[0], fence.start[1]]
|
||||||
|
const originalEnd: FencePlanPoint = [fence.end[0], fence.end[1]]
|
||||||
|
const originalMovingPoint = endpoint === 'start' ? originalStart : originalEnd
|
||||||
|
const fixedPoint = endpoint === 'start' ? originalEnd : originalStart
|
||||||
|
|
||||||
|
const { nodes } = useScene.getState()
|
||||||
|
const levelWalls: WallNode[] = []
|
||||||
|
const levelFences: FenceNode[] = []
|
||||||
|
for (const node of Object.values(nodes)) {
|
||||||
|
if (!node) continue
|
||||||
|
if ((node.parentId ?? null) !== parentId) continue
|
||||||
|
if (node.type === 'wall') levelWalls.push(node)
|
||||||
|
else if (node.type === 'fence') levelFences.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fenceId: fence.id as AnyNodeId,
|
||||||
|
endpoint,
|
||||||
|
originalStart,
|
||||||
|
originalEnd,
|
||||||
|
originalMovingPoint,
|
||||||
|
fixedPoint,
|
||||||
|
parentId,
|
||||||
|
linkedOriginals: snapshotLinked(fence.id, parentId, originalMovingPoint),
|
||||||
|
levelWalls,
|
||||||
|
levelFences,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
preview: (ctx, point, modifiers) => {
|
||||||
|
const planPoint: FencePlanPoint = [point[0], point[1]]
|
||||||
|
const snapped = snapFenceDraftPoint({
|
||||||
|
point: planPoint,
|
||||||
|
walls: ctx.levelWalls,
|
||||||
|
fences: ctx.levelFences,
|
||||||
|
start: ctx.fixedPoint,
|
||||||
|
angleSnap: !modifiers.shift,
|
||||||
|
ignoreFenceIds: [ctx.fenceId as string],
|
||||||
|
})
|
||||||
|
const nextStart = ctx.endpoint === 'start' ? snapped : ctx.fixedPoint
|
||||||
|
const nextEnd = ctx.endpoint === 'end' ? snapped : ctx.fixedPoint
|
||||||
|
const detached = modifiers.alt
|
||||||
|
const linkedUpdates = detached
|
||||||
|
? []
|
||||||
|
: linkedCascade(ctx.linkedOriginals, ctx.originalMovingPoint, snapped)
|
||||||
|
return {
|
||||||
|
movingPoint: snapped,
|
||||||
|
start: nextStart,
|
||||||
|
end: nextEnd,
|
||||||
|
linkedUpdates,
|
||||||
|
detached,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
apply: (draft, ctx, scene) => {
|
||||||
|
scene.update(ctx.fenceId, { start: draft.start, end: draft.end } as Partial<AnyNode>)
|
||||||
|
const dirty: AnyNodeId[] = [ctx.fenceId]
|
||||||
|
for (const linked of draft.linkedUpdates) {
|
||||||
|
scene.update(
|
||||||
|
linked.id as AnyNodeId,
|
||||||
|
{
|
||||||
|
start: linked.start,
|
||||||
|
end: linked.end,
|
||||||
|
} as Partial<AnyNode>,
|
||||||
|
)
|
||||||
|
dirty.push(linked.id as AnyNodeId)
|
||||||
|
}
|
||||||
|
return dirty
|
||||||
|
},
|
||||||
|
|
||||||
|
commit: (draft, ctx, scene) => {
|
||||||
|
// Reject when the drag didn't move OR the resulting fence would
|
||||||
|
// be shorter than the minimum length. createDragSession.cancel()
|
||||||
|
// restores originals via the snapshot.
|
||||||
|
const hasChanged = !samePoint(draft.movingPoint, ctx.originalMovingPoint)
|
||||||
|
if (!hasChanged) return false
|
||||||
|
if (!isWallLongEnough(draft.start, draft.end)) return false
|
||||||
|
|
||||||
|
// Single-undo dance: revert to originals (paused history → no
|
||||||
|
// zundo record), resume history, then re-apply the final draft
|
||||||
|
// so zundo captures the entire drag as one undo step. terminate()
|
||||||
|
// calls resumeHistory again — depth-counted, becomes a no-op.
|
||||||
|
scene.restoreAll()
|
||||||
|
scene.resumeHistory()
|
||||||
|
scene.update(ctx.fenceId, { start: draft.start, end: draft.end } as Partial<AnyNode>)
|
||||||
|
if (!draft.detached) {
|
||||||
|
for (const linked of draft.linkedUpdates) {
|
||||||
|
scene.update(
|
||||||
|
linked.id as AnyNodeId,
|
||||||
|
{
|
||||||
|
start: linked.start,
|
||||||
|
end: linked.end,
|
||||||
|
} as Partial<AnyNode>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: (_ctx, _scene) => {
|
||||||
|
// No-op — createDragSession.cancel() calls scene.restoreAll()
|
||||||
|
// which puts every touched node back via the snapshot.
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -76,6 +76,10 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
|||||||
// actions/curve.ts; this component is the React wrapper using
|
// actions/curve.ts; this component is the React wrapper using
|
||||||
// useDragAction + the cursor visuals.
|
// useDragAction + the cursor visuals.
|
||||||
curve: () => import('./curve-tool'),
|
curve: () => import('./curve-tool'),
|
||||||
|
// Triggered by useEditor.movingFenceEndpoint. Pure logic in
|
||||||
|
// 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'),
|
||||||
},
|
},
|
||||||
|
|
||||||
toolHints: [
|
toolHints: [
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type FenceNode, useScene, type WallNode } from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
CursorSphere,
|
||||||
|
type FencePlanPoint,
|
||||||
|
formatAngleRadians,
|
||||||
|
getAngleToSegmentReference,
|
||||||
|
getSegmentAngleReferenceAtPoint,
|
||||||
|
type MovingFenceEndpoint,
|
||||||
|
triggerSFX,
|
||||||
|
useDragAction,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { moveFenceEndpointDragAction } from './actions/move-endpoint'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 5 Stage D — thin React wrapper around `moveFenceEndpointDragAction`.
|
||||||
|
*
|
||||||
|
* Replaces the legacy `MoveFenceEndpointTool` (425 LoC). All the math
|
||||||
|
* (snap, linked cascade, length gate, single-undo dance) lives in the
|
||||||
|
* pure action; this wrapper owns the React-only surface:
|
||||||
|
*
|
||||||
|
* - Live cursor sphere tracking the moving endpoint (subscribed from
|
||||||
|
* `useScene` so it follows the draft as `apply()` writes).
|
||||||
|
* - Alt-key detach badge — pure UX, reads window keystate so the badge
|
||||||
|
* updates without requiring a pointer move.
|
||||||
|
* - Angle label between this segment and any neighbour segment sharing
|
||||||
|
* the dragged endpoint — same legacy treatment.
|
||||||
|
*
|
||||||
|
* Mounted by the legacy ToolManager via the `move-endpoint` affordance
|
||||||
|
* key. `target.fence` + `target.endpoint` come from the editor store
|
||||||
|
* (`useEditor.movingFenceEndpoint`).
|
||||||
|
*/
|
||||||
|
type SegmentLike = {
|
||||||
|
id: string
|
||||||
|
start: FencePlanPoint
|
||||||
|
end: FencePlanPoint
|
||||||
|
curveOffset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
|
||||||
|
return [
|
||||||
|
...walls.map((w) => ({ id: w.id, start: w.start, end: w.end, curveOffset: w.curveOffset })),
|
||||||
|
...fences.map((f) => ({ id: f.id, start: f.start, end: f.end, curveOffset: f.curveOffset })),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAngleLabel(args: {
|
||||||
|
fenceId: FenceNode['id']
|
||||||
|
start: FencePlanPoint
|
||||||
|
end: FencePlanPoint
|
||||||
|
curveOffset?: number
|
||||||
|
segments: SegmentLike[]
|
||||||
|
}): { label: string; position: [number, number, number] } | null {
|
||||||
|
const target: SegmentLike = {
|
||||||
|
id: args.fenceId,
|
||||||
|
start: args.start,
|
||||||
|
end: args.end,
|
||||||
|
curveOffset: args.curveOffset,
|
||||||
|
}
|
||||||
|
for (const endpoint of [args.start, args.end] as FencePlanPoint[]) {
|
||||||
|
const targetRef = getSegmentAngleReferenceAtPoint(endpoint, target)
|
||||||
|
if (!targetRef) continue
|
||||||
|
const neighbour = args.segments.find(
|
||||||
|
(s) => s.id !== args.fenceId && Boolean(getSegmentAngleReferenceAtPoint(endpoint, s)),
|
||||||
|
)
|
||||||
|
if (!neighbour) continue
|
||||||
|
const neighbourRef = getSegmentAngleReferenceAtPoint(endpoint, neighbour)
|
||||||
|
if (!neighbourRef) continue
|
||||||
|
const angle = getAngleToSegmentReference(targetRef.vector, neighbourRef)
|
||||||
|
if (angle === null) continue
|
||||||
|
return {
|
||||||
|
label: formatAngleRadians(angle),
|
||||||
|
position: [endpoint[0], 0.34, endpoint[1]],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => {
|
||||||
|
const fenceId = target.fence.id
|
||||||
|
const endpoint = target.endpoint
|
||||||
|
const initialPoint: FencePlanPoint =
|
||||||
|
endpoint === 'start'
|
||||||
|
? [target.fence.start[0], target.fence.start[1]]
|
||||||
|
: [target.fence.end[0], target.fence.end[1]]
|
||||||
|
|
||||||
|
const [altPressed, setAltPressed] = useState(false)
|
||||||
|
|
||||||
|
const exitMoveMode = (committed: boolean) => {
|
||||||
|
if (committed) triggerSFX('sfx:item-place')
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [fenceId] })
|
||||||
|
useEditor.getState().setMovingFenceEndpoint(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
useDragAction({
|
||||||
|
active: true,
|
||||||
|
action: moveFenceEndpointDragAction,
|
||||||
|
initial: {
|
||||||
|
node: target.fence,
|
||||||
|
handleId: endpoint,
|
||||||
|
point: initialPoint,
|
||||||
|
},
|
||||||
|
onCommit: () => exitMoveMode(true),
|
||||||
|
onCancel: () => exitMoveMode(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Live subscriptions — the action writes onto the fence node every
|
||||||
|
// grid:move, so the cursor + angle label can mirror current state.
|
||||||
|
const live = useScene((s) => s.nodes[fenceId])
|
||||||
|
const liveFence = live?.type === 'fence' ? (live as FenceNode) : null
|
||||||
|
const liveStart = liveFence?.start ?? target.fence.start
|
||||||
|
const liveEnd = liveFence?.end ?? target.fence.end
|
||||||
|
const movingPoint = endpoint === 'start' ? liveStart : liveEnd
|
||||||
|
|
||||||
|
// Neighbour segments at the parent level — computed once at mount.
|
||||||
|
const parentId = target.fence.parentId ?? null
|
||||||
|
const neighbourSegments = useMemo(() => {
|
||||||
|
const { nodes } = useScene.getState()
|
||||||
|
const walls: WallNode[] = []
|
||||||
|
const fences: FenceNode[] = []
|
||||||
|
for (const node of Object.values(nodes)) {
|
||||||
|
if (!node) continue
|
||||||
|
if ((node.parentId ?? null) !== parentId) continue
|
||||||
|
if (node.type === 'wall') walls.push(node)
|
||||||
|
else if (node.type === 'fence' && node.id !== fenceId) fences.push(node)
|
||||||
|
}
|
||||||
|
return referenceSegments(walls, fences)
|
||||||
|
}, [parentId, fenceId])
|
||||||
|
|
||||||
|
const angleLabel = useMemo(
|
||||||
|
() =>
|
||||||
|
pickAngleLabel({
|
||||||
|
fenceId,
|
||||||
|
start: liveStart,
|
||||||
|
end: liveEnd,
|
||||||
|
curveOffset: liveFence?.curveOffset ?? target.fence.curveOffset,
|
||||||
|
segments: neighbourSegments,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
fenceId,
|
||||||
|
liveStart,
|
||||||
|
liveEnd,
|
||||||
|
liveFence?.curveOffset,
|
||||||
|
target.fence.curveOffset,
|
||||||
|
neighbourSegments,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Window-level keystate for the detach badge — independent of grid
|
||||||
|
// event modifiers so the badge can toggle without a pointer move.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||||
|
if (e.key === 'Alt') setAltPressed(true)
|
||||||
|
}
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Alt') setAltPressed(false)
|
||||||
|
}
|
||||||
|
const onBlur = () => setAltPressed(false)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('keyup', onKeyUp)
|
||||||
|
window.addEventListener('blur', onBlur)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
|
window.removeEventListener('blur', onBlur)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cursorPos: [number, number, number] = [movingPoint[0], 0, movingPoint[1]]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<CursorSphere position={cursorPos} showTooltip={false} />
|
||||||
|
<Html
|
||||||
|
position={cursorPos}
|
||||||
|
style={{ pointerEvents: 'none', touchAction: 'none' }}
|
||||||
|
zIndexRange={[100, 0]}
|
||||||
|
>
|
||||||
|
<div className="translate-y-10">
|
||||||
|
<div
|
||||||
|
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
|
||||||
|
altPressed
|
||||||
|
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
|
||||||
|
: 'border-border/70 bg-background/90 text-foreground/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{altPressed ? 'Detach endpoint' : 'Drag endpoint'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
{angleLabel && (
|
||||||
|
<Html
|
||||||
|
center
|
||||||
|
position={angleLabel.position}
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
zIndexRange={[100, 0]}
|
||||||
|
>
|
||||||
|
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
||||||
|
{angleLabel.label}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MoveFenceEndpointTool
|
||||||
Reference in New Issue
Block a user