@pascal-app/editor 1.0.0-beta.4 → 1.0.0

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 (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -11,9 +11,18 @@ import {
11
11
  useScene,
12
12
  } from '@pascal-app/core'
13
13
  import { useViewer } from '@pascal-app/viewer'
14
- import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
15
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
16
- import { BoxGeometry, type Group, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
14
+ import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
15
+ import {
16
+ memo,
17
+ useCallback,
18
+ useEffect,
19
+ useLayoutEffect,
20
+ useMemo,
21
+ useRef,
22
+ useState,
23
+ useSyncExternalStore,
24
+ } from 'react'
25
+ import { Group, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
17
26
  import { useShallow } from 'zustand/react/shallow'
18
27
  import {
19
28
  clearCeilingSnapFeedback,
@@ -23,31 +32,22 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
23
32
  import useEditor, { isGridSnapActive } from '../../../store/use-editor'
24
33
  import useInteractionScope from '../../../store/use-interaction-scope'
25
34
  import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
35
+ import {
36
+ BRACKET_Y_OFFSET,
37
+ BracketPointerState,
38
+ type BracketTarget,
39
+ bracketTargetKey,
40
+ buildCornerBrackets,
41
+ CeilingBracketBatchStore,
42
+ type CornerBracketData,
43
+ } from './ceiling-bracket-batch'
26
44
 
27
- const BRACKET_THICKNESS = 0.04
28
- const BRACKET_HEIGHT = 0.04
29
- const BRACKET_Y_OFFSET = 0.035
30
- const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28]
31
- const HANDLE_COLOR = '#d4d4d4'
32
- const HANDLE_HOVER_COLOR = '#818cf8'
33
- const HANDLE_OPACITY = 0.72
34
- const HANDLE_HOVER_OPACITY = 0.92
35
45
  const HANDLE_DRAG_THRESHOLD_PX = 4
36
- const SHARED_HANDLE_BOX_GEOMETRY = new BoxGeometry(1, 1, 1)
37
- // Draw the corner handles after the ceiling surface so they read cleanly
38
- // when unobstructed, while material depth testing still lets other scene
39
- // geometry hide them.
40
- const CORNER_RENDER_ORDER = 1000
41
-
42
- type CornerBracketData = {
43
- corner: [number, number]
44
- index: number
45
- incomingEdgeIndex: number
46
- incomingDirection: [number, number]
47
- outgoingEdgeIndex: number
48
- outgoingDirection: [number, number]
49
- incomingLength: number
50
- outgoingLength: number
46
+
47
+ type CeilingBracketController = {
48
+ onHoverChange: (cornerIndex: number, hovered: boolean) => void
49
+ onPointerDown: (cornerIndex: number, event: ThreeEvent<PointerEvent>) => void
50
+ onClick: (cornerIndex: number, event: ThreeEvent<MouseEvent>) => void
51
51
  }
52
52
 
53
53
  type CornerDragState = {
@@ -128,23 +128,211 @@ export const CeilingSelectionAffordanceSystem = () => {
128
128
 
129
129
  if (!shouldRender) return null
130
130
 
131
+ return <LevelCeilingBrackets ceilings={ceilings} key={currentLevelId} levelId={currentLevelId} />
132
+ }
133
+
134
+ const LevelCeilingBrackets = ({
135
+ ceilings,
136
+ levelId,
137
+ }: {
138
+ ceilings: CeilingNode[]
139
+ levelId: string
140
+ }) => {
141
+ const [store] = useState(() => new CeilingBracketBatchStore())
142
+ const [controllers] = useState(() => new Map<CeilingNode['id'], CeilingBracketController>())
143
+ const [levelObject, setLevelObject] = useState<Object3D | null>(
144
+ () => sceneRegistry.nodes.get(levelId) ?? null,
145
+ )
146
+ // A stable portal container preserves instance event records when the level object changes.
147
+ const [bracketsRoot] = useState(() => new Group())
148
+ const registryRevision = useRef(sceneRegistry.revision)
149
+
150
+ useFrame(() => {
151
+ if (registryRevision.current === sceneRegistry.revision) return
152
+ registryRevision.current = sceneRegistry.revision
153
+ setLevelObject(sceneRegistry.nodes.get(levelId) ?? null)
154
+ })
155
+
156
+ useLayoutEffect(() => {
157
+ if (!levelObject) return
158
+ levelObject.add(bracketsRoot)
159
+ return () => {
160
+ bracketsRoot.removeFromParent()
161
+ }
162
+ }, [bracketsRoot, levelObject])
163
+
164
+ // The brackets render on SCENE_LAYER (scene-depth occlusion), so unlike
165
+ // EDITOR_LAYER affordances the thumbnail camera can't filter them — hide
166
+ // them around captures via synchronous Object3D.visible mutation (the
167
+ // capture renders right after the emit), same as `site-boundary-editor.tsx`.
168
+ useEffect(() => {
169
+ const hideForCapture = () => {
170
+ bracketsRoot.visible = false
171
+ }
172
+ const restoreAfterCapture = () => {
173
+ bracketsRoot.visible = true
174
+ }
175
+ emitter.on('thumbnail:before-capture', hideForCapture)
176
+ emitter.on('thumbnail:after-capture', restoreAfterCapture)
177
+ return () => {
178
+ emitter.off('thumbnail:before-capture', hideForCapture)
179
+ emitter.off('thumbnail:after-capture', restoreAfterCapture)
180
+ }
181
+ }, [bracketsRoot])
182
+
183
+ useEffect(() => {
184
+ let frameId = 0
185
+
186
+ const resolveLevelObject = () => {
187
+ const nextLevelObject = sceneRegistry.nodes.get(levelId) ?? null
188
+ setLevelObject((currentLevelObject) => {
189
+ if (currentLevelObject === nextLevelObject) {
190
+ return currentLevelObject
191
+ }
192
+ return nextLevelObject
193
+ })
194
+
195
+ if (!nextLevelObject) {
196
+ frameId = window.requestAnimationFrame(resolveLevelObject)
197
+ }
198
+ }
199
+
200
+ resolveLevelObject()
201
+
202
+ return () => {
203
+ if (frameId) {
204
+ window.cancelAnimationFrame(frameId)
205
+ }
206
+ }
207
+ }, [levelId])
208
+
209
+ useEffect(() => () => store.dispose(), [store])
210
+
131
211
  return (
132
212
  <>
133
213
  {ceilings.map((ceiling) => (
134
- <CeilingSelectionAffordance ceiling={ceiling} key={ceiling.id} levelId={currentLevelId} />
214
+ <CeilingSelectionAffordance
215
+ ceiling={ceiling}
216
+ controllers={controllers}
217
+ key={ceiling.id}
218
+ levelId={levelId}
219
+ store={store}
220
+ />
135
221
  ))}
222
+ {levelObject &&
223
+ createPortal(
224
+ <CeilingBracketMeshes controllers={controllers} store={store} />,
225
+ bracketsRoot,
226
+ )}
136
227
  </>
137
228
  )
138
229
  }
139
230
 
140
- const CeilingSelectionAffordance = ({
231
+ const CeilingBracketMeshes = memo(
232
+ ({
233
+ store,
234
+ controllers,
235
+ }: {
236
+ store: CeilingBracketBatchStore
237
+ controllers: Map<CeilingNode['id'], CeilingBracketController>
238
+ }) => {
239
+ const get = useThree((state) => state.get)
240
+ const [pointer] = useState(() => new BracketPointerState())
241
+ const previousMeshes = useRef(store.getSnapshot())
242
+ const meshes = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
243
+
244
+ useLayoutEffect(() => {
245
+ for (const [index, previous] of previousMeshes.current.entries()) {
246
+ const next = meshes[index]!
247
+ if (previous !== next) pointer.replaceObject(previous.uuid, next.uuid)
248
+ }
249
+ previousMeshes.current = meshes
250
+ }, [meshes, pointer])
251
+
252
+ useLayoutEffect(
253
+ () => () => {
254
+ for (const target of pointer.clearHover()) {
255
+ controllers.get(target.ceilingId)?.onHoverChange(target.cornerIndex, false)
256
+ }
257
+ },
258
+ [controllers, pointer],
259
+ )
260
+
261
+ const hover = (target: BracketTarget, hovered: boolean) => {
262
+ controllers.get(target.ceilingId)?.onHoverChange(target.cornerIndex, hovered)
263
+ }
264
+ const eventKey = (event: ThreeEvent<PointerEvent>) =>
265
+ `${event.object.uuid}/${event.index}/${event.instanceId}`
266
+ const handleOver = (event: ThreeEvent<PointerEvent>) => {
267
+ event.stopPropagation()
268
+ const target = store.resolveHitTarget(event, event.intersections)
269
+ if (!target) return
270
+ const previous = pointer.over(eventKey(event), target)
271
+ if (previous && bracketTargetKey(previous) === bracketTargetKey(target)) return
272
+ if (previous) hover(previous, false)
273
+ hover(target, true)
274
+ }
275
+ const handleOut = (event: ThreeEvent<PointerEvent>) => {
276
+ event.stopPropagation()
277
+ const target = pointer.out(eventKey(event))
278
+ if (target) hover(target, false)
279
+ }
280
+ const handlePointerDown = (event: ThreeEvent<PointerEvent>) => {
281
+ const target = store.resolveHitTarget(event, event.intersections)
282
+ if (!target) return
283
+ pointer.pointerDown(
284
+ event.intersections.flatMap((hit) => {
285
+ const hitTarget = store.getTarget(hit.object, hit.instanceId)
286
+ return hitTarget ? [hitTarget] : []
287
+ }),
288
+ )
289
+ // R3F gates clicks by mesh, not instance. Allow transfers between highlight batches,
290
+ // then enforce the original per-part pointer-down targets in handleClick.
291
+ let root = get()
292
+ while (root.previousRoot) root = root.previousRoot.getState()
293
+ const initialHits = root.internal.initialHits
294
+ for (const mesh of meshes) {
295
+ if (!initialHits.includes(mesh)) initialHits.push(mesh)
296
+ }
297
+ controllers.get(target.ceilingId)?.onPointerDown(target.cornerIndex, event)
298
+ }
299
+ const handleClick = (event: ThreeEvent<MouseEvent>) => {
300
+ const target = store.resolveHitTarget(event, event.intersections)
301
+ if (!target || !pointer.canClick(target)) return
302
+ controllers.get(target.ceilingId)?.onClick(target.cornerIndex, event)
303
+ }
304
+
305
+ return (
306
+ <>
307
+ {meshes.map((mesh) => (
308
+ <primitive
309
+ // Stable keys let R3F transfer hover and initial-hit records on capacity growth.
310
+ key={mesh.name}
311
+ object={mesh}
312
+ onClick={handleClick}
313
+ onPointerDown={handlePointerDown}
314
+ onPointerMove={handleOver}
315
+ onPointerOut={handleOut}
316
+ onPointerOver={handleOver}
317
+ />
318
+ ))}
319
+ </>
320
+ )
321
+ },
322
+ )
323
+
324
+ const CeilingSelectionAffordance = memo(function CeilingSelectionAffordance({
141
325
  ceiling,
142
326
  levelId,
327
+ store,
328
+ controllers,
143
329
  }: {
144
330
  ceiling: CeilingNode
145
331
  levelId: string
146
- }) => {
147
- const { camera, gl } = useThree()
332
+ store: CeilingBracketBatchStore
333
+ controllers: Map<CeilingNode['id'], CeilingBracketController>
334
+ }) {
335
+ const { camera, gl, invalidate } = useThree()
148
336
  const liveOverride = useLiveNodeOverrides(
149
337
  (state) => state.overrides.get(ceiling.id) as Partial<CeilingNode> | undefined,
150
338
  )
@@ -155,14 +343,10 @@ const CeilingSelectionAffordance = ({
155
343
  // Explicit height when stored, else the live level-top bound the ceiling
156
344
  // follows (primitive selector — re-render-safe).
157
345
  const resolvedHeight = useScene((s) => resolveCeilingHeight(effectiveCeiling, s.nodes))
158
- const [levelObject, setLevelObject] = useState<Object3D | null>(
159
- () => sceneRegistry.nodes.get(levelId) ?? null,
160
- )
161
346
  const [hoveredCornerIndex, setHoveredCornerIndex] = useState<number | null>(null)
162
347
  const [draggedCornerIndex, setDraggedCornerIndex] = useState<number | null>(null)
163
348
  const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
164
349
  const dragRef = useRef<CornerDragState | null>(null)
165
- const bracketsRootRef = useRef<Group>(null)
166
350
  const raycasterRef = useRef(new Raycaster())
167
351
  const ndcRef = useRef(new Vector2())
168
352
  const planeRef = useRef(new Plane())
@@ -175,22 +359,6 @@ const CeilingSelectionAffordance = ({
175
359
  const displayPolygon = previewPolygon ?? effectiveCeiling.polygon
176
360
  const activeCornerIndex = draggedCornerIndex ?? hoveredCornerIndex
177
361
  const corners = useMemo(() => buildCornerBrackets(displayPolygon), [displayPolygon])
178
- const highlightedEdgeIndices = useMemo(() => {
179
- const next = new Set<number>()
180
- if (activeCornerIndex === null || displayPolygon.length < 2) return next
181
- next.add(activeCornerIndex)
182
- next.add((activeCornerIndex - 1 + displayPolygon.length) % displayPolygon.length)
183
- return next
184
- }, [activeCornerIndex, displayPolygon.length])
185
- const highlightedCornerIndices = useMemo(() => {
186
- const next = new Set<number>()
187
- if (activeCornerIndex === null || displayPolygon.length < 2) return next
188
- next.add(activeCornerIndex)
189
- next.add((activeCornerIndex - 1 + displayPolygon.length) % displayPolygon.length)
190
- next.add((activeCornerIndex + 1) % displayPolygon.length)
191
- return next
192
- }, [activeCornerIndex, displayPolygon.length])
193
-
194
362
  useEffect(() => {
195
363
  if (activeCornerIndex === null) return
196
364
 
@@ -216,6 +384,7 @@ const CeilingSelectionAffordance = ({
216
384
 
217
385
  const getHandlePlanePoint = useCallback(
218
386
  (event: MouseEvent | PointerEvent): [number, number] | null => {
387
+ const levelObject = sceneRegistry.nodes.get(levelId)
219
388
  if (!levelObject) return null
220
389
 
221
390
  const rect = gl.domElement.getBoundingClientRect()
@@ -242,7 +411,7 @@ const CeilingSelectionAffordance = ({
242
411
  levelObject.worldToLocal(localIntersectionRef.current)
243
412
  return [localIntersectionRef.current.x, localIntersectionRef.current.z]
244
413
  },
245
- [camera, resolvedHeight, gl.domElement, levelObject],
414
+ [camera, resolvedHeight, gl.domElement, levelId],
246
415
  )
247
416
 
248
417
  const handleCornerPointerDown = useCallback(
@@ -394,264 +563,71 @@ const CeilingSelectionAffordance = ({
394
563
  }
395
564
  }, [effectiveCeiling.id, getHandlePlanePoint, levelId, selectCeilingForEdit])
396
565
 
397
- // The brackets render on SCENE_LAYER (scene-depth occlusion), so unlike
398
- // EDITOR_LAYER affordances the thumbnail camera can't filter them — hide
399
- // them around captures via synchronous Object3D.visible mutation (the
400
- // capture renders right after the emit), same as `site-boundary-editor.tsx`.
401
- useEffect(() => {
402
- const hideForCapture = () => {
403
- if (bracketsRootRef.current) bracketsRootRef.current.visible = false
404
- }
405
- const restoreAfterCapture = () => {
406
- if (bracketsRootRef.current) bracketsRootRef.current.visible = true
407
- }
408
- emitter.on('thumbnail:before-capture', hideForCapture)
409
- emitter.on('thumbnail:after-capture', restoreAfterCapture)
410
- return () => {
411
- emitter.off('thumbnail:before-capture', hideForCapture)
412
- emitter.off('thumbnail:after-capture', restoreAfterCapture)
413
- }
414
- }, [])
415
-
416
- useEffect(() => {
417
- let frameId = 0
418
-
419
- const resolveLevelObject = () => {
420
- const nextLevelObject = sceneRegistry.nodes.get(levelId) ?? null
421
- setLevelObject((currentLevelObject) => {
422
- if (currentLevelObject === nextLevelObject) {
423
- return currentLevelObject
424
- }
425
- return nextLevelObject
426
- })
566
+ useLayoutEffect(() => {
567
+ store.setGeometry(ceiling.id, corners, resolvedHeight)
568
+ invalidate()
569
+ }, [ceiling.id, corners, resolvedHeight, store, invalidate])
427
570
 
428
- if (!nextLevelObject) {
429
- frameId = window.requestAnimationFrame(resolveLevelObject)
430
- }
431
- }
432
-
433
- resolveLevelObject()
434
-
435
- return () => {
436
- if (frameId) {
437
- window.cancelAnimationFrame(frameId)
438
- }
439
- }
440
- }, [levelId])
571
+ useLayoutEffect(() => {
572
+ store.setHighlight(ceiling.id, activeCornerIndex)
573
+ invalidate()
574
+ }, [ceiling.id, activeCornerIndex, store, invalidate])
441
575
 
442
- if (!levelObject || corners.length === 0) return null
443
-
444
- return createPortal(
445
- <group position={[0, resolvedHeight + BRACKET_Y_OFFSET, 0]} ref={bracketsRootRef}>
446
- {corners.map((corner, index) => (
447
- <CornerBracket
448
- ceiling={effectiveCeiling}
449
- corner={corner}
450
- highlightIncoming={highlightedEdgeIndices.has(corner.incomingEdgeIndex)}
451
- highlightOutgoing={highlightedEdgeIndices.has(corner.outgoingEdgeIndex)}
452
- isHovered={activeCornerIndex === corner.index}
453
- isLinkedHovered={
454
- activeCornerIndex !== null &&
455
- activeCornerIndex !== corner.index &&
456
- highlightedCornerIndices.has(corner.index)
457
- }
458
- key={`${ceiling.id}-corner-${index}`}
459
- onHoverChange={(hovered) => {
460
- setHoveredCornerIndex((current) => {
461
- if (hovered) return corner.index
462
- return current === corner.index ? null : current
463
- })
464
- }}
465
- onPointerDown={(event) => handleCornerPointerDown(corner, event)}
466
- />
467
- ))}
468
- </group>,
469
- levelObject,
576
+ useLayoutEffect(
577
+ () => () => {
578
+ store.removeCeiling(ceiling.id)
579
+ invalidate()
580
+ },
581
+ [ceiling.id, store, invalidate],
470
582
  )
471
- }
472
583
 
473
- const CornerBracket = ({
474
- ceiling,
475
- corner,
476
- highlightIncoming,
477
- highlightOutgoing,
478
- isHovered,
479
- isLinkedHovered,
480
- onHoverChange,
481
- onPointerDown,
482
- }: {
483
- ceiling: CeilingNode
484
- corner: CornerBracketData
485
- highlightIncoming: boolean
486
- highlightOutgoing: boolean
487
- isHovered: boolean
488
- isLinkedHovered: boolean
489
- onHoverChange: (hovered: boolean) => void
490
- onPointerDown: (event: ThreeEvent<PointerEvent>) => void
491
- }) => {
492
- const cubeHighlighted = isHovered || isLinkedHovered
493
- const cubeColor = cubeHighlighted ? HANDLE_HOVER_COLOR : HANDLE_COLOR
494
- const cubeOpacity = cubeHighlighted ? HANDLE_HOVER_OPACITY : HANDLE_OPACITY
495
-
496
- const handleClick = (e: ThreeEvent<MouseEvent>) => {
497
- e.stopPropagation()
498
-
499
- useEditor.getState().setMovingNode(null)
500
- useInteractionScope
501
- .getState()
502
- .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'endpoint')
503
- useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'curve')
504
- useInteractionScope.getState().endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
505
- useEditor.getState().setMode('select')
506
-
507
- emitter.emit('ceiling:click' as any, {
508
- node: ceiling,
509
- nativeEvent: e.nativeEvent,
510
- localPosition: [0, 0, 0],
511
- position: [
512
- corner.corner[0],
513
- resolveCeilingHeight(ceiling, useScene.getState().nodes),
514
- corner.corner[1],
515
- ],
516
- stopPropagation: () => e.stopPropagation(),
517
- viaHandle: true,
584
+ useLayoutEffect(() => {
585
+ controllers.set(ceiling.id, {
586
+ onHoverChange: (cornerIndex, hovered) => {
587
+ setHoveredCornerIndex((current) => {
588
+ if (hovered) return cornerIndex
589
+ return current === cornerIndex ? null : current
590
+ })
591
+ },
592
+ onPointerDown: (cornerIndex, event) => {
593
+ const corner = corners[cornerIndex]
594
+ if (corner) handleCornerPointerDown(corner, event)
595
+ },
596
+ onClick: (cornerIndex, event) => {
597
+ const corner = corners[cornerIndex]
598
+ if (!corner) return
599
+ event.stopPropagation()
600
+ useEditor.getState().setMovingNode(null)
601
+ useInteractionScope
602
+ .getState()
603
+ .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'endpoint')
604
+ useInteractionScope
605
+ .getState()
606
+ .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'curve')
607
+ useInteractionScope
608
+ .getState()
609
+ .endIf((sc) => sc.kind === 'reshaping' && sc.reshape === 'hole')
610
+ useEditor.getState().setMode('select')
611
+
612
+ emitter.emit('ceiling:click' as any, {
613
+ node: effectiveCeiling,
614
+ nativeEvent: event.nativeEvent,
615
+ localPosition: [0, 0, 0],
616
+ // Position is level-local, matching the original ceiling handle payload.
617
+ position: [
618
+ corner.corner[0],
619
+ resolveCeilingHeight(effectiveCeiling, useScene.getState().nodes),
620
+ corner.corner[1],
621
+ ],
622
+ stopPropagation: () => event.stopPropagation(),
623
+ viaHandle: true,
624
+ })
625
+ },
518
626
  })
519
- }
520
-
521
- return (
522
- <group position={[corner.corner[0], 0, corner.corner[1]]}>
523
- <BracketLeg
524
- color={highlightIncoming ? HANDLE_HOVER_COLOR : HANDLE_COLOR}
525
- direction={corner.incomingDirection}
526
- highlighted={highlightIncoming}
527
- length={corner.incomingLength}
528
- onClick={handleClick}
529
- onHoverChange={onHoverChange}
530
- onPointerDown={onPointerDown}
531
- />
532
- <BracketLeg
533
- color={highlightOutgoing ? HANDLE_HOVER_COLOR : HANDLE_COLOR}
534
- direction={corner.outgoingDirection}
535
- highlighted={highlightOutgoing}
536
- length={corner.outgoingLength}
537
- onClick={handleClick}
538
- onHoverChange={onHoverChange}
539
- onPointerDown={onPointerDown}
540
- />
541
-
542
- <mesh
543
- geometry={SHARED_HANDLE_BOX_GEOMETRY}
544
- onClick={handleClick}
545
- onPointerDown={onPointerDown}
546
- onPointerEnter={(e) => {
547
- e.stopPropagation()
548
- onHoverChange(true)
549
- }}
550
- onPointerLeave={(e) => {
551
- e.stopPropagation()
552
- onHoverChange(false)
553
- }}
554
- renderOrder={CORNER_RENDER_ORDER}
555
- scale={HIT_BOX_SIZE}
556
- >
557
- <meshBasicMaterial
558
- color={cubeColor}
559
- depthTest
560
- depthWrite={false}
561
- opacity={cubeOpacity}
562
- transparent
563
- />
564
- </mesh>
565
- </group>
566
- )
567
- }
568
-
569
- const BracketLeg = ({
570
- direction,
571
- length,
572
- color,
573
- highlighted,
574
- onClick,
575
- onHoverChange,
576
- onPointerDown,
577
- }: {
578
- direction: [number, number]
579
- length: number
580
- color: string
581
- highlighted: boolean
582
- onClick: (e: ThreeEvent<MouseEvent>) => void
583
- onHoverChange: (hovered: boolean) => void
584
- onPointerDown: (event: ThreeEvent<PointerEvent>) => void
585
- }) => {
586
- const angle = -Math.atan2(direction[1], direction[0])
587
- const position: [number, number, number] = [
588
- direction[0] * (length / 2),
589
- 0,
590
- direction[1] * (length / 2),
591
- ]
592
-
593
- return (
594
- <mesh
595
- geometry={SHARED_HANDLE_BOX_GEOMETRY}
596
- onClick={onClick}
597
- onPointerDown={onPointerDown}
598
- onPointerEnter={(e) => {
599
- e.stopPropagation()
600
- onHoverChange(true)
601
- }}
602
- onPointerLeave={(e) => {
603
- e.stopPropagation()
604
- onHoverChange(false)
605
- }}
606
- position={position}
607
- renderOrder={CORNER_RENDER_ORDER}
608
- rotation={[0, angle, 0]}
609
- scale={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]}
610
- >
611
- <meshBasicMaterial
612
- color={color}
613
- depthTest
614
- depthWrite={false}
615
- opacity={highlighted ? HANDLE_HOVER_OPACITY : HANDLE_OPACITY}
616
- transparent
617
- />
618
- </mesh>
619
- )
620
- }
621
-
622
- function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
623
- if (polygon.length < 3) return []
624
-
625
- return polygon.map((corner, index) => {
626
- const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
627
- const next = polygon[(index + 1) % polygon.length]!
628
- const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
629
- const outgoingVector = [next[0] - corner[0], next[1] - corner[1]] as [number, number]
630
- const incomingDirection = normalize2D(incomingVector)
631
- const outgoingDirection = normalize2D(outgoingVector)
632
-
633
- const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
634
- const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
635
-
636
- return {
637
- corner,
638
- index,
639
- incomingEdgeIndex: (index - 1 + polygon.length) % polygon.length,
640
- incomingDirection,
641
- outgoingEdgeIndex: index,
642
- outgoingDirection,
643
- incomingLength: getBracketLength(incomingLength),
644
- outgoingLength: getBracketLength(outgoingLength),
627
+ return () => {
628
+ controllers.delete(ceiling.id)
645
629
  }
646
- })
647
- }
648
-
649
- function normalize2D(vector: [number, number]): [number, number] {
650
- const length = Math.hypot(vector[0], vector[1])
651
- if (length < 1e-6) return [1, 0]
652
- return [vector[0] / length, vector[1] / length]
653
- }
630
+ }, [ceiling.id, controllers, corners, effectiveCeiling, handleCornerPointerDown])
654
631
 
655
- function getBracketLength(edgeLength: number): number {
656
- return Math.max(0.14, Math.min(0.38, edgeLength * 0.22))
657
- }
632
+ return null
633
+ })