@pascal-app/editor 0.8.0 → 0.9.2

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 (350) hide show
  1. package/package.json +15 -13
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +168 -0
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +795 -28
  5. package/src/components/editor/editor-layout-mobile.tsx +8 -3
  6. package/src/components/editor/editor-layout-v2.tsx +76 -51
  7. package/src/components/editor/export-manager.tsx +69 -32
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +182 -0
  10. package/src/components/editor/first-person/build-collider-world.ts +228 -83
  11. package/src/components/editor/first-person-controls.tsx +943 -27
  12. package/src/components/editor/floating-action-menu.tsx +676 -208
  13. package/src/components/editor/floating-building-action-menu.tsx +2 -2
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +6916 -12137
  17. package/src/components/editor/grid.tsx +197 -36
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +420 -0
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +396 -0
  24. package/src/components/editor/group-transform-shared.ts +489 -0
  25. package/src/components/editor/handles/handle-arrow.tsx +581 -0
  26. package/src/components/editor/handles/use-handle-drag.ts +246 -0
  27. package/src/components/editor/index.tsx +412 -162
  28. package/src/components/editor/measurement-pill.tsx +115 -0
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +1646 -0
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +1106 -483
  36. package/src/components/editor/site-edge-labels.tsx +45 -21
  37. package/src/components/editor/slab-hole-highlights.tsx +474 -0
  38. package/src/components/editor/snapshot-capture-overlay.tsx +369 -155
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +140 -41
  41. package/src/components/editor/use-floorplan-background-placement.ts +190 -35
  42. package/src/components/editor/use-floorplan-hit-testing.ts +24 -0
  43. package/src/components/editor/use-floorplan-scene-data.ts +19 -7
  44. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  45. package/src/components/editor/wall-measurement-label.tsx +14 -19
  46. package/src/components/editor/wall-move-side-handles.tsx +870 -0
  47. package/src/components/editor/wall-opening-highlights.tsx +181 -0
  48. package/src/components/editor/wall-snap-beacon-layer.tsx +304 -0
  49. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +15 -6
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +142 -0
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +5 -3
  52. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  53. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  54. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  56. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +392 -0
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +857 -0
  59. package/src/components/editor-2d/floorplan-render-context.tsx +62 -0
  60. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +77 -0
  61. package/src/components/editor-2d/floorplan-wall-move-ghost-layer.tsx +47 -0
  62. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +266 -0
  63. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  64. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  65. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +108 -66
  66. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +62 -0
  67. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  68. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +3325 -0
  69. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +24 -9
  70. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  71. package/src/components/editor-2d/svg-paths.ts +10 -2
  72. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +439 -57
  73. package/src/components/systems/ceiling/ceiling-system.tsx +109 -12
  74. package/src/components/systems/roof/roof-edit-system.tsx +1851 -22
  75. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  76. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  77. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  78. package/src/components/systems/zone/zone-system.tsx +42 -13
  79. package/src/components/tools/elevator/elevator-defaults.ts +8 -0
  80. package/src/components/tools/elevator/elevator-tool.tsx +375 -0
  81. package/src/components/tools/elevator/move-elevator-tool.tsx +263 -0
  82. package/src/components/tools/fence/fence-drafting.ts +141 -27
  83. package/src/components/tools/item/move-tool.tsx +45 -105
  84. package/src/components/tools/item/placement-math.test.ts +22 -0
  85. package/src/components/tools/item/placement-math.ts +59 -7
  86. package/src/components/tools/item/placement-strategies.ts +432 -16
  87. package/src/components/tools/item/placement-types.ts +22 -1
  88. package/src/components/tools/item/use-draft-node.ts +51 -2
  89. package/src/components/tools/item/use-placement-coordinator.tsx +1399 -256
  90. package/src/components/tools/registry/move-registry-node-tool.tsx +1064 -0
  91. package/src/components/tools/roof/roof-tool.tsx +350 -28
  92. package/src/components/tools/select/box-select-state.ts +88 -0
  93. package/src/components/tools/select/box-select-tool.tsx +442 -520
  94. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  95. package/src/components/tools/select/marquee-geometry.ts +155 -0
  96. package/src/components/tools/select/plane-box-select-tool.tsx +570 -0
  97. package/src/components/tools/select/screen-rectangle-selection.ts +84 -0
  98. package/src/components/tools/select/select-candidates.test.ts +109 -0
  99. package/src/components/tools/select/select-candidates.ts +85 -0
  100. package/src/components/tools/shared/affordance-dispatch.ts +30 -0
  101. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  102. package/src/components/tools/shared/drag-bounding-box.tsx +195 -0
  103. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  104. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  105. package/src/components/tools/shared/floor-stack-preview.ts +25 -0
  106. package/src/components/tools/shared/fresh-placement-visibility.ts +61 -0
  107. package/src/components/tools/shared/placement-box-geometry.ts +123 -0
  108. package/src/components/tools/shared/placement-box.tsx +305 -0
  109. package/src/components/tools/shared/polygon-editor.tsx +866 -175
  110. package/src/components/tools/shared/segment-angle.ts +56 -0
  111. package/src/components/tools/site/site-boundary-editor.tsx +520 -16
  112. package/src/components/tools/site/site-flag-model.ts +18 -0
  113. package/src/components/tools/stair/stair-defaults.ts +2 -1
  114. package/src/components/tools/stair/stair-tool.tsx +342 -37
  115. package/src/components/tools/tool-manager.tsx +239 -62
  116. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  117. package/src/components/tools/wall/wall-drafting.ts +248 -171
  118. package/src/components/tools/wall/wall-snap-geometry.test.ts +123 -0
  119. package/src/components/tools/wall/wall-snap-geometry.ts +331 -0
  120. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  121. package/src/components/tools/zone/zone-tool.tsx +86 -82
  122. package/src/components/ui/action-menu/action-button.tsx +21 -1
  123. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  124. package/src/components/ui/action-menu/control-modes.tsx +40 -170
  125. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  126. package/src/components/ui/action-menu/index.tsx +3 -117
  127. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  128. package/src/components/ui/action-menu/structure-tools.tsx +27 -93
  129. package/src/components/ui/action-menu/view-toggles.tsx +279 -90
  130. package/src/components/ui/command-palette/editor-commands.tsx +20 -15
  131. package/src/components/ui/command-palette/index.tsx +5 -3
  132. package/src/components/ui/controls/material-paint-panel.tsx +152 -0
  133. package/src/components/ui/controls/material-picker.tsx +87 -177
  134. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  135. package/src/components/ui/controls/metric-control.tsx +142 -46
  136. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  137. package/src/components/ui/controls/slider-control.tsx +88 -38
  138. package/src/components/ui/floating-level-selector.tsx +47 -10
  139. package/src/components/ui/helpers/building-helper.tsx +10 -23
  140. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  141. package/src/components/ui/helpers/helper-manager.tsx +235 -23
  142. package/src/components/ui/helpers/item-helper.tsx +28 -32
  143. package/src/components/ui/helpers/registered-tool-helper.tsx +59 -0
  144. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  145. package/src/components/ui/icon-ref.tsx +47 -0
  146. package/src/components/ui/item-catalog/catalog-items.tsx +40 -1
  147. package/src/components/ui/item-catalog/item-catalog.tsx +16 -36
  148. package/src/components/ui/level-duplicate-dialog.tsx +2 -1
  149. package/src/components/ui/panels/node-display.ts +17 -16
  150. package/src/components/ui/panels/panel-manager.tsx +55 -58
  151. package/src/components/ui/panels/panel-wrapper.tsx +238 -12
  152. package/src/components/ui/panels/parametric-inspector.tsx +482 -0
  153. package/src/components/ui/panels/reference-panel.tsx +54 -19
  154. package/src/components/ui/primitives/dropdown-menu.tsx +12 -8
  155. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  156. package/src/components/ui/primitives/sidebar.tsx +1 -0
  157. package/src/components/ui/scene-loader.tsx +1 -3
  158. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  159. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  160. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +263 -0
  161. package/src/components/ui/sidebar/panels/items-panel/index.tsx +82 -8
  162. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  163. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +122 -41
  164. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +66 -7
  165. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +267 -0
  166. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -1
  167. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +9 -5
  168. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +86 -0
  169. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  170. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  171. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +92 -0
  172. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +75 -0
  173. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +3 -3
  174. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +86 -0
  175. package/src/components/ui/sidebar/panels/site-panel/index.tsx +42 -21
  176. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  177. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -1
  178. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +140 -0
  179. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +48 -8
  180. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +132 -0
  181. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -5
  182. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +86 -0
  183. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +7 -1
  184. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  185. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +112 -39
  186. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  187. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  188. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  189. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  190. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +51 -2
  191. package/src/components/ui/sidebar/tab-bar.tsx +75 -1
  192. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  193. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  194. package/src/components/ui/snap-target-badge.tsx +88 -0
  195. package/src/components/viewer-overlay.tsx +253 -61
  196. package/src/components/viewer-zone-system.tsx +7 -2
  197. package/src/hooks/use-auto-save.test.ts +14 -0
  198. package/src/hooks/use-auto-save.ts +64 -10
  199. package/src/hooks/use-ceiling-events.ts +176 -0
  200. package/src/hooks/use-drag-action.ts +127 -0
  201. package/src/hooks/use-keyboard.ts +493 -95
  202. package/src/hooks/use-selection.ts +64 -0
  203. package/src/index.tsx +451 -6
  204. package/src/lib/active-placement-surface.ts +35 -0
  205. package/src/lib/ceiling-plan-snap.ts +24 -0
  206. package/src/lib/constants.ts +6 -2
  207. package/src/lib/contextual-help.test.ts +112 -0
  208. package/src/lib/contextual-help.ts +144 -0
  209. package/src/lib/continuation.ts +64 -0
  210. package/src/lib/direct-manipulation.test.ts +192 -0
  211. package/src/lib/direct-manipulation.ts +109 -0
  212. package/src/lib/editor-api.ts +50 -0
  213. package/src/lib/elevator-support.ts +96 -0
  214. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  215. package/src/lib/floorplan/apply-alignment.ts +117 -0
  216. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  217. package/src/lib/floorplan/geometry.ts +53 -0
  218. package/src/lib/floorplan/index.ts +10 -0
  219. package/src/lib/floorplan/items.ts +5 -2
  220. package/src/lib/floorplan/plan-coords.ts +21 -0
  221. package/src/lib/floorplan/selection-tool.ts +40 -0
  222. package/src/lib/floorplan/stairs.ts +12 -1
  223. package/src/lib/fresh-planar-placement.test.ts +339 -0
  224. package/src/lib/fresh-planar-placement.ts +130 -0
  225. package/src/lib/glb-export.test.ts +441 -0
  226. package/src/lib/glb-export.ts +874 -0
  227. package/src/lib/history.ts +11 -1
  228. package/src/lib/interaction/hot-set.test.ts +133 -0
  229. package/src/lib/interaction/hot-set.ts +67 -0
  230. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  231. package/src/lib/interaction/overlay-policy.ts +59 -0
  232. package/src/lib/interaction/scope.ts +211 -0
  233. package/src/lib/level-duplication.ts +54 -42
  234. package/src/lib/level-selection.ts +2 -2
  235. package/src/lib/material-paint.ts +227 -48
  236. package/src/lib/measurement-kind.test.ts +30 -0
  237. package/src/lib/measurement-kind.ts +19 -0
  238. package/src/lib/measurement-label.test.ts +47 -0
  239. package/src/lib/measurement-label.ts +68 -0
  240. package/src/lib/measurement-parser.ts +116 -0
  241. package/src/lib/measurements.test.ts +183 -0
  242. package/src/lib/measurements.ts +199 -0
  243. package/src/lib/paint-scope.test.ts +454 -0
  244. package/src/lib/paint-scope.ts +461 -0
  245. package/src/lib/placement-drag-release.ts +23 -0
  246. package/src/lib/placement-metadata.ts +29 -0
  247. package/src/lib/planar-cursor-placement.test.ts +100 -0
  248. package/src/lib/planar-cursor-placement.ts +42 -0
  249. package/src/lib/plugin-panels.test.ts +19 -0
  250. package/src/lib/plugin-panels.ts +91 -0
  251. package/src/lib/quick-measurement.test.ts +77 -0
  252. package/src/lib/quick-measurement.ts +109 -0
  253. package/src/lib/roof-duplication.ts +7 -7
  254. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  255. package/src/lib/roof-wall-hit.ts +164 -0
  256. package/src/lib/scene-clipboard.test.ts +196 -0
  257. package/src/lib/scene-clipboard.ts +332 -0
  258. package/src/lib/scene.ts +40 -8
  259. package/src/lib/selection-routing.test.ts +298 -0
  260. package/src/lib/selection-routing.ts +175 -0
  261. package/src/lib/sfx/index.ts +1 -0
  262. package/src/lib/sfx/movement-tick.test.ts +65 -0
  263. package/src/lib/sfx/movement-tick.ts +18 -0
  264. package/src/lib/sfx-bus.ts +69 -10
  265. package/src/lib/sfx-player.test.ts +124 -0
  266. package/src/lib/sfx-player.ts +148 -55
  267. package/src/lib/site-boundary.ts +1 -0
  268. package/src/lib/slab-plan-snap.test.ts +93 -0
  269. package/src/lib/slab-plan-snap.ts +108 -0
  270. package/src/lib/snapping-mode.test.ts +138 -0
  271. package/src/lib/snapping-mode.ts +179 -0
  272. package/src/lib/stair-duplication.ts +4 -4
  273. package/src/lib/stair-levels.test.ts +146 -0
  274. package/src/lib/stair-levels.ts +177 -0
  275. package/src/lib/surface-plan-snap.test.ts +71 -0
  276. package/src/lib/surface-plan-snap.ts +256 -0
  277. package/src/lib/use-linear-display.ts +40 -0
  278. package/src/lib/world-grid-snap.ts +231 -0
  279. package/src/lib/zone-content.ts +130 -0
  280. package/src/store/use-alignment-guides.ts +21 -0
  281. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  282. package/src/store/use-editor.tsx +767 -114
  283. package/src/store/use-facing-pose.ts +44 -0
  284. package/src/store/use-fence-curve-draft.ts +21 -0
  285. package/src/store/use-floorplan-draft-preview.ts +101 -0
  286. package/src/store/use-floorplan-marquee.ts +50 -0
  287. package/src/store/use-interaction-scope.test.ts +186 -0
  288. package/src/store/use-interaction-scope.ts +132 -0
  289. package/src/store/use-measurement-draft.test.ts +530 -0
  290. package/src/store/use-measurement-draft.ts +543 -0
  291. package/src/store/use-opening-guides.ts +41 -0
  292. package/src/store/use-placement-preview.ts +39 -0
  293. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  294. package/src/store/use-quick-measurement-hud.ts +77 -0
  295. package/src/store/use-segment-draft-chain.ts +28 -0
  296. package/src/store/use-stair-build-preview.ts +43 -0
  297. package/src/store/use-wall-move-ghosts.ts +36 -0
  298. package/src/store/use-wall-snap-indicator.ts +34 -0
  299. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  300. package/src/components/editor/preset-thumbnail-generator.tsx +0 -125
  301. package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +0 -113
  302. package/src/components/tools/building/move-building-tool.tsx +0 -157
  303. package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +0 -43
  304. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +0 -49
  305. package/src/components/tools/ceiling/ceiling-tool.tsx +0 -465
  306. package/src/components/tools/ceiling/move-ceiling-tool.tsx +0 -264
  307. package/src/components/tools/column/column-tool.tsx +0 -97
  308. package/src/components/tools/column/move-column-tool.tsx +0 -105
  309. package/src/components/tools/door/door-math.ts +0 -110
  310. package/src/components/tools/door/door-tool.tsx +0 -324
  311. package/src/components/tools/door/move-door-tool.tsx +0 -412
  312. package/src/components/tools/fence/curve-fence-tool.tsx +0 -178
  313. package/src/components/tools/fence/fence-tool.tsx +0 -346
  314. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +0 -441
  315. package/src/components/tools/fence/move-fence-tool.tsx +0 -302
  316. package/src/components/tools/item/item-tool.tsx +0 -26
  317. package/src/components/tools/roof/move-roof-tool.tsx +0 -364
  318. package/src/components/tools/slab/move-slab-tool.tsx +0 -182
  319. package/src/components/tools/slab/slab-boundary-editor.tsx +0 -43
  320. package/src/components/tools/slab/slab-hole-editor.tsx +0 -49
  321. package/src/components/tools/slab/slab-tool.tsx +0 -322
  322. package/src/components/tools/spawn/move-spawn-tool.tsx +0 -101
  323. package/src/components/tools/spawn/spawn-tool.tsx +0 -130
  324. package/src/components/tools/wall/curve-wall-tool.tsx +0 -178
  325. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +0 -426
  326. package/src/components/tools/wall/move-wall-tool.tsx +0 -358
  327. package/src/components/tools/wall/wall-tool.tsx +0 -332
  328. package/src/components/tools/window/move-window-tool.tsx +0 -447
  329. package/src/components/tools/window/window-math.ts +0 -117
  330. package/src/components/tools/window/window-tool.tsx +0 -332
  331. package/src/components/ui/helpers/ceiling-helper.tsx +0 -20
  332. package/src/components/ui/helpers/slab-helper.tsx +0 -20
  333. package/src/components/ui/helpers/wall-helper.tsx +0 -20
  334. package/src/components/ui/panels/ceiling-panel.tsx +0 -249
  335. package/src/components/ui/panels/column-panel.tsx +0 -759
  336. package/src/components/ui/panels/door-panel.tsx +0 -1299
  337. package/src/components/ui/panels/fence-panel.tsx +0 -222
  338. package/src/components/ui/panels/item-panel.tsx +0 -306
  339. package/src/components/ui/panels/paint-panel.tsx +0 -163
  340. package/src/components/ui/panels/presets/presets-popover.tsx +0 -511
  341. package/src/components/ui/panels/roof-panel.tsx +0 -280
  342. package/src/components/ui/panels/roof-segment-panel.tsx +0 -311
  343. package/src/components/ui/panels/slab-panel.tsx +0 -250
  344. package/src/components/ui/panels/spawn-panel.tsx +0 -161
  345. package/src/components/ui/panels/stair-panel.tsx +0 -577
  346. package/src/components/ui/panels/stair-segment-panel.tsx +0 -325
  347. package/src/components/ui/panels/wall-panel.tsx +0 -273
  348. package/src/components/ui/panels/window-panel.tsx +0 -968
  349. package/src/contexts/presets-context.tsx +0 -121
  350. package/src/hooks/use-contextual-tools.ts +0 -61
@@ -0,0 +1,461 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ generateSceneMaterialId,
5
+ type ItemNode,
6
+ type MaterialSchema,
7
+ nodeRegistry,
8
+ pointInPolygon2D,
9
+ resolveLevelId,
10
+ type SceneMaterial,
11
+ type SceneMaterialId,
12
+ type SlabNode,
13
+ type Space,
14
+ slotLabelFromId,
15
+ toSceneMaterialRef,
16
+ useScene,
17
+ type WallNode,
18
+ } from '@pascal-app/core'
19
+
20
+ /**
21
+ * Painter application scope — how far one paint click spreads. The scope set is
22
+ * DERIVED from the hovered node, not a per-kind table: any slot-model node with
23
+ * more than one slot offers `object` (whole node); a node with an `asset` offers
24
+ * `matching` (every instance of that asset); a kind that declares
25
+ * `capabilities.paint.roomScope` offers `room`. One global mode (not per-tool),
26
+ * defaulting to the narrowest `'single'`; the active interaction's HUD shows +
27
+ * cycles it within the hovered node's set.
28
+ */
29
+ export type PaintScope = 'single' | 'object' | 'matching' | 'room'
30
+
31
+ /** What the paint HUD needs to render + cycle the scope chip for a hover. */
32
+ export type PaintHoverInfo = {
33
+ /** The scopes available for the hovered node, in cycle order (always ≥ 1). */
34
+ scopes: PaintScope[]
35
+ /** Display name of the hovered slot — the label for the `'single'` scope. */
36
+ slotLabel: string
37
+ /** Kind noun for the `'object'` label (e.g. "Whole shelf"). */
38
+ nodeNoun: string
39
+ }
40
+
41
+ function nodeHasAsset(node: AnyNode): boolean {
42
+ return Boolean((node as { asset?: { id?: string } }).asset?.id)
43
+ }
44
+
45
+ function nodeOffersRoomScope(node: AnyNode): boolean {
46
+ return nodeRegistry.get(node.type)?.capabilities?.paint?.roomScope === true
47
+ }
48
+
49
+ /**
50
+ * The scopes a hovered node offers, derived from the node itself: every node
51
+ * paints `single`; > 1 slot adds `object`; an `asset` adds `matching`; a
52
+ * `roomScope`-declaring kind adds `room`. `slotRoles` is the node's full slot set
53
+ * (declared or mesh-derived), passed in by the caller.
54
+ */
55
+ export function availablePaintScopes(args: { node: AnyNode; slotRoles: string[] }): PaintScope[] {
56
+ const scopes: PaintScope[] = ['single']
57
+ if (args.slotRoles.length > 1) scopes.push('object')
58
+ if (nodeHasAsset(args.node)) scopes.push('matching')
59
+ if (nodeOffersRoomScope(args.node)) scopes.push('room')
60
+ return scopes
61
+ }
62
+
63
+ export function cyclePaintScope(scope: PaintScope, scopes: PaintScope[]): PaintScope {
64
+ const list = scopes.length > 0 ? scopes : (['single'] as PaintScope[])
65
+ const index = list.indexOf(scope)
66
+ return list[(index + 1) % list.length] ?? 'single'
67
+ }
68
+
69
+ export function paintScopeLabel(scope: PaintScope, info: PaintHoverInfo): string {
70
+ switch (scope) {
71
+ case 'object':
72
+ return `Whole ${info.nodeNoun}`
73
+ case 'matching':
74
+ return 'All matching'
75
+ case 'room':
76
+ return 'Room'
77
+ default:
78
+ return info.slotLabel || 'This surface'
79
+ }
80
+ }
81
+
82
+ /**
83
+ * All paintable slot roles of a node. Prefers the kind's declared
84
+ * `capabilities.slots` (node-authored, stable); falls back to the runtime mesh
85
+ * tags via the injected `meshSlotRoles` for kinds whose slots come from a GLB
86
+ * (items) rather than a declaration.
87
+ */
88
+ export function nodeSlotRoles(node: AnyNode, meshSlotRoles: (node: AnyNode) => string[]): string[] {
89
+ const declared = nodeRegistry.get(node.type)?.capabilities?.slots?.(node)
90
+ if (declared && declared.length > 0) return declared.map((slot) => slot.slotId)
91
+ return meshSlotRoles(node)
92
+ }
93
+
94
+ /** Display label for the hovered slot — declared label wins, else derived from the id. */
95
+ export function slotDisplayLabel(node: AnyNode, role: string): string {
96
+ const declared = nodeRegistry
97
+ .get(node.type)
98
+ ?.capabilities?.slots?.(node)
99
+ ?.find((slot) => slot.slotId === role)
100
+ return declared?.label ?? slotLabelFromId(role)
101
+ }
102
+
103
+ // ── Fan-out resolution ──────────────────────────────────────────────────────
104
+
105
+ type SlotsNode = AnyNode & { slots?: Record<string, string> }
106
+
107
+ export type WallPaintHit = {
108
+ face: 'front' | 'back'
109
+ point: [number, number]
110
+ }
111
+
112
+ type WallBoundaryFace = Space['boundaryFaces'][number]
113
+
114
+ function distanceToSegment(
115
+ point: readonly [number, number],
116
+ start: readonly [number, number],
117
+ end: readonly [number, number],
118
+ ): number {
119
+ const dx = end[0] - start[0]
120
+ const dz = end[1] - start[1]
121
+ const lengthSquared = dx * dx + dz * dz
122
+ if (lengthSquared < 1e-12) return Math.hypot(point[0] - start[0], point[1] - start[1])
123
+ const t = Math.max(
124
+ 0,
125
+ Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
126
+ )
127
+ return Math.hypot(point[0] - (start[0] + dx * t), point[1] - (start[1] + dz * t))
128
+ }
129
+
130
+ function distanceToPolyline(
131
+ point: readonly [number, number],
132
+ points: ReadonlyArray<readonly [number, number]>,
133
+ ): number {
134
+ let distance = Number.POSITIVE_INFINITY
135
+ for (let index = 0; index < points.length - 1; index += 1) {
136
+ const start = points[index]
137
+ const end = points[index + 1]
138
+ if (!(start && end)) continue
139
+ distance = Math.min(distance, distanceToSegment(point, start, end))
140
+ }
141
+ return distance
142
+ }
143
+
144
+ function wallRoleForRoomFace(role: string, wall: WallNode, face: 'front' | 'back'): string | null {
145
+ const semantic = face === 'front' ? wall.frontSide : wall.backSide
146
+ const fallback = face === 'front' ? 'interior' : 'exterior'
147
+ const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback
148
+
149
+ if (role === 'interior' || role === 'exterior') return side
150
+ if (role.endsWith('Interior'))
151
+ return `${role.slice(0, -'Interior'.length)}${side === 'interior' ? 'Interior' : 'Exterior'}`
152
+ if (role.endsWith('Exterior'))
153
+ return `${role.slice(0, -'Exterior'.length)}${side === 'interior' ? 'Interior' : 'Exterior'}`
154
+ return null
155
+ }
156
+
157
+ function resolveWallPaintSpace(args: {
158
+ wall: WallNode
159
+ wallHit: WallPaintHit
160
+ nodes: Record<string, AnyNode>
161
+ spaces: Record<string, Space>
162
+ }): Space | null {
163
+ const { wall, wallHit, nodes, spaces } = args
164
+ const levelId = wall.parentId ?? resolveLevelId(wall, nodes)
165
+ const tolerance = (wall.thickness ?? 0.2) / 2 + 0.08
166
+ let best: { space: Space; distance: number } | null = null
167
+
168
+ for (const space of Object.values(spaces)) {
169
+ if (space.levelId !== levelId) continue
170
+ for (const boundary of space.boundaryFaces) {
171
+ if (boundary.wallId !== wall.id || boundary.face !== wallHit.face) continue
172
+ const distance = distanceToPolyline(wallHit.point, boundary.points)
173
+ if (distance > tolerance || (best && distance >= best.distance)) continue
174
+ best = { space, distance }
175
+ }
176
+ }
177
+
178
+ return best?.space ?? null
179
+ }
180
+
181
+ function boundaryPointKey(point: readonly [number, number]): string {
182
+ return `${point[0].toFixed(3)},${point[1].toFixed(3)}`
183
+ }
184
+
185
+ function boundarySegmentKey(boundary: WallBoundaryFace): string {
186
+ const forward = boundary.points.map(boundaryPointKey).join('|')
187
+ const reverse = [...boundary.points].reverse().map(boundaryPointKey).join('|')
188
+ return `${boundary.wallId}:${forward < reverse ? forward : reverse}`
189
+ }
190
+
191
+ function oppositeWallFace(face: 'front' | 'back'): 'front' | 'back' {
192
+ return face === 'front' ? 'back' : 'front'
193
+ }
194
+
195
+ function connectedExteriorBoundaries(args: {
196
+ wall: WallNode
197
+ wallHit: WallPaintHit
198
+ levelId: string
199
+ spaces: Record<string, Space>
200
+ }): WallBoundaryFace[] {
201
+ const { wall, wallHit, levelId, spaces } = args
202
+ const occurrences = new Map<string, WallBoundaryFace[]>()
203
+
204
+ for (const space of Object.values(spaces)) {
205
+ if (space.levelId !== levelId) continue
206
+ for (const boundary of space.boundaryFaces) {
207
+ const key = boundarySegmentKey(boundary)
208
+ const entries = occurrences.get(key) ?? []
209
+ entries.push(boundary)
210
+ occurrences.set(key, entries)
211
+ }
212
+ }
213
+
214
+ const exterior = [...occurrences.values()].flatMap((entries) => {
215
+ const boundary = entries.length === 1 ? entries[0] : undefined
216
+ if (!boundary) return []
217
+ return [{ ...boundary, face: oppositeWallFace(boundary.face) }]
218
+ })
219
+ const tolerance = (wall.thickness ?? 0.2) / 2 + 0.08
220
+ const seed = exterior
221
+ .filter((boundary) => boundary.wallId === wall.id && boundary.face === wallHit.face)
222
+ .map((boundary) => ({ boundary, distance: distanceToPolyline(wallHit.point, boundary.points) }))
223
+ .filter((candidate) => candidate.distance <= tolerance)
224
+ .sort((a, b) => a.distance - b.distance)[0]?.boundary
225
+ if (!seed) return []
226
+
227
+ const boundariesByEndpoint = new Map<string, WallBoundaryFace[]>()
228
+ for (const boundary of exterior) {
229
+ const first = boundary.points[0]
230
+ const last = boundary.points[boundary.points.length - 1]
231
+ for (const point of [first, last]) {
232
+ if (!point) continue
233
+ const key = boundaryPointKey(point)
234
+ const entries = boundariesByEndpoint.get(key) ?? []
235
+ entries.push(boundary)
236
+ boundariesByEndpoint.set(key, entries)
237
+ }
238
+ }
239
+
240
+ const connected: WallBoundaryFace[] = []
241
+ const visited = new Set<string>()
242
+ const queue = [seed]
243
+ while (queue.length > 0) {
244
+ const boundary = queue.shift()
245
+ if (!boundary) continue
246
+ const key = `${boundarySegmentKey(boundary)}:${boundary.face}`
247
+ if (visited.has(key)) continue
248
+ visited.add(key)
249
+ connected.push(boundary)
250
+
251
+ const first = boundary.points[0]
252
+ const last = boundary.points[boundary.points.length - 1]
253
+ for (const point of [first, last]) {
254
+ if (!point) continue
255
+ for (const neighbour of boundariesByEndpoint.get(boundaryPointKey(point)) ?? []) {
256
+ queue.push(neighbour)
257
+ }
258
+ }
259
+ }
260
+
261
+ return connected
262
+ }
263
+
264
+ function wallTargetsForBoundaries(args: {
265
+ boundaries: WallBoundaryFace[]
266
+ role: string
267
+ levelId: string
268
+ nodes: Record<string, AnyNode>
269
+ }): Array<{ nodeId: AnyNodeId; role: string }> {
270
+ const { boundaries, role, levelId, nodes } = args
271
+ const targets = new Map<string, { nodeId: AnyNodeId; role: string }>()
272
+ for (const boundary of boundaries) {
273
+ const targetWall = nodes[boundary.wallId]
274
+ if (
275
+ targetWall?.type !== 'wall' ||
276
+ (targetWall.parentId ?? resolveLevelId(targetWall, nodes)) !== levelId
277
+ ) {
278
+ continue
279
+ }
280
+ const targetRole = wallRoleForRoomFace(role, targetWall, boundary.face)
281
+ if (!targetRole) continue
282
+ const key = `${targetWall.id}:${targetRole}`
283
+ targets.set(key, { nodeId: targetWall.id as AnyNodeId, role: targetRole })
284
+ }
285
+ return [...targets.values()]
286
+ }
287
+
288
+ function polygonCentroid(
289
+ points: ReadonlyArray<readonly [number, number]>,
290
+ ): [number, number] | null {
291
+ if (points.length === 0) return null
292
+ let x = 0
293
+ let z = 0
294
+ for (const point of points) {
295
+ x += point[0]
296
+ z += point[1]
297
+ }
298
+ return [x / points.length, z / points.length]
299
+ }
300
+
301
+ /**
302
+ * Expand one paint hit (`node` + resolved `role`) into the full list of
303
+ * (node, role) targets the current `scope` should paint. Returns just the
304
+ * clicked surface for `'single'`, for any target whose scope set doesn't
305
+ * include the current scope, and whenever the spread resolves to a single
306
+ * element — so callers can keep the kind-specific single-node commit for that
307
+ * case and only batch when there's genuinely more than one target.
308
+ *
309
+ * `slotRolesOf` enumerates the node's full slot set (declared or mesh-derived,
310
+ * injected by the caller) for the whole-object scope.
311
+ */
312
+ export function resolvePaintScopeTargets(args: {
313
+ node: AnyNode
314
+ role: string
315
+ scope: PaintScope
316
+ nodes: Record<string, AnyNode>
317
+ spaces: Record<string, Space>
318
+ slotRolesOf: (node: AnyNode) => string[]
319
+ wallHit?: WallPaintHit
320
+ }): Array<{ nodeId: AnyNodeId; role: string }> {
321
+ const { node, role, scope, nodes, spaces, slotRolesOf, wallHit } = args
322
+ const single = [{ nodeId: node.id as AnyNodeId, role }]
323
+ if (scope === 'single') return single
324
+
325
+ // Whole object: paint every slot of the clicked node. Generic across any
326
+ // slot-model kind (item, shelf, door, …) — not item-specific.
327
+ if (scope === 'object') {
328
+ const roles = slotRolesOf(node)
329
+ const set = roles.length > 0 ? roles : [role]
330
+ return set.map((slotRole) => ({ nodeId: node.id as AnyNodeId, role: slotRole }))
331
+ }
332
+
333
+ // All matching: same slot across every instance of the node's asset (items).
334
+ if (scope === 'matching') {
335
+ const assetId = (node as ItemNode).asset?.id
336
+ if (!assetId) return single
337
+ return Object.values(nodes)
338
+ .filter((other) => other.type === 'item' && (other as ItemNode).asset?.id === assetId)
339
+ .map((other) => ({ nodeId: other.id as AnyNodeId, role }))
340
+ }
341
+
342
+ if (node.type === 'wall' && scope === 'room') {
343
+ const wall = node as WallNode
344
+ if (!wallHit) return single
345
+ const levelId = wall.parentId ?? resolveLevelId(wall, nodes)
346
+ if (!levelId) return single
347
+ const space = resolveWallPaintSpace({ wall, wallHit, nodes, spaces })
348
+ const boundaries = space
349
+ ? space.boundaryFaces
350
+ : connectedExteriorBoundaries({ wall, wallHit, levelId, spaces })
351
+ const targets = wallTargetsForBoundaries({ boundaries, role, levelId, nodes })
352
+ return targets.length > 0 ? targets : single
353
+ }
354
+
355
+ if (node.type === 'slab' && scope === 'room') {
356
+ const centroid = polygonCentroid((node as SlabNode).polygon)
357
+ if (!centroid) return single
358
+ const space = Object.values(spaces).find((candidate) =>
359
+ pointInPolygon2D(centroid, candidate.polygon),
360
+ )
361
+ if (!space) return single
362
+ return Object.values(nodes)
363
+ .filter((other) => {
364
+ if (other.type !== 'slab') return false
365
+ const otherCentroid = polygonCentroid((other as SlabNode).polygon)
366
+ return otherCentroid != null && pointInPolygon2D(otherCentroid, space.polygon)
367
+ })
368
+ .map((other) => ({ nodeId: other.id as AnyNodeId, role }))
369
+ }
370
+
371
+ return single
372
+ }
373
+
374
+ // ── Batched commit ──────────────────────────────────────────────────────────
375
+
376
+ // Structural equality for the one-off-colour dedup below. The slot model is
377
+ // uniform across item / wall / slab (`node.slots[role] = ref`), so the same
378
+ // matcher the per-kind commits use applies to the whole fan-out.
379
+ function materialsEqual(a: unknown, b: unknown): boolean {
380
+ if (Object.is(a, b)) return true
381
+ if (typeof a !== typeof b || a === null || b === null) return false
382
+ if (Array.isArray(a) || Array.isArray(b)) {
383
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
384
+ return a.every((value, index) => materialsEqual(value, b[index]))
385
+ }
386
+ if (typeof a === 'object') {
387
+ const aRecord = a as Record<string, unknown>
388
+ const bRecord = b as Record<string, unknown>
389
+ const aKeys = Object.keys(aRecord)
390
+ if (aKeys.length !== Object.keys(bRecord).length) return false
391
+ return aKeys.every(
392
+ (key) => Object.hasOwn(bRecord, key) && materialsEqual(aRecord[key], bRecord[key]),
393
+ )
394
+ }
395
+ return false
396
+ }
397
+
398
+ /**
399
+ * Apply one paint to many slot-model targets in a single undo step. Resolves
400
+ * the slot ref ONCE — a one-off colour creates a single shared scene material
401
+ * for the whole fan-out, not one per node — then writes every `node.slots[role]`
402
+ * (or deletes it, for the eraser) in one `useScene.setState`. Only ever called
403
+ * for item / wall / slab fan-outs, all of which use the unified slot model.
404
+ */
405
+ export function commitPaintScopeFanout(
406
+ targets: ReadonlyArray<{ nodeId: AnyNodeId; role: string }>,
407
+ material: MaterialSchema | undefined,
408
+ materialPreset: string | undefined,
409
+ ): void {
410
+ if (targets.length === 0) return
411
+ const state = useScene.getState()
412
+
413
+ let ref: string | undefined
414
+ let newSceneMaterial: SceneMaterial | null = null
415
+ if (material === undefined && materialPreset === undefined) {
416
+ ref = undefined // eraser → clear the slot back to its default
417
+ } else if (materialPreset) {
418
+ ref = materialPreset
419
+ } else if (material) {
420
+ const existing = Object.values(state.materials).find((scene) =>
421
+ materialsEqual(scene.material, material),
422
+ )
423
+ if (existing) {
424
+ ref = toSceneMaterialRef(existing.id)
425
+ } else {
426
+ const id = generateSceneMaterialId()
427
+ newSceneMaterial = {
428
+ id,
429
+ name: `Material ${Object.keys(state.materials).length + 1}`,
430
+ material,
431
+ }
432
+ ref = toSceneMaterialRef(id)
433
+ }
434
+ } else {
435
+ return
436
+ }
437
+
438
+ useScene.setState((current) => {
439
+ if (current.readOnly) return current
440
+ const nextNodes = { ...current.nodes }
441
+ let changed = false
442
+ for (const { nodeId, role } of targets) {
443
+ const node = nextNodes[nodeId] as SlotsNode | undefined
444
+ if (!node) continue
445
+ const nextSlots = { ...(node.slots ?? {}) }
446
+ if (ref) nextSlots[role] = ref
447
+ else delete nextSlots[role]
448
+ nextNodes[nodeId] = { ...node, slots: nextSlots } as AnyNode
449
+ changed = true
450
+ }
451
+ if (!changed) return current
452
+ return {
453
+ nodes: nextNodes,
454
+ materials: newSceneMaterial
455
+ ? { ...current.materials, [newSceneMaterial.id as SceneMaterialId]: newSceneMaterial }
456
+ : current.materials,
457
+ }
458
+ })
459
+
460
+ for (const { nodeId } of targets) state.markDirty(nodeId)
461
+ }
@@ -0,0 +1,23 @@
1
+ 'use client'
2
+
3
+ import useEditor from '../store/use-editor'
4
+
5
+ function swallowNextClick(timeoutMs = 300) {
6
+ const swallow = (event: Event) => {
7
+ event.stopPropagation()
8
+ event.preventDefault()
9
+ }
10
+
11
+ window.addEventListener('click', swallow, { capture: true, once: true })
12
+ setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), timeoutMs)
13
+ }
14
+
15
+ export function consumePlacementDragRelease(event: PointerEvent): boolean {
16
+ if (!useEditor.getState().placementDragMode) return false
17
+ if (event.button !== 0) return false
18
+
19
+ event.stopPropagation()
20
+ event.preventDefault()
21
+ swallowNextClick()
22
+ return true
23
+ }
@@ -0,0 +1,29 @@
1
+ export function getPlacementMetadataRecord(metadata: unknown): Record<string, unknown> {
2
+ if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
3
+ return {}
4
+ }
5
+
6
+ return metadata as Record<string, unknown>
7
+ }
8
+
9
+ export function addFreshPlacementMetadata(metadata: unknown): Record<string, unknown> {
10
+ return {
11
+ ...getPlacementMetadataRecord(metadata),
12
+ isNew: true,
13
+ }
14
+ }
15
+
16
+ export function isFreshPlacementMetadata(metadata: unknown): boolean {
17
+ return getPlacementMetadataRecord(metadata).isNew === true
18
+ }
19
+
20
+ export function stripPlacementMetadataFlags(metadata: unknown): unknown {
21
+ if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
22
+ return metadata
23
+ }
24
+
25
+ const nextMeta = { ...(metadata as Record<string, unknown>) }
26
+ delete nextMeta.isNew
27
+ delete nextMeta.isTransient
28
+ return nextMeta
29
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { resolvePlanarCursorPosition } from './planar-cursor-placement'
3
+
4
+ const snapHalf = (value: number) => Math.round(value / 0.5) * 0.5
5
+
6
+ describe('resolvePlanarCursorPosition', () => {
7
+ test('absolute mode places the point directly at the snapped cursor', () => {
8
+ const result = resolvePlanarCursorPosition({
9
+ cursor: [1.24, -2.26],
10
+ original: [10, 10],
11
+ anchor: null,
12
+ mode: 'absolute',
13
+ snap: snapHalf,
14
+ })
15
+
16
+ expect(result.point).toEqual([1, -2.5])
17
+ expect(result.anchor).toBeNull()
18
+ })
19
+
20
+ test('relative mode preserves the original grab offset from the first cursor sample', () => {
21
+ const start = resolvePlanarCursorPosition({
22
+ cursor: [4.1, 6.1],
23
+ original: [10, 20],
24
+ anchor: null,
25
+ mode: 'relative',
26
+ snap: snapHalf,
27
+ })
28
+
29
+ expect(start.point).toEqual([10, 20])
30
+ expect(start.anchor).toEqual([4.1, 6.1])
31
+
32
+ const moved = resolvePlanarCursorPosition({
33
+ cursor: [4.9, 5.2],
34
+ original: [10, 20],
35
+ anchor: start.anchor,
36
+ mode: 'relative',
37
+ snap: snapHalf,
38
+ })
39
+
40
+ expect(moved.point).toEqual([11, 19])
41
+ expect(moved.anchor).toEqual([4.1, 6.1])
42
+ })
43
+
44
+ // Track B regression: "off-slab cursor, on-slab footprint stays at center".
45
+ // When the gizmo is grabbed off the footprint center (e.g. near a slab edge),
46
+ // the resolved center must track original + cursorDelta and be independent of
47
+ // the initial grab offset — so a footprint fully inside a slab cannot be
48
+ // pushed off the edge just because the cursor sample landed off-center.
49
+ test('relative mode cancels the off-center gizmo grab offset so the committed center is offset-independent', () => {
50
+ const original: [number, number] = [2, 2]
51
+ const firstSample: [number, number] = [2.3, 2.3]
52
+ const cursor: [number, number] = [3.1, 1.6]
53
+
54
+ const start = resolvePlanarCursorPosition({
55
+ cursor: firstSample,
56
+ original,
57
+ anchor: null,
58
+ mode: 'relative',
59
+ })
60
+
61
+ // First sample absorbs the off-center grab: the footprint stays put.
62
+ expect(start.point).toEqual(original)
63
+ expect(start.anchor).toEqual(firstSample)
64
+
65
+ const moved = resolvePlanarCursorPosition({
66
+ cursor,
67
+ original,
68
+ anchor: start.anchor,
69
+ mode: 'relative',
70
+ })
71
+
72
+ // Committed center = original + (cursor - firstSample), i.e. the gizmo
73
+ // offset is cancelled regardless of where on the footprint it was grabbed.
74
+ const expected: [number, number] = [
75
+ original[0] + (cursor[0] - firstSample[0]),
76
+ original[1] + (cursor[1] - firstSample[1]),
77
+ ]
78
+ expect(moved.point[0]).toBeCloseTo(expected[0])
79
+ expect(moved.point[1]).toBeCloseTo(expected[1])
80
+
81
+ // The result must not depend on the absolute grab offset: grabbing the same
82
+ // footprint dead-center and moving by the same delta yields the same center.
83
+ const centerStart = resolvePlanarCursorPosition({
84
+ cursor: original,
85
+ original,
86
+ anchor: null,
87
+ mode: 'relative',
88
+ })
89
+ const delta: [number, number] = [cursor[0] - firstSample[0], cursor[1] - firstSample[1]]
90
+ const centerMoved = resolvePlanarCursorPosition({
91
+ cursor: [original[0] + delta[0], original[1] + delta[1]],
92
+ original,
93
+ anchor: centerStart.anchor,
94
+ mode: 'relative',
95
+ })
96
+
97
+ expect(centerMoved.point[0]).toBeCloseTo(moved.point[0])
98
+ expect(centerMoved.point[1]).toBeCloseTo(moved.point[1])
99
+ })
100
+ })
@@ -0,0 +1,42 @@
1
+ export type PlanarPoint = [number, number]
2
+
3
+ export type PlanarCursorPlacementMode = 'absolute' | 'relative'
4
+
5
+ type ResolvePlanarCursorPositionArgs = {
6
+ cursor: PlanarPoint
7
+ original: PlanarPoint
8
+ anchor: PlanarPoint | null
9
+ mode: PlanarCursorPlacementMode
10
+ snap?: (value: number) => number
11
+ }
12
+
13
+ type ResolvePlanarCursorPositionResult = {
14
+ point: PlanarPoint
15
+ anchor: PlanarPoint | null
16
+ }
17
+
18
+ const identity = (value: number) => value
19
+
20
+ export function resolvePlanarCursorPosition({
21
+ cursor,
22
+ original,
23
+ anchor,
24
+ mode,
25
+ snap = identity,
26
+ }: ResolvePlanarCursorPositionArgs): ResolvePlanarCursorPositionResult {
27
+ if (mode === 'absolute') {
28
+ return {
29
+ point: [snap(cursor[0]), snap(cursor[1])],
30
+ anchor,
31
+ }
32
+ }
33
+
34
+ const resolvedAnchor = anchor ?? cursor
35
+ return {
36
+ point: [
37
+ original[0] + snap(cursor[0] - resolvedAnchor[0]),
38
+ original[1] + snap(cursor[1] - resolvedAnchor[1]),
39
+ ],
40
+ anchor: resolvedAnchor,
41
+ }
42
+ }
@@ -0,0 +1,19 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { editorHostPanelRegistry, registerEditorHostPanel } from './plugin-panels'
3
+
4
+ describe('editorHostPanelRegistry', () => {
5
+ afterEach(() => editorHostPanelRegistry.reset())
6
+
7
+ test('maps registered node kinds back to their owning host panel', () => {
8
+ registerEditorHostPanel({
9
+ id: 'pascal:trees:trees',
10
+ label: 'Nature',
11
+ icon: { kind: 'url', src: '/nature.webp' },
12
+ component: async () => ({ default: () => null }),
13
+ kinds: ['trees:tree', 'trees:flower', 'trees:grass'],
14
+ })
15
+
16
+ expect(editorHostPanelRegistry.panelForKind('trees:flower')).toBe('pascal:trees:trees')
17
+ expect(editorHostPanelRegistry.panelForKind('wall')).toBeUndefined()
18
+ })
19
+ })