@pascal-app/editor 1.0.4 → 1.0.6

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +40 -38
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -129,114 +129,114 @@ test('grid moves throttle camera drags at 100 ms and resume immediately during t
129
129
  }
130
130
  })
131
131
 
132
- test.each([
133
- 'slab',
134
- 'ceiling',
135
- ] as const)('grid moves keep the same %s hit when its source joins or leaves a batch', async (kind) => {
136
- const previousViewer = useViewer.getState()
137
- const previousScope = useInteractionScope.getState().scope
138
- const restoreRegistry = nodeRegistry._snapshot()
139
- const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
140
- const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
141
- actGlobal.IS_REACT_ACT_ENVIRONMENT = true
142
- const canvas = Object.assign(new EventTarget(), {
143
- getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
144
- }) as unknown as HTMLCanvasElement
145
- const root = createRoot(canvas)
146
- const camera = new PerspectiveCamera(60, 1, 0.1, 100)
147
- camera.position.set(0, kind === 'ceiling' ? 1 : 10, 10)
148
- camera.lookAt(0, 5, 0)
149
- camera.updateMatrixWorld()
150
- const surface = new Mesh(
151
- new PlaneGeometry(20, 20).rotateX(-Math.PI / 2),
152
- new MeshBasicMaterial({ side: DoubleSide }),
153
- )
154
- surface.position.y = 5
155
- surface.updateMatrixWorld(true)
156
- const surfaceId = `${kind}_grid_batch_test` as const
157
- const wall = WallNode.parse({ start: [0, 0], end: [0, 2] })
158
- const delivered: GridEvent[] = []
159
- const onMove = (event: GridEvent) => delivered.push(event)
160
- emitter.on('grid:move', onMove)
161
-
162
- function Grid() {
163
- useRegistry(surfaceId, kind, { current: surface })
164
- useGridEvents(0)
165
- return null
166
- }
167
- const send = () => {
168
- const before = delivered.length
169
- canvas.dispatchEvent(
170
- Object.assign(new Event('pointermove'), { clientX: 55, clientY: 50, button: 0 }),
132
+ test.each(['slab', 'ceiling'] as const)(
133
+ 'grid moves keep the same %s hit when its source joins or leaves a batch',
134
+ async (kind) => {
135
+ const previousViewer = useViewer.getState()
136
+ const previousScope = useInteractionScope.getState().scope
137
+ const restoreRegistry = nodeRegistry._snapshot()
138
+ const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
139
+ const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
140
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true
141
+ const canvas = Object.assign(new EventTarget(), {
142
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 100, height: 100 }),
143
+ }) as unknown as HTMLCanvasElement
144
+ const root = createRoot(canvas)
145
+ const camera = new PerspectiveCamera(60, 1, 0.1, 100)
146
+ camera.position.set(0, kind === 'ceiling' ? 1 : 10, 10)
147
+ camera.lookAt(0, 5, 0)
148
+ camera.updateMatrixWorld()
149
+ const surface = new Mesh(
150
+ new PlaneGeometry(20, 20).rotateX(-Math.PI / 2),
151
+ new MeshBasicMaterial({ side: DoubleSide }),
171
152
  )
172
- expect(delivered).toHaveLength(before + 1)
173
- return delivered.at(-1)!
174
- }
153
+ surface.position.y = 5
154
+ surface.updateMatrixWorld(true)
155
+ const surfaceId = `${kind}_grid_batch_test` as const
156
+ const wall = WallNode.parse({ start: [0, 0], end: [0, 2] })
157
+ const delivered: GridEvent[] = []
158
+ const onMove = (event: GridEvent) => delivered.push(event)
159
+ emitter.on('grid:move', onMove)
175
160
 
176
- try {
177
- nodeRegistry._reset()
178
- registerNode({
179
- kind,
180
- schemaVersion: 1,
181
- category: 'structure',
182
- capabilities: {},
183
- schema: kind === 'ceiling' ? CeilingNode : SlabNode,
184
- defaults: () => ({}),
185
- drafting: { surfaceQuery: true },
186
- extensions: {
187
- [DRAFTING_SURFACE_EXTENSION_KEY]: {
188
- kind,
189
- ...(kind === 'ceiling' ? { raycast: 'underside' } : {}),
161
+ function Grid() {
162
+ useRegistry(surfaceId, kind, { current: surface })
163
+ useGridEvents(0)
164
+ return null
165
+ }
166
+ const send = () => {
167
+ const before = delivered.length
168
+ canvas.dispatchEvent(
169
+ Object.assign(new Event('pointermove'), { clientX: 55, clientY: 50, button: 0 }),
170
+ )
171
+ expect(delivered).toHaveLength(before + 1)
172
+ return delivered.at(-1)!
173
+ }
174
+
175
+ try {
176
+ nodeRegistry._reset()
177
+ registerNode({
178
+ kind,
179
+ schemaVersion: 1,
180
+ category: 'structure',
181
+ capabilities: {},
182
+ schema: kind === 'ceiling' ? CeilingNode : SlabNode,
183
+ defaults: () => ({}),
184
+ drafting: { surfaceQuery: true },
185
+ extensions: {
186
+ [DRAFTING_SURFACE_EXTENSION_KEY]: {
187
+ kind,
188
+ ...(kind === 'ceiling' ? { raycast: 'underside' } : {}),
189
+ },
190
190
  },
191
- },
192
- })
193
- useViewer.setState({
194
- cameraDragging: false,
195
- selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
196
- })
197
- await root.configure({
198
- gl: {
199
- domElement: canvas,
200
- render() {},
201
- setSize() {},
202
- setPixelRatio() {},
203
- xr: { isPresenting: false },
204
- } as unknown as WebGLRenderer,
205
- camera,
206
- frameloop: 'never',
207
- dpr: 1,
208
- size: { width: 100, height: 100, top: 0, left: 0 },
209
- })
210
- for (const scope of [
211
- { kind: 'moving', node: wall, nodeId: wall.id, nodeType: 'wall', view: '3d' },
212
- { kind: 'drafting', tool: kind },
213
- ] as const) {
191
+ })
192
+ useViewer.setState({
193
+ cameraDragging: false,
194
+ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
195
+ })
196
+ await root.configure({
197
+ gl: {
198
+ domElement: canvas,
199
+ render() {},
200
+ setSize() {},
201
+ setPixelRatio() {},
202
+ xr: { isPresenting: false },
203
+ } as unknown as WebGLRenderer,
204
+ camera,
205
+ frameloop: 'never',
206
+ dpr: 1,
207
+ size: { width: 100, height: 100, top: 0, left: 0 },
208
+ })
209
+ for (const scope of [
210
+ { kind: 'moving', node: wall, nodeId: wall.id, nodeType: 'wall', view: '3d' },
211
+ { kind: 'drafting', tool: kind },
212
+ ] as const) {
213
+ await act(async () => {
214
+ useInteractionScope.setState({ scope })
215
+ root.render(createElement(Grid))
216
+ })
217
+ const before = send()
218
+ expect(before.surfaceHit?.hostId).toBe(surfaceId)
219
+ expect(before.position[1]).toBeCloseTo(5)
220
+ hideFromScene(surface, 'batched')
221
+ const batched = send()
222
+ expect(batched.surfaceHit).toEqual(before.surfaceHit)
223
+ expect(batched.position).toEqual(before.position)
224
+ expect(batched.localPosition).toEqual(before.localPosition)
225
+ showInScene(surface, 'batched')
226
+ expect(send().position).toEqual(before.position)
227
+ }
228
+ } finally {
214
229
  await act(async () => {
215
- useInteractionScope.setState({ scope })
216
- root.render(createElement(Grid))
230
+ root.render(null)
217
231
  })
218
- const before = send()
219
- expect(before.surfaceHit?.hostId).toBe(surfaceId)
220
- expect(before.position[1]).toBeCloseTo(5)
221
- hideFromScene(surface, 'batched')
222
- const batched = send()
223
- expect(batched.surfaceHit).toEqual(before.surfaceHit)
224
- expect(batched.position).toEqual(before.position)
225
- expect(batched.localPosition).toEqual(before.localPosition)
226
- showInScene(surface, 'batched')
227
- expect(send().position).toEqual(before.position)
232
+ emitter.off('grid:move', onMove)
233
+ _roots.delete(canvas)
234
+ surface.geometry.dispose()
235
+ surface.material.dispose()
236
+ restoreRegistry()
237
+ useInteractionScope.setState({ scope: previousScope })
238
+ useViewer.setState(previousViewer)
239
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
228
240
  }
229
- } finally {
230
- await act(async () => {
231
- root.render(null)
232
- })
233
- emitter.off('grid:move', onMove)
234
- _roots.delete(canvas)
235
- surface.geometry.dispose()
236
- surface.material.dispose()
237
- restoreRegistry()
238
- useInteractionScope.setState({ scope: previousScope })
239
- useViewer.setState(previousViewer)
240
- actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
241
- }
242
- })
241
+ },
242
+ )
@@ -26,6 +26,7 @@ import {
26
26
  blocksSnappingShortcut,
27
27
  canCycleSnappingModeShortcut,
28
28
  canRunGlobalRotationShortcut,
29
+ hasSceneKeyboardFocus,
29
30
  isToolOwnedCanopyForm,
30
31
  isToolOwnedRotation,
31
32
  markToolCancelConsumed,
@@ -48,28 +49,76 @@ type RafFn = (callback: (time: number) => void) => number
48
49
  const NODE_ID = 'block_history' as AnyNodeId
49
50
 
50
51
  describe('snapping shortcuts while entering a run length', () => {
52
+ test('document panes keep copy, delete and editing shortcuts away from the scene until it is focused', () => {
53
+ useEditor.setState({ viewLayouts: {} })
54
+ expect(hasSceneKeyboardFocus()).toBe(true)
55
+ useEditor.getState().showView('document:plan', { beside: true })
56
+ expect(hasSceneKeyboardFocus()).toBe(false)
57
+ useEditor.getState().focusViewPane(0)
58
+ expect(hasSceneKeyboardFocus()).toBe(true)
59
+ useEditor
60
+ .getState()
61
+ .setViewLayout({ split: false, panes: ['document:plan', '3d'], ratio: 0.5, focus: 0 })
62
+ expect(hasSceneKeyboardFocus()).toBe(false)
63
+ useEditor.getState().closeView('document:plan')
64
+ })
51
65
  test('allows snap mode and spacing shortcuts in the duct and pipe length field', () => {
52
- expect(
53
- blocksSnappingShortcut({
54
- tagName: 'INPUT',
55
- isContentEditable: false,
56
- hasAttribute: (name) => name === 'data-run-length-input',
57
- }),
58
- ).toBe(false)
66
+ const field = {
67
+ tagName: 'INPUT',
68
+ isContentEditable: false,
69
+ hasAttribute: (name: string) => name === 'data-run-length-input',
70
+ closest: (): object => field,
71
+ }
72
+ expect(blocksSnappingShortcut(field)).toBe(false)
59
73
  })
60
74
 
61
75
  test('continues to protect ordinary text fields and editable content', () => {
62
- for (const tagName of ['INPUT', 'TEXTAREA', 'DIV']) {
63
- expect(
64
- blocksSnappingShortcut({
65
- tagName,
66
- isContentEditable: tagName === 'DIV',
67
- hasAttribute: () => false,
68
- }),
69
- ).toBe(true)
76
+ for (const tagName of ['INPUT', 'TEXTAREA', 'SELECT', 'DIV']) {
77
+ const field = {
78
+ tagName,
79
+ isContentEditable: tagName === 'DIV',
80
+ hasAttribute: () => false,
81
+ closest: (): object => field,
82
+ }
83
+ expect(blocksSnappingShortcut(field)).toBe(true)
70
84
  }
71
85
  expect(blocksSnappingShortcut(null)).toBe(false)
72
86
  })
87
+
88
+ test('honors closest-owned fields and dialogs without claiming ordinary canvas keys', () => {
89
+ // Model the DOM closest result, not its selector spelling. Native descendant
90
+ // propagation is checked by the protected browser fixture.
91
+ for (const role of ['textbox', 'combobox', 'dialog', 'alertdialog']) {
92
+ const owner = { role }
93
+ const descendant = {
94
+ tagName: 'SPAN',
95
+ isContentEditable: false,
96
+ hasAttribute: () => false,
97
+ closest: () => owner,
98
+ }
99
+ expect(blocksSnappingShortcut(descendant)).toBe(true)
100
+ }
101
+ const canvas = {
102
+ tagName: 'CANVAS',
103
+ isContentEditable: false,
104
+ hasAttribute: () => false,
105
+ closest: () => null,
106
+ }
107
+ expect(blocksSnappingShortcut(canvas)).toBe(false)
108
+ expect(blocksSnappingShortcut({ ...canvas, tagName: 'SPAN', isContentEditable: true })).toBe(
109
+ true,
110
+ )
111
+ })
112
+
113
+ test('reserves the run-length exception for its actual input, not another editable owner', () => {
114
+ const textarea = {
115
+ tagName: 'TEXTAREA',
116
+ isContentEditable: false,
117
+ hasAttribute: (name: string) => name === 'data-run-length-input',
118
+ closest: (): object => textarea,
119
+ }
120
+ expect(blocksSnappingShortcut(textarea)).toBe(true)
121
+ })
73
122
  })
74
123
 
75
124
  beforeEach(() => {
@@ -89,7 +138,7 @@ beforeEach(() => {
89
138
  afterEach(() => {
90
139
  useInteractionScope.getState().end()
91
140
  useEditor.getState().armToolMode({ mode: 'select' })
92
- useEditor.setState({ selectedItem: null })
141
+ useEditor.setState({ selectedItem: null, viewLayouts: {} })
93
142
  clearSceneHistory()
94
143
  })
95
144
 
@@ -26,6 +26,7 @@ import {
26
26
  import { steppedRotation } from '../components/tools/item/placement-math'
27
27
  import { keyCyclableContinuationContext } from '../lib/continuation'
28
28
  import { resolveDirectManipulationNode } from '../lib/direct-manipulation'
29
+ import { isSceneView } from '../lib/editor-views'
29
30
  import { cancelGestures } from '../lib/gesture-lifecycle'
30
31
  import { guideEmitter } from '../lib/guide-events'
31
32
  import { isHistoryShortcut, runRedo, runUndo, shouldCancelDraftOnHistoryJump } from '../lib/history'
@@ -36,6 +37,7 @@ import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard'
36
37
  import { sfxEmitter } from '../lib/sfx-bus'
37
38
  import { openSidebarPanel } from '../lib/sidebar-panel'
38
39
  import { activeSiteNode, clampBrushRadius } from '../lib/terrain-sculpt'
40
+ import { isTypingTarget } from '../lib/typing-target'
39
41
  import { leaveUnitFocus } from '../lib/units'
40
42
  import { selectWallDrawVariant } from '../lib/wall-draw-variant'
41
43
  import useDeleteConfirmation from '../store/use-delete-confirmation'
@@ -45,6 +47,12 @@ import useInteractionScope, {
45
47
  isInteractionSubtreeDraft,
46
48
  } from '../store/use-interaction-scope'
47
49
  import { groupCurrentSelection, ungroupCurrentSelection } from '../store/use-session-groups'
50
+ import { activeViewLayout } from '../store/view-layout'
51
+
52
+ export function hasSceneKeyboardFocus(): boolean {
53
+ const layout = activeViewLayout(useEditor.getState())
54
+ return isSceneView(layout.panes[layout.focus])
55
+ }
48
56
 
49
57
  // References (guide/scan) are selected via `useEditor.selectedReferenceId`, not
50
58
  // the viewer selection, so selection-based key arms (R/T rotate) need this
@@ -274,7 +282,13 @@ export function blocksSnappingShortcut(
274
282
  ): boolean {
275
283
  if (!target) return false
276
284
  if (target.tagName === 'INPUT' && target.hasAttribute('data-run-length-input')) return false
277
- return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
285
+ return (
286
+ target.tagName === 'INPUT' ||
287
+ target.tagName === 'TEXTAREA' ||
288
+ target.tagName === 'SELECT' ||
289
+ target.isContentEditable ||
290
+ isTypingTarget(target as HTMLElement)
291
+ )
278
292
  }
279
293
 
280
294
  /** E on one selected node: the kind's own E action, else its mechanism. False when neither applies. */
@@ -317,6 +331,11 @@ export const useKeyboard = ({
317
331
  let shiftTapClean = false
318
332
 
319
333
  const handleKeyDown = (e: KeyboardEvent) => {
334
+ if (e.defaultPrevented || !hasSceneKeyboardFocus()) {
335
+ ctrlTapClean = false
336
+ shiftTapClean = false
337
+ return
338
+ }
320
339
  if (e.key === 'Shift') {
321
340
  shiftTapClean = !e.repeat && !e.metaKey && !e.ctrlKey && !e.altKey
322
341
  } else {
@@ -345,12 +364,8 @@ export const useKeyboard = ({
345
364
  return
346
365
  }
347
366
 
348
- // Don't handle shortcuts if user is typing in an input
349
- if (
350
- e.target instanceof HTMLInputElement ||
351
- e.target instanceof HTMLTextAreaElement ||
352
- (e.target instanceof HTMLElement && e.target.isContentEditable)
353
- ) {
367
+ // Editable controls own their keyboard events.
368
+ if (isTypingTarget(e.target)) {
354
369
  return
355
370
  }
356
371
 
@@ -770,6 +785,11 @@ export const useKeyboard = ({
770
785
  }
771
786
  }
772
787
  const handleKeyUp = (e: KeyboardEvent) => {
788
+ if (e.defaultPrevented || !hasSceneKeyboardFocus()) {
789
+ ctrlTapClean = false
790
+ shiftTapClean = false
791
+ return
792
+ }
773
793
  if (e.key === 'Shift') {
774
794
  const wasClean = shiftTapClean
775
795
  shiftTapClean = false
@@ -804,13 +824,10 @@ export const useKeyboard = ({
804
824
  // registry move overlay) — safe only because none of them claim Ctrl/Cmd+G.
805
825
  // `e.code` keeps it on the physical G key across keyboard layouts.
806
826
  const handleSessionGroupKeyDown = (e: KeyboardEvent) => {
827
+ if (e.defaultPrevented || !hasSceneKeyboardFocus()) return
807
828
  if (e.key !== 'Shift') shiftTapClean = false
808
829
  if (e.key !== 'Control' && e.key !== 'Meta') ctrlTapClean = false
809
- if (
810
- e.target instanceof HTMLInputElement ||
811
- e.target instanceof HTMLTextAreaElement ||
812
- (e.target instanceof HTMLElement && e.target.isContentEditable)
813
- ) {
830
+ if (isTypingTarget(e.target)) {
814
831
  return
815
832
  }
816
833
  if (useDeleteConfirmation.getState().request) return
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import { type AnyNode, registerNode } from '@pascal-app/core'
3
- import { placementNoticeOf, transientDraftId } from './use-placement-notice'
3
+ import {
4
+ placementNoticeOf,
5
+ previewPlacementNoticeOf,
6
+ transientDraftId,
7
+ } from './use-placement-notice'
4
8
 
5
9
  // The editor asks a node's definition for its placement notice; it names no kind.
6
10
  function registerKind(placementNotice?: unknown): string {
@@ -46,3 +50,22 @@ describe('the placement notice of the node in hand', () => {
46
50
  expect(transientDraftId({ kept: nodes.kept })).toBeNull()
47
51
  })
48
52
  })
53
+
54
+ test('ephemeral preview notices see their context without inserting a draft into the scene', () => {
55
+ const kind = registerKind((node: AnyNode, ctx: { nodes: Record<string, AnyNode> }) => ({
56
+ line: `${node.id}:${ctx.nodes.child?.name}`,
57
+ }))
58
+ const nodes = {}
59
+ const draft = nodeOf('preview', kind)
60
+ expect(
61
+ previewPlacementNoticeOf(nodes, {
62
+ node: draft,
63
+ parentNode: null,
64
+ contextNodes: [{ ...nodeOf('child', kind), name: 'draft child' }],
65
+ }),
66
+ ).toEqual({ line: 'preview:draft child' })
67
+ expect(nodes).toEqual({})
68
+ expect(
69
+ previewPlacementNoticeOf(nodes, { node: null, parentNode: null, contextNodes: [] }),
70
+ ).toBeNull()
71
+ })
@@ -8,6 +8,8 @@ import {
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
10
  import { useMemo } from 'react'
11
+ import { useShallow } from 'zustand/react/shallow'
12
+ import usePlacementPreview from '../store/use-placement-preview'
11
13
 
12
14
  /** The notice `nodeId`'s kind gives for where it stands (`placementNotice`), if it declares one. */
13
15
  export function placementNoticeOf(
@@ -21,18 +23,47 @@ export function placementNoticeOf(
21
23
  return notice(node as never, live ? { nodes, live } : { nodes })
22
24
  }
23
25
 
26
+ export function previewPlacementNoticeOf(
27
+ nodes: Readonly<Record<string, AnyNode>>,
28
+ draft: Pick<
29
+ ReturnType<typeof usePlacementPreview.getState>,
30
+ 'node' | 'parentNode' | 'contextNodes'
31
+ >,
32
+ ): PlacementNotice | null {
33
+ if (!draft.node) return null
34
+ const context = {
35
+ ...nodes,
36
+ ...Object.fromEntries(draft.contextNodes.map((node) => [node.id, node])),
37
+ ...(draft.parentNode ? { [draft.parentNode.id]: draft.parentNode } : {}),
38
+ [draft.node.id]: draft.node,
39
+ }
40
+ return placementNoticeOf(context, draft.node.id)
41
+ }
42
+
24
43
  /**
25
44
  * What the node in hand, or the selected node, says about where it stands, read at its live pose
26
45
  * while it is placed or moved. Subscribes to the scene only while a node is given, so an
27
46
  * always-mounted caller doesn't re-render on every write.
28
47
  */
29
- export function usePlacementNotice(nodeId: string | null | undefined): PlacementNotice | null {
30
- const nodes = useScene((state) => (nodeId ? state.nodes : null))
31
- const live = useLiveTransforms((state) => (nodeId ? state.get(nodeId) : undefined))
32
- return useMemo(
33
- () => (nodeId && nodes ? placementNoticeOf(nodes, nodeId, live) : null),
34
- [nodes, live, nodeId],
48
+ export function usePlacementNotice(
49
+ nodeId: string | null | undefined,
50
+ preview = false,
51
+ ): PlacementNotice | null {
52
+ const draft = usePlacementPreview(
53
+ useShallow((state) =>
54
+ preview
55
+ ? { node: state.node, parentNode: state.parentNode, contextNodes: state.contextNodes }
56
+ : null,
57
+ ),
35
58
  )
59
+ const nodes = useScene((state) => (nodeId || draft?.node ? state.nodes : null))
60
+ const live = useLiveTransforms((state) => (nodeId ? state.get(nodeId) : undefined))
61
+ return useMemo(() => {
62
+ if (!nodes) return null
63
+ if (nodeId) return placementNoticeOf(nodes, nodeId, live)
64
+ if (!draft?.node) return null
65
+ return previewPlacementNoticeOf(nodes, draft)
66
+ }, [nodes, live, nodeId, draft])
36
67
  }
37
68
 
38
69
  /** The node a construction tool is placing: its transient draft. */