@pascal-app/editor 0.7.0 → 0.9.1
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 -13
- package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
- package/src/components/editor/custom-camera-controls.tsx +755 -25
- package/src/components/editor/editor-layout-mobile.tsx +3 -3
- package/src/components/editor/editor-layout-v2.tsx +62 -54
- package/src/components/editor/export-manager.tsx +32 -3
- package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
- package/src/components/editor/first-person/build-collider-world.ts +203 -87
- package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
- package/src/components/editor/first-person-controls.tsx +836 -31
- package/src/components/editor/floating-action-menu.tsx +287 -196
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-panel.tsx +5837 -12006
- package/src/components/editor/grid.tsx +51 -17
- package/src/components/editor/group-move-handle.tsx +316 -0
- package/src/components/editor/group-rotate-handle.tsx +382 -0
- package/src/components/editor/group-transform-shared.test.ts +205 -0
- package/src/components/editor/group-transform-shared.ts +256 -0
- package/src/components/editor/handles/handle-arrow.tsx +523 -0
- package/src/components/editor/handles/use-handle-drag.ts +213 -0
- package/src/components/editor/index.tsx +234 -190
- package/src/components/editor/measurement-pill.tsx +75 -0
- package/src/components/editor/node-arrow-handles.tsx +1453 -0
- package/src/components/editor/selection-manager.tsx +435 -158
- package/src/components/editor/site-edge-labels.tsx +44 -20
- package/src/components/editor/slab-hole-highlights.tsx +466 -0
- package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
- package/src/components/editor/thumbnail-generator.tsx +27 -64
- package/src/components/editor/use-floorplan-background-placement.ts +122 -35
- package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
- package/src/components/editor/use-floorplan-scene-data.ts +14 -2
- package/src/components/editor/wall-measurement-label.tsx +11 -6
- package/src/components/editor/wall-move-side-handles.tsx +855 -0
- package/src/components/editor/wall-opening-highlights.tsx +181 -0
- package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
- package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
- package/src/components/editor-2d/svg-paths.ts +10 -2
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
- package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
- package/src/components/systems/roof/roof-edit-system.tsx +86 -12
- package/src/components/systems/stair/stair-edit-system.tsx +2 -2
- package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
- package/src/components/systems/zone/zone-system.tsx +0 -0
- package/src/components/tools/elevator/elevator-defaults.ts +8 -0
- package/src/components/tools/elevator/elevator-tool.tsx +349 -0
- package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
- package/src/components/tools/fence/fence-drafting.ts +64 -22
- package/src/components/tools/item/move-tool.tsx +40 -107
- package/src/components/tools/item/placement-math.test.ts +10 -0
- package/src/components/tools/item/placement-math.ts +33 -9
- package/src/components/tools/item/placement-strategies.ts +201 -25
- package/src/components/tools/item/placement-types.ts +10 -1
- package/src/components/tools/item/use-draft-node.ts +20 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
- package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
- package/src/components/tools/roof/roof-tool.tsx +108 -12
- package/src/components/tools/select/box-select-state.ts +77 -0
- package/src/components/tools/select/box-select-tool.tsx +324 -543
- package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
- package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
- package/src/components/tools/select/select-candidates.test.ts +109 -0
- package/src/components/tools/select/select-candidates.ts +85 -0
- package/src/components/tools/shared/affordance-dispatch.ts +30 -0
- package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
- package/src/components/tools/shared/floor-stack-preview.ts +25 -0
- package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
- package/src/components/tools/shared/placement-box-geometry.ts +123 -0
- package/src/components/tools/shared/placement-box.tsx +123 -0
- package/src/components/tools/shared/polygon-editor.tsx +617 -167
- package/src/components/tools/shared/segment-angle.ts +57 -1
- package/src/components/tools/site/site-boundary-editor.tsx +464 -12
- package/src/components/tools/site/site-flag-model.ts +18 -0
- package/src/components/tools/stair/stair-defaults.ts +1 -1
- package/src/components/tools/stair/stair-tool.tsx +269 -35
- package/src/components/tools/tool-manager.tsx +172 -56
- package/src/components/tools/wall/wall-drafting.ts +98 -98
- package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
- package/src/components/tools/zone/zone-tool.tsx +36 -11
- package/src/components/ui/action-menu/action-button.tsx +21 -1
- package/src/components/ui/action-menu/camera-actions.tsx +0 -0
- package/src/components/ui/action-menu/control-modes.tsx +5 -76
- package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
- package/src/components/ui/action-menu/index.tsx +33 -196
- package/src/components/ui/action-menu/structure-tools.tsx +13 -88
- package/src/components/ui/action-menu/view-toggles.tsx +262 -37
- package/src/components/ui/command-palette/editor-commands.tsx +24 -17
- package/src/components/ui/command-palette/index.tsx +6 -256
- package/src/components/ui/controls/material-paint-panel.tsx +83 -0
- package/src/components/ui/controls/material-picker.tsx +17 -35
- package/src/components/ui/controls/metric-control.tsx +33 -11
- package/src/components/ui/floating-level-selector.tsx +48 -11
- package/src/components/ui/helpers/helper-manager.tsx +21 -17
- package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
- package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
- package/src/components/ui/level-duplicate-dialog.tsx +5 -6
- package/src/components/ui/panels/node-display.ts +1 -0
- package/src/components/ui/panels/paint-panel.tsx +66 -41
- package/src/components/ui/panels/panel-manager.tsx +33 -74
- package/src/components/ui/panels/panel-wrapper.tsx +217 -12
- package/src/components/ui/panels/parametric-inspector.tsx +469 -0
- package/src/components/ui/panels/reference-panel.tsx +28 -13
- package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
- package/src/components/ui/primitives/number-input.tsx +1 -1
- package/src/components/ui/primitives/sidebar.tsx +0 -0
- package/src/components/ui/scene-loader.tsx +1 -3
- package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
- package/src/components/ui/sidebar/icon-rail.tsx +0 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
- package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
- package/src/components/ui/sidebar/tab-bar.tsx +61 -1
- package/src/components/ui/slider.tsx +1 -1
- package/src/components/viewer-overlay.tsx +169 -52
- package/src/components/viewer-zone-system.tsx +1 -1
- package/src/hooks/use-auto-save.ts +24 -3
- package/src/hooks/use-ceiling-events.ts +175 -0
- package/src/hooks/use-drag-action.ts +124 -0
- package/src/hooks/use-keyboard.ts +125 -62
- package/src/hooks/use-selection.ts +64 -0
- package/src/index.tsx +243 -4
- package/src/lib/constants.ts +6 -2
- package/src/lib/editor-api.ts +62 -0
- package/src/lib/elevator-support.ts +96 -0
- package/src/lib/floorplan/apply-alignment.ts +111 -0
- package/src/lib/floorplan/index.ts +7 -0
- package/src/lib/floorplan/selection-tool.ts +40 -0
- package/src/lib/floorplan/stairs.ts +12 -1
- package/src/lib/fresh-planar-placement.test.ts +102 -0
- package/src/lib/fresh-planar-placement.ts +63 -0
- package/src/lib/history.ts +2 -1
- package/src/lib/level-duplication.test.ts +0 -2
- package/src/lib/level-duplication.ts +55 -43
- package/src/lib/level-name.ts +11 -0
- package/src/lib/material-paint.ts +212 -20
- package/src/lib/placement-metadata.ts +29 -0
- package/src/lib/planar-cursor-placement.test.ts +43 -0
- package/src/lib/planar-cursor-placement.ts +42 -0
- package/src/lib/roof-duplication.ts +2 -2
- package/src/lib/scene-bounds.ts +1 -1
- package/src/lib/scene-clipboard.ts +267 -0
- package/src/lib/scene.ts +13 -4
- package/src/lib/sfx-bus.ts +25 -1
- package/src/lib/sfx-player.ts +75 -27
- package/src/lib/site-boundary.ts +1 -0
- package/src/lib/stair-duplication.ts +2 -2
- package/src/lib/stair-levels.test.ts +146 -0
- package/src/lib/stair-levels.ts +177 -0
- package/src/lib/world-grid-snap.ts +199 -0
- package/src/lib/zone-content.ts +130 -0
- package/src/store/use-alignment-guides.ts +21 -0
- package/src/store/use-editor.tsx +342 -91
- package/src/store/use-placement-preview.ts +31 -0
- package/src/store/use-wall-move-ghosts.ts +36 -0
- package/src/store/use-wall-snap-indicator.ts +32 -0
- package/tsconfig.json +2 -1
- package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
- package/src/components/feedback-dialog.tsx +0 -265
- package/src/components/pascal-radio.tsx +0 -280
- package/src/components/preview-button.tsx +0 -16
- package/src/components/tools/building/move-building-tool.tsx +0 -157
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
- package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
- package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
- package/src/components/tools/column/column-tool.tsx +0 -97
- package/src/components/tools/column/move-column-tool.tsx +0 -105
- package/src/components/tools/door/door-math.ts +0 -110
- package/src/components/tools/door/door-tool.tsx +0 -324
- package/src/components/tools/door/move-door-tool.tsx +0 -412
- package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
- package/src/components/tools/fence/fence-tool.tsx +0 -346
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
- package/src/components/tools/fence/move-fence-tool.tsx +0 -298
- package/src/components/tools/item/item-tool.tsx +0 -26
- package/src/components/tools/roof/move-roof-tool.tsx +0 -359
- package/src/components/tools/slab/move-slab-tool.tsx +0 -182
- package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
- package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
- package/src/components/tools/slab/slab-tool.tsx +0 -322
- package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
- package/src/components/tools/spawn/spawn-tool.tsx +0 -130
- package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
- package/src/components/tools/wall/move-wall-tool.tsx +0 -356
- package/src/components/tools/wall/wall-tool.tsx +0 -332
- package/src/components/tools/window/move-window-tool.tsx +0 -447
- package/src/components/tools/window/window-math.ts +0 -117
- package/src/components/tools/window/window-tool.tsx +0 -332
- package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
- package/src/components/ui/helpers/slab-helper.tsx +0 -20
- package/src/components/ui/helpers/wall-helper.tsx +0 -20
- package/src/components/ui/panels/ceiling-panel.tsx +0 -250
- package/src/components/ui/panels/column-panel.tsx +0 -715
- package/src/components/ui/panels/door-panel.tsx +0 -1292
- package/src/components/ui/panels/fence-panel.tsx +0 -227
- package/src/components/ui/panels/item-panel.tsx +0 -306
- package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
- package/src/components/ui/panels/roof-panel.tsx +0 -230
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
- package/src/components/ui/panels/slab-panel.tsx +0 -250
- package/src/components/ui/panels/spawn-panel.tsx +0 -155
- package/src/components/ui/panels/stair-panel.tsx +0 -525
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
- package/src/components/ui/panels/wall-panel.tsx +0 -177
- package/src/components/ui/panels/window-panel.tsx +0 -960
- package/src/components/ui/viewer-toolbar.tsx +0 -436
- package/src/contexts/presets-context.tsx +0 -121
- package/src/hooks/use-contextual-tools.ts +0 -61
|
@@ -0,0 +1,1453 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
type ArcResizeHandle,
|
|
7
|
+
type Cursor,
|
|
8
|
+
createSceneApi,
|
|
9
|
+
type HandleDescriptor,
|
|
10
|
+
type HandlePortal,
|
|
11
|
+
type LinearResizeHandle,
|
|
12
|
+
nodeRegistry,
|
|
13
|
+
type RadialResizeHandle,
|
|
14
|
+
sceneRegistry,
|
|
15
|
+
snapScalar,
|
|
16
|
+
type TapActionHandle,
|
|
17
|
+
type TranslateHandle,
|
|
18
|
+
useLiveNodeOverrides,
|
|
19
|
+
useScene,
|
|
20
|
+
} from '@pascal-app/core'
|
|
21
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
22
|
+
import { Html } from '@react-three/drei'
|
|
23
|
+
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
|
24
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
25
|
+
import {
|
|
26
|
+
BufferGeometry,
|
|
27
|
+
Color,
|
|
28
|
+
CylinderGeometry,
|
|
29
|
+
DoubleSide,
|
|
30
|
+
Float32BufferAttribute,
|
|
31
|
+
type Group,
|
|
32
|
+
Matrix4,
|
|
33
|
+
type Object3D,
|
|
34
|
+
OrthographicCamera,
|
|
35
|
+
Plane,
|
|
36
|
+
Quaternion,
|
|
37
|
+
RingGeometry,
|
|
38
|
+
Vector3,
|
|
39
|
+
} from 'three'
|
|
40
|
+
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
|
41
|
+
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
42
|
+
import { EDITOR_LAYER } from '../../lib/constants'
|
|
43
|
+
import { createEditorApi } from '../../lib/editor-api'
|
|
44
|
+
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
45
|
+
import useEditor from '../../store/use-editor'
|
|
46
|
+
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
|
47
|
+
import {
|
|
48
|
+
ARROW_COLOR,
|
|
49
|
+
ARROW_HOVER_COLOR,
|
|
50
|
+
ARROW_SCALE,
|
|
51
|
+
CORNER_HEX_RADIUS,
|
|
52
|
+
HandleArrow,
|
|
53
|
+
NO_RAYCAST,
|
|
54
|
+
} from './handles/handle-arrow'
|
|
55
|
+
import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
ARROW_COLOR,
|
|
59
|
+
ARROW_HOVER_COLOR,
|
|
60
|
+
ARROW_SCALE,
|
|
61
|
+
createArrowHitAreaGeometry,
|
|
62
|
+
createEndpointHitAreaGeometry,
|
|
63
|
+
createMoveCrossHandleGeometry,
|
|
64
|
+
createRotateArrowHandleGeometry,
|
|
65
|
+
createRotateArrowHitAreaGeometry,
|
|
66
|
+
HIT_AREA_MARGIN,
|
|
67
|
+
InvisibleHandleHitArea,
|
|
68
|
+
NO_RAYCAST,
|
|
69
|
+
useArrowMaterial,
|
|
70
|
+
useInvisibleHitAreaMaterial,
|
|
71
|
+
} from './handles/handle-arrow'
|
|
72
|
+
export { swallowNextClick } from './handles/use-handle-drag'
|
|
73
|
+
|
|
74
|
+
// How far a DOWNWARD tracker's dashed leader pokes past its cube so the
|
|
75
|
+
// dashes visibly thread through it (the cube sits ON the line, not at
|
|
76
|
+
// its end). Upward trackers — wall / chimney height — stop at the cube.
|
|
77
|
+
const TRACKER_THROUGH = 0.12
|
|
78
|
+
|
|
79
|
+
// Mirrors the formatter used by wall / fence measurement labels so all
|
|
80
|
+
// in-world dimension chips read consistently.
|
|
81
|
+
function formatDimension(value: number, unit: 'metric' | 'imperial'): string {
|
|
82
|
+
if (unit === 'imperial') {
|
|
83
|
+
const feet = value * 3.280_84
|
|
84
|
+
const wholeFeet = Math.floor(feet)
|
|
85
|
+
const inches = Math.round((feet - wholeFeet) * 12)
|
|
86
|
+
if (inches === 12) return `${wholeFeet + 1}'0"`
|
|
87
|
+
return `${wholeFeet}'${inches}"`
|
|
88
|
+
}
|
|
89
|
+
return `${Number.parseFloat(value.toFixed(2))}m`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// In-world dimension chip rendered next to a resize arrow during hover
|
|
93
|
+
// or drag. Uses the same screen-space `<Html>` recipe + text-shadow
|
|
94
|
+
// halo as the wall measurement label so it reads at every camera angle.
|
|
95
|
+
function DimensionLabel({
|
|
96
|
+
position,
|
|
97
|
+
text,
|
|
98
|
+
}: {
|
|
99
|
+
position: readonly [number, number, number]
|
|
100
|
+
text: string
|
|
101
|
+
}) {
|
|
102
|
+
return (
|
|
103
|
+
<Html
|
|
104
|
+
center
|
|
105
|
+
position={position as unknown as [number, number, number]}
|
|
106
|
+
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
107
|
+
zIndexRange={[25, 0]}
|
|
108
|
+
>
|
|
109
|
+
<div
|
|
110
|
+
className="whitespace-nowrap font-bold font-mono text-[13px]"
|
|
111
|
+
style={{
|
|
112
|
+
color: '#fafafa',
|
|
113
|
+
textShadow:
|
|
114
|
+
'-1.5px -1.5px 0 #0b0b0b, 1.5px -1.5px 0 #0b0b0b, -1.5px 1.5px 0 #0b0b0b, 1.5px 1.5px 0 #0b0b0b, 0 0 4px #0b0b0b',
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
{text}
|
|
118
|
+
</div>
|
|
119
|
+
</Html>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function NodeArrowHandles() {
|
|
124
|
+
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
125
|
+
const mode = useEditor((state) => state.mode)
|
|
126
|
+
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
|
127
|
+
const movingNode = useEditor((state) => state.movingNode)
|
|
128
|
+
const placementDragMode = useEditor((state) => state.placementDragMode)
|
|
129
|
+
// Endpoint / curve drags reshape the selected wall or fence; hide its
|
|
130
|
+
// resize arrows for the duration so they don't clutter (or get blocked
|
|
131
|
+
// by) the drag's own cursor + dimension overlays. Mirrors the same guard
|
|
132
|
+
// on the legacy wall handles (`WallMoveSideHandles`).
|
|
133
|
+
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
|
|
134
|
+
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
|
135
|
+
const curvingWall = useEditor((state) => state.curvingWall)
|
|
136
|
+
const curvingFence = useEditor((state) => state.curvingFence)
|
|
137
|
+
|
|
138
|
+
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
|
139
|
+
const rawNode = useScene((state) =>
|
|
140
|
+
selectedId ? (state.nodes[selectedId as AnyNodeId] ?? null) : null,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
// Merge any live drag override so the arrows themselves (positions,
|
|
144
|
+
// ring decorations) track the in-flight drag instead of freezing at
|
|
145
|
+
// pre-drag values. Subscribe to just this node's entry so unrelated
|
|
146
|
+
// override writes don't re-render the handle stack.
|
|
147
|
+
const liveOverride = useLiveNodeOverrides((s) =>
|
|
148
|
+
rawNode ? s.overrides.get(rawNode.id) : undefined,
|
|
149
|
+
)
|
|
150
|
+
const node = useMemo<AnyNode | null>(
|
|
151
|
+
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
|
|
152
|
+
[rawNode, liveOverride],
|
|
153
|
+
)
|
|
154
|
+
const isOwnPressDragMove =
|
|
155
|
+
placementDragMode && movingNode !== null && selectedId !== null && movingNode.id === selectedId
|
|
156
|
+
|
|
157
|
+
const def = node ? nodeRegistry.get(node.type) : null
|
|
158
|
+
const descriptors = useMemo(() => {
|
|
159
|
+
if (!(node && def?.handles)) return null
|
|
160
|
+
return typeof def.handles === 'function'
|
|
161
|
+
? def.handles(node as never)
|
|
162
|
+
: (def.handles as HandleDescriptor[])
|
|
163
|
+
}, [node, def])
|
|
164
|
+
|
|
165
|
+
const shouldRender =
|
|
166
|
+
Boolean(node && descriptors?.length) &&
|
|
167
|
+
!isFloorplanHovered &&
|
|
168
|
+
mode !== 'delete' &&
|
|
169
|
+
(!movingNode || isOwnPressDragMove) &&
|
|
170
|
+
!movingWallEndpoint &&
|
|
171
|
+
!movingFenceEndpoint &&
|
|
172
|
+
!curvingWall &&
|
|
173
|
+
!curvingFence
|
|
174
|
+
|
|
175
|
+
if (!shouldRender || !node || !descriptors) return null
|
|
176
|
+
// Key by the selected node id so switching selection REMOUNTS the rig.
|
|
177
|
+
// The portal target + ride-mesh refs are seeded from the scene registry
|
|
178
|
+
// in `useState` initializers; without a remount they'd persist from the
|
|
179
|
+
// previous selection and the arrows would ride the old node's world pose
|
|
180
|
+
// (right local placements, wrong frame) until the resolve effect happened
|
|
181
|
+
// to catch up. Remounting re-resolves both refs synchronously for the new
|
|
182
|
+
// node, so the arrows land in the right place the instant it's selected.
|
|
183
|
+
return <NodeArrowHandlesForNode descriptors={descriptors} key={node.id} node={node} />
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Resolves the portal target + ride mesh chain. Descriptor-level `portal`
|
|
187
|
+
// toggles between two layout patterns; descriptor placement is *always* in
|
|
188
|
+
// the selected node's local frame regardless of mode.
|
|
189
|
+
//
|
|
190
|
+
// - 'parent' (default): mount inside the selected node's parent mesh.
|
|
191
|
+
// The wrapper mirrors the node's own local pose, so handles live in
|
|
192
|
+
// node-local coords directly. No inner group. Used by columns / walls
|
|
193
|
+
// / anything where the node IS the thing the user selected and whose
|
|
194
|
+
// rotation should drive the handle frame.
|
|
195
|
+
// - 'grandparent': mount inside the grandparent mesh (to escape the
|
|
196
|
+
// parent's selection-outline traversal). The wrapper mirrors the
|
|
197
|
+
// parent mesh's local pose; a nested inner group mirrors the node's
|
|
198
|
+
// own local pose. Handles end up in node-local coords. Used by doors /
|
|
199
|
+
// windows — handles need to ride the wall's rotation but not be
|
|
200
|
+
// children of the wall mesh.
|
|
201
|
+
function NodeArrowHandlesForNode({
|
|
202
|
+
node,
|
|
203
|
+
descriptors,
|
|
204
|
+
}: {
|
|
205
|
+
node: AnyNode
|
|
206
|
+
descriptors: HandleDescriptor[]
|
|
207
|
+
}) {
|
|
208
|
+
const parentId = node.parentId ?? null
|
|
209
|
+
const grandparentId = useScene((state) => {
|
|
210
|
+
if (!parentId) return null
|
|
211
|
+
const parent = state.nodes[parentId as AnyNodeId]
|
|
212
|
+
return parent?.parentId ?? null
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const portalMode: HandlePortal = descriptors.some((d) => d.portal === 'grandparent')
|
|
216
|
+
? 'grandparent'
|
|
217
|
+
: 'parent'
|
|
218
|
+
|
|
219
|
+
// Portal target: the mesh we createPortal into.
|
|
220
|
+
const portalTargetId = portalMode === 'grandparent' ? grandparentId : parentId
|
|
221
|
+
// Outer wrapper mirrors this mesh's local pose. For 'parent' mode the
|
|
222
|
+
// outer IS the node (so handles + drag math both live in node-local).
|
|
223
|
+
// For 'grandparent' the outer rides the parent and an inner group adds
|
|
224
|
+
// the node's own pose.
|
|
225
|
+
const outerRideId = portalMode === 'grandparent' ? parentId : (node.id as AnyNodeId)
|
|
226
|
+
const innerRideId = portalMode === 'grandparent' ? (node.id as AnyNodeId) : null
|
|
227
|
+
|
|
228
|
+
const [portalObject, setPortalObject] = useState<Object3D | null>(() =>
|
|
229
|
+
portalTargetId ? (sceneRegistry.nodes.get(portalTargetId as AnyNodeId) ?? null) : null,
|
|
230
|
+
)
|
|
231
|
+
const [outerRide, setOuterRide] = useState<Object3D | null>(() =>
|
|
232
|
+
outerRideId ? (sceneRegistry.nodes.get(outerRideId as AnyNodeId) ?? null) : null,
|
|
233
|
+
)
|
|
234
|
+
const [innerRide, setInnerRide] = useState<Object3D | null>(() =>
|
|
235
|
+
innerRideId ? (sceneRegistry.nodes.get(innerRideId as AnyNodeId) ?? null) : null,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
let frameId = 0
|
|
240
|
+
const resolve = () => {
|
|
241
|
+
const nextPortal = portalTargetId
|
|
242
|
+
? (sceneRegistry.nodes.get(portalTargetId as AnyNodeId) ?? null)
|
|
243
|
+
: null
|
|
244
|
+
const nextOuter = outerRideId
|
|
245
|
+
? (sceneRegistry.nodes.get(outerRideId as AnyNodeId) ?? null)
|
|
246
|
+
: null
|
|
247
|
+
const nextInner = innerRideId
|
|
248
|
+
? (sceneRegistry.nodes.get(innerRideId as AnyNodeId) ?? null)
|
|
249
|
+
: null
|
|
250
|
+
setPortalObject((cur) => (cur === nextPortal ? cur : nextPortal))
|
|
251
|
+
setOuterRide((cur) => (cur === nextOuter ? cur : nextOuter))
|
|
252
|
+
setInnerRide((cur) => (cur === nextInner ? cur : nextInner))
|
|
253
|
+
// Inner ride is optional ('parent' mode skips it).
|
|
254
|
+
const needInner = innerRideId !== null
|
|
255
|
+
if (!nextPortal || !nextOuter || (needInner && !nextInner)) {
|
|
256
|
+
frameId = window.requestAnimationFrame(resolve)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
resolve()
|
|
260
|
+
return () => {
|
|
261
|
+
if (frameId) window.cancelAnimationFrame(frameId)
|
|
262
|
+
}
|
|
263
|
+
}, [portalTargetId, outerRideId, innerRideId])
|
|
264
|
+
|
|
265
|
+
const outerRef = useRef<Group>(null)
|
|
266
|
+
const innerRef = useRef<Group>(null)
|
|
267
|
+
|
|
268
|
+
// Keep arrow objects on SCENE_LAYER so the post-processing scenePass
|
|
269
|
+
// captures them in the depth/normal MRT — that's what feeds the ink-edge
|
|
270
|
+
// shader, and it's the reason the wall height arrow (which also stays on
|
|
271
|
+
// SCENE_LAYER) reads as a proper 3D plate with outlined edges. Putting
|
|
272
|
+
// them on EDITOR_LAYER hides them from scenePass and the chevron renders
|
|
273
|
+
// flat. Arrows are only mounted while a node is selected, so thumbnail
|
|
274
|
+
// captures (which never have selection) don't need the layer-based
|
|
275
|
+
// exclusion the wall arrow also goes without.
|
|
276
|
+
|
|
277
|
+
useFrame(() => {
|
|
278
|
+
if (outerRef.current && outerRide) {
|
|
279
|
+
outerRef.current.position.copy(outerRide.position)
|
|
280
|
+
outerRef.current.quaternion.copy(outerRide.quaternion)
|
|
281
|
+
}
|
|
282
|
+
if (innerRef.current && innerRide) {
|
|
283
|
+
innerRef.current.position.copy(innerRide.position)
|
|
284
|
+
innerRef.current.quaternion.copy(innerRide.quaternion)
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
// Active-drag tracking. When a handle starts dragging, it claims its
|
|
289
|
+
// descriptor index here and snapshots the store node at drag-start.
|
|
290
|
+
// Non-active arrows re-render against the snapshot + a freeze offset
|
|
291
|
+
// that undoes the mesh's `position` drift in node-local frame — so
|
|
292
|
+
// asymmetric resize (width L/R, length L/R) doesn't visually slide the
|
|
293
|
+
// depth / height / rotate chevrons. They stay anchored at their
|
|
294
|
+
// pre-drag world positions for the duration of the drag.
|
|
295
|
+
//
|
|
296
|
+
// Hooks must sit ABOVE the early-return guard below — the registry-
|
|
297
|
+
// resolve `useEffect` flips `portalObject` from null → object after
|
|
298
|
+
// the first frame, so a guard between two hooks would change the
|
|
299
|
+
// hook count between renders and trip React's rules-of-hooks check.
|
|
300
|
+
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
|
301
|
+
const [preDragNode, setPreDragNode] = useState<AnyNode | null>(null)
|
|
302
|
+
const dragControls = useMemo<HandleDragControls>(
|
|
303
|
+
() => ({
|
|
304
|
+
onStart: (index: number, snapshot: AnyNode) => {
|
|
305
|
+
setActiveIndex(index)
|
|
306
|
+
setPreDragNode(snapshot)
|
|
307
|
+
},
|
|
308
|
+
onEnd: () => {
|
|
309
|
+
setActiveIndex(null)
|
|
310
|
+
setPreDragNode(null)
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
[],
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if (!portalObject || !outerRide || (innerRideId !== null && !innerRide)) return null
|
|
317
|
+
|
|
318
|
+
// `arrowFrame` is the Object3D used as the spatial reference for the
|
|
319
|
+
// per-arrow drag math — its world matrix maps node-local coords to
|
|
320
|
+
// world. In 'parent' mode that's the outer ride (= the node mesh
|
|
321
|
+
// itself). In 'grandparent' mode it's the inner ride (= the node mesh)
|
|
322
|
+
// because the inner group mirrors the node's local pose under the
|
|
323
|
+
// wall-riding outer wrapper.
|
|
324
|
+
const arrowFrame = innerRide ?? outerRide
|
|
325
|
+
|
|
326
|
+
// A translate drag moves `position`, so the whole handle rig should travel
|
|
327
|
+
// with the mesh — the freeze-at-pre-drag mechanism (built for asymmetric
|
|
328
|
+
// resize that re-centres the mesh) must NOT fire for the non-active arrows
|
|
329
|
+
// here, or they'd lag behind the moving item.
|
|
330
|
+
const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate'
|
|
331
|
+
|
|
332
|
+
const arrows = descriptors.map((descriptor, index) => (
|
|
333
|
+
<ArrowHandle
|
|
334
|
+
activeIndex={activeIndex}
|
|
335
|
+
descriptor={descriptor}
|
|
336
|
+
dragControls={dragControls}
|
|
337
|
+
handleIndex={index}
|
|
338
|
+
// Descriptors come from a per-node-kind static list, so index is a
|
|
339
|
+
// stable identity within this node's selection cycle.
|
|
340
|
+
key={index}
|
|
341
|
+
liveNode={node}
|
|
342
|
+
preDragNode={preDragNode}
|
|
343
|
+
rideObject={arrowFrame}
|
|
344
|
+
suppressFreeze={activeIsTranslate}
|
|
345
|
+
/>
|
|
346
|
+
))
|
|
347
|
+
|
|
348
|
+
return createPortal(
|
|
349
|
+
<group ref={outerRef}>
|
|
350
|
+
{innerRideId !== null ? <group ref={innerRef}>{arrows}</group> : arrows}
|
|
351
|
+
</group>,
|
|
352
|
+
portalObject,
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Offset, in node-local frame, that compensates for `position` drift on
|
|
357
|
+
// the mesh during an asymmetric resize. Width/length L+R recompute
|
|
358
|
+
// `position` so the anchored edge stays world-fixed — the renderer
|
|
359
|
+
// follows that override, the ride object moves, and every arrow under
|
|
360
|
+
// it would drift along with the mesh center. Subtracting this offset
|
|
361
|
+
// from a non-active arrow's local placement undoes that drift so it
|
|
362
|
+
// stays at its pre-drag world position.
|
|
363
|
+
//
|
|
364
|
+
// Rotation drags don't change `position`, so the offset collapses to
|
|
365
|
+
// zero and non-active arrows naturally rotate with the mesh — which is
|
|
366
|
+
// the desired behaviour (the whole rig rotates as a unit).
|
|
367
|
+
function computeFreezeOffset(liveNode: AnyNode, preDragNode: AnyNode): [number, number, number] {
|
|
368
|
+
// Not every node in the union carries a `position` field (sites are the
|
|
369
|
+
// notable holdout — they don't have handles anyway, but TypeScript still
|
|
370
|
+
// requires us to discriminate). Guarded access keeps the freeze logic
|
|
371
|
+
// safe for the few node kinds that lack the field.
|
|
372
|
+
const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0]
|
|
373
|
+
const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [
|
|
374
|
+
0, 0, 0,
|
|
375
|
+
]
|
|
376
|
+
const deltaWorldX = liveP[0] - preP[0]
|
|
377
|
+
const deltaWorldY = liveP[1] - preP[1]
|
|
378
|
+
const deltaWorldZ = liveP[2] - preP[2]
|
|
379
|
+
const rotY = (preDragNode as { rotation?: number }).rotation ?? 0
|
|
380
|
+
// World → node-local for Y-axis rotation by rotY (THREE.Object3D
|
|
381
|
+
// rotation-y convention): inverse is rotation by -rotY around +Y.
|
|
382
|
+
const cosR = Math.cos(rotY)
|
|
383
|
+
const sinR = Math.sin(rotY)
|
|
384
|
+
const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ
|
|
385
|
+
const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ
|
|
386
|
+
return [deltaLocalX, deltaWorldY, deltaLocalZ]
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function ArrowHandle({
|
|
390
|
+
descriptor,
|
|
391
|
+
liveNode,
|
|
392
|
+
preDragNode,
|
|
393
|
+
activeIndex,
|
|
394
|
+
handleIndex,
|
|
395
|
+
dragControls,
|
|
396
|
+
rideObject,
|
|
397
|
+
suppressFreeze,
|
|
398
|
+
}: {
|
|
399
|
+
descriptor: HandleDescriptor
|
|
400
|
+
liveNode: AnyNode
|
|
401
|
+
preDragNode: AnyNode | null
|
|
402
|
+
activeIndex: number | null
|
|
403
|
+
handleIndex: number
|
|
404
|
+
dragControls: HandleDragControls
|
|
405
|
+
rideObject: Object3D
|
|
406
|
+
/** When the active drag is a translate, non-active arrows ride the moving
|
|
407
|
+
* mesh instead of freezing at their pre-drag world position. */
|
|
408
|
+
suppressFreeze?: boolean
|
|
409
|
+
}) {
|
|
410
|
+
// During a drag, non-active arrows render against the pre-drag store
|
|
411
|
+
// snapshot. The active arrow always uses the live (override-merged)
|
|
412
|
+
// node so it tracks the cursor.
|
|
413
|
+
const isOtherActive = activeIndex !== null && activeIndex !== handleIndex && preDragNode !== null
|
|
414
|
+
const placementNode = isOtherActive ? (preDragNode as AnyNode) : liveNode
|
|
415
|
+
const freezeOffset =
|
|
416
|
+
isOtherActive && preDragNode && !suppressFreeze
|
|
417
|
+
? computeFreezeOffset(liveNode, preDragNode)
|
|
418
|
+
: null
|
|
419
|
+
|
|
420
|
+
if (descriptor.kind === 'linear-resize' || descriptor.kind === 'radial-resize') {
|
|
421
|
+
return (
|
|
422
|
+
<LinearArrow
|
|
423
|
+
descriptor={descriptor}
|
|
424
|
+
dragControls={dragControls}
|
|
425
|
+
freezeOffset={freezeOffset}
|
|
426
|
+
handleIndex={handleIndex}
|
|
427
|
+
liveNode={liveNode}
|
|
428
|
+
node={placementNode}
|
|
429
|
+
rideObject={rideObject}
|
|
430
|
+
/>
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
if (descriptor.kind === 'arc-resize') {
|
|
434
|
+
return (
|
|
435
|
+
<ArcArrow
|
|
436
|
+
descriptor={descriptor}
|
|
437
|
+
dragControls={dragControls}
|
|
438
|
+
freezeOffset={freezeOffset}
|
|
439
|
+
handleIndex={handleIndex}
|
|
440
|
+
liveNode={liveNode}
|
|
441
|
+
node={placementNode}
|
|
442
|
+
rideObject={rideObject}
|
|
443
|
+
/>
|
|
444
|
+
)
|
|
445
|
+
}
|
|
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
|
+
if (descriptor.kind === 'tap-action') {
|
|
458
|
+
// Tap-action handles (fence side-move arrows, corner pickers) aren't
|
|
459
|
+
// resize handles, so the freeze-at-pre-drag mechanism — which only
|
|
460
|
+
// exists to stop arrows sliding when an asymmetric width/length resize
|
|
461
|
+
// re-centers the mesh — doesn't apply to them. Track the live node so
|
|
462
|
+
// their height-dependent placement (side arrows ride the top, corner
|
|
463
|
+
// leaders span the full height) follows a height drag in real time.
|
|
464
|
+
return <TapActionArrow descriptor={descriptor} node={liveNode} />
|
|
465
|
+
}
|
|
466
|
+
// endpoint-move not yet implemented.
|
|
467
|
+
return null
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function pickCursor(descriptor: LinearResizeHandle<AnyNode> | RadialResizeHandle<AnyNode>): Cursor {
|
|
471
|
+
if (descriptor.kind === 'linear-resize' && descriptor.cursor) return descriptor.cursor
|
|
472
|
+
return descriptor.axis === 'y' ? 'ns-resize' : 'ew-resize'
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function resolveBound(
|
|
476
|
+
bound:
|
|
477
|
+
| number
|
|
478
|
+
| ((node: AnyNode, sceneApi: ReturnType<typeof createSceneApi>) => number)
|
|
479
|
+
| undefined,
|
|
480
|
+
fallback: number,
|
|
481
|
+
node: AnyNode,
|
|
482
|
+
sceneApi: ReturnType<typeof createSceneApi>,
|
|
483
|
+
): number {
|
|
484
|
+
if (bound === undefined) return fallback
|
|
485
|
+
return typeof bound === 'function' ? bound(node, sceneApi) : bound
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function LinearArrow({
|
|
489
|
+
descriptor,
|
|
490
|
+
node,
|
|
491
|
+
liveNode,
|
|
492
|
+
freezeOffset,
|
|
493
|
+
handleIndex,
|
|
494
|
+
dragControls,
|
|
495
|
+
rideObject,
|
|
496
|
+
}: {
|
|
497
|
+
descriptor: LinearResizeHandle<AnyNode> | RadialResizeHandle<AnyNode>
|
|
498
|
+
/** Effective node for placement (preDrag snapshot when another arrow is active). */
|
|
499
|
+
node: AnyNode
|
|
500
|
+
/** Always the live (override-merged) node — used inside drag handlers. */
|
|
501
|
+
liveNode: AnyNode
|
|
502
|
+
/** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */
|
|
503
|
+
freezeOffset: [number, number, number] | null
|
|
504
|
+
handleIndex: number
|
|
505
|
+
dragControls: HandleDragControls
|
|
506
|
+
rideObject: Object3D
|
|
507
|
+
}) {
|
|
508
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
509
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
510
|
+
const { camera } = useThree()
|
|
511
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
512
|
+
const baseScale = zoom * ARROW_SCALE
|
|
513
|
+
const unit = useViewer((s) => s.unit)
|
|
514
|
+
|
|
515
|
+
// Suppress "declared but unused" for `liveNode` — LinearArrow's apply
|
|
516
|
+
// operates on `initialNode` (snapshot inside activate) and reads value
|
|
517
|
+
// updates back via `useLiveNodeOverrides`. The prop is required for
|
|
518
|
+
// uniformity with ArrowHandle's variant dispatch but isn't consumed in
|
|
519
|
+
// this variant's render path.
|
|
520
|
+
void liveNode
|
|
521
|
+
|
|
522
|
+
const cursor = pickCursor(descriptor)
|
|
523
|
+
// When a handle declares `measureLabel`, its readout is routed to the
|
|
524
|
+
// floating dimension pill (via `activeHandleDrag`) and its own in-world
|
|
525
|
+
// chip is suppressed — matches the wall height handle.
|
|
526
|
+
const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined
|
|
527
|
+
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
528
|
+
const basePosition = descriptor.placement.position(node, placementSceneApi)
|
|
529
|
+
// `freezeOffset` (in node-local frame) cancels the mesh's `position`
|
|
530
|
+
// drift while another arrow is being dragged — `basePosition` is
|
|
531
|
+
// computed against the pre-drag snapshot, then we subtract the offset
|
|
532
|
+
// so the arrow's WORLD location matches its pre-drag world location.
|
|
533
|
+
// Active arrows + idle state have `freezeOffset === null`, so the
|
|
534
|
+
// position passes through unchanged.
|
|
535
|
+
const position: [number, number, number] = freezeOffset
|
|
536
|
+
? [
|
|
537
|
+
basePosition[0] - freezeOffset[0],
|
|
538
|
+
basePosition[1] - freezeOffset[1],
|
|
539
|
+
basePosition[2] - freezeOffset[2],
|
|
540
|
+
]
|
|
541
|
+
: [basePosition[0], basePosition[1], basePosition[2]]
|
|
542
|
+
const baseRotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
|
543
|
+
// Default chevron points +X. Rotate around Y to face the chosen axis.
|
|
544
|
+
const axisRotationY = descriptor.axis === 'z' ? -Math.PI / 2 : 0
|
|
545
|
+
// For axis === 'y' we orient the chevron up. Z-rotation by π/2 then
|
|
546
|
+
// Y-rotation chains via the parent <group> below.
|
|
547
|
+
const rotationY = baseRotationY + axisRotationY
|
|
548
|
+
|
|
549
|
+
const activate = useHandleDrag({
|
|
550
|
+
kind: 'drag',
|
|
551
|
+
cursor,
|
|
552
|
+
dragControls,
|
|
553
|
+
handleIndex,
|
|
554
|
+
node,
|
|
555
|
+
rideObject,
|
|
556
|
+
setIsDragging,
|
|
557
|
+
onStart: ({
|
|
558
|
+
camera: dragCamera,
|
|
559
|
+
event,
|
|
560
|
+
initialNode,
|
|
561
|
+
intersectPlane,
|
|
562
|
+
nodeId,
|
|
563
|
+
rideObject: dragRideObject,
|
|
564
|
+
sceneApi,
|
|
565
|
+
}) => {
|
|
566
|
+
const initialFrameInverse = new Matrix4().copy(dragRideObject.matrixWorld).invert()
|
|
567
|
+
const worldOrigin = new Vector3(...position).applyMatrix4(dragRideObject.matrixWorld)
|
|
568
|
+
const planeNormal = new Vector3().subVectors(dragCamera.position, worldOrigin).setY(0)
|
|
569
|
+
if (planeNormal.lengthSq() === 0) return null
|
|
570
|
+
planeNormal.normalize()
|
|
571
|
+
const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin)
|
|
572
|
+
|
|
573
|
+
const hitWorld = new Vector3()
|
|
574
|
+
if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) {
|
|
575
|
+
return null
|
|
576
|
+
}
|
|
577
|
+
const hitLocal = hitWorld.clone().applyMatrix4(initialFrameInverse)
|
|
578
|
+
|
|
579
|
+
const overrideId =
|
|
580
|
+
(descriptor.kind === 'linear-resize'
|
|
581
|
+
? descriptor.overrideTarget?.(initialNode as never, sceneApi)
|
|
582
|
+
: undefined) ?? nodeId
|
|
583
|
+
const initialValue = descriptor.currentValue(initialNode)
|
|
584
|
+
const initialPointer =
|
|
585
|
+
descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z
|
|
586
|
+
const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
|
|
587
|
+
const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
|
|
588
|
+
const gridSnapStep =
|
|
589
|
+
descriptor.kind === 'linear-resize' && descriptor.gridSnap
|
|
590
|
+
? useEditor.getState().gridSnapStep
|
|
591
|
+
: null
|
|
592
|
+
const factor =
|
|
593
|
+
descriptor.kind === 'radial-resize'
|
|
594
|
+
? 1
|
|
595
|
+
: descriptor.anchor === 'center'
|
|
596
|
+
? 2
|
|
597
|
+
: descriptor.anchor === 'min'
|
|
598
|
+
? 1
|
|
599
|
+
: -1
|
|
600
|
+
|
|
601
|
+
return {
|
|
602
|
+
overrideId,
|
|
603
|
+
onBegin: () => {
|
|
604
|
+
if (measureLabel) {
|
|
605
|
+
useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel })
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
onEnd: () => {
|
|
609
|
+
if (measureLabel) {
|
|
610
|
+
useEditor.getState().setActiveHandleDrag(null)
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
|
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)
|
|
619
|
+
const currentPointer =
|
|
620
|
+
descriptor.axis === 'x'
|
|
621
|
+
? intersectionLocal.x
|
|
622
|
+
: descriptor.axis === 'y'
|
|
623
|
+
? intersectionLocal.y
|
|
624
|
+
: intersectionLocal.z
|
|
625
|
+
const delta = currentPointer - initialPointer
|
|
626
|
+
const rawNext = initialValue + delta * factor
|
|
627
|
+
const snappedNext =
|
|
628
|
+
gridSnapStep && gridSnapStep > 0 ? snapScalar(rawNext, gridSnapStep) : rawNext
|
|
629
|
+
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
|
|
630
|
+
return descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
|
|
631
|
+
},
|
|
632
|
+
}
|
|
633
|
+
},
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
// For axis === 'y' (vertical handles), tilt the chevron up via local
|
|
637
|
+
// X+Z rotation chain matching DoorHeightArrowHandle. When the handle
|
|
638
|
+
// sits below the node (placement Y < 0, e.g. window bottom arrow),
|
|
639
|
+
// flip the Z rotation so the chevron points outward (downward).
|
|
640
|
+
const innerRotation: [number, number, number] =
|
|
641
|
+
descriptor.axis === 'y'
|
|
642
|
+
? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
|
|
643
|
+
: [0, 0, 0]
|
|
644
|
+
|
|
645
|
+
// Optional guide decoration — linear handles use it for curved-stair
|
|
646
|
+
// width / inner-radius rings; radial handles use it for the column's
|
|
647
|
+
// round footprint ring.
|
|
648
|
+
const decoration = descriptor.decoration
|
|
649
|
+
const showDecoration = Boolean(decoration) && (isHovered || isDragging)
|
|
650
|
+
|
|
651
|
+
// Dimension chip — shows the live value the drag is steering. `node`
|
|
652
|
+
// is already the effective (override-merged) node, so currentValue
|
|
653
|
+
// returns the in-flight value during a drag and the label tracks
|
|
654
|
+
// smoothly without an extra subscription.
|
|
655
|
+
// `measureLabel` handles route their readout to the floating dimension
|
|
656
|
+
// pill, so suppress the inline chip here to avoid showing it twice.
|
|
657
|
+
const showLabel = (isHovered || isDragging) && !measureLabel
|
|
658
|
+
const labelText = showLabel ? formatDimension(descriptor.currentValue(node), unit) : ''
|
|
659
|
+
|
|
660
|
+
// `tracker` shape on a linear-resize handle: render a dashed vertical
|
|
661
|
+
// leader from the floor up to a small cube at `placement.position`. The
|
|
662
|
+
// cube is the drag target and reuses the same `activate` pointer handler
|
|
663
|
+
// as the chevron path, so all the override/commit plumbing is unchanged.
|
|
664
|
+
// Only valid for axis='y' resize handles — the leader is rendered at
|
|
665
|
+
// (0,0,0)→(0,position.y,0) in the same group as the cube, so for x/z
|
|
666
|
+
// axes the leader would still climb vertically and look wrong.
|
|
667
|
+
const shape =
|
|
668
|
+
descriptor.kind === 'linear-resize' && descriptor.shape === 'tracker' ? 'tracker' : 'arrow'
|
|
669
|
+
|
|
670
|
+
if (shape === 'tracker') {
|
|
671
|
+
// Descriptors can pin the leader's bottom Y above the floor — e.g.
|
|
672
|
+
// chimney body height starts at the deck plane, not at y=0, so the
|
|
673
|
+
// dashed leader spans only the body's visible extent.
|
|
674
|
+
const trackerDescriptor = descriptor as LinearResizeHandle<AnyNode>
|
|
675
|
+
const baseY = trackerDescriptor.trackerBaseY?.(node as never, placementSceneApi) ?? 0
|
|
676
|
+
// Leader spans base ↔ cube either direction. Upward (cube above base:
|
|
677
|
+
// wall / chimney height) it stops at the cube as before. Downward
|
|
678
|
+
// (cube below base: a downspout's length cube under the gutter
|
|
679
|
+
// outlet) it pokes `TRACKER_THROUGH` past the cube so the dashes
|
|
680
|
+
// thread through it instead of the leader collapsing to nothing.
|
|
681
|
+
const cubeY = position[1]
|
|
682
|
+
const cubeBelowBase = cubeY < baseY
|
|
683
|
+
const leaderBottomY = Math.min(baseY, cubeY) - (cubeBelowBase ? TRACKER_THROUGH : 0)
|
|
684
|
+
const leaderHeight = Math.max(Math.max(baseY, cubeY) - leaderBottomY, 0)
|
|
685
|
+
return (
|
|
686
|
+
<>
|
|
687
|
+
{showDecoration && decoration ? (
|
|
688
|
+
<GuideRing
|
|
689
|
+
radius={decoration.radius(node as never)}
|
|
690
|
+
y={decoration.y?.(node as never) ?? 0}
|
|
691
|
+
/>
|
|
692
|
+
) : null}
|
|
693
|
+
{showLabel ? (
|
|
694
|
+
<DimensionLabel
|
|
695
|
+
position={[position[0], position[1] + 0.22, position[2]]}
|
|
696
|
+
text={labelText}
|
|
697
|
+
/>
|
|
698
|
+
) : null}
|
|
699
|
+
<TrackerShape
|
|
700
|
+
basePosition={[position[0], leaderBottomY, position[2]]}
|
|
701
|
+
baseScale={zoom}
|
|
702
|
+
cubePosition={position}
|
|
703
|
+
cursor={cursor}
|
|
704
|
+
hover={isHovered}
|
|
705
|
+
leaderHeight={leaderHeight}
|
|
706
|
+
onHoverChange={setIsHovered}
|
|
707
|
+
onPointerDown={activate}
|
|
708
|
+
/>
|
|
709
|
+
</>
|
|
710
|
+
)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return (
|
|
714
|
+
<>
|
|
715
|
+
{showDecoration && decoration ? (
|
|
716
|
+
<GuideRing
|
|
717
|
+
radius={decoration.radius(node as never)}
|
|
718
|
+
y={decoration.y?.(node as never) ?? 0}
|
|
719
|
+
/>
|
|
720
|
+
) : null}
|
|
721
|
+
<HandleArrow
|
|
722
|
+
cursor={cursor}
|
|
723
|
+
hover={isHovered}
|
|
724
|
+
indicatorRotation={innerRotation}
|
|
725
|
+
onHoverChange={setIsHovered}
|
|
726
|
+
onPointerDown={activate}
|
|
727
|
+
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
|
728
|
+
shape="chevron"
|
|
729
|
+
>
|
|
730
|
+
{showLabel ? <DimensionLabel position={[0, 0.22, 0]} text={labelText} /> : null}
|
|
731
|
+
</HandleArrow>
|
|
732
|
+
</>
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Thin horizontal ring used as a visual guide alongside a resize arrow —
|
|
737
|
+
// e.g. the curved-stair width arrow traces the outer rim, the inner-radius
|
|
738
|
+
// arrow traces the central pillar. Floats at node-local `y`, lies in the
|
|
739
|
+
// XZ plane.
|
|
740
|
+
export function GuideRing({ radius, y }: { radius: number; y: number }) {
|
|
741
|
+
const safeRadius = Math.max(radius, 0.01)
|
|
742
|
+
const ringGeometry = useMemo(() => {
|
|
743
|
+
const inner = Math.max(safeRadius - 0.015, 0.001)
|
|
744
|
+
const outer = safeRadius + 0.015
|
|
745
|
+
return new RingGeometry(inner, outer, 96)
|
|
746
|
+
}, [safeRadius])
|
|
747
|
+
const ringMaterial = useMemo(
|
|
748
|
+
() =>
|
|
749
|
+
new MeshBasicNodeMaterial({
|
|
750
|
+
color: new Color(ARROW_COLOR),
|
|
751
|
+
side: DoubleSide,
|
|
752
|
+
transparent: true,
|
|
753
|
+
opacity: 0.95,
|
|
754
|
+
depthTest: false,
|
|
755
|
+
depthWrite: false,
|
|
756
|
+
}),
|
|
757
|
+
[],
|
|
758
|
+
)
|
|
759
|
+
useEffect(() => () => ringGeometry.dispose(), [ringGeometry])
|
|
760
|
+
useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
|
|
761
|
+
|
|
762
|
+
return (
|
|
763
|
+
<mesh
|
|
764
|
+
frustumCulled={false}
|
|
765
|
+
geometry={ringGeometry}
|
|
766
|
+
material={ringMaterial}
|
|
767
|
+
position={[0, y, 0]}
|
|
768
|
+
renderOrder={1009}
|
|
769
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
770
|
+
/>
|
|
771
|
+
)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const ROTATION_GUIDE_COLOR = ARROW_COLOR
|
|
775
|
+
const ROTATION_GUIDE_SEGMENTS = 48
|
|
776
|
+
|
|
777
|
+
// Live rotation readout shown while a whole-node rotate gizmo is dragged.
|
|
778
|
+
// Mirrors the wall-draft angle arc: a filled wedge + outline swept from the
|
|
779
|
+
// pointer's bearing at grab (`startAngle`) to its current bearing
|
|
780
|
+
// (`endAngle`) around the rotation pivot, plus a degree chip at the wedge's
|
|
781
|
+
// midpoint. All coordinates are world-space — the guide is portalled to the
|
|
782
|
+
// scene root so it stays fixed while the node mesh rotates underneath it.
|
|
783
|
+
export type RotationGuideData = {
|
|
784
|
+
center: [number, number, number]
|
|
785
|
+
startAngle: number
|
|
786
|
+
endAngle: number
|
|
787
|
+
radius: number
|
|
788
|
+
labelPos: [number, number, number]
|
|
789
|
+
/** Swept magnitude in radians, for the degree chip. */
|
|
790
|
+
sweep: number
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export function RotationGuide({ data }: { data: RotationGuideData }) {
|
|
794
|
+
const { center, startAngle, endAngle, radius, labelPos, sweep } = data
|
|
795
|
+
const { outline, fill } = useMemo(() => {
|
|
796
|
+
const span = endAngle - startAngle
|
|
797
|
+
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_GUIDE_SEGMENTS))
|
|
798
|
+
const arc = Array.from({ length: count + 1 }, (_, index) => {
|
|
799
|
+
const angle = startAngle + (span * index) / count
|
|
800
|
+
return new Vector3(
|
|
801
|
+
center[0] + Math.cos(angle) * radius,
|
|
802
|
+
center[1],
|
|
803
|
+
center[2] + Math.sin(angle) * radius,
|
|
804
|
+
)
|
|
805
|
+
})
|
|
806
|
+
const centerV = new Vector3(center[0], center[1], center[2])
|
|
807
|
+
const outlineGeo = new BufferGeometry().setFromPoints([centerV, ...arc, centerV])
|
|
808
|
+
const positions: number[] = []
|
|
809
|
+
for (let i = 0; i < arc.length - 1; i++) {
|
|
810
|
+
const a = arc[i]
|
|
811
|
+
const b = arc[i + 1]
|
|
812
|
+
if (!a || !b) continue
|
|
813
|
+
positions.push(centerV.x, centerV.y, centerV.z, a.x, a.y, a.z, b.x, b.y, b.z)
|
|
814
|
+
}
|
|
815
|
+
const fillGeo = new BufferGeometry()
|
|
816
|
+
fillGeo.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
817
|
+
return { outline: outlineGeo, fill: fillGeo }
|
|
818
|
+
}, [center, startAngle, endAngle, radius])
|
|
819
|
+
useEffect(() => () => outline.dispose(), [outline])
|
|
820
|
+
useEffect(() => () => fill.dispose(), [fill])
|
|
821
|
+
|
|
822
|
+
return (
|
|
823
|
+
<>
|
|
824
|
+
<mesh
|
|
825
|
+
frustumCulled={false}
|
|
826
|
+
geometry={fill}
|
|
827
|
+
layers={EDITOR_LAYER}
|
|
828
|
+
raycast={NO_RAYCAST}
|
|
829
|
+
renderOrder={1008}
|
|
830
|
+
>
|
|
831
|
+
<meshBasicMaterial
|
|
832
|
+
color={ROTATION_GUIDE_COLOR}
|
|
833
|
+
depthTest={false}
|
|
834
|
+
depthWrite={false}
|
|
835
|
+
opacity={0.18}
|
|
836
|
+
side={DoubleSide}
|
|
837
|
+
transparent
|
|
838
|
+
/>
|
|
839
|
+
</mesh>
|
|
840
|
+
<RotationGuideOutline geometry={outline} />
|
|
841
|
+
<DimensionLabel position={labelPos} text={formatAngleRadians(sweep)} />
|
|
842
|
+
</>
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) {
|
|
847
|
+
return (
|
|
848
|
+
// @ts-expect-error - R3F accepts Three line primitives, matching the wall draft arc.
|
|
849
|
+
<line frustumCulled={false} geometry={geometry} layers={EDITOR_LAYER} renderOrder={1009}>
|
|
850
|
+
<lineBasicNodeMaterial
|
|
851
|
+
color={ROTATION_GUIDE_COLOR}
|
|
852
|
+
depthTest={false}
|
|
853
|
+
depthWrite={false}
|
|
854
|
+
linewidth={2}
|
|
855
|
+
opacity={0.95}
|
|
856
|
+
transparent
|
|
857
|
+
/>
|
|
858
|
+
</line>
|
|
859
|
+
)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Live whole-node rotation readout for a single-node rotate gizmo, rendered as
|
|
863
|
+
// a CHILD of the node frame (sibling of its guide ring). Because it lives in
|
|
864
|
+
// the node's own frame, it is automatically concentric and coplanar with the
|
|
865
|
+
// ring — on flat ground the frame's XZ is world-horizontal, on a pitched roof
|
|
866
|
+
// it follows the slope, with no world-space basis math.
|
|
867
|
+
//
|
|
868
|
+
// The wedge fills the node-local XZ plane at the ring's height `y`. Its leading
|
|
869
|
+
// edge sits at the rotate handle's bearing `handleAngle` (which is fixed in the
|
|
870
|
+
// node frame, so it tracks the orbiting handle), and it opens BACKWARD by the
|
|
871
|
+
// swept `delta` — so the trailing edge stays pinned to the grab direction in
|
|
872
|
+
// world while the node spins. `orbitRadius` is the handle's in-plane distance
|
|
873
|
+
// from the pivot; the fill is pulled inside it so it reads as the handle
|
|
874
|
+
// swinging around rather than overlapping the icon.
|
|
875
|
+
function RotationWedge({
|
|
876
|
+
delta,
|
|
877
|
+
handleAngle,
|
|
878
|
+
orbitRadius,
|
|
879
|
+
y,
|
|
880
|
+
}: {
|
|
881
|
+
delta: number
|
|
882
|
+
handleAngle: number
|
|
883
|
+
orbitRadius: number
|
|
884
|
+
y: number
|
|
885
|
+
}) {
|
|
886
|
+
const radius = Math.min(Math.max(orbitRadius * 0.72, 0.3), 1.6)
|
|
887
|
+
const { outline, fill } = useMemo(() => {
|
|
888
|
+
const start = handleAngle - delta
|
|
889
|
+
const span = delta
|
|
890
|
+
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_GUIDE_SEGMENTS))
|
|
891
|
+
const arc = Array.from({ length: count + 1 }, (_, index) => {
|
|
892
|
+
const angle = start + (span * index) / count
|
|
893
|
+
return new Vector3(Math.cos(angle) * radius, 0, Math.sin(angle) * radius)
|
|
894
|
+
})
|
|
895
|
+
const centerV = new Vector3(0, 0, 0)
|
|
896
|
+
const outlineGeo = new BufferGeometry().setFromPoints([centerV, ...arc, centerV])
|
|
897
|
+
const positions: number[] = []
|
|
898
|
+
for (let i = 0; i < arc.length - 1; i++) {
|
|
899
|
+
const a = arc[i]
|
|
900
|
+
const b = arc[i + 1]
|
|
901
|
+
if (!a || !b) continue
|
|
902
|
+
positions.push(0, 0, 0, a.x, a.y, a.z, b.x, b.y, b.z)
|
|
903
|
+
}
|
|
904
|
+
const fillGeo = new BufferGeometry()
|
|
905
|
+
fillGeo.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
|
906
|
+
return { outline: outlineGeo, fill: fillGeo }
|
|
907
|
+
}, [delta, handleAngle, radius])
|
|
908
|
+
useEffect(() => () => outline.dispose(), [outline])
|
|
909
|
+
useEffect(() => () => fill.dispose(), [fill])
|
|
910
|
+
|
|
911
|
+
const labelRadius = radius + 0.22
|
|
912
|
+
const midAngle = handleAngle - delta / 2
|
|
913
|
+
const labelPos: [number, number, number] = [
|
|
914
|
+
Math.cos(midAngle) * labelRadius,
|
|
915
|
+
0,
|
|
916
|
+
Math.sin(midAngle) * labelRadius,
|
|
917
|
+
]
|
|
918
|
+
|
|
919
|
+
return (
|
|
920
|
+
<group position={[0, y, 0]}>
|
|
921
|
+
<mesh
|
|
922
|
+
frustumCulled={false}
|
|
923
|
+
geometry={fill}
|
|
924
|
+
layers={EDITOR_LAYER}
|
|
925
|
+
raycast={NO_RAYCAST}
|
|
926
|
+
renderOrder={1008}
|
|
927
|
+
>
|
|
928
|
+
<meshBasicMaterial
|
|
929
|
+
color={ROTATION_GUIDE_COLOR}
|
|
930
|
+
depthTest={false}
|
|
931
|
+
depthWrite={false}
|
|
932
|
+
opacity={0.18}
|
|
933
|
+
side={DoubleSide}
|
|
934
|
+
transparent
|
|
935
|
+
/>
|
|
936
|
+
</mesh>
|
|
937
|
+
<RotationGuideOutline geometry={outline} />
|
|
938
|
+
<DimensionLabel position={labelPos} text={formatAngleRadians(Math.abs(delta))} />
|
|
939
|
+
</group>
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Angular drag: project pointer to a horizontal plane at the arrow's Y
|
|
944
|
+
// and measure the signed angle around the node's local origin (in world
|
|
945
|
+
// XZ). Pass the normalised delta to `apply` — the descriptor owns the
|
|
946
|
+
// per-field math (sweep handles write `sweepAngle` AND `rotation` from
|
|
947
|
+
// the same delta to keep the opposite edge world-fixed).
|
|
948
|
+
function ArcArrow({
|
|
949
|
+
descriptor,
|
|
950
|
+
node,
|
|
951
|
+
liveNode,
|
|
952
|
+
freezeOffset,
|
|
953
|
+
handleIndex,
|
|
954
|
+
dragControls,
|
|
955
|
+
rideObject,
|
|
956
|
+
}: {
|
|
957
|
+
descriptor: ArcResizeHandle<AnyNode>
|
|
958
|
+
/** Effective node for placement (preDrag snapshot when another arrow is active). */
|
|
959
|
+
node: AnyNode
|
|
960
|
+
/** Always the live (override-merged) node — used inside drag handlers. */
|
|
961
|
+
liveNode: AnyNode
|
|
962
|
+
/** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */
|
|
963
|
+
freezeOffset: [number, number, number] | null
|
|
964
|
+
handleIndex: number
|
|
965
|
+
dragControls: HandleDragControls
|
|
966
|
+
rideObject: Object3D
|
|
967
|
+
}) {
|
|
968
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
969
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
970
|
+
// 'rotate' descriptors (whole-node rotation handles like the elevator
|
|
971
|
+
// corner) render a two-headed curved arrow; everything else (stair
|
|
972
|
+
// sweep, etc.) keeps the chevron.
|
|
973
|
+
const isRotateShape = descriptor.shape === 'rotate'
|
|
974
|
+
// 'node-normal' spins the node about its local +Z (a wall item flat against
|
|
975
|
+
// its wall) instead of yaw about world-Y. The drag plane and the icon both
|
|
976
|
+
// tilt into that plane, and the horizontal-only wedge/ring readout is
|
|
977
|
+
// suppressed.
|
|
978
|
+
const isNodeNormalRot = descriptor.rotationPlane === 'node-normal'
|
|
979
|
+
const { camera } = useThree()
|
|
980
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
981
|
+
// The rotate icon is denser than the chevron; pump scale a touch so the
|
|
982
|
+
// ribbon reads at the same on-screen size as the other handles.
|
|
983
|
+
const arrowScale = isRotateShape ? ARROW_SCALE * 1.05 : ARROW_SCALE
|
|
984
|
+
const baseScale = zoom * arrowScale
|
|
985
|
+
// Live rotation amount (radians swept since grab) — non-null only while a
|
|
986
|
+
// `shape: 'rotate'` gizmo is mid-drag. Drives the in-frame wedge readout.
|
|
987
|
+
const [rotationDelta, setRotationDelta] = useState<number | null>(null)
|
|
988
|
+
|
|
989
|
+
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
990
|
+
const basePosition = descriptor.placement.position(node, placementSceneApi)
|
|
991
|
+
// See the LinearArrow note on freezeOffset — for rotation drags the
|
|
992
|
+
// delta collapses to zero (position doesn't change), so the rotate
|
|
993
|
+
// gizmo naturally rotates with the mesh while another arrow is being
|
|
994
|
+
// dragged. The offset only kicks in for asymmetric resize drags that
|
|
995
|
+
// recompute `position` to anchor the opposite edge.
|
|
996
|
+
const position: [number, number, number] = freezeOffset
|
|
997
|
+
? [
|
|
998
|
+
basePosition[0] - freezeOffset[0],
|
|
999
|
+
basePosition[1] - freezeOffset[1],
|
|
1000
|
+
basePosition[2] - freezeOffset[2],
|
|
1001
|
+
]
|
|
1002
|
+
: [basePosition[0], basePosition[1], basePosition[2]]
|
|
1003
|
+
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
|
1004
|
+
// Rotation gizmo: hover signals "grabbable", active drag signals
|
|
1005
|
+
// "grabbed". `ew-resize` was wrong — it implies linear width drag.
|
|
1006
|
+
const hoverCursor: Cursor = 'grab'
|
|
1007
|
+
const dragCursor: Cursor = 'grabbing'
|
|
1008
|
+
|
|
1009
|
+
// Optional guide ring (elevator rotation circle) shown while the arc
|
|
1010
|
+
// arrow is hovered or dragging. Same recipe as the linear / radial
|
|
1011
|
+
// decoration path.
|
|
1012
|
+
const decoration = descriptor.decoration
|
|
1013
|
+
const showDecoration = Boolean(decoration) && (isHovered || isDragging)
|
|
1014
|
+
|
|
1015
|
+
const activate = useHandleDrag({
|
|
1016
|
+
kind: 'drag',
|
|
1017
|
+
cursor: dragCursor,
|
|
1018
|
+
dragControls,
|
|
1019
|
+
handleIndex,
|
|
1020
|
+
node,
|
|
1021
|
+
rideObject,
|
|
1022
|
+
setIsDragging,
|
|
1023
|
+
onStart: ({ event, initialNode, intersectPlane, rideObject: dragRideObject, sceneApi }) => {
|
|
1024
|
+
const centerWorld =
|
|
1025
|
+
descriptor.rotationCenter !== undefined
|
|
1026
|
+
? new Vector3(...descriptor.rotationCenter(node as never, sceneApi)).applyMatrix4(
|
|
1027
|
+
dragRideObject.matrixWorld,
|
|
1028
|
+
)
|
|
1029
|
+
: new Vector3().setFromMatrixPosition(dragRideObject.matrixWorld)
|
|
1030
|
+
const arrowWorld = new Vector3(...position).applyMatrix4(dragRideObject.matrixWorld)
|
|
1031
|
+
const planeY = arrowWorld.y
|
|
1032
|
+
const axis = isNodeNormalRot
|
|
1033
|
+
? new Vector3().setFromMatrixColumn(dragRideObject.matrixWorld, 2).normalize()
|
|
1034
|
+
: new Vector3(0, 1, 0)
|
|
1035
|
+
const plane = isNodeNormalRot
|
|
1036
|
+
? new Plane().setFromNormalAndCoplanarPoint(axis, centerWorld)
|
|
1037
|
+
: new Plane(new Vector3(0, 1, 0), -planeY)
|
|
1038
|
+
|
|
1039
|
+
let basisU: Vector3
|
|
1040
|
+
if (isNodeNormalRot) {
|
|
1041
|
+
const up = new Vector3(0, 1, 0)
|
|
1042
|
+
basisU = up.clone().addScaledVector(axis, -up.dot(axis))
|
|
1043
|
+
if (basisU.lengthSq() < 1e-6) {
|
|
1044
|
+
const x = new Vector3(1, 0, 0)
|
|
1045
|
+
basisU = x.addScaledVector(axis, -x.dot(axis))
|
|
1046
|
+
}
|
|
1047
|
+
basisU.normalize()
|
|
1048
|
+
} else {
|
|
1049
|
+
basisU = new Vector3(1, 0, 0)
|
|
1050
|
+
}
|
|
1051
|
+
const basisV = isNodeNormalRot
|
|
1052
|
+
? new Vector3().crossVectors(axis, basisU).normalize()
|
|
1053
|
+
: new Vector3(0, 0, 1)
|
|
1054
|
+
const angleOf = (p: Vector3) => {
|
|
1055
|
+
const d = new Vector3().subVectors(p, centerWorld)
|
|
1056
|
+
return Math.atan2(d.dot(basisV), d.dot(basisU))
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const hitWorld = new Vector3()
|
|
1060
|
+
if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) {
|
|
1061
|
+
return null
|
|
1062
|
+
}
|
|
1063
|
+
const initialAngle = angleOf(hitWorld)
|
|
1064
|
+
|
|
1065
|
+
return {
|
|
1066
|
+
onEnd: () => setRotationDelta(null),
|
|
1067
|
+
move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => {
|
|
1068
|
+
const hit = new Vector3()
|
|
1069
|
+
if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null
|
|
1070
|
+
const currentAngle = angleOf(hit)
|
|
1071
|
+
let delta = currentAngle - initialAngle
|
|
1072
|
+
while (delta > Math.PI) delta -= 2 * Math.PI
|
|
1073
|
+
while (delta < -Math.PI) delta += 2 * Math.PI
|
|
1074
|
+
|
|
1075
|
+
if (moveEvent.shiftKey && descriptor.shape === 'rotate') {
|
|
1076
|
+
const step = Math.PI / 12
|
|
1077
|
+
delta = Math.round(delta / step) * step
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (isRotateShape && !isNodeNormalRot) {
|
|
1081
|
+
setRotationDelta(Math.abs(delta) < 0.0087 ? null : delta)
|
|
1082
|
+
}
|
|
1083
|
+
return descriptor.apply(initialNode as never, delta, sceneApi) as Partial<AnyNode>
|
|
1084
|
+
},
|
|
1085
|
+
}
|
|
1086
|
+
},
|
|
1087
|
+
})
|
|
1088
|
+
|
|
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
|
+
return (
|
|
1097
|
+
<>
|
|
1098
|
+
{showDecoration && decoration ? (
|
|
1099
|
+
<GuideRing
|
|
1100
|
+
radius={decoration.radius(node as never)}
|
|
1101
|
+
y={decoration.y?.(node as never) ?? 0}
|
|
1102
|
+
/>
|
|
1103
|
+
) : null}
|
|
1104
|
+
{/* Live rotation readout. Rendered HERE (a child of the node frame, the
|
|
1105
|
+
same frame the guide ring lives in) rather than portalled to world
|
|
1106
|
+
space, so the wedge is automatically concentric and coplanar with the
|
|
1107
|
+
ring on any surface — flat ground or a pitched roof. */}
|
|
1108
|
+
{rotationDelta !== null ? (
|
|
1109
|
+
<RotationWedge
|
|
1110
|
+
delta={rotationDelta}
|
|
1111
|
+
handleAngle={Math.atan2(position[2], position[0])}
|
|
1112
|
+
orbitRadius={Math.hypot(position[0], position[2])}
|
|
1113
|
+
y={decoration?.y?.(node as never) ?? 0}
|
|
1114
|
+
/>
|
|
1115
|
+
) : null}
|
|
1116
|
+
<HandleArrow
|
|
1117
|
+
activeCursor={dragCursor}
|
|
1118
|
+
cursor={hoverCursor}
|
|
1119
|
+
hover={isHovered}
|
|
1120
|
+
onHoverChange={setIsHovered}
|
|
1121
|
+
onPointerDown={activate}
|
|
1122
|
+
placement={{
|
|
1123
|
+
position,
|
|
1124
|
+
// The curved arrow is built flat in XZ. For a wall-normal spin, tilt
|
|
1125
|
+
// it up about X so it lies in the item-local XY plane (the wall face).
|
|
1126
|
+
rotation: isNodeNormalRot ? [Math.PI / 2, 0, rotationY] : [0, rotationY, 0],
|
|
1127
|
+
baseScale,
|
|
1128
|
+
}}
|
|
1129
|
+
shape={isRotateShape ? 'curved-arrow' : 'chevron'}
|
|
1130
|
+
/>
|
|
1131
|
+
</>
|
|
1132
|
+
)
|
|
1133
|
+
}
|
|
1134
|
+
|
|
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
|
+
// Click-to-engage affordance — no drag plumbing, just a click target. The
|
|
1194
|
+
// descriptor's `onActivate` receives sceneApi + editorApi so it can engage
|
|
1195
|
+
// move tools, endpoint drags, or any other editor-state transition without
|
|
1196
|
+
// importing editor internals from the node-def layer.
|
|
1197
|
+
function TapActionArrow({
|
|
1198
|
+
descriptor,
|
|
1199
|
+
node,
|
|
1200
|
+
}: {
|
|
1201
|
+
descriptor: TapActionHandle<AnyNode>
|
|
1202
|
+
node: AnyNode
|
|
1203
|
+
}) {
|
|
1204
|
+
const [isHovered, setIsHovered] = useState(false)
|
|
1205
|
+
const { camera } = useThree()
|
|
1206
|
+
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
1207
|
+
|
|
1208
|
+
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
1209
|
+
const position = descriptor.placement.position(node, placementSceneApi)
|
|
1210
|
+
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
|
1211
|
+
const shape = descriptor.shape ?? 'arrow'
|
|
1212
|
+
const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
|
|
1213
|
+
|
|
1214
|
+
const onActivate = useHandleDrag({
|
|
1215
|
+
kind: 'tap',
|
|
1216
|
+
onTap: () => {
|
|
1217
|
+
setIsHovered(false)
|
|
1218
|
+
descriptor.onActivate(node as never, createSceneApi(useScene), createEditorApi())
|
|
1219
|
+
},
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
if (shape === 'corner-picker') {
|
|
1223
|
+
const height = descriptor.nodeHeight?.(node) ?? 1
|
|
1224
|
+
return (
|
|
1225
|
+
<CornerPickerShape
|
|
1226
|
+
baseScale={zoom}
|
|
1227
|
+
cursor={cursor}
|
|
1228
|
+
height={height}
|
|
1229
|
+
hover={isHovered}
|
|
1230
|
+
onHoverChange={setIsHovered}
|
|
1231
|
+
onPointerDown={onActivate}
|
|
1232
|
+
position={position}
|
|
1233
|
+
/>
|
|
1234
|
+
)
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Default 'arrow' shape — the standard chevron.
|
|
1238
|
+
const baseScale = zoom * ARROW_SCALE
|
|
1239
|
+
return (
|
|
1240
|
+
<HandleArrow
|
|
1241
|
+
cursor={cursor}
|
|
1242
|
+
hover={isHovered}
|
|
1243
|
+
onHoverChange={setIsHovered}
|
|
1244
|
+
onPointerDown={onActivate}
|
|
1245
|
+
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
|
1246
|
+
shape="chevron"
|
|
1247
|
+
/>
|
|
1248
|
+
)
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Wall corner-picker visual: dashed vertical leader from floor up to
|
|
1252
|
+
// `height` + billboarded hex disc (the click target) + outer ring. The
|
|
1253
|
+
// hex disc is the only mesh with a pointer-down handler; the dashes and
|
|
1254
|
+
// ring are decorative.
|
|
1255
|
+
const CORNER_DASH_SIZE = 0.1
|
|
1256
|
+
const CORNER_GAP_SIZE = 0.07
|
|
1257
|
+
const CORNER_DASH_THICKNESS = 0.006
|
|
1258
|
+
const CORNER_FLOOR_OFFSET = 0.01
|
|
1259
|
+
|
|
1260
|
+
function buildDashedVerticalGeometry(height: number) {
|
|
1261
|
+
const dashes: BufferGeometry[] = []
|
|
1262
|
+
let y = 0
|
|
1263
|
+
while (y < height) {
|
|
1264
|
+
const end = Math.min(y + CORNER_DASH_SIZE, height)
|
|
1265
|
+
const length = end - y
|
|
1266
|
+
const cylinder = new CylinderGeometry(CORNER_DASH_THICKNESS, CORNER_DASH_THICKNESS, length, 8)
|
|
1267
|
+
cylinder.translate(0, y + length / 2, 0)
|
|
1268
|
+
dashes.push(cylinder)
|
|
1269
|
+
y = end + CORNER_GAP_SIZE
|
|
1270
|
+
}
|
|
1271
|
+
const merged = mergeGeometries(dashes, false) ?? dashes[0]
|
|
1272
|
+
for (const dash of dashes) dash.dispose()
|
|
1273
|
+
return merged
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function CornerPickerShape({
|
|
1277
|
+
position,
|
|
1278
|
+
height,
|
|
1279
|
+
baseScale,
|
|
1280
|
+
cursor,
|
|
1281
|
+
hover,
|
|
1282
|
+
onHoverChange,
|
|
1283
|
+
onPointerDown,
|
|
1284
|
+
}: {
|
|
1285
|
+
position: readonly [number, number, number]
|
|
1286
|
+
height: number
|
|
1287
|
+
baseScale: number
|
|
1288
|
+
cursor: Cursor
|
|
1289
|
+
hover: boolean
|
|
1290
|
+
onHoverChange: (hovered: boolean) => void
|
|
1291
|
+
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
|
1292
|
+
}) {
|
|
1293
|
+
const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(height), [height])
|
|
1294
|
+
useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
|
|
1295
|
+
|
|
1296
|
+
const dashMaterial = useMemo(
|
|
1297
|
+
() =>
|
|
1298
|
+
new MeshBasicNodeMaterial({
|
|
1299
|
+
color: new Color(ARROW_COLOR),
|
|
1300
|
+
transparent: true,
|
|
1301
|
+
opacity: 0.85,
|
|
1302
|
+
depthTest: false,
|
|
1303
|
+
depthWrite: false,
|
|
1304
|
+
}),
|
|
1305
|
+
[],
|
|
1306
|
+
)
|
|
1307
|
+
const ringMaterial = useMemo(
|
|
1308
|
+
() =>
|
|
1309
|
+
new MeshBasicNodeMaterial({
|
|
1310
|
+
color: new Color(ARROW_COLOR),
|
|
1311
|
+
side: DoubleSide,
|
|
1312
|
+
transparent: true,
|
|
1313
|
+
opacity: 1,
|
|
1314
|
+
depthTest: false,
|
|
1315
|
+
depthWrite: false,
|
|
1316
|
+
}),
|
|
1317
|
+
[],
|
|
1318
|
+
)
|
|
1319
|
+
useEffect(() => {
|
|
1320
|
+
const next = hover ? ARROW_HOVER_COLOR : ARROW_COLOR
|
|
1321
|
+
dashMaterial.color.set(next)
|
|
1322
|
+
ringMaterial.color.set(next)
|
|
1323
|
+
}, [dashMaterial, ringMaterial, hover])
|
|
1324
|
+
useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
|
|
1325
|
+
useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
|
|
1326
|
+
|
|
1327
|
+
const billboardRef = useRef<Group>(null)
|
|
1328
|
+
const { camera } = useThree()
|
|
1329
|
+
// Billboard the disc to the camera so the picker remains readable at any
|
|
1330
|
+
// viewing angle. The disc lives under the building-rotated tool group
|
|
1331
|
+
// (and possibly a rotated level), so copying `camera.quaternion` onto
|
|
1332
|
+
// the local quaternion no longer yields camera-aligned WORLD rotation
|
|
1333
|
+
// when the parent has a rotation of its own — compute the local
|
|
1334
|
+
// quaternion that, composed with the parent's world rotation, equals
|
|
1335
|
+
// the camera's world rotation.
|
|
1336
|
+
const parentWorldQuat = useMemo(() => new Quaternion(), [])
|
|
1337
|
+
const invParentWorldQuat = useMemo(() => new Quaternion(), [])
|
|
1338
|
+
useFrame(() => {
|
|
1339
|
+
const group = billboardRef.current
|
|
1340
|
+
if (!group) return
|
|
1341
|
+
if (group.parent) {
|
|
1342
|
+
group.parent.getWorldQuaternion(parentWorldQuat)
|
|
1343
|
+
invParentWorldQuat.copy(parentWorldQuat).invert()
|
|
1344
|
+
group.quaternion.copy(invParentWorldQuat).multiply(camera.quaternion)
|
|
1345
|
+
} else {
|
|
1346
|
+
group.quaternion.copy(camera.quaternion)
|
|
1347
|
+
}
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
const scale = (hover ? 1.25 : 1) * baseScale
|
|
1351
|
+
|
|
1352
|
+
return (
|
|
1353
|
+
<>
|
|
1354
|
+
<mesh
|
|
1355
|
+
frustumCulled={false}
|
|
1356
|
+
geometry={dashedGeometry}
|
|
1357
|
+
material={dashMaterial}
|
|
1358
|
+
position={position}
|
|
1359
|
+
renderOrder={1001}
|
|
1360
|
+
/>
|
|
1361
|
+
<group position={[position[0], CORNER_FLOOR_OFFSET, position[2]]} ref={billboardRef}>
|
|
1362
|
+
<HandleArrow
|
|
1363
|
+
cursor={cursor}
|
|
1364
|
+
hover={hover}
|
|
1365
|
+
hoverScale={1.25}
|
|
1366
|
+
onHoverChange={onHoverChange}
|
|
1367
|
+
onPointerDown={onPointerDown}
|
|
1368
|
+
placement={{ position: [0, 0, 0], baseScale }}
|
|
1369
|
+
shape="corner-picker"
|
|
1370
|
+
/>
|
|
1371
|
+
<mesh material={ringMaterial} renderOrder={1002} scale={scale}>
|
|
1372
|
+
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
|
|
1373
|
+
</mesh>
|
|
1374
|
+
</group>
|
|
1375
|
+
</>
|
|
1376
|
+
)
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Tracker visual for the `linear-resize` handle's `shape: 'tracker'` option.
|
|
1380
|
+
// Mirrors the corner picker (dashed vertical leader from the floor) but caps
|
|
1381
|
+
// the leader with a small draggable cube instead of a hex disc, and the cube
|
|
1382
|
+
// sits at the TOP of the leader rather than the floor — the visual reads as
|
|
1383
|
+
// "this cube is the wall top; drag it to raise/lower." All interactivity
|
|
1384
|
+
// (pointer-down → linear-resize drag) is wired by the parent `LinearArrow`.
|
|
1385
|
+
function TrackerShape({
|
|
1386
|
+
basePosition,
|
|
1387
|
+
baseScale,
|
|
1388
|
+
cubePosition,
|
|
1389
|
+
cursor,
|
|
1390
|
+
hover,
|
|
1391
|
+
leaderHeight,
|
|
1392
|
+
onHoverChange,
|
|
1393
|
+
onPointerDown,
|
|
1394
|
+
}: {
|
|
1395
|
+
basePosition: readonly [number, number, number]
|
|
1396
|
+
baseScale: number
|
|
1397
|
+
cubePosition: readonly [number, number, number]
|
|
1398
|
+
cursor: Cursor
|
|
1399
|
+
hover: boolean
|
|
1400
|
+
leaderHeight: number
|
|
1401
|
+
onHoverChange: (hovered: boolean) => void
|
|
1402
|
+
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
|
1403
|
+
}) {
|
|
1404
|
+
// `leaderHeight === 0` (wallHeight collapsed to floor) would make the
|
|
1405
|
+
// dashed builder return an empty geometry — skip the mesh entirely in
|
|
1406
|
+
// that case so the cube still renders by itself.
|
|
1407
|
+
const hasLeader = leaderHeight > 0.0001
|
|
1408
|
+
const dashedGeometry = useMemo(
|
|
1409
|
+
() => (hasLeader ? buildDashedVerticalGeometry(leaderHeight) : null),
|
|
1410
|
+
[hasLeader, leaderHeight],
|
|
1411
|
+
)
|
|
1412
|
+
useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry])
|
|
1413
|
+
|
|
1414
|
+
const dashMaterial = useMemo(
|
|
1415
|
+
() =>
|
|
1416
|
+
new MeshBasicNodeMaterial({
|
|
1417
|
+
color: new Color(ARROW_COLOR),
|
|
1418
|
+
transparent: true,
|
|
1419
|
+
opacity: 0.85,
|
|
1420
|
+
depthTest: false,
|
|
1421
|
+
depthWrite: false,
|
|
1422
|
+
}),
|
|
1423
|
+
[],
|
|
1424
|
+
)
|
|
1425
|
+
useEffect(() => {
|
|
1426
|
+
const next = hover ? ARROW_HOVER_COLOR : ARROW_COLOR
|
|
1427
|
+
dashMaterial.color.set(next)
|
|
1428
|
+
}, [dashMaterial, hover])
|
|
1429
|
+
useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
|
|
1430
|
+
|
|
1431
|
+
return (
|
|
1432
|
+
<>
|
|
1433
|
+
{dashedGeometry ? (
|
|
1434
|
+
<mesh
|
|
1435
|
+
frustumCulled={false}
|
|
1436
|
+
geometry={dashedGeometry}
|
|
1437
|
+
material={dashMaterial}
|
|
1438
|
+
position={basePosition}
|
|
1439
|
+
renderOrder={1001}
|
|
1440
|
+
/>
|
|
1441
|
+
) : null}
|
|
1442
|
+
<HandleArrow
|
|
1443
|
+
cursor={cursor}
|
|
1444
|
+
hover={hover}
|
|
1445
|
+
hoverScale={1.25}
|
|
1446
|
+
onHoverChange={onHoverChange}
|
|
1447
|
+
onPointerDown={onPointerDown}
|
|
1448
|
+
placement={{ position: cubePosition, baseScale }}
|
|
1449
|
+
shape="tracker"
|
|
1450
|
+
/>
|
|
1451
|
+
</>
|
|
1452
|
+
)
|
|
1453
|
+
}
|