@pascal-app/editor 1.0.3 → 1.0.4

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 (440) hide show
  1. package/package.json +11 -9
  2. package/src/components/editor/action-menu-button.tsx +83 -0
  3. package/src/components/editor/bake-thumbnail.tsx +315 -4
  4. package/src/components/editor/capture-level-picker.tsx +72 -0
  5. package/src/components/editor/custom-camera-controls.tsx +202 -57
  6. package/src/components/editor/delete-confirmation-dialog.tsx +297 -7
  7. package/src/components/editor/duplicate-node.ts +244 -0
  8. package/src/components/editor/editor-layout-mobile.tsx +36 -25
  9. package/src/components/editor/editor-layout-v2.tsx +131 -58
  10. package/src/components/editor/export-manager.tsx +34 -8
  11. package/src/components/editor/first-person/build-collider-world.test.ts +51 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +6 -2
  13. package/src/components/editor/first-person/mechanism-targets.test.ts +75 -0
  14. package/src/components/editor/first-person/mechanism-targets.ts +36 -0
  15. package/src/components/editor/first-person-controls.tsx +205 -2
  16. package/src/components/editor/floating-action-menu.tsx +63 -207
  17. package/src/components/editor/floor-aware-camera-controls.test.ts +442 -0
  18. package/src/components/editor/floor-aware-camera-controls.ts +324 -0
  19. package/src/components/editor/floor-openings-overlay.tsx +243 -0
  20. package/src/components/editor/floor-region-controls.tsx +846 -0
  21. package/src/components/editor/floor-region-webgpu-materials.test.ts +12 -0
  22. package/src/components/editor/floor-surface.test.ts +165 -0
  23. package/src/components/editor/floor-surface.ts +182 -0
  24. package/src/components/editor/floorplan-background-selection.test.ts +11 -2
  25. package/src/components/editor/floorplan-background-selection.ts +1 -3
  26. package/src/components/editor/floorplan-panel.tsx +344 -342
  27. package/src/components/editor/footprint-height-handle.tsx +204 -0
  28. package/src/components/editor/group-actions.ts +54 -2
  29. package/src/components/editor/handles/handle-hit-priority.test.tsx +63 -0
  30. package/src/components/editor/handles/handle-hit-priority.tsx +37 -0
  31. package/src/components/editor/handles/swallow-next-click.test.ts +28 -0
  32. package/src/components/editor/handles/use-handle-drag.ts +269 -197
  33. package/src/components/editor/index.tsx +294 -169
  34. package/src/components/editor/join-walls-pill.tsx +44 -0
  35. package/src/components/editor/node-action-menu.tsx +68 -70
  36. package/src/components/editor/node-arrow-handles.tsx +25 -6
  37. package/src/components/editor/open-wall-ends-3d-layer.tsx +273 -0
  38. package/src/components/editor/open-wall-ends-hint.tsx +89 -0
  39. package/src/components/editor/quick-measurement-hud.tsx +3 -2
  40. package/src/components/editor/registry-action-contributions.test.tsx +63 -0
  41. package/src/components/editor/registry-action-contributions.tsx +80 -1
  42. package/src/components/editor/room-controls.tsx +847 -0
  43. package/src/components/editor/room-floor-highlight.tsx +128 -0
  44. package/src/components/editor/room-handles-2d.tsx +354 -0
  45. package/src/components/editor/room-handles.tsx +675 -0
  46. package/src/components/editor/room-highlight.tsx +225 -0
  47. package/src/components/editor/room-labels.test.ts +357 -0
  48. package/src/components/editor/room-labels.tsx +990 -0
  49. package/src/components/editor/room-transform-ghost.tsx +256 -0
  50. package/src/components/editor/room-webgpu-materials.test.ts +27 -0
  51. package/src/components/editor/selection-manager.tsx +451 -317
  52. package/src/components/editor/slab-hole-highlights.tsx +3 -0
  53. package/src/components/editor/snapshot-capture-overlay.tsx +4 -0
  54. package/src/components/editor/snapshot-capture.test.ts +52 -4
  55. package/src/components/editor/snapshot-capture.ts +30 -5
  56. package/src/components/editor/thumbnail-generator.test.ts +308 -0
  57. package/src/components/editor/thumbnail-generator.tsx +1035 -229
  58. package/src/components/editor/use-floorplan-background-placement.ts +31 -11
  59. package/src/components/editor/vector-edge-extractor.tsx +121 -0
  60. package/src/components/editor/views/host-view.tsx +61 -0
  61. package/src/components/editor/views/plugins-view.tsx +670 -0
  62. package/src/components/editor/views/scene-region.tsx +59 -0
  63. package/src/components/editor/views/use-editor-views.ts +57 -0
  64. package/src/components/editor/views/view-bar.tsx +220 -0
  65. package/src/components/editor/wall-justification.test.ts +140 -0
  66. package/src/components/editor/wall-move-side-handles.tsx +43 -142
  67. package/src/components/editor/wall-push-arrow.tsx +92 -0
  68. package/src/components/editor/wall-region-handles.tsx +509 -0
  69. package/src/components/editor/wall-snap-beacon-layer.tsx +3 -0
  70. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +8 -4
  71. package/src/components/editor-2d/floorplan-open-wall-ends-layer.test.tsx +86 -0
  72. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +290 -0
  73. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +11 -1
  74. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +113 -102
  75. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +156 -0
  76. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +278 -0
  77. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +77 -0
  78. package/src/components/editor-2d/renderers/{floorplan-draft-wall-measurement.tsx → floorplan-draft-measurement.tsx} +8 -8
  79. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +4 -1
  80. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +38 -8
  81. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +291 -227
  82. package/src/components/editor-2d/svg-paths.ts +12 -15
  83. package/src/components/procedural-items/inspector.tsx +40 -15
  84. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +97 -14
  85. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +18 -18
  86. package/src/components/systems/zone/zone-system.test.ts +47 -0
  87. package/src/components/systems/zone/zone-system.tsx +29 -68
  88. package/src/components/tools/item/placement-history.test.tsx +474 -0
  89. package/src/components/tools/item/placement-strategies.ts +8 -4
  90. package/src/components/tools/item/use-draft-node.test.tsx +432 -1
  91. package/src/components/tools/item/use-draft-node.ts +166 -95
  92. package/src/components/tools/item/use-placement-coordinator.tsx +93 -46
  93. package/src/components/tools/paint-region/use-paint-tint.ts +37 -0
  94. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +466 -0
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +3 -1
  96. package/src/components/tools/select/box-select-tool.tsx +2 -12
  97. package/src/components/tools/select/plane-box-select-tool.test.tsx +79 -0
  98. package/src/components/tools/select/plane-box-select-tool.tsx +35 -22
  99. package/src/components/tools/select/select-candidates.test.ts +37 -4
  100. package/src/components/tools/select/select-candidates.ts +15 -11
  101. package/src/components/tools/shared/cursor-sphere.tsx +36 -12
  102. package/src/components/tools/site/site-boundary-editor.tsx +1 -2
  103. package/src/components/tools/stair/stair-defaults.ts +0 -3
  104. package/src/components/tools/stair/stair-tool.tsx +90 -42
  105. package/src/components/tools/tool-manager.tsx +46 -20
  106. package/src/components/tools/wall/wall-drafting.test.ts +19 -4
  107. package/src/components/tools/zone/zone-tool.tsx +4 -12
  108. package/src/components/ui/action-menu/action-button.tsx +3 -0
  109. package/src/components/ui/action-menu/camera-actions.tsx +2 -1
  110. package/src/components/ui/action-menu/control-modes.tsx +2 -35
  111. package/src/components/ui/action-menu/measurement-control.tsx +3 -10
  112. package/src/components/ui/build-panel/build-panel.tsx +235 -0
  113. package/src/components/ui/command-palette/editor-commands.tsx +4 -30
  114. package/src/components/ui/command-palette/index.tsx +5 -24
  115. package/src/components/ui/controls/finish-swatch.tsx +110 -0
  116. package/src/components/ui/controls/material-paint-panel.tsx +97 -26
  117. package/src/components/ui/controls/material-picker.tsx +4 -1
  118. package/src/components/ui/controls/scene-material-list.tsx +4 -32
  119. package/src/components/ui/controls/select-control.tsx +65 -0
  120. package/src/components/ui/floating-level-selector.tsx +9 -14
  121. package/src/components/ui/helpers/building-helper.tsx +4 -1
  122. package/src/components/ui/helpers/contextual-helper-panel.tsx +154 -70
  123. package/src/components/ui/helpers/helper-manager.test.tsx +69 -0
  124. package/src/components/ui/helpers/helper-manager.tsx +325 -14
  125. package/src/components/ui/helpers/item-helper.tsx +8 -0
  126. package/src/components/ui/helpers/registered-tool-helper.tsx +8 -1
  127. package/src/components/ui/icon-ref.tsx +45 -1
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/node-icon-image.tsx +16 -0
  130. package/src/components/ui/panels/collections/collections-popover.tsx +20 -14
  131. package/src/components/ui/panels/floor-foundation-panel.tsx +551 -0
  132. package/src/components/ui/panels/floor-opening-panel.tsx +143 -0
  133. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  134. package/src/components/ui/panels/mobile-selection-bar.tsx +2 -2
  135. package/src/components/ui/panels/multi-field-value.test.ts +26 -3
  136. package/src/components/ui/panels/multi-field-value.ts +6 -3
  137. package/src/components/ui/panels/multi-parametric-inspector.tsx +52 -2
  138. package/src/components/ui/panels/multi-selection-panel.tsx +43 -4
  139. package/src/components/ui/panels/node-display.ts +8 -3
  140. package/src/components/ui/panels/panel-manager.tsx +103 -68
  141. package/src/components/ui/panels/panel-wrapper.tsx +78 -36
  142. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  143. package/src/components/ui/panels/parametric-inspector.tsx +4 -4
  144. package/src/components/ui/panels/reference-panel.tsx +2 -3
  145. package/src/components/ui/panels/room-construction-rows.tsx +774 -0
  146. package/src/components/ui/panels/room-name-options.test.ts +55 -0
  147. package/src/components/ui/panels/room-name-options.ts +47 -0
  148. package/src/components/ui/panels/room-openings-field.tsx +110 -0
  149. package/src/components/ui/panels/room-panel.tsx +725 -0
  150. package/src/components/ui/panels/shape-choice.tsx +58 -0
  151. package/src/components/ui/panels/wall-region-list.tsx +109 -0
  152. package/src/components/ui/primitives/color-dot.tsx +36 -24
  153. package/src/components/ui/primitives/sidebar.tsx +2 -0
  154. package/src/components/ui/right-stack.test.ts +29 -0
  155. package/src/components/ui/right-stack.tsx +145 -0
  156. package/src/components/ui/sidebar/icon-rail.tsx +5 -0
  157. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +31 -4
  158. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +5 -5
  159. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -4
  160. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +1 -5
  161. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +197 -0
  162. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -5
  163. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +1 -5
  164. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +1 -5
  165. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -5
  166. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +24 -11
  167. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +1 -5
  168. package/src/components/ui/sidebar/panels/site-panel/index.tsx +176 -211
  169. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +30 -8
  170. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +1 -3
  171. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +4 -1
  172. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +1 -3
  173. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +1 -4
  174. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +1 -3
  175. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +21 -13
  176. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +1 -5
  177. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -5
  178. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +1 -5
  179. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +0 -27
  180. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -3
  181. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -5
  182. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +1 -5
  183. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +9 -4
  184. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +0 -2
  185. package/src/components/ui/sidebar/tab-bar.tsx +10 -0
  186. package/src/components/ui/sidebar/use-plugin-panels.tsx +15 -5
  187. package/src/components/viewer/floorplan-compass-button.tsx +9 -1
  188. package/src/components/viewer/floorplan-preview.tsx +5 -6
  189. package/src/components/viewer/viewer-hover-label.tsx +34 -0
  190. package/src/components/viewer/viewer-rooms.tsx +175 -0
  191. package/src/components/viewer/viewer-scene-header.tsx +181 -145
  192. package/src/components/viewer/viewer-selection-manager.tsx +124 -0
  193. package/src/components/viewer/viewer-stage.tsx +2 -2
  194. package/src/components/viewer-zone-system.tsx +3 -8
  195. package/src/hooks/use-auto-save.integration.test.tsx +123 -9
  196. package/src/hooks/use-auto-save.ts +34 -8
  197. package/src/hooks/use-keyboard.test.ts +178 -3
  198. package/src/hooks/use-keyboard.ts +89 -71
  199. package/src/hooks/use-open-wall-ends.test.ts +99 -0
  200. package/src/hooks/use-open-wall-ends.ts +238 -0
  201. package/src/hooks/use-placement-notice.test.ts +48 -0
  202. package/src/hooks/use-placement-notice.ts +42 -0
  203. package/src/hooks/use-selected-room.ts +143 -0
  204. package/src/index.tsx +246 -39
  205. package/src/lib/ceiling-edit-session.test.ts +321 -0
  206. package/src/lib/ceiling-edit-session.ts +206 -0
  207. package/src/lib/ceiling-region-session.test.ts +179 -0
  208. package/src/lib/continuation.test.ts +19 -1
  209. package/src/lib/continuation.ts +25 -4
  210. package/src/lib/door-interaction.ts +14 -0
  211. package/src/lib/editor-level-display.test.ts +96 -0
  212. package/src/lib/editor-level-display.ts +48 -0
  213. package/src/lib/editor-views.ts +144 -0
  214. package/src/lib/export-geometry-wait.test.ts +90 -0
  215. package/src/lib/floor-edit-session.ts +153 -0
  216. package/src/lib/floor-footprints.test.ts +563 -0
  217. package/src/lib/floor-footprints.ts +395 -0
  218. package/src/lib/floor-foundation-support.test.ts +283 -0
  219. package/src/lib/floor-opening-draft.ts +218 -0
  220. package/src/lib/floor-openings.test.ts +216 -0
  221. package/src/lib/floor-region-geometry.ts +191 -0
  222. package/src/lib/floor-region-session.test.ts +485 -0
  223. package/src/lib/floor-region-session.ts +589 -0
  224. package/src/lib/floor-region-snap.ts +101 -0
  225. package/src/lib/floor-step-hover.test.ts +32 -0
  226. package/src/lib/floorplan/annotation-visibility.ts +17 -0
  227. package/src/lib/floorplan/dimension-drive.test.ts +360 -0
  228. package/src/lib/floorplan/dimension-drive.ts +479 -0
  229. package/src/lib/floorplan/floorplan-export.test.ts +12 -8
  230. package/src/lib/floorplan/floorplan-export.tsx +32 -8
  231. package/src/lib/floorplan/floorplan-extension.ts +32 -0
  232. package/src/lib/floorplan/floorplan-mode.test.ts +4 -0
  233. package/src/lib/floorplan/floorplan-mode.ts +2 -0
  234. package/src/lib/floorplan/floorplan-pdfkit-document.ts +339 -18
  235. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +138 -8
  236. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +56 -25
  237. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +138 -0
  238. package/src/lib/floorplan/floorplan-readonly.ts +6 -0
  239. package/src/lib/floorplan/geometry.ts +13 -6
  240. package/src/lib/floorplan/index.ts +0 -9
  241. package/src/lib/floorplan/items.ts +5 -2
  242. package/src/lib/floorplan/marks.test.ts +213 -0
  243. package/src/lib/floorplan/marks.ts +238 -0
  244. package/src/lib/floorplan/open-wall-ends.test.ts +268 -0
  245. package/src/lib/floorplan/open-wall-ends.ts +162 -0
  246. package/src/lib/floorplan/pdf-fonts/GeistMono-Bold.ttf +0 -0
  247. package/src/lib/floorplan/pdf-fonts/GeistMono-Regular.ttf +0 -0
  248. package/src/lib/floorplan/pdf-fonts/LICENSE-GeistMono.txt +92 -0
  249. package/src/lib/floorplan/pdf-fonts/LICENSE-LiberationSans.txt +102 -0
  250. package/src/lib/floorplan/pdf-fonts/LiberationSans-Bold.ttf +0 -0
  251. package/src/lib/floorplan/pdf-fonts/LiberationSans-Regular.ttf +0 -0
  252. package/src/lib/floorplan/schedules.test.ts +189 -0
  253. package/src/lib/floorplan/schedules.ts +339 -0
  254. package/src/lib/floorplan/sheet-export.test.ts +100 -0
  255. package/src/lib/floorplan/sheet-export.ts +318 -0
  256. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +779 -0
  257. package/src/lib/floorplan/site-plan/contours.test.ts +43 -0
  258. package/src/lib/floorplan/site-plan/contributors.ts +97 -0
  259. package/src/lib/floorplan/site-plan/coverage.ts +232 -0
  260. package/src/lib/floorplan/site-plan/drawing-type-switch.test.ts +15 -0
  261. package/src/lib/floorplan/site-plan/drawing-type-switch.tsx +86 -0
  262. package/src/lib/floorplan/site-plan/front-edge.test.ts +103 -0
  263. package/src/lib/floorplan/site-plan/front-edge.ts +156 -0
  264. package/src/lib/floorplan/site-plan/geometry.test.ts +275 -0
  265. package/src/lib/floorplan/site-plan/geometry.ts +557 -0
  266. package/src/lib/floorplan/site-plan/index.ts +71 -0
  267. package/src/lib/floorplan/site-plan/site-annotations.test.ts +366 -0
  268. package/src/lib/floorplan/site-plan/site-annotations.ts +1083 -0
  269. package/src/lib/floorplan/site-plan/site-parts.ts +379 -0
  270. package/src/lib/floorplan/site-plan/site-plan-layer.tsx +138 -0
  271. package/src/lib/floorplan/site-plan/street-name.test.ts +69 -0
  272. package/src/lib/floorplan/site-plan/street-name.ts +302 -0
  273. package/src/lib/floorplan/site-plan/yards.test.ts +69 -0
  274. package/src/lib/floorplan/types.ts +1 -27
  275. package/src/lib/fresh-planar-placement.test.ts +36 -0
  276. package/src/lib/fresh-planar-placement.ts +59 -36
  277. package/src/lib/geometry-script/author.ts +152 -0
  278. package/src/lib/geometry-script/client.ts +152 -0
  279. package/src/lib/geometry-script/protocol.ts +12 -0
  280. package/src/lib/gesture-lifecycle.test.ts +595 -0
  281. package/src/lib/gesture-lifecycle.ts +290 -0
  282. package/src/lib/glb-export-zone-holes.test.ts +41 -0
  283. package/src/lib/glb-export.test.ts +485 -0
  284. package/src/lib/glb-export.ts +237 -119
  285. package/src/lib/history.test.ts +6 -0
  286. package/src/lib/history.ts +7 -0
  287. package/src/lib/hud-title.ts +109 -0
  288. package/src/lib/hydration-gated-storage.ts +49 -0
  289. package/src/lib/ifc-export.test.ts +174 -0
  290. package/src/lib/ifc-export.ts +201 -0
  291. package/src/lib/ifc-parts.ts +15 -0
  292. package/src/lib/inspector-expanded.test.ts +44 -0
  293. package/src/lib/inspector-expanded.ts +36 -0
  294. package/src/lib/interaction/scope.ts +18 -0
  295. package/src/lib/keyboard-pan.test.ts +129 -7
  296. package/src/lib/keyboard-pan.ts +161 -12
  297. package/src/lib/live-gesture-round-trip.test.ts +360 -0
  298. package/src/lib/lot/__fixtures__/map-dossier-miami-shores.json +1 -0
  299. package/src/lib/lot/__fixtures__/map-dossier-stpete.json +1 -0
  300. package/src/lib/lot/clean-ring.test.ts +186 -0
  301. package/src/lib/lot/clean-ring.ts +217 -0
  302. package/src/lib/lot/dossier.test.ts +202 -0
  303. package/src/lib/lot/dossier.ts +498 -0
  304. package/src/lib/lot/drop-in.test.ts +326 -0
  305. package/src/lib/lot/drop-in.ts +407 -0
  306. package/src/lib/lot/index.ts +43 -0
  307. package/src/lib/lot/lot-patch.test.ts +340 -0
  308. package/src/lib/lot/lot-patch.ts +394 -0
  309. package/src/lib/lot/parcel-provider.ts +43 -0
  310. package/src/lib/lot/terrain.test.ts +161 -0
  311. package/src/lib/lot/terrain.ts +266 -0
  312. package/src/lib/material-catalog-model.ts +3 -1
  313. package/src/lib/material-paint-panel-model.ts +9 -5
  314. package/src/lib/material-paint.ts +4 -0
  315. package/src/lib/mezzanine-actions.test.ts +342 -0
  316. package/src/lib/mezzanine-draft.test.ts +501 -0
  317. package/src/lib/mezzanine-draft.ts +278 -0
  318. package/src/lib/mezzanine-messages.ts +26 -0
  319. package/src/lib/mezzanine-selection.test.ts +207 -0
  320. package/src/lib/mezzanine-stairs.ts +46 -0
  321. package/src/lib/model-export.ts +3 -1
  322. package/src/lib/node-action-movement.ts +17 -6
  323. package/src/lib/node-display-label.ts +37 -0
  324. package/src/lib/orbit-floor-clearance.test.ts +165 -0
  325. package/src/lib/orbit-floor-clearance.ts +106 -0
  326. package/src/lib/paint-commit-route.test.ts +37 -0
  327. package/src/lib/paint-commit-route.ts +17 -0
  328. package/src/lib/paint-eyedropper.test.tsx +165 -0
  329. package/src/lib/paint-eyedropper.ts +99 -0
  330. package/src/lib/paint-pass-through.test.ts +111 -0
  331. package/src/lib/paint-pass-through.ts +76 -0
  332. package/src/lib/paint-region-hover.ts +25 -0
  333. package/src/lib/paint-region-mode.ts +154 -0
  334. package/src/lib/paint-regions.test.ts +166 -0
  335. package/src/lib/paint-regions.ts +192 -0
  336. package/src/lib/paint-scope.test.ts +317 -35
  337. package/src/lib/paint-scope.ts +266 -25
  338. package/src/lib/panel-rows.ts +21 -0
  339. package/src/lib/parametric-node-update.ts +1 -1
  340. package/src/lib/plate-paint-affected.test.ts +300 -0
  341. package/src/lib/plate-paint-affected.ts +177 -0
  342. package/src/lib/plugin-chat.ts +50 -0
  343. package/src/lib/plugin-panels.test.ts +25 -0
  344. package/src/lib/plugin-panels.ts +94 -0
  345. package/src/lib/print-shell-compiler.ts +1 -0
  346. package/src/lib/registered-draft-snap.ts +12 -0
  347. package/src/lib/roof-duplication.ts +10 -2
  348. package/src/lib/room-assembly-overlay.test.ts +275 -0
  349. package/src/lib/room-assembly-overlay.ts +266 -0
  350. package/src/lib/room-built-on.test.ts +379 -0
  351. package/src/lib/room-built-on.ts +138 -0
  352. package/src/lib/room-construction-commands.ts +712 -0
  353. package/src/lib/room-construction.test.ts +746 -0
  354. package/src/lib/room-construction.ts +385 -0
  355. package/src/lib/room-controls-phase6.test.ts +630 -0
  356. package/src/lib/room-divide-session.ts +523 -0
  357. package/src/lib/room-handle-drag.ts +1068 -0
  358. package/src/lib/room-name-catalog.test.ts +90 -0
  359. package/src/lib/room-name-catalog.ts +168 -0
  360. package/src/lib/room-push-dimensions.test.ts +311 -0
  361. package/src/lib/room-push-dimensions.ts +251 -0
  362. package/src/lib/room-selection-commands.ts +62 -0
  363. package/src/lib/room-selection.test.ts +973 -0
  364. package/src/lib/room-selection.ts +417 -0
  365. package/src/lib/room-structure-commands.ts +94 -0
  366. package/src/lib/room-structure.test.ts +921 -0
  367. package/src/lib/room-transform-session.ts +570 -0
  368. package/src/lib/room-zone-routing.test.ts +294 -0
  369. package/src/lib/room-zone-routing.ts +197 -0
  370. package/src/lib/scene-clipboard.test.ts +364 -0
  371. package/src/lib/scene-clipboard.ts +229 -48
  372. package/src/lib/scene-material-refs.test.ts +79 -0
  373. package/src/lib/scene-material-refs.ts +79 -0
  374. package/src/lib/scene.test.ts +24 -36
  375. package/src/lib/scene.ts +33 -57
  376. package/src/lib/selection-routing.test.ts +129 -45
  377. package/src/lib/selection-routing.ts +53 -74
  378. package/src/lib/session-writes.test.ts +120 -0
  379. package/src/lib/session-writes.ts +205 -0
  380. package/src/lib/sidebar-panel.ts +62 -0
  381. package/src/lib/site-boundary.test.ts +4 -6
  382. package/src/lib/site-boundary.ts +1 -1
  383. package/src/lib/snapping-mode.test.ts +4 -0
  384. package/src/lib/snapping-mode.ts +8 -1
  385. package/src/lib/terrace-draft.test.ts +145 -0
  386. package/src/lib/terrace-draft.ts +213 -0
  387. package/src/lib/tool-cursor-icon.test.ts +16 -0
  388. package/src/lib/tool-cursor-icon.ts +11 -0
  389. package/src/lib/typing-target.ts +9 -0
  390. package/src/lib/units.ts +77 -39
  391. package/src/lib/vector-edges.test.ts +58 -0
  392. package/src/lib/vector-edges.ts +400 -0
  393. package/src/lib/viewer-selection.test.ts +577 -0
  394. package/src/lib/viewer-selection.ts +569 -0
  395. package/src/lib/wall-draw-variant.test.ts +71 -0
  396. package/src/lib/wall-draw-variant.ts +91 -0
  397. package/src/lib/wall-paint-region-session.ts +246 -0
  398. package/src/lib/wall-paint-region.test.ts +373 -0
  399. package/src/lib/wall-polygon-draft.test.ts +262 -0
  400. package/src/lib/wall-polygon-draft.ts +286 -0
  401. package/src/lib/wall-push.test.ts +453 -0
  402. package/src/lib/wall-region-face.ts +352 -0
  403. package/src/lib/wall-region-handles.test.ts +304 -0
  404. package/src/lib/wall-region-handles.ts +368 -0
  405. package/src/lib/wall-region-list.test.ts +119 -0
  406. package/src/lib/wall-region-list.ts +136 -0
  407. package/src/lib/wall-region-snap.ts +110 -0
  408. package/src/lib/window-interaction.ts +14 -0
  409. package/src/lib/zone-content.ts +4 -1
  410. package/src/lib/zone-name.test.ts +17 -0
  411. package/src/lib/zone-name.ts +17 -0
  412. package/src/store/opens-in-select-mode.test.ts +223 -0
  413. package/src/store/paint-mode.test.ts +188 -0
  414. package/src/store/terrain-sculpt-mode.test.ts +19 -21
  415. package/src/store/tool-mode.test.ts +137 -6
  416. package/src/store/use-delete-confirmation.ts +28 -1
  417. package/src/store/use-drawing-view.test.ts +24 -3
  418. package/src/store/use-drawing-view.ts +29 -1
  419. package/src/store/use-editor.tsx +432 -321
  420. package/src/store/use-floorplan-draft-preview.ts +13 -0
  421. package/src/store/use-floorplan-marquee.ts +12 -2
  422. package/src/store/use-quick-measurement-hud.ts +3 -3
  423. package/src/store/use-stair-build-preview.ts +13 -5
  424. package/src/store/use-wall-move-ghosts.ts +4 -2
  425. package/src/store/use-wall-region-handles-selection.test.ts +222 -0
  426. package/src/store/use-wall-region-handles-selection.ts +139 -0
  427. package/src/store/view-layout.test.ts +175 -0
  428. package/src/store/view-layout.ts +196 -0
  429. package/src/test-utils/immediate-animation-frames.ts +16 -0
  430. package/src/test-utils/live-gesture-round-trip.ts +224 -0
  431. package/src/test-utils/selection-harness.ts +82 -0
  432. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +0 -499
  433. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -333
  434. package/src/components/tools/fence/fence-drafting.test.ts +0 -100
  435. package/src/components/tools/fence/fence-drafting.ts +0 -266
  436. package/src/components/ui/sidebar/panels/plugins-panel.tsx +0 -218
  437. package/src/components/viewer/viewer-units-panel.tsx +0 -116
  438. package/src/lib/floorplan/stairs.ts +0 -489
  439. package/src/lib/level-duplication.test.ts +0 -144
  440. package/src/lib/level-duplication.ts +0 -195
@@ -19,7 +19,9 @@ import {
19
19
  type WindowNode,
20
20
  type ZoneNode,
21
21
  } from '@pascal-app/core'
22
+ import { evaluateRecipe } from '@pascal-app/core/procedural-items'
22
23
  import {
24
+ decorateProceduralEmission,
23
25
  getPascalTextureRef,
24
26
  isViewerPresentationTextureBorrowed,
25
27
  poseDoorMovingParts,
@@ -38,6 +40,8 @@ import {
38
40
  } from 'three/examples/jsm/exporters/GLTFExporter.js'
39
41
  import { createExportTextureUtils, type ExportTextureUtils } from './export-texture-utils'
40
42
  import { cloneExportUserData } from './export-user-data'
43
+ import { isIfcRolePart } from './ifc-parts'
44
+ import { nodeDisplayLabel } from './node-display-label'
41
45
  import {
42
46
  type CompressedTextureDecompressor,
43
47
  decompressCanonicalNormalMaps,
@@ -141,6 +145,115 @@ export function nextFrames(): Promise<void> {
141
145
  })
142
146
  }
143
147
 
148
+ /**
149
+ * Export visibility, shared with the clone pruning: a hidden node hides its
150
+ * subtree, except a Site, which hides only its own ground (`hidesDescendants`).
151
+ */
152
+ export function createExportVisibility(nodes: Record<string, AnyNode>) {
153
+ const visibility = new Map<string, boolean>()
154
+ const isVisible = (id: string, path: Set<string> = new Set()): boolean => {
155
+ const cached = visibility.get(id)
156
+ if (cached !== undefined) return cached
157
+ const node = nodes[id]
158
+ if (!node) return true
159
+ if (node.visible === false) {
160
+ visibility.set(id, false)
161
+ return false
162
+ }
163
+ const parentId = node.parentId
164
+ const parent = parentId ? nodes[parentId] : undefined
165
+ if (!parentId || path.has(id) || (parent && !hidesDescendants(parent))) {
166
+ visibility.set(id, true)
167
+ return true
168
+ }
169
+ path.add(id)
170
+ const visible = isVisible(parentId, path)
171
+ path.delete(id)
172
+ visibility.set(id, visible)
173
+ return visible
174
+ }
175
+ return isVisible
176
+ }
177
+
178
+ export type ExportGeometryScope = Pick<GlbExportOptions, 'onlyVisible' | 'excludedNodeTypes'>
179
+
180
+ /**
181
+ * Included `bake: 'replace'` nodes whose live object holds no mesh yet. These
182
+ * kinds render collectively in the editor and mount their own geometry only
183
+ * while `isExporting` is set; kinds with bake hooks are rebuilt after the
184
+ * clone instead, and excluded or hidden nodes never reach the clone.
185
+ */
186
+ export function nodesAwaitingExportGeometry(
187
+ nodes: Record<string, AnyNode>,
188
+ scope: ExportGeometryScope = {},
189
+ ): string[] {
190
+ const sceneState = useScene.getState()
191
+ const installedPlugins = sceneState.hasExplicitPluginInstallState
192
+ ? sceneState.installedPlugins
193
+ : undefined
194
+ const excluded = new Set(scope.excludedNodeTypes)
195
+ const isVisible = createExportVisibility(nodes)
196
+ const typeExcluded = (node: AnyNode): boolean => {
197
+ for (let current: AnyNode | undefined = node, guard = 0; current && guard < 64; guard++) {
198
+ if (excluded.has(current.type)) return true
199
+ current = current.parentId ? nodes[current.parentId] : undefined
200
+ }
201
+ return false
202
+ }
203
+ const pending: string[] = []
204
+ for (const [id, object] of sceneRegistry.nodes) {
205
+ const node = nodes[id]
206
+ if (!node) continue
207
+ const definition = nodeRegistry.get(node.type)
208
+ if (definition?.bake !== 'replace' || definition.bakeGeometry || definition.bakeGeometryAsync) {
209
+ continue
210
+ }
211
+ if (!isNodeKindEnabled(node.type, installedPlugins) || typeExcluded(node)) continue
212
+ if ((scope.onlyVisible ?? true) && !isVisible(id)) continue
213
+ let hasMesh = false
214
+ object.traverse((child) => {
215
+ if ((child as THREE.Mesh).isMesh) hasMesh = true
216
+ })
217
+ if (!hasMesh) pending.push(id)
218
+ }
219
+ return pending
220
+ }
221
+
222
+ /** Two animation frames, or `ms` if frames have stopped (hidden tab, test runner). */
223
+ function nextFramesOrTimeout(ms: number): Promise<void> {
224
+ return new Promise((resolve) => {
225
+ const timer = setTimeout(resolve, ms)
226
+ if (typeof requestAnimationFrame !== 'function') return
227
+ requestAnimationFrame(() =>
228
+ requestAnimationFrame(() => {
229
+ clearTimeout(timer)
230
+ resolve()
231
+ }),
232
+ )
233
+ })
234
+ }
235
+
236
+ /**
237
+ * After `setExporting(true)`, let one commit land, then wait until every
238
+ * included replace-kind node has mounted its export geometry. Two frames are
239
+ * not enough: on a large scene with a busy main thread the proxies' re-render
240
+ * lands several frames later, and the clone would silently miss every plant.
241
+ * The deadline runs on its own timer, so stalled frames cannot hang an export.
242
+ */
243
+ export async function waitForExportGeometry(
244
+ nodes: Record<string, AnyNode>,
245
+ scope: ExportGeometryScope = {},
246
+ timeoutMs = 10_000,
247
+ ): Promise<void> {
248
+ const deadline = Date.now() + timeoutMs
249
+ await nextFramesOrTimeout(Math.min(500, timeoutMs))
250
+ while (nodesAwaitingExportGeometry(nodes, scope).length > 0) {
251
+ const remaining = deadline - Date.now()
252
+ if (remaining <= 0) return
253
+ await nextFramesOrTimeout(Math.min(50, remaining))
254
+ }
255
+ }
256
+
144
257
  type GltfExtrasDef = {
145
258
  extras?: Record<string, unknown>
146
259
  }
@@ -369,7 +482,10 @@ function startSceneExportPreparation(
369
482
 
370
483
  for (const [id, original] of registryEntries) {
371
484
  const node = nodes[id]
372
- if (!node) continue
485
+ if (!node) {
486
+ excludedObjects.add(original)
487
+ continue
488
+ }
373
489
  const installedPlugins = sceneState.hasExplicitPluginInstallState
374
490
  ? sceneState.installedPlugins
375
491
  : undefined
@@ -482,10 +598,18 @@ function finishSceneExportPreparation(preparation: SceneExportPreparation): GlbE
482
598
  if (clone) identityNodes.add(clone)
483
599
  }
484
600
 
485
- pruneNonRenderableMeshes(scene, identityNodes)
601
+ const warnings = pruneNonRenderableMeshes(scene, identityNodes)
486
602
  sanitizeMaterialGroups(scene, identityNodes)
487
603
  convertMaterials(scene, options.textures ?? 'embed', options.purpose ?? 'viewer')
488
604
 
605
+ for (const [id, original] of registryEntries) {
606
+ const node = nodes[id]
607
+ const clone = cloneByOriginal.get(original)
608
+ if (node?.type !== 'procedural-item' || !clone) continue
609
+ const lights = evaluateRecipe(node.recipe, node.parameters).lights
610
+ decorateProceduralEmission(clone, lights, true)
611
+ }
612
+
489
613
  const retainedCloneByOriginal = retainedClones(scene, cloneByOriginal)
490
614
  const keepClips = options.animations
491
615
  ? options.animations === 'keep'
@@ -493,13 +617,20 @@ function finishSceneExportPreparation(preparation: SceneExportPreparation): GlbE
493
617
  const animation = keepClips
494
618
  ? bakeAnimationClips(retainedCloneByOriginal, nodes, registryEntries)
495
619
  : { clips: [], clipNamesByNode: new Map<string, string[]>() }
620
+ if (!keepClips) {
621
+ for (const [id, original] of registryEntries) {
622
+ const node = nodes[id]
623
+ const clone = retainedCloneByOriginal.get(original)
624
+ if (node?.type === 'procedural-item' && clone) bakeRegistryAnimationClips(node, clone)
625
+ }
626
+ }
496
627
  stampIdentity(scene, retainedCloneByOriginal, nodes, animation.clipNamesByNode, registryEntries)
497
628
 
498
629
  let disposed = false
499
630
  return {
500
631
  scene,
501
632
  animations: animation.clips,
502
- warnings: [],
633
+ warnings,
503
634
  dispose: () => {
504
635
  if (disposed) return
505
636
  disposed = true
@@ -715,31 +846,7 @@ function pruneHiddenSceneNodes(
715
846
  nodes: Record<string, AnyNode>,
716
847
  registryEntries: readonly RegistryEntry[],
717
848
  ) {
718
- const visibility = new Map<string, boolean>()
719
-
720
- const isVisible = (id: string, path: Set<string>): boolean => {
721
- const cached = visibility.get(id)
722
- if (cached !== undefined) return cached
723
-
724
- const node = nodes[id]
725
- if (!node) return true
726
- if (node.visible === false) {
727
- visibility.set(id, false)
728
- return false
729
- }
730
- const parentId = node.parentId
731
- const parent = parentId ? nodes[parentId] : undefined
732
- if (!parentId || path.has(id) || (parent && !hidesDescendants(parent))) {
733
- visibility.set(id, true)
734
- return true
735
- }
736
-
737
- path.add(id)
738
- const visible = isVisible(parentId, path)
739
- path.delete(id)
740
- visibility.set(id, visible)
741
- return visible
742
- }
849
+ const isVisible = createExportVisibility(nodes)
743
850
 
744
851
  const nodeClones = new Set<THREE.Object3D>()
745
852
  for (const [, original] of registryEntries) {
@@ -748,7 +855,7 @@ function pruneHiddenSceneNodes(
748
855
  }
749
856
 
750
857
  for (const [id, original] of registryEntries) {
751
- if (isVisible(id, new Set())) continue
858
+ if (isVisible(id)) continue
752
859
  const clone = cloneByOriginal.get(original)
753
860
  if (!clone) continue
754
861
  const node = nodes[id]
@@ -902,7 +1009,16 @@ const PLACEHOLDER_MATERIAL = new THREE.MeshBasicMaterial({ visible: false })
902
1009
  */
903
1010
  function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
904
1011
  const toRemove: THREE.Object3D[] = []
1012
+ const warnings: string[] = []
905
1013
  root.traverse((object) => {
1014
+ if (toRemove.some((ancestor) => isDescendantOf(object, ancestor))) return
1015
+ if (typeof object.userData.pascalExportRefusal === 'string') {
1016
+ warnings.push(
1017
+ `Skipped ${object.name || object.userData.pascalId || 'object'}: ${object.userData.pascalExportRefusal}`,
1018
+ )
1019
+ toRemove.push(object)
1020
+ return
1021
+ }
906
1022
  if (object.userData.pascalExport === 'strip') {
907
1023
  toRemove.push(object)
908
1024
  return
@@ -961,6 +1077,7 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
961
1077
  for (const object of toRemove) {
962
1078
  object.removeFromParent()
963
1079
  }
1080
+ return warnings
964
1081
  }
965
1082
 
966
1083
  /**
@@ -1342,15 +1459,19 @@ function bakeAnimationClips(
1342
1459
  const target = cloneByOriginal.get(original)
1343
1460
  if (!node || !target) continue
1344
1461
 
1462
+ // A window or door built from a script carries its clips like an authored item.
1463
+ const scripted = (node.type === 'door' || node.type === 'window') && node.source
1345
1464
  const clip =
1346
1465
  bakeRegistryAnimationClips(node, target) ??
1347
- (node.type === 'door'
1348
- ? bakeDoorClip(id, node, target)
1349
- : node.type === 'window'
1350
- ? bakeWindowClip(id, node as WindowNode, target)
1351
- : node.type === 'item'
1352
- ? bakeItemClip(id, target)
1353
- : null)
1466
+ (scripted
1467
+ ? bakeItemClip(id, target)
1468
+ : node.type === 'door'
1469
+ ? bakeDoorClip(id, node, target)
1470
+ : node.type === 'window'
1471
+ ? bakeWindowClip(id, node as WindowNode, target)
1472
+ : node.type === 'item'
1473
+ ? bakeItemClip(id, target)
1474
+ : null)
1354
1475
 
1355
1476
  if (clip) {
1356
1477
  const nodeClips = Array.isArray(clip) ? clip : [clip]
@@ -1373,44 +1494,47 @@ function bakeRegistryAnimationClips(
1373
1494
  }
1374
1495
 
1375
1496
  /**
1376
- * Re-emit a catalog item's ambient clip (e.g. a fan's spin) onto the baked
1377
- * subtree. The source clip targets the item GLB's nodes by name (`lamp_018`);
1378
- * since every fan shares those names, we rebind each track to the specific
1379
- * cloned node's uuid so multiple fans animate independently. The clip is named
1380
- * per node (`<id>: loop`) so the baked viewer can drive each one on its own.
1497
+ * Re-emit an item's clips (a fan's spin, an authored object's motions) onto the
1498
+ * baked subtree. Source clips target the item GLB's nodes by name (`lamp_018`);
1499
+ * since every instance shares those names, we rebind each track to the
1500
+ * specific cloned node's uuid so instances animate independently. Clips are
1501
+ * named per node (`<id>: <name>`; the viewer drives `<id>: loop`).
1381
1502
  */
1382
- function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationClip | null {
1383
- const entry = itemClipRegistry.get(id)
1384
- if (!entry) return null
1503
+ function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationClip[] | null {
1504
+ const entries = itemClipRegistry.get(id)
1505
+ if (!entries?.length) return null
1385
1506
 
1386
- const tracks: THREE.KeyframeTrack[] = []
1387
1507
  // The catalog node names (e.g. "lamp_018") repeat across every instance of the
1388
1508
  // item, and the glTF export→import roundtrip rebinds clip tracks by node name —
1389
- // so a shared name would make all fans share one clip. Uniquify the targeted
1390
- // node's name per item once, then bind tracks by its (stable) uuid.
1509
+ // so a shared name would make all fans share one clip. Uniquify each targeted
1510
+ // node's name per item once (clips may share targets), then bind by uuid.
1391
1511
  const renamed = new Map<string, THREE.Object3D>()
1392
- for (const track of entry.clip.tracks) {
1393
- const dot = track.name.lastIndexOf('.')
1394
- if (dot < 0) continue
1395
- const targetName = track.name.slice(0, dot)
1396
- const property = track.name.slice(dot + 1)
1397
- let targetNode = renamed.get(targetName)
1398
- if (!targetNode) {
1399
- const found = itemObject.getObjectByName(targetName)
1400
- if (!found) continue
1401
- found.name = `${id}__${targetName}`
1402
- renamed.set(targetName, found)
1403
- targetNode = found
1512
+ const clips: THREE.AnimationClip[] = []
1513
+ for (const entry of entries) {
1514
+ const tracks: THREE.KeyframeTrack[] = []
1515
+ for (const track of entry.clip.tracks) {
1516
+ const dot = track.name.lastIndexOf('.')
1517
+ if (dot < 0) continue
1518
+ const targetName = track.name.slice(0, dot)
1519
+ const property = track.name.slice(dot + 1)
1520
+ let targetNode = renamed.get(targetName)
1521
+ if (!targetNode) {
1522
+ const found = itemObject.getObjectByName(targetName)
1523
+ if (!found) continue
1524
+ found.name = `${id}__${targetName}`
1525
+ renamed.set(targetName, found)
1526
+ targetNode = found
1527
+ }
1528
+ const retargeted = track.clone()
1529
+ retargeted.name = `${targetNode.uuid}.${property}`
1530
+ tracks.push(retargeted)
1404
1531
  }
1405
- const retargeted = track.clone()
1406
- retargeted.name = `${targetNode.uuid}.${property}`
1407
- tracks.push(retargeted)
1532
+ if (tracks.length === 0) continue
1533
+ const clip = new THREE.AnimationClip(`${id}: ${entry.name}`, entry.clip.duration, tracks)
1534
+ clip.userData = { loop: entry.loop }
1535
+ clips.push(clip)
1408
1536
  }
1409
-
1410
- if (tracks.length === 0) return null
1411
- const clip = new THREE.AnimationClip(`${id}: loop`, entry.clip.duration, tracks)
1412
- clip.userData = { loop: entry.loop }
1413
- return clip
1537
+ return clips.length > 0 ? clips : null
1414
1538
  }
1415
1539
 
1416
1540
  /**
@@ -1649,42 +1773,6 @@ function bakeWindowClip(
1649
1773
  * (e.g. `pascalSwingLeaf`, cached-material flags) leaks into glTF extras — the
1650
1774
  * file describes itself with exactly the fields a consumer needs.
1651
1775
  */
1652
- /**
1653
- * Human-readable label for a baked node, mirroring the viewer's `getNodeName`:
1654
- * an explicit name wins, items fall back to their catalog asset name, other
1655
- * kinds to a capitalized type. Levels override this with their display name.
1656
- */
1657
- function nodeDisplayLabel(node: AnyNode): string {
1658
- if (node.name) return node.name
1659
- switch (node.type) {
1660
- case 'item':
1661
- return (node as { asset?: { name?: string } }).asset?.name || 'Item'
1662
- case 'wall':
1663
- return 'Wall'
1664
- case 'door':
1665
- return 'Door'
1666
- case 'window':
1667
- return 'Window'
1668
- case 'cabinet':
1669
- case 'cabinet-module':
1670
- return 'Cabinet'
1671
- case 'slab':
1672
- return 'Slab'
1673
- case 'ceiling':
1674
- return 'Ceiling'
1675
- case 'roof':
1676
- return 'Roof'
1677
- case 'fence':
1678
- return 'Fence'
1679
- case 'column':
1680
- return 'Column'
1681
- case 'stair':
1682
- return 'Stairs'
1683
- default:
1684
- return node.type
1685
- }
1686
- }
1687
-
1688
1776
  function stampIdentity(
1689
1777
  scene: THREE.Object3D,
1690
1778
  cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
@@ -1695,8 +1783,43 @@ function stampIdentity(
1695
1783
  scene.traverse((object) => {
1696
1784
  const presentationId = object.userData.pascalPresentationId
1697
1785
  const label = object.userData.label
1786
+ const motion = object.userData.proceduralMotion as
1787
+ | {
1788
+ nodeId: string
1789
+ partId: string
1790
+ groupId: string
1791
+ kind: 'hinge' | 'slide' | 'spin'
1792
+ clip?: string
1793
+ activeWindow?: [number, number]
1794
+ }
1795
+ | undefined
1796
+ const surfaceNodeIds = object.userData.surfaceNodeIds
1797
+ const ifcRole = object.userData.pascalIfcRole
1798
+ const ifcParts = object.userData.pascalIfcParts
1799
+ const slotId = object.userData.slotId
1698
1800
  object.userData =
1699
1801
  typeof presentationId === 'string' ? { pascalPresentationId: presentationId, label } : {}
1802
+ if (
1803
+ Array.isArray(surfaceNodeIds) &&
1804
+ surfaceNodeIds.every((id) => typeof id === 'string' && nodes[id])
1805
+ )
1806
+ object.userData.surfaceNodeIds = surfaceNodeIds
1807
+ if (typeof ifcRole === 'string') object.userData.pascalIfcRole = ifcRole
1808
+ if (Array.isArray(ifcParts) && ifcParts.every(isIfcRolePart))
1809
+ object.userData.pascalIfcParts = ifcParts
1810
+ if (typeof slotId === 'string') object.userData.slotId = slotId
1811
+ if (motion) {
1812
+ object.userData.proceduralMotion = {
1813
+ nodeId: motion.nodeId,
1814
+ partId: motion.partId,
1815
+ groupId: motion.groupId,
1816
+ kind: motion.kind,
1817
+ ...(motion.clip && clipNamesByNode.get(motion.nodeId)?.includes(motion.clip)
1818
+ ? { clip: motion.clip }
1819
+ : {}),
1820
+ ...(motion.activeWindow ? { activeWindow: motion.activeWindow } : {}),
1821
+ }
1822
+ }
1700
1823
  })
1701
1824
 
1702
1825
  for (const [id, original] of registryEntries) {
@@ -1721,21 +1844,15 @@ function stampIdentity(
1721
1844
  extras.label = getLevelDisplayName(node as LevelNode)
1722
1845
  target.visible = true
1723
1846
  }
1724
- // Only nodes that actually baked an open clip are openable. A cased opening
1725
- // (no leaf), fixed window, or static cabinet produces no clip, so it stays
1726
- // unflagged — the file never claims a part opens when nothing moves.
1727
- if (clipNamesByNode.get(id)?.some((name) => name.endsWith(': open'))) {
1728
- const clipNames = clipNamesByNode.get(id)
1729
- if (clipNames?.length) {
1730
- extras.openable = true
1731
- extras.clips = clipNames
1732
- }
1733
- }
1734
- // Items with a baked ambient clip (a fan's spin) carry the clip name but no
1735
- // `openable` flag — nothing opens; the clip just loops.
1736
- if (node.type === 'item') {
1737
- const clipNames = clipNamesByNode.get(id)
1738
- if (clipNames?.length) extras.clips = clipNames
1847
+ // Every node that baked clips lists them, whatever its kind, so the viewer
1848
+ // can play them. Only nodes that actually baked an open clip are openable. A
1849
+ // cased opening (no leaf), fixed window, or static cabinet produces no clip,
1850
+ // so it stays unflagged — the file never claims a part opens when nothing
1851
+ // moves; an ambient loop (a fan's spin) is listed without the flag.
1852
+ const clipNames = clipNamesByNode.get(id)
1853
+ if (clipNames?.length) {
1854
+ if (clipNames.some((name) => name.endsWith(': open'))) extras.openable = true
1855
+ extras.clips = clipNames
1739
1856
  }
1740
1857
  if (node.type === 'zone') {
1741
1858
  // Zone fills are stripped from the bake; /viewer rebuilds the room from
@@ -1743,6 +1860,7 @@ function stampIdentity(
1743
1860
  // `onlyVisible` keeps it even when the editor had zones hidden at export.
1744
1861
  const zone = node as ZoneNode
1745
1862
  extras.polygon = zone.polygon
1863
+ if (zone.holes?.length) extras.holes = zone.holes
1746
1864
  extras.color = zone.color
1747
1865
  target.visible = true
1748
1866
  }
@@ -1754,6 +1872,6 @@ function stampIdentity(
1754
1872
  extras.rotation = (node as { rotation?: number }).rotation ?? 0
1755
1873
  target.visible = true
1756
1874
  }
1757
- target.userData = extras
1875
+ target.userData = { ...target.userData, ...extras }
1758
1876
  }
1759
1877
  }
@@ -217,6 +217,9 @@ describe('standalone history source invalidation', () => {
217
217
  const { Group, Mesh, MeshBasicMaterial, BoxGeometry } = await importShared('three')
218
218
  const viewer = await importShared('@pascal-app/viewer')
219
219
  const { captureChangedNodes, runBatchFrame, resetNodeBatchState } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/system.tsx'))})
220
+ // The node batch reads each kind's \`capabilities.batchable\` from the registry.
221
+ const { doorBatchable } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/batchable.ts'))})
222
+ core.nodeRegistry._register({ kind: 'door', schemaVersion: 1, capabilities: { batchable: doorBatchable } })
220
223
  const root = new Group()
221
224
  core.sceneRegistry.nodes.set(level.id, root)
222
225
  core.sceneRegistry.byType.level.add(level.id)
@@ -260,6 +263,9 @@ describe('standalone history source invalidation', () => {
260
263
  const { Group, Mesh, MeshBasicMaterial, BoxGeometry } = await importShared('three')
261
264
  const viewer = await importShared('@pascal-app/viewer')
262
265
  const { captureChangedNodes, runBatchFrame, resetNodeBatchState } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/system.tsx'))})
266
+ // The node batch reads each kind's \`capabilities.batchable\` from the registry.
267
+ const { doorBatchable } = await import(${JSON.stringify(resolve(import.meta.dir, '../../../nodes/src/shared/node-batch/batchable.ts'))})
268
+ core.nodeRegistry._register({ kind: 'door', schemaVersion: 1, capabilities: { batchable: doorBatchable } })
263
269
  const root = new Group()
264
270
  core.sceneRegistry.nodes.set(level.id, root)
265
271
  core.sceneRegistry.byType.level.add(level.id)
@@ -9,6 +9,7 @@ import {
9
9
  } from '@pascal-app/core'
10
10
  import { markPerfAction } from '@pascal-app/viewer'
11
11
  import useInteractionScope from '../store/use-interaction-scope'
12
+ import { cancelGestures } from './gesture-lifecycle'
12
13
  import { registeredDraftingConfig } from './interaction/registered-drafting'
13
14
 
14
15
  export type HistoryCommandState = {
@@ -130,6 +131,9 @@ export function shouldCancelDraftOnHistoryJump(): boolean {
130
131
  }
131
132
 
132
133
  export function runUndo(): HistoryCommandResult {
134
+ // A history jump under a live gesture would land on its paused baseline;
135
+ // every history command, not only the keyboard's, ends it first.
136
+ cancelGestures('history')
133
137
  if (shouldCancelDraftOnHistoryJump()) emitter.emit('tool:cancel')
134
138
  if (historyCommandDelegate) {
135
139
  const result = historyCommandDelegate.undo()
@@ -147,6 +151,9 @@ export function runUndo(): HistoryCommandResult {
147
151
  }
148
152
 
149
153
  export function runRedo(): HistoryCommandResult {
154
+ // A history jump under a live gesture would land on its paused baseline;
155
+ // every history command, not only the keyboard's, ends it first.
156
+ cancelGestures('history')
150
157
  if (shouldCancelDraftOnHistoryJump()) emitter.emit('tool:cancel')
151
158
  if (historyCommandDelegate) {
152
159
  const result = historyCommandDelegate.redo()
@@ -0,0 +1,109 @@
1
+ import { type IconRef, nodeRegistry, type TerrainVerb } from '@pascal-app/core'
2
+ import type { ContinuationMode } from './continuation'
3
+ import { getWallDrawVariantInfo, wallDrawVariantOf } from './wall-draw-variant'
4
+
5
+ /** The contextual helper panel's header: what is in hand, and its key. */
6
+ export type HudTitle = {
7
+ label: string
8
+ icon?: IconRef
9
+ shortcut?: string
10
+ }
11
+
12
+ const url = (src: string): IconRef => ({ kind: 'url', src })
13
+
14
+ /** Tools with a global arming key (`use-keyboard.ts`). */
15
+ const TOOL_SHORTCUTS: Record<string, string> = {
16
+ wall: 'B',
17
+ measurement: 'M',
18
+ }
19
+
20
+ export const SELECT_HUD_TITLE: HudTitle = {
21
+ label: 'Select',
22
+ icon: url('/icons/select.webp'),
23
+ shortcut: 'V',
24
+ }
25
+ export const PAINT_HUD_TITLE: HudTitle = { label: 'Paint', icon: url('/icons/paint.webp') }
26
+ export const ERASE_HUD_TITLE: HudTitle = {
27
+ label: 'Erase',
28
+ icon: { kind: 'iconify', name: 'lucide:eraser' },
29
+ }
30
+ export const PICK_MATERIAL_HUD_TITLE: HudTitle = {
31
+ label: 'Pick material',
32
+ icon: { kind: 'iconify', name: 'lucide:pipette' },
33
+ }
34
+ export const ROTATE_HUD_TITLE: HudTitle = { label: 'Rotate', icon: url('/icons/rotate.webp') }
35
+ export const MOVE_SELECTION_HUD_TITLE: HudTitle = {
36
+ label: 'Move selection',
37
+ icon: url('/icons/pan.webp'),
38
+ }
39
+ export const MOVE_ROOM_HUD_TITLE: HudTitle = { label: 'Move room', icon: url('/icons/room.webp') }
40
+ export const RESIZE_HUD_TITLE: HudTitle = {
41
+ label: 'Resize',
42
+ icon: url('/icons/resize.webp'),
43
+ }
44
+ export const DIVIDE_ROOM_HUD_TITLE: HudTitle = {
45
+ label: 'Divide room',
46
+ icon: url('/icons/divide-room.webp'),
47
+ }
48
+ export const MEZZANINE_HUD_TITLE: HudTitle = {
49
+ label: 'Mezzanine',
50
+ icon: url('/icons/mezzanine.webp'),
51
+ }
52
+ export const OPENING_HUD_TITLE: HudTitle = {
53
+ label: 'Cut opening',
54
+ icon: { kind: 'iconify', name: 'lucide:square-dashed' },
55
+ }
56
+ export const TERRACE_HUD_TITLE: HudTitle = {
57
+ label: 'Terrace',
58
+ icon: url('/icons/floor.webp'),
59
+ }
60
+ export const SMART_MEASURE_HUD_TITLE: HudTitle = {
61
+ label: 'Smart measure',
62
+ icon: url('/icons/measure.webp'),
63
+ shortcut: 'M',
64
+ }
65
+
66
+ const PAINT_REGION_LABELS = {
67
+ rectangle: 'Paint a rectangle',
68
+ polygon: 'Paint a shape',
69
+ } as const
70
+
71
+ export function paintRegionHudTitle(mode: keyof typeof PAINT_REGION_LABELS): HudTitle {
72
+ return { label: PAINT_REGION_LABELS[mode], icon: url('/icons/paint.webp') }
73
+ }
74
+
75
+ export function terrainHudTitle(verb: TerrainVerb): HudTitle {
76
+ const label =
77
+ verb === 'raise'
78
+ ? 'Raise terrain'
79
+ : verb === 'lower'
80
+ ? 'Lower terrain'
81
+ : verb === 'flatten'
82
+ ? 'Level terrain'
83
+ : 'Smooth terrain'
84
+ return { label, icon: url(`/icons/terrain-${verb}.webp`) }
85
+ }
86
+
87
+ /** A kind's registry name and icon, optionally behind a verb ("Move door"). */
88
+ export function nodeKindHudTitle(kind: string, verb?: string): HudTitle | null {
89
+ const presentation = nodeRegistry.get(kind)?.presentation
90
+ if (!presentation) return null
91
+ const name = presentation.label
92
+ return {
93
+ label: verb ? `${verb} ${name.toLowerCase()}` : name,
94
+ icon: presentation.icon,
95
+ shortcut: verb ? undefined : TOOL_SHORTCUTS[kind],
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The armed build tool. The wall tool is named after the Rooms variant it draws
101
+ * (Rectangle room / Polygon room / Walls) — the tile the user picked.
102
+ */
103
+ export function toolHudTitle(tool: string, wallMode: ContinuationMode): HudTitle | null {
104
+ if (tool === 'wall') {
105
+ const variant = getWallDrawVariantInfo(wallDrawVariantOf(wallMode))
106
+ return { label: variant.title, icon: url(variant.iconSrc), shortcut: TOOL_SHORTCUTS.wall }
107
+ }
108
+ return nodeKindHudTitle(tool)
109
+ }