@pascal-app/editor 1.0.0-beta.4 → 1.0.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 (283) 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/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  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 +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -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
+ }
@@ -0,0 +1,263 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ type AnyNodeId,
6
+ nodeRegistry,
7
+ type ParamField,
8
+ type ParametricDescriptor,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import { useViewer } from '@pascal-app/viewer'
12
+ import { useCallback, useMemo } from 'react'
13
+ import { useShallow } from 'zustand/react/shallow'
14
+ import { selectionMatchesSessionGroup } from '../../../lib/session-groups'
15
+ import useSessionGroups from '../../../store/use-session-groups'
16
+ import { PanelSection } from '../controls/panel-section'
17
+ import { SliderControl } from '../controls/slider-control'
18
+ import { resolveUniqueSelectionIds } from './homogeneous-selection'
19
+ import {
20
+ commitMultiNodeFields,
21
+ fieldVisibleForAll,
22
+ firstNumericFieldValue,
23
+ firstVec3FieldValue,
24
+ previewMultiNodeFields,
25
+ reduceFieldValue,
26
+ } from './multi-field-value'
27
+ import { MultiHeightModeField } from './multi-height-mode'
28
+ import { MultiSelectionActions } from './multi-selection-panel'
29
+ import { getTypeDisplay } from './node-display'
30
+ import { ParametricFieldControl } from './parametric-field-control'
31
+ import { PanelWrapper } from './panel-wrapper'
32
+ import { formatSelectionBreakdown } from './selection-breakdown'
33
+
34
+ export function MultiParametricInspector({ footer }: { footer?: React.ReactNode }) {
35
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
36
+ const setSelection = useViewer((s) => s.setSelection)
37
+ const nodeIds = useScene(
38
+ useShallow((s) => resolveUniqueSelectionIds(selectedIds, s.nodes)),
39
+ )
40
+ const nodeType = useScene((s) => {
41
+ const first = nodeIds[0] ? s.nodes[nodeIds[0]] : undefined
42
+ return first?.type ?? null
43
+ })
44
+ const breakdown = useScene((s) =>
45
+ formatSelectionBreakdown(nodeIds.map((id) => s.nodes[id]?.type)),
46
+ )
47
+ const sessionGroups = useSessionGroups((s) => s.groups)
48
+ const matchedGroup = useMemo(
49
+ () => selectionMatchesSessionGroup(sessionGroups, selectedIds),
50
+ [sessionGroups, selectedIds],
51
+ )
52
+
53
+ const def = nodeType ? nodeRegistry.get(nodeType) : undefined
54
+ const parametrics = def?.parametrics as ParametricDescriptor<AnyNode> | undefined
55
+
56
+ const handleClose = useCallback(() => {
57
+ setSelection({ selectedIds: [] })
58
+ }, [setSelection])
59
+
60
+ if (nodeIds.length < 2 || !nodeType || !parametrics) return null
61
+
62
+ const display = getTypeDisplay(nodeType)
63
+ const title = matchedGroup ? `${matchedGroup.label} · ${breakdown}` : breakdown || display.label
64
+
65
+ return (
66
+ <PanelWrapper footer={footer} icon={display.icon} onClose={handleClose} title={title} width={320}>
67
+ {matchedGroup && (
68
+ <div className="border-border/50 border-b px-3 py-2 text-muted-foreground text-xs">
69
+ {matchedGroup.label} (session only). Plain click reselects all members. Not saved with the
70
+ project.
71
+ </div>
72
+ )}
73
+ {parametrics.groups.map((group, gi) => (
74
+ <MultiGroupFields
75
+ fields={group.fields as ParamField<AnyNode>[]}
76
+ key={`group-${gi}`}
77
+ nodeIds={nodeIds}
78
+ nodeType={nodeType}
79
+ parametrics={parametrics}
80
+ title={group.label}
81
+ />
82
+ ))}
83
+ <div className="border-border/50 border-t p-3">
84
+ <MultiSelectionActions />
85
+ </div>
86
+ </PanelWrapper>
87
+ )
88
+ }
89
+
90
+ function MultiGroupFields({
91
+ title,
92
+ fields,
93
+ nodeIds,
94
+ nodeType,
95
+ parametrics,
96
+ }: {
97
+ title: string
98
+ fields: ParamField<AnyNode>[]
99
+ nodeIds: AnyNodeId[]
100
+ nodeType: AnyNode['type']
101
+ parametrics: ParametricDescriptor<AnyNode>
102
+ }) {
103
+ const genericFields = fields.filter((field) => field.kind !== 'custom')
104
+ const anyVisible = useScene((s) =>
105
+ genericFields.some((field) =>
106
+ fieldVisibleForAll(nodeIds, (field as { visibleIf?: (n: AnyNode) => boolean }).visibleIf, s.nodes),
107
+ ),
108
+ )
109
+ if (genericFields.length === 0 || !anyVisible) return null
110
+ return (
111
+ <PanelSection title={title}>
112
+ {genericFields.map((field, fi) => {
113
+ if (
114
+ String(field.key) === 'height' &&
115
+ (nodeType === 'wall' || nodeType === 'ceiling') &&
116
+ field.kind === 'number'
117
+ ) {
118
+ return (
119
+ <MultiHeightModeField
120
+ key={`field-${fi}-height-mode`}
121
+ max={field.max}
122
+ min={field.min}
123
+ nodeIds={nodeIds}
124
+ nodeType={nodeType}
125
+ parametrics={parametrics}
126
+ step={field.step}
127
+ />
128
+ )
129
+ }
130
+ return (
131
+ <MultiFieldRenderer
132
+ field={field}
133
+ key={`field-${fi}-${String(field.key)}`}
134
+ nodeIds={nodeIds}
135
+ parametrics={parametrics}
136
+ />
137
+ )
138
+ })}
139
+ </PanelSection>
140
+ )
141
+ }
142
+
143
+ function MultiFieldRenderer({
144
+ field,
145
+ nodeIds,
146
+ parametrics,
147
+ }: {
148
+ field: ParamField<AnyNode>
149
+ nodeIds: AnyNodeId[]
150
+ parametrics: ParametricDescriptor<AnyNode>
151
+ }) {
152
+ const key = String(field.key)
153
+ const visible = useScene((s) =>
154
+ fieldVisibleForAll(nodeIds, (field as { visibleIf?: (n: AnyNode) => boolean }).visibleIf, s.nodes),
155
+ )
156
+ const reduced = useScene(useShallow((s) => reduceFieldValue(nodeIds, key, s.nodes)))
157
+ const numericOrigin = useScene((s) => firstNumericFieldValue(nodeIds, key, s.nodes))
158
+ const vecOrigin = useScene(useShallow((s) => firstVec3FieldValue(nodeIds, key, s.nodes)))
159
+
160
+ const preview = useCallback(
161
+ (patch: Partial<AnyNode>) => {
162
+ previewMultiNodeFields(nodeIds.map((id) => [id, patch] as const))
163
+ },
164
+ [nodeIds],
165
+ )
166
+ const commit = useCallback(
167
+ (patch: Partial<AnyNode>) => {
168
+ commitMultiNodeFields(nodeIds, () => patch, parametrics)
169
+ },
170
+ [nodeIds, parametrics],
171
+ )
172
+
173
+ if (!visible) return null
174
+
175
+ if (field.kind === 'vec3') {
176
+ return (
177
+ <MultiVec3Field
178
+ fieldKey={key}
179
+ mixed={reduced.kind === 'mixed'}
180
+ nodeIds={nodeIds}
181
+ origin={vecOrigin}
182
+ parametrics={parametrics}
183
+ value={reduced.kind === 'same' && Array.isArray(reduced.value) ? (reduced.value as [number, number, number]) : vecOrigin}
184
+ />
185
+ )
186
+ }
187
+
188
+ const value =
189
+ reduced.kind === 'same' ? reduced.value : field.kind === 'number' ? numericOrigin : undefined
190
+
191
+ return (
192
+ <ParametricFieldControl
193
+ field={field}
194
+ mixed={reduced.kind === 'mixed'}
195
+ onChange={field.kind === 'number' ? preview : commit}
196
+ onCommit={field.kind === 'number' ? commit : undefined}
197
+ value={value}
198
+ />
199
+ )
200
+ }
201
+
202
+ function MultiVec3Field({
203
+ fieldKey,
204
+ mixed,
205
+ nodeIds,
206
+ origin,
207
+ parametrics,
208
+ value,
209
+ }: {
210
+ fieldKey: string
211
+ mixed: boolean
212
+ nodeIds: AnyNodeId[]
213
+ origin: [number, number, number]
214
+ parametrics: ParametricDescriptor<AnyNode>
215
+ value: [number, number, number]
216
+ }) {
217
+ const axes: Array<{ label: string; index: 0 | 1 | 2 }> = [
218
+ { label: 'X', index: 0 },
219
+ { label: 'Y', index: 1 },
220
+ { label: 'Z', index: 2 },
221
+ ]
222
+ return (
223
+ <>
224
+ {axes.map(({ label, index }) => {
225
+ const axisValue = value[index] ?? origin[index] ?? 0
226
+ const patchAxis = (next: number): Array<readonly [AnyNodeId, Partial<AnyNode>]> => {
227
+ const nodes = useScene.getState().nodes
228
+ return nodeIds.flatMap((id) => {
229
+ const node = nodes[id]
230
+ if (!node) return []
231
+ const current = (node as Record<string, unknown>)[fieldKey]
232
+ const nextVec = (
233
+ Array.isArray(current) && current.length >= 3 ? [...current] : [...origin]
234
+ ) as [number, number, number]
235
+ nextVec[index] = next
236
+ return [[id, { [fieldKey]: nextVec } as Partial<AnyNode>] as const]
237
+ })
238
+ }
239
+ return (
240
+ <SliderControl
241
+ key={`${fieldKey}-${label}`}
242
+ label={label}
243
+ mixed={mixed}
244
+ onChange={(next) => previewMultiNodeFields(patchAxis(next))}
245
+ onCommit={(next) => {
246
+ const entries = patchAxis(next)
247
+ commitMultiNodeFields(
248
+ nodeIds,
249
+ (node) => entries.find(([id]) => id === node.id)?.[1] ?? {},
250
+ parametrics,
251
+ )
252
+ }}
253
+ precision={2}
254
+ restoreOnCommit={false}
255
+ step={0.05}
256
+ unit="m"
257
+ value={Math.round(axisValue * 100) / 100}
258
+ />
259
+ )
260
+ })}
261
+ </>
262
+ )
263
+ }