@pascal-app/editor 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/package.json +12 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +160 -0
  3. package/src/components/editor/custom-camera-controls.tsx +755 -25
  4. package/src/components/editor/editor-layout-mobile.tsx +3 -3
  5. package/src/components/editor/editor-layout-v2.tsx +62 -54
  6. package/src/components/editor/export-manager.tsx +32 -3
  7. package/src/components/editor/first-person/build-collider-world.test.ts +149 -0
  8. package/src/components/editor/first-person/build-collider-world.ts +203 -87
  9. package/src/components/editor/first-person/bvh-ecctrl.tsx +127 -62
  10. package/src/components/editor/first-person-controls.tsx +836 -31
  11. package/src/components/editor/floating-action-menu.tsx +287 -196
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floorplan-panel.tsx +5837 -12006
  14. package/src/components/editor/grid.tsx +51 -17
  15. package/src/components/editor/group-move-handle.tsx +316 -0
  16. package/src/components/editor/group-rotate-handle.tsx +382 -0
  17. package/src/components/editor/group-transform-shared.test.ts +205 -0
  18. package/src/components/editor/group-transform-shared.ts +256 -0
  19. package/src/components/editor/handles/handle-arrow.tsx +523 -0
  20. package/src/components/editor/handles/use-handle-drag.ts +213 -0
  21. package/src/components/editor/index.tsx +234 -190
  22. package/src/components/editor/measurement-pill.tsx +75 -0
  23. package/src/components/editor/node-arrow-handles.tsx +1453 -0
  24. package/src/components/editor/selection-manager.tsx +435 -158
  25. package/src/components/editor/site-edge-labels.tsx +44 -20
  26. package/src/components/editor/slab-hole-highlights.tsx +466 -0
  27. package/src/components/editor/snapshot-capture-overlay.tsx +539 -0
  28. package/src/components/editor/thumbnail-generator.tsx +27 -64
  29. package/src/components/editor/use-floorplan-background-placement.ts +122 -35
  30. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  31. package/src/components/editor/use-floorplan-scene-data.ts +14 -2
  32. package/src/components/editor/wall-measurement-label.tsx +11 -6
  33. package/src/components/editor/wall-move-side-handles.tsx +855 -0
  34. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  35. package/src/components/editor/wall-snap-beacon-layer.tsx +150 -0
  36. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +6 -1
  37. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  38. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  39. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +227 -0
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +616 -0
  41. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  42. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +75 -0
  43. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  44. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  45. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  46. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -61
  47. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +59 -0
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1963 -0
  49. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +29 -14
  50. package/src/components/editor-2d/svg-paths.ts +10 -2
  51. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +30 -9
  52. package/src/components/systems/ceiling/ceiling-system.tsx +12 -8
  53. package/src/components/systems/roof/roof-edit-system.tsx +86 -12
  54. package/src/components/systems/stair/stair-edit-system.tsx +2 -2
  55. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  56. package/src/components/systems/zone/zone-system.tsx +0 -0
  57. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  58. package/src/components/tools/elevator/elevator-tool.tsx +349 -0
  59. package/src/components/tools/elevator/move-elevator-tool.tsx +252 -0
  60. package/src/components/tools/fence/fence-drafting.ts +64 -22
  61. package/src/components/tools/item/move-tool.tsx +40 -107
  62. package/src/components/tools/item/placement-math.test.ts +10 -0
  63. package/src/components/tools/item/placement-math.ts +33 -9
  64. package/src/components/tools/item/placement-strategies.ts +201 -25
  65. package/src/components/tools/item/placement-types.ts +10 -1
  66. package/src/components/tools/item/use-draft-node.ts +20 -2
  67. package/src/components/tools/item/use-placement-coordinator.tsx +700 -299
  68. package/src/components/tools/registry/move-registry-node-tool.tsx +602 -0
  69. package/src/components/tools/roof/roof-tool.tsx +108 -12
  70. package/src/components/tools/select/box-select-state.ts +77 -0
  71. package/src/components/tools/select/box-select-tool.tsx +324 -543
  72. package/src/components/tools/select/plane-box-select-tool.tsx +577 -0
  73. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  74. package/src/components/tools/select/select-candidates.test.ts +109 -0
  75. package/src/components/tools/select/select-candidates.ts +85 -0
  76. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  77. package/src/components/tools/shared/drag-bounding-box.tsx +171 -0
  78. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  79. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  80. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  81. package/src/components/tools/shared/placement-box.tsx +123 -0
  82. package/src/components/tools/shared/polygon-editor.tsx +617 -167
  83. package/src/components/tools/shared/segment-angle.ts +57 -1
  84. package/src/components/tools/site/site-boundary-editor.tsx +464 -12
  85. package/src/components/tools/site/site-flag-model.ts +18 -0
  86. package/src/components/tools/stair/stair-defaults.ts +1 -1
  87. package/src/components/tools/stair/stair-tool.tsx +269 -35
  88. package/src/components/tools/tool-manager.tsx +172 -56
  89. package/src/components/tools/wall/wall-drafting.ts +98 -98
  90. package/src/components/tools/wall/wall-snap-geometry.test.ts +79 -0
  91. package/src/components/tools/wall/wall-snap-geometry.ts +270 -0
  92. package/src/components/tools/zone/zone-tool.tsx +36 -11
  93. package/src/components/ui/action-menu/action-button.tsx +21 -1
  94. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  95. package/src/components/ui/action-menu/control-modes.tsx +5 -76
  96. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  97. package/src/components/ui/action-menu/index.tsx +33 -196
  98. package/src/components/ui/action-menu/structure-tools.tsx +13 -88
  99. package/src/components/ui/action-menu/view-toggles.tsx +262 -37
  100. package/src/components/ui/command-palette/editor-commands.tsx +24 -17
  101. package/src/components/ui/command-palette/index.tsx +6 -256
  102. package/src/components/ui/controls/material-paint-panel.tsx +83 -0
  103. package/src/components/ui/controls/material-picker.tsx +17 -35
  104. package/src/components/ui/controls/metric-control.tsx +33 -11
  105. package/src/components/ui/floating-level-selector.tsx +48 -11
  106. package/src/components/ui/helpers/helper-manager.tsx +21 -17
  107. package/src/components/ui/helpers/registered-tool-helper.tsx +25 -0
  108. package/src/components/ui/item-catalog/catalog-items.tsx +1743 -315
  109. package/src/components/ui/item-catalog/item-catalog.tsx +91 -46
  110. package/src/components/ui/level-duplicate-dialog.tsx +5 -6
  111. package/src/components/ui/panels/node-display.ts +1 -0
  112. package/src/components/ui/panels/paint-panel.tsx +66 -41
  113. package/src/components/ui/panels/panel-manager.tsx +33 -74
  114. package/src/components/ui/panels/panel-wrapper.tsx +217 -12
  115. package/src/components/ui/panels/parametric-inspector.tsx +469 -0
  116. package/src/components/ui/panels/reference-panel.tsx +28 -13
  117. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  118. package/src/components/ui/primitives/number-input.tsx +1 -1
  119. package/src/components/ui/primitives/sidebar.tsx +0 -0
  120. package/src/components/ui/scene-loader.tsx +1 -3
  121. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  122. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  123. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  124. package/src/components/ui/sidebar/panels/items-panel/index.tsx +404 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +59 -9
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +6 -1
  127. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +259 -0
  128. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  129. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -2
  130. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +83 -0
  131. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +89 -0
  132. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  133. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +2 -2
  134. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +83 -0
  135. package/src/components/ui/sidebar/panels/site-panel/index.tsx +8 -12
  136. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  137. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +93 -0
  138. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +41 -5
  139. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +128 -0
  140. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +5 -2
  141. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +83 -0
  142. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  143. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +68 -34
  144. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  145. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +52 -3
  146. package/src/components/ui/sidebar/tab-bar.tsx +61 -1
  147. package/src/components/ui/slider.tsx +1 -1
  148. package/src/components/viewer-overlay.tsx +169 -52
  149. package/src/components/viewer-zone-system.tsx +1 -1
  150. package/src/hooks/use-auto-save.ts +24 -3
  151. package/src/hooks/use-ceiling-events.ts +175 -0
  152. package/src/hooks/use-drag-action.ts +124 -0
  153. package/src/hooks/use-keyboard.ts +125 -62
  154. package/src/hooks/use-selection.ts +64 -0
  155. package/src/index.tsx +243 -4
  156. package/src/lib/constants.ts +6 -2
  157. package/src/lib/editor-api.ts +62 -0
  158. package/src/lib/elevator-support.ts +96 -0
  159. package/src/lib/floorplan/apply-alignment.ts +111 -0
  160. package/src/lib/floorplan/index.ts +7 -0
  161. package/src/lib/floorplan/selection-tool.ts +40 -0
  162. package/src/lib/floorplan/stairs.ts +12 -1
  163. package/src/lib/fresh-planar-placement.test.ts +102 -0
  164. package/src/lib/fresh-planar-placement.ts +63 -0
  165. package/src/lib/history.ts +2 -1
  166. package/src/lib/level-duplication.test.ts +0 -2
  167. package/src/lib/level-duplication.ts +55 -43
  168. package/src/lib/level-name.ts +11 -0
  169. package/src/lib/material-paint.ts +212 -20
  170. package/src/lib/placement-metadata.ts +29 -0
  171. package/src/lib/planar-cursor-placement.test.ts +43 -0
  172. package/src/lib/planar-cursor-placement.ts +42 -0
  173. package/src/lib/roof-duplication.ts +2 -2
  174. package/src/lib/scene-bounds.ts +1 -1
  175. package/src/lib/scene-clipboard.ts +267 -0
  176. package/src/lib/scene.ts +13 -4
  177. package/src/lib/sfx-bus.ts +25 -1
  178. package/src/lib/sfx-player.ts +75 -27
  179. package/src/lib/site-boundary.ts +1 -0
  180. package/src/lib/stair-duplication.ts +2 -2
  181. package/src/lib/stair-levels.test.ts +146 -0
  182. package/src/lib/stair-levels.ts +177 -0
  183. package/src/lib/world-grid-snap.ts +199 -0
  184. package/src/lib/zone-content.ts +130 -0
  185. package/src/store/use-alignment-guides.ts +21 -0
  186. package/src/store/use-editor.tsx +342 -91
  187. package/src/store/use-placement-preview.ts +31 -0
  188. package/src/store/use-wall-move-ghosts.ts +36 -0
  189. package/src/store/use-wall-snap-indicator.ts +32 -0
  190. package/tsconfig.json +2 -1
  191. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  192. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  193. package/src/components/feedback-dialog.tsx +0 -265
  194. package/src/components/pascal-radio.tsx +0 -280
  195. package/src/components/preview-button.tsx +0 -16
  196. package/src/components/tools/building/move-building-tool.tsx +0 -157
  197. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  198. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  199. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  200. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -257
  201. package/src/components/tools/column/column-tool.tsx +0 -97
  202. package/src/components/tools/column/move-column-tool.tsx +0 -105
  203. package/src/components/tools/door/door-math.ts +0 -110
  204. package/src/components/tools/door/door-tool.tsx +0 -324
  205. package/src/components/tools/door/move-door-tool.tsx +0 -412
  206. package/src/components/tools/fence/curve-fence-tool.tsx +0 -179
  207. package/src/components/tools/fence/fence-tool.tsx +0 -346
  208. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -438
  209. package/src/components/tools/fence/move-fence-tool.tsx +0 -298
  210. package/src/components/tools/item/item-tool.tsx +0 -26
  211. package/src/components/tools/roof/move-roof-tool.tsx +0 -359
  212. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  213. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  214. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  215. package/src/components/tools/slab/slab-tool.tsx +0 -322
  216. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  217. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  218. package/src/components/tools/wall/curve-wall-tool.tsx +0 -176
  219. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -423
  220. package/src/components/tools/wall/move-wall-tool.tsx +0 -356
  221. package/src/components/tools/wall/wall-tool.tsx +0 -332
  222. package/src/components/tools/window/move-window-tool.tsx +0 -447
  223. package/src/components/tools/window/window-math.ts +0 -117
  224. package/src/components/tools/window/window-tool.tsx +0 -332
  225. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  226. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  227. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  228. package/src/components/ui/panels/ceiling-panel.tsx +0 -250
  229. package/src/components/ui/panels/column-panel.tsx +0 -715
  230. package/src/components/ui/panels/door-panel.tsx +0 -1292
  231. package/src/components/ui/panels/fence-panel.tsx +0 -227
  232. package/src/components/ui/panels/item-panel.tsx +0 -306
  233. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  234. package/src/components/ui/panels/roof-panel.tsx +0 -230
  235. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  236. package/src/components/ui/panels/slab-panel.tsx +0 -250
  237. package/src/components/ui/panels/spawn-panel.tsx +0 -155
  238. package/src/components/ui/panels/stair-panel.tsx +0 -525
  239. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  240. package/src/components/ui/panels/wall-panel.tsx +0 -177
  241. package/src/components/ui/panels/window-panel.tsx +0 -960
  242. package/src/components/ui/viewer-toolbar.tsx +0 -436
  243. package/src/contexts/presets-context.tsx +0 -121
  244. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -1,22 +1,30 @@
1
1
  'use client'
2
2
 
3
3
  import type { SiteNode } from '@pascal-app/core'
4
- import { sceneRegistry, useScene } from '@pascal-app/core'
5
- import { useViewer } from '@pascal-app/viewer'
4
+ import { sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
5
+ import { getSceneTheme, useViewer } from '@pascal-app/viewer'
6
6
  import { Html } from '@react-three/drei'
7
- import { createPortal, useFrame } from '@react-three/fiber'
8
- import { useMemo, useRef, useState } from 'react'
9
- import type { Object3D } from 'three'
7
+ import { createPortal, useFrame, useThree } from '@react-three/fiber'
8
+ import { useCallback, useMemo, useRef, useState } from 'react'
9
+ import { type Camera, type Object3D, Vector3 } from 'three'
10
+ import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
11
+ import useEditor from '../../store/use-editor'
12
+ import { formatMeasurement } from './measurement-pill'
10
13
 
11
- function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
12
- if (unit === 'imperial') {
13
- const feet = value * 3.280_84
14
- const wholeFeet = Math.floor(feet)
15
- const inches = Math.round((feet - wholeFeet) * 12)
16
- if (inches === 12) return `${wholeFeet + 1}'0"`
17
- return `${wholeFeet}'${inches}"`
18
- }
19
- return `${Number.parseFloat(value.toFixed(2))}m`
14
+ type ViewportSize = {
15
+ width: number
16
+ height: number
17
+ }
18
+
19
+ const htmlPosition = new Vector3()
20
+
21
+ function calculateHtmlPosition(el: Object3D, camera: Camera, size: ViewportSize) {
22
+ htmlPosition.setFromMatrixPosition(el.matrixWorld)
23
+ htmlPosition.project(camera)
24
+
25
+ const widthHalf = size.width / 2
26
+ const heightHalf = size.height / 2
27
+ return [htmlPosition.x * widthHalf + widthHalf, -htmlPosition.y * heightHalf + heightHalf]
20
28
  }
21
29
 
22
30
  export function SiteEdgeLabels() {
@@ -29,12 +37,28 @@ export function SiteEdgeLabels() {
29
37
  const node = state.nodes[firstRoot]
30
38
  return node?.type === 'site' ? (node as SiteNode) : null
31
39
  })
40
+ const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
32
41
  const unit = useViewer((state) => state.unit)
33
- const theme = useViewer((state) => state.theme)
42
+ const cameraMode = useViewer((state) => state.cameraMode)
43
+ const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
44
+ const camera = useThree((state) => state.camera)
45
+ // Drei Html can hold the previous default camera across a camera-object swap.
46
+ const calculateLabelPosition = useCallback(
47
+ (el: Object3D, _camera: Camera, size: ViewportSize) => calculateHtmlPosition(el, camera, size),
48
+ [camera],
49
+ )
34
50
 
35
51
  const siteNodeId = siteNode?.id
52
+ const livePolygon = useLiveNodeOverrides((state) => {
53
+ if (!siteNodeId) return null
54
+ return (state.overrides.get(siteNodeId)?.polygon as SiteNode['polygon'] | undefined) ?? null
55
+ })
56
+ const polygon = livePolygon?.points ?? siteNode?.polygon?.points ?? []
57
+ const shouldShowLabels =
58
+ Boolean(siteNodeId) &&
59
+ activeHandleDrag?.nodeId === siteNodeId &&
60
+ activeHandleDrag?.label === SITE_BOUNDARY_DRAG_LABEL
36
61
 
37
- const isNight = theme === 'dark'
38
62
  const color = isNight ? '#ffffff' : '#111111'
39
63
  const shadowColor = isNight ? '#111111' : '#ffffff'
40
64
 
@@ -55,7 +79,6 @@ export function SiteEdgeLabels() {
55
79
  })
56
80
 
57
81
  const edges = useMemo(() => {
58
- const polygon = siteNode?.polygon?.points ?? []
59
82
  if (polygon.length < 2) return []
60
83
  return polygon.map(([x1, z1], i) => {
61
84
  const [x2, z2] = polygon[(i + 1) % polygon.length]!
@@ -64,16 +87,17 @@ export function SiteEdgeLabels() {
64
87
  const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
65
88
  return { midX, midZ, dist }
66
89
  })
67
- }, [siteNode?.polygon?.points])
90
+ }, [polygon])
68
91
 
69
- if (!siteObj || edges.length === 0) return null
92
+ if (!shouldShowLabels || !siteObj || edges.length === 0) return null
70
93
 
71
94
  return createPortal(
72
95
  <>
73
96
  {edges.map((edge, i) => (
74
97
  <Html
75
98
  center
76
- key={`edge-${i}`}
99
+ calculatePosition={calculateLabelPosition}
100
+ key={`${cameraMode}-${camera.uuid}-edge-${i}`}
77
101
  occlude
78
102
  position={[edge.midX, 0.5, edge.midZ]}
79
103
  style={{ pointerEvents: 'none', userSelect: 'none' }}
@@ -0,0 +1,466 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ type BuildingNode,
6
+ type LevelNode,
7
+ resolveBuildingForLevel,
8
+ resolveLevelId,
9
+ type SlabNode,
10
+ type SurfaceHoleMetadata,
11
+ sceneRegistry,
12
+ useLiveNodeOverrides,
13
+ useScene,
14
+ } from '@pascal-app/core'
15
+ import { useViewer } from '@pascal-app/viewer'
16
+ import { createPortal, type ThreeEvent } from '@react-three/fiber'
17
+ import { useCallback, useEffect, useMemo, useState } from 'react'
18
+ import {
19
+ BufferGeometry,
20
+ DoubleSide,
21
+ Float32BufferAttribute,
22
+ type Object3D,
23
+ ShapeUtils,
24
+ Vector2,
25
+ } from 'three'
26
+ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
27
+ import { EDITOR_LAYER } from '../../lib/constants'
28
+ import useEditor from '../../store/use-editor'
29
+ import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
30
+ import { swallowNextClick } from './handles/use-handle-drag'
31
+
32
+ const ACCENT = 0x83_81_ed
33
+ const SURFACE_OFFSET = 0.01
34
+ const MIN_HIT_HEIGHT = 0.16
35
+
36
+ const NO_RAYCAST = () => null
37
+ let restoreNodeClickSuppression: (() => void) | null = null
38
+
39
+ const outlineMaterial = new LineBasicNodeMaterial({
40
+ color: ACCENT,
41
+ depthTest: false,
42
+ depthWrite: false,
43
+ })
44
+
45
+ const fillMaterial = new MeshBasicNodeMaterial({
46
+ color: ACCENT,
47
+ transparent: true,
48
+ opacity: 0.5,
49
+ depthTest: false,
50
+ depthWrite: false,
51
+ side: DoubleSide,
52
+ })
53
+
54
+ // Invisible hit targets must stay out of the scene MRT pass; keep them on
55
+ // EDITOR_LAYER even though they never write color.
56
+ const hitMaterial = new MeshBasicNodeMaterial({
57
+ color: ACCENT,
58
+ colorWrite: false,
59
+ depthTest: false,
60
+ depthWrite: false,
61
+ opacity: 0,
62
+ side: DoubleSide,
63
+ transparent: true,
64
+ })
65
+
66
+ type HolePolygon = Array<[number, number]>
67
+
68
+ function makeFillGeometry(hole: HolePolygon, y: number): BufferGeometry {
69
+ const contour2d = hole.map(([x, z]) => new Vector2(x, z))
70
+ const positions: number[] = []
71
+ const indices: number[] = []
72
+
73
+ for (const point of contour2d) {
74
+ positions.push(point.x, y, point.y)
75
+ }
76
+
77
+ const triangles = ShapeUtils.triangulateShape(contour2d, [])
78
+ for (const tri of triangles) {
79
+ indices.push(tri[0]!, tri[2]!, tri[1]!)
80
+ }
81
+
82
+ const geometry = new BufferGeometry()
83
+ geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
84
+ geometry.setIndex(indices)
85
+ geometry.computeVertexNormals()
86
+ geometry.computeBoundingSphere()
87
+ return geometry
88
+ }
89
+
90
+ function makeOutlineGeometry(hole: HolePolygon, y: number): BufferGeometry {
91
+ const positions: number[] = []
92
+
93
+ for (let index = 0; index < hole.length; index += 1) {
94
+ const [ax, az] = hole[index]!
95
+ const [bx, bz] = hole[(index + 1) % hole.length]!
96
+ positions.push(ax, y, az, bx, y, bz)
97
+ }
98
+
99
+ const geometry = new BufferGeometry()
100
+ geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
101
+ geometry.computeBoundingSphere()
102
+ return geometry
103
+ }
104
+
105
+ function makeHitGeometry(hole: HolePolygon, centerY: number, height: number): BufferGeometry {
106
+ const topY = centerY + height / 2
107
+ const bottomY = centerY - height / 2
108
+ const positions: number[] = []
109
+ const indices: number[] = []
110
+
111
+ for (const [x, z] of hole) positions.push(x, topY, z)
112
+ for (const [x, z] of hole) positions.push(x, bottomY, z)
113
+
114
+ const triangles = ShapeUtils.triangulateShape(
115
+ hole.map(([x, z]) => new Vector2(x, z)),
116
+ [],
117
+ )
118
+ const bottomOffset = hole.length
119
+ for (const tri of triangles) {
120
+ indices.push(tri[0]!, tri[2]!, tri[1]!)
121
+ indices.push(bottomOffset + tri[0]!, bottomOffset + tri[1]!, bottomOffset + tri[2]!)
122
+ }
123
+
124
+ for (let index = 0; index < hole.length; index += 1) {
125
+ const nextIndex = (index + 1) % hole.length
126
+ indices.push(index, nextIndex, bottomOffset + nextIndex)
127
+ indices.push(index, bottomOffset + nextIndex, bottomOffset + index)
128
+ }
129
+
130
+ const geometry = new BufferGeometry()
131
+ geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
132
+ geometry.setIndex(indices)
133
+ geometry.computeVertexNormals()
134
+ geometry.computeBoundingSphere()
135
+ return geometry
136
+ }
137
+
138
+ function getSurfaceY(slab: SlabNode): number {
139
+ const elevation = slab.elevation ?? 0.05
140
+ return (elevation > 0 ? elevation : 0) + SURFACE_OFFSET
141
+ }
142
+
143
+ function getHitCenterY(slab: SlabNode): number {
144
+ const elevation = slab.elevation ?? 0.05
145
+ const surfaceTop = elevation > 0 ? elevation : 0
146
+ // Keep the hit box's TOP at the slab surface (never poke above it) so the
147
+ // slab's centre thickness/height handle, which sits on top, always wins the
148
+ // raycast over the hole's selectable area.
149
+ return surfaceTop - getHitHeight(slab) / 2
150
+ }
151
+
152
+ function getHitHeight(slab: SlabNode): number {
153
+ return Math.max(Math.abs(slab.elevation ?? 0.05), MIN_HIT_HEIGHT)
154
+ }
155
+
156
+ function clearHoveredHoleIfMatches(nodeId: string, holeIndex: number) {
157
+ const { hoveredHole, setHoveredHole } = useEditor.getState()
158
+ if (hoveredHole?.nodeId === nodeId && hoveredHole.holeIndex === holeIndex) {
159
+ setHoveredHole(null)
160
+ }
161
+ }
162
+
163
+ function selectOwnedNode(ownerId: string, expectedType: 'stair' | 'elevator') {
164
+ const nodes = useScene.getState().nodes
165
+ const owner = nodes[ownerId as AnyNodeId]
166
+ if (owner?.type !== expectedType) return
167
+
168
+ const selectedId = owner.id as AnyNodeId
169
+
170
+ if (owner.type === 'elevator') {
171
+ const buildingId =
172
+ owner.parentId && nodes[owner.parentId as AnyNodeId]?.type === 'building'
173
+ ? (owner.parentId as BuildingNode['id'])
174
+ : null
175
+
176
+ useViewer
177
+ .getState()
178
+ .setSelection(
179
+ buildingId ? { buildingId, selectedIds: [selectedId] } : { selectedIds: [selectedId] },
180
+ )
181
+ return
182
+ }
183
+
184
+ const levelId = resolveLevelId(owner, nodes)
185
+ const buildingId =
186
+ levelId && levelId !== 'default' ? resolveBuildingForLevel(levelId as AnyNodeId, nodes) : null
187
+
188
+ useViewer.getState().setSelection({
189
+ ...(buildingId ? { buildingId: buildingId as BuildingNode['id'] } : {}),
190
+ ...(levelId && levelId !== 'default' ? { levelId: levelId as LevelNode['id'] } : {}),
191
+ selectedIds: [selectedId],
192
+ })
193
+ }
194
+
195
+ function resetPointerCursor() {
196
+ if (document.body.style.cursor === 'pointer') {
197
+ document.body.style.cursor = ''
198
+ }
199
+ }
200
+
201
+ function stopPointerPropagation(event: ThreeEvent<PointerEvent>) {
202
+ event.stopPropagation()
203
+ suppressBoxSelectForPointer(event)
204
+ event.nativeEvent.stopPropagation()
205
+ event.nativeEvent.stopImmediatePropagation()
206
+ }
207
+
208
+ function suppressNodeClickUntilPointerUp() {
209
+ restoreNodeClickSuppression?.()
210
+ const previousInputDragging = useViewer.getState().inputDragging
211
+ useViewer.getState().setInputDragging(true)
212
+ swallowNextClick()
213
+
214
+ const restore = () => {
215
+ if (restoreNodeClickSuppression !== restore) return
216
+ useViewer.getState().setInputDragging(previousInputDragging)
217
+ window.removeEventListener('pointerup', restore)
218
+ window.removeEventListener('pointercancel', restore)
219
+ restoreNodeClickSuppression = null
220
+ }
221
+
222
+ restoreNodeClickSuppression = restore
223
+ window.addEventListener('pointerup', restore, { once: true })
224
+ window.addEventListener('pointercancel', restore, { once: true })
225
+ }
226
+
227
+ function releaseNodeClickSuppression() {
228
+ restoreNodeClickSuppression?.()
229
+ }
230
+
231
+ export function SlabHoleHighlights() {
232
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
233
+ const hoveredHole = useEditor((state) => state.hoveredHole)
234
+ const setHoveredHole = useEditor((state) => state.setHoveredHole)
235
+
236
+ useEffect(() => {
237
+ if (hoveredHole && !selectedIds.includes(hoveredHole.nodeId as AnyNodeId)) {
238
+ setHoveredHole(null)
239
+ }
240
+ }, [hoveredHole, selectedIds, setHoveredHole])
241
+
242
+ if (selectedIds.length === 0) return null
243
+
244
+ return (
245
+ <>
246
+ {selectedIds.map((id) => (
247
+ <SelectedSlabHoleHighlights key={id} slabId={id} />
248
+ ))}
249
+ </>
250
+ )
251
+ }
252
+
253
+ function SelectedSlabHoleHighlights({ slabId }: { slabId: string }) {
254
+ const node = useScene((state) => state.nodes[slabId as AnyNodeId])
255
+ const override = useLiveNodeOverrides((state) => state.overrides.get(slabId))
256
+ const hoveredHole = useEditor((state) => state.hoveredHole)
257
+ const editingHole = useEditor((state) => state.editingHole)
258
+ const setHoveredHole = useEditor((state) => state.setHoveredHole)
259
+
260
+ const slab = node?.type === 'slab' ? (node as SlabNode) : null
261
+ const effectiveSlab = slab ? ({ ...slab, ...(override ?? {}) } as SlabNode) : null
262
+ const holes = effectiveSlab?.holes ?? []
263
+ const holeMetadata = effectiveSlab?.holeMetadata ?? []
264
+
265
+ // Portal the highlights into the slab's OWN object — exactly how
266
+ // NodeArrowHandles portals into a node object. This is what makes R3F deliver
267
+ // pointer events to the hit meshes (portaling into the raw scene renders but
268
+ // never receives hover/click), and the children inherit the slab's world
269
+ // transform so the holes sit in slab-local space with no manual math.
270
+ const [slabObject, setSlabObject] = useState<Object3D | null>(
271
+ () => sceneRegistry.nodes.get(slabId as AnyNodeId) ?? null,
272
+ )
273
+ useEffect(() => {
274
+ let frameId = 0
275
+ const resolve = () => {
276
+ const next = sceneRegistry.nodes.get(slabId as AnyNodeId) ?? null
277
+ setSlabObject((cur) => (cur === next ? cur : next))
278
+ if (!next) {
279
+ frameId = window.requestAnimationFrame(resolve)
280
+ }
281
+ }
282
+ resolve()
283
+ return () => {
284
+ if (frameId) window.cancelAnimationFrame(frameId)
285
+ }
286
+ }, [slabId])
287
+
288
+ useEffect(() => {
289
+ if (hoveredHole?.nodeId !== slabId) return
290
+ if (!effectiveSlab || hoveredHole.holeIndex >= holes.length) {
291
+ setHoveredHole(null)
292
+ }
293
+ }, [effectiveSlab, holes.length, hoveredHole, slabId, setHoveredHole])
294
+
295
+ if (!effectiveSlab || holes.length === 0 || !slabObject) return null
296
+
297
+ const surfaceY = getSurfaceY(effectiveSlab)
298
+ const hitCenterY = getHitCenterY(effectiveSlab)
299
+ const hitHeight = getHitHeight(effectiveSlab)
300
+
301
+ return createPortal(
302
+ <group>
303
+ {holes.map((hole, holeIndex) => {
304
+ const metadata = holeMetadata[holeIndex]
305
+ if (hole.length < 3) return null
306
+
307
+ const isActive =
308
+ (hoveredHole?.nodeId === slabId && hoveredHole.holeIndex === holeIndex) ||
309
+ (editingHole?.nodeId === slabId && editingHole.holeIndex === holeIndex)
310
+
311
+ return (
312
+ <SlabHoleHighlight
313
+ active={isActive}
314
+ hitCenterY={hitCenterY}
315
+ hitHeight={hitHeight}
316
+ hole={hole}
317
+ holeIndex={holeIndex}
318
+ key={`${slabId}:${holeIndex}`}
319
+ metadata={metadata}
320
+ slabId={slabId}
321
+ surfaceY={surfaceY}
322
+ />
323
+ )
324
+ })}
325
+ </group>,
326
+ slabObject,
327
+ )
328
+ }
329
+
330
+ function SlabHoleHighlight({
331
+ active,
332
+ hitCenterY,
333
+ hitHeight,
334
+ hole,
335
+ holeIndex,
336
+ metadata,
337
+ slabId,
338
+ surfaceY,
339
+ }: {
340
+ active: boolean
341
+ hitCenterY: number
342
+ hitHeight: number
343
+ hole: HolePolygon
344
+ holeIndex: number
345
+ metadata: SurfaceHoleMetadata | undefined
346
+ slabId: string
347
+ surfaceY: number
348
+ }) {
349
+ const fillGeometry = useMemo(() => makeFillGeometry(hole, surfaceY), [hole, surfaceY])
350
+ const outlineGeometry = useMemo(() => makeOutlineGeometry(hole, surfaceY), [hole, surfaceY])
351
+ const hitGeometry = useMemo(
352
+ () => makeHitGeometry(hole, hitCenterY, hitHeight),
353
+ [hitCenterY, hitHeight, hole],
354
+ )
355
+
356
+ useEffect(() => () => fillGeometry.dispose(), [fillGeometry])
357
+ useEffect(() => () => outlineGeometry.dispose(), [outlineGeometry])
358
+ useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
359
+ useEffect(() => () => clearHoveredHoleIfMatches(slabId, holeIndex), [holeIndex, slabId])
360
+
361
+ const handlePointerEnter = useCallback(
362
+ (event: ThreeEvent<PointerEvent>) => {
363
+ event.stopPropagation()
364
+ useEditor.getState().setHoveredHole({ nodeId: slabId, holeIndex })
365
+ if (document.body.style.cursor !== 'pointer') {
366
+ document.body.style.cursor = 'pointer'
367
+ }
368
+ },
369
+ [holeIndex, slabId],
370
+ )
371
+
372
+ const handlePointerLeave = useCallback(
373
+ (event: ThreeEvent<PointerEvent>) => {
374
+ event.stopPropagation()
375
+ clearHoveredHoleIfMatches(slabId, holeIndex)
376
+ resetPointerCursor()
377
+ },
378
+ [holeIndex, slabId],
379
+ )
380
+
381
+ const handlePointerDown = useCallback(
382
+ (event: ThreeEvent<PointerEvent>) => {
383
+ if (event.button !== 0) return
384
+ stopPointerPropagation(event)
385
+ suppressNodeClickUntilPointerUp()
386
+ useEditor.getState().setHoveredHole({ nodeId: slabId, holeIndex })
387
+
388
+ // Auto-managed cutouts (stair / elevator) jump to their owner so the
389
+ // user edits the source rather than the synced hole. Everything else —
390
+ // manual holes and holes that predate holeMetadata — opens the editor.
391
+ if (metadata?.source === 'stair' && metadata.stairId) {
392
+ useEditor.getState().setEditingHole(null)
393
+ useEditor.getState().setHoveredHole(null)
394
+ resetPointerCursor()
395
+ selectOwnedNode(metadata.stairId, 'stair')
396
+ return
397
+ }
398
+ if (metadata?.source === 'elevator' && metadata.elevatorId) {
399
+ useEditor.getState().setEditingHole(null)
400
+ useEditor.getState().setHoveredHole(null)
401
+ resetPointerCursor()
402
+ selectOwnedNode(metadata.elevatorId, 'elevator')
403
+ return
404
+ }
405
+
406
+ useEditor.getState().setEditingHole({ nodeId: slabId, holeIndex })
407
+ useViewer.getState().setSelection({ selectedIds: [slabId as AnyNodeId] })
408
+ },
409
+ [holeIndex, metadata, slabId],
410
+ )
411
+
412
+ const handlePointerUp = useCallback((event: ThreeEvent<PointerEvent>) => {
413
+ releaseNodeClickSuppression()
414
+ stopPointerPropagation(event)
415
+ }, [])
416
+
417
+ return (
418
+ <>
419
+ <mesh
420
+ frustumCulled={false}
421
+ geometry={fillGeometry}
422
+ material={fillMaterial}
423
+ raycast={NO_RAYCAST}
424
+ renderOrder={1004}
425
+ />
426
+ <lineSegments
427
+ frustumCulled={false}
428
+ geometry={outlineGeometry}
429
+ material={outlineMaterial}
430
+ raycast={NO_RAYCAST}
431
+ renderOrder={1005}
432
+ />
433
+ {active && (
434
+ <>
435
+ <mesh
436
+ frustumCulled={false}
437
+ geometry={fillGeometry}
438
+ material={fillMaterial}
439
+ raycast={NO_RAYCAST}
440
+ renderOrder={1006}
441
+ />
442
+ <lineSegments
443
+ frustumCulled={false}
444
+ geometry={outlineGeometry}
445
+ material={outlineMaterial}
446
+ raycast={NO_RAYCAST}
447
+ renderOrder={1007}
448
+ />
449
+ </>
450
+ )}
451
+ <mesh
452
+ frustumCulled={false}
453
+ geometry={hitGeometry}
454
+ layers={EDITOR_LAYER}
455
+ material={hitMaterial}
456
+ onPointerDown={handlePointerDown}
457
+ onPointerEnter={handlePointerEnter}
458
+ onPointerLeave={handlePointerLeave}
459
+ onPointerUp={handlePointerUp}
460
+ renderOrder={1006}
461
+ />
462
+ </>
463
+ )
464
+ }
465
+
466
+ export default SlabHoleHighlights