@pascal-app/editor 0.9.2 → 1.0.0-beta.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 +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNodeId,
|
|
3
|
+
canHostOnTop,
|
|
4
|
+
GROUND_SUPPORT_ID,
|
|
5
|
+
type ItemNode,
|
|
6
|
+
isLowProfileItemSurface,
|
|
7
|
+
sceneRegistry,
|
|
8
|
+
spatialGridManager,
|
|
9
|
+
useScene,
|
|
10
|
+
} from '@pascal-app/core'
|
|
11
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
12
|
+
import { type Camera, Matrix3, type Object3D, Raycaster, Vector3 } from 'three'
|
|
13
|
+
import { resolveTerrainGroundHit } from '../../../lib/ground-surface'
|
|
14
|
+
|
|
15
|
+
const originScratch = new Vector3()
|
|
16
|
+
const hitScratch = new Vector3()
|
|
17
|
+
const worldScratch = new Vector3()
|
|
18
|
+
const pointScratch = new Vector3()
|
|
19
|
+
const worldRayOrigin = new Vector3()
|
|
20
|
+
const worldRayDirection = new Vector3()
|
|
21
|
+
const nodeTopRaycaster = new Raycaster()
|
|
22
|
+
const nodeTopNormal = new Vector3()
|
|
23
|
+
const nodeTopNormalMatrix = new Matrix3()
|
|
24
|
+
|
|
25
|
+
const NODE_TOP_SURFACE_KINDS = ['wall', 'item', 'column'] as const
|
|
26
|
+
|
|
27
|
+
export type PointerSupportSurface = {
|
|
28
|
+
/** Level-local elevation of the pointed surface — the election cap. */
|
|
29
|
+
elevation: number
|
|
30
|
+
/** Slab host, or the explicit ground sentinel when the level base won. */
|
|
31
|
+
supportSlabId: string | null
|
|
32
|
+
/** Node whose upward-facing geometry supplied this surface, when applicable. */
|
|
33
|
+
sourceNodeId: AnyNodeId | null
|
|
34
|
+
/** World-space Y of the same surface, for grid-plane / preview placement. */
|
|
35
|
+
worldY: number
|
|
36
|
+
/**
|
|
37
|
+
* World-space point where the pointer ray meets the pointed surface's
|
|
38
|
+
* plane, or null when the ray never reaches it. Unlike the grid event's
|
|
39
|
+
* own hit — whose XZ is perspective-skewed whenever the event plane
|
|
40
|
+
* rides at a different storey than the aimed-at surface — this point
|
|
41
|
+
* depends only on the ray and the pointed surface, so a preview /
|
|
42
|
+
* election fed from it cannot flip with the event plane's height.
|
|
43
|
+
*/
|
|
44
|
+
worldPoint: [number, number, number] | null
|
|
45
|
+
/** {@link PointerSupportSurface.worldPoint} in the grid event's
|
|
46
|
+
* `localPosition` (building-local) frame — a drop-in replacement for
|
|
47
|
+
* the event's plane-hit XZ. */
|
|
48
|
+
localPoint: [number, number, number] | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The walking surface the pointer actually points at: its level-local
|
|
53
|
+
* elevation (for use as the slab-support election cap, `maxElevation`),
|
|
54
|
+
* its world-space Y (for riding the grid event plane / draw preview on
|
|
55
|
+
* it), and the ray's crossing of that surface's plane (the plan point the
|
|
56
|
+
* cursor indicates).
|
|
57
|
+
*
|
|
58
|
+
* The grid event plane rides at the ghost's last height, so its hit point
|
|
59
|
+
* alone can't be trusted (that feedback loop is what made a ghost under an
|
|
60
|
+
* elevated deck blink between the deck top and the ground). But camera →
|
|
61
|
+
* hit reconstructs the true pointer ray regardless of the plane height,
|
|
62
|
+
* and the nearest slab plane that ray crosses inside its rendered polygon
|
|
63
|
+
* IS the surface under the cursor — the deck top when aiming at the deck,
|
|
64
|
+
* the floor/ground when aiming underneath it. The same reasoning applies
|
|
65
|
+
* to the cursor XZ: the event plane's hit is skewed along the ray whenever
|
|
66
|
+
* the plane sits on a different storey than the pointed surface, so
|
|
67
|
+
* callers should place at `localPoint` / `worldPoint`, not the event hit.
|
|
68
|
+
*
|
|
69
|
+
* When the ray crosses no slab, the surface is the level base — and on the
|
|
70
|
+
* storey that sits on the ground, that base is the sculpted terrain rather
|
|
71
|
+
* than a plane, so `elevation` varies with XZ. Everything downstream already
|
|
72
|
+
* treats it as a varying scalar, which is why terrain needs no new axis here.
|
|
73
|
+
*
|
|
74
|
+
* Returns null when no level is active (callers fall back to the
|
|
75
|
+
* uncapped max election and the raw event hit).
|
|
76
|
+
*/
|
|
77
|
+
export function resolvePointerSupportSurface(
|
|
78
|
+
camera: Camera,
|
|
79
|
+
worldHit: readonly [number, number, number],
|
|
80
|
+
options?: { includeNodeTopSurfaces?: boolean },
|
|
81
|
+
): PointerSupportSurface | null {
|
|
82
|
+
const levelId = useViewer.getState().selection.levelId
|
|
83
|
+
if (!levelId) return null
|
|
84
|
+
|
|
85
|
+
// The world ray, kept before the level conversion below: the terrain field is
|
|
86
|
+
// world-space (site geometry, not level-local), so the march needs this frame.
|
|
87
|
+
camera.getWorldPosition(worldRayOrigin)
|
|
88
|
+
worldRayDirection.set(worldHit[0], worldHit[1], worldHit[2]).sub(worldRayOrigin)
|
|
89
|
+
|
|
90
|
+
originScratch.copy(worldRayOrigin)
|
|
91
|
+
hitScratch.set(worldHit[0], worldHit[1], worldHit[2])
|
|
92
|
+
// Slab polygons/elevations live in the level frame; the level mesh
|
|
93
|
+
// carries the storey Y offset and any building rotation.
|
|
94
|
+
const levelMesh = sceneRegistry.nodes.get(levelId as AnyNodeId)
|
|
95
|
+
if (levelMesh) {
|
|
96
|
+
levelMesh.worldToLocal(originScratch)
|
|
97
|
+
levelMesh.worldToLocal(hitScratch)
|
|
98
|
+
}
|
|
99
|
+
hitScratch.sub(originScratch)
|
|
100
|
+
if (hitScratch.lengthSq() < 1e-12) return null
|
|
101
|
+
|
|
102
|
+
const pointed = spatialGridManager.getPointedSupportSurface(
|
|
103
|
+
levelId,
|
|
104
|
+
[originScratch.x, originScratch.y, originScratch.z],
|
|
105
|
+
[hitScratch.x, hitScratch.y, hitScratch.z],
|
|
106
|
+
)
|
|
107
|
+
let elevation = pointed.elevation
|
|
108
|
+
let point = pointed.point
|
|
109
|
+
|
|
110
|
+
// The level base is the second flat plane terrain has to displace (the first is
|
|
111
|
+
// `useGridEvents`'). The manager solves `t = -oy / dy` for it — a plane — which
|
|
112
|
+
// is still right for every storey whose base is a built floor, and wrong for the
|
|
113
|
+
// storey sitting on the ground, where the base IS the terrain.
|
|
114
|
+
//
|
|
115
|
+
// Substituted here rather than inside the manager for two reasons: the
|
|
116
|
+
// discrimination ("is this plane the site ground?") needs the level's *world*
|
|
117
|
+
// height, and this function is what owns the world↔level conversion; and
|
|
118
|
+
// `ground-surface` is meant to hold that rule once, shared with `useGridEvents`,
|
|
119
|
+
// rather than have `core` grow a terrain dependency it cannot express.
|
|
120
|
+
//
|
|
121
|
+
// Only the no-slab branch is replaced. A ray that crossed a slab inside its
|
|
122
|
+
// rendered polygon is aimed at a built floor, and terrain does not compete with
|
|
123
|
+
// one — that is the flat-floor invariant.
|
|
124
|
+
if (pointed.slabId === null) {
|
|
125
|
+
const baseWorldY = levelMesh ? levelMesh.localToWorld(worldScratch.set(0, 0, 0)).y : 0
|
|
126
|
+
const groundHit = resolveTerrainGroundHit(
|
|
127
|
+
[worldRayOrigin.x, worldRayOrigin.y, worldRayOrigin.z],
|
|
128
|
+
[worldRayDirection.x, worldRayDirection.y, worldRayDirection.z],
|
|
129
|
+
baseWorldY,
|
|
130
|
+
)
|
|
131
|
+
if (groundHit) {
|
|
132
|
+
pointScratch.set(groundHit.x, groundHit.y, groundHit.z)
|
|
133
|
+
if (levelMesh) levelMesh.worldToLocal(pointScratch)
|
|
134
|
+
elevation = pointScratch.y
|
|
135
|
+
point = [pointScratch.x, pointScratch.z]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const worldY = levelMesh ? levelMesh.localToWorld(worldScratch.set(0, elevation, 0)).y : elevation
|
|
140
|
+
|
|
141
|
+
let worldPoint: [number, number, number] | null = null
|
|
142
|
+
let localPoint: [number, number, number] | null = null
|
|
143
|
+
if (point) {
|
|
144
|
+
pointScratch.set(point[0], elevation, point[1])
|
|
145
|
+
if (levelMesh) levelMesh.localToWorld(pointScratch)
|
|
146
|
+
worldPoint = [pointScratch.x, pointScratch.y, pointScratch.z]
|
|
147
|
+
// Same frame the grid events report `localPosition` in (use-grid-events).
|
|
148
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
149
|
+
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
150
|
+
if (buildingMesh) buildingMesh.worldToLocal(pointScratch)
|
|
151
|
+
localPoint = [pointScratch.x, pointScratch.y, pointScratch.z]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (options?.includeNodeTopSurfaces) {
|
|
155
|
+
nodeTopRaycaster.set(worldRayOrigin, worldRayDirection.clone().normalize())
|
|
156
|
+
const nodes = useScene.getState().nodes
|
|
157
|
+
const registeredOwners = new Map(
|
|
158
|
+
[...sceneRegistry.nodes.entries()].map(([nodeId, object]) => [object, nodeId as AnyNodeId]),
|
|
159
|
+
)
|
|
160
|
+
const belongsToActiveLevel = (nodeId: AnyNodeId) => {
|
|
161
|
+
let current = nodes[nodeId]
|
|
162
|
+
const visited = new Set<AnyNodeId>()
|
|
163
|
+
while (current && !visited.has(current.id)) {
|
|
164
|
+
if (current.id === levelId) return true
|
|
165
|
+
visited.add(current.id)
|
|
166
|
+
current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined
|
|
167
|
+
}
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const pointedDistance = worldPoint
|
|
172
|
+
? worldRayOrigin.distanceTo(pointScratch.set(...worldPoint))
|
|
173
|
+
: Number.POSITIVE_INFINITY
|
|
174
|
+
let nearest:
|
|
175
|
+
| {
|
|
176
|
+
distance: number
|
|
177
|
+
nodeId: AnyNodeId
|
|
178
|
+
point: Vector3
|
|
179
|
+
}
|
|
180
|
+
| undefined
|
|
181
|
+
|
|
182
|
+
for (const kind of NODE_TOP_SURFACE_KINDS) {
|
|
183
|
+
for (const rawId of sceneRegistry.byType[kind] ?? []) {
|
|
184
|
+
const nodeId = rawId as AnyNodeId
|
|
185
|
+
const node = nodes[nodeId]
|
|
186
|
+
const object = sceneRegistry.nodes.get(nodeId)
|
|
187
|
+
if (!(node?.visible && object?.visible && belongsToActiveLevel(nodeId))) continue
|
|
188
|
+
if (
|
|
189
|
+
node.type === 'item' &&
|
|
190
|
+
(!canHostOnTop(node) || isLowProfileItemSurface(node as ItemNode))
|
|
191
|
+
) {
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const intersection of nodeTopRaycaster.intersectObject(object, true)) {
|
|
196
|
+
if (
|
|
197
|
+
intersection.distance >= (nearest?.distance ?? pointedDistance) ||
|
|
198
|
+
!intersection.face
|
|
199
|
+
) {
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
let ownerObject: Object3D | null = intersection.object
|
|
203
|
+
let belongsToNestedNode = false
|
|
204
|
+
while (ownerObject && ownerObject !== object) {
|
|
205
|
+
const ownerId = registeredOwners.get(ownerObject)
|
|
206
|
+
if (ownerId && ownerId !== nodeId) {
|
|
207
|
+
belongsToNestedNode = true
|
|
208
|
+
break
|
|
209
|
+
}
|
|
210
|
+
ownerObject = ownerObject.parent
|
|
211
|
+
}
|
|
212
|
+
if (belongsToNestedNode) continue
|
|
213
|
+
nodeTopNormal
|
|
214
|
+
.copy(intersection.face.normal)
|
|
215
|
+
.applyNormalMatrix(nodeTopNormalMatrix.getNormalMatrix(intersection.object.matrixWorld))
|
|
216
|
+
.normalize()
|
|
217
|
+
if (nodeTopNormal.y < 0.75) continue
|
|
218
|
+
nearest = {
|
|
219
|
+
distance: intersection.distance,
|
|
220
|
+
nodeId,
|
|
221
|
+
point: intersection.point.clone(),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (nearest) {
|
|
228
|
+
const sourceNode = nodes[nearest.nodeId]
|
|
229
|
+
pointScratch.copy(nearest.point)
|
|
230
|
+
const nodeWorldPoint: [number, number, number] = [
|
|
231
|
+
pointScratch.x,
|
|
232
|
+
pointScratch.y,
|
|
233
|
+
pointScratch.z,
|
|
234
|
+
]
|
|
235
|
+
if (levelMesh) levelMesh.worldToLocal(pointScratch)
|
|
236
|
+
elevation = pointScratch.y
|
|
237
|
+
|
|
238
|
+
pointScratch.copy(nearest.point)
|
|
239
|
+
const buildingId = useViewer.getState().selection.buildingId
|
|
240
|
+
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
|
241
|
+
if (buildingMesh) buildingMesh.worldToLocal(pointScratch)
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
elevation,
|
|
245
|
+
supportSlabId:
|
|
246
|
+
sourceNode && 'supportSlabId' in sourceNode
|
|
247
|
+
? ((sourceNode.supportSlabId as string | undefined) ?? null)
|
|
248
|
+
: null,
|
|
249
|
+
sourceNodeId: nearest.nodeId,
|
|
250
|
+
worldY: nearest.point.y,
|
|
251
|
+
worldPoint: nodeWorldPoint,
|
|
252
|
+
localPoint: [pointScratch.x, pointScratch.y, pointScratch.z],
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
elevation,
|
|
259
|
+
supportSlabId: pointed.slabId ?? GROUND_SUPPORT_ID,
|
|
260
|
+
sourceNodeId: null,
|
|
261
|
+
worldY,
|
|
262
|
+
worldPoint,
|
|
263
|
+
localPoint,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** {@link resolvePointerSupportSurface}, elevation only — the election cap. */
|
|
268
|
+
export function resolvePointerSupportElevation(
|
|
269
|
+
camera: Camera,
|
|
270
|
+
worldHit: readonly [number, number, number],
|
|
271
|
+
): number | null {
|
|
272
|
+
return resolvePointerSupportSurface(camera, worldHit)?.elevation ?? null
|
|
273
|
+
}
|
|
@@ -447,9 +447,12 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|
|
447
447
|
const activateSiteEditing = useCallback(() => {
|
|
448
448
|
isDraggingSiteBoundaryRef.current = true
|
|
449
449
|
setIsDraggingSiteBoundary(true)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
450
|
+
const editor = useEditor.getState()
|
|
451
|
+
// A boundary drag takes ownership away from the terrain brush. Going
|
|
452
|
+
// through setMode releases the sustained sculpting scope before the
|
|
453
|
+
// PolygonEditor begins its handle-drag scope.
|
|
454
|
+
if (editor.mode !== 'select') editor.setMode('select')
|
|
455
|
+
if (editor.phase !== 'site') editor.setPhase('site')
|
|
453
456
|
selectSiteFloorplanContext()
|
|
454
457
|
}, [])
|
|
455
458
|
|
|
@@ -461,10 +464,14 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|
|
461
464
|
}, [])
|
|
462
465
|
|
|
463
466
|
useEffect(() => {
|
|
464
|
-
|
|
467
|
+
// Terrain sculpting also lives in the site phase so the 3D flags stay
|
|
468
|
+
// available, but its ordinary terrain clicks belong to the brush. Only
|
|
469
|
+
// select mode treats an empty site-grid click as "leave site editing".
|
|
470
|
+
if (!isSiteEditing || mode !== 'select') return
|
|
465
471
|
|
|
466
472
|
const onGridClick = () => {
|
|
467
|
-
|
|
473
|
+
const editor = useEditor.getState()
|
|
474
|
+
if (editor.phase !== 'site' || editor.mode !== 'select') return
|
|
468
475
|
exitSiteEditing()
|
|
469
476
|
}
|
|
470
477
|
|
|
@@ -472,7 +479,7 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|
|
472
479
|
return () => {
|
|
473
480
|
emitter.off('grid:click', onGridClick)
|
|
474
481
|
}
|
|
475
|
-
}, [exitSiteEditing, isSiteEditing])
|
|
482
|
+
}, [exitSiteEditing, isSiteEditing, mode])
|
|
476
483
|
|
|
477
484
|
const renderSiteFlagVertex = useCallback(
|
|
478
485
|
({
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type StrokePointerAction,
|
|
4
|
+
type StrokePointerEvent,
|
|
5
|
+
strokePointerAction,
|
|
6
|
+
} from './stroke-pointer-ownership'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A stroke carries uncommitted state, so every one of these has a silent failure
|
|
10
|
+
* mode rather than a visible one — which is why the matrix is asserted rather
|
|
11
|
+
* than left to the four call sites.
|
|
12
|
+
*/
|
|
13
|
+
describe('strokePointerAction', () => {
|
|
14
|
+
const OWNER = 1
|
|
15
|
+
const OTHER = 2
|
|
16
|
+
const act = (event: StrokePointerEvent, pointerId: number, ownerPointerId: number | null) =>
|
|
17
|
+
strokePointerAction({ event, pointerId, ownerPointerId })
|
|
18
|
+
|
|
19
|
+
describe('with no stroke in flight', () => {
|
|
20
|
+
test('a down starts one', () => {
|
|
21
|
+
expect(act('down', OWNER, null)).toBe('begin')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('every other event is ignored', () => {
|
|
25
|
+
// Notably `up`: the pointerup of a click that never began a stroke (over the
|
|
26
|
+
// eyedropper, or off the field) must not commit anything.
|
|
27
|
+
for (const event of ['move', 'up', 'cancel'] as StrokePointerEvent[]) {
|
|
28
|
+
expect(act(event, OWNER, null)).toBe('ignore')
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('the owning pointer drives the stroke', () => {
|
|
34
|
+
test('move paints, up commits', () => {
|
|
35
|
+
expect(act('move', OWNER, OWNER)).toBe('apply')
|
|
36
|
+
expect(act('up', OWNER, OWNER)).toBe('commit')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('cancel abandons where up commits', () => {
|
|
40
|
+
// The browser fires `cancel` when it takes the gesture away, so the user
|
|
41
|
+
// never finished the stroke. These two must not be the same handler — they
|
|
42
|
+
// were, and a cancelled drag persisted.
|
|
43
|
+
expect(act('cancel', OWNER, OWNER)).toBe('abandon')
|
|
44
|
+
expect(act('cancel', OWNER, OWNER)).not.toBe(act('up', OWNER, OWNER))
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('a second pointer', () => {
|
|
49
|
+
test('down abandons the stroke in flight', () => {
|
|
50
|
+
// Two fingers is a pinch-zoom. Abandoning restores the ground exactly,
|
|
51
|
+
// because nothing was committed yet.
|
|
52
|
+
expect(act('down', OTHER, OWNER)).toBe('abandon')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('never touches the owner’s stroke any other way', () => {
|
|
56
|
+
for (const event of ['move', 'up', 'cancel'] as StrokePointerEvent[]) {
|
|
57
|
+
expect(act(event, OTHER, OWNER)).toBe('ignore')
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('lifting it does not commit — the bug this replaced', () => {
|
|
62
|
+
// The old handler keyed off `strokeRef` alone, so a resting finger lifting
|
|
63
|
+
// committed a stroke the other finger was still drawing.
|
|
64
|
+
expect(act('up', OTHER, OWNER)).not.toBe('commit')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('only a down can begin, and only an owning up can commit', () => {
|
|
69
|
+
// Swept over the whole matrix rather than spot-checked: these are the two
|
|
70
|
+
// actions that write to the scene, so anything else reaching them is a bug.
|
|
71
|
+
const events: StrokePointerEvent[] = ['down', 'move', 'up', 'cancel']
|
|
72
|
+
const owners: Array<number | null> = [null, OWNER]
|
|
73
|
+
const seen: Array<[StrokePointerEvent, number, number | null, StrokePointerAction]> = []
|
|
74
|
+
for (const event of events) {
|
|
75
|
+
for (const pointerId of [OWNER, OTHER]) {
|
|
76
|
+
for (const ownerPointerId of owners) {
|
|
77
|
+
seen.push([event, pointerId, ownerPointerId, act(event, pointerId, ownerPointerId)])
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
expect(seen.filter(([, , , action]) => action === 'begin').map(([event]) => event)).toEqual([
|
|
82
|
+
'down',
|
|
83
|
+
'down',
|
|
84
|
+
])
|
|
85
|
+
expect(
|
|
86
|
+
seen
|
|
87
|
+
.filter(([, , , action]) => action === 'commit')
|
|
88
|
+
.map(([event, pointerId, ownerPointerId]) => [event, pointerId === ownerPointerId]),
|
|
89
|
+
).toEqual([['up', true]])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which pointer a terrain stroke belongs to, and what every other one does.
|
|
3
|
+
*
|
|
4
|
+
* A stroke is a sustained drag with uncommitted state, so "who owns the pointer"
|
|
5
|
+
* is a real decision rather than plumbing — and each wrong answer fails silently
|
|
6
|
+
* in its own way. The tool used to have no answer at all: every branch below fell
|
|
7
|
+
* through to the owning-pointer path.
|
|
8
|
+
*
|
|
9
|
+
* Extracted as a pure function because the interesting part is the *matrix*, which
|
|
10
|
+
* cannot be asserted through the component — reproducing it needs a WebGL canvas,
|
|
11
|
+
* a camera, and synthetic multi-touch. The tool keeps the DOM plumbing.
|
|
12
|
+
*/
|
|
13
|
+
export type StrokePointerAction =
|
|
14
|
+
/** Start a stroke owned by this pointer. */
|
|
15
|
+
| 'begin'
|
|
16
|
+
/** Route the event into the active stroke. */
|
|
17
|
+
| 'apply'
|
|
18
|
+
/** Drop the stroke, restoring the pre-stroke ground. */
|
|
19
|
+
| 'abandon'
|
|
20
|
+
/** Finish and persist the stroke. */
|
|
21
|
+
| 'commit'
|
|
22
|
+
/** Not our pointer, or nothing in flight. */
|
|
23
|
+
| 'ignore'
|
|
24
|
+
|
|
25
|
+
export type StrokePointerEvent = 'down' | 'move' | 'up' | 'cancel'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `ownerPointerId` is `null` when no stroke is in flight.
|
|
29
|
+
*
|
|
30
|
+
* The rules, in the order they matter:
|
|
31
|
+
*
|
|
32
|
+
* - **A second `down` abandons.** Two fingers on the glass is a pinch-zoom, and by
|
|
33
|
+
* then the first finger has already deposited a dab; abandoning is what makes
|
|
34
|
+
* "pinch to look closer" leave no accidental bump. Falling through was the worst
|
|
35
|
+
* of the options: it orphaned an uncommitted stroke whose dabs were already
|
|
36
|
+
* baked into the live field's snapshot, so they survived on screen while being
|
|
37
|
+
* unreachable by undo.
|
|
38
|
+
* - **A non-owning `up` is ignored, not a commit.** Lifting a resting second finger
|
|
39
|
+
* would otherwise persist a stroke the other finger is still drawing.
|
|
40
|
+
* - **`cancel` abandons where `up` commits.** The browser fires `cancel` when it
|
|
41
|
+
* takes the gesture away (OS edge swipe, palm rejection), meaning the user never
|
|
42
|
+
* finished — committing there persists a half-drag nobody released.
|
|
43
|
+
*/
|
|
44
|
+
export function strokePointerAction(args: {
|
|
45
|
+
event: StrokePointerEvent
|
|
46
|
+
pointerId: number
|
|
47
|
+
ownerPointerId: number | null
|
|
48
|
+
}): StrokePointerAction {
|
|
49
|
+
const { event, pointerId, ownerPointerId } = args
|
|
50
|
+
|
|
51
|
+
if (event === 'down') {
|
|
52
|
+
return ownerPointerId === null ? 'begin' : 'abandon'
|
|
53
|
+
}
|
|
54
|
+
if (ownerPointerId === null || pointerId !== ownerPointerId) return 'ignore'
|
|
55
|
+
if (event === 'move') return 'apply'
|
|
56
|
+
return event === 'cancel' ? 'abandon' : 'commit'
|
|
57
|
+
}
|