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:
co-authored by
Claude Opus 4.7
parent
773b58ccd4
commit
713ef5009e
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user