@pascal-app/editor 1.0.0-beta.5 → 1.0.1

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 (297) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +5 -0
  6. package/src/components/editor/bake-thumbnail.tsx +4 -1
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +9 -2
  13. package/src/components/editor/export-manager.tsx +175 -33
  14. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  15. package/src/components/editor/first-person-controls.tsx +180 -12
  16. package/src/components/editor/floating-action-menu.tsx +55 -51
  17. package/src/components/editor/floorplan-panel.tsx +99 -15
  18. package/src/components/editor/grid.tsx +12 -7
  19. package/src/components/editor/group-actions.ts +34 -50
  20. package/src/components/editor/group-move-3d.ts +51 -21
  21. package/src/components/editor/group-transform-shared.test.ts +78 -0
  22. package/src/components/editor/group-transform-shared.ts +74 -7
  23. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  24. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  25. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  26. package/src/components/editor/handles/handle-placement.ts +69 -0
  27. package/src/components/editor/handles/linear-resize-drag.ts +78 -0
  28. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  29. package/src/components/editor/handles/resize-snap.ts +10 -1
  30. package/src/components/editor/handles/use-handle-drag.ts +23 -3
  31. package/src/components/editor/index.tsx +151 -24
  32. package/src/components/editor/node-action-menu.tsx +14 -1
  33. package/src/components/editor/node-arrow-handles.tsx +67 -119
  34. package/src/components/editor/selection-manager.tsx +153 -42
  35. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  36. package/src/components/editor/snapshot-capture.test.ts +422 -0
  37. package/src/components/editor/snapshot-capture.ts +145 -0
  38. package/src/components/editor/thumbnail-generator.tsx +295 -239
  39. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  40. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  41. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  42. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  43. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  44. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  45. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  46. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  47. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  48. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  49. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  50. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +226 -35
  51. package/src/components/procedural-items/inspector.tsx +223 -0
  52. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  53. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  54. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  55. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  56. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  57. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  58. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  59. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  60. package/src/components/systems/selection-affordance-services.ts +19 -0
  61. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  62. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  63. package/src/components/tools/fence/fence-drafting.ts +6 -25
  64. package/src/components/tools/item/draft-creation.test.ts +12 -0
  65. package/src/components/tools/item/draft-creation.ts +10 -0
  66. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  67. package/src/components/tools/item/face-host-commit.ts +56 -0
  68. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  69. package/src/components/tools/item/face-host-preview.ts +82 -0
  70. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  71. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  72. package/src/components/tools/item/move-tool.tsx +4 -3
  73. package/src/components/tools/item/placement-math.test.ts +11 -1
  74. package/src/components/tools/item/placement-math.ts +0 -20
  75. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  76. package/src/components/tools/item/placement-strategies.ts +304 -171
  77. package/src/components/tools/item/placement-surface.test.ts +61 -0
  78. package/src/components/tools/item/placement-surface.ts +24 -0
  79. package/src/components/tools/item/placement-types.ts +10 -0
  80. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  81. package/src/components/tools/item/test-face-host.ts +121 -0
  82. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  83. package/src/components/tools/item/use-draft-node.ts +116 -44
  84. package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
  85. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  86. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  87. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  88. package/src/components/tools/registry/item-surface-move.ts +441 -0
  89. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  90. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  91. package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
  92. package/src/components/tools/registry-tool-context.tsx +30 -0
  93. package/src/components/tools/select/box-select-tool.tsx +3 -1
  94. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  95. package/src/components/tools/select/marquee-footprint.ts +29 -0
  96. package/src/components/tools/shared/placement-box.tsx +57 -7
  97. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  98. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  99. package/src/components/tools/shared/pointer-support-cap.ts +56 -17
  100. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  101. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  102. package/src/components/tools/shared/surface-hit.ts +23 -0
  103. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  104. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  105. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  106. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  107. package/src/components/tools/stair/stair-tool.tsx +127 -74
  108. package/src/components/tools/tool-manager.tsx +25 -8
  109. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  110. package/src/components/tools/wall/wall-drafting.ts +23 -114
  111. package/src/components/tools/zone/zone-tool.tsx +13 -4
  112. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  113. package/src/components/ui/action-menu/index.tsx +11 -1
  114. package/src/components/ui/command-palette/editor-commands.tsx +63 -13
  115. package/src/components/ui/command-palette/index.tsx +7 -2
  116. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  117. package/src/components/ui/controls/material-picker.tsx +4 -4
  118. package/src/components/ui/controls/metric-control.tsx +29 -13
  119. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  120. package/src/components/ui/controls/segmented-control.tsx +8 -1
  121. package/src/components/ui/controls/slider-control.tsx +44 -21
  122. package/src/components/ui/controls/toggle-control.tsx +18 -5
  123. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  124. package/src/components/ui/floating-level-selector.tsx +103 -10
  125. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  126. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  127. package/src/components/ui/helpers/item-helper.tsx +1 -1
  128. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  129. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  130. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  131. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  132. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  133. package/src/components/ui/panels/multi-field-value.ts +163 -0
  134. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  135. package/src/components/ui/panels/multi-parametric-inspector.tsx +266 -0
  136. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  137. package/src/components/ui/panels/node-display.ts +4 -0
  138. package/src/components/ui/panels/panel-manager.tsx +77 -3
  139. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  140. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  141. package/src/components/ui/panels/parametric-inspector.tsx +35 -164
  142. package/src/components/ui/panels/reference-panel.tsx +34 -4
  143. package/src/components/ui/primitives/checkbox.tsx +34 -0
  144. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  145. package/src/components/ui/scene-loader.tsx +35 -0
  146. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +502 -31
  147. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  148. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
  149. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
  150. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  151. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  152. package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
  153. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  154. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  155. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  156. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  157. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  158. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -7
  159. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  160. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  161. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  162. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  163. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  164. package/src/components/ui/snap-target-badge.tsx +2 -0
  165. package/src/components/viewer/viewer-scene-header.tsx +5 -1
  166. package/src/components/viewer/viewer-stage.tsx +4 -1
  167. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  168. package/src/components/viewer-overlay.tsx +1 -22
  169. package/src/components/viewer-zone-system.tsx +6 -2
  170. package/src/components/walkthrough-hud.tsx +19 -10
  171. package/src/hooks/use-auto-save.test.ts +73 -1
  172. package/src/hooks/use-auto-save.ts +76 -5
  173. package/src/hooks/use-ceiling-events.test.ts +144 -0
  174. package/src/hooks/use-ceiling-events.ts +5 -1
  175. package/src/hooks/use-drag-action.ts +17 -3
  176. package/src/hooks/use-grid-events.test.ts +240 -0
  177. package/src/hooks/use-grid-events.ts +190 -11
  178. package/src/hooks/use-keyboard.test.ts +244 -0
  179. package/src/hooks/use-keyboard.ts +132 -63
  180. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  181. package/src/hooks/use-save-shortcut.ts +33 -0
  182. package/src/index.tsx +87 -4
  183. package/src/lib/active-placement-surface.test.ts +16 -0
  184. package/src/lib/active-placement-surface.ts +14 -0
  185. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  186. package/src/lib/ceiling-surface-raycast.ts +12 -0
  187. package/src/lib/contextual-help-extension.ts +17 -0
  188. package/src/lib/continuation.test.ts +11 -0
  189. package/src/lib/continuation.ts +8 -1
  190. package/src/lib/direct-manipulation.test.ts +132 -0
  191. package/src/lib/direct-manipulation.ts +38 -1
  192. package/src/lib/editor-api.ts +6 -3
  193. package/src/lib/elevation-guides.test.ts +28 -1
  194. package/src/lib/elevation-guides.ts +22 -7
  195. package/src/lib/export-texture-utils.test.ts +167 -0
  196. package/src/lib/export-texture-utils.ts +121 -0
  197. package/src/lib/export-user-data.test.ts +48 -0
  198. package/src/lib/export-user-data.ts +69 -0
  199. package/src/lib/floating-menu-scale.test.ts +29 -0
  200. package/src/lib/floating-menu-scale.ts +14 -0
  201. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  202. package/src/lib/floorplan/apply-alignment.ts +10 -1
  203. package/src/lib/floorplan/floorplan-export.test.ts +536 -2
  204. package/src/lib/floorplan/floorplan-export.tsx +120 -19
  205. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  206. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  207. package/src/lib/floorplan/floorplan-readonly.ts +5 -0
  208. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  209. package/src/lib/floorplan-grid-event-point.ts +13 -0
  210. package/src/lib/fresh-planar-placement.ts +13 -5
  211. package/src/lib/glb-export-serialize.test.ts +280 -0
  212. package/src/lib/glb-export.test.ts +1375 -21
  213. package/src/lib/glb-export.ts +781 -106
  214. package/src/lib/grid-event-presentation.ts +16 -0
  215. package/src/lib/history.test.ts +528 -147
  216. package/src/lib/history.ts +81 -7
  217. package/src/lib/host-tree-children.test.ts +27 -0
  218. package/src/lib/host-tree-children.ts +79 -0
  219. package/src/lib/interaction/hot-set.test.ts +10 -1
  220. package/src/lib/interaction/hot-set.ts +3 -1
  221. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  222. package/src/lib/interaction/overlay-policy.ts +16 -0
  223. package/src/lib/interaction/registered-drafting.ts +36 -0
  224. package/src/lib/interaction/scope.ts +31 -0
  225. package/src/lib/level-duplication.test.ts +73 -0
  226. package/src/lib/level-duplication.ts +32 -2
  227. package/src/lib/level-print-export.test.ts +484 -0
  228. package/src/lib/level-print-export.ts +526 -0
  229. package/src/lib/linear-display.test.ts +59 -0
  230. package/src/lib/linear-display.ts +39 -0
  231. package/src/lib/local-guide-image.test.ts +24 -0
  232. package/src/lib/local-guide-image.ts +34 -1
  233. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  234. package/src/lib/local-project-presentation-persistence.ts +335 -0
  235. package/src/lib/material-paint.ts +2 -0
  236. package/src/lib/model-export.ts +29 -0
  237. package/src/lib/node-action-movement.ts +21 -0
  238. package/src/lib/paint-preview-owner.ts +71 -0
  239. package/src/lib/planar-cursor-placement.test.ts +178 -1
  240. package/src/lib/planar-cursor-placement.ts +61 -5
  241. package/src/lib/plugin-panels.test.ts +92 -1
  242. package/src/lib/plugin-panels.ts +48 -0
  243. package/src/lib/portable-export.test.ts +455 -0
  244. package/src/lib/portable-export.ts +1042 -0
  245. package/src/lib/print-3mf.test.ts +86 -0
  246. package/src/lib/print-3mf.ts +161 -0
  247. package/src/lib/print-content-scope.test.ts +107 -0
  248. package/src/lib/print-content-scope.ts +90 -0
  249. package/src/lib/print-export.test.ts +294 -0
  250. package/src/lib/print-export.ts +735 -0
  251. package/src/lib/print-feature-thickness.test.ts +147 -0
  252. package/src/lib/print-feature-thickness.ts +180 -0
  253. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  254. package/src/lib/print-golden-house.test.ts +249 -0
  255. package/src/lib/print-roof-solids.test.ts +133 -0
  256. package/src/lib/print-roof-solids.ts +630 -0
  257. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  258. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  259. package/src/lib/print-shell-compiler-manifold-core.ts +379 -0
  260. package/src/lib/print-shell-compiler-manifold-worker.ts +200 -0
  261. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  262. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  263. package/src/lib/print-shell-compiler-protocol.ts +38 -0
  264. package/src/lib/print-shell-compiler.ts +273 -0
  265. package/src/lib/print-wall-solids.test.ts +194 -0
  266. package/src/lib/print-wall-solids.ts +328 -0
  267. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  268. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  269. package/src/lib/scene-clipboard.ts +1 -1
  270. package/src/lib/scene.ts +22 -8
  271. package/src/lib/selection-routing.test.ts +105 -1
  272. package/src/lib/selection-routing.ts +19 -0
  273. package/src/lib/send-to-app.test.ts +108 -0
  274. package/src/lib/send-to-app.ts +121 -0
  275. package/src/lib/sfx/movement-tick.ts +37 -0
  276. package/src/lib/sfx-bus.ts +4 -0
  277. package/src/lib/sfx-player.ts +8 -0
  278. package/src/lib/snapping-mode.test.ts +32 -4
  279. package/src/lib/snapping-mode.ts +11 -7
  280. package/src/lib/surface-attachment.ts +77 -0
  281. package/src/lib/units.ts +254 -0
  282. package/src/lib/usdz-export.ts +65 -0
  283. package/src/lib/use-linear-display.ts +8 -32
  284. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  285. package/src/store/live-draft-preview-stores.test.ts +3 -0
  286. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  287. package/src/store/tool-mode.test.ts +87 -0
  288. package/src/store/use-camera-hint-focus.ts +48 -0
  289. package/src/store/use-editor.tsx +299 -188
  290. package/src/store/use-floorplan-draft-preview.ts +9 -0
  291. package/src/store/use-handle-group.ts +4 -0
  292. package/src/store/use-interaction-scope.test.ts +23 -1
  293. package/src/store/use-interaction-scope.ts +63 -2
  294. package/src/store/use-placement-preview.ts +56 -3
  295. package/src/store/use-send-to-app.ts +21 -0
  296. package/src/components/tools/roof/roof-tool.tsx +0 -656
  297. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -0,0 +1,47 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ resolveSelectionProxyId,
5
+ } from '@pascal-app/core'
6
+
7
+ /**
8
+ * Resolve selection proxies and drop duplicates / missing ids. Session groups
9
+ * are just selections — mixed-type groups stay mixed after this pass, and a
10
+ * pair of children that both proxy to the same parent collapse to one id.
11
+ */
12
+ export function resolveUniqueSelectionIds(
13
+ ids: readonly string[],
14
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
15
+ ): AnyNodeId[] {
16
+ const resolved: AnyNodeId[] = []
17
+ const seen = new Set<string>()
18
+ for (const id of ids) {
19
+ const node = nodes[id]
20
+ if (!node) continue
21
+ const resolvedId = resolveSelectionProxyId(node, nodes)
22
+ if (seen.has(resolvedId)) continue
23
+ seen.add(resolvedId)
24
+ resolved.push(resolvedId)
25
+ }
26
+ return resolved
27
+ }
28
+
29
+ /**
30
+ * Shared type when every resolved id is the same kind and at least two nodes
31
+ * remain. Otherwise null — including mixed session groups and proxy-collapsed
32
+ * selections that shrink below two distinct nodes.
33
+ */
34
+ export function resolveHomogeneousSelection(
35
+ ids: readonly string[],
36
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
37
+ ): AnyNode['type'] | null {
38
+ const resolvedIds = resolveUniqueSelectionIds(ids, nodes)
39
+ if (resolvedIds.length < 2) return null
40
+ const first = nodes[resolvedIds[0] ?? '']
41
+ if (!first) return null
42
+ for (let i = 1; i < resolvedIds.length; i++) {
43
+ const node = nodes[resolvedIds[i] ?? '']
44
+ if (!node || node.type !== first.type) return null
45
+ }
46
+ return first.type
47
+ }
@@ -8,7 +8,9 @@ import { cn } from '../../../lib/utils'
8
8
  import { getNodeDisplay } from './node-display'
9
9
 
10
10
  interface MobileSelectionBarProps {
11
- node: AnyNode
11
+ node: AnyNode | null
12
+ label?: string
13
+ icon?: string
12
14
  onMove: () => void
13
15
  onDuplicate: () => void
14
16
  onDelete: () => void
@@ -20,19 +22,23 @@ const ACTION_BTN =
20
22
 
21
23
  export function MobileSelectionBar({
22
24
  node,
25
+ label,
26
+ icon,
23
27
  onMove,
24
28
  onDuplicate,
25
29
  onDelete,
26
30
  onEdit,
27
31
  }: MobileSelectionBarProps) {
28
- const { icon, label } = getNodeDisplay(node)
32
+ const display = getNodeDisplay(node)
33
+ const resolvedLabel = label ?? display.label
34
+ const resolvedIcon = icon ?? display.icon
29
35
 
30
36
  const stop: MouseEventHandler<HTMLButtonElement> = (e) => e.stopPropagation()
31
37
 
32
38
  return (
33
39
  <div className="pointer-events-auto absolute right-3 bottom-6 left-3 z-50 flex h-12 items-stretch gap-1 rounded-2xl border border-border/50 bg-background/95 px-2 shadow-2xl backdrop-blur-xl">
34
40
  <button
35
- aria-label={`Edit ${label}`}
41
+ aria-label={`Edit ${resolvedLabel}`}
36
42
  className={cn(
37
43
  'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left transition-colors hover:bg-white/8',
38
44
  )}
@@ -43,10 +49,10 @@ export function MobileSelectionBar({
43
49
  alt=""
44
50
  className="shrink-0 rounded object-contain"
45
51
  height={20}
46
- src={icon}
52
+ src={resolvedIcon}
47
53
  width={20}
48
54
  />
49
- <span className="truncate font-medium text-foreground text-sm">{label}</span>
55
+ <span className="truncate font-medium text-foreground text-sm">{resolvedLabel}</span>
50
56
  </button>
51
57
 
52
58
  <div className="flex items-center gap-0.5 border-border/40 border-l pl-1">
@@ -0,0 +1,209 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BuildingNode,
6
+ clearSceneHistory,
7
+ LevelNode,
8
+ useLiveNodeOverrides,
9
+ useScene,
10
+ WallNode,
11
+ } from '@pascal-app/core'
12
+ import {
13
+ buildMultiNodePatches,
14
+ commitMultiNodeFields,
15
+ fieldVisibleForAll,
16
+ reduceFieldValue,
17
+ reduceHeightBoundMode,
18
+ } from './multi-field-value'
19
+
20
+ type RafFn = (cb: (time: number) => void) => number
21
+ ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => {
22
+ cb(0)
23
+ return 0
24
+ }
25
+ ;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
26
+ () => {}
27
+
28
+ const BUILDING_ID = 'building_multi_edit' as AnyNodeId
29
+ const LEVEL_ID = 'level_multi_edit' as AnyNodeId
30
+ const WALL_A = 'wall_multi_a' as AnyNodeId
31
+ const WALL_B = 'wall_multi_b' as AnyNodeId
32
+ const WALL_C = 'wall_multi_c' as AnyNodeId
33
+
34
+ function makeNode(id: string, type: string, fields: Record<string, unknown> = {}): AnyNode {
35
+ return {
36
+ object: 'node',
37
+ id: id as AnyNodeId,
38
+ type,
39
+ parentId: null,
40
+ visible: true,
41
+ metadata: {},
42
+ children: [],
43
+ ...fields,
44
+ } as unknown as AnyNode
45
+ }
46
+
47
+ describe('reduceFieldValue', () => {
48
+ test('same values collapse to one', () => {
49
+ const nodes = {
50
+ a: makeNode('a', 'wall', { height: 2.4, thickness: 0.2 }),
51
+ b: makeNode('b', 'wall', { height: 2.4, thickness: 0.15 }),
52
+ }
53
+ expect(reduceFieldValue(['a', 'b'], 'height', nodes)).toEqual({ kind: 'same', value: 2.4 })
54
+ expect(reduceFieldValue(['a', 'b'], 'thickness', nodes)).toEqual({ kind: 'mixed' })
55
+ })
56
+
57
+ test('vec3 compares elementwise', () => {
58
+ const nodes = {
59
+ a: makeNode('a', 'item', { position: [1, 0, 2] }),
60
+ b: makeNode('b', 'item', { position: [1, 0, 2] }),
61
+ c: makeNode('c', 'item', { position: [1, 0, 3] }),
62
+ }
63
+ expect(reduceFieldValue(['a', 'b'], 'position', nodes)).toEqual({
64
+ kind: 'same',
65
+ value: [1, 0, 2],
66
+ })
67
+ expect(reduceFieldValue(['a', 'c'], 'position', nodes)).toEqual({ kind: 'mixed' })
68
+ })
69
+ })
70
+
71
+ describe('reduceHeightBoundMode', () => {
72
+ test('absent height is follows-level, present height is custom', () => {
73
+ const nodes = {
74
+ a: makeNode('a', 'wall'),
75
+ b: makeNode('b', 'wall'),
76
+ c: makeNode('c', 'wall', { height: 3 }),
77
+ }
78
+ expect(reduceHeightBoundMode(['a', 'b'], nodes)).toEqual({ kind: 'same', value: 'storey' })
79
+ expect(reduceHeightBoundMode(['c'], nodes)).toEqual({ kind: 'same', value: 'custom' })
80
+ expect(reduceHeightBoundMode(['a', 'c'], nodes)).toEqual({ kind: 'mixed' })
81
+ })
82
+ })
83
+
84
+ describe('fieldVisibleForAll', () => {
85
+ test('hides when any node would hide the field', () => {
86
+ const nodes = {
87
+ a: makeNode('a', 'slab', { recessed: false }),
88
+ b: makeNode('b', 'slab', { recessed: true }),
89
+ }
90
+ const visibleIf = (n: AnyNode) => !(n as { recessed?: boolean }).recessed
91
+ expect(fieldVisibleForAll(['a'], visibleIf, nodes)).toBe(true)
92
+ expect(fieldVisibleForAll(['a', 'b'], visibleIf, nodes)).toBe(false)
93
+ })
94
+ })
95
+
96
+ describe('buildMultiNodePatches', () => {
97
+ test('a mixed field that is never edited produces no patch for it', () => {
98
+ const nodes = {
99
+ a: makeNode('a', 'wall', { height: 2.4, thickness: 0.2 }),
100
+ b: makeNode('b', 'wall', { height: 3, thickness: 0.15 }),
101
+ }
102
+ const patches = buildMultiNodePatches(
103
+ ['a' as AnyNodeId, 'b' as AnyNodeId],
104
+ () => ({ thickness: 0.3 }),
105
+ nodes,
106
+ )
107
+ expect(patches).toEqual([
108
+ { id: 'a' as AnyNodeId, data: { thickness: 0.3 } },
109
+ { id: 'b' as AnyNodeId, data: { thickness: 0.3 } },
110
+ ])
111
+ for (const patch of patches) {
112
+ expect(patch.data).not.toHaveProperty('height')
113
+ }
114
+ })
115
+
116
+ test('derive and reconcile fan out per node into one batch', () => {
117
+ const nodes = {
118
+ a: makeNode('a', 'wall', { height: 2 }),
119
+ b: makeNode('b', 'wall', { height: 2 }),
120
+ }
121
+ const patches = buildMultiNodePatches(
122
+ ['a' as AnyNodeId, 'b' as AnyNodeId],
123
+ () => ({ height: 4 }),
124
+ nodes,
125
+ {
126
+ derive: (next) => ({ thickness: (next as { height: number }).height / 10 }),
127
+ reconcile: (prev) => [
128
+ { id: `${prev.id}_follow` as AnyNodeId, data: { height: 4 } },
129
+ ],
130
+ },
131
+ )
132
+ expect(patches).toEqual([
133
+ { id: 'a' as AnyNodeId, data: { height: 4, thickness: 0.4 } },
134
+ { id: 'b' as AnyNodeId, data: { height: 4, thickness: 0.4 } },
135
+ { id: 'a_follow' as AnyNodeId, data: { height: 4 } },
136
+ { id: 'b_follow' as AnyNodeId, data: { height: 4 } },
137
+ ])
138
+ })
139
+ })
140
+
141
+ describe('commitMultiNodeFields', () => {
142
+ beforeEach(() => {
143
+ const wallA = WallNode.parse({
144
+ id: WALL_A,
145
+ parentId: LEVEL_ID,
146
+ start: [0, 0],
147
+ end: [4, 0],
148
+ height: 2.4,
149
+ })
150
+ const wallB = WallNode.parse({
151
+ id: WALL_B,
152
+ parentId: LEVEL_ID,
153
+ start: [4, 0],
154
+ end: [4, 3],
155
+ height: 3,
156
+ })
157
+ const wallC = WallNode.parse({
158
+ id: WALL_C,
159
+ parentId: LEVEL_ID,
160
+ start: [4, 3],
161
+ end: [0, 3],
162
+ height: 2.7,
163
+ })
164
+ const level = LevelNode.parse({
165
+ id: LEVEL_ID,
166
+ parentId: BUILDING_ID,
167
+ children: [WALL_A, WALL_B, WALL_C],
168
+ level: 0,
169
+ })
170
+ const building = BuildingNode.parse({
171
+ id: BUILDING_ID,
172
+ parentId: null,
173
+ children: [LEVEL_ID],
174
+ })
175
+ useScene.setState({
176
+ nodes: {
177
+ [BUILDING_ID]: building,
178
+ [LEVEL_ID]: level,
179
+ [WALL_A]: wallA,
180
+ [WALL_B]: wallB,
181
+ [WALL_C]: wallC,
182
+ },
183
+ rootNodeIds: [BUILDING_ID],
184
+ dirtyNodes: new Set<AnyNodeId>(),
185
+ collections: {},
186
+ materials: {},
187
+ readOnly: false,
188
+ } as never)
189
+ clearSceneHistory()
190
+ useLiveNodeOverrides.getState().clearAll()
191
+ })
192
+
193
+ afterEach(() => {
194
+ useLiveNodeOverrides.getState().clearAll()
195
+ })
196
+
197
+ test('dragging height onto three walls is one undo step', () => {
198
+ commitMultiNodeFields([WALL_A, WALL_B, WALL_C], () => ({ height: 4 }))
199
+ expect((useScene.getState().nodes[WALL_A] as { height?: number }).height).toBe(4)
200
+ expect((useScene.getState().nodes[WALL_B] as { height?: number }).height).toBe(4)
201
+ expect((useScene.getState().nodes[WALL_C] as { height?: number }).height).toBe(4)
202
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
203
+
204
+ useScene.temporal.getState().undo()
205
+ expect((useScene.getState().nodes[WALL_A] as { height?: number }).height).toBe(2.4)
206
+ expect((useScene.getState().nodes[WALL_B] as { height?: number }).height).toBe(3)
207
+ expect((useScene.getState().nodes[WALL_C] as { height?: number }).height).toBe(2.7)
208
+ })
209
+ })
@@ -0,0 +1,163 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type ParametricDescriptor,
5
+ useLiveNodeOverrides,
6
+ useScene,
7
+ } from '@pascal-app/core'
8
+
9
+ export type ReducedFieldValue<T = unknown> =
10
+ | { kind: 'same'; value: T }
11
+ | { kind: 'mixed' }
12
+
13
+ const MIXED: { kind: 'mixed' } = { kind: 'mixed' }
14
+
15
+ function fieldValuesEqual(a: unknown, b: unknown): boolean {
16
+ if (Object.is(a, b)) return true
17
+ if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
18
+ return a.every((value, i) => Object.is(value, b[i]))
19
+ }
20
+ return false
21
+ }
22
+
23
+ export function reduceFieldValue(
24
+ nodeIds: readonly string[],
25
+ key: string,
26
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
27
+ ): ReducedFieldValue {
28
+ let seen = false
29
+ let shared: unknown
30
+ for (const id of nodeIds) {
31
+ const node = nodes[id]
32
+ if (!node) continue
33
+ const value = (node as Record<string, unknown>)[key]
34
+ if (!seen) {
35
+ seen = true
36
+ shared = value
37
+ continue
38
+ }
39
+ if (!fieldValuesEqual(shared, value)) return MIXED
40
+ }
41
+ if (!seen) return MIXED
42
+ return { kind: 'same', value: shared }
43
+ }
44
+
45
+ export type HeightBoundMode = 'storey' | 'custom'
46
+
47
+ export function reduceHeightBoundMode(
48
+ nodeIds: readonly string[],
49
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
50
+ ): ReducedFieldValue<HeightBoundMode> {
51
+ let seen = false
52
+ let shared: HeightBoundMode | undefined
53
+ for (const id of nodeIds) {
54
+ const node = nodes[id]
55
+ if (!node) continue
56
+ const mode: HeightBoundMode = (node as { height?: number }).height == null ? 'storey' : 'custom'
57
+ if (!seen) {
58
+ seen = true
59
+ shared = mode
60
+ continue
61
+ }
62
+ if (mode !== shared) return MIXED
63
+ }
64
+ if (!seen || !shared) return MIXED
65
+ return { kind: 'same', value: shared }
66
+ }
67
+
68
+ export function fieldVisibleForAll(
69
+ nodeIds: readonly string[],
70
+ visibleIf: ((node: AnyNode) => boolean) | undefined,
71
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
72
+ ): boolean {
73
+ if (!visibleIf) return true
74
+ for (const id of nodeIds) {
75
+ const node = nodes[id]
76
+ if (!node || !visibleIf(node)) return false
77
+ }
78
+ return true
79
+ }
80
+
81
+ export function firstNumericFieldValue(
82
+ nodeIds: readonly string[],
83
+ key: string,
84
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
85
+ fallback = 0,
86
+ ): number {
87
+ for (const id of nodeIds) {
88
+ const node = nodes[id]
89
+ const value = node ? (node as Record<string, unknown>)[key] : undefined
90
+ if (typeof value === 'number' && Number.isFinite(value)) return value
91
+ }
92
+ return fallback
93
+ }
94
+
95
+ export function firstVec3FieldValue(
96
+ nodeIds: readonly string[],
97
+ key: string,
98
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
99
+ ): [number, number, number] {
100
+ for (const id of nodeIds) {
101
+ const node = nodes[id]
102
+ const value = node ? (node as Record<string, unknown>)[key] : undefined
103
+ if (Array.isArray(value) && value.length >= 3) {
104
+ return [Number(value[0]) || 0, Number(value[1]) || 0, Number(value[2]) || 0]
105
+ }
106
+ }
107
+ return [0, 0, 0]
108
+ }
109
+
110
+ export function buildMultiNodePatches(
111
+ nodeIds: readonly AnyNodeId[],
112
+ patchFor: (node: AnyNode) => Partial<AnyNode>,
113
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
114
+ parametrics?: Pick<ParametricDescriptor<AnyNode>, 'derive' | 'reconcile'>,
115
+ ): Array<{ id: AnyNodeId; data: Partial<AnyNode> }> {
116
+ const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
117
+ const followUps: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
118
+ for (const id of nodeIds) {
119
+ const node = nodes[id]
120
+ if (!node) continue
121
+ let patch = patchFor(node)
122
+ if (Object.keys(patch).length === 0) continue
123
+ if (parametrics?.derive) {
124
+ const next = { ...node, ...patch } as AnyNode
125
+ patch = { ...patch, ...parametrics.derive(next, patch, node) } as Partial<AnyNode>
126
+ }
127
+ updates.push({ id, data: patch })
128
+ if (parametrics?.reconcile) {
129
+ const next = { ...node, ...patch } as AnyNode
130
+ followUps.push(...parametrics.reconcile(node, next))
131
+ }
132
+ }
133
+ return [...updates, ...followUps]
134
+ }
135
+
136
+ export function previewMultiNodeFields(
137
+ entries: ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>,
138
+ ): void {
139
+ if (entries.length === 0) return
140
+ useLiveNodeOverrides.getState().setMany(entries)
141
+ const scene = useScene.getState()
142
+ for (const [id] of entries) scene.markDirty(id)
143
+ }
144
+
145
+ export function commitMultiNodeFields(
146
+ nodeIds: readonly AnyNodeId[],
147
+ patchFor: (node: AnyNode) => Partial<AnyNode>,
148
+ parametrics?: Pick<ParametricDescriptor<AnyNode>, 'derive' | 'reconcile'>,
149
+ ): void {
150
+ const scene = useScene.getState()
151
+ const patches = buildMultiNodePatches(nodeIds, patchFor, scene.nodes, parametrics)
152
+ const keys = new Set<string>()
153
+ for (const patch of patches) {
154
+ for (const key of Object.keys(patch.data)) keys.add(key)
155
+ }
156
+ const live = useLiveNodeOverrides.getState()
157
+ const keyList = [...keys]
158
+ for (const id of nodeIds) {
159
+ if (keyList.length > 0) live.clearFields(id, keyList)
160
+ scene.markDirty(id)
161
+ }
162
+ if (patches.length > 0) scene.updateNodes(patches)
163
+ }
@@ -0,0 +1,185 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ type CeilingNode,
7
+ type ParametricDescriptor,
8
+ GROUND_SUPPORT_ID,
9
+ getCeilingClampBound,
10
+ getWallEffectiveHeightForNodes,
11
+ resolveCeilingHeight,
12
+ terrainSupportLift,
13
+ useScene,
14
+ type WallNode,
15
+ } from '@pascal-app/core'
16
+ import { useViewer } from '@pascal-app/viewer'
17
+ import { useCallback } from 'react'
18
+ import { useShallow } from 'zustand/react/shallow'
19
+ import { formatLinearMeasurement } from '../../../lib/measurements'
20
+ import { SegmentedControl } from '../controls/segmented-control'
21
+ import { SliderControl } from '../controls/slider-control'
22
+ import {
23
+ commitMultiNodeFields,
24
+ firstNumericFieldValue,
25
+ previewMultiNodeFields,
26
+ reduceFieldValue,
27
+ reduceHeightBoundMode,
28
+ } from './multi-field-value'
29
+ import { precisionForStep } from './parametric-field-utils'
30
+
31
+ function wallFollowsLevelPatch(wall: WallNode, nodes: Record<string, AnyNode>): Partial<WallNode> {
32
+ const terrainSupported =
33
+ wall.parentId != null &&
34
+ terrainSupportLift(nodes, wall.parentId, wall.start[0], wall.start[1]) != null
35
+ return {
36
+ height: undefined,
37
+ supportOffset: undefined,
38
+ ...(wall.supportSlabId === GROUND_SUPPORT_ID && !terrainSupported
39
+ ? { supportSlabId: undefined }
40
+ : {}),
41
+ }
42
+ }
43
+
44
+ function effectiveHeight(node: AnyNode, nodes: Record<string, AnyNode>): number {
45
+ if (node.type === 'wall') return getWallEffectiveHeightForNodes(node, nodes)
46
+ if (node.type === 'ceiling') return resolveCeilingHeight(node, nodes)
47
+ const height = (node as { height?: number }).height
48
+ return typeof height === 'number' ? height : 0
49
+ }
50
+
51
+ function ceilingCustomHeight(node: CeilingNode, nodes: Record<string, AnyNode>): number {
52
+ const resolved = resolveCeilingHeight(node, nodes)
53
+ const parent = node.parentId ? nodes[node.parentId] : undefined
54
+ const max =
55
+ parent?.type === 'level'
56
+ ? getCeilingClampBound(parent.id, nodes as Record<AnyNodeId, AnyNode>, node.polygon ?? [])
57
+ : Number.POSITIVE_INFINITY
58
+ return Math.min(resolved, max)
59
+ }
60
+
61
+ export function MultiHeightModeField({
62
+ nodeIds,
63
+ nodeType,
64
+ parametrics,
65
+ min = 1.5,
66
+ max = 20,
67
+ step = 0.05,
68
+ }: {
69
+ nodeIds: AnyNodeId[]
70
+ nodeType: 'wall' | 'ceiling'
71
+ parametrics: ParametricDescriptor<AnyNode>
72
+ min?: number
73
+ max?: number
74
+ step?: number
75
+ }) {
76
+ const unit = useViewer((s) => s.unit)
77
+ const metricNotation = useViewer((s) => s.metricNotation)
78
+ const mode = useScene(useShallow((s) => reduceHeightBoundMode(nodeIds, s.nodes)))
79
+ const storedHeight = useScene(useShallow((s) => reduceFieldValue(nodeIds, 'height', s.nodes)))
80
+ const heightOrigin = useScene((s) => firstNumericFieldValue(nodeIds, 'height', s.nodes, min))
81
+ const liveHeight = useScene((s) => {
82
+ let seen = false
83
+ let shared: number | undefined
84
+ for (const id of nodeIds) {
85
+ const node = s.nodes[id]
86
+ if (!node) continue
87
+ const height = effectiveHeight(node, s.nodes as Record<string, AnyNode>)
88
+ if (!seen) {
89
+ seen = true
90
+ shared = height
91
+ continue
92
+ }
93
+ if (!Object.is(shared, height)) return Number.NaN
94
+ }
95
+ return seen && shared !== undefined ? shared : Number.NaN
96
+ })
97
+
98
+ const applyMode = useCallback(
99
+ (next: 'storey' | 'custom') => {
100
+ const nodes = useScene.getState().nodes as Record<string, AnyNode>
101
+ commitMultiNodeFields(
102
+ nodeIds,
103
+ (node) => {
104
+ const isCustom = (node as { height?: number }).height != null
105
+ if (next === 'custom') {
106
+ if (isCustom) return {}
107
+ if (node.type === 'ceiling') {
108
+ return { height: ceilingCustomHeight(node, nodes) }
109
+ }
110
+ if (node.type === 'wall') {
111
+ return { height: Math.max(0.1, getWallEffectiveHeightForNodes(node, nodes)) }
112
+ }
113
+ return {}
114
+ }
115
+ if (!isCustom) return {}
116
+ if (node.type === 'wall') return wallFollowsLevelPatch(node, nodes)
117
+ return { height: undefined }
118
+ },
119
+ parametrics,
120
+ )
121
+ },
122
+ [nodeIds, parametrics],
123
+ )
124
+
125
+ const previewHeight = useCallback(
126
+ (height: number) => {
127
+ previewMultiNodeFields(nodeIds.map((id) => [id, { height }] as const))
128
+ },
129
+ [nodeIds],
130
+ )
131
+ const commitHeight = useCallback(
132
+ (height: number) => {
133
+ commitMultiNodeFields(nodeIds, () => ({ height }), parametrics)
134
+ },
135
+ [nodeIds, parametrics],
136
+ )
137
+
138
+ const mixedMode = mode.kind === 'mixed'
139
+ const isFollows = mode.kind === 'same' && mode.value === 'storey'
140
+ const isCustom = mode.kind === 'same' && mode.value === 'custom'
141
+ const sliderValue =
142
+ storedHeight.kind === 'same' && typeof storedHeight.value === 'number'
143
+ ? storedHeight.value
144
+ : heightOrigin
145
+ const sliderMixed = storedHeight.kind === 'mixed' || mixedMode
146
+ const currentLabel = Number.isFinite(liveHeight)
147
+ ? formatLinearMeasurement(liveHeight, unit, metricNotation)
148
+ : 'Mixed'
149
+
150
+ return (
151
+ <>
152
+ {nodeType === 'wall' && (
153
+ <div className="px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
154
+ Top
155
+ </div>
156
+ )}
157
+ <SegmentedControl
158
+ mixed={mixedMode}
159
+ onChange={applyMode}
160
+ options={[
161
+ { label: 'Follows level', value: 'storey' },
162
+ { label: 'Custom height', value: 'custom' },
163
+ ]}
164
+ value={mode.kind === 'same' ? mode.value : 'storey'}
165
+ />
166
+ {isFollows ? (
167
+ <div className="px-1 text-[11px] text-muted-foreground">Currently {currentLabel}</div>
168
+ ) : isCustom ? (
169
+ <SliderControl
170
+ label="Height"
171
+ max={max}
172
+ min={min}
173
+ mixed={sliderMixed}
174
+ onChange={previewHeight}
175
+ onCommit={commitHeight}
176
+ precision={precisionForStep(step)}
177
+ restoreOnCommit={false}
178
+ step={step}
179
+ unit="m"
180
+ value={sliderValue}
181
+ />
182
+ ) : null}
183
+ </>
184
+ )
185
+ }