@pascal-app/editor 1.0.0-beta.4 → 1.0.0

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 (283) 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/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  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 +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. 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
  })
@@ -4,13 +4,16 @@ import {
4
4
  GROUND_SUPPORT_ID,
5
5
  type ItemNode,
6
6
  isLowProfileItemSurface,
7
+ nodeRegistry,
7
8
  sceneRegistry,
8
9
  spatialGridManager,
9
10
  useScene,
10
11
  } from '@pascal-app/core'
11
- import { useViewer } from '@pascal-app/viewer'
12
+ import { setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer'
12
13
  import { type Camera, Matrix3, type Object3D, Raycaster, Vector3 } from 'three'
13
14
  import { resolveTerrainGroundHit } from '../../../lib/ground-surface'
15
+ import { scopeNodeId } from '../../../lib/interaction/scope'
16
+ import useInteractionScope from '../../../store/use-interaction-scope'
14
17
 
15
18
  const originScratch = new Vector3()
16
19
  const hitScratch = new Vector3()
@@ -19,11 +22,10 @@ const pointScratch = new Vector3()
19
22
  const worldRayOrigin = new Vector3()
20
23
  const worldRayDirection = new Vector3()
21
24
  const nodeTopRaycaster = new Raycaster()
25
+ setSurfaceRaycastLayers(nodeTopRaycaster.layers)
22
26
  const nodeTopNormal = new Vector3()
23
27
  const nodeTopNormalMatrix = new Matrix3()
24
28
 
25
- const NODE_TOP_SURFACE_KINDS = ['wall', 'item', 'column'] as const
26
-
27
29
  export type PointerSupportSurface = {
28
30
  /** Level-local elevation of the pointed surface — the election cap. */
29
31
  elevation: number
@@ -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) => {
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) => {
161
197
  let current = 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))
@@ -79,6 +79,7 @@ export const TerrainBrushCursor: React.FC<{
79
79
  const sampling = useEditor((state) => state.terrainSampling)
80
80
  const color = brushRingColor(verb, sampling)
81
81
  const unit = useViewer((state) => state.unit)
82
+ const metricNotation = useViewer((state) => state.metricNotation)
82
83
 
83
84
  const lineRef = useRef<Line>(null)
84
85
  const centerRef = useRef<Group>(null)
@@ -195,7 +196,7 @@ export const TerrainBrushCursor: React.FC<{
195
196
 
196
197
  const centerHeight = surfaceHeightAt(surface, hit.x, hit.z)
197
198
  center.position.set(hit.x, centerHeight + RING_LIFT, hit.z)
198
- const heightLabel = `H ${formatMeasurement(centerHeight, unit)}`
199
+ const heightLabel = `H ${formatMeasurement(centerHeight, unit, metricNotation)}`
199
200
  if (heightRef.current) {
200
201
  heightRef.current.style.display = ''
201
202
  if (heightLabel !== lastHeightLabelRef.current) {
@@ -39,10 +39,15 @@ function writeGridHeights(
39
39
  }
40
40
  }
41
41
 
42
+ let first = (row0 * field.cols + col0) * 3
43
+ let end = ((row1 - 1) * field.cols + col1) * 3
44
+ // Several dabs can precede a render; none may discard an earlier pending upload.
45
+ for (const pending of attribute.updateRanges) {
46
+ first = Math.min(first, pending.start)
47
+ end = Math.max(end, pending.start + pending.count)
48
+ }
42
49
  attribute.clearUpdateRanges()
43
- const first = (row0 * field.cols + col0) * 3
44
- const last = ((row1 - 1) * field.cols + (col1 - 1)) * 3 + 2
45
- attribute.addUpdateRange(first, last - first + 1)
50
+ attribute.addUpdateRange(first, end - first)
46
51
  attribute.needsUpdate = true
47
52
  }
48
53
 
@@ -162,7 +167,7 @@ export function TerrainSculptGrid({
162
167
  if (stroke?.lastPatch && stroke.lastPatch !== lastPatch) {
163
168
  lastPatch = stroke.lastPatch
164
169
  writeGridHeights(attribute, stroke.field, stroke.lastPatch)
165
- target.geometry.computeBoundingSphere()
170
+ // This grid is neither frustum-culled nor raycast, so no per-dab bounds scan.
166
171
  return
167
172
  }
168
173
 
@@ -173,7 +178,6 @@ export function TerrainSculptGrid({
173
178
  const restored = terrainFieldOf(current) ?? sculptFieldForSite(current)
174
179
  if (restored.cols !== field.cols || restored.rows !== field.rows) return
175
180
  writeGridHeights(attribute, restored, null)
176
- target.geometry.computeBoundingSphere()
177
181
  }
178
182
  })
179
183
  }, [field.cols, field.rows, site.id])
@@ -214,7 +214,7 @@ export const TerrainSculptTool: React.FC = () => {
214
214
  detachStrokeAnchor(active.stroke)
215
215
  return
216
216
  }
217
- const point = groundPoint(event, active.field)
217
+ const point = groundPoint(event, active.stroke.snapshot)
218
218
  if (!point) return
219
219
  const brushPatch = advanceStroke(active.stroke, point[0], point[1])
220
220
  if (!brushPatch) return