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

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* feat(editor): tag the 2D group selection box for drivability

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>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-09 15:10:35 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent feeabf4bd3
commit 352954aafc
33 changed files with 3487 additions and 568 deletions
@@ -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(
<div
className="pointer-events-none fixed z-30"
style={{
left: position.left,
top: position.top,
transform: 'translate(-50%, calc(-100% - 12px))',
}}
>
<NodeActionMenu
onDelete={() => deleteSelection()}
onDuplicate={() => duplicateSelectionAndPickUp()}
onMove={() => startGroupPickUp()}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
</div>,
document.body,
)
}
@@ -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<FloorplanGroupDragState>((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<typeof collectParticipants>['starts']
links: ReturnType<typeof collectParticipants>['links']
affectedIds: AnyNodeId[]
candidates: ReturnType<typeof collectAlignmentAnchors>
restAnchors: ReturnType<typeof bboxCornerAnchors>
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<string>(affectedIds)
const staticNodes: Record<string, AnyNode> = {}
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<AnyNode> }[] = []
for (const id of session.affectedIds) {
const patch = overrides.get(id)
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
}
// 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<AnyNode> }[] = []
for (const id of affectedIds) {
const patch = overrides.get(id)
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
}
// 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(
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24"><g fill="none" stroke="#fff" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></g><g fill="none" stroke="#000" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></g></svg>',
)
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 `<g>`, so
* plan coords render directly.
*/
export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionBox({
palette,
unitsPerPixel,
onPointerDown,
onRotatePointerDown,
}: {
palette: FloorplanPalette | undefined
unitsPerPixel: number
onPointerDown?: (event: ReactPointerEvent<SVGGElement>) => void
onRotatePointerDown?: (event: ReactPointerEvent<SVGGElement>) => 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 (
<g
data-group-selection-box
onPointerDown={interactive ? onPointerDown : undefined}
pointerEvents={interactive ? 'auto' : 'none'}
style={interactive ? GROUP_BOX_CURSOR_STYLE : undefined}
transform={transform}
>
<rect
fill="transparent"
height={box.depth + 2 * pad}
stroke={stroke}
strokeDasharray={`${4 * unitsPerPixel} ${3 * unitsPerPixel}`}
strokeWidth={1.2 * unitsPerPixel}
width={box.width + 2 * pad}
x={box.x - pad}
y={box.z - pad}
/>
{/* 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) => (
<g
data-group-rotate-handle
key={`corner-${index}`}
onPointerDown={(event) => {
event.stopPropagation()
onRotatePointerDown(event)
}}
style={GROUP_BOX_ROTATE_CURSOR_STYLE}
>
<circle cx={cx} cy={cz} fill="transparent" r={8 * unitsPerPixel} />
<circle
cx={cx}
cy={cz}
fill="#ffffff"
r={3.2 * unitsPerPixel}
stroke={stroke}
strokeWidth={1.2 * unitsPerPixel}
/>
</g>
))
: null}
</g>
)
})
@@ -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). * Hidden while in a move state (so we don't show buttons over a ghost).
*/ */
export function FloorplanRegistryActionMenu() { 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 movingNode = useMovingNode()
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
@@ -4,7 +4,6 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
createSceneApi, createSceneApi,
type FloorplanAffordancePoint,
type FloorplanAffordanceSession, type FloorplanAffordanceSession,
type FloorplanGeometry, type FloorplanGeometry,
type FloorplanPalette, type FloorplanPalette,
@@ -41,6 +40,7 @@ import {
snapDirectRotationDelta, snapDirectRotationDelta,
} from '../../../lib/direct-manipulation' } from '../../../lib/direct-manipulation'
import { createEditorApi } from '../../../lib/editor-api' import { createEditorApi } from '../../../lib/editor-api'
import { clientToPlan } from '../../../lib/floorplan/plan-coords'
import { import {
type ActiveInteractionScope, type ActiveInteractionScope,
boundaryReshapeScope, boundaryReshapeScope,
@@ -58,7 +58,14 @@ import useInteractionScope, {
useEndpointReshape, useEndpointReshape,
useMovingNode, useMovingNode,
} from '../../../store/use-interaction-scope' } 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 { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
import {
FloorplanGroupSelectionBox,
startFloorplanGroupMove,
startFloorplanGroupRotate,
} from '../floorplan-group-move'
import { useFloorplanRender } from '../floorplan-render-context' import { useFloorplanRender } from '../floorplan-render-context'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
@@ -244,6 +251,8 @@ type FloorplanLevelDataHook = (args: {
type FloorplanRenderPass = 'base' | 'overlay' type FloorplanRenderPass = 'base' | 'overlay'
const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const 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 const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
function snapshotNode(node: AnyNode): NodeSnapshot { function snapshotNode(node: AnyNode): NodeSnapshot {
@@ -354,6 +363,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// Marquee preview selection — matches the legacy `highlightedIdSet` use // Marquee preview selection — matches the legacy `highlightedIdSet` use
// (filter-while-marquee), surfaces selection chrome without keyboard focus. // (filter-while-marquee), surfaces selection chrome without keyboard focus.
const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds]) 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 // Interactive state lives in refs; only the visible feedback bits go
// into React state to keep re-renders cheap during drag. // 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] const node = useScene.getState().nodes[id]
if (!node || !isRegistryMovable(node.type)) return false 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.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -534,8 +559,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const node = useScene.getState().nodes[id] const node = useScene.getState().nodes[id]
if (!node || !canDirectRotateNode(node)) return false if (!node || !canDirectRotateNode(node)) return false
// Sole selection only — same stand-down as the direct move above.
const selectedIds = useViewer.getState().selection.selectedIds const selectedIds = useViewer.getState().selection.selectedIds
if (!selectedIds.includes(id)) return false if (selectedIds.length !== 1 || selectedIds[0] !== id) return false
event.preventDefault() event.preventDefault()
event.stopPropagation() 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<SVGGElement>, 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<SVGGElement>) => 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<SVGGElement>) => {
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<SVGGElement>) => {
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( const handleEntryPointerDown = useCallback(
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => { (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
if (startDirectMoveDrag(id, event)) return if (startDirectMoveDrag(id, event)) return
if (startDirectRotateDrag(id, event)) return if (startDirectRotateDrag(id, event)) return
if (startGroupMoveDrag(id, event)) return
handleSelect(id, event) handleSelect(id, event)
}, },
[handleSelect, startDirectMoveDrag, startDirectRotateDrag], [handleSelect, startDirectMoveDrag, startDirectRotateDrag, startGroupMoveDrag],
) )
const floorplanData = useMemo(() => { const floorplanData = useMemo(() => {
@@ -1074,6 +1163,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
nodes={nodes} nodes={nodes}
onClickStop={handleClickStop} onClickStop={handleClickStop}
onEntryPointerDown={handleEntryPointerDown} onEntryPointerDown={handleEntryPointerDown}
onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
onHandleHoverChange={setHoveredHandleId} onHandleHoverChange={setHoveredHandleId}
onHandlePointerDown={startAffordanceDrag} onHandlePointerDown={startAffordanceDrag}
onHoveredIdChange={setHoveredId} onHoveredIdChange={setHoveredId}
@@ -1081,6 +1171,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
pass="base" pass="base"
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0} sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
selected={selectedIdSet.has(entry.id)} selected={selectedIdSet.has(entry.id)}
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
setMovingNode={setMovingNode} setMovingNode={setMovingNode}
setMovingNodeOrigin={setMovingNodeOrigin} setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0} siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
@@ -1120,6 +1212,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
nodes={nodes} nodes={nodes}
onClickStop={handleClickStop} onClickStop={handleClickStop}
onEntryPointerDown={handleEntryPointerDown} onEntryPointerDown={handleEntryPointerDown}
onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
onHandleHoverChange={setHoveredHandleId} onHandleHoverChange={setHoveredHandleId}
onHandlePointerDown={startAffordanceDrag} onHandlePointerDown={startAffordanceDrag}
onHoveredIdChange={setHoveredId} onHoveredIdChange={setHoveredId}
@@ -1127,6 +1220,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
pass="overlay" pass="overlay"
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0} sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
selected={selectedIdSet.has(entry.id)} selected={selectedIdSet.has(entry.id)}
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
setMovingNode={setMovingNode} setMovingNode={setMovingNode}
setMovingNodeOrigin={setMovingNodeOrigin} setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0} siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
@@ -1136,6 +1231,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
/> />
))} ))}
</g> </g>
{/* 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. */}
<FloorplanGroupSelectionBox
onPointerDown={handleGroupBoxPointerDown}
onRotatePointerDown={handleGroupBoxRotatePointerDown}
palette={palette}
unitsPerPixel={unitsPerPixel}
/>
{/* Transient live-rotation readout — drawn last so the wedge + degree {/* Transient live-rotation readout — drawn last so the wedge + degree
chip sit above all handle chrome while a rotate-arrow is dragged. */} chip sit above all handle chrome while a rotate-arrow is dragged. */}
{rotationOverlay && palette ? ( {rotationOverlay && palette ? (
@@ -1169,8 +1273,13 @@ type FloorplanRegistryEntryProps = {
node: AnyNode node: AnyNode
nodeId: AnyNodeId nodeId: AnyNodeId
nodes: Record<string, AnyNode> nodes: Record<string, AnyNode>
/** 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<SVGGElement>) => void onClickStop: (event: React.MouseEvent<SVGGElement>) => void
onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
onHandleHoverChange: (id: string | null) => void onHandleHoverChange: (id: string | null) => void
onHandlePointerDown: ( onHandlePointerDown: (
nodeId: AnyNodeId, nodeId: AnyNodeId,
@@ -1211,8 +1320,11 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
node, node,
nodeId, nodeId,
nodes, nodes,
suppressHandles,
groupMoveCursor,
onClickStop, onClickStop,
onEntryPointerDown, onEntryPointerDown,
onGroupMovePointerDown,
onHandleHoverChange, onHandleHoverChange,
onHandlePointerDown, onHandlePointerDown,
onHoveredIdChange, onHoveredIdChange,
@@ -1276,13 +1388,16 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
suppressBoxSelectForPointer(event) 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') sfxEmitter.emit('sfx:item-pick')
setMovingNode(currentNode as never) setMovingNode(currentNode as never)
// Claim 2D ownership of this move at the source. `setMovingNode` // Claim 2D ownership of this move at the source. `setMovingNode`
// resets the origin to null, so this must follow it. // resets the origin to null, so this must follow it.
setMovingNodeOrigin('2d') setMovingNodeOrigin('2d')
}, },
[nodeId, setMovingNode, setMovingNodeOrigin], [nodeId, onGroupMovePointerDown, setMovingNode, setMovingNodeOrigin],
) )
const cacheEntry = buildFloorplanEntryGeometry({ const cacheEntry = buildFloorplanEntryGeometry({
@@ -1305,7 +1420,14 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
siblingEpoch, siblingEpoch,
visibilityRootId, 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 if (!geometry) return null
const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
@@ -1320,7 +1442,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
onPointerDown={entryPointerDown} onPointerDown={entryPointerDown}
onPointerEnter={handlePointerEnter} onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave} onPointerLeave={handlePointerLeave}
style={POINTER_CURSOR_STYLE} style={groupMoveCursor ? MOVE_CURSOR_STYLE : POINTER_CURSOR_STYLE}
> >
<InteractiveGeometry <InteractiveGeometry
activeDragId={activeDragId} activeDragId={activeDragId}
@@ -2598,6 +2720,36 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
return { base: g, overlay: null } return { base: g, overlay: null }
} }
/**
* Per-node edit chrome hidden while a multi-selection is active: the group is
* manipulated as one rigid piece (drag to move, R/T to rotate), so individual
* handles / dimension labels on each member would mislead. `text` stays —
* zone names are identification, not editing chrome.
*/
const HANDLE_CHROME_KINDS = new Set<FloorplanGeometry['kind']>([
'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 // Stable string key for a wall endpoint, rounded to 1 mm so floating-point
// drift collapses while distinct corners stay distinct. // drift collapses while distinct corners stay distinct.
function endpointKey(x: number, y: number): string { function endpointKey(x: number, y: number): string {
@@ -2871,24 +3023,6 @@ function formatGroupTransform(t?: {
return parts.length > 0 ? parts.join(' ') : undefined return parts.length > 0 ? parts.join(' ') : undefined
} }
function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null {
// The registry layer lives under the floor-plan scene `<g>`. 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 `<g>` 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) { function swallowNextClick(timeoutMs = 0) {
const swallowClick = (event: MouseEvent) => { const swallowClick = (event: MouseEvent) => {
event.stopPropagation() event.stopPropagation()
@@ -112,6 +112,7 @@ import usePlacementPreview from '../../store/use-placement-preview'
import { useStairBuildPreview } from '../../store/use-stair-build-preview' import { useStairBuildPreview } from '../../store/use-stair-build-preview'
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer' import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' 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 { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu' import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay' import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
@@ -135,6 +136,11 @@ import {
isBoxSelectPointerSuppressed, isBoxSelectPointerSuppressed,
markBoxSelectHandled, markBoxSelectHandled,
} from '../tools/select/box-select-state' } from '../tools/select/box-select-state'
import {
type Point2 as MarqueePoint2,
polygonsIntersect as marqueePolygonsIntersect,
segmentIntersectsPolygon as marqueeSegmentIntersectsPolygon,
} from '../tools/select/marquee-geometry'
import { import {
createScreenRectangleSelectionElement, createScreenRectangleSelectionElement,
hideScreenRectangleSelectionElement, 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[] { function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement): string[] {
const scene = svg.querySelector<SVGGElement>('[data-floorplan-scene]') const scene = svg.querySelector<SVGGElement>('[data-floorplan-scene]')
if (!scene) { if (!scene) {
@@ -916,7 +951,32 @@ function collectFloorplanScreenSelectionIds(rect: ScreenRect, svg: SVGSVGElement
return [] 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<string>()
const hitIdsFromData = new Set<string>()
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<string>() const hitIds = new Set<string>()
const baseElementsById = new Map<string, SVGGraphicsElement[]>() const baseElementsById = new Map<string, SVGGraphicsElement[]>()
const fallbackElementsById = new Map<string, SVGGraphicsElement[]>() const fallbackElementsById = new Map<string, SVGGraphicsElement[]>()
@@ -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) ?? [] const elements = baseElementsById.get(id) ?? fallbackElementsById.get(id) ?? []
for (const element of elements) { for (const element of elements) {
const elementRect = element.getBoundingClientRect() 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() { function swallowNextFloorplanScreenSelectionClick() {
@@ -3788,10 +3848,12 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer(
}) })
const FloorplanSiteLayer = memo(function FloorplanSiteLayer({ const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
dimmed,
isHighlighted, isHighlighted,
palette, palette,
sitePolygon, sitePolygon,
}: { }: {
dimmed: boolean
isHighlighted: boolean isHighlighted: boolean
palette: FloorplanPalette palette: FloorplanPalette
sitePolygon: SitePolygonEntry | null sitePolygon: SitePolygonEntry | null
@@ -3808,7 +3870,9 @@ const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
const dashPattern = `${dashLength} ${gapLength}` const dashPattern = `${dashLength} ${gapLength}`
return ( return (
<> // The dashed property line reads like the dashed group selection box —
// step it back while a multi (or in-flight marquee) selection exists.
<g data-site-boundary opacity={dimmed ? 0.2 : undefined}>
<polygon <polygon
fill="none" fill="none"
pointerEvents="none" pointerEvents="none"
@@ -3833,7 +3897,7 @@ const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
</> </g>
) )
}) })
@@ -10803,9 +10867,11 @@ export function FloorplanPanel({
/> />
)} )}
{/* Floating Move / Duplicate / Delete buttons for registered {/* Floating Move / Duplicate / Delete buttons for registered
kinds. All kinds are registry-driven now, so this is the kinds. All kinds are registry-driven now, so these are the
only action menu the floor plan mounts. */} only action menus the floor plan mounts the single-node
pill, plus the group pill for multi-selections. */}
<FloorplanRegistryActionMenu /> <FloorplanRegistryActionMenu />
<FloorplanGroupActionMenu />
{(levelNode?.type === 'level' || hasAmbientBuildingLevel) && {(levelNode?.type === 'level' || hasAmbientBuildingLevel) &&
(compassHost ? ( (compassHost ? (
@@ -11149,6 +11215,7 @@ export function FloorplanPanel({
<FloorplanAlignmentGuideLayer /> <FloorplanAlignmentGuideLayer />
<FloorplanSiteLayer <FloorplanSiteLayer
dimmed={selectedIds.length > 1 || previewSelectedIds.length > 1}
isHighlighted={isSiteBoundaryHighlighted} isHighlighted={isSiteBoundaryHighlighted}
palette={palette} palette={palette}
sitePolygon={visibleSitePolygon} sitePolygon={visibleSitePolygon}
@@ -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<string>(affectedIds)
const staticNodes: Record<string, AnyNode> = {}
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 `<g>` 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<AnyNode> }[] = []
for (const id of affectedIds) {
const patch = overrides.get(id)
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
}
// 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
}
@@ -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<THREE.Group>(null)
const menuScaleRef = useRef<HTMLDivElement>(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 (
<group position={anchor} ref={groupRef}>
<Html
center
style={{
pointerEvents: 'auto',
touchAction: 'none',
}}
zIndexRange={[25, 0]}
>
<div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
<NodeActionMenu
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onMove={handleMove}
onPointerDown={stopPointer}
onPointerUp={stopPointer}
/>
</div>
</Html>
</group>
)
}
@@ -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<typeof collectParticipants>['starts']
links: ReturnType<typeof collectParticipants>['links']
affectedIds: AnyNodeId[]
candidates: ReturnType<typeof collectAlignmentAnchors>
restAnchors: ReturnType<typeof bboxCornerAnchors>
restCenter: Vec2
plane: Plane
startLocal: Vector3
frameInv: ReturnType<typeof levelFrame>['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<string>(affectedIds)
const staticNodes: Record<string, AnyNode> = {}
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<AnyNode> }[] = []
for (const id of session.affectedIds) {
const patch = overrides.get(id)
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
}
// 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
}
@@ -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 <GroupMoveHandleInner ids={fullIds} key={fullIds.join(',')} />
}
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<Vec2>([0, 0])
const dragCleanupRef = useRef<(() => void) | null>(null)
const frozenCorner = useRef<Vector3 | null>(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<PointerEvent>) => {
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<string>([...starts.map((s) => s.id), ...links.map((l) => l.id)])
const staticNodes: Record<string, AnyNode> = {}
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<readonly [string, Record<string, unknown>]> = []
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<AnyNode> }[] = []
for (const id of affectedIds) {
const patch = overrides.get(id)
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
}
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(
<group position={corner} scale={scale}>
<mesh
frustumCulled={false}
geometry={arrowGeometry}
material={arrowMaterial}
onPointerDown={activate}
onPointerEnter={(event) => {
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}
/>
</group>,
scene,
)
}
export default GroupMoveHandle
@@ -4,6 +4,8 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
DEFAULT_ANGLE_STEP, DEFAULT_ANGLE_STEP,
pauseSpaceDetection,
resumeSpaceDetection,
useLiveNodeOverrides, useLiveNodeOverrides,
useLiveTransforms, useLiveTransforms,
useScene, useScene,
@@ -27,7 +29,7 @@ import {
computeGroupBox, computeGroupBox,
expandToComponent, expandToComponent,
levelFrame, levelFrame,
type Vec2, rotateGroupPatches,
type Vec3, type Vec3,
} from './group-transform-shared' } from './group-transform-shared'
import { import {
@@ -43,6 +45,7 @@ import {
useArrowMaterial, useArrowMaterial,
useInvisibleHitAreaMaterial, useInvisibleHitAreaMaterial,
} from './node-arrow-handles' } from './node-arrow-handles'
import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
/** /**
* Group-rotate gizmo. When 2+ transformable nodes in the active level frame are * 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). // Re-derive participants whenever the scene mutates (e.g. after a commit).
// Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag. // Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
const nodes = useScene((s) => s.nodes) 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( const participantIds = useMemo(
() => () =>
@@ -92,10 +97,10 @@ export function GroupRotateHandle() {
if (!shouldRender) return null if (!shouldRender) return null
// Remount when the moving set changes so the rest pivot re-seeds cleanly. // Remount when the moving set changes so the rest pivot re-seeds cleanly.
return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} /> return <GroupRotateHandleInner ids={fullIds} key={fullIds.join(',')} meshEpoch={meshEpoch} />
} }
function GroupRotateHandleInner({ ids }: { ids: string[] }) { function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: number }) {
const { camera, raycaster, gl, scene } = useThree() const { camera, raycaster, gl, scene } = useThree()
const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), []) const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), []) const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), [])
@@ -135,7 +140,8 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
box.max.z + CORNER_OFFSET, box.max.z + CORNER_OFFSET,
) )
return { pivot, corner } return { pivot, corner }
}, [ids]) // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
}, [ids, meshEpoch])
if (!rest) return null if (!rest) return null
const active = isDragging && frozenRest.current ? frozenRest.current : rest const active = isDragging && frozenRest.current ? frozenRest.current : rest
@@ -186,6 +192,10 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
if (s.kind === 'endpoint') { if (s.kind === 'endpoint') {
reach(s.start[0], s.start[1]) reach(s.start[0], s.start[1])
reach(s.end[0], s.end[1]) reach(s.end[0], s.end[1])
} else if (s.kind === 'polygon') {
for (const [x, z] of s.polygon) {
reach(x, z)
}
} else { } else {
reach(s.position[0], s.position[2]) reach(s.position[0], s.position[2])
} }
@@ -228,53 +238,29 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
while (delta < -Math.PI) delta += 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI
if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP 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 // Shared rigid-rotation math (also used by the keyboard group R/T);
// turn its yaw by `-delta` to match three.js Y-rotation handedness (same // see `rotateGroupPatches` for the orbit/yaw handedness contract.
// convention as the single-item rotate handle in item/definition.ts). const overrideEntries = rotateGroupPatches(
// Endpoint nodes (walls/fences) have no yaw — swinging both endpoints starts,
// around the pivot rotates them rigidly; their curveOffset sagitta is links,
// rotation-invariant, so arcs are preserved. { x: localCenter.x, z: localCenter.z },
const cos = Math.cos(delta) delta,
const sin = Math.sin(delta) )
const rot = (x: number, z: number): Vec2 => { const patchById = new Map(overrideEntries)
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<readonly [string, Record<string, unknown>]> = []
const liveTransforms = useLiveTransforms.getState() const liveTransforms = useLiveTransforms.getState()
for (const s of starts) { for (const s of starts) {
if (s.kind === 'endpoint') { if (s.kind === 'scalar') {
overrideEntries.push([ const patch = patchById.get(s.id)
s.id, if (patch) {
{ start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }, liveTransforms.set(s.id, {
]) position: patch.position as Vec3,
} else { rotation: patch.rotation as number,
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 })
} }
} }
useScene.getState().markDirty(s.id) 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) { 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) useScene.getState().markDirty(l.id)
} }
useLiveNodeOverrides.getState().setMany(overrideEntries) useLiveNodeOverrides.getState().setMany(overrideEntries)
@@ -344,8 +330,12 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
const updates = commitFromOverrides() const updates = commitFromOverrides()
// Resume before the commit so the single batched `updateNodes` is the // Resume before the commit so the single batched `updateNodes` is the
// one tracked set — collapsing the whole group rotation into one undo. // 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() useScene.temporal.getState().resume()
if (updates.length > 0) useScene.getState().updateNodes(updates) if (updates.length > 0) useScene.getState().updateNodes(updates)
resumeSpaceDetection()
clearLivePreviews() clearLivePreviews()
cleanup() cleanup()
} }
@@ -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<PointerEvent>) => {
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 (
<group position={position}>
<primitive object={edges} />
{/* Invisible whole-volume hit target — the box IS the drag handle. */}
<mesh
onPointerDown={handlePointerDown}
onPointerEnter={applyMoveCursor}
onPointerLeave={handlePointerLeave}
onPointerMove={applyMoveCursor}
>
<boxGeometry args={box.size} />
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
</mesh>
</group>
)
}
@@ -1,7 +1,12 @@
import { beforeAll, describe, expect, test } from 'bun:test' import { beforeAll, describe, expect, test } from 'bun:test'
import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core' import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
import { z } from 'zod' 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' 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<string, AnyNode>
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<string, AnyNode>
// 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', () => { test('supports legacy level-parented elevators already loaded in the editor', () => {
const nodes = { const nodes = {
building_test: { building_test: {
@@ -25,12 +25,15 @@ const isVec3 = (v: unknown): v is Vec3 =>
Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number') Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number')
const isVec2 = (v: unknown): v is Vec2 => const isVec2 = (v: unknown): v is Vec2 =>
Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number') 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: // How a participant's placement transforms rigidly around / with the group:
// - 'vec3' position + [x,y,z] rotation (items, …) // - 'vec3' position + [x,y,z] rotation (items, …)
// - 'scalar' position + numeric rotation (columns) // - 'scalar' position + numeric rotation (columns)
// - 'endpoint' start/end tuples (walls, fences) // - '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 // 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 // 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 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( export function classifyParticipant(
node: AnyNode | undefined, node: AnyNode | undefined,
levelId: string | null, levelId: string | null,
sceneNodes: Record<string, AnyNode | undefined>, sceneNodes: Record<string, AnyNode | undefined>,
): ParticipantKind | null { ): ParticipantKind | null {
if (!node || !isInGroupTransformScope(node, levelId, sceneNodes)) return null if (!node || !isInGroupTransformScope(node, levelId, sceneNodes)) return null
const p = getParticipantPosition(node) const shape = classifyPlacementShape(node)
const r = (node as { rotation?: unknown }).rotation if (shape) return shape
const start = (node as { start?: unknown }).start const start = (node as { start?: unknown }).start
const end = (node as { end?: unknown }).end 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 (isVec2(start) && isVec2(end)) return 'endpoint'
if (isVec2Array((node as { polygon?: unknown }).polygon)) return 'polygon'
return null 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 = export type ParticipantStart =
| { id: AnyNodeId; kind: 'vec3'; position: Vec3; rotation: Vec3 } | { id: AnyNodeId; kind: 'vec3'; position: Vec3; rotation: Vec3 }
| { id: AnyNodeId; kind: 'scalar'; position: Vec3; rotation: number } | { id: AnyNodeId; kind: 'scalar'; position: Vec3; rotation: number }
| { id: AnyNodeId; kind: 'endpoint'; start: Vec2; end: Vec2 } | { 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 // 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 // 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]], position: [position[0], position[1], position[2]],
rotation, rotation,
}) })
} else { } else if (kind === 'endpoint') {
const n = node as AnyNode & { start: Vec2; end: Vec2 } const n = node as AnyNode & { start: Vec2; end: Vec2 }
starts.push({ starts.push({
id: id as AnyNodeId, id: id as AnyNodeId,
@@ -151,6 +166,56 @@ export function collectParticipants(
start: [n.start[0], n.start[1]], start: [n.start[0], n.start[1]],
end: [n.end[0], n.end[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<string>(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) 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<string, unknown>]
// 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<string, unknown> = { 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<string, unknown> = { 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 // 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 // (`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 // gizmos raycast the ground plane in WORLD space. When the building is rotated
@@ -65,8 +65,9 @@ import { FloatingActionMenu } from './floating-action-menu'
import { FloatingBuildingActionMenu } from './floating-building-action-menu' import { FloatingBuildingActionMenu } from './floating-building-action-menu'
import { FloorplanPanel } from './floorplan-panel' import { FloorplanPanel } from './floorplan-panel'
import { Grid } from './grid' import { Grid } from './grid'
import { GroupMoveHandle } from './group-move-handle' import { GroupFloatingActionMenu } from './group-floating-action-menu'
import { GroupRotateHandle } from './group-rotate-handle' import { GroupRotateHandle } from './group-rotate-handle'
import { GroupSelectionBox3D } from './group-selection-box-3d'
import { NodeArrowHandles } from './node-arrow-handles' import { NodeArrowHandles } from './node-arrow-handles'
import { RiserDiagramPanel } from './riser-diagram-panel' import { RiserDiagramPanel } from './riser-diagram-panel'
import { SelectionManager } from './selection-manager' import { SelectionManager } from './selection-manager'
@@ -733,12 +734,13 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
{!noEditing && <BoxSelectTool />} {!noEditing && <BoxSelectTool />}
{!noEditing && <NodeArrowHandles />} {!noEditing && <NodeArrowHandles />}
{!noEditing && <GroupRotateHandle />} {!noEditing && <GroupRotateHandle />}
{!noEditing && <GroupMoveHandle />} {!noEditing && <GroupSelectionBox3D />}
{!noEditing && <WallOpeningHighlights />} {!noEditing && <WallOpeningHighlights />}
{!noEditing && <SlabHoleHighlights />} {!noEditing && <SlabHoleHighlights />}
{!noEditing && <WallMoveSideHandles />} {!noEditing && <WallMoveSideHandles />}
{!noEditing && <FenceTangentLines3D />} {!noEditing && <FenceTangentLines3D />}
{!noEditing && <FloatingActionMenu />} {!noEditing && <FloatingActionMenu />}
{!noEditing && <GroupFloatingActionMenu />}
{!noEditing && <FloatingBuildingActionMenu />} {!noEditing && <FloatingBuildingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />} {!isFirstPersonMode && <WallMeasurementLabel />}
<ExportManager /> <ExportManager />
@@ -75,7 +75,10 @@ import useInteractionScope, {
useMovingNode, useMovingNode,
} from '../../store/use-interaction-scope' } from '../../store/use-interaction-scope'
import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state' 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 { swallowNextClick } from './node-arrow-handles'
import { setEditorThreeContext } from './three-context-bridge'
const isNodeInCurrentLevel = (node: AnyNode): boolean => { const isNodeInCurrentLevel = (node: AnyNode): boolean => {
// Elevators are building-scoped, so they stay selectable across level filters. // 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 // 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. // (being a closer ancestor) overrides any body cursor over the canvas.
const glDomElement = useThree((s) => s.gl.domElement) 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) 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<SelectionModifierKeys>({ const modifierKeysRef = useRef<SelectionModifierKeys>({
meta: false, meta: false,
ctrl: false, ctrl: false,
@@ -1159,11 +1171,39 @@ export const SelectionManager = () => {
const onPointerDown = (event: NodeEvent) => { const onPointerDown = (event: NodeEvent) => {
const pointer = pointerEventFromNodeEvent(event) 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 const node = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node
if (!canDirectMoveNode(node)) return 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 startX = pointer.clientX
const startY = pointer.clientY const startY = pointer.clientY
@@ -1260,7 +1300,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:pointerdown` as any, onPointerDown as any) 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 // 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 // picks it up (replaces the removed move-cross gizmo). Reacts only when the
@@ -1272,15 +1312,24 @@ export const SelectionManager = () => {
let prevKey = '' let prevKey = ''
const applyCursor = () => { const applyCursor = () => {
const { selection, hoveredId } = useViewer.getState() const { selection, hoveredId } = useViewer.getState()
const sole = selection.selectedIds.length === 1 ? selection.selectedIds[0] : null const selectedIds = selection.selectedIds
const key = `${hoveredId ?? ''}|${sole ?? ''}` const sole = selectedIds.length === 1 ? selectedIds[0] : null
const key = `${hoveredId ?? ''}|${sole ?? ''}|${selectedIds.length}`
if (key === prevKey) return if (key === prevKey) return
prevKey = key prevKey = key
const node = let wantsMove = false
sole != null && hoveredId === sole && !getMovingNode() if (hoveredId && !getMovingNode()) {
? useScene.getState().nodes[sole as AnyNodeId] if (sole === hoveredId) {
: null const node = useScene.getState().nodes[sole as AnyNodeId]
if (node && canDirectMoveNode(node)) { 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' glDomElement.style.cursor = 'move'
owns = true owns = true
} else if (owns) { } else if (owns) {
@@ -1316,9 +1365,10 @@ export const SelectionManager = () => {
if (event.button !== 2 || !isCommandModifier(event)) return if (event.button !== 2 || !isCommandModifier(event)) return
if (!(event.target instanceof HTMLCanvasElement)) 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 selectedIds = useViewer.getState().selection.selectedIds
const hoveredId = useViewer.getState().hoveredId as AnyNodeId | null 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] const node = useScene.getState().nodes[hoveredId]
if (!node || !canDirectRotateNode(node)) return if (!node || !canDirectRotateNode(node)) return
@@ -241,7 +241,9 @@ export function SlabHoleHighlights() {
} }
}, [hoveredHole, selectedIds, setHoveredHole]) }, [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 ( return (
<> <>
@@ -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
}
@@ -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
}
@@ -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 { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react' 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 useEditor from '../../../store/use-editor'
import useInteractionScope from '../../../store/use-interaction-scope' import useInteractionScope from '../../../store/use-interaction-scope'
import { import {
@@ -10,6 +19,13 @@ import {
isBoxSelectPointerSuppressed, isBoxSelectPointerSuppressed,
markBoxSelectHandled, markBoxSelectHandled,
} from './box-select-state' } from './box-select-state'
import {
convexHull2D,
type Point2,
polygonsIntersect,
rectIntersectsHull,
segmentIntersectsPolygon,
} from './marquee-geometry'
import { PlaneBoxSelectTool } from './plane-box-select-tool' import { PlaneBoxSelectTool } from './plane-box-select-tool'
import { import {
createScreenRectangleSelectionElement, createScreenRectangleSelectionElement,
@@ -19,14 +35,20 @@ import {
SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX, SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX,
type ScreenRect, type ScreenRect,
screenRectFromDomRect, screenRectFromDomRect,
screenRectsIntersect,
updateScreenRectangleSelectionElement, updateScreenRectangleSelectionElement,
} from './screen-rectangle-selection' } from './screen-rectangle-selection'
import { collectSelectableCandidateIds } from './select-candidates' import { collectSelectableCandidateIds } from './select-candidates'
const tempBox = new Box3() const tempBox = new Box3()
const tempChildBox = new Box3()
const tempInvWorld = new Matrix4()
const tempRelMatrix = new Matrix4()
const tempWorldPoint = new Vector3() const tempWorldPoint = new Vector3()
const tempScreenPoint = 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 = [ const boxCorners = [
new Vector3(), new Vector3(),
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, object: Object3D,
camera: Camera, camera: Camera,
canvasRect: DOMRect, canvasRect: DOMRect,
): ScreenRect | null { ): Point2[] | null {
object.updateWorldMatrix(true, true) object.updateWorldMatrix(true, true)
tempBox.setFromObject(object) const localBox = computeLocalBox(object)
if (tempBox.isEmpty()) { if (!localBox) {
object.getWorldPosition(tempWorldPoint) object.getWorldPosition(tempWorldPoint)
const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect) const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
if (!projected) return null return projected ? [projected] : null
const [x, y] = projected
return { minX: x, minY: y, maxX: x, maxY: y }
} }
boxCorners[0]!.set(tempBox.min.x, tempBox.min.y, tempBox.min.z) boxCorners[0]!.set(localBox.min.x, localBox.min.y, localBox.min.z)
boxCorners[1]!.set(tempBox.min.x, tempBox.min.y, tempBox.max.z) boxCorners[1]!.set(localBox.min.x, localBox.min.y, localBox.max.z)
boxCorners[2]!.set(tempBox.min.x, tempBox.max.y, tempBox.min.z) boxCorners[2]!.set(localBox.min.x, localBox.max.y, localBox.min.z)
boxCorners[3]!.set(tempBox.min.x, tempBox.max.y, tempBox.max.z) boxCorners[3]!.set(localBox.min.x, localBox.max.y, localBox.max.z)
boxCorners[4]!.set(tempBox.max.x, tempBox.min.y, tempBox.min.z) boxCorners[4]!.set(localBox.max.x, localBox.min.y, localBox.min.z)
boxCorners[5]!.set(tempBox.max.x, tempBox.min.y, tempBox.max.z) boxCorners[5]!.set(localBox.max.x, localBox.min.y, localBox.max.z)
boxCorners[6]!.set(tempBox.max.x, tempBox.max.y, tempBox.min.z) boxCorners[6]!.set(localBox.max.x, localBox.max.y, localBox.min.z)
boxCorners[7]!.set(tempBox.max.x, tempBox.max.y, tempBox.max.z) boxCorners[7]!.set(localBox.max.x, localBox.max.y, localBox.max.z)
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
const projectedPoints: Point2[] = []
for (const corner of boxCorners) { for (const corner of boxCorners) {
corner.applyMatrix4(object.matrixWorld)
const projected = projectWorldPointToScreen(corner, camera, canvasRect) const projected = projectWorldPointToScreen(corner, camera, canvasRect)
if (!projected) continue if (projected) projectedPoints.push(projected)
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 (minX !== Number.POSITIVE_INFINITY) { if (projectedPoints.length === 0) {
return { minX, minY, maxX, maxY } object.getWorldPosition(tempWorldPoint)
const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
return projected ? [projected] : null
} }
object.getWorldPosition(tempWorldPoint) return convexHull2D(projectedPoints)
const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
if (!projected) return null
const [x, y] = projected
return { minX: x, minY: y, maxX: x, maxY: y }
} }
function isObjectVisible(object: Object3D): boolean { function isObjectVisible(object: Object3D): boolean {
@@ -119,6 +158,46 @@ function isObjectVisible(object: Object3D): boolean {
return true 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( function collectNodeIdsInScreenRect(
rect: ScreenRect, rect: ScreenRect,
camera: Camera, camera: Camera,
@@ -127,11 +206,37 @@ function collectNodeIdsInScreenRect(
const canvasRect = canvas.getBoundingClientRect() const canvasRect = canvas.getBoundingClientRect()
const result: string[] = [] 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()) { for (const id of collectSelectableCandidateIds()) {
const object = sceneRegistry.nodes.get(id) const object = sceneRegistry.nodes.get(id)
if (!object || !isObjectVisible(object)) continue 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) result.push(id)
} }
} }
@@ -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)
})
})
@@ -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 (02 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
}
@@ -132,10 +132,16 @@ export const ToolManager: React.FC = () => {
// switches to full polygon editing only after a flag activates site mode. // switches to full polygon editing only after a flag activates site mode.
const showSiteBoundaryEditor = phase === 'site' || mode === 'select' 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) // Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
const showSlabBoundaryEditor = const showSlabBoundaryEditor =
phase === 'structure' && phase === 'structure' &&
mode === 'select' && mode === 'select' &&
isSoleSelection &&
selectedSlabId !== undefined && selectedSlabId !== undefined &&
!editingSlabHoleIsManual !editingSlabHoleIsManual
@@ -150,6 +156,7 @@ export const ToolManager: React.FC = () => {
const showCeilingBoundaryEditor = const showCeilingBoundaryEditor =
phase === 'structure' && phase === 'structure' &&
mode === 'select' && mode === 'select' &&
isSoleSelection &&
selectedCeilingId !== undefined && selectedCeilingId !== undefined &&
(!editingHole || editingHole.nodeId !== selectedCeilingId) (!editingHole || editingHole.nodeId !== selectedCeilingId)
@@ -162,11 +162,16 @@ export function HelperManager() {
return <ContextualHelperPanel hints={resolveRotateHandleHelpHints(modifiers.shift)} /> return <ContextualHelperPanel hints={resolveRotateHandleHelpHints(modifiers.shift)} />
} }
// Group-move drag: the drag resolves to the 'item' snap context (see // Group-move drag / pick-up: the drag resolves to the 'item' snap context
// `snapContextOf`), so surface the snapping chips — mode + grid step, with // (see `snapContextOf`), so surface the snapping chips — mode + grid step,
// their Shift / Ctrl cycle shortcuts — for the duration. // with their Shift / Ctrl cycle shortcuts — plus the mid-move R/T rotate.
if (activeHandleDrag?.label === GROUP_MOVE_DRAG_LABEL) { if (activeHandleDrag?.label === GROUP_MOVE_DRAG_LABEL) {
return <ContextualHelperPanel hints={[]} snapContext={snapContext} /> return (
<ContextualHelperPanel
hints={[{ keys: ['R / T'], label: 'Rotate the selection ±45°' }]}
snapContext={snapContext}
/>
)
} }
// Reshaping a node's geometry (endpoint / curve / polygon corner). Checked // Reshaping a node's geometry (endpoint / curve / polygon corner). Checked
@@ -78,6 +78,22 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
action: 'Add or remove an object from canvas multi-selection', 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.', 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'], keys: ['Cmd/Ctrl', 'Left click'],
action: 'Move the selected movable object under the cursor', 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'], keys: ['Cmd/Ctrl', 'Right click'],
action: 'Rotate the selected object under the cursor', 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'], keys: ['Cmd/Ctrl', 'Shift', 'Right click'],
+78 -1
View File
@@ -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 { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' 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 { steppedRotation } from '../components/tools/item/placement-math'
import { toggleDoorOpenState } from '../lib/door-interaction' import { toggleDoorOpenState } from '../lib/door-interaction'
import { guideEmitter } from '../lib/guide-events' import { guideEmitter } from '../lib/guide-events'
@@ -26,6 +43,53 @@ function getRotatableSelectedReference() {
return node 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<AnyNode> })))
resumeSpaceDetection()
sfxEmitter.emit('sfx:item-rotate')
return true
}
// Tools call this in their onCancel handler when they have an active mid-action to cancel, // 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. // so that the global Escape handler knows not to also switch to select mode.
let _toolCancelConsumed = false let _toolCancelConsumed = false
@@ -326,6 +390,14 @@ export const useKeyboard = ({
// //
// References (guide/scan) live in `selectedReferenceId`, not the viewer // References (guide/scan) live in `selectedReferenceId`, not the viewer
// selection — check them first, like the Delete arm below. // 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() const rotatableReference = getRotatableSelectedReference()
if (rotatableReference) { if (rotatableReference) {
e.preventDefault() e.preventDefault()
@@ -394,6 +466,11 @@ export const useKeyboard = ({
} }
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) { } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
// Rotate selected node counter-clockwise // Rotate selected node counter-clockwise
// Multi-selection → group rotate, mirroring the R arm above.
if (rotateGroupSelection(-1)) {
e.preventDefault()
return
}
const rotatableReference = getRotatableSelectedReference() const rotatableReference = getRotatableSelectedReference()
if (rotatableReference) { if (rotatableReference) {
e.preventDefault() e.preventDefault()
@@ -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', () => { 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), // Guides/snapping are governed by the snapping mode (Shift toggles it),
// so no modifier-specific "freely / with guides / bypass" variants exist. // so no modifier-specific "freely / with guides / bypass" variants exist.
+23 -3
View File
@@ -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. // select-mode hints off-screen — a resize is its own action, not a selection.
export const RESIZE_HANDLE_DRAG_LABEL = 'resize-handle' export const RESIZE_HANDLE_DRAG_LABEL = 'resize-handle'
// `activeHandleDrag.label`s for the multi-selection group gizmos' drags. Kept // `activeHandleDrag.label`s for the multi-selection group drags: the body
// here (not in the gizmo components) so `snapping-mode.ts` can resolve the // drag sessions (2D floorplan + 3D ground plane) and the rotate gizmo. Kept
// group move to the 'item' snap context without importing component code. // 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_MOVE_DRAG_LABEL = 'group-move-handle'
export const GROUP_ROTATE_DRAG_LABEL = 'group-rotate-handle' export const GROUP_ROTATE_DRAG_LABEL = 'group-rotate-handle'
@@ -60,6 +61,7 @@ const SHIFT_KEY = 'Shift'
const CLICK = 'Click' const CLICK = 'Click'
const ALT_KEY = 'Alt' const ALT_KEY = 'Alt'
const ROTATE_KEYS = 'R / T' const ROTATE_KEYS = 'R / T'
const ESC_KEY = 'Esc'
export function resolveSelectModeHelpHints({ export function resolveSelectModeHelpHints({
selectedCount, selectedCount,
@@ -82,6 +84,24 @@ export function resolveSelectModeHelpHints({
return hints 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 // 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 // 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 // hints first. A run endpoint's side / up-down arrows swing the run and Alt
@@ -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 `<g>`'s screen CTM. The scene `<g>` 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]
}
+35 -6
View File
@@ -176,9 +176,8 @@ function remapNodeReferences(
return AnyNode.parse(clone) return AnyNode.parse(clone)
} }
export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) { function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
const scene = useScene.getState() const scene = useScene.getState()
const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[])
const selectedIdSet = new Set(ids) const selectedIdSet = new Set(ids)
const rootIds = ids.filter((id) => { const rootIds = ids.filter((id) => {
const node = scene.nodes[id] const node = scene.nodes[id]
@@ -191,7 +190,7 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
}) })
if (rootIds.length === 0) { if (rootIds.length === 0) {
return false return null
} }
const subtreeIds = new Set<AnyNodeId>() const subtreeIds = new Set<AnyNodeId>()
@@ -199,7 +198,7 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
collectSubtreeIds(scene.nodes, rootId, subtreeIds) collectSubtreeIds(scene.nodes, rootId, subtreeIds)
} }
clipboardPayload = { return {
copiedAt: Date.now(), copiedAt: Date.now(),
nodes: [...subtreeIds] nodes: [...subtreeIds]
.map((id) => scene.nodes[id]) .map((id) => scene.nodes[id])
@@ -207,15 +206,45 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) {
.map((node) => JSON.parse(JSON.stringify(node)) as AnyNode), .map((node) => JSON.parse(JSON.stringify(node)) as AnyNode),
rootIds, 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() notifySubscribers()
return true 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 { 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) const targetLevel = getPasteTargetLevel(targetLevelId)
if (!payload || !targetLevel) return null if (!targetLevel) return null
const scene = useScene.getState() const scene = useScene.getState()
const idMap = new Map<AnyNodeId, AnyNodeId>() const idMap = new Map<AnyNodeId, AnyNodeId>()
+5
View File
@@ -9,6 +9,7 @@ import type {
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides'
import { scaleHandleHeight } from './door-math' import { scaleHandleHeight } from './door-math'
import { buildDoorFloorplan } from './floorplan' import { buildDoorFloorplan } from './floorplan'
import { doorWidthAffordance } from './floorplan-affordances' import { doorWidthAffordance } from './floorplan-affordances'
@@ -226,6 +227,10 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
// direction + perpendicular for the cutout footprint. // direction + perpendicular for the cutout footprint.
floorplan: buildDoorFloorplan, floorplan: buildDoorFloorplan,
floorplanDependsOnSiblings: true, 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. // Stage D — placement (`def.tool`) + move-on-wall (`def.
// affordanceTools.move`). Both ports of the legacy tools at // affordanceTools.move`). Both ports of the legacy tools at
// `editor/components/tools/door/`, relocated into the kind folder and // `editor/components/tools/door/`, relocated into the kind folder and
+22 -2
View File
@@ -185,7 +185,11 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
}) })
const isSelected = ctx.viewState?.selected ?? false 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 isMoving = ctx.viewState?.moving ?? false
const selectedStroke = ctx.viewState?.palette?.selectedStroke ?? '#3b82f6'
const floorPlanUrl = node.asset.floorPlanUrl const floorPlanUrl = node.asset.floorPlanUrl
const children: FloorplanGeometry[] = [ 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 // the child renders a paintable surface. `fill="none"` would make
// clicks pass through to whatever's beneath, breaking selection. // clicks pass through to whatever's beneath, breaking selection.
fill: floorPlanUrl ? 'transparent' : '#fef3c7', fill: floorPlanUrl ? 'transparent' : '#fef3c7',
stroke: '#92400e', // Selected items read as selected: palette stroke + heavier weight
strokeWidth: 0.012, // (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, opacity: 0.85,
}, },
] ]
@@ -222,6 +229,19 @@ export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): Floorp
rotation: -transform.rotation, 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 // 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 // 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. // over it would re-arm the move (and re-enter edit) instead of committing.
+5
View File
@@ -9,6 +9,7 @@ import type {
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides'
import { buildWindowFloorplan } from './floorplan' import { buildWindowFloorplan } from './floorplan'
import { windowWidthAffordance } from './floorplan-affordances' import { windowWidthAffordance } from './floorplan-affordances'
import { windowFloorplanMoveTarget } from './floorplan-move' import { windowFloorplanMoveTarget } from './floorplan-move'
@@ -215,6 +216,10 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
// + thickness — same shape as door. // + thickness — same shape as door.
floorplan: buildWindowFloorplan, floorplan: buildWindowFloorplan,
floorplanDependsOnSiblings: true, 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 // Stage D — placement + move-on-wall. Same recipe as door. See
// `nodes/src/window/{tool,move-tool,window-math}.ts`. // `nodes/src/window/{tool,move-tool,window-math}.ts`.
tool: () => import('./tool'), tool: () => import('./tool'),
+21 -12
View File
@@ -1,6 +1,6 @@
'use client' '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 { useNodeEvents, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
@@ -115,37 +115,46 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
// its scene identity (selection registry, GLB export polygon stamping). // its scene identity (selection registry, GLB export polygon stamping).
const showZones = useViewer((s) => s.showZones) 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 // Create floor shape from polygon
const floorShape = useMemo(() => { const floorShape = useMemo(() => {
if (!node?.polygon || node.polygon.length < 3) return null if (!polygon || polygon.length < 3) return null
const shape = new Shape() 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 // Shape is in X-Y plane, we rotate it to X-Z plane
// Negate Y (which becomes Z) to get correct orientation // Negate Y (which becomes Z) to get correct orientation
shape.moveTo(firstPt[0]!, -firstPt[1]!) shape.moveTo(firstPt[0]!, -firstPt[1]!)
for (let i = 1; i < node.polygon.length; i++) { for (let i = 1; i < polygon.length; i++) {
const pt = node.polygon[i]! const pt = polygon[i]!
shape.lineTo(pt[0]!, -pt[1]!) shape.lineTo(pt[0]!, -pt[1]!)
} }
shape.closePath() shape.closePath()
return shape return shape
}, [node?.polygon]) }, [polygon])
// Create wall geometry from polygon // Create wall geometry from polygon
const wallGeometry = useMemo(() => { const wallGeometry = useMemo(() => {
if (!node?.polygon || node.polygon.length < 2) return null if (!polygon || polygon.length < 2) return null
return createWallGeometry(node.polygon) return createWallGeometry(polygon)
}, [node?.polygon]) }, [polygon])
// Calculate polygon centroid for label positioning using the geometric centroid formula // Calculate polygon centroid for label positioning using the geometric centroid formula
// This correctly handles polygons regardless of vertex distribution along edges // This correctly handles polygons regardless of vertex distribution along edges
const centroid = useMemo(() => { 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 signedArea = 0
let cx = 0 let cx = 0
let cz = 0 let cz = 0
@@ -165,7 +174,7 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
const factor = 1 / (6 * signedArea) const factor = 1 / (6 * signedArea)
return [cx * factor, cz * factor] as [number, number] return [cx * factor, cz * factor] as [number, number]
}, [node?.polygon]) }, [polygon])
// Create materials // Create materials
const floorMaterial = useMemo(() => { const floorMaterial = useMemo(() => {