@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,352 @@
1
+ import {
2
+ type AnyNode,
3
+ getWallArcData,
4
+ getWallCurveFrameAt,
5
+ getWallFaceAtLocalPoint,
6
+ getWallFaceOffsets,
7
+ isCurvedWall,
8
+ resolveLevelId,
9
+ spatialGridManager,
10
+ type WallFace,
11
+ type WallNode,
12
+ type WallSlabSupportSegment,
13
+ } from '@pascal-app/core'
14
+ import { getWallFinishData } from '@pascal-app/viewer'
15
+ import type { BufferGeometry } from 'three'
16
+ import { faceBaseAt, type WallFaceBaseRuns } from './wall-region-snap'
17
+
18
+ // Face geometry for the paint tool's wall regions, all in the wall mesh's local
19
+ // frame (x along the chord from the start, y up from the slab the mesh stands
20
+ // on, z toward the chord's left). A region's `u` is that local x, on straight
21
+ // and curved walls alike; its `v` is local y above the face's own base.
22
+
23
+ type WallShape = Pick<
24
+ WallNode,
25
+ 'start' | 'end' | 'thickness' | 'justification' | 'curveOffset' | 'height'
26
+ >
27
+
28
+ export type WallFaceFrame = {
29
+ face: WallFace
30
+ length: number
31
+ /** Straight wall: the face plane's local z. */
32
+ z: number
33
+ /** Curved wall: the face's circle in local xz and the side of its centre the face lies on. */
34
+ arc: { cx: number; cz: number; radius: number; side: number } | null
35
+ }
36
+
37
+ export type WallFaceExtent = {
38
+ /** Local x range the face covers (mitred ends reach past 0 and the length). */
39
+ uMin: number
40
+ uMax: number
41
+ /** Local y of the face top. */
42
+ top: number
43
+ }
44
+
45
+ type FaceBase = Record<WallFace, WallFaceBaseRuns> | null
46
+
47
+ function chordFrame(wall: Pick<WallNode, 'start' | 'end'>) {
48
+ const dx = wall.end[0] - wall.start[0]
49
+ const dz = wall.end[1] - wall.start[1]
50
+ const length = Math.hypot(dx, dz)
51
+ const tx = length > 1e-9 ? dx / length : 1
52
+ const tz = length > 1e-9 ? dz / length : 0
53
+ const toLocal = (x: number, z: number): [number, number] => {
54
+ const ox = x - wall.start[0]
55
+ const oz = z - wall.start[1]
56
+ return [ox * tx + oz * tz, -ox * tz + oz * tx]
57
+ }
58
+ return { length, toLocal }
59
+ }
60
+
61
+ export function wallFaceFrame(wall: WallShape, face: WallFace): WallFaceFrame {
62
+ const { length, toLocal } = chordFrame(wall)
63
+ const offset = getWallFaceOffsets(wall)[face]
64
+ const arc = isCurvedWall(wall) ? getWallArcData(wall) : null
65
+ if (!arc) return { face, length, z: offset, arc: null }
66
+ const [cx, cz] = toLocal(arc.center.x, arc.center.y)
67
+ const mid = getWallCurveFrameAt(wall, 0.5).point
68
+ const [, midZ] = toLocal(mid.x, mid.y)
69
+ // Left of a counter-clockwise arc points at its centre (`getWallFaceAtLocalPoint`).
70
+ const radius = arc.direction > 0 ? arc.radius - offset : arc.radius + offset
71
+ return { face, length, z: offset, arc: { cx, cz, radius, side: midZ >= cz ? 1 : -1 } }
72
+ }
73
+
74
+ /** Local (x, z) of the face surface at station `u`. */
75
+ export function faceSurfaceXZ(frame: WallFaceFrame, u: number): [number, number] {
76
+ const { arc } = frame
77
+ if (!arc) return [u, frame.z]
78
+ const dx = u - arc.cx
79
+ return [u, arc.cz + arc.side * Math.sqrt(Math.max(0, arc.radius * arc.radius - dx * dx))]
80
+ }
81
+
82
+ /** Unit local xz normal pointing out of the face at station `u`. */
83
+ export function faceOutwardXZ(
84
+ frame: WallFaceFrame,
85
+ other: WallFaceFrame,
86
+ u: number,
87
+ ): [number, number] {
88
+ const [x, z] = faceSurfaceXZ(frame, u)
89
+ const [ox, oz] = faceSurfaceXZ(other, u)
90
+ const length = Math.hypot(x - ox, z - oz)
91
+ if (length < 1e-9) return [0, frame.face === 'a' ? 1 : -1]
92
+ return [(x - ox) / length, (z - oz) / length]
93
+ }
94
+
95
+ /**
96
+ * Where a local ray meets the face surface (infinite plane or cylinder), as
97
+ * local (u, y). Null when the ray runs parallel or points away.
98
+ */
99
+ export function projectLocalRayToFace(
100
+ frame: WallFaceFrame,
101
+ origin: readonly [number, number, number],
102
+ direction: readonly [number, number, number],
103
+ ): { u: number; y: number } | null {
104
+ const [ox, oy, oz] = origin
105
+ const [dx, dy, dz] = direction
106
+ if (!frame.arc) {
107
+ if (Math.abs(dz) < 1e-9) return null
108
+ const t = (frame.z - oz) / dz
109
+ return t > 0 ? { u: ox + dx * t, y: oy + dy * t } : null
110
+ }
111
+ const { cx, cz, radius, side } = frame.arc
112
+ const px = ox - cx
113
+ const pz = oz - cz
114
+ const a = dx * dx + dz * dz
115
+ if (a < 1e-12) return null
116
+ const b = 2 * (px * dx + pz * dz)
117
+ const c = px * px + pz * pz - radius * radius
118
+ const disc = b * b - 4 * a * c
119
+ if (disc < 0) return null
120
+ const root = Math.sqrt(disc)
121
+ for (const t of [(-b - root) / (2 * a), (-b + root) / (2 * a)]) {
122
+ if (t <= 0) continue
123
+ // The circle's far half is not this face.
124
+ if ((oz + dz * t - cz) * side < 0) continue
125
+ return { u: ox + dx * t, y: oy + dy * t }
126
+ }
127
+ return null
128
+ }
129
+
130
+ /**
131
+ * Face and (u, v) of a hit on the wall mesh, by the same rule as the paint
132
+ * resolver (`resolveWallRole`): face from where the point lies, u = local x,
133
+ * v = local y above that face's base at u.
134
+ */
135
+ export function wallHitFaceUV(
136
+ wall: Pick<WallNode, 'start' | 'end' | 'thickness' | 'justification' | 'curveOffset'>,
137
+ localPosition: readonly [number, number, number],
138
+ localNormal: readonly [number, number, number] | undefined,
139
+ faceBase: FaceBase,
140
+ ): { face: WallFace; u: number; v: number } | null {
141
+ const face = getWallFaceAtLocalPoint(wall, localPosition, localNormal)
142
+ if (!face) return null
143
+ const u = localPosition[0]
144
+ return { face, u, v: localPosition[1] - faceBaseAt(faceBase?.[face] ?? null, u) }
145
+ }
146
+
147
+ /** Local x of a support-segment parameter, as the wall renderer measures it. */
148
+ function stationX(wall: WallShape, t: number, length: number): number {
149
+ if (!isCurvedWall(wall)) return t * length
150
+ const point = getWallCurveFrameAt(wall, t).point
151
+ const dx = wall.end[0] - wall.start[0]
152
+ const dz = wall.end[1] - wall.start[1]
153
+ return ((point.x - wall.start[0]) * dx + (point.y - wall.start[1]) * dz) / length
154
+ }
155
+
156
+ /**
157
+ * Each face's base runs as the renderer measures region heights. A built wall
158
+ * that carries finishes stores them on its geometry; a plain wall does not, so
159
+ * they are derived the way its first region's rebuild will derive them — a
160
+ * region drawn on a stepped face lands where it was drawn.
161
+ */
162
+ export function resolveWallFaceBase(
163
+ wall: WallNode,
164
+ geometry: BufferGeometry | undefined | null,
165
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
166
+ ): FaceBase {
167
+ const data = getWallFinishData(geometry)
168
+ if (data) return data.faceBase
169
+ const cached = derivedFaceBase.get(wall)
170
+ if (cached?.nodes === nodes) return cached.faceBase
171
+ const faceBase = deriveWallFaceBase(wall, nodes)
172
+ derivedFaceBase.set(wall, { nodes, faceBase })
173
+ return faceBase
174
+ }
175
+
176
+ // The derivation runs the slab-support election, so a hovering pointer reuses
177
+ // it until the wall or the scene changes.
178
+ const derivedFaceBase = new WeakMap<
179
+ WallNode,
180
+ { nodes: Readonly<Record<string, AnyNode | undefined>>; faceBase: FaceBase }
181
+ >()
182
+
183
+ function deriveWallFaceBase(
184
+ wall: WallNode,
185
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
186
+ ): FaceBase {
187
+ const support = spatialGridManager.getSlabSupportForWall(
188
+ resolveLevelId(wall, nodes as Record<string, AnyNode>),
189
+ wall.start,
190
+ wall.end,
191
+ wall.curveOffset ?? 0,
192
+ wall.thickness,
193
+ wall.supportSlabId,
194
+ undefined,
195
+ wall.supportOffset,
196
+ wall.justification,
197
+ )
198
+ const faceBase = support.faceDatum
199
+ if (!faceBase) return null
200
+ const same = [faceBase.a, faceBase.b].every(
201
+ (segments) => JSON.stringify(segments) === JSON.stringify(support.baseSegments),
202
+ )
203
+ if (same) return null
204
+ const { length } = chordFrame(wall)
205
+ const runs = (segments: readonly WallSlabSupportSegment[]) =>
206
+ segments
207
+ .map((segment) => ({
208
+ start: stationX(wall, segment.start, length),
209
+ end: stationX(wall, segment.end, length),
210
+ y: segment.elevation - support.elevation,
211
+ }))
212
+ .sort((left, right) => left.start - right.start)
213
+ return { a: runs(faceBase.a), b: runs(faceBase.b) }
214
+ }
215
+
216
+ const extentCache = new WeakMap<BufferGeometry, Map<string, WallFaceExtent>>()
217
+
218
+ /** The face's local x range and top, read off the built geometry. */
219
+ export function wallFaceExtent(
220
+ wall: WallShape,
221
+ face: WallFace,
222
+ geometry: BufferGeometry | undefined | null,
223
+ ): WallFaceExtent {
224
+ const frame = wallFaceFrame(wall, face)
225
+ const fallback = { uMin: 0, uMax: frame.length, top: wall.height ?? 2.5 }
226
+ const position = geometry?.getAttribute('position')
227
+ if (!(geometry && position)) return fallback
228
+ const key = `${face}:${frame.z}:${frame.arc?.radius ?? ''}`
229
+ const cached = extentCache.get(geometry)?.get(key)
230
+ if (cached) return cached
231
+ let uMin = Number.POSITIVE_INFINITY
232
+ let uMax = Number.NEGATIVE_INFINITY
233
+ let top = Number.NEGATIVE_INFINITY
234
+ for (let index = 0; index < position.count; index++) {
235
+ const x = position.getX(index)
236
+ const z = position.getZ(index)
237
+ const off = frame.arc
238
+ ? Math.abs(Math.hypot(x - frame.arc.cx, z - frame.arc.cz) - frame.arc.radius)
239
+ : Math.abs(z - frame.z)
240
+ if (off > 3e-3) continue
241
+ uMin = Math.min(uMin, x)
242
+ uMax = Math.max(uMax, x)
243
+ top = Math.max(top, position.getY(index))
244
+ }
245
+ const extent = Number.isFinite(top) && uMax > uMin ? { uMin, uMax, top } : fallback
246
+ const perGeometry = extentCache.get(geometry) ?? new Map<string, WallFaceExtent>()
247
+ perGeometry.set(key, extent)
248
+ extentCache.set(geometry, perGeometry)
249
+ return extent
250
+ }
251
+
252
+ /** A span of the face in region terms: absent bounds run to the face's edges. */
253
+ export type FaceSpan = { u0?: number; u1?: number; v0?: number; v1?: number }
254
+
255
+ type FaceStrip = { u0: number; u1: number; low: number; high: number }
256
+
257
+ /**
258
+ * The span cut into pieces that each stand on one base run (and, on a curved
259
+ * wall, short enough to follow the arc), with local y bottom and top.
260
+ */
261
+ export function faceSpanStrips(
262
+ frame: WallFaceFrame,
263
+ extent: WallFaceExtent,
264
+ runs: WallFaceBaseRuns,
265
+ span: FaceSpan,
266
+ ): FaceStrip[] {
267
+ const start = Math.max(span.u0 ?? extent.uMin, extent.uMin)
268
+ const end = Math.min(span.u1 ?? extent.uMax, extent.uMax)
269
+ if (end - start < 1e-6) return []
270
+ const stations = [start, end]
271
+ for (const run of runs ?? [])
272
+ for (const edge of [run.start, run.end]) if (edge > start && edge < end) stations.push(edge)
273
+ if (frame.arc) {
274
+ const pieces = Math.max(1, Math.ceil((end - start) / 0.1))
275
+ for (let index = 1; index < pieces; index++)
276
+ stations.push(start + ((end - start) * index) / pieces)
277
+ }
278
+ stations.sort((a, b) => a - b)
279
+ const strips: FaceStrip[] = []
280
+ for (let index = 0; index < stations.length - 1; index++) {
281
+ const u0 = stations[index]!
282
+ const u1 = stations[index + 1]!
283
+ if (u1 - u0 < 1e-6) continue
284
+ const base = faceBaseAt(runs, (u0 + u1) / 2)
285
+ const low = Math.max(base + (span.v0 ?? 0), base)
286
+ const high = Math.min(span.v1 === undefined ? extent.top : base + span.v1, extent.top)
287
+ if (high - low > 1e-6) strips.push({ u0, u1, low, high })
288
+ }
289
+ return strips
290
+ }
291
+
292
+ function lift(frame: WallFaceFrame, other: WallFaceFrame, u: number, offset: number) {
293
+ const [x, z] = faceSurfaceXZ(frame, u)
294
+ const [nx, nz] = faceOutwardXZ(frame, other, u)
295
+ return [x + nx * offset, z + nz * offset] as const
296
+ }
297
+
298
+ /** Triangles covering the strips, lifted `offset` metres off the face. */
299
+ export function faceStripTriangles(
300
+ frame: WallFaceFrame,
301
+ other: WallFaceFrame,
302
+ strips: readonly FaceStrip[],
303
+ offset: number,
304
+ ): Float32Array {
305
+ const out: number[] = []
306
+ for (const { u0, u1, low, high } of strips) {
307
+ const [ax, az] = lift(frame, other, u0, offset)
308
+ const [bx, bz] = lift(frame, other, u1, offset)
309
+ out.push(ax, low, az, bx, low, bz, bx, high, bz, ax, low, az, bx, high, bz, ax, high, az)
310
+ }
311
+ return new Float32Array(out)
312
+ }
313
+
314
+ /** Line segments around the strips' outline (steps included), lifted off the face. */
315
+ export function faceStripOutline(
316
+ frame: WallFaceFrame,
317
+ other: WallFaceFrame,
318
+ strips: readonly FaceStrip[],
319
+ offset: number,
320
+ ): Float32Array {
321
+ const out: number[] = []
322
+ strips.forEach((strip, index) => {
323
+ const [ax, az] = lift(frame, other, strip.u0, offset)
324
+ const [bx, bz] = lift(frame, other, strip.u1, offset)
325
+ out.push(ax, strip.low, az, bx, strip.low, bz, ax, strip.high, az, bx, strip.high, bz)
326
+ const previous = strips[index - 1]
327
+ if (!previous || Math.abs(previous.u1 - strip.u0) > 1e-6) {
328
+ out.push(ax, strip.low, az, ax, strip.high, az)
329
+ } else {
330
+ if (Math.abs(previous.low - strip.low) > 1e-6)
331
+ out.push(ax, previous.low, az, ax, strip.low, az)
332
+ if (Math.abs(previous.high - strip.high) > 1e-6)
333
+ out.push(ax, previous.high, az, ax, strip.high, az)
334
+ }
335
+ const next = strips[index + 1]
336
+ if (!next || Math.abs(next.u0 - strip.u1) > 1e-6)
337
+ out.push(bx, strip.low, bz, bx, strip.high, bz)
338
+ })
339
+ return new Float32Array(out)
340
+ }
341
+
342
+ /** Local position of face point (u, local y), lifted `offset` metres off the face. */
343
+ export function facePoint(
344
+ frame: WallFaceFrame,
345
+ other: WallFaceFrame,
346
+ u: number,
347
+ y: number,
348
+ offset: number,
349
+ ): [number, number, number] {
350
+ const [x, z] = lift(frame, other, u, offset)
351
+ return [x, y, z]
352
+ }
@@ -0,0 +1,304 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNodeId,
4
+ getWallCurveFrameAt,
5
+ getWallFaceOffsets,
6
+ runAsSingleSceneHistoryStep,
7
+ useLiveNodeOverrides,
8
+ useScene,
9
+ type WallFaceRegion,
10
+ WallNode,
11
+ } from '@pascal-app/core'
12
+ import { Matrix4, Ray, Vector3 } from 'three'
13
+ import { commitHandleDragPatch } from '../components/editor/handles/handle-drag-history'
14
+ import { updateWallRegion } from './paint-regions'
15
+ import {
16
+ buildOutlineRibbon,
17
+ faceSurfacePoint,
18
+ getWallFaceSurface,
19
+ intersectFaceSurface,
20
+ REGION_MIN_SIZE,
21
+ regionHandlePlacements,
22
+ regionOutline,
23
+ resolveRegionBoundDrag,
24
+ wallLocalMatrix,
25
+ withRegionBounds,
26
+ } from './wall-region-handles'
27
+ import { wallRegionSnapTargets } from './wall-region-snap'
28
+
29
+ type RafFn = (callback: (time: number) => void) => number
30
+ ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (callback) => {
31
+ callback(0)
32
+ return 0
33
+ }
34
+ ;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
35
+
36
+ const straight = WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.2 })
37
+ const curved = WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.2, curveOffset: 0.8 })
38
+ const curvedBack = WallNode.parse({
39
+ start: [0, 0],
40
+ end: [4, 0],
41
+ thickness: 0.2,
42
+ curveOffset: -0.8,
43
+ })
44
+ const flat = () => 0
45
+
46
+ /** A ray that starts `distance` out from the face point at (u, y) and aims back at it. */
47
+ function rayAt(surface: ReturnType<typeof getWallFaceSurface>, u: number, y: number, distance = 3) {
48
+ const point = faceSurfacePoint(surface, u)
49
+ const origin = {
50
+ x: point.x + point.nx * distance + 0.4,
51
+ y: y + 0.7,
52
+ z: point.z + point.nz * distance,
53
+ }
54
+ return {
55
+ origin,
56
+ direction: { x: point.x - origin.x, y: y - origin.y, z: point.z - origin.z },
57
+ }
58
+ }
59
+
60
+ describe('wall face surface', () => {
61
+ test('a straight face sits at its offset with an outward normal', () => {
62
+ const offsets = getWallFaceOffsets(straight)
63
+ const a = faceSurfacePoint(getWallFaceSurface(straight, 'a'), 1.5)
64
+ const b = faceSurfacePoint(getWallFaceSurface(straight, 'b'), 1.5)
65
+ expect(a).toEqual({ x: 1.5, z: offsets.a, nx: 0, nz: 1, tx: 1, tz: 0 })
66
+ expect(b.z).toBe(offsets.b)
67
+ expect(b.nz).toBe(-1)
68
+ })
69
+
70
+ for (const [label, wall] of [
71
+ ['bulging right', curved],
72
+ ['bulging left', curvedBack],
73
+ ] as const) {
74
+ test(`a curved face (${label}) follows the arc offset by the face offset`, () => {
75
+ const offsets = getWallFaceOffsets(wall)
76
+ for (const face of ['a', 'b'] as const) {
77
+ const surface = getWallFaceSurface(wall, face)
78
+ for (const t of [0.2, 0.5, 0.8]) {
79
+ const frame = getWallCurveFrameAt(wall, t)
80
+ const expected = {
81
+ x: frame.point.x + frame.normal.x * offsets[face],
82
+ z: frame.point.y + frame.normal.y * offsets[face],
83
+ }
84
+ const point = faceSurfacePoint(surface, expected.x)
85
+ expect(point.z).toBeCloseTo(expected.z, 6)
86
+ const outward = face === 'a' ? 1 : -1
87
+ expect(point.nx).toBeCloseTo(frame.normal.x * outward, 6)
88
+ expect(point.nz).toBeCloseTo(frame.normal.y * outward, 6)
89
+ expect(point.tx).toBeCloseTo(frame.tangent.x, 6)
90
+ expect(point.tz).toBeCloseTo(frame.tangent.y, 6)
91
+ }
92
+ }
93
+ })
94
+ }
95
+ })
96
+
97
+ describe('pointer on the face', () => {
98
+ for (const [label, wall] of [
99
+ ['straight', straight],
100
+ ['curved', curved],
101
+ ['curved the other way', curvedBack],
102
+ ] as const) {
103
+ test(`recovers u and y on a ${label} wall, both faces`, () => {
104
+ for (const face of ['a', 'b'] as const) {
105
+ const surface = getWallFaceSurface(wall, face)
106
+ for (const [u, y] of [
107
+ [0.4, 0.3],
108
+ [2, 1.2],
109
+ [3.5, 2.1],
110
+ ] as const) {
111
+ const { origin, direction } = rayAt(surface, u, y)
112
+ const hit = intersectFaceSurface(surface, origin, direction)
113
+ expect(hit?.u).toBeCloseTo(u, 6)
114
+ expect(hit?.y).toBeCloseTo(y, 6)
115
+ }
116
+ }
117
+ })
118
+ }
119
+
120
+ test('misses a ray running parallel to a straight face or pointing away', () => {
121
+ const surface = getWallFaceSurface(straight, 'a')
122
+ expect(intersectFaceSurface(surface, { x: 0, y: 1, z: 2 }, { x: 1, y: 0, z: 0 })).toBeNull()
123
+ expect(intersectFaceSurface(surface, { x: 0, y: 1, z: 2 }, { x: 0, y: 0, z: 1 })).toBeNull()
124
+ })
125
+
126
+ test('a world ray maps through the wall-local frame of a rotated wall', () => {
127
+ const wall = WallNode.parse({ start: [1, 2], end: [1, 6], thickness: 0.2 })
128
+ const level = new Matrix4().makeTranslation(10, 3, -4)
129
+ const local = wallLocalMatrix(wall, 0.25)
130
+ const toWorld = level.clone().multiply(local)
131
+ const surface = getWallFaceSurface(wall, 'b')
132
+ const { origin, direction } = rayAt(surface, 2.5, 1.1)
133
+ const worldOrigin = new Vector3(origin.x, origin.y, origin.z).applyMatrix4(toWorld)
134
+ const worldTarget = new Vector3(
135
+ origin.x + direction.x,
136
+ origin.y + direction.y,
137
+ origin.z + direction.z,
138
+ ).applyMatrix4(toWorld)
139
+ const ray = new Ray(worldOrigin, worldTarget.clone().sub(worldOrigin).normalize())
140
+ ray.applyMatrix4(toWorld.clone().invert())
141
+ const hit = intersectFaceSurface(surface, ray.origin, ray.direction)
142
+ expect(hit?.u).toBeCloseTo(2.5, 6)
143
+ expect(hit?.y).toBeCloseTo(1.1, 6)
144
+ })
145
+ })
146
+
147
+ describe('bound drag', () => {
148
+ const region: WallFaceRegion = { id: 'r1', face: 'a', u0: 1, u1: 2, v1: 0.9, finish: 'x' }
149
+ const drag = (edge: 'u0' | 'u1' | 'v0' | 'v1', raw: number, extra = {}) =>
150
+ resolveRegionBoundDrag({ region, edge, raw, length: 4, faceHeight: 2.5, ...extra })
151
+
152
+ test('moves only the dragged bound', () => {
153
+ expect(drag('v1', 1.234)).toEqual({ u0: 1, u1: 2, v1: 1.234 })
154
+ expect(drag('u0', 0.5)).toEqual({ u0: 0.5, u1: 2, v1: 0.9 })
155
+ })
156
+
157
+ test('snaps to the grid step', () => {
158
+ expect(drag('v1', 1.234, { gridStep: 0.1 }).v1).toBeCloseTo(1.2, 9)
159
+ expect(drag('u1', 2.63, { gridStep: 0.25 }).u1).toBeCloseTo(2.75, 9)
160
+ })
161
+
162
+ test('keeps the minimum size against the opposite bound', () => {
163
+ expect(drag('u0', 1.995).u0).toBeCloseTo(2 - REGION_MIN_SIZE, 9)
164
+ expect(drag('u1', 0.2).u1).toBeCloseTo(1 + REGION_MIN_SIZE, 9)
165
+ expect(drag('v1', -1).v1).toBeCloseTo(REGION_MIN_SIZE, 9)
166
+ })
167
+
168
+ test('a bound dragged onto or past the face edge becomes absent', () => {
169
+ expect(drag('v1', 3)).toEqual({ u0: 1, u1: 2 })
170
+ expect(drag('u0', -0.5)).toEqual({ u1: 2, v1: 0.9 })
171
+ expect(drag('u1', 4)).toEqual({ u0: 1, v1: 0.9 })
172
+ })
173
+
174
+ test('an absent bound starts from the face edge when dragged in', () => {
175
+ expect(drag('v0', 0.3)).toEqual({ u0: 1, u1: 2, v0: 0.3, v1: 0.9 })
176
+ expect(drag('v0', 0.95).v0).toBeCloseTo(0.9 - REGION_MIN_SIZE, 9)
177
+ })
178
+
179
+ test('edge snapping pulls to other regions and the face edges, over the grid', () => {
180
+ const others: WallFaceRegion[] = [
181
+ region,
182
+ { id: 'r2', face: 'a', u0: 2.47, finish: 'y' },
183
+ { id: 'r3', face: 'b', u0: 2.6, finish: 'z' },
184
+ ]
185
+ const wall = { start: [0, 0], end: [4, 0], children: [], faceRegions: others } as never
186
+ const snapTargets = wallRegionSnapTargets(wall, 'a', {}, null, { excludeRegionId: 'r1' }).u
187
+ expect(snapTargets).toEqual([0, 2.47, 4])
188
+ expect(drag('u1', 2.52, { gridStep: 0.1, edgeSnap: true, snapTargets }).u1).toBe(2.47)
189
+ expect(drag('u1', 2.52, { gridStep: 0.1, snapTargets }).u1).toBeCloseTo(2.5, 9)
190
+ expect(drag('v0', 0.05, { edgeSnap: true })).toEqual({ u0: 1, u1: 2, v1: 0.9 })
191
+ })
192
+
193
+ test('withRegionBounds replaces one region and keeps the rest', () => {
194
+ const regions: WallFaceRegion[] = [region, { id: 'r2', face: 'b', v0: 1, finish: 'y' }]
195
+ expect(withRegionBounds(regions, 'r1', { u0: 0.5 })).toEqual([
196
+ { id: 'r1', face: 'a', u0: 0.5, finish: 'x' },
197
+ regions[1]!,
198
+ ])
199
+ })
200
+ })
201
+
202
+ describe('handles and outline', () => {
203
+ test('one handle per bounded edge, at mid-height / mid-width on the face', () => {
204
+ const surface = getWallFaceSurface(straight, 'a')
205
+ const placements = regionHandlePlacements(
206
+ surface,
207
+ { id: 'r', face: 'a', u0: 1, u1: 3, v1: 0.9, finish: 'x' },
208
+ 2.5,
209
+ flat,
210
+ null,
211
+ 0,
212
+ )
213
+ expect(placements.map((p) => p.edge)).toEqual(['u0', 'u1', 'v1'])
214
+ expect(placements[0]!.position).toEqual([1, 0.45, 0.1])
215
+ expect(placements[2]!.position).toEqual([2, 0.9, 0.1])
216
+ expect(placements[2]!.tipAngle).toBeCloseTo(Math.PI / 2, 9)
217
+ })
218
+
219
+ test('a dragged edge keeps its handle after it goes absent', () => {
220
+ const surface = getWallFaceSurface(straight, 'b')
221
+ const region: WallFaceRegion = { id: 'r', face: 'b', u0: 1, finish: 'x' }
222
+ expect(regionHandlePlacements(surface, region, 2.5, flat).map((p) => p.edge)).toEqual(['u0'])
223
+ expect(regionHandlePlacements(surface, region, 2.5, flat, 'v1').map((p) => p.edge)).toEqual([
224
+ 'u0',
225
+ 'v1',
226
+ ])
227
+ })
228
+
229
+ test('v follows the face base', () => {
230
+ const surface = getWallFaceSurface(straight, 'a')
231
+ const [handle] = regionHandlePlacements(
232
+ surface,
233
+ { id: 'r', face: 'a', v1: 1, finish: 'x' },
234
+ 2.5,
235
+ () => -0.3,
236
+ )
237
+ expect(handle!.position[1]).toBeCloseTo(0.7, 9)
238
+ })
239
+
240
+ test('the outline of a curved region hugs the face', () => {
241
+ const surface = getWallFaceSurface(curved, 'a')
242
+ const outline = regionOutline(surface, { id: 'r', face: 'a', v1: 1, finish: 'x' }, 2.5, flat, 0)
243
+ expect(outline.length).toBeGreaterThan(4)
244
+ for (const point of outline) {
245
+ expect(point.position[2]).toBeCloseTo(faceSurfacePoint(surface, point.position[0]).z, 6)
246
+ }
247
+ const ribbon = buildOutlineRibbon(outline, 0.01)
248
+ expect(ribbon.length).toBe(outline.length * 6 * 3)
249
+ })
250
+ })
251
+
252
+ describe('commit', () => {
253
+ const WALL_ID = 'wall_region-handles-commit' as AnyNodeId
254
+ const wall = () => useScene.getState().nodes[WALL_ID] as WallNode
255
+
256
+ beforeEach(() => {
257
+ useScene.setState({ nodes: {}, rootNodeIds: [], dirtyNodes: new Set() } as never)
258
+ useScene.getState().createNode(
259
+ WallNode.parse({
260
+ id: WALL_ID,
261
+ start: [0, 0],
262
+ end: [4, 0],
263
+ faceRegions: [
264
+ { id: 'r1', face: 'a', v1: 0.9, finish: 'library:paint-white' },
265
+ { id: 'r2', face: 'b', u0: 1, u1: 2, finish: 'library:paint-white' },
266
+ ],
267
+ }),
268
+ )
269
+ useScene.temporal.getState().clear()
270
+ useScene.temporal.getState().resume()
271
+ })
272
+
273
+ test('a drag previews live and commits as one undo step', () => {
274
+ const regions = wall().faceRegions!
275
+ useScene.temporal.getState().pause()
276
+ const bounds = resolveRegionBoundDrag({
277
+ region: regions[0]!,
278
+ edge: 'v1',
279
+ raw: 1.2,
280
+ length: 4,
281
+ faceHeight: 2.5,
282
+ })
283
+ useLiveNodeOverrides
284
+ .getState()
285
+ .set(WALL_ID, { faceRegions: withRegionBounds(regions, 'r1', bounds) })
286
+ expect(wall().faceRegions![0]!.v1).toBe(0.9)
287
+
288
+ commitHandleDragPatch({
289
+ patch: bounds,
290
+ resumeHistory: () => useScene.temporal.getState().resume(),
291
+ runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
292
+ commit: (next) => updateWallRegion(WALL_ID, 'r1', next),
293
+ })
294
+ useLiveNodeOverrides.getState().clear(WALL_ID)
295
+
296
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
297
+ expect(wall().faceRegions).toEqual([
298
+ { id: 'r1', face: 'a', v1: 1.2, finish: 'library:paint-white' },
299
+ regions[1]!,
300
+ ])
301
+ useScene.temporal.getState().undo()
302
+ expect(wall().faceRegions![0]!.v1).toBe(0.9)
303
+ })
304
+ })