@pascal-app/editor 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (624) hide show
  1. package/README.md +106 -0
  2. package/package.json +42 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/action-menu-button.tsx +83 -0
  5. package/src/components/editor/bake-thumbnail.tsx +307 -5
  6. package/src/components/editor/capture-level-picker.tsx +72 -0
  7. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  8. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  9. package/src/components/editor/custom-camera-controls.tsx +275 -57
  10. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  11. package/src/components/editor/duplicate-node.ts +244 -0
  12. package/src/components/editor/editor-layout-mobile.tsx +37 -26
  13. package/src/components/editor/editor-layout-v2.tsx +132 -59
  14. package/src/components/editor/export-manager.tsx +47 -26
  15. package/src/components/editor/first-person/build-collider-world.test.ts +320 -0
  16. package/src/components/editor/first-person/build-collider-world.ts +28 -7
  17. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  18. package/src/components/editor/first-person/ground-holes.ts +130 -0
  19. package/src/components/editor/first-person/mechanism-targets.test.ts +162 -0
  20. package/src/components/editor/first-person/mechanism-targets.ts +88 -0
  21. package/src/components/editor/first-person-controls.tsx +220 -9
  22. package/src/components/editor/floating-action-menu.tsx +63 -207
  23. package/src/components/editor/floor-aware-camera-controls.test.ts +466 -0
  24. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  25. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  26. package/src/components/editor/floor-region-controls.tsx +846 -0
  27. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  28. package/src/components/editor/floor-surface.test.ts +165 -0
  29. package/src/components/editor/floor-surface.ts +182 -0
  30. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  31. package/src/components/editor/floorplan-background-selection.ts +1 -3
  32. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  33. package/src/components/editor/floorplan-panel.tsx +414 -363
  34. package/src/components/editor/follow-pascal.test.tsx +912 -0
  35. package/src/components/editor/follow-pascal.tsx +354 -0
  36. package/src/components/editor/footprint-height-handle.tsx +204 -0
  37. package/src/components/editor/group-actions.ts +56 -2
  38. package/src/components/editor/group-move-3d.ts +2 -0
  39. package/src/components/editor/group-rotate-handle.tsx +2 -0
  40. package/src/components/editor/group-transform-shared.test.ts +460 -0
  41. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  42. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  43. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  44. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  45. package/src/components/editor/index.tsx +409 -197
  46. package/src/components/editor/join-walls-pill.tsx +44 -0
  47. package/src/components/editor/node-action-menu.tsx +68 -70
  48. package/src/components/editor/node-arrow-handles.tsx +25 -6
  49. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  50. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  51. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  52. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  53. package/src/components/editor/registry-action-contributions.tsx +80 -1
  54. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  55. package/src/components/editor/room-controls.tsx +847 -0
  56. package/src/components/editor/room-floor-highlight.tsx +128 -0
  57. package/src/components/editor/room-handles-2d.tsx +354 -0
  58. package/src/components/editor/room-handles.tsx +675 -0
  59. package/src/components/editor/room-highlight.tsx +233 -0
  60. package/src/components/editor/room-labels.test.ts +850 -0
  61. package/src/components/editor/room-labels.tsx +1890 -0
  62. package/src/components/editor/room-transform-ghost.tsx +256 -0
  63. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  64. package/src/components/editor/selection-manager.tsx +474 -419
  65. package/src/components/editor/slab-hole-highlights.tsx +5 -2
  66. package/src/components/editor/snapshot-capture-overlay.tsx +7 -2
  67. package/src/components/editor/snapshot-capture.test.ts +119 -60
  68. package/src/components/editor/snapshot-capture.ts +36 -5
  69. package/src/components/editor/thumbnail-generator.test.ts +654 -0
  70. package/src/components/editor/thumbnail-generator.tsx +1103 -223
  71. package/src/components/editor/use-floorplan-background-placement.ts +34 -12
  72. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  73. package/src/components/editor/views/host-view.tsx +61 -0
  74. package/src/components/editor/views/plugins-view.tsx +663 -0
  75. package/src/components/editor/views/scene-region.tsx +59 -0
  76. package/src/components/editor/views/use-editor-views.ts +57 -0
  77. package/src/components/editor/views/view-bar.test.tsx +54 -0
  78. package/src/components/editor/views/view-bar.tsx +343 -0
  79. package/src/components/editor/wall-justification.test.ts +140 -0
  80. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  81. package/src/components/editor/wall-push-arrow.tsx +92 -0
  82. package/src/components/editor/wall-region-handles.tsx +509 -0
  83. package/src/components/editor/wall-snap-beacon-layer.tsx +8 -4
  84. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  85. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  86. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  87. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  88. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  89. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  90. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +116 -106
  91. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  92. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  93. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  94. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  95. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  96. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  97. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +319 -233
  98. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  99. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  100. package/src/components/editor-2d/svg-paths.ts +12 -15
  101. package/src/components/point-ask/bubble.tsx +538 -0
  102. package/src/components/point-ask/hover-chip.tsx +91 -0
  103. package/src/components/point-ask/mode-pill.tsx +92 -0
  104. package/src/components/point-ask/optional-motion.ts +22 -0
  105. package/src/components/point-ask/outline-corners.tsx +93 -0
  106. package/src/components/point-ask/pins.tsx +711 -0
  107. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  108. package/src/components/point-ask/point-ask-styles.ts +105 -0
  109. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  110. package/src/components/point-ask/region-marquee.tsx +64 -0
  111. package/src/components/procedural-items/inspector.tsx +40 -15
  112. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  113. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  114. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  115. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  116. package/src/components/systems/zone/zone-system.test.ts +47 -0
  117. package/src/components/systems/zone/zone-system.tsx +29 -68
  118. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  119. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  120. package/src/components/tools/distribution/distribution-public.ts +9 -0
  121. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  122. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  123. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  124. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  125. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  126. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  127. package/src/components/tools/distribution/ports.test.ts +56 -0
  128. package/src/components/tools/distribution/ports.ts +378 -0
  129. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  130. package/src/components/tools/distribution/run-cursor.ts +36 -0
  131. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  132. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  133. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  134. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  135. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  136. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  137. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  138. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  139. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  140. package/src/components/tools/item/placement-history.test.tsx +512 -0
  141. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  142. package/src/components/tools/item/placement-strategies.ts +11 -10
  143. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  144. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  145. package/src/components/tools/item/use-draft-node.ts +166 -95
  146. package/src/components/tools/item/use-placement-coordinator.tsx +148 -95
  147. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  148. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  149. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  150. package/src/components/tools/registry/item-surface-move.ts +2 -0
  151. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  152. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  153. package/src/components/tools/registry/move-registry-node-tool.tsx +66 -30
  154. package/src/components/tools/select/box-select-tool.tsx +2 -12
  155. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  156. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  157. package/src/components/tools/select/select-candidates.test.ts +37 -4
  158. package/src/components/tools/select/select-candidates.ts +15 -11
  159. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  160. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  161. package/src/components/tools/tool-manager.tsx +59 -24
  162. package/src/components/tools/wall/wall-drafting.test.ts +175 -47
  163. package/src/components/tools/zone/zone-tool.tsx +4 -12
  164. package/src/components/ui/action-menu/action-button.tsx +9 -1
  165. package/src/components/ui/action-menu/camera-actions.tsx +9 -4
  166. package/src/components/ui/action-menu/control-modes.tsx +15 -42
  167. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  168. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  169. package/src/components/ui/action-menu/index.tsx +56 -33
  170. package/src/components/ui/action-menu/measurement-control.tsx +14 -19
  171. package/src/components/ui/action-menu/placement.ts +20 -0
  172. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  173. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  174. package/src/components/ui/build-panel/build-panel.tsx +242 -0
  175. package/src/components/ui/command-palette/editor-commands.tsx +7 -42
  176. package/src/components/ui/command-palette/index.tsx +50 -39
  177. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  178. package/src/components/ui/controls/action-button.tsx +5 -2
  179. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  180. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  181. package/src/components/ui/controls/material-picker.tsx +4 -1
  182. package/src/components/ui/controls/metric-control.tsx +1 -1
  183. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  184. package/src/components/ui/controls/segmented-control.tsx +21 -8
  185. package/src/components/ui/controls/select-control.tsx +65 -0
  186. package/src/components/ui/controls/slider-control.tsx +1 -1
  187. package/src/components/ui/controls/toggle-control.tsx +3 -3
  188. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  189. package/src/components/ui/floating-level-selector.tsx +159 -59
  190. package/src/components/ui/helpers/building-helper.tsx +4 -1
  191. package/src/components/ui/helpers/contextual-helper-panel.tsx +181 -72
  192. package/src/components/ui/helpers/helper-manager.test.tsx +121 -0
  193. package/src/components/ui/helpers/helper-manager.tsx +331 -14
  194. package/src/components/ui/helpers/item-helper.tsx +17 -1
  195. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  196. package/src/components/ui/icon-ref.tsx +45 -1
  197. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  198. package/src/components/ui/node-icon-image.tsx +16 -0
  199. package/src/components/ui/panels/collections/collections-popover.tsx +28 -22
  200. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  201. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  202. package/src/components/ui/panels/mobile-panel-sheet.tsx +4 -4
  203. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -5
  204. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  205. package/src/components/ui/panels/multi-field-value.ts +6 -3
  206. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  207. package/src/components/ui/panels/multi-selection-panel.tsx +161 -5
  208. package/src/components/ui/panels/node-display.ts +8 -3
  209. package/src/components/ui/panels/panel-manager.tsx +103 -68
  210. package/src/components/ui/panels/panel-wrapper.tsx +130 -62
  211. package/src/components/ui/panels/parametric-field-control.tsx +3 -3
  212. package/src/components/ui/panels/parametric-inspector.tsx +7 -7
  213. package/src/components/ui/panels/reference-panel.tsx +2 -3
  214. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  215. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  216. package/src/components/ui/panels/room-name-options.ts +47 -0
  217. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  218. package/src/components/ui/panels/room-panel.tsx +790 -0
  219. package/src/components/ui/panels/shape-choice.tsx +58 -0
  220. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  221. package/src/components/ui/primitives/badge.tsx +8 -0
  222. package/src/components/ui/primitives/color-dot.tsx +36 -24
  223. package/src/components/ui/primitives/sidebar.tsx +3 -1
  224. package/src/components/ui/right-stack.test.ts +29 -0
  225. package/src/components/ui/right-stack.tsx +164 -0
  226. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  227. package/src/components/ui/sidebar/icon-rail.tsx +8 -3
  228. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  229. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  230. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  231. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  232. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  233. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  234. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +138 -27
  235. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  236. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  237. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  238. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  239. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  240. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  241. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  242. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  243. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  244. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  245. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  246. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  247. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  248. package/src/components/ui/sidebar/panels/site-panel/index.tsx +181 -223
  249. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  250. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  251. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  252. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  253. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  254. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  255. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  256. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  257. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  258. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  259. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -37
  260. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  261. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  262. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  263. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  264. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -4
  265. package/src/components/ui/sidebar/tab-bar.tsx +14 -4
  266. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  267. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  268. package/src/components/viewer/floorplan-preview.tsx +5 -6
  269. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  270. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  271. package/src/components/viewer/viewer-rooms.tsx +191 -0
  272. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  273. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  274. package/src/components/viewer/viewer-stage.tsx +2 -2
  275. package/src/components/viewer-zone-system.tsx +3 -8
  276. package/src/hooks/use-auto-save.integration.test.tsx +215 -43
  277. package/src/hooks/use-auto-save.ts +60 -9
  278. package/src/hooks/use-ceiling-events.test.ts +123 -118
  279. package/src/hooks/use-grid-events.test.ts +105 -105
  280. package/src/hooks/use-keyboard.test.ts +243 -19
  281. package/src/hooks/use-keyboard.ts +118 -83
  282. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  283. package/src/hooks/use-open-wall-ends.ts +238 -0
  284. package/src/hooks/use-placement-notice.test.ts +71 -0
  285. package/src/hooks/use-placement-notice.ts +73 -0
  286. package/src/hooks/use-selected-room.ts +143 -0
  287. package/src/index.tsx +312 -58
  288. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  289. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  290. package/src/lib/add-storey.test.ts +177 -0
  291. package/src/lib/add-storey.ts +59 -0
  292. package/src/lib/below-grade.test.tsx +171 -0
  293. package/src/lib/below-grade.ts +133 -0
  294. package/src/lib/camera-facing.test.ts +24 -0
  295. package/src/lib/camera-facing.ts +24 -0
  296. package/src/lib/ceiling-edit-session.test.ts +321 -0
  297. package/src/lib/ceiling-edit-session.ts +206 -0
  298. package/src/lib/ceiling-region-session.test.ts +179 -0
  299. package/src/lib/continuation.test.ts +19 -1
  300. package/src/lib/continuation.ts +25 -4
  301. package/src/lib/direct-manipulation.ts +2 -0
  302. package/src/lib/door-interaction.ts +14 -0
  303. package/src/lib/download-project-file.ts +17 -0
  304. package/src/lib/editor-level-display.test.ts +96 -0
  305. package/src/lib/editor-level-display.ts +55 -0
  306. package/src/lib/editor-views.ts +178 -0
  307. package/src/lib/export-geometry-wait.test.ts +90 -0
  308. package/src/lib/export-texture-utils.ts +2 -4
  309. package/src/lib/floor-edit-session.ts +153 -0
  310. package/src/lib/floor-footprints.test.ts +563 -0
  311. package/src/lib/floor-footprints.ts +395 -0
  312. package/src/lib/floor-foundation-support.test.ts +284 -0
  313. package/src/lib/floor-opening-draft.ts +218 -0
  314. package/src/lib/floor-openings.test.ts +216 -0
  315. package/src/lib/floor-region-geometry.ts +191 -0
  316. package/src/lib/floor-region-session.test.ts +485 -0
  317. package/src/lib/floor-region-session.ts +589 -0
  318. package/src/lib/floor-region-snap.ts +101 -0
  319. package/src/lib/floor-step-hover.test.ts +32 -0
  320. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  321. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  322. package/src/lib/floorplan/dimension-drive.ts +479 -0
  323. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  324. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  325. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  326. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  327. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  328. package/src/lib/floorplan/floorplan-pdfkit-document.ts +363 -27
  329. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +177 -8
  330. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +60 -25
  331. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  332. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  333. package/src/lib/floorplan/geometry.ts +13 -6
  334. package/src/lib/floorplan/index.ts +0 -9
  335. package/src/lib/floorplan/items.ts +5 -2
  336. package/src/lib/floorplan/marks.test.ts +213 -0
  337. package/src/lib/floorplan/marks.ts +238 -0
  338. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  339. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  340. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  341. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  342. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  343. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  344. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  345. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  346. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  347. package/src/lib/floorplan/schedules.test.ts +189 -0
  348. package/src/lib/floorplan/schedules.ts +339 -0
  349. package/src/lib/floorplan/sheet-export.test.ts +124 -0
  350. package/src/lib/floorplan/sheet-export.ts +318 -0
  351. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +768 -0
  352. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  353. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  354. package/src/lib/floorplan/site-plan/coverage.ts +230 -0
  355. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  356. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  357. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  358. package/src/lib/floorplan/site-plan/front-edge.ts +157 -0
  359. package/src/lib/floorplan/site-plan/geometry.test.ts +269 -0
  360. package/src/lib/floorplan/site-plan/geometry.ts +142 -0
  361. package/src/lib/floorplan/site-plan/index.ts +55 -0
  362. package/src/lib/floorplan/site-plan/site-annotations.test.ts +483 -0
  363. package/src/lib/floorplan/site-plan/site-annotations.ts +1084 -0
  364. package/src/lib/floorplan/site-plan/site-parts.ts +287 -0
  365. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  366. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  367. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  368. package/src/lib/floorplan/site-plan/yards.test.ts +70 -0
  369. package/src/lib/floorplan/types.ts +1 -27
  370. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  371. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  372. package/src/lib/follow-pascal-exterior.ts +39 -0
  373. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  374. package/src/lib/follow-pascal-presentation.ts +129 -0
  375. package/src/lib/follow-pascal-sight.ts +84 -0
  376. package/src/lib/follow-pascal-targets.test.ts +406 -0
  377. package/src/lib/follow-pascal-targets.ts +198 -0
  378. package/src/lib/follow-pascal.test.ts +350 -0
  379. package/src/lib/follow-pascal.ts +284 -0
  380. package/src/lib/fresh-planar-placement.test.ts +148 -0
  381. package/src/lib/fresh-planar-placement.ts +114 -37
  382. package/src/lib/geometry-script/author.ts +157 -0
  383. package/src/lib/geometry-script/client.ts +159 -0
  384. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  385. package/src/lib/geometry-script/protocol.ts +13 -0
  386. package/src/lib/gesture-lifecycle.test.ts +595 -0
  387. package/src/lib/gesture-lifecycle.ts +290 -0
  388. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  389. package/src/lib/glb-export.test.ts +636 -126
  390. package/src/lib/glb-export.ts +241 -120
  391. package/src/lib/history.test.ts +18 -7
  392. package/src/lib/history.ts +7 -0
  393. package/src/lib/hud-title.ts +109 -0
  394. package/src/lib/hydration-gated-storage.ts +49 -0
  395. package/src/lib/ifc-export.test.ts +174 -0
  396. package/src/lib/ifc-export.ts +201 -0
  397. package/src/lib/ifc-parts.ts +15 -0
  398. package/src/lib/inspector-expanded.test.ts +44 -0
  399. package/src/lib/inspector-expanded.ts +36 -0
  400. package/src/lib/interaction/scope.ts +22 -0
  401. package/src/lib/keyboard-pan.test.ts +129 -7
  402. package/src/lib/keyboard-pan.ts +161 -12
  403. package/src/lib/level-selection.test.ts +57 -0
  404. package/src/lib/level-selection.ts +16 -1
  405. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  406. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  407. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  408. package/src/lib/lot/clean-ring.test.ts +187 -0
  409. package/src/lib/lot/clean-ring.ts +217 -0
  410. package/src/lib/lot/dossier.test.ts +202 -0
  411. package/src/lib/lot/dossier.ts +498 -0
  412. package/src/lib/lot/drop-in.test.ts +326 -0
  413. package/src/lib/lot/drop-in.ts +392 -0
  414. package/src/lib/lot/index.ts +43 -0
  415. package/src/lib/lot/lot-patch.test.ts +340 -0
  416. package/src/lib/lot/lot-patch.ts +394 -0
  417. package/src/lib/lot/parcel-provider.ts +43 -0
  418. package/src/lib/lot/terrain.test.ts +161 -0
  419. package/src/lib/lot/terrain.ts +266 -0
  420. package/src/lib/material-catalog-model.ts +3 -1
  421. package/src/lib/material-paint-panel-model.ts +9 -5
  422. package/src/lib/material-paint.ts +4 -0
  423. package/src/lib/measurement-kind.test.ts +6 -8
  424. package/src/lib/measurements.test.ts +23 -0
  425. package/src/lib/mezzanine-actions.test.ts +342 -0
  426. package/src/lib/mezzanine-draft.test.ts +501 -0
  427. package/src/lib/mezzanine-draft.ts +278 -0
  428. package/src/lib/mezzanine-messages.ts +26 -0
  429. package/src/lib/mezzanine-selection.test.ts +207 -0
  430. package/src/lib/mezzanine-stairs.ts +46 -0
  431. package/src/lib/model-export.ts +3 -1
  432. package/src/lib/node-action-movement.ts +17 -6
  433. package/src/lib/node-display-label.ts +37 -0
  434. package/src/lib/orbit-floor-clearance.test.ts +201 -0
  435. package/src/lib/orbit-floor-clearance.ts +154 -0
  436. package/src/lib/paint-commit-route.test.ts +37 -0
  437. package/src/lib/paint-commit-route.ts +17 -0
  438. package/src/lib/paint-eyedropper.test.tsx +165 -0
  439. package/src/lib/paint-eyedropper.ts +99 -0
  440. package/src/lib/paint-pass-through.test.ts +122 -0
  441. package/src/lib/paint-pass-through.ts +78 -0
  442. package/src/lib/paint-region-hover.ts +25 -0
  443. package/src/lib/paint-region-mode.ts +154 -0
  444. package/src/lib/paint-regions.test.ts +166 -0
  445. package/src/lib/paint-regions.ts +192 -0
  446. package/src/lib/paint-scope.test.ts +317 -35
  447. package/src/lib/paint-scope.ts +267 -25
  448. package/src/lib/panel-rows.ts +21 -0
  449. package/src/lib/parametric-node-update.ts +16 -7
  450. package/src/lib/plate-paint-affected.test.ts +300 -0
  451. package/src/lib/plate-paint-affected.ts +177 -0
  452. package/src/lib/plugin-chat.ts +65 -0
  453. package/src/lib/plugin-panels.test.ts +25 -0
  454. package/src/lib/plugin-panels.ts +94 -0
  455. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  456. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  457. package/src/lib/point-ask/ask-created.test.ts +195 -0
  458. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  459. package/src/lib/point-ask/build-context.test.ts +173 -0
  460. package/src/lib/point-ask/build-context.ts +94 -0
  461. package/src/lib/point-ask/capture-crop.ts +129 -0
  462. package/src/lib/point-ask/choreography.ts +69 -0
  463. package/src/lib/point-ask/compare.test.ts +145 -0
  464. package/src/lib/point-ask/compare.ts +73 -0
  465. package/src/lib/point-ask/describe-target.test.ts +165 -0
  466. package/src/lib/point-ask/describe-target.ts +234 -0
  467. package/src/lib/point-ask/dictation.ts +79 -0
  468. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  469. package/src/lib/point-ask/edge-marker.ts +27 -0
  470. package/src/lib/point-ask/frame-loop.ts +25 -0
  471. package/src/lib/point-ask/history-marks.test.ts +94 -0
  472. package/src/lib/point-ask/history-marks.ts +39 -0
  473. package/src/lib/point-ask/host-api.ts +32 -0
  474. package/src/lib/point-ask/outline-box.test.ts +30 -0
  475. package/src/lib/point-ask/outline-box.ts +11 -0
  476. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  477. package/src/lib/point-ask/pick.test.ts +220 -0
  478. package/src/lib/point-ask/pick.ts +121 -0
  479. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  480. package/src/lib/point-ask/pin-machine.ts +217 -0
  481. package/src/lib/point-ask/placement.test.ts +203 -0
  482. package/src/lib/point-ask/placement.ts +141 -0
  483. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  484. package/src/lib/point-ask/projector.ts +83 -0
  485. package/src/lib/point-ask/region.test.ts +157 -0
  486. package/src/lib/point-ask/region.ts +136 -0
  487. package/src/lib/point-ask/retry.test.ts +217 -0
  488. package/src/lib/point-ask/retry.ts +37 -0
  489. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  490. package/src/lib/point-ask/scene-commit.ts +32 -0
  491. package/src/lib/point-ask/session.ts +1391 -0
  492. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  493. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  494. package/src/lib/point-ask/spring.test.ts +63 -0
  495. package/src/lib/point-ask/spring.ts +107 -0
  496. package/src/lib/point-ask/suggestions.test.ts +80 -0
  497. package/src/lib/point-ask/suggestions.ts +125 -0
  498. package/src/lib/point-ask/target-scope.test.ts +622 -0
  499. package/src/lib/point-ask/target-scope.ts +338 -0
  500. package/src/lib/point-ask/types.ts +48 -0
  501. package/src/lib/portable-export.test.ts +34 -1
  502. package/src/lib/portable-export.ts +119 -53
  503. package/src/lib/print-feature-thickness.ts +10 -1
  504. package/src/lib/print-roof-solids.test.ts +39 -1
  505. package/src/lib/print-roof-solids.ts +43 -10
  506. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  507. package/src/lib/print-shell-compiler.ts +1 -0
  508. package/src/lib/registered-draft-snap.ts +12 -0
  509. package/src/lib/roof-duplication.ts +10 -2
  510. package/src/lib/room-assembly-overlay.test.ts +275 -0
  511. package/src/lib/room-assembly-overlay.ts +265 -0
  512. package/src/lib/room-built-on.test.ts +379 -0
  513. package/src/lib/room-built-on.ts +138 -0
  514. package/src/lib/room-construction-commands.ts +712 -0
  515. package/src/lib/room-construction.test.ts +746 -0
  516. package/src/lib/room-construction.ts +385 -0
  517. package/src/lib/room-controls-phase6.test.ts +630 -0
  518. package/src/lib/room-divide-session.ts +523 -0
  519. package/src/lib/room-handle-drag.ts +1068 -0
  520. package/src/lib/room-name-catalog.test.ts +90 -0
  521. package/src/lib/room-name-catalog.ts +168 -0
  522. package/src/lib/room-push-dimensions.test.ts +311 -0
  523. package/src/lib/room-push-dimensions.ts +251 -0
  524. package/src/lib/room-selection-commands.ts +62 -0
  525. package/src/lib/room-selection.test.ts +1017 -0
  526. package/src/lib/room-selection.ts +417 -0
  527. package/src/lib/room-structure-commands.ts +94 -0
  528. package/src/lib/room-structure.test.ts +921 -0
  529. package/src/lib/room-transform-session.ts +570 -0
  530. package/src/lib/room-zone-routing.test.ts +294 -0
  531. package/src/lib/room-zone-routing.ts +197 -0
  532. package/src/lib/scene-clipboard.test.ts +610 -1
  533. package/src/lib/scene-clipboard.ts +269 -58
  534. package/src/lib/scene-material-refs.test.ts +79 -0
  535. package/src/lib/scene-material-refs.ts +79 -0
  536. package/src/lib/scene.test.ts +24 -36
  537. package/src/lib/scene.ts +33 -57
  538. package/src/lib/selection-routing.test.ts +129 -45
  539. package/src/lib/selection-routing.ts +54 -75
  540. package/src/lib/session-writes.test.ts +120 -0
  541. package/src/lib/session-writes.ts +205 -0
  542. package/src/lib/sfx-bus.ts +13 -0
  543. package/src/lib/sfx-player.test.ts +70 -9
  544. package/src/lib/sfx-player.ts +6 -0
  545. package/src/lib/sidebar-panel.ts +62 -0
  546. package/src/lib/site-boundary.test.ts +4 -6
  547. package/src/lib/site-boundary.ts +1 -1
  548. package/src/lib/snapping-mode.test.ts +4 -0
  549. package/src/lib/snapping-mode.ts +8 -1
  550. package/src/lib/stair-levels.test.ts +25 -0
  551. package/src/lib/stair-levels.ts +22 -3
  552. package/src/lib/terrace-draft.test.ts +145 -0
  553. package/src/lib/terrace-draft.ts +214 -0
  554. package/src/lib/tool-cursor-icon.test.ts +16 -0
  555. package/src/lib/tool-cursor-icon.ts +11 -0
  556. package/src/lib/typing-target.ts +9 -0
  557. package/src/lib/units.ts +77 -39
  558. package/src/lib/vector-edges.test.ts +58 -0
  559. package/src/lib/vector-edges.ts +400 -0
  560. package/src/lib/viewer-selection.test.ts +577 -0
  561. package/src/lib/viewer-selection.ts +580 -0
  562. package/src/lib/wall-draw-variant.test.ts +71 -0
  563. package/src/lib/wall-draw-variant.ts +91 -0
  564. package/src/lib/wall-paint-region-session.ts +246 -0
  565. package/src/lib/wall-paint-region.test.ts +373 -0
  566. package/src/lib/wall-placement-pointer.test.ts +42 -0
  567. package/src/lib/wall-placement-pointer.ts +57 -0
  568. package/src/lib/wall-polygon-draft.test.ts +262 -0
  569. package/src/lib/wall-polygon-draft.ts +286 -0
  570. package/src/lib/wall-push.test.ts +453 -0
  571. package/src/lib/wall-region-face.ts +352 -0
  572. package/src/lib/wall-region-handles.test.ts +304 -0
  573. package/src/lib/wall-region-handles.ts +368 -0
  574. package/src/lib/wall-region-list.test.ts +119 -0
  575. package/src/lib/wall-region-list.ts +136 -0
  576. package/src/lib/wall-region-snap.ts +110 -0
  577. package/src/lib/window-changes.ts +35 -0
  578. package/src/lib/window-interaction.ts +14 -0
  579. package/src/lib/zone-content.ts +4 -1
  580. package/src/lib/zone-name.test.ts +17 -0
  581. package/src/lib/zone-name.ts +17 -0
  582. package/src/store/opens-in-select-mode.test.ts +223 -0
  583. package/src/store/paint-mode.test.ts +188 -0
  584. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  585. package/src/store/tool-mode.test.ts +137 -6
  586. package/src/store/use-audio.test.ts +146 -0
  587. package/src/store/use-audio.tsx +127 -3
  588. package/src/store/use-construction-reveal.test.ts +173 -0
  589. package/src/store/use-construction-reveal.ts +145 -0
  590. package/src/store/use-corner-snap-hint.test.ts +24 -0
  591. package/src/store/use-corner-snap-hint.ts +47 -0
  592. package/src/store/use-delete-confirmation.ts +28 -1
  593. package/src/store/use-drawing-view.test.ts +24 -3
  594. package/src/store/use-drawing-view.ts +29 -1
  595. package/src/store/use-editor.tsx +432 -321
  596. package/src/store/use-floorplan-draft-preview.ts +13 -0
  597. package/src/store/use-floorplan-marquee.ts +12 -2
  598. package/src/store/use-follow-pascal.test.ts +62 -0
  599. package/src/store/use-follow-pascal.ts +53 -0
  600. package/src/store/use-point-ask.ts +247 -0
  601. package/src/store/use-quick-measurement-hud.ts +3 -3
  602. package/src/store/use-stair-build-preview.ts +33 -5
  603. package/src/store/use-wall-move-ghosts.ts +4 -2
  604. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  605. package/src/store/use-wall-region-handles-selection.ts +139 -0
  606. package/src/store/view-layout.test.ts +201 -0
  607. package/src/store/view-layout.ts +199 -0
  608. package/src/test-utils/immediate-animation-frames.ts +16 -0
  609. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  610. package/src/test-utils/render-current-markup.tsx +16 -0
  611. package/src/test-utils/selection-harness.ts +82 -0
  612. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  613. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  614. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  615. package/src/components/tools/fence/fence-drafting.ts +0 -266
  616. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  617. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  618. package/src/components/tools/stair/stair-defaults.ts +0 -18
  619. package/src/components/tools/stair/stair-tool.tsx +0 -634
  620. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  621. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  622. package/src/lib/floorplan/stairs.ts +0 -489
  623. package/src/lib/level-duplication.test.ts +0 -144
  624. package/src/lib/level-duplication.ts +0 -195
@@ -0,0 +1,201 @@
1
+ import { expect, test } from 'bun:test'
2
+ import {
3
+ DIVE_DEPTH,
4
+ DIVE_MARGIN,
5
+ diveFloorY,
6
+ floorLimitAt,
7
+ glideFloorLimit,
8
+ LOOK_UP_PITCH_GAIN,
9
+ LOOK_UP_PUSH_HALF_ANGLE,
10
+ MAX_ORBIT_POLAR_ANGLE,
11
+ ORBIT_FLOOR_CLEARANCE,
12
+ ORBIT_TARGET_HEIGHT,
13
+ OVERVIEW_GROUND_CLEARANCE,
14
+ orbitAngleBelowFloor,
15
+ orbitCameraAboveFloor,
16
+ orbitFloorMinY,
17
+ overviewEyeY,
18
+ } from './orbit-floor-clearance'
19
+
20
+ type Vec3 = { x: number; y: number; z: number }
21
+
22
+ const FLOOR_Y = 3
23
+ const MIN_Y = FLOOR_Y + ORBIT_FLOOR_CLEARANCE
24
+ const target: Vec3 = { x: 1, y: FLOOR_Y, z: -2 }
25
+
26
+ // camera-controls' orbit: polar 0 looks straight down, PI/2 is level with the target.
27
+ function orbit(distance: number, polar: number, azimuth = 0.4): Vec3 {
28
+ return {
29
+ x: target.x + distance * Math.sin(polar) * Math.sin(azimuth),
30
+ y: target.y + distance * Math.cos(polar),
31
+ z: target.z + distance * Math.sin(polar) * Math.cos(azimuth),
32
+ }
33
+ }
34
+
35
+ function present(position: Vec3) {
36
+ const out = { x: Number.NaN, y: Number.NaN, z: Number.NaN }
37
+ const polar = orbitCameraAboveFloor(out, position, target, MIN_Y)
38
+ return { grounded: out.y === MIN_Y && position.y < MIN_Y, out, polar }
39
+ }
40
+
41
+ const horizontalDistance = (p: Vec3) => Math.hypot(p.x - target.x, p.z - target.z)
42
+ const DEGREE = Math.PI / 180
43
+
44
+ test('an orbit above the clearance is drawn exactly where it is', () => {
45
+ const position = orbit(10, Math.PI / 3)
46
+ const { grounded, out, polar } = present(position)
47
+ expect(grounded).toBe(false)
48
+ expect(out).toEqual(position)
49
+ expect(polar).toBeCloseTo(Math.PI / 3, 9)
50
+ })
51
+
52
+ test('tilting past the horizon keeps the camera on the floor clearance', () => {
53
+ for (const distance of [2, 8, 40]) {
54
+ for (const polar of [Math.PI / 2, 0.6 * Math.PI, 0.75 * Math.PI, Math.PI - 0.05]) {
55
+ const position = orbit(distance, polar)
56
+ const { grounded, out } = present(position)
57
+ expect(grounded).toBe(true)
58
+ expect(out.y).toBeCloseTo(MIN_Y, 9)
59
+ }
60
+ }
61
+ })
62
+
63
+ test('past the clearance the view pitches up faster than the drag and stops at straight up', () => {
64
+ for (const distance of [2.2, 8, 40]) {
65
+ const contactPolar = Math.acos(ORBIT_FLOOR_CLEARANCE / distance)
66
+ for (let past = 0; past <= 60 * DEGREE; past += DEGREE) {
67
+ const { polar } = present(orbit(distance, contactPolar + past))
68
+ expect(polar).toBeCloseTo(
69
+ Math.min(contactPolar + past * LOOK_UP_PITCH_GAIN, MAX_ORBIT_POLAR_ANGLE),
70
+ 9,
71
+ )
72
+ }
73
+ // From level to (almost) straight up within ~40° of drag.
74
+ const straightUp = (MAX_ORBIT_POLAR_ANGLE - contactPolar) / LOOK_UP_PITCH_GAIN
75
+ expect(straightUp).toBeLessThan(95 * DEGREE)
76
+ // And stops there: more drag changes nothing.
77
+ const atTop = present(orbit(distance, contactPolar + straightUp))
78
+ const beyond = present(orbit(distance, contactPolar + straightUp + 5 * DEGREE))
79
+ expect(beyond.polar).toBeCloseTo(MAX_ORBIT_POLAR_ANGLE, 9)
80
+ expect(horizontalDistance(beyond.out)).toBeCloseTo(horizontalDistance(atTop.out), 9)
81
+ }
82
+ })
83
+
84
+ test('past the clearance the camera closes on its target gently, at the half-angle rate', () => {
85
+ for (const distance of [2.2, 8, 40]) {
86
+ const contactPolar = Math.acos(ORBIT_FLOOR_CLEARANCE / distance)
87
+ const contactReach = horizontalDistance(present(orbit(distance, contactPolar)).out)
88
+ let previous = contactReach
89
+ const straightUp = (MAX_ORBIT_POLAR_ANGLE - contactPolar) / LOOK_UP_PITCH_GAIN
90
+ for (let polar = contactPolar + 0.01; polar <= contactPolar + straightUp; polar += 0.01) {
91
+ const position = orbit(distance, polar)
92
+ expect(orbitAngleBelowFloor(position, target, MIN_Y)).toBeCloseTo(polar - contactPolar, 9)
93
+ const reach = horizontalDistance(present(position).out)
94
+ expect(reach).toBeCloseTo(
95
+ contactReach * 0.5 ** ((polar - contactPolar) / LOOK_UP_PUSH_HALF_ANGLE),
96
+ 9,
97
+ )
98
+ expect(reach).toBeLessThan(previous)
99
+ previous = reach
100
+ }
101
+ // By the time the view looks straight up it has closed at most ~25% of the way.
102
+ const there = horizontalDistance(present(orbit(distance, contactPolar + straightUp)).out)
103
+ expect(1 - there / contactReach).toBeLessThan(0.75)
104
+ expect(1 - there / contactReach).toBeGreaterThan(0.35)
105
+ }
106
+ })
107
+
108
+ test('crossing the clearance is continuous, so orbiting back up has no jump', () => {
109
+ const distance = 6
110
+ // The polar angle at which the orbit itself touches the clearance.
111
+ const contact = Math.acos(ORBIT_FLOOR_CLEARANCE / distance)
112
+ const before = present(orbit(distance, contact - 1e-6)).out
113
+ const after = present(orbit(distance, contact + 1e-6)).out
114
+ expect(Math.hypot(after.x - before.x, after.y - before.y, after.z - before.z)).toBeLessThan(1e-4)
115
+ })
116
+
117
+ test('zooming in while grounded still moves the camera toward the target', () => {
118
+ const polar = 0.8 * Math.PI
119
+ let previous = Number.POSITIVE_INFINITY
120
+ for (let distance = 40; distance >= 2; distance -= 2) {
121
+ const reach = horizontalDistance(present(orbit(distance, polar)).out)
122
+ expect(reach).toBeLessThan(previous)
123
+ previous = reach
124
+ }
125
+ })
126
+
127
+ test('with the pivot at standing height, a close orbit looks around before the floor stops it', () => {
128
+ const pivot = { x: 0, y: FLOOR_Y + ORBIT_TARGET_HEIGHT, z: 0 }
129
+ const distance = 2
130
+ const at = (polar: number) => ({
131
+ x: distance * Math.sin(polar),
132
+ y: pivot.y + distance * Math.cos(polar),
133
+ z: 0,
134
+ })
135
+ // Level with the pivot and a little past it: a free orbit at about eye
136
+ // height, already looking up, nothing pushed.
137
+ for (const polar of [Math.PI / 2, 100 * DEGREE, 105 * DEGREE]) {
138
+ const out = { x: 0, y: 0, z: 0 }
139
+ expect(orbitCameraAboveFloor(out, at(polar), pivot, MIN_Y)).toBeCloseTo(polar, 9)
140
+ expect(out).toEqual(at(polar))
141
+ }
142
+ // The clearance takes over only where the orbit would reach it.
143
+ const contact = Math.acos((MIN_Y - pivot.y) / distance)
144
+ expect(contact).toBeGreaterThan(105 * DEGREE)
145
+ const out = { x: 0, y: 0, z: 0 }
146
+ orbitCameraAboveFloor(out, at(contact + 5 * DEGREE), pivot, MIN_Y)
147
+ expect(out.y).toBeCloseTo(MIN_Y, 9)
148
+ })
149
+
150
+ test('the floor limit sits the clearance above the surface under the camera, or the level floor', () => {
151
+ // A raised platform 0.45 m above a level at 2.71.
152
+ expect(floorLimitAt(3.16, 2.71)).toBeCloseTo(3.16 + ORBIT_FLOOR_CLEARANCE, 9)
153
+ // A sunken floor below the level's own floor.
154
+ expect(floorLimitAt(2.21, 2.71)).toBeCloseTo(2.21 + ORBIT_FLOOR_CLEARANCE, 9)
155
+ // Nothing under the camera (outside, no terrain): the level floor, never 0.
156
+ expect(floorLimitAt(null, 2.71)).toBeCloseTo(2.71 + ORBIT_FLOOR_CLEARANCE, 9)
157
+ expect(floorLimitAt(null, 0)).toBe(ORBIT_FLOOR_CLEARANCE)
158
+ })
159
+
160
+ test('a change in floor height is glided over, never popped', () => {
161
+ expect(glideFloorLimit(null, 1, 1 / 60)).toBe(1)
162
+ let limit = 0.3
163
+ let previous = limit
164
+ for (let frame = 0; frame < 120; frame++) {
165
+ limit = glideFloorLimit(limit, 0.75, 1 / 60)
166
+ expect(limit).toBeGreaterThan(previous)
167
+ expect(limit - previous).toBeLessThan(0.07)
168
+ previous = limit
169
+ }
170
+ expect(limit).toBeCloseTo(0.75, 6)
171
+ })
172
+
173
+ test('a pivot sent four storeys down keeps the eye where it was, over the ground; a diver stays under', () => {
174
+ // Eye 12 m up over a pivot at 1.1; the pivot goes to -12: the move alone would put the eye at -0.1.
175
+ expect(overviewEyeY(12 - 13.1, 12, -0.05, false)).toBe(12)
176
+ // An eye that was already low is brought up to the clearance.
177
+ expect(overviewEyeY(-9, 0.5, -0.05, false)).toBe(-0.05 + OVERVIEW_GROUND_CLEARANCE)
178
+ // The move leaves it high enough: it goes with the pivot, as always.
179
+ expect(overviewEyeY(20, 25, -0.05, false)).toBe(20)
180
+ // The person is under the ground: the move is theirs.
181
+ expect(overviewEyeY(-8, 12, -0.05, true)).toBe(-8)
182
+ })
183
+
184
+ test('the dive floor sits 3 m under the lowest ground, or 1 m under the deepest drawn thing when that is deeper', () => {
185
+ // A house at grade: nothing drawn is deeper than the ground's 3 m.
186
+ expect(diveFloorY(-0.05, -0.36)).toBeCloseTo(-0.05 - DIVE_DEPTH, 9)
187
+ expect(diveFloorY(-0.05, null)).toBeCloseTo(-0.05 - DIVE_DEPTH, 9)
188
+ // The villa's basement footing at -3.83 goes a metre deeper.
189
+ expect(diveFloorY(-0.05, -3.83)).toBeCloseTo(-3.83 - DIVE_MARGIN, 9)
190
+ })
191
+
192
+ test('the eye may sit the clearance over the dive floor from any storey, over the level floor otherwise', () => {
193
+ const dive = diveFloorY(-0.05, null)
194
+ // The ground floor, and a storey four metres up: both dive to the same floor.
195
+ expect(orbitFloorMinY(0, dive)).toBeCloseTo(dive + ORBIT_FLOOR_CLEARANCE, 9)
196
+ expect(orbitFloorMinY(4, dive)).toBeCloseTo(dive + ORBIT_FLOOR_CLEARANCE, 9)
197
+ // No dive (Solid): the level's floor, as before.
198
+ expect(orbitFloorMinY(4, null)).toBeCloseTo(4 + ORBIT_FLOOR_CLEARANCE, 9)
199
+ // A basement deeper than the dive floor keeps its own floor: the dive never lifts the limit.
200
+ expect(orbitFloorMinY(-12, dive)).toBeCloseTo(-12 + ORBIT_FLOOR_CLEARANCE, 9)
201
+ })
@@ -0,0 +1,154 @@
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
+ /**
15
+ * The dive under the ground: with the ground on Auto the orbit's eye may leave the ground for the
16
+ * earth under any building, down to a floor this far under the lowest ground, or this far under
17
+ * the deepest thing drawn (a basement's footing), whichever is deeper.
18
+ */
19
+ export const DIVE_DEPTH = 3
20
+ export const DIVE_MARGIN = 1
21
+
22
+ const DEGREE = Math.PI / 180
23
+
24
+ /** The floor of the dive, world Y: the eye's clearance over it is {@link ORBIT_FLOOR_CLEARANCE}. */
25
+ export function diveFloorY(lowestGroundY: number, deepestDrawnY: number | null): number {
26
+ const underGround = lowestGroundY - DIVE_DEPTH
27
+ return deepestDrawnY === null ? underGround : Math.min(underGround, deepestDrawnY - DIVE_MARGIN)
28
+ }
29
+
30
+ /**
31
+ * The lowest the orbit's eye may sit: the clearance over the selected level's floor, or over the
32
+ * dive's floor when the person may dive and it lies deeper (`diveFloor` null: no dive). Selecting a
33
+ * level never lifts the limit above the dive floor, so a dive works from any storey.
34
+ */
35
+ export function orbitFloorMinY(levelFloorY: number, diveFloor: number | null): number {
36
+ return Math.min(levelFloorY, diveFloor ?? levelFloorY) + ORBIT_FLOOR_CLEARANCE
37
+ }
38
+
39
+ /** The steepest an orbit camera looks: straight down is 0, straight up π. */
40
+ export const MAX_ORBIT_POLAR_ANGLE = Math.PI - 0.05
41
+
42
+ /**
43
+ * Past the point where the camera reached the floor clearance, the view
44
+ * pitches up this many times faster than the orbit drag: about 80° more drag
45
+ * takes it from level to straight up.
46
+ */
47
+ export const LOOK_UP_PITCH_GAIN = 1.1
48
+
49
+ /**
50
+ * The look-up push: every this many degrees of drag past the clearance, the
51
+ * camera's horizontal distance to its target halves. Over the ~80° it takes
52
+ * to look straight up, the camera closes about two thirds of the way in.
53
+ */
54
+ export const LOOK_UP_PUSH_HALF_ANGLE = 55 * DEGREE
55
+
56
+ /**
57
+ * How far the orbit has tilted past the point where its camera reaches
58
+ * `minY` (radians, negative while it is still above). Zero exactly when the
59
+ * orbit camera sits at `minY`.
60
+ */
61
+ export function orbitAngleBelowFloor(position: Vec3, target: Vec3, minY: number): number {
62
+ const horizontal = Math.hypot(position.x - target.x, position.z - target.z)
63
+ const height = position.y - target.y
64
+ const distance = Math.hypot(horizontal, height)
65
+ if (distance < 1e-9) return 0
66
+ const polar = Math.atan2(horizontal, height)
67
+ const contactPolar = Math.acos(Math.max(-1, Math.min(1, (minY - target.y) / distance)))
68
+ return polar - contactPolar
69
+ }
70
+
71
+ /**
72
+ * Where to draw an orbit camera so it never drops below `minY`. Above it the
73
+ * orbit pose stands. An orbit that would take it lower keeps the camera on
74
+ * `minY`, pitches the view up `LOOK_UP_PITCH_GAIN` times faster than the
75
+ * drag (stopping at straight up), and pushes the camera gently toward its
76
+ * target at the `LOOK_UP_PUSH_HALF_ANGLE` rate. A pure function of the orbit
77
+ * state, so orbiting back up returns the exact orbit pose.
78
+ *
79
+ * Writes the drawn position into `out` and returns the drawn polar angle.
80
+ */
81
+ export function orbitCameraAboveFloor(
82
+ out: Vec3,
83
+ position: Vec3,
84
+ target: Vec3,
85
+ minY: number,
86
+ ): number {
87
+ const dx = position.x - target.x
88
+ const dz = position.z - target.z
89
+ const horizontal = Math.hypot(dx, dz)
90
+ const height = position.y - target.y
91
+ if (!(position.y < minY)) {
92
+ out.x = position.x
93
+ out.y = position.y
94
+ out.z = position.z
95
+ return Math.atan2(horizontal, height)
96
+ }
97
+
98
+ const rise = minY - target.y
99
+ const distance = Math.hypot(horizontal, height)
100
+ const contactRadius = Math.sqrt(Math.max(0, distance * distance - rise * rise))
101
+ const below = Math.max(0, orbitAngleBelowFloor(position, target, minY))
102
+ const contactPolar = Math.atan2(horizontal, height) - below
103
+ // Nothing moves once the view looks straight up.
104
+ const pastContact = Math.min(below, (MAX_ORBIT_POLAR_ANGLE - contactPolar) / LOOK_UP_PITCH_GAIN)
105
+ const reach = contactRadius * 0.5 ** (pastContact / LOOK_UP_PUSH_HALF_ANGLE)
106
+ const scale = horizontal > 1e-9 ? reach / horizontal : 0
107
+ out.x = target.x + dx * scale
108
+ out.y = minY
109
+ out.z = target.z + dz * scale
110
+ return contactPolar + pastContact * LOOK_UP_PITCH_GAIN
111
+ }
112
+
113
+ // How quickly the floor limit follows a change in the floor under the camera
114
+ // (stepping over a raised platform's edge): about this many seconds per e-fold.
115
+ const FLOOR_GLIDE_SECONDS = 0.12
116
+
117
+ /**
118
+ * The lowest the camera may sit: the clearance above the floor surface under
119
+ * it, or above the level's own floor when there is none (outside, no terrain).
120
+ */
121
+ export function floorLimitAt(surfaceTop: number | null, levelFloorY: number): number {
122
+ return (surfaceTop ?? levelFloorY) + ORBIT_FLOOR_CLEARANCE
123
+ }
124
+
125
+ /** Eases a floor limit toward `target` over `delta` seconds, so it never pops. */
126
+ export function glideFloorLimit(current: number | null, target: number, delta: number): number {
127
+ if (current === null || !(delta > 0)) return current ?? target
128
+ return target + (current - target) * Math.exp(-delta / FLOOR_GLIDE_SECONDS)
129
+ }
130
+
131
+ /**
132
+ * How far over the ground the overview's eye stays when the pivot goes down to a deep level: the
133
+ * ground is a medium, the eye above it sees the level through the hole and the dive is the
134
+ * person's own act, so picking a level four storeys under grade does not sink the eye with it.
135
+ */
136
+ export const OVERVIEW_GROUND_CLEARANCE = 2
137
+
138
+ /**
139
+ * The height an overview eye is kept at when its pivot moves to another level: `movedEyeY` as the
140
+ * move leaves it (the eye following the pivot down), unless that would sink the eye under the
141
+ * ground (the eye was above it, `wasUnder` false): then the eye stays where it was, over the ground
142
+ * (at least {@link OVERVIEW_GROUND_CLEARANCE} over it), and looks down at the new pivot. An eye
143
+ * already under the ground (the person dived) goes where the move puts it.
144
+ */
145
+ export function overviewEyeY(
146
+ movedEyeY: number,
147
+ previousEyeY: number,
148
+ groundY: number,
149
+ wasUnder: boolean,
150
+ ): number {
151
+ const lowest = groundY + OVERVIEW_GROUND_CLEARANCE
152
+ if (wasUnder || movedEyeY >= lowest) return movedEyeY
153
+ return Math.max(previousEyeY, lowest)
154
+ }
@@ -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
+ }