@pascal-app/editor 1.0.0-beta.5 → 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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -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
+ }
@@ -18,6 +18,53 @@ import { ActionButton, ActionGroup } from '../controls/action-button'
18
18
  import { PanelWrapper } from './panel-wrapper'
19
19
  import { formatSelectionBreakdown } from './selection-breakdown'
20
20
 
21
+ export function MultiSelectionActions() {
22
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
23
+ const sessionGroups = useSessionGroups((s) => s.groups)
24
+ const sceneNodes = useScene((s) => s.nodes)
25
+ const liveIds = useMemo(() => new Set(Object.keys(sceneNodes)), [sceneNodes])
26
+ const showGroup = useMemo(
27
+ () => canCreateSessionGroup(sessionGroups, selectedIds, liveIds),
28
+ [sessionGroups, selectedIds, liveIds],
29
+ )
30
+ const showUngroup = useMemo(
31
+ () => selectionIntersectsSessionGroup(sessionGroups, selectedIds, liveIds),
32
+ [sessionGroups, selectedIds, liveIds],
33
+ )
34
+
35
+ return (
36
+ <ActionGroup>
37
+ {showGroup && (
38
+ <ActionButton
39
+ icon={<Group className="h-4 w-4" />}
40
+ label="Group"
41
+ onClick={() => groupCurrentSelection()}
42
+ title="Group (Ctrl/Cmd+G)"
43
+ />
44
+ )}
45
+ {showUngroup && (
46
+ <ActionButton
47
+ icon={<Ungroup className="h-4 w-4" />}
48
+ label="Ungroup"
49
+ onClick={() => ungroupCurrentSelection()}
50
+ title="Ungroup (Ctrl/Cmd+Shift+G)"
51
+ />
52
+ )}
53
+ <ActionButton
54
+ icon={<Copy className="h-4 w-4" />}
55
+ label="Duplicate"
56
+ onClick={() => duplicateSelectionAndPickUp()}
57
+ />
58
+ <ActionButton
59
+ className="border-red-500/40 text-red-200 hover:bg-red-500/15"
60
+ icon={<Trash2 className="h-4 w-4 text-red-400" />}
61
+ label="Delete"
62
+ onClick={() => deleteSelection()}
63
+ />
64
+ </ActionGroup>
65
+ )
66
+ }
67
+
21
68
  /**
22
69
  * Docked multi-selection panel. Includes Group / Ungroup for session selection sets.
23
70
  */
@@ -34,14 +81,6 @@ export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) {
34
81
  () => selectionMatchesSessionGroup(sessionGroups, selectedIds, liveIds),
35
82
  [sessionGroups, selectedIds, liveIds],
36
83
  )
37
- const showGroup = useMemo(
38
- () => canCreateSessionGroup(sessionGroups, selectedIds, liveIds),
39
- [sessionGroups, selectedIds, liveIds],
40
- )
41
- const showUngroup = useMemo(
42
- () => selectionIntersectsSessionGroup(sessionGroups, selectedIds, liveIds),
43
- [sessionGroups, selectedIds, liveIds],
44
- )
45
84
 
46
85
  return (
47
86
  <PanelWrapper
@@ -63,35 +102,7 @@ export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) {
63
102
  </div>
64
103
  )}
65
104
  <div className="border-border/50 border-t p-3">
66
- <ActionGroup>
67
- {showGroup && (
68
- <ActionButton
69
- icon={<Group className="h-4 w-4" />}
70
- label="Group"
71
- onClick={() => groupCurrentSelection()}
72
- title="Group (Ctrl/Cmd+G)"
73
- />
74
- )}
75
- {showUngroup && (
76
- <ActionButton
77
- icon={<Ungroup className="h-4 w-4" />}
78
- label="Ungroup"
79
- onClick={() => ungroupCurrentSelection()}
80
- title="Ungroup (Ctrl/Cmd+Shift+G)"
81
- />
82
- )}
83
- <ActionButton
84
- icon={<Copy className="h-4 w-4" />}
85
- label="Duplicate"
86
- onClick={() => duplicateSelectionAndPickUp()}
87
- />
88
- <ActionButton
89
- className="border-red-500/40 text-red-200 hover:bg-red-500/15"
90
- icon={<Trash2 className="h-4 w-4" />}
91
- label="Delete"
92
- onClick={() => deleteSelection()}
93
- />
94
- </ActionGroup>
105
+ <MultiSelectionActions />
95
106
  </div>
96
107
  </PanelWrapper>
97
108
  )
@@ -23,6 +23,10 @@ const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
23
23
  guide: { icon: '/icons/floorplan.webp', label: 'Guide image' },
24
24
  }
25
25
 
26
+ export function getTypeDisplay(type: string): NodeDisplay {
27
+ return TYPE_DEFAULTS[type] ?? { icon: '/icons/select.webp', label: type }
28
+ }
29
+
26
30
  export function getNodeDisplay(node: AnyNode | null | undefined): NodeDisplay {
27
31
  if (!node) return { icon: '/icons/select.webp', label: 'Selection' }
28
32
  const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.webp', label: node.type }
@@ -24,15 +24,20 @@ import {
24
24
  import { useViewer } from '@pascal-app/viewer'
25
25
  import { useCallback, useEffect, useState } from 'react'
26
26
  import { useIsMobile } from '../../../hooks/use-mobile'
27
+ import { shouldShowEditingControls } from '../../../lib/interaction/overlay-policy'
27
28
  import { sfxEmitter } from '../../../lib/sfx-bus'
28
29
  import useEditor from '../../../store/use-editor'
30
+ import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from '../../editor/group-actions'
31
+ import { resolveHomogeneousSelection } from './homogeneous-selection'
29
32
  import { MobilePanelSheet } from './mobile-panel-sheet'
30
33
  import { MobileSelectionBar } from './mobile-selection-bar'
34
+ import { MultiParametricInspector } from './multi-parametric-inspector'
31
35
  import { MultiSelectionPanel } from './multi-selection-panel'
32
- import { getNodeDisplay } from './node-display'
36
+ import { getNodeDisplay, getTypeDisplay } from './node-display'
33
37
  import { resetDesktopInspectorCollapsed } from './panel-wrapper'
34
38
  import { ParametricInspector } from './parametric-inspector'
35
39
  import { ReferencePanel } from './reference-panel'
40
+ import { formatSelectionBreakdown } from './selection-breakdown'
36
41
 
37
42
  type MovableNode =
38
43
  | ItemNode
@@ -169,6 +174,46 @@ function MobilePanelLayer({
169
174
  )
170
175
  }
171
176
 
177
+ function MobileMultiPanelLayer({
178
+ breakdown,
179
+ panel,
180
+ type,
181
+ }: {
182
+ breakdown: string
183
+ panel: React.ReactNode
184
+ type: string | null
185
+ }) {
186
+ const [isSheetOpen, setIsSheetOpen] = useState(false)
187
+ const display = type ? getTypeDisplay(type) : { icon: '/icons/select.webp', label: 'Selection' }
188
+ const title = breakdown || display.label
189
+
190
+ useEffect(() => {
191
+ setIsSheetOpen(false)
192
+ }, [breakdown])
193
+
194
+ return (
195
+ <>
196
+ <MobileSelectionBar
197
+ icon={display.icon}
198
+ label={title}
199
+ node={null}
200
+ onDelete={() => deleteSelection()}
201
+ onDuplicate={() => duplicateSelectionAndPickUp()}
202
+ onEdit={() => setIsSheetOpen((v) => !v)}
203
+ onMove={() => startGroupPickUp()}
204
+ />
205
+ <MobilePanelSheet
206
+ icon={display.icon}
207
+ onClose={() => setIsSheetOpen(false)}
208
+ open={isSheetOpen}
209
+ title={title}
210
+ >
211
+ {panel}
212
+ </MobilePanelSheet>
213
+ </>
214
+ )
215
+ }
216
+
172
217
  export function PanelManager({
173
218
  inspectorFooter,
174
219
  multiSelectionFooter,
@@ -181,6 +226,7 @@ export function PanelManager({
181
226
  const selectedZoneId = useViewer((s) => s.selection.zoneId)
182
227
  const setSelection = useViewer((s) => s.setSelection)
183
228
  const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
229
+ const readOnly = useScene((s) => s.readOnly)
184
230
  // Only subscribe to the *type* of the single-selected node — string primitive
185
231
  // so we don't re-render on unrelated scene mutations.
186
232
  const selectedNodeType = useScene((s) => {
@@ -193,6 +239,14 @@ export function PanelManager({
193
239
  const id = selectedIds[0]
194
240
  return id ? (s.nodes[id as AnyNodeId] ?? null) : null
195
241
  })
242
+ const homogeneousType = useScene((s) =>
243
+ selectedIds.length > 1 ? resolveHomogeneousSelection(selectedIds, s.nodes) : null,
244
+ )
245
+ const multiBreakdown = useScene((s) =>
246
+ selectedIds.length > 1
247
+ ? formatSelectionBreakdown(selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type))
248
+ : '',
249
+ )
196
250
 
197
251
  // Node and reference selection are mutually exclusive: selecting a guide
198
252
  // clears the node selection (handleGuideSelect), but node selection never
@@ -215,10 +269,27 @@ export function PanelManager({
215
269
  }
216
270
  }, [hasAnySelection])
217
271
 
272
+ if (!shouldShowEditingControls(readOnly)) return null
273
+
218
274
  if (isMobile) {
219
275
  if (selectedReferenceId) {
220
276
  return <MobilePanelLayer isReference={true} node={null} panel={<ReferencePanel />} />
221
277
  }
278
+ if (selectedIds.length > 1) {
279
+ return (
280
+ <MobileMultiPanelLayer
281
+ breakdown={multiBreakdown}
282
+ panel={
283
+ homogeneousType ? (
284
+ <MultiParametricInspector footer={multiSelectionFooter} />
285
+ ) : (
286
+ <MultiSelectionPanel footer={multiSelectionFooter} />
287
+ )
288
+ }
289
+ type={homogeneousType}
290
+ />
291
+ )
292
+ }
222
293
  return (
223
294
  <MobilePanelLayer
224
295
  isReference={false}
@@ -244,9 +315,12 @@ export function PanelManager({
244
315
  )
245
316
  }
246
317
 
247
- // Multi-selection: compact docked panel (desktop only the mobile branch
248
- // above keeps today's behavior and renders nothing for multi-selections).
318
+ // Multi-selection: parametric inspector when every resolved id shares a type,
319
+ // otherwise the actions-only panel. Mobile uses the same panels in a sheet.
249
320
  if (selectedIds.length > 1) {
321
+ if (homogeneousType) {
322
+ return <MultiParametricInspector footer={multiSelectionFooter} />
323
+ }
250
324
  return <MultiSelectionPanel footer={multiSelectionFooter} />
251
325
  }
252
326