@pascal-app/editor 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/level-name.ts +0 -11
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
bboxCornerAnchors,
|
|
7
|
+
collectAlignmentAnchors,
|
|
8
|
+
DEFAULT_ANGLE_STEP,
|
|
9
|
+
type FloorplanPalette,
|
|
10
|
+
pauseSceneHistory,
|
|
11
|
+
pauseSpaceDetection,
|
|
12
|
+
resumeSceneHistory,
|
|
13
|
+
resumeSpaceDetection,
|
|
14
|
+
useLiveNodeOverrides,
|
|
15
|
+
useLiveTransforms,
|
|
16
|
+
useScene,
|
|
17
|
+
} from '@pascal-app/core'
|
|
18
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
19
|
+
import { memo, type PointerEvent as ReactPointerEvent, useEffect, useMemo, useState } from 'react'
|
|
20
|
+
import { create } from 'zustand'
|
|
21
|
+
import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help'
|
|
22
|
+
import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
|
|
23
|
+
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
|
24
|
+
import { isHistoryShortcut } from '../../lib/history'
|
|
25
|
+
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
26
|
+
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
27
|
+
import useEditor, {
|
|
28
|
+
isAlignmentGuideActive,
|
|
29
|
+
isGridSnapActive,
|
|
30
|
+
isMagneticSnapActive,
|
|
31
|
+
} from '../../store/use-editor'
|
|
32
|
+
import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
|
|
33
|
+
import {
|
|
34
|
+
classifyParticipant,
|
|
35
|
+
collectParticipants,
|
|
36
|
+
computeGroupBox,
|
|
37
|
+
expandToComponent,
|
|
38
|
+
levelFrame,
|
|
39
|
+
participantExtents,
|
|
40
|
+
rotateGroupPatches,
|
|
41
|
+
rotateGroupSnapshots,
|
|
42
|
+
translateGroupPatches,
|
|
43
|
+
type Vec2,
|
|
44
|
+
} from '../editor/group-transform-shared'
|
|
45
|
+
import { swallowNextClick } from '../editor/handles/use-handle-drag'
|
|
46
|
+
import { useMeshSettleEpoch } from '../editor/use-mesh-settle-epoch'
|
|
47
|
+
|
|
48
|
+
// 2D sibling of the 3D body-drag group move (`group-move-3d.ts`): dragging
|
|
49
|
+
// any selected element of a multi-selection slides the whole selection
|
|
50
|
+
// rigidly (Photoshop semantics). Both share the participant snapshot +
|
|
51
|
+
// translate math, the live preview channel (`useLiveNodeOverrides.setMany` +
|
|
52
|
+
// welded `LinkedNeighbor` endpoints), the snapping entry points (grid step
|
|
53
|
+
// via `isGridSnapActive`, Figma alignment via the shared resolver), and the
|
|
54
|
+
// single-undo commit (history pause → one `updateNodes` → resume).
|
|
55
|
+
|
|
56
|
+
// Same engage threshold as the layer's Cmd-drag direct move.
|
|
57
|
+
const DRAG_THRESHOLD_PX = 4
|
|
58
|
+
|
|
59
|
+
// Live plan-frame drag delta / rotation, published so the dashed group bbox
|
|
60
|
+
// overlay rides along without re-rendering the whole registry layer per tick.
|
|
61
|
+
type FloorplanGroupDragState = {
|
|
62
|
+
delta: Vec2 | null
|
|
63
|
+
rotation: { pivotX: number; pivotZ: number; angle: number } | null
|
|
64
|
+
set: (delta: Vec2 | null) => void
|
|
65
|
+
setRotation: (rotation: FloorplanGroupDragState['rotation']) => void
|
|
66
|
+
}
|
|
67
|
+
export const useFloorplanGroupDrag = create<FloorplanGroupDragState>((set) => ({
|
|
68
|
+
delta: null,
|
|
69
|
+
rotation: null,
|
|
70
|
+
set: (delta) => set({ delta }),
|
|
71
|
+
setRotation: (rotation) => set({ rotation }),
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Try to start a 2D group move for a pointer-down on `nodeId`. Returns false
|
|
76
|
+
* (attaching nothing) unless the node is a transformable member of a
|
|
77
|
+
* multi-selection — the caller falls through to its single-node behavior.
|
|
78
|
+
*
|
|
79
|
+
* `immediate` engages the session on pointer-down (move-handle dot semantics:
|
|
80
|
+
* the dot is an explicit move control); otherwise the session arms and only
|
|
81
|
+
* engages once the pointer travels past the drag threshold, and a plain
|
|
82
|
+
* click falls through to `onClickFallthrough` (the collapse-to-single
|
|
83
|
+
* selection click).
|
|
84
|
+
*/
|
|
85
|
+
export function startFloorplanGroupMove(
|
|
86
|
+
nodeId: AnyNodeId,
|
|
87
|
+
event: { clientX: number; clientY: number; pointerId: number },
|
|
88
|
+
opts: { immediate?: boolean; onClickFallthrough?: () => void } = {},
|
|
89
|
+
): boolean {
|
|
90
|
+
const { selectedIds, levelId } = useViewer.getState().selection
|
|
91
|
+
if (selectedIds.length < 2 || !selectedIds.includes(nodeId)) return false
|
|
92
|
+
const sceneNodes = useScene.getState().nodes
|
|
93
|
+
// The pressed element itself must transform — dragging a selected door /
|
|
94
|
+
// window (which rides its host wall) keeps today's single-node behavior.
|
|
95
|
+
if (classifyParticipant(sceneNodes[nodeId], levelId, sceneNodes) === null) return false
|
|
96
|
+
const startPlan = clientToPlan(event.clientX, event.clientY)
|
|
97
|
+
if (!startPlan) return false
|
|
98
|
+
|
|
99
|
+
const startX = event.clientX
|
|
100
|
+
const startY = event.clientY
|
|
101
|
+
const pointerId = event.pointerId
|
|
102
|
+
|
|
103
|
+
type Session = {
|
|
104
|
+
starts: ReturnType<typeof collectParticipants>['starts']
|
|
105
|
+
links: ReturnType<typeof collectParticipants>['links']
|
|
106
|
+
affectedIds: AnyNodeId[]
|
|
107
|
+
candidates: ReturnType<typeof collectAlignmentAnchors>
|
|
108
|
+
restAnchors: ReturnType<typeof bboxCornerAnchors>
|
|
109
|
+
restCenter: Vec2
|
|
110
|
+
lastDelta: Vec2 | null
|
|
111
|
+
}
|
|
112
|
+
let session: Session | null = null
|
|
113
|
+
|
|
114
|
+
const engage = (): Session | null => {
|
|
115
|
+
const nodes = useScene.getState().nodes
|
|
116
|
+
const participantIds = selectedIds.filter(
|
|
117
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
118
|
+
)
|
|
119
|
+
// Move the full connected wall/fence component, mirroring the 3D gizmo.
|
|
120
|
+
const fullIds = expandToComponent(participantIds, nodes, levelId)
|
|
121
|
+
const { starts, links } = collectParticipants(fullIds, nodes, levelId)
|
|
122
|
+
if (starts.length === 0) return null
|
|
123
|
+
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
|
124
|
+
|
|
125
|
+
// Alignment candidates — anchors of everything OUTSIDE the moving set
|
|
126
|
+
// (selection + welded neighbours), gathered once at drag start.
|
|
127
|
+
const movingIdSet = new Set<string>(affectedIds)
|
|
128
|
+
const staticNodes: Record<string, AnyNode> = {}
|
|
129
|
+
for (const [nid, n] of Object.entries(nodes)) {
|
|
130
|
+
if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
|
|
131
|
+
}
|
|
132
|
+
const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
|
|
133
|
+
|
|
134
|
+
// The group aligns as one rigid footprint: its bbox corners + center are
|
|
135
|
+
// the moving anchors. `computeGroupBox` is world-space (the 3D scene stays
|
|
136
|
+
// mounted under every view mode); plan coords are level-frame, so convert.
|
|
137
|
+
const restBox = computeGroupBox(fullIds)
|
|
138
|
+
const { inverse: frameInv } = levelFrame(levelId)
|
|
139
|
+
const boxMin = restBox ? restBox.min.clone().applyMatrix4(frameInv) : null
|
|
140
|
+
const boxMax = restBox ? restBox.max.clone().applyMatrix4(frameInv) : null
|
|
141
|
+
const restAnchors =
|
|
142
|
+
boxMin && boxMax
|
|
143
|
+
? bboxCornerAnchors(
|
|
144
|
+
'group-move',
|
|
145
|
+
Math.min(boxMin.x, boxMax.x),
|
|
146
|
+
Math.min(boxMin.z, boxMax.z),
|
|
147
|
+
Math.max(boxMin.x, boxMax.x),
|
|
148
|
+
Math.max(boxMin.z, boxMax.z),
|
|
149
|
+
)
|
|
150
|
+
: []
|
|
151
|
+
|
|
152
|
+
for (const id of affectedIds) {
|
|
153
|
+
useLiveTransforms.getState().clear(id)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
document.body.style.cursor = 'grabbing'
|
|
157
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
158
|
+
swallowNextClick()
|
|
159
|
+
useViewer.getState().setInputDragging(true)
|
|
160
|
+
pauseSceneHistory(useScene)
|
|
161
|
+
useInteractionScope.getState().begin({
|
|
162
|
+
kind: 'handle-drag',
|
|
163
|
+
nodeId,
|
|
164
|
+
handle: GROUP_MOVE_DRAG_LABEL,
|
|
165
|
+
})
|
|
166
|
+
// Rotation pivot for mid-drag R/T — the participant DATA extents' center
|
|
167
|
+
// (stable across the drag; rotations re-seed around the same point).
|
|
168
|
+
const ext = participantExtents(starts)
|
|
169
|
+
const restCenter: Vec2 = ext ? [(ext.minX + ext.maxX) / 2, (ext.minZ + ext.maxZ) / 2] : [0, 0]
|
|
170
|
+
|
|
171
|
+
return { starts, links, affectedIds, candidates, restAnchors, restCenter, lastDelta: null }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const applyMove = (e: PointerEvent, s: Session) => {
|
|
175
|
+
const plan = clientToPlan(e.clientX, e.clientY)
|
|
176
|
+
if (!plan) return
|
|
177
|
+
// Snap the slide DELTA to the active grid step so the selection's
|
|
178
|
+
// internal layout stays intact — grid-aligned members stay aligned.
|
|
179
|
+
// Mode-driven like the 3D group move: the `handle-drag` scope resolves
|
|
180
|
+
// to the item snap context, so Shift cycles the mode mid-drag.
|
|
181
|
+
const step = useEditor.getState().gridSnapStep
|
|
182
|
+
const snap = isGridSnapActive() && step > 0
|
|
183
|
+
let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0]
|
|
184
|
+
let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
|
|
185
|
+
|
|
186
|
+
// Figma-style alignment layered on top: guides display in every mode
|
|
187
|
+
// except Off; the magnetic pull applies only in 'lines' mode.
|
|
188
|
+
if (isAlignmentGuideActive() && s.candidates.length > 0 && s.restAnchors.length > 0) {
|
|
189
|
+
const proposed: [number, number] = [startPlan[0] + dx, startPlan[1] + dz]
|
|
190
|
+
const aligned = applyFloorplanAlignment(
|
|
191
|
+
proposed,
|
|
192
|
+
s.restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
|
|
193
|
+
s.candidates,
|
|
194
|
+
{ applySnap: isMagneticSnapActive() },
|
|
195
|
+
)
|
|
196
|
+
dx = aligned.point[0] - startPlan[0]
|
|
197
|
+
dz = aligned.point[1] - startPlan[1]
|
|
198
|
+
} else {
|
|
199
|
+
useAlignmentGuides.getState().clear()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
applyDelta(s, dx, dz)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const applyDelta = (s: Session, dx: number, dz: number) => {
|
|
206
|
+
// Ticker on each delta change — parity with the 3D group move's SFX.
|
|
207
|
+
if (!s.lastDelta || s.lastDelta[0] !== dx || s.lastDelta[1] !== dz) {
|
|
208
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
209
|
+
s.lastDelta = [dx, dz]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const entries = translateGroupPatches(s.starts, s.links, dx, dz)
|
|
213
|
+
const patchById = new Map(entries)
|
|
214
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
215
|
+
for (const start of s.starts) {
|
|
216
|
+
if (start.kind === 'scalar') {
|
|
217
|
+
const patch = patchById.get(start.id)
|
|
218
|
+
if (patch) {
|
|
219
|
+
liveTransforms.set(start.id, {
|
|
220
|
+
position: patch.position as [number, number, number],
|
|
221
|
+
rotation: start.rotation,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
useScene.getState().markDirty(start.id)
|
|
226
|
+
}
|
|
227
|
+
for (const l of s.links) {
|
|
228
|
+
useScene.getState().markDirty(l.id)
|
|
229
|
+
}
|
|
230
|
+
useLiveNodeOverrides.getState().setMany(entries)
|
|
231
|
+
useFloorplanGroupDrag.getState().set([dx, dz])
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Mid-drag R/T: rotate the SNAPSHOTS around the rest pivot and re-apply the
|
|
235
|
+
// current delta — the carried group turns exactly like the idle keyboard
|
|
236
|
+
// rotate, and the commit stays a single updateNodes.
|
|
237
|
+
const rotateSession = (s: Session, direction: 1 | -1) => {
|
|
238
|
+
const rotated = rotateGroupSnapshots(
|
|
239
|
+
s.starts,
|
|
240
|
+
s.links,
|
|
241
|
+
{ x: s.restCenter[0], z: s.restCenter[1] },
|
|
242
|
+
-direction * (Math.PI / 4),
|
|
243
|
+
)
|
|
244
|
+
s.starts = rotated.starts
|
|
245
|
+
s.links = rotated.links
|
|
246
|
+
const ext = participantExtents(rotated.starts)
|
|
247
|
+
if (ext) {
|
|
248
|
+
s.restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
|
|
249
|
+
}
|
|
250
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
251
|
+
applyDelta(s, s.lastDelta?.[0] ?? 0, s.lastDelta?.[1] ?? 0)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const clearLivePreviews = (s: Session) => {
|
|
255
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
256
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
257
|
+
for (const id of s.affectedIds) {
|
|
258
|
+
overrides.clear(id)
|
|
259
|
+
liveTransforms.clear(id)
|
|
260
|
+
useScene.getState().markDirty(id)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const removeListeners = () => {
|
|
265
|
+
window.removeEventListener('pointermove', onMove)
|
|
266
|
+
window.removeEventListener('pointerup', onUp, true)
|
|
267
|
+
window.removeEventListener('pointercancel', onPointerCancel)
|
|
268
|
+
window.removeEventListener('keydown', onKeyDown, true)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// History resume is NOT here — it pairs exactly one-to-one with the
|
|
272
|
+
// `pauseSceneHistory` in `engage()`, on the commit and cancel paths.
|
|
273
|
+
const teardown = () => {
|
|
274
|
+
removeListeners()
|
|
275
|
+
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
|
276
|
+
useAlignmentGuides.getState().clear()
|
|
277
|
+
// Deferred so a canvas pointerup later in this same dispatch still sees
|
|
278
|
+
// the drag as active (split view — see onUp).
|
|
279
|
+
setTimeout(() => useViewer.getState().setInputDragging(false), 0)
|
|
280
|
+
useInteractionScope
|
|
281
|
+
.getState()
|
|
282
|
+
.endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_MOVE_DRAG_LABEL)
|
|
283
|
+
useFloorplanGroupDrag.getState().set(null)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const onMove = (e: PointerEvent) => {
|
|
287
|
+
if (e.pointerId !== pointerId) return
|
|
288
|
+
if (!session) {
|
|
289
|
+
if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX) return
|
|
290
|
+
session = engage()
|
|
291
|
+
if (!session) {
|
|
292
|
+
removeListeners()
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
applyMove(e, session)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Capture-phase registration: in split view the release can land over the
|
|
300
|
+
// 3D canvas, whose use-node-events synthesizes a selection click on every
|
|
301
|
+
// pointerup — suppressed while `inputDragging` is raised, which teardown
|
|
302
|
+
// therefore lowers on a 0ms timer (after this event's dispatch).
|
|
303
|
+
const onUp = (e: PointerEvent) => {
|
|
304
|
+
if (e.pointerId !== pointerId) return
|
|
305
|
+
if (!session) {
|
|
306
|
+
removeListeners()
|
|
307
|
+
opts.onClickFallthrough?.()
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
// Eat the click that follows pointer-up so the background click handler
|
|
311
|
+
// doesn't clear the multi-selection the drag is preserving.
|
|
312
|
+
swallowNextClick()
|
|
313
|
+
sfxEmitter.emit('sfx:item-place')
|
|
314
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
315
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
316
|
+
for (const id of session.affectedIds) {
|
|
317
|
+
const patch = overrides.get(id)
|
|
318
|
+
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
|
|
319
|
+
}
|
|
320
|
+
// Resume before the commit so the single batched `updateNodes` is the one
|
|
321
|
+
// tracked set — collapsing the whole group move into one undo step.
|
|
322
|
+
// Group transforms move existing structure rigidly — the wall-driven room
|
|
323
|
+
// auto-detection must not re-create floors/ceilings for the walls' new
|
|
324
|
+
// positions (that belongs to wall building/editing). Paused around the
|
|
325
|
+
// commit; the sync rolls its baseline forward for paused changes.
|
|
326
|
+
pauseSpaceDetection()
|
|
327
|
+
resumeSceneHistory(useScene)
|
|
328
|
+
if (updates.length > 0) useScene.getState().updateNodes(updates)
|
|
329
|
+
resumeSpaceDetection()
|
|
330
|
+
clearLivePreviews(session)
|
|
331
|
+
session = null
|
|
332
|
+
teardown()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const cancel = () => {
|
|
336
|
+
if (session) {
|
|
337
|
+
clearLivePreviews(session)
|
|
338
|
+
resumeSceneHistory(useScene)
|
|
339
|
+
session = null
|
|
340
|
+
}
|
|
341
|
+
teardown()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const onPointerCancel = (e: PointerEvent) => {
|
|
345
|
+
if (e.pointerId !== pointerId) return
|
|
346
|
+
cancel()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Capture phase so the drag's Escape wins over the global `use-keyboard`
|
|
350
|
+
// Escape arm (registered earlier on window in the bubble phase), which
|
|
351
|
+
// would otherwise clear the multi-selection mid-cancel.
|
|
352
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
353
|
+
const key = e.key.toLowerCase()
|
|
354
|
+
if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
|
355
|
+
if (!session) return
|
|
356
|
+
e.preventDefault()
|
|
357
|
+
e.stopPropagation()
|
|
358
|
+
rotateSession(session, key === 'r' ? 1 : -1)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
362
|
+
// Deleting mid-move: revert the session first, then let the global
|
|
363
|
+
// Delete arm remove the selection — no dangling carry.
|
|
364
|
+
cancel()
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
// ⌘Z mid-gesture cancels like Escape — never a history jump under a live pointer.
|
|
368
|
+
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
|
369
|
+
e.preventDefault()
|
|
370
|
+
e.stopPropagation()
|
|
371
|
+
swallowNextClick()
|
|
372
|
+
cancel()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
window.addEventListener('pointermove', onMove)
|
|
376
|
+
window.addEventListener('pointerup', onUp, true)
|
|
377
|
+
window.addEventListener('pointercancel', onPointerCancel)
|
|
378
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
379
|
+
|
|
380
|
+
if (opts.immediate) {
|
|
381
|
+
session = engage()
|
|
382
|
+
if (!session) {
|
|
383
|
+
removeListeners()
|
|
384
|
+
return false
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return true
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 2D group rotate, driven from the dashed selection box's corner handles —
|
|
392
|
+
* the floor-plan sibling of the 3D `GroupRotateHandle`. The group spins
|
|
393
|
+
* rigidly around its data-extents center; 15° increments by default, Shift
|
|
394
|
+
* for free rotation, one `updateNodes` on release (one undo step).
|
|
395
|
+
*/
|
|
396
|
+
export function startFloorplanGroupRotate(event: {
|
|
397
|
+
clientX: number
|
|
398
|
+
clientY: number
|
|
399
|
+
pointerId: number
|
|
400
|
+
}): boolean {
|
|
401
|
+
const { selectedIds, levelId } = useViewer.getState().selection
|
|
402
|
+
if (selectedIds.length < 2) return false
|
|
403
|
+
const nodes = useScene.getState().nodes
|
|
404
|
+
const participantIds = selectedIds.filter(
|
|
405
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
406
|
+
)
|
|
407
|
+
if (participantIds.length === 0) return false
|
|
408
|
+
const fullIds = expandToComponent(participantIds, nodes, levelId)
|
|
409
|
+
const { starts, links } = collectParticipants(fullIds, nodes, levelId)
|
|
410
|
+
if (starts.length === 0) return false
|
|
411
|
+
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
|
412
|
+
const ext = participantExtents(starts)
|
|
413
|
+
if (!ext) return false
|
|
414
|
+
const pivot = { x: (ext.minX + ext.maxX) / 2, z: (ext.minZ + ext.maxZ) / 2 }
|
|
415
|
+
const startPlan = clientToPlan(event.clientX, event.clientY)
|
|
416
|
+
if (!startPlan) return false
|
|
417
|
+
// Bearing around the pivot in the plan frame — the same atan2 x→z sense
|
|
418
|
+
// `rotateGroupPatches` orbits in.
|
|
419
|
+
const angleOf = (p: readonly [number, number]) => Math.atan2(p[1] - pivot.z, p[0] - pivot.x)
|
|
420
|
+
const initialAngle = angleOf(startPlan)
|
|
421
|
+
const pointerId = event.pointerId
|
|
422
|
+
|
|
423
|
+
for (const id of affectedIds) {
|
|
424
|
+
useLiveTransforms.getState().clear(id)
|
|
425
|
+
}
|
|
426
|
+
document.body.style.cursor = 'grabbing'
|
|
427
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
428
|
+
swallowNextClick()
|
|
429
|
+
useViewer.getState().setInputDragging(true)
|
|
430
|
+
pauseSceneHistory(useScene)
|
|
431
|
+
useInteractionScope.getState().begin({
|
|
432
|
+
kind: 'handle-drag',
|
|
433
|
+
nodeId: (participantIds[0] ?? '') as AnyNodeId,
|
|
434
|
+
handle: GROUP_ROTATE_DRAG_LABEL,
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
const onMove = (e: PointerEvent) => {
|
|
438
|
+
if (e.pointerId !== pointerId) return
|
|
439
|
+
const plan = clientToPlan(e.clientX, e.clientY)
|
|
440
|
+
if (!plan) return
|
|
441
|
+
let delta = angleOf([plan[0], plan[1]]) - initialAngle
|
|
442
|
+
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
443
|
+
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
444
|
+
// 15° increments by default; Shift rotates freely — the same contract
|
|
445
|
+
// as the 3D group rotate gizmo (and the HUD hint its scope surfaces).
|
|
446
|
+
if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
|
447
|
+
|
|
448
|
+
const entries = rotateGroupPatches(starts, links, pivot, delta)
|
|
449
|
+
const patchById = new Map(entries)
|
|
450
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
451
|
+
for (const start of starts) {
|
|
452
|
+
if (start.kind === 'scalar') {
|
|
453
|
+
const patch = patchById.get(start.id)
|
|
454
|
+
if (patch) {
|
|
455
|
+
liveTransforms.set(start.id, {
|
|
456
|
+
position: patch.position as [number, number, number],
|
|
457
|
+
rotation: patch.rotation as number,
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
useScene.getState().markDirty(start.id)
|
|
462
|
+
}
|
|
463
|
+
for (const l of links) {
|
|
464
|
+
useScene.getState().markDirty(l.id)
|
|
465
|
+
}
|
|
466
|
+
useLiveNodeOverrides.getState().setMany(entries)
|
|
467
|
+
// The dashed box spins with the group for live feedback.
|
|
468
|
+
useFloorplanGroupDrag.getState().setRotation({ pivotX: pivot.x, pivotZ: pivot.z, angle: delta })
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const clearLivePreviews = () => {
|
|
472
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
473
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
474
|
+
for (const id of affectedIds) {
|
|
475
|
+
overrides.clear(id)
|
|
476
|
+
liveTransforms.clear(id)
|
|
477
|
+
useScene.getState().markDirty(id)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const removeListeners = () => {
|
|
482
|
+
window.removeEventListener('pointermove', onMove)
|
|
483
|
+
window.removeEventListener('pointerup', onUp, true)
|
|
484
|
+
window.removeEventListener('pointercancel', onPointerCancel)
|
|
485
|
+
window.removeEventListener('keydown', onKeyDown, true)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// History resume pairs one-to-one with the pause above, on the commit and
|
|
489
|
+
// cancel paths only.
|
|
490
|
+
const teardown = () => {
|
|
491
|
+
removeListeners()
|
|
492
|
+
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
|
493
|
+
// Deferred so a canvas pointerup later in this same dispatch still sees
|
|
494
|
+
// the drag as active (split view — see onUp).
|
|
495
|
+
setTimeout(() => useViewer.getState().setInputDragging(false), 0)
|
|
496
|
+
useInteractionScope
|
|
497
|
+
.getState()
|
|
498
|
+
.endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_ROTATE_DRAG_LABEL)
|
|
499
|
+
useFloorplanGroupDrag.getState().setRotation(null)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Capture registration + deferred inputDragging — see the drag session.
|
|
503
|
+
const onUp = (e: PointerEvent) => {
|
|
504
|
+
if (e.pointerId !== pointerId) return
|
|
505
|
+
swallowNextClick()
|
|
506
|
+
sfxEmitter.emit('sfx:item-place')
|
|
507
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
508
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
509
|
+
for (const id of affectedIds) {
|
|
510
|
+
const patch = overrides.get(id)
|
|
511
|
+
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
|
|
512
|
+
}
|
|
513
|
+
// One tracked set = one undo step; rigid rotations must not re-create
|
|
514
|
+
// the room's auto floors/ceilings (see the move sessions).
|
|
515
|
+
pauseSpaceDetection()
|
|
516
|
+
resumeSceneHistory(useScene)
|
|
517
|
+
if (updates.length > 0) useScene.getState().updateNodes(updates)
|
|
518
|
+
resumeSpaceDetection()
|
|
519
|
+
clearLivePreviews()
|
|
520
|
+
teardown()
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const cancel = () => {
|
|
524
|
+
clearLivePreviews()
|
|
525
|
+
resumeSceneHistory(useScene)
|
|
526
|
+
teardown()
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const onPointerCancel = (e: PointerEvent) => {
|
|
530
|
+
if (e.pointerId !== pointerId) return
|
|
531
|
+
cancel()
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Capture phase so Escape wins over the global `use-keyboard` arm.
|
|
535
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
536
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
537
|
+
// Deleting mid-rotate: revert first, then let the global Delete arm run.
|
|
538
|
+
cancel()
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
// ⌘Z mid-gesture cancels like Escape — never a history jump under a live pointer.
|
|
542
|
+
if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
|
|
543
|
+
e.preventDefault()
|
|
544
|
+
e.stopPropagation()
|
|
545
|
+
swallowNextClick()
|
|
546
|
+
cancel()
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
window.addEventListener('pointermove', onMove)
|
|
550
|
+
window.addEventListener('pointerup', onUp, true)
|
|
551
|
+
window.addEventListener('pointercancel', onPointerCancel)
|
|
552
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
553
|
+
return true
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const GROUP_BOX_CURSOR_STYLE = { cursor: 'move' } as const
|
|
557
|
+
// Curved-arrow rotate cursor (no native CSS equivalent) — black glyph with a
|
|
558
|
+
// white halo so it reads on light and dark plans; falls back to `grab`.
|
|
559
|
+
const ROTATE_CURSOR_SVG = encodeURIComponent(
|
|
560
|
+
'<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>',
|
|
561
|
+
)
|
|
562
|
+
const GROUP_BOX_ROTATE_CURSOR_STYLE = {
|
|
563
|
+
cursor: `url("data:image/svg+xml,${ROTATE_CURSOR_SVG}") 11 11, grab`,
|
|
564
|
+
} as const
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Dashed bounding box around the current multi-selection's transformable
|
|
568
|
+
* participants (expanded to the welded wall/fence component) — shows what a
|
|
569
|
+
* group drag will carry along, and IS the group's drag handle: press anywhere
|
|
570
|
+
* inside it to slide the group, click to pick it up. Holding a selection
|
|
571
|
+
* modifier (Cmd/Ctrl/Shift) lets pointer events pass through so members under
|
|
572
|
+
* the box can still be toggled in and out. Rides the live drag delta so it
|
|
573
|
+
* tracks the group mid-gesture. Mounted inside the floor-plan scene `<g>`, so
|
|
574
|
+
* plan coords render directly.
|
|
575
|
+
*/
|
|
576
|
+
export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionBox({
|
|
577
|
+
palette,
|
|
578
|
+
unitsPerPixel,
|
|
579
|
+
onPointerDown,
|
|
580
|
+
onRotatePointerDown,
|
|
581
|
+
}: {
|
|
582
|
+
palette: FloorplanPalette | undefined
|
|
583
|
+
unitsPerPixel: number
|
|
584
|
+
onPointerDown?: (event: ReactPointerEvent<SVGGElement>) => void
|
|
585
|
+
onRotatePointerDown?: (event: ReactPointerEvent<SVGGElement>) => void
|
|
586
|
+
}) {
|
|
587
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
588
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
589
|
+
const nodes = useScene((s) => s.nodes)
|
|
590
|
+
const delta = useFloorplanGroupDrag((s) => s.delta)
|
|
591
|
+
const liveRotation = useFloorplanGroupDrag((s) => s.rotation)
|
|
592
|
+
const movingNode = useMovingNode()
|
|
593
|
+
const mode = useEditor((s) => s.mode)
|
|
594
|
+
|
|
595
|
+
// While a selection modifier is held the box steps aside so clicks reach
|
|
596
|
+
// the entries underneath (toggle membership) instead of starting a drag.
|
|
597
|
+
const [modifierHeld, setModifierHeld] = useState(false)
|
|
598
|
+
useEffect(() => {
|
|
599
|
+
const update = (e: KeyboardEvent) => setModifierHeld(e.metaKey || e.ctrlKey || e.shiftKey)
|
|
600
|
+
const clear = () => setModifierHeld(false)
|
|
601
|
+
window.addEventListener('keydown', update)
|
|
602
|
+
window.addEventListener('keyup', update)
|
|
603
|
+
window.addEventListener('blur', clear)
|
|
604
|
+
return () => {
|
|
605
|
+
window.removeEventListener('keydown', update)
|
|
606
|
+
window.removeEventListener('keyup', update)
|
|
607
|
+
window.removeEventListener('blur', clear)
|
|
608
|
+
}
|
|
609
|
+
}, [])
|
|
610
|
+
|
|
611
|
+
// `meshEpoch` re-runs the measurement once the meshes settle after a scene
|
|
612
|
+
// change (undo/redo included) — `computeGroupBox` reads mesh world bounds,
|
|
613
|
+
// which lag the `nodes` commit by a frame or two.
|
|
614
|
+
const meshEpoch = useMeshSettleEpoch(nodes)
|
|
615
|
+
const box = useMemo(() => {
|
|
616
|
+
if (selectedIds.length < 2 || !levelId) return null
|
|
617
|
+
const participantIds = selectedIds.filter(
|
|
618
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
619
|
+
)
|
|
620
|
+
if (participantIds.length === 0) return null
|
|
621
|
+
const fullIds = expandToComponent(participantIds, nodes, levelId)
|
|
622
|
+
const world = computeGroupBox(fullIds)
|
|
623
|
+
if (!world) return null
|
|
624
|
+
const { inverse } = levelFrame(levelId)
|
|
625
|
+
const min = world.min.clone().applyMatrix4(inverse)
|
|
626
|
+
const max = world.max.clone().applyMatrix4(inverse)
|
|
627
|
+
return {
|
|
628
|
+
x: Math.min(min.x, max.x),
|
|
629
|
+
z: Math.min(min.z, max.z),
|
|
630
|
+
width: Math.abs(max.x - min.x),
|
|
631
|
+
depth: Math.abs(max.z - min.z),
|
|
632
|
+
}
|
|
633
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
|
|
634
|
+
}, [selectedIds, levelId, nodes, meshEpoch])
|
|
635
|
+
|
|
636
|
+
if (!box || movingNode || mode === 'delete') return null
|
|
637
|
+
|
|
638
|
+
const pad = 6 * unitsPerPixel
|
|
639
|
+
const stroke = palette?.selectedStroke ?? '#3b82f6'
|
|
640
|
+
const interactive = !modifierHeld && !!onPointerDown
|
|
641
|
+
// Mid-gesture the box rides the live delta (group move) or spins around the
|
|
642
|
+
// rotation pivot (corner rotate) — SVG rotate() is degrees around a plan
|
|
643
|
+
// point, and positive matches the atan2 x→z sense on the y-down plan.
|
|
644
|
+
const transform = liveRotation
|
|
645
|
+
? `rotate(${(liveRotation.angle * 180) / Math.PI} ${liveRotation.pivotX} ${liveRotation.pivotZ})`
|
|
646
|
+
: delta
|
|
647
|
+
? `translate(${delta[0]} ${delta[1]})`
|
|
648
|
+
: undefined
|
|
649
|
+
return (
|
|
650
|
+
<g
|
|
651
|
+
data-group-selection-box
|
|
652
|
+
onPointerDown={interactive ? onPointerDown : undefined}
|
|
653
|
+
pointerEvents={interactive ? 'auto' : 'none'}
|
|
654
|
+
style={interactive ? GROUP_BOX_CURSOR_STYLE : undefined}
|
|
655
|
+
transform={transform}
|
|
656
|
+
>
|
|
657
|
+
<rect
|
|
658
|
+
fill="transparent"
|
|
659
|
+
height={box.depth + 2 * pad}
|
|
660
|
+
stroke={stroke}
|
|
661
|
+
strokeDasharray={`${4 * unitsPerPixel} ${3 * unitsPerPixel}`}
|
|
662
|
+
strokeWidth={1.2 * unitsPerPixel}
|
|
663
|
+
width={box.width + 2 * pad}
|
|
664
|
+
x={box.x - pad}
|
|
665
|
+
y={box.z - pad}
|
|
666
|
+
/>
|
|
667
|
+
{/* Corner rotate handles — the 2D counterpart of the 3D rotate gizmo:
|
|
668
|
+
drag a corner to spin the group (15° steps, Shift = free). */}
|
|
669
|
+
{interactive && onRotatePointerDown
|
|
670
|
+
? (
|
|
671
|
+
[
|
|
672
|
+
[box.x - pad, box.z - pad],
|
|
673
|
+
[box.x + box.width + pad, box.z - pad],
|
|
674
|
+
[box.x + box.width + pad, box.z + box.depth + pad],
|
|
675
|
+
[box.x - pad, box.z + box.depth + pad],
|
|
676
|
+
] as Array<[number, number]>
|
|
677
|
+
).map(([cx, cz], index) => (
|
|
678
|
+
<g
|
|
679
|
+
data-group-rotate-handle
|
|
680
|
+
key={`corner-${index}`}
|
|
681
|
+
onPointerDown={(event) => {
|
|
682
|
+
event.stopPropagation()
|
|
683
|
+
onRotatePointerDown(event)
|
|
684
|
+
}}
|
|
685
|
+
style={GROUP_BOX_ROTATE_CURSOR_STYLE}
|
|
686
|
+
>
|
|
687
|
+
<circle cx={cx} cy={cz} fill="transparent" r={8 * unitsPerPixel} />
|
|
688
|
+
<circle
|
|
689
|
+
cx={cx}
|
|
690
|
+
cy={cz}
|
|
691
|
+
fill="#ffffff"
|
|
692
|
+
r={3.2 * unitsPerPixel}
|
|
693
|
+
stroke={stroke}
|
|
694
|
+
strokeWidth={1.2 * unitsPerPixel}
|
|
695
|
+
/>
|
|
696
|
+
</g>
|
|
697
|
+
))
|
|
698
|
+
: null}
|
|
699
|
+
</g>
|
|
700
|
+
)
|
|
701
|
+
})
|