@pascal-app/editor 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (382) 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 +110 -42
  6. package/src/components/editor/duplicate-node.ts +2 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +77 -68
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +31 -11
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +2 -2
  15. package/src/components/editor/floorplan-panel.test.ts +220 -0
  16. package/src/components/editor/floorplan-panel.tsx +458 -140
  17. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  18. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  19. package/src/components/editor/footprint-height-handle.tsx +2 -2
  20. package/src/components/editor/grid.tsx +24 -15
  21. package/src/components/editor/group-actions.ts +19 -1
  22. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  23. package/src/components/editor/group-move-3d.ts +27 -2
  24. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  25. package/src/components/editor/group-transform-shared.ts +23 -1
  26. package/src/components/editor/index.tsx +150 -20
  27. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  28. package/src/components/editor/localized-html.tsx +22 -0
  29. package/src/components/editor/measurement-pill.tsx +1 -1
  30. package/src/components/editor/node-action-menu.tsx +46 -12
  31. package/src/components/editor/node-arrow-handles.tsx +2 -3
  32. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  33. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  34. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  35. package/src/components/editor/quick-measurement-card.tsx +1 -1
  36. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  37. package/src/components/editor/registry-action-contributions.tsx +115 -15
  38. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  39. package/src/components/editor/room-controls.tsx +4 -3
  40. package/src/components/editor/room-handles-2d.tsx +158 -7
  41. package/src/components/editor/room-handles.tsx +244 -54
  42. package/src/components/editor/room-highlight.tsx +2 -0
  43. package/src/components/editor/room-labels.tsx +33 -17
  44. package/src/components/editor/selection-manager.tsx +15 -102
  45. package/src/components/editor/separator-end-grip.tsx +74 -0
  46. package/src/components/editor/site-edge-labels.tsx +7 -3
  47. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  48. package/src/components/editor/thumbnail-generator.tsx +26 -1
  49. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  50. package/src/components/editor/views/view-bar.tsx +111 -26
  51. package/src/components/editor/wall-measurement-label.tsx +31 -15
  52. package/src/components/editor/wall-move-side-handles.tsx +15 -7
  53. package/src/components/editor/wall-pull-grip.tsx +193 -0
  54. package/src/components/editor/wall-region-handles.tsx +2 -2
  55. package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
  56. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  57. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  58. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  59. package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
  60. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  61. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
  62. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
  63. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  64. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
  65. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  66. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  67. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  68. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  69. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  70. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
  72. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
  73. package/src/components/procedural-items/inspector.tsx +59 -12
  74. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  75. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  76. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  77. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  78. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  79. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  80. package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
  81. package/src/components/tools/item/move-tool.tsx +1 -6
  82. package/src/components/tools/item/placement-history.test.tsx +1 -1
  83. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  84. package/src/components/tools/item/placement-strategies.ts +5 -9
  85. package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
  86. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  87. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  88. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  89. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  90. package/src/components/tools/select/select-candidates.test.ts +45 -0
  91. package/src/components/tools/select/select-candidates.ts +13 -2
  92. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  93. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  94. package/src/components/tools/shared/placement-box.tsx +2 -6
  95. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  96. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  97. package/src/components/tools/shared/segment-angle.ts +0 -4
  98. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  99. package/src/components/tools/tool-manager.tsx +1 -24
  100. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  101. package/src/components/tools/wall/wall-drafting.ts +132 -24
  102. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  103. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  104. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  105. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  106. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  107. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  108. package/src/components/tools/zone/zone-tool.tsx +3 -3
  109. package/src/components/ui/action-menu/action-button.tsx +2 -0
  110. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  111. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  112. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  113. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  114. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  115. package/src/components/ui/action-menu/index.tsx +1 -0
  116. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  117. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  118. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  119. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  120. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  121. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  122. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  123. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  124. package/src/components/ui/controls/material-picker.tsx +24 -17
  125. package/src/components/ui/controls/metric-control.test.ts +52 -0
  126. package/src/components/ui/controls/metric-control.tsx +62 -24
  127. package/src/components/ui/controls/segmented-control.tsx +5 -2
  128. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  129. package/src/components/ui/controls/slider-control.tsx +47 -19
  130. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  131. package/src/components/ui/floating-level-selector.tsx +100 -48
  132. package/src/components/ui/helpers/building-helper.tsx +5 -3
  133. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  134. package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
  135. package/src/components/ui/helpers/helper-manager.tsx +136 -57
  136. package/src/components/ui/helpers/item-helper.tsx +6 -4
  137. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  138. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  139. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  140. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  141. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  142. package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
  143. package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
  144. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  145. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  146. package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
  147. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  148. package/src/components/ui/panels/multi-field-value.ts +20 -1
  149. package/src/components/ui/panels/multi-height-mode.tsx +11 -10
  150. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  151. package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
  152. package/src/components/ui/panels/node-display.ts +35 -24
  153. package/src/components/ui/panels/panel-manager.tsx +9 -5
  154. package/src/components/ui/panels/panel-wrapper.tsx +63 -127
  155. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  156. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  157. package/src/components/ui/panels/reference-panel.tsx +51 -35
  158. package/src/components/ui/panels/room-construction-rows.tsx +465 -66
  159. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  160. package/src/components/ui/panels/room-name-options.ts +110 -11
  161. package/src/components/ui/panels/room-openings-field.tsx +22 -13
  162. package/src/components/ui/panels/room-panel.tsx +207 -100
  163. package/src/components/ui/panels/shape-choice.tsx +7 -5
  164. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  165. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  166. package/src/components/ui/right-stack.test.ts +13 -6
  167. package/src/components/ui/right-stack.tsx +21 -10
  168. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  169. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  170. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  171. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  172. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  173. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  174. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  175. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
  176. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  177. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  178. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  179. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  180. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  181. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  182. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  183. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  184. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  185. package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
  186. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  187. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  188. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  189. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  190. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  191. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  192. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
  193. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  194. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  195. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  196. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  197. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  198. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  199. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  200. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  201. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  202. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
  203. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  204. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  205. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  206. package/src/components/ui/snap-target-badge.tsx +7 -6
  207. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  208. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  209. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  210. package/src/components/viewer/floorplan-preview.tsx +27 -19
  211. package/src/components/viewer/render-settings-menu.tsx +294 -0
  212. package/src/components/viewer/sky-control.tsx +710 -0
  213. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  214. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  215. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  216. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  217. package/src/components/viewer/viewer-rooms.tsx +62 -5
  218. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  219. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  220. package/src/components/viewer-overlay.tsx +8 -0
  221. package/src/components/walkthrough-hud.tsx +75 -2
  222. package/src/hooks/use-keyboard.test.ts +85 -4
  223. package/src/hooks/use-keyboard.ts +65 -7
  224. package/src/hooks/use-selected-room.ts +12 -1
  225. package/src/i18n/floor-footprint-name.ts +30 -0
  226. package/src/i18n/level-name.ts +39 -0
  227. package/src/i18n/locales/en/messages.po +15849 -0
  228. package/src/i18n/locales/en/messages.ts +1 -0
  229. package/src/i18n/locales/en-XA/messages.ts +1 -0
  230. package/src/i18n/locales/es/messages.po +39331 -0
  231. package/src/i18n/locales/es/messages.ts +1 -0
  232. package/src/i18n/locales/fr/messages.po +39331 -0
  233. package/src/i18n/locales/fr/messages.ts +1 -0
  234. package/src/i18n/locales/ja/messages.po +39331 -0
  235. package/src/i18n/locales/ja/messages.ts +1 -0
  236. package/src/i18n/locales/zh-CN/messages.po +39331 -0
  237. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  238. package/src/i18n/locales.test.ts +63 -0
  239. package/src/i18n/locales.ts +32 -0
  240. package/src/i18n/message-slot.tsx +25 -0
  241. package/src/i18n/node-messages.ts +169 -0
  242. package/src/i18n/pseudo-locale.ts +17 -0
  243. package/src/i18n/published-entry.test.ts +17 -0
  244. package/src/i18n/scene-theme-name.ts +16 -0
  245. package/src/index.tsx +224 -45
  246. package/src/lib/add-storey.test.ts +3 -3
  247. package/src/lib/contextual-help.test.ts +20 -1
  248. package/src/lib/contextual-help.ts +101 -19
  249. package/src/lib/continuation.ts +38 -11
  250. package/src/lib/elevation-guides.ts +21 -6
  251. package/src/lib/elevator-support.ts +1 -72
  252. package/src/lib/floor-footprints.test.ts +3 -2
  253. package/src/lib/floor-footprints.ts +1 -9
  254. package/src/lib/floor-foundation-support.test.ts +15 -6
  255. package/src/lib/floor-opening-draft.ts +1 -1
  256. package/src/lib/floor-region-session.ts +1 -1
  257. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  258. package/src/lib/floorplan/dimension-drive.ts +68 -3
  259. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  260. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  261. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  262. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  263. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  264. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  265. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  266. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  267. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  268. package/src/lib/floorplan/schedules.ts +10 -6
  269. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  270. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  271. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  272. package/src/lib/geometry-script/author.test.ts +114 -0
  273. package/src/lib/geometry-script/author.ts +55 -23
  274. package/src/lib/geometry-script/client.ts +3 -0
  275. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  276. package/src/lib/gesture-lifecycle.ts +3 -1
  277. package/src/lib/glb-export.test.ts +327 -0
  278. package/src/lib/glb-export.ts +70 -2
  279. package/src/lib/grid-frame.test.ts +56 -3
  280. package/src/lib/grid-frame.ts +28 -1
  281. package/src/lib/history.test.ts +2 -1
  282. package/src/lib/hud-title.ts +48 -24
  283. package/src/lib/ifc-export.test.ts +41 -1
  284. package/src/lib/inspector-card-mode.test.ts +3 -31
  285. package/src/lib/inspector-card-mode.ts +5 -10
  286. package/src/lib/inspector-expanded.test.ts +13 -0
  287. package/src/lib/inspector-expanded.ts +13 -1
  288. package/src/lib/interaction/scope.ts +1 -0
  289. package/src/lib/keyboard-pan.test.ts +7 -0
  290. package/src/lib/keyboard-pan.ts +9 -2
  291. package/src/lib/layer-thickness.test.ts +22 -0
  292. package/src/lib/layer-thickness.ts +79 -0
  293. package/src/lib/level-selection.test.ts +18 -2
  294. package/src/lib/level-selection.ts +31 -8
  295. package/src/lib/linear-display.ts +1 -1
  296. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  297. package/src/lib/lot/drop-in.test.ts +59 -1
  298. package/src/lib/lot/drop-in.ts +42 -50
  299. package/src/lib/material-swatch-style.ts +24 -0
  300. package/src/lib/measurement-label.test.ts +1 -1
  301. package/src/lib/measurement-parser.ts +7 -3
  302. package/src/lib/measurements.test.ts +24 -6
  303. package/src/lib/measurements.ts +1 -203
  304. package/src/lib/mezzanine-selection.test.ts +3 -1
  305. package/src/lib/node-action-movement.test.tsx +115 -0
  306. package/src/lib/node-action-movement.ts +2 -0
  307. package/src/lib/orbit-near-plane.test.ts +12 -0
  308. package/src/lib/orbit-near-plane.ts +13 -0
  309. package/src/lib/paint-regions.test.ts +8 -5
  310. package/src/lib/paint-scope.test.ts +53 -0
  311. package/src/lib/paint-scope.ts +18 -4
  312. package/src/lib/panel-rows.ts +17 -8
  313. package/src/lib/placement-metadata.ts +4 -0
  314. package/src/lib/point-ask/describe-target.ts +6 -8
  315. package/src/lib/point-ask/region.ts +1 -1
  316. package/src/lib/print-shell-compiler.ts +12 -1
  317. package/src/lib/print-wall-solids.test.ts +11 -0
  318. package/src/lib/print-wall-solids.ts +9 -0
  319. package/src/lib/reference-scale.ts +83 -0
  320. package/src/lib/room-assembly-overlay.ts +1 -1
  321. package/src/lib/room-built-on.test.ts +28 -17
  322. package/src/lib/room-built-on.ts +21 -17
  323. package/src/lib/room-construction-commands.ts +3 -2
  324. package/src/lib/room-construction.test.ts +28 -1
  325. package/src/lib/room-construction.ts +14 -0
  326. package/src/lib/room-controls-phase6.test.ts +141 -1
  327. package/src/lib/room-handle-drag.ts +554 -58
  328. package/src/lib/room-name-catalog.test.ts +8 -0
  329. package/src/lib/room-name-catalog.ts +8 -1
  330. package/src/lib/room-name-messages.test.ts +132 -0
  331. package/src/lib/room-name-messages.ts +147 -0
  332. package/src/lib/room-name-storage.test.ts +334 -0
  333. package/src/lib/room-push-dimensions.test.ts +38 -1
  334. package/src/lib/room-push-dimensions.ts +226 -2
  335. package/src/lib/room-selection.test.ts +23 -6
  336. package/src/lib/scene-bounds.test.ts +144 -0
  337. package/src/lib/scene-bounds.ts +79 -15
  338. package/src/lib/scene-clipboard.test.ts +70 -49
  339. package/src/lib/scene-clipboard.ts +11 -5
  340. package/src/lib/selection-cycle.ts +63 -0
  341. package/src/lib/separator-edit.test.ts +431 -0
  342. package/src/lib/separator-edit.ts +179 -0
  343. package/src/lib/separator-lines.ts +38 -0
  344. package/src/lib/sfx-bus.ts +27 -1
  345. package/src/lib/sfx-player.ts +16 -0
  346. package/src/lib/snapping-mode.test.ts +23 -1
  347. package/src/lib/tool-cursor-icon.ts +5 -1
  348. package/src/lib/tool-shortcuts.ts +130 -0
  349. package/src/lib/viewer-selection.test.ts +28 -24
  350. package/src/lib/viewer-selection.ts +21 -6
  351. package/src/lib/wall-draw-variant.test.ts +7 -6
  352. package/src/lib/wall-draw-variant.ts +107 -6
  353. package/src/lib/wall-nudge.test.ts +44 -0
  354. package/src/lib/wall-nudge.ts +131 -0
  355. package/src/lib/wall-parallel-snap.test.ts +83 -0
  356. package/src/lib/wall-parallel-snap.ts +176 -0
  357. package/src/lib/wall-placement-pointer.test.ts +23 -0
  358. package/src/lib/wall-placement-pointer.ts +18 -0
  359. package/src/lib/wall-pull.test.ts +245 -0
  360. package/src/lib/wall-pull.ts +209 -0
  361. package/src/lib/wall-push-line-up.test.ts +132 -0
  362. package/src/lib/wall-region-list.ts +17 -6
  363. package/src/lib/wall-run-snap.test.ts +188 -0
  364. package/src/lib/wall-run-snap.ts +83 -0
  365. package/src/lib/wall-type.test.ts +125 -0
  366. package/src/lib/wall-type.ts +119 -0
  367. package/src/lib/window-interaction.test.ts +44 -0
  368. package/src/lib/world-grid-snap.test.ts +139 -2
  369. package/src/lib/world-grid-snap.ts +102 -105
  370. package/src/store/use-alignment-guides.ts +27 -1
  371. package/src/store/use-editor.tsx +53 -0
  372. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  373. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  374. package/src/store/use-placement-typing.test.ts +165 -0
  375. package/src/store/use-placement-typing.ts +186 -0
  376. package/src/store/use-wall-snap-indicator.ts +15 -2
  377. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  378. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  379. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  380. package/src/lib/door-interaction.ts +0 -102
  381. package/src/lib/measurement-label.ts +0 -68
  382. package/src/lib/window-interaction.ts +0 -100
@@ -0,0 +1,710 @@
1
+ 'use client'
2
+
3
+ import { type PascalI18n, usePascalI18n, useScene } from '@pascal-app/core'
4
+ import {
5
+ presetInstant,
6
+ requestDeviceLocation,
7
+ resolveLocation,
8
+ type SkyPhase,
9
+ skyPhase,
10
+ solarClockInstant,
11
+ solarClockMinutes,
12
+ solarPosition,
13
+ sunEvents,
14
+ type TimeOfDay,
15
+ useViewer,
16
+ WEATHER_COVER,
17
+ type Weather,
18
+ } from '@pascal-app/viewer'
19
+ import {
20
+ Cloud,
21
+ CloudSun,
22
+ Cloudy,
23
+ LocateFixed,
24
+ Moon,
25
+ RotateCcw,
26
+ Sun,
27
+ Sunrise,
28
+ Sunset,
29
+ X,
30
+ } from 'lucide-react'
31
+ import {
32
+ type CSSProperties,
33
+ type PointerEvent,
34
+ useEffect,
35
+ useLayoutEffect,
36
+ useMemo,
37
+ useRef,
38
+ useState,
39
+ } from 'react'
40
+ import { create } from 'zustand'
41
+ import { cn } from '../../lib/utils'
42
+ import { Popover, PopoverAnchor, PopoverContent } from '../ui/primitives/popover'
43
+ import { Switch } from '../ui/primitives/switch'
44
+
45
+ /**
46
+ * The sky (time of day and weather) as a chip beside the compass, opening a panel: the sun's
47
+ * arc to drag through the day, the presets, the live clock, and the cloud cover. While a chip is
48
+ * mounted the display menus point here instead of carrying their own time and weather menus.
49
+ */
50
+ export const useSkyPanel = create<{ open: boolean; hosts: number }>(() => ({
51
+ open: false,
52
+ hosts: 0,
53
+ }))
54
+
55
+ export function openSkyPanel() {
56
+ useSkyPanel.setState({ open: true })
57
+ }
58
+
59
+ /**
60
+ * The bottom docks the chip lines up with (measured): the editor's tool dock and the viewer's
61
+ * controls bar, both 24 px up. The compass beside the chip centres on them.
62
+ */
63
+ export const SKY_DOCK_BOTTOM = 24
64
+ export const SKY_DOCK_HEIGHT = { editor: 58, viewer: 56 } as const
65
+ export const SKY_COMPASS_BOTTOM = 36
66
+
67
+ /**
68
+ * A preset or reset eases the sun to its time the short way round the clock, slowly enough to
69
+ * watch the light change: 1.5 s for a short hop, up to 3 s for half a day, easing in and out.
70
+ */
71
+ const TWEEN_MIN_MS = 1500
72
+ const TWEEN_MAX_MS = 3000
73
+ let tween = 0
74
+
75
+ function stopTween() {
76
+ cancelAnimationFrame(tween)
77
+ tween = 0
78
+ }
79
+
80
+ function easeTimeTo(from: number, to: number, done: () => void) {
81
+ stopTween()
82
+ const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
83
+ const delta = ((((to - from + 2160) % 1440) + 1440) % 1440) - 720
84
+ if (reduce || Math.abs(delta) < 1) {
85
+ done()
86
+ return
87
+ }
88
+ const start = performance.now()
89
+ const duration = TWEEN_MIN_MS + (TWEEN_MAX_MS - TWEEN_MIN_MS) * (Math.abs(delta) / 720)
90
+ const step = (now: number) => {
91
+ const t = Math.min(1, (now - start) / duration)
92
+ const eased = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
93
+ if (t >= 1) {
94
+ tween = 0
95
+ done()
96
+ return
97
+ }
98
+ useViewer.getState().setTimeOfDayMinutes((((from + delta * eased) % 1440) + 1440) % 1440)
99
+ tween = requestAnimationFrame(step)
100
+ }
101
+ tween = requestAnimationFrame(step)
102
+ }
103
+
104
+ const PRESETS: readonly { id: TimeOfDay; icon: typeof Sun }[] = [
105
+ { id: 'midday', icon: Sun },
106
+ { id: 'golden', icon: Sunset },
107
+ { id: 'blue', icon: Sunrise },
108
+ { id: 'night', icon: Moon },
109
+ ]
110
+
111
+ const WEATHERS: readonly { id: Weather; icon: typeof Sun }[] = [
112
+ { id: 'clear', icon: Sun },
113
+ { id: 'fair', icon: CloudSun },
114
+ { id: 'cloudy', icon: Cloudy },
115
+ { id: 'overcast', icon: Cloud },
116
+ ]
117
+
118
+ export function weatherIcon(weather: Weather) {
119
+ return WEATHERS.find((option) => option.id === weather)?.icon ?? CloudSun
120
+ }
121
+
122
+ function presetName(i18n: PascalI18n, id: TimeOfDay) {
123
+ switch (id) {
124
+ case 'golden':
125
+ return i18n._({ id: 'viewer.daylight.time.golden', message: 'Golden hour' })
126
+ case 'midday':
127
+ return i18n._({ id: 'viewer.daylight.time.midday', message: 'Midday' })
128
+ case 'blue':
129
+ return i18n._({ id: 'viewer.daylight.time.blue', message: 'Blue hour' })
130
+ case 'night':
131
+ return i18n._({ id: 'viewer.daylight.time.night', message: 'Night' })
132
+ }
133
+ }
134
+
135
+ function phaseName(i18n: PascalI18n, phase: SkyPhase) {
136
+ switch (phase) {
137
+ case 'night':
138
+ return presetName(i18n, 'night')
139
+ case 'blue':
140
+ return presetName(i18n, 'blue')
141
+ case 'golden':
142
+ return presetName(i18n, 'golden')
143
+ case 'midday':
144
+ return presetName(i18n, 'midday')
145
+ case 'morning':
146
+ return i18n._({ id: 'viewer.daylight.phase.morning', message: 'Morning' })
147
+ case 'afternoon':
148
+ return i18n._({ id: 'viewer.daylight.phase.afternoon', message: 'Afternoon' })
149
+ }
150
+ }
151
+
152
+ export function weatherName(i18n: PascalI18n, id: Weather) {
153
+ switch (id) {
154
+ case 'clear':
155
+ return i18n._({ id: 'viewer.daylight.weather.clear', message: 'Clear' })
156
+ case 'fair':
157
+ return i18n._({ id: 'viewer.daylight.weather.fair', message: 'Fair' })
158
+ case 'cloudy':
159
+ return i18n._({ id: 'viewer.daylight.weather.cloudy', message: 'Cloudy' })
160
+ case 'overcast':
161
+ return i18n._({ id: 'viewer.daylight.weather.overcast', message: 'Overcast' })
162
+ }
163
+ }
164
+
165
+ /** Sky colours (zenith, horizon) per phase, for the chip swatch and the arc's backdrop. */
166
+ const PHASE_SKY: Record<SkyPhase, [string, string]> = {
167
+ night: ['#121a36', '#36406c'],
168
+ blue: ['#263465', '#8c88b4'],
169
+ golden: ['#8e98c6', '#f2cfb2'],
170
+ morning: ['#6f9dd6', '#dbe7f2'],
171
+ midday: ['#5a93d5', '#d3e4f3'],
172
+ afternoon: ['#6a98d0', '#e6e2da'],
173
+ }
174
+
175
+ /** Minutes after midnight, as the locale writes a time of day. */
176
+ const formatMinutes = (() => {
177
+ let format: Intl.DateTimeFormat | null = null
178
+ return (minutes: number) => {
179
+ format ??= new Intl.DateTimeFormat(undefined, {
180
+ hour: 'numeric',
181
+ minute: '2-digit',
182
+ timeZone: 'UTC',
183
+ })
184
+ return format.format(new Date(Math.round(minutes) * 60_000))
185
+ }
186
+ })()
187
+
188
+ type SiteFacts = Parameters<typeof resolveLocation>[0]
189
+
190
+ /** The sky the viewer shows right now: its time on the site's sun clock, phase and events. */
191
+ function useSky() {
192
+ const timeOfDay = useViewer((s) => s.timeOfDay)
193
+ const timeOfDayMinutes = useViewer((s) => s.timeOfDayMinutes)
194
+ const skyLive = useViewer((s) => s.skyLive)
195
+ const weather = useViewer((s) => s.weather)
196
+ const deviceLocation = useViewer((s) => s.deviceLocation)
197
+ const site = useScene((state) => {
198
+ for (const id of state.rootNodeIds) {
199
+ const node = state.nodes[id]
200
+ if (node?.type === 'site') return node as unknown as SiteFacts
201
+ }
202
+ return undefined
203
+ })
204
+ // The live clock moves the chip on once a minute.
205
+ const [now, setNow] = useState(() => new Date())
206
+ useEffect(() => {
207
+ if (!skyLive) return
208
+ setNow(new Date())
209
+ const timer = setInterval(() => setNow(new Date()), 60_000)
210
+ return () => clearInterval(timer)
211
+ }, [skyLive])
212
+
213
+ const { location, source } = resolveLocation(site, deviceLocation)
214
+ const { lat, lng } = location
215
+ const day = useMemo(() => {
216
+ const today = new Date()
217
+ const presets = Object.fromEntries(
218
+ PRESETS.map(({ id }) => [id, solarClockMinutes(presetInstant(today, lat, lng, id), lng)]),
219
+ ) as Record<TimeOfDay, number>
220
+ return { today, presets, events: sunEvents(today, lat, lng) }
221
+ }, [lat, lng])
222
+
223
+ const minutes = skyLive
224
+ ? solarClockMinutes(now, location.lng)
225
+ : (timeOfDayMinutes ?? day.presets[timeOfDay])
226
+ const elevation = solarPosition(
227
+ solarClockInstant(day.today, location.lng, minutes),
228
+ location.lat,
229
+ location.lng,
230
+ ).elevation
231
+ return {
232
+ minutes,
233
+ // The live sky reads the wall clock the person knows; set times read the site's sun clock.
234
+ clock: skyLive ? now.getHours() * 60 + now.getMinutes() : minutes,
235
+ phase: skyPhase(elevation, minutes),
236
+ activePreset: skyLive || timeOfDayMinutes !== null ? null : timeOfDay,
237
+ presets: day.presets,
238
+ events: day.events ?? { sunrise: 6 * 60, sunset: 18 * 60 },
239
+ skyLive,
240
+ weather,
241
+ source,
242
+ }
243
+ }
244
+
245
+ /**
246
+ * The chip and its panel, absolutely placed by the caller (the scene's bottom-left corner, beside
247
+ * the compass). `dark` for the viewer's dark chrome.
248
+ */
249
+ export function SkyControl({
250
+ className,
251
+ dark = false,
252
+ height,
253
+ style,
254
+ }: {
255
+ className?: string
256
+ dark?: boolean
257
+ /** The dock's height beside it, so the two line up. */
258
+ height: number
259
+ style?: CSSProperties
260
+ }) {
261
+ const i18n = usePascalI18n()
262
+ const open = useSkyPanel((s) => s.open)
263
+ const sky = useSky()
264
+ const WeatherIcon = weatherIcon(sky.weather)
265
+ const nextWeather =
266
+ WEATHERS[(WEATHERS.findIndex((w) => w.id === sky.weather) + 1) % WEATHERS.length]!
267
+ const chip = useRef<HTMLDivElement | null>(null)
268
+ const compact = useClearOfDock(chip)
269
+
270
+ useEffect(() => {
271
+ useSkyPanel.setState((s) => ({ hosts: s.hosts + 1 }))
272
+ return () => useSkyPanel.setState((s) => ({ hosts: s.hosts - 1, open: false }))
273
+ }, [])
274
+
275
+ return (
276
+ <Popover onOpenChange={(next) => useSkyPanel.setState({ open: next })} open={open}>
277
+ <PopoverAnchor asChild>
278
+ <div
279
+ className={cn(
280
+ 'corner-smooth pointer-events-auto absolute z-30 flex items-stretch gap-0.5 rounded-2xl border border-border/40 bg-background/95 p-1 text-foreground text-xs shadow-elevation-4 backdrop-blur-xl',
281
+ dark && 'dark',
282
+ className,
283
+ )}
284
+ onPointerDown={(event) => event.stopPropagation()}
285
+ ref={chip}
286
+ style={{ bottom: SKY_DOCK_BOTTOM, height, ...style }}
287
+ >
288
+ <button
289
+ aria-expanded={open}
290
+ aria-label={i18n._({ id: 'viewer.daylight.sky.open', message: 'Time and weather' })}
291
+ className={cn(
292
+ 'flex items-center gap-2 rounded-xl pr-3 pl-1.5 transition-colors',
293
+ open ? 'bg-violet-500/10' : 'hover:bg-accent',
294
+ )}
295
+ onClick={() => useSkyPanel.setState({ open: !open })}
296
+ type="button"
297
+ >
298
+ <MiniSky sky={sky} />
299
+ <span className="font-mono text-foreground text-sm tabular-nums">
300
+ {formatMinutes(sky.clock)}
301
+ </span>
302
+ {compact ? null : (
303
+ <span className="text-muted-foreground text-sm">{phaseName(i18n, sky.phase)}</span>
304
+ )}
305
+ </button>
306
+ <span aria-hidden="true" className="mx-0.5 my-auto h-6 w-px bg-border" />
307
+ <button
308
+ aria-label={i18n._({
309
+ id: 'viewer.daylight.sky.cycleWeather',
310
+ message: 'Cloud cover: {current}. Change to {next}',
311
+ values: {
312
+ current: weatherName(i18n, sky.weather),
313
+ next: weatherName(i18n, nextWeather.id),
314
+ },
315
+ })}
316
+ className="flex items-center gap-1.5 rounded-xl px-2.5 text-sm transition-colors hover:bg-accent"
317
+ onClick={() => useViewer.getState().setWeather(nextWeather.id)}
318
+ title={i18n._({
319
+ id: 'viewer.daylight.sky.cycleWeatherTip',
320
+ message: 'Cycle cloud cover',
321
+ })}
322
+ type="button"
323
+ >
324
+ <WeatherIcon aria-hidden="true" className="h-[18px] w-[18px] text-muted-foreground" />
325
+ {compact ? null : <span>{weatherName(i18n, sky.weather)}</span>}
326
+ </button>
327
+ </div>
328
+ </PopoverAnchor>
329
+ <PopoverContent
330
+ align="start"
331
+ className={cn('w-[22rem] rounded-2xl p-4', dark && 'dark')}
332
+ // Opened from a menu, focus returns to that menu's trigger: only a click outside closes.
333
+ onFocusOutside={(event) => event.preventDefault()}
334
+ onOpenAutoFocus={(event) => event.preventDefault()}
335
+ onPointerDown={(event) => event.stopPropagation()}
336
+ side="top"
337
+ sideOffset={8}
338
+ >
339
+ <SkyPanel sky={sky} />
340
+ </PopoverContent>
341
+ </Popover>
342
+ )
343
+ }
344
+
345
+ /**
346
+ * Whether the chip must drop its phase and weather labels to stay clear of the bottom dock (centred
347
+ * on the window, so a sidebar or a narrow pane brings it close). Measures the full chip, then
348
+ * compares where it would end with where the dock starts.
349
+ */
350
+ function useClearOfDock(chip: { current: HTMLDivElement | null }): boolean {
351
+ const [compact, setCompact] = useState(false)
352
+ const fullWidth = useRef(0)
353
+ useLayoutEffect(() => {
354
+ const element = chip.current
355
+ if (!element) return
356
+ const check = () => {
357
+ const box = element.getBoundingClientRect()
358
+ if (!compact) fullWidth.current = box.width
359
+ const dock = document.querySelector(
360
+ '[data-surface="tool-dock"], [data-surface="viewer-dock"]',
361
+ )
362
+ const dockBox = dock?.getBoundingClientRect()
363
+ const overlaps =
364
+ !!dockBox &&
365
+ dockBox.width > 0 &&
366
+ Math.abs(dockBox.bottom - box.bottom) < 40 &&
367
+ box.left + fullWidth.current + 12 > dockBox.left
368
+ if (overlaps !== compact) setCompact(overlaps)
369
+ }
370
+ check()
371
+ const observer = new ResizeObserver(check)
372
+ observer.observe(element)
373
+ if (element.offsetParent) observer.observe(element.offsetParent)
374
+ window.addEventListener('resize', check)
375
+ return () => {
376
+ observer.disconnect()
377
+ window.removeEventListener('resize', check)
378
+ }
379
+ })
380
+ return compact
381
+ }
382
+
383
+ /** The chip's swatch: the sky's colours with the sun (or moon) where it stands on its arc. */
384
+ function MiniSky({ sky }: { sky: ReturnType<typeof useSky> }) {
385
+ const [top, horizon] = PHASE_SKY[sky.phase]
386
+ const sun = arcPoint(sky.minutes, sky.events.sunrise, sky.events.sunset)
387
+ return (
388
+ <span
389
+ aria-hidden="true"
390
+ className="relative h-8 w-8 shrink-0 overflow-hidden rounded-lg border border-black/10"
391
+ style={{ background: `linear-gradient(${top}, ${horizon} 62%, #8a8a94 63%, #6c6c7a)` }}
392
+ >
393
+ <span
394
+ className="absolute h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full"
395
+ style={{
396
+ left: `${(sun.x / W) * 100}%`,
397
+ top: `${Math.min(92, (sun.y / H) * 100)}%`,
398
+ background: sun.day ? '#fcd165' : '#d6dcf0',
399
+ boxShadow: sun.day ? '0 0 4px 1px rgba(252, 209, 101, 0.6)' : 'none',
400
+ }}
401
+ />
402
+ </span>
403
+ )
404
+ }
405
+
406
+ function SkyPanel({ sky }: { sky: ReturnType<typeof useSky> }) {
407
+ const i18n = usePascalI18n()
408
+ const [locating, setLocating] = useState(false)
409
+ const viewer = useViewer.getState
410
+ return (
411
+ <div className="flex flex-col gap-3">
412
+ <div className="flex items-start gap-2">
413
+ <div className="flex-1">
414
+ <div className="font-medium text-foreground text-sm">
415
+ {i18n._({ id: 'viewer.daylight.sky.title', message: 'Sky' })}
416
+ </div>
417
+ <div className="text-muted-foreground text-xs">
418
+ {i18n._({
419
+ id: 'viewer.daylight.sky.subtitle',
420
+ message: 'Light and weather for this view',
421
+ })}
422
+ </div>
423
+ </div>
424
+ <button
425
+ className="flex items-center gap-1 rounded-md px-1.5 py-1 text-violet-600 text-xs hover:bg-accent dark:text-violet-300"
426
+ onClick={() => {
427
+ easeTimeTo(sky.minutes, sky.presets.midday, () => viewer().setTimeOfDay('midday'))
428
+ viewer().setWeather('fair')
429
+ }}
430
+ type="button"
431
+ >
432
+ <RotateCcw className="h-3.5 w-3.5" />
433
+ {i18n._({ id: 'viewer.daylight.sky.reset', message: 'Reset' })}
434
+ </button>
435
+ <button
436
+ aria-label={i18n._({ id: 'viewer.daylight.sky.close', message: 'Close' })}
437
+ className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
438
+ onClick={() => useSkyPanel.setState({ open: false })}
439
+ type="button"
440
+ >
441
+ <X className="h-4 w-4" />
442
+ </button>
443
+ </div>
444
+
445
+ <div className="flex items-center gap-2">
446
+ <span className="flex-1 font-medium text-[11px] text-muted-foreground uppercase tracking-wide">
447
+ {i18n._({ id: 'viewer.daylight.time.title', message: 'Time of day' })}
448
+ </span>
449
+ <span className="font-mono text-foreground text-sm tabular-nums">
450
+ {formatMinutes(sky.clock)}
451
+ </span>
452
+ <span className="rounded-full bg-muted px-2 py-0.5 text-foreground text-xs">
453
+ {phaseName(i18n, sky.phase)}
454
+ </span>
455
+ </div>
456
+
457
+ <SunArc sky={sky} />
458
+
459
+ <div className="-mt-1 flex justify-between text-muted-foreground text-xs">
460
+ <span className="flex items-center gap-1">
461
+ <Sunrise className="h-3.5 w-3.5" />
462
+ {i18n._({ id: 'viewer.daylight.sky.sunrise', message: 'Sunrise' })}
463
+ <span className="font-mono text-foreground tabular-nums">
464
+ {formatMinutes(sky.events.sunrise)}
465
+ </span>
466
+ </span>
467
+ <span className="flex items-center gap-1">
468
+ {i18n._({ id: 'viewer.daylight.sky.sunset', message: 'Sunset' })}
469
+ <span className="font-mono text-foreground tabular-nums">
470
+ {formatMinutes(sky.events.sunset)}
471
+ </span>
472
+ <Sunset className="h-3.5 w-3.5" />
473
+ </span>
474
+ </div>
475
+
476
+ <div className="grid grid-cols-4 gap-2">
477
+ {PRESETS.map((preset) => (
478
+ <OptionCard
479
+ active={sky.activePreset === preset.id}
480
+ detail={formatMinutes(sky.presets[preset.id])}
481
+ icon={preset.icon}
482
+ key={preset.id}
483
+ name={presetName(i18n, preset.id)}
484
+ onSelect={() =>
485
+ easeTimeTo(sky.minutes, sky.presets[preset.id], () =>
486
+ viewer().setTimeOfDay(preset.id),
487
+ )
488
+ }
489
+ />
490
+ ))}
491
+ </div>
492
+
493
+ <div className="flex items-center gap-3 rounded-xl bg-muted/60 px-3 py-2.5">
494
+ <LocateFixed className="h-4 w-4 shrink-0 text-muted-foreground" />
495
+ <div className="flex-1">
496
+ <div className="text-foreground text-sm">
497
+ {sky.source === 'site'
498
+ ? i18n._({ id: 'viewer.daylight.sky.followClock', message: 'Follow the clock' })
499
+ : i18n._({ id: 'viewer.daylight.location.use', message: 'Use my location' })}
500
+ </div>
501
+ <div className="text-muted-foreground text-xs">
502
+ {sky.source === 'site'
503
+ ? i18n._({
504
+ id: 'viewer.daylight.sky.followClockDetail',
505
+ message: 'The sun moves with your local clock.',
506
+ })
507
+ : i18n._({
508
+ id: 'viewer.daylight.sky.liveDetail',
509
+ message: 'Follows your local clock. Sun position is approximate.',
510
+ })}
511
+ </div>
512
+ </div>
513
+ <Switch
514
+ checked={sky.skyLive}
515
+ disabled={locating}
516
+ onCheckedChange={(live) => {
517
+ stopTween()
518
+ viewer().setSkyLive(live)
519
+ if (!live || sky.source === 'site' || sky.source === 'device') return
520
+ setLocating(true)
521
+ requestDeviceLocation()
522
+ .then((location) => useViewer.getState().setDeviceLocation(location))
523
+ .catch(() => {})
524
+ .finally(() => setLocating(false))
525
+ }}
526
+ />
527
+ </div>
528
+
529
+ <div className="h-px bg-border" />
530
+
531
+ <div className="flex items-center">
532
+ <span className="flex-1 font-medium text-[11px] text-muted-foreground uppercase tracking-wide">
533
+ {i18n._({ id: 'viewer.daylight.sky.cloudCover', message: 'Cloud cover' })}
534
+ </span>
535
+ <span className="text-foreground text-sm">{weatherName(i18n, sky.weather)}</span>
536
+ </div>
537
+ <div className="grid grid-cols-4 gap-2">
538
+ {WEATHERS.map((option) => (
539
+ <OptionCard
540
+ active={sky.weather === option.id}
541
+ detail={`${Math.round(WEATHER_COVER[option.id] * 100)}%`}
542
+ icon={option.icon}
543
+ key={option.id}
544
+ name={weatherName(i18n, option.id)}
545
+ onSelect={() => viewer().setWeather(option.id)}
546
+ />
547
+ ))}
548
+ </div>
549
+ </div>
550
+ )
551
+ }
552
+
553
+ function OptionCard({
554
+ active,
555
+ detail,
556
+ icon: Icon,
557
+ name,
558
+ onSelect,
559
+ }: {
560
+ active: boolean
561
+ detail: string
562
+ icon: typeof Sun
563
+ name: string
564
+ onSelect: () => void
565
+ }) {
566
+ return (
567
+ <button
568
+ aria-pressed={active}
569
+ className={cn(
570
+ 'flex flex-col items-center gap-0.5 rounded-xl border px-1 py-2 text-center transition hover:bg-accent',
571
+ active
572
+ ? 'border-violet-500 bg-violet-500/10 text-violet-700 dark:text-violet-300'
573
+ : 'border-border text-foreground',
574
+ )}
575
+ onClick={onSelect}
576
+ type="button"
577
+ >
578
+ <Icon className={cn('mb-0.5 h-4 w-4', active ? '' : 'text-amber-600 dark:text-amber-400')} />
579
+ <span className="text-xs leading-tight">{name}</span>
580
+ <span className="font-mono text-[11px] text-muted-foreground tabular-nums">{detail}</span>
581
+ </button>
582
+ )
583
+ }
584
+
585
+ // The arc: day above the horizon from sunrise (left) to sunset (right), night dotted below.
586
+ const W = 320
587
+ const H = 150
588
+ const CX = W / 2
589
+ const CY = 100
590
+ const RX = 136
591
+ const RY_DAY = 80
592
+ const RY_NIGHT = 30
593
+ const SNAP = 5
594
+
595
+ function arcPoint(minutes: number, sunrise: number, sunset: number) {
596
+ const dayLength = sunset - sunrise
597
+ if (minutes >= sunrise && minutes <= sunset) {
598
+ const angle = Math.PI * (1 - (minutes - sunrise) / dayLength)
599
+ return { x: CX + RX * Math.cos(angle), y: CY - RY_DAY * Math.sin(angle), day: true }
600
+ }
601
+ const since = (minutes < sunrise ? minutes + 1440 : minutes) - sunset
602
+ const angle = Math.PI * (since / (1440 - dayLength))
603
+ return { x: CX + RX * Math.cos(angle), y: CY + RY_NIGHT * Math.sin(angle), day: false }
604
+ }
605
+
606
+ function arcMinutes(x: number, y: number, sunrise: number, sunset: number) {
607
+ const dayLength = sunset - sunrise
608
+ const angle = Math.atan2((CY - y) / (y < CY ? RY_DAY : RY_NIGHT), (x - CX) / RX)
609
+ const minutes =
610
+ angle >= 0
611
+ ? sunrise + (1 - angle / Math.PI) * dayLength
612
+ : sunset + (-angle / Math.PI) * (1440 - dayLength)
613
+ return (((Math.round(minutes / SNAP) * SNAP) % 1440) + 1440) % 1440
614
+ }
615
+
616
+ /** The sun's course from `from` to `to` minutes after sunrise, as an SVG path. */
617
+ function coursePath(sunrise: number, sunset: number, from: number, to: number) {
618
+ if (to - from < 1) return ''
619
+ const steps = Math.max(2, Math.ceil((to - from) / 6))
620
+ let d = ''
621
+ for (let i = 0; i <= steps; i++) {
622
+ const point = arcPoint((sunrise + from + ((to - from) * i) / steps) % 1440, sunrise, sunset)
623
+ d += `${i ? 'L' : 'M'} ${point.x.toFixed(1)} ${point.y.toFixed(1)} `
624
+ }
625
+ return d
626
+ }
627
+
628
+ function SunArc({ sky }: { sky: ReturnType<typeof useSky> }) {
629
+ const i18n = usePascalI18n()
630
+ const slider = useRef<HTMLDivElement | null>(null)
631
+ const { sunrise, sunset } = sky.events
632
+ const sun = arcPoint(sky.minutes, sunrise, sunset)
633
+ const [top, horizon] = PHASE_SKY[sky.phase]
634
+ // The day's course from sunrise: drawn solid as far as the sun, dotted for what is left.
635
+ const elapsed = (((sky.minutes - sunrise) % 1440) + 1440) % 1440
636
+ const travelled = coursePath(sunrise, sunset, 0, elapsed)
637
+ const ahead = coursePath(sunrise, sunset, elapsed, 1440)
638
+
639
+ const setFromPointer = (event: PointerEvent<HTMLDivElement>) => {
640
+ const rect = slider.current?.getBoundingClientRect()
641
+ if (!rect) return
642
+ const x = ((event.clientX - rect.left) / rect.width) * W
643
+ const y = ((event.clientY - rect.top) / rect.height) * H
644
+ stopTween()
645
+ useViewer.getState().setTimeOfDayMinutes(arcMinutes(x, y, sunrise, sunset))
646
+ }
647
+
648
+ return (
649
+ <div
650
+ aria-label={i18n._({
651
+ id: 'viewer.daylight.sky.arc',
652
+ message: 'Drag the sun to set the time',
653
+ })}
654
+ aria-valuemax={1440}
655
+ aria-valuemin={0}
656
+ aria-valuenow={Math.round(sky.minutes)}
657
+ aria-valuetext={formatMinutes(sky.clock)}
658
+ className="cursor-grab touch-none select-none overflow-hidden rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
659
+ onKeyDown={(event) => {
660
+ const step = event.key === 'ArrowRight' ? 15 : event.key === 'ArrowLeft' ? -15 : 0
661
+ if (!step) return
662
+ event.preventDefault()
663
+ stopTween()
664
+ useViewer.getState().setTimeOfDayMinutes((sky.minutes + step + 1440) % 1440)
665
+ }}
666
+ onPointerDown={(event) => {
667
+ event.currentTarget.setPointerCapture(event.pointerId)
668
+ setFromPointer(event)
669
+ }}
670
+ onPointerMove={(event) => {
671
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) setFromPointer(event)
672
+ }}
673
+ ref={slider}
674
+ role="slider"
675
+ tabIndex={0}
676
+ >
677
+ <svg aria-hidden="true" className="block w-full" viewBox={`0 0 ${W} ${H}`}>
678
+ <defs>
679
+ <linearGradient id="sky-arc-backdrop" x1="0" x2="0" y1="0" y2="1">
680
+ <stop offset="0" stopColor={top} />
681
+ <stop offset={CY / H} stopColor={horizon} />
682
+ <stop offset={CY / H} stopColor="#9a9aa6" />
683
+ <stop offset="1" stopColor="#6c6c7a" />
684
+ </linearGradient>
685
+ </defs>
686
+ <rect fill="url(#sky-arc-backdrop)" height={H} width={W} />
687
+ <line stroke="rgba(0,0,0,0.25)" x1="0" x2={W} y1={CY} y2={CY} />
688
+ <path d={travelled} fill="none" stroke="#252a44" strokeLinecap="round" strokeWidth="2" />
689
+ <path
690
+ d={ahead}
691
+ fill="none"
692
+ stroke="#252a44"
693
+ strokeDasharray="2 5"
694
+ strokeLinecap="round"
695
+ strokeOpacity="0.7"
696
+ strokeWidth="1.5"
697
+ />
698
+ {sun.day ? <circle cx={sun.x} cy={sun.y} fill="#fcd165" opacity="0.3" r="15" /> : null}
699
+ <circle
700
+ cx={sun.x}
701
+ cy={sun.y}
702
+ fill={sun.day ? '#fcd165' : '#d6dcf0'}
703
+ r="9"
704
+ stroke="#ffffff"
705
+ strokeWidth="2.5"
706
+ />
707
+ </svg>
708
+ </div>
709
+ )
710
+ }