@pascal-app/editor 1.0.0-beta.5 → 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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,371 @@
1
+ import type { CeilingNode } from '@pascal-app/core'
2
+ import {
3
+ BoxGeometry,
4
+ Color,
5
+ Euler,
6
+ InstancedMesh,
7
+ type Intersection,
8
+ Matrix4,
9
+ MeshBasicMaterial,
10
+ type Object3D,
11
+ Quaternion,
12
+ Sphere,
13
+ StaticDrawUsage,
14
+ Vector3,
15
+ } from 'three'
16
+
17
+ export const BRACKET_Y_OFFSET = 0.035
18
+ const SHARED_HANDLE_BOX_GEOMETRY = new BoxGeometry(1, 1, 1)
19
+ SHARED_HANDLE_BOX_GEOMETRY.computeBoundingSphere()
20
+ const NORMAL_COLOR = new Color('#d4d4d4')
21
+ const HIGHLIGHT_COLOR = new Color('#818cf8')
22
+
23
+ export type CornerBracketData = {
24
+ corner: [number, number]
25
+ index: number
26
+ incomingEdgeIndex: number
27
+ incomingDirection: [number, number]
28
+ outgoingEdgeIndex: number
29
+ outgoingDirection: [number, number]
30
+ incomingLength: number
31
+ outgoingLength: number
32
+ }
33
+
34
+ export type BracketPart = 'incoming' | 'outgoing' | 'cube'
35
+ export type BracketTarget = {
36
+ ceilingId: CeilingNode['id']
37
+ cornerIndex: number
38
+ part: BracketPart
39
+ }
40
+
41
+ type BracketInstance = BracketTarget & {
42
+ matrix: Matrix4
43
+ highlighted: boolean
44
+ instanceId: number
45
+ }
46
+
47
+ type CeilingInstances = {
48
+ corners: CornerBracketData[]
49
+ height: number
50
+ activeCornerIndex: number | null
51
+ instances: BracketInstance[]
52
+ }
53
+
54
+ type BracketBatch = {
55
+ mesh: InstancedMesh<BoxGeometry, MeshBasicMaterial>
56
+ instances: BracketInstance[]
57
+ }
58
+
59
+ export function growBracketCapacity(current: number, required: number): number {
60
+ return required <= current ? current : Math.max(32, required * 2)
61
+ }
62
+
63
+ export function getBracketHighlights(cornerCount: number, activeCornerIndex: number | null) {
64
+ const edges = new Set<number>()
65
+ const corners = new Set<number>()
66
+ if (activeCornerIndex !== null && cornerCount >= 2) {
67
+ const previous = (activeCornerIndex - 1 + cornerCount) % cornerCount
68
+ edges.add(activeCornerIndex)
69
+ edges.add(previous)
70
+ corners.add(activeCornerIndex)
71
+ corners.add(previous)
72
+ corners.add((activeCornerIndex + 1) % cornerCount)
73
+ }
74
+ return { edges, corners }
75
+ }
76
+
77
+ export function getBracketMatrix(corner: CornerBracketData, part: BracketPart, height: number) {
78
+ const position = new Vector3(corner.corner[0], height + BRACKET_Y_OFFSET, corner.corner[1])
79
+ const rotation = new Quaternion()
80
+ const scale = new Vector3(0.28, 0.08, 0.28)
81
+ if (part !== 'cube') {
82
+ const direction = part === 'incoming' ? corner.incomingDirection : corner.outgoingDirection
83
+ const length = part === 'incoming' ? corner.incomingLength : corner.outgoingLength
84
+ position.x += direction[0] * (length / 2)
85
+ position.z += direction[1] * (length / 2)
86
+ rotation.setFromEuler(new Euler(0, -Math.atan2(direction[1], direction[0]), 0))
87
+ scale.set(length, 0.04, 0.04)
88
+ }
89
+ return new Matrix4().compose(position, rotation, scale)
90
+ }
91
+
92
+ function createBatch(highlighted: boolean, capacity: number): BracketBatch {
93
+ const material = new MeshBasicMaterial({
94
+ transparent: true,
95
+ opacity: highlighted ? 0.92 : 0.72,
96
+ depthTest: true,
97
+ depthWrite: false,
98
+ })
99
+ // WebGPU releases instance attributes through the geometry's dispose listener.
100
+ const mesh = new InstancedMesh(SHARED_HANDLE_BOX_GEOMETRY.clone(), material, capacity)
101
+ mesh.name = highlighted ? 'ceiling-brackets-highlighted' : 'ceiling-brackets-normal'
102
+ mesh.renderOrder = highlighted ? 1001 : 1000
103
+ mesh.frustumCulled = false
104
+ // Three 0.185.1 re-uploads the whole matrix array every render when it fits the device's
105
+ // uniform-buffer limit (roughly 1024 matrices), ignoring versions/ranges; accepted for
106
+ // small batches. Above that limit, the attribute path honors versions and ranges.
107
+ mesh.instanceMatrix.setUsage(StaticDrawUsage)
108
+ mesh.setColorAt(0, highlighted ? HIGHLIGHT_COLOR : NORMAL_COLOR)
109
+ mesh.instanceColor!.setUsage(StaticDrawUsage)
110
+ mesh.onAfterRender = () => {
111
+ // TSL uploads internal wrappers; clear the source ranges after they have been consumed.
112
+ mesh.instanceMatrix.clearUpdateRanges()
113
+ mesh.instanceColor!.clearUpdateRanges()
114
+ }
115
+ mesh.count = 0
116
+ mesh.boundingSphere = new Sphere()
117
+ return { mesh, instances: [] }
118
+ }
119
+
120
+ export class CeilingBracketBatchStore {
121
+ private readonly ceilings = new Map<CeilingNode['id'], CeilingInstances>()
122
+ private readonly batches = [createBatch(false, 32), createBatch(true, 32)]
123
+ private readonly listeners = new Set<() => void>()
124
+ private meshes = this.batches.map((batch) => batch.mesh)
125
+ private readonly instanceSphere = new Sphere()
126
+
127
+ readonly getSnapshot = () => this.meshes
128
+ readonly subscribe = (listener: () => void) => {
129
+ this.listeners.add(listener)
130
+ return () => this.listeners.delete(listener)
131
+ }
132
+
133
+ getTarget(object: Object3D, instanceId: number | undefined): BracketTarget | undefined {
134
+ if (instanceId === undefined) return undefined
135
+ return this.batches.find((batch) => batch.mesh === object)?.instances[instanceId]
136
+ }
137
+
138
+ resolveHitTarget(
139
+ event: Pick<Intersection, 'object' | 'instanceId' | 'distance'>,
140
+ hits: Array<Pick<Intersection, 'object' | 'instanceId' | 'distance'>>,
141
+ ) {
142
+ let target = this.getTarget(event.object, event.instanceId)
143
+ if (!target) return undefined
144
+ // Equal-distance ownership must not depend on which opacity batch is raycast first.
145
+ for (const hit of hits) {
146
+ if (hit.distance !== event.distance) continue
147
+ const candidate = this.getTarget(hit.object, hit.instanceId)
148
+ if (candidate && bracketTargetKey(candidate) < bracketTargetKey(target)) target = candidate
149
+ }
150
+ return target
151
+ }
152
+
153
+ getLocation(target: BracketTarget) {
154
+ const instance = this.ceilings
155
+ .get(target.ceilingId)
156
+ ?.instances.find(
157
+ (item) => item.cornerIndex === target.cornerIndex && item.part === target.part,
158
+ )
159
+ if (!instance) return undefined
160
+ return {
161
+ mesh: this.batches[Number(instance.highlighted)]!.mesh,
162
+ instanceId: instance.instanceId,
163
+ }
164
+ }
165
+
166
+ setGeometry(ceilingId: CeilingNode['id'], corners: CornerBracketData[], height: number) {
167
+ let ceiling = this.ceilings.get(ceilingId)
168
+ if (ceiling?.corners === corners && ceiling.height === height) return
169
+ if (!ceiling || ceiling.corners.length !== corners.length) {
170
+ const activeCornerIndex = ceiling?.activeCornerIndex ?? null
171
+ this.removeCeiling(ceilingId)
172
+ ceiling = { corners, height, activeCornerIndex: null, instances: [] }
173
+ this.ceilings.set(ceilingId, ceiling)
174
+ this.ensureCapacity(false, this.batches[0]!.instances.length + corners.length * 3)
175
+ for (const corner of corners) {
176
+ for (const part of ['incoming', 'outgoing', 'cube'] as const) {
177
+ const instance: BracketInstance = {
178
+ ceilingId,
179
+ cornerIndex: corner.index,
180
+ part,
181
+ matrix: getBracketMatrix(corner, part, height),
182
+ highlighted: false,
183
+ instanceId: -1,
184
+ }
185
+ ceiling.instances.push(instance)
186
+ this.append(instance)
187
+ }
188
+ }
189
+ this.setHighlight(ceilingId, activeCornerIndex)
190
+ return
191
+ }
192
+ ceiling.corners = corners
193
+ ceiling.height = height
194
+ for (const instance of ceiling.instances) {
195
+ instance.matrix = getBracketMatrix(corners[instance.cornerIndex]!, instance.part, height)
196
+ this.write(instance)
197
+ }
198
+ }
199
+
200
+ setHighlight(ceilingId: CeilingNode['id'], activeCornerIndex: number | null) {
201
+ const ceiling = this.ceilings.get(ceilingId)
202
+ if (!ceiling || ceiling.activeCornerIndex === activeCornerIndex) return
203
+ ceiling.activeCornerIndex = activeCornerIndex
204
+ const { edges, corners } = getBracketHighlights(ceiling.corners.length, activeCornerIndex)
205
+ for (const instance of ceiling.instances) {
206
+ const corner = ceiling.corners[instance.cornerIndex]!
207
+ const highlighted =
208
+ instance.part === 'cube'
209
+ ? corners.has(instance.cornerIndex)
210
+ : edges.has(
211
+ instance.part === 'incoming' ? corner.incomingEdgeIndex : corner.outgoingEdgeIndex,
212
+ )
213
+ if (highlighted === instance.highlighted) continue
214
+ this.remove(instance)
215
+ instance.highlighted = highlighted
216
+ this.append(instance)
217
+ }
218
+ }
219
+
220
+ removeCeiling(ceilingId: CeilingNode['id']) {
221
+ const ceiling = this.ceilings.get(ceilingId)
222
+ if (!ceiling) return
223
+ for (const instance of ceiling.instances) this.remove(instance)
224
+ this.ceilings.delete(ceilingId)
225
+ }
226
+
227
+ dispose() {
228
+ for (const batch of this.batches) {
229
+ batch.mesh.geometry.dispose()
230
+ batch.mesh.dispose()
231
+ batch.mesh.material.dispose()
232
+ }
233
+ }
234
+
235
+ private ensureCapacity(highlighted: boolean, required: number) {
236
+ const index = Number(highlighted)
237
+ const batch = this.batches[index]!
238
+ const capacity = growBracketCapacity(batch.mesh.instanceMatrix.count, required)
239
+ if (capacity === batch.mesh.instanceMatrix.count) return
240
+ const replacement = createBatch(highlighted, capacity)
241
+ replacement.instances = batch.instances
242
+ this.batches[index] = replacement
243
+ for (const instance of replacement.instances) this.write(instance)
244
+ replacement.mesh.count = replacement.instances.length
245
+ batch.mesh.geometry.dispose()
246
+ batch.mesh.dispose()
247
+ batch.mesh.material.dispose()
248
+ this.meshes = this.batches.map((item) => item.mesh)
249
+ for (const listener of this.listeners) listener()
250
+ }
251
+
252
+ private append(instance: BracketInstance) {
253
+ this.ensureCapacity(
254
+ instance.highlighted,
255
+ this.batches[Number(instance.highlighted)]!.instances.length + 1,
256
+ )
257
+ const batch = this.batches[Number(instance.highlighted)]!
258
+ instance.instanceId = batch.instances.length
259
+ batch.instances.push(instance)
260
+ batch.mesh.count = batch.instances.length
261
+ this.write(instance)
262
+ }
263
+
264
+ private remove(instance: BracketInstance) {
265
+ const batch = this.batches[Number(instance.highlighted)]!
266
+ const last = batch.instances.pop()!
267
+ if (last !== instance) {
268
+ last.instanceId = instance.instanceId
269
+ batch.instances[last.instanceId] = last
270
+ this.write(last)
271
+ }
272
+ batch.mesh.count = batch.instances.length
273
+ }
274
+
275
+ private write(instance: BracketInstance) {
276
+ const mesh = this.batches[Number(instance.highlighted)]!.mesh
277
+ mesh.setMatrixAt(instance.instanceId, instance.matrix)
278
+ mesh.setColorAt(instance.instanceId, instance.highlighted ? HIGHLIGHT_COLOR : NORMAL_COLOR)
279
+ mesh.instanceMatrix.addUpdateRange(instance.instanceId * 16, 16)
280
+ mesh.instanceColor!.addUpdateRange(instance.instanceId * 3, 3)
281
+ mesh.instanceMatrix.needsUpdate = true
282
+ mesh.instanceColor!.needsUpdate = true
283
+ // Native InstancedMesh.raycast still tests its sphere even with frustum culling off.
284
+ // Expand it on writes; retaining removed instances' bounds avoids a full scan on hover.
285
+ this.instanceSphere
286
+ .copy(SHARED_HANDLE_BOX_GEOMETRY.boundingSphere!)
287
+ .applyMatrix4(instance.matrix)
288
+ mesh.boundingSphere!.union(this.instanceSphere)
289
+ }
290
+ }
291
+
292
+ export function bracketTargetKey(target: BracketTarget) {
293
+ return `${target.ceilingId}/${target.cornerIndex}/${target.part}`
294
+ }
295
+
296
+ export class BracketPointerState {
297
+ private readonly hovered = new Map<string, BracketTarget>()
298
+ private initialTargets = new Set<string>()
299
+
300
+ over(key: string, target: BracketTarget) {
301
+ const previous = this.hovered.get(key)
302
+ this.hovered.set(key, target)
303
+ return previous
304
+ }
305
+
306
+ out(key: string) {
307
+ // R3F's pointer-out contains the old instanceId, which may now belong to another corner.
308
+ const target = this.hovered.get(key)
309
+ this.hovered.delete(key)
310
+ return target
311
+ }
312
+
313
+ replaceObject(previousUuid: string, nextUuid: string) {
314
+ for (const [key, target] of this.hovered) {
315
+ if (!key.startsWith(`${previousUuid}/`)) continue
316
+ this.hovered.delete(key)
317
+ this.hovered.set(nextUuid + key.slice(previousUuid.length), target)
318
+ }
319
+ }
320
+
321
+ clearHover() {
322
+ const targets = [...this.hovered.values()]
323
+ this.hovered.clear()
324
+ return targets
325
+ }
326
+
327
+ pointerDown(targets: BracketTarget[]) {
328
+ this.initialTargets = new Set(targets.map(bracketTargetKey))
329
+ }
330
+
331
+ canClick(target: BracketTarget) {
332
+ return this.initialTargets.has(bracketTargetKey(target))
333
+ }
334
+ }
335
+
336
+ export function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
337
+ if (polygon.length < 3) return []
338
+
339
+ return polygon.map((corner, index) => {
340
+ const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
341
+ const next = polygon[(index + 1) % polygon.length]!
342
+ const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
343
+ const outgoingVector = [next[0] - corner[0], next[1] - corner[1]] as [number, number]
344
+ const incomingDirection = normalize2D(incomingVector)
345
+ const outgoingDirection = normalize2D(outgoingVector)
346
+
347
+ const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
348
+ const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
349
+
350
+ return {
351
+ corner,
352
+ index,
353
+ incomingEdgeIndex: (index - 1 + polygon.length) % polygon.length,
354
+ incomingDirection,
355
+ outgoingEdgeIndex: index,
356
+ outgoingDirection,
357
+ incomingLength: getBracketLength(incomingLength),
358
+ outgoingLength: getBracketLength(outgoingLength),
359
+ }
360
+ })
361
+ }
362
+
363
+ function normalize2D(vector: [number, number]): [number, number] {
364
+ const length = Math.hypot(vector[0], vector[1])
365
+ if (length < 1e-6) return [1, 0]
366
+ return [vector[0] / length, vector[1] / length]
367
+ }
368
+
369
+ function getBracketLength(edgeLength: number): number {
370
+ return Math.max(0.14, Math.min(0.38, edgeLength * 0.22))
371
+ }