@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.
Files changed (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +236 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. 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
- if (useEditor.getState().phase !== 'site') {
451
- useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
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
- if (!isSiteEditing) return
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
- if (useEditor.getState().phase !== 'site') return
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
+ }