diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 45b3fee5..a6f18920 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -3,15 +3,14 @@ import { type AnyNode, type AnyNodeId, - type FloorplanGeometry, - type GeometryContext, nodeRegistry, + snapPointToGrid, useScene, } from '@pascal-app/core' -import { useEffect, useState } from 'react' -import { createPortal } from 'react-dom' +import { useEffect } from 'react' 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. @@ -19,38 +18,46 @@ import { FloorplanGeometryRenderer } from './renderers/floorplan-geometry-render * Activates when `useEditor.movingNode` is set to a node whose kind is * registered with `def.floorplan`. Tracks the pointer on the floor plan * SVG via the `[data-floorplan-scene]` `` (set by floorplan-panel.tsx - * via a one-line attribute) and renders a translucent ghost at the - * cursor position. Click commits via `updateNode`; Esc cancels. + * via a one-line attribute) and **imperatively translates the original + * rendered entry** so the user sees the actual shape follow the cursor — + * no ghost overlay, no double rendering. * - * Coordinate conversion routes through the scene ``'s `getScreenCTM`, - * matching the legacy `getSvgPointFromClientPoint` so cursor → meters - * accounts for the floor plan's pan / zoom / building rotation. + * Coordinate conversion routes through the scene ``'s `getScreenCTM` + * so cursor → meters accounts for the floor plan's pan / zoom / building + * 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 * at the panel root; renders nothing unless the active movingNode is a * registered kind. - * - * Wired to wall / item / etc. as those kinds migrate — same shape for - * every kind that supplies `def.floorplan`. */ export function FloorplanRegistryMoveOverlay() { const movingNode = useEditor((s) => s.movingNode) 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 builder = def?.floorplan - const isActive = !!movingNode && !!builder + const isActive = !!movingNode && !!def?.floorplan useEffect(() => { - if (!isActive) { - setCursor(null) - return - } + if (!isActive || !movingNode) return const scene = document.querySelector('[data-floorplan-scene]') as SVGGElement | null 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 svg = scene.ownerSVGElement if (!svg) return null @@ -65,88 +72,61 @@ export function FloorplanRegistryMoveOverlay() { const onMove = (event: PointerEvent) => { 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 m = toMeters(event.clientX, event.clientY) - if (!(m && movingNode)) return - // Only commit when click happens inside the floor plan SVG. - const path = event.composedPath() - if (!path.some((el) => el === scene)) return - event.stopPropagation() + const onPointerUp = (event: PointerEvent) => { + if (event.button !== 0) return + // Commit only when the pointerup happened inside the floor plan + // SVG (so clicks on the inspector / palette / tabs don't accidentally + // commit a placement). + const target = event.target as Element | null + if (!target || !target.closest('[data-floorplan-scene]')) return - const node = useScene.getState().nodes[movingNode.id as AnyNodeId] - // Treat the existing position's Y as preserved (floor plan only - // moves on the X-Z plane). For new (`isNew` metadata) nodes from - // duplicate, this is still the cloned source's height — correct. - const oldPos = ((node ?? movingNode) as unknown as { position?: [number, number, number] }) - .position ?? [0, 0, 0] - useScene.getState().updateNode( - movingNode.id as AnyNodeId, - { - position: [m.x, oldPos[1], m.y], - } as Partial, - ) - // Clear isNew so duplicated nodes don't try to re-place themselves. - const meta = (movingNode as unknown as { metadata?: Record }).metadata - if (meta?.isNew) { - useScene.getState().updateNode( - movingNode.id as AnyNodeId, - { - metadata: { ...meta, isNew: false }, - } as Partial, - ) + const snapped = lastSnapped + if (snapped) { + const [sx, sz] = snapped + const [, oldY] = originalPosition + useScene + .getState() + .updateNode(movingNode.id as AnyNodeId, { position: [sx, oldY, sz] } as Partial) + const meta = (movingNode as unknown as { metadata?: Record }).metadata + if (meta?.isNew) { + useScene.getState().updateNode( + movingNode.id as AnyNodeId, + { + metadata: { ...meta, isNew: false }, + } as Partial, + ) + } } + entry.removeAttribute('transform') setMovingNode(null) } 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('click', onClick, { capture: true }) + window.addEventListener('pointerup', onPointerUp) window.addEventListener('keydown', onKey) return () => { window.removeEventListener('pointermove', onMove) - window.removeEventListener('click', onClick, { capture: true } as EventListenerOptions) + window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('keydown', onKey) + // Defensive cleanup in case the component unmounts mid-drag. + entry.removeAttribute('transform') } }, [isActive, movingNode, setMovingNode]) - if (!(isActive && cursor && movingNode && builder)) 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: (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( - - - - - , - scene as unknown as Element, - ) + return null } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 2cb66c2a..a1dc64be 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -25,6 +25,7 @@ import { isCurvedWall, type LevelNode, loadAssetUrl, + nodeRegistry, normalizeWallCurveOffset, type Point2D, type RoofNode, @@ -9678,6 +9679,12 @@ export function FloorplanPanel() { (mode === 'build' && tool === 'item') || movingNode?.type === 'item' const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.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 = isFenceBuildActive || isRoofBuildActive || @@ -9695,7 +9702,8 @@ export function FloorplanPanel() { isFenceCurveActive || isFenceEndpointMoveActive || isFloorItemBuildActive || - isFloorItemMoveActive + isFloorItemMoveActive || + isRegistryToolBuildActive const floorplanPreviewStairSegment = useMemo( () => StairSegmentNodeSchema.parse({