@pascal-app/editor 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -5,39 +5,104 @@ import {
5
5
  emitter,
6
6
  resolveLevelId,
7
7
  sceneRegistry,
8
+ snapPointToGrid,
9
+ useLiveNodeOverrides,
8
10
  useScene,
9
11
  } from '@pascal-app/core'
10
12
  import { useViewer } from '@pascal-app/viewer'
11
- import { createPortal, type ThreeEvent } from '@react-three/fiber'
12
- import { useEffect, useMemo, useState } from 'react'
13
- import type { Object3D } from 'three'
13
+ import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
15
+ import { BoxGeometry, type Group, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
14
16
  import { useShallow } from 'zustand/react/shallow'
15
- import useEditor from '../../../store/use-editor'
17
+ import {
18
+ clearCeilingSnapFeedback,
19
+ resolveCeilingPlanPointSnap,
20
+ } from '../../../lib/ceiling-plan-snap'
21
+ import { sfxEmitter } from '../../../lib/sfx-bus'
22
+ import useEditor, { isGridSnapActive } from '../../../store/use-editor'
23
+ import useInteractionScope from '../../../store/use-interaction-scope'
24
+ import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
16
25
 
17
26
  const BRACKET_THICKNESS = 0.04
18
27
  const BRACKET_HEIGHT = 0.04
19
28
  const BRACKET_Y_OFFSET = 0.035
20
29
  const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28]
21
- // Draw the corner handles after everything else and with depth testing
22
- // off (see materials below) so they stay visible — and clickable — even
23
- // when a wall, roof, or the ceiling itself would otherwise occlude them.
30
+ const HANDLE_COLOR = '#d4d4d4'
31
+ const HANDLE_HOVER_COLOR = '#818cf8'
32
+ const HANDLE_OPACITY = 0.72
33
+ const HANDLE_HOVER_OPACITY = 0.92
34
+ const HANDLE_DRAG_THRESHOLD_PX = 4
35
+ const SHARED_HANDLE_BOX_GEOMETRY = new BoxGeometry(1, 1, 1)
36
+ // Draw the corner handles after the ceiling surface so they read cleanly
37
+ // when unobstructed, while material depth testing still lets other scene
38
+ // geometry hide them.
24
39
  const CORNER_RENDER_ORDER = 1000
25
40
 
26
41
  type CornerBracketData = {
27
42
  corner: [number, number]
43
+ index: number
44
+ incomingEdgeIndex: number
28
45
  incomingDirection: [number, number]
46
+ outgoingEdgeIndex: number
29
47
  outgoingDirection: [number, number]
30
48
  incomingLength: number
31
49
  outgoingLength: number
32
- cornerStrength: number
50
+ }
51
+
52
+ type CornerDragState = {
53
+ ceilingId: CeilingNode['id']
54
+ cornerIndex: number
55
+ didDrag: boolean
56
+ initialPolygon: Array<[number, number]>
57
+ inputDraggingSet: boolean
58
+ pointerId: number
59
+ previewPolygon: Array<[number, number]> | null
60
+ previousSnappedPosition: [number, number] | null
61
+ previousInputDragging: boolean
62
+ startClientX: number
63
+ startClientY: number
64
+ startPlanePosition: [number, number]
65
+ }
66
+
67
+ function stopHandlePointerDown(event: ThreeEvent<PointerEvent>) {
68
+ event.stopPropagation()
69
+ suppressBoxSelectForPointer(event, { markHandled: false })
70
+ }
71
+
72
+ function suppressNextClick() {
73
+ const suppressClick = (clickEvent: MouseEvent) => {
74
+ clickEvent.stopImmediatePropagation()
75
+ clickEvent.preventDefault()
76
+ window.removeEventListener('click', suppressClick, true)
77
+ }
78
+ window.addEventListener('click', suppressClick, true)
79
+ requestAnimationFrame(() => {
80
+ window.removeEventListener('click', suppressClick, true)
81
+ })
82
+ }
83
+
84
+ function clearCornerDragPreview(drag: CornerDragState) {
85
+ if (drag.didDrag) {
86
+ useLiveNodeOverrides.getState().clear(drag.ceilingId)
87
+ useScene.getState().markDirty(drag.ceilingId)
88
+ }
89
+ if (drag.inputDraggingSet) {
90
+ useViewer.getState().setInputDragging(drag.previousInputDragging)
91
+ }
92
+ clearCeilingSnapFeedback()
33
93
  }
34
94
 
35
95
  export const CeilingSelectionAffordanceSystem = () => {
36
96
  const phase = useEditor((state) => state.phase)
37
97
  const mode = useEditor((state) => state.mode)
38
98
  const structureLayer = useEditor((state) => state.structureLayer)
39
- const movingNode = useEditor((state) => state.movingNode)
40
- const curvingWall = useEditor((state) => state.curvingWall)
99
+ // ANY active interaction (moving/placing a node, reshaping a boundary or
100
+ // curve, dragging a handle) unmounts the brackets: their ceiling-height hit
101
+ // boxes would otherwise catch drag-time hover, set `hoveredId` to the
102
+ // ceiling, and flash the ceiling grid mid-gesture (e.g. while dragging a
103
+ // slab polygon vertex). The brackets' own corner drag doesn't begin a
104
+ // scope, so it can't unmount itself.
105
+ const scopeIdle = useInteractionScope((state) => state.scope.kind === 'idle')
41
106
  const currentLevelId = useViewer((state) => state.selection.levelId)
42
107
 
43
108
  const ceilings = useScene(
@@ -57,8 +122,7 @@ export const CeilingSelectionAffordanceSystem = () => {
57
122
  phase === 'structure' &&
58
123
  mode === 'select' &&
59
124
  structureLayer === 'elements' &&
60
- !movingNode &&
61
- !curvingWall &&
125
+ scopeIdle &&
62
126
  currentLevelId !== null
63
127
 
64
128
  if (!shouldRender) return null
@@ -79,11 +143,271 @@ const CeilingSelectionAffordance = ({
79
143
  ceiling: CeilingNode
80
144
  levelId: string
81
145
  }) => {
146
+ const { camera, gl } = useThree()
147
+ const liveOverride = useLiveNodeOverrides(
148
+ (state) => state.overrides.get(ceiling.id) as Partial<CeilingNode> | undefined,
149
+ )
150
+ const effectiveCeiling = useMemo(
151
+ () => (liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling),
152
+ [ceiling, liveOverride],
153
+ )
82
154
  const [levelObject, setLevelObject] = useState<Object3D | null>(
83
155
  () => sceneRegistry.nodes.get(levelId) ?? null,
84
156
  )
157
+ const [hoveredCornerIndex, setHoveredCornerIndex] = useState<number | null>(null)
158
+ const [draggedCornerIndex, setDraggedCornerIndex] = useState<number | null>(null)
159
+ const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
160
+ const dragRef = useRef<CornerDragState | null>(null)
161
+ const bracketsRootRef = useRef<Group>(null)
162
+ const raycasterRef = useRef(new Raycaster())
163
+ const ndcRef = useRef(new Vector2())
164
+ const planeRef = useRef(new Plane())
165
+ const planePointRef = useRef(new Vector3())
166
+ const planeNormalRef = useRef(new Vector3())
167
+ const planeOriginRef = useRef(new Vector3())
168
+ const intersectionRef = useRef(new Vector3())
169
+ const localIntersectionRef = useRef(new Vector3())
170
+
171
+ const displayPolygon = previewPolygon ?? effectiveCeiling.polygon
172
+ const activeCornerIndex = draggedCornerIndex ?? hoveredCornerIndex
173
+ const corners = useMemo(() => buildCornerBrackets(displayPolygon), [displayPolygon])
174
+ const highlightedEdgeIndices = useMemo(() => {
175
+ const next = new Set<number>()
176
+ if (activeCornerIndex === null || displayPolygon.length < 2) return next
177
+ next.add(activeCornerIndex)
178
+ next.add((activeCornerIndex - 1 + displayPolygon.length) % displayPolygon.length)
179
+ return next
180
+ }, [activeCornerIndex, displayPolygon.length])
181
+ const highlightedCornerIndices = useMemo(() => {
182
+ const next = new Set<number>()
183
+ if (activeCornerIndex === null || displayPolygon.length < 2) return next
184
+ next.add(activeCornerIndex)
185
+ next.add((activeCornerIndex - 1 + displayPolygon.length) % displayPolygon.length)
186
+ next.add((activeCornerIndex + 1) % displayPolygon.length)
187
+ return next
188
+ }, [activeCornerIndex, displayPolygon.length])
189
+
190
+ useEffect(() => {
191
+ if (activeCornerIndex === null) return
192
+
193
+ useViewer.getState().setHoveredId(effectiveCeiling.id)
194
+ return () => {
195
+ if (useViewer.getState().hoveredId === effectiveCeiling.id) {
196
+ useViewer.getState().setHoveredId(null)
197
+ }
198
+ }
199
+ }, [activeCornerIndex, effectiveCeiling.id])
200
+
201
+ const selectCeilingForEdit = useCallback(() => {
202
+ const editor = useEditor.getState()
203
+ editor.setMovingNode(null)
204
+ useInteractionScope
205
+ .getState()
206
+ .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'endpoint')
207
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'curve')
208
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
209
+ editor.setMode('select')
210
+ useViewer.getState().setSelection({ selectedIds: [effectiveCeiling.id] })
211
+ }, [effectiveCeiling.id])
212
+
213
+ const getHandlePlanePoint = useCallback(
214
+ (event: MouseEvent | PointerEvent): [number, number] | null => {
215
+ if (!levelObject) return null
216
+
217
+ const rect = gl.domElement.getBoundingClientRect()
218
+ ndcRef.current.set(
219
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
220
+ -((event.clientY - rect.top) / rect.height) * 2 + 1,
221
+ )
222
+ raycasterRef.current.setFromCamera(ndcRef.current, camera)
223
+
224
+ planePointRef.current.set(0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0)
225
+ levelObject.localToWorld(planePointRef.current)
226
+
227
+ planeOriginRef.current.set(0, 0, 0)
228
+ levelObject.localToWorld(planeOriginRef.current)
229
+ planeNormalRef.current.set(0, 1, 0)
230
+ levelObject.localToWorld(planeNormalRef.current)
231
+ planeNormalRef.current.sub(planeOriginRef.current).normalize()
232
+ planeRef.current.setFromNormalAndCoplanarPoint(planeNormalRef.current, planePointRef.current)
233
+
234
+ const hit = raycasterRef.current.ray.intersectPlane(planeRef.current, intersectionRef.current)
235
+ if (!hit) return null
236
+
237
+ localIntersectionRef.current.copy(intersectionRef.current)
238
+ levelObject.worldToLocal(localIntersectionRef.current)
239
+ return [localIntersectionRef.current.x, localIntersectionRef.current.z]
240
+ },
241
+ [camera, effectiveCeiling.height, gl.domElement, levelObject],
242
+ )
243
+
244
+ const handleCornerPointerDown = useCallback(
245
+ (corner: CornerBracketData, event: ThreeEvent<PointerEvent>) => {
246
+ if (event.button !== 0) return
247
+ stopHandlePointerDown(event)
248
+
249
+ const startPlanePosition = getHandlePlanePoint(event.nativeEvent)
250
+ if (!startPlanePosition) return
251
+ const initialCorner = effectiveCeiling.polygon[corner.index]
252
+ if (!initialCorner) return
253
+
254
+ dragRef.current = {
255
+ ceilingId: effectiveCeiling.id,
256
+ cornerIndex: corner.index,
257
+ didDrag: false,
258
+ initialPolygon: effectiveCeiling.polygon.map(([x, z]) => [x, z] as [number, number]),
259
+ inputDraggingSet: false,
260
+ pointerId: event.pointerId,
261
+ previewPolygon: null,
262
+ previousSnappedPosition: [initialCorner[0], initialCorner[1]],
263
+ previousInputDragging: useViewer.getState().inputDragging,
264
+ startClientX: event.nativeEvent.clientX,
265
+ startClientY: event.nativeEvent.clientY,
266
+ startPlanePosition,
267
+ }
268
+ },
269
+ [effectiveCeiling.id, effectiveCeiling.polygon, getHandlePlanePoint],
270
+ )
271
+
272
+ useEffect(() => {
273
+ const handlePointerMove = (event: PointerEvent) => {
274
+ const drag = dragRef.current
275
+ if (!drag || drag.ceilingId !== effectiveCeiling.id) return
276
+ if (event.pointerId !== drag.pointerId) return
277
+
278
+ const dragDistance = Math.hypot(
279
+ event.clientX - drag.startClientX,
280
+ event.clientY - drag.startClientY,
281
+ )
282
+
283
+ const planePosition = getHandlePlanePoint(event)
284
+ if (!planePosition) return
285
+
286
+ if (!drag.didDrag) {
287
+ if (dragDistance < HANDLE_DRAG_THRESHOLD_PX) return
288
+
289
+ drag.didDrag = true
290
+ drag.inputDraggingSet = true
291
+ useViewer.getState().setInputDragging(true)
292
+ setDraggedCornerIndex(drag.cornerIndex)
293
+ selectCeilingForEdit()
294
+ sfxEmitter.emit('sfx:item-pick')
295
+ }
296
+
297
+ const initialCorner = drag.initialPolygon[drag.cornerIndex]
298
+ if (!initialCorner) return
299
+
300
+ const rawNextPosition: [number, number] = [
301
+ initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]),
302
+ initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]),
303
+ ]
304
+ const rawDelta: [number, number] = [
305
+ planePosition[0] - drag.startPlanePosition[0],
306
+ planePosition[1] - drag.startPlanePosition[1],
307
+ ]
308
+ const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
309
+ const snappedDelta = snapPointToGrid(rawDelta, gridStep)
310
+ const gridNextPosition: [number, number] = [
311
+ initialCorner[0] + snappedDelta[0],
312
+ initialCorner[1] + snappedDelta[1],
313
+ ]
314
+ const nextPosition = resolveCeilingPlanPointSnap({
315
+ rawPoint: rawNextPosition,
316
+ fallbackPoint: gridNextPosition,
317
+ levelId,
318
+ excludeId: drag.ceilingId,
319
+ }).point
320
+
321
+ const snapEpsilon = 1e-6
322
+ const alignmentSnapped =
323
+ Math.abs(nextPosition[0] - gridNextPosition[0]) > snapEpsilon ||
324
+ Math.abs(nextPosition[1] - gridNextPosition[1]) > snapEpsilon
325
+ if (
326
+ (gridStep > 0 || alignmentSnapped) &&
327
+ drag.previousSnappedPosition &&
328
+ (nextPosition[0] !== drag.previousSnappedPosition[0] ||
329
+ nextPosition[1] !== drag.previousSnappedPosition[1])
330
+ ) {
331
+ sfxEmitter.emit('sfx:grid-snap')
332
+ }
333
+ drag.previousSnappedPosition = nextPosition
85
334
 
86
- const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
335
+ const nextPolygon = drag.initialPolygon.map((polygonPoint, index) =>
336
+ index === drag.cornerIndex ? nextPosition : polygonPoint,
337
+ )
338
+
339
+ drag.previewPolygon = nextPolygon
340
+ setPreviewPolygon(nextPolygon)
341
+ useLiveNodeOverrides.getState().set(drag.ceilingId, { polygon: nextPolygon })
342
+ useScene.getState().markDirty(drag.ceilingId)
343
+ }
344
+
345
+ const finishDrag = (event: PointerEvent) => {
346
+ const drag = dragRef.current
347
+ if (!drag || event.pointerId !== drag.pointerId) return
348
+
349
+ dragRef.current = null
350
+ setDraggedCornerIndex(null)
351
+ setPreviewPolygon(null)
352
+
353
+ if (drag.didDrag) {
354
+ event.preventDefault()
355
+ suppressNextClick()
356
+
357
+ if (drag.previewPolygon) {
358
+ useScene.getState().updateNode(drag.ceilingId, { polygon: drag.previewPolygon })
359
+ useViewer.getState().setSelection({ selectedIds: [drag.ceilingId] })
360
+ }
361
+
362
+ sfxEmitter.emit('sfx:item-place')
363
+ }
364
+
365
+ clearCornerDragPreview(drag)
366
+ }
367
+
368
+ const cancelDrag = (event: PointerEvent) => {
369
+ const drag = dragRef.current
370
+ if (!drag || event.pointerId !== drag.pointerId) return
371
+
372
+ dragRef.current = null
373
+ setDraggedCornerIndex(null)
374
+ setPreviewPolygon(null)
375
+ clearCornerDragPreview(drag)
376
+ }
377
+
378
+ window.addEventListener('pointermove', handlePointerMove)
379
+ window.addEventListener('pointerup', finishDrag, true)
380
+ window.addEventListener('pointercancel', cancelDrag, true)
381
+ return () => {
382
+ window.removeEventListener('pointermove', handlePointerMove)
383
+ window.removeEventListener('pointerup', finishDrag, true)
384
+ window.removeEventListener('pointercancel', cancelDrag, true)
385
+
386
+ const drag = dragRef.current
387
+ if (!drag || drag.ceilingId !== effectiveCeiling.id) return
388
+ dragRef.current = null
389
+ clearCornerDragPreview(drag)
390
+ }
391
+ }, [effectiveCeiling.id, getHandlePlanePoint, levelId, selectCeilingForEdit])
392
+
393
+ // The brackets render on SCENE_LAYER (scene-depth occlusion), so unlike
394
+ // EDITOR_LAYER affordances the thumbnail camera can't filter them — hide
395
+ // them around captures via synchronous Object3D.visible mutation (the
396
+ // capture renders right after the emit), same as `site-boundary-editor.tsx`.
397
+ useEffect(() => {
398
+ const hideForCapture = () => {
399
+ if (bracketsRootRef.current) bracketsRootRef.current.visible = false
400
+ }
401
+ const restoreAfterCapture = () => {
402
+ if (bracketsRootRef.current) bracketsRootRef.current.visible = true
403
+ }
404
+ emitter.on('thumbnail:before-capture', hideForCapture)
405
+ emitter.on('thumbnail:after-capture', restoreAfterCapture)
406
+ return () => {
407
+ emitter.off('thumbnail:before-capture', hideForCapture)
408
+ emitter.off('thumbnail:after-capture', restoreAfterCapture)
409
+ }
410
+ }, [])
87
411
 
88
412
  useEffect(() => {
89
413
  let frameId = 0
@@ -114,9 +438,31 @@ const CeilingSelectionAffordance = ({
114
438
  if (!levelObject || corners.length === 0) return null
115
439
 
116
440
  return createPortal(
117
- <group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
441
+ <group
442
+ position={[0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}
443
+ ref={bracketsRootRef}
444
+ >
118
445
  {corners.map((corner, index) => (
119
- <CornerBracket ceiling={ceiling} corner={corner} key={`${ceiling.id}-corner-${index}`} />
446
+ <CornerBracket
447
+ ceiling={effectiveCeiling}
448
+ corner={corner}
449
+ highlightIncoming={highlightedEdgeIndices.has(corner.incomingEdgeIndex)}
450
+ highlightOutgoing={highlightedEdgeIndices.has(corner.outgoingEdgeIndex)}
451
+ isHovered={activeCornerIndex === corner.index}
452
+ isLinkedHovered={
453
+ activeCornerIndex !== null &&
454
+ activeCornerIndex !== corner.index &&
455
+ highlightedCornerIndices.has(corner.index)
456
+ }
457
+ key={`${ceiling.id}-corner-${index}`}
458
+ onHoverChange={(hovered) => {
459
+ setHoveredCornerIndex((current) => {
460
+ if (hovered) return corner.index
461
+ return current === corner.index ? null : current
462
+ })
463
+ }}
464
+ onPointerDown={(event) => handleCornerPointerDown(corner, event)}
465
+ />
120
466
  ))}
121
467
  </group>,
122
468
  levelObject,
@@ -126,25 +472,35 @@ const CeilingSelectionAffordance = ({
126
472
  const CornerBracket = ({
127
473
  ceiling,
128
474
  corner,
475
+ highlightIncoming,
476
+ highlightOutgoing,
477
+ isHovered,
478
+ isLinkedHovered,
479
+ onHoverChange,
480
+ onPointerDown,
129
481
  }: {
130
482
  ceiling: CeilingNode
131
483
  corner: CornerBracketData
484
+ highlightIncoming: boolean
485
+ highlightOutgoing: boolean
486
+ isHovered: boolean
487
+ isLinkedHovered: boolean
488
+ onHoverChange: (hovered: boolean) => void
489
+ onPointerDown: (event: ThreeEvent<PointerEvent>) => void
132
490
  }) => {
133
- const [isHovered, setIsHovered] = useState(false)
134
- const color = '#d4d4d4'
135
- const opacity = 0.72
136
- const cubeColor = isHovered ? '#818cf8' : '#d4d4d4'
137
- const cubeOpacity = isHovered ? 0.92 : 0.72
491
+ const cubeHighlighted = isHovered || isLinkedHovered
492
+ const cubeColor = cubeHighlighted ? HANDLE_HOVER_COLOR : HANDLE_COLOR
493
+ const cubeOpacity = cubeHighlighted ? HANDLE_HOVER_OPACITY : HANDLE_OPACITY
138
494
 
139
495
  const handleClick = (e: ThreeEvent<MouseEvent>) => {
140
496
  e.stopPropagation()
141
497
 
142
- const nodes = useScene.getState().nodes
143
-
144
498
  useEditor.getState().setMovingNode(null)
145
- useEditor.getState().setMovingWallEndpoint(null)
146
- useEditor.getState().setCurvingWall(null)
147
- useEditor.getState().setEditingHole(null)
499
+ useInteractionScope
500
+ .getState()
501
+ .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'endpoint')
502
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'curve')
503
+ useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
148
504
  useEditor.getState().setMode('select')
149
505
 
150
506
  emitter.emit('ceiling:click' as any, {
@@ -160,36 +516,42 @@ const CornerBracket = ({
160
516
  return (
161
517
  <group position={[corner.corner[0], 0, corner.corner[1]]}>
162
518
  <BracketLeg
163
- color={color}
519
+ color={highlightIncoming ? HANDLE_HOVER_COLOR : HANDLE_COLOR}
164
520
  direction={corner.incomingDirection}
521
+ highlighted={highlightIncoming}
165
522
  length={corner.incomingLength}
166
523
  onClick={handleClick}
167
- opacity={opacity}
524
+ onHoverChange={onHoverChange}
525
+ onPointerDown={onPointerDown}
168
526
  />
169
527
  <BracketLeg
170
- color={color}
528
+ color={highlightOutgoing ? HANDLE_HOVER_COLOR : HANDLE_COLOR}
171
529
  direction={corner.outgoingDirection}
530
+ highlighted={highlightOutgoing}
172
531
  length={corner.outgoingLength}
173
532
  onClick={handleClick}
174
- opacity={opacity}
533
+ onHoverChange={onHoverChange}
534
+ onPointerDown={onPointerDown}
175
535
  />
176
536
 
177
537
  <mesh
538
+ geometry={SHARED_HANDLE_BOX_GEOMETRY}
178
539
  onClick={handleClick}
540
+ onPointerDown={onPointerDown}
179
541
  onPointerEnter={(e) => {
180
542
  e.stopPropagation()
181
- setIsHovered(true)
543
+ onHoverChange(true)
182
544
  }}
183
545
  onPointerLeave={(e) => {
184
546
  e.stopPropagation()
185
- setIsHovered(false)
547
+ onHoverChange(false)
186
548
  }}
187
549
  renderOrder={CORNER_RENDER_ORDER}
550
+ scale={HIT_BOX_SIZE}
188
551
  >
189
- <boxGeometry args={HIT_BOX_SIZE} />
190
552
  <meshBasicMaterial
191
553
  color={cubeColor}
192
- depthTest={false}
554
+ depthTest
193
555
  depthWrite={false}
194
556
  opacity={cubeOpacity}
195
557
  transparent
@@ -203,16 +565,20 @@ const BracketLeg = ({
203
565
  direction,
204
566
  length,
205
567
  color,
568
+ highlighted,
206
569
  onClick,
207
- opacity,
570
+ onHoverChange,
571
+ onPointerDown,
208
572
  }: {
209
573
  direction: [number, number]
210
574
  length: number
211
575
  color: string
576
+ highlighted: boolean
212
577
  onClick: (e: ThreeEvent<MouseEvent>) => void
213
- opacity: number
578
+ onHoverChange: (hovered: boolean) => void
579
+ onPointerDown: (event: ThreeEvent<PointerEvent>) => void
214
580
  }) => {
215
- const angle = Math.atan2(direction[1], direction[0])
581
+ const angle = -Math.atan2(direction[1], direction[0])
216
582
  const position: [number, number, number] = [
217
583
  direction[0] * (length / 2),
218
584
  0,
@@ -221,17 +587,27 @@ const BracketLeg = ({
221
587
 
222
588
  return (
223
589
  <mesh
590
+ geometry={SHARED_HANDLE_BOX_GEOMETRY}
224
591
  onClick={onClick}
592
+ onPointerDown={onPointerDown}
593
+ onPointerEnter={(e) => {
594
+ e.stopPropagation()
595
+ onHoverChange(true)
596
+ }}
597
+ onPointerLeave={(e) => {
598
+ e.stopPropagation()
599
+ onHoverChange(false)
600
+ }}
225
601
  position={position}
226
602
  renderOrder={CORNER_RENDER_ORDER}
227
603
  rotation={[0, angle, 0]}
604
+ scale={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]}
228
605
  >
229
- <boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
230
606
  <meshBasicMaterial
231
607
  color={color}
232
- depthTest={false}
608
+ depthTest
233
609
  depthWrite={false}
234
- opacity={opacity}
610
+ opacity={highlighted ? HANDLE_HOVER_OPACITY : HANDLE_OPACITY}
235
611
  transparent
236
612
  />
237
613
  </mesh>
@@ -241,7 +617,7 @@ const BracketLeg = ({
241
617
  function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
242
618
  if (polygon.length < 3) return []
243
619
 
244
- const allCorners = polygon.map((corner, index) => {
620
+ return polygon.map((corner, index) => {
245
621
  const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
246
622
  const next = polygon[(index + 1) % polygon.length]!
247
623
  const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
@@ -251,35 +627,18 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
251
627
 
252
628
  const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
253
629
  const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
254
- const cornerStrength =
255
- 1 -
256
- Math.abs(
257
- incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1],
258
- )
259
630
 
260
631
  return {
261
632
  corner,
633
+ index,
634
+ incomingEdgeIndex: (index - 1 + polygon.length) % polygon.length,
262
635
  incomingDirection,
636
+ outgoingEdgeIndex: index,
263
637
  outgoingDirection,
264
638
  incomingLength: getBracketLength(incomingLength),
265
639
  outgoingLength: getBracketLength(outgoingLength),
266
- cornerStrength,
267
640
  }
268
641
  })
269
-
270
- if (allCorners.length <= 4) {
271
- return allCorners
272
- }
273
-
274
- const selectedIndices = new Set(
275
- allCorners
276
- .map((corner, index) => ({ index, strength: corner.cornerStrength }))
277
- .sort((a, b) => b.strength - a.strength)
278
- .slice(0, 4)
279
- .map(({ index }) => index),
280
- )
281
-
282
- return allCorners.filter((_, index) => selectedIndices.has(index))
283
642
  }
284
643
 
285
644
  function normalize2D(vector: [number, number]): [number, number] {