@pascal-app/editor 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -9
- package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
- package/src/components/editor/bake-exporter.tsx +47 -0
- package/src/components/editor/custom-camera-controls.tsx +63 -25
- package/src/components/editor/editor-layout-mobile.tsx +5 -0
- package/src/components/editor/editor-layout-v2.tsx +18 -0
- package/src/components/editor/export-manager.tsx +71 -63
- package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
- package/src/components/editor/first-person/build-collider-world.ts +32 -5
- package/src/components/editor/first-person-controls.tsx +118 -7
- package/src/components/editor/floating-action-menu.tsx +410 -33
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floorplan-background-selection.test.ts +58 -0
- package/src/components/editor/floorplan-background-selection.ts +3 -2
- package/src/components/editor/floorplan-panel.tsx +1804 -849
- package/src/components/editor/grid.tsx +166 -39
- package/src/components/editor/group-actions.ts +457 -0
- package/src/components/editor/group-floating-action-menu.tsx +130 -0
- package/src/components/editor/group-move-3d.ts +397 -0
- package/src/components/editor/group-rotate-handle.tsx +102 -64
- package/src/components/editor/group-selection-box-3d.tsx +173 -0
- package/src/components/editor/group-transform-shared.test.ts +192 -1
- package/src/components/editor/group-transform-shared.ts +240 -7
- package/src/components/editor/handles/handle-arrow.tsx +81 -23
- package/src/components/editor/handles/use-handle-drag.ts +37 -4
- package/src/components/editor/index.tsx +248 -27
- package/src/components/editor/measurement-pill.tsx +62 -22
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +366 -173
- package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
- package/src/components/editor/quick-measurement-card.tsx +70 -0
- package/src/components/editor/quick-measurement-hud.tsx +28 -0
- package/src/components/editor/riser-diagram-panel.tsx +137 -0
- package/src/components/editor/selection-manager.tsx +812 -466
- package/src/components/editor/site-edge-labels.tsx +4 -4
- package/src/components/editor/slab-hole-highlights.tsx +13 -5
- package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
- package/src/components/editor/three-context-bridge.ts +22 -0
- package/src/components/editor/thumbnail-generator.tsx +136 -43
- package/src/components/editor/use-floorplan-background-placement.ts +113 -45
- package/src/components/editor/use-floorplan-scene-data.ts +5 -5
- package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
- package/src/components/editor/wall-measurement-label.tsx +3 -13
- package/src/components/editor/wall-move-side-handles.tsx +36 -21
- package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
- package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
- package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
- package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
- package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
- package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
- package/src/components/systems/selection-affordance-manager.tsx +38 -0
- package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
- package/src/components/systems/zone/zone-system.tsx +42 -13
- package/src/components/tools/elevator/elevator-tool.tsx +34 -8
- package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
- package/src/components/tools/fence/fence-drafting.ts +80 -8
- package/src/components/tools/item/move-tool.tsx +17 -13
- package/src/components/tools/item/placement-math.test.ts +13 -1
- package/src/components/tools/item/placement-math.ts +28 -2
- package/src/components/tools/item/placement-strategies.ts +246 -5
- package/src/components/tools/item/placement-types.ts +13 -1
- package/src/components/tools/item/use-draft-node.ts +31 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
- package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
- package/src/components/tools/roof/roof-tool.tsx +320 -94
- package/src/components/tools/select/box-select-state.ts +18 -7
- package/src/components/tools/select/box-select-tool.tsx +176 -40
- package/src/components/tools/select/marquee-geometry.test.ts +161 -0
- package/src/components/tools/select/marquee-geometry.ts +155 -0
- package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
- package/src/components/tools/select/select-candidates.ts +1 -1
- package/src/components/tools/shared/cursor-sphere.tsx +62 -26
- package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
- package/src/components/tools/shared/facing-indicator.tsx +87 -0
- package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
- package/src/components/tools/shared/placement-box.tsx +183 -1
- package/src/components/tools/shared/polygon-editor.tsx +276 -35
- package/src/components/tools/site/site-boundary-editor.tsx +88 -36
- package/src/components/tools/stair/stair-defaults.ts +1 -0
- package/src/components/tools/stair/stair-tool.tsx +82 -11
- package/src/components/tools/tool-manager.tsx +86 -25
- package/src/components/tools/wall/wall-drafting.test.ts +330 -0
- package/src/components/tools/wall/wall-drafting.ts +180 -103
- package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
- package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
- package/src/components/tools/zone/zone-tool.tsx +82 -88
- package/src/components/ui/action-menu/camera-actions.tsx +24 -17
- package/src/components/ui/action-menu/control-modes.tsx +40 -93
- package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
- package/src/components/ui/action-menu/index.tsx +2 -4
- package/src/components/ui/action-menu/measurement-control.tsx +188 -0
- package/src/components/ui/action-menu/structure-tools.tsx +22 -13
- package/src/components/ui/action-menu/view-toggles.tsx +42 -90
- package/src/components/ui/command-palette/editor-commands.tsx +2 -2
- package/src/components/ui/command-palette/index.tsx +4 -3
- package/src/components/ui/controls/material-paint-panel.tsx +86 -17
- package/src/components/ui/controls/material-picker.tsx +83 -152
- package/src/components/ui/controls/material-properties-editor.tsx +108 -0
- package/src/components/ui/controls/metric-control.tsx +118 -44
- package/src/components/ui/controls/scene-material-list.tsx +247 -0
- package/src/components/ui/controls/slider-control.tsx +88 -38
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/helpers/building-helper.tsx +10 -23
- package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
- package/src/components/ui/helpers/helper-manager.tsx +227 -14
- package/src/components/ui/helpers/item-helper.tsx +28 -32
- package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
- package/src/components/ui/helpers/roof-helper.tsx +10 -12
- package/src/components/ui/icon-ref.tsx +47 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
- package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/node-display.ts +17 -17
- package/src/components/ui/panels/panel-manager.tsx +22 -13
- package/src/components/ui/panels/panel-wrapper.tsx +24 -3
- package/src/components/ui/panels/parametric-inspector.tsx +15 -2
- package/src/components/ui/panels/reference-panel.tsx +54 -19
- package/src/components/ui/primitives/shortcut-token.tsx +39 -3
- package/src/components/ui/primitives/sidebar.tsx +1 -0
- package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
- package/src/components/ui/sidebar/icon-rail.tsx +50 -31
- package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
- package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
- package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
- package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
- package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
- package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +42 -28
- package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
- package/src/components/ui/snap-target-badge.test.tsx +40 -0
- package/src/components/ui/snap-target-badge.tsx +88 -0
- package/src/components/viewer-overlay.tsx +85 -10
- package/src/components/viewer-zone-system.tsx +6 -1
- package/src/hooks/use-auto-save.test.ts +14 -0
- package/src/hooks/use-auto-save.ts +54 -7
- package/src/hooks/use-ceiling-events.ts +3 -2
- package/src/hooks/use-drag-action.ts +4 -1
- package/src/hooks/use-keyboard.ts +385 -40
- package/src/index.tsx +219 -6
- package/src/lib/active-placement-surface.ts +35 -0
- package/src/lib/ceiling-plan-snap.ts +24 -0
- package/src/lib/contextual-help.test.ts +112 -0
- package/src/lib/contextual-help.ts +144 -0
- package/src/lib/continuation.ts +64 -0
- package/src/lib/direct-manipulation.test.ts +192 -0
- package/src/lib/direct-manipulation.ts +109 -0
- package/src/lib/editor-api.ts +23 -35
- package/src/lib/floorplan/apply-alignment.test.ts +25 -0
- package/src/lib/floorplan/apply-alignment.ts +18 -12
- package/src/lib/floorplan/floorplan-export.tsx +364 -0
- package/src/lib/floorplan/geometry.ts +53 -0
- package/src/lib/floorplan/index.ts +3 -0
- package/src/lib/floorplan/items.ts +5 -2
- package/src/lib/floorplan/plan-coords.ts +21 -0
- package/src/lib/fresh-planar-placement.test.ts +240 -3
- package/src/lib/fresh-planar-placement.ts +68 -1
- package/src/lib/glb-export.test.ts +441 -0
- package/src/lib/glb-export.ts +874 -0
- package/src/lib/history.ts +9 -0
- package/src/lib/interaction/hot-set.test.ts +133 -0
- package/src/lib/interaction/hot-set.ts +67 -0
- package/src/lib/interaction/overlay-policy.test.ts +51 -0
- package/src/lib/interaction/overlay-policy.ts +59 -0
- package/src/lib/interaction/scope.ts +211 -0
- package/src/lib/level-selection.ts +2 -2
- package/src/lib/material-paint.ts +36 -49
- package/src/lib/measurement-kind.test.ts +30 -0
- package/src/lib/measurement-kind.ts +19 -0
- package/src/lib/measurement-label.test.ts +47 -0
- package/src/lib/measurement-label.ts +68 -0
- package/src/lib/measurement-parser.ts +116 -0
- package/src/lib/measurements.test.ts +183 -0
- package/src/lib/measurements.ts +199 -0
- package/src/lib/paint-scope.test.ts +454 -0
- package/src/lib/paint-scope.ts +461 -0
- package/src/lib/placement-drag-release.ts +23 -0
- package/src/lib/planar-cursor-placement.test.ts +57 -0
- package/src/lib/plugin-panels.test.ts +19 -0
- package/src/lib/plugin-panels.ts +91 -0
- package/src/lib/quick-measurement.test.ts +77 -0
- package/src/lib/quick-measurement.ts +109 -0
- package/src/lib/roof-duplication.ts +6 -6
- package/src/lib/roof-hover-outline-proxy.ts +22 -0
- package/src/lib/roof-wall-hit.ts +164 -0
- package/src/lib/scene-clipboard.test.ts +196 -0
- package/src/lib/scene-clipboard.ts +73 -8
- package/src/lib/scene.ts +28 -5
- package/src/lib/selection-routing.test.ts +298 -0
- package/src/lib/selection-routing.ts +175 -0
- package/src/lib/sfx/index.ts +1 -0
- package/src/lib/sfx/movement-tick.test.ts +65 -0
- package/src/lib/sfx/movement-tick.ts +18 -0
- package/src/lib/sfx-bus.ts +50 -13
- package/src/lib/sfx-player.test.ts +124 -0
- package/src/lib/sfx-player.ts +111 -66
- package/src/lib/slab-plan-snap.test.ts +93 -0
- package/src/lib/slab-plan-snap.ts +108 -0
- package/src/lib/snapping-mode.test.ts +138 -0
- package/src/lib/snapping-mode.ts +179 -0
- package/src/lib/stair-duplication.ts +4 -4
- package/src/lib/surface-plan-snap.test.ts +71 -0
- package/src/lib/surface-plan-snap.ts +256 -0
- package/src/lib/use-linear-display.ts +40 -0
- package/src/lib/world-grid-snap.ts +37 -5
- package/src/store/use-direct-manipulation-feedback.ts +20 -0
- package/src/store/use-editor.tsx +484 -114
- package/src/store/use-facing-pose.ts +44 -0
- package/src/store/use-fence-curve-draft.ts +21 -0
- package/src/store/use-floorplan-draft-preview.ts +101 -0
- package/src/store/use-floorplan-marquee.ts +50 -0
- package/src/store/use-interaction-scope.test.ts +186 -0
- package/src/store/use-interaction-scope.ts +132 -0
- package/src/store/use-measurement-draft.test.ts +530 -0
- package/src/store/use-measurement-draft.ts +543 -0
- package/src/store/use-opening-guides.ts +41 -0
- package/src/store/use-placement-preview.ts +11 -3
- package/src/store/use-quick-measurement-hud.test.ts +53 -0
- package/src/store/use-quick-measurement-hud.ts +77 -0
- package/src/store/use-segment-draft-chain.ts +28 -0
- package/src/store/use-stair-build-preview.ts +43 -0
- package/src/store/use-wall-snap-indicator.ts +2 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
- package/src/components/editor/group-move-handle.tsx +0 -316
- package/src/components/ui/panels/paint-panel.tsx +0 -163
- package/src/lib/level-name.ts +0 -11
|
@@ -1,14 +1,31 @@
|
|
|
1
|
-
import { sceneRegistry, type ZoneNode } from '@pascal-app/core'
|
|
1
|
+
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
|
|
2
2
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
3
|
import { useThree } from '@react-three/fiber'
|
|
4
4
|
import { useCallback, useEffect, useRef } from 'react'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Box3,
|
|
7
|
+
type Camera,
|
|
8
|
+
Matrix4,
|
|
9
|
+
type Object3D,
|
|
10
|
+
Plane,
|
|
11
|
+
Raycaster,
|
|
12
|
+
Vector2,
|
|
13
|
+
Vector3,
|
|
14
|
+
} from 'three'
|
|
6
15
|
import useEditor from '../../../store/use-editor'
|
|
16
|
+
import useInteractionScope from '../../../store/use-interaction-scope'
|
|
7
17
|
import {
|
|
8
18
|
clearBoxSelectHandled,
|
|
9
19
|
isBoxSelectPointerSuppressed,
|
|
10
20
|
markBoxSelectHandled,
|
|
11
21
|
} from './box-select-state'
|
|
22
|
+
import {
|
|
23
|
+
convexHull2D,
|
|
24
|
+
type Point2,
|
|
25
|
+
polygonsIntersect,
|
|
26
|
+
rectIntersectsHull,
|
|
27
|
+
segmentIntersectsPolygon,
|
|
28
|
+
} from './marquee-geometry'
|
|
12
29
|
import { PlaneBoxSelectTool } from './plane-box-select-tool'
|
|
13
30
|
import {
|
|
14
31
|
createScreenRectangleSelectionElement,
|
|
@@ -18,14 +35,20 @@ import {
|
|
|
18
35
|
SCREEN_RECTANGLE_SELECTION_DRAG_THRESHOLD_PX,
|
|
19
36
|
type ScreenRect,
|
|
20
37
|
screenRectFromDomRect,
|
|
21
|
-
screenRectsIntersect,
|
|
22
38
|
updateScreenRectangleSelectionElement,
|
|
23
39
|
} from './screen-rectangle-selection'
|
|
24
40
|
import { collectSelectableCandidateIds } from './select-candidates'
|
|
25
41
|
|
|
26
42
|
const tempBox = new Box3()
|
|
43
|
+
const tempChildBox = new Box3()
|
|
44
|
+
const tempInvWorld = new Matrix4()
|
|
45
|
+
const tempRelMatrix = new Matrix4()
|
|
27
46
|
const tempWorldPoint = new Vector3()
|
|
28
47
|
const tempScreenPoint = new Vector3()
|
|
48
|
+
const tempNDC = new Vector2()
|
|
49
|
+
const tempPlane = new Plane()
|
|
50
|
+
const tempRaycaster = new Raycaster()
|
|
51
|
+
const UP = new Vector3(0, 1, 0)
|
|
29
52
|
const boxCorners = [
|
|
30
53
|
new Vector3(),
|
|
31
54
|
new Vector3(),
|
|
@@ -58,55 +81,72 @@ function projectWorldPointToScreen(
|
|
|
58
81
|
]
|
|
59
82
|
}
|
|
60
83
|
|
|
61
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Union bounding box of the object's mesh geometry in the OBJECT's OWN frame
|
|
86
|
+
* (an oriented box). The world AABB the previous implementation used inflates
|
|
87
|
+
* around rotated geometry — a diagonal wall's world AABB spans a whole square
|
|
88
|
+
* — and projecting THAT to a screen AABB inflates again, which made the
|
|
89
|
+
* marquee select objects visually far from the cursor.
|
|
90
|
+
*/
|
|
91
|
+
function computeLocalBox(object: Object3D): Box3 | null {
|
|
92
|
+
tempBox.makeEmpty()
|
|
93
|
+
tempInvWorld.copy(object.matrixWorld).invert()
|
|
94
|
+
object.traverse((child) => {
|
|
95
|
+
const mesh = child as {
|
|
96
|
+
isMesh?: boolean
|
|
97
|
+
geometry?: { boundingBox: Box3 | null; computeBoundingBox: () => void }
|
|
98
|
+
matrixWorld: import('three').Matrix4
|
|
99
|
+
}
|
|
100
|
+
if (!mesh.isMesh || !mesh.geometry) return
|
|
101
|
+
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
|
|
102
|
+
const bounds = mesh.geometry.boundingBox
|
|
103
|
+
if (!bounds || bounds.isEmpty()) return
|
|
104
|
+
tempChildBox.copy(bounds)
|
|
105
|
+
tempRelMatrix.multiplyMatrices(tempInvWorld, mesh.matrixWorld)
|
|
106
|
+
tempChildBox.applyMatrix4(tempRelMatrix)
|
|
107
|
+
tempBox.union(tempChildBox)
|
|
108
|
+
})
|
|
109
|
+
return tempBox.isEmpty() ? null : tempBox
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Screen-space convex hull of the object's oriented bounding box. */
|
|
113
|
+
function getObjectScreenHull(
|
|
62
114
|
object: Object3D,
|
|
63
115
|
camera: Camera,
|
|
64
116
|
canvasRect: DOMRect,
|
|
65
|
-
):
|
|
117
|
+
): Point2[] | null {
|
|
66
118
|
object.updateWorldMatrix(true, true)
|
|
67
|
-
|
|
119
|
+
const localBox = computeLocalBox(object)
|
|
68
120
|
|
|
69
|
-
if (
|
|
121
|
+
if (!localBox) {
|
|
70
122
|
object.getWorldPosition(tempWorldPoint)
|
|
71
123
|
const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
|
|
72
|
-
|
|
73
|
-
const [x, y] = projected
|
|
74
|
-
return { minX: x, minY: y, maxX: x, maxY: y }
|
|
124
|
+
return projected ? [projected] : null
|
|
75
125
|
}
|
|
76
126
|
|
|
77
|
-
boxCorners[0]!.set(
|
|
78
|
-
boxCorners[1]!.set(
|
|
79
|
-
boxCorners[2]!.set(
|
|
80
|
-
boxCorners[3]!.set(
|
|
81
|
-
boxCorners[4]!.set(
|
|
82
|
-
boxCorners[5]!.set(
|
|
83
|
-
boxCorners[6]!.set(
|
|
84
|
-
boxCorners[7]!.set(
|
|
85
|
-
|
|
86
|
-
let minX = Number.POSITIVE_INFINITY
|
|
87
|
-
let minY = Number.POSITIVE_INFINITY
|
|
88
|
-
let maxX = Number.NEGATIVE_INFINITY
|
|
89
|
-
let maxY = Number.NEGATIVE_INFINITY
|
|
127
|
+
boxCorners[0]!.set(localBox.min.x, localBox.min.y, localBox.min.z)
|
|
128
|
+
boxCorners[1]!.set(localBox.min.x, localBox.min.y, localBox.max.z)
|
|
129
|
+
boxCorners[2]!.set(localBox.min.x, localBox.max.y, localBox.min.z)
|
|
130
|
+
boxCorners[3]!.set(localBox.min.x, localBox.max.y, localBox.max.z)
|
|
131
|
+
boxCorners[4]!.set(localBox.max.x, localBox.min.y, localBox.min.z)
|
|
132
|
+
boxCorners[5]!.set(localBox.max.x, localBox.min.y, localBox.max.z)
|
|
133
|
+
boxCorners[6]!.set(localBox.max.x, localBox.max.y, localBox.min.z)
|
|
134
|
+
boxCorners[7]!.set(localBox.max.x, localBox.max.y, localBox.max.z)
|
|
90
135
|
|
|
136
|
+
const projectedPoints: Point2[] = []
|
|
91
137
|
for (const corner of boxCorners) {
|
|
138
|
+
corner.applyMatrix4(object.matrixWorld)
|
|
92
139
|
const projected = projectWorldPointToScreen(corner, camera, canvasRect)
|
|
93
|
-
if (
|
|
94
|
-
const [x, y] = projected
|
|
95
|
-
minX = Math.min(minX, x)
|
|
96
|
-
minY = Math.min(minY, y)
|
|
97
|
-
maxX = Math.max(maxX, x)
|
|
98
|
-
maxY = Math.max(maxY, y)
|
|
140
|
+
if (projected) projectedPoints.push(projected)
|
|
99
141
|
}
|
|
100
142
|
|
|
101
|
-
if (
|
|
102
|
-
|
|
143
|
+
if (projectedPoints.length === 0) {
|
|
144
|
+
object.getWorldPosition(tempWorldPoint)
|
|
145
|
+
const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
|
|
146
|
+
return projected ? [projected] : null
|
|
103
147
|
}
|
|
104
148
|
|
|
105
|
-
|
|
106
|
-
const projected = projectWorldPointToScreen(tempWorldPoint, camera, canvasRect)
|
|
107
|
-
if (!projected) return null
|
|
108
|
-
const [x, y] = projected
|
|
109
|
-
return { minX: x, minY: y, maxX: x, maxY: y }
|
|
149
|
+
return convexHull2D(projectedPoints)
|
|
110
150
|
}
|
|
111
151
|
|
|
112
152
|
function isObjectVisible(object: Object3D): boolean {
|
|
@@ -118,6 +158,46 @@ function isObjectVisible(object: Object3D): boolean {
|
|
|
118
158
|
return true
|
|
119
159
|
}
|
|
120
160
|
|
|
161
|
+
const isVec2 = (v: unknown): v is [number, number] =>
|
|
162
|
+
Array.isArray(v) && v.length === 2 && v.every((n) => typeof n === 'number')
|
|
163
|
+
const isVec2Array = (v: unknown): v is [number, number][] =>
|
|
164
|
+
Array.isArray(v) && v.length > 0 && v.every(isVec2)
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The marquee rect projected onto the active level's floor plane, in the
|
|
168
|
+
* LEVEL frame — a convex quad (perspective keeps rect convexity). Null when
|
|
169
|
+
* any corner ray misses the plane (camera near the horizon); callers fall
|
|
170
|
+
* back to the screen-hull test then.
|
|
171
|
+
*/
|
|
172
|
+
function marqueeGroundQuad(rect: ScreenRect, camera: Camera, canvasRect: DOMRect): Point2[] | null {
|
|
173
|
+
const levelId = useViewer.getState().selection.levelId
|
|
174
|
+
const levelObject = levelId ? sceneRegistry.nodes.get(levelId) : null
|
|
175
|
+
if (!levelObject) return null
|
|
176
|
+
levelObject.updateWorldMatrix(true, false)
|
|
177
|
+
tempInvWorld.copy(levelObject.matrixWorld).invert()
|
|
178
|
+
levelObject.getWorldPosition(tempWorldPoint)
|
|
179
|
+
tempPlane.set(UP, -tempWorldPoint.y)
|
|
180
|
+
|
|
181
|
+
const corners: [number, number][] = [
|
|
182
|
+
[rect.minX, rect.minY],
|
|
183
|
+
[rect.maxX, rect.minY],
|
|
184
|
+
[rect.maxX, rect.maxY],
|
|
185
|
+
[rect.minX, rect.maxY],
|
|
186
|
+
]
|
|
187
|
+
const quad: Point2[] = []
|
|
188
|
+
for (const [cx, cy] of corners) {
|
|
189
|
+
tempNDC.set(
|
|
190
|
+
((cx - canvasRect.left) / canvasRect.width) * 2 - 1,
|
|
191
|
+
-((cy - canvasRect.top) / canvasRect.height) * 2 + 1,
|
|
192
|
+
)
|
|
193
|
+
tempRaycaster.setFromCamera(tempNDC, camera)
|
|
194
|
+
if (!tempRaycaster.ray.intersectPlane(tempPlane, tempWorldPoint)) return null
|
|
195
|
+
tempWorldPoint.applyMatrix4(tempInvWorld)
|
|
196
|
+
quad.push([tempWorldPoint.x, tempWorldPoint.z])
|
|
197
|
+
}
|
|
198
|
+
return quad
|
|
199
|
+
}
|
|
200
|
+
|
|
121
201
|
function collectNodeIdsInScreenRect(
|
|
122
202
|
rect: ScreenRect,
|
|
123
203
|
camera: Camera,
|
|
@@ -126,11 +206,37 @@ function collectNodeIdsInScreenRect(
|
|
|
126
206
|
const canvasRect = canvas.getBoundingClientRect()
|
|
127
207
|
const result: string[] = []
|
|
128
208
|
|
|
209
|
+
// Plan-footprint membership for the data kinds (walls / fences by their
|
|
210
|
+
// segment, slabs / ceilings / zones by their polygon) — exact under any
|
|
211
|
+
// rotation, matching the plane-marquee tool's semantics. Kinds whose
|
|
212
|
+
// placement lives in mesh transforms (items, columns, …) intersect the
|
|
213
|
+
// marquee with their oriented bbox projected to a screen hull instead.
|
|
214
|
+
const quad = marqueeGroundQuad(rect, camera, canvasRect)
|
|
215
|
+
const nodes = useScene.getState().nodes
|
|
216
|
+
|
|
129
217
|
for (const id of collectSelectableCandidateIds()) {
|
|
130
218
|
const object = sceneRegistry.nodes.get(id)
|
|
131
219
|
if (!object || !isObjectVisible(object)) continue
|
|
132
|
-
|
|
133
|
-
if (
|
|
220
|
+
|
|
221
|
+
if (quad) {
|
|
222
|
+
const node = nodes[id as keyof typeof nodes] as
|
|
223
|
+
| { start?: unknown; end?: unknown; polygon?: unknown }
|
|
224
|
+
| undefined
|
|
225
|
+
if (node) {
|
|
226
|
+
const { start, end, polygon } = node
|
|
227
|
+
if (isVec2(start) && isVec2(end)) {
|
|
228
|
+
if (segmentIntersectsPolygon(start, end, quad)) result.push(id)
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
if (isVec2Array(polygon)) {
|
|
232
|
+
if (polygonsIntersect(polygon, quad)) result.push(id)
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const hull = getObjectScreenHull(object, camera, canvasRect)
|
|
239
|
+
if (hull && rectIntersectsHull(rect, hull)) {
|
|
134
240
|
result.push(id)
|
|
135
241
|
}
|
|
136
242
|
}
|
|
@@ -139,7 +245,7 @@ function collectNodeIdsInScreenRect(
|
|
|
139
245
|
}
|
|
140
246
|
|
|
141
247
|
function commitBoxSelection(ids: string[], event: PointerEvent) {
|
|
142
|
-
const shouldAppend = event.metaKey || event.ctrlKey
|
|
248
|
+
const shouldAppend = event.metaKey || event.ctrlKey || event.shiftKey
|
|
143
249
|
const { phase, structureLayer } = useEditor.getState()
|
|
144
250
|
const viewer = useViewer.getState()
|
|
145
251
|
|
|
@@ -191,6 +297,12 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
|
|
191
297
|
const currentClientXRef = useRef(0)
|
|
192
298
|
const currentClientYRef = useRef(0)
|
|
193
299
|
const spaceDownRef = useRef(false)
|
|
300
|
+
// rAF throttle for the expensive marquee preview pass. pointermove can fire
|
|
301
|
+
// several times per animation frame; the per-node AABB projection in
|
|
302
|
+
// `collectNodeIdsInScreenRect` only needs to run once per frame. We stash the
|
|
303
|
+
// latest clamped rect and process it inside the rAF callback.
|
|
304
|
+
const previewRafRef = useRef<number | null>(null)
|
|
305
|
+
const pendingPreviewRectRef = useRef<ScreenRect | null>(null)
|
|
194
306
|
|
|
195
307
|
const syncPreviewSelectedIds = useCallback(
|
|
196
308
|
(nextIds: string[]) => {
|
|
@@ -206,6 +318,11 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
|
|
206
318
|
pointerDownRef.current = false
|
|
207
319
|
isDraggingRef.current = false
|
|
208
320
|
pointerIdRef.current = null
|
|
321
|
+
if (previewRafRef.current !== null) {
|
|
322
|
+
cancelAnimationFrame(previewRafRef.current)
|
|
323
|
+
previewRafRef.current = null
|
|
324
|
+
}
|
|
325
|
+
pendingPreviewRectRef.current = null
|
|
209
326
|
hideScreenRectangleSelectionElement(elementRef.current)
|
|
210
327
|
syncPreviewSelectedIds([])
|
|
211
328
|
|
|
@@ -213,6 +330,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
|
|
213
330
|
useViewer.getState().setInputDragging(false)
|
|
214
331
|
ownsInputDraggingRef.current = false
|
|
215
332
|
}
|
|
333
|
+
useInteractionScope.getState().endIf((s) => s.kind === 'box-select')
|
|
216
334
|
}, [syncPreviewSelectedIds])
|
|
217
335
|
|
|
218
336
|
useEffect(() => {
|
|
@@ -263,6 +381,14 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
|
|
263
381
|
useEffect(() => {
|
|
264
382
|
const canvas = gl.domElement
|
|
265
383
|
|
|
384
|
+
const flushPreview = () => {
|
|
385
|
+
previewRafRef.current = null
|
|
386
|
+
const rect = pendingPreviewRectRef.current
|
|
387
|
+
if (!rect) return
|
|
388
|
+
pendingPreviewRectRef.current = null
|
|
389
|
+
syncPreviewSelectedIds(collectNodeIdsInScreenRect(rect, camera, canvas))
|
|
390
|
+
}
|
|
391
|
+
|
|
266
392
|
const updateDrag = (event: PointerEvent) => {
|
|
267
393
|
if (!pointerDownRef.current) return
|
|
268
394
|
if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) return
|
|
@@ -291,6 +417,7 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
|
|
291
417
|
isDraggingRef.current = true
|
|
292
418
|
ownsInputDraggingRef.current = true
|
|
293
419
|
useViewer.getState().setInputDragging(true)
|
|
420
|
+
useInteractionScope.getState().begin({ kind: 'box-select' })
|
|
294
421
|
markBoxSelectHandled()
|
|
295
422
|
try {
|
|
296
423
|
canvas.setPointerCapture(event.pointerId)
|
|
@@ -311,13 +438,22 @@ const ScreenRectangleSelectTool: React.FC = () => {
|
|
|
311
438
|
screenRectFromDomRect(canvas.getBoundingClientRect()),
|
|
312
439
|
)
|
|
313
440
|
if (!clampedRect) {
|
|
441
|
+
if (previewRafRef.current !== null) {
|
|
442
|
+
cancelAnimationFrame(previewRafRef.current)
|
|
443
|
+
previewRafRef.current = null
|
|
444
|
+
}
|
|
445
|
+
pendingPreviewRectRef.current = null
|
|
314
446
|
hideScreenRectangleSelectionElement(elementRef.current)
|
|
315
447
|
syncPreviewSelectedIds([])
|
|
316
448
|
return
|
|
317
449
|
}
|
|
318
450
|
|
|
319
451
|
updateScreenRectangleSelectionElement(elementRef.current!, clampedRect)
|
|
320
|
-
|
|
452
|
+
// Coalesce the per-node AABB projection to one run per animation frame.
|
|
453
|
+
pendingPreviewRectRef.current = clampedRect
|
|
454
|
+
if (previewRafRef.current === null) {
|
|
455
|
+
previewRafRef.current = requestAnimationFrame(flushPreview)
|
|
456
|
+
}
|
|
321
457
|
}
|
|
322
458
|
|
|
323
459
|
const finishDrag = (event: PointerEvent) => {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
convexHull2D,
|
|
4
|
+
type Point2,
|
|
5
|
+
polygonsIntersect,
|
|
6
|
+
rectIntersectsHull,
|
|
7
|
+
type ScreenRect,
|
|
8
|
+
segmentIntersectsPolygon,
|
|
9
|
+
} from './marquee-geometry'
|
|
10
|
+
|
|
11
|
+
const rect = (minX: number, minY: number, maxX: number, maxY: number): ScreenRect => ({
|
|
12
|
+
minX,
|
|
13
|
+
minY,
|
|
14
|
+
maxX,
|
|
15
|
+
maxY,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('convexHull2D', () => {
|
|
19
|
+
test('drops interior points and keeps the hull', () => {
|
|
20
|
+
const hull = convexHull2D([
|
|
21
|
+
[0, 0],
|
|
22
|
+
[4, 0],
|
|
23
|
+
[4, 4],
|
|
24
|
+
[0, 4],
|
|
25
|
+
[2, 2],
|
|
26
|
+
[1, 3],
|
|
27
|
+
])
|
|
28
|
+
expect(hull).toHaveLength(4)
|
|
29
|
+
const set = new Set(hull.map((p) => p.join(',')))
|
|
30
|
+
expect(set).toEqual(new Set(['0,0', '4,0', '4,4', '0,4']))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('passes degenerate inputs through', () => {
|
|
34
|
+
expect(convexHull2D([])).toEqual([])
|
|
35
|
+
expect(convexHull2D([[1, 2]])).toEqual([[1, 2]])
|
|
36
|
+
expect(
|
|
37
|
+
convexHull2D([
|
|
38
|
+
[0, 0],
|
|
39
|
+
[2, 2],
|
|
40
|
+
]),
|
|
41
|
+
).toHaveLength(2)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('rectIntersectsHull', () => {
|
|
46
|
+
// The regression case: a thin diagonal wall. Its world AABB spans the full
|
|
47
|
+
// square, so the old AABB test matched a marquee sitting in the empty
|
|
48
|
+
// corner; the hull test must not.
|
|
49
|
+
const diagonalWall: Point2[] = convexHull2D([
|
|
50
|
+
[0, 0],
|
|
51
|
+
[1, 0.6],
|
|
52
|
+
[10, 9.4],
|
|
53
|
+
[10, 10],
|
|
54
|
+
[9, 9.4],
|
|
55
|
+
[0, 0.6],
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
test('marquee in the empty corner of a diagonal shape does not match', () => {
|
|
59
|
+
expect(rectIntersectsHull(rect(7, 0, 9, 2), diagonalWall)).toBe(false)
|
|
60
|
+
expect(rectIntersectsHull(rect(0, 7, 2, 9), diagonalWall)).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('marquee crossing the diagonal matches', () => {
|
|
64
|
+
expect(rectIntersectsHull(rect(4, 4, 6, 6), diagonalWall)).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('hull vertex inside the marquee matches', () => {
|
|
68
|
+
expect(rectIntersectsHull(rect(-1, -1, 0.5, 0.5), diagonalWall)).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('marquee fully inside a large hull matches', () => {
|
|
72
|
+
const square: Point2[] = [
|
|
73
|
+
[0, 0],
|
|
74
|
+
[10, 0],
|
|
75
|
+
[10, 10],
|
|
76
|
+
[0, 10],
|
|
77
|
+
]
|
|
78
|
+
expect(rectIntersectsHull(rect(4, 4, 5, 5), square)).toBe(true)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('edge-only crossing (no vertices contained either way) matches', () => {
|
|
82
|
+
// Tall thin rect crossing a wide thin hull like a plus sign.
|
|
83
|
+
const bar: Point2[] = [
|
|
84
|
+
[0, 4],
|
|
85
|
+
[10, 4],
|
|
86
|
+
[10, 6],
|
|
87
|
+
[0, 6],
|
|
88
|
+
]
|
|
89
|
+
expect(rectIntersectsHull(rect(4, 0, 6, 10), bar)).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('disjoint shapes do not match', () => {
|
|
93
|
+
const tri: Point2[] = [
|
|
94
|
+
[0, 0],
|
|
95
|
+
[2, 0],
|
|
96
|
+
[1, 2],
|
|
97
|
+
]
|
|
98
|
+
expect(rectIntersectsHull(rect(5, 5, 7, 7), tri)).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('single projected point matches only when inside the rect', () => {
|
|
102
|
+
expect(rectIntersectsHull(rect(0, 0, 2, 2), [[1, 1]])).toBe(true)
|
|
103
|
+
expect(rectIntersectsHull(rect(0, 0, 2, 2), [[3, 3]])).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('segmentIntersectsPolygon', () => {
|
|
108
|
+
const quad: Point2[] = [
|
|
109
|
+
[0, 0],
|
|
110
|
+
[4, 0],
|
|
111
|
+
[4, 4],
|
|
112
|
+
[0, 4],
|
|
113
|
+
]
|
|
114
|
+
test('crossing segment matches', () => {
|
|
115
|
+
expect(segmentIntersectsPolygon([-1, 2], [5, 2], quad)).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
test('fully inside matches', () => {
|
|
118
|
+
expect(segmentIntersectsPolygon([1, 1], [2, 2], quad)).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
test('outside segment does not match', () => {
|
|
121
|
+
expect(segmentIntersectsPolygon([5, 5], [7, 8], quad)).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('polygonsIntersect', () => {
|
|
126
|
+
const quad: Point2[] = [
|
|
127
|
+
[0, 0],
|
|
128
|
+
[4, 0],
|
|
129
|
+
[4, 4],
|
|
130
|
+
[0, 4],
|
|
131
|
+
]
|
|
132
|
+
test('overlapping polygons match', () => {
|
|
133
|
+
expect(
|
|
134
|
+
polygonsIntersect(quad, [
|
|
135
|
+
[3, 3],
|
|
136
|
+
[6, 3],
|
|
137
|
+
[6, 6],
|
|
138
|
+
[3, 6],
|
|
139
|
+
]),
|
|
140
|
+
).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
test('containment matches both ways', () => {
|
|
143
|
+
const inner: Point2[] = [
|
|
144
|
+
[1, 1],
|
|
145
|
+
[2, 1],
|
|
146
|
+
[2, 2],
|
|
147
|
+
]
|
|
148
|
+
expect(polygonsIntersect(quad, inner)).toBe(true)
|
|
149
|
+
expect(polygonsIntersect(inner, quad)).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
test('a rotated diamond beside the quad does not match', () => {
|
|
152
|
+
expect(
|
|
153
|
+
polygonsIntersect(quad, [
|
|
154
|
+
[7, 2],
|
|
155
|
+
[9, 0],
|
|
156
|
+
[11, 2],
|
|
157
|
+
[9, 4],
|
|
158
|
+
]),
|
|
159
|
+
).toBe(false)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Pure 2D geometry for the screen-rect marquee's membership test. The
|
|
2
|
+
// marquee intersects each node's ORIENTED bounding box projected to screen —
|
|
3
|
+
// a convex hull — instead of a screen-space AABB of the world AABB, whose
|
|
4
|
+
// double inflation (world AABB around rotated geometry, then a 2D AABB
|
|
5
|
+
// around its projection) selected objects visually far from the cursor.
|
|
6
|
+
|
|
7
|
+
export type ScreenRect = {
|
|
8
|
+
minX: number
|
|
9
|
+
minY: number
|
|
10
|
+
maxX: number
|
|
11
|
+
maxY: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type Point2 = [number, number]
|
|
15
|
+
|
|
16
|
+
function cross(o: Point2, a: Point2, b: Point2): number {
|
|
17
|
+
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Andrew monotone-chain convex hull. Returns CCW hull without the closing
|
|
21
|
+
* point; degenerate inputs (0–2 points, collinear sets) pass through. */
|
|
22
|
+
export function convexHull2D(points: readonly Point2[]): Point2[] {
|
|
23
|
+
if (points.length <= 2) return [...points]
|
|
24
|
+
const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
|
25
|
+
const lower: Point2[] = []
|
|
26
|
+
for (const p of sorted) {
|
|
27
|
+
while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, p) <= 0) {
|
|
28
|
+
lower.pop()
|
|
29
|
+
}
|
|
30
|
+
lower.push(p)
|
|
31
|
+
}
|
|
32
|
+
const upper: Point2[] = []
|
|
33
|
+
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
34
|
+
const p = sorted[i]!
|
|
35
|
+
while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, p) <= 0) {
|
|
36
|
+
upper.pop()
|
|
37
|
+
}
|
|
38
|
+
upper.push(p)
|
|
39
|
+
}
|
|
40
|
+
lower.pop()
|
|
41
|
+
upper.pop()
|
|
42
|
+
const hull = [...lower, ...upper]
|
|
43
|
+
return hull.length > 0 ? hull : [sorted[0]!]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pointInRect(p: Point2, rect: ScreenRect): boolean {
|
|
47
|
+
return p[0] >= rect.minX && p[0] <= rect.maxX && p[1] >= rect.minY && p[1] <= rect.maxY
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Ray-crossing point-in-polygon — works for convex and degenerate hulls. */
|
|
51
|
+
function pointInPolygon(p: Point2, polygon: readonly Point2[]): boolean {
|
|
52
|
+
let inside = false
|
|
53
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
54
|
+
const [xi, yi] = polygon[i]!
|
|
55
|
+
const [xj, yj] = polygon[j]!
|
|
56
|
+
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) {
|
|
57
|
+
inside = !inside
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return inside
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function orientation(a: Point2, b: Point2, c: Point2): number {
|
|
64
|
+
const v = cross(a, b, c)
|
|
65
|
+
if (v > 0) return 1
|
|
66
|
+
if (v < 0) return -1
|
|
67
|
+
return 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function onSegment(a: Point2, b: Point2, p: Point2): boolean {
|
|
71
|
+
return (
|
|
72
|
+
Math.min(a[0], b[0]) <= p[0] &&
|
|
73
|
+
p[0] <= Math.max(a[0], b[0]) &&
|
|
74
|
+
Math.min(a[1], b[1]) <= p[1] &&
|
|
75
|
+
p[1] <= Math.max(a[1], b[1])
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function segmentsIntersect(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean {
|
|
80
|
+
const o1 = orientation(a1, a2, b1)
|
|
81
|
+
const o2 = orientation(a1, a2, b2)
|
|
82
|
+
const o3 = orientation(b1, b2, a1)
|
|
83
|
+
const o4 = orientation(b1, b2, a2)
|
|
84
|
+
if (o1 !== o2 && o3 !== o4) return true
|
|
85
|
+
if (o1 === 0 && onSegment(a1, a2, b1)) return true
|
|
86
|
+
if (o2 === 0 && onSegment(a1, a2, b2)) return true
|
|
87
|
+
if (o3 === 0 && onSegment(b1, b2, a1)) return true
|
|
88
|
+
if (o4 === 0 && onSegment(b1, b2, a2)) return true
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Segment vs polygon (general, possibly concave): endpoint containment or
|
|
93
|
+
* any edge crossing. */
|
|
94
|
+
export function segmentIntersectsPolygon(
|
|
95
|
+
a: Point2,
|
|
96
|
+
b: Point2,
|
|
97
|
+
polygon: readonly Point2[],
|
|
98
|
+
): boolean {
|
|
99
|
+
if (polygon.length === 0) return false
|
|
100
|
+
if (polygon.length === 1) return false
|
|
101
|
+
if (polygon.length >= 3 && (pointInPolygon(a, polygon) || pointInPolygon(b, polygon))) return true
|
|
102
|
+
for (let i = 0; i < polygon.length; i++) {
|
|
103
|
+
if (segmentsIntersect(a, b, polygon[i]!, polygon[(i + 1) % polygon.length]!)) return true
|
|
104
|
+
}
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Polygon vs polygon (general): containment either way or any edge crossing. */
|
|
109
|
+
export function polygonsIntersect(a: readonly Point2[], b: readonly Point2[]): boolean {
|
|
110
|
+
if (a.length === 0 || b.length === 0) return false
|
|
111
|
+
if (a.length >= 3 && b.some((p) => pointInPolygon(p, a))) return true
|
|
112
|
+
if (b.length >= 3 && a.some((p) => pointInPolygon(p, b))) return true
|
|
113
|
+
for (let i = 0; i < a.length; i++) {
|
|
114
|
+
const a1 = a[i]!
|
|
115
|
+
const a2 = a[(i + 1) % a.length]!
|
|
116
|
+
for (let j = 0; j < b.length; j++) {
|
|
117
|
+
if (segmentsIntersect(a1, a2, b[j]!, b[(j + 1) % b.length]!)) return true
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Does the axis-aligned marquee rect intersect the (convex) hull polygon?
|
|
125
|
+
* Covers all cases: hull vertex inside the rect, rect fully inside the hull,
|
|
126
|
+
* and pure edge crossings.
|
|
127
|
+
*/
|
|
128
|
+
export function rectIntersectsHull(rect: ScreenRect, hull: readonly Point2[]): boolean {
|
|
129
|
+
if (hull.length === 0) return false
|
|
130
|
+
for (const p of hull) {
|
|
131
|
+
if (pointInRect(p, rect)) return true
|
|
132
|
+
}
|
|
133
|
+
if (hull.length === 1) return false
|
|
134
|
+
const rectCorners: Point2[] = [
|
|
135
|
+
[rect.minX, rect.minY],
|
|
136
|
+
[rect.maxX, rect.minY],
|
|
137
|
+
[rect.maxX, rect.maxY],
|
|
138
|
+
[rect.minX, rect.maxY],
|
|
139
|
+
]
|
|
140
|
+
if (hull.length >= 3) {
|
|
141
|
+
for (const c of rectCorners) {
|
|
142
|
+
if (pointInPolygon(c, hull)) return true
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (let i = 0; i < hull.length; i++) {
|
|
146
|
+
const h1 = hull[i]!
|
|
147
|
+
const h2 = hull[(i + 1) % hull.length]!
|
|
148
|
+
for (let j = 0; j < 4; j++) {
|
|
149
|
+
const r1 = rectCorners[j]!
|
|
150
|
+
const r2 = rectCorners[(j + 1) % 4]!
|
|
151
|
+
if (segmentsIntersect(h1, h2, r1, r2)) return true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
type ZoneNode,
|
|
11
11
|
} from '@pascal-app/core'
|
|
12
12
|
import { useViewer } from '@pascal-app/viewer'
|
|
13
|
-
import type { ThreeElements } from '@react-three/fiber'
|
|
14
13
|
import { useThree } from '@react-three/fiber'
|
|
15
14
|
import { useCallback, useEffect, useRef } from 'react'
|
|
16
15
|
import {
|
|
@@ -34,12 +33,6 @@ import { CursorSphere } from '../shared/cursor-sphere'
|
|
|
34
33
|
import { isBoxSelectPointerSuppressed, markBoxSelectHandled } from './box-select-state'
|
|
35
34
|
import { collectSelectableCandidateIds } from './select-candidates'
|
|
36
35
|
|
|
37
|
-
declare module 'react/jsx-runtime' {
|
|
38
|
-
namespace JSX {
|
|
39
|
-
interface IntrinsicElements extends ThreeElements {}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
36
|
type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number }
|
|
44
37
|
|
|
45
38
|
const BOX_SELECT_ACCENT_COLOR = '#818cf8'
|
|
@@ -446,7 +439,7 @@ export const PlaneBoxSelectTool: React.FC = () => {
|
|
|
446
439
|
}
|
|
447
440
|
|
|
448
441
|
const ids = collectNodeIdsInPlaneBounds(bounds)
|
|
449
|
-
const shouldAppend = event.metaKey || event.ctrlKey
|
|
442
|
+
const shouldAppend = event.metaKey || event.ctrlKey || event.shiftKey
|
|
450
443
|
const { phase, structureLayer } = useEditor.getState()
|
|
451
444
|
|
|
452
445
|
if (phase === 'structure' && structureLayer === 'zones') {
|
|
@@ -50,7 +50,7 @@ export function collectSelectableCandidateIds(): string[] {
|
|
|
50
50
|
|
|
51
51
|
if (!levelId) return []
|
|
52
52
|
const levelNode = nodes[levelId as AnyNodeId] as LevelNode | undefined
|
|
53
|
-
if (
|
|
53
|
+
if (levelNode?.type !== 'level') return []
|
|
54
54
|
|
|
55
55
|
if (phase === 'structure' && structureLayer === 'zones') {
|
|
56
56
|
for (const childId of levelNode.children) {
|