@pascal-app/editor 1.0.0-beta.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) 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/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  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 +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,223 @@
1
+ 'use client'
2
+ import { type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
3
+ import {
4
+ ProceduralItemNode,
5
+ snapParameters,
6
+ validateProceduralRelations,
7
+ } from '@pascal-app/core/procedural-items'
8
+ import { useViewer } from '@pascal-app/viewer'
9
+ import { useEffect, useState } from 'react'
10
+ import { useHandleGroup } from '../../store/use-handle-group'
11
+ import { PanelSection } from '../ui/controls/panel-section'
12
+ import { SliderControl } from '../ui/controls/slider-control'
13
+ import { PanelWrapper } from '../ui/panels/panel-wrapper'
14
+ export function ProceduralInspector({
15
+ nodeId,
16
+ partId,
17
+ onPartChange,
18
+ }: {
19
+ nodeId: string
20
+ partId: string | null
21
+ onPartChange: (id: string | null) => void
22
+ }) {
23
+ const committed = useScene((s) => s.nodes[nodeId as AnyNodeId]) as unknown as
24
+ | ProceduralItemNode
25
+ | undefined
26
+ const override = useLiveNodeOverrides((s) => s.overrides.get(nodeId))
27
+ const node = committed ? ({ ...committed, ...override } as ProceduralItemNode) : null
28
+ useEffect(() => {
29
+ useHandleGroup.setState({ active: partId ? { nodeId, group: partId } : null })
30
+ return () => {
31
+ if (useHandleGroup.getState().active?.nodeId === nodeId)
32
+ useHandleGroup.setState({ active: null })
33
+ }
34
+ }, [nodeId, partId])
35
+ const [error, setError] = useState('')
36
+ useEffect(() => () => useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId), [nodeId])
37
+ if (!node || !committed) return null
38
+ const change = (id: string, value: number, commit: boolean) => {
39
+ const values = { ...node.parameters, [id]: value }
40
+ const parameters = commit ? snapParameters(node.recipe, values) : values
41
+ const parsed = ProceduralItemNode.safeParse({ ...node, parameters })
42
+ if (!parsed.success) {
43
+ setError(parsed.error.issues[0]?.message ?? 'Invalid edit')
44
+ if (commit) useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId)
45
+ return
46
+ }
47
+ try {
48
+ validateProceduralRelations({ ...node, parameters }, useScene.getState().nodes)
49
+ } catch (e) {
50
+ setError(e instanceof Error ? e.message : 'Invalid attachment')
51
+ if (commit) useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId)
52
+ return
53
+ }
54
+ setError('')
55
+ if (commit) {
56
+ useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId)
57
+ useScene.getState().updateNode(nodeId as AnyNodeId, { parameters } as never)
58
+ } else useLiveNodeOverrides.getState().set(nodeId as AnyNodeId, { parameters } as never)
59
+ }
60
+ const fields = node.recipe.parameters.filter((p) => (partId ? p.part === partId : !p.part))
61
+ return (
62
+ <div className="space-y-5">
63
+ <div className="flex items-center justify-between">
64
+ <h2 className="font-semibold text-lg">
65
+ {partId ? node.recipe.parts.find((p) => p.id === partId)?.label : 'Adjust design'}
66
+ </h2>
67
+ {partId && (
68
+ <button
69
+ className="rounded-full border px-3 py-1 text-xs"
70
+ onClick={() => onPartChange(null)}
71
+ type="button"
72
+ >
73
+ Back
74
+ </button>
75
+ )}
76
+ </div>
77
+ <div className="space-y-3">
78
+ {fields.map((p) => (
79
+ <SliderControl
80
+ key={p.id}
81
+ label={p.label}
82
+ value={node.parameters[p.id] ?? p.default}
83
+ min={p.min}
84
+ max={p.max}
85
+ step={p.step}
86
+ precision={3}
87
+ unit={p.unit === 'm' ? 'm' : ''}
88
+ onChange={(v) => change(p.id, v, false)}
89
+ onCommit={(v) => change(p.id, v, true)}
90
+ restoreOnCommit={false}
91
+ />
92
+ ))}
93
+ </div>
94
+ {partId && (
95
+ <button
96
+ type="button"
97
+ className="rounded-full border px-3 py-1 text-xs"
98
+ onClick={() => {
99
+ const parameters = { ...committed.parameters }
100
+ for (const p of fields) delete parameters[p.id]
101
+ try {
102
+ ProceduralItemNode.parse({ ...committed, parameters })
103
+ useScene.getState().updateNode(nodeId as AnyNodeId, { parameters } as never)
104
+ setError('')
105
+ } catch (e) {
106
+ setError(e instanceof Error ? e.message : 'Invalid reset')
107
+ }
108
+ }}
109
+ >
110
+ Reset this part
111
+ </button>
112
+ )}
113
+ {error && (
114
+ <p role="alert" className="text-red-600 text-sm">
115
+ {error}
116
+ </p>
117
+ )}
118
+ {!partId && node.recipe.parameters.some((p) => p.part) && (
119
+ <div>
120
+ <h3 className="mb-2 font-medium text-sm">Edit parts</h3>
121
+ <div className="flex flex-wrap gap-2">
122
+ {node.recipe.parts
123
+ .filter((part) => node.recipe.parameters.some((p) => p.part === part.id))
124
+ .map((p) => (
125
+ <button
126
+ key={p.id}
127
+ type="button"
128
+ className="rounded-full border px-3 py-1.5 text-sm text-foreground hover:bg-muted hover:text-foreground"
129
+ onClick={() => onPartChange(p.id)}
130
+ >
131
+ {p.label}
132
+ </button>
133
+ ))}
134
+ </div>
135
+ </div>
136
+ )}
137
+ </div>
138
+ )
139
+ }
140
+
141
+ export default function ProceduralItemPanel({ node }: { node: ProceduralItemNode }) {
142
+ return <ProceduralPanel key={node.id} node={node} />
143
+ }
144
+
145
+ function ProceduralPanel({ node }: { node: ProceduralItemNode }) {
146
+ const [partId, setPartId] = useState<string | null>(null)
147
+ return (
148
+ <PanelWrapper
149
+ title={node.name ?? node.recipe.name}
150
+ onClose={() => useViewer.getState().setSelection({ selectedIds: [] })}
151
+ >
152
+ <div className="space-y-5 p-3">
153
+ <ProceduralInspector
154
+ key={node.id}
155
+ nodeId={node.id}
156
+ partId={partId}
157
+ onPartChange={setPartId}
158
+ />
159
+ <ProceduralPlacementControls node={node} />
160
+ </div>
161
+ </PanelWrapper>
162
+ )
163
+ }
164
+
165
+ function ProceduralPlacementControls({ node }: { node: ProceduralItemNode }) {
166
+ const [error, setError] = useState('')
167
+ const update = (patch: Partial<ProceduralItemNode>) => {
168
+ try {
169
+ useScene.getState().updateNode(node.id as AnyNodeId, patch as never)
170
+ setError('')
171
+ } catch (e) {
172
+ setError(e instanceof Error ? e.message : 'Invalid placement')
173
+ }
174
+ }
175
+ const changePosition = (index: number, value: number, commit: boolean) => {
176
+ const live = useLiveNodeOverrides.getState().overrides.get(node.id)
177
+ const position = [
178
+ ...((live?.position as typeof node.position | undefined) ?? node.position),
179
+ ] as typeof node.position
180
+ position[index] = Number(value.toFixed(3))
181
+ const patch = { position }
182
+ try {
183
+ validateProceduralRelations({ ...node, ...patch }, useScene.getState().nodes)
184
+ if (commit) {
185
+ useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
186
+ update(patch)
187
+ } else useLiveNodeOverrides.getState().set(node.id as AnyNodeId, patch)
188
+ setError('')
189
+ } catch (e) {
190
+ if (commit) useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
191
+ setError(e instanceof Error ? e.message : 'Invalid position')
192
+ }
193
+ }
194
+ return (
195
+ <PanelSection title="Position">
196
+ {(['X', 'Y', 'Z'] as const).map((axis, index) => (
197
+ <SliderControl
198
+ key={axis}
199
+ label={
200
+ <>
201
+ {axis}
202
+ <sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
203
+ </>
204
+ }
205
+ value={node.position[index]!}
206
+ min={node.position[index]! - 2}
207
+ max={node.position[index]! + 2}
208
+ precision={2}
209
+ step={0.01}
210
+ unit="m"
211
+ onChange={(value) => changePosition(index, value, false)}
212
+ onCommit={(value) => changePosition(index, value, true)}
213
+ restoreOnCommit={false}
214
+ />
215
+ ))}
216
+ {error && (
217
+ <p role="alert" className="text-destructive text-xs">
218
+ {error}
219
+ </p>
220
+ )}
221
+ </PanelSection>
222
+ )
223
+ }
@@ -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
+ })