@pascal-app/editor 1.0.3 → 1.0.4

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