@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
|
@@ -5,23 +5,32 @@ import {
|
|
|
5
5
|
type AnyNodeId,
|
|
6
6
|
type CeilingNode,
|
|
7
7
|
ColumnNode,
|
|
8
|
+
createSceneApi,
|
|
8
9
|
DEFAULT_WALL_HEIGHT,
|
|
9
10
|
DoorNode,
|
|
10
11
|
ElevatorNode,
|
|
12
|
+
emitter,
|
|
11
13
|
FenceNode,
|
|
12
14
|
generateId,
|
|
15
|
+
getActiveRoofHeight,
|
|
16
|
+
getEffectiveNode,
|
|
13
17
|
getWallCurveLength,
|
|
14
18
|
getWallThickness,
|
|
15
19
|
ItemNode,
|
|
20
|
+
isCurvedWall,
|
|
16
21
|
isRegistryMovable,
|
|
17
22
|
isRegistrySelectable,
|
|
23
|
+
isSplineFence,
|
|
24
|
+
type NodeQuickAction,
|
|
18
25
|
nodeRegistry,
|
|
19
26
|
RoofSegmentNode,
|
|
27
|
+
runAsSingleSceneHistoryStep,
|
|
20
28
|
type SlabNode,
|
|
21
29
|
SpawnNode,
|
|
22
30
|
StairNode,
|
|
23
31
|
StairSegmentNode,
|
|
24
32
|
sceneRegistry,
|
|
33
|
+
summarizeSystemFor,
|
|
25
34
|
useLiveNodeOverrides,
|
|
26
35
|
useScene,
|
|
27
36
|
WallNode,
|
|
@@ -30,15 +39,43 @@ import {
|
|
|
30
39
|
import { useViewer } from '@pascal-app/viewer'
|
|
31
40
|
import { Html } from '@react-three/drei'
|
|
32
41
|
import { useFrame } from '@react-three/fiber'
|
|
33
|
-
import { useCallback, useRef } from 'react'
|
|
42
|
+
import { useCallback, useMemo, useRef } from 'react'
|
|
34
43
|
import * as THREE from 'three'
|
|
44
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
45
|
+
import {
|
|
46
|
+
createFreshPlacementSubtree,
|
|
47
|
+
duplicatesAsFreshSubtree,
|
|
48
|
+
} from '../../lib/fresh-planar-placement'
|
|
49
|
+
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
|
|
50
|
+
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
|
|
35
51
|
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
|
36
52
|
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
|
37
53
|
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
|
38
54
|
import useEditor from '../../store/use-editor'
|
|
55
|
+
import useInteractionScope, {
|
|
56
|
+
useActiveHandleDrag,
|
|
57
|
+
useEndpointReshape,
|
|
58
|
+
useIsCurveReshape,
|
|
59
|
+
} from '../../store/use-interaction-scope'
|
|
60
|
+
import { IconRefGlyph } from '../ui/icon-ref'
|
|
39
61
|
import { formatMeasurement, MeasurementPill } from './measurement-pill'
|
|
40
62
|
import { NodeActionMenu } from './node-action-menu'
|
|
41
63
|
|
|
64
|
+
/**
|
|
65
|
+
* A kind shows the system pill when it exposes typed ports — `def.ports`
|
|
66
|
+
* is exactly what makes a node participate in the supply/return graph the
|
|
67
|
+
* pill summarizes. Keeps the menu off a hand-maintained kind list.
|
|
68
|
+
*/
|
|
69
|
+
const hasPorts = (type: string) => nodeRegistry.get(type)?.ports != null
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A kind shows the rotation-axis pill when its R/T keyboard rotation
|
|
73
|
+
* turns around a user-cyclable axis (`keyboardActions.axisCycling`) —
|
|
74
|
+
* duct / pipe fittings with full 3D orientation.
|
|
75
|
+
*/
|
|
76
|
+
const hasAxisCycling = (type: string) =>
|
|
77
|
+
nodeRegistry.get(type)?.keyboardActions?.axisCycling === true
|
|
78
|
+
|
|
42
79
|
const ALLOWED_TYPES = [
|
|
43
80
|
'item',
|
|
44
81
|
'door',
|
|
@@ -111,6 +148,127 @@ function getMenuYOffset(node: AnyNode | null): number {
|
|
|
111
148
|
return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
|
|
112
149
|
}
|
|
113
150
|
|
|
151
|
+
function getAttributeVersion(
|
|
152
|
+
attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute | null | undefined,
|
|
153
|
+
): number {
|
|
154
|
+
return attribute && 'version' in attribute && typeof attribute.version === 'number'
|
|
155
|
+
? attribute.version
|
|
156
|
+
: 0
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) {
|
|
160
|
+
return (
|
|
161
|
+
<svg
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
className="h-3.5 w-3.5"
|
|
164
|
+
fill="none"
|
|
165
|
+
stroke="currentColor"
|
|
166
|
+
strokeLinecap="round"
|
|
167
|
+
strokeLinejoin="round"
|
|
168
|
+
strokeWidth="1.7"
|
|
169
|
+
viewBox="0 0 24 24"
|
|
170
|
+
>
|
|
171
|
+
<rect x="8" y="7" width="9" height="10" rx="1.75" />
|
|
172
|
+
<path d="M12.5 7v10" />
|
|
173
|
+
{direction === 'left' ? (
|
|
174
|
+
<>
|
|
175
|
+
<path d="M6.5 12H2.75" />
|
|
176
|
+
<path d="m5.5 9.75-2.75 2.25 2.75 2.25" />
|
|
177
|
+
</>
|
|
178
|
+
) : (
|
|
179
|
+
<>
|
|
180
|
+
<path d="M17.5 12h3.75" />
|
|
181
|
+
<path d="m18.5 9.75 2.75 2.25-2.75 2.25" />
|
|
182
|
+
</>
|
|
183
|
+
)}
|
|
184
|
+
</svg>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Builtin string tokens map to the generic glyphs above; kind-owned marks
|
|
189
|
+
// arrive as IconRef objects and render through the shared IconRefGlyph.
|
|
190
|
+
function QuickActionIcon({ action }: { action: NodeQuickAction }) {
|
|
191
|
+
const icon = action.icon
|
|
192
|
+
if (!icon) return null
|
|
193
|
+
if (typeof icon === 'object') return <IconRefGlyph icon={icon} size={14} />
|
|
194
|
+
switch (icon) {
|
|
195
|
+
case 'add-left':
|
|
196
|
+
return <SideAddGlyph direction="left" />
|
|
197
|
+
case 'add-right':
|
|
198
|
+
return <SideAddGlyph direction="right" />
|
|
199
|
+
default:
|
|
200
|
+
return null
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function collectQuickActionNodes(
|
|
205
|
+
nodes: Record<AnyNodeId, AnyNode>,
|
|
206
|
+
selectedId: string | null,
|
|
207
|
+
): Record<AnyNodeId, AnyNode> | null {
|
|
208
|
+
if (!selectedId) return null
|
|
209
|
+
const selected = nodes[selectedId as AnyNodeId]
|
|
210
|
+
if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null
|
|
211
|
+
|
|
212
|
+
const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
|
|
213
|
+
const add = (id: string | null | undefined) => {
|
|
214
|
+
if (!id) return
|
|
215
|
+
const node = nodes[id as AnyNodeId]
|
|
216
|
+
if (node) collected[node.id as AnyNodeId] = node
|
|
217
|
+
}
|
|
218
|
+
const addChildren = (node: AnyNode | undefined) => {
|
|
219
|
+
for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
|
|
220
|
+
add(childId)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
add(selected.parentId ?? null)
|
|
225
|
+
addChildren(selected)
|
|
226
|
+
const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
|
|
227
|
+
addChildren(parent)
|
|
228
|
+
|
|
229
|
+
return collected
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
|
|
233
|
+
// dragged node doesn't allocate a fresh Box3 + Vector3 every frame.
|
|
234
|
+
const _anchorBox = new THREE.Box3()
|
|
235
|
+
const _anchorCenter = new THREE.Vector3()
|
|
236
|
+
|
|
237
|
+
function getObjectGeometryKey(object: THREE.Object3D): string {
|
|
238
|
+
const parts: string[] = []
|
|
239
|
+
object.traverse((child) => {
|
|
240
|
+
const geometry = (child as Partial<THREE.Mesh>).geometry
|
|
241
|
+
if (!geometry) return
|
|
242
|
+
|
|
243
|
+
parts.push(
|
|
244
|
+
[
|
|
245
|
+
geometry.id,
|
|
246
|
+
getAttributeVersion(geometry.getAttribute('position')),
|
|
247
|
+
getAttributeVersion(geometry.getIndex()),
|
|
248
|
+
].join(':'),
|
|
249
|
+
)
|
|
250
|
+
})
|
|
251
|
+
return parts.join('|')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function setNodeDerivedMenuAnchor(
|
|
255
|
+
node: AnyNode,
|
|
256
|
+
object: THREE.Object3D,
|
|
257
|
+
target: THREE.Vector3,
|
|
258
|
+
): boolean {
|
|
259
|
+
if (node.type !== 'roof-segment') return false
|
|
260
|
+
|
|
261
|
+
const visualTop =
|
|
262
|
+
node.wallHeight +
|
|
263
|
+
getActiveRoofHeight(node) +
|
|
264
|
+
Math.max(0, node.deckThickness ?? 0) +
|
|
265
|
+
Math.max(0, node.shingleThickness ?? 0)
|
|
266
|
+
|
|
267
|
+
target.set(0, visualTop, 0).applyMatrix4(object.matrixWorld)
|
|
268
|
+
target.y += getMenuYOffset(node)
|
|
269
|
+
return true
|
|
270
|
+
}
|
|
271
|
+
|
|
114
272
|
// Fence schema defaults — mirror packages/nodes/src/fence/definition.ts so the
|
|
115
273
|
// pill reads sensibly before an explicit height / thickness is set.
|
|
116
274
|
const FENCE_DEFAULT_HEIGHT = 1.8
|
|
@@ -142,19 +300,22 @@ export function FloatingActionMenu() {
|
|
|
142
300
|
const updateNode = useScene((s) => s.updateNode)
|
|
143
301
|
const mode = useEditor((s) => s.mode)
|
|
144
302
|
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const
|
|
303
|
+
const canFindNode = useEditor((s) => s.canFindNode)
|
|
304
|
+
const endpointReshape = useEndpointReshape()
|
|
305
|
+
const isCurveReshape = useIsCurveReshape()
|
|
148
306
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
149
|
-
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
|
150
|
-
const setCurvingFence = useEditor((s) => s.setCurvingFence)
|
|
151
307
|
const setSelection = useViewer((s) => s.setSelection)
|
|
152
|
-
const setEditingHole = useEditor((s) => s.setEditingHole)
|
|
153
308
|
const unit = useViewer((s) => s.unit)
|
|
154
309
|
// Drives the height-drag dimension pill below the menu. `activeHandleDrag`
|
|
155
310
|
// flips only at drag start / end, so subscribing here is cheap — the live
|
|
156
311
|
// height value is written imperatively in the useFrame below.
|
|
157
|
-
const activeHandleDrag =
|
|
312
|
+
const activeHandleDrag = useActiveHandleDrag()
|
|
313
|
+
// R/T rotation axis for kinds with full 3D orientation (duct fittings).
|
|
314
|
+
const rotationAxis = useEditor((s) => s.rotationAxis)
|
|
315
|
+
// The floating action menu is an action-conflicting control: hard-hidden
|
|
316
|
+
// during any active interaction so it never competes with the live action.
|
|
317
|
+
const scope = useInteractionScope((s) => s.scope)
|
|
318
|
+
const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
|
|
158
319
|
|
|
159
320
|
const groupRef = useRef<THREE.Group>(null)
|
|
160
321
|
const menuScaleRef = useRef<HTMLDivElement>(null)
|
|
@@ -171,22 +332,39 @@ export function FloatingActionMenu() {
|
|
|
171
332
|
const anchorRef = useRef(new THREE.Vector3())
|
|
172
333
|
const hasAnchorRef = useRef(false)
|
|
173
334
|
const lastMatrixRef = useRef(new THREE.Matrix4())
|
|
174
|
-
const lastAnchorKeyRef = useRef<{
|
|
335
|
+
const lastAnchorKeyRef = useRef<{
|
|
336
|
+
id: string | null
|
|
337
|
+
node: AnyNode | null
|
|
338
|
+
geometryKey: string | null
|
|
339
|
+
}>({
|
|
175
340
|
id: null,
|
|
176
341
|
node: null,
|
|
342
|
+
geometryKey: null,
|
|
177
343
|
})
|
|
178
344
|
|
|
179
345
|
// Only show for single selection of specific types
|
|
180
|
-
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
|
346
|
+
const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
|
|
181
347
|
|
|
182
348
|
// Subscribe just to the selected node so unrelated scene updates do not
|
|
183
349
|
// re-render this menu.
|
|
184
350
|
const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
|
|
351
|
+
const quickActionNodes = useScene(useShallow((s) => collectQuickActionNodes(s.nodes, selectedId)))
|
|
352
|
+
const quickActions = useMemo<NodeQuickAction[]>(
|
|
353
|
+
() =>
|
|
354
|
+
node && quickActionNodes
|
|
355
|
+
? (nodeRegistry.get(node.type)?.quickActions?.({
|
|
356
|
+
node: node as never,
|
|
357
|
+
nodes: quickActionNodes,
|
|
358
|
+
}) ?? [])
|
|
359
|
+
: [],
|
|
360
|
+
[node, quickActionNodes],
|
|
361
|
+
)
|
|
185
362
|
// ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any
|
|
186
363
|
// NodeDefinition with `capabilities.selectable`) get the floating menu
|
|
187
364
|
// by default too. Phase 4 collapses these into a single registry check.
|
|
188
365
|
const isValidType = node
|
|
189
|
-
?
|
|
366
|
+
? nodeRegistry.get(node.type)?.presentation?.actionMenu !== false &&
|
|
367
|
+
(ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type))
|
|
190
368
|
: false
|
|
191
369
|
|
|
192
370
|
// Height-drag pill: shown just above the menu only while the selected
|
|
@@ -218,7 +396,7 @@ export function FloatingActionMenu() {
|
|
|
218
396
|
})
|
|
219
397
|
|
|
220
398
|
useFrame((state) => {
|
|
221
|
-
if (!(selectedId && isValidType && groupRef.current)) return
|
|
399
|
+
if (!(selectedId && node && isValidType && groupRef.current)) return
|
|
222
400
|
|
|
223
401
|
// Scale the HTML menu with camera zoom (ortho) or inverse distance
|
|
224
402
|
// (perspective) so it feels anchored to the world, clamped on both ends
|
|
@@ -253,29 +431,59 @@ export function FloatingActionMenu() {
|
|
|
253
431
|
|
|
254
432
|
const obj = sceneRegistry.nodes.get(selectedId)
|
|
255
433
|
if (obj) {
|
|
434
|
+
obj.updateWorldMatrix(true, false)
|
|
435
|
+
|
|
256
436
|
// Recompute the anchor only when the object genuinely changes —
|
|
257
437
|
// reselected, moved (its own world matrix changed), or resized
|
|
258
438
|
// (a fresh store node on commit, or a live override / handle drag
|
|
259
439
|
// mid-resize). A spinning child changes the head's matrix, not the
|
|
260
440
|
// registered group's, so it never triggers a recompute → the menu
|
|
261
441
|
// holds still.
|
|
442
|
+
// Cheapest guards first: a selection swap, the object's own world
|
|
443
|
+
// transform changing (true every frame during a drag), a live override,
|
|
444
|
+
// or an active handle drag all force a recompute on their own — so skip
|
|
445
|
+
// the geometry traversal (`getObjectGeometryKey` walks the whole subtree
|
|
446
|
+
// reading attribute versions) until none of them fired and a
|
|
447
|
+
// geometry-only change is the only thing left that could move the anchor.
|
|
262
448
|
const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
|
|
263
449
|
const dragActive = activeHandleDrag?.nodeId === selectedId
|
|
264
450
|
const selectionChanged =
|
|
265
451
|
lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
|
|
266
452
|
const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
|
|
267
453
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
454
|
+
let geometryKey = lastAnchorKeyRef.current.geometryKey
|
|
455
|
+
let needsRecompute = selectionChanged || matrixChanged || overrideActive || dragActive
|
|
456
|
+
// Only when nothing cheaper fired do we pay for the subtree traversal —
|
|
457
|
+
// a geometry-only change is the lone remaining trigger. When a cheaper
|
|
458
|
+
// guard already forced a recompute the stored key is reused; the matrix
|
|
459
|
+
// (or override/drag) keeps recomputing the anchor every frame, so a
|
|
460
|
+
// geometry edit mid-drag is absorbed, and the next idle frame refreshes
|
|
461
|
+
// the key against the live geometry.
|
|
462
|
+
if (!needsRecompute) {
|
|
463
|
+
geometryKey = getObjectGeometryKey(obj)
|
|
464
|
+
if (geometryKey !== lastAnchorKeyRef.current.geometryKey) needsRecompute = true
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (needsRecompute) {
|
|
468
|
+
const effectiveNode = getEffectiveNode(node)
|
|
469
|
+
if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) {
|
|
470
|
+
_anchorBox.setFromObject(obj)
|
|
471
|
+
if (!_anchorBox.isEmpty()) {
|
|
472
|
+
_anchorBox.getCenter(_anchorCenter)
|
|
473
|
+
// Position above the object. Per-type offsets clear each kind's
|
|
474
|
+
// in-world chrome (height-resize arrows, measurement labels).
|
|
475
|
+
anchorRef.current.set(
|
|
476
|
+
_anchorCenter.x,
|
|
477
|
+
_anchorBox.max.y + getMenuYOffset(effectiveNode),
|
|
478
|
+
_anchorCenter.z,
|
|
479
|
+
)
|
|
480
|
+
hasAnchorRef.current = true
|
|
481
|
+
}
|
|
482
|
+
} else {
|
|
275
483
|
hasAnchorRef.current = true
|
|
276
484
|
}
|
|
277
485
|
lastMatrixRef.current.copy(obj.matrixWorld)
|
|
278
|
-
lastAnchorKeyRef.current = { id: selectedId, node }
|
|
486
|
+
lastAnchorKeyRef.current = { id: selectedId, node, geometryKey }
|
|
279
487
|
}
|
|
280
488
|
|
|
281
489
|
if (hasAnchorRef.current) {
|
|
@@ -291,15 +499,15 @@ export function FloatingActionMenu() {
|
|
|
291
499
|
sfxEmitter.emit('sfx:item-pick')
|
|
292
500
|
if (node.type === 'wall') {
|
|
293
501
|
if (!canCurveSelectedWall) return
|
|
294
|
-
|
|
502
|
+
useInteractionScope.getState().begin(curveReshapeScope(node.id))
|
|
295
503
|
} else if (node.type === 'fence') {
|
|
296
|
-
|
|
504
|
+
useInteractionScope.getState().begin(curveReshapeScope(node.id))
|
|
297
505
|
} else {
|
|
298
506
|
return
|
|
299
507
|
}
|
|
300
508
|
setSelection({ selectedIds: [] })
|
|
301
509
|
},
|
|
302
|
-
[canCurveSelectedWall, node,
|
|
510
|
+
[canCurveSelectedWall, node, setSelection],
|
|
303
511
|
)
|
|
304
512
|
const handleMove = useCallback(
|
|
305
513
|
(e: React.MouseEvent) => {
|
|
@@ -328,6 +536,18 @@ export function FloatingActionMenu() {
|
|
|
328
536
|
|
|
329
537
|
useScene.temporal.getState().pause()
|
|
330
538
|
|
|
539
|
+
if (duplicatesAsFreshSubtree(node as AnyNode)) {
|
|
540
|
+
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
|
|
541
|
+
const draft = draftId ? useScene.getState().nodes[draftId] : null
|
|
542
|
+
if (draft) {
|
|
543
|
+
setMovingNode(draft as any)
|
|
544
|
+
setSelection({ selectedIds: [] })
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
useScene.temporal.getState().resume()
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
|
|
331
551
|
let duplicateInfo = structuredClone(node) as any
|
|
332
552
|
delete duplicateInfo.id
|
|
333
553
|
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
|
@@ -431,10 +651,26 @@ export function FloatingActionMenu() {
|
|
|
431
651
|
// item without clicking" bug. (Item has its own
|
|
432
652
|
// draft-committing move tool, so it must skip the generic
|
|
433
653
|
// registry auto-create branch below.)
|
|
654
|
+
} else if (
|
|
655
|
+
duplicate.type === 'duct-segment' ||
|
|
656
|
+
duplicate.type === 'duct-fitting' ||
|
|
657
|
+
duplicate.type === 'pipe-segment' ||
|
|
658
|
+
duplicate.type === 'lineset' ||
|
|
659
|
+
duplicate.type === 'liquid-line'
|
|
660
|
+
) {
|
|
661
|
+
// Duct runs & fittings, DWV pipe runs, and refrigerant linesets use
|
|
662
|
+
// pure drag-to-place: NO node is inserted into the scene until the
|
|
663
|
+
// commit click. `setMovingNode` below hands the clone (with
|
|
664
|
+
// `metadata.isNew`) to its ghost tool (`MoveDuctSegmentTool` /
|
|
665
|
+
// `MoveDuctFittingTool` / `MovePipeSegmentTool` / `MoveLinesetTool`),
|
|
666
|
+
// which previews a translucent copy inside a footprint bounding box
|
|
667
|
+
// on the cursor and calls `createNode` on the drop click.
|
|
668
|
+
// Pre-creating here would drop a copy before any click — the
|
|
669
|
+
// "auto-places it" bug.
|
|
434
670
|
} else if (nodeRegistry.has(duplicate.type)) {
|
|
435
|
-
// Registry-driven kinds: offset
|
|
436
|
-
//
|
|
437
|
-
//
|
|
671
|
+
// Registry-driven kinds: offset slightly so the duplicate doesn't
|
|
672
|
+
// overlap exactly, then create + hand to the move tool. Mirrors the
|
|
673
|
+
// roof-segment / stair-segment behavior.
|
|
438
674
|
if ('position' in duplicate && Array.isArray((duplicate as any).position)) {
|
|
439
675
|
const pos = (duplicate as { position: [number, number, number] }).position
|
|
440
676
|
;(duplicate as { position: [number, number, number] }).position = [
|
|
@@ -442,6 +678,12 @@ export function FloatingActionMenu() {
|
|
|
442
678
|
pos[1],
|
|
443
679
|
pos[2] + 1,
|
|
444
680
|
]
|
|
681
|
+
} else if ('path' in duplicate && Array.isArray((duplicate as any).path)) {
|
|
682
|
+
// Other polyline kinds (pipe / lineset) carry a `path`, not a
|
|
683
|
+
// `position`. Create the copy HIDDEN so nothing is auto-placed:
|
|
684
|
+
// their shared path mover reveals it as a cursor-following
|
|
685
|
+
// preview on the first mouse move and commits on the next click.
|
|
686
|
+
;(duplicate as { visible?: boolean }).visible = false
|
|
445
687
|
}
|
|
446
688
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
447
689
|
}
|
|
@@ -503,11 +745,13 @@ export function FloatingActionMenu() {
|
|
|
503
745
|
holes: [...currentHoles, newHole],
|
|
504
746
|
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
|
505
747
|
})
|
|
506
|
-
|
|
748
|
+
useInteractionScope
|
|
749
|
+
.getState()
|
|
750
|
+
.begin(holeEditScope({ nodeId: selectedId, holeIndex: currentHoles.length }))
|
|
507
751
|
// Re-assert selection so the node stays selected
|
|
508
752
|
setSelection({ selectedIds: [selectedId] })
|
|
509
753
|
},
|
|
510
|
-
[node, selectedId, updateNode,
|
|
754
|
+
[node, selectedId, updateNode, setSelection],
|
|
511
755
|
)
|
|
512
756
|
|
|
513
757
|
const handleDelete = useCallback(
|
|
@@ -521,11 +765,37 @@ export function FloatingActionMenu() {
|
|
|
521
765
|
[node?.type, selectedId, setSelection],
|
|
522
766
|
)
|
|
523
767
|
|
|
768
|
+
// "Find in catalog": the editor only signals intent — the host (community)
|
|
769
|
+
// listens for `selection:find-node` and reveals the node in its browser.
|
|
770
|
+
const handleFind = useCallback(
|
|
771
|
+
(e: React.MouseEvent) => {
|
|
772
|
+
e.stopPropagation()
|
|
773
|
+
if (node) emitter.emit('selection:find-node', node)
|
|
774
|
+
},
|
|
775
|
+
[node],
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
const handleQuickAction = useCallback(
|
|
779
|
+
(action: NodeQuickAction) => (e: React.MouseEvent) => {
|
|
780
|
+
e.stopPropagation()
|
|
781
|
+
if (!node || action.disabled) return
|
|
782
|
+
const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
|
|
783
|
+
const result =
|
|
784
|
+
action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
|
|
785
|
+
if (result?.selectedIds) setSelection({ selectedIds: result.selectedIds })
|
|
786
|
+
if (result?.selectedIds) {
|
|
787
|
+
const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id)
|
|
788
|
+
sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
[node, setSelection],
|
|
792
|
+
)
|
|
793
|
+
|
|
524
794
|
if (
|
|
525
795
|
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
796
|
+
endpointReshape ||
|
|
797
|
+
isCurveReshape ||
|
|
798
|
+
menuStepBack
|
|
529
799
|
)
|
|
530
800
|
return null
|
|
531
801
|
|
|
@@ -540,11 +810,17 @@ export function FloatingActionMenu() {
|
|
|
540
810
|
}}
|
|
541
811
|
zIndexRange={[25, 0]}
|
|
542
812
|
>
|
|
543
|
-
<div
|
|
813
|
+
<div
|
|
814
|
+
className="relative flex flex-col items-center"
|
|
815
|
+
ref={menuScaleRef}
|
|
816
|
+
style={{ transformOrigin: 'center center' }}
|
|
817
|
+
>
|
|
544
818
|
<NodeActionMenu
|
|
819
|
+
onFind={node && canFindNode ? handleFind : undefined}
|
|
545
820
|
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
|
546
821
|
onCurve={
|
|
547
|
-
node?.type === 'fence'
|
|
822
|
+
(node?.type === 'fence' && !isSplineFence(node) && !isCurvedWall(node)) ||
|
|
823
|
+
(node?.type === 'wall' && canCurveSelectedWall)
|
|
548
824
|
? handleCurve
|
|
549
825
|
: undefined
|
|
550
826
|
}
|
|
@@ -567,6 +843,30 @@ export function FloatingActionMenu() {
|
|
|
567
843
|
onPointerDown={(e) => e.stopPropagation()}
|
|
568
844
|
onPointerUp={(e) => e.stopPropagation()}
|
|
569
845
|
/>
|
|
846
|
+
{quickActions.length > 0 ? (
|
|
847
|
+
<div
|
|
848
|
+
className="pointer-events-auto mt-1 inline-flex w-max items-center justify-center gap-0.5 rounded-lg border border-border/50 bg-background/90 px-1.5 py-1 shadow-md backdrop-blur-md"
|
|
849
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
850
|
+
onPointerUp={(e) => e.stopPropagation()}
|
|
851
|
+
>
|
|
852
|
+
{quickActions.map((action) => (
|
|
853
|
+
<button
|
|
854
|
+
aria-label={action.title ?? action.label}
|
|
855
|
+
className="tooltip-trigger flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
|
856
|
+
disabled={action.disabled}
|
|
857
|
+
key={action.id}
|
|
858
|
+
onClick={handleQuickAction(action)}
|
|
859
|
+
title={action.title ?? action.label}
|
|
860
|
+
type="button"
|
|
861
|
+
>
|
|
862
|
+
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
|
|
863
|
+
<QuickActionIcon action={action} />
|
|
864
|
+
</span>
|
|
865
|
+
<span className="whitespace-nowrap leading-none">{action.label}</span>
|
|
866
|
+
</button>
|
|
867
|
+
))}
|
|
868
|
+
</div>
|
|
869
|
+
) : null}
|
|
570
870
|
{/* Height-drag dimension pill. Absolutely positioned just above
|
|
571
871
|
the menu (away from the height arrow below it) so it rides the
|
|
572
872
|
same scale transform + anchor, never overlaps the menu, and
|
|
@@ -584,9 +884,86 @@ export function FloatingActionMenu() {
|
|
|
584
884
|
/>
|
|
585
885
|
</div>
|
|
586
886
|
) : null}
|
|
887
|
+
{/* HVAC chrome above the menu — same slot as the wall height
|
|
888
|
+
pill. System pill (which tree, run length, equipment reach)
|
|
889
|
+
for every distribution kind; the rotation-axis pill stacks
|
|
890
|
+
under it for duct fittings. */}
|
|
891
|
+
{node && hasPorts(node.type) ? (
|
|
892
|
+
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 mb-2 flex flex-col items-center gap-1">
|
|
893
|
+
<SystemSummaryPill nodeId={node.id} unit={unit} />
|
|
894
|
+
{hasAxisCycling(node.type) ? (
|
|
895
|
+
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
|
896
|
+
<span className="font-medium text-foreground">
|
|
897
|
+
Axis {rotationAxis.toUpperCase()}
|
|
898
|
+
</span>
|
|
899
|
+
<span aria-hidden className="text-muted-foreground">
|
|
900
|
+
·
|
|
901
|
+
</span>
|
|
902
|
+
<span className="text-muted-foreground">R/T rotate</span>
|
|
903
|
+
<span aria-hidden className="text-muted-foreground">
|
|
904
|
+
·
|
|
905
|
+
</span>
|
|
906
|
+
<span className="text-muted-foreground">⌥ axis</span>
|
|
907
|
+
</div>
|
|
908
|
+
) : null}
|
|
909
|
+
</div>
|
|
910
|
+
) : null}
|
|
587
911
|
</div>
|
|
588
912
|
</Html>
|
|
589
913
|
</group>
|
|
590
914
|
</group>
|
|
591
915
|
)
|
|
592
916
|
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* System summary pill for a selected distribution kind (HVAC duct / DWV
|
|
920
|
+
* pipe / refrigerant lineset): which supply/return tree it belongs to, its
|
|
921
|
+
* run length, and whether it actually reaches a piece of equipment.
|
|
922
|
+
*
|
|
923
|
+
* Mounted only while an HVAC node is selected, so the full-`nodes`
|
|
924
|
+
* subscription it needs (connectivity changes when ANY joint moves) doesn't
|
|
925
|
+
* re-render the always-mounted parent menu on every unrelated scene tick.
|
|
926
|
+
*/
|
|
927
|
+
function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric' | 'imperial' }) {
|
|
928
|
+
const allNodes = useScene((s) => s.nodes)
|
|
929
|
+
const summary = useMemo(() => summarizeSystemFor(nodeId, allNodes), [nodeId, allNodes])
|
|
930
|
+
if (!summary) return null
|
|
931
|
+
return (
|
|
932
|
+
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
|
933
|
+
<span className="font-medium text-foreground">
|
|
934
|
+
{summary.systems.length > 0
|
|
935
|
+
? summary.systems.map((sys) => sys[0]!.toUpperCase() + sys.slice(1)).join(' + ')
|
|
936
|
+
: 'System'}
|
|
937
|
+
</span>
|
|
938
|
+
{summary.runCount > 0 ? (
|
|
939
|
+
<>
|
|
940
|
+
<span aria-hidden className="text-muted-foreground">
|
|
941
|
+
·
|
|
942
|
+
</span>
|
|
943
|
+
<span className="text-muted-foreground">
|
|
944
|
+
{formatMeasurement(summary.runLengthM, unit)} · {summary.runCount}{' '}
|
|
945
|
+
{summary.runCount === 1 ? 'run' : 'runs'}
|
|
946
|
+
</span>
|
|
947
|
+
</>
|
|
948
|
+
) : null}
|
|
949
|
+
{summary.terminalCount > 0 ? (
|
|
950
|
+
<>
|
|
951
|
+
<span aria-hidden className="text-muted-foreground">
|
|
952
|
+
·
|
|
953
|
+
</span>
|
|
954
|
+
<span className="text-muted-foreground">
|
|
955
|
+
{summary.terminalCount} {summary.terminalCount === 1 ? 'register' : 'registers'}
|
|
956
|
+
</span>
|
|
957
|
+
</>
|
|
958
|
+
) : null}
|
|
959
|
+
{summary.connectedToEquipment ? null : (
|
|
960
|
+
<>
|
|
961
|
+
<span aria-hidden className="text-muted-foreground">
|
|
962
|
+
·
|
|
963
|
+
</span>
|
|
964
|
+
<span className="font-medium text-amber-500">⚠ no equipment</span>
|
|
965
|
+
</>
|
|
966
|
+
)}
|
|
967
|
+
</div>
|
|
968
|
+
)
|
|
969
|
+
}
|
|
@@ -38,7 +38,7 @@ export function FloatingBuildingActionMenu() {
|
|
|
38
38
|
// Read lazily at click time — no need to subscribe to nodes for a
|
|
39
39
|
// one-shot action.
|
|
40
40
|
const node = useScene.getState().nodes[buildingId]
|
|
41
|
-
if (
|
|
41
|
+
if (node?.type !== 'building') return
|
|
42
42
|
sfxEmitter.emit('sfx:item-pick')
|
|
43
43
|
setMovingNode(node as BuildingNode)
|
|
44
44
|
setSelection({ buildingId: null })
|