diff --git a/packages/editor/src/components/editor-2d/floorplan-group-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-group-action-menu.tsx index 47274f1186..88f9ca09e2 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-action-menu.tsx @@ -1,11 +1,17 @@ 'use client' +import { useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { isActive } from '../../lib/interaction/scope' +import { canCreateSessionGroup, selectionIntersectsSessionGroup } from '../../lib/session-groups' import useEditor from '../../store/use-editor' import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope' +import useSessionGroups, { + groupCurrentSelection, + ungroupCurrentSelection, +} from '../../store/use-session-groups' import { deleteSelection, duplicateSelectionAndPickUp, @@ -14,25 +20,27 @@ import { 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. + * Floating multi-select pill on the floor plan: Move, Group, Ungroup, Duplicate, Delete. */ export function FloorplanGroupActionMenu() { - const isMultiSelect = useViewer((s) => s.selection.selectedIds.length > 1) + const selectedIds = useViewer((s) => s.selection.selectedIds) + const isMultiSelect = selectedIds.length > 1 const movingNode = useMovingNode() const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const scopeActive = useInteractionScope((s) => isActive(s.scope)) + const sessionGroups = useSessionGroups((s) => s.groups) + const sceneNodes = useScene((s) => s.nodes) + const liveIds = useMemo(() => new Set(Object.keys(sceneNodes)), [sceneNodes]) + const showGroup = useMemo( + () => canCreateSessionGroup(sessionGroups, selectedIds, liveIds), + [sessionGroups, selectedIds, liveIds], + ) + const showUngroup = useMemo( + () => selectionIntersectsSessionGroup(sessionGroups, selectedIds, liveIds), + [sessionGroups, selectedIds, liveIds], + ) const [position, setPosition] = useState<{ left: number; top: number } | null>(null) - const isVisible = isMultiSelect && !movingNode && isFloorplanHovered && !scopeActive useEffect(() => { @@ -43,9 +51,6 @@ export function FloorplanGroupActionMenu() { 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)) @@ -77,9 +82,11 @@ export function FloorplanGroupActionMenu() { deleteSelection()} onDuplicate={() => duplicateSelectionAndPickUp()} + onGroup={showGroup ? () => groupCurrentSelection() : undefined} onMove={() => startGroupPickUp()} onPointerDown={(event) => event.stopPropagation()} onPointerUp={(event) => event.stopPropagation()} + onUngroup={showUngroup ? () => ungroupCurrentSelection() : undefined} /> , document.body, 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 75ad84e24e..ba07c0c15f 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 @@ -91,6 +91,7 @@ import useInteractionScope, { useEndpointReshape, useMovingNode, } from '../../../store/use-interaction-scope' +import { expandSessionSelectionForNode } from '../../../store/use-session-groups' import { startGroupPickUp } from '../../editor/group-actions' import { classifyParticipant } from '../../editor/group-transform-shared' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' @@ -589,13 +590,19 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { }, [bumpAffectedSiblingEpochs]) const applyEntrySelection = useCallback( - (id: AnyNodeId, shouldToggle: boolean) => { + (id: AnyNodeId, options: { shouldToggle: boolean; isolateMember: boolean }) => { const currentSelectedIds = useViewer.getState().selection.selectedIds - const nextSelectedIds = shouldToggle - ? currentSelectedIds.includes(id) + let nextSelectedIds: string[] + if (options.shouldToggle) { + nextSelectedIds = currentSelectedIds.includes(id) ? currentSelectedIds.filter((selectedId) => selectedId !== id) : [...currentSelectedIds, id] - : [id] + } else if (options.isolateMember) { + nextSelectedIds = [id] + } else { + const expanded = expandSessionSelectionForNode(id) + nextSelectedIds = expanded && expanded.length > 1 ? expanded : [id] + } setSelection({ selectedIds: nextSelectedIds }) if (nextSelectedIds.length === 1 && nextSelectedIds[0] === id) { const node = useScene.getState().nodes[id] @@ -618,7 +625,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { (id: AnyNodeId, event: React.PointerEvent) => { if (event.button !== 0) return event.stopPropagation() - applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey) + applyEntrySelection(id, { + shouldToggle: event.metaKey || event.ctrlKey || event.shiftKey, + isolateMember: event.altKey && !(event.metaKey || event.ctrlKey || event.shiftKey), + }) }, [applyEntrySelection], ) @@ -691,7 +701,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { if (endEvent.pointerId !== pointerId) return cleanup() if (!engaged) { - applyEntrySelection(id, true) + // Cmd/Ctrl+click without drag: toggle member (options object, not bare boolean). + applyEntrySelection(id, { + shouldToggle: true, + isolateMember: false, + }) } } diff --git a/packages/editor/src/components/editor/floorplan-background-selection.test.ts b/packages/editor/src/components/editor/floorplan-background-selection.test.ts index 856a7259c0..b0e5212170 100644 --- a/packages/editor/src/components/editor/floorplan-background-selection.test.ts +++ b/packages/editor/src/components/editor/floorplan-background-selection.test.ts @@ -7,7 +7,7 @@ const baseArgs = { currentSelectedIds: ['wall_1'], getFloorplanHitIdAtPoint: () => 'door_1', isWallBuildActive: false, - modifierKeys: { meta: false, ctrl: false, shift: false }, + modifierKeys: { meta: false, ctrl: false, shift: false, alt: false }, planPoint: [0, 0] as [number, number], structureLayer: 'elements', } @@ -16,7 +16,7 @@ describe('resolveFloorplanBackgroundSelection', () => { test('shift-click on a floorplan node toggles into the current selection', () => { const result = resolveFloorplanBackgroundSelection({ ...baseArgs, - modifierKeys: { meta: false, ctrl: false, shift: true }, + modifierKeys: { meta: false, ctrl: false, shift: true, alt: false }, }) expect(result).toEqual({ @@ -30,7 +30,7 @@ describe('resolveFloorplanBackgroundSelection', () => { const result = resolveFloorplanBackgroundSelection({ ...baseArgs, currentSelectedIds: ['wall_1', 'door_1'], - modifierKeys: { meta: false, ctrl: false, shift: true }, + modifierKeys: { meta: false, ctrl: false, shift: true, alt: false }, }) expect(result).toEqual({ @@ -44,7 +44,7 @@ describe('resolveFloorplanBackgroundSelection', () => { const result = resolveFloorplanBackgroundSelection({ ...baseArgs, getFloorplanHitIdAtPoint: () => null, - modifierKeys: { meta: false, ctrl: false, shift: true }, + modifierKeys: { meta: false, ctrl: false, shift: true, alt: false }, }) expect(result).toEqual({ @@ -54,6 +54,33 @@ describe('resolveFloorplanBackgroundSelection', () => { }) }) + test('plain click expands a session group', () => { + const result = resolveFloorplanBackgroundSelection({ + ...baseArgs, + expandIdsForNode: (nodeId) => (nodeId === 'door_1' ? ['door_1', 'wall_2'] : null), + }) + + expect(result).toEqual({ + handled: true, + kind: 'select-elements', + selectedIds: ['door_1', 'wall_2'], + }) + }) + + test('alt-click selects one member without expanding', () => { + const result = resolveFloorplanBackgroundSelection({ + ...baseArgs, + expandIdsForNode: () => ['door_1', 'wall_2'], + modifierKeys: { meta: false, ctrl: false, shift: false, alt: true }, + }) + + expect(result).toEqual({ + handled: true, + kind: 'select-elements', + selectedIds: ['door_1'], + }) + }) + test('uses the registry hit result for zone selection', () => { const result = resolveFloorplanBackgroundSelection({ ...baseArgs, diff --git a/packages/editor/src/components/editor/floorplan-background-selection.ts b/packages/editor/src/components/editor/floorplan-background-selection.ts index 7845aa09a7..a031761984 100644 --- a/packages/editor/src/components/editor/floorplan-background-selection.ts +++ b/packages/editor/src/components/editor/floorplan-background-selection.ts @@ -7,12 +7,16 @@ type ModifierKeys = { meta: boolean ctrl: boolean shift: boolean + /** Alt alone: select one session-group member without expanding. */ + alt: boolean } type ResolveFloorplanBackgroundSelectionArgs = { canSelectElementFloorplanGeometry: boolean canSelectFloorplanZones: boolean currentSelectedIds: string[] + /** Session-group expand on plain click (not on modifier/Alt). */ + expandIdsForNode?: (nodeId: string) => string[] | null getFloorplanHitIdAtPoint: (planPoint: WallPlanPoint) => string | null isWallBuildActive: boolean modifierKeys: ModifierKeys @@ -20,6 +24,26 @@ type ResolveFloorplanBackgroundSelectionArgs = { structureLayer: string } +function hasToggleModifier(modifierKeys: ModifierKeys): boolean { + return modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift +} + +function resolveHitSelection( + hitId: string, + currentSelectedIds: string[], + modifierKeys: ModifierKeys, + expandIdsForNode?: (nodeId: string) => string[] | null, +): string[] { + if (hasToggleModifier(modifierKeys)) { + return currentSelectedIds.includes(hitId) + ? currentSelectedIds.filter((selectedId) => selectedId !== hitId) + : [...currentSelectedIds, hitId] + } + if (modifierKeys.alt) return [hitId] + const expanded = expandIdsForNode?.(hitId) + return expanded && expanded.length > 1 ? expanded : [hitId] +} + export type FloorplanBackgroundSelectionResult = | { handled: true @@ -48,6 +72,7 @@ export function resolveFloorplanBackgroundSelection({ canSelectElementFloorplanGeometry, canSelectFloorplanZones, currentSelectedIds, + expandIdsForNode, getFloorplanHitIdAtPoint, isWallBuildActive, modifierKeys, @@ -71,12 +96,7 @@ export function resolveFloorplanBackgroundSelection({ return { handled: true, kind: 'select-elements', - selectedIds: - modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift - ? currentSelectedIds.includes(hitId) - ? currentSelectedIds.filter((selectedId) => selectedId !== hitId) - : [...currentSelectedIds, hitId] - : [hitId], + selectedIds: resolveHitSelection(hitId, currentSelectedIds, modifierKeys, expandIdsForNode), } } } @@ -92,7 +112,7 @@ export function resolveFloorplanBackgroundSelection({ return { handled: true, kind: 'clear-elements', - preserveSelection: modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift, + preserveSelection: hasToggleModifier(modifierKeys), } } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 4e2be01a4a..99c771931b 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -115,6 +115,7 @@ import useInteractionScope, { useReshapingNode, } from '../../store/use-interaction-scope' import usePlacementPreview from '../../store/use-placement-preview' +import { expandSessionSelectionForNode } from '../../store/use-session-groups' import { useStairBuildPreview } from '../../store/use-stair-build-preview' import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' @@ -843,11 +844,13 @@ function getSelectionModifierKeys(event?: { metaKey?: boolean ctrlKey?: boolean shiftKey?: boolean + altKey?: boolean }) { return { meta: Boolean(event?.metaKey), ctrl: Boolean(event?.ctrlKey), shift: Boolean(event?.shiftKey), + alt: Boolean(event?.altKey), } } @@ -9978,6 +9981,7 @@ export function FloorplanPanel({ canSelectElementFloorplanGeometry, canSelectFloorplanZones, currentSelectedIds: useViewer.getState().selection.selectedIds, + expandIdsForNode: expandSessionSelectionForNode, getFloorplanHitIdAtPoint, isWallBuildActive, modifierKeys, diff --git a/packages/editor/src/components/editor/group-floating-action-menu.tsx b/packages/editor/src/components/editor/group-floating-action-menu.tsx index 1ad52801e7..6f755add3c 100644 --- a/packages/editor/src/components/editor/group-floating-action-menu.tsx +++ b/packages/editor/src/components/editor/group-floating-action-menu.tsx @@ -7,28 +7,27 @@ 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 { canCreateSessionGroup, selectionIntersectsSessionGroup } from '../../lib/session-groups' import useEditor from '../../store/use-editor' import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope' +import useSessionGroups, { + groupCurrentSelection, + ungroupCurrentSelection, +} from '../../store/use-session-groups' 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. + * Floating pill for MULTI-selection in 3D: Move, Group, Ungroup, Duplicate, Delete. + * Group/Ungroup are session-only (Ctrl/Cmd+G / Ctrl/Cmd+Shift+G). */ export function GroupFloatingActionMenu() { const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -37,8 +36,7 @@ export function GroupFloatingActionMenu() { 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 sessionGroups = useSessionGroups((s) => s.groups) const scope = useInteractionScope((s) => s.scope) const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden' @@ -55,9 +53,18 @@ export function GroupFloatingActionMenu() { [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 liveIds = useMemo(() => new Set(Object.keys(nodes)), [nodes]) + // Always offer Group when multi-select is not already exactly a session group. + const showGroup = useMemo( + () => canCreateSessionGroup(sessionGroups, selectedIds, liveIds), + [sessionGroups, selectedIds, liveIds], + ) + // Ungroup when any selected member is in a session group. + const showUngroup = useMemo( + () => selectionIntersectsSessionGroup(sessionGroups, selectedIds, liveIds), + [sessionGroups, selectedIds, liveIds], + ) + const meshEpoch = useMeshSettleEpoch(nodes) const anchor = useMemo(() => { void meshEpoch @@ -73,8 +80,6 @@ export function GroupFloatingActionMenu() { }, [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 @@ -92,6 +97,14 @@ export function GroupFloatingActionMenu() { event.stopPropagation() startGroupPickUp() }, []) + const handleGroup = useCallback((event: React.MouseEvent) => { + event.stopPropagation() + groupCurrentSelection() + }, []) + const handleUngroup = useCallback((event: React.MouseEvent) => { + event.stopPropagation() + ungroupCurrentSelection() + }, []) const handleDuplicate = useCallback((event: React.MouseEvent) => { event.stopPropagation() duplicateSelectionAndPickUp() @@ -119,9 +132,11 @@ export function GroupFloatingActionMenu() { diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 90130ecdf8..59f60bed43 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -33,6 +33,7 @@ import { import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import useFloorplanMode from '../../store/use-floorplan-mode' +import useSessionGroups from '../../store/use-session-groups' import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system' import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { RoofEditSystem } from '../systems/roof/roof-edit-system' @@ -1208,6 +1209,8 @@ export default function Editor({ setIsSceneLoading(true) useScene.getState().unloadScene() useViewer.getState().resetSelection() + // Session groups are not scene-graph state — clear on every load/switch. + useSessionGroups.getState().clearGroups() try { const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage() @@ -1243,6 +1246,9 @@ export default function Editor({ // Apply preview scene when version preview mode changes useEffect(() => { if (isVersionPreviewMode && previewScene) { + // Drop session groups from the edit session so plain-click expand cannot + // pull in members that are not part of the preview graph. + useSessionGroups.getState().clearGroups() applySceneGraphToEditor(previewScene) } }, [isVersionPreviewMode, previewScene]) diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index 39f2248504..a503d24ba0 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -1,7 +1,7 @@ 'use client' import { Icon } from '@iconify/react' -import { Copy, Move, Search, Spline, Trash2 } from 'lucide-react' +import { Copy, Group, Move, Search, Spline, Trash2, Ungroup } from 'lucide-react' import type { MouseEventHandler, PointerEventHandler } from 'react' type NodeActionMenuProps = { @@ -11,6 +11,10 @@ type NodeActionMenuProps = { onDuplicate?: MouseEventHandler onMove?: MouseEventHandler onCurve?: MouseEventHandler + /** Session group (Ctrl/Cmd+G) — multi-selection floating pill. */ + onGroup?: MouseEventHandler + /** Dissolve session group (Ctrl/Cmd+Shift+G). */ + onUngroup?: MouseEventHandler onPointerDown?: PointerEventHandler onPointerUp?: PointerEventHandler onPointerEnter?: PointerEventHandler @@ -24,6 +28,8 @@ export function NodeActionMenu({ onDuplicate, onMove, onCurve, + onGroup, + onUngroup, onPointerDown, onPointerUp, onPointerEnter, @@ -59,6 +65,28 @@ export function NodeActionMenu({ )} + {onGroup && ( + + )} + {onUngroup && ( + + )} {onCurve && (