FloorplanRegistryLayer: strip drag-to-move, keep click-to-select

The drag-with-grab-cursor model was wrong design — 3D doesn't drag,
it uses select → inspector "Move" button → click-to-place. Floor
plan should match. Drag also had a coord-conversion bug (used outer
<svg> CTM instead of the floorplanSceneRef <g>, so screen→meter
conversion didn't account for the floor plan's pan/zoom/building-
rotation transforms).

Strips the drag pointerdown/move/up handlers, the imperative
transform override, the temporal pause bracketing, and the global
window listeners. Cursor goes back to 'pointer'. Only click-to-
select remains.

The right pattern (move via inspector / action menu + cursor-driven
placement) needs:
 - Registry-aware FloorplanActionMenuLayer path
 - Generic movingNode handler in floor-plan-panel for any registered
   kind with capabilities.movable
 - Shared floorplanSceneRef for accurate coord conversion
Both flagged in the plan as Phase 4 follow-on gaps with their
acceptance criteria. 3D-realtime-sync-while-moving is documented as
deferred (legacy doesn't do it for any kind either; design + ship
in a dedicated PR later).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 11:02:44 -04:00
co-authored by Claude Opus 4.7
parent d10d7f8deb
commit a3fb5bd622
@@ -9,7 +9,7 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import { memo, useCallback, useMemo } from 'react'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
/** /**
@@ -18,14 +18,22 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
* For every node in the active level whose definition exposes * For every node in the active level whose definition exposes
* `def.floorplan`, builds a `GeometryContext`, calls the builder, and * `def.floorplan`, builds a `GeometryContext`, calls the builder, and
* emits the resulting SVG via `<FloorplanGeometryRenderer>`. Each entry * emits the resulting SVG via `<FloorplanGeometryRenderer>`. Each entry
* is wrapped in an interactive `<g>` that handles: * is wrapped in an interactive `<g>` that handles **click → select**.
* *
* - **Click → select**. Sets `useViewer.selection.selectedIds = [id]`. * Move and delete happen via the same flow as 3D: select sets
* - **Drag → move**. Pure imperative translation via the wrapping `<g>`'s * `useViewer.selection`, the `<ParametricInspector>` opens with Move /
* transform attribute during drag; one `updateNode(id, { position })` * Delete buttons, and the user enters move mode from there. Floor-plan
* call on pointerup. Same pattern as `MoveRegistryNodeTool` (the * cursor-driven placement for registry kinds (the "while in moving
* validated "smooth move" from Phase 2/3): no per-tick store update, * state, click in floor plan to place") is a follow-up — see the
* no re-render storm, no zundo bloat. Only the dragged node mutates. * plan's Phase 4 follow-on section.
*
* Drag-to-move was prototyped here briefly but removed: the grab cursor
* + drag interaction model is inconsistent with the rest of the editor
* (3D doesn't drag, it uses select → Move button → cursor-driven
* placement) and the SVG coord conversion needs to route through the
* floor-plan scene `<g>` (legacy `floorplanSceneRef`) to account for
* pan/zoom transforms. Both warrant a proper port via the action menu
* + movingNode flow, not an inline drag in this layer.
* *
* Coexists with the legacy `floorplan-panel.tsx` inline rendering — * Coexists with the legacy `floorplan-panel.tsx` inline rendering —
* unmigrated kinds keep their hand-written branches. As each kind ports * unmigrated kinds keep their hand-written branches. As each kind ports
@@ -41,101 +49,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
// Drag state — tracks the active pointer drag across global pointermove const handleSelect = useCallback(
// / pointerup so the pointer can leave the dragged element without
// breaking the gesture. Imperative DOM updates avoid React re-renders;
// store update happens once on commit.
const dragRef = useRef<{
id: AnyNodeId
pointerId: number
startSvgX: number
startSvgY: number
originalPosition: [number, number, number]
element: SVGGElement
moved: boolean
} | null>(null)
const handlePointerDown = useCallback(
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => { (id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
if (event.button !== 0) return if (event.button !== 0) return
event.stopPropagation() event.stopPropagation()
const node = useScene.getState().nodes[id]
if (!node || typeof (node as { position?: unknown }).position === 'undefined') return
const position = (node as unknown as { position: [number, number, number] }).position
if (!Array.isArray(position) || position.length < 3) return
const svg = event.currentTarget.ownerSVGElement
if (!svg) return
const pt = svgPoint(svg, event.clientX, event.clientY)
setSelection({ selectedIds: [id] }) setSelection({ selectedIds: [id] })
dragRef.current = {
id,
pointerId: event.pointerId,
startSvgX: pt.x,
startSvgY: pt.y,
originalPosition: [position[0], position[1], position[2]],
element: event.currentTarget,
moved: false,
}
// Pause undo while we drag so the commit on pointerup lands as a
// single history step. Resume in the pointerup handler.
useScene.temporal.getState().pause()
}, },
[setSelection], [setSelection],
) )
// Global pointermove / pointerup so the drag survives the cursor
// leaving the entry's bounding box.
useEffect(() => {
const onMove = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag || event.pointerId !== drag.pointerId) return
const svg = drag.element.ownerSVGElement
if (!svg) return
const pt = svgPoint(svg, event.clientX, event.clientY)
const dx = pt.x - drag.startSvgX
const dy = pt.y - drag.startSvgY
if (!drag.moved && (dx !== 0 || dy !== 0)) drag.moved = true
drag.element.setAttribute('transform', `translate(${dx} ${dy})`)
}
const onUp = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag || event.pointerId !== drag.pointerId) return
// Clear the imperative override before committing; the store update
// will re-render the entry with the new position baked in via the
// builder, so the temporary transform is no longer needed.
drag.element.removeAttribute('transform')
if (drag.moved) {
const svg = drag.element.ownerSVGElement
if (svg) {
const pt = svgPoint(svg, event.clientX, event.clientY)
const dx = pt.x - drag.startSvgX
const dy = pt.y - drag.startSvgY
const [ox, oy, oz] = drag.originalPosition
useScene
.getState()
.updateNode(drag.id, { position: [ox + dx, oy, oz + dy] } as Partial<AnyNode>)
}
}
useScene.temporal.getState().resume()
dragRef.current = null
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
}, [])
const entries = useMemo(() => { const entries = useMemo(() => {
if (!levelId) return [] if (!levelId) return []
const out: { const out: {
@@ -180,8 +102,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
} }
data-node-id={id} data-node-id={id}
key={id} key={id}
onPointerDown={(e) => handlePointerDown(id, e)} onPointerDown={(e) => handleSelect(id, e)}
style={{ cursor: 'grab' }} style={{ cursor: 'pointer' }}
> >
<FloorplanGeometryRenderer geometry={geometry} /> <FloorplanGeometryRenderer geometry={geometry} />
{isSelected && <SelectionOutline geometry={geometry} />} {isSelected && <SelectionOutline geometry={geometry} />}
@@ -208,16 +130,6 @@ function withSelectionStyle(g: FloorplanGeometry): FloorplanGeometry {
return { ...g, ...accent } return { ...g, ...accent }
} }
function svgPoint(svg: SVGSVGElement, clientX: number, clientY: number): { x: number; y: number } {
const pt = svg.createSVGPoint()
pt.x = clientX
pt.y = clientY
const ctm = svg.getScreenCTM()
if (!ctm) return { x: 0, y: 0 }
const transformed = pt.matrixTransform(ctm.inverse())
return { x: transformed.x, y: transformed.y }
}
function buildContext(node: AnyNode, nodes: Record<string, AnyNode>): GeometryContext { function buildContext(node: AnyNode, nodes: Record<string, AnyNode>): GeometryContext {
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined