@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,457 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
bboxCornerAnchors,
|
|
5
|
+
collectAlignmentAnchors,
|
|
6
|
+
pauseSceneHistory,
|
|
7
|
+
pauseSpaceDetection,
|
|
8
|
+
resolveAlignment,
|
|
9
|
+
resumeSceneHistory,
|
|
10
|
+
resumeSpaceDetection,
|
|
11
|
+
useLiveNodeOverrides,
|
|
12
|
+
useLiveTransforms,
|
|
13
|
+
useScene,
|
|
14
|
+
} from '@pascal-app/core'
|
|
15
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
16
|
+
import { Plane, Vector2, Vector3 } from 'three'
|
|
17
|
+
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
|
|
18
|
+
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
|
19
|
+
import { duplicateNodesToLevel } from '../../lib/scene-clipboard'
|
|
20
|
+
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
21
|
+
import useAlignmentGuides from '../../store/use-alignment-guides'
|
|
22
|
+
import useEditor, {
|
|
23
|
+
isAlignmentGuideActive,
|
|
24
|
+
isGridSnapActive,
|
|
25
|
+
isMagneticSnapActive,
|
|
26
|
+
} from '../../store/use-editor'
|
|
27
|
+
import useInteractionScope from '../../store/use-interaction-scope'
|
|
28
|
+
import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move'
|
|
29
|
+
import {
|
|
30
|
+
classifyParticipant,
|
|
31
|
+
collectParticipants,
|
|
32
|
+
computeGroupBox,
|
|
33
|
+
expandToComponent,
|
|
34
|
+
levelFrame,
|
|
35
|
+
participantExtents,
|
|
36
|
+
rotateGroupSnapshots,
|
|
37
|
+
translateGroupPatches,
|
|
38
|
+
type Vec2,
|
|
39
|
+
} from './group-transform-shared'
|
|
40
|
+
import { swallowNextClick } from './handles/use-handle-drag'
|
|
41
|
+
import { getEditorThreeContext } from './three-context-bridge'
|
|
42
|
+
|
|
43
|
+
// Whole-selection actions behind the group action menu (Move / Duplicate /
|
|
44
|
+
// Delete), shared by the 2D and 3D menus. The Move flow is a "pick-up": the
|
|
45
|
+
// selection rides the cursor (relative to where tracking starts, so nothing
|
|
46
|
+
// teleports) across BOTH surfaces — floor plan via the scene CTM, 3D via a
|
|
47
|
+
// ground-plane raycast through the bridged camera — until a click commits it
|
|
48
|
+
// as one undo step. Escape / right-click cancels.
|
|
49
|
+
|
|
50
|
+
const ALIGNMENT_THRESHOLD_M = 0.08
|
|
51
|
+
// Matches the keyboard Delete arm's accidental-bulk-delete guard.
|
|
52
|
+
const BULK_DELETE_THRESHOLD = 10
|
|
53
|
+
|
|
54
|
+
// Any non-empty selection can be picked up (the menus themselves gate on a
|
|
55
|
+
// multi-selection; Duplicate can legitimately land on a single cloned root).
|
|
56
|
+
function groupParticipantIds(): string[] {
|
|
57
|
+
const { selectedIds, levelId } = useViewer.getState().selection
|
|
58
|
+
if (selectedIds.length === 0) return []
|
|
59
|
+
const nodes = useScene.getState().nodes
|
|
60
|
+
return selectedIds.filter(
|
|
61
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True when the current selection is a group the pick-up move can carry. */
|
|
66
|
+
export function canGroupPickUp(): boolean {
|
|
67
|
+
return groupParticipantIds().length > 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pick up the current multi-selection: it follows the cursor (delta-relative)
|
|
72
|
+
* until a click commits, mirroring the single-node `movingNode` flow. Returns
|
|
73
|
+
* false when the selection holds no transformable participants.
|
|
74
|
+
*
|
|
75
|
+
* `scopeToSelection` limits the moving set to the selected participants —
|
|
76
|
+
* no connected-component expansion and no welded-neighbor endpoints. The
|
|
77
|
+
* Duplicate flow needs this: its clones sit EXACTLY on the originals, so
|
|
78
|
+
* junction coincidence would otherwise weld the originals into the pick-up
|
|
79
|
+
* and drag them along with the copies.
|
|
80
|
+
*/
|
|
81
|
+
export function startGroupPickUp(
|
|
82
|
+
opts: { onCancel?: () => void; scopeToSelection?: boolean } = {},
|
|
83
|
+
): boolean {
|
|
84
|
+
const { selectedIds, levelId } = useViewer.getState().selection
|
|
85
|
+
const participantIds = groupParticipantIds()
|
|
86
|
+
if (participantIds.length === 0) return false
|
|
87
|
+
const nodes = useScene.getState().nodes
|
|
88
|
+
const fullIds = opts.scopeToSelection
|
|
89
|
+
? participantIds
|
|
90
|
+
: expandToComponent(participantIds, nodes, levelId)
|
|
91
|
+
const collected = collectParticipants(fullIds, nodes, levelId)
|
|
92
|
+
// Mutable: mid-carry R/T rotates these snapshots in place.
|
|
93
|
+
let starts = collected.starts
|
|
94
|
+
let links = opts.scopeToSelection ? [] : collected.links
|
|
95
|
+
if (starts.length === 0) return false
|
|
96
|
+
const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)]
|
|
97
|
+
|
|
98
|
+
// Rest bounds in the level frame. Prefer the mounted meshes' world box
|
|
99
|
+
// (footprint-accurate), but fall back to the participant DATA when the
|
|
100
|
+
// meshes aren't up yet — Duplicate starts the pick-up synchronously after
|
|
101
|
+
// `createNodes`, one frame before the clones' renderers mount.
|
|
102
|
+
const { inverse: frameInv } = levelFrame(levelId)
|
|
103
|
+
const restBox = computeGroupBox(fullIds)
|
|
104
|
+
let minX = Number.POSITIVE_INFINITY
|
|
105
|
+
let minZ = Number.POSITIVE_INFINITY
|
|
106
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
107
|
+
let maxZ = Number.NEGATIVE_INFINITY
|
|
108
|
+
if (restBox) {
|
|
109
|
+
const boxMin = restBox.min.clone().applyMatrix4(frameInv)
|
|
110
|
+
const boxMax = restBox.max.clone().applyMatrix4(frameInv)
|
|
111
|
+
minX = Math.min(boxMin.x, boxMax.x)
|
|
112
|
+
minZ = Math.min(boxMin.z, boxMax.z)
|
|
113
|
+
maxX = Math.max(boxMin.x, boxMax.x)
|
|
114
|
+
maxZ = Math.max(boxMin.z, boxMax.z)
|
|
115
|
+
} else {
|
|
116
|
+
const reach = (x: number, z: number) => {
|
|
117
|
+
minX = Math.min(minX, x)
|
|
118
|
+
minZ = Math.min(minZ, z)
|
|
119
|
+
maxX = Math.max(maxX, x)
|
|
120
|
+
maxZ = Math.max(maxZ, z)
|
|
121
|
+
}
|
|
122
|
+
for (const s of starts) {
|
|
123
|
+
if (s.kind === 'endpoint') {
|
|
124
|
+
reach(s.start[0], s.start[1])
|
|
125
|
+
reach(s.end[0], s.end[1])
|
|
126
|
+
} else if (s.kind === 'polygon') {
|
|
127
|
+
for (const [x, z] of s.polygon) {
|
|
128
|
+
reach(x, z)
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
reach(s.position[0], s.position[2])
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!Number.isFinite(minX)) return false
|
|
136
|
+
// Rotation pivot for mid-carry R/T; stable across the whole pick-up.
|
|
137
|
+
const restCenter: [number, number] = [(minX + maxX) / 2, (minZ + maxZ) / 2]
|
|
138
|
+
// Ground plane for the 3D surface: the meshes' base when available, floor
|
|
139
|
+
// level otherwise. Placements live in the level frame, so both surfaces
|
|
140
|
+
// resolve into it before measuring.
|
|
141
|
+
const plane = new Plane(new Vector3(0, 1, 0), -(restBox?.min.y ?? 0))
|
|
142
|
+
|
|
143
|
+
// Alignment candidates — everything outside the moving set; the group
|
|
144
|
+
// aligns as one rigid footprint via its bbox corners + center.
|
|
145
|
+
const movingIdSet = new Set<string>(affectedIds)
|
|
146
|
+
const staticNodes: Record<string, AnyNode> = {}
|
|
147
|
+
for (const [nid, n] of Object.entries(nodes)) {
|
|
148
|
+
if (n && !movingIdSet.has(nid)) staticNodes[nid] = n
|
|
149
|
+
}
|
|
150
|
+
const candidates = collectAlignmentAnchors(staticNodes, '', levelId)
|
|
151
|
+
let restAnchors = bboxCornerAnchors('group-move', minX, minZ, maxX, maxZ)
|
|
152
|
+
|
|
153
|
+
// Cursor → level-frame plan point, whichever surface the pointer is over.
|
|
154
|
+
const ndc = new Vector2()
|
|
155
|
+
const resolvePlanPoint = (e: PointerEvent): Vec2 | null => {
|
|
156
|
+
const three = getEditorThreeContext()
|
|
157
|
+
if (three && e.target === three.domElement) {
|
|
158
|
+
const rect = three.domElement.getBoundingClientRect()
|
|
159
|
+
ndc.set(
|
|
160
|
+
((e.clientX - rect.left) / rect.width) * 2 - 1,
|
|
161
|
+
-((e.clientY - rect.top) / rect.height) * 2 + 1,
|
|
162
|
+
)
|
|
163
|
+
three.raycaster.setFromCamera(ndc, three.camera)
|
|
164
|
+
const hit = new Vector3()
|
|
165
|
+
if (!three.raycaster.ray.intersectPlane(plane, hit)) return null
|
|
166
|
+
const local = hit.applyMatrix4(frameInv)
|
|
167
|
+
return [local.x, local.z]
|
|
168
|
+
}
|
|
169
|
+
// Anywhere inside the floor-plan viewport counts (the scene `<g>` only
|
|
170
|
+
// covers painted elements, so bounds-test the owning SVG).
|
|
171
|
+
const sceneEl = document.querySelector('g[data-floorplan-scene]') as SVGGElement | null
|
|
172
|
+
const svg = sceneEl?.ownerSVGElement
|
|
173
|
+
if (!svg) return null
|
|
174
|
+
const rect = svg.getBoundingClientRect()
|
|
175
|
+
if (
|
|
176
|
+
e.clientX < rect.left ||
|
|
177
|
+
e.clientX > rect.right ||
|
|
178
|
+
e.clientY < rect.top ||
|
|
179
|
+
e.clientY > rect.bottom
|
|
180
|
+
) {
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
const plan = clientToPlan(e.clientX, e.clientY)
|
|
184
|
+
return plan ? [plan[0], plan[1]] : null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let startPlan: Vec2 | null = null
|
|
188
|
+
let lastDelta: Vec2 | null = null
|
|
189
|
+
|
|
190
|
+
for (const id of affectedIds) {
|
|
191
|
+
useLiveTransforms.getState().clear(id)
|
|
192
|
+
}
|
|
193
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
194
|
+
pauseSceneHistory(useScene)
|
|
195
|
+
useInteractionScope.getState().begin({
|
|
196
|
+
kind: 'handle-drag',
|
|
197
|
+
nodeId: (participantIds[0] ?? '') as AnyNodeId,
|
|
198
|
+
handle: GROUP_MOVE_DRAG_LABEL,
|
|
199
|
+
})
|
|
200
|
+
document.body.style.cursor = 'grabbing'
|
|
201
|
+
|
|
202
|
+
const applyMove = (e: PointerEvent) => {
|
|
203
|
+
const plan = resolvePlanPoint(e)
|
|
204
|
+
if (!plan) return
|
|
205
|
+
// Delta-relative to where tracking starts so the group never teleports
|
|
206
|
+
// to the cursor.
|
|
207
|
+
if (!startPlan) {
|
|
208
|
+
startPlan = plan
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
const step = useEditor.getState().gridSnapStep
|
|
212
|
+
const snap = isGridSnapActive() && step > 0
|
|
213
|
+
let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0]
|
|
214
|
+
let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
|
|
215
|
+
|
|
216
|
+
if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) {
|
|
217
|
+
const result = resolveAlignment({
|
|
218
|
+
moving: restAnchors.map((a) => ({ ...a, x: a.x + dx, z: a.z + dz })),
|
|
219
|
+
candidates,
|
|
220
|
+
threshold: ALIGNMENT_THRESHOLD_M,
|
|
221
|
+
})
|
|
222
|
+
if (result.snap && isMagneticSnapActive()) {
|
|
223
|
+
dx += result.snap.dx
|
|
224
|
+
dz += result.snap.dz
|
|
225
|
+
}
|
|
226
|
+
useAlignmentGuides.getState().set(result.guides)
|
|
227
|
+
} else {
|
|
228
|
+
useAlignmentGuides.getState().clear()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
applyDelta(dx, dz)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const applyDelta = (dx: number, dz: number) => {
|
|
235
|
+
if (!lastDelta || lastDelta[0] !== dx || lastDelta[1] !== dz) {
|
|
236
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
237
|
+
lastDelta = [dx, dz]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const entries = translateGroupPatches(starts, links, dx, dz)
|
|
241
|
+
const patchById = new Map(entries)
|
|
242
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
243
|
+
for (const start of starts) {
|
|
244
|
+
if (start.kind === 'scalar') {
|
|
245
|
+
const patch = patchById.get(start.id)
|
|
246
|
+
if (patch) {
|
|
247
|
+
liveTransforms.set(start.id, {
|
|
248
|
+
position: patch.position as [number, number, number],
|
|
249
|
+
rotation: start.rotation,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
useScene.getState().markDirty(start.id)
|
|
254
|
+
}
|
|
255
|
+
for (const l of links) {
|
|
256
|
+
useScene.getState().markDirty(l.id)
|
|
257
|
+
}
|
|
258
|
+
useLiveNodeOverrides.getState().setMany(entries)
|
|
259
|
+
useFloorplanGroupDrag.getState().set([dx, dz])
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Mid-carry R/T: rotate the SNAPSHOTS around the rest pivot and re-apply
|
|
263
|
+
// the current delta — the carried group turns exactly like the idle
|
|
264
|
+
// keyboard rotate, and the placement stays a single updateNodes.
|
|
265
|
+
const rotateCarried = (direction: 1 | -1) => {
|
|
266
|
+
const rotated = rotateGroupSnapshots(
|
|
267
|
+
starts,
|
|
268
|
+
links,
|
|
269
|
+
{ x: restCenter[0], z: restCenter[1] },
|
|
270
|
+
-direction * (Math.PI / 4),
|
|
271
|
+
)
|
|
272
|
+
starts = rotated.starts
|
|
273
|
+
links = rotated.links
|
|
274
|
+
const ext = participantExtents(rotated.starts)
|
|
275
|
+
if (ext) {
|
|
276
|
+
restAnchors = bboxCornerAnchors('group-move', ext.minX, ext.minZ, ext.maxX, ext.maxZ)
|
|
277
|
+
}
|
|
278
|
+
sfxEmitter.emit('sfx:item-rotate')
|
|
279
|
+
applyDelta(lastDelta?.[0] ?? 0, lastDelta?.[1] ?? 0)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const clearLivePreviews = () => {
|
|
283
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
284
|
+
const liveTransforms = useLiveTransforms.getState()
|
|
285
|
+
for (const id of affectedIds) {
|
|
286
|
+
overrides.clear(id)
|
|
287
|
+
liveTransforms.clear(id)
|
|
288
|
+
useScene.getState().markDirty(id)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const removeListeners = () => {
|
|
293
|
+
window.removeEventListener('pointermove', onMove)
|
|
294
|
+
window.removeEventListener('pointerdown', onPointerDown, true)
|
|
295
|
+
window.removeEventListener('pointerup', onPointerUp, true)
|
|
296
|
+
window.removeEventListener('keydown', onKeyDown, true)
|
|
297
|
+
window.removeEventListener('contextmenu', onContextMenu, true)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// History resume pairs one-to-one with the pause above, on the commit and
|
|
301
|
+
// cancel paths only.
|
|
302
|
+
const teardown = () => {
|
|
303
|
+
removeListeners()
|
|
304
|
+
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
|
305
|
+
useAlignmentGuides.getState().clear()
|
|
306
|
+
useInteractionScope
|
|
307
|
+
.getState()
|
|
308
|
+
.endIf((sc) => sc.kind === 'handle-drag' && sc.handle === GROUP_MOVE_DRAG_LABEL)
|
|
309
|
+
useFloorplanGroupDrag.getState().set(null)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const commit = () => {
|
|
313
|
+
swallowNextClick()
|
|
314
|
+
sfxEmitter.emit('sfx:item-place')
|
|
315
|
+
const overrides = useLiveNodeOverrides.getState()
|
|
316
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
317
|
+
for (const id of affectedIds) {
|
|
318
|
+
const patch = overrides.get(id)
|
|
319
|
+
if (patch) updates.push({ id, data: patch as Partial<AnyNode> })
|
|
320
|
+
}
|
|
321
|
+
// Group transforms move existing structure rigidly — the wall-driven room
|
|
322
|
+
// auto-detection must not re-create floors/ceilings for the walls' new
|
|
323
|
+
// positions (that belongs to wall building/editing). Paused around the
|
|
324
|
+
// commit; the sync rolls its baseline forward for paused changes.
|
|
325
|
+
pauseSpaceDetection()
|
|
326
|
+
resumeSceneHistory(useScene)
|
|
327
|
+
if (updates.length > 0) useScene.getState().updateNodes(updates)
|
|
328
|
+
resumeSpaceDetection()
|
|
329
|
+
clearLivePreviews()
|
|
330
|
+
teardown()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const cancel = () => {
|
|
334
|
+
clearLivePreviews()
|
|
335
|
+
resumeSceneHistory(useScene)
|
|
336
|
+
teardown()
|
|
337
|
+
opts.onCancel?.()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const onMove = (e: PointerEvent) => {
|
|
341
|
+
applyMove(e)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Capture phase: commit before the press reaches selection / tools, so the
|
|
345
|
+
// drop click can't also select whatever lands under the cursor.
|
|
346
|
+
// The placement gesture: the press over a tracked surface is claimed in
|
|
347
|
+
// capture phase and the commit runs on ITS pointerup, also claimed — the
|
|
348
|
+
// canvas never sees either, so `use-node-events` cannot synthesize a
|
|
349
|
+
// selection click from the release (which would re-select whatever sits
|
|
350
|
+
// under the cursor and break the multi-selection).
|
|
351
|
+
let commitPointerId: number | null = null
|
|
352
|
+
|
|
353
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
354
|
+
if (e.button === 2) {
|
|
355
|
+
e.preventDefault()
|
|
356
|
+
e.stopPropagation()
|
|
357
|
+
cancel()
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
if (e.button !== 0) return
|
|
361
|
+
// Only a press over a tracked surface commits; a click on side panels or
|
|
362
|
+
// the toolbar keeps the pick-up alive.
|
|
363
|
+
if (!resolvePlanPoint(e)) return
|
|
364
|
+
e.preventDefault()
|
|
365
|
+
e.stopPropagation()
|
|
366
|
+
commitPointerId = e.pointerId
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const onPointerUp = (e: PointerEvent) => {
|
|
370
|
+
if (commitPointerId === null || e.pointerId !== commitPointerId) return
|
|
371
|
+
commitPointerId = null
|
|
372
|
+
// Raise `inputDragging` through this event's dispatch so the canvas's
|
|
373
|
+
// use-node-events suppresses the selection click it synthesizes on every
|
|
374
|
+
// pointerup (stopPropagation would also break the window bubble
|
|
375
|
+
// listeners that clear the box-select pointer suppression).
|
|
376
|
+
useViewer.getState().setInputDragging(true)
|
|
377
|
+
setTimeout(() => useViewer.getState().setInputDragging(false), 0)
|
|
378
|
+
commit()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
382
|
+
const key = e.key.toLowerCase()
|
|
383
|
+
if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
|
384
|
+
e.preventDefault()
|
|
385
|
+
e.stopPropagation()
|
|
386
|
+
rotateCarried(key === 'r' ? 1 : -1)
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
390
|
+
// Deleting mid-carry: put the group down first (revert), then let the
|
|
391
|
+
// global Delete arm remove the selection — no dangling carry. The
|
|
392
|
+
// duplicate flow's onCancel already discards the clones instead.
|
|
393
|
+
cancel()
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
if (e.key !== 'Escape') return
|
|
397
|
+
e.preventDefault()
|
|
398
|
+
e.stopPropagation()
|
|
399
|
+
cancel()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const onContextMenu = (e: Event) => {
|
|
403
|
+
e.preventDefault()
|
|
404
|
+
e.stopPropagation()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
window.addEventListener('pointermove', onMove)
|
|
408
|
+
window.addEventListener('pointerdown', onPointerDown, true)
|
|
409
|
+
window.addEventListener('pointerup', onPointerUp, true)
|
|
410
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
411
|
+
window.addEventListener('contextmenu', onContextMenu, true)
|
|
412
|
+
return true
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Duplicate the whole selection (subtrees + id remap via the clipboard
|
|
417
|
+
* pipeline, without touching the clipboard), select the clones, and pick
|
|
418
|
+
* them up so the next click places them. Cancelling the pick-up removes the
|
|
419
|
+
* clones again.
|
|
420
|
+
*/
|
|
421
|
+
export function duplicateSelectionAndPickUp(): boolean {
|
|
422
|
+
const { selectedIds, levelId } = useViewer.getState().selection
|
|
423
|
+
if (selectedIds.length < 2) return false
|
|
424
|
+
const result = duplicateNodesToLevel(
|
|
425
|
+
selectedIds as AnyNodeId[],
|
|
426
|
+
(levelId ?? undefined) as AnyNodeId | undefined,
|
|
427
|
+
)
|
|
428
|
+
if (!result || result.pastedIds.length === 0) return false
|
|
429
|
+
sfxEmitter.emit('sfx:item-pick')
|
|
430
|
+
startGroupPickUp({
|
|
431
|
+
scopeToSelection: true,
|
|
432
|
+
onCancel: () => {
|
|
433
|
+
useScene.getState().deleteNodes(result.pastedIds)
|
|
434
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
435
|
+
},
|
|
436
|
+
})
|
|
437
|
+
return true
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Delete every selected node — same semantics as the keyboard Delete arm,
|
|
442
|
+
* including the accidental-bulk-delete confirm.
|
|
443
|
+
*/
|
|
444
|
+
export function deleteSelection(): boolean {
|
|
445
|
+
const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
|
446
|
+
if (selectedIds.length === 0) return false
|
|
447
|
+
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
|
|
448
|
+
const confirmed = window.confirm(
|
|
449
|
+
`Delete ${selectedIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
|
|
450
|
+
)
|
|
451
|
+
if (!confirmed) return false
|
|
452
|
+
}
|
|
453
|
+
sfxEmitter.emit('sfx:structure-delete')
|
|
454
|
+
useScene.getState().deleteNodes(selectedIds)
|
|
455
|
+
useViewer.getState().setSelection({ selectedIds: [] })
|
|
456
|
+
return true
|
|
457
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
4
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { Html } from '@react-three/drei'
|
|
6
|
+
import { useFrame } from '@react-three/fiber'
|
|
7
|
+
import { useCallback, useMemo, useRef } from 'react'
|
|
8
|
+
import * as THREE from 'three'
|
|
9
|
+
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
|
|
10
|
+
import useEditor from '../../store/use-editor'
|
|
11
|
+
import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
|
|
12
|
+
import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from './group-actions'
|
|
13
|
+
import { classifyParticipant, computeGroupBox, expandToComponent } from './group-transform-shared'
|
|
14
|
+
import { NodeActionMenu } from './node-action-menu'
|
|
15
|
+
import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
|
|
16
|
+
|
|
17
|
+
// Matches the single-node FloatingActionMenu's zoom-compensation constants.
|
|
18
|
+
const REF_ORTHO_ZOOM = 50
|
|
19
|
+
const REF_CAMERA_DISTANCE = 12
|
|
20
|
+
const MIN_MENU_SCALE = 0.6
|
|
21
|
+
const MAX_MENU_SCALE = 1.4
|
|
22
|
+
// Clearance above the group's bbox top so the pill doesn't sit on the meshes.
|
|
23
|
+
const MENU_Y_OFFSET = 0.42
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Floating Move / Duplicate / Delete pill for a MULTI-selection in the 3D
|
|
27
|
+
* view — the group sibling of the single-node `FloatingActionMenu` (which is
|
|
28
|
+
* sole-selection only). Anchored above the selection's bounding-box center;
|
|
29
|
+
* every action targets the whole selection: Move picks the group up (it rides
|
|
30
|
+
* the cursor until a click places it), Duplicate clones the selection and
|
|
31
|
+
* picks the clones up, Delete removes everything selected.
|
|
32
|
+
*/
|
|
33
|
+
export function GroupFloatingActionMenu() {
|
|
34
|
+
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
35
|
+
const levelId = useViewer((s) => s.selection.levelId)
|
|
36
|
+
const mode = useEditor((s) => s.mode)
|
|
37
|
+
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
38
|
+
const movingNode = useMovingNode()
|
|
39
|
+
const nodes = useScene((s) => s.nodes)
|
|
40
|
+
// Hard-hidden during any active interaction (drag, pick-up, reshape) so the
|
|
41
|
+
// pill never competes with the live action — same policy as the 1-node menu.
|
|
42
|
+
const scope = useInteractionScope((s) => s.scope)
|
|
43
|
+
const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
|
|
44
|
+
|
|
45
|
+
const groupRef = useRef<THREE.Group>(null)
|
|
46
|
+
const menuScaleRef = useRef<HTMLDivElement>(null)
|
|
47
|
+
|
|
48
|
+
const participantIds = useMemo(
|
|
49
|
+
() =>
|
|
50
|
+
selectedIds.length > 1
|
|
51
|
+
? selectedIds.filter(
|
|
52
|
+
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
|
53
|
+
)
|
|
54
|
+
: [],
|
|
55
|
+
[selectedIds, levelId, nodes],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// World anchor above the group bbox. Depends on the scene (post-commit
|
|
59
|
+
// positions), not the camera, and the menu hides during drags — so a
|
|
60
|
+
// memo keyed on selection + nodes is enough, no per-frame box traversal.
|
|
61
|
+
const meshEpoch = useMeshSettleEpoch(nodes)
|
|
62
|
+
const anchor = useMemo(() => {
|
|
63
|
+
if (participantIds.length === 0) return null
|
|
64
|
+
const fullIds = expandToComponent(participantIds, nodes, levelId)
|
|
65
|
+
const box = computeGroupBox(fullIds)
|
|
66
|
+
if (!box) return null
|
|
67
|
+
return new THREE.Vector3(
|
|
68
|
+
(box.min.x + box.max.x) / 2,
|
|
69
|
+
box.max.y + MENU_Y_OFFSET,
|
|
70
|
+
(box.min.z + box.max.z) / 2,
|
|
71
|
+
)
|
|
72
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
|
|
73
|
+
}, [participantIds, nodes, levelId, meshEpoch])
|
|
74
|
+
|
|
75
|
+
useFrame((state) => {
|
|
76
|
+
// Scale the HTML pill with camera zoom / distance so it feels anchored to
|
|
77
|
+
// the world — mirrors the single-node menu.
|
|
78
|
+
if (!(menuScaleRef.current && groupRef.current)) return
|
|
79
|
+
const raw =
|
|
80
|
+
state.camera instanceof THREE.OrthographicCamera
|
|
81
|
+
? state.camera.zoom / REF_ORTHO_ZOOM
|
|
82
|
+
: REF_CAMERA_DISTANCE /
|
|
83
|
+
Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001)
|
|
84
|
+
const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw))
|
|
85
|
+
menuScaleRef.current.style.transform = `scale(${scale})`
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const stopPointer = useCallback((event: React.PointerEvent) => {
|
|
89
|
+
event.stopPropagation()
|
|
90
|
+
}, [])
|
|
91
|
+
const handleMove = useCallback((event: React.MouseEvent) => {
|
|
92
|
+
event.stopPropagation()
|
|
93
|
+
startGroupPickUp()
|
|
94
|
+
}, [])
|
|
95
|
+
const handleDuplicate = useCallback((event: React.MouseEvent) => {
|
|
96
|
+
event.stopPropagation()
|
|
97
|
+
duplicateSelectionAndPickUp()
|
|
98
|
+
}, [])
|
|
99
|
+
const handleDelete = useCallback((event: React.MouseEvent) => {
|
|
100
|
+
event.stopPropagation()
|
|
101
|
+
deleteSelection()
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
104
|
+
if (!anchor || mode === 'delete' || isFloorplanHovered || movingNode || menuStepBack) {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<group position={anchor} ref={groupRef}>
|
|
110
|
+
<Html
|
|
111
|
+
center
|
|
112
|
+
style={{
|
|
113
|
+
pointerEvents: 'auto',
|
|
114
|
+
touchAction: 'none',
|
|
115
|
+
}}
|
|
116
|
+
zIndexRange={[25, 0]}
|
|
117
|
+
>
|
|
118
|
+
<div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
|
|
119
|
+
<NodeActionMenu
|
|
120
|
+
onDelete={handleDelete}
|
|
121
|
+
onDuplicate={handleDuplicate}
|
|
122
|
+
onMove={handleMove}
|
|
123
|
+
onPointerDown={stopPointer}
|
|
124
|
+
onPointerUp={stopPointer}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
</Html>
|
|
128
|
+
</group>
|
|
129
|
+
)
|
|
130
|
+
}
|