diff --git a/packages/editor/src/components/editor-2d/floorplan-group-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-group-action-menu.tsx
new file mode 100644
index 000000000..47274f118
--- /dev/null
+++ b/packages/editor/src/components/editor-2d/floorplan-group-action-menu.tsx
@@ -0,0 +1,87 @@
+'use client'
+
+import { useViewer } from '@pascal-app/viewer'
+import { useEffect, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { isActive } from '../../lib/interaction/scope'
+import useEditor from '../../store/use-editor'
+import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
+import {
+ deleteSelection,
+ duplicateSelectionAndPickUp,
+ startGroupPickUp,
+} from '../editor/group-actions'
+import { NodeActionMenu } from '../editor/node-action-menu'
+
+/**
+ * Floating Move / Duplicate / Delete pill for a MULTI-selection in the 2D
+ * floor plan — the group sibling of `FloorplanRegistryActionMenu` (which is
+ * sole-selection only). Anchored above the dashed group selection box; every
+ * action targets the whole selection: Move picks the group up (it rides the
+ * cursor until a click places it), Duplicate clones the selection and picks
+ * the clones up, Delete removes everything selected.
+ *
+ * Gated on floorplan hover so it never coexists with the 3D group menu in
+ * split view (that one hides while the floor plan is hovered), and hidden
+ * during any active interaction so it never competes with a live drag.
+ */
+export function FloorplanGroupActionMenu() {
+ const isMultiSelect = useViewer((s) => s.selection.selectedIds.length > 1)
+ const movingNode = useMovingNode()
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
+ const scopeActive = useInteractionScope((s) => isActive(s.scope))
+
+ const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
+
+ const isVisible = isMultiSelect && !movingNode && isFloorplanHovered && !scopeActive
+
+ useEffect(() => {
+ if (!isVisible) {
+ setPosition(null)
+ return
+ }
+ let raf = 0
+ const tick = () => {
+ raf = requestAnimationFrame(tick)
+ // The dashed group box exists exactly while the multi-selection has
+ // transformable participants — anchor to its top edge. Only publish
+ // actual changes so the idle poll doesn't re-render every frame.
+ const box = document.querySelector('[data-group-selection-box]') as SVGGElement | null
+ if (!box) {
+ setPosition((prev) => (prev === null ? prev : null))
+ return
+ }
+ const rect = box.getBoundingClientRect()
+ const next = { left: rect.left + rect.width / 2, top: rect.top }
+ setPosition((prev) =>
+ prev && Math.abs(prev.left - next.left) < 0.5 && Math.abs(prev.top - next.top) < 0.5
+ ? prev
+ : next,
+ )
+ }
+ raf = requestAnimationFrame(tick)
+ return () => cancelAnimationFrame(raf)
+ }, [isVisible])
+
+ if (!(isVisible && position)) return null
+
+ return createPortal(
+
+ deleteSelection()}
+ onDuplicate={() => duplicateSelectionAndPickUp()}
+ onMove={() => startGroupPickUp()}
+ onPointerDown={(event) => event.stopPropagation()}
+ onPointerUp={(event) => event.stopPropagation()}
+ />
+
,
+ document.body,
+ )
+}
diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx
new file mode 100644
index 000000000..a987866b6
--- /dev/null
+++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx
@@ -0,0 +1,698 @@
+'use client'
+
+import {
+ type AnyNode,
+ type AnyNodeId,
+ bboxCornerAnchors,
+ collectAlignmentAnchors,
+ DEFAULT_ANGLE_STEP,
+ type FloorplanPalette,
+ pauseSceneHistory,
+ pauseSpaceDetection,
+ resumeSceneHistory,
+ resumeSpaceDetection,
+ useLiveNodeOverrides,
+ useLiveTransforms,
+ useScene,
+} from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { memo, type PointerEvent as ReactPointerEvent, useEffect, useMemo, useState } from 'react'
+import { create } from 'zustand'
+import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
+import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
+import { clientToPlan } from '../../lib/floorplan/plan-coords'
+import { sfxEmitter } from '../../lib/sfx-bus'
+import useAlignmentGuides from '../../store/use-alignment-guides'
+import useEditor, {
+ isAlignmentGuideActive,
+ isGridSnapActive,
+ isMagneticSnapActive,
+} from '../../store/use-editor'
+import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
+import {
+ classifyParticipant,
+ collectParticipants,
+ computeGroupBox,
+ expandToComponent,
+ levelFrame,
+ participantExtents,
+ rotateGroupPatches,
+ rotateGroupSnapshots,
+ translateGroupPatches,
+ type Vec2,
+} from '../editor/group-transform-shared'
+import { swallowNextClick } from '../editor/handles/use-handle-drag'
+import { useMeshSettleEpoch } from '../editor/use-mesh-settle-epoch'
+
+// 2D sibling of the 3D body-drag group move (`group-move-3d.ts`): dragging
+// any selected element of a multi-selection slides the whole selection
+// rigidly (Photoshop semantics). Both share the participant snapshot +
+// translate math, the live preview channel (`useLiveNodeOverrides.setMany` +
+// welded `LinkedNeighbor` endpoints), the snapping entry points (grid step
+// via `isGridSnapActive`, Figma alignment via the shared resolver), and the
+// single-undo commit (history pause → one `updateNodes` → resume).
+
+// Same engage threshold as the layer's Cmd-drag direct move.
+const DRAG_THRESHOLD_PX = 4
+
+// Live plan-frame drag delta / rotation, published so the dashed group bbox
+// overlay rides along without re-rendering the whole registry layer per tick.
+type FloorplanGroupDragState = {
+ delta: Vec2 | null
+ rotation: { pivotX: number; pivotZ: number; angle: number } | null
+ set: (delta: Vec2 | null) => void
+ setRotation: (rotation: FloorplanGroupDragState['rotation']) => void
+}
+export const useFloorplanGroupDrag = create((set) => ({
+ delta: null,
+ rotation: null,
+ set: (delta) => set({ delta }),
+ setRotation: (rotation) => set({ rotation }),
+}))
+
+/**
+ * Try to start a 2D group move for a pointer-down on `nodeId`. Returns false
+ * (attaching nothing) unless the node is a transformable member of a
+ * multi-selection — the caller falls through to its single-node behavior.
+ *
+ * `immediate` engages the session on pointer-down (move-handle dot semantics:
+ * the dot is an explicit move control); otherwise the session arms and only
+ * engages once the pointer travels past the drag threshold, and a plain
+ * click falls through to `onClickFallthrough` (the collapse-to-single
+ * selection click).
+ */
+export function startFloorplanGroupMove(
+ nodeId: AnyNodeId,
+ event: { clientX: number; clientY: number; pointerId: number },
+ opts: { immediate?: boolean; onClickFallthrough?: () => void } = {},
+): boolean {
+ const { selectedIds, levelId } = useViewer.getState().selection
+ if (selectedIds.length < 2 || !selectedIds.includes(nodeId)) return false
+ const sceneNodes = useScene.getState().nodes
+ // The pressed element itself must transform — dragging a selected door /
+ // window (which rides its host wall) keeps today's single-node behavior.
+ if (classifyParticipant(sceneNodes[nodeId], levelId, sceneNodes) === null) return false
+ const startPlan = clientToPlan(event.clientX, event.clientY)
+ if (!startPlan) return false
+
+ const startX = event.clientX
+ const startY = event.clientY
+ const pointerId = event.pointerId
+
+ type Session = {
+ starts: ReturnType['starts']
+ links: ReturnType['links']
+ affectedIds: AnyNodeId[]
+ candidates: ReturnType
+ restAnchors: ReturnType
+ restCenter: Vec2
+ lastDelta: Vec2 | null
+ }
+ let session: Session | null = null
+
+ const engage = (): Session | null => {
+ const nodes = useScene.getState().nodes
+ const participantIds = selectedIds.filter(
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
+ )
+ // Move the full connected wall/fence component, mirroring the 3D gizmo.
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
+ const { starts, links } = collectParticipants(fullIds, nodes, levelId)
+ if (starts.length === 0) return null
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
+
+ // Alignment candidates — anchors of everything OUTSIDE the moving set
+ // (selection + welded neighbours), gathered once at drag start.
+ const movingIdSet = new Set(affectedIds)
+ const staticNodes: Record = {}
+ for (const [nid, n] of Object.entries(nodes)) {
+ if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
+ }
+ const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
+
+ // The group aligns as one rigid footprint: its bbox corners + center are
+ // the moving anchors. `computeGroupBox` is world-space (the 3D scene stays
+ // mounted under every view mode); plan coords are level-frame, so convert.
+ const restBox = computeGroupBox(fullIds)
+ const { inverse: frameInv } = levelFrame(levelId)
+ const boxMin = restBox ? restBox.min.clone().applyMatrix4(frameInv) : null
+ const boxMax = restBox ? restBox.max.clone().applyMatrix4(frameInv) : null
+ const restAnchors =
+ boxMin && boxMax
+ ? bboxCornerAnchors(
+ 'group-move',
+ Math.min(boxMin.x, boxMax.x),
+ Math.min(boxMin.z, boxMax.z),
+ Math.max(boxMin.x, boxMax.x),
+ Math.max(boxMin.z, boxMax.z),
+ )
+ : []
+
+ for (const id of affectedIds) {
+ useLiveTransforms.getState().clear(id)
+ }
+
+ document.body.style.cursor = 'grabbing'
+ sfxEmitter.emit('sfx:item-pick')
+ swallowNextClick()
+ useViewer.getState().setInputDragging(true)
+ pauseSceneHistory(useScene)
+ useInteractionScope.getState().begin({
+ kind: 'handle-drag',
+ nodeId,
+ handle: GROUP_MOVE_DRAG_LABEL,
+ })
+ // Rotation pivot for mid-drag R/T — the participant DATA extents' center
+ // (stable across the drag; rotations re-seed around the same point).
+ const ext = participantExtents(starts)
+ const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0]
+
+ return { starts, links, affectedIds, candidates, restAnchors, restCenter, lastDelta: null }
+ }
+
+ const applyMove = (e: PointerEvent, s: Session) => {
+ const plan = clientToPlan(e.clientX, e.clientY)
+ if (!plan) return
+ // Snap the slide DELTA to the active grid step so the selection's
+ // internal layout stays intact — grid-aligned members stay aligned.
+ // Mode-driven like the 3D group move: the `handle-drag` scope resolves
+ // to the item snap context, so Shift cycles the mode mid-drag.
+ const step = useEditor.getState().gridSnapStep
+ const snap = isGridSnapActive() && step > 0
+ let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0]
+ let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
+
+ // Figma-style alignment layered on top: guides display in every mode
+ // except Off; the magnetic pull applies only in 'lines' mode.
+ if (isAlignmentGuideActive() && s.candidates.length > 0 && s.restAnchors.length > 0) {
+ const proposed: [number, number] = [startPlan[0] + dx, startPlan[1] + dz]
+ const aligned = applyFloorplanAlignment(
+ proposed,
+ s.restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
+ s.candidates,
+ { applySnap: isMagneticSnapActive() },
+ )
+ dx = aligned.point[0] - startPlan[0]
+ dz = aligned.point[1] - startPlan[1]
+ } else {
+ useAlignmentGuides.getState().clear()
+ }
+
+ applyDelta(s, dx, dz)
+ }
+
+ const applyDelta = (s: Session, dx: number, dz: number) => {
+ // Ticker on each delta change — parity with the 3D group move's SFX.
+ if (!s.lastDelta || s.lastDelta[0] !== dx || s.lastDelta[1] !== dz) {
+ sfxEmitter.emit('sfx:grid-snap')
+ s.lastDelta = [dx, dz]
+ }
+
+ const entries = translateGroupPatches(s.starts, s.links, dx, dz)
+ const patchById = new Map(entries)
+ const liveTransforms = useLiveTransforms.getState()
+ for (const start of s.starts) {
+ if (start.kind === 'scalar') {
+ const patch = patchById.get(start.id)
+ if (patch) {
+ liveTransforms.set(start.id, {
+ position: patch.position as [number, number, number],
+ rotation: start.rotation,
+ })
+ }
+ }
+ useScene.getState().markDirty(start.id)
+ }
+ for (const l of s.links) {
+ useScene.getState().markDirty(l.id)
+ }
+ useLiveNodeOverrides.getState().setMany(entries)
+ useFloorplanGroupDrag.getState().set([dx, dz])
+ }
+
+ // Mid-drag R/T: rotate the SNAPSHOTS around the rest pivot and re-apply the
+ // current delta — the carried group turns exactly like the idle keyboard
+ // rotate, and the commit stays a single updateNodes.
+ const rotateSession = (s: Session, direction: 1 | -1) => {
+ const rotated = rotateGroupSnapshots(
+ s.starts,
+ s.links,
+ { x: s.restCenter[0], z: s.restCenter[1] },
+ -direction * (Math.PI / 4),
+ )
+ s.starts = rotated.starts
+ s.links = rotated.links
+ const ext = participantExtents(rotated.starts)
+ if (ext) {
+ s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
+ }
+ sfxEmitter.emit('sfx:item-rotate')
+ applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0)
+ }
+
+ const clearLivePreviews = (s: Session) => {
+ const overrides = useLiveNodeOverrides.getState()
+ const liveTransforms = useLiveTransforms.getState()
+ for (const id of s.affectedIds) {
+ overrides.clear(id)
+ liveTransforms.clear(id)
+ useScene.getState().markDirty(id)
+ }
+ }
+
+ const removeListeners = () => {
+ window.removeEventListener('pointermove', onMove)
+ window.removeEventListener('pointerup', onUp, true)
+ window.removeEventListener('pointercancel', onPointerCancel)
+ window.removeEventListener('keydown', onKeyDown, true)
+ }
+
+ // History resume is NOT here — it pairs exactly one-to-one with the
+ // `pauseSceneHistory` in `engage()`, on the commit and cancel paths.
+ const teardown = () => {
+ removeListeners()
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
+ useAlignmentGuides.getState().clear()
+ // Deferred so a canvas pointerup later in this same dispatch still sees
+ // the drag as active (split view — see onUp).
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
+ useInteractionScope
+ .getState()
+ .endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_MOVE_DRAG_LABEL)
+ useFloorplanGroupDrag.getState().set(null)
+ }
+
+ const onMove = (e: PointerEvent) => {
+ if (e.pointerId !== pointerId) return
+ if (!session) {
+ if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX) return
+ session = engage()
+ if (!session) {
+ removeListeners()
+ return
+ }
+ }
+ applyMove(e, session)
+ }
+
+ // Capture-phase registration: in split view the release can land over the
+ // 3D canvas, whose use-node-events synthesizes a selection click on every
+ // pointerup — suppressed while `inputDragging` is raised, which teardown
+ // therefore lowers on a 0ms timer (after this event's dispatch).
+ const onUp = (e: PointerEvent) => {
+ if (e.pointerId !== pointerId) return
+ if (!session) {
+ removeListeners()
+ opts.onClickFallthrough?.()
+ return
+ }
+ // Eat the click that follows pointer-up so the background click handler
+ // doesn't clear the multi-selection the drag is preserving.
+ swallowNextClick()
+ sfxEmitter.emit('sfx:item-place')
+ const overrides = useLiveNodeOverrides.getState()
+ const updates: { id: AnyNodeId; data: Partial }[] = []
+ for (const id of session.affectedIds) {
+ const patch = overrides.get(id)
+ if (patch) updates.push({ id, data: patch as Partial })
+ }
+ // Resume before the commit so the single batched `updateNodes` is the one
+ // tracked set — collapsing the whole group move into one undo step.
+ // Group transforms move existing structure rigidly — the wall-driven room
+ // auto-detection must not re-create floors/ceilings for the walls' new
+ // positions (that belongs to wall building/editing). Paused around the
+ // commit; the sync rolls its baseline forward for paused changes.
+ pauseSpaceDetection()
+ resumeSceneHistory(useScene)
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
+ resumeSpaceDetection()
+ clearLivePreviews(session)
+ session = null
+ teardown()
+ }
+
+ const cancel = () => {
+ if (session) {
+ clearLivePreviews(session)
+ resumeSceneHistory(useScene)
+ session = null
+ }
+ teardown()
+ }
+
+ const onPointerCancel = (e: PointerEvent) => {
+ if (e.pointerId !== pointerId) return
+ cancel()
+ }
+
+ // Capture phase so the drag's Escape wins over the global `use-keyboard`
+ // Escape arm (registered earlier on window in the bubble phase), which
+ // would otherwise clear the multi-selection mid-cancel.
+ const onKeyDown = (e: KeyboardEvent) => {
+ const key = e.key.toLowerCase()
+ if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
+ if (!session) return
+ e.preventDefault()
+ e.stopPropagation()
+ rotateSession(session, key === 'r' ? 1 : -1)
+ return
+ }
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ // Deleting mid-move: revert the session first, then let the global
+ // Delete arm remove the selection — no dangling carry.
+ cancel()
+ return
+ }
+ if (e.key !== 'Escape') return
+ e.preventDefault()
+ e.stopPropagation()
+ swallowNextClick()
+ cancel()
+ }
+
+ window.addEventListener('pointermove', onMove)
+ window.addEventListener('pointerup', onUp, true)
+ window.addEventListener('pointercancel', onPointerCancel)
+ window.addEventListener('keydown', onKeyDown, true)
+
+ if (opts.immediate) {
+ session = engage()
+ if (!session) {
+ removeListeners()
+ return false
+ }
+ }
+ return true
+}
+
+/**
+ * 2D group rotate, driven from the dashed selection box's corner handles —
+ * the floor-plan sibling of the 3D `GroupRotateHandle`. The group spins
+ * rigidly around its data-extents center; 15° increments by default, Shift
+ * for free rotation, one `updateNodes` on release (one undo step).
+ */
+export function startFloorplanGroupRotate(event: {
+ clientX: number
+ clientY: number
+ pointerId: number
+}): boolean {
+ const { selectedIds, levelId } = useViewer.getState().selection
+ if (selectedIds.length < 2) return false
+ const nodes = useScene.getState().nodes
+ const participantIds = selectedIds.filter(
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
+ )
+ if (participantIds.length === 0) return false
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
+ const { starts, links } = collectParticipants(fullIds, nodes, levelId)
+ if (starts.length === 0) return false
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
+ const ext = participantExtents(starts)
+ if (!ext) return false
+ const pivot = { x: (ext.minX + ext.maxX) / 2, z: (ext.minZ + ext.maxZ) / 2 }
+ const startPlan = clientToPlan(event.clientX, event.clientY)
+ if (!startPlan) return false
+ // Bearing around the pivot in the plan frame — the same atan2 x→z sense
+ // `rotateGroupPatches` orbits in.
+ const angleOf = (p: readonly [number, number]) => Math.atan2(p[1] - pivot.z, p[0] - pivot.x)
+ const initialAngle = angleOf(startPlan)
+ const pointerId = event.pointerId
+
+ for (const id of affectedIds) {
+ useLiveTransforms.getState().clear(id)
+ }
+ document.body.style.cursor = 'grabbing'
+ sfxEmitter.emit('sfx:item-pick')
+ swallowNextClick()
+ useViewer.getState().setInputDragging(true)
+ pauseSceneHistory(useScene)
+ useInteractionScope.getState().begin({
+ kind: 'handle-drag',
+ nodeId: (participantIds[0] ?? '') as AnyNodeId,
+ handle: GROUP_ROTATE_DRAG_LABEL,
+ })
+
+ const onMove = (e: PointerEvent) => {
+ if (e.pointerId !== pointerId) return
+ const plan = clientToPlan(e.clientX, e.clientY)
+ if (!plan) return
+ let delta = angleOf([plan[0], plan[1]]) - initialAngle
+ while (delta > Math.PI) delta -= 2 * Math.PI
+ while (delta < -Math.PI) delta += 2 * Math.PI
+ // 15° increments by default; Shift rotates freely — the same contract
+ // as the 3D group rotate gizmo (and the HUD hint its scope surfaces).
+ if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
+
+ const entries = rotateGroupPatches(starts, links, pivot, delta)
+ const patchById = new Map(entries)
+ const liveTransforms = useLiveTransforms.getState()
+ for (const start of starts) {
+ if (start.kind === 'scalar') {
+ const patch = patchById.get(start.id)
+ if (patch) {
+ liveTransforms.set(start.id, {
+ position: patch.position as [number, number, number],
+ rotation: patch.rotation as number,
+ })
+ }
+ }
+ useScene.getState().markDirty(start.id)
+ }
+ for (const l of links) {
+ useScene.getState().markDirty(l.id)
+ }
+ useLiveNodeOverrides.getState().setMany(entries)
+ // The dashed box spins with the group for live feedback.
+ useFloorplanGroupDrag.getState().setRotation({ pivotX: pivot.x, pivotZ: pivot.z, angle: delta })
+ }
+
+ const clearLivePreviews = () => {
+ const overrides = useLiveNodeOverrides.getState()
+ const liveTransforms = useLiveTransforms.getState()
+ for (const id of affectedIds) {
+ overrides.clear(id)
+ liveTransforms.clear(id)
+ useScene.getState().markDirty(id)
+ }
+ }
+
+ const removeListeners = () => {
+ window.removeEventListener('pointermove', onMove)
+ window.removeEventListener('pointerup', onUp, true)
+ window.removeEventListener('pointercancel', onPointerCancel)
+ window.removeEventListener('keydown', onKeyDown, true)
+ }
+
+ // History resume pairs one-to-one with the pause above, on the commit and
+ // cancel paths only.
+ const teardown = () => {
+ removeListeners()
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
+ // Deferred so a canvas pointerup later in this same dispatch still sees
+ // the drag as active (split view — see onUp).
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
+ useInteractionScope
+ .getState()
+ .endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_ROTATE_DRAG_LABEL)
+ useFloorplanGroupDrag.getState().setRotation(null)
+ }
+
+ // Capture registration + deferred inputDragging — see the drag session.
+ const onUp = (e: PointerEvent) => {
+ if (e.pointerId !== pointerId) return
+ swallowNextClick()
+ sfxEmitter.emit('sfx:item-place')
+ const overrides = useLiveNodeOverrides.getState()
+ const updates: { id: AnyNodeId; data: Partial }[] = []
+ for (const id of affectedIds) {
+ const patch = overrides.get(id)
+ if (patch) updates.push({ id, data: patch as Partial })
+ }
+ // One tracked set = one undo step; rigid rotations must not re-create
+ // the room's auto floors/ceilings (see the move sessions).
+ pauseSpaceDetection()
+ resumeSceneHistory(useScene)
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
+ resumeSpaceDetection()
+ clearLivePreviews()
+ teardown()
+ }
+
+ const cancel = () => {
+ clearLivePreviews()
+ resumeSceneHistory(useScene)
+ teardown()
+ }
+
+ const onPointerCancel = (e: PointerEvent) => {
+ if (e.pointerId !== pointerId) return
+ cancel()
+ }
+
+ // Capture phase so Escape wins over the global `use-keyboard` arm.
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ // Deleting mid-rotate: revert first, then let the global Delete arm run.
+ cancel()
+ return
+ }
+ if (e.key !== 'Escape') return
+ e.preventDefault()
+ e.stopPropagation()
+ swallowNextClick()
+ cancel()
+ }
+
+ window.addEventListener('pointermove', onMove)
+ window.addEventListener('pointerup', onUp, true)
+ window.addEventListener('pointercancel', onPointerCancel)
+ window.addEventListener('keydown', onKeyDown, true)
+ return true
+}
+
+const GROUP_BOX_CURSOR_STYLE = { cursor: 'move' } as const
+// Curved-arrow rotate cursor (no native CSS equivalent) — black glyph with a
+// white halo so it reads on light and dark plans; falls back to `grab`.
+const ROTATE_CURSOR_SVG = encodeURIComponent(
+ '',
+)
+const GROUP_BOX_ROTATE_CURSOR_STYLE = {
+ cursor: `url("data:image/svg+xml,${ROTATE_CURSOR_SVG}") 11 11, grab`,
+} as const
+
+/**
+ * Dashed bounding box around the current multi-selection's transformable
+ * participants (expanded to the welded wall/fence component) — shows what a
+ * group drag will carry along, and IS the group's drag handle: press anywhere
+ * inside it to slide the group, click to pick it up. Holding a selection
+ * modifier (Cmd/Ctrl/Shift) lets pointer events pass through so members under
+ * the box can still be toggled in and out. Rides the live drag delta so it
+ * tracks the group mid-gesture. Mounted inside the floor-plan scene ``, so
+ * plan coords render directly.
+ */
+export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionBox({
+ palette,
+ unitsPerPixel,
+ onPointerDown,
+ onRotatePointerDown,
+}: {
+ palette: FloorplanPalette | undefined
+ unitsPerPixel: number
+ onPointerDown?: (event: ReactPointerEvent) => void
+ onRotatePointerDown?: (event: ReactPointerEvent) => void
+}) {
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
+ const levelId = useViewer((s) => s.selection.levelId)
+ const nodes = useScene((s) => s.nodes)
+ const delta = useFloorplanGroupDrag((s) => s.delta)
+ const liveRotation = useFloorplanGroupDrag((s) => s.rotation)
+ const movingNode = useMovingNode()
+ const mode = useEditor((s) => s.mode)
+
+ // While a selection modifier is held the box steps aside so clicks reach
+ // the entries underneath (toggle membership) instead of starting a drag.
+ const [modifierHeld, setModifierHeld] = useState(false)
+ useEffect(() => {
+ const update = (e: KeyboardEvent) => setModifierHeld(e.metaKey || e.ctrlKey || e.shiftKey)
+ const clear = () => setModifierHeld(false)
+ window.addEventListener('keydown', update)
+ window.addEventListener('keyup', update)
+ window.addEventListener('blur', clear)
+ return () => {
+ window.removeEventListener('keydown', update)
+ window.removeEventListener('keyup', update)
+ window.removeEventListener('blur', clear)
+ }
+ }, [])
+
+ // `meshEpoch` re-runs the measurement once the meshes settle after a scene
+ // change (undo/redo included) — `computeGroupBox` reads mesh world bounds,
+ // which lag the `nodes` commit by a frame or two.
+ const meshEpoch = useMeshSettleEpoch(nodes)
+ const box = useMemo(() => {
+ if (selectedIds.length < 2 || !levelId) return null
+ const participantIds = selectedIds.filter(
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
+ )
+ if (participantIds.length === 0) return null
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
+ const world = computeGroupBox(fullIds)
+ if (!world) return null
+ const { inverse } = levelFrame(levelId)
+ const min = world.min.clone().applyMatrix4(inverse)
+ const max = world.max.clone().applyMatrix4(inverse)
+ return {
+ x: Math.min(min.x, max.x),
+ z: Math.min(min.z, max.z),
+ width: Math.abs(max.x - min.x),
+ depth: Math.abs(max.z - min.z),
+ }
+ // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
+ }, [selectedIds, levelId, nodes, meshEpoch])
+
+ if (!box || movingNode || mode === 'delete') return null
+
+ const pad = 6 * unitsPerPixel
+ const stroke = palette?.selectedStroke ?? '#3b82f6'
+ const interactive = !modifierHeld && !!onPointerDown
+ // Mid-gesture the box rides the live delta (group move) or spins around the
+ // rotation pivot (corner rotate) — SVG rotate() is degrees around a plan
+ // point, and positive matches the atan2 x→z sense on the y-down plan.
+ const transform = liveRotation
+ ? `rotate(${(liveRotation.angle * 180) / Math.PI} ${liveRotation.pivotX} ${liveRotation.pivotZ})`
+ : delta
+ ? `translate(${delta[0]} ${delta[1]})`
+ : undefined
+ return (
+
+
+ {/* Corner rotate handles — the 2D counterpart of the 3D rotate gizmo:
+ drag a corner to spin the group (15° steps, Shift = free). */}
+ {interactive && onRotatePointerDown
+ ? (
+ [
+ [box.x - pad, box.z - pad],
+ [box.x + box.width + pad, box.z - pad],
+ [box.x + box.width + pad, box.z + box.depth + pad],
+ [box.x - pad, box.z + box.depth + pad],
+ ] as Array<[number, number]>
+ ).map(([cx, cz], index) => (
+ {
+ event.stopPropagation()
+ onRotatePointerDown(event)
+ }}
+ style={GROUP_BOX_ROTATE_CURSOR_STYLE}
+ >
+
+
+
+ ))
+ : null}
+
+ )
+})
diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx
index 03e4a14fc..c209c87d8 100644
--- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx
+++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx
@@ -46,7 +46,11 @@ import { NodeActionMenu } from '../editor/node-action-menu'
* Hidden while in a move state (so we don't show buttons over a ghost).
*/
export function FloorplanRegistryActionMenu() {
- const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
+ // Sole selection only — a multi-selection gets the group menu
+ // (`FloorplanGroupActionMenu`), whose actions target the whole selection.
+ const selectedId = useViewer((s) =>
+ s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
+ ) as AnyNodeId | undefined
const movingNode = useMovingNode()
const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
index cb7a949f4..b899ea24a 100644
--- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
@@ -4,7 +4,6 @@ import {
type AnyNode,
type AnyNodeId,
createSceneApi,
- type FloorplanAffordancePoint,
type FloorplanAffordanceSession,
type FloorplanGeometry,
type FloorplanPalette,
@@ -41,6 +40,7 @@ import {
snapDirectRotationDelta,
} from '../../../lib/direct-manipulation'
import { createEditorApi } from '../../../lib/editor-api'
+import { clientToPlan } from '../../../lib/floorplan/plan-coords'
import {
type ActiveInteractionScope,
boundaryReshapeScope,
@@ -58,7 +58,14 @@ import useInteractionScope, {
useEndpointReshape,
useMovingNode,
} from '../../../store/use-interaction-scope'
+import { startGroupPickUp } from '../../editor/group-actions'
+import { classifyParticipant } from '../../editor/group-transform-shared'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
+import {
+ FloorplanGroupSelectionBox,
+ startFloorplanGroupMove,
+ startFloorplanGroupRotate,
+} from '../floorplan-group-move'
import { useFloorplanRender } from '../floorplan-render-context'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
@@ -244,6 +251,8 @@ type FloorplanLevelDataHook = (args: {
type FloorplanRenderPass = 'base' | 'overlay'
const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const
+// Group members advertise the drag-to-move-the-selection gesture.
+const MOVE_CURSOR_STYLE = { cursor: 'move' } as const
const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
function snapshotNode(node: AnyNode): NodeSnapshot {
@@ -354,6 +363,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// Marquee preview selection — matches the legacy `highlightedIdSet` use
// (filter-while-marquee), surfaces selection chrome without keyboard focus.
const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds])
+ // Multi-selection: members show highlight only (per-node edit chrome hidden)
+ // and transformable members advertise the drag-to-move gesture.
+ const isMultiSelect = selectedIds.length > 1
+ const groupParticipantIdSet = useMemo(() => {
+ if (selectedIds.length < 2 || !levelId) return null
+ return new Set(
+ selectedIds.filter(
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
+ ),
+ )
+ }, [selectedIds, levelId, nodes])
// Interactive state lives in refs; only the visible feedback bits go
// into React state to keep re-renders cheap during drag.
@@ -464,7 +484,12 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const node = useScene.getState().nodes[id]
if (!node || !isRegistryMovable(node.type)) return false
- if (!useViewer.getState().selection.selectedIds.includes(id)) return false
+ // Sole selection only: per-node direct manipulation stands down for a
+ // multi-selection (the group session owns plain drags there, and Cmd is
+ // the selection-toggle key — a wobbly Cmd+click must not yank one
+ // member out of the group).
+ const currentSelectedIds = useViewer.getState().selection.selectedIds
+ if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== id) return false
event.preventDefault()
event.stopPropagation()
@@ -534,8 +559,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const node = useScene.getState().nodes[id]
if (!node || !canDirectRotateNode(node)) return false
+ // Sole selection only — same stand-down as the direct move above.
const selectedIds = useViewer.getState().selection.selectedIds
- if (!selectedIds.includes(id)) return false
+ if (selectedIds.length !== 1 || selectedIds[0] !== id) return false
event.preventDefault()
event.stopPropagation()
@@ -625,13 +651,76 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
[],
)
+ // Photoshop-style group drag: plain pointer-down on a transformable member
+ // of a multi-selection slides the whole selection rigidly; a plain click
+ // (no drag) enters the group pick-up instead — parity with the single-item
+ // click-to-move (clicking outside still deselects). Modified clicks
+ // (selection toggle) and Cmd-drag / direct-rotate keep their existing
+ // paths. `immediate` engages without the drag threshold — the move-handle
+ // dot's pick-up semantics.
+ const startGroupMoveDrag = useCallback(
+ (id: AnyNodeId, event: ReactPointerEvent, immediate = false): boolean => {
+ if (event.button !== 0) return false
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
+ if (movingNode) return false
+ if (useEditor.getState().mode === 'delete') return false
+ const started = startFloorplanGroupMove(id, event, {
+ immediate,
+ onClickFallthrough: immediate ? undefined : () => startGroupPickUp(),
+ })
+ if (!started) return false
+ event.preventDefault()
+ event.stopPropagation()
+ suppressBoxSelectForPointer(event)
+ return true
+ },
+ [movingNode],
+ )
+
+ // Move-handle dot variant — routes the dot through the group session when
+ // the owning node is part of a multi-selection.
+ const handleGroupMoveHandlePointerDown = useCallback(
+ (id: AnyNodeId, event: ReactPointerEvent) => startGroupMoveDrag(id, event, true),
+ [startGroupMoveDrag],
+ )
+
+ // The dashed selection box is itself the group's drag handle: a press
+ // anywhere inside it slides the group (or picks it up on a plain click),
+ // anchored on the first transformable member.
+ const handleGroupBoxPointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ const { selectedIds: currentIds, levelId: currentLevelId } = useViewer.getState().selection
+ const sceneNodes = useScene.getState().nodes
+ const anchor = currentIds.find(
+ (id) =>
+ classifyParticipant(sceneNodes[id as AnyNodeId], currentLevelId, sceneNodes) !== null,
+ )
+ if (!anchor) return
+ startGroupMoveDrag(anchor as AnyNodeId, event)
+ },
+ [startGroupMoveDrag],
+ )
+
+ // Corner rotate handles on the dashed selection box — 15° steps, Shift
+ // free, mirroring the 3D group rotate gizmo.
+ const handleGroupBoxRotatePointerDown = useCallback((event: ReactPointerEvent) => {
+ if (event.button !== 0) return
+ if (event.metaKey || event.ctrlKey || event.altKey) return
+ if (useEditor.getState().mode === 'delete') return
+ if (startFloorplanGroupRotate(event)) {
+ event.preventDefault()
+ suppressBoxSelectForPointer(event)
+ }
+ }, [])
+
const handleEntryPointerDown = useCallback(
(id: AnyNodeId, event: ReactPointerEvent) => {
if (startDirectMoveDrag(id, event)) return
if (startDirectRotateDrag(id, event)) return
+ if (startGroupMoveDrag(id, event)) return
handleSelect(id, event)
},
- [handleSelect, startDirectMoveDrag, startDirectRotateDrag],
+ [handleSelect, startDirectMoveDrag, startDirectRotateDrag, startGroupMoveDrag],
)
const floorplanData = useMemo(() => {
@@ -1074,6 +1163,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
nodes={nodes}
onClickStop={handleClickStop}
onEntryPointerDown={handleEntryPointerDown}
+ onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
onHandleHoverChange={setHoveredHandleId}
onHandlePointerDown={startAffordanceDrag}
onHoveredIdChange={setHoveredId}
@@ -1081,6 +1171,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
pass="base"
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
selected={selectedIdSet.has(entry.id)}
+ suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
+ groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
setMovingNode={setMovingNode}
setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
@@ -1120,6 +1212,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
nodes={nodes}
onClickStop={handleClickStop}
onEntryPointerDown={handleEntryPointerDown}
+ onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
onHandleHoverChange={setHoveredHandleId}
onHandlePointerDown={startAffordanceDrag}
onHoveredIdChange={setHoveredId}
@@ -1127,6 +1220,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
pass="overlay"
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
selected={selectedIdSet.has(entry.id)}
+ suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
+ groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
setMovingNode={setMovingNode}
setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
@@ -1136,6 +1231,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
/>
))}
+ {/* Dashed group bbox — shows what a group drag carries along while a
+ multi-selection exists, rides the live delta mid-drag, and doubles
+ as the group's whole-area drag handle. */}
+
{/* Transient live-rotation readout — drawn last so the wedge + degree
chip sit above all handle chrome while a rotate-arrow is dragged. */}
{rotationOverlay && palette ? (
@@ -1169,8 +1273,13 @@ type FloorplanRegistryEntryProps = {
node: AnyNode
nodeId: AnyNodeId
nodes: Record
+ /** Selected member of a multi-selection: hide its per-node edit chrome. */
+ suppressHandles: boolean
+ /** Transformable member of a multi-selection: advertise drag-to-move. */
+ groupMoveCursor: boolean
onClickStop: (event: React.MouseEvent) => void
onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent) => void
+ onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent) => boolean
onHandleHoverChange: (id: string | null) => void
onHandlePointerDown: (
nodeId: AnyNodeId,
@@ -1211,8 +1320,11 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
node,
nodeId,
nodes,
+ suppressHandles,
+ groupMoveCursor,
onClickStop,
onEntryPointerDown,
+ onGroupMovePointerDown,
onHandleHoverChange,
onHandlePointerDown,
onHoveredIdChange,
@@ -1276,13 +1388,16 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
event.preventDefault()
event.stopPropagation()
suppressBoxSelectForPointer(event)
+ // In a multi-selection the move dot drives the group session, matching
+ // the body-drag gesture — the whole selection slides, not one member.
+ if (onGroupMovePointerDown(nodeId, event)) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(currentNode as never)
// Claim 2D ownership of this move at the source. `setMovingNode`
// resets the origin to null, so this must follow it.
setMovingNodeOrigin('2d')
},
- [nodeId, setMovingNode, setMovingNodeOrigin],
+ [nodeId, onGroupMovePointerDown, setMovingNode, setMovingNodeOrigin],
)
const cacheEntry = buildFloorplanEntryGeometry({
@@ -1305,7 +1420,14 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
siblingEpoch,
visibilityRootId,
})
- const geometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
+ const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
+ // Multi-selection shows highlight only: strip this member's edit handles /
+ // dimension chrome (all of which live in the overlay pass) while keeping
+ // its highlighted body geometry.
+ const geometry =
+ rawGeometry && suppressHandles && pass === 'overlay'
+ ? stripHandleChrome(rawGeometry)
+ : rawGeometry
if (!geometry) return null
const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
@@ -1320,7 +1442,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
onPointerDown={entryPointerDown}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
- style={POINTER_CURSOR_STYLE}
+ style={groupMoveCursor ? MOVE_CURSOR_STYLE : POINTER_CURSOR_STYLE}
>
([
+ 'endpoint-handle',
+ 'midpoint-handle',
+ 'edge-handle',
+ 'move-handle',
+ 'move-arrow',
+ 'rotate-arrow',
+ 'dimension',
+ 'dimension-label',
+ 'equal-spacing-badge',
+])
+
+function stripHandleChrome(g: FloorplanGeometry): FloorplanGeometry | null {
+ if (HANDLE_CHROME_KINDS.has(g.kind)) return null
+ if (g.kind === 'group') {
+ const children = g.children
+ .map(stripHandleChrome)
+ .filter((c): c is FloorplanGeometry => c !== null)
+ if (children.length === 0) return null
+ return { kind: 'group', children, transform: g.transform }
+ }
+ return g
+}
+
// Stable string key for a wall endpoint, rounded to 1 mm so floating-point
// drift collapses while distinct corners stay distinct.
function endpointKey(x: number, y: number): string {
@@ -2871,24 +3023,6 @@ function formatGroupTransform(t?: {
return parts.length > 0 ? parts.join(' ') : undefined
}
-function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null {
- // The registry layer lives under the floor-plan scene ``. The
- // legacy panel computes the same conversion via floorplanSceneRef +
- // getScreenCTM; we replicate it by walking up to the SVG owner.
- const target = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
- const svg = target?.ownerSVGElement
- if (!(svg && target)) return null
- const ctm = target.getScreenCTM()
- if (!ctm) return null
- const point = svg.createSVGPoint()
- point.x = clientX
- point.y = clientY
- const transformed = point.matrixTransform(ctm.inverse())
- // The floor-plan `` maps plan X/Z directly to SVG x/y (Z stored as
- // the Y axis on screen — same convention as `toSvgPlanPoint`).
- return [transformed.x, transformed.y]
-}
-
function swallowNextClick(timeoutMs = 0) {
const swallowClick = (event: MouseEvent) => {
event.stopPropagation()
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx
index 7335dac90..7a22a0dc9 100644
--- a/packages/editor/src/components/editor/floorplan-panel.tsx
+++ b/packages/editor/src/components/editor/floorplan-panel.tsx
@@ -112,6 +112,7 @@ import usePlacementPreview from '../../store/use-placement-preview'
import { useStairBuildPreview } from '../../store/use-stair-build-preview'
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
+import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu'
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
@@ -135,6 +136,11 @@ import {
isBoxSelectPointerSuppressed,
markBoxSelectHandled,
} from '../tools/select/box-select-state'
+import {
+ type Point2 as MarqueePoint2,
+ polygonsIntersect as marqueePolygonsIntersect,
+ segmentIntersectsPolygon as marqueeSegmentIntersectsPolygon,
+} from '../tools/select/marquee-geometry'
import {
createScreenRectangleSelectionElement,
hideScreenRectangleSelectionElement,
@@ -905,6 +911,35 @@ function getSelectionModifierKeys(event?: {
}
}
+const isMarqueeVec2 = (v: unknown): v is [number, number] =>
+ Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
+const isMarqueeVec2Array = (v: unknown): v is [number, number][] =>
+ Array.isArray(v) && v.length > 0 && v.every(isMarqueeVec2)
+
+/** The screen marquee mapped into plan coordinates through the scene CTM —
+ * a quad (rotated views give a rotated quad, so tests stay exact). */
+function screenRectToPlanQuad(rect: ScreenRect, scene: SVGGElement): MarqueePoint2[] | null {
+ const svg = scene.ownerSVGElement
+ const ctm = scene.getScreenCTM()
+ if (!(svg && ctm)) return null
+ const inverse = ctm.inverse()
+ const corners: [number, number][] = [
+ [rect.minX, rect.minY],
+ [rect.maxX, rect.minY],
+ [rect.maxX, rect.maxY],
+ [rect.minX, rect.maxY],
+ ]
+ const quad: MarqueePoint2[] = []
+ for (const [x, y] of corners) {
+ const pt = svg.createSVGPoint()
+ pt.x = x
+ pt.y = y
+ const plan = pt.matrixTransform(inverse)
+ quad.push([plan.x, plan.y])
+ }
+ return quad
+}
+
function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement): string[] {
const scene = svg.querySelector('[data-floorplan-scene]')
if (!scene) {
@@ -916,7 +951,32 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
return []
}
- const candidateIdSet = new Set(candidateIds)
+ // Plan-footprint membership for the data kinds — walls/fences by their
+ // segment, slab/ceiling/zone by their polygon — exact under rotated
+ // geometry AND rotated views. The DOM-rect fallback below is an
+ // axis-aligned screen AABB, which inflates around anything diagonal.
+ const planQuad = screenRectToPlanQuad(rect, scene)
+ const sceneNodes = useScene.getState().nodes
+ const dataTested = new Set()
+ const hitIdsFromData = new Set()
+ if (planQuad) {
+ for (const id of candidateIds) {
+ const node = sceneNodes[id as AnyNodeId] as
+ | { start?: unknown; end?: unknown; polygon?: unknown }
+ | undefined
+ if (!node) continue
+ const { start, end, polygon } = node
+ if (isMarqueeVec2(start) && isMarqueeVec2(end)) {
+ dataTested.add(id)
+ if (marqueeSegmentIntersectsPolygon(start, end, planQuad)) hitIdsFromData.add(id)
+ } else if (isMarqueeVec2Array(polygon)) {
+ dataTested.add(id)
+ if (marqueePolygonsIntersect(polygon, planQuad)) hitIdsFromData.add(id)
+ }
+ }
+ }
+
+ const candidateIdSet = new Set(candidateIds.filter((id) => !dataTested.has(id)))
const hitIds = new Set()
const baseElementsById = new Map()
const fallbackElementsById = new Map()
@@ -937,7 +997,7 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
}
}
- for (const id of candidateIds) {
+ for (const id of candidateIdSet) {
const elements = baseElementsById.get(id) ?? fallbackElementsById.get(id) ?? []
for (const element of elements) {
const elementRect = element.getBoundingClientRect()
@@ -952,7 +1012,7 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
}
}
- return candidateIds.filter((id) => hitIds.has(id))
+ return candidateIds.filter((id) => hitIds.has(id) || hitIdsFromData.has(id))
}
function swallowNextFloorplanScreenSelectionClick() {
@@ -3788,10 +3848,12 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer(
})
const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
+ dimmed,
isHighlighted,
palette,
sitePolygon,
}: {
+ dimmed: boolean
isHighlighted: boolean
palette: FloorplanPalette
sitePolygon: SitePolygonEntry | null
@@ -3808,7 +3870,9 @@ const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
const dashPattern = `${dashLength} ${gapLength}`
return (
- <>
+ // The dashed property line reads like the dashed group selection box —
+ // step it back while a multi (or in-flight marquee) selection exists.
+
- >
+
)
})
@@ -10803,9 +10867,11 @@ export function FloorplanPanel({
/>
)}
{/* Floating Move / Duplicate / Delete buttons for registered
- kinds. All kinds are registry-driven now, so this is the
- only action menu the floor plan mounts. */}
+ kinds. All kinds are registry-driven now, so these are the
+ only action menus the floor plan mounts — the single-node
+ pill, plus the group pill for multi-selections. */}
+
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
(compassHost ? (
@@ -11149,6 +11215,7 @@ export function FloorplanPanel({
1 || previewSelectedIds.length > 1}
isHighlighted={isSiteBoundaryHighlighted}
palette={palette}
sitePolygon={visibleSitePolygon}
diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts
new file mode 100644
index 000000000..983ff5d36
--- /dev/null
+++ b/packages/editor/src/components/editor/group-actions.ts
@@ -0,0 +1,457 @@
+import {
+ type AnyNode,
+ type AnyNodeId,
+ bboxCornerAnchors,
+ collectAlignmentAnchors,
+ pauseSceneHistory,
+ pauseSpaceDetection,
+ resolveAlignment,
+ resumeSceneHistory,
+ resumeSpaceDetection,
+ useLiveNodeOverrides,
+ useLiveTransforms,
+ useScene,
+} from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { Plane, Vector2, Vector3 } from 'three'
+import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
+import { clientToPlan } from '../../lib/floorplan/plan-coords'
+import { duplicateNodesToLevel } from '../../lib/scene-clipboard'
+import { sfxEmitter } from '../../lib/sfx-bus'
+import useAlignmentGuides from '../../store/use-alignment-guides'
+import useEditor, {
+ isAlignmentGuideActive,
+ isGridSnapActive,
+ isMagneticSnapActive,
+} from '../../store/use-editor'
+import useInteractionScope from '../../store/use-interaction-scope'
+import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
+import {
+ classifyParticipant,
+ collectParticipants,
+ computeGroupBox,
+ expandToComponent,
+ levelFrame,
+ participantExtents,
+ rotateGroupSnapshots,
+ translateGroupPatches,
+ type Vec2,
+} from './group-transform-shared'
+import { swallowNextClick } from './handles/use-handle-drag'
+import { getEditorThreeContext } from './three-context-bridge'
+
+// Whole-selection actions behind the group action menu (Move / Duplicate /
+// Delete), shared by the 2D and 3D menus. The Move flow is a "pick-up": the
+// selection rides the cursor (relative to where tracking starts, so nothing
+// teleports) across BOTH surfaces — floor plan via the scene CTM, 3D via a
+// ground-plane raycast through the bridged camera — until a click commits it
+// as one undo step. Escape / right-click cancels.
+
+const ALIGNMENT_THRESHOLD_M = 0.08
+// Matches the keyboard Delete arm's accidental-bulk-delete guard.
+const BULK_DELETE_THRESHOLD = 10
+
+// Any non-empty selection can be picked up (the menus themselves gate on a
+// multi-selection; Duplicate can legitimately land on a single cloned root).
+function groupParticipantIds(): string[] {
+ const { selectedIds, levelId } = useViewer.getState().selection
+ if (selectedIds.length === 0) return []
+ const nodes = useScene.getState().nodes
+ return selectedIds.filter(
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
+ )
+}
+
+/** True when the current selection is a group the pick-up move can carry. */
+export function canGroupPickUp(): boolean {
+ return groupParticipantIds().length > 0
+}
+
+/**
+ * Pick up the current multi-selection: it follows the cursor (delta-relative)
+ * until a click commits, mirroring the single-node `movingNode` flow. Returns
+ * false when the selection holds no transformable participants.
+ *
+ * `scopeToSelection` limits the moving set to the selected participants —
+ * no connected-component expansion and no welded-neighbor endpoints. The
+ * Duplicate flow needs this: its clones sit EXACTLY on the originals, so
+ * junction coincidence would otherwise weld the originals into the pick-up
+ * and drag them along with the copies.
+ */
+export function startGroupPickUp(
+ opts: { onCancel?: () => void; scopeToSelection?: boolean } = {},
+): boolean {
+ const { selectedIds, levelId } = useViewer.getState().selection
+ const participantIds = groupParticipantIds()
+ if (participantIds.length === 0) return false
+ const nodes = useScene.getState().nodes
+ const fullIds = opts.scopeToSelection
+ ? participantIds
+ : expandToComponent(participantIds, nodes, levelId)
+ const collected = collectParticipants(fullIds, nodes, levelId)
+ // Mutable: mid-carry R/T rotates these snapshots in place.
+ let starts = collected.starts
+ let links = opts.scopeToSelection ? [] : collected.links
+ if (starts.length === 0) return false
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
+
+ // Rest bounds in the level frame. Prefer the mounted meshes' world box
+ // (footprint-accurate), but fall back to the participant DATA when the
+ // meshes aren't up yet — Duplicate starts the pick-up synchronously after
+ // `createNodes`, one frame before the clones' renderers mount.
+ const { inverse: frameInv } = levelFrame(levelId)
+ const restBox = computeGroupBox(fullIds)
+ let minX = Number.POSITIVE_INFINITY
+ let minZ = Number.POSITIVE_INFINITY
+ let maxX = Number.NEGATIVE_INFINITY
+ let maxZ = Number.NEGATIVE_INFINITY
+ if (restBox) {
+ const boxMin = restBox.min.clone().applyMatrix4(frameInv)
+ const boxMax = restBox.max.clone().applyMatrix4(frameInv)
+ minX = Math.min(boxMin.x, boxMax.x)
+ minZ = Math.min(boxMin.z, boxMax.z)
+ maxX = Math.max(boxMin.x, boxMax.x)
+ maxZ = Math.max(boxMin.z, boxMax.z)
+ } else {
+ const reach = (x: number, z: number) => {
+ minX = Math.min(minX, x)
+ minZ = Math.min(minZ, z)
+ maxX = Math.max(maxX, x)
+ maxZ = Math.max(maxZ, z)
+ }
+ for (const s of starts) {
+ if (s.kind === 'endpoint') {
+ reach(s.start[0], s.start[1])
+ reach(s.end[0], s.end[1])
+ } else if (s.kind === 'polygon') {
+ for (const [x, z] of s.polygon) {
+ reach(x, z)
+ }
+ } else {
+ reach(s.position[0], s.position[2])
+ }
+ }
+ }
+ if (!Number.isFinite(minX)) return false
+ // Rotation pivot for mid-carry R/T; stable across the whole pick-up.
+ const restCenter: [number, number] = [(minX + maxX) / 2, (minZ + maxZ) / 2]
+ // Ground plane for the 3D surface: the meshes' base when available, floor
+ // level otherwise. Placements live in the level frame, so both surfaces
+ // resolve into it before measuring.
+ const plane = new Plane(new Vector3(0, 1, 0), -(restBox?.min.y ?? 0))
+
+ // Alignment candidates — everything outside the moving set; the group
+ // aligns as one rigid footprint via its bbox corners + center.
+ const movingIdSet = new Set(affectedIds)
+ const staticNodes: Record = {}
+ for (const [nid, n] of Object.entries(nodes)) {
+ if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
+ }
+ const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
+ let restAnchors = bboxCornerAnchors('group-move', minX, minZ, maxX, maxZ)
+
+ // Cursor → level-frame plan point, whichever surface the pointer is over.
+ const ndc = new Vector2()
+ const resolvePlanPoint = (e: PointerEvent): Vec2 | null => {
+ const three = getEditorThreeContext()
+ if (three && e.target === three.domElement) {
+ const rect = three.domElement.getBoundingClientRect()
+ ndc.set(
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
+ -((e.clientY - rect.top) / rect.height) * 2 + 1,
+ )
+ three.raycaster.setFromCamera(ndc, three.camera)
+ const hit = new Vector3()
+ if (!three.raycaster.ray.intersectPlane(plane, hit)) return null
+ const local = hit.applyMatrix4(frameInv)
+ return [local.x, local.z]
+ }
+ // Anywhere inside the floor-plan viewport counts (the scene `` only
+ // covers painted elements, so bounds-test the owning SVG).
+ const sceneEl = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
+ const svg = sceneEl?.ownerSVGElement
+ if (!svg) return null
+ const rect = svg.getBoundingClientRect()
+ if (
+ e.clientX < rect.left ||
+ e.clientX > rect.right ||
+ e.clientY < rect.top ||
+ e.clientY > rect.bottom
+ ) {
+ return null
+ }
+ const plan = clientToPlan(e.clientX, e.clientY)
+ return plan ? [plan[0], plan[1]] : null
+ }
+
+ let startPlan: Vec2 | null = null
+ let lastDelta: Vec2 | null = null
+
+ for (const id of affectedIds) {
+ useLiveTransforms.getState().clear(id)
+ }
+ sfxEmitter.emit('sfx:item-pick')
+ pauseSceneHistory(useScene)
+ useInteractionScope.getState().begin({
+ kind: 'handle-drag',
+ nodeId: (participantIds[0] ?? '') as AnyNodeId,
+ handle: GROUP_MOVE_DRAG_LABEL,
+ })
+ document.body.style.cursor = 'grabbing'
+
+ const applyMove = (e: PointerEvent) => {
+ const plan = resolvePlanPoint(e)
+ if (!plan) return
+ // Delta-relative to where tracking starts so the group never teleports
+ // to the cursor.
+ if (!startPlan) {
+ startPlan = plan
+ return
+ }
+ const step = useEditor.getState().gridSnapStep
+ const snap = isGridSnapActive() && step > 0
+ let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0]
+ let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
+
+ if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) {
+ const result = resolveAlignment({
+ moving: restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
+ candidates,
+ threshold: ALIGNMENT_THRESHOLD_M,
+ })
+ if (result.snap && isMagneticSnapActive()) {
+ dx += result.snap.dx
+ dz += result.snap.dz
+ }
+ useAlignmentGuides.getState().set(result.guides)
+ } else {
+ useAlignmentGuides.getState().clear()
+ }
+
+ applyDelta(dx, dz)
+ }
+
+ const applyDelta = (dx: number, dz: number) => {
+ if (!lastDelta || lastDelta[0] !== dx || lastDelta[1] !== dz) {
+ sfxEmitter.emit('sfx:grid-snap')
+ lastDelta = [dx, dz]
+ }
+
+ const entries = translateGroupPatches(starts, links, dx, dz)
+ const patchById = new Map(entries)
+ const liveTransforms = useLiveTransforms.getState()
+ for (const start of starts) {
+ if (start.kind === 'scalar') {
+ const patch = patchById.get(start.id)
+ if (patch) {
+ liveTransforms.set(start.id, {
+ position: patch.position as [number, number, number],
+ rotation: start.rotation,
+ })
+ }
+ }
+ useScene.getState().markDirty(start.id)
+ }
+ for (const l of links) {
+ useScene.getState().markDirty(l.id)
+ }
+ useLiveNodeOverrides.getState().setMany(entries)
+ useFloorplanGroupDrag.getState().set([dx, dz])
+ }
+
+ // Mid-carry R/T: rotate the SNAPSHOTS around the rest pivot and re-apply
+ // the current delta — the carried group turns exactly like the idle
+ // keyboard rotate, and the placement stays a single updateNodes.
+ const rotateCarried = (direction: 1 | -1) => {
+ const rotated = rotateGroupSnapshots(
+ starts,
+ links,
+ { x: restCenter[0], z: restCenter[1] },
+ -direction * (Math.PI / 4),
+ )
+ starts = rotated.starts
+ links = rotated.links
+ const ext = participantExtents(rotated.starts)
+ if (ext) {
+ restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
+ }
+ sfxEmitter.emit('sfx:item-rotate')
+ applyDelta(lastDelta?.[0] ?? 0, lastDelta?.[1] ?? 0)
+ }
+
+ const clearLivePreviews = () => {
+ const overrides = useLiveNodeOverrides.getState()
+ const liveTransforms = useLiveTransforms.getState()
+ for (const id of affectedIds) {
+ overrides.clear(id)
+ liveTransforms.clear(id)
+ useScene.getState().markDirty(id)
+ }
+ }
+
+ const removeListeners = () => {
+ window.removeEventListener('pointermove', onMove)
+ window.removeEventListener('pointerdown', onPointerDown, true)
+ window.removeEventListener('pointerup', onPointerUp, true)
+ window.removeEventListener('keydown', onKeyDown, true)
+ window.removeEventListener('contextmenu', onContextMenu, true)
+ }
+
+ // History resume pairs one-to-one with the pause above, on the commit and
+ // cancel paths only.
+ const teardown = () => {
+ removeListeners()
+ if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
+ useAlignmentGuides.getState().clear()
+ useInteractionScope
+ .getState()
+ .endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_MOVE_DRAG_LABEL)
+ useFloorplanGroupDrag.getState().set(null)
+ }
+
+ const commit = () => {
+ swallowNextClick()
+ sfxEmitter.emit('sfx:item-place')
+ const overrides = useLiveNodeOverrides.getState()
+ const updates: { id: AnyNodeId; data: Partial }[] = []
+ for (const id of affectedIds) {
+ const patch = overrides.get(id)
+ if (patch) updates.push({ id, data: patch as Partial })
+ }
+ // Group transforms move existing structure rigidly — the wall-driven room
+ // auto-detection must not re-create floors/ceilings for the walls' new
+ // positions (that belongs to wall building/editing). Paused around the
+ // commit; the sync rolls its baseline forward for paused changes.
+ pauseSpaceDetection()
+ resumeSceneHistory(useScene)
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
+ resumeSpaceDetection()
+ clearLivePreviews()
+ teardown()
+ }
+
+ const cancel = () => {
+ clearLivePreviews()
+ resumeSceneHistory(useScene)
+ teardown()
+ opts.onCancel?.()
+ }
+
+ const onMove = (e: PointerEvent) => {
+ applyMove(e)
+ }
+
+ // Capture phase: commit before the press reaches selection / tools, so the
+ // drop click can't also select whatever lands under the cursor.
+ // The placement gesture: the press over a tracked surface is claimed in
+ // capture phase and the commit runs on ITS pointerup, also claimed — the
+ // canvas never sees either, so `use-node-events` cannot synthesize a
+ // selection click from the release (which would re-select whatever sits
+ // under the cursor and break the multi-selection).
+ let commitPointerId: number | null = null
+
+ const onPointerDown = (e: PointerEvent) => {
+ if (e.button === 2) {
+ e.preventDefault()
+ e.stopPropagation()
+ cancel()
+ return
+ }
+ if (e.button !== 0) return
+ // Only a press over a tracked surface commits; a click on side panels or
+ // the toolbar keeps the pick-up alive.
+ if (!resolvePlanPoint(e)) return
+ e.preventDefault()
+ e.stopPropagation()
+ commitPointerId = e.pointerId
+ }
+
+ const onPointerUp = (e: PointerEvent) => {
+ if (commitPointerId === null || e.pointerId !== commitPointerId) return
+ commitPointerId = null
+ // Raise `inputDragging` through this event's dispatch so the canvas's
+ // use-node-events suppresses the selection click it synthesizes on every
+ // pointerup (stopPropagation would also break the window bubble
+ // listeners that clear the box-select pointer suppression).
+ useViewer.getState().setInputDragging(true)
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
+ commit()
+ }
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ const key = e.key.toLowerCase()
+ if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
+ e.preventDefault()
+ e.stopPropagation()
+ rotateCarried(key === 'r' ? 1 : -1)
+ return
+ }
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ // Deleting mid-carry: put the group down first (revert), then let the
+ // global Delete arm remove the selection — no dangling carry. The
+ // duplicate flow's onCancel already discards the clones instead.
+ cancel()
+ return
+ }
+ if (e.key !== 'Escape') return
+ e.preventDefault()
+ e.stopPropagation()
+ cancel()
+ }
+
+ const onContextMenu = (e: Event) => {
+ e.preventDefault()
+ e.stopPropagation()
+ }
+
+ window.addEventListener('pointermove', onMove)
+ window.addEventListener('pointerdown', onPointerDown, true)
+ window.addEventListener('pointerup', onPointerUp, true)
+ window.addEventListener('keydown', onKeyDown, true)
+ window.addEventListener('contextmenu', onContextMenu, true)
+ return true
+}
+
+/**
+ * Duplicate the whole selection (subtrees + id remap via the clipboard
+ * pipeline, without touching the clipboard), select the clones, and pick
+ * them up so the next click places them. Cancelling the pick-up removes the
+ * clones again.
+ */
+export function duplicateSelectionAndPickUp(): boolean {
+ const { selectedIds, levelId } = useViewer.getState().selection
+ if (selectedIds.length < 2) return false
+ const result = duplicateNodesToLevel(
+ selectedIds as AnyNodeId[],
+ (levelId ?? undefined) as AnyNodeId | undefined,
+ )
+ if (!result || result.pastedIds.length === 0) return false
+ sfxEmitter.emit('sfx:item-pick')
+ startGroupPickUp({
+ scopeToSelection: true,
+ onCancel: () => {
+ useScene.getState().deleteNodes(result.pastedIds)
+ useViewer.getState().setSelection({ selectedIds: [] })
+ },
+ })
+ return true
+}
+
+/**
+ * Delete every selected node — same semantics as the keyboard Delete arm,
+ * including the accidental-bulk-delete confirm.
+ */
+export function deleteSelection(): boolean {
+ const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
+ if (selectedIds.length === 0) return false
+ if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
+ const confirmed = window.confirm(
+ `Delete ${selectedIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
+ )
+ if (!confirmed) return false
+ }
+ sfxEmitter.emit('sfx:structure-delete')
+ useScene.getState().deleteNodes(selectedIds)
+ useViewer.getState().setSelection({ selectedIds: [] })
+ return true
+}
diff --git a/packages/editor/src/components/editor/group-floating-action-menu.tsx b/packages/editor/src/components/editor/group-floating-action-menu.tsx
new file mode 100644
index 000000000..f9e2ad6e2
--- /dev/null
+++ b/packages/editor/src/components/editor/group-floating-action-menu.tsx
@@ -0,0 +1,130 @@
+'use client'
+
+import { type AnyNodeId, useScene } from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { Html } from '@react-three/drei'
+import { useFrame } from '@react-three/fiber'
+import { useCallback, useMemo, useRef } from 'react'
+import * as THREE from 'three'
+import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
+import useEditor from '../../store/use-editor'
+import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
+import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from './group-actions'
+import { classifyParticipant, computeGroupBox, expandToComponent } from './group-transform-shared'
+import { NodeActionMenu } from './node-action-menu'
+import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
+
+// Matches the single-node FloatingActionMenu's zoom-compensation constants.
+const REF_ORTHO_ZOOM = 50
+const REF_CAMERA_DISTANCE = 12
+const MIN_MENU_SCALE = 0.6
+const MAX_MENU_SCALE = 1.4
+// Clearance above the group's bbox top so the pill doesn't sit on the meshes.
+const MENU_Y_OFFSET = 0.42
+
+/**
+ * Floating Move / Duplicate / Delete pill for a MULTI-selection in the 3D
+ * view — the group sibling of the single-node `FloatingActionMenu` (which is
+ * sole-selection only). Anchored above the selection's bounding-box center;
+ * every action targets the whole selection: Move picks the group up (it rides
+ * the cursor until a click places it), Duplicate clones the selection and
+ * picks the clones up, Delete removes everything selected.
+ */
+export function GroupFloatingActionMenu() {
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
+ const levelId = useViewer((s) => s.selection.levelId)
+ const mode = useEditor((s) => s.mode)
+ const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
+ const movingNode = useMovingNode()
+ const nodes = useScene((s) => s.nodes)
+ // Hard-hidden during any active interaction (drag, pick-up, reshape) so the
+ // pill never competes with the live action — same policy as the 1-node menu.
+ const scope = useInteractionScope((s) => s.scope)
+ const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
+
+ const groupRef = useRef(null)
+ const menuScaleRef = useRef(null)
+
+ const participantIds = useMemo(
+ () =>
+ selectedIds.length > 1
+ ? selectedIds.filter(
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
+ )
+ : [],
+ [selectedIds, levelId, nodes],
+ )
+
+ // World anchor above the group bbox. Depends on the scene (post-commit
+ // positions), not the camera, and the menu hides during drags — so a
+ // memo keyed on selection + nodes is enough, no per-frame box traversal.
+ const meshEpoch = useMeshSettleEpoch(nodes)
+ const anchor = useMemo(() => {
+ if (participantIds.length === 0) return null
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
+ const box = computeGroupBox(fullIds)
+ if (!box) return null
+ return new THREE.Vector3(
+ (box.min.x + box.max.x) / 2,
+ box.max.y + MENU_Y_OFFSET,
+ (box.min.z + box.max.z) / 2,
+ )
+ // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
+ }, [participantIds, nodes, levelId, meshEpoch])
+
+ useFrame((state) => {
+ // Scale the HTML pill with camera zoom / distance so it feels anchored to
+ // the world — mirrors the single-node menu.
+ if (!(menuScaleRef.current && groupRef.current)) return
+ const raw =
+ state.camera instanceof THREE.OrthographicCamera
+ ? state.camera.zoom / REF_ORTHO_ZOOM
+ : REF_CAMERA_DISTANCE /
+ Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001)
+ const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
+ menuScaleRef.current.style.transform = `scale(${scale})`
+ })
+
+ const stopPointer = useCallback((event: React.PointerEvent) => {
+ event.stopPropagation()
+ }, [])
+ const handleMove = useCallback((event: React.MouseEvent) => {
+ event.stopPropagation()
+ startGroupPickUp()
+ }, [])
+ const handleDuplicate = useCallback((event: React.MouseEvent) => {
+ event.stopPropagation()
+ duplicateSelectionAndPickUp()
+ }, [])
+ const handleDelete = useCallback((event: React.MouseEvent) => {
+ event.stopPropagation()
+ deleteSelection()
+ }, [])
+
+ if (!anchor || mode === 'delete' || isFloorplanHovered || movingNode || menuStepBack) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/editor/src/components/editor/group-move-3d.ts b/packages/editor/src/components/editor/group-move-3d.ts
new file mode 100644
index 000000000..2efcc1b74
--- /dev/null
+++ b/packages/editor/src/components/editor/group-move-3d.ts
@@ -0,0 +1,395 @@
+import {
+ type AnyNode,
+ type AnyNodeId,
+ bboxCornerAnchors,
+ collectAlignmentAnchors,
+ pauseSceneHistory,
+ pauseSpaceDetection,
+ resolveAlignment,
+ resumeSceneHistory,
+ resumeSpaceDetection,
+ useLiveNodeOverrides,
+ useLiveTransforms,
+ useScene,
+} from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { type Camera, Plane, type Raycaster, Vector2, Vector3 } from 'three'
+import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
+import { sfxEmitter } from '../../lib/sfx-bus'
+import useAlignmentGuides from '../../store/use-alignment-guides'
+import useEditor, {
+ isAlignmentGuideActive,
+ isGridSnapActive,
+ isMagneticSnapActive,
+} from '../../store/use-editor'
+import useInteractionScope from '../../store/use-interaction-scope'
+import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
+import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
+import { startGroupPickUp } from './group-actions'
+import {
+ classifyParticipant,
+ collectParticipants,
+ computeGroupBox,
+ expandToComponent,
+ levelFrame,
+ participantExtents,
+ rotateGroupSnapshots,
+ translateGroupPatches,
+ type Vec2,
+} from './group-transform-shared'
+import { swallowNextClick } from './handles/use-handle-drag'
+
+// 3D sibling of the 2D floorplan group move (and successor of the removed
+// group-move gizmo cross): pressing any selected element's body in a
+// multi-selection and dragging past the threshold slides the whole selection
+// on the ground plane; a plain click (no drag) enters the group pick-up,
+// parity with the single-item click-to-move. Shares the group participant
+// snapshot, welded junctions, snapping entry points, live override previews,
+// and single-undo commit with the 2D session.
+
+// Figma-style alignment-snap threshold (meters) — same pull distance as the
+// single-node registry move.
+const ALIGNMENT_THRESHOLD_M = 0.08
+const DRAG_THRESHOLD_PX = 4
+
+/**
+ * Arm a 3D group move from a pointer-down on `nodeId`. Returns false
+ * (attaching nothing) unless the node is a transformable member of a
+ * multi-selection.
+ */
+export function armGroupMove3d(args: {
+ nodeId: AnyNodeId
+ clientX: number
+ clientY: number
+ pointerId: number
+ nativeEvent: PointerEvent
+ camera: Camera
+ raycaster: Raycaster
+ domElement: HTMLCanvasElement
+}): boolean {
+ const { nodeId, clientX, clientY, pointerId, camera, raycaster, domElement } = args
+ const { selectedIds, levelId } = useViewer.getState().selection
+ if (selectedIds.length < 2 || !selectedIds.includes(nodeId)) return false
+ const sceneNodes = useScene.getState().nodes
+ if (classifyParticipant(sceneNodes[nodeId], levelId, sceneNodes) === null) return false
+
+ suppressBoxSelectForPointer(args.nativeEvent)
+
+ const ndc = new Vector2()
+ const setNDC = (x: number, y: number) => {
+ const rect = domElement.getBoundingClientRect()
+ ndc.set(((x - rect.left) / rect.width) * 2 - 1, -((y - rect.top) / rect.height) * 2 + 1)
+ }
+
+ type Session = {
+ starts: ReturnType['starts']
+ links: ReturnType['links']
+ affectedIds: AnyNodeId[]
+ candidates: ReturnType
+ restAnchors: ReturnType
+ restCenter: Vec2
+ plane: Plane
+ startLocal: Vector3
+ frameInv: ReturnType['inverse']
+ lastDelta: Vec2 | null
+ }
+ let session: Session | null = null
+
+ const engage = (): Session | null => {
+ const nodes = useScene.getState().nodes
+ const participantIds = selectedIds.filter(
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
+ )
+ // Move the full connected wall/fence component, mirroring the 2D session.
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
+ const { starts, links } = collectParticipants(fullIds, nodes, levelId)
+ if (starts.length === 0) return null
+ const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
+
+ // Horizontal drag plane at the group's base; placements live in the level
+ // frame, so world-space plane hits convert through it (a rotated building
+ // would otherwise drift off-axis from the cursor).
+ const restBox = computeGroupBox(fullIds)
+ if (!restBox) return null
+ const plane = new Plane(new Vector3(0, 1, 0), -restBox.min.y)
+ const { inverse: frameInv } = levelFrame(levelId)
+
+ setNDC(clientX, clientY)
+ raycaster.setFromCamera(ndc, camera)
+ const hit = new Vector3()
+ if (!raycaster.ray.intersectPlane(plane, hit)) return null
+ const startLocal = hit.clone().applyMatrix4(frameInv)
+
+ // Alignment candidates — anchors of everything OUTSIDE the moving set,
+ // gathered once at drag start. The group aligns as one rigid footprint:
+ // its bbox corners + center are the moving anchors.
+ const movingIdSet = new Set(affectedIds)
+ const staticNodes: Record = {}
+ for (const [nid, n] of Object.entries(nodes)) {
+ if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
+ }
+ const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
+ const boxMin = restBox.min.clone().applyMatrix4(frameInv)
+ const boxMax = restBox.max.clone().applyMatrix4(frameInv)
+ const restAnchors = bboxCornerAnchors(
+ 'group-move',
+ Math.min(boxMin.x, boxMax.x),
+ Math.min(boxMin.z, boxMax.z),
+ Math.max(boxMin.x, boxMax.x),
+ Math.max(boxMin.z, boxMax.z),
+ )
+
+ for (const id of affectedIds) {
+ useLiveTransforms.getState().clear(id)
+ }
+
+ domElement.style.cursor = 'grabbing'
+ sfxEmitter.emit('sfx:item-pick')
+ swallowNextClick()
+ useViewer.getState().setInputDragging(true)
+ pauseSceneHistory(useScene)
+ useInteractionScope.getState().begin({
+ kind: 'handle-drag',
+ nodeId,
+ handle: GROUP_MOVE_DRAG_LABEL,
+ })
+ // Rotation pivot for mid-drag R/T — the participant DATA extents' center.
+ const ext = participantExtents(starts)
+ const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0]
+
+ return {
+ starts,
+ links,
+ affectedIds,
+ candidates,
+ restAnchors,
+ restCenter,
+ plane,
+ startLocal,
+ frameInv,
+ lastDelta: null,
+ }
+ }
+
+ const applyMove = (e: PointerEvent, s: Session) => {
+ setNDC(e.clientX, e.clientY)
+ raycaster.setFromCamera(ndc, camera)
+ const moveHit = new Vector3()
+ if (!raycaster.ray.intersectPlane(s.plane, moveHit)) return
+ const moveLocal = moveHit.applyMatrix4(s.frameInv)
+
+ // Snap the slide DELTA to the active grid step so the selection's internal
+ // layout stays intact. Mode-driven: the `handle-drag` scope resolves to
+ // the item snap context, so Shift cycles the mode mid-drag.
+ const step = useEditor.getState().gridSnapStep
+ const snap = isGridSnapActive() && step > 0
+ let dx = snap
+ ? Math.round((moveLocal.x - s.startLocal.x) / step) * step
+ : moveLocal.x - s.startLocal.x
+ let dz = snap
+ ? Math.round((moveLocal.z - s.startLocal.z) / step) * step
+ : moveLocal.z - s.startLocal.z
+
+ // Figma-style alignment layered on top: guides display in every mode
+ // except Off; the magnetic pull applies only in 'lines' mode.
+ if (isAlignmentGuideActive() && s.candidates.length > 0 && s.restAnchors.length > 0) {
+ const result = resolveAlignment({
+ moving: s.restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
+ candidates: s.candidates,
+ threshold: ALIGNMENT_THRESHOLD_M,
+ })
+ if (result.snap && isMagneticSnapActive()) {
+ dx += result.snap.dx
+ dz += result.snap.dz
+ }
+ useAlignmentGuides.getState().set(result.guides)
+ } else {
+ useAlignmentGuides.getState().clear()
+ }
+
+ applyDelta(s, dx, dz)
+ }
+
+ const applyDelta = (s: Session, dx: number, dz: number) => {
+ // Ticker on each delta change — parity with the single-node move SFX.
+ if (!s.lastDelta || s.lastDelta[0] !== dx || s.lastDelta[1] !== dz) {
+ sfxEmitter.emit('sfx:grid-snap')
+ s.lastDelta = [dx, dz]
+ }
+
+ const entries = translateGroupPatches(s.starts, s.links, dx, dz)
+ const patchById = new Map(entries)
+ const liveTransforms = useLiveTransforms.getState()
+ for (const start of s.starts) {
+ if (start.kind === 'scalar') {
+ const patch = patchById.get(start.id)
+ if (patch) {
+ liveTransforms.set(start.id, {
+ position: patch.position as [number, number, number],
+ rotation: start.rotation,
+ })
+ }
+ }
+ useScene.getState().markDirty(start.id)
+ }
+ for (const l of s.links) {
+ useScene.getState().markDirty(l.id)
+ }
+ useLiveNodeOverrides.getState().setMany(entries)
+ // The 2D dashed group bbox rides the same delta in split view.
+ useFloorplanGroupDrag.getState().set([dx, dz])
+ }
+
+ // Mid-drag R/T: rotate the SNAPSHOTS around the rest pivot and re-apply the
+ // current delta — the carried group turns exactly like the idle keyboard
+ // rotate, and the commit stays a single updateNodes.
+ const rotateSession = (s: Session, direction: 1 | -1) => {
+ const rotated = rotateGroupSnapshots(
+ s.starts,
+ s.links,
+ { x: s.restCenter[0], z: s.restCenter[1] },
+ -direction * (Math.PI / 4),
+ )
+ s.starts = rotated.starts
+ s.links = rotated.links
+ const ext = participantExtents(rotated.starts)
+ if (ext) {
+ s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
+ }
+ sfxEmitter.emit('sfx:item-rotate')
+ applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0)
+ }
+
+ const clearLivePreviews = (s: Session) => {
+ const overrides = useLiveNodeOverrides.getState()
+ const liveTransforms = useLiveTransforms.getState()
+ for (const id of s.affectedIds) {
+ overrides.clear(id)
+ liveTransforms.clear(id)
+ useScene.getState().markDirty(id)
+ }
+ }
+
+ const removeListeners = () => {
+ window.removeEventListener('pointermove', onMove)
+ window.removeEventListener('pointerup', onUp, true)
+ window.removeEventListener('pointercancel', onPointerCancel)
+ window.removeEventListener('keydown', onKeyDown, true)
+ }
+
+ // History resume is NOT here — it pairs one-to-one with the
+ // `pauseSceneHistory` in `engage()`, on the commit and cancel paths.
+ const teardown = () => {
+ removeListeners()
+ if (domElement.style.cursor === 'grabbing') domElement.style.cursor = ''
+ useAlignmentGuides.getState().clear()
+ // Deferred so the canvas pointerup later in this same dispatch still sees
+ // the drag as active (see onUp).
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
+ useInteractionScope
+ .getState()
+ .endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_MOVE_DRAG_LABEL)
+ useFloorplanGroupDrag.getState().set(null)
+ }
+
+ const onMove = (e: PointerEvent) => {
+ if (e.pointerId !== pointerId) return
+ if (!session) {
+ if (Math.hypot(e.clientX - clientX, e.clientY - clientY) < DRAG_THRESHOLD_PX) return
+ session = engage()
+ if (!session) {
+ removeListeners()
+ return
+ }
+ }
+ applyMove(e, session)
+ }
+
+ // Registered in CAPTURE phase so this runs before the canvas handlers:
+ // `use-node-events` synthesizes a selection click on EVERY pointerup,
+ // suppressed only while `inputDragging` is set — so the gesture must keep
+ // it raised through this event's dispatch (released on a 0ms timer) or the
+ // release re-routes selection to whatever sits under the cursor. Plain
+ // stopPropagation would ALSO kill the window bubble listeners that clear
+ // the box-select pointer suppression, leaving the marquee dead.
+ const onUp = (e: PointerEvent) => {
+ if (e.pointerId !== pointerId) return
+ if (!session) {
+ // Plain click — enter the group pick-up, parity with the single-item
+ // click-to-move. Eat the click so the selection manager's click
+ // handling doesn't collapse the multi-selection underneath it.
+ removeListeners()
+ swallowNextClick()
+ useViewer.getState().setInputDragging(true)
+ setTimeout(() => useViewer.getState().setInputDragging(false), 0)
+ startGroupPickUp()
+ return
+ }
+ swallowNextClick()
+ sfxEmitter.emit('sfx:item-place')
+ const overrides = useLiveNodeOverrides.getState()
+ const updates: { id: AnyNodeId; data: Partial }[] = []
+ for (const id of session.affectedIds) {
+ const patch = overrides.get(id)
+ if (patch) updates.push({ id, data: patch as Partial })
+ }
+ // Resume before the commit so the single batched `updateNodes` is the one
+ // tracked set — collapsing the whole group move into one undo step.
+ // Group transforms move existing structure rigidly — the wall-driven room
+ // auto-detection must not re-create floors/ceilings for the walls' new
+ // positions (that belongs to wall building/editing). Paused around the
+ // commit; the sync rolls its baseline forward for paused changes.
+ pauseSpaceDetection()
+ resumeSceneHistory(useScene)
+ if (updates.length > 0) useScene.getState().updateNodes(updates)
+ resumeSpaceDetection()
+ clearLivePreviews(session)
+ session = null
+ teardown()
+ }
+
+ const cancel = () => {
+ if (session) {
+ clearLivePreviews(session)
+ resumeSceneHistory(useScene)
+ session = null
+ }
+ teardown()
+ }
+
+ const onPointerCancel = (e: PointerEvent) => {
+ if (e.pointerId !== pointerId) return
+ cancel()
+ }
+
+ // Capture phase so the drag's Escape / R / T win over the global
+ // `use-keyboard` arms, which would otherwise act on stale store state
+ // (or clear the multi-selection) mid-session.
+ const onKeyDown = (e: KeyboardEvent) => {
+ const key = e.key.toLowerCase()
+ if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
+ if (!session) return
+ e.preventDefault()
+ e.stopPropagation()
+ rotateSession(session, key === 'r' ? 1 : -1)
+ return
+ }
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ // Deleting mid-move: revert the session first, then let the global
+ // Delete arm remove the selection — no dangling carry.
+ cancel()
+ return
+ }
+ if (e.key !== 'Escape') return
+ e.preventDefault()
+ e.stopPropagation()
+ swallowNextClick()
+ cancel()
+ }
+
+ window.addEventListener('pointermove', onMove)
+ window.addEventListener('pointerup', onUp, true)
+ window.addEventListener('pointercancel', onPointerCancel)
+ window.addEventListener('keydown', onKeyDown, true)
+ return true
+}
diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx
deleted file mode 100644
index 5cff385bd..000000000
--- a/packages/editor/src/components/editor/group-move-handle.tsx
+++ /dev/null
@@ -1,401 +0,0 @@
-'use client'
-
-import {
- type AnyNode,
- type AnyNodeId,
- bboxCornerAnchors,
- collectAlignmentAnchors,
- resolveAlignment,
- useLiveNodeOverrides,
- useLiveTransforms,
- useScene,
-} from '@pascal-app/core'
-import { useViewer } from '@pascal-app/viewer'
-import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
-import { useEffect, useMemo, useRef, useState } from 'react'
-import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
-import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
-import { sfxEmitter } from '../../lib/sfx-bus'
-import useAlignmentGuides from '../../store/use-alignment-guides'
-import useEditor, {
- isAlignmentGuideActive,
- isGridSnapActive,
- isMagneticSnapActive,
-} from '../../store/use-editor'
-import useInteractionScope, {
- useActiveHandleDrag,
- useMovingNode,
-} from '../../store/use-interaction-scope'
-import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
-import {
- CORNER_OFFSET,
- classifyParticipant,
- collectParticipants,
- computeGroupBox,
- expandToComponent,
- levelFrame,
- type Vec2,
-} from './group-transform-shared'
-import {
- ARROW_COLOR,
- ARROW_HOVER_COLOR,
- ARROW_SCALE,
- createMoveCrossHandleGeometry,
- swallowNextClick,
- useArrowMaterial,
-} from './node-arrow-handles'
-
-// Figma-style alignment-snap threshold (meters) — same pull distance as the
-// single-node registry move.
-const ALIGNMENT_THRESHOLD_M = 0.08
-
-/**
- * Group-move gizmo — the 4-way cross sibling of `GroupRotateHandle`. When 2+
- * transformable nodes are selected, a single move cross appears at the
- * selection's front-left bounding-box corner (the rotate gizmo sits on the
- * right). Dragging it slides every selected node by the same ground-plane
- * delta; connected (unselected) wall/fence endpoints follow so junctions stay
- * welded. Commits the whole slide in one batched `updateNodes` (one undo).
- */
-export function GroupMoveHandle() {
- const selectedIds = useViewer((s) => s.selection.selectedIds)
- const levelId = useViewer((s) => s.selection.levelId)
- const mode = useEditor((s) => s.mode)
- const movingNode = useMovingNode()
- const activeHandleDrag = useActiveHandleDrag()
- const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
- const nodes = useScene((s) => s.nodes)
-
- const participantIds = useMemo(
- () =>
- selectedIds.filter(
- (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
- ),
- [selectedIds, levelId, nodes],
- )
-
- // Gate on the explicit selection, but move the full connected wall/fence
- // component so attached structure slides rigidly as one piece.
- const fullIds = useMemo(
- () => expandToComponent(participantIds, nodes, levelId),
- [participantIds, levelId, nodes],
- )
-
- const shouldRender =
- participantIds.length >= 2 &&
- mode !== 'delete' &&
- !movingNode &&
- !isFloorplanHovered &&
- // Hide while the sibling rotate gizmo drags the group — the frozen corner
- // this handle would sit at goes stale as the group spins under it.
- activeHandleDrag?.label !== GROUP_ROTATE_DRAG_LABEL
-
- if (!shouldRender) return null
- return
-}
-
-function GroupMoveHandleInner({ ids }: { ids: string[] }) {
- const { camera, raycaster, gl, scene } = useThree()
- const arrowGeometry = useMemo(() => createMoveCrossHandleGeometry(), [])
- const arrowMaterial = useArrowMaterial()
- const [isHovered, setIsHovered] = useState(false)
- const [isDragging, setIsDragging] = useState(false)
- // Live ground-plane delta so the gizmo rides along with the group it moves.
- const [liveDelta, setLiveDelta] = useState([0, 0])
- const dragCleanupRef = useRef<(() => void) | null>(null)
- const frozenCorner = useRef(null)
-
- useEffect(() => {
- arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
- }, [arrowMaterial, isHovered])
- useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
- useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
- useEffect(() => () => dragCleanupRef.current?.(), [])
-
- const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
- const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
-
- // Front-left bbox corner at mid-height (mirrors the rotate gizmo on the
- // right), plus the group's base Y for the ground drag plane.
- const rest = useMemo(() => {
- const box = computeGroupBox(ids)
- if (!box) return null
- const corner = new Vector3(
- box.min.x - CORNER_OFFSET,
- (box.min.y + box.max.y) / 2,
- box.max.z + CORNER_OFFSET,
- )
- return { corner, baseY: box.min.y }
- }, [ids])
-
- if (!rest) return null
- const baseCorner = isDragging && frozenCorner.current ? frozenCorner.current : rest.corner
- const corner: [number, number, number] = [
- baseCorner.x + liveDelta[0],
- baseCorner.y,
- baseCorner.z + liveDelta[1],
- ]
-
- const activate = (event: ThreeEvent) => {
- event.stopPropagation()
- suppressBoxSelectForPointer(event)
- frozenCorner.current = rest.corner.clone()
- const planeY = rest.baseY
-
- // Snapshot selected participants + connected wall/fence neighbours.
- const levelId = useViewer.getState().selection.levelId
- const { starts, links } = collectParticipants(ids, useScene.getState().nodes, levelId)
- if (starts.length === 0) return
-
- // Placements are stored in the level frame, so convert each world-space
- // ground-plane hit into that frame before measuring the delta. Frozen at
- // drag-start; `frameOrigin` lets us map the local delta back to world for
- // the gizmo's own travel (it's portalled to the scene root).
- const { matrix: frame, inverse: frameInv } = levelFrame(levelId)
- const frameOrigin = new Vector3().applyMatrix4(frame)
-
- // Horizontal drag plane at the group's base.
- const plane = new Plane(new Vector3(0, 1, 0), -planeY)
- const ndc = new Vector2()
- const setNDC = (clientX: number, clientY: number) => {
- const rect = gl.domElement.getBoundingClientRect()
- ndc.set(
- ((clientX - rect.left) / rect.width) * 2 - 1,
- -((clientY - rect.top) / rect.height) * 2 + 1,
- )
- }
-
- setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
- raycaster.setFromCamera(ndc, camera)
- const hit = new Vector3()
- if (!raycaster.ray.intersectPlane(plane, hit)) return
- const startLocal = hit.clone().applyMatrix4(frameInv)
-
- document.body.style.cursor = 'grabbing'
- sfxEmitter.emit('sfx:item-pick')
- useViewer.getState().setInputDragging(true)
- useScene.temporal.getState().pause()
- useInteractionScope.getState().begin({
- kind: 'handle-drag',
- nodeId: ids[0] ?? '',
- handle: GROUP_MOVE_DRAG_LABEL,
- })
- setIsDragging(true)
-
- // Alignment candidates — anchors of everything OUTSIDE the moving set
- // (selection + welded neighbours), level-scoped, in the same level frame
- // the deltas are computed in. Gathered once at drag start like the
- // single-node move; the scene graph is stable during the drag.
- const movingIds = new Set([...starts.map((s) => s.id), ...links.map((l) => l.id)])
- const staticNodes: Record = {}
- for (const [nid, n] of Object.entries(useScene.getState().nodes)) {
- if (n && !movingIds.has(nid)) staticNodes[nid] = n
- }
- const alignmentCandidates = collectAlignmentAnchors(staticNodes, '', levelId)
-
- // The group aligns as one rigid footprint: its bbox corners + center are
- // the moving anchors, seeded from the rest box and shifted by the live
- // delta each move.
- const restBox = computeGroupBox(ids)
- const boxMin = restBox ? restBox.min.clone().applyMatrix4(frameInv) : null
- const boxMax = restBox ? restBox.max.clone().applyMatrix4(frameInv) : null
- const restAnchors =
- boxMin && boxMax
- ? bboxCornerAnchors(
- 'group-move',
- Math.min(boxMin.x, boxMax.x),
- Math.min(boxMin.z, boxMax.z),
- Math.max(boxMin.x, boxMax.x),
- Math.max(boxMin.z, boxMax.z),
- )
- : []
-
- let lastSnap: Vec2 | null = null
-
- const onMove = (e: PointerEvent) => {
- setNDC(e.clientX, e.clientY)
- raycaster.setFromCamera(ndc, camera)
- const moveHit = new Vector3()
- if (!raycaster.ray.intersectPlane(plane, moveHit)) return
- const moveLocal = moveHit.applyMatrix4(frameInv)
- // Snap the slide to the active grid step so the group lands on the grid.
- // Mode-driven like single-item moves: the drag's `handle-drag` scope
- // resolves to the 'item' snap context, so Shift cycles the snapping mode
- // and Ctrl the grid step — both read live per move so a mid-drag cycle
- // applies immediately. Snapping the delta keeps the selection's internal
- // layout intact — grid-aligned items stay aligned.
- const step = useEditor.getState().gridSnapStep
- const snap = isGridSnapActive() && step > 0
- let dx = snap
- ? Math.round((moveLocal.x - startLocal.x) / step) * step
- : moveLocal.x - startLocal.x
- let dz = snap
- ? Math.round((moveLocal.z - startLocal.z) / step) * step
- : moveLocal.z - startLocal.z
-
- // Figma-style alignment layered on top, mirroring the single-node move:
- // guides are DISPLAYED in every mode except Off; the magnetic pull
- // toward them applies only in 'lines' mode.
- if (isAlignmentGuideActive() && alignmentCandidates.length > 0 && restAnchors.length > 0) {
- const result = resolveAlignment({
- moving: restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
- candidates: alignmentCandidates,
- threshold: ALIGNMENT_THRESHOLD_M,
- })
- if (result.snap && isMagneticSnapActive()) {
- dx += result.snap.dx
- dz += result.snap.dz
- }
- useAlignmentGuides.getState().set(result.guides)
- } else {
- useAlignmentGuides.getState().clear()
- }
-
- // Ticker on each delta change — mirrors the single-node move, which
- // emits per position change in EVERY mode (grid crossings are discrete;
- // the lines/off stream is rate-limited into a texture by the player's
- // minIntervalMs), so the group drag sounds the same as a single one.
- if (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz) {
- sfxEmitter.emit('sfx:grid-snap')
- lastSnap = [dx, dz]
- }
-
- const overrideEntries: Array]> = []
- const liveTransforms = useLiveTransforms.getState()
- for (const s of starts) {
- if (s.kind === 'endpoint') {
- overrideEntries.push([
- s.id,
- {
- start: [s.start[0] + dx, s.start[1] + dz],
- end: [s.end[0] + dx, s.end[1] + dz],
- },
- ])
- } else {
- // Slide on the floor: XZ shift, Y and rotation untouched.
- const position: [number, number, number] = [
- s.position[0] + dx,
- s.position[1],
- s.position[2] + dz,
- ]
- overrideEntries.push([s.id, { position }])
- if (s.kind === 'scalar') {
- liveTransforms.set(s.id, { position, rotation: s.rotation })
- }
- }
- useScene.getState().markDirty(s.id)
- }
-
- // Shared endpoints of connected neighbours follow by the same delta so
- // the junction stays welded; the far end stays put.
- for (const l of links) {
- overrideEntries.push([
- l.id,
- {
- start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start,
- end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end,
- },
- ])
- useScene.getState().markDirty(l.id)
- }
- useLiveNodeOverrides.getState().setMany(overrideEntries)
-
- // Gizmo rides the group in world space; map the level-frame delta back out.
- const worldDelta = new Vector3(dx, 0, dz).applyMatrix4(frame).sub(frameOrigin)
- setLiveDelta([worldDelta.x, worldDelta.z])
- }
-
- const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
- const clearLivePreviews = () => {
- const overrides = useLiveNodeOverrides.getState()
- const liveTransforms = useLiveTransforms.getState()
- for (const id of affectedIds) {
- overrides.clear(id)
- liveTransforms.clear(id)
- useScene.getState().markDirty(id)
- }
- }
-
- const cleanup = () => {
- window.removeEventListener('pointermove', onMove)
- window.removeEventListener('pointerup', onUp)
- window.removeEventListener('pointercancel', onCancel)
- if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
- useAlignmentGuides.getState().clear()
- useScene.temporal.getState().resume()
- useViewer.getState().setInputDragging(false)
- useInteractionScope
- .getState()
- .endIf((s) => s.kind === 'handle-drag' && s.handle === GROUP_MOVE_DRAG_LABEL)
- setIsDragging(false)
- setLiveDelta([0, 0])
- frozenCorner.current = null
- dragCleanupRef.current = null
- }
-
- const commitFromOverrides = () => {
- const overrides = useLiveNodeOverrides.getState()
- const updates: { id: AnyNodeId; data: Partial }[] = []
- for (const id of affectedIds) {
- const patch = overrides.get(id)
- if (patch) updates.push({ id, data: patch as Partial })
- }
- return updates
- }
-
- const onUp = () => {
- // Eat the click that follows pointer-up so the selection manager doesn't
- // treat it as a canvas click and clear the multi-selection.
- swallowNextClick()
- sfxEmitter.emit('sfx:item-place')
- const updates = commitFromOverrides()
- // Resume before the commit so the single batched `updateNodes` is the one
- // tracked set — collapsing the whole group move into one undo.
- useScene.temporal.getState().resume()
- if (updates.length > 0) useScene.getState().updateNodes(updates)
- clearLivePreviews()
- cleanup()
- }
-
- const onCancel = () => {
- clearLivePreviews()
- cleanup()
- }
-
- dragCleanupRef.current = () => {
- clearLivePreviews()
- cleanup()
- }
- for (const id of affectedIds) {
- useLiveTransforms.getState().clear(id)
- }
- window.addEventListener('pointermove', onMove)
- window.addEventListener('pointerup', onUp)
- window.addEventListener('pointercancel', onCancel)
- }
-
- return createPortal(
-
- {
- event.stopPropagation()
- setIsHovered(true)
- if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'move'
- }}
- onPointerLeave={(event) => {
- event.stopPropagation()
- setIsHovered(false)
- if (document.body.style.cursor === 'move') document.body.style.cursor = ''
- }}
- renderOrder={1010}
- />
- ,
- scene,
- )
-}
-
-export default GroupMoveHandle
diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx
index e0831fc9e..5ef130f78 100644
--- a/packages/editor/src/components/editor/group-rotate-handle.tsx
+++ b/packages/editor/src/components/editor/group-rotate-handle.tsx
@@ -4,6 +4,8 @@ import {
type AnyNode,
type AnyNodeId,
DEFAULT_ANGLE_STEP,
+ pauseSpaceDetection,
+ resumeSpaceDetection,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
@@ -27,7 +29,7 @@ import {
computeGroupBox,
expandToComponent,
levelFrame,
- type Vec2,
+ rotateGroupPatches,
type Vec3,
} from './group-transform-shared'
import {
@@ -43,6 +45,7 @@ import {
useArrowMaterial,
useInvisibleHitAreaMaterial,
} from './node-arrow-handles'
+import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
/**
* Group-rotate gizmo. When 2+ transformable nodes in the active level frame are
@@ -64,6 +67,8 @@ export function GroupRotateHandle() {
// Re-derive participants whenever the scene mutates (e.g. after a commit).
// Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
const nodes = useScene((s) => s.nodes)
+ // Re-measure the pivot/corner once the meshes settle after a scene change.
+ const meshEpoch = useMeshSettleEpoch(nodes)
const participantIds = useMemo(
() =>
@@ -92,10 +97,10 @@ export function GroupRotateHandle() {
if (!shouldRender) return null
// Remount when the moving set changes so the rest pivot re-seeds cleanly.
- return
+ return
}
-function GroupRotateHandleInner({ ids }: { ids: string[] }) {
+function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: number }) {
const { camera, raycaster, gl, scene } = useThree()
const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), [])
@@ -135,7 +140,8 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
box.max.z + CORNER_OFFSET,
)
return { pivot, corner }
- }, [ids])
+ // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
+ }, [ids, meshEpoch])
if (!rest) return null
const active = isDragging && frozenRest.current ? frozenRest.current : rest
@@ -186,6 +192,10 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
if (s.kind === 'endpoint') {
reach(s.start[0], s.start[1])
reach(s.end[0], s.end[1])
+ } else if (s.kind === 'polygon') {
+ for (const [x, z] of s.polygon) {
+ reach(x, z)
+ }
} else {
reach(s.position[0], s.position[2])
}
@@ -228,53 +238,29 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
while (delta < -Math.PI) delta += 2 * Math.PI
if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
- // Orbit each node's anchor point(s) CCW by `delta` (atan2 x→z sense) and
- // turn its yaw by `-delta` to match three.js Y-rotation handedness (same
- // convention as the single-item rotate handle in item/definition.ts).
- // Endpoint nodes (walls/fences) have no yaw — swinging both endpoints
- // around the pivot rotates them rigidly; their curveOffset sagitta is
- // rotation-invariant, so arcs are preserved.
- const cos = Math.cos(delta)
- const sin = Math.sin(delta)
- const rot = (x: number, z: number): Vec2 => {
- const dx = x - localCenter.x
- const dz = z - localCenter.z
- return [localCenter.x + dx * cos - dz * sin, localCenter.z + dx * sin + dz * cos]
- }
- const overrideEntries: Array]> = []
+ // Shared rigid-rotation math (also used by the keyboard group R/T);
+ // see `rotateGroupPatches` for the orbit/yaw handedness contract.
+ const overrideEntries = rotateGroupPatches(
+ starts,
+ links,
+ { x: localCenter.x, z: localCenter.z },
+ delta,
+ )
+ const patchById = new Map(overrideEntries)
const liveTransforms = useLiveTransforms.getState()
for (const s of starts) {
- if (s.kind === 'endpoint') {
- overrideEntries.push([
- s.id,
- { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) },
- ])
- } else {
- const [px, pz] = rot(s.position[0], s.position[2])
- const position: Vec3 = [px, s.position[1], pz]
- const rotation =
- s.kind === 'vec3'
- ? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3)
- : s.rotation - delta
- overrideEntries.push([s.id, { position, rotation }])
- if (s.kind === 'scalar') {
- liveTransforms.set(s.id, { position, rotation: s.rotation - delta })
+ if (s.kind === 'scalar') {
+ const patch = patchById.get(s.id)
+ if (patch) {
+ liveTransforms.set(s.id, {
+ position: patch.position as Vec3,
+ rotation: patch.rotation as number,
+ })
}
}
useScene.getState().markDirty(s.id)
}
-
- // Drag each linked neighbour's shared endpoint to the same rotated spot
- // (rot is deterministic, so it lands exactly on the selected wall's
- // rotated endpoint), keeping the junction welded; the far end stays put.
for (const l of links) {
- overrideEntries.push([
- l.id,
- {
- start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
- end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
- },
- ])
useScene.getState().markDirty(l.id)
}
useLiveNodeOverrides.getState().setMany(overrideEntries)
@@ -344,8 +330,12 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
const updates = commitFromOverrides()
// Resume before the commit so the single batched `updateNodes` is the
// one tracked set — collapsing the whole group rotation into one undo.
+ // Space detection stays out: a rigid rotation of existing walls must
+ // not re-create the room's auto floors/ceilings at the new bearing.
+ pauseSpaceDetection()
useScene.temporal.getState().resume()
if (updates.length > 0) useScene.getState().updateNodes(updates)
+ resumeSpaceDetection()
clearLivePreviews()
cleanup()
}
diff --git a/packages/editor/src/components/editor/group-selection-box-3d.tsx b/packages/editor/src/components/editor/group-selection-box-3d.tsx
new file mode 100644
index 000000000..6fbc73774
--- /dev/null
+++ b/packages/editor/src/components/editor/group-selection-box-3d.tsx
@@ -0,0 +1,173 @@
+'use client'
+
+import { type AnyNodeId, useScene } from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { type ThreeEvent, useThree } from '@react-three/fiber'
+import { useEffect, useMemo } from 'react'
+import * as THREE from 'three'
+import useEditor from '../../store/use-editor'
+import { useMovingNode } from '../../store/use-interaction-scope'
+import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
+import { armGroupMove3d } from './group-move-3d'
+import {
+ classifyParticipant,
+ computeGroupBox,
+ expandToComponent,
+ levelFrame,
+} from './group-transform-shared'
+import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
+
+// Matches the 2D dashed selection box's stroke.
+const BOX_COLOR = '#3b82f6'
+// Small clearance so the dashes don't z-fight the selection's outer faces.
+const BOX_PAD = 0.06
+
+/**
+ * 3D sibling of the 2D dashed group selection box: a dashed wireframe around
+ * the multi-selection's transformable participants (expanded to the welded
+ * wall/fence component) that doubles as the group's whole-volume drag
+ * handle — move cursor across it, press-drag anywhere on it slides the group,
+ * a plain click picks it up. Holding a selection modifier passes the press
+ * through so members inside can still be toggled. Rides the live drag delta
+ * so it tracks the group mid-gesture.
+ */
+export function GroupSelectionBox3D() {
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
+ const levelId = useViewer((s) => s.selection.levelId)
+ const nodes = useScene((s) => s.nodes)
+ const mode = useEditor((s) => s.mode)
+ const movingNode = useMovingNode()
+ const delta = useFloorplanGroupDrag((s) => s.delta)
+ const { camera, raycaster, gl } = useThree()
+
+ const participantIds = useMemo(
+ () =>
+ selectedIds.length > 1
+ ? selectedIds.filter(
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
+ )
+ : [],
+ [selectedIds, levelId, nodes],
+ )
+
+ // Re-measure once the meshes settle after a scene change (undo included).
+ const meshEpoch = useMeshSettleEpoch(nodes)
+ const box = useMemo(() => {
+ if (participantIds.length === 0) return null
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
+ const world = computeGroupBox(fullIds)
+ if (!world) return null
+ const size = new THREE.Vector3()
+ world.getSize(size)
+ const center = new THREE.Vector3()
+ world.getCenter(center)
+ return {
+ size: [size.x + 2 * BOX_PAD, size.y + 2 * BOX_PAD, size.z + 2 * BOX_PAD] as [
+ number,
+ number,
+ number,
+ ],
+ center,
+ }
+ // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
+ }, [participantIds, nodes, levelId, meshEpoch])
+
+ // Dashed wireframe. Built per box size (rare — selection / commit changes)
+ // because LineDashedMaterial measures dashes along the line, so scaling a
+ // unit box would stretch them per axis.
+ const edges = useMemo(() => {
+ if (!box) return null
+ const geometry = new THREE.EdgesGeometry(new THREE.BoxGeometry(...box.size))
+ const line = new THREE.LineSegments(
+ geometry,
+ new THREE.LineDashedMaterial({
+ color: BOX_COLOR,
+ dashSize: 0.18,
+ gapSize: 0.12,
+ opacity: 0.9,
+ transparent: true,
+ }),
+ )
+ line.computeLineDistances()
+ return line
+ }, [box])
+ useEffect(
+ () => () => {
+ if (edges) {
+ edges.geometry.dispose()
+ ;(edges.material as THREE.Material).dispose()
+ }
+ },
+ [edges],
+ )
+
+ // Mid-drag the sessions publish a level-frame delta; map it to world so the
+ // box rides the group (shared with the 2D box's store).
+ const worldOffset = useMemo(() => {
+ if (!delta) return null
+ const { matrix } = levelFrame(levelId)
+ const origin = new THREE.Vector3().applyMatrix4(matrix)
+ return new THREE.Vector3(delta[0], 0, delta[1]).applyMatrix4(matrix).sub(origin)
+ }, [delta, levelId])
+
+ if (!box || !edges || movingNode || mode !== 'select') return null
+
+ const handlePointerDown = (event: ThreeEvent) => {
+ const native = event.nativeEvent
+ if (native.button !== 0) return
+ // A selection modifier passes through so members inside the box can
+ // still be toggled in and out of the selection.
+ if (native.metaKey || native.ctrlKey || native.shiftKey || native.altKey) return
+ const { selectedIds: currentIds, levelId: currentLevelId } = useViewer.getState().selection
+ const sceneNodes = useScene.getState().nodes
+ const anchor = currentIds.find(
+ (id) => classifyParticipant(sceneNodes[id as AnyNodeId], currentLevelId, sceneNodes) !== null,
+ )
+ if (!anchor) return
+ const armed = armGroupMove3d({
+ nodeId: anchor as AnyNodeId,
+ clientX: native.clientX,
+ clientY: native.clientY,
+ pointerId: native.pointerId,
+ nativeEvent: native,
+ camera,
+ raycaster,
+ domElement: gl.domElement,
+ })
+ // Own the press: deeper hits (members, ground) must not also select.
+ if (armed) event.stopPropagation()
+ }
+
+ // Set on move (not just enter): the canvas may carry an app default
+ // cursor, and member hovers inside the box write theirs — keep 'move'
+ // asserted across the whole volume, except while a drag shows 'grabbing'.
+ const applyMoveCursor = () => {
+ const cursor = gl.domElement.style.cursor
+ if (cursor !== 'move' && cursor !== 'grabbing') gl.domElement.style.cursor = 'move'
+ }
+ const handlePointerLeave = () => {
+ if (gl.domElement.style.cursor === 'move') gl.domElement.style.cursor = ''
+ }
+
+ const position: [number, number, number] = [
+ box.center.x + (worldOffset?.x ?? 0),
+ box.center.y + (worldOffset?.y ?? 0),
+ box.center.z + (worldOffset?.z ?? 0),
+ ]
+
+ return (
+
+
+ {/* Invisible whole-volume hit target — the box IS the drag handle. */}
+
+
+
+
+
+ )
+}
diff --git a/packages/editor/src/components/editor/group-transform-shared.test.ts b/packages/editor/src/components/editor/group-transform-shared.test.ts
index 6e1ae5e1c..5b741b45d 100644
--- a/packages/editor/src/components/editor/group-transform-shared.test.ts
+++ b/packages/editor/src/components/editor/group-transform-shared.test.ts
@@ -1,7 +1,12 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
import { z } from 'zod'
-import { classifyParticipant, collectParticipants } from './group-transform-shared'
+import {
+ classifyParticipant,
+ collectParticipants,
+ rotateGroupPatches,
+ translateGroupPatches,
+} from './group-transform-shared'
const BUILDING_SCOPED_KIND = 'group-transform-building-scoped-test'
@@ -171,6 +176,192 @@ describe('group transform participants', () => {
])
})
+ test('classifies and transforms polygon kinds (slab / ceiling / zone)', () => {
+ const nodes = {
+ building_test: {
+ id: 'building_test',
+ type: 'building',
+ children: ['level_test'],
+ },
+ level_test: {
+ id: 'level_test',
+ type: 'level',
+ parentId: 'building_test',
+ children: ['slab_test', 'zone_test'],
+ },
+ slab_test: {
+ id: 'slab_test',
+ type: 'slab',
+ parentId: 'level_test',
+ polygon: [
+ [0, 0],
+ [2, 0],
+ [2, 2],
+ [0, 2],
+ ],
+ holes: [
+ [
+ [0.5, 0.5],
+ [1, 0.5],
+ [1, 1],
+ ],
+ ],
+ },
+ zone_test: {
+ id: 'zone_test',
+ type: 'zone',
+ parentId: 'level_test',
+ polygon: [
+ [0, 0],
+ [1, 0],
+ [1, 1],
+ ],
+ },
+ slab_elsewhere: {
+ id: 'slab_elsewhere',
+ type: 'slab',
+ parentId: 'level_other',
+ polygon: [
+ [0, 0],
+ [1, 0],
+ [1, 1],
+ ],
+ },
+ } as unknown as Record
+
+ expect(classifyParticipant(nodes.slab_test, 'level_test', nodes)).toBe('polygon')
+ expect(classifyParticipant(nodes.zone_test, 'level_test', nodes)).toBe('polygon')
+ // Not in the active level frame → excluded.
+ expect(classifyParticipant(nodes.slab_elsewhere, 'level_test', nodes)).toBeNull()
+
+ const { starts, links } = collectParticipants(['slab_test', 'zone_test'], nodes, 'level_test')
+ expect(links).toEqual([])
+ expect(starts).toEqual([
+ {
+ id: 'slab_test',
+ kind: 'polygon',
+ polygon: [
+ [0, 0],
+ [2, 0],
+ [2, 2],
+ [0, 2],
+ ],
+ holes: [
+ [
+ [0.5, 0.5],
+ [1, 0.5],
+ [1, 1],
+ ],
+ ],
+ },
+ {
+ id: 'zone_test',
+ kind: 'polygon',
+ polygon: [
+ [0, 0],
+ [1, 0],
+ [1, 1],
+ ],
+ holes: null,
+ },
+ ])
+
+ // Translate: every vertex (holes included) shifts; the hole-less zone's
+ // patch never grows a `holes` field.
+ const moved = translateGroupPatches(starts, [], 1, -2)
+ expect(moved).toEqual([
+ [
+ 'slab_test',
+ {
+ polygon: [
+ [1, -2],
+ [3, -2],
+ [3, 0],
+ [1, 0],
+ ],
+ holes: [
+ [
+ [1.5, -1.5],
+ [2, -1.5],
+ [2, -1],
+ ],
+ ],
+ },
+ ],
+ [
+ 'zone_test',
+ {
+ polygon: [
+ [1, -2],
+ [2, -2],
+ [2, -1],
+ ],
+ },
+ ],
+ ])
+
+ // Rotate 90° in the atan2 x→z sense around the origin: (x, z) → (-z, x).
+ const rotated = rotateGroupPatches(
+ starts.filter((s) => s.id === 'zone_test'),
+ [],
+ { x: 0, z: 0 },
+ Math.PI / 2,
+ )
+ expect(rotated).toHaveLength(1)
+ const rotatedPolygon = (rotated[0]![1] as { polygon: [number, number][] }).polygon
+ const expected = [
+ [0, 0],
+ [0, 1],
+ [-1, 1],
+ ]
+ rotatedPolygon.forEach((point, i) => {
+ expect(point[0]).toBeCloseTo(expected[i]![0]!)
+ expect(point[1]).toBeCloseTo(expected[i]![1]!)
+ })
+ })
+
+ test('polygon hosts carry their attached positioned children (ceiling items)', () => {
+ const nodes = {
+ building_test: { id: 'building_test', type: 'building', children: ['level_test'] },
+ level_test: {
+ id: 'level_test',
+ type: 'level',
+ parentId: 'building_test',
+ children: ['ceiling_test'],
+ },
+ ceiling_test: {
+ id: 'ceiling_test',
+ type: 'ceiling',
+ parentId: 'level_test',
+ children: ['item_lamp'],
+ polygon: [
+ [0, 0],
+ [2, 0],
+ [2, 2],
+ [0, 2],
+ ],
+ },
+ item_lamp: {
+ id: 'item_lamp',
+ type: 'item',
+ parentId: 'ceiling_test',
+ position: [1, 2.4, 1],
+ rotation: [0, 0.5, 0],
+ },
+ } as unknown as Record
+
+ // The lamp itself is not level-parented, so it is not an independent
+ // participant — it rides its host ceiling.
+ expect(classifyParticipant(nodes.item_lamp, 'level_test', nodes)).toBeNull()
+
+ const { starts } = collectParticipants(['ceiling_test'], nodes, 'level_test')
+ expect(starts.map((s) => s.id).sort()).toEqual(['ceiling_test', 'item_lamp'])
+
+ const moved = translateGroupPatches(starts, [], 2, 1)
+ const lampPatch = Object.fromEntries(moved).item_lamp as { position: [number, number, number] }
+ expect(lampPatch.position).toEqual([3, 2.4, 2])
+ })
+
test('supports legacy level-parented elevators already loaded in the editor', () => {
const nodes = {
building_test: {
diff --git a/packages/editor/src/components/editor/group-transform-shared.ts b/packages/editor/src/components/editor/group-transform-shared.ts
index 5a000dcea..8e077474c 100644
--- a/packages/editor/src/components/editor/group-transform-shared.ts
+++ b/packages/editor/src/components/editor/group-transform-shared.ts
@@ -25,12 +25,15 @@ const isVec3 = (v: unknown): v is Vec3 =>
Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number')
const isVec2 = (v: unknown): v is Vec2 =>
Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
+const isVec2Array = (v: unknown): v is Vec2[] => Array.isArray(v) && v.length > 0 && v.every(isVec2)
// How a participant's placement transforms rigidly around / with the group:
// - 'vec3' position + [x,y,z] rotation (items, …)
// - 'scalar' position + numeric rotation (columns)
// - 'endpoint' start/end tuples (walls, fences)
-export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint'
+// - 'polygon' [x,z] vertex arrays (slabs, ceilings, zones) — the placement
+// lives in the vertices themselves (plus optional hole rings)
+export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint' | 'polygon'
// A selected node qualifies when it belongs to the active level's horizontal
// frame: either parented to that level, or declared building-scoped and parented
@@ -75,27 +78,39 @@ function getParticipantScalarRotation(node: AnyNode): number | null {
return sceneRegistry.nodes.get(node.id)?.rotation.y ?? 0
}
+// Shape-only classification of a positioned placement (no level-scope check).
+// Used for polygon hosts' attached children, whose parent is the host rather
+// than the level.
+function classifyPlacementShape(node: AnyNode): 'vec3' | 'scalar' | null {
+ const p = getParticipantPosition(node)
+ const r = (node as { rotation?: unknown }).rotation
+ if (isVec3(p) && isVec3(r)) return 'vec3'
+ if (isVec3(p) && getParticipantScalarRotation(node) !== null) return 'scalar'
+ return null
+}
+
export function classifyParticipant(
node: AnyNode | undefined,
levelId: string | null,
sceneNodes: Record,
): ParticipantKind | null {
if (!node || !isInGroupTransformScope(node, levelId, sceneNodes)) return null
- const p = getParticipantPosition(node)
- const r = (node as { rotation?: unknown }).rotation
+ const shape = classifyPlacementShape(node)
+ if (shape) return shape
const start = (node as { start?: unknown }).start
const end = (node as { end?: unknown }).end
- if (isVec3(p) && isVec3(r)) return 'vec3'
- if (isVec3(p) && getParticipantScalarRotation(node) !== null) return 'scalar'
if (isVec2(start) && isVec2(end)) return 'endpoint'
+ if (isVec2Array((node as { polygon?: unknown }).polygon)) return 'polygon'
return null
}
-// Pre-drag placement snapshot + how to transform it.
+// Pre-drag placement snapshot + how to transform it. `holes` is null when the
+// kind carries no holes field (zone), so patches never write one onto it.
export type ParticipantStart =
| { id: AnyNodeId; kind: 'vec3'; position: Vec3; rotation: Vec3 }
| { id: AnyNodeId; kind: 'scalar'; position: Vec3; rotation: number }
| { id: AnyNodeId; kind: 'endpoint'; start: Vec2; end: Vec2 }
+ | { id: AnyNodeId; kind: 'polygon'; polygon: Vec2[]; holes: Vec2[][] | null }
// An unselected wall/fence sharing a junction with a transforming endpoint. Only
// the touching endpoint(s) follow, so the neighbour stays attached while its far
@@ -143,7 +158,7 @@ export function collectParticipants(
position: [position[0], position[1], position[2]],
rotation,
})
- } else {
+ } else if (kind === 'endpoint') {
const n = node as AnyNode & { start: Vec2; end: Vec2 }
starts.push({
id: id as AnyNodeId,
@@ -151,6 +166,56 @@ export function collectParticipants(
start: [n.start[0], n.start[1]],
end: [n.end[0], n.end[1]],
})
+ } else {
+ const n = node as AnyNode & { polygon: Vec2[]; holes?: Vec2[][] }
+ starts.push({
+ id: id as AnyNodeId,
+ kind,
+ polygon: n.polygon.map(([x, z]) => [x, z] as Vec2),
+ holes: Array.isArray(n.holes)
+ ? n.holes.map((hole) => hole.map(([x, z]) => [x, z] as Vec2))
+ : null,
+ })
+ }
+ }
+
+ // Polygon hosts (slab/ceiling) rebuild their geometry from vertices rather
+ // than transforming a group, so — unlike wall children, which ride the wall
+ // mesh — their attached children (ceiling-mounted items) must transform
+ // explicitly. Their positions are stored in the level frame (the host group
+ // sits at the origin), so the same rigid patches apply.
+ const includedIds = new Set(starts.map((s) => s.id))
+ for (const s of [...starts]) {
+ if (s.kind !== 'polygon') continue
+ const host = sceneNodes[s.id]
+ const childIds = (host as { children?: string[] } | undefined)?.children
+ if (!Array.isArray(childIds)) continue
+ for (const childId of childIds) {
+ if (includedIds.has(childId)) continue
+ const child = sceneNodes[childId]
+ if (!child) continue
+ const shape = classifyPlacementShape(child)
+ const position = child ? getParticipantPosition(child) : null
+ if (!(shape && position)) continue
+ if (shape === 'vec3') {
+ const c = child as AnyNode & { rotation: Vec3 }
+ starts.push({
+ id: childId as AnyNodeId,
+ kind: 'vec3',
+ position: [position[0], position[1], position[2]],
+ rotation: [c.rotation[0], c.rotation[1], c.rotation[2]],
+ })
+ } else {
+ const rotation = getParticipantScalarRotation(child)
+ if (rotation === null) continue
+ starts.push({
+ id: childId as AnyNodeId,
+ kind: 'scalar',
+ position: [position[0], position[1], position[2]],
+ rotation,
+ })
+ }
+ includedIds.add(childId)
}
}
@@ -219,6 +284,174 @@ export function expandToComponent(
return Array.from(included)
}
+// Per-node field patch, keyed for `useLiveNodeOverrides.setMany` during a live
+// preview and for the single batched `updateNodes` on commit.
+export type GroupPatch = readonly [AnyNodeId, Record]
+
+// Rigid group rotation: orbit each participant's anchor point(s) CCW by
+// `delta` (atan2 x→z sense) around `center` (level-frame XZ) and turn yaws by
+// `-delta` to match three.js Y-rotation handedness (same convention as the
+// single-item rotate handle in item/definition.ts). Endpoint nodes
+// (walls/fences) have no yaw — swinging both endpoints around the pivot
+// rotates them rigidly; their curveOffset sagitta is rotation-invariant, so
+// arcs are preserved. Linked neighbours' shared endpoints land exactly on the
+// selected wall's rotated endpoint (rot is deterministic), keeping junctions
+// welded while the far end stays put.
+export function rotateGroupPatches(
+ starts: ParticipantStart[],
+ links: LinkedNeighbor[],
+ center: { x: number; z: number },
+ delta: number,
+): GroupPatch[] {
+ const cos = Math.cos(delta)
+ const sin = Math.sin(delta)
+ const rot = (x: number, z: number): Vec2 => {
+ const dx = x - center.x
+ const dz = z - center.z
+ return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos]
+ }
+ const patches: GroupPatch[] = []
+ for (const s of starts) {
+ if (s.kind === 'endpoint') {
+ patches.push([s.id, { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }])
+ } else if (s.kind === 'polygon') {
+ const patch: Record = { polygon: s.polygon.map(([x, z]) => rot(x, z)) }
+ if (s.holes) patch.holes = s.holes.map((hole) => hole.map(([x, z]) => rot(x, z)))
+ patches.push([s.id, patch])
+ } else {
+ const [px, pz] = rot(s.position[0], s.position[2])
+ const position: Vec3 = [px, s.position[1], pz]
+ const rotation =
+ s.kind === 'vec3'
+ ? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3)
+ : s.rotation - delta
+ patches.push([s.id, { position, rotation }])
+ }
+ }
+ for (const l of links) {
+ patches.push([
+ l.id,
+ {
+ start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
+ end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
+ },
+ ])
+ }
+ return patches
+}
+
+// Rotate the SNAPSHOTS themselves (same math as `rotateGroupPatches`, but
+// producing new snapshot shapes instead of node patches). Lets an in-flight
+// group move re-seed its rest state after a mid-drag R/T: subsequent
+// translate patches then place the rotated layout at the live delta.
+export function rotateGroupSnapshots(
+ starts: ParticipantStart[],
+ links: LinkedNeighbor[],
+ center: { x: number; z: number },
+ delta: number,
+): { starts: ParticipantStart[]; links: LinkedNeighbor[] } {
+ const cos = Math.cos(delta)
+ const sin = Math.sin(delta)
+ const rot = (x: number, z: number): Vec2 => {
+ const dx = x - center.x
+ const dz = z - center.z
+ return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos]
+ }
+ const rotatedStarts = starts.map((s): ParticipantStart => {
+ if (s.kind === 'endpoint') {
+ return { ...s, start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }
+ }
+ if (s.kind === 'polygon') {
+ return {
+ ...s,
+ polygon: s.polygon.map(([x, z]) => rot(x, z)),
+ holes: s.holes ? s.holes.map((hole) => hole.map(([x, z]) => rot(x, z))) : null,
+ }
+ }
+ const [px, pz] = rot(s.position[0], s.position[2])
+ const position: Vec3 = [px, s.position[1], pz]
+ if (s.kind === 'vec3') {
+ return { ...s, position, rotation: [s.rotation[0], s.rotation[1] - delta, s.rotation[2]] }
+ }
+ return { ...s, position, rotation: s.rotation - delta }
+ })
+ // Only the welded (linked) endpoints follow the rotation; the far ends
+ // stay put, exactly like the per-tick patch path.
+ const rotatedLinks = links.map(
+ (l): LinkedNeighbor => ({
+ ...l,
+ start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start,
+ end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end,
+ }),
+ )
+ return { starts: rotatedStarts, links: rotatedLinks }
+}
+
+// Level-frame XZ extents of the participant DATA — the mesh-free sibling of
+// `computeGroupBox`, used when meshes aren't mounted yet and to re-seed
+// alignment anchors after a mid-drag rotation.
+export function participantExtents(
+ starts: ParticipantStart[],
+): { minX: number; minZ: number; maxX: number; maxZ: number } | null {
+ let minX = Number.POSITIVE_INFINITY
+ let minZ = Number.POSITIVE_INFINITY
+ let maxX = Number.NEGATIVE_INFINITY
+ let maxZ = Number.NEGATIVE_INFINITY
+ const reach = (x: number, z: number) => {
+ minX = Math.min(minX, x)
+ minZ = Math.min(minZ, z)
+ maxX = Math.max(maxX, x)
+ maxZ = Math.max(maxZ, z)
+ }
+ for (const s of starts) {
+ if (s.kind === 'endpoint') {
+ reach(s.start[0], s.start[1])
+ reach(s.end[0], s.end[1])
+ } else if (s.kind === 'polygon') {
+ for (const [x, z] of s.polygon) {
+ reach(x, z)
+ }
+ } else {
+ reach(s.position[0], s.position[2])
+ }
+ }
+ if (!Number.isFinite(minX)) return null
+ return { minX, minZ, maxX, maxZ }
+}
+
+// Rigid group slide: shift every participant (and each linked neighbour's
+// shared endpoint) by the same level-frame XZ delta. Y and rotations untouched.
+export function translateGroupPatches(
+ starts: ParticipantStart[],
+ links: LinkedNeighbor[],
+ dx: number,
+ dz: number,
+): GroupPatch[] {
+ const patches: GroupPatch[] = []
+ const shift = ([x, z]: Vec2): Vec2 => [x + dx, z + dz]
+ for (const s of starts) {
+ if (s.kind === 'endpoint') {
+ patches.push([s.id, { start: shift(s.start), end: shift(s.end) }])
+ } else if (s.kind === 'polygon') {
+ const patch: Record = { polygon: s.polygon.map(shift) }
+ if (s.holes) patch.holes = s.holes.map((hole) => hole.map(shift))
+ patches.push([s.id, patch])
+ } else {
+ patches.push([s.id, { position: [s.position[0] + dx, s.position[1], s.position[2] + dz] }])
+ }
+ }
+ for (const l of links) {
+ patches.push([
+ l.id,
+ {
+ start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start,
+ end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end,
+ },
+ ])
+ }
+ return patches
+}
+
// Frozen world matrix of the level group + its inverse. A node's placement
// (`position` / `start` / `end`) is stored in its parent level's frame, but the
// gizmos raycast the ground plane in WORLD space. When the building is rotated
diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx
index 37fbf95e5..5202fe56a 100644
--- a/packages/editor/src/components/editor/index.tsx
+++ b/packages/editor/src/components/editor/index.tsx
@@ -65,8 +65,9 @@ import { FloatingActionMenu } from './floating-action-menu'
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
import { FloorplanPanel } from './floorplan-panel'
import { Grid } from './grid'
-import { GroupMoveHandle } from './group-move-handle'
+import { GroupFloatingActionMenu } from './group-floating-action-menu'
import { GroupRotateHandle } from './group-rotate-handle'
+import { GroupSelectionBox3D } from './group-selection-box-3d'
import { NodeArrowHandles } from './node-arrow-handles'
import { RiserDiagramPanel } from './riser-diagram-panel'
import { SelectionManager } from './selection-manager'
@@ -733,12 +734,13 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
{!noEditing && }
{!noEditing && }
{!noEditing && }
- {!noEditing && }
+ {!noEditing && }
{!noEditing && }
{!noEditing && }
{!noEditing && }
{!noEditing && }
{!noEditing && }
+ {!noEditing && }
{!noEditing && }
{!isFirstPersonMode && }
diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx
index d23204803..df25db81c 100644
--- a/packages/editor/src/components/editor/selection-manager.tsx
+++ b/packages/editor/src/components/editor/selection-manager.tsx
@@ -75,7 +75,10 @@ import useInteractionScope, {
useMovingNode,
} from '../../store/use-interaction-scope'
import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state'
+import { armGroupMove3d } from './group-move-3d'
+import { classifyParticipant } from './group-transform-shared'
import { swallowNextClick } from './node-arrow-handles'
+import { setEditorThreeContext } from './three-context-bridge'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
// Elevators are building-scoped, so they stay selectable across level filters.
@@ -711,7 +714,16 @@ export const SelectionManager = () => {
// the editor wraps the canvas in a div with a custom `cursor: url(...)`, which
// (being a closer ancestor) overrides any body cursor over the canvas.
const glDomElement = useThree((s) => s.gl.domElement)
+ const camera = useThree((s) => s.camera)
+ const raycaster = useThree((s) => s.raycaster)
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
+
+ // Publish the live three context for DOM-level sessions (group pick-up
+ // move) that raycast the 3D view from outside the R3F tree.
+ useEffect(() => {
+ setEditorThreeContext({ camera, raycaster, domElement: glDomElement })
+ return () => setEditorThreeContext(null)
+ }, [camera, raycaster, glDomElement])
const modifierKeysRef = useRef({
meta: false,
ctrl: false,
@@ -1159,11 +1171,39 @@ export const SelectionManager = () => {
const onPointerDown = (event: NodeEvent) => {
const pointer = pointerEventFromNodeEvent(event)
- if (pointer.button !== 0 || !isCommandModifier(pointer)) return
+ if (pointer.button !== 0) return
+
+ // Plain press on a transformable member of a multi-selection arms the
+ // group move — dragging slides the whole selection on the ground plane
+ // (the 3D sibling of the 2D floorplan group drag, replacing the removed
+ // group-move gizmo cross). A plain click (no drag) still falls through
+ // to the normal click handling, which collapses to the pressed node.
+ if (
+ !(pointer.shiftKey || pointer.altKey || isCommandModifier(pointer)) &&
+ armGroupMove3d({
+ nodeId: event.node.id as AnyNodeId,
+ clientX: pointer.clientX,
+ clientY: pointer.clientY,
+ pointerId: pointer.pointerId,
+ nativeEvent: pointer,
+ camera,
+ raycaster,
+ domElement: glDomElement,
+ })
+ ) {
+ return
+ }
+
+ if (!isCommandModifier(pointer)) return
const node = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node
if (!canDirectMoveNode(node)) return
- if (!useViewer.getState().selection.selectedIds.includes(node.id)) return
+ // Sole selection only: per-node direct manipulation stands down for a
+ // multi-selection (the group sessions own plain drags there, and Cmd is
+ // the selection-toggle key — a wobbly Cmd+click must not yank one
+ // member out of the group).
+ const currentSelectedIds = useViewer.getState().selection.selectedIds
+ if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== node.id) return
const startX = pointer.clientX
const startY = pointer.clientY
@@ -1260,7 +1300,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:pointerdown` as any, onPointerDown as any)
}
}
- }, [isCurveReshape, mode, movingNode])
+ }, [isCurveReshape, mode, movingNode, camera, raycaster, glDomElement])
// Move cursor over the selected movable node: the visual cue that clicking it
// picks it up (replaces the removed move-cross gizmo). Reacts only when the
@@ -1272,15 +1312,24 @@ export const SelectionManager = () => {
let prevKey = ' '
const applyCursor = () => {
const { selection, hoveredId } = useViewer.getState()
- const sole = selection.selectedIds.length === 1 ? selection.selectedIds[0] : null
- const key = `${hoveredId ?? ''}|${sole ?? ''}`
+ const selectedIds = selection.selectedIds
+ const sole = selectedIds.length === 1 ? selectedIds[0] : null
+ const key = `${hoveredId ?? ''}|${sole ?? ''}|${selectedIds.length}`
if (key === prevKey) return
prevKey = key
- const node =
- sole != null && hoveredId === sole && !getMovingNode()
- ? useScene.getState().nodes[sole as AnyNodeId]
- : null
- if (node && canDirectMoveNode(node)) {
+ let wantsMove = false
+ if (hoveredId && !getMovingNode()) {
+ if (sole === hoveredId) {
+ const node = useScene.getState().nodes[sole as AnyNodeId]
+ wantsMove = !!node && canDirectMoveNode(node)
+ } else if (selectedIds.length > 1 && selectedIds.includes(hoveredId)) {
+ // Group member: dragging it slides the whole selection.
+ const nodes = useScene.getState().nodes
+ wantsMove =
+ classifyParticipant(nodes[hoveredId as AnyNodeId], selection.levelId, nodes) !== null
+ }
+ }
+ if (wantsMove) {
glDomElement.style.cursor = 'move'
owns = true
} else if (owns) {
@@ -1316,9 +1365,10 @@ export const SelectionManager = () => {
if (event.button !== 2 || !isCommandModifier(event)) return
if (!(event.target instanceof HTMLCanvasElement)) return
+ // Sole selection only — same stand-down as the Cmd-drag move above.
const selectedIds = useViewer.getState().selection.selectedIds
const hoveredId = useViewer.getState().hoveredId as AnyNodeId | null
- if (!hoveredId || !selectedIds.includes(hoveredId)) return
+ if (!hoveredId || selectedIds.length !== 1 || selectedIds[0] !== hoveredId) return
const node = useScene.getState().nodes[hoveredId]
if (!node || !canDirectRotateNode(node)) return
diff --git a/packages/editor/src/components/editor/slab-hole-highlights.tsx b/packages/editor/src/components/editor/slab-hole-highlights.tsx
index d94abe8a5..bf369aef2 100644
--- a/packages/editor/src/components/editor/slab-hole-highlights.tsx
+++ b/packages/editor/src/components/editor/slab-hole-highlights.tsx
@@ -241,7 +241,9 @@ export function SlabHoleHighlights() {
}
}, [hoveredHole, selectedIds, setHoveredHole])
- if (selectedIds.length === 0) return null
+ // Sole selection only — hole outlines are click-to-edit chrome, and a
+ // multi-selection shows highlight only (the group transforms as one piece).
+ if (selectedIds.length !== 1) return null
return (
<>
diff --git a/packages/editor/src/components/editor/three-context-bridge.ts b/packages/editor/src/components/editor/three-context-bridge.ts
new file mode 100644
index 000000000..076f95732
--- /dev/null
+++ b/packages/editor/src/components/editor/three-context-bridge.ts
@@ -0,0 +1,22 @@
+import type { Camera, Raycaster } from 'three'
+
+// Escape hatch for DOM-level interaction sessions (group pick-up move) that
+// need to raycast the 3D view without living inside the R3F tree. The
+// editor's SelectionManager (always mounted with the canvas) publishes the
+// live camera / raycaster / canvas here; consumers must handle `null`
+// (canvas not mounted yet, or torn down).
+export type EditorThreeContext = {
+ camera: Camera
+ raycaster: Raycaster
+ domElement: HTMLCanvasElement
+}
+
+let current: EditorThreeContext | null = null
+
+export function setEditorThreeContext(ctx: EditorThreeContext | null) {
+ current = ctx
+}
+
+export function getEditorThreeContext(): EditorThreeContext | null {
+ return current
+}
diff --git a/packages/editor/src/components/editor/use-mesh-settle-epoch.ts b/packages/editor/src/components/editor/use-mesh-settle-epoch.ts
new file mode 100644
index 000000000..14fd822ec
--- /dev/null
+++ b/packages/editor/src/components/editor/use-mesh-settle-epoch.ts
@@ -0,0 +1,26 @@
+import { useEffect, useState } from 'react'
+
+/**
+ * Bumps after the 3D meshes have had a chance to settle following a scene
+ * change. `computeGroupBox` reads mesh WORLD bounds, but a scene commit
+ * (undo/redo included) reaches the meshes asynchronously — renderers
+ * reconcile on the next React commit and the geometry systems rebuild in the
+ * next frame — so anything that derives geometry from the meshes in the same
+ * render as the `nodes` change reads STALE positions. Depend on this epoch
+ * (alongside `nodes`) to recompute once more after two animation frames,
+ * when transforms and rebuilt geometry are in place.
+ */
+export function useMeshSettleEpoch(nodes: unknown): number {
+ const [epoch, setEpoch] = useState(0)
+ useEffect(() => {
+ let raf2 = 0
+ const raf1 = requestAnimationFrame(() => {
+ raf2 = requestAnimationFrame(() => setEpoch((e) => e + 1))
+ })
+ return () => {
+ cancelAnimationFrame(raf1)
+ cancelAnimationFrame(raf2)
+ }
+ }, [nodes])
+ return epoch
+}
diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx
index d58b11c1f..4fa848f74 100644
--- a/packages/editor/src/components/tools/select/box-select-tool.tsx
+++ b/packages/editor/src/components/tools/select/box-select-tool.tsx
@@ -1,8 +1,17 @@
-import { sceneRegistry, type ZoneNode } from '@pascal-app/core'
+import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
-import { Box3, type Camera, type Object3D, Vector3 } from 'three'
+import {
+ Box3,
+ type Camera,
+ Matrix4,
+ type Object3D,
+ Plane,
+ Raycaster,
+ Vector2,
+ Vector3,
+} from 'three'
import useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope'
import {
@@ -10,6 +19,13 @@ import {
isBoxSelectPointerSuppressed,
markBoxSelectHandled,
} from './box-select-state'
+import {
+ convexHull2D,
+ type Point2,
+ polygonsIntersect,
+ rectIntersectsHull,
+ segmentIntersectsPolygon,
+} from './marquee-geometry'
import { PlaneBoxSelectTool } from './plane-box-select-tool'
import {
createScreenRectangleSelectionElement,
@@ -19,14 +35,20 @@ import {
SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX,
type ScreenRect,
screenRectFromDomRect,
- screenRectsIntersect,
updateScreenRectangleSelectionElement,
} from './screen-rectangle-selection'
import { collectSelectableCandidateIds } from './select-candidates'
const tempBox = new Box3()
+const tempChildBox = new Box3()
+const tempInvWorld = new Matrix4()
+const tempRelMatrix = new Matrix4()
const tempWorldPoint = new Vector3()
const tempScreenPoint = new Vector3()
+const tempNDC = new Vector2()
+const tempPlane = new Plane()
+const tempRaycaster = new Raycaster()
+const UP = new Vector3(0, 1, 0)
const boxCorners = [
new Vector3(),
new Vector3(),
@@ -59,55 +81,72 @@ function projectWorldPointToScreen(
]
}
-function getObjectScreenRect(
+/**
+ * Union bounding box of the object's mesh geometry in the OBJECT's OWN frame
+ * (an oriented box). The world AABB the previous implementation used inflates
+ * around rotated geometry — a diagonal wall's world AABB spans a whole square
+ * — and projecting THAT to a screen AABB inflates again, which made the
+ * marquee select objects visually far from the cursor.
+ */
+function computeLocalBox(object: Object3D): Box3 | null {
+ tempBox.makeEmpty()
+ tempInvWorld.copy(object.matrixWorld).invert()
+ object.traverse((child) => {
+ const mesh = child as {
+ isMesh?: boolean
+ geometry?: { boundingBox: Box3 | null; computeBoundingBox: () => void }
+ matrixWorld: import('three').Matrix4
+ }
+ if (!mesh.isMesh || !mesh.geometry) return
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
+ const bounds = mesh.geometry.boundingBox
+ if (!bounds || bounds.isEmpty()) return
+ tempChildBox.copy(bounds)
+ tempRelMatrix.multiplyMatrices(tempInvWorld, mesh.matrixWorld)
+ tempChildBox.applyMatrix4(tempRelMatrix)
+ tempBox.union(tempChildBox)
+ })
+ return tempBox.isEmpty() ? null : tempBox
+}
+
+/** Screen-space convex hull of the object's oriented bounding box. */
+function getObjectScreenHull(
object: Object3D,
camera: Camera,
canvasRect: DOMRect,
-): ScreenRect | null {
+): Point2[] | null {
object.updateWorldMatrix(true, true)
- tempBox.setFromObject(object)
+ const localBox = computeLocalBox(object)
- if (tempBox.isEmpty()) {
+ if (!localBox) {
object.getWorldPosition(tempWorldPoint)
const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
- if (!projected) return null
- const [x, y] = projected
- return { minX: x, minY: y, maxX: x, maxY: y }
+ return projected ? [projected] : null
}
- boxCorners[0]!.set(tempBox.min.x, tempBox.min.y, tempBox.min.z)
- boxCorners[1]!.set(tempBox.min.x, tempBox.min.y, tempBox.max.z)
- boxCorners[2]!.set(tempBox.min.x, tempBox.max.y, tempBox.min.z)
- boxCorners[3]!.set(tempBox.min.x, tempBox.max.y, tempBox.max.z)
- boxCorners[4]!.set(tempBox.max.x, tempBox.min.y, tempBox.min.z)
- boxCorners[5]!.set(tempBox.max.x, tempBox.min.y, tempBox.max.z)
- boxCorners[6]!.set(tempBox.max.x, tempBox.max.y, tempBox.min.z)
- boxCorners[7]!.set(tempBox.max.x, tempBox.max.y, tempBox.max.z)
-
- let minX = Number.POSITIVE_INFINITY
- let minY = Number.POSITIVE_INFINITY
- let maxX = Number.NEGATIVE_INFINITY
- let maxY = Number.NEGATIVE_INFINITY
+ boxCorners[0]!.set(localBox.min.x, localBox.min.y, localBox.min.z)
+ boxCorners[1]!.set(localBox.min.x, localBox.min.y, localBox.max.z)
+ boxCorners[2]!.set(localBox.min.x, localBox.max.y, localBox.min.z)
+ boxCorners[3]!.set(localBox.min.x, localBox.max.y, localBox.max.z)
+ boxCorners[4]!.set(localBox.max.x, localBox.min.y, localBox.min.z)
+ boxCorners[5]!.set(localBox.max.x, localBox.min.y, localBox.max.z)
+ boxCorners[6]!.set(localBox.max.x, localBox.max.y, localBox.min.z)
+ boxCorners[7]!.set(localBox.max.x, localBox.max.y, localBox.max.z)
+ const projectedPoints: Point2[] = []
for (const corner of boxCorners) {
+ corner.applyMatrix4(object.matrixWorld)
const projected = projectWorldPointToScreen(corner, camera, canvasRect)
- if (!projected) continue
- const [x, y] = projected
- minX = Math.min(minX, x)
- minY = Math.min(minY, y)
- maxX = Math.max(maxX, x)
- maxY = Math.max(maxY, y)
+ if (projected) projectedPoints.push(projected)
}
- if (minX !== Number.POSITIVE_INFINITY) {
- return { minX, minY, maxX, maxY }
+ if (projectedPoints.length === 0) {
+ object.getWorldPosition(tempWorldPoint)
+ const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
+ return projected ? [projected] : null
}
- object.getWorldPosition(tempWorldPoint)
- const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
- if (!projected) return null
- const [x, y] = projected
- return { minX: x, minY: y, maxX: x, maxY: y }
+ return convexHull2D(projectedPoints)
}
function isObjectVisible(object: Object3D): boolean {
@@ -119,6 +158,46 @@ function isObjectVisible(object: Object3D): boolean {
return true
}
+const isVec2 = (v: unknown): v is [number, number] =>
+ Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
+const isVec2Array = (v: unknown): v is [number, number][] =>
+ Array.isArray(v) && v.length > 0 && v.every(isVec2)
+
+/**
+ * The marquee rect projected onto the active level's floor plane, in the
+ * LEVEL frame — a convex quad (perspective keeps rect convexity). Null when
+ * any corner ray misses the plane (camera near the horizon); callers fall
+ * back to the screen-hull test then.
+ */
+function marqueeGroundQuad(rect: ScreenRect, camera: Camera, canvasRect: DOMRect): Point2[] | null {
+ const levelId = useViewer.getState().selection.levelId
+ const levelObject = levelId ? sceneRegistry.nodes.get(levelId) : null
+ if (!levelObject) return null
+ levelObject.updateWorldMatrix(true, false)
+ tempInvWorld.copy(levelObject.matrixWorld).invert()
+ levelObject.getWorldPosition(tempWorldPoint)
+ tempPlane.set(UP, -tempWorldPoint.y)
+
+ const corners: [number, number][] = [
+ [rect.minX, rect.minY],
+ [rect.maxX, rect.minY],
+ [rect.maxX, rect.maxY],
+ [rect.minX, rect.maxY],
+ ]
+ const quad: Point2[] = []
+ for (const [cx, cy] of corners) {
+ tempNDC.set(
+ ((cx - canvasRect.left) / canvasRect.width) * 2 - 1,
+ -((cy - canvasRect.top) / canvasRect.height) * 2 + 1,
+ )
+ tempRaycaster.setFromCamera(tempNDC, camera)
+ if (!tempRaycaster.ray.intersectPlane(tempPlane, tempWorldPoint)) return null
+ tempWorldPoint.applyMatrix4(tempInvWorld)
+ quad.push([tempWorldPoint.x, tempWorldPoint.z])
+ }
+ return quad
+}
+
function collectNodeIdsInScreenRect(
rect: ScreenRect,
camera: Camera,
@@ -127,11 +206,37 @@ function collectNodeIdsInScreenRect(
const canvasRect = canvas.getBoundingClientRect()
const result: string[] = []
+ // Plan-footprint membership for the data kinds (walls / fences by their
+ // segment, slabs / ceilings / zones by their polygon) — exact under any
+ // rotation, matching the plane-marquee tool's semantics. Kinds whose
+ // placement lives in mesh transforms (items, columns, …) intersect the
+ // marquee with their oriented bbox projected to a screen hull instead.
+ const quad = marqueeGroundQuad(rect, camera, canvasRect)
+ const nodes = useScene.getState().nodes
+
for (const id of collectSelectableCandidateIds()) {
const object = sceneRegistry.nodes.get(id)
if (!object || !isObjectVisible(object)) continue
- const objectRect = getObjectScreenRect(object, camera, canvasRect)
- if (objectRect && screenRectsIntersect(rect, objectRect)) {
+
+ if (quad) {
+ const node = nodes[id as keyof typeof nodes] as
+ | { start?: unknown; end?: unknown; polygon?: unknown }
+ | undefined
+ if (node) {
+ const { start, end, polygon } = node
+ if (isVec2(start) && isVec2(end)) {
+ if (segmentIntersectsPolygon(start, end, quad)) result.push(id)
+ continue
+ }
+ if (isVec2Array(polygon)) {
+ if (polygonsIntersect(polygon, quad)) result.push(id)
+ continue
+ }
+ }
+ }
+
+ const hull = getObjectScreenHull(object, camera, canvasRect)
+ if (hull && rectIntersectsHull(rect, hull)) {
result.push(id)
}
}
diff --git a/packages/editor/src/components/tools/select/marquee-geometry.test.ts b/packages/editor/src/components/tools/select/marquee-geometry.test.ts
new file mode 100644
index 000000000..077f227ef
--- /dev/null
+++ b/packages/editor/src/components/tools/select/marquee-geometry.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, test } from 'bun:test'
+import {
+ convexHull2D,
+ type Point2,
+ polygonsIntersect,
+ rectIntersectsHull,
+ type ScreenRect,
+ segmentIntersectsPolygon,
+} from './marquee-geometry'
+
+const rect = (minX: number, minY: number, maxX: number, maxY: number): ScreenRect => ({
+ minX,
+ minY,
+ maxX,
+ maxY,
+})
+
+describe('convexHull2D', () => {
+ test('drops interior points and keeps the hull', () => {
+ const hull = convexHull2D([
+ [0, 0],
+ [4, 0],
+ [4, 4],
+ [0, 4],
+ [2, 2],
+ [1, 3],
+ ])
+ expect(hull).toHaveLength(4)
+ const set = new Set(hull.map((p) => p.join(',')))
+ expect(set).toEqual(new Set(['0,0', '4,0', '4,4', '0,4']))
+ })
+
+ test('passes degenerate inputs through', () => {
+ expect(convexHull2D([])).toEqual([])
+ expect(convexHull2D([[1, 2]])).toEqual([[1, 2]])
+ expect(
+ convexHull2D([
+ [0, 0],
+ [2, 2],
+ ]),
+ ).toHaveLength(2)
+ })
+})
+
+describe('rectIntersectsHull', () => {
+ // The regression case: a thin diagonal wall. Its world AABB spans the full
+ // square, so the old AABB test matched a marquee sitting in the empty
+ // corner; the hull test must not.
+ const diagonalWall: Point2[] = convexHull2D([
+ [0, 0],
+ [1, 0.6],
+ [10, 9.4],
+ [10, 10],
+ [9, 9.4],
+ [0, 0.6],
+ ])
+
+ test('marquee in the empty corner of a diagonal shape does not match', () => {
+ expect(rectIntersectsHull(rect(7, 0, 9, 2), diagonalWall)).toBe(false)
+ expect(rectIntersectsHull(rect(0, 7, 2, 9), diagonalWall)).toBe(false)
+ })
+
+ test('marquee crossing the diagonal matches', () => {
+ expect(rectIntersectsHull(rect(4, 4, 6, 6), diagonalWall)).toBe(true)
+ })
+
+ test('hull vertex inside the marquee matches', () => {
+ expect(rectIntersectsHull(rect(-1, -1, 0.5, 0.5), diagonalWall)).toBe(true)
+ })
+
+ test('marquee fully inside a large hull matches', () => {
+ const square: Point2[] = [
+ [0, 0],
+ [10, 0],
+ [10, 10],
+ [0, 10],
+ ]
+ expect(rectIntersectsHull(rect(4, 4, 5, 5), square)).toBe(true)
+ })
+
+ test('edge-only crossing (no vertices contained either way) matches', () => {
+ // Tall thin rect crossing a wide thin hull like a plus sign.
+ const bar: Point2[] = [
+ [0, 4],
+ [10, 4],
+ [10, 6],
+ [0, 6],
+ ]
+ expect(rectIntersectsHull(rect(4, 0, 6, 10), bar)).toBe(true)
+ })
+
+ test('disjoint shapes do not match', () => {
+ const tri: Point2[] = [
+ [0, 0],
+ [2, 0],
+ [1, 2],
+ ]
+ expect(rectIntersectsHull(rect(5, 5, 7, 7), tri)).toBe(false)
+ })
+
+ test('single projected point matches only when inside the rect', () => {
+ expect(rectIntersectsHull(rect(0, 0, 2, 2), [[1, 1]])).toBe(true)
+ expect(rectIntersectsHull(rect(0, 0, 2, 2), [[3, 3]])).toBe(false)
+ })
+})
+
+describe('segmentIntersectsPolygon', () => {
+ const quad: Point2[] = [
+ [0, 0],
+ [4, 0],
+ [4, 4],
+ [0, 4],
+ ]
+ test('crossing segment matches', () => {
+ expect(segmentIntersectsPolygon([-1, 2], [5, 2], quad)).toBe(true)
+ })
+ test('fully inside matches', () => {
+ expect(segmentIntersectsPolygon([1, 1], [2, 2], quad)).toBe(true)
+ })
+ test('outside segment does not match', () => {
+ expect(segmentIntersectsPolygon([5, 5], [7, 8], quad)).toBe(false)
+ })
+})
+
+describe('polygonsIntersect', () => {
+ const quad: Point2[] = [
+ [0, 0],
+ [4, 0],
+ [4, 4],
+ [0, 4],
+ ]
+ test('overlapping polygons match', () => {
+ expect(
+ polygonsIntersect(quad, [
+ [3, 3],
+ [6, 3],
+ [6, 6],
+ [3, 6],
+ ]),
+ ).toBe(true)
+ })
+ test('containment matches both ways', () => {
+ const inner: Point2[] = [
+ [1, 1],
+ [2, 1],
+ [2, 2],
+ ]
+ expect(polygonsIntersect(quad, inner)).toBe(true)
+ expect(polygonsIntersect(inner, quad)).toBe(true)
+ })
+ test('a rotated diamond beside the quad does not match', () => {
+ expect(
+ polygonsIntersect(quad, [
+ [7, 2],
+ [9, 0],
+ [11, 2],
+ [9, 4],
+ ]),
+ ).toBe(false)
+ })
+})
diff --git a/packages/editor/src/components/tools/select/marquee-geometry.ts b/packages/editor/src/components/tools/select/marquee-geometry.ts
new file mode 100644
index 000000000..4d57aa345
--- /dev/null
+++ b/packages/editor/src/components/tools/select/marquee-geometry.ts
@@ -0,0 +1,155 @@
+// Pure 2D geometry for the screen-rect marquee's membership test. The
+// marquee intersects each node's ORIENTED bounding box projected to screen —
+// a convex hull — instead of a screen-space AABB of the world AABB, whose
+// double inflation (world AABB around rotated geometry, then a 2D AABB
+// around its projection) selected objects visually far from the cursor.
+
+export type ScreenRect = {
+ minX: number
+ minY: number
+ maxX: number
+ maxY: number
+}
+
+export type Point2 = [number, number]
+
+function cross(o: Point2, a: Point2, b: Point2): number {
+ return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
+}
+
+/** Andrew monotone-chain convex hull. Returns CCW hull without the closing
+ * point; degenerate inputs (0–2 points, collinear sets) pass through. */
+export function convexHull2D(points: readonly Point2[]): Point2[] {
+ if (points.length <= 2) return [...points]
+ const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1])
+ const lower: Point2[] = []
+ for (const p of sorted) {
+ while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, p) <= 0) {
+ lower.pop()
+ }
+ lower.push(p)
+ }
+ const upper: Point2[] = []
+ for (let i = sorted.length - 1; i >= 0; i--) {
+ const p = sorted[i]!
+ while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, p) <= 0) {
+ upper.pop()
+ }
+ upper.push(p)
+ }
+ lower.pop()
+ upper.pop()
+ const hull = [...lower, ...upper]
+ return hull.length > 0 ? hull : [sorted[0]!]
+}
+
+function pointInRect(p: Point2, rect: ScreenRect): boolean {
+ return p[0] >= rect.minX && p[0] <= rect.maxX && p[1] >= rect.minY && p[1] <= rect.maxY
+}
+
+/** Ray-crossing point-in-polygon — works for convex and degenerate hulls. */
+function pointInPolygon(p: Point2, polygon: readonly Point2[]): boolean {
+ let inside = false
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const [xi, yi] = polygon[i]!
+ const [xj, yj] = polygon[j]!
+ if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) {
+ inside = !inside
+ }
+ }
+ return inside
+}
+
+function orientation(a: Point2, b: Point2, c: Point2): number {
+ const v = cross(a, b, c)
+ if (v > 0) return 1
+ if (v < 0) return -1
+ return 0
+}
+
+function onSegment(a: Point2, b: Point2, p: Point2): boolean {
+ return (
+ Math.min(a[0], b[0]) <= p[0] &&
+ p[0] <= Math.max(a[0], b[0]) &&
+ Math.min(a[1], b[1]) <= p[1] &&
+ p[1] <= Math.max(a[1], b[1])
+ )
+}
+
+function segmentsIntersect(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean {
+ const o1 = orientation(a1, a2, b1)
+ const o2 = orientation(a1, a2, b2)
+ const o3 = orientation(b1, b2, a1)
+ const o4 = orientation(b1, b2, a2)
+ if (o1 !== o2 && o3 !== o4) return true
+ if (o1 === 0 && onSegment(a1, a2, b1)) return true
+ if (o2 === 0 && onSegment(a1, a2, b2)) return true
+ if (o3 === 0 && onSegment(b1, b2, a1)) return true
+ if (o4 === 0 && onSegment(b1, b2, a2)) return true
+ return false
+}
+
+/** Segment vs polygon (general, possibly concave): endpoint containment or
+ * any edge crossing. */
+export function segmentIntersectsPolygon(
+ a: Point2,
+ b: Point2,
+ polygon: readonly Point2[],
+): boolean {
+ if (polygon.length === 0) return false
+ if (polygon.length === 1) return false
+ if (polygon.length >= 3 && (pointInPolygon(a, polygon) || pointInPolygon(b, polygon))) return true
+ for (let i = 0; i < polygon.length; i++) {
+ if (segmentsIntersect(a, b, polygon[i]!, polygon[(i + 1) % polygon.length]!)) return true
+ }
+ return false
+}
+
+/** Polygon vs polygon (general): containment either way or any edge crossing. */
+export function polygonsIntersect(a: readonly Point2[], b: readonly Point2[]): boolean {
+ if (a.length === 0 || b.length === 0) return false
+ if (a.length >= 3 && b.some((p) => pointInPolygon(p, a))) return true
+ if (b.length >= 3 && a.some((p) => pointInPolygon(p, b))) return true
+ for (let i = 0; i < a.length; i++) {
+ const a1 = a[i]!
+ const a2 = a[(i + 1) % a.length]!
+ for (let j = 0; j < b.length; j++) {
+ if (segmentsIntersect(a1, a2, b[j]!, b[(j + 1) % b.length]!)) return true
+ }
+ }
+ return false
+}
+
+/**
+ * Does the axis-aligned marquee rect intersect the (convex) hull polygon?
+ * Covers all cases: hull vertex inside the rect, rect fully inside the hull,
+ * and pure edge crossings.
+ */
+export function rectIntersectsHull(rect: ScreenRect, hull: readonly Point2[]): boolean {
+ if (hull.length === 0) return false
+ for (const p of hull) {
+ if (pointInRect(p, rect)) return true
+ }
+ if (hull.length === 1) return false
+ const rectCorners: Point2[] = [
+ [rect.minX, rect.minY],
+ [rect.maxX, rect.minY],
+ [rect.maxX, rect.maxY],
+ [rect.minX, rect.maxY],
+ ]
+ if (hull.length >= 3) {
+ for (const c of rectCorners) {
+ if (pointInPolygon(c, hull)) return true
+ }
+ }
+ for (let i = 0; i < hull.length; i++) {
+ const h1 = hull[i]!
+ const h2 = hull[(i + 1) % hull.length]!
+ for (let j = 0; j < 4; j++) {
+ const r1 = rectCorners[j]!
+ const r2 = rectCorners[(j + 1) % 4]!
+ if (segmentsIntersect(h1, h2, r1, r2)) return true
+ }
+ }
+ return false
+}
diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx
index 26a6d606f..12f3fec24 100644
--- a/packages/editor/src/components/tools/tool-manager.tsx
+++ b/packages/editor/src/components/tools/tool-manager.tsx
@@ -132,10 +132,16 @@ export const ToolManager: React.FC = () => {
// switches to full polygon editing only after a flag activates site mode.
const showSiteBoundaryEditor = phase === 'site' || mode === 'select'
+ // A multi-selection is manipulated as one rigid group (drag / R / T), so
+ // per-node reshape chrome — the slab / ceiling boundary editors' vertex and
+ // edge handles — mounts only for a sole selection.
+ const isSoleSelection = selectedIds.length === 1
+
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
const showSlabBoundaryEditor =
phase === 'structure' &&
mode === 'select' &&
+ isSoleSelection &&
selectedSlabId !== undefined &&
!editingSlabHoleIsManual
@@ -150,6 +156,7 @@ export const ToolManager: React.FC = () => {
const showCeilingBoundaryEditor =
phase === 'structure' &&
mode === 'select' &&
+ isSoleSelection &&
selectedCeilingId !== undefined &&
(!editingHole || editingHole.nodeId !== selectedCeilingId)
diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx
index f3bacf0c6..402b6d8c4 100644
--- a/packages/editor/src/components/ui/helpers/helper-manager.tsx
+++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx
@@ -162,11 +162,16 @@ export function HelperManager() {
return
}
- // Group-move drag: the drag resolves to the 'item' snap context (see
- // `snapContextOf`), so surface the snapping chips — mode + grid step, with
- // their Shift / Ctrl cycle shortcuts — for the duration.
+ // Group-move drag / pick-up: the drag resolves to the 'item' snap context
+ // (see `snapContextOf`), so surface the snapping chips — mode + grid step,
+ // with their Shift / Ctrl cycle shortcuts — plus the mid-move R/T rotate.
if (activeHandleDrag?.label === GROUP_MOVE_DRAG_LABEL) {
- return
+ return (
+
+ )
}
// Reshaping a node's geometry (endpoint / curve / polygon corner). Checked
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx
index a95c044ee..7e516dcee 100644
--- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx
@@ -78,6 +78,22 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
action: 'Add or remove an object from canvas multi-selection',
note: 'In the scene graph, Shift-click selects the visible range like a file browser.',
},
+ {
+ keys: ['Left click'],
+ action: 'Move the whole multi-selection',
+ note:
+ 'With 2+ objects selected, in 2D and 3D alike: drag the selection (or its dashed box) to slide it; click it to pick it up and place with the next click.',
+ },
+ {
+ keys: ['R', 'T'],
+ action: 'Rotate a multi-selection ±45° around its center',
+ note: 'Also works mid-move while carrying the selection.',
+ },
+ {
+ keys: ['Esc'],
+ action: 'Clear the selection',
+ note: 'Clicking empty space does the same.',
+ },
],
},
{
@@ -86,12 +102,12 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{
keys: ['Cmd/Ctrl', 'Left click'],
action: 'Move the selected movable object under the cursor',
- note: 'Drag in Select mode. Guided snapping and guides are enabled by default.',
+ note: 'Drag in Select mode with a single object selected. Guided snapping and guides are enabled by default.',
},
{
keys: ['Cmd/Ctrl', 'Right click'],
action: 'Rotate the selected object under the cursor',
- note: 'Drag left or right in Select mode. Rotation snaps to 15° increments by default.',
+ note: 'Drag left or right in Select mode with a single object selected. Rotation snaps to 15° increments by default.',
},
{
keys: ['Cmd/Ctrl', 'Shift', 'Right click'],
diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts
index 0a7955b99..d34b58af8 100644
--- a/packages/editor/src/hooks/use-keyboard.ts
+++ b/packages/editor/src/hooks/use-keyboard.ts
@@ -1,6 +1,23 @@
-import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
+import {
+ type AnyNode,
+ type AnyNodeId,
+ emitter,
+ nodeRegistry,
+ pauseSpaceDetection,
+ resumeSpaceDetection,
+ useScene,
+} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
+import { Vector3 } from 'three'
+import {
+ classifyParticipant,
+ collectParticipants,
+ computeGroupBox,
+ expandToComponent,
+ levelFrame,
+ rotateGroupPatches,
+} from '../components/editor/group-transform-shared'
import { steppedRotation } from '../components/tools/item/placement-math'
import { toggleDoorOpenState } from '../lib/door-interaction'
import { guideEmitter } from '../lib/guide-events'
@@ -26,6 +43,53 @@ function getRotatableSelectedReference() {
return node
}
+// Group rotate: R/T on a multi-selection spins the whole selection rigidly
+// ±45° around its bbox center — the keyboard sibling of the 3D group-rotate
+// gizmo, sharing its participant snapshot + rigid-rotation math (welded
+// wall/fence junctions, connected-component expansion). One batched
+// `updateNodes` call = one undo step. Returns false when the selection holds
+// no transformable participants so the caller can fall through to the
+// single-selection arms.
+function rotateGroupSelection(direction: 1 | -1): boolean {
+ const { selectedIds, levelId } = useViewer.getState().selection
+ if (selectedIds.length <= 1) return false
+ const nodes = useScene.getState().nodes
+ const participantIds = selectedIds.filter(
+ (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
+ )
+ if (participantIds.length === 0) return false
+ const fullIds = expandToComponent(participantIds, nodes, levelId)
+ const { starts, links } = collectParticipants(fullIds, nodes, levelId)
+ if (starts.length === 0) return false
+
+ // Same pivot as the 3D gizmo: the selection's world bbox center, converted
+ // into the level frame before orbiting placements (a rotated building would
+ // otherwise displace the centre).
+ const box = computeGroupBox(fullIds)
+ if (!box) return false
+ const worldCenter = new Vector3(
+ (box.min.x + box.max.x) / 2,
+ box.min.y,
+ (box.min.z + box.max.z) / 2,
+ )
+ const localCenter = worldCenter.applyMatrix4(levelFrame(levelId).inverse)
+
+ // R (+45° yaw) orbits by -45° in the atan2 x→z sense: yaw = rotation - delta
+ // (see rotateGroupPatches), so keyboard direction matches the single-node
+ // steppedRotation sense.
+ const delta = -direction * (Math.PI / 4)
+ const patches = rotateGroupPatches(starts, links, { x: localCenter.x, z: localCenter.z }, delta)
+ // Space detection stays out: a rigid rotation of existing walls must not
+ // re-create the room's auto floors/ceilings at the new bearing.
+ pauseSpaceDetection()
+ useScene
+ .getState()
+ .updateNodes(patches.map(([id, data]) => ({ id, data: data as Partial })))
+ resumeSpaceDetection()
+ sfxEmitter.emit('sfx:item-rotate')
+ return true
+}
+
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
// so that the global Escape handler knows not to also switch to select mode.
let _toolCancelConsumed = false
@@ -326,6 +390,14 @@ export const useKeyboard = ({
//
// References (guide/scan) live in `selectedReferenceId`, not the viewer
// selection — check them first, like the Delete arm below.
+ //
+ // Multi-selection branches to the group rotate before any of the
+ // single-selection arms (reference, door/window flip, registry
+ // keyboardActions, plain rotate) — those stay single-selection-only.
+ if (rotateGroupSelection(1)) {
+ e.preventDefault()
+ return
+ }
const rotatableReference = getRotatableSelectedReference()
if (rotatableReference) {
e.preventDefault()
@@ -394,6 +466,11 @@ export const useKeyboard = ({
}
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
// Rotate selected node counter-clockwise
+ // Multi-selection → group rotate, mirroring the R arm above.
+ if (rotateGroupSelection(-1)) {
+ e.preventDefault()
+ return
+ }
const rotatableReference = getRotatableSelectedReference()
if (rotatableReference) {
e.preventDefault()
diff --git a/packages/editor/src/lib/contextual-help.test.ts b/packages/editor/src/lib/contextual-help.test.ts
index f4ea906f0..df5284ea2 100644
--- a/packages/editor/src/lib/contextual-help.test.ts
+++ b/packages/editor/src/lib/contextual-help.test.ts
@@ -58,6 +58,30 @@ describe('resolveSelectModeHelpHints', () => {
})
})
+ test('multi-selection advertises the group move + rotate gestures', () => {
+ const hints = resolveSelectModeHelpHints({
+ selectedCount: 3,
+ hasMovableSelection: true,
+ hasRotatableSelection: true,
+ commandPressed: false,
+ shiftPressed: false,
+ })
+
+ expect(hints).toEqual([
+ {
+ keys: ['Left click'],
+ label: 'Click or drag the selection to move it as one',
+ },
+ { keys: ['R / T'], label: 'Rotate the selection ±45°' },
+ {
+ keys: [['Cmd/Ctrl', 'Shift'], 'Left click'],
+ label: 'Add or remove objects from the selection',
+ active: false,
+ },
+ { keys: ['Esc'], label: 'Clear the selection (or click outside)' },
+ ])
+ })
+
test('holding a modifier keeps the same rows and only lights the selection one', () => {
// Guides/snapping are governed by the snapping mode (Shift toggles it),
// so no modifier-specific "freely / with guides / bypass" variants exist.
diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts
index 6620654cd..098bc3849 100644
--- a/packages/editor/src/lib/contextual-help.ts
+++ b/packages/editor/src/lib/contextual-help.ts
@@ -22,9 +22,10 @@ export const ROTATE_HANDLE_DRAG_LABEL = 'rotate-handle'
// select-mode hints off-screen — a resize is its own action, not a selection.
export const RESIZE_HANDLE_DRAG_LABEL = 'resize-handle'
-// `activeHandleDrag.label`s for the multi-selection group gizmos' drags. Kept
-// here (not in the gizmo components) so `snapping-mode.ts` can resolve the
-// group move to the 'item' snap context without importing component code.
+// `activeHandleDrag.label`s for the multi-selection group drags: the body
+// drag sessions (2D floorplan + 3D ground plane) and the rotate gizmo. Kept
+// here (not in the session/gizmo modules) so `snapping-mode.ts` can resolve
+// the group move to the 'item' snap context without importing component code.
export const GROUP_MOVE_DRAG_LABEL = 'group-move-handle'
export const GROUP_ROTATE_DRAG_LABEL = 'group-rotate-handle'
@@ -60,6 +61,7 @@ const SHIFT_KEY = 'Shift'
const CLICK = 'Click'
const ALT_KEY = 'Alt'
const ROTATE_KEYS = 'R / T'
+const ESC_KEY = 'Esc'
export function resolveSelectModeHelpHints({
selectedCount,
@@ -82,6 +84,24 @@ export function resolveSelectModeHelpHints({
return hints
}
+ // Multi-selection — advertise the group gestures: drag any member to slide
+ // the whole selection (2D floor plan body drag + the 3D move gizmo), R / T
+ // to step-rotate the group around its bounding-box center.
+ if (selectedCount > 1) {
+ hints.push({
+ keys: [LEFT_CLICK],
+ label: 'Click or drag the selection to move it as one',
+ })
+ hints.push({ keys: [ROTATE_KEYS], label: 'Rotate the selection ±45°' })
+ hints.push({
+ keys: [[COMMAND_KEY, SHIFT_KEY], LEFT_CLICK],
+ label: 'Add or remove objects from the selection',
+ active: commandPressed || shiftPressed,
+ })
+ hints.push({ keys: [ESC_KEY], label: 'Clear the selection (or click outside)' })
+ return hints
+ }
+
// MEP handle workflow — duct/pipe runs and fittings are edited through the
// in-world arrow rig that a click on the handle dot reveals, so surface those
// hints first. A run endpoint's side / up-down arrows swing the run and Alt
diff --git a/packages/editor/src/lib/floorplan/plan-coords.ts b/packages/editor/src/lib/floorplan/plan-coords.ts
new file mode 100644
index 000000000..ac49eb5ab
--- /dev/null
+++ b/packages/editor/src/lib/floorplan/plan-coords.ts
@@ -0,0 +1,21 @@
+import type { FloorplanAffordancePoint } from '@pascal-app/core'
+
+/**
+ * Convert client (screen) coordinates into floor-plan plan coordinates via
+ * the mounted floor-plan scene ``'s screen CTM. The scene `` maps plan
+ * X/Z directly to SVG x/y (Z stored as the Y axis on screen — same convention
+ * as `toSvgPlanPoint`), so the returned point is in the same level-frame
+ * meters node placements use. Null when no floor plan is mounted.
+ */
+export function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null {
+ const target = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
+ const svg = target?.ownerSVGElement
+ if (!(svg && target)) return null
+ const ctm = target.getScreenCTM()
+ if (!ctm) return null
+ const point = svg.createSVGPoint()
+ point.x = clientX
+ point.y = clientY
+ const transformed = point.matrixTransform(ctm.inverse())
+ return [transformed.x, transformed.y]
+}
diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts
index b40f96acc..f27c1f1f5 100644
--- a/packages/editor/src/lib/scene-clipboard.ts
+++ b/packages/editor/src/lib/scene-clipboard.ts
@@ -176,9 +176,8 @@ function remapNodeReferences(
return AnyNode.parse(clone)
}
-export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
+function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
const scene = useScene.getState()
- const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
const selectedIdSet = new Set(ids)
const rootIds = ids.filter((id) => {
const node = scene.nodes[id]
@@ -191,7 +190,7 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
})
if (rootIds.length === 0) {
- return false
+ return null
}
const subtreeIds = new Set()
@@ -199,7 +198,7 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
collectSubtreeIds(scene.nodes, rootId, subtreeIds)
}
- clipboardPayload = {
+ return {
copiedAt: Date.now(),
nodes: [...subtreeIds]
.map((id) => scene.nodes[id])
@@ -207,15 +206,45 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
.map((node) => JSON.parse(JSON.stringify(node)) as AnyNode),
rootIds,
}
+}
+
+export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
+ const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
+ const payload = buildClipboardPayload(ids)
+ if (!payload) return false
+
+ clipboardPayload = payload
notifySubscribers()
return true
}
+/**
+ * Clone the given nodes (subtrees included, ids remapped) onto the target /
+ * active level in place — the same copy + paste pipeline in one step, WITHOUT
+ * touching the user's editor clipboard. Selects the clones. Used by the group
+ * action menu's Duplicate.
+ */
+export function duplicateNodesToLevel(
+ ids: AnyNodeId[],
+ targetLevelId?: AnyNodeId,
+): PasteResult | null {
+ const payload = buildClipboardPayload(ids)
+ if (!payload) return null
+ return applyClipboardPayloadToLevel(payload, targetLevelId)
+}
+
export function pasteEditorClipboardToLevel(targetLevelId?: AnyNodeId): PasteResult | null {
- const payload = clipboardPayload
+ if (!clipboardPayload) return null
+ return applyClipboardPayloadToLevel(clipboardPayload, targetLevelId)
+}
+
+function applyClipboardPayloadToLevel(
+ payload: ClipboardPayload,
+ targetLevelId?: AnyNodeId,
+): PasteResult | null {
const targetLevel = getPasteTargetLevel(targetLevelId)
- if (!payload || !targetLevel) return null
+ if (!targetLevel) return null
const scene = useScene.getState()
const idMap = new Map()
diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts
index 0f97d0ccf..8b335cca9 100644
--- a/packages/nodes/src/door/definition.ts
+++ b/packages/nodes/src/door/definition.ts
@@ -9,6 +9,7 @@ import type {
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
+import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides'
import { scaleHandleHeight } from './door-math'
import { buildDoorFloorplan } from './floorplan'
import { doorWidthAffordance } from './floorplan-affordances'
@@ -226,6 +227,10 @@ export const doorDefinition: NodeDefinition = {
// direction + perpendicular for the cutout footprint.
floorplan: buildDoorFloorplan,
floorplanDependsOnSiblings: true,
+ // Opening symbols position from `ctx.parent` (the host wall); merge the
+ // walls' live drag overrides so the symbol tracks a wall / group drag in
+ // realtime instead of jumping on commit.
+ floorplanSiblingOverrides: wallFloorplanSiblingOverrides,
// Stage D — placement (`def.tool`) + move-on-wall (`def.
// affordanceTools.move`). Both ports of the legacy tools at
// `editor/components/tools/door/`, relocated into the kind folder and
diff --git a/packages/nodes/src/item/floorplan.ts b/packages/nodes/src/item/floorplan.ts
index 365d896fc..865290ea6 100644
--- a/packages/nodes/src/item/floorplan.ts
+++ b/packages/nodes/src/item/floorplan.ts
@@ -185,7 +185,11 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
})
const isSelected = ctx.viewState?.selected ?? false
+ // Marquee preview — the about-to-be-selected tint every other kind shows.
+ const isHighlighted = ctx.viewState?.highlighted ?? false
+ const showSelection = isSelected || isHighlighted
const isMoving = ctx.viewState?.moving ?? false
+ const selectedStroke = ctx.viewState?.palette?.selectedStroke ?? '#3b82f6'
const floorPlanUrl = node.asset.floorPlanUrl
const children: FloorplanGeometry[] = [
{
@@ -200,8 +204,11 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
// the child renders a paintable surface. `fill="none"` would make
// clicks pass through to whatever's beneath, breaking selection.
fill: floorPlanUrl ? 'transparent' : '#fef3c7',
- stroke: '#92400e',
- strokeWidth: 0.012,
+ // Selected items read as selected: palette stroke + heavier weight
+ // (the move dot used to be the only cue, and it hides in
+ // multi-selections).
+ stroke: showSelection ? selectedStroke : '#92400e',
+ strokeWidth: showSelection ? 0.035 : 0.012,
opacity: 0.85,
},
]
@@ -222,6 +229,19 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
rotation: -transform.rotation,
})
}
+ // Selection ring ABOVE the thumbnail — the base polygon's selected stroke
+ // sits underneath the image, so re-draw it on top. `fill: none` keeps the
+ // ring click-through; the base polygon owns hit-testing.
+ if (showSelection && floorPlanUrl) {
+ children.push({
+ kind: 'polygon',
+ points,
+ fill: 'none',
+ stroke: selectedStroke,
+ strokeWidth: 0.035,
+ opacity: isSelected ? 0.95 : 0.7,
+ })
+ }
// Move handle — orange dot at the item center. Only when selected and not
// already moving: during a move the dot sits under the cursor, so a release
// over it would re-arm the move (and re-enter edit) instead of committing.
diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts
index aeda9f6a5..6c6e8b438 100644
--- a/packages/nodes/src/window/definition.ts
+++ b/packages/nodes/src/window/definition.ts
@@ -9,6 +9,7 @@ import type {
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
+import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides'
import { buildWindowFloorplan } from './floorplan'
import { windowWidthAffordance } from './floorplan-affordances'
import { windowFloorplanMoveTarget } from './floorplan-move'
@@ -215,6 +216,10 @@ export const windowDefinition: NodeDefinition = {
// + thickness — same shape as door.
floorplan: buildWindowFloorplan,
floorplanDependsOnSiblings: true,
+ // Opening symbols position from `ctx.parent` (the host wall); merge the
+ // walls' live drag overrides so the symbol tracks a wall / group drag in
+ // realtime instead of jumping on commit.
+ floorplanSiblingOverrides: wallFloorplanSiblingOverrides,
// Stage D — placement + move-on-wall. Same recipe as door. See
// `nodes/src/window/{tool,move-tool,window-math}.ts`.
tool: () => import('./tool'),
diff --git a/packages/nodes/src/zone/renderer.tsx b/packages/nodes/src/zone/renderer.tsx
index 4f2ae3b7a..271a35435 100644
--- a/packages/nodes/src/zone/renderer.tsx
+++ b/packages/nodes/src/zone/renderer.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useRegistry, type ZoneNode } from '@pascal-app/core'
+import { useLiveNodeOverrides, useRegistry, type ZoneNode } from '@pascal-app/core'
import { useNodeEvents, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useMemo, useRef } from 'react'
@@ -115,37 +115,46 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
// its scene identity (selection registry, GLB export polygon stamping).
const showZones = useViewer((s) => s.showZones)
+ // Group-transform drags publish a translated/rotated polygon to
+ // `useLiveNodeOverrides`; merge it so the zone previews live instead of
+ // snapping only on commit (slab/ceiling get this via their systems'
+ // `getEffectiveNode`). Per-node subscription — unrelated overrides don't
+ // re-render this zone.
+ const livePolygon = useLiveNodeOverrides((s) => s.overrides.get(node.id)?.polygon) as
+ | Array<[number, number]>
+ | undefined
+ const polygon = livePolygon ?? node?.polygon
+
// Create floor shape from polygon
const floorShape = useMemo(() => {
- if (!node?.polygon || node.polygon.length < 3) return null
+ if (!polygon || polygon.length < 3) return null
const shape = new Shape()
- const firstPt = node.polygon[0]!
+ const firstPt = polygon[0]!
// Shape is in X-Y plane, we rotate it to X-Z plane
// Negate Y (which becomes Z) to get correct orientation
shape.moveTo(firstPt[0]!, -firstPt[1]!)
- for (let i = 1; i < node.polygon.length; i++) {
- const pt = node.polygon[i]!
+ for (let i = 1; i < polygon.length; i++) {
+ const pt = polygon[i]!
shape.lineTo(pt[0]!, -pt[1]!)
}
shape.closePath()
return shape
- }, [node?.polygon])
+ }, [polygon])
// Create wall geometry from polygon
const wallGeometry = useMemo(() => {
- if (!node?.polygon || node.polygon.length < 2) return null
- return createWallGeometry(node.polygon)
- }, [node?.polygon])
+ if (!polygon || polygon.length < 2) return null
+ return createWallGeometry(polygon)
+ }, [polygon])
// Calculate polygon centroid for label positioning using the geometric centroid formula
// This correctly handles polygons regardless of vertex distribution along edges
const centroid = useMemo(() => {
- if (!node?.polygon || node.polygon.length < 3) return [0, 0] as [number, number]
+ if (!polygon || polygon.length < 3) return [0, 0] as [number, number]
- const polygon = node.polygon
let signedArea = 0
let cx = 0
let cz = 0
@@ -165,7 +174,7 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
const factor = 1 / (6 * signedArea)
return [cx * factor, cz * factor] as [number, number]
- }, [node?.polygon])
+ }, [polygon])
// Create materials
const floorMaterial = useMemo(() => {