@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
@@ -0,0 +1,1084 @@
1
+ /**
2
+ * The permit-set layers of the site plan (A1.0) beyond the lot, the yards
3
+ * and the house: the street each street edge fronts, the contour labels,
4
+ * the driveway and walks, the utility services a plugin located, the drainage
5
+ * arrows, and the finish-floor and spot elevations. Every function is pure
6
+ * and returns site-metre primitives (x east, y south) for
7
+ * `buildSitePlanDrawing`, which owns the order they stack in.
8
+ */
9
+ import {
10
+ type AnyNode,
11
+ type AnyNodeId,
12
+ type BuildingNode,
13
+ type FloorplanGeometry,
14
+ heightAt,
15
+ type LevelNode,
16
+ outwardNormal,
17
+ type SceneSnapshot,
18
+ type SiteNode,
19
+ siteFrame,
20
+ type TerrainField,
21
+ terrainContours,
22
+ } from '@pascal-app/core'
23
+ import {
24
+ type SitePlanServicePoint,
25
+ type SitePlanServiceRole,
26
+ sitePlanServiceAnswers,
27
+ } from './contributors'
28
+ import {
29
+ formatFeetInches,
30
+ METRES_PER_FOOT,
31
+ type Pt,
32
+ pointInPolygon,
33
+ polygonArea,
34
+ polygonBounds,
35
+ polygonCentroid,
36
+ } from './geometry'
37
+ import { flatworkKindOf, type OutdoorPart } from './site-parts'
38
+
39
+ const INK = '#111827'
40
+ const INK_SOFT = '#4b5563'
41
+ const CONTOUR_INK = '#8b5a2b'
42
+ const HALO = '#ffffff'
43
+ const FT = METRES_PER_FOOT
44
+
45
+ /** An angle a label reads at: within a quarter turn of the paper's horizontal. */
46
+ function readable(a: number): number {
47
+ let r = a
48
+ while (r > Math.PI / 2) r -= Math.PI
49
+ while (r <= -Math.PI / 2) r += Math.PI
50
+ return r
51
+ }
52
+
53
+ /** A label laid along a run, centred on `at`, knocked out of what it crosses. */
54
+ function runLabel(
55
+ at: Pt,
56
+ angle: number,
57
+ text: string,
58
+ fontSize: number,
59
+ fill: string,
60
+ metadata: Record<string, unknown>,
61
+ weight = 600,
62
+ ): FloorplanGeometry {
63
+ return {
64
+ kind: 'group',
65
+ transform: { translate: at, rotate: readable(angle) },
66
+ children: [
67
+ {
68
+ kind: 'text',
69
+ x: 0,
70
+ y: 0,
71
+ text,
72
+ fontSize,
73
+ fill,
74
+ fontWeight: weight,
75
+ textAnchor: 'middle',
76
+ dominantBaseline: 'middle',
77
+ stroke: HALO,
78
+ strokeWidth: fontSize * 0.28,
79
+ paintOrder: 'stroke',
80
+ },
81
+ ],
82
+ metadata,
83
+ }
84
+ }
85
+
86
+ const sub = (a: Pt, b: Pt): Pt => [a[0] - b[0], a[1] - b[1]]
87
+ const add = (a: Pt, b: Pt): Pt => [a[0] + b[0], a[1] + b[1]]
88
+ const mul = (a: Pt, k: number): Pt => [a[0] * k, a[1] * k]
89
+ const len = (a: Pt) => Math.hypot(a[0], a[1])
90
+ const unit = (a: Pt): Pt => {
91
+ const l = len(a) || 1
92
+ return [a[0] / l, a[1] / l]
93
+ }
94
+
95
+ /** The nearest point to `p` on segment ab. */
96
+ function footOn(p: Pt, a: Pt, b: Pt): Pt {
97
+ const ab = sub(b, a)
98
+ const l2 = ab[0] * ab[0] + ab[1] * ab[1]
99
+ const t =
100
+ l2 < 1e-12 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1]) / l2))
101
+ return [a[0] + ab[0] * t, a[1] + ab[1] * t]
102
+ }
103
+
104
+ /** Proper crossing of segments pq and ab. */
105
+ function crosses(p: Pt, q: Pt, a: Pt, b: Pt): boolean {
106
+ const d = (u: Pt, v: Pt, w: Pt) => (v[0] - u[0]) * (w[1] - u[1]) - (v[1] - u[1]) * (w[0] - u[0])
107
+ const d1 = d(a, b, p)
108
+ const d2 = d(a, b, q)
109
+ const d3 = d(p, q, a)
110
+ const d4 = d(p, q, b)
111
+ return d1 * d2 < 0 && d3 * d4 < 0
112
+ }
113
+
114
+ function crossesRings(p: Pt, q: Pt, rings: readonly Pt[][]): boolean {
115
+ for (const ring of rings) {
116
+ for (let i = 0; i < ring.length; i++) {
117
+ if (crosses(p, q, ring[i] as Pt, ring[(i + 1) % ring.length] as Pt)) return true
118
+ }
119
+ }
120
+ return false
121
+ }
122
+
123
+ /* ============================================================ streets */
124
+
125
+ const DIRECTIONS: Record<string, string> = {
126
+ north: 'N',
127
+ south: 'S',
128
+ east: 'E',
129
+ west: 'W',
130
+ northeast: 'NE',
131
+ northwest: 'NW',
132
+ southeast: 'SE',
133
+ southwest: 'SW',
134
+ n: 'N',
135
+ s: 'S',
136
+ e: 'E',
137
+ w: 'W',
138
+ ne: 'NE',
139
+ nw: 'NW',
140
+ se: 'SE',
141
+ sw: 'SW',
142
+ }
143
+
144
+ const SUFFIXES: Record<string, string> = {
145
+ st: 'STREET',
146
+ street: 'STREET',
147
+ ave: 'AVENUE',
148
+ av: 'AVENUE',
149
+ avenue: 'AVENUE',
150
+ rd: 'ROAD',
151
+ road: 'ROAD',
152
+ dr: 'DRIVE',
153
+ drive: 'DRIVE',
154
+ ln: 'LANE',
155
+ lane: 'LANE',
156
+ ct: 'COURT',
157
+ court: 'COURT',
158
+ pl: 'PLACE',
159
+ place: 'PLACE',
160
+ blvd: 'BOULEVARD',
161
+ boulevard: 'BOULEVARD',
162
+ cir: 'CIRCLE',
163
+ circle: 'CIRCLE',
164
+ ter: 'TERRACE',
165
+ terr: 'TERRACE',
166
+ terrace: 'TERRACE',
167
+ trl: 'TRAIL',
168
+ trail: 'TRAIL',
169
+ pkwy: 'PARKWAY',
170
+ parkway: 'PARKWAY',
171
+ hwy: 'HIGHWAY',
172
+ highway: 'HIGHWAY',
173
+ way: 'WAY',
174
+ loop: 'LOOP',
175
+ }
176
+
177
+ /**
178
+ * A street name as a site plan prints it: the house number dropped, the
179
+ * quadrant abbreviated, the street type spelled out — "4121 NW 34th St" and
180
+ * OSM's "Northwest 34th Street" both read "NW 34TH STREET", and "Northwest
181
+ * 34th Terrace" reads "NW 34TH TERRACE" (a different street: the type is part
182
+ * of the name). Empty for an empty name.
183
+ */
184
+ export function formatStreetName(raw: string | null | undefined): string {
185
+ const words = String(raw ?? '')
186
+ .replace(/^\s*\d+[A-Za-z]?(?:[-–]\d+)?\s+/, '')
187
+ .replace(/\./g, '')
188
+ .split(/\s+/)
189
+ .filter(Boolean)
190
+ if (words.length === 0) return ''
191
+ const out = words.map((w) => w.toUpperCase())
192
+ const first = DIRECTIONS[(words[0] as string).toLowerCase()]
193
+ if (first && words.length > 1) out[0] = first
194
+ const lastIndex = words.length - 1
195
+ const last = (words[lastIndex] as string).toLowerCase()
196
+ const trailingDir = DIRECTIONS[last]
197
+ if (trailingDir && lastIndex > 0) {
198
+ out[lastIndex] = trailingDir
199
+ const type = SUFFIXES[(words[lastIndex - 1] as string).toLowerCase()]
200
+ if (type && lastIndex - 1 > 0) out[lastIndex - 1] = type
201
+ } else {
202
+ const type = SUFFIXES[last]
203
+ if (type && lastIndex > 0) out[lastIndex] = type
204
+ }
205
+ return out.join(' ')
206
+ }
207
+
208
+ /** The front edge's street as the lot drop-in's note recorded it (scenes dropped before per-edge names were kept). */
209
+ function frontStreetFromNotes(
210
+ notes: readonly string[] | undefined,
211
+ frontEdge: number,
212
+ ): string | null {
213
+ for (const note of notes ?? []) {
214
+ const osm = /^Front edge (\d+): fronts "([^"]+)"/.exec(note)
215
+ if (osm && Number(osm[1]) - 1 === frontEdge) return osm[2] as string
216
+ const fabric =
217
+ /^Front edge: edge (\d+) of the parcel fabric's frontage \([^,)]*,\s*([^)]+)\)/.exec(note)
218
+ if (fabric && Number(fabric[1]) - 1 === frontEdge) return (fabric[2] as string).trim()
219
+ }
220
+ return null
221
+ }
222
+
223
+ /** Printed on a street edge whose street the lot drop-in could not name. */
224
+ export const UNNAMED_STREET = 'STREET (NAME NOT ON RECORD — VERIFY)'
225
+
226
+ /**
227
+ * The street each street edge of the lot runs along, as printed: only the
228
+ * edges that front a street — `site.streetEdges` and the front edge — and
229
+ * each with ITS street: the name the lot drop-in stored for that edge
230
+ * (`site.metadata.streetNames`, from the mapped roads), else — for a lot dropped
231
+ * before names were kept per edge — the front edge's street from the
232
+ * drop-in's note, the address's street on the one other street edge when
233
+ * the front is a different street (a through or corner lot is addressed on
234
+ * one of its streets), and "name not on record" where neither says.
235
+ */
236
+ export function streetEdgeNames(
237
+ site: Pick<SiteNode, 'address' | 'parcel' | 'streetEdges'> & { metadata?: unknown },
238
+ frontEdge: number,
239
+ ): Map<number, string> {
240
+ const edges = [...new Set([frontEdge, ...(site.streetEdges ?? [])])].filter(
241
+ (i) => Number.isInteger(i) && i >= 0,
242
+ )
243
+ const stored = ((site.metadata as { streetNames?: unknown } | undefined)?.streetNames ??
244
+ {}) as Record<string, string>
245
+ const address = formatStreetName(site.address?.street)
246
+ const names = new Map<number, string>()
247
+ for (const i of edges) {
248
+ const own = formatStreetName(stored[String(i)])
249
+ if (own) names.set(i, own)
250
+ }
251
+ if (!names.has(frontEdge)) {
252
+ const noted = formatStreetName(frontStreetFromNotes(site.parcel?.notes, frontEdge))
253
+ const front = noted || address
254
+ if (front) names.set(frontEdge, front)
255
+ }
256
+ const unnamed = edges.filter((i) => !names.has(i))
257
+ const taken = new Set(names.values())
258
+ if (address && unnamed.length === 1 && !taken.has(address))
259
+ names.set(unnamed[0] as number, address)
260
+ for (const i of edges) if (!names.has(i)) names.set(i, UNNAMED_STREET)
261
+ return names
262
+ }
263
+
264
+ /* =========================================================== contours */
265
+
266
+ type ContourLine = { levelM: number; absFt: number | null; points: Pt[]; index: boolean }
267
+
268
+ function polylineLength(points: readonly Pt[]): number {
269
+ let s = 0
270
+ for (let i = 1; i < points.length; i++) s += len(sub(points[i] as Pt, points[i - 1] as Pt))
271
+ return s
272
+ }
273
+
274
+ /** The point `t` (0–1) of the way along a polyline, with the run's direction there. */
275
+ function alongPolyline(points: readonly Pt[], t: number): { at: Pt; angle: number } {
276
+ const total = polylineLength(points)
277
+ let want = total * t
278
+ for (let i = 1; i < points.length; i++) {
279
+ const a = points[i - 1] as Pt
280
+ const b = points[i] as Pt
281
+ const l = len(sub(b, a))
282
+ if (want <= l || i === points.length - 1) {
283
+ const k = l > 0 ? Math.min(1, want / l) : 0
284
+ return {
285
+ at: [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k],
286
+ angle: Math.atan2(b[1] - a[1], b[0] - a[0]),
287
+ }
288
+ }
289
+ want -= l
290
+ }
291
+ const p = points[0] as Pt
292
+ return { at: p, angle: 0 }
293
+ }
294
+
295
+ /**
296
+ * The contour lines and their labels. With a survey datum (the terrain
297
+ * sample's USGS elevation, or the dossier's 3DEP lines) the lines fall on
298
+ * whole multiples of the interval in ABSOLUTE feet — 173, 174, 175 — not on
299
+ * the site datum's fraction, and each is labelled with its elevation; every
300
+ * fifth is an index contour (heavier). Each elevation is labelled once, or
301
+ * twice on a long line, on its longest pieces — never on every loop.
302
+ */
303
+ export function contourPrimitives(args: {
304
+ site: SiteNode
305
+ lot: readonly Pt[]
306
+ field: TerrainField
307
+ intervalIn: number
308
+ datumFt: number | null
309
+ fontSize: number
310
+ /** Rings no label is set inside (the house, porches, paving). */
311
+ avoid?: readonly Pt[][]
312
+ }): FloorplanGeometry[] {
313
+ const { site, lot, field, intervalIn, datumFt, fontSize } = args
314
+ const avoid = args.avoid ?? []
315
+ const clear = (p: Pt) => !avoid.some((ring) => pointInPolygon(ring, p[0], p[1]))
316
+ const stepFt = intervalIn / 12
317
+ const isIndex = (ft: number) => Math.abs(ft / (stepFt * 5) - Math.round(ft / (stepFt * 5))) < 1e-6
318
+ let lines: ContourLine[]
319
+ const surveyed = site.terrainContours
320
+ const useSurveyed =
321
+ surveyed !== undefined &&
322
+ surveyed.lines.length > 0 &&
323
+ Math.abs(stepFt / surveyed.intervalFt - Math.round(stepFt / surveyed.intervalFt)) < 1e-9
324
+ if (useSurveyed) {
325
+ lines = surveyed.lines
326
+ .filter((l) => Math.abs(l.elevationFt / stepFt - Math.round(l.elevationFt / stepFt)) < 1e-9)
327
+ .map((l) => ({
328
+ levelM: (l.elevationFt - (datumFt ?? 0)) * FT,
329
+ absFt: datumFt !== null ? l.elevationFt : null,
330
+ points: l.points.filter((p) => lot.length < 3 || pointInPolygon(lot, p[0], p[1])) as Pt[],
331
+ index: isIndex(l.elevationFt),
332
+ }))
333
+ .filter((c) => c.points.length >= 2)
334
+ } else if (datumFt !== null) {
335
+ // shift the field by the datum's fraction of an interval so the lines
336
+ // land on whole absolute elevations
337
+ const frac = ((datumFt % stepFt) + stepFt) % stepFt
338
+ const shift = Math.round((frac * FT) / field.step)
339
+ const shifted: TerrainField =
340
+ shift === 0 ? field : { ...field, heights: field.heights.map((h) => h + shift) }
341
+ lines = terrainContours(shifted, stepFt * FT, lot).map((c) => {
342
+ const rel = c.levelM - shift * field.step
343
+ const absFt = Math.round((datumFt + rel / FT) / stepFt) * stepFt
344
+ return { levelM: rel, absFt, points: c.points, index: isIndex(absFt) }
345
+ })
346
+ } else {
347
+ lines = terrainContours(field, stepFt * FT, lot).map((c) => ({
348
+ levelM: c.levelM,
349
+ absFt: null,
350
+ points: c.points,
351
+ index: c.index,
352
+ }))
353
+ }
354
+
355
+ const out: FloorplanGeometry[] = []
356
+ for (const c of lines) {
357
+ out.push({
358
+ kind: 'polyline',
359
+ points: c.points,
360
+ stroke: CONTOUR_INK,
361
+ strokeWidth: c.index ? 0.045 : 0.02,
362
+ opacity: c.index ? 0.85 : 0.6,
363
+ fill: 'none',
364
+ metadata: { sitePlan: 'contour', levelM: c.levelM },
365
+ })
366
+ }
367
+ // labels: per elevation, on its longest pieces — two on a long line, one otherwise
368
+ const byLevel = new Map<string, ContourLine[]>()
369
+ for (const c of lines) {
370
+ const key = c.levelM.toFixed(4)
371
+ const list = byLevel.get(key)
372
+ if (list) list.push(c)
373
+ else byLevel.set(key, [c])
374
+ }
375
+ for (const pieces of byLevel.values()) {
376
+ const ranked = pieces
377
+ .map((c) => ({ c, length: polylineLength(c.points) }))
378
+ .filter((p) => p.length >= fontSize * 6)
379
+ .sort((a, b) => b.length - a.length)
380
+ const first = ranked[0]
381
+ if (!first) continue
382
+ const text =
383
+ first.c.absFt !== null
384
+ ? first.c.absFt.toFixed(stepFt < 1 ? 1 : 0)
385
+ : `${first.c.levelM >= 0 ? '+' : ''}${(first.c.levelM / FT).toFixed(1)}'`
386
+ // two labels on a long line, one otherwise; each at the first clear
387
+ // station near its mark (never on the house, a porch or the paving),
388
+ // the two kept apart
389
+ const wanted = first.length >= fontSize * 40 ? [0.28, 0.72] : [0.5]
390
+ const placed: Pt[] = []
391
+ for (const [k, t] of wanted.entries()) {
392
+ const candidates = [t, t - 0.1, t + 0.1, t - 0.2, t + 0.2, 0.5, 0.35, 0.65, 0.15, 0.85]
393
+ const pieceList = k === 1 || ranked.length === 1 ? [first] : ranked.slice(0, 3)
394
+ let done = false
395
+ for (const piece of pieceList) {
396
+ for (const u of candidates) {
397
+ if (u <= 0.02 || u >= 0.98) continue
398
+ const { at, angle } = alongPolyline(piece.c.points, u)
399
+ if (!clear(at) || placed.some((p) => len(sub(p, at)) < fontSize * 12)) continue
400
+ out.push(
401
+ runLabel(at, angle, text, fontSize, CONTOUR_INK, { sitePlan: 'contour-label' }, 500),
402
+ )
403
+ placed.push(at)
404
+ done = true
405
+ break
406
+ }
407
+ if (done) break
408
+ }
409
+ }
410
+ }
411
+ return out
412
+ }
413
+
414
+ /* ========================================================== flatwork */
415
+
416
+ type FlatworkDims = {
417
+ /** Level-local points across the driveway's throat at the garage door, and across its mouth at the street. */
418
+ throat?: [number, number][]
419
+ mouth?: [number, number][]
420
+ widthM?: number
421
+ }
422
+
423
+ /**
424
+ * The driveway and walks: a concrete tone under a fine edge, the driveway
425
+ * dimensioned across its throat (the garage door) and its flared mouth at
426
+ * the property line, the walk's width printed along it.
427
+ */
428
+ export function flatworkPrimitives(
429
+ scene: SceneSnapshot,
430
+ building: BuildingNode | null,
431
+ parts: readonly OutdoorPart[],
432
+ fontSize: number,
433
+ ): FloorplanGeometry[] {
434
+ const toSite = siteFrame(building)
435
+ const out: FloorplanGeometry[] = []
436
+ for (const part of parts) {
437
+ if (part.kind !== 'driveway' && part.kind !== 'walk') continue
438
+ out.push({
439
+ kind: 'polygon',
440
+ points: part.ring,
441
+ fill: '#e5e7eb',
442
+ fillOpacity: 0.95,
443
+ stroke: INK,
444
+ strokeWidth: 0.03,
445
+ metadata: { sitePlan: part.kind },
446
+ })
447
+ out.push({ kind: 'hatch', points: part.ring, color: '#9ca3af', opacity: 0.35 })
448
+ const node = scene.nodes[part.id as AnyNodeId] as
449
+ | (AnyNode & { metadata?: Record<string, unknown> })
450
+ | undefined
451
+ const dims = (node?.metadata?.flatworkDims ?? {}) as FlatworkDims
452
+ const c = polygonCentroid(part.ring)
453
+ if (part.kind === 'driveway') {
454
+ const lines = ['CONC. DRIVEWAY']
455
+ out.push(
456
+ runLabel(c, 0, lines[0] as string, fontSize, INK, { sitePlan: 'driveway-label' }, 700),
457
+ )
458
+ for (const [key, across] of [
459
+ ['throat', dims.throat],
460
+ ['mouth', dims.mouth],
461
+ ] as const) {
462
+ if (across?.length !== 2) continue
463
+ const a = toSite(across[0]![0], across[0]![1])
464
+ const b = toSite(across[1]![0], across[1]![1])
465
+ const d = unit(sub(b, a))
466
+ // the dimension line sits just inside the driveway, the text toward its middle
467
+ const inward = key === 'throat' ? unit(sub(c, a)) : unit(sub(c, a))
468
+ const n: Pt = [-d[1], d[0]]
469
+ const sign = n[0] * inward[0] + n[1] * inward[1] >= 0 ? 1 : -1
470
+ out.push({
471
+ kind: 'dimension',
472
+ start: a,
473
+ end: b,
474
+ offsetNormal: [n[0] * sign, n[1] * sign],
475
+ offsetDistance: 0.6,
476
+ extensionOvershoot: 0.25,
477
+ stroke: '#334155',
478
+ terminator: 'architectural-tick',
479
+ text: formatFeetInches(len(sub(b, a))),
480
+ metadata: { sitePlan: `driveway-${key}` },
481
+ } as FloorplanGeometry)
482
+ }
483
+ } else {
484
+ // the walk's width along its longest leg
485
+ let best: { at: Pt; angle: number; l: number } | null = null
486
+ const ring = part.ring
487
+ for (let i = 0; i < ring.length; i++) {
488
+ const a = ring[i] as Pt
489
+ const b = ring[(i + 1) % ring.length] as Pt
490
+ const l = len(sub(b, a))
491
+ if (!best || l > best.l)
492
+ best = {
493
+ at: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
494
+ angle: Math.atan2(b[1] - a[1], b[0] - a[0]),
495
+ l,
496
+ }
497
+ }
498
+ const width = typeof dims.widthM === 'number' ? dims.widthM : null
499
+ const text = width ? `${formatFeetInches(width)} CONC. WALK` : 'CONC. WALK'
500
+ if (best) {
501
+ // on the walk's centre line, along its longest leg
502
+ const inward = unit(sub(c, best.at))
503
+ const d = unit([Math.cos(best.angle), Math.sin(best.angle)])
504
+ const n: Pt = [-d[1], d[0]]
505
+ const sign = n[0] * inward[0] + n[1] * inward[1] >= 0 ? 1 : -1
506
+ const half = (width ?? 1.2) / 2
507
+ out.push(
508
+ runLabel(
509
+ add(best.at, mul(n, sign * half)),
510
+ best.angle,
511
+ text,
512
+ fontSize * 0.8,
513
+ INK,
514
+ { sitePlan: 'walk-label' },
515
+ 600,
516
+ ),
517
+ )
518
+ }
519
+ }
520
+ }
521
+ return out
522
+ }
523
+
524
+ /* ========================================================= utilities */
525
+
526
+ export type ServiceRole = SitePlanServiceRole
527
+
528
+ type ServicePoint = { role: SitePlanServicePoint['role']; at: Pt; normal: Pt | null }
529
+
530
+ /** The registered service points of the building's ground storey, in site metres. */
531
+ export function servicePoints(
532
+ scene: SceneSnapshot,
533
+ level: LevelNode | null,
534
+ building: BuildingNode | null,
535
+ outline: readonly Pt[][],
536
+ ): ServicePoint[] {
537
+ if (!level) return []
538
+ const toSite = siteFrame(building)
539
+ const out: ServicePoint[] = []
540
+ const byRole = new Map<ServicePoint['role'], SitePlanServicePoint>()
541
+ for (const points of sitePlanServiceAnswers((services) => services.points?.(scene, level.id))) {
542
+ for (const point of points) if (!byRole.has(point.role)) byRole.set(point.role, point)
543
+ }
544
+ const place = (node: SitePlanServicePoint): { at: Pt; normal: Pt | null } | null => {
545
+ const pos = node.position
546
+ const placed = Math.abs(pos[0] ?? 0) > 1e-6 || Math.abs(pos[2] ?? 0) > 1e-6
547
+ if (!placed && typeof node.wallId === 'string') {
548
+ const wall = scene.nodes[node.wallId] as
549
+ | { start?: number[]; end?: number[]; thickness?: number }
550
+ | undefined
551
+ if (!wall?.start || !wall.end) return null
552
+ const t = typeof node.wallT === 'number' ? node.wallT : 0.5
553
+ const p = toSite(
554
+ (wall.start[0] ?? 0) + ((wall.end[0] ?? 0) - (wall.start[0] ?? 0)) * t,
555
+ (wall.start[1] ?? 0) + ((wall.end[1] ?? 0) - (wall.start[1] ?? 0)) * t,
556
+ )
557
+ const a = toSite(wall.start[0] ?? 0, wall.start[1] ?? 0)
558
+ const b = toSite(wall.end[0] ?? 0, wall.end[1] ?? 0)
559
+ const d = unit(sub(b, a))
560
+ let n: Pt = [-d[1], d[0]]
561
+ const probe = add(p, mul(n, 0.4))
562
+ if (outline.some((ring) => pointInPolygon(ring, probe[0], probe[1]))) n = [-n[0], -n[1]]
563
+ return { at: add(p, mul(n, (wall.thickness ?? 0.15) / 2)), normal: n }
564
+ }
565
+ // a floor-placed point on (or just off) a wall — the sewer exit — leaves
566
+ // square from the nearest face; one standing clear of the house has no face
567
+ const at = toSite(pos[0] ?? 0, pos[2] ?? 0)
568
+ let near: { d: number; foot: Pt; n: Pt } | null = null
569
+ for (const ring of outline) {
570
+ for (let i = 0; i < ring.length; i++) {
571
+ const foot = footOn(at, ring[i] as Pt, ring[(i + 1) % ring.length] as Pt)
572
+ const d = len(sub(at, foot))
573
+ if (!near || d < near.d) near = { d, foot, n: outwardNormal(ring, i) }
574
+ }
575
+ }
576
+ return near && near.d <= 0.6
577
+ ? { at: add(near.foot, mul(near.n, 0.02)), normal: near.n }
578
+ : { at, normal: null }
579
+ }
580
+ for (const role of ['electric', 'water', 'sewer', 'ac', 'pole'] as const) {
581
+ const node = byRole.get(role)
582
+ if (!node) continue
583
+ const spot = place(node)
584
+ if (spot) out.push({ role, ...spot })
585
+ }
586
+ return out
587
+ }
588
+
589
+ /** The storey's electric service entrance: a registered plugin's choice, else the building's, else overhead. */
590
+ export function serviceEntranceOf(
591
+ scene: SceneSnapshot,
592
+ level: LevelNode | null,
593
+ building: BuildingNode | null,
594
+ ): {
595
+ kind: 'overhead' | 'underground'
596
+ source: 'plugin' | 'building' | 'default'
597
+ } {
598
+ if (level) {
599
+ const [kind] = sitePlanServiceAnswers((services) => services.entrance?.(scene, level.id))
600
+ if (kind === 'overhead' || kind === 'underground') return { kind, source: 'plugin' }
601
+ }
602
+ const services = (building?.metadata as { services?: { serviceEntrance?: unknown } } | undefined)
603
+ ?.services
604
+ if (services?.serviceEntrance === 'overhead' || services?.serviceEntrance === 'underground')
605
+ return { kind: services.serviceEntrance, source: 'building' }
606
+ return { kind: 'overhead', source: 'default' }
607
+ }
608
+
609
+ const SERVICE_STYLE: Record<ServiceRole, { color: string; dash: string }> = {
610
+ electric: { color: '#dc2626', dash: '0.6 0.25' },
611
+ water: { color: '#2563eb', dash: '0.45 0.2' },
612
+ sewer: { color: '#65a30d', dash: '0.9 0.25' },
613
+ }
614
+
615
+ /**
616
+ * A schematic route from a service point to the street: out square from
617
+ * the wall, then to the nearest point of a street edge — around the house
618
+ * (a dogleg past its end) when the straight run would cross it.
619
+ */
620
+ export function serviceRoute(
621
+ from: Pt,
622
+ normal: Pt | null,
623
+ lot: readonly Pt[],
624
+ streetEdges: readonly number[],
625
+ outline: readonly Pt[][],
626
+ ): Pt[] | null {
627
+ const start = normal ? add(from, mul(normal, 0.9)) : from
628
+ const candidates: Pt[][] = []
629
+ const hull = outline.flat()
630
+ const b = hull.length > 0 ? polygonBounds(hull) : null
631
+ for (const i of streetEdges) {
632
+ const a = lot[i]
633
+ const c = lot[(i + 1) % lot.length]
634
+ if (!a || !c) continue
635
+ const direct = footOn(start, a, c)
636
+ if (!crossesRings(start, direct, outline)) {
637
+ candidates.push(normal ? [from, start, direct] : [from, direct])
638
+ continue
639
+ }
640
+ // a dogleg past either end of the house, along the wall's run
641
+ if (!b) continue
642
+ const run: Pt = normal ? [-normal[1], normal[0]] : unit(sub(c, a))
643
+ for (const s of [1, -1]) {
644
+ const r = mul(run, s)
645
+ // how far along `r` the house reaches past the start, plus a yard
646
+ let reach = 0
647
+ for (const p of hull)
648
+ reach = Math.max(reach, (p[0] - start[0]) * r[0] + (p[1] - start[1]) * r[1])
649
+ const corner = add(start, mul(r, reach + 1.2))
650
+ const end = footOn(corner, a, c)
651
+ if (crossesRings(start, corner, outline) || crossesRings(corner, end, outline)) continue
652
+ candidates.push(normal ? [from, start, corner, end] : [from, corner, end])
653
+ }
654
+ }
655
+ if (candidates.length === 0) return null
656
+ candidates.sort((p, q) => polylineLength(p) - polylineLength(q))
657
+ return candidates[0] as Pt[]
658
+ }
659
+
660
+ /**
661
+ * The utility services a plugin located, drawn schematically to the street:
662
+ * the electric service from the meter (to the utility pole overhead, or
663
+ * underground to the street), the water service from its entry to a meter
664
+ * box at the property line, the sewer lateral from its exit to a cleanout at
665
+ * the property line, and the A/C condenser on its pad. Sizes are the common
666
+ * residential ones and say "verify"; the utility sets the real ones.
667
+ */
668
+ export function servicePrimitives(args: {
669
+ scene: SceneSnapshot
670
+ level: LevelNode | null
671
+ building: BuildingNode | null
672
+ lot: readonly Pt[]
673
+ streetEdges: readonly number[]
674
+ outline: readonly Pt[][]
675
+ fontSize: number
676
+ }): {
677
+ primitives: FloorplanGeometry[]
678
+ drawn: ServiceRole[]
679
+ entrance: 'overhead' | 'underground'
680
+ /** The condenser pad's ring, for the marks that should keep off it. */
681
+ pad: Pt[] | null
682
+ } {
683
+ const { scene, level, building, lot, streetEdges, outline, fontSize } = args
684
+ const points = servicePoints(scene, level, building, outline)
685
+ const entrance = serviceEntranceOf(scene, level, building)
686
+ const out: FloorplanGeometry[] = []
687
+ const drawn: ServiceRole[] = []
688
+ // a utility a plugin already runs as its own line is not drawn twice
689
+ const drawnRuns = new Set(
690
+ sitePlanServiceAnswers((services) => services.drawnRuns?.(scene)).flat(),
691
+ )
692
+ const pole = points.find((p) => p.role === 'pole')
693
+
694
+ const labels: Record<ServiceRole, string> = {
695
+ electric: `ELECTRIC SERVICE (${entrance.kind.toUpperCase()})`,
696
+ water: 'WATER SERVICE 3/4" (VERIFY)',
697
+ sewer: 'SEWER LATERAL 4" (VERIFY)',
698
+ }
699
+ for (const role of ['electric', 'water', 'sewer'] as const) {
700
+ const point = points.find((p) => p.role === role)
701
+ if (!point || drawnRuns.has(role)) continue
702
+ const route =
703
+ role === 'electric' && pole
704
+ ? entrance.kind === 'overhead'
705
+ ? [point.at, pole.at]
706
+ : (serviceRoute(point.at, point.normal, lot, streetEdges, outline) ?? [point.at, pole.at])
707
+ : serviceRoute(point.at, point.normal, lot, streetEdges, outline)
708
+ if (!route || route.length < 2) continue
709
+ drawn.push(role)
710
+ const style = SERVICE_STYLE[role]
711
+ out.push({
712
+ kind: 'polyline',
713
+ points: route,
714
+ fill: 'none',
715
+ stroke: style.color,
716
+ strokeWidth: 0.06,
717
+ strokeDasharray: style.dash,
718
+ metadata: { sitePlan: 'service', role },
719
+ })
720
+ // the label on the longest run
721
+ let best = { i: 1, l: 0 }
722
+ for (let i = 1; i < route.length; i++) {
723
+ const l = len(sub(route[i] as Pt, route[i - 1] as Pt))
724
+ if (l > best.l) best = { i, l }
725
+ }
726
+ const a = route[best.i - 1] as Pt
727
+ const b = route[best.i] as Pt
728
+ const angle = Math.atan2(b[1] - a[1], b[0] - a[0])
729
+ const n = unit([-(b[1] - a[1]), b[0] - a[0]])
730
+ out.push(
731
+ runLabel(
732
+ add(mul(add(a, b), 0.5), mul(n, fontSize * 0.75)),
733
+ angle,
734
+ labels[role],
735
+ fontSize,
736
+ style.color,
737
+ { sitePlan: 'service-label', role },
738
+ ),
739
+ )
740
+ // the ends: the meter at the house (electric), the meter box / cleanout at the line
741
+ const end = route[route.length - 1] as Pt
742
+ const tag = (at: Pt, text: string, circle: boolean) => {
743
+ const r = fontSize * 0.75
744
+ out.push(
745
+ circle
746
+ ? {
747
+ kind: 'circle',
748
+ cx: at[0],
749
+ cy: at[1],
750
+ r,
751
+ fill: HALO,
752
+ stroke: style.color,
753
+ strokeWidth: 0.04,
754
+ }
755
+ : {
756
+ kind: 'rect',
757
+ x: at[0] - r,
758
+ y: at[1] - r * 0.75,
759
+ width: r * 2,
760
+ height: r * 1.5,
761
+ fill: HALO,
762
+ stroke: style.color,
763
+ strokeWidth: 0.04,
764
+ },
765
+ )
766
+ out.push({
767
+ kind: 'text',
768
+ x: at[0],
769
+ y: at[1],
770
+ text,
771
+ fontSize: fontSize * 0.7,
772
+ fill: style.color,
773
+ fontWeight: 700,
774
+ textAnchor: 'middle',
775
+ dominantBaseline: 'middle',
776
+ upright: true,
777
+ })
778
+ }
779
+ if (role === 'electric') tag(point.at, 'EM', false)
780
+ if (role === 'water') tag(end, 'WM', false)
781
+ if (role === 'sewer') {
782
+ tag(point.at, 'CO', true)
783
+ tag(end, 'CO', true)
784
+ }
785
+ }
786
+ if (pole && drawn.includes('electric')) {
787
+ out.push({
788
+ kind: 'circle',
789
+ cx: pole.at[0],
790
+ cy: pole.at[1],
791
+ r: 0.25,
792
+ fill: INK,
793
+ stroke: INK,
794
+ strokeWidth: 0.02,
795
+ })
796
+ out.push({
797
+ kind: 'text',
798
+ x: pole.at[0],
799
+ y: pole.at[1] - fontSize * 1.1,
800
+ text: entrance.kind === 'overhead' ? 'UTILITY POLE (VERIFY)' : 'PAD TRANSFORMER (VERIFY)',
801
+ fontSize: fontSize * 0.8,
802
+ fill: INK_SOFT,
803
+ fontWeight: 600,
804
+ textAnchor: 'middle',
805
+ dominantBaseline: 'middle',
806
+ upright: true,
807
+ stroke: HALO,
808
+ strokeWidth: fontSize * 0.2,
809
+ paintOrder: 'stroke',
810
+ })
811
+ }
812
+ const ac = points.find((p) => p.role === 'ac')
813
+ let pad: Pt[] | null = null
814
+ if (ac) {
815
+ const s = 0.9144 // a 36 in pad
816
+ pad = [
817
+ [ac.at[0] - s / 2, ac.at[1] - s / 2],
818
+ [ac.at[0] + s / 2, ac.at[1] - s / 2],
819
+ [ac.at[0] + s / 2, ac.at[1] + s / 2],
820
+ [ac.at[0] - s / 2, ac.at[1] + s / 2],
821
+ ]
822
+ out.push({
823
+ kind: 'rect',
824
+ x: ac.at[0] - s / 2,
825
+ y: ac.at[1] - s / 2,
826
+ width: s,
827
+ height: s,
828
+ fill: '#f3f4f6',
829
+ stroke: INK,
830
+ strokeWidth: 0.03,
831
+ metadata: { sitePlan: 'ac-pad' },
832
+ })
833
+ out.push({
834
+ kind: 'line',
835
+ x1: ac.at[0] - s / 2,
836
+ y1: ac.at[1] - s / 2,
837
+ x2: ac.at[0] + s / 2,
838
+ y2: ac.at[1] + s / 2,
839
+ stroke: INK,
840
+ strokeWidth: 0.015,
841
+ })
842
+ out.push({
843
+ kind: 'line',
844
+ x1: ac.at[0] - s / 2,
845
+ y1: ac.at[1] + s / 2,
846
+ x2: ac.at[0] + s / 2,
847
+ y2: ac.at[1] - s / 2,
848
+ stroke: INK,
849
+ strokeWidth: 0.015,
850
+ })
851
+ // the label away from the house
852
+ const c = outline[0] ? polygonCentroid(outline[0]) : ac.at
853
+ const away = unit(sub(ac.at, c))
854
+ out.push({
855
+ kind: 'text',
856
+ x: ac.at[0] + away[0] * (s * 0.6 + fontSize * 2.6),
857
+ y: ac.at[1] + away[1] * (s * 0.6 + fontSize * 1.2),
858
+ text: 'A/C CONDENSER ON 36" PAD',
859
+ fontSize: fontSize * 0.8,
860
+ fill: INK,
861
+ fontWeight: 600,
862
+ textAnchor: 'middle',
863
+ dominantBaseline: 'middle',
864
+ upright: true,
865
+ stroke: HALO,
866
+ strokeWidth: fontSize * 0.2,
867
+ paintOrder: 'stroke',
868
+ metadata: { sitePlan: 'ac-label' },
869
+ })
870
+ }
871
+ return { primitives: out, drawn, entrance: entrance.kind, pad }
872
+ }
873
+
874
+ /* ========================================================== drainage */
875
+
876
+ /**
877
+ * Drainage arrows off every face of the house: the finish grade falls away
878
+ * from the foundation — 6 in in the first 10 ft (FBC-R / IRC R401.3) — so
879
+ * each arrow runs from the wall out 10 ft. One arrow carries the rule.
880
+ */
881
+ export function drainagePrimitives(
882
+ outline: readonly Pt[][],
883
+ fontSize: number,
884
+ obstacles: readonly Pt[][] = [],
885
+ ): FloorplanGeometry[] {
886
+ const out: FloorplanGeometry[] = []
887
+ const raw = [...outline].sort((a, b) => polygonArea(b) - polygonArea(a))[0]
888
+ if (!raw || raw.length < 3) return out
889
+ // one arrow per face: a vertex in the middle of a straight run is not a corner
890
+ const ring = raw.filter((p, i) => {
891
+ const prev = raw[(i - 1 + raw.length) % raw.length] as Pt
892
+ const next = raw[(i + 1) % raw.length] as Pt
893
+ const d1 = unit(sub(p, prev))
894
+ const d2 = unit(sub(next, p))
895
+ return Math.abs(d1[0] * d2[1] - d1[1] * d2[0]) > 0.05
896
+ })
897
+ if (ring.length < 3) return out
898
+ const tenFt = 10 * FT
899
+ // the rule rides the arrow off the longest face
900
+ let longest = -1
901
+ let longestLen = 0
902
+ for (let i = 0; i < ring.length; i++) {
903
+ const l = len(sub(ring[(i + 1) % ring.length] as Pt, ring[i] as Pt))
904
+ if (l > longestLen) {
905
+ longest = i
906
+ longestLen = l
907
+ }
908
+ }
909
+ for (let i = 0; i < ring.length; i++) {
910
+ const a = ring[i] as Pt
911
+ const b = ring[(i + 1) % ring.length] as Pt
912
+ if (len(sub(b, a)) < 4) continue
913
+ const n = outwardNormal(ring, i)
914
+ // off the face where it crosses no porch, deck or paving (and clear of
915
+ // the yard dimension off the face's middle)
916
+ const run = (t: number) => {
917
+ const base = add(a, mul(sub(b, a), t))
918
+ return { tail: add(base, mul(n, 0.5)), tip: add(base, mul(n, tenFt)) }
919
+ }
920
+ const free = (r: { tail: Pt; tip: Pt }) =>
921
+ !crossesRings(r.tail, r.tip, obstacles) &&
922
+ !obstacles.some((o) => pointInPolygon(o, r.tail[0], r.tail[1]))
923
+ const spot = [0.36, 0.64, 0.22, 0.78, 0.12, 0.88].map(run).find(free)
924
+ if (!spot) continue
925
+ const { tail, tip } = spot
926
+ const d = unit(sub(tip, tail))
927
+ const side: Pt = [-d[1], d[0]]
928
+ const head = 0.45
929
+ out.push({
930
+ kind: 'line',
931
+ x1: tail[0],
932
+ y1: tail[1],
933
+ x2: tip[0],
934
+ y2: tip[1],
935
+ stroke: '#0e7490',
936
+ strokeWidth: 0.04,
937
+ metadata: { sitePlan: 'drainage' },
938
+ })
939
+ out.push({
940
+ kind: 'polygon',
941
+ points: [
942
+ tip,
943
+ add(sub(tip, mul(d, head)), mul(side, head * 0.4)),
944
+ add(sub(tip, mul(d, head)), mul(side, -head * 0.4)),
945
+ ],
946
+ fill: '#0e7490',
947
+ stroke: '#0e7490',
948
+ strokeWidth: 0.01,
949
+ metadata: { sitePlan: 'drainage' },
950
+ })
951
+ if (i === longest) {
952
+ out.push(
953
+ runLabel(
954
+ add(mul(add(tail, tip), 0.5), mul(side, fontSize * 0.75)),
955
+ Math.atan2(d[1], d[0]),
956
+ '6" MIN. FALL IN 10\'',
957
+ fontSize * 0.75,
958
+ '#0e7490',
959
+ { sitePlan: 'drainage-label' },
960
+ ),
961
+ )
962
+ }
963
+ }
964
+ return out
965
+ }
966
+
967
+ /* ========================================================= elevations */
968
+
969
+ /** "174.25'" from an absolute elevation in feet, or "+0.42'" relative to the site datum. */
970
+ export function formatElevation(relM: number, datumFt: number | null): string {
971
+ const ft = relM / FT
972
+ return datumFt !== null
973
+ ? `${(datumFt + ft).toFixed(2)}'`
974
+ : `${ft >= 0 ? '+' : ''}${ft.toFixed(2)}'`
975
+ }
976
+
977
+ /**
978
+ * Spot grades: an × and its elevation at each lot corner and just off each
979
+ * corner of the house (the finish grade the pad grading left), read from
980
+ * the site's terrain.
981
+ */
982
+ export function spotElevationPrimitives(args: {
983
+ field: TerrainField
984
+ datumFt: number | null
985
+ lot: readonly Pt[]
986
+ outline: readonly Pt[][]
987
+ fontSize: number
988
+ }): FloorplanGeometry[] {
989
+ const { field, datumFt, lot, outline, fontSize } = args
990
+ const out: FloorplanGeometry[] = []
991
+ const spot = (at: Pt, labelAt: Pt, kind: string) => {
992
+ const s = fontSize * 0.35
993
+ out.push({
994
+ kind: 'line',
995
+ x1: at[0] - s,
996
+ y1: at[1] - s,
997
+ x2: at[0] + s,
998
+ y2: at[1] + s,
999
+ stroke: INK,
1000
+ strokeWidth: 0.02,
1001
+ })
1002
+ out.push({
1003
+ kind: 'line',
1004
+ x1: at[0] - s,
1005
+ y1: at[1] + s,
1006
+ x2: at[0] + s,
1007
+ y2: at[1] - s,
1008
+ stroke: INK,
1009
+ strokeWidth: 0.02,
1010
+ })
1011
+ out.push({
1012
+ kind: 'text',
1013
+ x: labelAt[0],
1014
+ y: labelAt[1],
1015
+ text: formatElevation(heightAt(field, at[0], at[1]), datumFt),
1016
+ fontSize,
1017
+ fill: INK,
1018
+ fontWeight: 500,
1019
+ fontFamily: 'Helvetica, Arial, sans-serif',
1020
+ textAnchor: 'middle',
1021
+ dominantBaseline: 'middle',
1022
+ upright: true,
1023
+ stroke: HALO,
1024
+ strokeWidth: fontSize * 0.25,
1025
+ paintOrder: 'stroke',
1026
+ metadata: { sitePlan: 'spot-elevation', at: kind },
1027
+ })
1028
+ }
1029
+ // lot corners: the × just inside the corner, its number further in
1030
+ const lc = lot.length >= 3 ? polygonCentroid(lot) : null
1031
+ if (lc) {
1032
+ for (const p of lot) {
1033
+ const inward = unit(sub(lc, p))
1034
+ spot(add(p, mul(inward, 0.9)), add(p, mul(inward, 0.9 + fontSize * 2.2)), 'lot')
1035
+ }
1036
+ }
1037
+ // house corners: out along the corner's bisector
1038
+ const ring = [...outline].sort((a, b) => polygonArea(b) - polygonArea(a))[0]
1039
+ if (ring && ring.length >= 3) {
1040
+ for (let i = 0; i < ring.length; i++) {
1041
+ const prev = ring[(i - 1 + ring.length) % ring.length] as Pt
1042
+ const p = ring[i] as Pt
1043
+ const next = ring[(i + 1) % ring.length] as Pt
1044
+ const d1 = unit(sub(p, prev))
1045
+ const d2 = unit(sub(next, p))
1046
+ // a collinear vertex is not a corner
1047
+ if (Math.abs(d1[0] * d2[1] - d1[1] * d2[0]) < 0.2) continue
1048
+ const n1 = outwardNormal(ring, (i - 1 + ring.length) % ring.length)
1049
+ const n2 = outwardNormal(ring, i)
1050
+ const bis = unit(add(n1, n2))
1051
+ // a re-entrant corner's bisector points into the house: skip it
1052
+ const probe = add(p, mul(bis, 0.3))
1053
+ if (outline.some((r) => pointInPolygon(r, probe[0], probe[1]))) continue
1054
+ const at = add(p, mul(bis, 0.8))
1055
+ spot(at, add(at, mul(bis, fontSize * 2.4)), 'house')
1056
+ }
1057
+ }
1058
+ return out
1059
+ }
1060
+
1061
+ /** The storey slab's top — the finish floor — level-local metres, and the garage pad's. */
1062
+ export function floorTops(
1063
+ scene: SceneSnapshot,
1064
+ level: LevelNode | null,
1065
+ ): { floor: number | null; garage: number | null } {
1066
+ let floor: number | null = null
1067
+ let garage: number | null = null
1068
+ for (const childId of level?.children ?? []) {
1069
+ const n = scene.nodes[childId as AnyNodeId] as
1070
+ | (AnyNode & { elevation?: number; metadata?: { floor?: unknown } })
1071
+ | undefined
1072
+ if (n?.type !== 'slab') continue
1073
+ // the driveway and walks are paving at grade, never the finish floor
1074
+ if (flatworkKindOf(n)) continue
1075
+ const tag = n.metadata?.floor
1076
+ const top = typeof n.elevation === 'number' ? n.elevation : 0.05
1077
+ if (tag === 'garage-slab-at-grade') garage = garage === null ? top : Math.min(garage, top)
1078
+ else if (tag === 'slab-on-grade' || tag === 'platform' || tag === undefined) {
1079
+ if (tag === undefined && (n.metadata as { porch?: unknown } | undefined)?.porch) continue
1080
+ floor = floor === null ? top : Math.max(floor, top)
1081
+ }
1082
+ }
1083
+ return { floor, garage }
1084
+ }