@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,402 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ type BufferAttribute,
4
+ Color,
5
+ Group,
6
+ InstancedMesh,
7
+ Matrix4,
8
+ Mesh,
9
+ Raycaster,
10
+ StaticDrawUsage,
11
+ Vector3,
12
+ } from 'three'
13
+ import Attributes from 'three/src/renderers/common/Attributes.js'
14
+ import { AttributeType } from 'three/src/renderers/common/Constants.js'
15
+ import Info from 'three/src/renderers/common/Info.js'
16
+ import {
17
+ BRACKET_Y_OFFSET,
18
+ BracketPointerState,
19
+ type BracketTarget,
20
+ bracketTargetKey,
21
+ buildCornerBrackets,
22
+ CeilingBracketBatchStore,
23
+ getBracketHighlights,
24
+ getBracketMatrix,
25
+ growBracketCapacity,
26
+ } from './ceiling-bracket-batch'
27
+
28
+ const polygon: Array<[number, number]> = [
29
+ [0, 0],
30
+ [4, 0],
31
+ [4, 4],
32
+ [0, 4],
33
+ ]
34
+ const ceilingId = 'ceiling:first' as BracketTarget['ceilingId']
35
+ const otherId = 'ceiling:second' as BracketTarget['ceilingId']
36
+ const corners = buildCornerBrackets(polygon)
37
+ const target = (
38
+ cornerIndex: number,
39
+ part: BracketTarget['part'] = 'cube',
40
+ id = ceilingId,
41
+ ): BracketTarget => ({ ceilingId: id, cornerIndex, part })
42
+
43
+ function expectMatrix(actual: Matrix4, expected: Matrix4, precision = 10) {
44
+ actual.elements.forEach((value, index) => {
45
+ expect(value).toBeCloseTo(expected.elements[index]!, precision)
46
+ })
47
+ }
48
+
49
+ function expectIndex(store: CeilingBracketBatchStore, ids = [ceilingId, otherId]) {
50
+ const seen = new Set<string>()
51
+ for (const mesh of store.getSnapshot()) {
52
+ for (let index = 0; index < mesh.count; index++) {
53
+ const hit = store.getTarget(mesh, index)!
54
+ expect(hit).toBeDefined()
55
+ expect(ids).toContain(hit.ceilingId)
56
+ expect(store.getLocation(hit)).toEqual({ mesh, instanceId: index })
57
+ seen.add(bracketTargetKey(hit))
58
+ const matrix = new Matrix4()
59
+ mesh.getMatrixAt(index, matrix)
60
+ expectMatrix(matrix, getBracketMatrix(corners[hit.cornerIndex]!, hit.part, 3), 6)
61
+ }
62
+ expect(store.getTarget(mesh, mesh.count)).toBeUndefined()
63
+ }
64
+ expect(seen.size).toBe(ids.length * 12)
65
+ }
66
+
67
+ describe('ceiling bracket layout', () => {
68
+ test('leg matrices match the old nested level/corner/BracketLeg transforms', () => {
69
+ const sample: Array<[number, number]> = [
70
+ [2, -3],
71
+ [2.3, -2.6],
72
+ [5, 1],
73
+ [-1, 2],
74
+ ]
75
+ for (const corner of buildCornerBrackets(sample)) {
76
+ for (const part of ['incoming', 'outgoing'] as const) {
77
+ const neighborIndex =
78
+ part === 'incoming'
79
+ ? (corner.index - 1 + sample.length) % sample.length
80
+ : (corner.index + 1) % sample.length
81
+ const neighbor = sample[neighborIndex]!
82
+ const dx = neighbor[0] - corner.corner[0]
83
+ const dz = neighbor[1] - corner.corner[1]
84
+ const edgeLength = Math.hypot(dx, dz)
85
+ const direction = [dx / edgeLength, dz / edgeLength]
86
+ const length = Math.max(0.14, Math.min(0.38, edgeLength * 0.22))
87
+ const level = new Group()
88
+ level.position.y = 3 + 0.035
89
+ const cornerGroup = new Group()
90
+ cornerGroup.position.set(corner.corner[0], 0, corner.corner[1])
91
+ const leg = new Mesh()
92
+ leg.position.set((direction[0]! * length) / 2, 0, (direction[1]! * length) / 2)
93
+ leg.rotation.y = -Math.atan2(direction[1]!, direction[0]!)
94
+ leg.scale.set(length, 0.04, 0.04)
95
+ level.add(cornerGroup)
96
+ cornerGroup.add(leg)
97
+ level.updateMatrixWorld(true)
98
+ expectMatrix(getBracketMatrix(corner, part, 3), leg.matrixWorld)
99
+ }
100
+ }
101
+ })
102
+
103
+ test('cube matrix uses the same offset and dimensions', () => {
104
+ const expected = new Matrix4().makeScale(0.28, 0.08, 0.28)
105
+ expected.setPosition(4, 3.035, 4)
106
+ expectMatrix(getBracketMatrix(corners[2]!, 'cube', 3), expected)
107
+ })
108
+
109
+ test('length clamps, degenerate edges, and invalid polygons match the original', () => {
110
+ const sample = buildCornerBrackets([
111
+ [0, 0],
112
+ [0, 0],
113
+ [1, 0],
114
+ [10, 10],
115
+ ])
116
+ expect(sample[0]!.outgoingDirection).toEqual([1, 0])
117
+ expect(sample[0]!.outgoingLength).toBe(0.14)
118
+ expect(sample[1]!.outgoingLength).toBe(0.22)
119
+ expect(sample[2]!.outgoingLength).toBe(0.38)
120
+ expect(buildCornerBrackets([])).toEqual([])
121
+ expect(
122
+ buildCornerBrackets([
123
+ [0, 0],
124
+ [1, 1],
125
+ ]),
126
+ ).toEqual([])
127
+ })
128
+ })
129
+
130
+ describe('ceiling bracket highlights', () => {
131
+ test('wraps both incident edges and linked cubes for every corner', () => {
132
+ for (const count of [3, 4, 7]) {
133
+ for (let active = 0; active < count; active++) {
134
+ const highlights = getBracketHighlights(count, active)
135
+ expect(highlights.edges).toEqual(new Set([active, (active - 1 + count) % count]))
136
+ expect(highlights.corners).toEqual(
137
+ new Set([active, (active - 1 + count) % count, (active + 1) % count]),
138
+ )
139
+ }
140
+ }
141
+ expect(getBracketHighlights(4, null)).toEqual({ edges: new Set(), corners: new Set() })
142
+ expect(getBracketHighlights(1, 0)).toEqual({ edges: new Set(), corners: new Set() })
143
+ })
144
+
145
+ test('highlights both ends of each edge and only the three linked cubes', () => {
146
+ const store = new CeilingBracketBatchStore()
147
+ store.setGeometry(ceilingId, corners, 3)
148
+ store.setGeometry(otherId, corners, 3)
149
+ const snapshot = store.getSnapshot()
150
+ store.setHighlight(ceilingId, 0)
151
+ const [normal, highlighted] = store.getSnapshot()
152
+ expect(store.getSnapshot()).toBe(snapshot)
153
+ expect(normal!.count).toBe(17)
154
+ expect(highlighted!.count).toBe(7)
155
+ const parts = Array.from({ length: highlighted!.count }, (_, index) => {
156
+ const hit = store.getTarget(highlighted!, index)!
157
+ expect(hit.ceilingId).toBe(ceilingId)
158
+ return `${hit.cornerIndex}/${hit.part}`
159
+ })
160
+ expect(new Set(parts)).toEqual(
161
+ new Set([
162
+ '0/incoming',
163
+ '0/outgoing',
164
+ '0/cube',
165
+ '1/incoming',
166
+ '1/cube',
167
+ '3/outgoing',
168
+ '3/cube',
169
+ ]),
170
+ )
171
+ for (const mesh of store.getSnapshot()) {
172
+ const expected = new Color(mesh === highlighted ? '#818cf8' : '#d4d4d4')
173
+ for (let index = 0; index < mesh.count; index++) {
174
+ const color = new Color()
175
+ mesh.getColorAt(index, color)
176
+ expect(color.r).toBeCloseTo(expected.r, 6)
177
+ expect(color.g).toBeCloseTo(expected.g, 6)
178
+ expect(color.b).toBeCloseTo(expected.b, 6)
179
+ }
180
+ }
181
+ store.setHighlight(ceilingId, null)
182
+ expect(normal!.count).toBe(24)
183
+ expect(highlighted!.count).toBe(0)
184
+ store.dispose()
185
+ })
186
+ })
187
+
188
+ describe('ceiling bracket batches', () => {
189
+ test('round-trips instance indices through highlighting, swap removal, and removal of a ceiling', () => {
190
+ const store = new CeilingBracketBatchStore()
191
+ store.setGeometry(ceilingId, corners, 3)
192
+ store.setGeometry(otherId, corners, 3)
193
+ expectIndex(store)
194
+ for (const active of [0, 1, 3, null]) {
195
+ store.setHighlight(ceilingId, active)
196
+ expectIndex(store)
197
+ }
198
+ store.setHighlight(otherId, 2)
199
+ store.removeCeiling(ceilingId)
200
+ expectIndex(store, [otherId])
201
+ expect(store.getLocation(target(0))).toBeUndefined()
202
+ expect(store.getTarget(new Group(), 0)).toBeUndefined()
203
+ expect(store.getTarget(store.getSnapshot()[0]!, undefined)).toBeUndefined()
204
+ store.removeCeiling(otherId)
205
+ expect(store.getSnapshot().map((mesh) => mesh.count)).toEqual([0, 0])
206
+ store.dispose()
207
+ })
208
+
209
+ test('capacity has headroom, grows only on overflow, and never shrinks', () => {
210
+ expect(growBracketCapacity(0, 0)).toBe(0)
211
+ expect(growBracketCapacity(0, 1)).toBe(32)
212
+ expect(growBracketCapacity(32, 32)).toBe(32)
213
+ expect(growBracketCapacity(32, 33)).toBe(66)
214
+ expect(growBracketCapacity(66, 12)).toBe(66)
215
+ const store = new CeilingBracketBatchStore()
216
+ let reallocations = 0
217
+ const unsubscribe = store.subscribe(() => reallocations++)
218
+ const initial = store.getSnapshot()
219
+ for (let index = 0; index < 103; index++) {
220
+ const id = `ceiling:${index}` as BracketTarget['ceilingId']
221
+ store.setGeometry(id, corners, 3)
222
+ }
223
+ const [normal, highlighted] = store.getSnapshot()
224
+ expect(normal).not.toBe(initial[0])
225
+ expect(highlighted).toBe(initial[1])
226
+ expect(normal!.count).toBe(1236)
227
+ expect(normal!.instanceMatrix.count).toBeGreaterThan(1236)
228
+ expect(reallocations).toBeLessThan(7)
229
+ const grown = store.getSnapshot()
230
+ const versions = grown.map((mesh) => mesh.instanceMatrix.version)
231
+ store.setHighlight('ceiling:0' as BracketTarget['ceilingId'], 0)
232
+ expect(store.getSnapshot()).toBe(grown)
233
+ expect(normal!.instanceMatrix.version - versions[0]!).toBeLessThanOrEqual(7)
234
+ expect(highlighted!.instanceMatrix.version - versions[1]!).toBe(7)
235
+ unsubscribe()
236
+ store.dispose()
237
+ })
238
+
239
+ test('geometry changes preserve targets, update heights, and keep native raycasts in bounds', () => {
240
+ const store = new CeilingBracketBatchStore()
241
+ store.setGeometry(ceilingId, corners, 3)
242
+ const oldTarget = store.getTarget(store.getSnapshot()[0]!, 2)
243
+ const shifted = buildCornerBrackets(polygon.map(([x, z]) => [x + 1000, z - 1000]))
244
+ store.setGeometry(ceilingId, shifted, 8)
245
+ expect(store.getTarget(store.getSnapshot()[0]!, 2)).toBe(oldTarget)
246
+ const level = new Group()
247
+ level.position.set(20, 4, 30)
248
+ for (const mesh of store.getSnapshot()) level.add(mesh)
249
+ level.updateMatrixWorld(true)
250
+ const ray = new Raycaster(new Vector3(1020, 20, -970), new Vector3(0, -1, 0))
251
+ for (const active of [null, 0, 1, null]) {
252
+ store.setHighlight(ceilingId, active)
253
+ const hits = ray.intersectObjects(store.getSnapshot())
254
+ expect(hits.length).toBeGreaterThan(0)
255
+ expect(hits.some((hit) => store.getTarget(hit.object, hit.instanceId)?.part === 'cube')).toBe(
256
+ true,
257
+ )
258
+ expect(hits[0]!.point.y).toBeCloseTo(4 + 8 + BRACKET_Y_OFFSET + 0.04, 5)
259
+ }
260
+ store.setGeometry(ceilingId, shifted.slice(0, 3), 8)
261
+ expect(store.getSnapshot().reduce((sum, mesh) => sum + mesh.count, 0)).toBe(9)
262
+ for (const mesh of store.getSnapshot()) {
263
+ expect(mesh.raycast).toBe(InstancedMesh.prototype.raycast)
264
+ expect(mesh.frustumCulled).toBe(false)
265
+ expect(mesh.layers.mask).toBe(1)
266
+ expect(mesh.material.depthTest).toBe(true)
267
+ expect(mesh.material.depthWrite).toBe(false)
268
+ expect(mesh.material.transparent).toBe(true)
269
+ }
270
+ expect(store.getSnapshot().map((mesh) => mesh.renderOrder)).toEqual([1000, 1001])
271
+ expect(store.getSnapshot().map((mesh) => mesh.material.opacity)).toEqual([0.72, 0.92])
272
+ store.dispose()
273
+ })
274
+
275
+ test('unchanged geometry and highlights do not rewrite buffers or notify React', () => {
276
+ const store = new CeilingBracketBatchStore()
277
+ store.setGeometry(ceilingId, corners, 3)
278
+ const versions = store.getSnapshot().map((mesh) => mesh.instanceMatrix.version)
279
+ let notifications = 0
280
+ store.subscribe(() => notifications++)
281
+ store.setGeometry(ceilingId, corners, 3)
282
+ store.setHighlight(ceilingId, null)
283
+ expect(store.getSnapshot().map((mesh) => mesh.instanceMatrix.version)).toEqual(versions)
284
+ expect(notifications).toBe(0)
285
+ store.dispose()
286
+ })
287
+ })
288
+
289
+ describe('ceiling bracket pointer identities', () => {
290
+ test('pointer-out retains the old target after its packed slot is reassigned', () => {
291
+ const store = new CeilingBracketBatchStore()
292
+ const pointer = new BracketPointerState()
293
+ store.setGeometry(ceilingId, corners, 3)
294
+ store.setGeometry(otherId, corners, 3)
295
+ const { mesh, instanceId } = store.getLocation(target(0))!
296
+ const hit = store.getTarget(mesh, instanceId)!
297
+ pointer.over('old-hit', hit)
298
+ store.setHighlight(ceilingId, 0)
299
+ expect(bracketTargetKey(store.getTarget(mesh, instanceId)!)).not.toBe(bracketTargetKey(hit))
300
+ expect(pointer.out('old-hit')).toBe(hit)
301
+ expect(pointer.out('old-hit')).toBeUndefined()
302
+ store.dispose()
303
+ })
304
+
305
+ test('reused hover IDs and click targets retain ceiling, corner, and part identity', () => {
306
+ const pointer = new BracketPointerState()
307
+ const first = target(0)
308
+ const next = target(1)
309
+ expect(pointer.over('same-slot', first)).toBeUndefined()
310
+ expect(pointer.over('same-slot', next)).toBe(first)
311
+ expect(pointer.out('same-slot')).toBe(next)
312
+ pointer.pointerDown([first, target(0, 'incoming')])
313
+ expect(pointer.canClick({ ...first })).toBe(true)
314
+ expect(pointer.canClick(target(0, 'incoming'))).toBe(true)
315
+ expect(pointer.canClick(next)).toBe(false)
316
+ expect(pointer.canClick(target(0, 'outgoing'))).toBe(false)
317
+ expect(pointer.canClick(target(0, 'cube', otherId))).toBe(false)
318
+ pointer.over('old-mesh/undefined/2', first)
319
+ pointer.replaceObject('old-mesh', 'new-mesh')
320
+ expect(pointer.out('old-mesh/undefined/2')).toBeUndefined()
321
+ expect(pointer.out('new-mesh/undefined/2')).toBe(first)
322
+ pointer.pointerDown([])
323
+ expect(pointer.canClick(first)).toBe(false)
324
+ })
325
+ })
326
+
327
+ test('each batch owns and disposes its geometry on capacity growth and teardown', () => {
328
+ const store = new CeilingBracketBatchStore()
329
+ const [initialNormal, highlighted] = store.getSnapshot()
330
+ expect(initialNormal!.geometry).not.toBe(highlighted!.geometry)
331
+ expect(initialNormal!.geometry.attributes.position!.array).not.toBe(
332
+ highlighted!.geometry.attributes.position!.array,
333
+ )
334
+ let retiredDisposals = 0
335
+ initialNormal!.geometry.addEventListener('dispose', () => {
336
+ retiredDisposals++
337
+ })
338
+ for (let index = 0; index < 3; index++) {
339
+ store.setGeometry(`ceiling:${index}` as BracketTarget['ceilingId'], corners, 3)
340
+ }
341
+ expect(retiredDisposals).toBe(1)
342
+ expect(store.getSnapshot()[0]!.geometry).not.toBe(initialNormal!.geometry)
343
+ let finalDisposals = 0
344
+ for (const mesh of store.getSnapshot()) {
345
+ mesh.geometry.addEventListener('dispose', () => {
346
+ finalDisposals++
347
+ })
348
+ }
349
+ store.dispose()
350
+ expect(finalDisposals).toBe(2)
351
+ expect(retiredDisposals).toBe(1)
352
+ })
353
+
354
+ test('WebGPU attribute updates skip resting frames and upload only written slots after a change', () => {
355
+ const store = new CeilingBracketBatchStore()
356
+ store.setGeometry(ceilingId, corners, 3)
357
+ const uploads: Array<{
358
+ attribute: BufferAttribute
359
+ ranges: Array<{ start: number; count: number }>
360
+ }> = []
361
+ const attributes = new Attributes(
362
+ {
363
+ createAttribute() {},
364
+ updateAttribute(attribute: BufferAttribute) {
365
+ uploads.push({ attribute, ranges: attribute.updateRanges.map((range) => ({ ...range })) })
366
+ attribute.clearUpdateRanges()
367
+ },
368
+ } as unknown as ConstructorParameters<typeof Attributes>[0],
369
+ new Info(),
370
+ )
371
+ const render = () => {
372
+ for (const mesh of store.getSnapshot()) {
373
+ for (const attribute of [mesh.instanceMatrix, mesh.instanceColor!]) {
374
+ expect(attribute.usage).toBe(StaticDrawUsage)
375
+ attributes.update(attribute, AttributeType.VERTEX)
376
+ }
377
+ mesh.onAfterRender(...([] as unknown as Parameters<typeof mesh.onAfterRender>))
378
+ }
379
+ }
380
+ render()
381
+ for (let frame = 0; frame < 20; frame++) render()
382
+ expect(uploads).toHaveLength(0)
383
+ store.setHighlight(ceilingId, 0)
384
+ render()
385
+ expect(uploads).toHaveLength(4)
386
+ for (const upload of uploads) {
387
+ expect(upload.ranges.length).toBeGreaterThan(0)
388
+ expect(upload.ranges.length).toBeLessThanOrEqual(7)
389
+ for (const range of upload.ranges) {
390
+ expect(range.count).toBe(upload.attribute.itemSize)
391
+ expect(range.start % upload.attribute.itemSize).toBe(0)
392
+ }
393
+ }
394
+ uploads.length = 0
395
+ for (let frame = 0; frame < 20; frame++) render()
396
+ expect(uploads).toHaveLength(0)
397
+ for (const mesh of store.getSnapshot()) {
398
+ expect(mesh.instanceMatrix.updateRanges).toEqual([])
399
+ expect(mesh.instanceColor!.updateRanges).toEqual([])
400
+ }
401
+ store.dispose()
402
+ })