@pascal-app/editor 1.0.3 → 1.0.5

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 (624) hide show
  1. package/README.md +106 -0
  2. package/package.json +42 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/action-menu-button.tsx +83 -0
  5. package/src/components/editor/bake-thumbnail.tsx +307 -5
  6. package/src/components/editor/capture-level-picker.tsx +72 -0
  7. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  8. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  9. package/src/components/editor/custom-camera-controls.tsx +275 -57
  10. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  11. package/src/components/editor/duplicate-node.ts +244 -0
  12. package/src/components/editor/editor-layout-mobile.tsx +37 -26
  13. package/src/components/editor/editor-layout-v2.tsx +132 -59
  14. package/src/components/editor/export-manager.tsx +47 -26
  15. package/src/components/editor/first-person/build-collider-world.test.ts +320 -0
  16. package/src/components/editor/first-person/build-collider-world.ts +28 -7
  17. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  18. package/src/components/editor/first-person/ground-holes.ts +130 -0
  19. package/src/components/editor/first-person/mechanism-targets.test.ts +162 -0
  20. package/src/components/editor/first-person/mechanism-targets.ts +88 -0
  21. package/src/components/editor/first-person-controls.tsx +220 -9
  22. package/src/components/editor/floating-action-menu.tsx +63 -207
  23. package/src/components/editor/floor-aware-camera-controls.test.ts +466 -0
  24. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  25. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  26. package/src/components/editor/floor-region-controls.tsx +846 -0
  27. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  28. package/src/components/editor/floor-surface.test.ts +165 -0
  29. package/src/components/editor/floor-surface.ts +182 -0
  30. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  31. package/src/components/editor/floorplan-background-selection.ts +1 -3
  32. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  33. package/src/components/editor/floorplan-panel.tsx +414 -363
  34. package/src/components/editor/follow-pascal.test.tsx +912 -0
  35. package/src/components/editor/follow-pascal.tsx +354 -0
  36. package/src/components/editor/footprint-height-handle.tsx +204 -0
  37. package/src/components/editor/group-actions.ts +56 -2
  38. package/src/components/editor/group-move-3d.ts +2 -0
  39. package/src/components/editor/group-rotate-handle.tsx +2 -0
  40. package/src/components/editor/group-transform-shared.test.ts +460 -0
  41. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  42. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  43. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  45. package/src/components/editor/index.tsx +409 -197
  46. package/src/components/editor/join-walls-pill.tsx +44 -0
  47. package/src/components/editor/node-action-menu.tsx +68 -70
  48. package/src/components/editor/node-arrow-handles.tsx +25 -6
  49. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  50. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  52. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  53. package/src/components/editor/registry-action-contributions.tsx +80 -1
  54. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  55. package/src/components/editor/room-controls.tsx +847 -0
  56. package/src/components/editor/room-floor-highlight.tsx +128 -0
  57. package/src/components/editor/room-handles-2d.tsx +354 -0
  58. package/src/components/editor/room-handles.tsx +675 -0
  59. package/src/components/editor/room-highlight.tsx +233 -0
  60. package/src/components/editor/room-labels.test.ts +850 -0
  61. package/src/components/editor/room-labels.tsx +1890 -0
  62. package/src/components/editor/room-transform-ghost.tsx +256 -0
  63. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  64. package/src/components/editor/selection-manager.tsx +474 -419
  65. package/src/components/editor/slab-hole-highlights.tsx +5 -2
  66. package/src/components/editor/snapshot-capture-overlay.tsx +7 -2
  67. package/src/components/editor/snapshot-capture.test.ts +119 -60
  68. package/src/components/editor/snapshot-capture.ts +36 -5
  69. package/src/components/editor/thumbnail-generator.test.ts +654 -0
  70. package/src/components/editor/thumbnail-generator.tsx +1103 -223
  71. package/src/components/editor/use-floorplan-background-placement.ts +34 -12
  72. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  73. package/src/components/editor/views/host-view.tsx +61 -0
  74. package/src/components/editor/views/plugins-view.tsx +663 -0
  75. package/src/components/editor/views/scene-region.tsx +59 -0
  76. package/src/components/editor/views/use-editor-views.ts +57 -0
  77. package/src/components/editor/views/view-bar.test.tsx +54 -0
  78. package/src/components/editor/views/view-bar.tsx +343 -0
  79. package/src/components/editor/wall-justification.test.ts +140 -0
  80. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  81. package/src/components/editor/wall-push-arrow.tsx +92 -0
  82. package/src/components/editor/wall-region-handles.tsx +509 -0
  83. package/src/components/editor/wall-snap-beacon-layer.tsx +8 -4
  84. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  85. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  86. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  87. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  88. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  89. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  90. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +116 -106
  91. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  92. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  93. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  94. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  95. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  96. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  97. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +319 -233
  98. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  99. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  100. package/src/components/editor-2d/svg-paths.ts +12 -15
  101. package/src/components/point-ask/bubble.tsx +538 -0
  102. package/src/components/point-ask/hover-chip.tsx +91 -0
  103. package/src/components/point-ask/mode-pill.tsx +92 -0
  104. package/src/components/point-ask/optional-motion.ts +22 -0
  105. package/src/components/point-ask/outline-corners.tsx +93 -0
  106. package/src/components/point-ask/pins.tsx +711 -0
  107. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  108. package/src/components/point-ask/point-ask-styles.ts +105 -0
  109. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  110. package/src/components/point-ask/region-marquee.tsx +64 -0
  111. package/src/components/procedural-items/inspector.tsx +40 -15
  112. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  113. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  114. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  115. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  116. package/src/components/systems/zone/zone-system.test.ts +47 -0
  117. package/src/components/systems/zone/zone-system.tsx +29 -68
  118. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  119. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  120. package/src/components/tools/distribution/distribution-public.ts +9 -0
  121. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  122. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  123. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  124. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  125. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  126. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  127. package/src/components/tools/distribution/ports.test.ts +56 -0
  128. package/src/components/tools/distribution/ports.ts +378 -0
  129. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  130. package/src/components/tools/distribution/run-cursor.ts +36 -0
  131. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  132. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  133. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  134. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  135. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  136. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  137. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  138. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  139. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  140. package/src/components/tools/item/placement-history.test.tsx +512 -0
  141. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  142. package/src/components/tools/item/placement-strategies.ts +11 -10
  143. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  144. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  145. package/src/components/tools/item/use-draft-node.ts +166 -95
  146. package/src/components/tools/item/use-placement-coordinator.tsx +148 -95
  147. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  148. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  149. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  150. package/src/components/tools/registry/item-surface-move.ts +2 -0
  151. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  152. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  153. package/src/components/tools/registry/move-registry-node-tool.tsx +66 -30
  154. package/src/components/tools/select/box-select-tool.tsx +2 -12
  155. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  156. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  157. package/src/components/tools/select/select-candidates.test.ts +37 -4
  158. package/src/components/tools/select/select-candidates.ts +15 -11
  159. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  160. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  161. package/src/components/tools/tool-manager.tsx +59 -24
  162. package/src/components/tools/wall/wall-drafting.test.ts +175 -47
  163. package/src/components/tools/zone/zone-tool.tsx +4 -12
  164. package/src/components/ui/action-menu/action-button.tsx +9 -1
  165. package/src/components/ui/action-menu/camera-actions.tsx +9 -4
  166. package/src/components/ui/action-menu/control-modes.tsx +15 -42
  167. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  168. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  169. package/src/components/ui/action-menu/index.tsx +56 -33
  170. package/src/components/ui/action-menu/measurement-control.tsx +14 -19
  171. package/src/components/ui/action-menu/placement.ts +20 -0
  172. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  173. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  174. package/src/components/ui/build-panel/build-panel.tsx +242 -0
  175. package/src/components/ui/command-palette/editor-commands.tsx +7 -42
  176. package/src/components/ui/command-palette/index.tsx +50 -39
  177. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  178. package/src/components/ui/controls/action-button.tsx +5 -2
  179. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  180. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  181. package/src/components/ui/controls/material-picker.tsx +4 -1
  182. package/src/components/ui/controls/metric-control.tsx +1 -1
  183. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  184. package/src/components/ui/controls/segmented-control.tsx +21 -8
  185. package/src/components/ui/controls/select-control.tsx +65 -0
  186. package/src/components/ui/controls/slider-control.tsx +1 -1
  187. package/src/components/ui/controls/toggle-control.tsx +3 -3
  188. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  189. package/src/components/ui/floating-level-selector.tsx +159 -59
  190. package/src/components/ui/helpers/building-helper.tsx +4 -1
  191. package/src/components/ui/helpers/contextual-helper-panel.tsx +181 -72
  192. package/src/components/ui/helpers/helper-manager.test.tsx +121 -0
  193. package/src/components/ui/helpers/helper-manager.tsx +331 -14
  194. package/src/components/ui/helpers/item-helper.tsx +17 -1
  195. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  196. package/src/components/ui/icon-ref.tsx +45 -1
  197. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  198. package/src/components/ui/node-icon-image.tsx +16 -0
  199. package/src/components/ui/panels/collections/collections-popover.tsx +28 -22
  200. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  201. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  202. package/src/components/ui/panels/mobile-panel-sheet.tsx +4 -4
  203. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -5
  204. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  205. package/src/components/ui/panels/multi-field-value.ts +6 -3
  206. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  207. package/src/components/ui/panels/multi-selection-panel.tsx +161 -5
  208. package/src/components/ui/panels/node-display.ts +8 -3
  209. package/src/components/ui/panels/panel-manager.tsx +103 -68
  210. package/src/components/ui/panels/panel-wrapper.tsx +130 -62
  211. package/src/components/ui/panels/parametric-field-control.tsx +3 -3
  212. package/src/components/ui/panels/parametric-inspector.tsx +7 -7
  213. package/src/components/ui/panels/reference-panel.tsx +2 -3
  214. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  215. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  216. package/src/components/ui/panels/room-name-options.ts +47 -0
  217. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  218. package/src/components/ui/panels/room-panel.tsx +790 -0
  219. package/src/components/ui/panels/shape-choice.tsx +58 -0
  220. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  221. package/src/components/ui/primitives/badge.tsx +8 -0
  222. package/src/components/ui/primitives/color-dot.tsx +36 -24
  223. package/src/components/ui/primitives/sidebar.tsx +3 -1
  224. package/src/components/ui/right-stack.test.ts +29 -0
  225. package/src/components/ui/right-stack.tsx +164 -0
  226. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  227. package/src/components/ui/sidebar/icon-rail.tsx +8 -3
  228. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  229. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  230. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  231. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  232. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  233. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  234. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +138 -27
  235. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  236. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  237. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  238. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  239. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  240. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  241. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  242. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  243. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  244. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  245. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  246. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  247. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  248. package/src/components/ui/sidebar/panels/site-panel/index.tsx +181 -223
  249. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  250. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  251. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  252. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  253. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  254. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  255. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  256. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  257. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  258. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  259. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -37
  260. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  261. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  262. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  263. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  264. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -4
  265. package/src/components/ui/sidebar/tab-bar.tsx +14 -4
  266. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  267. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  268. package/src/components/viewer/floorplan-preview.tsx +5 -6
  269. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  270. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  271. package/src/components/viewer/viewer-rooms.tsx +191 -0
  272. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  273. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  274. package/src/components/viewer/viewer-stage.tsx +2 -2
  275. package/src/components/viewer-zone-system.tsx +3 -8
  276. package/src/hooks/use-auto-save.integration.test.tsx +215 -43
  277. package/src/hooks/use-auto-save.ts +60 -9
  278. package/src/hooks/use-ceiling-events.test.ts +123 -118
  279. package/src/hooks/use-grid-events.test.ts +105 -105
  280. package/src/hooks/use-keyboard.test.ts +243 -19
  281. package/src/hooks/use-keyboard.ts +118 -83
  282. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  283. package/src/hooks/use-open-wall-ends.ts +238 -0
  284. package/src/hooks/use-placement-notice.test.ts +71 -0
  285. package/src/hooks/use-placement-notice.ts +73 -0
  286. package/src/hooks/use-selected-room.ts +143 -0
  287. package/src/index.tsx +312 -58
  288. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  289. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  290. package/src/lib/add-storey.test.ts +177 -0
  291. package/src/lib/add-storey.ts +59 -0
  292. package/src/lib/below-grade.test.tsx +171 -0
  293. package/src/lib/below-grade.ts +133 -0
  294. package/src/lib/camera-facing.test.ts +24 -0
  295. package/src/lib/camera-facing.ts +24 -0
  296. package/src/lib/ceiling-edit-session.test.ts +321 -0
  297. package/src/lib/ceiling-edit-session.ts +206 -0
  298. package/src/lib/ceiling-region-session.test.ts +179 -0
  299. package/src/lib/continuation.test.ts +19 -1
  300. package/src/lib/continuation.ts +25 -4
  301. package/src/lib/direct-manipulation.ts +2 -0
  302. package/src/lib/door-interaction.ts +14 -0
  303. package/src/lib/download-project-file.ts +17 -0
  304. package/src/lib/editor-level-display.test.ts +96 -0
  305. package/src/lib/editor-level-display.ts +55 -0
  306. package/src/lib/editor-views.ts +178 -0
  307. package/src/lib/export-geometry-wait.test.ts +90 -0
  308. package/src/lib/export-texture-utils.ts +2 -4
  309. package/src/lib/floor-edit-session.ts +153 -0
  310. package/src/lib/floor-footprints.test.ts +563 -0
  311. package/src/lib/floor-footprints.ts +395 -0
  312. package/src/lib/floor-foundation-support.test.ts +284 -0
  313. package/src/lib/floor-opening-draft.ts +218 -0
  314. package/src/lib/floor-openings.test.ts +216 -0
  315. package/src/lib/floor-region-geometry.ts +191 -0
  316. package/src/lib/floor-region-session.test.ts +485 -0
  317. package/src/lib/floor-region-session.ts +589 -0
  318. package/src/lib/floor-region-snap.ts +101 -0
  319. package/src/lib/floor-step-hover.test.ts +32 -0
  320. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  321. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  322. package/src/lib/floorplan/dimension-drive.ts +479 -0
  323. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  324. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  325. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  326. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  327. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  328. package/src/lib/floorplan/floorplan-pdfkit-document.ts +363 -27
  329. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +177 -8
  330. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +60 -25
  331. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  332. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  333. package/src/lib/floorplan/geometry.ts +13 -6
  334. package/src/lib/floorplan/index.ts +0 -9
  335. package/src/lib/floorplan/items.ts +5 -2
  336. package/src/lib/floorplan/marks.test.ts +213 -0
  337. package/src/lib/floorplan/marks.ts +238 -0
  338. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  339. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  340. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  341. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  342. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  343. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  344. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  345. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  346. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  347. package/src/lib/floorplan/schedules.test.ts +189 -0
  348. package/src/lib/floorplan/schedules.ts +339 -0
  349. package/src/lib/floorplan/sheet-export.test.ts +124 -0
  350. package/src/lib/floorplan/sheet-export.ts +318 -0
  351. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +768 -0
  352. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  353. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  354. package/src/lib/floorplan/site-plan/coverage.ts +230 -0
  355. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  356. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  357. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  358. package/src/lib/floorplan/site-plan/front-edge.ts +157 -0
  359. package/src/lib/floorplan/site-plan/geometry.test.ts +269 -0
  360. package/src/lib/floorplan/site-plan/geometry.ts +142 -0
  361. package/src/lib/floorplan/site-plan/index.ts +55 -0
  362. package/src/lib/floorplan/site-plan/site-annotations.test.ts +483 -0
  363. package/src/lib/floorplan/site-plan/site-annotations.ts +1084 -0
  364. package/src/lib/floorplan/site-plan/site-parts.ts +287 -0
  365. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  366. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  367. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  368. package/src/lib/floorplan/site-plan/yards.test.ts +70 -0
  369. package/src/lib/floorplan/types.ts +1 -27
  370. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  371. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  372. package/src/lib/follow-pascal-exterior.ts +39 -0
  373. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  374. package/src/lib/follow-pascal-presentation.ts +129 -0
  375. package/src/lib/follow-pascal-sight.ts +84 -0
  376. package/src/lib/follow-pascal-targets.test.ts +406 -0
  377. package/src/lib/follow-pascal-targets.ts +198 -0
  378. package/src/lib/follow-pascal.test.ts +350 -0
  379. package/src/lib/follow-pascal.ts +284 -0
  380. package/src/lib/fresh-planar-placement.test.ts +148 -0
  381. package/src/lib/fresh-planar-placement.ts +114 -37
  382. package/src/lib/geometry-script/author.ts +157 -0
  383. package/src/lib/geometry-script/client.ts +159 -0
  384. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  385. package/src/lib/geometry-script/protocol.ts +13 -0
  386. package/src/lib/gesture-lifecycle.test.ts +595 -0
  387. package/src/lib/gesture-lifecycle.ts +290 -0
  388. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  389. package/src/lib/glb-export.test.ts +636 -126
  390. package/src/lib/glb-export.ts +241 -120
  391. package/src/lib/history.test.ts +18 -7
  392. package/src/lib/history.ts +7 -0
  393. package/src/lib/hud-title.ts +109 -0
  394. package/src/lib/hydration-gated-storage.ts +49 -0
  395. package/src/lib/ifc-export.test.ts +174 -0
  396. package/src/lib/ifc-export.ts +201 -0
  397. package/src/lib/ifc-parts.ts +15 -0
  398. package/src/lib/inspector-expanded.test.ts +44 -0
  399. package/src/lib/inspector-expanded.ts +36 -0
  400. package/src/lib/interaction/scope.ts +22 -0
  401. package/src/lib/keyboard-pan.test.ts +129 -7
  402. package/src/lib/keyboard-pan.ts +161 -12
  403. package/src/lib/level-selection.test.ts +57 -0
  404. package/src/lib/level-selection.ts +16 -1
  405. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  406. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  407. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  408. package/src/lib/lot/clean-ring.test.ts +187 -0
  409. package/src/lib/lot/clean-ring.ts +217 -0
  410. package/src/lib/lot/dossier.test.ts +202 -0
  411. package/src/lib/lot/dossier.ts +498 -0
  412. package/src/lib/lot/drop-in.test.ts +326 -0
  413. package/src/lib/lot/drop-in.ts +392 -0
  414. package/src/lib/lot/index.ts +43 -0
  415. package/src/lib/lot/lot-patch.test.ts +340 -0
  416. package/src/lib/lot/lot-patch.ts +394 -0
  417. package/src/lib/lot/parcel-provider.ts +43 -0
  418. package/src/lib/lot/terrain.test.ts +161 -0
  419. package/src/lib/lot/terrain.ts +266 -0
  420. package/src/lib/material-catalog-model.ts +3 -1
  421. package/src/lib/material-paint-panel-model.ts +9 -5
  422. package/src/lib/material-paint.ts +4 -0
  423. package/src/lib/measurement-kind.test.ts +6 -8
  424. package/src/lib/measurements.test.ts +23 -0
  425. package/src/lib/mezzanine-actions.test.ts +342 -0
  426. package/src/lib/mezzanine-draft.test.ts +501 -0
  427. package/src/lib/mezzanine-draft.ts +278 -0
  428. package/src/lib/mezzanine-messages.ts +26 -0
  429. package/src/lib/mezzanine-selection.test.ts +207 -0
  430. package/src/lib/mezzanine-stairs.ts +46 -0
  431. package/src/lib/model-export.ts +3 -1
  432. package/src/lib/node-action-movement.ts +17 -6
  433. package/src/lib/node-display-label.ts +37 -0
  434. package/src/lib/orbit-floor-clearance.test.ts +201 -0
  435. package/src/lib/orbit-floor-clearance.ts +154 -0
  436. package/src/lib/paint-commit-route.test.ts +37 -0
  437. package/src/lib/paint-commit-route.ts +17 -0
  438. package/src/lib/paint-eyedropper.test.tsx +165 -0
  439. package/src/lib/paint-eyedropper.ts +99 -0
  440. package/src/lib/paint-pass-through.test.ts +122 -0
  441. package/src/lib/paint-pass-through.ts +78 -0
  442. package/src/lib/paint-region-hover.ts +25 -0
  443. package/src/lib/paint-region-mode.ts +154 -0
  444. package/src/lib/paint-regions.test.ts +166 -0
  445. package/src/lib/paint-regions.ts +192 -0
  446. package/src/lib/paint-scope.test.ts +317 -35
  447. package/src/lib/paint-scope.ts +267 -25
  448. package/src/lib/panel-rows.ts +21 -0
  449. package/src/lib/parametric-node-update.ts +16 -7
  450. package/src/lib/plate-paint-affected.test.ts +300 -0
  451. package/src/lib/plate-paint-affected.ts +177 -0
  452. package/src/lib/plugin-chat.ts +65 -0
  453. package/src/lib/plugin-panels.test.ts +25 -0
  454. package/src/lib/plugin-panels.ts +94 -0
  455. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  456. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  457. package/src/lib/point-ask/ask-created.test.ts +195 -0
  458. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  459. package/src/lib/point-ask/build-context.test.ts +173 -0
  460. package/src/lib/point-ask/build-context.ts +94 -0
  461. package/src/lib/point-ask/capture-crop.ts +129 -0
  462. package/src/lib/point-ask/choreography.ts +69 -0
  463. package/src/lib/point-ask/compare.test.ts +145 -0
  464. package/src/lib/point-ask/compare.ts +73 -0
  465. package/src/lib/point-ask/describe-target.test.ts +165 -0
  466. package/src/lib/point-ask/describe-target.ts +234 -0
  467. package/src/lib/point-ask/dictation.ts +79 -0
  468. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  469. package/src/lib/point-ask/edge-marker.ts +27 -0
  470. package/src/lib/point-ask/frame-loop.ts +25 -0
  471. package/src/lib/point-ask/history-marks.test.ts +94 -0
  472. package/src/lib/point-ask/history-marks.ts +39 -0
  473. package/src/lib/point-ask/host-api.ts +32 -0
  474. package/src/lib/point-ask/outline-box.test.ts +30 -0
  475. package/src/lib/point-ask/outline-box.ts +11 -0
  476. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  477. package/src/lib/point-ask/pick.test.ts +220 -0
  478. package/src/lib/point-ask/pick.ts +121 -0
  479. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  480. package/src/lib/point-ask/pin-machine.ts +217 -0
  481. package/src/lib/point-ask/placement.test.ts +203 -0
  482. package/src/lib/point-ask/placement.ts +141 -0
  483. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  484. package/src/lib/point-ask/projector.ts +83 -0
  485. package/src/lib/point-ask/region.test.ts +157 -0
  486. package/src/lib/point-ask/region.ts +136 -0
  487. package/src/lib/point-ask/retry.test.ts +217 -0
  488. package/src/lib/point-ask/retry.ts +37 -0
  489. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  490. package/src/lib/point-ask/scene-commit.ts +32 -0
  491. package/src/lib/point-ask/session.ts +1391 -0
  492. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  493. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  494. package/src/lib/point-ask/spring.test.ts +63 -0
  495. package/src/lib/point-ask/spring.ts +107 -0
  496. package/src/lib/point-ask/suggestions.test.ts +80 -0
  497. package/src/lib/point-ask/suggestions.ts +125 -0
  498. package/src/lib/point-ask/target-scope.test.ts +622 -0
  499. package/src/lib/point-ask/target-scope.ts +338 -0
  500. package/src/lib/point-ask/types.ts +48 -0
  501. package/src/lib/portable-export.test.ts +34 -1
  502. package/src/lib/portable-export.ts +119 -53
  503. package/src/lib/print-feature-thickness.ts +10 -1
  504. package/src/lib/print-roof-solids.test.ts +39 -1
  505. package/src/lib/print-roof-solids.ts +43 -10
  506. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  507. package/src/lib/print-shell-compiler.ts +1 -0
  508. package/src/lib/registered-draft-snap.ts +12 -0
  509. package/src/lib/roof-duplication.ts +10 -2
  510. package/src/lib/room-assembly-overlay.test.ts +275 -0
  511. package/src/lib/room-assembly-overlay.ts +265 -0
  512. package/src/lib/room-built-on.test.ts +379 -0
  513. package/src/lib/room-built-on.ts +138 -0
  514. package/src/lib/room-construction-commands.ts +712 -0
  515. package/src/lib/room-construction.test.ts +746 -0
  516. package/src/lib/room-construction.ts +385 -0
  517. package/src/lib/room-controls-phase6.test.ts +630 -0
  518. package/src/lib/room-divide-session.ts +523 -0
  519. package/src/lib/room-handle-drag.ts +1068 -0
  520. package/src/lib/room-name-catalog.test.ts +90 -0
  521. package/src/lib/room-name-catalog.ts +168 -0
  522. package/src/lib/room-push-dimensions.test.ts +311 -0
  523. package/src/lib/room-push-dimensions.ts +251 -0
  524. package/src/lib/room-selection-commands.ts +62 -0
  525. package/src/lib/room-selection.test.ts +1017 -0
  526. package/src/lib/room-selection.ts +417 -0
  527. package/src/lib/room-structure-commands.ts +94 -0
  528. package/src/lib/room-structure.test.ts +921 -0
  529. package/src/lib/room-transform-session.ts +570 -0
  530. package/src/lib/room-zone-routing.test.ts +294 -0
  531. package/src/lib/room-zone-routing.ts +197 -0
  532. package/src/lib/scene-clipboard.test.ts +610 -1
  533. package/src/lib/scene-clipboard.ts +269 -58
  534. package/src/lib/scene-material-refs.test.ts +79 -0
  535. package/src/lib/scene-material-refs.ts +79 -0
  536. package/src/lib/scene.test.ts +24 -36
  537. package/src/lib/scene.ts +33 -57
  538. package/src/lib/selection-routing.test.ts +129 -45
  539. package/src/lib/selection-routing.ts +54 -75
  540. package/src/lib/session-writes.test.ts +120 -0
  541. package/src/lib/session-writes.ts +205 -0
  542. package/src/lib/sfx-bus.ts +13 -0
  543. package/src/lib/sfx-player.test.ts +70 -9
  544. package/src/lib/sfx-player.ts +6 -0
  545. package/src/lib/sidebar-panel.ts +62 -0
  546. package/src/lib/site-boundary.test.ts +4 -6
  547. package/src/lib/site-boundary.ts +1 -1
  548. package/src/lib/snapping-mode.test.ts +4 -0
  549. package/src/lib/snapping-mode.ts +8 -1
  550. package/src/lib/stair-levels.test.ts +25 -0
  551. package/src/lib/stair-levels.ts +22 -3
  552. package/src/lib/terrace-draft.test.ts +145 -0
  553. package/src/lib/terrace-draft.ts +214 -0
  554. package/src/lib/tool-cursor-icon.test.ts +16 -0
  555. package/src/lib/tool-cursor-icon.ts +11 -0
  556. package/src/lib/typing-target.ts +9 -0
  557. package/src/lib/units.ts +77 -39
  558. package/src/lib/vector-edges.test.ts +58 -0
  559. package/src/lib/vector-edges.ts +400 -0
  560. package/src/lib/viewer-selection.test.ts +577 -0
  561. package/src/lib/viewer-selection.ts +580 -0
  562. package/src/lib/wall-draw-variant.test.ts +71 -0
  563. package/src/lib/wall-draw-variant.ts +91 -0
  564. package/src/lib/wall-paint-region-session.ts +246 -0
  565. package/src/lib/wall-paint-region.test.ts +373 -0
  566. package/src/lib/wall-placement-pointer.test.ts +42 -0
  567. package/src/lib/wall-placement-pointer.ts +57 -0
  568. package/src/lib/wall-polygon-draft.test.ts +262 -0
  569. package/src/lib/wall-polygon-draft.ts +286 -0
  570. package/src/lib/wall-push.test.ts +453 -0
  571. package/src/lib/wall-region-face.ts +352 -0
  572. package/src/lib/wall-region-handles.test.ts +304 -0
  573. package/src/lib/wall-region-handles.ts +368 -0
  574. package/src/lib/wall-region-list.test.ts +119 -0
  575. package/src/lib/wall-region-list.ts +136 -0
  576. package/src/lib/wall-region-snap.ts +110 -0
  577. package/src/lib/window-changes.ts +35 -0
  578. package/src/lib/window-interaction.ts +14 -0
  579. package/src/lib/zone-content.ts +4 -1
  580. package/src/lib/zone-name.test.ts +17 -0
  581. package/src/lib/zone-name.ts +17 -0
  582. package/src/store/opens-in-select-mode.test.ts +223 -0
  583. package/src/store/paint-mode.test.ts +188 -0
  584. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  585. package/src/store/tool-mode.test.ts +137 -6
  586. package/src/store/use-audio.test.ts +146 -0
  587. package/src/store/use-audio.tsx +127 -3
  588. package/src/store/use-construction-reveal.test.ts +173 -0
  589. package/src/store/use-construction-reveal.ts +145 -0
  590. package/src/store/use-corner-snap-hint.test.ts +24 -0
  591. package/src/store/use-corner-snap-hint.ts +47 -0
  592. package/src/store/use-delete-confirmation.ts +28 -1
  593. package/src/store/use-drawing-view.test.ts +24 -3
  594. package/src/store/use-drawing-view.ts +29 -1
  595. package/src/store/use-editor.tsx +432 -321
  596. package/src/store/use-floorplan-draft-preview.ts +13 -0
  597. package/src/store/use-floorplan-marquee.ts +12 -2
  598. package/src/store/use-follow-pascal.test.ts +62 -0
  599. package/src/store/use-follow-pascal.ts +53 -0
  600. package/src/store/use-point-ask.ts +247 -0
  601. package/src/store/use-quick-measurement-hud.ts +3 -3
  602. package/src/store/use-stair-build-preview.ts +33 -5
  603. package/src/store/use-wall-move-ghosts.ts +4 -2
  604. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  605. package/src/store/use-wall-region-handles-selection.ts +139 -0
  606. package/src/store/view-layout.test.ts +201 -0
  607. package/src/store/view-layout.ts +199 -0
  608. package/src/test-utils/immediate-animation-frames.ts +16 -0
  609. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  610. package/src/test-utils/render-current-markup.tsx +16 -0
  611. package/src/test-utils/selection-harness.ts +82 -0
  612. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  613. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  614. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  615. package/src/components/tools/fence/fence-drafting.ts +0 -266
  616. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  617. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  618. package/src/components/tools/stair/stair-defaults.ts +0 -18
  619. package/src/components/tools/stair/stair-tool.tsx +0 -634
  620. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  621. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  622. package/src/lib/floorplan/stairs.ts +0 -489
  623. package/src/lib/level-duplication.test.ts +0 -144
  624. package/src/lib/level-duplication.ts +0 -195
package/src/lib/units.ts CHANGED
@@ -4,7 +4,8 @@ import {
4
4
  type BuildingNode,
5
5
  type LevelNode,
6
6
  pointInPolygon2D,
7
- resolveAutoZonePolygon,
7
+ runAsSingleSceneHistoryStep,
8
+ sceneRegistry,
8
9
  snapWorldXZToBuildingLocal,
9
10
  UnitNode,
10
11
  useScene,
@@ -12,14 +13,13 @@ import {
12
13
  } from '@pascal-app/core'
13
14
  import { useViewer } from '@pascal-app/viewer'
14
15
  import { useEffect } from 'react'
15
- import useEditor, { type StructureLayer } from '../store/use-editor'
16
+ import useEditor from '../store/use-editor'
16
17
  import { getActiveBuildingPose } from './world-grid-snap'
17
18
 
18
19
  type Nodes = Readonly<Record<AnyNodeId, AnyNode>>
19
20
 
20
21
  // Unit focus is a session gesture: the layer the user was on when focus began
21
22
  // comes back when focus ends, unless they switched layers by hand meanwhile.
22
- let restoreLayer: StructureLayer | null = null
23
23
  let entering = false
24
24
  let leaving = false
25
25
 
@@ -49,11 +49,7 @@ export function enterUnitFocus(unitId: UnitNode['id']): void {
49
49
  const unit = nodes[unitId]
50
50
  if (unit?.type !== 'unit') return
51
51
  const editor = useEditor.getState()
52
- if (restoreLayer === null) restoreLayer = editor.structureLayer
53
- if (editor.phase !== 'structure') editor.setPhase('structure')
54
- if (useEditor.getState().structureLayer !== 'zones') {
55
- useEditor.getState().setStructureLayer('zones')
56
- }
52
+ if (editor.phase === 'site') editor.setPhase('building')
57
53
  const viewer = useViewer.getState()
58
54
  if (viewer.focusedUnitId !== unitId) viewer.setFocusedUnit(unitId)
59
55
 
@@ -72,19 +68,7 @@ export function enterUnitFocus(unitId: UnitNode['id']): void {
72
68
  }
73
69
  }
74
70
 
75
- function finishLeave(unitId: UnitNode['id'], keepLayer: boolean) {
76
- const layer = restoreLayer
77
- restoreLayer = null
78
- const editor = useEditor.getState()
79
- if (
80
- !keepLayer &&
81
- layer &&
82
- layer !== 'zones' &&
83
- editor.phase === 'structure' &&
84
- editor.structureLayer === 'zones'
85
- ) {
86
- editor.setStructureLayer(layer)
87
- }
71
+ function finishLeave(unitId: UnitNode['id']) {
88
72
  const { selection, setSelection } = useViewer.getState()
89
73
  if (selection.selectedIds.length === 1 && selection.selectedIds[0] === unitId) {
90
74
  setSelection({ selectedIds: [] })
@@ -92,7 +76,7 @@ function finishLeave(unitId: UnitNode['id'], keepLayer: boolean) {
92
76
  }
93
77
 
94
78
  /** Ends unit focus. Returns false when no unit was focused. */
95
- export function leaveUnitFocus(options?: { keepLayer?: boolean }): boolean {
79
+ export function leaveUnitFocus(): boolean {
96
80
  const viewer = useViewer.getState()
97
81
  const unitId = viewer.focusedUnitId
98
82
  if (!unitId) return false
@@ -102,7 +86,7 @@ export function leaveUnitFocus(options?: { keepLayer?: boolean }): boolean {
102
86
  } finally {
103
87
  leaving = false
104
88
  }
105
- finishLeave(unitId, options?.keepLayer ?? false)
89
+ finishLeave(unitId)
106
90
  return true
107
91
  }
108
92
 
@@ -124,28 +108,75 @@ export function assignZoneToUnit(zoneId: ZoneNode['id'], unitId: UnitNode['id']
124
108
  if (updates.length > 0) updateNodes(updates)
125
109
  }
126
110
 
111
+ /** The units of the building a zone's level belongs to, and the one listing the zone. */
112
+ export function zoneUnits(
113
+ nodes: Nodes,
114
+ zoneId: string,
115
+ ): { buildingId: BuildingNode['id'] | null; units: UnitNode[]; current: UnitNode | null } {
116
+ const zone = nodes[zoneId as AnyNodeId]
117
+ const level = zone?.parentId ? nodes[zone.parentId as AnyNodeId] : undefined
118
+ const building = level?.parentId ? nodes[level.parentId as AnyNodeId] : undefined
119
+ if (building?.type !== 'building') return { buildingId: null, units: [], current: null }
120
+ const units = building.children.flatMap((id) => {
121
+ const child = nodes[id as AnyNodeId]
122
+ return child?.type === 'unit' ? [child] : []
123
+ })
124
+ return {
125
+ buildingId: building.id,
126
+ units,
127
+ current: units.find((unit) => unit.members.includes(zoneId as ZoneNode['id'])) ?? null,
128
+ }
129
+ }
130
+
131
+ /** Puts a zone in a new unit of its building (named "Unit N"), in one undo step, without focusing it. */
132
+ export function addZoneToNewUnit(zoneId: ZoneNode['id']): UnitNode['id'] | null {
133
+ const { buildingId, units } = zoneUnits(useScene.getState().nodes, zoneId)
134
+ if (!buildingId) return null
135
+ const unit = UnitNode.parse({ name: `Unit ${units.length + 1}` })
136
+ runAsSingleSceneHistoryStep(useScene, () => {
137
+ useScene.getState().createNode(unit, buildingId)
138
+ assignZoneToUnit(zoneId, unit.id)
139
+ })
140
+ return unit.id
141
+ }
142
+
127
143
  export function toggleZoneMembership(unitId: UnitNode['id'], zoneId: ZoneNode['id']): void {
128
144
  const unit = useScene.getState().nodes[unitId]
129
145
  if (unit?.type !== 'unit') return
130
146
  assignZoneToUnit(zoneId, unit.members.includes(zoneId) ? null : unitId)
131
147
  }
132
148
 
149
+ /** How far (m) below a mezzanine's walking surface a hit still counts as on it (its plate and underside). */
150
+ const MEZZANINE_HIT_TOLERANCE = 0.05
151
+
133
152
  /**
134
153
  * The zone under a point on the current level, in building-local XZ. A 3D
135
154
  * click lands on whatever surface is closest (a floor slab sits above the
136
155
  * zone fill), so painting resolves the zone from the hit point instead of the
137
- * hit mesh. Nested zones resolve to the smallest one.
156
+ * hit mesh. Nested zones resolve to the smallest one, except that a mezzanine
157
+ * the hit may be on always wins over its host. With the hit's height (`y`,
158
+ * level-local) a mezzanine counts only when the hit is on or above its plate —
159
+ * the host floor under it is the host's; without one (the plan, seen from
160
+ * above) the mezzanine on top wins.
138
161
  */
139
- export function zoneAtLevelPoint(x: number, z: number): ZoneNode | null {
162
+ export function zoneAtLevelPoint(x: number, z: number, y?: number): ZoneNode | null {
140
163
  const levelId = useViewer.getState().selection.levelId
141
164
  if (!levelId) return null
142
165
  const nodes = useScene.getState().nodes
143
- const resolve = (id: AnyNodeId) => nodes[id]
144
166
  let best: ZoneNode | null = null
145
167
  let bestArea = Number.POSITIVE_INFINITY
168
+ let bestOnTop = false
146
169
  for (const node of Object.values(nodes)) {
147
170
  if (node.type !== 'zone' || node.parentId !== levelId) continue
148
- const polygon = resolveAutoZonePolygon(node, resolve)
171
+ // An eligible mezzanine is the surface on top: it beats its host whatever
172
+ // their areas (a mezzanine may cover its whole host).
173
+ const onTop = node.floor?.support === 'open'
174
+ if (y !== undefined && onTop) {
175
+ const underside = (node.floor!.elevation ?? 0) - (node.floor!.thickness ?? 0.2)
176
+ if (y < underside - MEZZANINE_HIT_TOLERANCE) continue
177
+ }
178
+ if (bestOnTop && !onTop) continue
179
+ const polygon = node.polygon
149
180
  if (!pointInPolygon2D([x, z], polygon)) continue
150
181
  let area = 0
151
182
  for (let i = 0; i < polygon.length; i++) {
@@ -154,20 +185,30 @@ export function zoneAtLevelPoint(x: number, z: number): ZoneNode | null {
154
185
  area += a[0] * b[1] - b[0] * a[1]
155
186
  }
156
187
  area = Math.abs(area) / 2
157
- if (area < bestArea) {
188
+ if (area < bestArea || (onTop && !bestOnTop)) {
158
189
  best = node
159
190
  bestArea = area
191
+ bestOnTop = onTop
160
192
  }
161
193
  }
162
194
  return best
163
195
  }
164
196
 
165
- export function zoneAtWorldPoint(worldX: number, worldZ: number): ZoneNode | null {
197
+ /** `worldY` (optional): the hit's height, so a click under a mezzanine picks the host. */
198
+ export function zoneAtWorldPoint(worldX: number, worldZ: number, worldY?: number): ZoneNode | null {
166
199
  const pose = getActiveBuildingPose()
167
200
  const [x, z] = pose
168
201
  ? snapWorldXZToBuildingLocal(worldX, worldZ, pose.position, pose.rotationY, 0).local
169
202
  : [worldX, worldZ]
170
- return zoneAtLevelPoint(x, z)
203
+ return zoneAtLevelPoint(x, z, worldY === undefined ? undefined : levelLocalY(worldY))
204
+ }
205
+
206
+ function levelLocalY(worldY: number): number {
207
+ const levelId = useViewer.getState().selection.levelId
208
+ const level = levelId ? sceneRegistry.nodes.get(levelId) : undefined
209
+ if (!level) return worldY
210
+ level.updateWorldMatrix(true, false)
211
+ return worldY - level.matrixWorld.elements[13]!
171
212
  }
172
213
 
173
214
  export const ZONE_PAINT_DELAY_MS = 250
@@ -211,32 +252,29 @@ export function createUnitInBuilding(buildingId: BuildingNode['id']): UnitNode['
211
252
  /**
212
253
  * Keeps unit focus coherent with the rest of the editor: a focus set from
213
254
  * anywhere (the unit inspector calls the viewer store directly) gets the full
214
- * enter treatment, and focus ends when the user switches layer or phase by
215
- * hand, selects anything other than the focused unit, or the unit is deleted.
255
+ * enter treatment, and focus ends when the user goes to site, selects
256
+ * anything other than the focused unit, or the unit is deleted.
216
257
  */
217
258
  export function useUnitFocusRules(): void {
218
259
  useEffect(() => {
219
260
  const unsubscribeViewer = useViewer.subscribe((state, prev) => {
220
261
  if (state.focusedUnitId !== prev.focusedUnitId) {
221
262
  if (state.focusedUnitId) enterUnitFocus(state.focusedUnitId)
222
- else if (prev.focusedUnitId && !leaving) finishLeave(prev.focusedUnitId, false)
263
+ else if (prev.focusedUnitId && !leaving) finishLeave(prev.focusedUnitId)
223
264
  return
224
265
  }
225
266
  const unitId = state.focusedUnitId
226
267
  if (!unitId || state.selection === prev.selection) return
227
268
  // Selecting a zone keeps focus: renaming or recolouring a zone row is
228
- // part of arranging the unit, and zones live on the layer focus uses.
269
+ // part of arranging the unit.
229
270
  const { selectedIds } = state.selection
230
271
  const selectsOther =
231
272
  selectedIds.length > 0 && !(selectedIds.length === 1 && selectedIds[0] === unitId)
232
273
  if (selectsOther) leaveUnitFocus()
233
274
  })
234
275
  const unsubscribeEditor = useEditor.subscribe((state, prev) => {
235
- if (state.structureLayer === prev.structureLayer && state.phase === prev.phase) return
236
- if (!useViewer.getState().focusedUnitId) return
237
- if (state.phase !== 'structure' || state.structureLayer !== 'zones') {
238
- leaveUnitFocus({ keepLayer: true })
239
- }
276
+ if (state.phase === prev.phase || state.phase === 'building') return
277
+ if (useViewer.getState().focusedUnitId) leaveUnitFocus()
240
278
  })
241
279
  const unsubscribeScene = useScene.subscribe((state, prev) => {
242
280
  if (state.nodes === prev.nodes) return
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import * as THREE from 'three'
3
+ import { candidateEdges, hideUndrawnMeshes, materialShows } from './vector-edges'
4
+
5
+ /** A unit box's feature edges: 12, six floats each. */
6
+ const BOX_EDGE_FLOATS = 12 * 6
7
+
8
+ function sceneWithProxy() {
9
+ const scene = new THREE.Scene()
10
+ const house = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial())
11
+ // a utility-pole pick proxy: a tall box that draws nothing
12
+ const proxy = new THREE.Mesh(
13
+ new THREE.BoxGeometry(0.5, 10.7, 0.5),
14
+ new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: false }),
15
+ )
16
+ proxy.position.set(3, 0, 0)
17
+ scene.add(house, proxy)
18
+ scene.updateMatrixWorld(true)
19
+ return { scene, house, proxy }
20
+ }
21
+
22
+ describe('vector edges skip what draws nothing', () => {
23
+ test('a pick proxy (colorWrite false) has no lines', () => {
24
+ const { scene } = sceneWithProxy()
25
+ const camera = new THREE.OrthographicCamera(-10, 10, 10, -10, 0.1, 100)
26
+ const world = candidateEdges(scene, camera, 20, 400_000)
27
+ expect(world).toHaveLength(BOX_EDGE_FLOATS)
28
+ // every edge is the 1 m box's, none the 10.7 m proxy's
29
+ for (let i = 1; i < world.length; i += 3)
30
+ expect(Math.abs(world[i] as number)).toBeLessThanOrEqual(0.5)
31
+ })
32
+
33
+ test('hidden materials, see-through hit boxes and colour-masked colliders all count as undrawn', () => {
34
+ expect(materialShows(new THREE.MeshBasicMaterial())).toBe(true)
35
+ expect(materialShows(new THREE.MeshBasicMaterial({ colorWrite: false }))).toBe(false)
36
+ expect(materialShows(new THREE.MeshBasicMaterial({ visible: false }))).toBe(false)
37
+ expect(materialShows(new THREE.MeshBasicMaterial({ transparent: true, opacity: 0 }))).toBe(
38
+ false,
39
+ )
40
+ expect(
41
+ materialShows([
42
+ new THREE.MeshBasicMaterial({ colorWrite: false }),
43
+ new THREE.MeshBasicMaterial(),
44
+ ]),
45
+ ).toBe(true)
46
+ })
47
+
48
+ test('the depth and normal passes see no pick proxy, and it comes back after', () => {
49
+ const { scene, house, proxy } = sceneWithProxy()
50
+ const show = hideUndrawnMeshes(scene)
51
+ // under the passes' override material the proxy would write depth and
52
+ // hide the real edges behind it
53
+ expect(proxy.visible).toBe(false)
54
+ expect(house.visible).toBe(true)
55
+ show()
56
+ expect(proxy.visible).toBe(true)
57
+ })
58
+ })
@@ -0,0 +1,400 @@
1
+ /**
2
+ * VECTOR EDGES FROM THE VIEWER — the elevation's lines taken from the same
3
+ * meshes the picture is rendered from, with hidden-line removal.
4
+ *
5
+ * A drawing built from a second model of the house (a plugin's section solids)
6
+ * never quite matches the render: its roof sat on the plate plane while the
7
+ * 3D roof stacks a deck and shingles on it, its door was the opening while
8
+ * the render showed the leaf. This is how Revit and Chief Architect avoid
9
+ * that — ONE model, the view's lines derived from it:
10
+ *
11
+ * 1. every visible mesh's FEATURE EDGES (creases over `thresholdDeg`,
12
+ * boundaries — three's EdgesGeometry, cached per geometry), carried
13
+ * to world space, instance by instance for InstancedMeshes;
14
+ * 2. a DEPTH pass of the scene through the capture camera — view-space
15
+ * depth as a float colour, read back once;
16
+ * 3. each edge projected through that camera and SAMPLED along its length
17
+ * against the depth buffer: a point deeper than what the buffer saw is
18
+ * behind something. The visible runs come back as world segments.
19
+ *
20
+ * Orthographic only (an elevation, a section): depth is linear across a face
21
+ * so the interpolated edge depth is exact. Not a boolean operation — an
22
+ * edge test, cheap enough to run at capture time.
23
+ */
24
+ import * as THREE from 'three'
25
+ import { normalView, positionView, vec4 } from 'three/tsl'
26
+ import { MeshBasicNodeMaterial, RenderTarget } from 'three/webgpu'
27
+
28
+ export type VisibleEdges = {
29
+ /** World-space segments, six floats each: ax ay az bx by bz. */
30
+ segments: Float32Array
31
+ count: number
32
+ /** How many candidate edges were tested, and the depth pass' size. */
33
+ tested: number
34
+ width: number
35
+ height: number
36
+ ms: number
37
+ /** Whether the normal pass came back (without it every in-view edge is kept). */
38
+ normals: boolean
39
+ }
40
+
41
+ const edgeCache = new WeakMap<THREE.BufferGeometry, Map<number, Float32Array | null>>()
42
+
43
+ /** The feature edges of a geometry, local space, as pairs of points (cached per threshold; null when the geometry is too heavy). */
44
+ function featureEdgesOf(
45
+ geometry: THREE.BufferGeometry,
46
+ thresholdDeg: number,
47
+ maxTriangles: number,
48
+ ): Float32Array | null {
49
+ const position = geometry.getAttribute('position')
50
+ if (!position) return null
51
+ const triangles = geometry.index ? geometry.index.count / 3 : position.count / 3
52
+ if (triangles > maxTriangles) return null
53
+ let byThreshold = edgeCache.get(geometry)
54
+ if (!byThreshold) {
55
+ byThreshold = new Map()
56
+ edgeCache.set(geometry, byThreshold)
57
+ }
58
+ const cached = byThreshold.get(thresholdDeg)
59
+ if (cached !== undefined) return cached
60
+ let edges: Float32Array | null = null
61
+ try {
62
+ const eg = new THREE.EdgesGeometry(geometry, thresholdDeg)
63
+ const attr = eg.getAttribute('position')
64
+ edges = attr ? new Float32Array(attr.array as ArrayLike<number>) : null
65
+ eg.dispose()
66
+ } catch {
67
+ edges = null
68
+ }
69
+ byThreshold.set(thresholdDeg, edges)
70
+ return edges
71
+ }
72
+
73
+ function isShown(object: THREE.Object3D): boolean {
74
+ let o: THREE.Object3D | null = object
75
+ while (o) {
76
+ if (!o.visible) return false
77
+ o = o.parent
78
+ }
79
+ return true
80
+ }
81
+
82
+ /**
83
+ * Whether a material draws anything. `colorWrite: false` is how a pick-only
84
+ * collider hides on the GPU (a plugin's utility-pole proxy, 0.5 × 10.7 m, once
85
+ * drew a tall rectangle through the elevations, 2026-09-23 — the rule
86
+ * glb-export's `isRenderableMesh` already follows).
87
+ */
88
+ export function materialShows(material: THREE.Material | THREE.Material[]): boolean {
89
+ const list = Array.isArray(material) ? material : [material]
90
+ return list.some(
91
+ (m) =>
92
+ m && m.visible !== false && m.colorWrite !== false && !(m.transparent && m.opacity <= 0.02),
93
+ )
94
+ }
95
+
96
+ /**
97
+ * The shown meshes whose own materials draw nothing — pick proxies, hit
98
+ * boxes — hidden for the length of the buffer passes: under the passes'
99
+ * override material they would write depth and hide the real edges behind
100
+ * them. The returned function shows them again.
101
+ */
102
+ export function hideUndrawnMeshes(scene: THREE.Object3D): () => void {
103
+ const hidden: THREE.Object3D[] = []
104
+ scene.traverse((object) => {
105
+ const mesh = object as THREE.Mesh
106
+ if (!mesh.isMesh || !mesh.visible || materialShows(mesh.material)) return
107
+ mesh.visible = false
108
+ hidden.push(mesh)
109
+ })
110
+ return () => {
111
+ for (const mesh of hidden) mesh.visible = true
112
+ }
113
+ }
114
+
115
+ /**
116
+ * One pass of the scene through `material` into a float target, read back:
117
+ * the depth pass writes view-space depth (metres in front of the camera) in
118
+ * red; the normal pass the view-space normal in RGB.
119
+ */
120
+ async function bufferPass(
121
+ renderer: THREE.WebGLRenderer,
122
+ scene: THREE.Scene,
123
+ camera: THREE.Camera,
124
+ width: number,
125
+ height: number,
126
+ colorNode: unknown,
127
+ ): Promise<{ data: Float32Array; topDown: boolean } | null> {
128
+ const material = new MeshBasicNodeMaterial()
129
+ material.colorNode = colorNode as never
130
+ material.side = THREE.DoubleSide
131
+ material.transparent = false
132
+ material.depthTest = true
133
+ material.depthWrite = true
134
+ const target = new RenderTarget(width, height, {
135
+ type: THREE.FloatType,
136
+ format: THREE.RGBAFormat,
137
+ depthBuffer: true,
138
+ minFilter: THREE.NearestFilter,
139
+ magFilter: THREE.NearestFilter,
140
+ generateMipmaps: false,
141
+ })
142
+ const gl = renderer as unknown as {
143
+ getRenderTarget: () => unknown
144
+ setRenderTarget: (t: unknown) => void
145
+ getClearColor: (c: THREE.Color) => THREE.Color
146
+ getClearAlpha: () => number
147
+ setClearColor: (c: THREE.Color | number, a?: number) => void
148
+ renderAsync: (s: THREE.Scene, c: THREE.Camera) => Promise<void>
149
+ readRenderTargetPixelsAsync: (
150
+ t: unknown,
151
+ x: number,
152
+ y: number,
153
+ w: number,
154
+ h: number,
155
+ ) => Promise<ArrayLike<number>>
156
+ backend?: { device?: unknown }
157
+ }
158
+ const previousTarget = gl.getRenderTarget()
159
+ const previousOverride = scene.overrideMaterial
160
+ const previousClear = gl.getClearColor(new THREE.Color())
161
+ const previousAlpha = gl.getClearAlpha()
162
+ try {
163
+ scene.overrideMaterial = material
164
+ gl.setClearColor(new THREE.Color(0, 0, 0), 0)
165
+ gl.setRenderTarget(target)
166
+ await gl.renderAsync(scene, camera)
167
+ gl.setRenderTarget(null)
168
+ const raw = await gl.readRenderTargetPixelsAsync(target, 0, 0, width, height)
169
+ const data = raw instanceof Float32Array ? raw : new Float32Array(raw)
170
+ if (data.length < width * height * 4) return null
171
+ // WebGPU reads rows top-down; the WebGL fallback bottom-up
172
+ return { data, topDown: Boolean(gl.backend?.device) }
173
+ } catch (error) {
174
+ console.warn('[vector-edges] buffer pass failed', error)
175
+ return null
176
+ } finally {
177
+ scene.overrideMaterial = previousOverride
178
+ gl.setClearColor(previousClear, previousAlpha)
179
+ gl.setRenderTarget(previousTarget)
180
+ target.dispose()
181
+ material.dispose()
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Every drawn mesh's feature edges in world space, six floats per edge —
187
+ * instance by instance for an InstancedMesh. What draws nothing (a hidden
188
+ * mesh, a pick proxy, a layer the camera does not see) has no lines.
189
+ */
190
+ export function candidateEdges(
191
+ scene: THREE.Object3D,
192
+ camera: THREE.Camera,
193
+ thresholdDeg: number,
194
+ maxTriangles: number,
195
+ ): number[] {
196
+ const world: number[] = []
197
+ const a = new THREE.Vector3()
198
+ const b = new THREE.Vector3()
199
+ const instance = new THREE.Matrix4()
200
+ const composed = new THREE.Matrix4()
201
+ scene.traverse((object) => {
202
+ const mesh = object as THREE.Mesh
203
+ if (!mesh.isMesh || !mesh.geometry) return
204
+ if ((mesh as unknown as { isSkinnedMesh?: boolean }).isSkinnedMesh) return
205
+ if (!camera.layers.test(mesh.layers)) return
206
+ if (!isShown(mesh) || !materialShows(mesh.material)) return
207
+ const edges = featureEdgesOf(mesh.geometry, thresholdDeg, maxTriangles)
208
+ if (!edges || edges.length < 6) return
209
+ const instanced = mesh as unknown as THREE.InstancedMesh
210
+ const matrices: THREE.Matrix4[] = []
211
+ if (instanced.isInstancedMesh) {
212
+ for (let i = 0; i < instanced.count; i++) {
213
+ instanced.getMatrixAt(i, instance)
214
+ matrices.push(composed.copy(mesh.matrixWorld).multiply(instance).clone())
215
+ }
216
+ } else {
217
+ matrices.push(mesh.matrixWorld)
218
+ }
219
+ for (const m of matrices) {
220
+ for (let i = 0; i + 5 < edges.length; i += 6) {
221
+ a.set(edges[i] as number, edges[i + 1] as number, edges[i + 2] as number).applyMatrix4(m)
222
+ b.set(edges[i + 3] as number, edges[i + 4] as number, edges[i + 5] as number).applyMatrix4(
223
+ m,
224
+ )
225
+ world.push(a.x, a.y, a.z, b.x, b.y, b.z)
226
+ }
227
+ }
228
+ })
229
+ return world
230
+ }
231
+
232
+ /**
233
+ * The visible feature edges of `scene` through `camera` (orthographic), as
234
+ * world segments. `width` sets the depth pass (a multiple of 16 keeps the
235
+ * WebGPU readback rows unpadded); `height` follows the camera's aspect.
236
+ */
237
+ export async function extractVisibleEdges(
238
+ renderer: THREE.WebGLRenderer,
239
+ scene: THREE.Scene,
240
+ camera: THREE.OrthographicCamera,
241
+ options: { width?: number; thresholdDeg?: number; maxTriangles?: number; bias?: number } = {},
242
+ ): Promise<VisibleEdges | null> {
243
+ const started = performance.now()
244
+ const thresholdDeg = options.thresholdDeg ?? 20
245
+ const maxTriangles = options.maxTriangles ?? 400_000
246
+ const aspect = (camera.right - camera.left) / Math.max(1e-6, camera.top - camera.bottom)
247
+ const width = Math.max(16, Math.round((options.width ?? 2048) / 16) * 16)
248
+ const height = Math.max(16, Math.round(width / aspect / 16) * 16)
249
+ camera.updateMatrixWorld(true)
250
+ scene.updateMatrixWorld(true)
251
+
252
+ const showUndrawn = hideUndrawnMeshes(scene)
253
+ let pass: Awaited<ReturnType<typeof bufferPass>>
254
+ let normalPass: Awaited<ReturnType<typeof bufferPass>> = null
255
+ try {
256
+ pass = await bufferPass(
257
+ renderer,
258
+ scene,
259
+ camera,
260
+ width,
261
+ height,
262
+ vec4(positionView.z.negate(), 0, 0, 1),
263
+ )
264
+ if (pass)
265
+ normalPass = await bufferPass(renderer, scene, camera, width, height, vec4(normalView, 1))
266
+ } finally {
267
+ showUndrawn()
268
+ }
269
+ if (!pass) return null
270
+ const { data: depth, topDown } = pass
271
+ const normals = normalPass?.data ?? null
272
+
273
+ const world = candidateEdges(scene, camera, thresholdDeg, maxTriangles)
274
+
275
+ // ── the visibility test ──────────────────────────────────────────────
276
+ const view = camera.matrixWorldInverse
277
+ const proj = camera.projectionMatrix
278
+ const bias = options.bias ?? 0.008
279
+ const out: number[] = []
280
+ const va = new THREE.Vector3()
281
+ const vb = new THREE.Vector3()
282
+ const pa = new THREE.Vector3()
283
+ const pb = new THREE.Vector3()
284
+ const indexAt = (px: number, py: number): number => {
285
+ const x = Math.floor(px)
286
+ const y = Math.floor(py)
287
+ if (x < 0 || y < 0 || x >= width || y >= height) return -1
288
+ const row = topDown ? y : height - 1 - y
289
+ return (row * width + x) * 4
290
+ }
291
+ const sampleAt = (px: number, py: number): number => {
292
+ const k = indexAt(px, py)
293
+ return k < 0 ? Number.POSITIVE_INFINITY : (depth[k] as number)
294
+ }
295
+ // A line is only a line where the render CHANGES across it: a step in
296
+ // depth (a silhouette, a reveal, the far side of a box) or a crease in
297
+ // the normal (wall meets roof, the corner of a post). A flat face's own
298
+ // triangulation — the CSG walls come with T-junctions, so the edge
299
+ // finder cannot pair those triangles — shows neither and is dropped.
300
+ const CREASE = Math.cos((15 * Math.PI) / 180)
301
+ const ACROSS = 2
302
+ const featureAt = (px: number, py: number, nx: number, ny: number): boolean => {
303
+ const c = sampleAt(px, py)
304
+ const l = sampleAt(px + nx * ACROSS, py + ny * ACROSS)
305
+ const r = sampleAt(px - nx * ACROSS, py - ny * ACROSS)
306
+ // the sky on either side, or off the frame: a silhouette
307
+ if (c === 0 || l === 0 || r === 0) return true
308
+ if (!Number.isFinite(l) || !Number.isFinite(r) || !Number.isFinite(c)) return true
309
+ // a step, not a slope: the second difference is zero on any plane
310
+ if (Math.abs(l + r - 2 * c) > 0.012) return true
311
+ if (!normals) return true
312
+ const kl = indexAt(px + nx * ACROSS, py + ny * ACROSS)
313
+ const kr = indexAt(px - nx * ACROSS, py - ny * ACROSS)
314
+ if (kl < 0 || kr < 0) return true
315
+ // unsigned: a CSG face's triangles may wind either way, and a flipped
316
+ // normal on a flat wall is not a corner
317
+ const dot =
318
+ (normals[kl] as number) * (normals[kr] as number) +
319
+ (normals[kl + 1] as number) * (normals[kr + 1] as number) +
320
+ (normals[kl + 2] as number) * (normals[kr + 2] as number)
321
+ return Math.abs(dot) < CREASE
322
+ }
323
+ const tested = world.length / 6
324
+ for (let i = 0; i + 5 < world.length; i += 6) {
325
+ va.set(world[i] as number, world[i + 1] as number, world[i + 2] as number).applyMatrix4(view)
326
+ vb.set(world[i + 3] as number, world[i + 4] as number, world[i + 5] as number).applyMatrix4(
327
+ view,
328
+ )
329
+ const da = -va.z
330
+ const db = -vb.z
331
+ if (da <= camera.near && db <= camera.near) continue
332
+ pa.copy(va).applyMatrix4(proj)
333
+ pb.copy(vb).applyMatrix4(proj)
334
+ const ax = ((pa.x + 1) / 2) * width
335
+ const ay = ((1 - pa.y) / 2) * height
336
+ const bx = ((pb.x + 1) / 2) * width
337
+ const by = ((1 - pb.y) / 2) * height
338
+ // wholly off the frame: nothing to test
339
+ if (
340
+ (ax < 0 && bx < 0) ||
341
+ (ax >= width && bx >= width) ||
342
+ (ay < 0 && by < 0) ||
343
+ (ay >= height && by >= height)
344
+ ) {
345
+ continue
346
+ }
347
+ const lengthPx = Math.hypot(bx - ax, by - ay)
348
+ const n = Math.min(4096, Math.max(2, Math.ceil(lengthPx)))
349
+ const slope = Math.abs(db - da) / n
350
+ const tolerance = bias + slope * 1.5
351
+ // the screen-space perpendicular, to look across the edge
352
+ const nx = lengthPx > 1e-6 ? -(by - ay) / lengthPx : 1
353
+ const ny = lengthPx > 1e-6 ? (bx - ax) / lengthPx : 0
354
+ let runStart = -1
355
+ let gap = 0
356
+ const emit = (t0: number, t1: number) => {
357
+ if (t1 - t0 <= 0) return
358
+ out.push(
359
+ (world[i] as number) + ((world[i + 3] as number) - (world[i] as number)) * t0,
360
+ (world[i + 1] as number) + ((world[i + 4] as number) - (world[i + 1] as number)) * t0,
361
+ (world[i + 2] as number) + ((world[i + 5] as number) - (world[i + 2] as number)) * t0,
362
+ (world[i] as number) + ((world[i + 3] as number) - (world[i] as number)) * t1,
363
+ (world[i + 1] as number) + ((world[i + 4] as number) - (world[i + 1] as number)) * t1,
364
+ (world[i + 2] as number) + ((world[i + 5] as number) - (world[i + 2] as number)) * t1,
365
+ )
366
+ }
367
+ for (let s = 0; s <= n; s++) {
368
+ const t = s / n
369
+ const px = ax + (bx - ax) * t
370
+ const py = ay + (by - ay) * t
371
+ const d = da + (db - da) * t
372
+ const seen = sampleAt(px, py)
373
+ // the background (nothing drawn) reads 0; off the frame reads Infinity — both leave the edge in view
374
+ const inView = seen === 0 || seen === Number.POSITIVE_INFINITY || d <= seen + tolerance
375
+ const visible = inView && featureAt(px, py, nx, ny)
376
+ if (visible) {
377
+ if (runStart < 0) runStart = t
378
+ gap = 0
379
+ } else if (runStart >= 0) {
380
+ // a single hidden sample inside a run is rasterisation noise, not an occluder
381
+ gap++
382
+ if (gap > 1) {
383
+ emit(runStart, (s - gap) / n)
384
+ runStart = -1
385
+ gap = 0
386
+ }
387
+ }
388
+ }
389
+ if (runStart >= 0) emit(runStart, 1)
390
+ }
391
+ return {
392
+ segments: new Float32Array(out),
393
+ count: out.length / 6,
394
+ tested,
395
+ width,
396
+ height,
397
+ ms: Math.round(performance.now() - started),
398
+ normals: normals !== null,
399
+ }
400
+ }