From 9153a6780be2c7c80ed2129f5a5e0952202a03ee Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 8 Jul 2026 15:19:33 -0400 Subject: [PATCH 01/22] =?UTF-8?q?feat(editor):=20group=20R/T=20=E2=80=94?= =?UTF-8?q?=20keyboard=20rotate=20for=20multi-selections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/editor/group-move-handle.tsx | 41 +++------ .../components/editor/group-rotate-handle.tsx | 58 ++++--------- .../editor/group-transform-shared.ts | 86 +++++++++++++++++++ packages/editor/src/hooks/use-keyboard.ts | 67 ++++++++++++++- 4 files changed, 181 insertions(+), 71 deletions(-) diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx index 5cff385bd..291bf0533 100644 --- a/packages/editor/src/components/editor/group-move-handle.tsx +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -34,6 +34,7 @@ import { computeGroupBox, expandToComponent, levelFrame, + translateGroupPatches, type Vec2, } from './group-transform-shared' import { @@ -260,42 +261,24 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { lastSnap = [dx, dz] } - const overrideEntries: Array]> = [] + // Shared rigid-slide math (also used by the 2D floorplan group drag); + // linked neighbours' shared endpoints follow so junctions stay welded. + const overrideEntries = translateGroupPatches(starts, links, dx, dz) + const patchById = new Map(overrideEntries) 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 }) + if (s.kind === 'scalar') { + const patch = patchById.get(s.id) + if (patch) { + liveTransforms.set(s.id, { + position: patch.position as [number, number, number], + 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) diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index e0831fc9e..429d3c977 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -27,7 +27,7 @@ import { computeGroupBox, expandToComponent, levelFrame, - type Vec2, + rotateGroupPatches, type Vec3, } from './group-transform-shared' import { @@ -228,53 +228,29 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { while (delta < -Math.PI) delta += 2 * Math.PI if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP - // Orbit each node's anchor point(s) CCW by `delta` (atan2 x→z sense) and - // turn its yaw by `-delta` to match three.js Y-rotation handedness (same - // convention as the single-item rotate handle in item/definition.ts). - // Endpoint nodes (walls/fences) have no yaw — swinging both endpoints - // around the pivot rotates them rigidly; their curveOffset sagitta is - // rotation-invariant, so arcs are preserved. - const cos = Math.cos(delta) - const sin = Math.sin(delta) - const rot = (x: number, z: number): Vec2 => { - const dx = x - localCenter.x - const dz = z - localCenter.z - return [localCenter.x + dx * cos - dz * sin, localCenter.z + dx * sin + dz * cos] - } - const overrideEntries: Array]> = [] + // Shared rigid-rotation math (also used by the keyboard group R/T); + // see `rotateGroupPatches` for the orbit/yaw handedness contract. + const overrideEntries = rotateGroupPatches( + starts, + links, + { x: localCenter.x, z: localCenter.z }, + delta, + ) + const patchById = new Map(overrideEntries) const liveTransforms = useLiveTransforms.getState() for (const s of starts) { - if (s.kind === 'endpoint') { - overrideEntries.push([ - s.id, - { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }, - ]) - } else { - const [px, pz] = rot(s.position[0], s.position[2]) - const position: Vec3 = [px, s.position[1], pz] - const rotation = - s.kind === 'vec3' - ? ([s.rotation[0], s.rotation[1] - delta, s.rotation[2]] as Vec3) - : s.rotation - delta - overrideEntries.push([s.id, { position, rotation }]) - if (s.kind === 'scalar') { - liveTransforms.set(s.id, { position, rotation: s.rotation - delta }) + if (s.kind === 'scalar') { + const patch = patchById.get(s.id) + if (patch) { + liveTransforms.set(s.id, { + position: patch.position as Vec3, + rotation: patch.rotation as number, + }) } } useScene.getState().markDirty(s.id) } - - // Drag each linked neighbour's shared endpoint to the same rotated spot - // (rot is deterministic, so it lands exactly on the selected wall's - // rotated endpoint), keeping the junction welded; the far end stays put. for (const l of links) { - overrideEntries.push([ - l.id, - { - start: l.startLinked ? rot(l.start[0], l.start[1]) : l.start, - end: l.endLinked ? rot(l.end[0], l.end[1]) : l.end, - }, - ]) useScene.getState().markDirty(l.id) } useLiveNodeOverrides.getState().setMany(overrideEntries) diff --git a/packages/editor/src/components/editor/group-transform-shared.ts b/packages/editor/src/components/editor/group-transform-shared.ts index 5a000dcea..5d89d334c 100644 --- a/packages/editor/src/components/editor/group-transform-shared.ts +++ b/packages/editor/src/components/editor/group-transform-shared.ts @@ -219,6 +219,92 @@ export function expandToComponent( return Array.from(included) } +// Per-node field patch, keyed for `useLiveNodeOverrides.setMany` during a live +// preview and for the single batched `updateNodes` on commit. +export type GroupPatch = readonly [AnyNodeId, Record] + +// Rigid group rotation: orbit each participant's anchor point(s) CCW by +// `delta` (atan2 x→z sense) around `center` (level-frame XZ) and turn yaws by +// `-delta` to match three.js Y-rotation handedness (same convention as the +// single-item rotate handle in item/definition.ts). Endpoint nodes +// (walls/fences) have no yaw — swinging both endpoints around the pivot +// rotates them rigidly; their curveOffset sagitta is rotation-invariant, so +// arcs are preserved. Linked neighbours' shared endpoints land exactly on the +// selected wall's rotated endpoint (rot is deterministic), keeping junctions +// welded while the far end stays put. +export function rotateGroupPatches( + starts: ParticipantStart[], + links: LinkedNeighbor[], + center: { x: number; z: number }, + delta: number, +): GroupPatch[] { + const cos = Math.cos(delta) + const sin = Math.sin(delta) + const rot = (x: number, z: number): Vec2 => { + const dx = x - center.x + const dz = z - center.z + return [center.x + dx * cos - dz * sin, center.z + dx * sin + dz * cos] + } + const patches: GroupPatch[] = [] + for (const s of starts) { + if (s.kind === 'endpoint') { + patches.push([s.id, { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }]) + } else { + 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 +} + +// 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[] = [] + for (const s of starts) { + if (s.kind === 'endpoint') { + patches.push([ + s.id, + { + start: [s.start[0] + dx, s.start[1] + dz], + end: [s.end[0] + dx, s.end[1] + dz], + }, + ]) + } else { + patches.push([s.id, { position: [s.position[0] + dx, s.position[1], s.position[2] + dz] }]) + } + } + for (const l of links) { + patches.push([ + l.id, + { + start: l.startLinked ? [l.start[0] + dx, l.start[1] + dz] : l.start, + end: l.endLinked ? [l.end[0] + dx, l.end[1] + dz] : l.end, + }, + ]) + } + return patches +} + // Frozen world matrix of the level group + its inverse. A node's placement // (`position` / `start` / `end`) is stored in its parent level's frame, but the // gizmos raycast the ground plane in WORLD space. When the building is rotated diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 0a7955b99..199416b00 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -1,6 +1,15 @@ -import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' +import { Vector3 } from 'three' +import { + classifyParticipant, + collectParticipants, + computeGroupBox, + expandToComponent, + levelFrame, + rotateGroupPatches, +} from '../components/editor/group-transform-shared' import { steppedRotation } from '../components/tools/item/placement-math' import { toggleDoorOpenState } from '../lib/door-interaction' import { guideEmitter } from '../lib/guide-events' @@ -26,6 +35,49 @@ function getRotatableSelectedReference() { return node } +// Group rotate: R/T on a multi-selection spins the whole selection rigidly +// ±45° around its bbox center — the keyboard sibling of the 3D group-rotate +// gizmo, sharing its participant snapshot + rigid-rotation math (welded +// wall/fence junctions, connected-component expansion). One batched +// `updateNodes` call = one undo step. Returns false when the selection holds +// no transformable participants so the caller can fall through to the +// single-selection arms. +function rotateGroupSelection(direction: 1 | -1): boolean { + const { selectedIds, levelId } = useViewer.getState().selection + if (selectedIds.length <= 1) return false + const nodes = useScene.getState().nodes + const participantIds = selectedIds.filter( + (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, + ) + if (participantIds.length === 0) return false + const fullIds = expandToComponent(participantIds, nodes, levelId) + const { starts, links } = collectParticipants(fullIds, nodes, levelId) + if (starts.length === 0) return false + + // Same pivot as the 3D gizmo: the selection's world bbox center, converted + // into the level frame before orbiting placements (a rotated building would + // otherwise displace the centre). + const box = computeGroupBox(fullIds) + if (!box) return false + const worldCenter = new Vector3( + (box.min.x + box.max.x) / 2, + box.min.y, + (box.min.z + box.max.z) / 2, + ) + const localCenter = worldCenter.applyMatrix4(levelFrame(levelId).inverse) + + // R (+45° yaw) orbits by -45° in the atan2 x→z sense: yaw = rotation - delta + // (see rotateGroupPatches), so keyboard direction matches the single-node + // steppedRotation sense. + const delta = -direction * (Math.PI / 4) + const patches = rotateGroupPatches(starts, links, { x: localCenter.x, z: localCenter.z }, delta) + useScene + .getState() + .updateNodes(patches.map(([id, data]) => ({ id, data: data as Partial }))) + sfxEmitter.emit('sfx:item-rotate') + return true +} + // Tools call this in their onCancel handler when they have an active mid-action to cancel, // so that the global Escape handler knows not to also switch to select mode. let _toolCancelConsumed = false @@ -326,6 +378,14 @@ export const useKeyboard = ({ // // References (guide/scan) live in `selectedReferenceId`, not the viewer // selection — check them first, like the Delete arm below. + // + // Multi-selection branches to the group rotate before any of the + // single-selection arms (reference, door/window flip, registry + // keyboardActions, plain rotate) — those stay single-selection-only. + if (rotateGroupSelection(1)) { + e.preventDefault() + return + } const rotatableReference = getRotatableSelectedReference() if (rotatableReference) { e.preventDefault() @@ -394,6 +454,11 @@ export const useKeyboard = ({ } } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) { // Rotate selected node counter-clockwise + // Multi-selection → group rotate, mirroring the R arm above. + if (rotateGroupSelection(-1)) { + e.preventDefault() + return + } const rotatableReference = getRotatableSelectedReference() if (rotatableReference) { e.preventDefault() From 0f059c6d1a8333781ca0c02684babbd74adf469c Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 8 Jul 2026 15:24:33 -0400 Subject: [PATCH 02/22] =?UTF-8?q?feat(editor):=20polygon=20participant=20k?= =?UTF-8?q?ind=20=E2=80=94=20slabs/ceilings/zones=20join=20group=20transfo?= =?UTF-8?q?rms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/editor/group-rotate-handle.tsx | 4 + .../editor/group-transform-shared.test.ts | 151 +++++++++++++++++- .../editor/group-transform-shared.ts | 39 +++-- packages/nodes/src/zone/renderer.tsx | 31 ++-- 4 files changed, 203 insertions(+), 22 deletions(-) diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index 429d3c977..7b58c1de9 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -186,6 +186,10 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { if (s.kind === 'endpoint') { reach(s.start[0], s.start[1]) reach(s.end[0], s.end[1]) + } else if (s.kind === 'polygon') { + for (const [x, z] of s.polygon) { + reach(x, z) + } } else { reach(s.position[0], s.position[2]) } diff --git a/packages/editor/src/components/editor/group-transform-shared.test.ts b/packages/editor/src/components/editor/group-transform-shared.test.ts index 6e1ae5e1c..0d3a8e314 100644 --- a/packages/editor/src/components/editor/group-transform-shared.test.ts +++ b/packages/editor/src/components/editor/group-transform-shared.test.ts @@ -1,7 +1,12 @@ import { beforeAll, describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core' import { z } from 'zod' -import { classifyParticipant, collectParticipants } from './group-transform-shared' +import { + classifyParticipant, + collectParticipants, + rotateGroupPatches, + translateGroupPatches, +} from './group-transform-shared' const BUILDING_SCOPED_KIND = 'group-transform-building-scoped-test' @@ -171,6 +176,150 @@ describe('group transform participants', () => { ]) }) + test('classifies and transforms polygon kinds (slab / ceiling / zone)', () => { + const nodes = { + building_test: { + id: 'building_test', + type: 'building', + children: ['level_test'], + }, + level_test: { + id: 'level_test', + type: 'level', + parentId: 'building_test', + children: ['slab_test', 'zone_test'], + }, + slab_test: { + id: 'slab_test', + type: 'slab', + parentId: 'level_test', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + holes: [ + [ + [0.5, 0.5], + [1, 0.5], + [1, 1], + ], + ], + }, + zone_test: { + id: 'zone_test', + type: 'zone', + parentId: 'level_test', + polygon: [ + [0, 0], + [1, 0], + [1, 1], + ], + }, + slab_elsewhere: { + id: 'slab_elsewhere', + type: 'slab', + parentId: 'level_other', + polygon: [ + [0, 0], + [1, 0], + [1, 1], + ], + }, + } as unknown as Record + + expect(classifyParticipant(nodes.slab_test, 'level_test', nodes)).toBe('polygon') + expect(classifyParticipant(nodes.zone_test, 'level_test', nodes)).toBe('polygon') + // Not in the active level frame → excluded. + expect(classifyParticipant(nodes.slab_elsewhere, 'level_test', nodes)).toBeNull() + + const { starts, links } = collectParticipants(['slab_test', 'zone_test'], nodes, 'level_test') + expect(links).toEqual([]) + expect(starts).toEqual([ + { + id: 'slab_test', + kind: 'polygon', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + holes: [ + [ + [0.5, 0.5], + [1, 0.5], + [1, 1], + ], + ], + }, + { + id: 'zone_test', + kind: 'polygon', + polygon: [ + [0, 0], + [1, 0], + [1, 1], + ], + holes: null, + }, + ]) + + // Translate: every vertex (holes included) shifts; the hole-less zone's + // patch never grows a `holes` field. + const moved = translateGroupPatches(starts, [], 1, -2) + expect(moved).toEqual([ + [ + 'slab_test', + { + polygon: [ + [1, -2], + [3, -2], + [3, 0], + [1, 0], + ], + holes: [ + [ + [1.5, -1.5], + [2, -1.5], + [2, -1], + ], + ], + }, + ], + [ + 'zone_test', + { + polygon: [ + [1, -2], + [2, -2], + [2, -1], + ], + }, + ], + ]) + + // Rotate 90° in the atan2 x→z sense around the origin: (x, z) → (-z, x). + const rotated = rotateGroupPatches( + starts.filter((s) => s.id === 'zone_test'), + [], + { x: 0, z: 0 }, + Math.PI / 2, + ) + expect(rotated).toHaveLength(1) + const rotatedPolygon = (rotated[0]![1] as { polygon: [number, number][] }).polygon + const expected = [ + [0, 0], + [0, 1], + [-1, 1], + ] + rotatedPolygon.forEach((point, i) => { + expect(point[0]).toBeCloseTo(expected[i]![0]!) + expect(point[1]).toBeCloseTo(expected[i]![1]!) + }) + }) + test('supports legacy level-parented elevators already loaded in the editor', () => { const nodes = { building_test: { diff --git a/packages/editor/src/components/editor/group-transform-shared.ts b/packages/editor/src/components/editor/group-transform-shared.ts index 5d89d334c..22965a0a0 100644 --- a/packages/editor/src/components/editor/group-transform-shared.ts +++ b/packages/editor/src/components/editor/group-transform-shared.ts @@ -25,12 +25,15 @@ const isVec3 = (v: unknown): v is Vec3 => Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number') const isVec2 = (v: unknown): v is Vec2 => Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number') +const isVec2Array = (v: unknown): v is Vec2[] => Array.isArray(v) && v.length > 0 && v.every(isVec2) // How a participant's placement transforms rigidly around / with the group: // - 'vec3' position + [x,y,z] rotation (items, …) // - 'scalar' position + numeric rotation (columns) // - 'endpoint' start/end tuples (walls, fences) -export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint' +// - 'polygon' [x,z] vertex arrays (slabs, ceilings, zones) — the placement +// lives in the vertices themselves (plus optional hole rings) +export type ParticipantKind = 'vec3' | 'scalar' | 'endpoint' | 'polygon' // A selected node qualifies when it belongs to the active level's horizontal // frame: either parented to that level, or declared building-scoped and parented @@ -88,14 +91,17 @@ export function classifyParticipant( if (isVec3(p) && isVec3(r)) return 'vec3' if (isVec3(p) && getParticipantScalarRotation(node) !== null) return 'scalar' if (isVec2(start) && isVec2(end)) return 'endpoint' + if (isVec2Array((node as { polygon?: unknown }).polygon)) return 'polygon' return null } -// Pre-drag placement snapshot + how to transform it. +// Pre-drag placement snapshot + how to transform it. `holes` is null when the +// kind carries no holes field (zone), so patches never write one onto it. export type ParticipantStart = | { id: AnyNodeId; kind: 'vec3'; position: Vec3; rotation: Vec3 } | { id: AnyNodeId; kind: 'scalar'; position: Vec3; rotation: number } | { id: AnyNodeId; kind: 'endpoint'; start: Vec2; end: Vec2 } + | { id: AnyNodeId; kind: 'polygon'; polygon: Vec2[]; holes: Vec2[][] | null } // An unselected wall/fence sharing a junction with a transforming endpoint. Only // the touching endpoint(s) follow, so the neighbour stays attached while its far @@ -143,7 +149,7 @@ export function collectParticipants( position: [position[0], position[1], position[2]], rotation, }) - } else { + } else if (kind === 'endpoint') { const n = node as AnyNode & { start: Vec2; end: Vec2 } starts.push({ id: id as AnyNodeId, @@ -151,6 +157,16 @@ export function collectParticipants( start: [n.start[0], n.start[1]], end: [n.end[0], n.end[1]], }) + } else { + const n = node as AnyNode & { polygon: Vec2[]; holes?: Vec2[][] } + starts.push({ + id: id as AnyNodeId, + kind, + polygon: n.polygon.map(([x, z]) => [x, z] as Vec2), + holes: Array.isArray(n.holes) + ? n.holes.map((hole) => hole.map(([x, z]) => [x, z] as Vec2)) + : null, + }) } } @@ -249,6 +265,10 @@ export function rotateGroupPatches( for (const s of starts) { if (s.kind === 'endpoint') { patches.push([s.id, { start: rot(s.start[0], s.start[1]), end: rot(s.end[0], s.end[1]) }]) + } else if (s.kind === 'polygon') { + const patch: Record = { polygon: s.polygon.map(([x, z]) => rot(x, z)) } + if (s.holes) patch.holes = s.holes.map((hole) => hole.map(([x, z]) => rot(x, z))) + patches.push([s.id, patch]) } else { const [px, pz] = rot(s.position[0], s.position[2]) const position: Vec3 = [px, s.position[1], pz] @@ -280,15 +300,14 @@ export function translateGroupPatches( 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: [s.start[0] + dx, s.start[1] + dz], - end: [s.end[0] + dx, s.end[1] + dz], - }, - ]) + patches.push([s.id, { start: shift(s.start), end: shift(s.end) }]) + } else if (s.kind === 'polygon') { + const patch: Record = { polygon: s.polygon.map(shift) } + if (s.holes) patch.holes = s.holes.map((hole) => hole.map(shift)) + patches.push([s.id, patch]) } else { patches.push([s.id, { position: [s.position[0] + dx, s.position[1], s.position[2] + dz] }]) } diff --git a/packages/nodes/src/zone/renderer.tsx b/packages/nodes/src/zone/renderer.tsx index 4f2ae3b7a..70b15b88d 100644 --- a/packages/nodes/src/zone/renderer.tsx +++ b/packages/nodes/src/zone/renderer.tsx @@ -115,37 +115,46 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => { // its scene identity (selection registry, GLB export polygon stamping). const showZones = useViewer((s) => s.showZones) + // Group-transform drags publish a translated/rotated polygon to + // `useLiveNodeOverrides`; merge it so the zone previews live instead of + // snapping only on commit (slab/ceiling get this via their systems' + // `getEffectiveNode`). Per-node subscription — unrelated overrides don't + // re-render this zone. + const livePolygon = useLiveNodeOverrides((s) => s.overrides.get(node.id)?.polygon) as + | Array<[number, number]> + | undefined + const polygon = livePolygon ?? node?.polygon + // Create floor shape from polygon const floorShape = useMemo(() => { - if (!node?.polygon || node.polygon.length < 3) return null + if (!polygon || polygon.length < 3) return null const shape = new Shape() - const firstPt = node.polygon[0]! + const firstPt = polygon[0]! // Shape is in X-Y plane, we rotate it to X-Z plane // Negate Y (which becomes Z) to get correct orientation shape.moveTo(firstPt[0]!, -firstPt[1]!) - for (let i = 1; i < node.polygon.length; i++) { - const pt = node.polygon[i]! + for (let i = 1; i < polygon.length; i++) { + const pt = polygon[i]! shape.lineTo(pt[0]!, -pt[1]!) } shape.closePath() return shape - }, [node?.polygon]) + }, [polygon]) // Create wall geometry from polygon const wallGeometry = useMemo(() => { - if (!node?.polygon || node.polygon.length < 2) return null - return createWallGeometry(node.polygon) - }, [node?.polygon]) + if (!polygon || polygon.length < 2) return null + return createWallGeometry(polygon) + }, [polygon]) // Calculate polygon centroid for label positioning using the geometric centroid formula // This correctly handles polygons regardless of vertex distribution along edges const centroid = useMemo(() => { - if (!node?.polygon || node.polygon.length < 3) return [0, 0] as [number, number] + if (!polygon || polygon.length < 3) return [0, 0] as [number, number] - const polygon = node.polygon let signedArea = 0 let cx = 0 let cz = 0 @@ -165,7 +174,7 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => { const factor = 1 / (6 * signedArea) return [cx * factor, cz * factor] as [number, number] - }, [node?.polygon]) + }, [polygon]) // Create materials const floorMaterial = useMemo(() => { From acd4c94b26ae12668714e664cafa57db85e6e21d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 8 Jul 2026 15:41:28 -0400 Subject: [PATCH 03/22] =?UTF-8?q?feat(editor):=202D=20floorplan=20group=20?= =?UTF-8?q?drag=20=E2=80=94=20Photoshop-style=20multi-selection=20move?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../editor-2d/floorplan-group-move.tsx | 379 ++++++++++++++++++ .../renderers/floorplan-registry-layer.tsx | 68 +++- .../editor/src/lib/floorplan/plan-coords.ts | 21 + 3 files changed, 447 insertions(+), 21 deletions(-) create mode 100644 packages/editor/src/components/editor-2d/floorplan-group-move.tsx create mode 100644 packages/editor/src/lib/floorplan/plan-coords.ts diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx new file mode 100644 index 000000000..0613a35d9 --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -0,0 +1,379 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + bboxCornerAnchors, + collectAlignmentAnchors, + type FloorplanPalette, + pauseSceneHistory, + resumeSceneHistory, + useLiveNodeOverrides, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { memo, useMemo } from 'react' +import { create } from 'zustand' +import { GROUP_MOVE_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, + translateGroupPatches, + type Vec2, +} from '../editor/group-transform-shared' +import { swallowNextClick } from '../editor/handles/use-handle-drag' + +// 2D sibling of the 3D `GroupMoveHandle`: dragging any selected element of a +// multi-selection slides the whole selection rigidly (Photoshop semantics). +// Shares the 3D gizmo's participant snapshot + translate math, its live +// preview channel (`useLiveNodeOverrides.setMany` + welded `LinkedNeighbor` +// endpoints), its snapping entry points (grid step via `isGridSnapActive`, +// Figma alignment via the shared resolver), and its 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, published so the dashed group bbox overlay +// rides along without re-rendering the whole registry layer per tick. +type FloorplanGroupDragState = { + delta: Vec2 | null + set: (delta: Vec2 | null) => void +} +const useFloorplanGroupDrag = create((set) => ({ + delta: null, + set: (delta) => set({ delta }), +})) + +/** + * Try to start a 2D group move for a pointer-down on `nodeId`. Returns false + * (attaching nothing) unless the node is a transformable member of a + * multi-selection — the caller falls through to its single-node behavior. + * + * `immediate` engages the session on pointer-down (move-handle dot semantics: + * the dot is an explicit move control); otherwise the session arms and only + * engages once the pointer travels past the drag threshold, and a plain + * click falls through to `onClickFallthrough` (the collapse-to-single + * selection click). + */ +export function startFloorplanGroupMove( + nodeId: AnyNodeId, + event: { clientX: number; clientY: number; pointerId: number }, + opts: { immediate?: boolean; onClickFallthrough?: () => void } = {}, +): boolean { + const { selectedIds, levelId } = useViewer.getState().selection + if (selectedIds.length < 2 || !selectedIds.includes(nodeId)) return false + const sceneNodes = useScene.getState().nodes + // The pressed element itself must transform — dragging a selected door / + // window (which rides its host wall) keeps today's single-node behavior. + if (classifyParticipant(sceneNodes[nodeId], levelId, sceneNodes) === null) return false + const startPlan = clientToPlan(event.clientX, event.clientY) + if (!startPlan) return false + + const startX = event.clientX + const startY = event.clientY + const pointerId = event.pointerId + + type Session = { + starts: ReturnType['starts'] + links: ReturnType['links'] + affectedIds: AnyNodeId[] + candidates: ReturnType + restAnchors: ReturnType + lastDelta: Vec2 | null + } + let session: Session | null = null + + const engage = (): Session | null => { + const nodes = useScene.getState().nodes + const participantIds = selectedIds.filter( + (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, + ) + // Move the full connected wall/fence component, mirroring the 3D gizmo. + const fullIds = expandToComponent(participantIds, nodes, levelId) + const { starts, links } = collectParticipants(fullIds, nodes, levelId) + if (starts.length === 0) return null + const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] + + // Alignment candidates — anchors of everything OUTSIDE the moving set + // (selection + welded neighbours), gathered once at drag start. + const movingIdSet = new Set(affectedIds) + const staticNodes: Record = {} + for (const [nid, n] of Object.entries(nodes)) { + if (n && !movingIdSet.has(nid)) staticNodes[nid] = n + } + const candidates = collectAlignmentAnchors(staticNodes, '', levelId) + + // The group aligns as one rigid footprint: its bbox corners + center are + // the moving anchors. `computeGroupBox` is world-space (the 3D scene stays + // mounted under every view mode); plan coords are level-frame, so convert. + const restBox = computeGroupBox(fullIds) + const { inverse: frameInv } = levelFrame(levelId) + const boxMin = restBox ? restBox.min.clone().applyMatrix4(frameInv) : null + const boxMax = restBox ? restBox.max.clone().applyMatrix4(frameInv) : null + const restAnchors = + boxMin && boxMax + ? bboxCornerAnchors( + 'group-move', + Math.min(boxMin.x, boxMax.x), + Math.min(boxMin.z, boxMax.z), + Math.max(boxMin.x, boxMax.x), + Math.max(boxMin.z, boxMax.z), + ) + : [] + + for (const id of affectedIds) { + useLiveTransforms.getState().clear(id) + } + + document.body.style.cursor = 'grabbing' + sfxEmitter.emit('sfx:item-pick') + swallowNextClick() + useViewer.getState().setInputDragging(true) + pauseSceneHistory(useScene) + useInteractionScope.getState().begin({ + kind: 'handle-drag', + nodeId, + handle: GROUP_MOVE_DRAG_LABEL, + }) + return { starts, links, affectedIds, candidates, restAnchors, 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() + } + + // 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]) + } + + 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) + 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() + useViewer.getState().setInputDragging(false) + 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) + } + + const onUp = (e: PointerEvent) => { + if (e.pointerId !== pointerId) return + if (!session) { + removeListeners() + opts.onClickFallthrough?.() + return + } + // Eat the click that follows pointer-up so the background click handler + // doesn't clear the multi-selection the drag is preserving. + swallowNextClick() + sfxEmitter.emit('sfx:item-place') + const overrides = useLiveNodeOverrides.getState() + const updates: { id: AnyNodeId; data: Partial }[] = [] + for (const id of session.affectedIds) { + const patch = overrides.get(id) + if (patch) updates.push({ id, data: patch as Partial }) + } + // Resume before the commit so the single batched `updateNodes` is the one + // tracked set — collapsing the whole group move into one undo step. + resumeSceneHistory(useScene) + if (updates.length > 0) useScene.getState().updateNodes(updates) + 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) => { + if (e.key !== 'Escape') return + e.preventDefault() + e.stopPropagation() + swallowNextClick() + cancel() + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onPointerCancel) + window.addEventListener('keydown', onKeyDown, true) + + if (opts.immediate) { + session = engage() + if (!session) { + removeListeners() + return false + } + } + return true +} + +/** + * 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. Rides the live drag delta so it tracks the + * group mid-gesture. Mounted inside the floor-plan scene ``, so plan + * coords render directly. + */ +export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionBox({ + palette, + unitsPerPixel, +}: { + palette: FloorplanPalette | undefined + unitsPerPixel: number +}) { + 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 movingNode = useMovingNode() + + 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), + } + }, [selectedIds, levelId, nodes]) + + if (!box || movingNode) return null + + const pad = 6 * unitsPerPixel + const stroke = palette?.selectedStroke ?? '#3b82f6' + return ( + + + + ) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index cb7a949f4..bd67cd28d 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -4,7 +4,6 @@ import { type AnyNode, type AnyNodeId, createSceneApi, - type FloorplanAffordancePoint, type FloorplanAffordanceSession, type FloorplanGeometry, type FloorplanPalette, @@ -41,6 +40,7 @@ import { snapDirectRotationDelta, } from '../../../lib/direct-manipulation' import { createEditorApi } from '../../../lib/editor-api' +import { clientToPlan } from '../../../lib/floorplan/plan-coords' import { type ActiveInteractionScope, boundaryReshapeScope, @@ -59,6 +59,7 @@ import useInteractionScope, { useMovingNode, } from '../../../store/use-interaction-scope' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' +import { FloorplanGroupSelectionBox, startFloorplanGroupMove } from '../floorplan-group-move' import { useFloorplanRender } from '../floorplan-render-context' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' @@ -625,13 +626,46 @@ 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) still collapses the selection to the pressed node on release. + // Modified clicks (selection toggle) and Cmd-drag / direct-rotate keep + // their existing paths. `immediate` engages without the drag threshold — + // the move-handle dot's pick-up semantics. + const startGroupMoveDrag = useCallback( + (id: AnyNodeId, event: ReactPointerEvent, immediate = false): boolean => { + if (event.button !== 0) return false + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false + if (movingNode) return false + if (useEditor.getState().mode === 'delete') return false + const started = startFloorplanGroupMove(id, event, { + immediate, + onClickFallthrough: immediate ? undefined : () => applyEntrySelection(id, false), + }) + if (!started) return false + event.preventDefault() + event.stopPropagation() + suppressBoxSelectForPointer(event) + return true + }, + [movingNode, applyEntrySelection], + ) + + // Move-handle dot variant — routes the dot through the group session when + // the owning node is part of a multi-selection. + const handleGroupMoveHandlePointerDown = useCallback( + (id: AnyNodeId, event: ReactPointerEvent) => startGroupMoveDrag(id, event, true), + [startGroupMoveDrag], + ) + const handleEntryPointerDown = useCallback( (id: AnyNodeId, event: ReactPointerEvent) => { if (startDirectMoveDrag(id, event)) return if (startDirectRotateDrag(id, event)) return + if (startGroupMoveDrag(id, event)) return handleSelect(id, event) }, - [handleSelect, startDirectMoveDrag, startDirectRotateDrag], + [handleSelect, startDirectMoveDrag, startDirectRotateDrag, startGroupMoveDrag], ) const floorplanData = useMemo(() => { @@ -1074,6 +1108,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { nodes={nodes} onClickStop={handleClickStop} onEntryPointerDown={handleEntryPointerDown} + onGroupMovePointerDown={handleGroupMoveHandlePointerDown} onHandleHoverChange={setHoveredHandleId} onHandlePointerDown={startAffordanceDrag} onHoveredIdChange={setHoveredId} @@ -1120,6 +1155,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { nodes={nodes} onClickStop={handleClickStop} onEntryPointerDown={handleEntryPointerDown} + onGroupMovePointerDown={handleGroupMoveHandlePointerDown} onHandleHoverChange={setHoveredHandleId} onHandlePointerDown={startAffordanceDrag} onHoveredIdChange={setHoveredId} @@ -1136,6 +1172,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { /> ))} + {/* Dashed group bbox — shows what a group drag carries along while a + multi-selection exists; rides the live delta mid-drag. */} + {/* Transient live-rotation readout — drawn last so the wedge + degree chip sit above all handle chrome while a rotate-arrow is dragged. */} {rotationOverlay && palette ? ( @@ -1171,6 +1210,7 @@ type FloorplanRegistryEntryProps = { nodes: Record onClickStop: (event: React.MouseEvent) => void onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent) => void + onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent) => boolean onHandleHoverChange: (id: string | null) => void onHandlePointerDown: ( nodeId: AnyNodeId, @@ -1213,6 +1253,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ nodes, onClickStop, onEntryPointerDown, + onGroupMovePointerDown, onHandleHoverChange, onHandlePointerDown, onHoveredIdChange, @@ -1276,13 +1317,16 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ event.preventDefault() event.stopPropagation() suppressBoxSelectForPointer(event) + // In a multi-selection the move dot drives the group session, matching + // the body-drag gesture — the whole selection slides, not one member. + if (onGroupMovePointerDown(nodeId, event)) return sfxEmitter.emit('sfx:item-pick') setMovingNode(currentNode as never) // Claim 2D ownership of this move at the source. `setMovingNode` // resets the origin to null, so this must follow it. setMovingNodeOrigin('2d') }, - [nodeId, setMovingNode, setMovingNodeOrigin], + [nodeId, onGroupMovePointerDown, setMovingNode, setMovingNodeOrigin], ) const cacheEntry = buildFloorplanEntryGeometry({ @@ -2871,24 +2915,6 @@ function formatGroupTransform(t?: { return parts.length > 0 ? parts.join(' ') : undefined } -function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null { - // The registry layer lives under the floor-plan scene ``. The - // legacy panel computes the same conversion via floorplanSceneRef + - // getScreenCTM; we replicate it by walking up to the SVG owner. - const target = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null - const svg = target?.ownerSVGElement - if (!(svg && target)) return null - const ctm = target.getScreenCTM() - if (!ctm) return null - const point = svg.createSVGPoint() - point.x = clientX - point.y = clientY - const transformed = point.matrixTransform(ctm.inverse()) - // The floor-plan `` maps plan X/Z directly to SVG x/y (Z stored as - // the Y axis on screen — same convention as `toSvgPlanPoint`). - return [transformed.x, transformed.y] -} - function swallowNextClick(timeoutMs = 0) { const swallowClick = (event: MouseEvent) => { event.stopPropagation() diff --git a/packages/editor/src/lib/floorplan/plan-coords.ts b/packages/editor/src/lib/floorplan/plan-coords.ts new file mode 100644 index 000000000..ac49eb5ab --- /dev/null +++ b/packages/editor/src/lib/floorplan/plan-coords.ts @@ -0,0 +1,21 @@ +import type { FloorplanAffordancePoint } from '@pascal-app/core' + +/** + * Convert client (screen) coordinates into floor-plan plan coordinates via + * the mounted floor-plan scene ``'s screen CTM. The scene `` maps plan + * X/Z directly to SVG x/y (Z stored as the Y axis on screen — same convention + * as `toSvgPlanPoint`), so the returned point is in the same level-frame + * meters node placements use. Null when no floor plan is mounted. + */ +export function clientToPlan(clientX: number, clientY: number): FloorplanAffordancePoint | null { + const target = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null + const svg = target?.ownerSVGElement + if (!(svg && target)) return null + const ctm = target.getScreenCTM() + if (!ctm) return null + const point = svg.createSVGPoint() + point.x = clientX + point.y = clientY + const transformed = point.matrixTransform(ctm.inverse()) + return [transformed.x, transformed.y] +} From 8e2b11f91aa983364d93f24c5a61d911d97b480e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 8 Jul 2026 15:44:01 -0400 Subject: [PATCH 04/22] =?UTF-8?q?feat(editor):=20group=20manipulation=20po?= =?UTF-8?q?lish=20=E2=80=94=20helper=20text=20+=20shortcuts=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../keyboard-shortcuts-dialog.tsx | 9 ++++++++ .../editor/src/lib/contextual-help.test.ts | 23 +++++++++++++++++++ packages/editor/src/lib/contextual-help.ts | 17 ++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index a95c044ee..5fab0538c 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -78,6 +78,15 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ action: 'Add or remove an object from canvas multi-selection', note: 'In the scene graph, Shift-click selects the visible range like a file browser.', }, + { + keys: ['Left click'], + action: 'Drag any selected object to move the whole multi-selection', + note: 'With 2+ objects selected — in the 2D floor plan and via the 3D move handle alike.', + }, + { + keys: ['R', 'T'], + action: 'Rotate a multi-selection ±45° around its center', + }, ], }, { diff --git a/packages/editor/src/lib/contextual-help.test.ts b/packages/editor/src/lib/contextual-help.test.ts index f4ea906f0..151878387 100644 --- a/packages/editor/src/lib/contextual-help.test.ts +++ b/packages/editor/src/lib/contextual-help.test.ts @@ -58,6 +58,29 @@ 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: 'Drag any selected object to move the whole selection', + }, + { keys: ['R / T'], label: 'Rotate the selection ±45°' }, + { + keys: [['Cmd/Ctrl', 'Shift'], 'Left click'], + label: 'Add or remove objects from the selection', + active: false, + }, + ]) + }) + test('holding a modifier keeps the same rows and only lights the selection one', () => { // Guides/snapping are governed by the snapping mode (Shift toggles it), // so no modifier-specific "freely / with guides / bypass" variants exist. diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts index 6620654cd..fd64a9787 100644 --- a/packages/editor/src/lib/contextual-help.ts +++ b/packages/editor/src/lib/contextual-help.ts @@ -82,6 +82,23 @@ export function resolveSelectModeHelpHints({ return hints } + // Multi-selection — advertise the group gestures: drag any member to slide + // the whole selection (2D floor plan body drag + the 3D move gizmo), R / T + // to step-rotate the group around its bounding-box center. + if (selectedCount > 1) { + hints.push({ + keys: [LEFT_CLICK], + label: 'Drag any selected object to move the whole selection', + }) + 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, + }) + return hints + } + // MEP handle workflow — duct/pipe runs and fittings are edited through the // in-world arrow rig that a click on the handle dot reveals, so surface those // hints first. A run endpoint's side / up-down arrows swing the run and Alt From 5d57758545c0f675eb05e8c76b20baf5c45197b3 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 8 Jul 2026 15:45:33 -0400 Subject: [PATCH 05/22] 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 --- packages/nodes/src/zone/renderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes/src/zone/renderer.tsx b/packages/nodes/src/zone/renderer.tsx index 70b15b88d..271a35435 100644 --- a/packages/nodes/src/zone/renderer.tsx +++ b/packages/nodes/src/zone/renderer.tsx @@ -1,6 +1,6 @@ 'use client' -import { useRegistry, type ZoneNode } from '@pascal-app/core' +import { useLiveNodeOverrides, useRegistry, type ZoneNode } from '@pascal-app/core' import { useNodeEvents, useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useMemo, useRef } from 'react' From 4a71b82b1658c02ba642a111c7b4b5d370d8544c Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 8 Jul 2026 16:10:36 -0400 Subject: [PATCH 06/22] feat(editor): tag the 2D group selection box for drivability data-group-selection-box on the overlay so smoke drivers and devtools can assert the multi-selection chrome. Co-Authored-By: Claude Fable 5 --- .../src/components/editor-2d/floorplan-group-move.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx index 0613a35d9..a31a7db27 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -363,7 +363,11 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB const pad = 6 * unitsPerPixel const stroke = palette?.selectedStroke ?? '#3b82f6' return ( - + Date: Thu, 9 Jul 2026 08:10:09 -0400 Subject: [PATCH 07/22] =?UTF-8?q?feat(editor):=20multi-selection=20shows?= =?UTF-8?q?=20highlight=20only=20=E2=80=94=20hide=20per-node=20edit=20chro?= =?UTF-8?q?me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../renderers/floorplan-registry-layer.tsx | 65 ++++++++++++++++++- .../editor/slab-hole-highlights.tsx | 4 +- .../src/components/tools/tool-manager.tsx | 7 ++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index bd67cd28d..5def0d11a 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -58,6 +58,7 @@ import useInteractionScope, { useEndpointReshape, useMovingNode, } from '../../../store/use-interaction-scope' +import { classifyParticipant } from '../../editor/group-transform-shared' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { FloorplanGroupSelectionBox, startFloorplanGroupMove } from '../floorplan-group-move' import { useFloorplanRender } from '../floorplan-render-context' @@ -245,6 +246,8 @@ type FloorplanLevelDataHook = (args: { type FloorplanRenderPass = 'base' | 'overlay' const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const +// Group members advertise the drag-to-move-the-selection gesture. +const MOVE_CURSOR_STYLE = { cursor: 'move' } as const const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const function snapshotNode(node: AnyNode): NodeSnapshot { @@ -355,6 +358,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // Marquee preview selection — matches the legacy `highlightedIdSet` use // (filter-while-marquee), surfaces selection chrome without keyboard focus. const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds]) + // Multi-selection: members show highlight only (per-node edit chrome hidden) + // and transformable members advertise the drag-to-move gesture. + const isMultiSelect = selectedIds.length > 1 + const groupParticipantIdSet = useMemo(() => { + if (selectedIds.length < 2 || !levelId) return null + return new Set( + selectedIds.filter( + (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, + ), + ) + }, [selectedIds, levelId, nodes]) // Interactive state lives in refs; only the visible feedback bits go // into React state to keep re-renders cheap during drag. @@ -1116,6 +1130,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { pass="base" sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0} selected={selectedIdSet.has(entry.id)} + suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)} + groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false} setMovingNode={setMovingNode} setMovingNodeOrigin={setMovingNodeOrigin} siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0} @@ -1163,6 +1179,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { pass="overlay" sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0} selected={selectedIdSet.has(entry.id)} + suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)} + groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false} setMovingNode={setMovingNode} setMovingNodeOrigin={setMovingNodeOrigin} siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0} @@ -1208,6 +1226,10 @@ type FloorplanRegistryEntryProps = { node: AnyNode nodeId: AnyNodeId nodes: Record + /** Selected member of a multi-selection: hide its per-node edit chrome. */ + suppressHandles: boolean + /** Transformable member of a multi-selection: advertise drag-to-move. */ + groupMoveCursor: boolean onClickStop: (event: React.MouseEvent) => void onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent) => void onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent) => boolean @@ -1251,6 +1273,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ node, nodeId, nodes, + suppressHandles, + groupMoveCursor, onClickStop, onEntryPointerDown, onGroupMovePointerDown, @@ -1349,7 +1373,14 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ siblingEpoch, visibilityRootId, }) - const geometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null + const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null + // Multi-selection shows highlight only: strip this member's edit handles / + // dimension chrome (all of which live in the overlay pass) while keeping + // its highlighted body geometry. + const geometry = + rawGeometry && suppressHandles && pass === 'overlay' + ? stripHandleChrome(rawGeometry) + : rawGeometry if (!geometry) return null const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop @@ -1364,7 +1395,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ onPointerDown={entryPointerDown} onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} - style={POINTER_CURSOR_STYLE} + style={groupMoveCursor ? MOVE_CURSOR_STYLE : POINTER_CURSOR_STYLE} >