@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,165 @@
1
+ import { expect, test } from 'bun:test'
2
+ import {
3
+ floorLimitAt,
4
+ glideFloorLimit,
5
+ LOOK_UP_PITCH_GAIN,
6
+ LOOK_UP_PUSH_HALF_ANGLE,
7
+ MAX_ORBIT_POLAR_ANGLE,
8
+ ORBIT_FLOOR_CLEARANCE,
9
+ ORBIT_TARGET_HEIGHT,
10
+ orbitAngleBelowFloor,
11
+ orbitCameraAboveFloor,
12
+ } from './orbit-floor-clearance'
13
+
14
+ type Vec3 = { x: number; y: number; z: number }
15
+
16
+ const FLOOR_Y = 3
17
+ const MIN_Y = FLOOR_Y + ORBIT_FLOOR_CLEARANCE
18
+ const target: Vec3 = { x: 1, y: FLOOR_Y, z: -2 }
19
+
20
+ // camera-controls' orbit: polar 0 looks straight down, PI/2 is level with the target.
21
+ function orbit(distance: number, polar: number, azimuth = 0.4): Vec3 {
22
+ return {
23
+ x: target.x + distance * Math.sin(polar) * Math.sin(azimuth),
24
+ y: target.y + distance * Math.cos(polar),
25
+ z: target.z + distance * Math.sin(polar) * Math.cos(azimuth),
26
+ }
27
+ }
28
+
29
+ function present(position: Vec3) {
30
+ const out = { x: Number.NaN, y: Number.NaN, z: Number.NaN }
31
+ const polar = orbitCameraAboveFloor(out, position, target, MIN_Y)
32
+ return { grounded: out.y === MIN_Y && position.y < MIN_Y, out, polar }
33
+ }
34
+
35
+ const horizontalDistance = (p: Vec3) => Math.hypot(p.x - target.x, p.z - target.z)
36
+ const DEGREE = Math.PI / 180
37
+
38
+ test('an orbit above the clearance is drawn exactly where it is', () => {
39
+ const position = orbit(10, Math.PI / 3)
40
+ const { grounded, out, polar } = present(position)
41
+ expect(grounded).toBe(false)
42
+ expect(out).toEqual(position)
43
+ expect(polar).toBeCloseTo(Math.PI / 3, 9)
44
+ })
45
+
46
+ test('tilting past the horizon keeps the camera on the floor clearance', () => {
47
+ for (const distance of [2, 8, 40]) {
48
+ for (const polar of [Math.PI / 2, 0.6 * Math.PI, 0.75 * Math.PI, Math.PI - 0.05]) {
49
+ const position = orbit(distance, polar)
50
+ const { grounded, out } = present(position)
51
+ expect(grounded).toBe(true)
52
+ expect(out.y).toBeCloseTo(MIN_Y, 9)
53
+ }
54
+ }
55
+ })
56
+
57
+ test('past the clearance the view pitches up faster than the drag and stops at straight up', () => {
58
+ for (const distance of [2.2, 8, 40]) {
59
+ const contactPolar = Math.acos(ORBIT_FLOOR_CLEARANCE / distance)
60
+ for (let past = 0; past <= 60 * DEGREE; past += DEGREE) {
61
+ const { polar } = present(orbit(distance, contactPolar + past))
62
+ expect(polar).toBeCloseTo(
63
+ Math.min(contactPolar + past * LOOK_UP_PITCH_GAIN, MAX_ORBIT_POLAR_ANGLE),
64
+ 9,
65
+ )
66
+ }
67
+ // From level to (almost) straight up within ~40° of drag.
68
+ const straightUp = (MAX_ORBIT_POLAR_ANGLE - contactPolar) / LOOK_UP_PITCH_GAIN
69
+ expect(straightUp).toBeLessThan(95 * DEGREE)
70
+ // And stops there: more drag changes nothing.
71
+ const atTop = present(orbit(distance, contactPolar + straightUp))
72
+ const beyond = present(orbit(distance, contactPolar + straightUp + 5 * DEGREE))
73
+ expect(beyond.polar).toBeCloseTo(MAX_ORBIT_POLAR_ANGLE, 9)
74
+ expect(horizontalDistance(beyond.out)).toBeCloseTo(horizontalDistance(atTop.out), 9)
75
+ }
76
+ })
77
+
78
+ test('past the clearance the camera closes on its target gently, at the half-angle rate', () => {
79
+ for (const distance of [2.2, 8, 40]) {
80
+ const contactPolar = Math.acos(ORBIT_FLOOR_CLEARANCE / distance)
81
+ const contactReach = horizontalDistance(present(orbit(distance, contactPolar)).out)
82
+ let previous = contactReach
83
+ const straightUp = (MAX_ORBIT_POLAR_ANGLE - contactPolar) / LOOK_UP_PITCH_GAIN
84
+ for (let polar = contactPolar + 0.01; polar <= contactPolar + straightUp; polar += 0.01) {
85
+ const position = orbit(distance, polar)
86
+ expect(orbitAngleBelowFloor(position, target, MIN_Y)).toBeCloseTo(polar - contactPolar, 9)
87
+ const reach = horizontalDistance(present(position).out)
88
+ expect(reach).toBeCloseTo(
89
+ contactReach * 0.5 ** ((polar - contactPolar) / LOOK_UP_PUSH_HALF_ANGLE),
90
+ 9,
91
+ )
92
+ expect(reach).toBeLessThan(previous)
93
+ previous = reach
94
+ }
95
+ // By the time the view looks straight up it has closed at most ~25% of the way.
96
+ const there = horizontalDistance(present(orbit(distance, contactPolar + straightUp)).out)
97
+ expect(1 - there / contactReach).toBeLessThan(0.75)
98
+ expect(1 - there / contactReach).toBeGreaterThan(0.35)
99
+ }
100
+ })
101
+
102
+ test('crossing the clearance is continuous, so orbiting back up has no jump', () => {
103
+ const distance = 6
104
+ // The polar angle at which the orbit itself touches the clearance.
105
+ const contact = Math.acos(ORBIT_FLOOR_CLEARANCE / distance)
106
+ const before = present(orbit(distance, contact - 1e-6)).out
107
+ const after = present(orbit(distance, contact + 1e-6)).out
108
+ expect(Math.hypot(after.x - before.x, after.y - before.y, after.z - before.z)).toBeLessThan(1e-4)
109
+ })
110
+
111
+ test('zooming in while grounded still moves the camera toward the target', () => {
112
+ const polar = 0.8 * Math.PI
113
+ let previous = Number.POSITIVE_INFINITY
114
+ for (let distance = 40; distance >= 2; distance -= 2) {
115
+ const reach = horizontalDistance(present(orbit(distance, polar)).out)
116
+ expect(reach).toBeLessThan(previous)
117
+ previous = reach
118
+ }
119
+ })
120
+
121
+ test('with the pivot at standing height, a close orbit looks around before the floor stops it', () => {
122
+ const pivot = { x: 0, y: FLOOR_Y + ORBIT_TARGET_HEIGHT, z: 0 }
123
+ const distance = 2
124
+ const at = (polar: number) => ({
125
+ x: distance * Math.sin(polar),
126
+ y: pivot.y + distance * Math.cos(polar),
127
+ z: 0,
128
+ })
129
+ // Level with the pivot and a little past it: a free orbit at about eye
130
+ // height, already looking up, nothing pushed.
131
+ for (const polar of [Math.PI / 2, 100 * DEGREE, 105 * DEGREE]) {
132
+ const out = { x: 0, y: 0, z: 0 }
133
+ expect(orbitCameraAboveFloor(out, at(polar), pivot, MIN_Y)).toBeCloseTo(polar, 9)
134
+ expect(out).toEqual(at(polar))
135
+ }
136
+ // The clearance takes over only where the orbit would reach it.
137
+ const contact = Math.acos((MIN_Y - pivot.y) / distance)
138
+ expect(contact).toBeGreaterThan(105 * DEGREE)
139
+ const out = { x: 0, y: 0, z: 0 }
140
+ orbitCameraAboveFloor(out, at(contact + 5 * DEGREE), pivot, MIN_Y)
141
+ expect(out.y).toBeCloseTo(MIN_Y, 9)
142
+ })
143
+
144
+ test('the floor limit sits the clearance above the surface under the camera, or the level floor', () => {
145
+ // A raised platform 0.45 m above a level at 2.71.
146
+ expect(floorLimitAt(3.16, 2.71)).toBeCloseTo(3.16 + ORBIT_FLOOR_CLEARANCE, 9)
147
+ // A sunken floor below the level's own floor.
148
+ expect(floorLimitAt(2.21, 2.71)).toBeCloseTo(2.21 + ORBIT_FLOOR_CLEARANCE, 9)
149
+ // Nothing under the camera (outside, no terrain): the level floor, never 0.
150
+ expect(floorLimitAt(null, 2.71)).toBeCloseTo(2.71 + ORBIT_FLOOR_CLEARANCE, 9)
151
+ expect(floorLimitAt(null, 0)).toBe(ORBIT_FLOOR_CLEARANCE)
152
+ })
153
+
154
+ test('a change in floor height is glided over, never popped', () => {
155
+ expect(glideFloorLimit(null, 1, 1 / 60)).toBe(1)
156
+ let limit = 0.3
157
+ let previous = limit
158
+ for (let frame = 0; frame < 120; frame++) {
159
+ limit = glideFloorLimit(limit, 0.75, 1 / 60)
160
+ expect(limit).toBeGreaterThan(previous)
161
+ expect(limit - previous).toBeLessThan(0.07)
162
+ previous = limit
163
+ }
164
+ expect(limit).toBeCloseTo(0.75, 6)
165
+ })
@@ -0,0 +1,106 @@
1
+ type Vec3 = { x: number; y: number; z: number }
2
+
3
+ /** The lowest an orbiting camera sits above the active level's floor. */
4
+ export const ORBIT_FLOOR_CLEARANCE = 0.45
5
+
6
+ /**
7
+ * How high above the active level's floor the orbit's pivot rests when the
8
+ * editor aims it at the ground (level switch, panning, framing, focusing a
9
+ * floor or room): about standing eye height, so zoomed all the way in the
10
+ * camera looks around the room the way a walkthrough does.
11
+ */
12
+ export const ORBIT_TARGET_HEIGHT = 1.1
13
+
14
+ const DEGREE = Math.PI / 180
15
+
16
+ /** The steepest an orbit camera looks: straight down is 0, straight up π. */
17
+ export const MAX_ORBIT_POLAR_ANGLE = Math.PI - 0.05
18
+
19
+ /**
20
+ * Past the point where the camera reached the floor clearance, the view
21
+ * pitches up this many times faster than the orbit drag: about 80° more drag
22
+ * takes it from level to straight up.
23
+ */
24
+ export const LOOK_UP_PITCH_GAIN = 1.1
25
+
26
+ /**
27
+ * The look-up push: every this many degrees of drag past the clearance, the
28
+ * camera's horizontal distance to its target halves. Over the ~80° it takes
29
+ * to look straight up, the camera closes about two thirds of the way in.
30
+ */
31
+ export const LOOK_UP_PUSH_HALF_ANGLE = 55 * DEGREE
32
+
33
+ /**
34
+ * How far the orbit has tilted past the point where its camera reaches
35
+ * `minY` (radians, negative while it is still above). Zero exactly when the
36
+ * orbit camera sits at `minY`.
37
+ */
38
+ export function orbitAngleBelowFloor(position: Vec3, target: Vec3, minY: number): number {
39
+ const horizontal = Math.hypot(position.x - target.x, position.z - target.z)
40
+ const height = position.y - target.y
41
+ const distance = Math.hypot(horizontal, height)
42
+ if (distance < 1e-9) return 0
43
+ const polar = Math.atan2(horizontal, height)
44
+ const contactPolar = Math.acos(Math.max(-1, Math.min(1, (minY - target.y) / distance)))
45
+ return polar - contactPolar
46
+ }
47
+
48
+ /**
49
+ * Where to draw an orbit camera so it never drops below `minY`. Above it the
50
+ * orbit pose stands. An orbit that would take it lower keeps the camera on
51
+ * `minY`, pitches the view up `LOOK_UP_PITCH_GAIN` times faster than the
52
+ * drag (stopping at straight up), and pushes the camera gently toward its
53
+ * target at the `LOOK_UP_PUSH_HALF_ANGLE` rate. A pure function of the orbit
54
+ * state, so orbiting back up returns the exact orbit pose.
55
+ *
56
+ * Writes the drawn position into `out` and returns the drawn polar angle.
57
+ */
58
+ export function orbitCameraAboveFloor(
59
+ out: Vec3,
60
+ position: Vec3,
61
+ target: Vec3,
62
+ minY: number,
63
+ ): number {
64
+ const dx = position.x - target.x
65
+ const dz = position.z - target.z
66
+ const horizontal = Math.hypot(dx, dz)
67
+ const height = position.y - target.y
68
+ if (!(position.y < minY)) {
69
+ out.x = position.x
70
+ out.y = position.y
71
+ out.z = position.z
72
+ return Math.atan2(horizontal, height)
73
+ }
74
+
75
+ const rise = minY - target.y
76
+ const distance = Math.hypot(horizontal, height)
77
+ const contactRadius = Math.sqrt(Math.max(0, distance * distance - rise * rise))
78
+ const below = Math.max(0, orbitAngleBelowFloor(position, target, minY))
79
+ const contactPolar = Math.atan2(horizontal, height) - below
80
+ // Nothing moves once the view looks straight up.
81
+ const pastContact = Math.min(below, (MAX_ORBIT_POLAR_ANGLE - contactPolar) / LOOK_UP_PITCH_GAIN)
82
+ const reach = contactRadius * 0.5 ** (pastContact / LOOK_UP_PUSH_HALF_ANGLE)
83
+ const scale = horizontal > 1e-9 ? reach / horizontal : 0
84
+ out.x = target.x + dx * scale
85
+ out.y = minY
86
+ out.z = target.z + dz * scale
87
+ return contactPolar + pastContact * LOOK_UP_PITCH_GAIN
88
+ }
89
+
90
+ // How quickly the floor limit follows a change in the floor under the camera
91
+ // (stepping over a raised platform's edge): about this many seconds per e-fold.
92
+ const FLOOR_GLIDE_SECONDS = 0.12
93
+
94
+ /**
95
+ * The lowest the camera may sit: the clearance above the floor surface under
96
+ * it, or above the level's own floor when there is none (outside, no terrain).
97
+ */
98
+ export function floorLimitAt(surfaceTop: number | null, levelFloorY: number): number {
99
+ return (surfaceTop ?? levelFloorY) + ORBIT_FLOOR_CLEARANCE
100
+ }
101
+
102
+ /** Eases a floor limit toward `target` over `delta` seconds, so it never pops. */
103
+ export function glideFloorLimit(current: number | null, target: number, delta: number): number {
104
+ if (current === null || !(delta > 0)) return current ?? target
105
+ return target + (current - target) * Math.exp(-delta / FLOOR_GLIDE_SECONDS)
106
+ }
@@ -0,0 +1,28 @@
1
+ import { test } from 'bun:test'
2
+ import assert from 'node:assert/strict'
3
+ import { Group } from 'three'
4
+ import { copyOverlayWorldTransform } from './overlay-transform'
5
+
6
+ test('opening overlays retain the source transform through God-mode zoom and rotation', () => {
7
+ const model = new Group()
8
+ const wall = new Group()
9
+ const opening = new Group()
10
+ model.add(wall)
11
+ wall.add(opening)
12
+ wall.rotation.y = 0.8
13
+ opening.position.set(2, 1.5, 0.1)
14
+ const portal = new Group()
15
+ portal.position.set(4, 1, -2)
16
+ portal.rotation.y = -0.4
17
+ const overlay = new Group()
18
+ portal.add(overlay)
19
+ for (const scale of [0.01, 0.2, 1, 4, 0.05]) {
20
+ model.scale.set(scale, scale * 2, scale)
21
+ model.rotation.y += 0.2
22
+ copyOverlayWorldTransform(opening, overlay)
23
+ overlay.updateWorldMatrix(true, false)
24
+ overlay.matrixWorld.elements.forEach((value, i) => {
25
+ assert.ok(Math.abs(value - opening.matrixWorld.elements[i]!) < 1e-8)
26
+ })
27
+ }
28
+ })
@@ -0,0 +1,12 @@
1
+ import type { Object3D } from 'three'
2
+
3
+ /** Preserve scale and shear when an overlay is portalled outside its source hierarchy. */
4
+ export function copyOverlayWorldTransform(source: Object3D, overlay: Object3D) {
5
+ source.updateWorldMatrix(true, false)
6
+ overlay.parent?.updateWorldMatrix(true, false)
7
+ overlay.matrixAutoUpdate = false
8
+ if (overlay.parent)
9
+ overlay.matrix.copy(overlay.parent.matrixWorld).invert().multiply(source.matrixWorld)
10
+ else overlay.matrix.copy(source.matrixWorld)
11
+ overlay.matrixWorldNeedsUpdate = true
12
+ }
@@ -0,0 +1,37 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { paintCommitRoute } from './paint-commit-route'
3
+
4
+ test('only the hovered surface itself keeps the kind commit', () => {
5
+ expect(paintCommitRoute([], 'wall_a', 'a')).toBe('own')
6
+ expect(paintCommitRoute([{ nodeId: 'wall_a', role: 'a' }], 'wall_a', 'a')).toBe('own')
7
+ // A one-wall room: one target, but on the room role.
8
+ expect(paintCommitRoute([{ nodeId: 'wall_a', role: 'room:zone_1' }], 'wall_a', 'a')).toBe(
9
+ 'fanout',
10
+ )
11
+ expect(paintCommitRoute([{ nodeId: 'wall_b', role: 'a' }], 'wall_a', 'a')).toBe('fanout')
12
+ expect(
13
+ paintCommitRoute(
14
+ [
15
+ { nodeId: 'wall_a', role: 'a' },
16
+ { nodeId: 'wall_b', role: 'a' },
17
+ ],
18
+ 'wall_a',
19
+ 'a',
20
+ ),
21
+ ).toBe('fanout')
22
+ })
23
+
24
+ for (const role of ['step:zone_upper', 'edge:zone_upper'])
25
+ test(`${role} retains the room side role through paint routing`, () => {
26
+ expect(paintCommitRoute([{ nodeId: 'slab_platform', role }], 'slab_platform', role)).toBe('own')
27
+ expect(
28
+ paintCommitRoute(
29
+ [
30
+ { nodeId: 'slab_platform', role },
31
+ { nodeId: 'slab_component', role },
32
+ ],
33
+ 'slab_platform',
34
+ role,
35
+ ),
36
+ ).toBe('fanout')
37
+ })
@@ -0,0 +1,17 @@
1
+ /**
2
+ * How a paint click commits its resolved spread. Only a spread that is exactly
3
+ * the hovered surface keeps the kind's own commit; anything else — several
4
+ * targets, or one target on another role such as a whole room's
5
+ * `room:<zoneId>` role — goes through the fan-out, which routes each role to
6
+ * the kind that owns it. A one-wall room therefore needs no second target to
7
+ * reach its room role.
8
+ */
9
+ export function paintCommitRoute(
10
+ targets: ReadonlyArray<{ nodeId: string; role: string }>,
11
+ nodeId: string,
12
+ role: string,
13
+ ): 'own' | 'fanout' {
14
+ if (targets.length === 0) return 'own'
15
+ const [only] = targets
16
+ return targets.length === 1 && only!.nodeId === nodeId && only!.role === role ? 'own' : 'fanout'
17
+ }
@@ -0,0 +1,165 @@
1
+ import { expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ nodeRegistry,
5
+ registerNode,
6
+ SlabNode,
7
+ useScene,
8
+ WallNode,
9
+ } from '@pascal-app/core'
10
+ import { useNodeEvents, useViewer } from '@pascal-app/viewer'
11
+ import { _roots, act, events } from '@react-three/fiber'
12
+ import type { OrthographicCamera } from 'three'
13
+ import { z } from 'zod'
14
+ import { SelectionManager, useEditor } from '../index'
15
+ import { withSelectionHarness } from '../test-utils/selection-harness'
16
+ import { resetPaintMode, usePaintRegionMode } from './paint-region-mode'
17
+
18
+ const picked = { materialPreset: 'library:wall-red', sourceTarget: 'wall' } as const
19
+
20
+ function Surface({ node }: { node: AnyNode }) {
21
+ const wall = node.type === 'wall'
22
+ return (
23
+ <mesh
24
+ {...useNodeEvents(node, node.type)}
25
+ name={node.id}
26
+ position={wall ? [0, 1.5, 0] : [0, 0, 0]}
27
+ rotation={wall ? [0, 0, 0] : [-Math.PI / 2, 0, 0]}
28
+ >
29
+ <planeGeometry args={wall ? [20, 3] : [20, 20]} />
30
+ <meshBasicMaterial />
31
+ </mesh>
32
+ )
33
+ }
34
+
35
+ for (const activation of ['icon', 'Alt'] as const) {
36
+ test(`${activation} eyedrop consumes the front wall click; only the next click paints the slab behind`, async () => {
37
+ const restoreRegistry = nodeRegistry._snapshot()
38
+ const previousPaint = usePaintRegionMode.getState()
39
+ const previousDocument = globalThis.document
40
+ const previousCancel = globalThis.cancelAnimationFrame
41
+ globalThis.cancelAnimationFrame = () => {}
42
+ globalThis.document = { body: { style: { cursor: '' } } } as unknown as Document
43
+ nodeRegistry._reset()
44
+ const writes: string[] = []
45
+ try {
46
+ for (const kind of ['wall', 'slab']) {
47
+ registerNode({
48
+ kind,
49
+ schemaVersion: 1,
50
+ schema: z.object({ type: z.literal(kind) }) as never,
51
+ category: 'structure',
52
+ defaults: () => ({}),
53
+ capabilities: {
54
+ slots: () => [{ slotId: 'default', label: 'Surface' }],
55
+ paint: {
56
+ resolveRole: () => 'default',
57
+ getEffectiveMaterial: () => ({
58
+ material: undefined,
59
+ materialPreset: picked.materialPreset,
60
+ }),
61
+ buildPatch: ({ materialPreset }) => ({ materialPreset }) as Partial<AnyNode>,
62
+ commit: ({ node, materialPreset }) => {
63
+ writes.push(node.id)
64
+ useScene.getState().updateNode(node.id, { materialPreset } as Partial<AnyNode>)
65
+ },
66
+ applyPreview: () => () => {},
67
+ },
68
+ },
69
+ })
70
+ }
71
+ await withSelectionHarness(async ({ canvas, render }) => {
72
+ const wall = WallNode.parse({ id: 'wall_front', start: [-10, 0], end: [10, 0] })
73
+ const slab = SlabNode.parse({
74
+ id: 'slab_behind',
75
+ polygon: [
76
+ [-10, -10],
77
+ [10, -10],
78
+ [10, 10],
79
+ [-10, 10],
80
+ ],
81
+ })
82
+ useScene.setState({ nodes: { [wall.id]: wall, [slab.id]: slab }, readOnly: false })
83
+ useViewer.getState().resetSelection()
84
+ useViewer.setState({ cameraDragging: false, inputDragging: false })
85
+ resetPaintMode()
86
+ useEditor.getState().armMaterialPaint(undefined, activation === 'icon' ? 'pick' : 'surface')
87
+ await render(
88
+ <>
89
+ <SelectionManager />
90
+ <Surface node={wall} />
91
+ <Surface node={slab} />
92
+ </>,
93
+ )
94
+ if (activation === 'Alt') {
95
+ await act(async () => {
96
+ window.dispatchEvent(Object.assign(new Event('keydown'), { key: 'Alt', repeat: false }))
97
+ })
98
+ }
99
+ expect(usePaintRegionMode.getState().mode).toBe('pick')
100
+ const store = _roots.get(canvas)!.store
101
+ const state = store.getState()
102
+ const camera = state.camera as OrthographicCamera
103
+ Object.assign(camera, { left: -5, right: 5, top: 5, bottom: -5 })
104
+ camera.position.set(0, 5, 10)
105
+ camera.up.set(0, 1, 0)
106
+ camera.lookAt(0, 1, 0)
107
+ camera.updateProjectionMatrix()
108
+ camera.updateMatrixWorld(true)
109
+ state.setEvents(events(store))
110
+ state.scene.updateMatrixWorld(true)
111
+ const send = async (type: 'onPointerMove' | 'onPointerDown' | 'onPointerUp') => {
112
+ await act(async () => {
113
+ store.getState().events.handlers![type]({
114
+ offsetX: 500,
115
+ offsetY: 500,
116
+ clientX: 500,
117
+ clientY: 500,
118
+ button: 0,
119
+ pointerId: 1,
120
+ target: canvas,
121
+ type,
122
+ } as unknown as PointerEvent)
123
+ })
124
+ }
125
+ // Hover first: sampling synchronously replays this event when it returns to paint.
126
+ await send('onPointerMove')
127
+ expect(useViewer.getState().hoveredId).toBe(wall.id)
128
+ const hits = state.raycaster.intersectObjects(state.internal.interaction, true)
129
+ expect([...new Set(hits.map((hit) => hit.object.name))]).toEqual([wall.id, slab.id])
130
+ const before = useScene.getState().nodes
131
+ await send('onPointerDown')
132
+ await send('onPointerUp')
133
+ expect(useEditor.getState().activePaintMaterial).toEqual(picked)
134
+ expect(usePaintRegionMode.getState().mode).toBe('surface')
135
+ expect(writes).toEqual([])
136
+ expect(useScene.getState().nodes).toBe(before)
137
+ if (activation === 'Alt') {
138
+ await act(async () =>
139
+ window.dispatchEvent(Object.assign(new Event('keyup'), { key: 'Alt' })),
140
+ )
141
+ }
142
+ // Expose the slab at the same cursor, then prove the next gesture paints once.
143
+ await render(
144
+ <>
145
+ <SelectionManager />
146
+ <Surface node={slab} />
147
+ </>,
148
+ )
149
+ state.scene.updateMatrixWorld(true)
150
+ await send('onPointerMove')
151
+ await send('onPointerDown')
152
+ await send('onPointerUp')
153
+ expect(writes).toEqual([slab.id])
154
+ expect((useScene.getState().nodes[slab.id] as SlabNode).materialPreset).toBe(
155
+ picked.materialPreset,
156
+ )
157
+ })
158
+ } finally {
159
+ restoreRegistry()
160
+ usePaintRegionMode.setState(previousPaint)
161
+ globalThis.document = previousDocument
162
+ globalThis.cancelAnimationFrame = previousCancel
163
+ }
164
+ })
165
+ }
@@ -0,0 +1,99 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type MaterialSchema,
5
+ nodeRegistry,
6
+ type SceneMaterial,
7
+ type SceneMaterialId,
8
+ slotDefaultPaintMaterial,
9
+ } from '@pascal-app/core'
10
+ import type { Material, Object3D } from 'three'
11
+ import {
12
+ type ActivePaintMaterial,
13
+ hasActivePaintMaterial,
14
+ type PaintableMaterialTarget,
15
+ resolveActivePaintMaterialFromSelection,
16
+ } from './material-paint'
17
+
18
+ type RenderedMaterial = Material & {
19
+ color?: { getHexString: () => string }
20
+ roughness?: number
21
+ metalness?: number
22
+ }
23
+
24
+ /** The drawn material of a mesh hit, for a slot that carries no finish of its own (an item's GLB). */
25
+ function drawnMaterial(hitObject: Object3D | undefined, materialIndex: number | null) {
26
+ const material = (hitObject as { material?: Material | Material[] } | undefined)?.material
27
+ const drawn = (Array.isArray(material) ? material[materialIndex ?? 0] : material) as
28
+ | RenderedMaterial
29
+ | undefined
30
+ if (!drawn?.color) return null
31
+ return {
32
+ preset: 'custom',
33
+ properties: {
34
+ color: `#${drawn.color.getHexString()}`,
35
+ roughness: drawn.roughness ?? 0.5,
36
+ metalness: drawn.metalness ?? 0,
37
+ opacity: drawn.opacity ?? 1,
38
+ transparent: drawn.transparent ?? false,
39
+ side: 'front',
40
+ },
41
+ } satisfies MaterialSchema
42
+ }
43
+
44
+ /**
45
+ * The material the eyedropper takes from `role` of `node`: what the surface
46
+ * shows, read through the paint path — the kind's `getEffectiveMaterial` (the
47
+ * finish a room, region or override gives it, with derived roles falling to
48
+ * what they draw), the legacy per-kind arms, then the slot's declared default
49
+ * (`node.slots[slot]` → `default`, the renderer's own chain). A slot with none
50
+ * of those (an item's GLB material) is read off the drawn mesh.
51
+ */
52
+ export function eyedropperMaterial(args: {
53
+ node: AnyNode
54
+ role: string
55
+ nodes: Record<string, AnyNode>
56
+ materials?: Record<SceneMaterialId, SceneMaterial>
57
+ hitObject?: Object3D
58
+ materialIndex?: number | null
59
+ }): ActivePaintMaterial | null {
60
+ const { node, role, nodes } = args
61
+ const definition = nodeRegistry.get(node.type)
62
+ const paint = definition?.capabilities?.paint
63
+ const sourceTarget = (paint?.materialTarget ?? node.type) as PaintableMaterialTarget
64
+ const valid = (value: { material?: MaterialSchema; materialPreset?: string } | null) =>
65
+ value && hasActivePaintMaterial({ ...value, sourceTarget })
66
+ ? { material: value.material, materialPreset: value.materialPreset, sourceTarget }
67
+ : null
68
+
69
+ const effective =
70
+ valid(
71
+ paint?.getEffectiveMaterial?.({
72
+ node,
73
+ role,
74
+ nodes: nodes as Record<AnyNodeId, AnyNode>,
75
+ rendered: true,
76
+ materials: args.materials,
77
+ }) ?? null,
78
+ ) ??
79
+ resolveActivePaintMaterialFromSelection({
80
+ nodes,
81
+ selectedId: node.id,
82
+ materials: args.materials,
83
+ selectedMaterialTarget: { nodeId: node.id, role },
84
+ })
85
+ if (effective) return effective
86
+
87
+ const declared = definition?.capabilities?.slots?.(node).find((slot) => slot.slotId === role)
88
+ const fallback = valid(slotDefaultPaintMaterial(declared?.default))
89
+ if (fallback) return fallback
90
+
91
+ const drawn = drawnMaterial(args.hitObject, args.materialIndex ?? null)
92
+ return drawn ? { material: drawn, sourceTarget } : null
93
+ }
94
+
95
+ /** A stable key for a picked material, so an unchanged pick is not re-announced. */
96
+ export function paintMaterialKey(material: ActivePaintMaterial | null): string {
97
+ if (!material) return ''
98
+ return material.materialPreset ?? JSON.stringify(material.material ?? null)
99
+ }