Floor-plan registry: snap, real-SVG move, commit on pointerup, placement events

Four issues in one pass:

1. Move snaps to grid

   MoveOverlay's cursor is now snapped via snapPointToGrid([m.x, m.y],
   GRID_STEP=0.5). Matches the 3D shelf tool's placement step so 2D
   and 3D placement feel identical.

2. Move translates the actual rendered SVG, not a ghost

   MoveOverlay no longer portals a 50%-opacity ghost. Instead it finds
   the rendered [data-node-id] <g> inside the floor-plan scene and sets
   its `transform` attribute imperatively each pointermove. The inner
   group's translate(px pz) rotate(deg) stays untouched — the outer
   transform composes as a pure delta. Same "smooth move" pattern as
   the 3D MoveRegistryNodeTool: no React re-renders, no zundo bloat,
   the actual shape follows the cursor with full fidelity.

3. Click commits the position (previously did nothing)

   Switched from `window click` (with capture + composedPath check)
   to `window pointerup`. Pointerup fires reliably regardless of
   click-vs-drag semantics in the floor-plan panel's pointer-down
   handlers (which can preventDefault on certain modes and suppress
   the synthesized click). Target check uses
   `target.closest('[data-floorplan-scene]')` instead of composedPath
   for cross-browser SVG reliability.

4. Clicking in floor plan with shelf tool active creates a shelf

   Root cause: `isFloorplanGridInteractionActive` is a hardcoded OR of
   build/move modes that doesn't include registry kinds, so the panel
   never emits `grid:click` / `grid:move` for them. Shelf tool listens
   on those events; without them, clicks were silently dropped.

   Fix: new `isRegistryToolBuildActive` derived from
   `mode === 'build' && tool != null && nodeRegistry.has(tool)` — added
   to the OR chain. Future Phase 5 kinds (fence, item, etc.) inherit
   floor-plan placement automatically the moment they register a tool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 11:35:15 -04:00
co-authored by Claude Opus 4.7
parent 773b58ccd4
commit 713ef5009e
2 changed files with 75 additions and 87 deletions
@@ -3,15 +3,14 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type FloorplanGeometry,
type GeometryContext,
nodeRegistry, nodeRegistry,
snapPointToGrid,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useEffect, useState } from 'react' import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { FloorplanGeometryRenderer } from './renderers/floorplan-geometry-renderer'
const GRID_STEP = 0.5
/** /**
* Cursor-driven placement for registered kinds in the floor plan. * Cursor-driven placement for registered kinds in the floor plan.
@@ -19,38 +18,46 @@ import { FloorplanGeometryRenderer } from './renderers/floorplan-geometry-render
* Activates when `useEditor.movingNode` is set to a node whose kind is * Activates when `useEditor.movingNode` is set to a node whose kind is
* registered with `def.floorplan`. Tracks the pointer on the floor plan * registered with `def.floorplan`. Tracks the pointer on the floor plan
* SVG via the `[data-floorplan-scene]` `<g>` (set by floorplan-panel.tsx * SVG via the `[data-floorplan-scene]` `<g>` (set by floorplan-panel.tsx
* via a one-line attribute) and renders a translucent ghost at the * via a one-line attribute) and **imperatively translates the original
* cursor position. Click commits via `updateNode`; Esc cancels. * rendered entry** so the user sees the actual shape follow the cursor —
* no ghost overlay, no double rendering.
* *
* Coordinate conversion routes through the scene `<g>`'s `getScreenCTM`, * Coordinate conversion routes through the scene `<g>`'s `getScreenCTM`
* matching the legacy `getSvgPointFromClientPoint` so cursor → meters * so cursor → meters accounts for the floor plan's pan / zoom / building
* accounts for the floor plan's pan / zoom / building rotation. * rotation. Position snaps to a 0.5m grid (matches the 3D placement
* tool's GRID_STEP). Pointerup commits via `updateNode`; Esc cancels.
* *
* Lives outside the floorplan-panel.tsx monolith. Mounts once globally * Lives outside the floorplan-panel.tsx monolith. Mounts once globally
* at the panel root; renders nothing unless the active movingNode is a * at the panel root; renders nothing unless the active movingNode is a
* registered kind. * registered kind.
*
* Wired to wall / item / etc. as those kinds migrate — same shape for
* every kind that supplies `def.floorplan`.
*/ */
export function FloorplanRegistryMoveOverlay() { export function FloorplanRegistryMoveOverlay() {
const movingNode = useEditor((s) => s.movingNode) const movingNode = useEditor((s) => s.movingNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null)
const def = movingNode ? nodeRegistry.get(movingNode.type) : null const def = movingNode ? nodeRegistry.get(movingNode.type) : null
const builder = def?.floorplan const isActive = !!movingNode && !!def?.floorplan
const isActive = !!movingNode && !!builder
useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive || !movingNode) return
setCursor(null)
return
}
const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
if (!scene) return if (!scene) return
const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
if (!entry) return
// Capture the original position so the imperative translate is a
// pure delta — the inner FloorplanGeometry transform (the shelf
// builder's `translate(px pz) rotate(deg)`) stays untouched.
const originalPosition = ((
movingNode as unknown as {
position?: [number, number, number]
}
).position ?? [0, 0, 0]) as [number, number, number]
let lastSnapped: [number, number] | null = null
const toMeters = (clientX: number, clientY: number): { x: number; y: number } | null => { const toMeters = (clientX: number, clientY: number): { x: number; y: number } | null => {
const svg = scene.ownerSVGElement const svg = scene.ownerSVGElement
if (!svg) return null if (!svg) return null
@@ -65,30 +72,29 @@ export function FloorplanRegistryMoveOverlay() {
const onMove = (event: PointerEvent) => { const onMove = (event: PointerEvent) => {
const m = toMeters(event.clientX, event.clientY) const m = toMeters(event.clientX, event.clientY)
if (m) setCursor(m) if (!m) return
const [sx, sz] = snapPointToGrid([m.x, m.y], GRID_STEP)
const dx = sx - originalPosition[0]
const dz = sz - originalPosition[2]
entry.setAttribute('transform', `translate(${dx} ${dz})`)
lastSnapped = [sx, sz]
} }
const onClick = (event: MouseEvent) => { const onPointerUp = (event: PointerEvent) => {
const m = toMeters(event.clientX, event.clientY) if (event.button !== 0) return
if (!(m && movingNode)) return // Commit only when the pointerup happened inside the floor plan
// Only commit when click happens inside the floor plan SVG. // SVG (so clicks on the inspector / palette / tabs don't accidentally
const path = event.composedPath() // commit a placement).
if (!path.some((el) => el === scene)) return const target = event.target as Element | null
event.stopPropagation() if (!target || !target.closest('[data-floorplan-scene]')) return
const node = useScene.getState().nodes[movingNode.id as AnyNodeId] const snapped = lastSnapped
// Treat the existing position's Y as preserved (floor plan only if (snapped) {
// moves on the X-Z plane). For new (`isNew` metadata) nodes from const [sx, sz] = snapped
// duplicate, this is still the cloned source's height — correct. const [, oldY] = originalPosition
const oldPos = ((node ?? movingNode) as unknown as { position?: [number, number, number] }) useScene
.position ?? [0, 0, 0] .getState()
useScene.getState().updateNode( .updateNode(movingNode.id as AnyNodeId, { position: [sx, oldY, sz] } as Partial<AnyNode>)
movingNode.id as AnyNodeId,
{
position: [m.x, oldPos[1], m.y],
} as Partial<AnyNode>,
)
// Clear isNew so duplicated nodes don't try to re-place themselves.
const meta = (movingNode as unknown as { metadata?: Record<string, unknown> }).metadata const meta = (movingNode as unknown as { metadata?: Record<string, unknown> }).metadata
if (meta?.isNew) { if (meta?.isNew) {
useScene.getState().updateNode( useScene.getState().updateNode(
@@ -98,55 +104,29 @@ export function FloorplanRegistryMoveOverlay() {
} as Partial<AnyNode>, } as Partial<AnyNode>,
) )
} }
}
entry.removeAttribute('transform')
setMovingNode(null) setMovingNode(null)
} }
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMovingNode(null) if (event.key === 'Escape') {
entry.removeAttribute('transform')
setMovingNode(null)
}
} }
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('click', onClick, { capture: true }) window.addEventListener('pointerup', onPointerUp)
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => { return () => {
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('click', onClick, { capture: true } as EventListenerOptions) window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKey) window.removeEventListener('keydown', onKey)
// Defensive cleanup in case the component unmounts mid-drag.
entry.removeAttribute('transform')
} }
}, [isActive, movingNode, setMovingNode]) }, [isActive, movingNode, setMovingNode])
if (!(isActive && cursor && movingNode && builder)) return null return null
// Build the ghost at the cursor position. Clone the node and swap
// position to the cursor; pass to the kind's builder. Same path the
// FloorplanRegistryLayer uses for the static render — visual identity
// matches automatically.
const nodes = useScene.getState().nodes
const ghostNode = {
...(movingNode as unknown as { position: [number, number, number] }),
position: [cursor.x, 0, cursor.y],
} as unknown as AnyNode
const ctx: GeometryContext = {
resolve: <N = AnyNode>(id: AnyNodeId) => nodes[id] as N | undefined,
children: [],
siblings: [],
parent: null,
}
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
ghostNode,
ctx,
)
if (!geometry) return null
const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null
if (!scene) return null
return createPortal(
<g pointerEvents="none">
<g opacity={0.5}>
<FloorplanGeometryRenderer geometry={geometry} />
</g>
</g>,
scene as unknown as Element,
)
} }
@@ -25,6 +25,7 @@ import {
isCurvedWall, isCurvedWall,
type LevelNode, type LevelNode,
loadAssetUrl, loadAssetUrl,
nodeRegistry,
normalizeWallCurveOffset, normalizeWallCurveOffset,
type Point2D, type Point2D,
type RoofNode, type RoofNode,
@@ -9678,6 +9679,12 @@ export function FloorplanPanel() {
(mode === 'build' && tool === 'item') || movingNode?.type === 'item' (mode === 'build' && tool === 'item') || movingNode?.type === 'item'
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo
// Any registry-driven kind whose tool is currently active. Lets the floor
// plan emit `grid:click` / `grid:move` events to that kind's placement tool
// (shelf today; future Phase 5 kinds the moment they register a `tool`).
// Independent of whether the kind has a `def.floorplan` builder — placement
// works as long as the kind's tool subscribes to the emitter.
const isRegistryToolBuildActive = mode === 'build' && tool != null && nodeRegistry.has(tool)
const isFloorplanGridInteractionActive = const isFloorplanGridInteractionActive =
isFenceBuildActive || isFenceBuildActive ||
isRoofBuildActive || isRoofBuildActive ||
@@ -9695,7 +9702,8 @@ export function FloorplanPanel() {
isFenceCurveActive || isFenceCurveActive ||
isFenceEndpointMoveActive || isFenceEndpointMoveActive ||
isFloorItemBuildActive || isFloorItemBuildActive ||
isFloorItemMoveActive isFloorItemMoveActive ||
isRegistryToolBuildActive
const floorplanPreviewStairSegment = useMemo( const floorplanPreviewStairSegment = useMemo(
() => () =>
StairSegmentNodeSchema.parse({ StairSegmentNodeSchema.parse({