@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
@@ -6,8 +6,7 @@ import {
6
6
  emitter,
7
7
  floorPlatePaintRefusal,
8
8
  type GridEvent,
9
- getEffectiveRoofSurfaceMaterial,
10
- getEffectiveSegmentSurfaceMaterial,
9
+ getRoofSegmentDeckReach,
11
10
  getRoofSegmentSurfaceY,
12
11
  getSelectableKinds,
13
12
  type ItemNode,
@@ -28,14 +27,9 @@ import {
28
27
  useLiveNodeOverrides,
29
28
  useRegistryVersion,
30
29
  useScene,
30
+ wallFaceScriptPaintRefusal,
31
31
  } from '@pascal-app/core'
32
- import {
33
- createMaterial,
34
- getRoofMaterialArray,
35
- registerMaterialCacheCleanup,
36
- resolveMaterialRef,
37
- useViewer,
38
- } from '@pascal-app/viewer'
32
+ import { getRoofMaterialArray, registerMaterialCacheCleanup, useViewer } from '@pascal-app/viewer'
39
33
  import { useThree } from '@react-three/fiber'
40
34
  import { useCallback, useEffect, useRef } from 'react'
41
35
  import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
@@ -63,7 +57,6 @@ import {
63
57
  import { selectionEnabled } from '../../lib/interaction/scope'
64
58
  import {
65
59
  type ActivePaintMaterial,
66
- buildRoofSegmentSurfaceMaterialPatch,
67
60
  buildRoofSurfaceMaterialPatch,
68
61
  hasActivePaintMaterial,
69
62
  resolveActivePaintMaterialFromSelection,
@@ -355,7 +348,7 @@ const wallPaintWorldPoint = new Vector3()
355
348
 
356
349
  const roomHitPoint = new Vector3()
357
350
  const roomHitXZ: [number, number] = [0, 0]
358
- function roomForEvent(event: NodeEvent) {
351
+ export function roomForEvent(event: NodeEvent) {
359
352
  if (event.node.type !== 'wall' && event.node.type !== 'slab' && event.node.type !== 'ceiling')
360
353
  return null
361
354
  // The floor edge band and the foundation belong to the footprint, not the
@@ -400,7 +393,7 @@ function resolveWallPaintHit(event: NodeEvent): WallPaintHit | undefined {
400
393
  }
401
394
  }
402
395
 
403
- function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
396
+ export function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | null {
404
397
  const roof = event.node
405
398
  if (roof.type !== 'roof') return null
406
399
 
@@ -420,9 +413,9 @@ function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode |
420
413
 
421
414
  object.updateWorldMatrix(true, false)
422
415
  const local = object.worldToLocal(roofSelectionWorldPoint.clone())
423
- const overhang = segment.overhang ?? 0
424
- const halfWidth = segment.width / 2 + overhang
425
- const halfDepth = segment.depth / 2 + overhang
416
+ const reach = getRoofSegmentDeckReach(segment)
417
+ const halfWidth = segment.width / 2 + reach.x
418
+ const halfDepth = segment.depth / 2 + reach.z
426
419
 
427
420
  if (Math.abs(local.x) > halfWidth || Math.abs(local.z) > halfDepth) {
428
421
  continue
@@ -501,47 +494,6 @@ function applyRoofPaintPreview(
501
494
  return previewMeshMaterial(mesh, previewMaterial)
502
495
  }
503
496
 
504
- function applyRoofSegmentPaintPreview(
505
- node: RoofSegmentNode,
506
- parent: RoofNode | null,
507
- role: 'top' | 'edge' | 'wall',
508
- material: ActivePaintMaterial,
509
- ): PaintPreviewCleanup | null {
510
- const mesh = getRegisteredMesh(node.id)
511
- if (!mesh) return null
512
-
513
- // Synthesise the segment node as if the paint had committed, then build
514
- // the same 4-slot array the renderer would. Mirrors getRoofMaterialArray
515
- // layout (slot 0 ← edge, 1 ← wall, 2 ← wall, 3 ← top) so the preview
516
- // material lands on the matching CSG groups.
517
- const previewNode: RoofSegmentNode = {
518
- ...node,
519
- ...buildRoofSegmentSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
520
- }
521
- const sceneMaterials = useScene.getState().materials
522
- const resolveSlot = (r: 'top' | 'edge' | 'wall'): Material | null => {
523
- const parentSpec = parent ? getEffectiveRoofSurfaceMaterial(parent, r) : undefined
524
- const spec = getEffectiveSegmentSurfaceMaterial(previewNode, r, parentSpec)
525
- const resolved = resolveMaterialRef(spec.materialPreset, sceneMaterials)
526
- if (resolved) return resolved
527
- if (spec.material !== undefined) return createMaterial(spec.material)
528
- return null
529
- }
530
- const edge = resolveSlot('edge')
531
- const wall = resolveSlot('wall')
532
- const top = resolveSlot('top')
533
- if (!(edge || wall || top)) return null
534
- const fallback = parent
535
- ? getRoofMaterialArray(parent, undefined, undefined, undefined, undefined, null, sceneMaterials)
536
- : null
537
- const fb = (n: number) => fallback?.[n] ?? null
538
- // Per-role only, then the parent's themed slot — matches the renderer so the
539
- // preview never bleeds a painted surface onto the segment's other surfaces.
540
- const arr: Material[] = [edge ?? fb(0)!, wall ?? fb(1)!, wall ?? fb(2)!, top ?? fb(3)!]
541
- if (arr.some((m) => !m)) return null
542
- return previewMeshMaterial(mesh, arr)
543
- }
544
-
545
497
  // Chimney + dormer paint dispatch lives on their NodeDefinition's
546
498
  // `capabilities.paint` (see packages/nodes/src/{chimney,dormer}/
547
499
  // paint.ts). The generic registry-driven arm in this file consults
@@ -1031,8 +983,11 @@ export const SelectionManager = () => {
1031
983
  compatible && role
1032
984
  ? () => {
1033
985
  // A surface with nothing of its own to paint (a footprint's
1034
- // top where no room stands) says so instead of painting.
1035
- const refusal = floorPlatePaintRefusal(useScene.getState().nodes, node, role)
986
+ // top where no room stands), or one a script builds, says
987
+ // so instead of painting.
988
+ const refusal =
989
+ floorPlatePaintRefusal(useScene.getState().nodes, node, role) ??
990
+ wallFaceScriptPaintRefusal(node, role)
1036
991
  usePaintRegionMode.getState().setNotice(refusal?.message ?? null)
1037
992
  if (refusal) return
1038
993
  // Spread targets are all the same slot-model kind, so one
@@ -1108,30 +1063,15 @@ export const SelectionManager = () => {
1108
1063
  }
1109
1064
  }
1110
1065
 
1111
- if (node.type === 'roof' || node.type === 'roof-segment') {
1112
- const isSegmentHit = node.type === 'roof-segment'
1113
- const roofNode =
1114
- node.type === 'roof'
1115
- ? node
1116
- : node.parentId
1117
- ? useScene.getState().nodes[node.parentId as AnyNodeId]
1118
- : null
1119
- if (roofNode?.type !== 'roof') return null
1120
-
1121
- const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
1122
- if (isPaintPicking())
1123
- return pickInteraction(isSegmentHit ? node : roofNode, role, event, null)
1066
+ // A segment paints through its definition's `capabilities.paint`; the merged shell's hit is
1067
+ // the roof's, and fans to it.
1068
+ if (node.type === 'roof') {
1069
+ const role = resolveRoofMaterialTarget(event as RoofEvent)
1070
+ if (isPaintPicking()) return pickInteraction(node, role, event, null)
1124
1071
  const compatible = role !== null && paintEnabled
1125
- // Painting directly on a segment (only possible in segment edit
1126
- // mode, where the per-segment mesh is visible) writes to the
1127
- // segment's own role-specific fields. Painting the merged shell
1128
- // — or a roof node directly — keeps fanning to the parent roof.
1129
- const segmentTarget = isSegmentHit ? (node as RoofSegmentNode) : null
1130
1072
  return {
1131
- key: `${segmentTarget ? 'roof-segment' : 'roof'}:${
1132
- segmentTarget ? segmentTarget.id : roofNode.id
1133
- }:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
1134
- hoveredId: (segmentTarget ? segmentTarget.id : roofNode.id) as AnyNodeId,
1073
+ key: `roof:${node.id}:${role ?? 'unsupported'}:${eraser ? 'erase' : 'paint'}`,
1074
+ hoveredId: node.id as AnyNodeId,
1135
1075
  hoverMode: compatible ? (eraser ? 'erase-ready' : 'paint-ready') : 'paint-disabled',
1136
1076
  // Roof isn't on the slot model (role-specific fields, custom commit),
1137
1077
  // so it offers only the single surface — but still labels it.
@@ -1139,53 +1079,34 @@ export const SelectionManager = () => {
1139
1079
  compatible && role
1140
1080
  ? {
1141
1081
  scopes: ['single'],
1142
- slotLabel: slotDisplayLabel(roofNode, role),
1082
+ slotLabel: slotDisplayLabel(node, role),
1143
1083
  nodeNoun: 'roof',
1144
1084
  }
1145
1085
  : null,
1146
1086
  apply:
1147
1087
  compatible && role
1148
1088
  ? () => {
1149
- const sceneState = useScene.getState()
1150
- if (segmentTarget) {
1151
- sceneState.updateNode(
1152
- segmentTarget.id as AnyNodeId,
1153
- buildRoofSegmentSurfaceMaterialPatch(
1154
- segmentTarget,
1155
- role,
1156
- paintSpec.material,
1157
- paintSpec.materialPreset,
1158
- ),
1159
- )
1160
- } else {
1161
- sceneState.updateNode(
1162
- roofNode.id as AnyNodeId,
1089
+ useScene
1090
+ .getState()
1091
+ .updateNode(
1092
+ node.id as AnyNodeId,
1163
1093
  buildRoofSurfaceMaterialPatch(
1164
- roofNode as RoofNode,
1094
+ node,
1165
1095
  role,
1166
1096
  paintSpec.material,
1167
1097
  paintSpec.materialPreset,
1168
1098
  ),
1169
1099
  )
1170
- }
1171
1100
  }
1172
1101
  : null,
1173
1102
  preview:
1174
1103
  compatible && role
1175
- ? () =>
1176
- segmentTarget
1177
- ? applyRoofSegmentPaintPreview(
1178
- segmentTarget,
1179
- roofNode as RoofNode,
1180
- role,
1181
- paintSpec,
1182
- )
1183
- : applyRoofPaintPreview(roofNode as RoofNode, role, paintSpec)
1104
+ ? () => applyRoofPaintPreview(node, role, paintSpec)
1184
1105
  : () => previewCursor('not-allowed'),
1185
1106
  }
1186
1107
  }
1187
1108
 
1188
- // Only `roof` / `roof-segment` reach a legacy paint arm (above) — every
1109
+ // Only `roof` reaches a legacy paint arm (above) — every
1189
1110
  // other paintable kind declares `capabilities.paint` and returns from the
1190
1111
  // registry-driven dispatch at the top of this function.
1191
1112
 
@@ -1244,23 +1165,20 @@ export const SelectionManager = () => {
1244
1165
 
1245
1166
  const onLeave = (event: NodeEvent) => {
1246
1167
  if (isCeilingGridHit(event)) return
1247
- const interaction = getPaintInteraction(event)
1248
- // A surface behind the hovered one (under an opening that never stops
1249
- // the pointer) leaving says nothing about the hover.
1250
- if (!interaction || interaction.hoveredId !== hoveredNodeId) return
1168
+ // Only the hovered node leaving ends the hover; a surface behind it (under
1169
+ // an opening that never stops the pointer) leaving says nothing about it.
1170
+ // This goes by the node, not by resolving the hit again: a leave event's
1171
+ // ray no longer meets the node, so that resolved to nothing and every
1172
+ // leave was ignored — the last surface stayed previewed, with its HUD.
1173
+ if (event.node.id !== hoveredNodeId) return
1251
1174
 
1252
1175
  // Leaving any surface → the HUD shows the "hover a surface" hint again.
1253
1176
  hoveredNodeId = null
1254
1177
  lastEnterEvent = null
1255
1178
  useEditor.getState().setPaintHover(null)
1256
1179
  usePaintOutline.setState({ surfaces: null })
1257
-
1258
- if (activePreview?.key !== interaction.key) {
1259
- return
1260
- }
1261
-
1262
1180
  clearActivePreview()
1263
- if (useViewer.getState().hoveredId === interaction.hoveredId) {
1181
+ if (useViewer.getState().hoveredId === event.node.id) {
1264
1182
  useViewer.setState({ hoveredId: null })
1265
1183
  }
1266
1184
  setHoverHighlightMode('default')
@@ -1879,6 +1797,9 @@ export const SelectionManager = () => {
1879
1797
  !getMovingNode() &&
1880
1798
  canDirectMoveNode(nodeToSelect)
1881
1799
  ) {
1800
+ // The move tool mounts before the browser's click that ends this
1801
+ // gesture, which would otherwise commit the node where it stands.
1802
+ swallowNextClick()
1882
1803
  sfxEmitter.emit('sfx:item-pick')
1883
1804
  const moveTarget = resolveDirectManipulationNode(nodeToSelect, useScene.getState().nodes)
1884
1805
  useEditor.getState().setMovingNode(moveTarget as never)
@@ -139,12 +139,12 @@ function makeHitGeometry(hole: HolePolygon, centerY: number, height: number): Bu
139
139
 
140
140
  function getSurfaceY(slab: SlabNode): number {
141
141
  const elevation = slab.elevation ?? 0.05
142
- return (elevation > 0 ? elevation : 0) + SURFACE_OFFSET
142
+ return Math.max(elevation, 0) + SURFACE_OFFSET
143
143
  }
144
144
 
145
145
  function getHitCenterY(slab: SlabNode): number {
146
146
  const elevation = slab.elevation ?? 0.05
147
- const surfaceTop = elevation > 0 ? elevation : 0
147
+ const surfaceTop = Math.max(elevation, 0)
148
148
  // Keep the hit box's TOP at the slab surface (never poke above it) so the
149
149
  // slab's centre thickness/height handle, which sits on top, always wins the
150
150
  // raycast over the hole's selectable area.
@@ -18,6 +18,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
18
18
  import { flushSync } from 'react-dom'
19
19
  import { useIsMobile } from '../../hooks/use-mobile'
20
20
  import { triggerSFX } from '../../lib/sfx-bus'
21
+ import { isTypingTarget } from '../../lib/typing-target'
21
22
  import { requestWalkthroughPointerLock } from '../../lib/walkthrough-pointer-lock'
22
23
  import useEditor, {
23
24
  CAPTURE_FOV_MAX,
@@ -467,7 +468,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
467
468
  return
468
469
  }
469
470
  if (e.key !== 'Enter') return
470
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
471
+ if (isTypingTarget(e.target)) return
471
472
  e.preventDefault()
472
473
  handleCapture()
473
474
  }
@@ -566,7 +567,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
566
567
  const captureDisabled = captureState !== 'idle' || (mode === 'area' && !hasSelection)
567
568
 
568
569
  return (
569
- <div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
570
+ <div className="dark pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
570
571
  {/* Walk / drone keep the walkthrough's centered pointer (the ring means
571
572
  E opens the door / window under it) — the capture overlay replaces
572
573
  the walkthrough HUD, so the crosshair rides along here. */}
@@ -240,62 +240,61 @@ describe('snapshot request correlation', () => {
240
240
  ])
241
241
  })
242
242
 
243
- test.each([
244
- 'standard',
245
- 'viewport',
246
- 'area',
247
- ] as const)('a manual %s shutter waits for a background upload; redundant autosaves are dropped', async (captureMode) => {
248
- const enqueue = createSnapshotQueue()
249
- const busy = { current: false }
250
- const failures: SnapshotCaptureFailedEvent[] = []
251
- const events: string[] = []
252
- let finishUpload!: () => void
253
- const uploading = new Promise<void>((resolve) => {
254
- finishUpload = resolve
255
- })
256
- const background = enqueue({ requestId: 'background' }, () =>
257
- runSnapshotCapture(
258
- 'background',
259
- busy,
260
- async () => {
261
- events.push('background rendered')
262
- await uploading
263
- events.push('background saved')
264
- },
265
- (failure) => failures.push(failure),
266
- ),
267
- )
268
- await Promise.resolve()
269
- expect(busy.current).toBe(true)
270
- const autosave = enqueue({}, async () => {
271
- events.push('autosave')
272
- })
273
- let overlayState = 'capturing'
274
- const manual = enqueue({ captureMode }, () =>
275
- runSnapshotCapture(
276
- undefined,
277
- busy,
278
- async () => {
279
- events.push('manual saved')
280
- overlayState = 'saved'
281
- },
282
- (failure) => failures.push(failure),
283
- ),
284
- )
285
- await autosave
286
- expect(overlayState).toBe('capturing')
287
- expect(events).toEqual(['background rendered'])
288
- finishUpload()
289
- await Promise.all([background, manual])
290
- expect(events).toEqual(['background rendered', 'background saved', 'manual saved'])
291
- expect(overlayState).toBe('saved')
292
- expect(failures).toEqual([])
293
- expect(busy.current).toBe(false)
294
- await enqueue({}, async () => {
295
- events.push('idle autosave')
296
- })
297
- expect(events.at(-1)).toBe('idle autosave')
298
- })
243
+ test.each(['standard', 'viewport', 'area'] as const)(
244
+ 'a manual %s shutter waits for a background upload; redundant autosaves are dropped',
245
+ async (captureMode) => {
246
+ const enqueue = createSnapshotQueue()
247
+ const busy = { current: false }
248
+ const failures: SnapshotCaptureFailedEvent[] = []
249
+ const events: string[] = []
250
+ let finishUpload!: () => void
251
+ const uploading = new Promise<void>((resolve) => {
252
+ finishUpload = resolve
253
+ })
254
+ const background = enqueue({ requestId: 'background' }, () =>
255
+ runSnapshotCapture(
256
+ 'background',
257
+ busy,
258
+ async () => {
259
+ events.push('background rendered')
260
+ await uploading
261
+ events.push('background saved')
262
+ },
263
+ (failure) => failures.push(failure),
264
+ ),
265
+ )
266
+ await Promise.resolve()
267
+ expect(busy.current).toBe(true)
268
+ const autosave = enqueue({}, async () => {
269
+ events.push('autosave')
270
+ })
271
+ let overlayState = 'capturing'
272
+ const manual = enqueue({ captureMode }, () =>
273
+ runSnapshotCapture(
274
+ undefined,
275
+ busy,
276
+ async () => {
277
+ events.push('manual saved')
278
+ overlayState = 'saved'
279
+ },
280
+ (failure) => failures.push(failure),
281
+ ),
282
+ )
283
+ await autosave
284
+ expect(overlayState).toBe('capturing')
285
+ expect(events).toEqual(['background rendered'])
286
+ finishUpload()
287
+ await Promise.all([background, manual])
288
+ expect(events).toEqual(['background rendered', 'background saved', 'manual saved'])
289
+ expect(overlayState).toBe('saved')
290
+ expect(failures).toEqual([])
291
+ expect(busy.current).toBe(false)
292
+ await enqueue({}, async () => {
293
+ events.push('idle autosave')
294
+ })
295
+ expect(events.at(-1)).toBe('idle autosave')
296
+ },
297
+ )
299
298
 
300
299
  test('queues a frame dispatched by a save callback until the previous capture releases its lock', async () => {
301
300
  const enqueue = createSnapshotQueue()
@@ -439,13 +438,25 @@ describe('an ephemeral capture', () => {
439
438
  await deliverSnapshot(
440
439
  { requestId: 'view-1', ephemeral: true },
441
440
  blob,
442
- { ...cameraData, resolution: { w: 1280, h: 800 } },
441
+ {
442
+ ...cameraData,
443
+ resolution: { w: 1280, h: 800 },
444
+ presentation: { mode: 'solo', levelId: 'level_upper' },
445
+ },
443
446
  (frame) => {
444
447
  stored.push(frame)
445
448
  },
446
449
  )
447
450
  expect(stored).toEqual([])
448
- expect(received).toEqual([{ requestId: 'view-1', blob, width: 1280, height: 800 }])
451
+ expect(received).toEqual([
452
+ {
453
+ requestId: 'view-1',
454
+ blob,
455
+ width: 1280,
456
+ height: 800,
457
+ presentation: { mode: 'solo', levelId: 'level_upper' },
458
+ },
459
+ ])
449
460
  } finally {
450
461
  emitter.off('snapshot:captured', onCaptured)
451
462
  }
@@ -149,7 +149,12 @@ export async function runSnapshotCapture(
149
149
  * Where a captured frame goes: to the host, which stores it (a snapshot, the project's
150
150
  * thumbnail), or for an ephemeral capture straight back to its caller, stored nowhere.
151
151
  */
152
- export function deliverSnapshot<T extends object & { resolution?: { w: number; h: number } }>(
152
+ export function deliverSnapshot<
153
+ T extends object & {
154
+ resolution?: { w: number; h: number }
155
+ presentation?: ThumbnailGenerateEvent['presentation']
156
+ },
157
+ >(
153
158
  event: Pick<ThumbnailGenerateEvent, 'requestId' | 'ephemeral'>,
154
159
  blob: Blob,
155
160
  cameraData: T,
@@ -163,6 +168,7 @@ export function deliverSnapshot<T extends object & { resolution?: { w: number; h
163
168
  blob,
164
169
  width: cameraData.resolution?.w ?? 0,
165
170
  height: cameraData.resolution?.h ?? 0,
171
+ ...(cameraData.presentation ? { presentation: cameraData.presentation } : {}),
166
172
  })
167
173
  return
168
174
  }