@pascal-app/editor 1.0.1 → 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 (498) hide show
  1. package/package.json +16 -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 +221 -128
  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 +37 -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 +536 -487
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/grid.tsx +31 -7
  29. package/src/components/editor/group-actions.ts +59 -11
  30. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  31. package/src/components/editor/group-move-3d.ts +4 -6
  32. package/src/components/editor/group-rotate-handle.tsx +81 -35
  33. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  34. package/src/components/editor/group-transform-shared.test.ts +126 -1
  35. package/src/components/editor/group-transform-shared.ts +79 -73
  36. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  37. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  38. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  39. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  40. package/src/components/editor/handles/use-handle-drag.ts +272 -131
  41. package/src/components/editor/index.tsx +316 -176
  42. package/src/components/editor/join-walls-pill.tsx +44 -0
  43. package/src/components/editor/node-action-menu.tsx +70 -70
  44. package/src/components/editor/node-arrow-handles.tsx +167 -14
  45. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  46. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  47. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  48. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  49. package/src/components/editor/registry-action-contributions.tsx +132 -0
  50. package/src/components/editor/room-controls.tsx +847 -0
  51. package/src/components/editor/room-floor-highlight.tsx +128 -0
  52. package/src/components/editor/room-handles-2d.tsx +354 -0
  53. package/src/components/editor/room-handles.tsx +675 -0
  54. package/src/components/editor/room-highlight.tsx +225 -0
  55. package/src/components/editor/room-labels.test.ts +357 -0
  56. package/src/components/editor/room-labels.tsx +990 -0
  57. package/src/components/editor/room-transform-ghost.tsx +256 -0
  58. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  59. package/src/components/editor/selection-manager.tsx +451 -317
  60. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  61. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  62. package/src/components/editor/snapshot-capture.test.ts +52 -4
  63. package/src/components/editor/snapshot-capture.ts +30 -5
  64. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  65. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  66. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  67. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  68. package/src/components/editor/views/host-view.tsx +61 -0
  69. package/src/components/editor/views/plugins-view.tsx +670 -0
  70. package/src/components/editor/views/scene-region.tsx +59 -0
  71. package/src/components/editor/views/use-editor-views.ts +57 -0
  72. package/src/components/editor/views/view-bar.tsx +220 -0
  73. package/src/components/editor/wall-justification.test.ts +140 -0
  74. package/src/components/editor/wall-move-side-handles.tsx +114 -163
  75. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  76. package/src/components/editor/wall-push-arrow.tsx +92 -0
  77. package/src/components/editor/wall-region-handles.tsx +509 -0
  78. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  79. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  80. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  81. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  82. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  83. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  84. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  85. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +242 -126
  86. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  87. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  88. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  89. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +166 -0
  90. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  91. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +87 -8
  92. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +355 -245
  93. package/src/components/editor-2d/svg-paths.ts +12 -15
  94. package/src/components/procedural-items/inspector.tsx +40 -15
  95. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  96. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  97. package/src/components/systems/zone/zone-system.test.ts +47 -0
  98. package/src/components/systems/zone/zone-system.tsx +31 -66
  99. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  100. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  101. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  102. package/src/components/tools/item/placement-history.test.tsx +474 -0
  103. package/src/components/tools/item/placement-strategies.ts +63 -47
  104. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  105. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  106. package/src/components/tools/item/use-draft-node.ts +207 -97
  107. package/src/components/tools/item/use-placement-coordinator.tsx +251 -111
  108. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  109. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  110. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  111. package/src/components/tools/registry/item-surface-move.ts +46 -24
  112. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  113. package/src/components/tools/registry/move-registry-node-tool.tsx +26 -10
  114. package/src/components/tools/select/box-select-tool.tsx +2 -12
  115. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  116. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  117. package/src/components/tools/select/select-candidates.test.ts +37 -4
  118. package/src/components/tools/select/select-candidates.ts +15 -11
  119. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  120. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  121. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  122. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  123. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  124. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  125. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  126. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  127. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  128. package/src/components/tools/stair/stair-defaults.ts +0 -3
  129. package/src/components/tools/stair/stair-tool.tsx +90 -42
  130. package/src/components/tools/tool-manager.tsx +82 -23
  131. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  132. package/src/components/tools/zone/zone-tool.tsx +4 -12
  133. package/src/components/ui/action-menu/action-button.tsx +3 -0
  134. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  135. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  136. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  137. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  138. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  139. package/src/components/ui/command-palette/index.tsx +5 -24
  140. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  141. package/src/components/ui/controls/material-paint-panel.tsx +101 -91
  142. package/src/components/ui/controls/material-picker.tsx +8 -67
  143. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  144. package/src/components/ui/controls/select-control.tsx +65 -0
  145. package/src/components/ui/controls/slider-control.tsx +76 -31
  146. package/src/components/ui/floating-level-selector.tsx +9 -14
  147. package/src/components/ui/helpers/building-helper.tsx +4 -1
  148. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  149. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  150. package/src/components/ui/helpers/helper-manager.tsx +348 -19
  151. package/src/components/ui/helpers/item-helper.tsx +8 -0
  152. package/src/components/ui/helpers/registered-tool-helper.tsx +10 -33
  153. package/src/components/ui/icon-ref.tsx +45 -1
  154. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  155. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  156. package/src/components/ui/node-icon-image.tsx +16 -0
  157. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  158. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  159. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  160. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  161. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  162. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  163. package/src/components/ui/panels/multi-field-value.ts +6 -3
  164. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  165. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  166. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  167. package/src/components/ui/panels/node-display.ts +8 -3
  168. package/src/components/ui/panels/panel-manager.tsx +103 -68
  169. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  170. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  171. package/src/components/ui/panels/parametric-inspector.tsx +7 -19
  172. package/src/components/ui/panels/reference-panel.tsx +2 -3
  173. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  174. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  175. package/src/components/ui/panels/room-name-options.ts +47 -0
  176. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  177. package/src/components/ui/panels/room-panel.tsx +725 -0
  178. package/src/components/ui/panels/shape-choice.tsx +58 -0
  179. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  180. package/src/components/ui/primitives/color-dot.tsx +36 -24
  181. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  182. package/src/components/ui/primitives/sidebar.tsx +2 -0
  183. package/src/components/ui/right-stack.test.ts +29 -0
  184. package/src/components/ui/right-stack.tsx +145 -0
  185. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  186. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  187. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +615 -372
  188. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +7 -7
  189. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  190. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  191. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  192. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  193. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  194. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  195. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  196. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  197. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  198. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  199. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  200. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  201. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  202. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  203. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  204. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  205. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  206. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  207. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  208. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  209. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  210. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  211. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  212. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  213. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  214. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  215. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  216. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  217. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  218. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  219. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  220. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  221. package/src/components/viewer/floorplan-preview.tsx +6 -18
  222. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  223. package/src/components/viewer/viewer-rooms.tsx +175 -0
  224. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  225. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  226. package/src/components/viewer/viewer-stage.tsx +2 -2
  227. package/src/components/viewer-zone-system.tsx +9 -9
  228. package/src/hooks/use-auto-save.integration.test.tsx +311 -0
  229. package/src/hooks/use-auto-save.ts +55 -14
  230. package/src/hooks/use-ceiling-events.test.ts +1 -0
  231. package/src/hooks/use-ceiling-events.ts +2 -0
  232. package/src/hooks/use-grid-events.test.ts +2 -0
  233. package/src/hooks/use-grid-events.ts +16 -11
  234. package/src/hooks/use-keyboard.test.ts +182 -0
  235. package/src/hooks/use-keyboard.ts +122 -91
  236. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  237. package/src/hooks/use-open-wall-ends.ts +238 -0
  238. package/src/hooks/use-placement-notice.test.ts +48 -0
  239. package/src/hooks/use-placement-notice.ts +42 -0
  240. package/src/hooks/use-selected-room.ts +143 -0
  241. package/src/index.tsx +306 -39
  242. package/src/lib/catalog-panel-model.ts +39 -0
  243. package/src/lib/catalog-selection.test.ts +27 -0
  244. package/src/lib/catalog-selection.ts +8 -0
  245. package/src/lib/ceiling-edit-session.test.ts +321 -0
  246. package/src/lib/ceiling-edit-session.ts +206 -0
  247. package/src/lib/ceiling-region-session.test.ts +179 -0
  248. package/src/lib/contextual-help.test.ts +27 -1
  249. package/src/lib/contextual-help.ts +16 -9
  250. package/src/lib/continuation.test.ts +19 -1
  251. package/src/lib/continuation.ts +25 -4
  252. package/src/lib/door-interaction.ts +14 -0
  253. package/src/lib/editor-level-display.test.ts +96 -0
  254. package/src/lib/editor-level-display.ts +48 -0
  255. package/src/lib/editor-views.ts +144 -0
  256. package/src/lib/export-geometry-wait.test.ts +90 -0
  257. package/src/lib/floor-edit-session.ts +153 -0
  258. package/src/lib/floor-footprints.test.ts +563 -0
  259. package/src/lib/floor-footprints.ts +395 -0
  260. package/src/lib/floor-foundation-support.test.ts +283 -0
  261. package/src/lib/floor-opening-draft.ts +218 -0
  262. package/src/lib/floor-openings.test.ts +216 -0
  263. package/src/lib/floor-region-geometry.ts +191 -0
  264. package/src/lib/floor-region-session.test.ts +485 -0
  265. package/src/lib/floor-region-session.ts +589 -0
  266. package/src/lib/floor-region-snap.ts +101 -0
  267. package/src/lib/floor-step-hover.test.ts +32 -0
  268. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  269. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  270. package/src/lib/floorplan/dimension-drive.ts +479 -0
  271. package/src/lib/floorplan/floorplan-export.test.ts +15 -9
  272. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  273. package/src/lib/floorplan/floorplan-extension.ts +48 -0
  274. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  275. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  276. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  277. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  278. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  279. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  280. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  281. package/src/lib/floorplan/geometry.ts +13 -6
  282. package/src/lib/floorplan/index.ts +0 -9
  283. package/src/lib/floorplan/items.ts +5 -2
  284. package/src/lib/floorplan/marks.test.ts +213 -0
  285. package/src/lib/floorplan/marks.ts +238 -0
  286. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  287. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  288. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  289. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  290. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  291. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  292. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  293. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  294. package/src/lib/floorplan/schedules.test.ts +189 -0
  295. package/src/lib/floorplan/schedules.ts +339 -0
  296. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  297. package/src/lib/floorplan/sheet-export.ts +318 -0
  298. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  299. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  300. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  301. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  302. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  303. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  304. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  305. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  306. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  307. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  308. package/src/lib/floorplan/site-plan/index.ts +71 -0
  309. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  310. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  311. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  312. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  313. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  314. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  315. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  316. package/src/lib/floorplan/types.ts +1 -27
  317. package/src/lib/fresh-planar-placement.test.ts +36 -0
  318. package/src/lib/fresh-planar-placement.ts +191 -36
  319. package/src/lib/geometry-script/author.ts +152 -0
  320. package/src/lib/geometry-script/client.ts +152 -0
  321. package/src/lib/geometry-script/protocol.ts +12 -0
  322. package/src/lib/gesture-lifecycle.test.ts +595 -0
  323. package/src/lib/gesture-lifecycle.ts +290 -0
  324. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  325. package/src/lib/glb-export.test.ts +544 -16
  326. package/src/lib/glb-export.ts +256 -125
  327. package/src/lib/grid-frame.test.ts +19 -0
  328. package/src/lib/grid-frame.ts +18 -0
  329. package/src/lib/history.test.ts +6 -0
  330. package/src/lib/history.ts +7 -0
  331. package/src/lib/hud-title.ts +109 -0
  332. package/src/lib/hydration-gated-storage.ts +49 -0
  333. package/src/lib/ifc-export.test.ts +174 -0
  334. package/src/lib/ifc-export.ts +201 -0
  335. package/src/lib/ifc-parts.ts +15 -0
  336. package/src/lib/inspector-expanded.test.ts +44 -0
  337. package/src/lib/inspector-expanded.ts +36 -0
  338. package/src/lib/interaction/scope.ts +27 -1
  339. package/src/lib/keyboard-pan.test.ts +184 -0
  340. package/src/lib/keyboard-pan.ts +238 -0
  341. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  342. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  343. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  344. package/src/lib/lot/clean-ring.test.ts +186 -0
  345. package/src/lib/lot/clean-ring.ts +217 -0
  346. package/src/lib/lot/dossier.test.ts +202 -0
  347. package/src/lib/lot/dossier.ts +498 -0
  348. package/src/lib/lot/drop-in.test.ts +326 -0
  349. package/src/lib/lot/drop-in.ts +407 -0
  350. package/src/lib/lot/index.ts +43 -0
  351. package/src/lib/lot/lot-patch.test.ts +340 -0
  352. package/src/lib/lot/lot-patch.ts +394 -0
  353. package/src/lib/lot/parcel-provider.ts +43 -0
  354. package/src/lib/lot/terrain.test.ts +161 -0
  355. package/src/lib/lot/terrain.ts +266 -0
  356. package/src/lib/material-catalog-model.ts +74 -0
  357. package/src/lib/material-paint-panel-model.ts +80 -0
  358. package/src/lib/material-paint.ts +4 -0
  359. package/src/lib/mezzanine-actions.test.ts +342 -0
  360. package/src/lib/mezzanine-draft.test.ts +501 -0
  361. package/src/lib/mezzanine-draft.ts +278 -0
  362. package/src/lib/mezzanine-messages.ts +26 -0
  363. package/src/lib/mezzanine-selection.test.ts +207 -0
  364. package/src/lib/mezzanine-stairs.ts +46 -0
  365. package/src/lib/model-export.ts +3 -1
  366. package/src/lib/node-action-movement.ts +17 -6
  367. package/src/lib/node-display-label.ts +37 -0
  368. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  369. package/src/lib/orbit-floor-clearance.ts +106 -0
  370. package/src/lib/overlay-transform.test.ts +28 -0
  371. package/src/lib/overlay-transform.ts +12 -0
  372. package/src/lib/paint-commit-route.test.ts +37 -0
  373. package/src/lib/paint-commit-route.ts +17 -0
  374. package/src/lib/paint-eyedropper.test.tsx +165 -0
  375. package/src/lib/paint-eyedropper.ts +99 -0
  376. package/src/lib/paint-pass-through.test.ts +111 -0
  377. package/src/lib/paint-pass-through.ts +76 -0
  378. package/src/lib/paint-region-hover.ts +25 -0
  379. package/src/lib/paint-region-mode.ts +154 -0
  380. package/src/lib/paint-regions.test.ts +166 -0
  381. package/src/lib/paint-regions.ts +192 -0
  382. package/src/lib/paint-scope.test.ts +317 -35
  383. package/src/lib/paint-scope.ts +266 -25
  384. package/src/lib/panel-rows.ts +96 -0
  385. package/src/lib/panel-tool-options.test.ts +34 -0
  386. package/src/lib/panel-tool-options.ts +46 -0
  387. package/src/lib/parametric-node-update.ts +37 -0
  388. package/src/lib/plate-paint-affected.test.ts +300 -0
  389. package/src/lib/plate-paint-affected.ts +177 -0
  390. package/src/lib/plugin-chat.ts +50 -0
  391. package/src/lib/plugin-panels.test.ts +25 -0
  392. package/src/lib/plugin-panels.ts +94 -0
  393. package/src/lib/print-shell-compiler.ts +1 -0
  394. package/src/lib/registered-draft-snap.ts +12 -0
  395. package/src/lib/roof-duplication.ts +10 -2
  396. package/src/lib/room-assembly-overlay.test.ts +275 -0
  397. package/src/lib/room-assembly-overlay.ts +266 -0
  398. package/src/lib/room-built-on.test.ts +379 -0
  399. package/src/lib/room-built-on.ts +138 -0
  400. package/src/lib/room-construction-commands.ts +712 -0
  401. package/src/lib/room-construction.test.ts +746 -0
  402. package/src/lib/room-construction.ts +385 -0
  403. package/src/lib/room-controls-phase6.test.ts +630 -0
  404. package/src/lib/room-divide-session.ts +523 -0
  405. package/src/lib/room-handle-drag.ts +1068 -0
  406. package/src/lib/room-name-catalog.test.ts +90 -0
  407. package/src/lib/room-name-catalog.ts +168 -0
  408. package/src/lib/room-push-dimensions.test.ts +311 -0
  409. package/src/lib/room-push-dimensions.ts +251 -0
  410. package/src/lib/room-selection-commands.ts +62 -0
  411. package/src/lib/room-selection.test.ts +973 -0
  412. package/src/lib/room-selection.ts +417 -0
  413. package/src/lib/room-structure-commands.ts +94 -0
  414. package/src/lib/room-structure.test.ts +921 -0
  415. package/src/lib/room-transform-session.ts +570 -0
  416. package/src/lib/room-zone-routing.test.ts +294 -0
  417. package/src/lib/room-zone-routing.ts +197 -0
  418. package/src/lib/scene-clipboard.test.ts +364 -0
  419. package/src/lib/scene-clipboard.ts +229 -48
  420. package/src/lib/scene-material-refs.test.ts +79 -0
  421. package/src/lib/scene-material-refs.ts +79 -0
  422. package/src/lib/scene.test.ts +87 -0
  423. package/src/lib/scene.ts +39 -45
  424. package/src/lib/selection-routing.test.ts +129 -45
  425. package/src/lib/selection-routing.ts +53 -74
  426. package/src/lib/session-writes.test.ts +120 -0
  427. package/src/lib/session-writes.ts +205 -0
  428. package/src/lib/sidebar-panel.ts +62 -0
  429. package/src/lib/site-boundary.test.ts +4 -6
  430. package/src/lib/site-boundary.ts +1 -1
  431. package/src/lib/snapping-mode.test.ts +17 -2
  432. package/src/lib/snapping-mode.ts +19 -7
  433. package/src/lib/spatial-drag-plane.test.ts +53 -0
  434. package/src/lib/spatial-drag-plane.ts +19 -0
  435. package/src/lib/spatial-pointer-input.test.ts +72 -0
  436. package/src/lib/spatial-pointer-input.ts +62 -0
  437. package/src/lib/spatial-snap.test.ts +47 -0
  438. package/src/lib/terrace-draft.test.ts +145 -0
  439. package/src/lib/terrace-draft.ts +213 -0
  440. package/src/lib/terrain-panel-model.ts +117 -0
  441. package/src/lib/tool-cursor-icon.test.ts +16 -0
  442. package/src/lib/tool-cursor-icon.ts +11 -0
  443. package/src/lib/typing-target.ts +9 -0
  444. package/src/lib/units.ts +77 -39
  445. package/src/lib/vector-edges.test.ts +58 -0
  446. package/src/lib/vector-edges.ts +400 -0
  447. package/src/lib/viewer-selection.test.ts +577 -0
  448. package/src/lib/viewer-selection.ts +569 -0
  449. package/src/lib/wall-draw-variant.test.ts +71 -0
  450. package/src/lib/wall-draw-variant.ts +91 -0
  451. package/src/lib/wall-paint-region-session.ts +246 -0
  452. package/src/lib/wall-paint-region.test.ts +373 -0
  453. package/src/lib/wall-polygon-draft.test.ts +262 -0
  454. package/src/lib/wall-polygon-draft.ts +286 -0
  455. package/src/lib/wall-push.test.ts +453 -0
  456. package/src/lib/wall-region-face.ts +352 -0
  457. package/src/lib/wall-region-handles.test.ts +304 -0
  458. package/src/lib/wall-region-handles.ts +368 -0
  459. package/src/lib/wall-region-list.test.ts +119 -0
  460. package/src/lib/wall-region-list.ts +136 -0
  461. package/src/lib/wall-region-snap.ts +110 -0
  462. package/src/lib/window-interaction.ts +14 -0
  463. package/src/lib/world-grid-snap.test.ts +48 -0
  464. package/src/lib/world-grid-snap.ts +9 -5
  465. package/src/lib/zone-content.ts +4 -1
  466. package/src/lib/zone-name.test.ts +17 -0
  467. package/src/lib/zone-name.ts +17 -0
  468. package/src/store/opens-in-select-mode.test.ts +223 -0
  469. package/src/store/paint-mode.test.ts +188 -0
  470. package/src/store/rotation-snapping.test.ts +53 -0
  471. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  472. package/src/store/tool-mode.test.ts +137 -6
  473. package/src/store/use-delete-confirmation.ts +28 -1
  474. package/src/store/use-drawing-view.test.ts +24 -3
  475. package/src/store/use-drawing-view.ts +29 -1
  476. package/src/store/use-editor.tsx +442 -323
  477. package/src/store/use-floorplan-draft-preview.ts +23 -1
  478. package/src/store/use-floorplan-marquee.ts +12 -2
  479. package/src/store/use-interaction-scope.ts +155 -6
  480. package/src/store/use-quick-measurement-hud.ts +3 -3
  481. package/src/store/use-stair-build-preview.ts +13 -5
  482. package/src/store/use-wall-move-ghosts.ts +4 -2
  483. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  484. package/src/store/use-wall-region-handles-selection.ts +139 -0
  485. package/src/store/view-layout.test.ts +175 -0
  486. package/src/store/view-layout.ts +196 -0
  487. package/src/test-utils/immediate-animation-frames.ts +16 -0
  488. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  489. package/src/test-utils/selection-harness.ts +82 -0
  490. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  491. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  492. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  493. package/src/components/tools/fence/fence-drafting.ts +0 -266
  494. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  495. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  496. package/src/lib/floorplan/stairs.ts +0 -489
  497. package/src/lib/level-duplication.test.ts +0 -144
  498. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Lot drop-in — the PURE half. Given the parcel service's answer and the
3
+ * mapped roads, compute the site node patch and a summary of what was
4
+ * decided (front edge, setbacks, notes). No store, no network, so every
5
+ * rule here is unit-tested; `drop-in.ts` does the fetching and writing.
6
+ *
7
+ * Rules:
8
+ * - The lot ring, address, parcel provenance and zoning come straight from
9
+ * the parcel `resolve` answer; a new ring invalidates any front edge picked on
10
+ * the old one.
11
+ * - The FRONT edge is the lot edge the real street fronts
12
+ * (`detectFrontEdgeFromRoads`: parallel, outside, nearest, addressed
13
+ * street wins on a corner). No road data → left undefined, which the site
14
+ * plan resolves to the most north-facing edge, and the notes say so.
15
+ * - With the parcel fabric's frontage (Pascal Map), the front is the fronting
16
+ * edge on the addressed street (`frontageFront`), else the longest fronting
17
+ * edge; the note says which rule decided.
18
+ * - Setbacks: when the site has none, the planning defaults
19
+ * (`DEFAULT_SETBACKS_FT` — front 20 ft, side 5 ft, rear 15 ft) are
20
+ * written with a `setbacksSource` that says they are defaults. Existing
21
+ * setbacks are never overwritten.
22
+ * - `northRotation` is 0: the parcel frame is x east / z south, so plan up
23
+ * is true north.
24
+ */
25
+ import type { SiteNode, SiteSetbacks } from '@pascal-app/core'
26
+ import {
27
+ detectFrontEdgeFromRoads,
28
+ type FrontEdgeMatch,
29
+ type RoadCenterline,
30
+ } from '../floorplan/site-plan/front-edge'
31
+ import { edgeLength, METRES_PER_FOOT, type Pt } from '../floorplan/site-plan/geometry'
32
+ import { sameStreet } from '../floorplan/site-plan/street-name'
33
+ import { cleanLotRing, describeRingCleanup } from './clean-ring'
34
+ import { detectFrontEdgeFromFrontage, type FrontageMatch } from './dossier'
35
+
36
+ export const DEFAULT_SETBACKS_FT = { front: 20, side: 5, rear: 15 } as const
37
+ export const DEFAULT_SETBACKS_M: SiteSetbacks = {
38
+ front: DEFAULT_SETBACKS_FT.front * METRES_PER_FOOT,
39
+ side: DEFAULT_SETBACKS_FT.side * METRES_PER_FOOT,
40
+ rear: DEFAULT_SETBACKS_FT.rear * METRES_PER_FOOT,
41
+ }
42
+ export const DEFAULT_SETBACKS_SOURCE =
43
+ 'Planning defaults (front 20 ft, side 5 ft, rear 15 ft; typical single-family yards) — confirm with the zoning district.'
44
+
45
+ /** OSM highway classes a house can front: streets, not alleys, driveways or sidewalks. */
46
+ export const STREET_CLASSES: ReadonlySet<string> = new Set([
47
+ 'motorway',
48
+ 'trunk',
49
+ 'primary',
50
+ 'secondary',
51
+ 'tertiary',
52
+ 'residential',
53
+ 'living_street',
54
+ 'unclassified',
55
+ ])
56
+
57
+ /** The parcel provider's `resolve` answer (the fields the drop-in reads). */
58
+ export interface ParcelResolveData {
59
+ ok: boolean
60
+ error?: string
61
+ apn?: string
62
+ county?: string
63
+ state?: string
64
+ zip?: string
65
+ zoning?: string
66
+ lotAreaSqFt?: number
67
+ originLngLat?: [number, number]
68
+ geocodedBy?: string
69
+ matchPrecision?: string
70
+ notes?: string[]
71
+ polygonM?: [number, number][]
72
+ address?: { street?: string; city?: string; state?: string; zip?: string } | null
73
+ }
74
+
75
+ /** A road from the parcel provider's `roads` (centerline in the lot's frame, metres). */
76
+ export interface LotRoad extends RoadCenterline {
77
+ id?: string
78
+ klass?: string
79
+ widthM?: number
80
+ }
81
+
82
+ /** What the user typed or picked. Coordinates skip geocoding when present. */
83
+ export interface DropInInput {
84
+ address?: string
85
+ latitude?: number
86
+ longitude?: number
87
+ state?: string
88
+ street?: string
89
+ city?: string
90
+ zip?: string
91
+ }
92
+
93
+ /**
94
+ * What the Pascal Map dossier adds to a lot patch (lot drop-in, dossier.ts):
95
+ * the frontage segments the front edge is read from, the zoning setbacks
96
+ * with their citation, and the facts the site keeps for the sheets.
97
+ */
98
+ export interface DossierExtras {
99
+ /** Frontage segments in plan metres — the lot edges that touch no neighbour. */
100
+ frontageSegmentsM?: readonly [readonly [number, number], readonly [number, number]][]
101
+ /** Zoning setbacks, metres — null when the code's rule is conditional. */
102
+ setbacks?: { front: number; side: number; rear: number } | null
103
+ setbacksSource?: string
104
+ /** The code's verbatim condition when a number cannot say it. */
105
+ dimensionalNote?: string | null
106
+ zone?: string
107
+ /** The geometry-free record the site node keeps. */
108
+ facts: SiteNode['dossier']
109
+ /** One status line, e.g. "Pascal Map: 9 sections answered (parcel not here)". */
110
+ line: string
111
+ }
112
+
113
+ export interface LotSummary {
114
+ apn: string
115
+ county: string
116
+ state: string
117
+ lotAreaSqFt: number
118
+ /** The street-facing edge index, or null when the north-facing fallback stands. */
119
+ frontEdge: number | null
120
+ frontStreet: string | null
121
+ frontEdgeSource: string
122
+ /** How many lot edges front a street / water per the parcel fabric (corner lots ≥ 2); absent without frontage. */
123
+ frontingEdges?: number
124
+ /** The dossier's own status line, when one was read. */
125
+ dossierLine?: string
126
+ setbacksDefaulted: boolean
127
+ roadsFound: number
128
+ notes: string[]
129
+ }
130
+
131
+ /** A trailing "ST zip" (US) or "STATE postcode" (AU), optionally after the city in the same part. */
132
+ const STATE_ZIP_TAIL = /^(?:(.*?)\s+)?([A-Z]{2}|NSW|VIC|QLD|TAS|ACT)(?:\s+(\d{4,5}(?:-\d{4})?))?$/i
133
+ const COUNTRY_PART = /^(usa?|u\.s\.a?\.?|united states(?: of america)?|australia)$/i
134
+
135
+ /**
136
+ * The typed "street, city, ST zip" split into its parts — what the address
137
+ * says when the resolver answered no situs line (the parcel route never
138
+ * does). Without a comma it is all street.
139
+ */
140
+ export function splitTypedAddress(typed: string | undefined): {
141
+ street?: string
142
+ city?: string
143
+ state?: string
144
+ zip?: string
145
+ } {
146
+ const parts = String(typed ?? '')
147
+ .split(',')
148
+ .map((p) => p.trim())
149
+ .filter(Boolean)
150
+ if (parts.length > 1 && COUNTRY_PART.test(parts[parts.length - 1] as string)) parts.pop()
151
+ if (parts.length === 0) return {}
152
+ if (parts.length === 1) return { street: parts[0] }
153
+ const tail = STATE_ZIP_TAIL.exec(parts[parts.length - 1] as string)
154
+ if (!tail) return { street: parts.slice(0, -1).join(', '), city: parts[parts.length - 1] }
155
+ const [, cityInTail, state, zip] = tail
156
+ const streetParts = cityInTail ? parts.slice(0, -1) : parts.slice(0, -2)
157
+ const city = cityInTail ?? (parts.length > 2 ? parts[parts.length - 2] : undefined)
158
+ return {
159
+ street: (streetParts.length > 0 ? streetParts : parts.slice(0, 1)).join(', '),
160
+ ...(city ? { city } : {}),
161
+ state: (state as string).toUpperCase(),
162
+ ...(zip ? { zip } : {}),
163
+ }
164
+ }
165
+
166
+ const streetOf = (input: DropInInput, data: ParcelResolveData): string | undefined =>
167
+ input.street ?? data.address?.street ?? splitTypedAddress(input.address).street
168
+
169
+ /** The front-edge decision, as text for the parcel notes and the panel. */
170
+ export function describeFrontEdge(match: FrontEdgeMatch | null, roadsFound: number): string {
171
+ if (match) {
172
+ const name = match.name ? `"${match.name}"` : 'an unnamed street'
173
+ return `Front edge ${match.index + 1}: fronts ${name} (OpenStreetMap${match.named ? ', the addressed street' : ', nearest street'}).`
174
+ }
175
+ return roadsFound > 0
176
+ ? 'Front edge: no mapped street fronts a lot edge — the most north-facing edge stands; pick the street side in the Site panel.'
177
+ : 'Front edge: no road data — the most north-facing edge stands; pick the street side in the Site panel.'
178
+ }
179
+
180
+ /**
181
+ * Under this share of the longest frontage the addressed street's fronting
182
+ * edge is a sliver of the lot (a corner cut, a flag's pole), not its face.
183
+ */
184
+ export const ADDRESSED_FRONTAGE_MIN_SHARE = 0.5
185
+
186
+ /**
187
+ * The front among the parcel fabric's fronting edges: the one on the
188
+ * addressed street (a corner or through lot is addressed on its front
189
+ * street) — `streetNames` are the mapped street along each edge — unless it
190
+ * is under half the longest frontage; else the longest. `rule` says which
191
+ * decided, for the note.
192
+ */
193
+ export function frontageFront(
194
+ ring: readonly Pt[],
195
+ frontage: FrontageMatch,
196
+ streetNames: Readonly<Record<string, string>>,
197
+ addressStreet: string | null | undefined,
198
+ ): { index: number; name: string; named: boolean; rule: string } {
199
+ const nameOf = (i: number) => streetNames[String(i)] ?? ''
200
+ const addressed = frontage.edges
201
+ .filter((i) => sameStreet(addressStreet, nameOf(i)))
202
+ .sort((a, b) => edgeLength(ring, b) - edgeLength(ring, a))[0]
203
+ if (
204
+ addressed !== undefined &&
205
+ edgeLength(ring, addressed) >= frontage.lengthM * ADDRESSED_FRONTAGE_MIN_SHARE
206
+ )
207
+ return {
208
+ index: addressed,
209
+ name: nameOf(addressed),
210
+ named: true,
211
+ rule: "the addressed street's frontage",
212
+ }
213
+ const rule =
214
+ addressed !== undefined
215
+ ? `the longest frontage — the addressed street's edge ${addressed + 1} is ${edgeLength(ring, addressed).toFixed(1)} m, under half the longest ${frontage.lengthM.toFixed(1)} m`
216
+ : frontage.edges.length === 1
217
+ ? 'the only frontage'
218
+ : frontage.edges.some((i) => nameOf(i))
219
+ ? 'the longest frontage — no fronting street is the addressed street'
220
+ : 'the longest frontage — no mapped street names to match the address'
221
+ return { index: frontage.index, name: nameOf(frontage.index), named: false, rule }
222
+ }
223
+
224
+ /**
225
+ * The site patch for a resolved parcel. `null` when the answer has no usable
226
+ * ring. `now` stamps `parcel.resolvedAt` (injectable for tests).
227
+ */
228
+ export function sitePatchFromParcel(
229
+ site: (Pick<SiteNode, 'setbacks' | 'zone'> & { metadata?: unknown }) | null | undefined,
230
+ input: DropInInput,
231
+ data: ParcelResolveData,
232
+ roads: readonly LotRoad[] | null,
233
+ now: string = new Date().toISOString(),
234
+ dossier?: DossierExtras | null,
235
+ ): { patch: Partial<SiteNode>; summary: LotSummary } | null {
236
+ const raw = data.polygonM
237
+ if (!data.ok || !raw || raw.length < 3) return null
238
+ // The registry ring, made fit for planning (curb returns squared, surplus
239
+ // vertices gone) — see clean-ring.ts for why the raw ring cannot be used.
240
+ const cleaned = cleanLotRing(raw as readonly Pt[])
241
+ if (cleaned.points.length < 3) return null
242
+ const polygon = cleaned.points.map((p) => [p[0], p[1]] as [number, number])
243
+ const cleanupNote = describeRingCleanup(raw.length, cleaned)
244
+
245
+ const streets = (roads ?? []).filter((r) => !r.klass || STREET_CLASSES.has(r.klass))
246
+ const roadMatch = detectFrontEdgeFromRoads(
247
+ polygon as readonly Pt[],
248
+ streets,
249
+ streetOf(input, data),
250
+ )
251
+ // The parcel FABRIC beats the road match: the edges shared with no
252
+ // neighbour are the street (or water) edges, and the one on the addressed
253
+ // street (else the longest) is the front; the road match names the
254
+ // streets and stands in without a frontage; north-facing is the last resort.
255
+ const frontage = dossier?.frontageSegmentsM?.length
256
+ ? detectFrontEdgeFromFrontage(polygon as readonly Pt[], dossier.frontageSegmentsM)
257
+ : null
258
+ const fabricFront = frontage
259
+ ? frontageFront(
260
+ polygon as readonly Pt[],
261
+ frontage,
262
+ roadMatch?.streetNames ?? {},
263
+ streetOf(input, data),
264
+ )
265
+ : null
266
+ const match = fabricFront
267
+ ? { index: fabricFront.index, distance: 0, name: fabricFront.name, named: fabricFront.named }
268
+ : roadMatch
269
+ // (site-annotations reads the street back from this note up to its ')')
270
+ const frontNote =
271
+ frontage && fabricFront
272
+ ? `Front edge: edge ${fabricFront.index + 1} of the parcel fabric's frontage (${frontage.frontingEdges} fronting edge${frontage.frontingEdges === 1 ? '' : 's'}${fabricFront.name ? `, ${fabricFront.name}` : ''}) — ${fabricFront.rule} (Pascal Map).`
273
+ : describeFrontEdge(roadMatch, roads?.length ?? 0)
274
+
275
+ // Setbacks: the site's own stay; else the zoning code's numbers with
276
+ // their citation; else the default, with the code's condition printed
277
+ // beside it when the dossier carried one.
278
+ const zoned = !site?.setbacks && dossier?.setbacks ? dossier.setbacks : null
279
+ const setbacksDefaulted = !site?.setbacks && !zoned
280
+ const notes = [...(data.notes ?? []), ...(cleanupNote ? [cleanupNote] : []), frontNote]
281
+ if (zoned) notes.push(`Setbacks: ${dossier?.setbacksSource ?? 'zoning code via Pascal Map'}`)
282
+ if (setbacksDefaulted) notes.push(`Setbacks: ${DEFAULT_SETBACKS_SOURCE}`)
283
+ if (dossier?.dimensionalNote)
284
+ notes.push(`Zoning condition (verbatim, verify): ${dossier.dimensionalNote}`)
285
+ if (dossier?.line) notes.push(dossier.line)
286
+
287
+ // the street edges: the parcel fabric's frontage when it answered, else
288
+ // every edge a mapped road runs along
289
+ const streetEdges: number[] =
290
+ frontage && frontage.edges.length > 0 ? frontage.edges : (roadMatch?.streetEdges ?? [])
291
+ // the street each of those edges runs along, from the mapped roads (the
292
+ // site plan labels every street edge with its own street, not the address's)
293
+ const streetNames: Record<string, string> = {}
294
+ for (const i of streetEdges) {
295
+ const name = roadMatch?.streetNames?.[String(i)]
296
+ if (name) streetNames[String(i)] = name
297
+ }
298
+ const typed = splitTypedAddress(input.address)
299
+ const state = data.state || input.state || data.address?.state || typed.state
300
+ const patch: Partial<SiteNode> = {
301
+ address: {
302
+ street: streetOf(input, data),
303
+ city: input.city ?? data.address?.city ?? typed.city,
304
+ state,
305
+ zip: data.zip || input.zip || data.address?.zip || typed.zip,
306
+ },
307
+ parcel: {
308
+ apn: data.apn,
309
+ county: data.county,
310
+ layer: data.geocodedBy,
311
+ lotAreaSqFt: data.lotAreaSqFt,
312
+ notes,
313
+ originLngLat: data.originLngLat,
314
+ resolvedAt: now,
315
+ source: 'gis-parcel',
316
+ state,
317
+ },
318
+ polygon: { points: polygon, type: 'polygon' },
319
+ // A new lot invalidates a front-edge index picked on the old ring; the
320
+ // street decides the new one.
321
+ frontEdge: match ? match.index : undefined,
322
+ // every street edge (a corner lot has two or more) and the corner
323
+ // clear-vision triangle's leg (measured from the right of way) — the
324
+ // common residential 25 ft, verify locally
325
+ ...(streetEdges.length > 0 ? { streetEdges } : {}),
326
+ ...(streetEdges.length >= 2 ? { sightTriangleFt: 25 } : {}),
327
+ // The parcel frame is x east / z south: plan up is true north.
328
+ northRotation: 0,
329
+ ...((dossier?.zone ?? data.zoning) && !site?.zone
330
+ ? { zone: dossier?.zone ?? data.zoning }
331
+ : {}),
332
+ ...(zoned
333
+ ? {
334
+ setbacks: { ...zoned },
335
+ setbacksSource: dossier?.setbacksSource ?? 'zoning code via Pascal Map',
336
+ }
337
+ : setbacksDefaulted
338
+ ? { setbacks: { ...DEFAULT_SETBACKS_M }, setbacksSource: DEFAULT_SETBACKS_SOURCE }
339
+ : {}),
340
+ // always written: undefined clears the previous lot's dossier when this lookup read none
341
+ dossier: dossier?.facts,
342
+ // the street each street edge runs along (`metadata.streetNames`, by
343
+ // edge index) — the site plan names every street edge with its own
344
+ // street; the previous lot's names never carry over
345
+ metadata: (() => {
346
+ const { streetNames: _previous, ...rest } = (
347
+ site?.metadata && typeof site.metadata === 'object' && !Array.isArray(site.metadata)
348
+ ? site.metadata
349
+ : {}
350
+ ) as Record<string, unknown>
351
+ return Object.keys(streetNames).length > 0 ? { ...rest, streetNames } : rest
352
+ })(),
353
+ }
354
+
355
+ return {
356
+ patch,
357
+ summary: {
358
+ apn: data.apn ?? '',
359
+ county: data.county ?? '',
360
+ state: state ?? '',
361
+ lotAreaSqFt: data.lotAreaSqFt ?? 0,
362
+ frontEdge: match ? match.index : null,
363
+ frontStreet: match ? match.name || null : null,
364
+ frontEdgeSource: frontage
365
+ ? 'frontage'
366
+ : match
367
+ ? `osm:${match.name || 'unnamed'}`
368
+ : 'north-facing',
369
+ ...(frontage ? { frontingEdges: frontage.frontingEdges } : {}),
370
+ ...(dossier?.line ? { dossierLine: dossier.line } : {}),
371
+ setbacksDefaulted,
372
+ roadsFound: roads?.length ?? 0,
373
+ notes,
374
+ },
375
+ }
376
+ }
377
+
378
+ /** One line for a status row: "Lot set — APN 123 · 5,300 sq ft · Sacramento · fronts Castro Way · default setbacks". */
379
+ export function describeLotSummary(summary: LotSummary, extra: string[] = []): string {
380
+ const parts = [
381
+ summary.apn ? `APN ${summary.apn}` : '',
382
+ summary.lotAreaSqFt ? `${Math.round(summary.lotAreaSqFt).toLocaleString('en-US')} sq ft` : '',
383
+ summary.county,
384
+ summary.frontEdge !== null
385
+ ? summary.frontEdgeSource === 'frontage'
386
+ ? `fronts ${summary.frontStreet || 'the street'} (edge ${summary.frontEdge + 1}, parcel frontage)`
387
+ : `fronts ${summary.frontStreet ?? 'an unnamed street'} (edge ${summary.frontEdge + 1})`
388
+ : 'front edge: most north-facing (no road match)',
389
+ summary.dossierLine ?? '',
390
+ summary.setbacksDefaulted ? 'setbacks defaulted 20 / 5 / 15 ft' : '',
391
+ ...extra,
392
+ ].filter(Boolean)
393
+ return `Lot set — ${parts.join(' · ')}`
394
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The parcel service the lot tools read — injected by the host. The editor
3
+ * ships no parcel backend: a host that has one calls `setParcelProvider` at
4
+ * bootstrap, and until it does the address lookup is hidden and `dropInLot`
5
+ * answers that no service is available.
6
+ */
7
+ import { useSyncExternalStore } from 'react'
8
+
9
+ export type ParcelEndpoint = 'autocomplete' | 'resolve' | 'roads' | 'elevation' | 'dossier'
10
+
11
+ /**
12
+ * Answers one parcel call with the endpoint's JSON. `body` is the request:
13
+ * `{ q }` for `autocomplete`, the posted JSON for the others.
14
+ */
15
+ export type ParcelProvider = (
16
+ endpoint: ParcelEndpoint,
17
+ body: Record<string, unknown>,
18
+ ) => Promise<unknown>
19
+
20
+ let provider: ParcelProvider | null = null
21
+ const listeners = new Set<() => void>()
22
+
23
+ export function setParcelProvider(next: ParcelProvider | null): void {
24
+ provider = next
25
+ for (const listener of listeners) listener()
26
+ }
27
+
28
+ export function getParcelProvider(): ParcelProvider | null {
29
+ return provider
30
+ }
31
+
32
+ function subscribe(listener: () => void): () => void {
33
+ listeners.add(listener)
34
+ return () => {
35
+ listeners.delete(listener)
36
+ }
37
+ }
38
+
39
+ export function useParcelProvider(): ParcelProvider | null {
40
+ return useSyncExternalStore(subscribe, getParcelProvider, getParcelProvider)
41
+ }
42
+
43
+ export const NO_PARCEL_SERVICE = 'No parcel service is available.'
@@ -0,0 +1,161 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { decodeTerrainField, heightAt } from '@pascal-app/core'
3
+ import type { ParcelProvider } from './parcel-provider'
4
+ import {
5
+ coarseHeightAt,
6
+ DEFAULT_GRID_N,
7
+ describeTerrainSample,
8
+ fieldFromSamples,
9
+ gridOver,
10
+ localMetresToLngLat,
11
+ MIN_RELIEF_M,
12
+ type Pt,
13
+ sampleLotTerrain,
14
+ } from './terrain'
15
+
16
+ // a 50 × 107 ft lot, x across, z into the lot
17
+ const LOT: Pt[] = [
18
+ [0, 0],
19
+ [15.24, 0],
20
+ [15.24, 32.6],
21
+ [0, 32.6],
22
+ ]
23
+ const ORIGIN: [number, number] = [-121.4944, 38.5816]
24
+ const FT = 0.3048
25
+
26
+ /** A fake elevation route: the ground is a plane, `slopeFtPerM` feet per metre of x, `base` ft, optional holes. */
27
+ function fakeProvider(slopeFtPerM: number, base = 30, holeEvery = 0, fail = false): ParcelProvider {
28
+ return async (_endpoint, request) => {
29
+ const body = request as { points: { lat: number; lng: number }[] }
30
+ if (fail) return { ok: false, error: 'USGS down' }
31
+ // invert the projection the way the sampler projected: lng → x metres
32
+ const results = body.points.map((p, i) => {
33
+ const xM = (p.lng - ORIGIN[0]) * 364000 * Math.cos((ORIGIN[1] * Math.PI) / 180) * FT
34
+ const elevation = holeEvery > 0 && i % holeEvery === 3 ? null : base + slopeFtPerM * xM
35
+ return { lat: p.lat, lng: p.lng, elevation }
36
+ })
37
+ return { ok: true, results }
38
+ }
39
+ }
40
+
41
+ describe('grid and projection', () => {
42
+ test('the grid pads the lot bbox and runs row-major; the projection inverts the plan frame', () => {
43
+ const g = gridOver(LOT, 5)
44
+ expect(g.n).toBe(5)
45
+ expect(g.points).toHaveLength(25)
46
+ expect(g.x0).toBeCloseTo(-15.24 * 0.08, 6)
47
+ expect(g.x1).toBeCloseTo(15.24 * 1.08, 6)
48
+ expect(g.points[0]).toEqual([g.x0, g.z0])
49
+ expect(g.points[4]![0]).toBeCloseTo(g.x1, 9)
50
+ expect(g.points[5]![1]).toBeCloseTo(g.z0 + (g.z1 - g.z0) / 4, 9)
51
+ const ll = localMetresToLngLat([100 * FT, 200 * FT], ORIGIN)
52
+ // 100 ft east, 200 ft south of the origin
53
+ expect(ll.lat).toBeCloseTo(ORIGIN[1] - 200 / 364000, 9)
54
+ expect(ll.lng).toBeCloseTo(
55
+ ORIGIN[0] + 100 / (364000 * Math.cos((ORIGIN[1] * Math.PI) / 180)),
56
+ 9,
57
+ )
58
+ })
59
+
60
+ test('bilinear reads interpolate the coarse grid and fill holes', () => {
61
+ const g = gridOver(LOT, 3)
62
+ const elev = g.points.map((p) => p[0]) // elevation = x
63
+ expect(coarseHeightAt(g, elev, 7, 10, 0)).toBeCloseTo(7, 6)
64
+ const holed = elev.map((v, i) => (i === 4 ? null : v))
65
+ // the centre sample is a hole → the fill (the mean) stands in for it
66
+ const mean = elev.reduce((s, v) => s + v, 0) / elev.length
67
+ expect(coarseHeightAt(g, holed, (g.x0 + g.x1) / 2, (g.z0 + g.z1) / 2, mean)).toBeCloseTo(
68
+ mean,
69
+ 6,
70
+ )
71
+ })
72
+
73
+ test('the heightfield follows the samples relative to the datum', () => {
74
+ const g = gridOver(LOT, 5)
75
+ const elevM = g.points.map((p) => 0.1 * p[0]) // 10 % grade in x, metres
76
+ const field = fieldFromSamples(g, elevM, 0.1 * 7.62, 0)
77
+ expect(heightAt(field, 7.62, 10)).toBeCloseTo(0, 2)
78
+ expect(heightAt(field, 0, 10)).toBeCloseTo(-0.762, 2)
79
+ expect(heightAt(field, 15, 5)).toBeCloseTo(0.738, 2)
80
+ expect(field.cols).toBeLessThanOrEqual(129)
81
+ })
82
+ })
83
+
84
+ describe('sampleLotTerrain', () => {
85
+ test('a sloping lot writes a heightfield with the datum at the lot centre and says how much fall', async () => {
86
+ const r = await sampleLotTerrain(LOT, ORIGIN, { provider: fakeProvider(0.3), now: () => 't' })
87
+ expect(r.ok).toBe(true)
88
+ expect(r.terrain).toBeDefined()
89
+ expect(r.summary?.flat).toBe(false)
90
+ expect(r.summary?.grid).toBe(DEFAULT_GRID_N)
91
+ expect(r.summary?.sampled).toBe(81)
92
+ // the datum is the ground at the lot centre (x = 7.62 m): 30 + 0.3 × 7.62 ft
93
+ expect(r.summary?.datumFt).toBeCloseTo(30 + 0.3 * 7.62, 4)
94
+ const field = decodeTerrainField(r.terrain)
95
+ expect(field).not.toBeNull()
96
+ // 0.3 ft per metre of x: 7.62 m west of the centre the ground is 2.29 ft lower
97
+ expect(heightAt(field!, 0, 16.3)).toBeCloseTo(-0.3 * 7.62 * FT, 2)
98
+ expect(heightAt(field!, 7.62, 16.3)).toBeCloseTo(0, 2)
99
+ expect(heightAt(field!, 15.24, 16.3)).toBeCloseTo(0.3 * 7.62 * FT, 2)
100
+ })
101
+
102
+ test('a flat lot writes nothing; holes are counted; failures say why', async () => {
103
+ const flat = await sampleLotTerrain(LOT, ORIGIN, { provider: fakeProvider(0.001) })
104
+ expect(flat.ok).toBe(true)
105
+ expect(flat.terrain).toBeUndefined()
106
+ expect(flat.summary?.flat).toBe(true)
107
+ expect(flat.summary!.reliefFt * FT).toBeLessThan(MIN_RELIEF_M)
108
+ const holed = await sampleLotTerrain(LOT, ORIGIN, { provider: fakeProvider(0.3, 30, 7) })
109
+ expect(holed.ok).toBe(true)
110
+ expect(holed.summary!.holes).toBeGreaterThan(0)
111
+ expect(holed.summary!.sampled + holed.summary!.holes).toBe(81)
112
+ const down = await sampleLotTerrain(LOT, ORIGIN, { provider: fakeProvider(0.3, 30, 0, true) })
113
+ expect(down.ok).toBe(false)
114
+ expect(down.reason).toBe('USGS down')
115
+ const thrown = await sampleLotTerrain(LOT, ORIGIN, {
116
+ provider: async () => {
117
+ throw new Error('offline')
118
+ },
119
+ })
120
+ expect(thrown.ok).toBe(false)
121
+ expect(thrown.reason).toBe('offline')
122
+ expect((await sampleLotTerrain([[0, 0]], ORIGIN)).reason).toBe('no lot ring')
123
+ })
124
+
125
+ test('the status fragment reads plainly', () => {
126
+ expect(describeTerrainSample(null, 'USGS down')).toBe(
127
+ 'terrain read failed (USGS down) — the ground is flat until it is read',
128
+ )
129
+ expect(describeTerrainSample(null, 'skipped')).toBe('')
130
+ expect(
131
+ describeTerrainSample(
132
+ {
133
+ source: 'USGS EPQS',
134
+ grid: 9,
135
+ sampled: 79,
136
+ holes: 2,
137
+ datumFt: 30,
138
+ reliefFt: 4.56,
139
+ flat: false,
140
+ at: 't',
141
+ },
142
+ '',
143
+ ),
144
+ ).toBe("terrain: 4.6' of fall across the lot (USGS, 79 pts, 2 unread)")
145
+ expect(
146
+ describeTerrainSample(
147
+ {
148
+ source: 'USGS EPQS',
149
+ grid: 9,
150
+ sampled: 81,
151
+ holes: 0,
152
+ datumFt: 30,
153
+ reliefFt: 0.2,
154
+ flat: true,
155
+ at: 't',
156
+ },
157
+ '',
158
+ ),
159
+ ).toBe('ground flat within 2" (USGS)')
160
+ })
161
+ })