@pascal-app/editor 1.0.6 → 1.0.7

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 (317) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +20 -8
  6. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  7. package/src/components/editor/first-person-controls.tsx +72 -61
  8. package/src/components/editor/floating-action-menu.tsx +27 -9
  9. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  10. package/src/components/editor/floorplan-panel.test.ts +220 -0
  11. package/src/components/editor/floorplan-panel.tsx +285 -132
  12. package/src/components/editor/footprint-height-handle.tsx +1 -1
  13. package/src/components/editor/grid.tsx +24 -15
  14. package/src/components/editor/group-actions.ts +19 -1
  15. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  16. package/src/components/editor/group-move-3d.ts +27 -2
  17. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  18. package/src/components/editor/group-transform-shared.ts +20 -0
  19. package/src/components/editor/index.tsx +130 -20
  20. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  21. package/src/components/editor/localized-html.tsx +22 -0
  22. package/src/components/editor/node-action-menu.tsx +46 -12
  23. package/src/components/editor/node-arrow-handles.tsx +1 -2
  24. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
  25. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  26. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  27. package/src/components/editor/registry-action-contributions.tsx +115 -15
  28. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  29. package/src/components/editor/room-controls.tsx +1 -1
  30. package/src/components/editor/room-handles-2d.tsx +156 -6
  31. package/src/components/editor/room-handles.tsx +241 -51
  32. package/src/components/editor/room-highlight.tsx +2 -0
  33. package/src/components/editor/room-labels.tsx +29 -15
  34. package/src/components/editor/selection-manager.tsx +15 -102
  35. package/src/components/editor/separator-end-grip.tsx +74 -0
  36. package/src/components/editor/site-edge-labels.tsx +1 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  38. package/src/components/editor/thumbnail-generator.tsx +26 -1
  39. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  40. package/src/components/editor/views/view-bar.tsx +111 -26
  41. package/src/components/editor/wall-measurement-label.tsx +1 -1
  42. package/src/components/editor/wall-move-side-handles.tsx +5 -1
  43. package/src/components/editor/wall-pull-grip.tsx +193 -0
  44. package/src/components/editor/wall-snap-beacon-layer.tsx +81 -1
  45. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  46. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  47. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  48. package/src/components/editor-2d/floorplan-group-move.tsx +31 -3
  49. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  50. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +22 -13
  51. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +11 -3
  52. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +7 -1
  53. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  54. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +12 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +197 -37
  57. package/src/components/procedural-items/inspector.tsx +59 -12
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  59. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  60. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  61. package/src/components/tools/distribution/distribution-run-tool.tsx +50 -26
  62. package/src/components/tools/item/placement-history.test.tsx +1 -1
  63. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  64. package/src/components/tools/item/placement-strategies.ts +5 -9
  65. package/src/components/tools/item/use-placement-coordinator.tsx +30 -34
  66. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  67. package/src/components/tools/select/select-candidates.test.ts +45 -0
  68. package/src/components/tools/select/select-candidates.ts +13 -2
  69. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  70. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  71. package/src/components/tools/shared/placement-box.tsx +1 -1
  72. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  73. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  74. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  75. package/src/components/tools/wall/wall-drafting.ts +132 -24
  76. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  77. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  78. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  79. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  80. package/src/components/tools/zone/zone-tool.tsx +3 -3
  81. package/src/components/ui/action-menu/action-button.tsx +2 -0
  82. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  83. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  84. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  85. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  86. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  87. package/src/components/ui/action-menu/index.tsx +1 -0
  88. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  89. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  90. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  91. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  92. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  93. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  94. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  95. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  96. package/src/components/ui/controls/material-picker.tsx +24 -17
  97. package/src/components/ui/controls/metric-control.test.ts +52 -0
  98. package/src/components/ui/controls/metric-control.tsx +62 -24
  99. package/src/components/ui/controls/segmented-control.tsx +5 -2
  100. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  101. package/src/components/ui/controls/slider-control.tsx +30 -14
  102. package/src/components/ui/floating-level-selector.tsx +100 -48
  103. package/src/components/ui/helpers/building-helper.tsx +5 -3
  104. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  105. package/src/components/ui/helpers/helper-manager.test.tsx +53 -3
  106. package/src/components/ui/helpers/helper-manager.tsx +82 -57
  107. package/src/components/ui/helpers/item-helper.tsx +6 -4
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  109. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  110. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  111. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  112. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  113. package/src/components/ui/panels/floor-foundation-panel.tsx +113 -38
  114. package/src/components/ui/panels/floor-opening-panel.tsx +73 -25
  115. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  116. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  117. package/src/components/ui/panels/mobile-selection-bar.tsx +8 -6
  118. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  119. package/src/components/ui/panels/multi-field-value.ts +20 -1
  120. package/src/components/ui/panels/multi-height-mode.tsx +8 -7
  121. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  122. package/src/components/ui/panels/multi-selection-panel.tsx +75 -35
  123. package/src/components/ui/panels/node-display.ts +35 -24
  124. package/src/components/ui/panels/panel-manager.tsx +5 -3
  125. package/src/components/ui/panels/panel-wrapper.tsx +22 -109
  126. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  127. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  128. package/src/components/ui/panels/reference-panel.tsx +51 -35
  129. package/src/components/ui/panels/room-construction-rows.tsx +464 -65
  130. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  131. package/src/components/ui/panels/room-name-options.ts +110 -11
  132. package/src/components/ui/panels/room-openings-field.tsx +21 -12
  133. package/src/components/ui/panels/room-panel.tsx +206 -99
  134. package/src/components/ui/panels/shape-choice.tsx +7 -5
  135. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  136. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  137. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  138. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  139. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  140. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  142. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -2
  144. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  145. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  146. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  147. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  148. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  149. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  150. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  151. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  152. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  153. package/src/components/ui/sidebar/panels/site-panel/index.tsx +117 -64
  154. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  155. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  156. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  157. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  158. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  159. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  160. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +8 -3
  161. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  162. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  163. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  164. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  165. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  166. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  167. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  168. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  169. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  170. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +18 -8
  171. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  172. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  173. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  174. package/src/components/ui/snap-target-badge.tsx +7 -6
  175. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  176. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  177. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  178. package/src/components/viewer/floorplan-preview.tsx +27 -19
  179. package/src/components/viewer/render-settings-menu.tsx +294 -0
  180. package/src/components/viewer/sky-control.tsx +710 -0
  181. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  182. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  183. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  184. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  185. package/src/components/viewer/viewer-rooms.tsx +61 -4
  186. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  187. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  188. package/src/components/viewer-overlay.tsx +8 -0
  189. package/src/components/walkthrough-hud.tsx +75 -2
  190. package/src/hooks/use-keyboard.test.ts +85 -4
  191. package/src/hooks/use-keyboard.ts +65 -7
  192. package/src/hooks/use-selected-room.ts +12 -1
  193. package/src/i18n/floor-footprint-name.ts +30 -0
  194. package/src/i18n/level-name.ts +39 -0
  195. package/src/i18n/locales/en/messages.po +15117 -0
  196. package/src/i18n/locales/en/messages.ts +1 -0
  197. package/src/i18n/locales/en-XA/messages.ts +1 -0
  198. package/src/i18n/locales/es/messages.po +37501 -0
  199. package/src/i18n/locales/es/messages.ts +1 -0
  200. package/src/i18n/locales/fr/messages.po +37501 -0
  201. package/src/i18n/locales/fr/messages.ts +1 -0
  202. package/src/i18n/locales/ja/messages.po +37501 -0
  203. package/src/i18n/locales/ja/messages.ts +1 -0
  204. package/src/i18n/locales/zh-CN/messages.po +37501 -0
  205. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  206. package/src/i18n/locales.test.ts +63 -0
  207. package/src/i18n/locales.ts +32 -0
  208. package/src/i18n/message-slot.tsx +25 -0
  209. package/src/i18n/node-messages.ts +169 -0
  210. package/src/i18n/pseudo-locale.ts +17 -0
  211. package/src/i18n/published-entry.test.ts +17 -0
  212. package/src/i18n/scene-theme-name.ts +16 -0
  213. package/src/index.tsx +156 -4
  214. package/src/lib/add-storey.test.ts +3 -3
  215. package/src/lib/contextual-help.test.ts +20 -1
  216. package/src/lib/contextual-help.ts +95 -19
  217. package/src/lib/continuation.ts +38 -11
  218. package/src/lib/floor-footprints.test.ts +2 -1
  219. package/src/lib/floor-foundation-support.test.ts +7 -3
  220. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  221. package/src/lib/floorplan/dimension-drive.ts +68 -3
  222. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  223. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  224. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  225. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  226. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  227. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  228. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  229. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  230. package/src/lib/floorplan/schedules.ts +10 -6
  231. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  232. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  233. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  234. package/src/lib/geometry-script/author.test.ts +114 -0
  235. package/src/lib/geometry-script/author.ts +55 -23
  236. package/src/lib/geometry-script/client.ts +3 -0
  237. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  238. package/src/lib/glb-export.test.ts +327 -0
  239. package/src/lib/glb-export.ts +70 -2
  240. package/src/lib/grid-frame.test.ts +56 -3
  241. package/src/lib/grid-frame.ts +28 -1
  242. package/src/lib/history.test.ts +2 -1
  243. package/src/lib/hud-title.ts +48 -24
  244. package/src/lib/ifc-export.test.ts +41 -1
  245. package/src/lib/inspector-card-mode.test.ts +3 -31
  246. package/src/lib/inspector-card-mode.ts +5 -10
  247. package/src/lib/inspector-expanded.test.ts +13 -0
  248. package/src/lib/inspector-expanded.ts +13 -1
  249. package/src/lib/keyboard-pan.test.ts +7 -0
  250. package/src/lib/keyboard-pan.ts +9 -2
  251. package/src/lib/layer-thickness.test.ts +22 -0
  252. package/src/lib/layer-thickness.ts +79 -0
  253. package/src/lib/lot/drop-in.test.ts +59 -1
  254. package/src/lib/lot/drop-in.ts +42 -50
  255. package/src/lib/material-swatch-style.ts +24 -0
  256. package/src/lib/measurement-parser.ts +7 -3
  257. package/src/lib/measurements.test.ts +17 -0
  258. package/src/lib/measurements.ts +20 -5
  259. package/src/lib/mezzanine-selection.test.ts +3 -1
  260. package/src/lib/orbit-near-plane.test.ts +12 -0
  261. package/src/lib/orbit-near-plane.ts +13 -0
  262. package/src/lib/paint-regions.test.ts +8 -5
  263. package/src/lib/paint-scope.test.ts +53 -0
  264. package/src/lib/paint-scope.ts +18 -4
  265. package/src/lib/panel-rows.ts +17 -8
  266. package/src/lib/reference-scale.ts +83 -0
  267. package/src/lib/room-built-on.test.ts +26 -12
  268. package/src/lib/room-built-on.ts +20 -2
  269. package/src/lib/room-construction.test.ts +28 -1
  270. package/src/lib/room-construction.ts +14 -0
  271. package/src/lib/room-controls-phase6.test.ts +140 -0
  272. package/src/lib/room-handle-drag.ts +557 -42
  273. package/src/lib/room-name-catalog.test.ts +8 -0
  274. package/src/lib/room-name-catalog.ts +8 -1
  275. package/src/lib/room-name-messages.test.ts +132 -0
  276. package/src/lib/room-name-messages.ts +147 -0
  277. package/src/lib/room-name-storage.test.ts +334 -0
  278. package/src/lib/room-push-dimensions.test.ts +37 -0
  279. package/src/lib/room-push-dimensions.ts +226 -2
  280. package/src/lib/room-selection.test.ts +23 -6
  281. package/src/lib/scene-clipboard.test.ts +70 -49
  282. package/src/lib/scene-clipboard.ts +9 -4
  283. package/src/lib/selection-cycle.ts +63 -0
  284. package/src/lib/separator-edit.test.ts +431 -0
  285. package/src/lib/separator-edit.ts +179 -0
  286. package/src/lib/separator-lines.ts +38 -0
  287. package/src/lib/sfx-bus.ts +27 -1
  288. package/src/lib/sfx-player.ts +16 -0
  289. package/src/lib/tool-cursor-icon.ts +5 -1
  290. package/src/lib/tool-shortcuts.ts +130 -0
  291. package/src/lib/viewer-selection.test.ts +28 -24
  292. package/src/lib/viewer-selection.ts +20 -5
  293. package/src/lib/wall-draw-variant.test.ts +7 -6
  294. package/src/lib/wall-draw-variant.ts +107 -6
  295. package/src/lib/wall-nudge.test.ts +44 -0
  296. package/src/lib/wall-nudge.ts +131 -0
  297. package/src/lib/wall-parallel-snap.test.ts +83 -0
  298. package/src/lib/wall-parallel-snap.ts +176 -0
  299. package/src/lib/wall-placement-pointer.test.ts +23 -0
  300. package/src/lib/wall-placement-pointer.ts +18 -0
  301. package/src/lib/wall-pull.test.ts +245 -0
  302. package/src/lib/wall-pull.ts +209 -0
  303. package/src/lib/wall-push-line-up.test.ts +132 -0
  304. package/src/lib/wall-region-list.ts +14 -5
  305. package/src/lib/wall-run-snap.test.ts +188 -0
  306. package/src/lib/wall-run-snap.ts +83 -0
  307. package/src/lib/wall-type.test.ts +125 -0
  308. package/src/lib/wall-type.ts +119 -0
  309. package/src/lib/window-interaction.test.ts +42 -0
  310. package/src/lib/window-interaction.ts +47 -5
  311. package/src/lib/world-grid-snap.test.ts +139 -2
  312. package/src/lib/world-grid-snap.ts +102 -105
  313. package/src/store/use-alignment-guides.ts +27 -1
  314. package/src/store/use-editor.tsx +48 -0
  315. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  316. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  317. package/src/store/use-wall-snap-indicator.ts +15 -2
@@ -0,0 +1,188 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ detectSpacesForLevel,
6
+ LevelNode as LevelSchema,
7
+ nodeRegistry,
8
+ registerNode,
9
+ useScene,
10
+ type WallNode,
11
+ WallNode as WallSchema,
12
+ wallClosesRoom,
13
+ } from '@pascal-app/core'
14
+ import { useViewer } from '@pascal-app/viewer'
15
+ import { createWallOnCurrentLevel } from '../components/tools/wall/wall-drafting'
16
+ import type { WallPlanPoint } from '../components/tools/wall/wall-snap-geometry'
17
+ import useEditor from '../store/use-editor'
18
+ import {
19
+ addWallPolygonDraftCorner,
20
+ discardWallPolygonDraft,
21
+ startWallPolygonDraft,
22
+ wallPolygonDraftWalls,
23
+ } from './wall-polygon-draft'
24
+ import { isWallRunFree, snapWallRunDraftPoint, wallRunDirection } from './wall-run-snap'
25
+
26
+ // `updateNodes` batches its dirty-marking through requestAnimationFrame,
27
+ // which bun's test runtime doesn't provide.
28
+ if (typeof globalThis.requestAnimationFrame === 'undefined') {
29
+ globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) =>
30
+ setTimeout(() => callback(0), 0)) as unknown as typeof requestAnimationFrame
31
+ globalThis.cancelAnimationFrame = ((id: number) =>
32
+ clearTimeout(id)) as typeof cancelAnimationFrame
33
+ }
34
+
35
+ const LEVEL_ID = 'level_run' as AnyNodeId
36
+ const deg = (degrees: number) => (degrees * Math.PI) / 180
37
+
38
+ function levelWalls(): WallNode[] {
39
+ return Object.values(useScene.getState().nodes).filter(
40
+ (node): node is WallNode => node?.type === 'wall' && node.parentId === LEVEL_ID,
41
+ )
42
+ }
43
+
44
+ /**
45
+ * A plain run as the wall tool drafts it once its first point is placed: each
46
+ * wall from the last one's end, square to it, snapped against the level's walls.
47
+ */
48
+ function plainRun(runStart: WallPlanPoint) {
49
+ let start = runStart
50
+ let reference: WallPlanPoint | null = null
51
+ return (cursor: WallPlanPoint, free = false) => {
52
+ const snapped = snapWallRunDraftPoint({
53
+ point: cursor,
54
+ start,
55
+ runStart,
56
+ reference,
57
+ walls: levelWalls(),
58
+ free,
59
+ })
60
+ const wall = createWallOnCurrentLevel(start, snapped.point)!
61
+ start = wall.end
62
+ reference = wallRunDirection(wall.start, wall.end)
63
+ return { wall, snapped }
64
+ }
65
+ }
66
+
67
+ describe('a plain wall run snaps like a pulled one once started', () => {
68
+ let restoreRegistry: (() => void) | undefined
69
+ let previousEditor: Partial<ReturnType<typeof useEditor.getState>>
70
+
71
+ beforeEach(() => {
72
+ previousEditor = {
73
+ toolDefaults: useEditor.getState().toolDefaults,
74
+ continuationByContext: useEditor.getState().continuationByContext,
75
+ }
76
+ // The wall's snap profile makes the armed wall tool the 'wall' snapping
77
+ // context (grid lengths), as in the app.
78
+ restoreRegistry = nodeRegistry._snapshot()
79
+ registerNode({
80
+ kind: 'wall',
81
+ schemaVersion: 10,
82
+ schema: WallSchema,
83
+ category: 'structure',
84
+ snapProfile: 'structural',
85
+ defaults: () => WallSchema.parse({ start: [0, 0], end: [1, 0] }),
86
+ capabilities: {},
87
+ } as never)
88
+ useEditor.getState().setSnappingMode('wall', 'grid')
89
+ useEditor.getState().armToolMode({ mode: 'build', tool: 'wall' })
90
+ useViewer.setState({
91
+ selection: { buildingId: null, levelId: LEVEL_ID, zoneId: null, selectedIds: [] },
92
+ } as never)
93
+ const level = LevelSchema.parse({ id: LEVEL_ID, level: 0, height: 2.7, children: [] })
94
+ useScene.setState({
95
+ nodes: { [level.id]: level as AnyNode },
96
+ rootNodeIds: [level.id],
97
+ dirtyNodes: new Set(),
98
+ collections: {},
99
+ } as never)
100
+ })
101
+
102
+ afterEach(() => {
103
+ discardWallPolygonDraft()
104
+ restoreRegistry?.()
105
+ restoreRegistry = undefined
106
+ useEditor.getState().setSnappingMode('wall', 'grid')
107
+ useEditor.setState(previousEditor as never)
108
+ useEditor.getState().armToolMode({ mode: 'select' })
109
+ })
110
+
111
+ test("the first wall holds to the house's axes, steps 15° past them, and Alt frees it", () => {
112
+ const snap = (cursor: WallPlanPoint, free = false) =>
113
+ snapWallRunDraftPoint({
114
+ point: cursor,
115
+ start: [0, 0],
116
+ runStart: [0, 0],
117
+ reference: null,
118
+ walls: [],
119
+ free,
120
+ })
121
+ const axis = snap([4.1, 0.35])
122
+ expect(axis.lock).toBe('axis')
123
+ expect(axis.point).toEqual([4, 0])
124
+ const stepped = snap([Math.cos(deg(40)) * 3, Math.sin(deg(40)) * 3])
125
+ expect(stepped.lock).toBe('angle')
126
+ expect(stepped.point[0]).toBeCloseTo(Math.cos(deg(45)) * 3, 6)
127
+ expect(stepped.point[1]).toBeCloseTo(Math.sin(deg(45)) * 3, 6)
128
+ expect(isWallRunFree({ altKey: true })).toBe(true)
129
+ expect(isWallRunFree()).toBe(false)
130
+ expect(snap([3.13, 1.07], isWallRunFree({ altKey: true })).point).toEqual([3.13, 1.07])
131
+ // Snapping off frees it too.
132
+ useEditor.getState().setSnappingMode('wall', 'off')
133
+ expect(isWallRunFree()).toBe(true)
134
+ })
135
+
136
+ test('a Walls run squares up, lines up with its start and closes into a room', () => {
137
+ // The first point keeps the tool's own snapping; the run starts at (0, 0).
138
+ const place = plainRun([0, 0])
139
+ const first = place([5.1, 0.4])
140
+ expect(first.snapped.lock).toBe('axis')
141
+ expect(first.wall.end).toEqual([5, 0])
142
+ const second = place([5.3, 3.9])
143
+ expect(second.snapped.lock).toBe('square')
144
+ expect(second.wall.end).toEqual([5, 4])
145
+ const third = place([0.1, 4.05])
146
+ expect(third.snapped.snap).toBe('alignment')
147
+ expect(third.snapped.label).toBe('In line with start')
148
+ expect(third.snapped.guides.some((guide) => guide.kind === 'run-start')).toBe(true)
149
+ expect(third.snapped.guides.some((guide) => guide.kind === 'extension')).toBe(true)
150
+ expect(third.wall.end).toEqual([0, 4])
151
+ const last = place([0.12, 0.18])
152
+ expect(last.snapped.snap).toBe('endpoint')
153
+ expect(last.wall.end).toEqual([0, 0])
154
+ expect(wallClosesRoom(levelWalls(), last.wall)).toBe(true)
155
+ const rooms = detectSpacesForLevel(LEVEL_ID, levelWalls()).spaces.filter(
156
+ (space) => !space.isExterior,
157
+ )
158
+ expect(rooms).toHaveLength(1)
159
+ })
160
+
161
+ test('a Polygon room snaps its corners the same way and closes on its first', () => {
162
+ startWallPolygonDraft(LEVEL_ID, [0, 0])
163
+ let start: WallPlanPoint = [0, 0]
164
+ let reference: WallPlanPoint | null = null
165
+ const corner = (cursor: WallPlanPoint) => {
166
+ const snapped = snapWallRunDraftPoint({
167
+ point: cursor,
168
+ start,
169
+ runStart: [0, 0],
170
+ reference,
171
+ walls: [...levelWalls(), ...wallPolygonDraftWalls()],
172
+ free: false,
173
+ })
174
+ const outcome = addWallPolygonDraftCorner(snapped.point)
175
+ reference = wallRunDirection(start, snapped.point)
176
+ start = snapped.point
177
+ return { snapped, outcome }
178
+ }
179
+ expect(corner([3.9, -0.3]).snapped.point).toEqual([4, 0])
180
+ expect(corner([4.2, 2.95]).snapped.point).toEqual([4, 3])
181
+ const across = corner([-0.08, 3.1])
182
+ expect(across.snapped.label).toBe('In line with start')
183
+ expect(across.snapped.point).toEqual([0, 3])
184
+ const close = corner([0.1, 0.2])
185
+ expect(close.snapped.snap).toBe('endpoint')
186
+ expect(close.outcome).toBe('closed')
187
+ })
188
+ })
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Snapping for the wall tool's line runs once their first point is placed —
3
+ * a Polygon room's sides and a run pulled from a wall end (`wall-pull.ts`,
4
+ * `wallDraftIsRun`); a Walls segment is one wall and snaps as its first point.
5
+ *
6
+ * Each wall holds square: straight on from the wall before it, square to it,
7
+ * or along the house's axes within about 7°, and on 15° steps past that, with
8
+ * its length on the grid; existing walls, the run's start and the projection
9
+ * guides win over that (`snapWallPullPoint`). Alt, or snapping off, leaves the
10
+ * end at the cursor. The run's first point keeps the tool's own snapping.
11
+ *
12
+ * The 3D wall tool and the floor plan each draft a run from their own start,
13
+ * run start and previous wall, and snap through here, so both views place the
14
+ * same point; the view that owns the input publishes the guides and marker.
15
+ */
16
+
17
+ import type { WallNode, WallPlanPoint } from '@pascal-app/core'
18
+ import { getSegmentGridStep } from '../components/tools/wall/wall-drafting'
19
+ import { snapWallPullPoint, type WallPullSnapResult } from '../components/tools/wall/wall-pull-snap'
20
+ import useAlignmentGuides from '../store/use-alignment-guides'
21
+ import { getActiveSnappingMode } from '../store/use-editor'
22
+ import useWallSnapIndicator from '../store/use-wall-snap-indicator'
23
+
24
+ /** True while a run's walls follow the cursor freely: Alt, or snapping off. */
25
+ export function isWallRunFree(event?: { altKey?: boolean } | null): boolean {
26
+ return Boolean(event?.altKey) || getActiveSnappingMode() === 'off'
27
+ }
28
+
29
+ /** Unit direction of a placed wall `start` → `end`: the next wall holds square to it. */
30
+ export function wallRunDirection(start: WallPlanPoint, end: WallPlanPoint): WallPlanPoint | null {
31
+ const dx = end[0] - start[0]
32
+ const dz = end[1] - start[1]
33
+ const length = Math.hypot(dx, dz)
34
+ return length > 1e-9 ? [dx / length, dz / length] : null
35
+ }
36
+
37
+ /**
38
+ * Snap the drafted end of a run's current wall, which starts at `start`.
39
+ * `runStart` is where the run began, `reference` the direction of the wall
40
+ * before (null for the run's first wall), and `walls` the level's walls with
41
+ * any open Polygon room's sides.
42
+ */
43
+ export function snapWallRunDraftPoint(args: {
44
+ point: WallPlanPoint
45
+ start: WallPlanPoint
46
+ runStart: WallPlanPoint
47
+ reference: WallPlanPoint | null
48
+ walls: readonly WallNode[]
49
+ free: boolean
50
+ }): WallPullSnapResult {
51
+ return snapWallPullPoint({ ...args, gridStep: getSegmentGridStep() })
52
+ }
53
+
54
+ /**
55
+ * Show a run's snap: its projection guides and crossings (the alignment guide
56
+ * layers draw them) and the snap marker with what it caught. Null clears both.
57
+ */
58
+ export function publishWallRunFeedback(result: WallPullSnapResult | null): void {
59
+ if (!result) {
60
+ useAlignmentGuides.getState().clear()
61
+ useWallSnapIndicator.getState().clear()
62
+ return
63
+ }
64
+ useAlignmentGuides.getState().setDraft({
65
+ lines: result.guides.map((guide) => ({
66
+ kind: guide.kind,
67
+ from: [guide.from[0], guide.from[1]],
68
+ to: [guide.to[0], guide.to[1]],
69
+ })),
70
+ intersections: result.intersections.map((point) => [point[0], point[1]]),
71
+ })
72
+ useWallSnapIndicator.getState().set(
73
+ result.snap
74
+ ? {
75
+ x: result.point[0],
76
+ z: result.point[1],
77
+ kind: result.snap,
78
+ wallIds: result.targetWallIds.length > 0 ? result.targetWallIds : undefined,
79
+ label: result.label ?? undefined,
80
+ }
81
+ : null,
82
+ )
83
+ }
@@ -0,0 +1,125 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ GYPSUM_HALF,
4
+ getSceneDocument,
5
+ projectWallTypes,
6
+ SiteNode,
7
+ STUD_2X8,
8
+ useScene,
9
+ WallTypeDefinition,
10
+ wallTypeAssembly,
11
+ } from '@pascal-app/core'
12
+ import {
13
+ deleteProjectWallType,
14
+ newWallTypeBlockedReason,
15
+ PROJECT_WALL_TYPE_LIMIT,
16
+ saveProjectWallType,
17
+ WALL_TYPE_LAYER_LIMIT,
18
+ WALL_TYPE_SAVE_FAILURE,
19
+ } from './wall-type'
20
+
21
+ // Scene writes schedule their dirty pass on a frame; bun has none.
22
+ globalThis.requestAnimationFrame ??= () => 0
23
+ globalThis.cancelAnimationFrame ??= () => {}
24
+
25
+ const currentProjectWallTypes = () => {
26
+ const { nodes, rootNodeIds } = useScene.getState()
27
+ return projectWallTypes(nodes, rootNodeIds)
28
+ }
29
+
30
+ /** A 2x8 at 24 in partition, drywall both faces. */
31
+ const SOUND_WALL: WallTypeDefinition = {
32
+ id: 'custom-soundwall',
33
+ label: '2x8 sound wall',
34
+ assembly: wallTypeAssembly('custom-soundwall', [
35
+ { role: 'lining', material: 'drywall', thickness: GYPSUM_HALF },
36
+ { role: 'structure', core: true, material: 'wood', thickness: STUD_2X8, spacing: 0.6096 },
37
+ { role: 'lining', material: 'drywall', thickness: GYPSUM_HALF },
38
+ ]),
39
+ }
40
+
41
+ beforeEach(() => {
42
+ useScene.getState().unloadScene()
43
+ useScene.getState().loadScene()
44
+ })
45
+
46
+ afterEach(() => {
47
+ useScene.getState().unloadScene()
48
+ })
49
+
50
+ describe('project wall types', () => {
51
+ test('save with the project and come back from a saved scene', () => {
52
+ expect(saveProjectWallType(SOUND_WALL)).toBe('saved')
53
+ expect(currentProjectWallTypes()).toEqual([SOUND_WALL])
54
+ const document = JSON.parse(JSON.stringify(getSceneDocument()))
55
+ useScene.getState().unloadScene()
56
+ expect(currentProjectWallTypes()).toEqual([])
57
+ useScene.getState().setScene(document.nodes, document.rootNodeIds)
58
+ expect(currentProjectWallTypes()).toEqual([SOUND_WALL])
59
+ // Saving again under the same id edits it in place.
60
+ saveProjectWallType({ ...SOUND_WALL, label: 'Sound wall' })
61
+ expect(currentProjectWallTypes().map((type) => type.label)).toEqual(['Sound wall'])
62
+ deleteProjectWallType(SOUND_WALL.id)
63
+ expect(currentProjectWallTypes()).toEqual([])
64
+ })
65
+
66
+ test('a 13th layer and a 65th type are refused, each saying why, and nothing is written', () => {
67
+ const stack = (linings: number) =>
68
+ wallTypeAssembly('custom-thick', [
69
+ { role: 'structure', core: true, material: 'wood', thickness: STUD_2X8 },
70
+ ...Array.from({ length: linings }, () => ({
71
+ role: 'lining' as const,
72
+ material: 'drywall',
73
+ thickness: GYPSUM_HALF,
74
+ })),
75
+ ])
76
+ const thick = { id: 'custom-thick', label: 'Thick', assembly: stack(WALL_TYPE_LAYER_LIMIT) }
77
+ expect(saveProjectWallType(thick)).toBe('too-many-layers')
78
+ expect(currentProjectWallTypes()).toEqual([])
79
+ // The limits are the schema's own.
80
+ expect(WallTypeDefinition.safeParse(thick).success).toBe(false)
81
+ expect(saveProjectWallType({ ...thick, assembly: stack(WALL_TYPE_LAYER_LIMIT - 1) })).toBe(
82
+ 'saved',
83
+ )
84
+ deleteProjectWallType(thick.id)
85
+
86
+ const numbered = (n: number) => ({
87
+ ...SOUND_WALL,
88
+ id: `custom-sound-${n}`,
89
+ label: `Sound ${n}`,
90
+ })
91
+ for (let n = 1; n <= PROJECT_WALL_TYPE_LIMIT; n += 1)
92
+ expect(saveProjectWallType(numbered(n))).toBe('saved')
93
+ expect(newWallTypeBlockedReason(currentProjectWallTypes())).toContain(
94
+ `${PROJECT_WALL_TYPE_LIMIT} wall types`,
95
+ )
96
+ expect(saveProjectWallType(numbered(PROJECT_WALL_TYPE_LIMIT + 1))).toBe('too-many-types')
97
+ expect(currentProjectWallTypes()).toHaveLength(PROJECT_WALL_TYPE_LIMIT)
98
+ const site = Object.values(useScene.getState().nodes).find((node) => node.type === 'site')
99
+ expect(SiteNode.safeParse(site).success).toBe(true)
100
+ const over = [...currentProjectWallTypes(), numbered(PROJECT_WALL_TYPE_LIMIT + 1)]
101
+ expect(SiteNode.safeParse({ ...site, wallTypes: over }).success).toBe(false)
102
+ // A type the project keeps can still be edited in place.
103
+ expect(saveProjectWallType({ ...numbered(1), label: 'Quiet wall' })).toBe('saved')
104
+ expect(currentProjectWallTypes()[0]?.label).toBe('Quiet wall')
105
+ deleteProjectWallType(numbered(1).id)
106
+ expect(newWallTypeBlockedReason(currentProjectWallTypes())).toBeNull()
107
+ })
108
+
109
+ test('a type that does not parse says so, apart from a scene with no site', () => {
110
+ const coreless = {
111
+ ...SOUND_WALL,
112
+ assembly: {
113
+ ...SOUND_WALL.assembly,
114
+ layers: SOUND_WALL.assembly.layers.filter((l) => !l.core),
115
+ },
116
+ }
117
+ expect(saveProjectWallType(coreless)).toBe('invalid')
118
+ expect(saveProjectWallType({ ...SOUND_WALL, label: ' ' })).toBe('invalid')
119
+ expect(currentProjectWallTypes()).toEqual([])
120
+ useScene.getState().unloadScene()
121
+ expect(saveProjectWallType(SOUND_WALL)).toBe('no-site')
122
+ expect(WALL_TYPE_SAVE_FAILURE.invalid).not.toBe(WALL_TYPE_SAVE_FAILURE['no-site'])
123
+ expect(WALL_TYPE_SAVE_FAILURE.invalid).not.toContain('site')
124
+ })
125
+ })
@@ -0,0 +1,119 @@
1
+ import {
2
+ type AnyNodeId,
3
+ type Assembly,
4
+ isExteriorWallAssembly,
5
+ parseWallTypes,
6
+ projectWallTypes,
7
+ type SiteNode,
8
+ useScene,
9
+ WallTypeDefinition,
10
+ } from '@pascal-app/core'
11
+ import { useMemo } from 'react'
12
+
13
+ /**
14
+ * A project's own wall types (`SiteNode.wallTypes`), made and edited from a wall's
15
+ * Assembly controls (`WallTypeMenu`): saved on the site, so they travel with the
16
+ * project and undo like any edit.
17
+ *
18
+ * A placement is the kind of wall a stack builds, read from its layers: `exterior`
19
+ * with a cladding side, `interior` lined alike on both faces. A new type starts from
20
+ * a typical stack of its placement.
21
+ */
22
+ export type WallTypePlacement = 'exterior' | 'interior'
23
+
24
+ function siteOf(state: ReturnType<typeof useScene.getState>): SiteNode | undefined {
25
+ for (const id of state.rootNodeIds) {
26
+ const node = state.nodes[id]
27
+ if (node?.type === 'site') return node
28
+ }
29
+ return undefined
30
+ }
31
+
32
+ /** The project's own wall types, kept current as they are saved, edited or undone. */
33
+ export function useProjectWallTypes(): WallTypeDefinition[] {
34
+ const saved = useScene((state) => siteOf(state)?.wallTypes)
35
+ return useMemo(() => parseWallTypes(saved), [saved])
36
+ }
37
+
38
+ /** The placement a stack builds, read from its layers (`isExteriorWallAssembly`). */
39
+ export function wallTypePlacementOf(assembly: Assembly): WallTypePlacement {
40
+ return isExteriorWallAssembly(assembly) ? 'exterior' : 'interior'
41
+ }
42
+
43
+ /** The most layers a wall type's stack holds (`Assembly.layers`). */
44
+ export const WALL_TYPE_LAYER_LIMIT = 12
45
+
46
+ /** The most wall types a project keeps on its site (`SiteNode.wallTypes`). */
47
+ export const PROJECT_WALL_TYPE_LIMIT = 64
48
+
49
+ /**
50
+ * Why no new wall type can be made in this project — it keeps `PROJECT_WALL_TYPE_LIMIT`
51
+ * already — or null while it has room: what "New wall type…" and "Duplicate as a new
52
+ * type…" say when they are off.
53
+ */
54
+ export function newWallTypeBlockedReason(
55
+ projectTypes: readonly WallTypeDefinition[],
56
+ ): string | null {
57
+ return projectTypes.length >= PROJECT_WALL_TYPE_LIMIT
58
+ ? `This project keeps ${PROJECT_WALL_TYPE_LIMIT} wall types, the most it can. Delete one to make another.`
59
+ : null
60
+ }
61
+
62
+ /**
63
+ * What saving a project wall type came to: `saved`, or why not — the scene has no
64
+ * site to keep it on, its stack has more layers than a wall type holds, the project
65
+ * keeps as many types as it can and this one is new, or it does not parse.
66
+ */
67
+ export type WallTypeSaveResult =
68
+ | 'saved'
69
+ | 'no-site'
70
+ | 'too-many-layers'
71
+ | 'too-many-types'
72
+ | 'invalid'
73
+
74
+ /** What the wall type editor says when a type was not saved. */
75
+ export const WALL_TYPE_SAVE_FAILURE: Record<Exclude<WallTypeSaveResult, 'saved'>, string> = {
76
+ 'no-site': 'This project has no site to keep wall types on, so the type was not saved.',
77
+ 'too-many-layers': `A wall type holds at most ${WALL_TYPE_LAYER_LIMIT} layers. Remove one to save it.`,
78
+ 'too-many-types': `This project keeps ${PROJECT_WALL_TYPE_LIMIT} wall types, the most it can. Delete one to save another.`,
79
+ invalid:
80
+ 'These layers do not make a wall type (one framing core, every layer a thickness), so it was not saved.',
81
+ }
82
+
83
+ /**
84
+ * Saves a project wall type on the site, in place of the one with its id or after
85
+ * the others: one undo step, saved and shared with the project. Nothing is written
86
+ * unless it comes to `saved` (`WallTypeSaveResult`): a stack of more than
87
+ * `WALL_TYPE_LAYER_LIMIT` layers, or a new type past `PROJECT_WALL_TYPE_LIMIT`, would
88
+ * leave a site that no longer parses.
89
+ */
90
+ export function saveProjectWallType(type: WallTypeDefinition): WallTypeSaveResult {
91
+ const state = useScene.getState()
92
+ const site = siteOf(state)
93
+ if (!site) return 'no-site'
94
+ if ((type.assembly?.layers?.length ?? 0) > WALL_TYPE_LAYER_LIMIT) return 'too-many-layers'
95
+ const parsed = WallTypeDefinition.safeParse(type)
96
+ if (!parsed.success) return 'invalid'
97
+ const current = projectWallTypes(state.nodes, state.rootNodeIds)
98
+ const editing = current.some((entry) => entry.id === type.id)
99
+ if (!editing && current.length >= PROJECT_WALL_TYPE_LIMIT) return 'too-many-types'
100
+ const next = editing
101
+ ? current.map((entry) => (entry.id === type.id ? parsed.data : entry))
102
+ : [...current, parsed.data]
103
+ state.updateNode(site.id as AnyNodeId, { wallTypes: next } as Partial<SiteNode>)
104
+ return 'saved'
105
+ }
106
+
107
+ /** Removes a project wall type from the site. Walls drawn with it keep their stack. */
108
+ export function deleteProjectWallType(id: string): void {
109
+ const state = useScene.getState()
110
+ const site = siteOf(state)
111
+ if (!site) return
112
+ const next = projectWallTypes(state.nodes, state.rootNodeIds).filter((type) => type.id !== id)
113
+ state.updateNode(
114
+ site.id as AnyNodeId,
115
+ {
116
+ wallTypes: next.length > 0 ? next : undefined,
117
+ } as Partial<SiteNode>,
118
+ )
119
+ }
@@ -0,0 +1,42 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { useInteractive, useScene, WindowNode } from '@pascal-app/core'
3
+ import {
4
+ getDisplayedWindowShutterValue,
5
+ getDisplayedWindowValue,
6
+ openWindowOpenState,
7
+ setWindowShuttersOpenState,
8
+ } from '../index'
9
+
10
+ const originalScene = useScene.getState()
11
+ const originalInteractive = useInteractive.getState()
12
+ afterEach(() => {
13
+ useScene.setState(originalScene)
14
+ useInteractive.setState(originalInteractive)
15
+ })
16
+
17
+ test.each(['box', 'concealed'] as const)(
18
+ '%s shutter previews use saved defaults, can reverse, and leave sash animation queued',
19
+ (rollerShutter) => {
20
+ const node = WindowNode.parse({ windowType: 'casement', shutters: 'panel', rollerShutter })
21
+ useScene.setState({ nodes: { [node.id]: node } })
22
+ useInteractive.setState({ windows: {}, windowAnimations: {} })
23
+ expect(getDisplayedWindowShutterValue(node.id, 'shutterOpen', undefined)).toBe(1)
24
+ expect(getDisplayedWindowShutterValue(node.id, 'rollerShutterOpen', 0.7)).toBe(0.7)
25
+ openWindowOpenState(node.id, { persist: false })
26
+ setWindowShuttersOpenState(node.id, false, { persist: false })
27
+ expect(getDisplayedWindowValue(node.id, node.operationState)).toBe(0)
28
+ expect(useInteractive.getState().windowAnimations[node.id]?.operationState?.to).toBe(1)
29
+ useInteractive
30
+ .getState()
31
+ .setWindowOpenState(node.id, { shutterOpen: 0.3, rollerShutterOpen: 0.5 })
32
+ setWindowShuttersOpenState(node.id, true, { persist: false })
33
+ expect(useInteractive.getState().windowAnimations[node.id]).toMatchObject({
34
+ shutterOpen: { from: 0.3, to: 1, persist: false },
35
+ rollerShutterOpen: { from: 0.5, to: 1, persist: false },
36
+ operationState: { to: 1 },
37
+ })
38
+ useInteractive.getState().cancelWindowAnimation(node.id)
39
+ useInteractive.getState().removeWindowOpenState(node.id)
40
+ expect(getDisplayedWindowShutterValue(node.id, 'shutterOpen', 0.7)).toBe(0.7)
41
+ },
42
+ )
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type AnyNodeId,
3
+ type InteractiveState,
3
4
  useInteractive,
4
5
  useScene,
5
6
  type WindowInteractiveState,
@@ -7,7 +8,7 @@ import {
7
8
 
8
9
  export const WINDOW_TOGGLE_ANIMATION_MS = 520
9
10
 
10
- type WindowOpenAnimationOptions = {
11
+ export type WindowOpenAnimationOptions = {
11
12
  persist?: boolean
12
13
  }
13
14
 
@@ -17,18 +18,23 @@ export function isOperableWindowType(windowType: string | undefined) {
17
18
  windowType === 'casement' ||
18
19
  windowType === 'awning' ||
19
20
  windowType === 'hopper' ||
21
+ windowType === 'tilt-turn' ||
20
22
  windowType === 'single-hung' ||
21
23
  windowType === 'double-hung' ||
22
24
  windowType === 'louvered'
23
25
  )
24
26
  }
25
27
 
26
- export function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) {
27
- const interactive = useInteractive.getState()
28
- const runtimeValue = interactive.windows[windowId]?.operationState
28
+ export function getDisplayedWindowValue(
29
+ windowId: AnyNodeId,
30
+ nodeValue: number | undefined,
31
+ field: keyof WindowInteractiveState = 'operationState',
32
+ interactive: InteractiveState = useInteractive.getState(),
33
+ ) {
34
+ const runtimeValue = interactive.windows[windowId]?.[field]
29
35
  if (runtimeValue !== undefined) return runtimeValue
30
36
 
31
- const queuedValue = interactive.windowAnimations[windowId]?.from
37
+ const queuedValue = interactive.windowAnimations[windowId]?.[field]?.from
32
38
  if (queuedValue !== undefined) return queuedValue
33
39
 
34
40
  return nodeValue ?? 0
@@ -98,3 +104,39 @@ export function openWindowOpenState(windowId: AnyNodeId, options?: WindowOpenAni
98
104
  const currentOpenAmount = getDisplayedWindowValue(windowId, node.operationState)
99
105
  startWindowOpenAnimation(windowId, 'operationState', currentOpenAmount, 1, options)
100
106
  }
107
+
108
+ export type WindowShutterField = 'shutterOpen' | 'rollerShutterOpen'
109
+
110
+ export function getDisplayedWindowShutterValue(
111
+ windowId: AnyNodeId,
112
+ field: WindowShutterField,
113
+ nodeValue: number | undefined,
114
+ state: InteractiveState = useInteractive.getState(),
115
+ ): number {
116
+ return getDisplayedWindowValue(windowId, nodeValue ?? 1, field, state)
117
+ }
118
+
119
+ /** Preview both shutter styles together, independently of the sash. */
120
+ export function setWindowShuttersOpenState(
121
+ windowId: AnyNodeId,
122
+ on: boolean,
123
+ options?: WindowOpenAnimationOptions,
124
+ ): void {
125
+ const node = useScene.getState().nodes[windowId]
126
+ if (node?.type !== 'window' || node.source) return
127
+ const fields: WindowShutterField[] = []
128
+ if (node.shutters && node.shutters !== 'none') fields.push('shutterOpen')
129
+ if (node.rollerShutter === 'box' || node.rollerShutter === 'concealed')
130
+ fields.push('rollerShutterOpen')
131
+ if (!fields.length) return
132
+ useInteractive.getState().setMechanismPart(windowId, 'shutters', on)
133
+ for (const field of fields) {
134
+ startWindowOpenAnimation(
135
+ windowId,
136
+ field,
137
+ getDisplayedWindowShutterValue(windowId, field, node[field]),
138
+ on ? 1 : 0,
139
+ options,
140
+ )
141
+ }
142
+ }