Skip to content

editor: group manipulation — Photoshop-style multi-selection move, rotate, duplicate#481

Merged
wass08 merged 23 commits into
mainfrom
feat/group-manipulation
Jul 9, 2026
Merged

editor: group manipulation — Photoshop-style multi-selection move, rotate, duplicate#481
wass08 merged 23 commits into
mainfrom
feat/group-manipulation

Conversation

@wass08

@wass08 wass08 commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Makes a multi-selection behave like one object, in both views. Box-select (or meta-click) several nodes and:

  • Move it as one — drag anywhere on a member or on the dashed selection box (2D SVG rect / 3D wireframe box, both are drag handles), or click a member to pick the group up and click again to place. Space detection is paused for the whole gesture, so group transforms never re-create auto rooms; ceiling/slab/zone-attached items ride along; welded wall junctions move coherently. One undo step restores everything.
  • Rotate itR/T rotate ±45° around the group's bbox center (idle and mid-carry), and the 2D box grows corner rotate handles (15° steps, Shift for free rotation) with a proper rotate cursor and a live spinning box.
  • Group action pill — Move / Duplicate / Delete for the whole selection in both views; Duplicate clones the selection and picks the clones up; Delete confirms above 10 nodes.
  • Quiet chrome — members show highlight only (per-node handles hidden), per-node direct-manipulation gestures stand down while a multi-selection exists, the site's dashed property line dims so it can't be confused with the selection box, and doors/windows keep their 2D symbols live during drags.
  • Precise marquee — membership is tested against plan footprints (exact segment/polygon tests for data kinds, projected oriented bounds for meshes) instead of double-inflated screen AABBs, in both the 3D screen marquee and the 2D floorplan marquee; items get a marquee preview tint in 2D.
  • Selection overlays (dashed boxes, pill anchor, rotate pivot) re-measure after undo/redo via a shared mesh-settle epoch.

New shared geometry lives in group-transform-shared.ts (participant classification, group patches/snapshots) and marquee-geometry.ts, both with colocated unit tests (22 new tests). Helper texts and the shortcuts dialog document the new gestures.

How to test

  1. bun dev, open a project with a few walls + a slab, and box-select them in 2D.
  2. Drag inside the dashed box (or any member) — the group moves as one; undo once — everything restores.
  3. Press R/T — the group rotates ±45° around its center; drag a corner handle of the 2D box — 15° steps, Shift = free.
  4. Use the floating pill: Move (click to place), Duplicate (clones follow the cursor), Delete.
  5. Switch to 3D and repeat 2–4 — body drag shows a move cursor, the dashed wireframe box is a drag handle, click-to-pick-up works.
  6. Marquee precision: draw a marquee near (not over) a rotated slab or diagonal wall in both views — it must not get selected.

Screenshots / screen recording

N/A — will add a short recording on request; the "How to test" steps cover the interactive surface.

Checklist

  • I've tested this locally with bun dev (scripted Playwright smoke: 37/37 checks across 2D/3D/split, plus manual passes)
  • My code follows the existing code style (run bun check to verify)
  • I've updated relevant documentation (helper texts + keyboard-shortcuts dialog)
  • This PR targets the main branch

Note

Medium Risk
Large interaction surface touching scene updates, selection, and split-view pointer routing; mitigated by shared math, history batching, and new unit tests, but regressions in marquee or group weld behavior are plausible.

Overview
Adds unified multi-selection manipulation in the floor plan and 3D canvas: drag a member or the dashed selection box to move the group rigidly (grid/alignment snap, welded walls, one undo step); click without dragging enters pick-up / place; R/T and 2D corner handles rotate ±45° or in 15° steps. Replaces the old 3D group-move cross gizmo with dashed selection boxes as drag handles in both views.

Introduces shared group-actions (move, duplicate-with-pick-up, bulk delete) and group-transform-shared extensions for polygon slabs/zones/ceilings and attached children, plus marquee-geometry so box-select uses plan footprints and oriented screen hulls instead of inflated AABBs.

UX polish: group action pills in 2D/3D, sole-selection-only per-node menus and direct manipulation, hidden per-node edit chrome during multi-select, dimmed site boundary, pauseSpaceDetection on group commits, mesh-settle epoch for bbox overlays after undo, and updated shortcuts/help text.

Reviewed by Cursor Bugbot for commit d55e464. Bugbot is set up for automated code reviews on this repo. Configure here.

wass08 and others added 23 commits July 8, 2026 15:19
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 <noreply@anthropic.com>
…oup 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 <noreply@anthropic.com>
…on 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
data-group-selection-box on the overlay <g> so smoke drivers and devtools
can assert the multi-selection chrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…it 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…orms

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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…s, 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 <noreply@anthropic.com>
…e 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 <noreply@anthropic.com>
…tions

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 <noreply@anthropic.com>
…ed 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…ew; 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d55e464. Configure here.

useViewer.getState().setSelection({ selectedIds: [] })
},
})
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate ignores pick-up failure

Medium Severity

The duplicateSelectionAndPickUp function commits cloned nodes to the scene even if startGroupPickUp fails to initiate a drag. This leaves duplicates stacked on the originals without a carry session, and the function incorrectly returns success.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d55e464. Configure here.

/>
{/* Corner rotate handles — the 2D counterpart of the 3D rotate gizmo:
drag a corner to spin the group (15° steps, Shift = free). */}
{interactive && onRotatePointerDown

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shift blocks 2D rotate handles

Medium Severity

The 2D group selection box's corner rotate handles are inactive when Shift is held, preventing a rotate drag from starting with Shift already down. This occurs because Shift is incorrectly treated as a pass-through modifier, which conflicts with its intended use for free rotation during a drag.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d55e464. Configure here.

dx += result.snap.dx
dz += result.snap.dz
}
useAlignmentGuides.getState().set(result.guides)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Floorplan pick-up wrong alignment

Medium Severity

During group pick-up, magnetic alignment always uses core resolveAlignment, while the new 2D group drag uses applyFloorplanAlignment / the floor-plan view frame. Carrying a selection over the floor plan (especially with a rotated building) can show misaligned guides and snap the group off the visible grid.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d55e464. Configure here.

if (!session) {
removeListeners()
opts.onClickFallthrough?.()
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2D pick-up missing click guard

Medium Severity

On a plain click that starts group pick-up from the 2D move session, onUp only calls onClickFallthrough and does not swallow the following click or briefly raise inputDragging. The 3D group-move path does both before startGroupPickUp, specifically to suppress synthetic selection clicks in split view.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d55e464. Configure here.

@wass08 wass08 merged commit 352954a into main Jul 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant