@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
|
@@ -6,15 +6,16 @@ import {
|
|
|
6
6
|
type ArcResizeHandle,
|
|
7
7
|
type Cursor,
|
|
8
8
|
createSceneApi,
|
|
9
|
+
DEFAULT_ANGLE_STEP,
|
|
9
10
|
type HandleDescriptor,
|
|
10
11
|
type HandlePortal,
|
|
12
|
+
type LatchHandle,
|
|
11
13
|
type LinearResizeHandle,
|
|
12
14
|
nodeRegistry,
|
|
13
15
|
type RadialResizeHandle,
|
|
14
16
|
sceneRegistry,
|
|
15
17
|
snapScalar,
|
|
16
18
|
type TapActionHandle,
|
|
17
|
-
type TranslateHandle,
|
|
18
19
|
useLiveNodeOverrides,
|
|
19
20
|
useScene,
|
|
20
21
|
} from '@pascal-app/core'
|
|
@@ -34,15 +35,26 @@ import {
|
|
|
34
35
|
OrthographicCamera,
|
|
35
36
|
Plane,
|
|
36
37
|
Quaternion,
|
|
38
|
+
Ray,
|
|
37
39
|
RingGeometry,
|
|
38
40
|
Vector3,
|
|
39
41
|
} from 'three'
|
|
40
42
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
43
|
+
|
|
41
44
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
42
45
|
import { EDITOR_LAYER } from '../../lib/constants'
|
|
46
|
+
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
|
|
47
|
+
import { resolveDirectManipulationNode } from '../../lib/direct-manipulation'
|
|
43
48
|
import { createEditorApi } from '../../lib/editor-api'
|
|
44
49
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
50
|
+
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
|
45
51
|
import useEditor from '../../store/use-editor'
|
|
52
|
+
import useInteractionScope, {
|
|
53
|
+
useEndpointReshape,
|
|
54
|
+
useIsCurveReshape,
|
|
55
|
+
useMovingNode,
|
|
56
|
+
} from '../../store/use-interaction-scope'
|
|
57
|
+
import useOpeningGuides from '../../store/use-opening-guides'
|
|
46
58
|
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
|
47
59
|
import {
|
|
48
60
|
ARROW_COLOR,
|
|
@@ -54,15 +66,66 @@ import {
|
|
|
54
66
|
} from './handles/handle-arrow'
|
|
55
67
|
import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
|
|
56
68
|
|
|
69
|
+
// Pooled scratch for the handle rig's world-relative pose mapping.
|
|
70
|
+
const _rigRelative = new Matrix4()
|
|
71
|
+
const _rigScratchScale = new Vector3()
|
|
72
|
+
const _resizeAxisW = new Vector3()
|
|
73
|
+
const _resizeScale = new Vector3()
|
|
74
|
+
const _resizeQuaternion = new Quaternion()
|
|
75
|
+
const _resizeOriginW = new Vector3()
|
|
76
|
+
const _resizePositionW = new Vector3()
|
|
77
|
+
const _resizeRay = new Ray()
|
|
78
|
+
const _resizeRayW = new Vector3()
|
|
79
|
+
const MEASUREMENT_SURFACE_EXCLUDE_USER_DATA = { measurementSurface: false }
|
|
80
|
+
|
|
81
|
+
// Tilt that stands a flat XZ-plane move cross up into a node's facing plane
|
|
82
|
+
// (its local XY = a wall face) for `plane: 'node-normal'` handles.
|
|
83
|
+
const NODE_NORMAL_TILT: [number, number, number] = [Math.PI / 2, 0, 0]
|
|
84
|
+
|
|
85
|
+
function axisVector(axis: 'x' | 'y' | 'z', target: Vector3) {
|
|
86
|
+
target.set(0, 0, 0)
|
|
87
|
+
if (axis === 'x') target.x = 1
|
|
88
|
+
else if (axis === 'y') target.y = 1
|
|
89
|
+
else target.z = 1
|
|
90
|
+
return target
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function axisScale(axis: 'x' | 'y' | 'z', scale: Vector3) {
|
|
94
|
+
return axis === 'x' ? scale.x : axis === 'y' ? scale.y : scale.z
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function closestAxisParameterToRay(axisOrigin: Vector3, axisDirection: Vector3, ray: Ray) {
|
|
98
|
+
_resizeRayW.subVectors(axisOrigin, ray.origin)
|
|
99
|
+
const b = axisDirection.dot(ray.direction)
|
|
100
|
+
const d = axisDirection.dot(_resizeRayW)
|
|
101
|
+
const e = ray.direction.dot(_resizeRayW)
|
|
102
|
+
const denominator = 1 - b * b
|
|
103
|
+
if (Math.abs(denominator) < 1e-6) {
|
|
104
|
+
return -d
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const axisParameter = (b * e - d) / denominator
|
|
108
|
+
const rayParameter = e + b * axisParameter
|
|
109
|
+
if (rayParameter < 0) {
|
|
110
|
+
return -d
|
|
111
|
+
}
|
|
112
|
+
return axisParameter
|
|
113
|
+
}
|
|
114
|
+
|
|
57
115
|
export {
|
|
58
116
|
ARROW_COLOR,
|
|
59
117
|
ARROW_HOVER_COLOR,
|
|
60
118
|
ARROW_SCALE,
|
|
119
|
+
createArrowHandleGeometry,
|
|
61
120
|
createArrowHitAreaGeometry,
|
|
62
121
|
createEndpointHitAreaGeometry,
|
|
63
122
|
createMoveCrossHandleGeometry,
|
|
64
123
|
createRotateArrowHandleGeometry,
|
|
65
124
|
createRotateArrowHitAreaGeometry,
|
|
125
|
+
HandleArrow,
|
|
126
|
+
type HandleArrowInputShape,
|
|
127
|
+
type HandleArrowPlacement,
|
|
128
|
+
type HandleArrowProps,
|
|
66
129
|
HIT_AREA_MARGIN,
|
|
67
130
|
InvisibleHandleHitArea,
|
|
68
131
|
NO_RAYCAST,
|
|
@@ -76,6 +139,20 @@ export { swallowNextClick } from './handles/use-handle-drag'
|
|
|
76
139
|
// its end). Upward trackers — wall / chimney height — stop at the cube.
|
|
77
140
|
const TRACKER_THROUGH = 0.12
|
|
78
141
|
|
|
142
|
+
type SceneApiForHandles = ReturnType<typeof createSceneApi>
|
|
143
|
+
type CenteredGuideDecoration = {
|
|
144
|
+
center?: (node: AnyNode, sceneApi: SceneApiForHandles) => readonly [number, number, number]
|
|
145
|
+
radius: (node: AnyNode, sceneApi: SceneApiForHandles) => number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function guideDecorationCenter(decoration: unknown, node: AnyNode, sceneApi: SceneApiForHandles) {
|
|
149
|
+
return (decoration as CenteredGuideDecoration | undefined)?.center?.(node, sceneApi)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function guideDecorationRadius(decoration: unknown, node: AnyNode, sceneApi: SceneApiForHandles) {
|
|
153
|
+
return (decoration as CenteredGuideDecoration).radius(node, sceneApi)
|
|
154
|
+
}
|
|
155
|
+
|
|
79
156
|
// Mirrors the formatter used by wall / fence measurement labels so all
|
|
80
157
|
// in-world dimension chips read consistently.
|
|
81
158
|
function formatDimension(value: number, unit: 'metric' | 'imperial'): string {
|
|
@@ -122,23 +199,23 @@ function DimensionLabel({
|
|
|
122
199
|
|
|
123
200
|
export function NodeArrowHandles() {
|
|
124
201
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
202
|
+
const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
|
|
125
203
|
const mode = useEditor((state) => state.mode)
|
|
126
204
|
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
|
127
|
-
const movingNode =
|
|
128
|
-
const placementDragMode = useEditor((state) => state.placementDragMode)
|
|
205
|
+
const movingNode = useMovingNode()
|
|
129
206
|
// Endpoint / curve drags reshape the selected wall or fence; hide its
|
|
130
207
|
// resize arrows for the duration so they don't clutter (or get blocked
|
|
131
208
|
// by) the drag's own cursor + dimension overlays. Mirrors the same guard
|
|
132
209
|
// on the legacy wall handles (`WallMoveSideHandles`).
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
)
|
|
210
|
+
const endpointReshape = useEndpointReshape()
|
|
211
|
+
const isCurveReshape = useIsCurveReshape()
|
|
212
|
+
|
|
213
|
+
const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId
|
|
214
|
+
const rawNode = useScene((state) => {
|
|
215
|
+
if (!selectedId) return null
|
|
216
|
+
const selectedNode = state.nodes[selectedId as AnyNodeId]
|
|
217
|
+
return selectedNode ? resolveDirectManipulationNode(selectedNode, state.nodes) : null
|
|
218
|
+
})
|
|
142
219
|
|
|
143
220
|
// Merge any live drag override so the arrows themselves (positions,
|
|
144
221
|
// ring decorations) track the in-flight drag instead of freezing at
|
|
@@ -151,26 +228,37 @@ export function NodeArrowHandles() {
|
|
|
151
228
|
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
|
|
152
229
|
[rawNode, liveOverride],
|
|
153
230
|
)
|
|
154
|
-
const isOwnPressDragMove =
|
|
155
|
-
placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
|
|
156
|
-
|
|
157
231
|
const def = node ? nodeRegistry.get(node.type) : null
|
|
232
|
+
const descriptorSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
158
233
|
const descriptors = useMemo(() => {
|
|
159
234
|
if (!(node && def?.handles)) return null
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
235
|
+
const all =
|
|
236
|
+
typeof def.handles === 'function'
|
|
237
|
+
? def.handles(node as never)
|
|
238
|
+
: (def.handles as HandleDescriptor[])
|
|
239
|
+
// The whole-node move-cross gizmo is gone: moving is now click-to-move on
|
|
240
|
+
// the selected node body (see selection-manager). Drop both flavours — the
|
|
241
|
+
// `translate` ground cross (column/roof/shelf/spawn) and the `tap-action`
|
|
242
|
+
// `move-cross` (item/door/window/elevator/stair) — keep rotate/resize.
|
|
243
|
+
return all.filter(
|
|
244
|
+
(d) =>
|
|
245
|
+
d.kind !== 'translate' &&
|
|
246
|
+
!('shape' in d && d.shape === 'move-cross') &&
|
|
247
|
+
(d.kind !== 'linear-resize' || d.visible?.(node as never, descriptorSceneApi) !== false),
|
|
248
|
+
)
|
|
249
|
+
}, [node, def, descriptorSceneApi])
|
|
164
250
|
|
|
165
251
|
const shouldRender =
|
|
166
252
|
Boolean(node && descriptors?.length) &&
|
|
167
253
|
!isFloorplanHovered &&
|
|
168
254
|
mode !== 'delete' &&
|
|
169
|
-
(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
!
|
|
255
|
+
// Any whole-node move (placement or press-drag) hides the rig: the item is
|
|
256
|
+
// following the cursor, so its rotate/resize handles would only clutter and
|
|
257
|
+
// draw stray selection rays. The active handle-drag scope (resize/rotate)
|
|
258
|
+
// sets `activeHandleDrag`, not `movingNode`, so those are unaffected.
|
|
259
|
+
!movingNode &&
|
|
260
|
+
!endpointReshape &&
|
|
261
|
+
!isCurveReshape
|
|
174
262
|
|
|
175
263
|
if (!shouldRender || !node || !descriptors) return null
|
|
176
264
|
// Key by the selected node id so switching selection REMOUNTS the rig.
|
|
@@ -275,14 +363,32 @@ function NodeArrowHandlesForNode({
|
|
|
275
363
|
// exclusion the wall arrow also goes without.
|
|
276
364
|
|
|
277
365
|
useFrame(() => {
|
|
366
|
+
if (innerRef.current && innerRide && portalObject) {
|
|
367
|
+
// Grandparent mode: pose the rig by mapping the node's WORLD pose
|
|
368
|
+
// into the portal target's frame. Copying the parent + node
|
|
369
|
+
// registry poses (the previous approach) assumed the node mesh is
|
|
370
|
+
// a DIRECT child of the parent's registered object — roof-hosted
|
|
371
|
+
// openings break that with an intermediate face-frame group, which
|
|
372
|
+
// the world-relative mapping absorbs for free. For wall children
|
|
373
|
+
// the result is identical (portal⁻¹ ∘ node = wall.local ∘ node.local).
|
|
374
|
+
if (outerRef.current) {
|
|
375
|
+
outerRef.current.position.set(0, 0, 0)
|
|
376
|
+
outerRef.current.quaternion.identity()
|
|
377
|
+
}
|
|
378
|
+
portalObject.updateWorldMatrix(true, false)
|
|
379
|
+
innerRide.updateWorldMatrix(true, false)
|
|
380
|
+
_rigRelative.copy(portalObject.matrixWorld).invert().multiply(innerRide.matrixWorld)
|
|
381
|
+
_rigRelative.decompose(
|
|
382
|
+
innerRef.current.position,
|
|
383
|
+
innerRef.current.quaternion,
|
|
384
|
+
_rigScratchScale,
|
|
385
|
+
)
|
|
386
|
+
return
|
|
387
|
+
}
|
|
278
388
|
if (outerRef.current && outerRide) {
|
|
279
389
|
outerRef.current.position.copy(outerRide.position)
|
|
280
390
|
outerRef.current.quaternion.copy(outerRide.quaternion)
|
|
281
391
|
}
|
|
282
|
-
if (innerRef.current && innerRide) {
|
|
283
|
-
innerRef.current.position.copy(innerRide.position)
|
|
284
|
-
innerRef.current.quaternion.copy(innerRide.quaternion)
|
|
285
|
-
}
|
|
286
392
|
})
|
|
287
393
|
|
|
288
394
|
// Active-drag tracking. When a handle starts dragging, it claims its
|
|
@@ -299,6 +405,21 @@ function NodeArrowHandlesForNode({
|
|
|
299
405
|
// hook count between renders and trip React's rules-of-hooks check.
|
|
300
406
|
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
|
301
407
|
const [preDragNode, setPreDragNode] = useState<AnyNode | null>(null)
|
|
408
|
+
// Latch groups currently toggled open. A `latch` cube descriptor flips its
|
|
409
|
+
// group here on click; arrows tagged with a `latchGroup` only render while
|
|
410
|
+
// their group is in this set. Local to this mount, so it resets on deselect
|
|
411
|
+
// (the rig remounts per selection — see the `key` on NodeArrowHandlesForNode).
|
|
412
|
+
const [openLatchGroups, setOpenLatchGroups] = useState<ReadonlySet<string>>(() => new Set())
|
|
413
|
+
const toggleLatchGroup = useMemo(
|
|
414
|
+
() => (group: string) =>
|
|
415
|
+
setOpenLatchGroups((prev) => {
|
|
416
|
+
const next = new Set(prev)
|
|
417
|
+
if (next.has(group)) next.delete(group)
|
|
418
|
+
else next.add(group)
|
|
419
|
+
return next
|
|
420
|
+
}),
|
|
421
|
+
[],
|
|
422
|
+
)
|
|
302
423
|
const dragControls = useMemo<HandleDragControls>(
|
|
303
424
|
() => ({
|
|
304
425
|
onStart: (index: number, snapshot: AnyNode) => {
|
|
@@ -328,25 +449,48 @@ function NodeArrowHandlesForNode({
|
|
|
328
449
|
// resize that re-centres the mesh) must NOT fire for the non-active arrows
|
|
329
450
|
// here, or they'd lag behind the moving item.
|
|
330
451
|
const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate'
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
452
|
+
// While a rotate gizmo is mid-drag, drop the opposite-side move cross: you
|
|
453
|
+
// can't move and rotate at once, so it only clutters the rotation.
|
|
454
|
+
const activeDescriptor = activeIndex !== null ? descriptors[activeIndex] : undefined
|
|
455
|
+
const activeIsRotate =
|
|
456
|
+
!!activeDescriptor && 'shape' in activeDescriptor && activeDescriptor.shape === 'rotate'
|
|
457
|
+
|
|
458
|
+
const arrows = descriptors.map((descriptor, index) => {
|
|
459
|
+
if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null
|
|
460
|
+
// A `latch` cube toggles its group's visibility; render it always.
|
|
461
|
+
if (descriptor.kind === 'latch') {
|
|
462
|
+
return (
|
|
463
|
+
<LatchCube
|
|
464
|
+
descriptor={descriptor}
|
|
465
|
+
key={index}
|
|
466
|
+
node={node}
|
|
467
|
+
onToggle={toggleLatchGroup}
|
|
468
|
+
open={openLatchGroups.has(descriptor.group)}
|
|
469
|
+
/>
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
// Arrows tagged with a latch group stay hidden until that group is open.
|
|
473
|
+
const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined
|
|
474
|
+
if (latchGroup && !openLatchGroups.has(latchGroup)) return null
|
|
475
|
+
return (
|
|
476
|
+
<ArrowHandle
|
|
477
|
+
activeIndex={activeIndex}
|
|
478
|
+
descriptor={descriptor}
|
|
479
|
+
dragControls={dragControls}
|
|
480
|
+
handleIndex={index}
|
|
481
|
+
// Descriptors come from a per-node-kind static list, so index is a
|
|
482
|
+
// stable identity within this node's selection cycle.
|
|
483
|
+
key={index}
|
|
484
|
+
liveNode={node}
|
|
485
|
+
preDragNode={preDragNode}
|
|
486
|
+
rideObject={arrowFrame}
|
|
487
|
+
suppressFreeze={activeIsTranslate}
|
|
488
|
+
/>
|
|
489
|
+
)
|
|
490
|
+
})
|
|
347
491
|
|
|
348
492
|
return createPortal(
|
|
349
|
-
<group ref={outerRef}>
|
|
493
|
+
<group ref={outerRef} userData={MEASUREMENT_SURFACE_EXCLUDE_USER_DATA}>
|
|
350
494
|
{innerRideId !== null ? <group ref={innerRef}>{arrows}</group> : arrows}
|
|
351
495
|
</group>,
|
|
352
496
|
portalObject,
|
|
@@ -443,17 +587,6 @@ function ArrowHandle({
|
|
|
443
587
|
/>
|
|
444
588
|
)
|
|
445
589
|
}
|
|
446
|
-
if (descriptor.kind === 'translate') {
|
|
447
|
-
return (
|
|
448
|
-
<TranslateArrow
|
|
449
|
-
descriptor={descriptor}
|
|
450
|
-
dragControls={dragControls}
|
|
451
|
-
handleIndex={handleIndex}
|
|
452
|
-
node={placementNode}
|
|
453
|
-
rideObject={rideObject}
|
|
454
|
-
/>
|
|
455
|
-
)
|
|
456
|
-
}
|
|
457
590
|
if (descriptor.kind === 'tap-action') {
|
|
458
591
|
// Tap-action handles (fence side-move arrows, corner pickers) aren't
|
|
459
592
|
// resize handles, so the freeze-at-pre-drag mechanism — which only
|
|
@@ -524,6 +657,9 @@ function LinearArrow({
|
|
|
524
657
|
// floating dimension pill (via `activeHandleDrag`) and its own in-world
|
|
525
658
|
// chip is suppressed — matches the wall height handle.
|
|
526
659
|
const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined
|
|
660
|
+
// Optional per-tick feedback hook (doors/windows publish proximity/sill guides
|
|
661
|
+
// for the edge being resized); cleared when the drag ends.
|
|
662
|
+
const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined
|
|
527
663
|
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
528
664
|
const basePosition = descriptor.placement.position(node, placementSceneApi)
|
|
529
665
|
// `freezeOffset` (in node-local frame) cancels the mesh's `position`
|
|
@@ -555,34 +691,31 @@ function LinearArrow({
|
|
|
555
691
|
rideObject,
|
|
556
692
|
setIsDragging,
|
|
557
693
|
onStart: ({
|
|
558
|
-
camera: dragCamera,
|
|
559
694
|
event,
|
|
695
|
+
getPointerRay,
|
|
560
696
|
initialNode,
|
|
561
|
-
intersectPlane,
|
|
562
697
|
nodeId,
|
|
563
698
|
rideObject: dragRideObject,
|
|
564
699
|
sceneApi,
|
|
565
700
|
}) => {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin)
|
|
701
|
+
dragRideObject.matrixWorld.decompose(_resizePositionW, _resizeQuaternion, _resizeScale)
|
|
702
|
+
_resizeOriginW.set(...position).applyMatrix4(dragRideObject.matrixWorld)
|
|
703
|
+
axisVector(descriptor.axis, _resizeAxisW).applyQuaternion(_resizeQuaternion).normalize()
|
|
704
|
+
const localToWorldScale = axisScale(descriptor.axis, _resizeScale)
|
|
705
|
+
if (Math.abs(localToWorldScale) < 1e-6 || _resizeAxisW.lengthSq() === 0) return null
|
|
572
706
|
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
707
|
+
const initialPointer =
|
|
708
|
+
closestAxisParameterToRay(
|
|
709
|
+
_resizeOriginW,
|
|
710
|
+
_resizeAxisW,
|
|
711
|
+
getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, _resizeRay),
|
|
712
|
+
) / localToWorldScale
|
|
578
713
|
|
|
579
714
|
const overrideId =
|
|
580
715
|
(descriptor.kind === 'linear-resize'
|
|
581
716
|
? descriptor.overrideTarget?.(initialNode as never, sceneApi)
|
|
582
717
|
: undefined) ?? nodeId
|
|
583
718
|
const initialValue = descriptor.currentValue(initialNode)
|
|
584
|
-
const initialPointer =
|
|
585
|
-
descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z
|
|
586
719
|
const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
|
|
587
720
|
const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
|
|
588
721
|
const gridSnapStep =
|
|
@@ -598,36 +731,54 @@ function LinearArrow({
|
|
|
598
731
|
? 1
|
|
599
732
|
: -1
|
|
600
733
|
|
|
734
|
+
// Last value an emitted resize tick fired at — a new tick fires only
|
|
735
|
+
// when the (snapped + clamped) value actually changes, so the cue
|
|
736
|
+
// tracks real size steps instead of every sub-pixel pointer jitter.
|
|
737
|
+
let lastTickValue = initialValue
|
|
738
|
+
|
|
601
739
|
return {
|
|
602
740
|
overrideId,
|
|
741
|
+
commit:
|
|
742
|
+
descriptor.kind === 'linear-resize' && descriptor.commit
|
|
743
|
+
? (patch) => descriptor.commit?.(initialNode, patch, sceneApi)
|
|
744
|
+
: undefined,
|
|
603
745
|
onBegin: () => {
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
746
|
+
// Always claim the handle-drag scope so the HUD knows a resize is the
|
|
747
|
+
// active interaction (keeps the idle select hints off-screen). The
|
|
748
|
+
// dimension-pill handles carry their `measureLabel`; plain resize
|
|
749
|
+
// arrows use the generic label.
|
|
750
|
+
useInteractionScope.getState().begin({
|
|
751
|
+
kind: 'handle-drag',
|
|
752
|
+
nodeId,
|
|
753
|
+
handle: measureLabel ?? RESIZE_HANDLE_DRAG_LABEL,
|
|
754
|
+
})
|
|
607
755
|
},
|
|
608
756
|
onEnd: () => {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
757
|
+
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
|
758
|
+
if (onDrag) useOpeningGuides.getState().clear()
|
|
612
759
|
},
|
|
613
|
-
move: ({ event: moveEvent,
|
|
614
|
-
const intersection = new Vector3()
|
|
615
|
-
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, intersection)) {
|
|
616
|
-
return null
|
|
617
|
-
}
|
|
618
|
-
const intersectionLocal = intersection.clone().applyMatrix4(initialFrameInverse)
|
|
760
|
+
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
|
|
619
761
|
const currentPointer =
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
762
|
+
closestAxisParameterToRay(
|
|
763
|
+
_resizeOriginW,
|
|
764
|
+
_resizeAxisW,
|
|
765
|
+
getMovePointerRay(moveEvent.clientX, moveEvent.clientY, _resizeRay),
|
|
766
|
+
) / localToWorldScale
|
|
625
767
|
const delta = currentPointer - initialPointer
|
|
626
768
|
const rawNext = initialValue + delta * factor
|
|
627
769
|
const snappedNext =
|
|
628
|
-
gridSnapStep && gridSnapStep > 0
|
|
770
|
+
!moveEvent.shiftKey && gridSnapStep && gridSnapStep > 0
|
|
771
|
+
? snapScalar(rawNext, gridSnapStep)
|
|
772
|
+
: rawNext
|
|
629
773
|
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
|
|
630
|
-
|
|
774
|
+
if (next !== lastTickValue) {
|
|
775
|
+
lastTickValue = next
|
|
776
|
+
sfxEmitter.emit('sfx:resize')
|
|
777
|
+
}
|
|
778
|
+
const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
|
|
779
|
+
// Let the kind publish live guides for the edge being resized.
|
|
780
|
+
onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
|
|
781
|
+
return patch
|
|
631
782
|
},
|
|
632
783
|
}
|
|
633
784
|
},
|
|
@@ -637,10 +788,19 @@ function LinearArrow({
|
|
|
637
788
|
// X+Z rotation chain matching DoorHeightArrowHandle. When the handle
|
|
638
789
|
// sits below the node (placement Y < 0, e.g. window bottom arrow),
|
|
639
790
|
// flip the Z rotation so the chevron points outward (downward).
|
|
791
|
+
//
|
|
792
|
+
// For axis === 'x' with `faceNormal` (wall-mounted opening width arrows),
|
|
793
|
+
// roll the blade 90° about its own pointing (X) axis so it stands up from
|
|
794
|
+
// the horizontal XZ plane into the node's facing plane (XY = the wall
|
|
795
|
+
// face) — otherwise the blade is seen edge-on from the front.
|
|
796
|
+
const faceNormalX =
|
|
797
|
+
descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal === true
|
|
640
798
|
const innerRotation: [number, number, number] =
|
|
641
799
|
descriptor.axis === 'y'
|
|
642
800
|
? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
|
|
643
|
-
:
|
|
801
|
+
: faceNormalX
|
|
802
|
+
? [Math.PI / 2, 0, 0]
|
|
803
|
+
: [0, 0, 0]
|
|
644
804
|
|
|
645
805
|
// Optional guide decoration — linear handles use it for curved-stair
|
|
646
806
|
// width / inner-radius rings; radial handles use it for the column's
|
|
@@ -686,7 +846,8 @@ function LinearArrow({
|
|
|
686
846
|
<>
|
|
687
847
|
{showDecoration && decoration ? (
|
|
688
848
|
<GuideRing
|
|
689
|
-
|
|
849
|
+
center={guideDecorationCenter(decoration, node, placementSceneApi)}
|
|
850
|
+
radius={guideDecorationRadius(decoration, node, placementSceneApi)}
|
|
690
851
|
y={decoration.y?.(node as never) ?? 0}
|
|
691
852
|
/>
|
|
692
853
|
) : null}
|
|
@@ -714,7 +875,8 @@ function LinearArrow({
|
|
|
714
875
|
<>
|
|
715
876
|
{showDecoration && decoration ? (
|
|
716
877
|
<GuideRing
|
|
717
|
-
|
|
878
|
+
center={guideDecorationCenter(decoration, node, placementSceneApi)}
|
|
879
|
+
radius={guideDecorationRadius(decoration, node, placementSceneApi)}
|
|
718
880
|
y={decoration.y?.(node as never) ?? 0}
|
|
719
881
|
/>
|
|
720
882
|
) : null}
|
|
@@ -726,6 +888,7 @@ function LinearArrow({
|
|
|
726
888
|
onPointerDown={activate}
|
|
727
889
|
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
|
728
890
|
shape="chevron"
|
|
891
|
+
thin
|
|
729
892
|
>
|
|
730
893
|
{showLabel ? <DimensionLabel position={[0, 0.22, 0]} text={labelText} /> : null}
|
|
731
894
|
</HandleArrow>
|
|
@@ -737,7 +900,15 @@ function LinearArrow({
|
|
|
737
900
|
// e.g. the curved-stair width arrow traces the outer rim, the inner-radius
|
|
738
901
|
// arrow traces the central pillar. Floats at node-local `y`, lies in the
|
|
739
902
|
// XZ plane.
|
|
740
|
-
export function GuideRing({
|
|
903
|
+
export function GuideRing({
|
|
904
|
+
center,
|
|
905
|
+
radius,
|
|
906
|
+
y,
|
|
907
|
+
}: {
|
|
908
|
+
center?: readonly [number, number, number]
|
|
909
|
+
radius: number
|
|
910
|
+
y: number
|
|
911
|
+
}) {
|
|
741
912
|
const safeRadius = Math.max(radius, 0.01)
|
|
742
913
|
const ringGeometry = useMemo(() => {
|
|
743
914
|
const inner = Math.max(safeRadius - 0.015, 0.001)
|
|
@@ -764,7 +935,7 @@ export function GuideRing({ radius, y }: { radius: number; y: number }) {
|
|
|
764
935
|
frustumCulled={false}
|
|
765
936
|
geometry={ringGeometry}
|
|
766
937
|
material={ringMaterial}
|
|
767
|
-
position={[0, y, 0]}
|
|
938
|
+
position={center ? [center[0], y, center[2]] : [0, y, 0]}
|
|
768
939
|
renderOrder={1009}
|
|
769
940
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
770
941
|
/>
|
|
@@ -873,11 +1044,13 @@ function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) {
|
|
|
873
1044
|
// from the pivot; the fill is pulled inside it so it reads as the handle
|
|
874
1045
|
// swinging around rather than overlapping the icon.
|
|
875
1046
|
function RotationWedge({
|
|
1047
|
+
center,
|
|
876
1048
|
delta,
|
|
877
1049
|
handleAngle,
|
|
878
1050
|
orbitRadius,
|
|
879
1051
|
y,
|
|
880
1052
|
}: {
|
|
1053
|
+
center?: readonly [number, number, number]
|
|
881
1054
|
delta: number
|
|
882
1055
|
handleAngle: number
|
|
883
1056
|
orbitRadius: number
|
|
@@ -917,7 +1090,7 @@ function RotationWedge({
|
|
|
917
1090
|
]
|
|
918
1091
|
|
|
919
1092
|
return (
|
|
920
|
-
<group position={[0, y, 0]}>
|
|
1093
|
+
<group position={center ? [center[0], y, center[2]] : [0, y, 0]}>
|
|
921
1094
|
<mesh
|
|
922
1095
|
frustumCulled={false}
|
|
923
1096
|
geometry={fill}
|
|
@@ -971,6 +1144,8 @@ function ArcArrow({
|
|
|
971
1144
|
// corner) render a two-headed curved arrow; everything else (stair
|
|
972
1145
|
// sweep, etc.) keeps the chevron.
|
|
973
1146
|
const isRotateShape = descriptor.shape === 'rotate'
|
|
1147
|
+
const activeRotateNodeId = useDirectManipulationFeedback((state) => state.activeRotateNodeId)
|
|
1148
|
+
const isDirectRotating = isRotateShape && activeRotateNodeId === liveNode.id
|
|
974
1149
|
// 'node-normal' spins the node about its local +Z (a wall item flat against
|
|
975
1150
|
// its wall) instead of yaw about world-Y. The drag plane and the icon both
|
|
976
1151
|
// tilt into that plane, and the horizontal-only wedge/ring readout is
|
|
@@ -1010,7 +1185,8 @@ function ArcArrow({
|
|
|
1010
1185
|
// arrow is hovered or dragging. Same recipe as the linear / radial
|
|
1011
1186
|
// decoration path.
|
|
1012
1187
|
const decoration = descriptor.decoration
|
|
1013
|
-
const
|
|
1188
|
+
const decorationCenter = guideDecorationCenter(decoration, node, placementSceneApi)
|
|
1189
|
+
const showDecoration = Boolean(decoration) && (isHovered || isDragging || isDirectRotating)
|
|
1014
1190
|
|
|
1015
1191
|
const activate = useHandleDrag({
|
|
1016
1192
|
kind: 'drag',
|
|
@@ -1062,8 +1238,23 @@ function ArcArrow({
|
|
|
1062
1238
|
}
|
|
1063
1239
|
const initialAngle = angleOf(hitWorld)
|
|
1064
1240
|
|
|
1241
|
+
// Advertise the rotate interaction so the contextual HUD can surface the
|
|
1242
|
+
// Shift = free-rotation toggle (the angle-step bypass below). Resize
|
|
1243
|
+
// handles route a measurement label here; rotate gets a sentinel label so
|
|
1244
|
+
// the HUD shows the rotate hint, not a dimension pill.
|
|
1245
|
+
if (isRotateShape) {
|
|
1246
|
+
useInteractionScope
|
|
1247
|
+
.getState()
|
|
1248
|
+
.begin({ kind: 'handle-drag', nodeId: node.id, handle: ROTATE_HANDLE_DRAG_LABEL })
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1065
1251
|
return {
|
|
1066
|
-
onEnd: () =>
|
|
1252
|
+
onEnd: () => {
|
|
1253
|
+
setRotationDelta(null)
|
|
1254
|
+
if (isRotateShape) {
|
|
1255
|
+
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
|
|
1256
|
+
}
|
|
1257
|
+
},
|
|
1067
1258
|
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
|
1068
1259
|
const hit = new Vector3()
|
|
1069
1260
|
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
|
|
@@ -1072,9 +1263,8 @@ function ArcArrow({
|
|
|
1072
1263
|
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
1073
1264
|
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
1074
1265
|
|
|
1075
|
-
if (moveEvent.shiftKey && descriptor.shape === 'rotate') {
|
|
1076
|
-
|
|
1077
|
-
delta = Math.round(delta / step) * step
|
|
1266
|
+
if (!moveEvent.shiftKey && descriptor.shape === 'rotate') {
|
|
1267
|
+
delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
|
1078
1268
|
}
|
|
1079
1269
|
|
|
1080
1270
|
if (isRotateShape && !isNodeNormalRot) {
|
|
@@ -1086,18 +1276,12 @@ function ArcArrow({
|
|
|
1086
1276
|
},
|
|
1087
1277
|
})
|
|
1088
1278
|
|
|
1089
|
-
// Suppress "declared but unused" for `liveNode` — ArcArrow's apply
|
|
1090
|
-
// operates entirely on `initialNode` (snapshot taken inside activate)
|
|
1091
|
-
// and `delta` (live cursor angle), so the live store node doesn't
|
|
1092
|
-
// appear in the rotation pipeline. The prop is still required because
|
|
1093
|
-
// ArrowHandle passes it uniformly to every variant.
|
|
1094
|
-
void liveNode
|
|
1095
|
-
|
|
1096
1279
|
return (
|
|
1097
1280
|
<>
|
|
1098
1281
|
{showDecoration && decoration ? (
|
|
1099
1282
|
<GuideRing
|
|
1100
|
-
|
|
1283
|
+
center={guideDecorationCenter(decoration, node, placementSceneApi)}
|
|
1284
|
+
radius={guideDecorationRadius(decoration, node, placementSceneApi)}
|
|
1101
1285
|
y={decoration.y?.(node as never) ?? 0}
|
|
1102
1286
|
/>
|
|
1103
1287
|
) : null}
|
|
@@ -1107,16 +1291,23 @@ function ArcArrow({
|
|
|
1107
1291
|
ring on any surface — flat ground or a pitched roof. */}
|
|
1108
1292
|
{rotationDelta !== null ? (
|
|
1109
1293
|
<RotationWedge
|
|
1294
|
+
center={decorationCenter}
|
|
1110
1295
|
delta={rotationDelta}
|
|
1111
|
-
handleAngle={Math.atan2(
|
|
1112
|
-
|
|
1296
|
+
handleAngle={Math.atan2(
|
|
1297
|
+
position[2] - (decorationCenter?.[2] ?? 0),
|
|
1298
|
+
position[0] - (decorationCenter?.[0] ?? 0),
|
|
1299
|
+
)}
|
|
1300
|
+
orbitRadius={Math.hypot(
|
|
1301
|
+
position[0] - (decorationCenter?.[0] ?? 0),
|
|
1302
|
+
position[2] - (decorationCenter?.[2] ?? 0),
|
|
1303
|
+
)}
|
|
1113
1304
|
y={decoration?.y?.(node as never) ?? 0}
|
|
1114
1305
|
/>
|
|
1115
1306
|
) : null}
|
|
1116
1307
|
<HandleArrow
|
|
1117
1308
|
activeCursor={dragCursor}
|
|
1118
1309
|
cursor={hoverCursor}
|
|
1119
|
-
hover={isHovered}
|
|
1310
|
+
hover={isHovered || isDirectRotating}
|
|
1120
1311
|
onHoverChange={setIsHovered}
|
|
1121
1312
|
onPointerDown={activate}
|
|
1122
1313
|
placement={{
|
|
@@ -1127,69 +1318,12 @@ function ArcArrow({
|
|
|
1127
1318
|
baseScale,
|
|
1128
1319
|
}}
|
|
1129
1320
|
shape={isRotateShape ? 'curved-arrow' : 'chevron'}
|
|
1321
|
+
thin
|
|
1130
1322
|
/>
|
|
1131
1323
|
</>
|
|
1132
1324
|
)
|
|
1133
1325
|
}
|
|
1134
1326
|
|
|
1135
|
-
// Free ground-plane move gizmo (the 4-way cross). Press-drag-release: raycast
|
|
1136
|
-
// the horizontal plane at the node's base, convert the hit into the node's
|
|
1137
|
-
// parent-local frame, add the delta to the node's drag-start position, grid-
|
|
1138
|
-
// snap via the descriptor's `snapExtents`, and publish to `useLiveNodeOverrides`
|
|
1139
|
-
// each move — committing one write to the store on release. The override stays
|
|
1140
|
-
// at base Y; `<FloorElevationSystem>` reads that effective node and owns the
|
|
1141
|
-
// presentation-only slab lift so the handle path shares the menu-move stacking
|
|
1142
|
-
// contract without storing lifted positions.
|
|
1143
|
-
function TranslateArrow({
|
|
1144
|
-
descriptor,
|
|
1145
|
-
node,
|
|
1146
|
-
}: {
|
|
1147
|
-
descriptor: TranslateHandle<AnyNode>
|
|
1148
|
-
node: AnyNode
|
|
1149
|
-
handleIndex: number
|
|
1150
|
-
dragControls: HandleDragControls
|
|
1151
|
-
rideObject: Object3D
|
|
1152
|
-
}) {
|
|
1153
|
-
const [isHovered, setIsHovered] = useState(false)
|
|
1154
|
-
const { camera } = useThree()
|
|
1155
|
-
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
1156
|
-
const baseScale = zoom * ARROW_SCALE
|
|
1157
|
-
|
|
1158
|
-
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
1159
|
-
const position = descriptor.placement.position(node, placementSceneApi)
|
|
1160
|
-
const cursor: Cursor = 'move'
|
|
1161
|
-
// 'node-normal' constrains the drag to the wall face (plane ⟂ the node's
|
|
1162
|
-
// local +Z). Its cross icon stands up into that plane (tilt about X).
|
|
1163
|
-
const isWallPlane = descriptor.plane === 'node-normal'
|
|
1164
|
-
|
|
1165
|
-
// Same function as the floating action menu's Move button
|
|
1166
|
-
// (`floating-action-menu.tsx` → `handleMove`): arm the registry move tool,
|
|
1167
|
-
// which owns the cursor follow, grid + alignment snap, green guide overlay,
|
|
1168
|
-
// and click-to-commit. Routes both entry points through one path so the
|
|
1169
|
-
// 3D translate gizmo and the floating Move button behave identically.
|
|
1170
|
-
const activate = (event: ThreeEvent<PointerEvent>) => {
|
|
1171
|
-
event.stopPropagation()
|
|
1172
|
-
sfxEmitter.emit('sfx:item-pick')
|
|
1173
|
-
useEditor.getState().setMovingNode(node as never)
|
|
1174
|
-
useViewer.getState().setSelection({ selectedIds: [] })
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
// The cross is built flat in the XZ plane. On a wall, tilt it up about X so
|
|
1178
|
-
// it lies in the item-local XY plane (= the wall face).
|
|
1179
|
-
const iconRotation: [number, number, number] = isWallPlane ? [Math.PI / 2, 0, 0] : [0, 0, 0]
|
|
1180
|
-
|
|
1181
|
-
return (
|
|
1182
|
-
<HandleArrow
|
|
1183
|
-
cursor={cursor}
|
|
1184
|
-
hover={isHovered}
|
|
1185
|
-
onHoverChange={setIsHovered}
|
|
1186
|
-
onPointerDown={activate}
|
|
1187
|
-
placement={{ position, rotation: iconRotation, baseScale }}
|
|
1188
|
-
shape="cross"
|
|
1189
|
-
/>
|
|
1190
|
-
)
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
1327
|
// Click-to-engage affordance — no drag plumbing, just a click target. The
|
|
1194
1328
|
// descriptor's `onActivate` receives sceneApi + editorApi so it can engage
|
|
1195
1329
|
// move tools, endpoint drags, or any other editor-state transition without
|
|
@@ -1210,6 +1344,7 @@ function TapActionArrow({
|
|
|
1210
1344
|
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
|
1211
1345
|
const shape = descriptor.shape ?? 'arrow'
|
|
1212
1346
|
const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
|
|
1347
|
+
const round = descriptor.round ?? false
|
|
1213
1348
|
|
|
1214
1349
|
const onActivate = useHandleDrag({
|
|
1215
1350
|
kind: 'tap',
|
|
@@ -1230,20 +1365,75 @@ function TapActionArrow({
|
|
|
1230
1365
|
onHoverChange={setIsHovered}
|
|
1231
1366
|
onPointerDown={onActivate}
|
|
1232
1367
|
position={position}
|
|
1368
|
+
round={round}
|
|
1233
1369
|
/>
|
|
1234
1370
|
)
|
|
1235
1371
|
}
|
|
1236
1372
|
|
|
1237
|
-
// Default 'arrow' shape — the standard chevron.
|
|
1238
1373
|
const baseScale = zoom * ARROW_SCALE
|
|
1374
|
+
// A `move-cross` with `plane: 'node-normal'` stands up into the node's facing
|
|
1375
|
+
// plane (a wall face) like the door / window / wall-item move grips; other
|
|
1376
|
+
// tap-actions keep their in-plane `rotationY`.
|
|
1377
|
+
const rotation: [number, number, number] =
|
|
1378
|
+
descriptor.plane === 'node-normal' ? NODE_NORMAL_TILT : [0, rotationY, 0]
|
|
1239
1379
|
return (
|
|
1240
1380
|
<HandleArrow
|
|
1241
1381
|
cursor={cursor}
|
|
1242
1382
|
hover={isHovered}
|
|
1243
1383
|
onHoverChange={setIsHovered}
|
|
1244
1384
|
onPointerDown={onActivate}
|
|
1385
|
+
placement={{ position, rotation, baseScale }}
|
|
1386
|
+
shape={shape === 'move-cross' ? 'move-cross' : 'chevron'}
|
|
1387
|
+
thin
|
|
1388
|
+
/>
|
|
1389
|
+
)
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Click-to-latch cube. A persistent grip (the `tracker` cube) that toggles
|
|
1393
|
+
// the visibility of every arrow tagged with its `latchGroup` on click. Sized
|
|
1394
|
+
// to match the duct selection cube (`baseScale = zoom`, full TRACKER_CUBE_SIZE)
|
|
1395
|
+
// so every latch grip reads the same across the app. Stays highlighted while
|
|
1396
|
+
// its group is open so the user can tell it's engaged.
|
|
1397
|
+
function LatchCube({
|
|
1398
|
+
descriptor,
|
|
1399
|
+
node,
|
|
1400
|
+
open,
|
|
1401
|
+
onToggle,
|
|
1402
|
+
}: {
|
|
1403
|
+
descriptor: LatchHandle<AnyNode>
|
|
1404
|
+
node: AnyNode
|
|
1405
|
+
open: boolean
|
|
1406
|
+
onToggle: (group: string) => void
|
|
1407
|
+
}) {
|
|
1408
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
1409
|
+
const { camera } = useThree()
|
|
1410
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
1411
|
+
const baseScale = zoom
|
|
1412
|
+
|
|
1413
|
+
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
1414
|
+
const position = descriptor.placement.position(node, placementSceneApi)
|
|
1415
|
+
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
|
1416
|
+
|
|
1417
|
+
// Route through the shared tap path so the cube click is swallowed before it
|
|
1418
|
+
// reaches the select tool — stops R3F propagation, suppresses box-select, and
|
|
1419
|
+
// eats the trailing DOM click that would otherwise select the host node.
|
|
1420
|
+
const onPointerDown = useHandleDrag({
|
|
1421
|
+
kind: 'tap',
|
|
1422
|
+
onTap: () => {
|
|
1423
|
+
setIsHovered(false)
|
|
1424
|
+
onToggle(descriptor.group)
|
|
1425
|
+
},
|
|
1426
|
+
})
|
|
1427
|
+
|
|
1428
|
+
return (
|
|
1429
|
+
<HandleArrow
|
|
1430
|
+
cursor="grab"
|
|
1431
|
+
hover={isHovered || open}
|
|
1432
|
+
hoverScale={1.15}
|
|
1433
|
+
onHoverChange={setIsHovered}
|
|
1434
|
+
onPointerDown={onPointerDown}
|
|
1245
1435
|
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
|
1246
|
-
shape="
|
|
1436
|
+
shape="tracker"
|
|
1247
1437
|
/>
|
|
1248
1438
|
)
|
|
1249
1439
|
}
|
|
@@ -1281,6 +1471,7 @@ function CornerPickerShape({
|
|
|
1281
1471
|
hover,
|
|
1282
1472
|
onHoverChange,
|
|
1283
1473
|
onPointerDown,
|
|
1474
|
+
round = false,
|
|
1284
1475
|
}: {
|
|
1285
1476
|
position: readonly [number, number, number]
|
|
1286
1477
|
height: number
|
|
@@ -1289,6 +1480,7 @@ function CornerPickerShape({
|
|
|
1289
1480
|
hover: boolean
|
|
1290
1481
|
onHoverChange: (hovered: boolean) => void
|
|
1291
1482
|
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
|
1483
|
+
round?: boolean
|
|
1292
1484
|
}) {
|
|
1293
1485
|
const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(height), [height])
|
|
1294
1486
|
useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
|
|
@@ -1366,10 +1558,11 @@ function CornerPickerShape({
|
|
|
1366
1558
|
onHoverChange={onHoverChange}
|
|
1367
1559
|
onPointerDown={onPointerDown}
|
|
1368
1560
|
placement={{ position: [0, 0, 0], baseScale }}
|
|
1561
|
+
round={round}
|
|
1369
1562
|
shape="corner-picker"
|
|
1370
1563
|
/>
|
|
1371
1564
|
<mesh material={ringMaterial} renderOrder={1002} scale={scale}>
|
|
1372
|
-
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
|
|
1565
|
+
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, round ? 32 : 6]} />
|
|
1373
1566
|
</mesh>
|
|
1374
1567
|
</group>
|
|
1375
1568
|
</>
|