@pascal-app/editor 1.0.3 → 1.0.4

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 (440) hide show
  1. package/package.json +11 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +202 -57
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +36 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,990 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ area,
7
+ DEFAULT_LEVEL_HEIGHT,
8
+ emitter,
9
+ polygonInteriorPoint,
10
+ sceneRegistry,
11
+ useScene,
12
+ type ZoneNode,
13
+ } from '@pascal-app/core'
14
+ import { useViewer } from '@pascal-app/viewer'
15
+ import { Html } from '@react-three/drei'
16
+ import { useFrame, useThree } from '@react-three/fiber'
17
+ import {
18
+ createContext,
19
+ Fragment,
20
+ type MouseEvent as ReactMouseEvent,
21
+ type ReactNode,
22
+ type PointerEvent as ReactPointerEvent,
23
+ useCallback,
24
+ useContext,
25
+ useEffect,
26
+ useLayoutEffect,
27
+ useMemo,
28
+ useRef,
29
+ useState,
30
+ } from 'react'
31
+ import { createPortal } from 'react-dom'
32
+ import { type Group, type Object3D, Vector3 } from 'three'
33
+ import { useShallow } from 'zustand/react/shallow'
34
+ import { useRoomRecords } from '../../hooks/use-selected-room'
35
+ import { getAreaUnitLabel, type LinearUnit, squareMetersToAreaUnit } from '../../lib/measurements'
36
+ import { roomFloorElevation } from '../../lib/room-handle-drag'
37
+ import type { RoomSelectionRecord } from '../../lib/room-selection'
38
+ import {
39
+ clickZoneArea,
40
+ hoverZoneArea,
41
+ leaveZoneArea,
42
+ zoneKindLabel,
43
+ } from '../../lib/room-zone-routing'
44
+ import { selectionModifiersFromEvent } from '../../lib/selection-routing'
45
+ import useEditor from '../../store/use-editor'
46
+ import useInteractionScope from '../../store/use-interaction-scope'
47
+ import { isViewVisible, VIEW_2D, VIEW_3D } from '../../store/view-layout'
48
+
49
+ const CONTEXT_TYPES = new Set(['site', 'building', 'level'])
50
+
51
+ export type RoomLabelVisibilityState = {
52
+ phase: string
53
+ mode: string
54
+ scopeIdle: boolean
55
+ room: unknown
56
+ selectedTypes: readonly (string | undefined)[]
57
+ zoneId: string | null
58
+ focusedUnitId: string | null
59
+ isCaptureMode: boolean
60
+ isThumbnailCapture: boolean
61
+ }
62
+
63
+ /**
64
+ * Room pills are the resting view of a level: inside the building, select tool
65
+ * idle, and nothing picked but the site, building or level itself. Anything
66
+ * selected, a unit being arranged, or a gesture in flight takes them away.
67
+ */
68
+ export function roomLabelsVisible(state: RoomLabelVisibilityState): boolean {
69
+ return (
70
+ state.phase === 'building' &&
71
+ state.mode === 'select' &&
72
+ state.scopeIdle &&
73
+ !state.room &&
74
+ !state.zoneId &&
75
+ !state.focusedUnitId &&
76
+ !state.isCaptureMode &&
77
+ !state.isThumbnailCapture &&
78
+ state.selectedTypes.every((type) => type !== undefined && CONTEXT_TYPES.has(type))
79
+ )
80
+ }
81
+
82
+ export function formatRoomArea(squareMeters: number, unit: LinearUnit): string {
83
+ return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(1)} ${getAreaUnitLabel(unit)}`
84
+ }
85
+
86
+ function useRoomLabelsVisible() {
87
+ const [isThumbnailCapture, setThumbnailCapture] = useState(false)
88
+ useEffect(() => {
89
+ const hide = () => setThumbnailCapture(true)
90
+ const restore = () => setThumbnailCapture(false)
91
+ emitter.on('thumbnail:before-capture', hide)
92
+ emitter.on('thumbnail:after-capture', restore)
93
+ return () => {
94
+ emitter.off('thumbnail:before-capture', hide)
95
+ emitter.off('thumbnail:after-capture', restore)
96
+ }
97
+ }, [])
98
+ const editor = useEditor(
99
+ useShallow((s) => ({
100
+ phase: s.phase,
101
+ mode: s.mode,
102
+ room: s.room,
103
+ isCaptureMode: s.isCaptureMode,
104
+ })),
105
+ )
106
+ const scopeIdle = useInteractionScope((s) => s.scope.kind === 'idle')
107
+ const zoneId = useViewer((s) => s.selection.zoneId)
108
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
109
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
110
+ const selectedTypes = useScene(
111
+ useShallow((s) =>
112
+ selectedIds.map((id) => (s.nodes[id as AnyNodeId] as AnyNode | undefined)?.type),
113
+ ),
114
+ )
115
+ return roomLabelsVisible({
116
+ ...editor,
117
+ scopeIdle,
118
+ zoneId,
119
+ focusedUnitId,
120
+ selectedTypes,
121
+ isThumbnailCapture,
122
+ })
123
+ }
124
+
125
+ type LabelRect = { left: number; top: number; right: number; bottom: number }
126
+
127
+ /** A pill's screen rect: centred on where its anchor lands, at its size. */
128
+ export function pillRect(x: number, y: number, [width, height]: readonly [number, number]) {
129
+ return { left: x - width / 2, top: y - height / 2, right: x + width / 2, bottom: y + height / 2 }
130
+ }
131
+
132
+ /** A pill to place: where its room's anchor lands on screen, its full size and its room's area. */
133
+ export type PillCandidate = {
134
+ id: string
135
+ x: number
136
+ y: number
137
+ size: readonly [number, number]
138
+ area: number
139
+ }
140
+
141
+ /** Where a pill goes: in full (moved `dy` px off its anchor), as its colour dot, or nowhere. */
142
+ export type PillPlacement = { mode: 'full' | 'dot' | 'hidden'; dy: number }
143
+
144
+ /** The collapsed pill: its colour dot in a small round box. */
145
+ export const PILL_DOT_SIZE = 16
146
+ const PILL_GAP = 2
147
+ const NUDGE_STEP = 4
148
+
149
+ function overlaps(a: LabelRect, b: LabelRect) {
150
+ return (
151
+ a.left < b.right + PILL_GAP &&
152
+ b.left < a.right + PILL_GAP &&
153
+ a.top < b.bottom + PILL_GAP &&
154
+ b.top < a.bottom + PILL_GAP
155
+ )
156
+ }
157
+
158
+ /** 0, then up and down by growing steps, up to `budget` px either way. */
159
+ function* nudges(budget: number) {
160
+ yield 0
161
+ for (let d = NUDGE_STEP; d <= budget; d += NUDGE_STEP) {
162
+ yield -d
163
+ yield d
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Where every pill goes so none overlap. Larger rooms are placed first (ties
169
+ * by id, so the order is stable while the camera moves). A pill that overlaps
170
+ * one already placed takes the nearest free slot above or below its room,
171
+ * within one pill height; failing that it collapses to its colour dot, which
172
+ * takes the nearest free slot the same way; only a dot with no free slot is
173
+ * hidden.
174
+ */
175
+ export function placePills(candidates: readonly PillCandidate[]): Map<string, PillPlacement> {
176
+ const placed: LabelRect[] = []
177
+ const placements = new Map<string, PillPlacement>()
178
+ const order = [...candidates].sort(
179
+ (a, b) => b.area - a.area || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
180
+ )
181
+ for (const pill of order) {
182
+ let placement: PillPlacement = { mode: 'hidden', dy: 0 }
183
+ for (const dy of nudges(pill.size[1])) {
184
+ const rect = pillRect(pill.x, pill.y + dy, pill.size)
185
+ if (placed.some((other) => overlaps(rect, other))) continue
186
+ placed.push(rect)
187
+ placement = { mode: 'full', dy }
188
+ break
189
+ }
190
+ if (placement.mode === 'hidden') {
191
+ for (const dy of nudges(pill.size[1])) {
192
+ const dot = pillRect(pill.x, pill.y + dy, [PILL_DOT_SIZE, PILL_DOT_SIZE])
193
+ if (placed.some((other) => overlaps(dot, other))) continue
194
+ placed.push(dot)
195
+ placement = { mode: 'dot', dy }
196
+ break
197
+ }
198
+ }
199
+ placements.set(pill.id, placement)
200
+ }
201
+ return placements
202
+ }
203
+
204
+ export type RoomLabelEntry = {
205
+ zoneId: string
206
+ polygon: [number, number][]
207
+ holes: [number, number][][]
208
+ area: number
209
+ }
210
+ type LabelEntry = RoomLabelEntry
211
+
212
+ // Mid-height of a default storey: above furniture, below the ceiling.
213
+ const LABEL_HEIGHT = DEFAULT_LEVEL_HEIGHT / 2
214
+ // The view has to hold still this long before pills are re-placed; while it
215
+ // moves they keep their places and ride their rooms, so nothing jitters.
216
+ const DECLUTTER_REST_SECONDS = 0.12
217
+ // Below this the view counts as still: well under a pixel on screen.
218
+ const VIEW_EPSILON = 1e-4
219
+ /** How long a pill takes to fade, move or collapse. */
220
+ export const PILL_FADE_MS = 180
221
+
222
+ const labelWorld = new Vector3()
223
+
224
+ function pillFadeMs(): number {
225
+ return typeof window !== 'undefined' &&
226
+ window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
227
+ ? 0
228
+ : PILL_FADE_MS
229
+ }
230
+
231
+ /**
232
+ * When a layer re-places its pills: once the view has held still, and again
233
+ * whenever its pills change while it rests. Still means within an epsilon of
234
+ * the view last seen moving — camera damping keeps nudging the camera by
235
+ * fractions of a millimetre for a second or more after it visibly stops, and
236
+ * waiting for it to stop exactly left pills misplaced for that long.
237
+ */
238
+ export class DeclutterClock {
239
+ private reference: number[] | null = null
240
+ private movedAt = Number.NEGATIVE_INFINITY
241
+ private settled = false
242
+ private dirty = true
243
+
244
+ /** The pills changed (mounted, unmounted, resized): place them again. */
245
+ invalidate() {
246
+ this.dirty = true
247
+ }
248
+
249
+ /** Whether to place pills for this view (any flat list of numbers) at `now` seconds. */
250
+ shouldRead(view: ArrayLike<number>, now: number): boolean {
251
+ const reference = this.reference
252
+ let moved = !reference || reference.length !== view.length
253
+ for (let i = 0; !moved && i < view.length; i++) {
254
+ moved = Math.abs(view[i]! - reference![i]!) > VIEW_EPSILON
255
+ }
256
+ if (moved) {
257
+ this.reference = Array.from(view)
258
+ this.movedAt = now
259
+ this.settled = false
260
+ }
261
+ if (now - this.movedAt < DECLUTTER_REST_SECONDS || (this.settled && !this.dirty)) return false
262
+ this.settled = true
263
+ this.dirty = false
264
+ return true
265
+ }
266
+ }
267
+
268
+ /** The pills a layer has mounted, by zone id; the layer hears each one come and go. */
269
+ export class PillElements extends Map<string, HTMLElement> {
270
+ onMount: ((id: string, element: HTMLElement) => void) | null = null
271
+ onUnmount: ((element: HTMLElement) => void) | null = null
272
+ onMeasure: ((id: string, element: HTMLElement) => void) | null = null
273
+
274
+ measure(id: string) {
275
+ const element = this.get(id)
276
+ if (element) this.onMeasure?.(id, element)
277
+ }
278
+
279
+ override set(id: string, element: HTMLElement) {
280
+ super.set(id, element)
281
+ this.onMount?.(id, element)
282
+ return this
283
+ }
284
+
285
+ override delete(id: string) {
286
+ const element = this.get(id)
287
+ const removed = super.delete(id)
288
+ if (element) this.onUnmount?.(element)
289
+ return removed
290
+ }
291
+ }
292
+
293
+ /** Read the full border-box in CSS pixels, even when the dot is showing. */
294
+ export function measureFullPill(
295
+ element: HTMLElement,
296
+ read: (element: HTMLElement) => Pick<CSSStyleDeclaration, 'width' | 'height'> = getComputedStyle,
297
+ ): readonly [number, number] {
298
+ const collapsed = element.dataset.roomLabelCollapsed
299
+ delete element.dataset.roomLabelCollapsed
300
+ try {
301
+ const style = read(element)
302
+ return [Number.parseFloat(style.width), Number.parseFloat(style.height)]
303
+ } finally {
304
+ if (collapsed !== undefined) element.dataset.roomLabelCollapsed = collapsed
305
+ }
306
+ }
307
+
308
+ type Timer = ReturnType<typeof setTimeout>
309
+
310
+ /**
311
+ * Applies placements to the mounted pills as direct style writes: the nudge
312
+ * as a `translate`, the dot as `data-room-label-collapsed`, both eased by the
313
+ * pill's transition. A hidden pill fades out without taking the pointer, then
314
+ * leaves the DOM (`gone`); one given room again mounts and fades back in.
315
+ */
316
+ export class PillPlacer {
317
+ gone: ReadonlySet<string> = new Set()
318
+ private placements = new Map<string, PillPlacement>()
319
+ private readonly fading = new Map<string, Timer>()
320
+
321
+ constructor(
322
+ private readonly elements: ReadonlyMap<string, HTMLElement>,
323
+ private readonly onGone: (gone: ReadonlySet<string>) => void,
324
+ private readonly schedule: (run: () => void, ms: number) => Timer = setTimeout,
325
+ private readonly cancel: (timer: Timer) => void = clearTimeout,
326
+ ) {}
327
+
328
+ apply(placements: ReadonlyMap<string, PillPlacement>, fadeMs = pillFadeMs()) {
329
+ this.placements = new Map([...this.placements, ...placements])
330
+ for (const [id, element] of this.elements) {
331
+ const placement = placements.get(id)
332
+ if (!placement) continue
333
+ if (placement.mode !== 'hidden') {
334
+ const timer = this.fading.get(id)
335
+ if (timer !== undefined) this.cancel(timer)
336
+ this.fading.delete(id)
337
+ delete element.dataset.roomLabelHidden
338
+ placePill(element, placement)
339
+ continue
340
+ }
341
+ if (this.fading.has(id)) continue
342
+ element.dataset.roomLabelHidden = ''
343
+ this.fading.set(
344
+ id,
345
+ this.schedule(() => {
346
+ this.fading.delete(id)
347
+ this.setGone(new Set(this.gone).add(id))
348
+ }, fadeMs),
349
+ )
350
+ }
351
+ const kept = [...this.gone].filter((id) => {
352
+ const placement = placements.get(id)
353
+ return !placement || placement.mode === 'hidden'
354
+ })
355
+ if (kept.length !== this.gone.size) this.setGone(new Set(kept))
356
+ }
357
+
358
+ /** A pill mounting takes its last placement at once. */
359
+ mount(id: string, element: HTMLElement) {
360
+ const placement = this.placements.get(id)
361
+ if (placement?.mode === 'hidden') element.dataset.roomLabelHidden = ''
362
+ else if (placement) placePill(element, placement)
363
+ }
364
+
365
+ /** Remount hidden pills invisibly so changed text or fonts can be measured. */
366
+ remeasure() {
367
+ if (this.gone.size) this.setGone(new Set())
368
+ }
369
+
370
+ /** Fade every mounted pill out (the layer is leaving), or back in. */
371
+ showAll(shown: boolean) {
372
+ for (const [id, element] of this.elements) {
373
+ if (!shown) element.dataset.roomLabelHidden = ''
374
+ else if (!this.fading.has(id)) delete element.dataset.roomLabelHidden
375
+ }
376
+ }
377
+
378
+ dispose() {
379
+ for (const timer of this.fading.values()) this.cancel(timer)
380
+ this.fading.clear()
381
+ }
382
+
383
+ private setGone(gone: ReadonlySet<string>) {
384
+ this.gone = gone
385
+ this.onGone(gone)
386
+ }
387
+ }
388
+
389
+ function placePill(element: HTMLElement, placement: PillPlacement) {
390
+ element.style.translate = placement.dy ? `0 ${placement.dy}px` : ''
391
+ if (placement.mode === 'dot') element.dataset.roomLabelCollapsed = ''
392
+ else delete element.dataset.roomLabelCollapsed
393
+ }
394
+
395
+ /** True while `shown`, and through the fade once it turns false; then false, so the pills unmount. */
396
+ export function useFadePresence(shown: boolean, onGone?: () => void): boolean {
397
+ const [present, setPresent] = useState(shown)
398
+ useEffect(() => {
399
+ if (shown) {
400
+ setPresent(true)
401
+ return
402
+ }
403
+ const timer = setTimeout(() => setPresent(false), pillFadeMs())
404
+ return () => clearTimeout(timer)
405
+ }, [shown])
406
+ const goneRef = useRef(onGone)
407
+ goneRef.current = onGone
408
+ const isPresent = shown || present
409
+ useEffect(() => {
410
+ if (!isPresent) goneRef.current?.()
411
+ }, [isPresent])
412
+ return isPresent
413
+ }
414
+
415
+ /**
416
+ * One pill layer per level: the current level's fades in, a level just left
417
+ * fades out over the next one, then unmounts.
418
+ */
419
+ function useFadingLevels(levelId: string | null, visible: boolean) {
420
+ const [levels, setLevels] = useState<string[]>([])
421
+ useEffect(() => {
422
+ if (levelId && visible) {
423
+ setLevels((previous) => (previous.includes(levelId) ? previous : [...previous, levelId]))
424
+ }
425
+ }, [levelId, visible])
426
+ const drop = useCallback(
427
+ (id: string) => setLevels((previous) => previous.filter((level) => level !== id)),
428
+ [],
429
+ )
430
+ return { levels, drop }
431
+ }
432
+
433
+ /**
434
+ * The placement state of one layer: its mounted pills, the full size each
435
+ * last rendered at (observed, so a web font arriving, a rename or a unit
436
+ * change re-places them; kept for pills that have left the DOM), and the
437
+ * placer that moves, collapses and fades them.
438
+ */
439
+ function usePillPlacement(shown: boolean, entries: readonly LabelEntry[]) {
440
+ const [elements] = useState(() => new PillElements())
441
+ const [clock] = useState(() => new DeclutterClock())
442
+ const [gone, setGone] = useState<ReadonlySet<string>>(() => new Set())
443
+ const [placer] = useState(() => new PillPlacer(elements, setGone))
444
+ const unit = useViewer((s) => s.unit)
445
+ const sizes = useRef(new Map<string, readonly [number, number]>())
446
+ const shownRef = useRef(shown)
447
+ shownRef.current = shown
448
+ useEffect(() => {
449
+ elements.onMeasure = (id, element) => {
450
+ const size = measureFullPill(element)
451
+ if (!(size[0] > 0 && size[1] > 0)) return
452
+ const previous = sizes.current.get(id)
453
+ if (
454
+ previous &&
455
+ Math.abs(previous[0] - size[0]) < 0.01 &&
456
+ Math.abs(previous[1] - size[1]) < 0.01
457
+ )
458
+ return
459
+ sizes.current.set(id, size)
460
+ clock.invalidate()
461
+ }
462
+ const observer =
463
+ typeof ResizeObserver === 'undefined'
464
+ ? null
465
+ : new ResizeObserver((records) => {
466
+ for (const record of records) {
467
+ const element = record.target as HTMLElement
468
+ const id = element.dataset.roomLabel
469
+ const box = record.borderBoxSize?.[0]
470
+ if (!(id && box && box.inlineSize > 0)) continue
471
+ // A dot coming back on screen may have changed text while its
472
+ // wrapper was display:none, when its full width was unreadable.
473
+ if ('roomLabelCollapsed' in element.dataset) elements.measure(id)
474
+ else sizes.current.set(id, [box.inlineSize, box.blockSize])
475
+ }
476
+ clock.invalidate()
477
+ })
478
+ elements.onMount = (id, element) => {
479
+ if (!shownRef.current) element.dataset.roomLabelHidden = ''
480
+ placer.mount(id, element)
481
+ elements.measure(id)
482
+ observer?.observe(element)
483
+ clock.invalidate()
484
+ }
485
+ elements.onUnmount = (element) => {
486
+ observer?.unobserve(element)
487
+ clock.invalidate()
488
+ }
489
+ for (const [id, element] of elements) elements.onMount(id, element)
490
+ const fontsChanged = () => {
491
+ placer.remeasure()
492
+ for (const id of elements.keys()) elements.measure(id)
493
+ }
494
+ document.fonts?.addEventListener('loadingdone', fontsChanged)
495
+ return () => {
496
+ elements.onMount = null
497
+ elements.onUnmount = null
498
+ elements.onMeasure = null
499
+ document.fonts?.removeEventListener('loadingdone', fontsChanged)
500
+ observer?.disconnect()
501
+ placer.dispose()
502
+ }
503
+ }, [clock, elements, placer])
504
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Hidden pills must remount to measure changed entries or units.
505
+ useEffect(() => {
506
+ placer.remeasure()
507
+ clock.invalidate()
508
+ }, [clock, entries, placer, unit])
509
+ useEffect(() => {
510
+ placer.showAll(shown)
511
+ if (shown) clock.invalidate()
512
+ }, [clock, placer, shown])
513
+ return { elements, clock, gone, placer, sizes: sizes.current }
514
+ }
515
+
516
+ /** The pills of one level: every detected room, and every drawn zone that bounds none. */
517
+ export function roomLabelEntries(
518
+ rooms: readonly RoomSelectionRecord[],
519
+ zones: readonly ZoneNode[],
520
+ ): RoomLabelEntry[] {
521
+ const hiddenIds = new Set<string>(
522
+ zones.filter((zone) => zone.visible === false).map((zone) => zone.id),
523
+ )
524
+ const roomIds = new Set(rooms.map((room) => room.zoneId))
525
+ return [
526
+ ...rooms
527
+ .filter((room) => !hiddenIds.has(room.zoneId))
528
+ .map((room) => ({
529
+ zoneId: room.zoneId,
530
+ polygon: room.polygon,
531
+ holes: room.holes,
532
+ area: room.area,
533
+ })),
534
+ ...zones
535
+ .filter((zone) => zone.visible !== false && !roomIds.has(zone.id) && zone.polygon.length >= 3)
536
+ .map((zone) => ({
537
+ zoneId: zone.id,
538
+ polygon: zone.polygon,
539
+ holes: zone.holes ?? [],
540
+ area: area([{ outer: zone.polygon, holes: zone.holes ?? [] }]),
541
+ })),
542
+ ]
543
+ }
544
+
545
+ function useRoomLabelEntries(levelId: string | null): LabelEntry[] {
546
+ const rooms = useRoomRecords(levelId)
547
+ const zones = useScene(
548
+ useShallow((s) =>
549
+ levelId
550
+ ? Object.values(s.nodes).filter(
551
+ (node): node is ZoneNode => node.type === 'zone' && node.parentId === levelId,
552
+ )
553
+ : [],
554
+ ),
555
+ )
556
+ return useMemo(() => roomLabelEntries(rooms, zones), [rooms, zones])
557
+ }
558
+
559
+ /** The pill itself: a colour dot, the name, the area in the active unit. */
560
+ export function RoomPillChip({
561
+ zoneId,
562
+ view,
563
+ color,
564
+ name,
565
+ areaM2,
566
+ elements,
567
+ onClick,
568
+ onHover,
569
+ onLeave,
570
+ }: {
571
+ zoneId: string
572
+ view: '2d' | '3d'
573
+ color: string
574
+ name: string
575
+ areaM2: number
576
+ elements?: Map<string, HTMLElement>
577
+ onClick: (event: ReactMouseEvent) => void
578
+ onHover: (event: ReactPointerEvent) => void
579
+ onLeave: () => void
580
+ }) {
581
+ const unit = useViewer((s) => s.unit)
582
+ // biome-ignore lint/correctness/useExhaustiveDependencies: A dot's border box does not resize when its hidden text changes.
583
+ useLayoutEffect(() => {
584
+ if (elements instanceof PillElements) elements.measure(zoneId)
585
+ }, [areaM2, elements, name, unit, zoneId])
586
+ const register = useCallback(
587
+ (element: HTMLDivElement | null) => {
588
+ if (element) elements?.set(zoneId, element)
589
+ else elements?.delete(zoneId)
590
+ },
591
+ [elements, zoneId],
592
+ )
593
+ return (
594
+ <div
595
+ // Fades in as it mounts and out (taking no pointer) while the layer hides
596
+ // it; eases into its nudged place; collapsed, it is its dot until hovered.
597
+ className="pointer-events-auto flex cursor-pointer select-none items-center gap-1.5 whitespace-nowrap rounded-full border border-white/10 bg-neutral-950/75 px-2.5 py-1 text-white text-xs shadow-sm backdrop-blur-sm transition-[opacity,translate,background-color] duration-180 hover:bg-neutral-950/90 starting:opacity-0 data-room-label-hidden:pointer-events-none data-room-label-hidden:opacity-0 motion-reduce:transition-none [&[data-room-label-collapsed]:not(:hover)]:gap-0 [&[data-room-label-collapsed]:not(:hover)]:p-[3px]"
598
+ data-room-label={zoneId}
599
+ data-room-label-view={view}
600
+ onClick={(event) => {
601
+ event.stopPropagation()
602
+ onClick(event)
603
+ }}
604
+ onPointerDown={(event) => event.stopPropagation()}
605
+ // Move as well as enter: leaving the canvas for the pill ends the canvas
606
+ // hover after the pill's enter has run.
607
+ onPointerEnter={onHover}
608
+ onPointerLeave={onLeave}
609
+ onPointerMove={onHover}
610
+ ref={register}
611
+ >
612
+ <span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: color }} />
613
+ <span className="font-medium [[data-room-label-collapsed]:not(:hover)>&]:hidden">{name}</span>
614
+ <span className="font-mono text-white/60 tabular-nums [[data-room-label-collapsed]:not(:hover)>&]:hidden">
615
+ {formatRoomArea(areaM2, unit)}
616
+ </span>
617
+ </div>
618
+ )
619
+ }
620
+
621
+ /**
622
+ * One room's pill in the editor. Hover and click take the same rule as the
623
+ * zone's area in the plan (`hoverZoneArea`, `clickZoneArea`); only the pill
624
+ * itself takes the pointer.
625
+ */
626
+ function RoomPill({
627
+ entry,
628
+ view,
629
+ elements,
630
+ }: {
631
+ entry: LabelEntry
632
+ view: '2d' | '3d'
633
+ elements?: Map<string, HTMLElement>
634
+ }) {
635
+ const zone = useScene((s) => s.nodes[entry.zoneId as AnyNodeId] as ZoneNode | undefined)
636
+ // A pill that disappears under the cursor (its click selected something)
637
+ // fires no leave: end the hover it set.
638
+ useEffect(() => () => leaveZoneArea(entry.zoneId), [entry.zoneId])
639
+ if (!zone || zone.visible === false) return null
640
+ return (
641
+ <RoomPillChip
642
+ areaM2={entry.area}
643
+ color={zone.color}
644
+ elements={elements}
645
+ name={zone.name?.trim() || zoneKindLabel(zone)}
646
+ onClick={(event) => clickZoneArea(entry.zoneId, selectionModifiersFromEvent(event))}
647
+ onHover={(event) => hoverZoneArea(entry.zoneId, selectionModifiersFromEvent(event))}
648
+ onLeave={() => leaveZoneArea(entry.zoneId)}
649
+ view={view}
650
+ zoneId={entry.zoneId}
651
+ />
652
+ )
653
+ }
654
+
655
+ /** Light name-and-area pills over each room and drawn zone of the active level. */
656
+ export function RoomLabels3D() {
657
+ const levelId = useViewer((s) => s.selection.levelId)
658
+ const sceneShown = useEditor((s) => isViewVisible(s, VIEW_3D))
659
+ const visible = useRoomLabelsVisible() && sceneShown
660
+ const { levels, drop } = useFadingLevels(levelId, visible)
661
+ return levels.map((id) => (
662
+ <LevelRoomLabels3D key={id} levelId={id} onGone={drop} shown={visible && id === levelId} />
663
+ ))
664
+ }
665
+
666
+ function LevelRoomLabels3D({
667
+ levelId,
668
+ shown,
669
+ onGone,
670
+ }: {
671
+ levelId: string
672
+ shown: boolean
673
+ onGone: (levelId: string) => void
674
+ }) {
675
+ const present = useFadePresence(shown, () => onGone(levelId))
676
+ const entries = useRoomLabelEntries(present ? levelId : null)
677
+ if (!present) return null
678
+ return (
679
+ <RoomPillsLayer3D
680
+ entries={entries}
681
+ levelObject={() => sceneRegistry.nodes.get(levelId)}
682
+ renderLabel={(entry, anchors, elements) => (
683
+ <EditorRoomLabel3D anchors={anchors} elements={elements} entry={entry} />
684
+ )}
685
+ shown={shown}
686
+ />
687
+ )
688
+ }
689
+
690
+ function EditorRoomLabel3D({
691
+ entry,
692
+ anchors,
693
+ elements,
694
+ }: {
695
+ entry: LabelEntry
696
+ anchors: Map<string, Vector3>
697
+ elements: Map<string, HTMLElement>
698
+ }) {
699
+ const floorY = useScene((s) => roomFloorElevation(s.nodes, entry.zoneId))
700
+ return (
701
+ <RoomLabelAnchor3D anchors={anchors} entry={entry} floorY={floorY}>
702
+ <RoomPill elements={elements} entry={entry} view="3d" />
703
+ </RoomLabelAnchor3D>
704
+ )
705
+ }
706
+
707
+ /**
708
+ * The pills of one level in 3D, riding the level's object (so they follow
709
+ * level stacking) and placed from where their anchors land on screen. They
710
+ * live in their own stacking layer clipped to the canvas, so every piece of
711
+ * UI over the canvas stays above them.
712
+ */
713
+ export function RoomPillsLayer3D({
714
+ entries,
715
+ levelObject,
716
+ renderLabel,
717
+ shown = true,
718
+ }: {
719
+ entries: readonly LabelEntry[]
720
+ levelObject: () => Object3D | undefined
721
+ renderLabel: (
722
+ entry: LabelEntry,
723
+ anchors: Map<string, Vector3>,
724
+ elements: Map<string, HTMLElement>,
725
+ ) => ReactNode
726
+ shown?: boolean
727
+ }) {
728
+ const ref = useRef<Group>(null)
729
+ const anchors = useRef(new Map<string, Vector3>())
730
+ const view = useRef(new Float64Array(50))
731
+ const { elements, clock, gone, placer, sizes } = usePillPlacement(shown, entries)
732
+ const host = useThree((s) => (s.events.connected ?? s.gl.domElement.parentNode) as HTMLElement)
733
+ const [container] = useState(() =>
734
+ typeof document === 'undefined' ? null : document.createElement('div'),
735
+ )
736
+ const portal = useMemo(() => (container ? { current: container } : null), [container])
737
+ useEffect(() => {
738
+ if (!(host && container)) return
739
+ container.className = 'pointer-events-none absolute inset-0 isolate z-0 overflow-hidden'
740
+ host.appendChild(container)
741
+ return () => container.remove()
742
+ }, [container, host])
743
+
744
+ useEffect(() => {
745
+ const ids = new Set(entries.map((entry) => entry.zoneId))
746
+ for (const id of anchors.current.keys()) if (!ids.has(id)) anchors.current.delete(id)
747
+ clock.invalidate()
748
+ }, [clock, entries])
749
+
750
+ useFrame(({ camera, clock: frameClock, size }) => {
751
+ if (!ref.current) return
752
+ const level = levelObject()
753
+ ref.current.visible = !!level
754
+ if (!level) return
755
+ level.updateWorldMatrix(true, false)
756
+ ref.current.matrix.copy(level.matrixWorld)
757
+
758
+ const flat = view.current
759
+ flat.set(camera.matrixWorld.elements, 0)
760
+ flat.set(camera.projectionMatrix.elements, 16)
761
+ flat.set(level.matrixWorld.elements, 32)
762
+ flat[48] = size.width
763
+ flat[49] = size.height
764
+ if (!(shown && clock.shouldRead(flat, frameClock.elapsedTime))) return
765
+
766
+ const candidates: PillCandidate[] = []
767
+ for (const entry of entries) {
768
+ const anchor = anchors.current.get(entry.zoneId)
769
+ const pill = sizes.get(entry.zoneId)
770
+ if (!(anchor && pill)) continue
771
+ labelWorld.copy(anchor).applyMatrix4(level.matrixWorld).project(camera)
772
+ // Behind the camera or past its far plane: not on screen to collide.
773
+ if (Math.abs(labelWorld.z) > 1) continue
774
+ candidates.push({
775
+ id: entry.zoneId,
776
+ x: ((labelWorld.x + 1) / 2) * size.width,
777
+ y: ((1 - labelWorld.y) / 2) * size.height,
778
+ size: pill,
779
+ area: entry.area,
780
+ })
781
+ }
782
+ placer.apply(placePills(candidates))
783
+ })
784
+
785
+ if (entries.length === 0 || !portal) return null
786
+ return (
787
+ <PillPortalContext.Provider value={portal}>
788
+ <HiddenPillsContext.Provider value={gone}>
789
+ <group matrixAutoUpdate={false} ref={ref}>
790
+ {entries.map((entry) => (
791
+ <Fragment key={entry.zoneId}>{renderLabel(entry, anchors.current, elements)}</Fragment>
792
+ ))}
793
+ </group>
794
+ </HiddenPillsContext.Provider>
795
+ </PillPortalContext.Provider>
796
+ )
797
+ }
798
+
799
+ const PillPortalContext = createContext<{ current: HTMLElement } | null>(null)
800
+ const HiddenPillsContext = createContext<ReadonlySet<string>>(new Set())
801
+
802
+ /** Where a room's pill floats: over its interior, at mid-storey above its floor. */
803
+ export function RoomLabelAnchor3D({
804
+ entry,
805
+ floorY,
806
+ anchors,
807
+ children,
808
+ }: {
809
+ entry: LabelEntry
810
+ floorY: number
811
+ anchors: Map<string, Vector3>
812
+ children: ReactNode
813
+ }) {
814
+ const portal = useContext(PillPortalContext)
815
+ const gone = useContext(HiddenPillsContext)
816
+ const position = useMemo(() => {
817
+ const [x, z] = polygonInteriorPoint({ polygon: entry.polygon, holes: entry.holes })
818
+ return new Vector3(x, floorY + LABEL_HEIGHT, z)
819
+ }, [entry.polygon, entry.holes, floorY])
820
+ // Hidden HTML still needs a live anchor so moving a room can give its pill
821
+ // room again. The layer drops anchors of rooms that left.
822
+ useEffect(() => {
823
+ anchors.set(entry.zoneId, position)
824
+ }, [anchors, entry.zoneId, position])
825
+ if (gone.has(entry.zoneId)) return null
826
+ return (
827
+ <Html
828
+ center
829
+ portal={portal ?? undefined}
830
+ position={position}
831
+ // The wrapper lets the canvas keep everything but the pill itself; a
832
+ // hovered pill (a dot opening to its full pill) comes to the front.
833
+ style={{ pointerEvents: 'none' }}
834
+ wrapperClass="has-[[data-room-label]:hover]:z-30!"
835
+ zIndexRange={[20, 0]}
836
+ >
837
+ {children}
838
+ </Html>
839
+ )
840
+ }
841
+
842
+ /**
843
+ * The same pills over the 2D plan: screen-positioned from the plan's transform,
844
+ * re-placed as in 3D once the plan rests, and clipped to the plan's pane in the
845
+ * plan's own stacking layer, under the UI over it.
846
+ */
847
+ export function RoomLabels2D({ levelId }: { levelId: string | null }) {
848
+ const planShown = useEditor((s) => isViewVisible(s, VIEW_2D))
849
+ const visible = useRoomLabelsVisible() && planShown
850
+ const { levels, drop } = useFadingLevels(levelId, visible)
851
+ return levels.map((id) => (
852
+ <LevelRoomLabels2D key={id} levelId={id} onGone={drop} shown={visible && id === levelId} />
853
+ ))
854
+ }
855
+
856
+ function LevelRoomLabels2D({
857
+ levelId,
858
+ shown,
859
+ onGone,
860
+ }: {
861
+ levelId: string
862
+ shown: boolean
863
+ onGone: (levelId: string) => void
864
+ }) {
865
+ const present = useFadePresence(shown, () => onGone(levelId))
866
+ const entries = useRoomLabelEntries(present ? levelId : null)
867
+ const ref = useRef<SVGGElement>(null)
868
+ const paneRef = useRef<HTMLDivElement>(null)
869
+ const [host, setHost] = useState<HTMLElement | null>(null)
870
+ const [nodes] = useState(() => new PillElements())
871
+ const { elements, clock, gone, placer, sizes } = usePillPlacement(shown, entries)
872
+ const shownRef = useRef(shown)
873
+ shownRef.current = shown
874
+ const anchors = useMemo(
875
+ () =>
876
+ new Map(
877
+ entries.map((entry) => [
878
+ entry.zoneId,
879
+ polygonInteriorPoint({ polygon: entry.polygon, holes: entry.holes }),
880
+ ]),
881
+ ),
882
+ [entries],
883
+ )
884
+
885
+ useEffect(() => {
886
+ setHost(ref.current?.ownerSVGElement?.parentElement ?? null)
887
+ }, [])
888
+
889
+ useEffect(() => {
890
+ if (!present || entries.length === 0) return
891
+ let raf = 0
892
+ let lastKey = ''
893
+ const view = new Float64Array(10)
894
+ // A pill wrapper that mounts is placed on the next frame.
895
+ nodes.onMount = () => {
896
+ lastKey = ''
897
+ }
898
+ clock.invalidate()
899
+ const tick = (now: number) => {
900
+ raf = requestAnimationFrame(tick)
901
+ const group = ref.current
902
+ const svg = group?.ownerSVGElement
903
+ const ctm = group?.getScreenCTM()
904
+ const paneElement = paneRef.current
905
+ if (!(svg && ctm && paneElement)) return
906
+ const pane = svg.getBoundingClientRect()
907
+ const origin = paneElement.offsetParent?.getBoundingClientRect() ?? { left: 0, top: 0 }
908
+ const key = `${ctm.a},${ctm.b},${ctm.c},${ctm.d},${ctm.e},${ctm.f},${pane.left},${pane.top},${pane.width},${pane.height},${origin.left},${origin.top}`
909
+ const point = svg.createSVGPoint()
910
+ const place = (anchor: [number, number]) => {
911
+ point.x = anchor[0]
912
+ point.y = anchor[1]
913
+ const screen = point.matrixTransform(ctm)
914
+ return [screen.x - pane.left, screen.y - pane.top] as const
915
+ }
916
+ const inside = ([x, y]: readonly [number, number]) =>
917
+ x >= 0 && x <= pane.width && y >= 0 && y <= pane.height
918
+ if (key !== lastKey) {
919
+ lastKey = key
920
+ Object.assign(paneElement.style, {
921
+ left: `${pane.left - origin.left}px`,
922
+ top: `${pane.top - origin.top}px`,
923
+ width: `${pane.width}px`,
924
+ height: `${pane.height}px`,
925
+ })
926
+ for (const [id, element] of nodes) {
927
+ const anchor = anchors.get(id)
928
+ if (!anchor) continue
929
+ const at = place(anchor)
930
+ element.style.left = `${at[0]}px`
931
+ element.style.top = `${at[1]}px`
932
+ element.style.display = inside(at) ? '' : 'none'
933
+ }
934
+ }
935
+ view[0] = ctm.a
936
+ view[1] = ctm.b
937
+ view[2] = ctm.c
938
+ view[3] = ctm.d
939
+ view[4] = ctm.e
940
+ view[5] = ctm.f
941
+ view[6] = pane.left
942
+ view[7] = pane.top
943
+ view[8] = pane.width
944
+ view[9] = pane.height
945
+ if (!(shownRef.current && clock.shouldRead(view, now / 1000))) return
946
+ const candidates: PillCandidate[] = []
947
+ for (const entry of entries) {
948
+ const anchor = anchors.get(entry.zoneId)
949
+ const pill = sizes.get(entry.zoneId)
950
+ if (!(anchor && pill)) continue
951
+ const at = place(anchor)
952
+ if (!inside(at)) continue
953
+ candidates.push({ id: entry.zoneId, x: at[0], y: at[1], size: pill, area: entry.area })
954
+ }
955
+ placer.apply(placePills(candidates))
956
+ }
957
+ raf = requestAnimationFrame(tick)
958
+ return () => {
959
+ cancelAnimationFrame(raf)
960
+ nodes.onMount = null
961
+ }
962
+ }, [anchors, clock, entries, nodes, placer, present, sizes])
963
+
964
+ return (
965
+ <g ref={ref}>
966
+ {present &&
967
+ host &&
968
+ createPortal(
969
+ <div className="pointer-events-none absolute isolate z-0 overflow-hidden" ref={paneRef}>
970
+ {entries
971
+ .filter((entry) => !gone.has(entry.zoneId))
972
+ .map((entry) => (
973
+ <div
974
+ className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 has-[[data-room-label]:hover]:z-10"
975
+ key={entry.zoneId}
976
+ ref={(element) => {
977
+ if (element) nodes.set(entry.zoneId, element)
978
+ else nodes.delete(entry.zoneId)
979
+ }}
980
+ style={{ display: 'none' }}
981
+ >
982
+ <RoomPill elements={elements} entry={entry} view="2d" />
983
+ </div>
984
+ ))}
985
+ </div>,
986
+ host,
987
+ )}
988
+ </g>
989
+ )
990
+ }