From 352954aafc66180338258bd1765eb0ecc7b0ee13 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 9 Jul 2026 15:10:35 -0400 Subject: [PATCH] =?UTF-8?q?editor:=20group=20manipulation=20=E2=80=94=20Ph?= =?UTF-8?q?otoshop-style=20multi-selection=20move,=20rotate,=20duplicate?= =?UTF-8?q?=20(#481)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(editor): group R/T — keyboard rotate for multi-selections Extract the rigid rotate/translate math from the group gizmos into group-transform-shared (rotateGroupPatches / translateGroupPatches) and add a keyboard group-rotate path: R/T on a multi-selection steps the whole group ±45° around its bbox center, welded junctions and connected-component expansion included, committed as one updateNodes batch (one undo step). Single-selection R/T arms (reference, door/window flip, registry keyboardActions) are untouched. Co-Authored-By: Claude Fable 5 * feat(editor): polygon participant kind — slabs/ceilings/zones join group transforms classifyParticipant learns a 'polygon' kind ([x,z] vertex arrays + optional hole rings): slab/ceiling/zone now ride along in group move and rotate — the two 3D gizmos and the keyboard group R/T all flow through the shared rotateGroupPatches/translateGroupPatches, so the wiring is the classifier plus the rotate gizmo's spread sampling. Zone's 3D renderer merges its live polygon override so it previews during the drag (slab/ceiling already rebuild via getEffectiveNode in their systems). Classifier + patch unit tests included. Co-Authored-By: Claude Fable 5 * feat(editor): 2D floorplan group drag — Photoshop-style multi-selection move Plain pointer-down on a transformable member of a multi-selection now slides the whole selection rigidly in the floor plan (the 2D parity fill for the 3D GroupMoveHandle), instead of collapsing the selection to the pressed node. A plain click (no drag) still collapses on release; modified clicks, Cmd-drag direct move, and reshape affordances keep their paths. The move-handle dot routes through the same group session when its node is part of the multi-selection. The session shares group-transform-shared verbatim: welded LinkedNeighbor endpoints, connected-component expansion, live previews via useLiveNodeOverrides.setMany, grid snapping on the delta plus Figma alignment through applyFloorplanAlignment (guides in every mode except off, magnetic pull in lines mode), interaction scope handle-drag with the shared group-move label, and a single batched updateNodes (one undo). A dashed group bbox overlay shows what rides along and tracks the live delta. clientToPlan moves to lib/floorplan/plan-coords for reuse. Co-Authored-By: Claude Fable 5 * feat(editor): group manipulation polish — helper text + shortcuts dialog Multi-selection select-mode HUD now advertises the group gestures (drag any member to move the whole selection, R/T to step-rotate), and the keyboard-shortcuts dialog documents both under Selection. Co-Authored-By: Claude Fable 5 * fix(editor): restore zone renderer useLiveNodeOverrides import Formatter stripped it between the import and usage edits; nodes' tsc --build catches it where the cached turbo typecheck did not. Co-Authored-By: Claude Fable 5 * feat(editor): tag the 2D group selection box for drivability data-group-selection-box on the overlay so smoke drivers and devtools can assert the multi-selection chrome. Co-Authored-By: Claude Fable 5 * feat(editor): multi-selection shows highlight only — hide per-node edit chrome While 2+ nodes are selected the group is manipulated as one rigid piece, so per-member edit affordances hide in both views: the 2D overlay pass strips handle/dimension chrome centrally (stripHandleChrome — body highlight, hit targets, and zone name text stay), and in 3D the slab / ceiling boundary editors and slab hole click-to-edit outlines mount only for a sole selection (the arrow-handle rig, wall side arrows, selection affordances, and floating menu were already single-gated). Transformable members show a move cursor in the floor plan. Co-Authored-By: Claude Fable 5 * feat(editor): 3D body-drag group move replaces the move gizmo cross Pressing any transformable member of a multi-selection in 3D and dragging past the threshold now slides the whole selection on the ground plane (group-move-3d.ts, armed from the selection manager's pointer-down choke point) — same participant snapshot, welded junctions, grid + alignment snapping, live overrides, and single-undo commit as the 2D session; the 2D dashed bbox rides the same delta in split view. A plain click still collapses the selection to the pressed node. The purple GroupMoveHandle cross is deleted, and the hover cursor advertises the gesture: 'move' over any transformable member in both views (3D selection-manager cursor + 2D entry cursor). Co-Authored-By: Claude Fable 5 * feat(editor): group action menu — move / duplicate / delete the whole selection Multi-selections get a floating Move / Duplicate / Delete pill in BOTH views (GroupFloatingActionMenu anchored above the 3D group bbox, FloorplanGroupActionMenu anchored above the 2D dashed group box); the single-node menus are now sole-selection only. All three actions target the whole selection via shared group-actions: - Move starts a group pick-up: the selection rides the cursor (delta-relative, grid + alignment snapped) across BOTH surfaces — floorplan via the scene CTM, 3D via a ground-plane raycast through a three-context bridge published by the selection manager — until a click places it as one undo step; Escape / right-click cancels. - Duplicate clones the selection through the clipboard pipeline (subtrees + id remap, without touching the user's clipboard — new duplicateNodesToLevel), selects the clones, and picks them up; cancelling deletes the clones again. The pick-up scopes to the selection (no component expansion / welded links) because the clones sit exactly on the originals, and falls back to participant-data bounds because the clones' meshes haven't mounted yet. - Delete removes everything selected with the keyboard arm's bulk-confirm semantics. Verified end-to-end with the scripted smoke (16/16): chrome hiding, 2D/3D/split group drags, R/T round-trip, one-step undo, menu move / duplicate / delete, 3D move cursor. (Headless runs of the suite can deadlock in SwiftShader's GL readback on the split-view resize — environment-only; run headed.) Co-Authored-By: Claude Fable 5 * fix(editor): group transforms no longer re-create auto rooms Every group commit (2D/3D body drags, pick-up, rotate gizmo, keyboard R/T) now wraps its single updateNodes in pauseSpaceDetection / resumeSpaceDetection: the wall-driven room auto-detection rolls its baseline forward instead of treating rigidly-moved walls as a new room and duplicating its floors/ceilings. Room creation stays where it belongs — building and editing walls. Co-Authored-By: Claude Fable 5 * fix(editor): polygon hosts carry their attached items in group transforms Ceiling-mounted items are children of the ceiling, but polygon hosts (slab/ceiling) move by rewriting vertices — no group transform — so, unlike wall children which ride the wall mesh, their children were left behind by group moves/rotates. collectParticipants now snapshots a polygon participant's positioned children (level-frame coords, since the host group sits at the origin) so they ride every group path. Unit test included. Co-Authored-By: Claude Fable 5 * feat(editor): click picks up the group; the 2D dashed box is the drag handle Clicking a selected member of a multi-selection (no drag) now enters the group pick-up — the same click-to-move the sole-selection has — instead of collapsing the selection, in 2D and 3D alike; clicking outside still deselects. The 2D dashed selection box is itself the group's handle: move cursor across its whole area, press-drag anywhere inside slides the group, a plain click picks it up, and holding Cmd/Ctrl/Shift lets clicks pass through to the entries so membership toggling keeps working. HUD hint + shortcuts dialog updated. Co-Authored-By: Claude Fable 5 * feat(editor): 3D dashed group selection box doubles as the drag handle The multi-selection now shows a dashed wireframe box in 3D (sibling of the 2D dashed rect): move cursor across its whole volume, press-drag anywhere on it slides the group, a plain click picks it up, and holding a selection modifier passes the press through so members inside can still be toggled. Rides the shared live-drag delta so it tracks the group mid-gesture in both panes. Co-Authored-By: Claude Fable 5 * feat(editor): mid-move R/T, 2D corner rotate, realtime opening symbols, box cursor + Esc hint - R/T while carrying a group (body drag or pick-up) now rotates the carried snapshots around the rest pivot — identical to the idle keyboard rotate — instead of leaking into the store mid-session; the session keydown owns the keys in capture phase and the group-move HUD advertises them. - The 2D dashed box grows corner rotate handles: drag a corner to spin the group in 15° steps, Shift for free rotation — the floor-plan sibling of the 3D rotate gizmo, sharing its scope, HUD hints, and single-undo + space-detection-paused commit. - Door / window floor-plan symbols now track wall drags in realtime: their defs merge the walls' live overrides (wallFloorplanSiblingOverrides) so ctx.parent is the effective wall instead of the stale store one. - The 3D selection box asserts the move cursor across its whole volume on pointer move (the old ''-guard lost to app default cursors). - Multi-select HUD + shortcuts dialog mention Esc / click-outside to clear, and the mid-move rotate. Co-Authored-By: Claude Fable 5 * feat(editor): rotate cursor + spinning box on 2D corner rotate; Delete ends a carry - The 2D corner handles show a proper curved-arrow rotate cursor (inline SVG data URI, black glyph with white halo, grab fallback). - The dashed selection box spins live with the group during a corner rotate: the rotate session publishes pivot+angle through the shared drag store and the box applies the SVG rotate transform. - Delete / Backspace during any group session (drag, rotate, pick-up) reverts the session first and then lets the global Delete arm remove the selection — no more dangling carry after deleting mid-move; the duplicate flow's cancel still discards the clones instead. Co-Authored-By: Claude Fable 5 * fix(editor): per-node direct manipulation stands down for multi-selections Cmd is both the selection-toggle key and the single-node direct-move / direct-rotate trigger, so a Cmd+click that wobbled past the drag threshold over a selected member (slab or any kind) armed the legacy single-node move and yanked that one member out of the group. All four per-node direct-manipulation entry points — the 3D Cmd-drag move and Cmd+right-drag rotate in the selection manager, and their 2D siblings in the registry layer — now require the pressed node to be the SOLE selection; with a multi-selection the gesture is simply not registered (the group sessions own plain drags, Cmd+click keeps toggling). The shortcuts dialog notes the single-selection scope. Co-Authored-By: Claude Fable 5 * fix(editor): group gestures own their ending pointerup — no synthesized click leak use-node-events synthesizes a selection click on EVERY canvas pointerup (deliberately more forgiving than R3F's onClick). Body drags were safe only because inputDragging suppresses it, but the pick-up flows leaked it as a race: the release that started a pick-up could collapse the multi-selection to the pressed node first (then only that node was carried), and the placement press re-selected whatever sat under the cursor after commit. The group sessions now claim their gesture-ending pointer events in the CAPTURE phase and stop propagation, so the canvas never sees them and no click is synthesized: the pick-up claims the placement press on pointerdown and commits on its pointerup; the 3D and 2D drag/rotate sessions stop their ending pointerup (split view: a 2D-started release over the 3D canvas had the same hole). Co-Authored-By: Claude Fable 5 * fix(editor): screen-rect marquee tests projected oriented bounds, not double-inflated AABBs The 3D screen-rect box select intersected the marquee with each node's world AABB re-boxed in screen space — two nested axis-aligned inflations. Any oblique camera padded every node's claim, and rotated geometry (now common: group rotation) made it flagrant — a diagonal wall's world AABB spans a full square, so marquees selected objects visually far from the cursor. Membership now tests the marquee against the convex hull of the node's ORIENTED (local-frame) bounding box projected to screen — tight under any rotation and camera, uniform for every kind. Pure helpers (convexHull2D / rectIntersectsHull) live in marquee-geometry.ts with unit tests covering the diagonal-wall regression, containment both ways, and edge-only crossings. The plane marquee variant was already precise and is untouched. Co-Authored-By: Claude Fable 5 * fix(editor): keep box select alive after group gestures The capture-phase stopPropagation the sessions used to silence the canvas's synthesized selection click also killed the window bubble listeners that clear the box-select pointer suppression — and the mouse pointerId is constant, so one group gesture left the marquee dead until blur. The sessions now use the designed suppression instead: they hold inputDragging raised through the release event's dispatch (lowered on a 0ms timer) so use-node-events suppresses the click itself, and every other pointerup listener runs normally. Co-Authored-By: Claude Fable 5 * fix(editor): marquee membership by plan footprint; 2D item highlight; site line steps back - The 3D screen-rect marquee now projects itself onto the active level's floor plane and tests the DATA kinds exactly there — walls/fences by their segment, slabs/ceilings/zones by their polygon — matching the plane-marquee tool's semantics. This kills the two remaining imprecision sources the hull pass couldn't: vertex-baked rotation (polygon/fence geometry lives in level coords, so after a group rotation even the local bbox is an inflated axis-aligned square) and ceiling parallax (an elevated plane projects to a screen quad offset from its room, selecting 'from far'). Mesh-transform kinds (items, columns) keep the projected oriented-bbox hull test; plan-space segment/polygon intersection helpers join marquee-geometry.ts with unit tests. - Selected items finally read as selected in the floor plan: palette stroke + heavier weight on the footprint, plus a selection ring drawn above the thumbnail (the move dot used to be the only cue, and it hides in multi-selections). - The site's dashed property line drops to 20% opacity while a multi or in-flight marquee selection exists so it stops fighting the dashed group selection box. Co-Authored-By: Claude Fable 5 * fix(editor): 2D marquee plan-footprint membership; item marquee preview; overlays re-measure after undo - The 2D screen marquee intersected each entry's getBoundingClientRect — an axis-aligned DOM box, inflated for rotated polygons and diagonal walls (the same double-inflation class as the 3D bug). It now maps the marquee through the scene CTM into a plan quad (rotated views included) and tests the data kinds exactly — walls/fences by segment, slab/ceiling/zone by polygon — reusing the shared marquee-geometry helpers; other kinds keep the DOM-rect fallback. - Items now show the marquee preview tint in 2D (highlighted viewState = the same palette stroke + thumbnail ring the selection shows, slightly lighter), instead of giving zero feedback until the drop. - Everything that measures the selection's meshes (2D/3D dashed boxes, the 3D group menu anchor, the rotate gizmo pivot) re-measures once the meshes settle after a scene change via useMeshSettleEpoch — an undo after a move no longer leaves the box at the pre-undo position (computeGroupBox reads mesh world bounds, which lag the store commit by a frame or two). Co-Authored-By: Claude Fable 5 * fix(editor): dim the actual site boundary line during multi-selections The earlier dimming targeted registry-layer site entries, but the site lives at the scene root — outside the level DFS and the building-scoped sweep — so no entry ever matched and the dashed property line kept full opacity next to the dashed group selection box. Move the dimming to FloorplanSiteLayer (the component that actually draws the line) and drop the dead registry plumbing. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../editor-2d/floorplan-group-action-menu.tsx | 87 +++ .../editor-2d/floorplan-group-move.tsx | 698 ++++++++++++++++++ .../floorplan-registry-action-menu.tsx | 6 +- .../renderers/floorplan-registry-layer.tsx | 184 ++++- .../src/components/editor/floorplan-panel.tsx | 81 +- .../src/components/editor/group-actions.ts | 457 ++++++++++++ .../editor/group-floating-action-menu.tsx | 130 ++++ .../src/components/editor/group-move-3d.ts | 395 ++++++++++ .../components/editor/group-move-handle.tsx | 401 ---------- .../components/editor/group-rotate-handle.tsx | 78 +- .../editor/group-selection-box-3d.tsx | 173 +++++ .../editor/group-transform-shared.test.ts | 193 ++++- .../editor/group-transform-shared.ts | 247 ++++++- .../editor/src/components/editor/index.tsx | 6 +- .../components/editor/selection-manager.tsx | 72 +- .../editor/slab-hole-highlights.tsx | 4 +- .../components/editor/three-context-bridge.ts | 22 + .../editor/use-mesh-settle-epoch.ts | 26 + .../tools/select/box-select-tool.tsx | 181 ++++- .../tools/select/marquee-geometry.test.ts | 161 ++++ .../tools/select/marquee-geometry.ts | 155 ++++ .../src/components/tools/tool-manager.tsx | 7 + .../components/ui/helpers/helper-manager.tsx | 13 +- .../keyboard-shortcuts-dialog.tsx | 20 +- packages/editor/src/hooks/use-keyboard.ts | 79 +- .../editor/src/lib/contextual-help.test.ts | 24 + packages/editor/src/lib/contextual-help.ts | 26 +- .../editor/src/lib/floorplan/plan-coords.ts | 21 + packages/editor/src/lib/scene-clipboard.ts | 41 +- packages/nodes/src/door/definition.ts | 5 + packages/nodes/src/item/floorplan.ts | 24 +- packages/nodes/src/window/definition.ts | 5 + packages/nodes/src/zone/renderer.tsx | 33 +- 33 files changed, 3487 insertions(+), 568 deletions(-) create mode 100644 packages/editor/src/components/editor-2d/floorplan-group-action-menu.tsx create mode 100644 packages/editor/src/components/editor-2d/floorplan-group-move.tsx create mode 100644 packages/editor/src/components/editor/group-actions.ts create mode 100644 packages/editor/src/components/editor/group-floating-action-menu.tsx create mode 100644 packages/editor/src/components/editor/group-move-3d.ts delete mode 100644 packages/editor/src/components/editor/group-move-handle.tsx create mode 100644 packages/editor/src/components/editor/group-selection-box-3d.tsx create mode 100644 packages/editor/src/components/editor/three-context-bridge.ts create mode 100644 packages/editor/src/components/editor/use-mesh-settle-epoch.ts create mode 100644 packages/editor/src/components/tools/select/marquee-geometry.test.ts create mode 100644 packages/editor/src/components/tools/select/marquee-geometry.ts create mode 100644 packages/editor/src/lib/floorplan/plan-coords.ts 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 00000000..47274f11 --- /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 00000000..a987866b --- /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 03e4a14f..c209c87d 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 cb7a949f..b899ea24 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} >