@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
|
@@ -1,12 +1,70 @@
|
|
|
1
1
|
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { SCENE_LAYER, useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
|
4
|
+
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
+
import {
|
|
6
|
+
BufferGeometry,
|
|
7
|
+
Color,
|
|
8
|
+
CylinderGeometry,
|
|
9
|
+
ExtrudeGeometry,
|
|
10
|
+
Float32BufferAttribute,
|
|
11
|
+
type Line,
|
|
12
|
+
type Object3D,
|
|
13
|
+
Shape,
|
|
14
|
+
} from 'three'
|
|
15
|
+
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|
5
16
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
|
6
17
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
18
|
+
import {
|
|
19
|
+
createMoveCrossHandleGeometry,
|
|
20
|
+
ARROW_COLOR as EDGE_ARROW_COLOR,
|
|
21
|
+
ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR,
|
|
22
|
+
ARROW_SCALE as EDGE_ARROW_SCALE,
|
|
23
|
+
useArrowMaterial,
|
|
24
|
+
useInvisibleHitAreaMaterial,
|
|
25
|
+
} from '../../editor/node-arrow-handles'
|
|
7
26
|
import { snapToHalf } from '../item/placement-math'
|
|
8
27
|
|
|
9
28
|
const Y_OFFSET = 0.02
|
|
29
|
+
// Per-side resize arrows: indigo chevrons that match the registry arrow
|
|
30
|
+
// handles (wall / column / fence). Each arrow sits just outside an edge
|
|
31
|
+
// midpoint, pointing along the edge's outward normal — dragging an arrow
|
|
32
|
+
// translates that edge only (its two vertices), leaving the opposite side
|
|
33
|
+
// fixed. Reuses the existing 'edge' drag mode in PolygonEditor.
|
|
34
|
+
const EDGE_ARROW_OFFSET = 0.34
|
|
35
|
+
|
|
36
|
+
// Disables R3F pointer-picking on a mesh. Used on the visual-only meshes —
|
|
37
|
+
// the edge bar and the border line — so they render without stealing pointer
|
|
38
|
+
// events that belong to the vertex/midpoint handles overlapping them. Mirrors
|
|
39
|
+
// the `NO_RAYCAST` sentinel in node-arrow-handles.tsx.
|
|
40
|
+
const NO_RAYCAST = () => null
|
|
41
|
+
|
|
42
|
+
function createEdgeArrowGeometry() {
|
|
43
|
+
const shape = new Shape()
|
|
44
|
+
shape.moveTo(0.22, 0)
|
|
45
|
+
shape.lineTo(-0.04, 0.12)
|
|
46
|
+
shape.lineTo(-0.04, 0.035)
|
|
47
|
+
shape.lineTo(-0.2, 0.035)
|
|
48
|
+
shape.lineTo(-0.2, -0.035)
|
|
49
|
+
shape.lineTo(-0.04, -0.035)
|
|
50
|
+
shape.lineTo(-0.04, -0.12)
|
|
51
|
+
shape.lineTo(0.22, 0)
|
|
52
|
+
const geometry = new ExtrudeGeometry(shape, {
|
|
53
|
+
depth: 0.045,
|
|
54
|
+
bevelEnabled: true,
|
|
55
|
+
bevelThickness: 0.018,
|
|
56
|
+
bevelSize: 0.02,
|
|
57
|
+
bevelOffset: 0,
|
|
58
|
+
bevelSegments: 8,
|
|
59
|
+
curveSegments: 16,
|
|
60
|
+
steps: 1,
|
|
61
|
+
})
|
|
62
|
+
geometry.translate(0, 0, -0.0225)
|
|
63
|
+
geometry.rotateX(-Math.PI / 2)
|
|
64
|
+
geometry.computeVertexNormals()
|
|
65
|
+
geometry.computeBoundingSphere()
|
|
66
|
+
return geometry
|
|
67
|
+
}
|
|
10
68
|
|
|
11
69
|
type DragState = {
|
|
12
70
|
isDragging: boolean
|
|
@@ -23,6 +81,14 @@ export interface PolygonEditorProps {
|
|
|
23
81
|
polygon: Array<[number, number]>
|
|
24
82
|
color?: string
|
|
25
83
|
onPolygonChange: (polygon: Array<[number, number]>) => void
|
|
84
|
+
/**
|
|
85
|
+
* Fires on every drag tick with the in-flight polygon, then once with
|
|
86
|
+
* `null` when the drag commits or is otherwise cleared. Hosts wire
|
|
87
|
+
* this to `useLiveNodeOverrides` so the underlying mesh rebuilds at
|
|
88
|
+
* pointer rate while `onPolygonChange` stays a single store commit
|
|
89
|
+
* on release.
|
|
90
|
+
*/
|
|
91
|
+
onPolygonPreview?: (polygon: ReadonlyArray<readonly [number, number]> | null) => void
|
|
26
92
|
minVertices?: number
|
|
27
93
|
/** Level ID to mount the editor to. If provided, uses createPortal for automatic level animation following. */
|
|
28
94
|
levelId?: string
|
|
@@ -32,6 +98,26 @@ export interface PolygonEditorProps {
|
|
|
32
98
|
allowPolygonMove?: boolean
|
|
33
99
|
/** Whether polygon edges can be dragged along their perpendicular normal. */
|
|
34
100
|
allowEdgeMove?: boolean
|
|
101
|
+
/** Called just before a vertex drag session starts. */
|
|
102
|
+
onBeforeVertexDrag?: (vertexIndex: number, position: [number, number]) => void
|
|
103
|
+
/** Called when a vertex handle enters or leaves hover. */
|
|
104
|
+
onVertexHoverChange?: (vertexIndex: number | null) => void
|
|
105
|
+
/** Called when a midpoint add-vertex handle enters or leaves hover. */
|
|
106
|
+
onMidpointHoverChange?: (edgeIndex: number | null) => void
|
|
107
|
+
/** Called when any polygon drag starts or ends. */
|
|
108
|
+
onDragStateChange?: (isDragging: boolean) => void
|
|
109
|
+
/** Called once when a polygon drag starts. */
|
|
110
|
+
onDragStart?: () => void
|
|
111
|
+
/** Called once when a polygon drag commits on pointer release. */
|
|
112
|
+
onDragCommit?: () => void
|
|
113
|
+
/** Whether to render the editor-owned polygon outline. */
|
|
114
|
+
showBorderLine?: boolean
|
|
115
|
+
/** Whether midpoint handles can add new vertices. */
|
|
116
|
+
showMidpointHandles?: boolean
|
|
117
|
+
/** Optional vertex handle renderer for host-specific affordances. */
|
|
118
|
+
renderVertexHandle?: PolygonVertexHandleRenderer
|
|
119
|
+
/** Optional midpoint handle renderer for host-specific add-vertex affordances. */
|
|
120
|
+
renderMidpointHandle?: PolygonMidpointHandleRenderer
|
|
35
121
|
}
|
|
36
122
|
|
|
37
123
|
/**
|
|
@@ -51,15 +137,200 @@ function getEdgeNormal(start: [number, number], end: [number, number]): [number,
|
|
|
51
137
|
return [-dz / length, dx / length]
|
|
52
138
|
}
|
|
53
139
|
|
|
140
|
+
type HandleClickHandler = (event: ThreeEvent<MouseEvent>) => void
|
|
141
|
+
type HandlePointerHandler = (event: ThreeEvent<PointerEvent>) => void
|
|
142
|
+
|
|
143
|
+
export type PolygonHandleHandlers = {
|
|
144
|
+
onClick?: HandleClickHandler
|
|
145
|
+
onDoubleClick?: HandleClickHandler
|
|
146
|
+
onPointerDown?: HandlePointerHandler
|
|
147
|
+
onPointerEnter?: HandlePointerHandler
|
|
148
|
+
onPointerLeave?: HandlePointerHandler
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type PolygonVertexHandleRenderProps = {
|
|
152
|
+
canDelete: boolean
|
|
153
|
+
handleProps: PolygonHandleHandlers
|
|
154
|
+
height: number
|
|
155
|
+
index: number
|
|
156
|
+
isDragging: boolean
|
|
157
|
+
isHovered: boolean
|
|
158
|
+
point: [number, number]
|
|
159
|
+
position: [number, number, number]
|
|
160
|
+
radius: number
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type PolygonVertexHandleRenderer = (props: PolygonVertexHandleRenderProps) => React.ReactNode
|
|
164
|
+
|
|
165
|
+
export type PolygonMidpointHandleRenderProps = {
|
|
166
|
+
handleProps: PolygonHandleHandlers
|
|
167
|
+
height: number
|
|
168
|
+
index: number
|
|
169
|
+
isHovered: boolean
|
|
170
|
+
point: [number, number]
|
|
171
|
+
position: [number, number, number]
|
|
172
|
+
radius: number
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type PolygonMidpointHandleRenderer = (
|
|
176
|
+
props: PolygonMidpointHandleRenderProps,
|
|
177
|
+
) => React.ReactNode
|
|
178
|
+
|
|
179
|
+
function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMaterial {
|
|
180
|
+
const material = useMemo(
|
|
181
|
+
() =>
|
|
182
|
+
new MeshBasicNodeMaterial({
|
|
183
|
+
color: new Color('#ffffff'),
|
|
184
|
+
depthTest: false,
|
|
185
|
+
depthWrite: true,
|
|
186
|
+
opacity: 1,
|
|
187
|
+
transparent: true,
|
|
188
|
+
}),
|
|
189
|
+
[],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
material.color.set(color)
|
|
194
|
+
material.opacity = opacity
|
|
195
|
+
}, [color, material, opacity])
|
|
196
|
+
useEffect(() => () => material.dispose(), [material])
|
|
197
|
+
|
|
198
|
+
return material
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// One mesh per handle: lives on SCENE_LAYER with a node material so the
|
|
202
|
+
// post-processing ink-edge pass outlines it, and carries the pointer handlers
|
|
203
|
+
// directly so it stays grabbable — matching the registry arrow gizmos in
|
|
204
|
+
// node-arrow-handles.tsx. No paired hit mesh is needed; the R3F event
|
|
205
|
+
// raycaster picks SCENE_LAYER meshes too.
|
|
206
|
+
function OutlinedCylinderHandle({
|
|
207
|
+
radius,
|
|
208
|
+
height,
|
|
209
|
+
color,
|
|
210
|
+
opacity = 1,
|
|
211
|
+
position,
|
|
212
|
+
...handlers
|
|
213
|
+
}: {
|
|
214
|
+
radius: number
|
|
215
|
+
height: number
|
|
216
|
+
color: string
|
|
217
|
+
opacity?: number
|
|
218
|
+
position: [number, number, number]
|
|
219
|
+
} & PolygonHandleHandlers) {
|
|
220
|
+
const geometry = useMemo(() => new CylinderGeometry(radius, radius, height, 16), [height, radius])
|
|
221
|
+
const material = usePolygonNodeMaterial(color, opacity)
|
|
222
|
+
useEffect(() => () => geometry.dispose(), [geometry])
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<mesh
|
|
226
|
+
frustumCulled={false}
|
|
227
|
+
geometry={geometry}
|
|
228
|
+
layers={SCENE_LAYER}
|
|
229
|
+
material={material}
|
|
230
|
+
position={position}
|
|
231
|
+
renderOrder={1010}
|
|
232
|
+
{...handlers}
|
|
233
|
+
/>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Whole-polygon move grip — the generic 4-way cross-arrow (matching the node
|
|
238
|
+
// move handles) with an invisible cylinder hit area, replacing the old sphere.
|
|
239
|
+
// The cross sits on SCENE_LAYER (so the ink pass outlines it) while the
|
|
240
|
+
// cylinder hit mesh is on EDITOR_LAYER (grabbable, out of the MRT scene pass).
|
|
241
|
+
function OutlinedCrossHandle({
|
|
242
|
+
color,
|
|
243
|
+
position,
|
|
244
|
+
...handlers
|
|
245
|
+
}: {
|
|
246
|
+
color: string
|
|
247
|
+
position: [number, number, number]
|
|
248
|
+
} & PolygonHandleHandlers) {
|
|
249
|
+
const geometry = useMemo(() => createMoveCrossHandleGeometry(), [])
|
|
250
|
+
const material = usePolygonNodeMaterial(color)
|
|
251
|
+
const hitGeometry = useMemo(() => new CylinderGeometry(0.24, 0.24, 0.18, 24), [])
|
|
252
|
+
const hitMaterial = useInvisibleHitAreaMaterial()
|
|
253
|
+
useEffect(() => () => geometry.dispose(), [geometry])
|
|
254
|
+
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<group position={position}>
|
|
258
|
+
<mesh
|
|
259
|
+
frustumCulled={false}
|
|
260
|
+
geometry={geometry}
|
|
261
|
+
layers={SCENE_LAYER}
|
|
262
|
+
material={material}
|
|
263
|
+
raycast={NO_RAYCAST}
|
|
264
|
+
renderOrder={1010}
|
|
265
|
+
scale={EDGE_ARROW_SCALE}
|
|
266
|
+
/>
|
|
267
|
+
<mesh
|
|
268
|
+
frustumCulled={false}
|
|
269
|
+
geometry={hitGeometry}
|
|
270
|
+
layers={EDITOR_LAYER}
|
|
271
|
+
material={hitMaterial}
|
|
272
|
+
renderOrder={1011}
|
|
273
|
+
{...handlers}
|
|
274
|
+
/>
|
|
275
|
+
</group>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function OutlinedEdgeArrowHandle({
|
|
280
|
+
geometry,
|
|
281
|
+
color,
|
|
282
|
+
position,
|
|
283
|
+
rotationY,
|
|
284
|
+
scale,
|
|
285
|
+
...handlers
|
|
286
|
+
}: {
|
|
287
|
+
geometry: BufferGeometry
|
|
288
|
+
color: string
|
|
289
|
+
position: [number, number, number]
|
|
290
|
+
rotationY: number
|
|
291
|
+
scale: number
|
|
292
|
+
} & PolygonHandleHandlers) {
|
|
293
|
+
const material = useArrowMaterial()
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
material.color.set(color)
|
|
296
|
+
}, [color, material])
|
|
297
|
+
useEffect(() => () => material.dispose(), [material])
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<mesh
|
|
301
|
+
frustumCulled={false}
|
|
302
|
+
geometry={geometry}
|
|
303
|
+
layers={SCENE_LAYER}
|
|
304
|
+
material={material}
|
|
305
|
+
position={position}
|
|
306
|
+
renderOrder={1010}
|
|
307
|
+
rotation={[0, rotationY, 0]}
|
|
308
|
+
scale={scale}
|
|
309
|
+
{...handlers}
|
|
310
|
+
/>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
54
314
|
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
55
315
|
polygon,
|
|
56
316
|
color = '#3b82f6',
|
|
57
317
|
onPolygonChange,
|
|
318
|
+
onPolygonPreview,
|
|
58
319
|
minVertices = 3,
|
|
59
320
|
levelId,
|
|
60
321
|
surfaceHeight = 0,
|
|
61
322
|
allowPolygonMove = false,
|
|
62
323
|
allowEdgeMove = false,
|
|
324
|
+
onBeforeVertexDrag,
|
|
325
|
+
onVertexHoverChange,
|
|
326
|
+
onMidpointHoverChange,
|
|
327
|
+
onDragStateChange,
|
|
328
|
+
onDragStart,
|
|
329
|
+
onDragCommit,
|
|
330
|
+
showBorderLine = true,
|
|
331
|
+
showMidpointHandles = true,
|
|
332
|
+
renderMidpointHandle,
|
|
333
|
+
renderVertexHandle,
|
|
63
334
|
}) => {
|
|
64
335
|
const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
|
|
65
336
|
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
|
|
@@ -104,10 +375,36 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
104
375
|
const [dragState, setDragState] = useState<DragState | null>(null)
|
|
105
376
|
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
|
106
377
|
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
|
|
378
|
+
const previousInputDraggingRef = useRef(false)
|
|
379
|
+
|
|
380
|
+
const onDragStateChangeRef = useRef(onDragStateChange)
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
onDragStateChangeRef.current = onDragStateChange
|
|
383
|
+
}, [onDragStateChange])
|
|
384
|
+
|
|
385
|
+
const onDragStartRef = useRef(onDragStart)
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
onDragStartRef.current = onDragStart
|
|
388
|
+
}, [onDragStart])
|
|
389
|
+
|
|
390
|
+
const onDragCommitRef = useRef(onDragCommit)
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
onDragCommitRef.current = onDragCommit
|
|
393
|
+
}, [onDragCommit])
|
|
394
|
+
|
|
395
|
+
const onPolygonPreviewRef = useRef(onPolygonPreview)
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
onPolygonPreviewRef.current = onPolygonPreview
|
|
398
|
+
}, [onPolygonPreview])
|
|
107
399
|
|
|
108
400
|
const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
|
|
109
401
|
previewPolygonRef.current = nextPolygon
|
|
110
402
|
setPreviewPolygon(nextPolygon)
|
|
403
|
+
// Notify the host so it can mirror the in-flight polygon onto
|
|
404
|
+
// `useLiveNodeOverrides` (drag rebuilds the mesh at pointer rate)
|
|
405
|
+
// and clear that override when we hand `null` back at commit /
|
|
406
|
+
// cancel / external-undo time.
|
|
407
|
+
onPolygonPreviewRef.current?.(nextPolygon)
|
|
111
408
|
}, [])
|
|
112
409
|
|
|
113
410
|
// Keep ref in sync
|
|
@@ -120,18 +417,77 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
120
417
|
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
|
|
121
418
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
|
122
419
|
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
onVertexHoverChange?.(hoveredVertex)
|
|
422
|
+
}, [hoveredVertex, onVertexHoverChange])
|
|
423
|
+
|
|
424
|
+
useEffect(() => () => onVertexHoverChange?.(null), [onVertexHoverChange])
|
|
425
|
+
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
onMidpointHoverChange?.(hoveredMidpoint)
|
|
428
|
+
}, [hoveredMidpoint, onMidpointHoverChange])
|
|
429
|
+
|
|
430
|
+
useEffect(() => () => onMidpointHoverChange?.(null), [onMidpointHoverChange])
|
|
431
|
+
|
|
123
432
|
const lineRef = useRef<Line>(null!)
|
|
124
433
|
const previousPositionRef = useRef<[number, number] | null>(null)
|
|
125
434
|
|
|
126
|
-
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
onDragStateChangeRef.current?.(dragState?.isDragging ?? false)
|
|
437
|
+
}, [dragState?.isDragging])
|
|
438
|
+
|
|
439
|
+
useEffect(() => () => onDragStateChangeRef.current?.(false), [])
|
|
440
|
+
|
|
441
|
+
const startDrag = useCallback((nextDragState: DragState) => {
|
|
442
|
+
previousInputDraggingRef.current = useViewer.getState().inputDragging
|
|
443
|
+
useViewer.getState().setInputDragging(true)
|
|
444
|
+
setDragState(nextDragState)
|
|
445
|
+
onDragStartRef.current?.()
|
|
446
|
+
}, [])
|
|
447
|
+
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
if (!dragState?.isDragging) return
|
|
450
|
+
|
|
451
|
+
return () => {
|
|
452
|
+
useViewer.getState().setInputDragging(previousInputDraggingRef.current)
|
|
453
|
+
}
|
|
454
|
+
}, [dragState?.isDragging])
|
|
455
|
+
|
|
456
|
+
// Track the last polygon prop to detect external changes (undo/redo) or
|
|
457
|
+
// our own post-commit prop update arriving while a preview is still in
|
|
458
|
+
// flight. Either way, drop the stale preview/drag.
|
|
459
|
+
//
|
|
460
|
+
// This block runs during render, so it may only touch THIS component's
|
|
461
|
+
// own state (setPreviewPolygon / setDragState — React re-renders in
|
|
462
|
+
// place, which is the sanctioned "adjust state on prop change" pattern).
|
|
463
|
+
// The host `onPolygonPreview(null)` notification clears
|
|
464
|
+
// `useLiveNodeOverrides` — a store OTHER components subscribe to (e.g.
|
|
465
|
+
// NodeArrowHandles) — so calling it here throws "Cannot update a
|
|
466
|
+
// component while rendering a different component". Defer it to the
|
|
467
|
+
// effect below.
|
|
127
468
|
const lastPolygonRef = useRef(polygon)
|
|
469
|
+
const pendingPreviewClearRef = useRef(false)
|
|
128
470
|
if (polygon !== lastPolygonRef.current) {
|
|
129
471
|
lastPolygonRef.current = polygon
|
|
130
|
-
//
|
|
131
|
-
|
|
472
|
+
// `previewPolygonRef` is the synchronously-updated source of truth for
|
|
473
|
+
// an in-flight preview (state can lag it by a render).
|
|
474
|
+
if (previewPolygonRef.current !== null || previewPolygon !== null) {
|
|
475
|
+
previewPolygonRef.current = null
|
|
476
|
+
setPreviewPolygon(null)
|
|
477
|
+
pendingPreviewClearRef.current = true
|
|
478
|
+
}
|
|
132
479
|
if (dragState) setDragState(null)
|
|
133
480
|
}
|
|
134
481
|
|
|
482
|
+
// Flush the deferred host preview-clear (see note above) after the
|
|
483
|
+
// commit, where writing to other stores is allowed.
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
if (pendingPreviewClearRef.current) {
|
|
486
|
+
pendingPreviewClearRef.current = false
|
|
487
|
+
onPolygonPreviewRef.current?.(null)
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
|
|
135
491
|
// The polygon to display (preview during drag, or actual polygon)
|
|
136
492
|
const displayPolygon = previewPolygon ?? polygon
|
|
137
493
|
|
|
@@ -159,6 +515,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
159
515
|
const edgeHandles = useMemo(() => {
|
|
160
516
|
if (displayPolygon.length < 2) return []
|
|
161
517
|
|
|
518
|
+
let cx = 0
|
|
519
|
+
let cz = 0
|
|
520
|
+
for (const [x, z] of displayPolygon) {
|
|
521
|
+
cx += x
|
|
522
|
+
cz += z
|
|
523
|
+
}
|
|
524
|
+
cx /= displayPolygon.length
|
|
525
|
+
cz /= displayPolygon.length
|
|
526
|
+
|
|
162
527
|
return displayPolygon.flatMap(([x1, z1], index) => {
|
|
163
528
|
const nextIndex = (index + 1) % displayPolygon.length
|
|
164
529
|
const [x2, z2] = displayPolygon[nextIndex]!
|
|
@@ -167,17 +532,33 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
167
532
|
const length = Math.hypot(dx, dz)
|
|
168
533
|
if (length < 1e-6) return []
|
|
169
534
|
|
|
535
|
+
const midpoint: [number, number] = [(x1 + x2) / 2, (z1 + z2) / 2]
|
|
536
|
+
// Outward normal: edge perpendicular flipped to point away from the
|
|
537
|
+
// polygon centroid. Independent of winding order so arrows always
|
|
538
|
+
// face outward even on hand-traced (mixed-winding) polygons.
|
|
539
|
+
let nx = -dz / length
|
|
540
|
+
let nz = dx / length
|
|
541
|
+
if (nx * (midpoint[0] - cx) + nz * (midpoint[1] - cz) < 0) {
|
|
542
|
+
nx = -nx
|
|
543
|
+
nz = -nz
|
|
544
|
+
}
|
|
545
|
+
|
|
170
546
|
return [
|
|
171
547
|
{
|
|
172
548
|
index,
|
|
173
549
|
length,
|
|
174
|
-
midpoint
|
|
550
|
+
midpoint,
|
|
175
551
|
rotationY: -Math.atan2(dz, dx),
|
|
552
|
+
outwardNormal: [nx, nz] as [number, number],
|
|
553
|
+
outwardAngle: -Math.atan2(nz, nx),
|
|
176
554
|
},
|
|
177
555
|
]
|
|
178
556
|
})
|
|
179
557
|
}, [displayPolygon])
|
|
180
558
|
|
|
559
|
+
const arrowGeometry = useMemo(() => createEdgeArrowGeometry(), [])
|
|
560
|
+
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
|
561
|
+
|
|
181
562
|
// Update vertex position using grid cursor position
|
|
182
563
|
const handleVertexDrag = useCallback(
|
|
183
564
|
(vertexIndex: number, position: [number, number]) => {
|
|
@@ -194,6 +575,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
194
575
|
if (previewPolygonRef.current) {
|
|
195
576
|
onPolygonChange(previewPolygonRef.current)
|
|
196
577
|
}
|
|
578
|
+
onDragCommitRef.current?.()
|
|
197
579
|
updatePreviewPolygon(null)
|
|
198
580
|
setDragState(null)
|
|
199
581
|
}, [onPolygonChange, updatePreviewPolygon])
|
|
@@ -233,8 +615,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
233
615
|
// Listen to grid:move events to track cursor position
|
|
234
616
|
useEffect(() => {
|
|
235
617
|
const onGridMove = (event: GridEvent) => {
|
|
236
|
-
const
|
|
237
|
-
const
|
|
618
|
+
const point = levelNode ? event.localPosition : event.position
|
|
619
|
+
const gridX = snapToHalf(point[0])
|
|
620
|
+
const gridZ = snapToHalf(point[2])
|
|
238
621
|
const newPosition: [number, number] = [gridX, gridZ]
|
|
239
622
|
|
|
240
623
|
// Play snap sound when cursor moves to a new grid cell during drag
|
|
@@ -290,7 +673,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
290
673
|
return () => {
|
|
291
674
|
emitter.off('grid:move', onGridMove)
|
|
292
675
|
}
|
|
293
|
-
}, [dragState, handleVertexDrag, updatePreviewPolygon])
|
|
676
|
+
}, [dragState, handleVertexDrag, levelNode, updatePreviewPolygon])
|
|
294
677
|
|
|
295
678
|
// Set up pointer up listener for ending drag
|
|
296
679
|
useEffect(() => {
|
|
@@ -335,7 +718,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
335
718
|
|
|
336
719
|
// Update line geometry when polygon changes
|
|
337
720
|
useEffect(() => {
|
|
338
|
-
if (!lineRef.current || displayPolygon.length < 2) return
|
|
721
|
+
if (!showBorderLine || !lineRef.current || displayPolygon.length < 2) return
|
|
339
722
|
|
|
340
723
|
const positions: number[] = []
|
|
341
724
|
for (const [x, z] of displayPolygon) {
|
|
@@ -350,7 +733,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
350
733
|
|
|
351
734
|
lineRef.current.geometry.dispose()
|
|
352
735
|
lineRef.current.geometry = geometry
|
|
353
|
-
}, [displayPolygon, editY])
|
|
736
|
+
}, [displayPolygon, editY, showBorderLine])
|
|
354
737
|
|
|
355
738
|
if (displayPolygon.length < minVertices) return null
|
|
356
739
|
|
|
@@ -358,27 +741,35 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
358
741
|
const handleHeight = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
|
|
359
742
|
const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2
|
|
360
743
|
|
|
744
|
+
// Interactive handles are single SCENE_LAYER node-material meshes (like the
|
|
745
|
+
// registry arrow gizmos) so the ink-edge pass outlines them while they stay
|
|
746
|
+
// grabbable. The edge BAR and border line stay on EDITOR_LAYER, visual-only
|
|
747
|
+
// (raycast disabled) so they never steal clicks from the vertex/midpoint
|
|
748
|
+
// handles overlapping them — edge dragging starts from the chevron arrow
|
|
749
|
+
// outside the polygon edge.
|
|
361
750
|
const editorContent = (
|
|
362
751
|
<group>
|
|
363
752
|
{/* Border line */}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
753
|
+
{showBorderLine && (
|
|
754
|
+
<line
|
|
755
|
+
frustumCulled={false}
|
|
756
|
+
layers={EDITOR_LAYER}
|
|
757
|
+
raycast={NO_RAYCAST}
|
|
758
|
+
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
|
|
759
|
+
ref={lineRef}
|
|
760
|
+
renderOrder={10}
|
|
761
|
+
>
|
|
762
|
+
<bufferGeometry />
|
|
763
|
+
<lineBasicNodeMaterial
|
|
764
|
+
color={color}
|
|
765
|
+
depthTest={false}
|
|
766
|
+
depthWrite={false}
|
|
767
|
+
linewidth={2}
|
|
768
|
+
opacity={0.8}
|
|
769
|
+
transparent
|
|
770
|
+
/>
|
|
771
|
+
</line>
|
|
772
|
+
)}
|
|
382
773
|
|
|
383
774
|
{/* Vertex handles - blue cylinders that match surface height */}
|
|
384
775
|
{displayPolygon.map(([x, z], index) => {
|
|
@@ -386,58 +777,77 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
386
777
|
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
|
|
387
778
|
const radius = 0.1
|
|
388
779
|
const height = handleHeight
|
|
780
|
+
const point: [number, number] = [x!, z!]
|
|
781
|
+
const position: [number, number, number] = [x!, editY + height / 2, z!]
|
|
782
|
+
const handleProps: PolygonHandleHandlers = {
|
|
783
|
+
onClick: (e) => {
|
|
784
|
+
if (e.button !== 0) return
|
|
785
|
+
e.stopPropagation()
|
|
786
|
+
},
|
|
787
|
+
onDoubleClick: (e) => {
|
|
788
|
+
if (e.button !== 0) return
|
|
789
|
+
e.stopPropagation()
|
|
790
|
+
if (canDelete) {
|
|
791
|
+
handleDeleteVertex(index)
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
onPointerDown: (e) => {
|
|
795
|
+
if (e.button !== 0) return
|
|
796
|
+
e.stopPropagation()
|
|
797
|
+
setHoveredEdge(null)
|
|
798
|
+
onBeforeVertexDrag?.(index, point)
|
|
799
|
+
startDrag({
|
|
800
|
+
isDragging: true,
|
|
801
|
+
mode: 'vertex',
|
|
802
|
+
vertexIndex: index,
|
|
803
|
+
initialPosition: [x!, z!],
|
|
804
|
+
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
|
805
|
+
pointerId: e.pointerId,
|
|
806
|
+
})
|
|
807
|
+
},
|
|
808
|
+
onPointerEnter: (e) => {
|
|
809
|
+
e.stopPropagation()
|
|
810
|
+
setHoveredVertex(index)
|
|
811
|
+
},
|
|
812
|
+
onPointerLeave: (e) => {
|
|
813
|
+
e.stopPropagation()
|
|
814
|
+
setHoveredVertex(null)
|
|
815
|
+
},
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (renderVertexHandle) {
|
|
819
|
+
return (
|
|
820
|
+
<Fragment key={`vertex-${index}`}>
|
|
821
|
+
{renderVertexHandle({
|
|
822
|
+
canDelete,
|
|
823
|
+
handleProps,
|
|
824
|
+
height,
|
|
825
|
+
index,
|
|
826
|
+
isDragging,
|
|
827
|
+
isHovered,
|
|
828
|
+
point,
|
|
829
|
+
position,
|
|
830
|
+
radius,
|
|
831
|
+
})}
|
|
832
|
+
</Fragment>
|
|
833
|
+
)
|
|
834
|
+
}
|
|
389
835
|
|
|
390
836
|
return (
|
|
391
|
-
<
|
|
392
|
-
|
|
837
|
+
<OutlinedCylinderHandle
|
|
838
|
+
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
|
839
|
+
height={height}
|
|
393
840
|
key={`vertex-${index}`}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}}
|
|
399
|
-
onDoubleClick={(e) => {
|
|
400
|
-
if (e.button !== 0) return
|
|
401
|
-
e.stopPropagation()
|
|
402
|
-
if (canDelete) {
|
|
403
|
-
handleDeleteVertex(index)
|
|
404
|
-
}
|
|
405
|
-
}}
|
|
406
|
-
onPointerDown={(e) => {
|
|
407
|
-
if (e.button !== 0) return
|
|
408
|
-
e.stopPropagation()
|
|
409
|
-
setHoveredEdge(null)
|
|
410
|
-
setDragState({
|
|
411
|
-
isDragging: true,
|
|
412
|
-
mode: 'vertex',
|
|
413
|
-
vertexIndex: index,
|
|
414
|
-
initialPosition: [x!, z!],
|
|
415
|
-
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
|
416
|
-
pointerId: e.pointerId,
|
|
417
|
-
})
|
|
418
|
-
}}
|
|
419
|
-
onPointerEnter={(e) => {
|
|
420
|
-
e.stopPropagation()
|
|
421
|
-
setHoveredVertex(index)
|
|
422
|
-
}}
|
|
423
|
-
onPointerLeave={(e) => {
|
|
424
|
-
e.stopPropagation()
|
|
425
|
-
setHoveredVertex(null)
|
|
426
|
-
}}
|
|
427
|
-
position={[x!, editY + height / 2, z!]}
|
|
428
|
-
>
|
|
429
|
-
<cylinderGeometry args={[radius, radius, height, 16]} />
|
|
430
|
-
<meshStandardMaterial
|
|
431
|
-
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'}
|
|
432
|
-
/>
|
|
433
|
-
</mesh>
|
|
841
|
+
{...handleProps}
|
|
842
|
+
position={position}
|
|
843
|
+
radius={radius}
|
|
844
|
+
/>
|
|
434
845
|
)
|
|
435
846
|
})}
|
|
436
847
|
|
|
437
848
|
{allowPolygonMove && (
|
|
438
|
-
<
|
|
439
|
-
|
|
440
|
-
layers={EDITOR_LAYER}
|
|
849
|
+
<OutlinedCrossHandle
|
|
850
|
+
color={dragState?.mode === 'polygon' ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
|
441
851
|
onClick={(e) => {
|
|
442
852
|
if (e.button !== 0) return
|
|
443
853
|
e.stopPropagation()
|
|
@@ -446,7 +856,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
446
856
|
if (e.button !== 0) return
|
|
447
857
|
e.stopPropagation()
|
|
448
858
|
setHoveredEdge(null)
|
|
449
|
-
|
|
859
|
+
startDrag({
|
|
450
860
|
isDragging: true,
|
|
451
861
|
mode: 'polygon',
|
|
452
862
|
vertexIndex: null,
|
|
@@ -456,116 +866,156 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|
|
456
866
|
})
|
|
457
867
|
}}
|
|
458
868
|
position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]}
|
|
459
|
-
|
|
460
|
-
<sphereGeometry args={[0.09, 20, 20]} />
|
|
461
|
-
<meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
|
|
462
|
-
</mesh>
|
|
869
|
+
/>
|
|
463
870
|
)}
|
|
464
871
|
|
|
465
872
|
{allowEdgeMove &&
|
|
466
|
-
edgeHandles.map(({ index, length, midpoint, rotationY }) => {
|
|
873
|
+
edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => {
|
|
467
874
|
const isHovered = hoveredEdge === index
|
|
468
875
|
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
|
|
876
|
+
const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET
|
|
877
|
+
const arrowZ = midpoint[1] + outwardNormal[1] * EDGE_ARROW_OFFSET
|
|
878
|
+
|
|
879
|
+
const beginEdgeDrag = (e: { button: number; pointerId: number }) => {
|
|
880
|
+
const start = displayPolygon[index]
|
|
881
|
+
const end = displayPolygon[(index + 1) % displayPolygon.length]
|
|
882
|
+
if (!(start && end)) return
|
|
883
|
+
|
|
884
|
+
const edgeNormal = getEdgeNormal(start, end)
|
|
885
|
+
if (!edgeNormal) return
|
|
886
|
+
|
|
887
|
+
setHoveredEdge(null)
|
|
888
|
+
startDrag({
|
|
889
|
+
isDragging: true,
|
|
890
|
+
mode: 'edge',
|
|
891
|
+
vertexIndex: null,
|
|
892
|
+
edgeIndex: index,
|
|
893
|
+
edgeNormal,
|
|
894
|
+
initialPosition: cursorPosition,
|
|
895
|
+
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
|
896
|
+
pointerId: e.pointerId,
|
|
897
|
+
})
|
|
898
|
+
}
|
|
469
899
|
|
|
470
900
|
return (
|
|
471
|
-
<
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
e
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
901
|
+
<group key={`edge-${index}`}>
|
|
902
|
+
{/* Edge bar — VISUAL ONLY (raycast disabled). It runs the full
|
|
903
|
+
length of the edge and overlaps the vertex/midpoint handles at
|
|
904
|
+
its ends + centre, so making it pickable let edges steal those
|
|
905
|
+
clicks. Edge dragging runs through the chevron arrow below,
|
|
906
|
+
which sits outside the polygon and never overlaps a
|
|
907
|
+
vertex/midpoint handle. */}
|
|
908
|
+
<mesh
|
|
909
|
+
layers={EDITOR_LAYER}
|
|
910
|
+
position={[midpoint[0], edgeHandleY, midpoint[1]]}
|
|
911
|
+
raycast={NO_RAYCAST}
|
|
912
|
+
rotation={[0, rotationY, 0]}
|
|
913
|
+
>
|
|
914
|
+
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
|
|
915
|
+
<meshBasicMaterial
|
|
916
|
+
color={isDragging ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
|
917
|
+
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
|
|
918
|
+
transparent
|
|
919
|
+
/>
|
|
920
|
+
</mesh>
|
|
921
|
+
{/* Per-side resize arrow — the interactive edge-drag handle.
|
|
922
|
+
Points outward from the edge; dragging it translates only this
|
|
923
|
+
edge's two vertices along the outward normal. */}
|
|
924
|
+
<OutlinedEdgeArrowHandle
|
|
925
|
+
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
|
926
|
+
geometry={arrowGeometry}
|
|
927
|
+
onClick={(e) => {
|
|
928
|
+
if (e.button !== 0) return
|
|
929
|
+
e.stopPropagation()
|
|
930
|
+
}}
|
|
931
|
+
onPointerDown={(e) => {
|
|
932
|
+
if (e.button !== 0) return
|
|
933
|
+
e.stopPropagation()
|
|
934
|
+
beginEdgeDrag(e)
|
|
935
|
+
}}
|
|
936
|
+
onPointerEnter={(e) => {
|
|
937
|
+
e.stopPropagation()
|
|
938
|
+
setHoveredEdge(index)
|
|
939
|
+
}}
|
|
940
|
+
onPointerLeave={(e) => {
|
|
941
|
+
e.stopPropagation()
|
|
942
|
+
setHoveredEdge(null)
|
|
943
|
+
}}
|
|
944
|
+
position={[arrowX, edgeHandleY, arrowZ]}
|
|
945
|
+
rotationY={outwardAngle}
|
|
946
|
+
scale={EDGE_ARROW_SCALE}
|
|
516
947
|
/>
|
|
517
|
-
</
|
|
948
|
+
</group>
|
|
518
949
|
)
|
|
519
950
|
})}
|
|
520
951
|
|
|
521
952
|
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
|
|
522
|
-
{
|
|
953
|
+
{showMidpointHandles &&
|
|
954
|
+
!dragState &&
|
|
523
955
|
midpoints.map(([x, z], index) => {
|
|
524
956
|
const isHovered = hoveredMidpoint === index
|
|
525
957
|
const radius = 0.06
|
|
526
958
|
const height = handleHeight
|
|
959
|
+
const point: [number, number] = [x!, z!]
|
|
960
|
+
const position: [number, number, number] = [x!, editY + height / 2, z!]
|
|
961
|
+
const handleProps: PolygonHandleHandlers = {
|
|
962
|
+
onClick: (e) => {
|
|
963
|
+
if (e.button !== 0) return
|
|
964
|
+
e.stopPropagation()
|
|
965
|
+
},
|
|
966
|
+
onPointerDown: (e) => {
|
|
967
|
+
if (e.button !== 0) return
|
|
968
|
+
e.stopPropagation()
|
|
969
|
+
onBeforeVertexDrag?.(index + 1, point)
|
|
970
|
+
const insertedVertex = handleAddVertex(index, point)
|
|
971
|
+
if (insertedVertex.vertexIndex >= 0) {
|
|
972
|
+
startDrag({
|
|
973
|
+
isDragging: true,
|
|
974
|
+
mode: 'vertex',
|
|
975
|
+
vertexIndex: insertedVertex.vertexIndex,
|
|
976
|
+
initialPosition: point,
|
|
977
|
+
initialPolygon: insertedVertex.polygon,
|
|
978
|
+
pointerId: e.pointerId,
|
|
979
|
+
})
|
|
980
|
+
setHoveredMidpoint(null)
|
|
981
|
+
}
|
|
982
|
+
},
|
|
983
|
+
onPointerEnter: (e) => {
|
|
984
|
+
e.stopPropagation()
|
|
985
|
+
setHoveredMidpoint(index)
|
|
986
|
+
},
|
|
987
|
+
onPointerLeave: (e) => {
|
|
988
|
+
e.stopPropagation()
|
|
989
|
+
setHoveredMidpoint(null)
|
|
990
|
+
},
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (renderMidpointHandle) {
|
|
994
|
+
return (
|
|
995
|
+
<Fragment key={`midpoint-${index}`}>
|
|
996
|
+
{renderMidpointHandle({
|
|
997
|
+
handleProps,
|
|
998
|
+
height,
|
|
999
|
+
index,
|
|
1000
|
+
isHovered,
|
|
1001
|
+
point,
|
|
1002
|
+
position,
|
|
1003
|
+
radius,
|
|
1004
|
+
})}
|
|
1005
|
+
</Fragment>
|
|
1006
|
+
)
|
|
1007
|
+
}
|
|
527
1008
|
|
|
528
1009
|
return (
|
|
529
|
-
<
|
|
1010
|
+
<OutlinedCylinderHandle
|
|
1011
|
+
color={isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
|
1012
|
+
height={height}
|
|
530
1013
|
key={`midpoint-${index}`}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
onPointerDown={(e) => {
|
|
537
|
-
if (e.button !== 0) return
|
|
538
|
-
e.stopPropagation()
|
|
539
|
-
const insertedVertex = handleAddVertex(index, [x!, z!])
|
|
540
|
-
if (insertedVertex.vertexIndex >= 0) {
|
|
541
|
-
setDragState({
|
|
542
|
-
isDragging: true,
|
|
543
|
-
mode: 'vertex',
|
|
544
|
-
vertexIndex: insertedVertex.vertexIndex,
|
|
545
|
-
initialPosition: [x!, z!],
|
|
546
|
-
initialPolygon: insertedVertex.polygon,
|
|
547
|
-
pointerId: e.pointerId,
|
|
548
|
-
})
|
|
549
|
-
setHoveredMidpoint(null)
|
|
550
|
-
}
|
|
551
|
-
}}
|
|
552
|
-
onPointerEnter={(e) => {
|
|
553
|
-
e.stopPropagation()
|
|
554
|
-
setHoveredMidpoint(index)
|
|
555
|
-
}}
|
|
556
|
-
onPointerLeave={(e) => {
|
|
557
|
-
e.stopPropagation()
|
|
558
|
-
setHoveredMidpoint(null)
|
|
559
|
-
}}
|
|
560
|
-
position={[x!, editY + height / 2, z!]}
|
|
561
|
-
>
|
|
562
|
-
<cylinderGeometry args={[radius, radius, height, 16]} />
|
|
563
|
-
<meshStandardMaterial
|
|
564
|
-
color={isHovered ? '#4ade80' : '#22c55e'}
|
|
565
|
-
opacity={isHovered ? 1 : 0.7}
|
|
566
|
-
transparent
|
|
567
|
-
/>
|
|
568
|
-
</mesh>
|
|
1014
|
+
{...handleProps}
|
|
1015
|
+
opacity={isHovered ? 1 : 0.7}
|
|
1016
|
+
position={position}
|
|
1017
|
+
radius={radius}
|
|
1018
|
+
/>
|
|
569
1019
|
)
|
|
570
1020
|
})}
|
|
571
1021
|
</group>
|