@pascal-app/editor 1.0.0-beta.5 → 1.0.1

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 (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,22 +1,53 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
2
2
  import {
3
3
  type AnyNode,
4
+ type AnyNodeDefinition,
4
5
  type AnyNodeId,
6
+ getWallBaseElevationForNodes,
7
+ ItemNode,
8
+ nodeRegistry,
9
+ registerNode,
10
+ type SlabNode,
5
11
  sceneRegistry,
6
12
  spatialGridManager,
7
13
  useScene,
8
14
  } from '@pascal-app/core'
9
- import { useViewer } from '@pascal-app/viewer'
10
- import { BoxGeometry, Mesh, MeshBasicMaterial, PerspectiveCamera } from 'three'
15
+ import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
16
+ import {
17
+ BoxGeometry,
18
+ Mesh,
19
+ MeshBasicMaterial,
20
+ OrthographicCamera,
21
+ PerspectiveCamera,
22
+ Vector3,
23
+ } from 'three'
24
+ import { z } from 'zod'
25
+ import useInteractionScope from '../../../store/use-interaction-scope'
26
+ import { createWallOnCurrentLevel } from '../wall/wall-drafting'
11
27
  import { resolvePointerSupportSurface } from './pointer-support-cap'
12
28
 
13
29
  const LEVEL_ID = 'level_test' as AnyNodeId
14
30
  const WALL_ID = 'wall_test' as AnyNodeId
31
+ const PLATFORM_ID = 'plugin-platform_test' as AnyNodeId
32
+ const PLATFORM_KIND = 'plugin-platform'
15
33
 
16
34
  describe('resolvePointerSupportSurface node tops', () => {
35
+ beforeAll(() => {
36
+ if (nodeRegistry.has(PLATFORM_KIND)) return
37
+ registerNode({
38
+ kind: PLATFORM_KIND,
39
+ schemaVersion: 1,
40
+ schema: z.object({}),
41
+ category: 'structure',
42
+ defaults: () => ({}),
43
+ capabilities: { surfaces: { top: { height: 2 } } },
44
+ } as unknown as AnyNodeDefinition)
45
+ })
46
+
17
47
  beforeEach(() => {
18
48
  spatialGridManager.clear()
19
49
  sceneRegistry.clear()
50
+ useInteractionScope.getState().end()
20
51
  useViewer.setState({
21
52
  selection: {
22
53
  buildingId: null,
@@ -59,12 +90,63 @@ describe('resolvePointerSupportSurface node tops', () => {
59
90
  sceneRegistry.clear()
60
91
  })
61
92
 
62
- test('prefers the nearest upward-facing registered node surface over the ground', () => {
63
- const wallMesh = new Mesh(new BoxGeometry(2, 2, 0.2), new MeshBasicMaterial())
64
- wallMesh.position.y = 1
65
- wallMesh.updateMatrixWorld(true)
66
- sceneRegistry.nodes.set(WALL_ID, wallMesh)
67
- sceneRegistry.byType.wall!.add(WALL_ID)
93
+ const addPluginPlatform = (z = 0, size: [number, number, number] = [4, 2, 4]) => {
94
+ useScene.setState((state) => ({
95
+ nodes: {
96
+ ...state.nodes,
97
+ [PLATFORM_ID]: {
98
+ id: PLATFORM_ID,
99
+ type: PLATFORM_KIND,
100
+ object: 'node',
101
+ parentId: LEVEL_ID,
102
+ visible: true,
103
+ metadata: {},
104
+ } as unknown as AnyNode,
105
+ },
106
+ }))
107
+ const platformMesh = new Mesh(new BoxGeometry(...size), new MeshBasicMaterial())
108
+ platformMesh.position.set(0, 1, z)
109
+ platformMesh.updateMatrixWorld(true)
110
+ sceneRegistry.nodes.set(PLATFORM_ID, platformMesh)
111
+ sceneRegistry.byType[PLATFORM_KIND]!.add(PLATFORM_ID)
112
+ }
113
+
114
+ test('keeps an off-center orthographic ray on the cursor line', () => {
115
+ const camera = new OrthographicCamera(-20, 20, 20, -20, -1000, 1000)
116
+ camera.position.set(10, 10, 10)
117
+ camera.lookAt(0, 0, 0)
118
+ camera.updateMatrixWorld(true)
119
+
120
+ const direction = camera.getWorldDirection(new Vector3())
121
+ const right = new Vector3(1, 0, 0).applyQuaternion(camera.quaternion)
122
+ const up = new Vector3(0, 1, 0).applyQuaternion(camera.quaternion)
123
+ const rayOrigin = camera.position.clone().addScaledVector(right, 3).addScaledVector(up, 2)
124
+ const groundDistance = -rayOrigin.y / direction.y
125
+ const worldHit = rayOrigin.clone().addScaledVector(direction, groundDistance)
126
+ const topDistance = (2 - rayOrigin.y) / direction.y
127
+ const expectedTop = rayOrigin.clone().addScaledVector(direction, topDistance)
128
+
129
+ addPluginPlatform(expectedTop.z, [0.5, 2, 0.5])
130
+ const platformMesh = sceneRegistry.nodes.get(PLATFORM_ID)!
131
+ platformMesh.position.set(expectedTop.x, 1, expectedTop.z)
132
+ platformMesh.updateMatrixWorld(true)
133
+
134
+ const support = resolvePointerSupportSurface(
135
+ camera,
136
+ worldHit.toArray() as [number, number, number],
137
+ {
138
+ includeNodeTopSurfaces: true,
139
+ },
140
+ )
141
+
142
+ expect(support?.sourceNodeId).toBe(PLATFORM_ID)
143
+ expect(support?.worldPoint?.[0]).toBeCloseTo(expectedTop.x)
144
+ expect(support?.worldPoint?.[1]).toBeCloseTo(expectedTop.y)
145
+ expect(support?.worldPoint?.[2]).toBeCloseTo(expectedTop.z)
146
+ })
147
+
148
+ test('discovers a plugin-declared top surface without a kind-name list', () => {
149
+ addPluginPlatform()
68
150
 
69
151
  const camera = new PerspectiveCamera()
70
152
  camera.position.set(0, 5, 0)
@@ -74,25 +156,245 @@ describe('resolvePointerSupportSurface node tops', () => {
74
156
  includeNodeTopSurfaces: true,
75
157
  })
76
158
 
77
- expect(support?.sourceNodeId).toBe(WALL_ID)
159
+ expect(support?.sourceNodeId).toBe(PLATFORM_ID)
78
160
  expect(support?.elevation).toBeCloseTo(2)
79
161
  expect(support?.worldPoint).toEqual([0, 2, 0])
80
162
  })
81
163
 
82
- test('keeps the ground result when node-top surfaces are not requested', () => {
83
- const wallMesh = new Mesh(new BoxGeometry(2, 2, 0.2), new MeshBasicMaterial())
84
- wallMesh.position.y = 1
85
- wallMesh.updateMatrixWorld(true)
86
- sceneRegistry.nodes.set(WALL_ID, wallMesh)
87
- sceneRegistry.byType.wall!.add(WALL_ID)
164
+ test('keeps an unhovered item top above a batched slab across batch transitions', () => {
165
+ const restoreRegistry = nodeRegistry._snapshot()
166
+ const item = ItemNode.parse({
167
+ id: 'item_support_batch',
168
+ parentId: LEVEL_ID,
169
+ asset: {
170
+ id: 'table',
171
+ name: 'Table',
172
+ src: '/table.glb',
173
+ category: 'furniture',
174
+ thumbnail: '/table.png',
175
+ dimensions: [4, 2, 4],
176
+ },
177
+ })
178
+ const slab = {
179
+ id: 'slab_support_batch',
180
+ type: 'slab',
181
+ parentId: LEVEL_ID,
182
+ polygon: [
183
+ [-3, -3],
184
+ [3, -3],
185
+ [3, 3],
186
+ [-3, 3],
187
+ ],
188
+ elevation: 0.25,
189
+ thickness: 0.25,
190
+ holes: [],
191
+ visible: true,
192
+ } as unknown as SlabNode
193
+ const itemMesh = new Mesh(new BoxGeometry(4, 2, 4), new MeshBasicMaterial())
194
+ itemMesh.position.y = 1.25
195
+ itemMesh.updateMatrixWorld(true)
196
+ const slabMesh = new Mesh(new BoxGeometry(6, 0.25, 6), new MeshBasicMaterial())
197
+ slabMesh.position.y = 0.125
198
+ slabMesh.updateMatrixWorld(true)
199
+ try {
200
+ registerNode({
201
+ kind: 'item',
202
+ schema: ItemNode,
203
+ schemaVersion: 1,
204
+ category: 'furnish',
205
+ defaults: () => ({}),
206
+ capabilities: { surfaces: { top: { height: 2 } } },
207
+ } as unknown as AnyNodeDefinition)
208
+ useScene.setState((state) => ({
209
+ nodes: { ...state.nodes, [item.id]: item, [slab.id]: slab },
210
+ }))
211
+ sceneRegistry.nodes.set(item.id, itemMesh)
212
+ sceneRegistry.byType.item!.add(item.id)
213
+ sceneRegistry.nodes.set(slab.id, slabMesh)
214
+ sceneRegistry.byType.slab!.add(slab.id)
215
+ spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
216
+ useViewer.setState({ hoveredId: null })
217
+ const camera = new PerspectiveCamera()
218
+ camera.position.set(0, 5, 0)
219
+ camera.updateMatrixWorld(true)
220
+ for (const batched of [false, true, false]) {
221
+ for (const mesh of [itemMesh, slabMesh]) {
222
+ if (batched) hideFromScene(mesh, 'batched')
223
+ else showInScene(mesh, 'batched')
224
+ }
225
+ const top = resolvePointerSupportSurface(camera, [0, 0, 0], {
226
+ includeNodeTopSurfaces: true,
227
+ })
228
+ expect(top?.sourceNodeId).toBe(item.id)
229
+ expect(top?.worldPoint).toEqual([0, 2.25, 0])
230
+ const floor = resolvePointerSupportSurface(camera, [0, 0, 0])
231
+ expect(floor?.supportSlabId).toBe(slab.id)
232
+ expect(floor?.worldPoint).toEqual([0, 0.25, 0])
233
+ }
234
+ } finally {
235
+ restoreRegistry()
236
+ for (const mesh of [itemMesh, slabMesh]) {
237
+ mesh.geometry.dispose()
238
+ mesh.material.dispose()
239
+ }
240
+ }
241
+ })
242
+
243
+ test('keeps the ground result unless node-top surfaces are asked for', () => {
244
+ addPluginPlatform()
245
+
246
+ const camera = new PerspectiveCamera()
247
+ camera.position.set(0, 5, 0)
248
+ camera.updateMatrixWorld(true)
249
+
250
+ // The default. A floor placement aims THROUGH whatever upward-facing
251
+ // geometry sits between the camera and the floor — a room's ceiling, the
252
+ // top of the wall the ray passes over — so only the tools that build on a
253
+ // surface opt in.
254
+ for (const options of [undefined, { includeNodeTopSurfaces: false }]) {
255
+ const support = resolvePointerSupportSurface(camera, [0, 0, 0], options)
256
+ expect(support?.sourceNodeId).toBeNull()
257
+ expect(support?.elevation).toBe(0)
258
+ }
259
+ })
260
+
261
+ test('never elects the node the active interaction is placing or moving', () => {
262
+ addPluginPlatform()
263
+ useInteractionScope.getState().begin({
264
+ kind: 'placing',
265
+ node: useScene.getState().nodes[PLATFORM_ID]!,
266
+ nodeId: PLATFORM_ID,
267
+ nodeType: PLATFORM_KIND,
268
+ view: '3d',
269
+ pressDrag: false,
270
+ driver: 'registry-tool',
271
+ })
88
272
 
89
273
  const camera = new PerspectiveCamera()
90
274
  camera.position.set(0, 5, 0)
91
275
  camera.updateMatrixWorld(true)
92
276
 
93
- const support = resolvePointerSupportSurface(camera, [0, 0, 0])
277
+ const support = resolvePointerSupportSurface(camera, [0, 0, 0], {
278
+ includeNodeTopSurfaces: true,
279
+ })
94
280
 
281
+ // Its own top would raise it by its own height on every pointer move.
95
282
  expect(support?.sourceNodeId).toBeNull()
96
283
  expect(support?.elevation).toBe(0)
97
284
  })
285
+
286
+ test('uses a plugin-declared top as the wall construction surface', () => {
287
+ const lowSlab = {
288
+ id: 'slab_low',
289
+ type: 'slab',
290
+ object: 'node',
291
+ parentId: LEVEL_ID,
292
+ visible: true,
293
+ metadata: {},
294
+ children: [],
295
+ polygon: [
296
+ [-2, 1],
297
+ [2, 1],
298
+ [2, 3],
299
+ [-2, 3],
300
+ ],
301
+ holes: [],
302
+ holeMetadata: [],
303
+ elevation: 0.25,
304
+ thickness: 0.25,
305
+ recessed: false,
306
+ autoFromWalls: false,
307
+ } as SlabNode
308
+ const highSlab = {
309
+ ...lowSlab,
310
+ id: 'slab_high',
311
+ polygon: [
312
+ [-1, 1],
313
+ [0, 1],
314
+ [0, 3],
315
+ [-1, 3],
316
+ ],
317
+ elevation: 1,
318
+ } as SlabNode
319
+ useScene.setState((state) => ({
320
+ nodes: {
321
+ ...state.nodes,
322
+ [lowSlab.id]: lowSlab,
323
+ [highSlab.id]: highSlab,
324
+ },
325
+ }))
326
+ spatialGridManager.handleNodeCreated(lowSlab as AnyNode, LEVEL_ID)
327
+ spatialGridManager.handleNodeCreated(highSlab as AnyNode, LEVEL_ID)
328
+ addPluginPlatform(2)
329
+
330
+ const camera = new PerspectiveCamera()
331
+ camera.position.set(0, 5, 2)
332
+ camera.updateMatrixWorld(true)
333
+
334
+ const support = resolvePointerSupportSurface(camera, [0, 0, 2], {
335
+ includeNodeTopSurfaces: true,
336
+ })
337
+
338
+ expect(support?.sourceNodeId).toBe(PLATFORM_ID)
339
+ expect(support?.elevation).toBeCloseTo(2)
340
+ expect(support?.worldPoint).toEqual([0, 2, 2])
341
+
342
+ const wall = createWallOnCurrentLevel([-0.75, 2], [0.75, 2], {
343
+ supportCap: support?.elevation,
344
+ preferredSupportSlabId: support?.supportSlabId,
345
+ constructionElevation: support?.elevation,
346
+ constructionHeight: 2.5,
347
+ flatConstructionBase: support?.sourceNodeId != null,
348
+ })
349
+ expect(wall).not.toBeNull()
350
+ expect(getWallBaseElevationForNodes(wall!, useScene.getState().nodes)).toBeCloseTo(2)
351
+ const wallSupport = spatialGridManager.getSlabSupportForWall(
352
+ LEVEL_ID,
353
+ wall!.start,
354
+ wall!.end,
355
+ wall!.curveOffset,
356
+ wall!.thickness,
357
+ wall!.supportSlabId,
358
+ undefined,
359
+ wall!.supportOffset,
360
+ )
361
+ expect(
362
+ wallSupport.baseSegments.every((segment) => Math.abs(segment.elevation - 2) < 1e-6),
363
+ ).toBe(true)
364
+ })
365
+
366
+ test('tolerates a registered definition without capabilities', () => {
367
+ // Gates the pollution class behind the night-8 CI flake (run
368
+ // 32580694134): `capabilities` is typed required, but a minimal plugin
369
+ // definition (or a leaked test fixture) can ship without it at runtime.
370
+ // The resolver enumerates every registered kind, so one capabilities-less
371
+ // entry must read as "no top surface" — not crash the election.
372
+ const restoreRegistry = nodeRegistry._snapshot()
373
+ try {
374
+ registerNode({
375
+ kind: 'plugin-minimal-capless',
376
+ schemaVersion: 1,
377
+ schema: z.object({}),
378
+ category: 'structure',
379
+ defaults: () => ({}),
380
+ // No `capabilities` — deliberately.
381
+ } as unknown as AnyNodeDefinition)
382
+ addPluginPlatform()
383
+
384
+ const camera = new PerspectiveCamera()
385
+ camera.position.set(0, 5, 0)
386
+ camera.updateMatrixWorld(true)
387
+
388
+ const support = resolvePointerSupportSurface(camera, [0, 0, 0], {
389
+ includeNodeTopSurfaces: true,
390
+ })
391
+
392
+ // No throw, and the election still works: the capabilities-less kind is
393
+ // skipped while the platform's declared top is found as usual.
394
+ expect(support?.sourceNodeId).toBe(PLATFORM_ID)
395
+ expect(support?.elevation).toBeCloseTo(2)
396
+ } finally {
397
+ restoreRegistry()
398
+ }
399
+ })
98
400
  })
@@ -1,16 +1,21 @@
1
1
  import {
2
+ type AnyNode,
2
3
  type AnyNodeId,
3
4
  canHostOnTop,
4
5
  GROUND_SUPPORT_ID,
5
6
  type ItemNode,
6
7
  isLowProfileItemSurface,
8
+ nodeRegistry,
7
9
  sceneRegistry,
8
10
  spatialGridManager,
9
11
  useScene,
10
12
  } from '@pascal-app/core'
11
- import { useViewer } from '@pascal-app/viewer'
12
- import { type Camera, Matrix3, type Object3D, Raycaster, Vector3 } from 'three'
13
+ import { setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer'
14
+ import { type Camera, type Object3D, Raycaster, Vector3 } from 'three'
13
15
  import { resolveTerrainGroundHit } from '../../../lib/ground-surface'
16
+ import { scopeNodeId } from '../../../lib/interaction/scope'
17
+ import useInteractionScope from '../../../store/use-interaction-scope'
18
+ import { surfaceWorldNormalY } from './surface-hit'
14
19
 
15
20
  const originScratch = new Vector3()
16
21
  const hitScratch = new Vector3()
@@ -19,10 +24,7 @@ const pointScratch = new Vector3()
19
24
  const worldRayOrigin = new Vector3()
20
25
  const worldRayDirection = new Vector3()
21
26
  const nodeTopRaycaster = new Raycaster()
22
- const nodeTopNormal = new Vector3()
23
- const nodeTopNormalMatrix = new Matrix3()
24
-
25
- const NODE_TOP_SURFACE_KINDS = ['wall', 'item', 'column'] as const
27
+ setSurfaceRaycastLayers(nodeTopRaycaster.layers)
26
28
 
27
29
  export type PointerSupportSurface = {
28
30
  /** Level-local elevation of the pointed surface — the election cap. */
@@ -85,7 +87,19 @@ export function resolvePointerSupportSurface(
85
87
  // The world ray, kept before the level conversion below: the terrain field is
86
88
  // world-space (site geometry, not level-local), so the march needs this frame.
87
89
  camera.getWorldPosition(worldRayOrigin)
88
- worldRayDirection.set(worldHit[0], worldHit[1], worldHit[2]).sub(worldRayOrigin)
90
+ const cameraToHit = hitScratch.set(worldHit[0], worldHit[1], worldHit[2]).sub(worldRayOrigin)
91
+ if ((camera as Camera & { isOrthographicCamera?: boolean }).isOrthographicCamera) {
92
+ // For an orthographic camera every screen pixel has the same direction. The
93
+ // hit point is offset from the camera along the view plane, so using
94
+ // `camera.position -> hit` tilts the ray toward the screen centre and makes
95
+ // support surfaces drift away from the cursor off-axis.
96
+ camera.getWorldDirection(worldRayDirection).normalize()
97
+ worldRayOrigin
98
+ .set(worldHit[0], worldHit[1], worldHit[2])
99
+ .addScaledVector(worldRayDirection, -cameraToHit.dot(worldRayDirection))
100
+ } else {
101
+ worldRayDirection.copy(cameraToHit).normalize()
102
+ }
89
103
 
90
104
  originScratch.copy(worldRayOrigin)
91
105
  hitScratch.set(worldHit[0], worldHit[1], worldHit[2])
@@ -151,16 +165,39 @@ export function resolvePointerSupportSurface(
151
165
  localPoint = [pointScratch.x, pointScratch.y, pointScratch.z]
152
166
  }
153
167
 
154
- if (options?.includeNodeTopSurfaces) {
168
+ // Node tops are opt-in. A ray aimed at a floor crosses every upward-facing
169
+ // face above that floor first — a room's ceiling, the top of the wall it
170
+ // passes over — so electing "the nearest node top along the ray" silently
171
+ // lifts anything placed inside a finished room. Only the tools that build ON
172
+ // a surface (wall / column / fence / stair / block) mean that, and they say
173
+ // so. Everything else places against the floor the pointer indicates.
174
+ //
175
+ // `capabilities` is typed required on NodeDefinition, but this enumerates
176
+ // EVERY registered kind — including plugin bundles that bypass the type at
177
+ // runtime. A minimal definition without `capabilities` must read as "no top
178
+ // surface", not crash the resolver (night-8 CI, run 32580694134).
179
+ const nodeTopSurfaceKinds = options?.includeNodeTopSurfaces
180
+ ? Array.from(nodeRegistry.entries())
181
+ .filter(([, definition]) => definition.capabilities?.surfaces?.top !== undefined)
182
+ .map(([kind]) => kind)
183
+ : []
184
+ if (nodeTopSurfaceKinds.some((kind) => (sceneRegistry.byType[kind]?.size ?? 0) > 0)) {
155
185
  nodeTopRaycaster.set(worldRayOrigin, worldRayDirection.clone().normalize())
156
186
  const nodes = useScene.getState().nodes
157
187
  const registeredOwners = new Map(
158
188
  [...sceneRegistry.nodes.entries()].map(([nodeId, object]) => [object, nodeId as AnyNodeId]),
159
189
  )
160
- const belongsToActiveLevel = (nodeId: AnyNodeId) => {
161
- let current = nodes[nodeId]
190
+ // The node the active interaction is placing/moving cannot be a surface for
191
+ // itself: its mesh rides the cursor, so electing its own top would raise it
192
+ // by its own height on every pointer move. Tools neuter the dragged mesh's
193
+ // `raycast` for their own pointer routing, but that is each tool's private
194
+ // convention — the election owns the invariant.
195
+ const interactingNodeId = scopeNodeId(useInteractionScope.getState().scope)
196
+ const isEligibleCandidate = (nodeId: AnyNodeId) => {
197
+ let current: AnyNode | undefined = nodes[nodeId]
162
198
  const visited = new Set<AnyNodeId>()
163
199
  while (current && !visited.has(current.id)) {
200
+ if (current.id === interactingNodeId) return false
164
201
  if (current.id === levelId) return true
165
202
  visited.add(current.id)
166
203
  current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined
@@ -179,12 +216,12 @@ export function resolvePointerSupportSurface(
179
216
  }
180
217
  | undefined
181
218
 
182
- for (const kind of NODE_TOP_SURFACE_KINDS) {
219
+ for (const kind of nodeTopSurfaceKinds) {
183
220
  for (const rawId of sceneRegistry.byType[kind] ?? []) {
184
221
  const nodeId = rawId as AnyNodeId
185
222
  const node = nodes[nodeId]
186
223
  const object = sceneRegistry.nodes.get(nodeId)
187
- if (!(node?.visible && object?.visible && belongsToActiveLevel(nodeId))) continue
224
+ if (!(node?.visible && object?.visible && isEligibleCandidate(nodeId))) continue
188
225
  if (
189
226
  node.type === 'item' &&
190
227
  (!canHostOnTop(node) || isLowProfileItemSurface(node as ItemNode))
@@ -210,11 +247,13 @@ export function resolvePointerSupportSurface(
210
247
  ownerObject = ownerObject.parent
211
248
  }
212
249
  if (belongsToNestedNode) continue
213
- nodeTopNormal
214
- .copy(intersection.face.normal)
215
- .applyNormalMatrix(nodeTopNormalMatrix.getNormalMatrix(intersection.object.matrixWorld))
216
- .normalize()
217
- if (nodeTopNormal.y < 0.75) continue
250
+ if (
251
+ surfaceWorldNormalY(
252
+ intersection.face.normal.toArray(),
253
+ intersection.object.matrixWorld,
254
+ ) < 0.75
255
+ )
256
+ continue
218
257
  nearest = {
219
258
  distance: intersection.distance,
220
259
  nodeId,
@@ -0,0 +1,54 @@
1
+ import {
2
+ type AnyNodeId,
3
+ createSceneApi,
4
+ getSurfaceProvider,
5
+ sceneRegistry,
6
+ surfaceRegionContainsPoint,
7
+ useScene,
8
+ } from '@pascal-app/core'
9
+ import { Box3, type Camera, Matrix4, Ray, Vector3 } from 'three'
10
+
11
+ export function createShelfStickiness() {
12
+ const ray = new Ray()
13
+ const box = new Box3()
14
+ const matrix = new Matrix4()
15
+ const cameraPosition = new Vector3()
16
+
17
+ // A ray through a board gap can hit the floor behind the shelf while still targeting its volume.
18
+ return (
19
+ shelfId: string | null | undefined,
20
+ camera: Camera,
21
+ worldPoint: readonly [number, number, number],
22
+ ) => {
23
+ if (!shelfId) return false
24
+ const mesh = sceneRegistry.nodes.get(shelfId)
25
+ const shelf = useScene.getState().nodes[shelfId as AnyNodeId]
26
+ if (!mesh || !shelf || (shelf.type !== 'shelf' && shelf.type !== 'cabinet')) return false
27
+ camera.getWorldPosition(cameraPosition)
28
+ ray.origin.copy(cameraPosition)
29
+ ray.direction
30
+ .set(...worldPoint)
31
+ .sub(cameraPosition)
32
+ .normalize()
33
+ ray.applyMatrix4(matrix.copy(mesh.matrixWorld).invert())
34
+ if (shelf.type === 'cabinet') {
35
+ const surfaces =
36
+ getSurfaceProvider(shelf).surfaces?.(shelf, { scene: createSceneApi(useScene) }) ?? []
37
+ return surfaces.some((surface) => {
38
+ if (Math.abs(ray.direction.y) < 1e-8) return false
39
+ const distance = (surface.position[1] - ray.origin.y) / ray.direction.y
40
+ if (distance < 0) return false
41
+ const point = ray.at(distance, new Vector3())
42
+ return surfaceRegionContainsPoint(surface.region, [
43
+ point.x - surface.position[0],
44
+ point.z - surface.position[2],
45
+ ])
46
+ })
47
+ }
48
+ if (!shelf.width || !shelf.depth || !shelf.height) return false
49
+ const margin = 0.08
50
+ box.min.set(-shelf.width / 2 - margin, -margin, -shelf.depth / 2 - margin)
51
+ box.max.set(shelf.width / 2 + margin, shelf.height + margin, shelf.depth / 2 + margin)
52
+ return ray.intersectsBox(box)
53
+ }
54
+ }
@@ -0,0 +1,56 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { type ItemEvent, ItemNode, sceneRegistry } from '@pascal-app/core'
3
+ import { Group, Matrix3, Vector3 } from 'three'
4
+ import { itemEventToSurfaceHit } from './surface-hit'
5
+
6
+ const host = ItemNode.parse({
7
+ id: 'item_hit-adapter',
8
+ asset: { id: 'host', name: 'Host', category: 'furniture', thumbnail: '', src: '/host.glb' },
9
+ })
10
+
11
+ afterEach(() => sceneRegistry.nodes.delete(host.id))
12
+
13
+ test('matches the legacy host-local point and hit-mesh normal exactly under rotation and scale', () => {
14
+ const mesh = new Group()
15
+ mesh.position.set(4.3, 2.1, -3.7)
16
+ mesh.rotation.set(0.2, 0.7, -0.1)
17
+ mesh.scale.set(2, 0.7, 1.3)
18
+ const object = new Group()
19
+ object.name = 'surface-mesh'
20
+ object.position.set(0.4, 0.8, -0.2)
21
+ object.rotation.set(-0.1, 0.3, 0.2)
22
+ object.scale.set(0.8, 1.4, 0.6)
23
+ mesh.add(object)
24
+ mesh.updateMatrixWorld(true)
25
+ sceneRegistry.nodes.set(host.id, mesh)
26
+ const event = {
27
+ node: host,
28
+ object,
29
+ position: object.localToWorld(new Vector3(0.37, 0.81, -0.39)).toArray(),
30
+ localPosition: [0.37, 0.81, -0.39],
31
+ normal: [0.2, 1, -0.3],
32
+ nativeEvent: {} as ItemEvent['nativeEvent'],
33
+ stopPropagation() {},
34
+ } as ItemEvent
35
+ const before = structuredClone({ position: event.position, normal: event.normal })
36
+ const expectedPoint = mesh.worldToLocal(new Vector3(...event.position)).toArray()
37
+ const expectedNormal = new Vector3(...event.normal!)
38
+ .applyNormalMatrix(new Matrix3().getNormalMatrix(object.matrixWorld))
39
+ .normalize().y
40
+ const hit = itemEventToSurfaceHit(host, event)!
41
+ expect(hit.point).toEqual(expectedPoint)
42
+ expect(hit.point).not.toEqual(event.localPosition)
43
+ expect(hit.normalWorldY).toBe(expectedNormal)
44
+ expect(hit.meshName).toBe(object.name)
45
+ expect({ position: event.position, normal: event.normal }).toEqual(before)
46
+
47
+ const roundTrip = mesh.localToWorld(new Vector3(...expectedPoint)).toArray()
48
+ expect(itemEventToSurfaceHit(host, { ...event, position: roundTrip })!.point).toEqual(
49
+ mesh.worldToLocal(new Vector3(...roundTrip)).toArray(),
50
+ )
51
+ expect(itemEventToSurfaceHit(host, { ...event, normal: undefined })!.normalWorldY).toBeNaN()
52
+ })
53
+
54
+ test('a missing registered host mesh yields no hit', () => {
55
+ expect(itemEventToSurfaceHit(host, {} as ItemEvent)).toBeNull()
56
+ })
@@ -0,0 +1,23 @@
1
+ import { type AnyNode, type NodeEvent, type SurfaceHit, sceneRegistry } from '@pascal-app/core'
2
+ import { Matrix3, type Matrix4, Vector3 } from 'three'
3
+
4
+ export function surfaceWorldNormalY(
5
+ normal: NodeEvent<AnyNode>['normal'],
6
+ matrixWorld: Matrix4,
7
+ ): number {
8
+ return normal
9
+ ? new Vector3(...normal)
10
+ .applyNormalMatrix(new Matrix3().getNormalMatrix(matrixWorld))
11
+ .normalize().y
12
+ : Number.NaN
13
+ }
14
+
15
+ export function itemEventToSurfaceHit(host: AnyNode, event: NodeEvent<AnyNode>): SurfaceHit | null {
16
+ const mesh = sceneRegistry.nodes.get(host.id)
17
+ if (!mesh) return null
18
+ return {
19
+ point: mesh.worldToLocal(new Vector3(...event.position)).toArray(),
20
+ normalWorldY: surfaceWorldNormalY(event.normal, event.object.matrixWorld),
21
+ meshName: event.object.name,
22
+ }
23
+ }