@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
@@ -2,55 +2,107 @@
2
2
 
3
3
  import { type AnyNodeId, useScene } from '@pascal-app/core'
4
4
  import { useViewer } from '@pascal-app/viewer'
5
- import { Copy, Trash2 } from 'lucide-react'
5
+ import { Copy, Group, Trash2, Ungroup } from 'lucide-react'
6
+ import { useMemo } from 'react'
6
7
  import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions'
8
+ import {
9
+ canCreateSessionGroup,
10
+ selectionIntersectsSessionGroup,
11
+ selectionMatchesSessionGroup,
12
+ } from '../../../lib/session-groups'
13
+ import useSessionGroups, {
14
+ groupCurrentSelection,
15
+ ungroupCurrentSelection,
16
+ } from '../../../store/use-session-groups'
7
17
  import { ActionButton, ActionGroup } from '../controls/action-button'
8
18
  import { PanelWrapper } from './panel-wrapper'
9
19
  import { formatSelectionBreakdown } from './selection-breakdown'
10
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
+
11
68
  /**
12
- * Docked right-side panel for a MULTI-selection the compact sibling of the
13
- * single-node inspector, rendered by `PanelManager` when more than one node
14
- * is selected. Same collapsed-by-default `PanelWrapper` shell (header always
15
- * visible; the shared desktop collapse state carries across single ↔ multi
16
- * swaps). Actions mirror the floating group pill: Duplicate clones the
17
- * selection and picks the clones up, Delete removes the whole selection
18
- * (including its bulk-delete confirm). Unlike the pill, the docked panel
19
- * stays visible during interactions. `footer` is the host-injected slot
20
- * (e.g. community's "Save to my catalog").
69
+ * Docked multi-selection panel. Includes Group / Ungroup for session selection sets.
21
70
  */
22
71
  export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) {
23
72
  const selectedIds = useViewer((s) => s.selection.selectedIds)
24
73
  const setSelection = useViewer((s) => s.setSelection)
25
- // String selector recomputed on scene ticks, but the === compare keeps
26
- // unrelated mutations from re-rendering the panel.
74
+ const sessionGroups = useSessionGroups((s) => s.groups)
27
75
  const breakdown = useScene((s) =>
28
76
  formatSelectionBreakdown(selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type)),
29
77
  )
78
+ const sceneNodes = useScene((s) => s.nodes)
79
+ const liveIds = useMemo(() => new Set(Object.keys(sceneNodes)), [sceneNodes])
80
+ const matchedGroup = useMemo(
81
+ () => selectionMatchesSessionGroup(sessionGroups, selectedIds, liveIds),
82
+ [sessionGroups, selectedIds, liveIds],
83
+ )
30
84
 
31
85
  return (
32
86
  <PanelWrapper
33
87
  footer={footer}
34
88
  icon="/icons/select.webp"
35
89
  onClose={() => setSelection({ selectedIds: [] })}
36
- title={`${selectedIds.length} selected`}
90
+ title={
91
+ matchedGroup
92
+ ? `${matchedGroup.label} · ${selectedIds.length}`
93
+ : `${selectedIds.length} selected`
94
+ }
37
95
  width={320}
38
96
  >
39
97
  {breakdown && <div className="px-3 py-3 text-muted-foreground text-xs">{breakdown}</div>}
98
+ {matchedGroup && (
99
+ <div className="border-border/50 border-t px-3 py-2 text-muted-foreground text-xs">
100
+ {matchedGroup.label} (session only). Plain click reselects all members. Not saved with the
101
+ project.
102
+ </div>
103
+ )}
40
104
  <div className="border-border/50 border-t p-3">
41
- <ActionGroup>
42
- <ActionButton
43
- icon={<Copy className="h-4 w-4" />}
44
- label="Duplicate"
45
- onClick={() => duplicateSelectionAndPickUp()}
46
- />
47
- <ActionButton
48
- className="border-red-500/40 text-red-200 hover:bg-red-500/15"
49
- icon={<Trash2 className="h-4 w-4" />}
50
- label="Delete"
51
- onClick={() => deleteSelection()}
52
- />
53
- </ActionGroup>
105
+ <MultiSelectionActions />
54
106
  </div>
55
107
  </PanelWrapper>
56
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
 
@@ -1,17 +1,40 @@
1
1
  'use client'
2
2
 
3
+ import { Icon } from '@iconify/react'
4
+ import {
5
+ type AnyNode,
6
+ type AnyNodeId,
7
+ getInspectorExtensions,
8
+ type IconRef,
9
+ type InspectorExtension,
10
+ useRegistryVersion,
11
+ useScene,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
3
14
  import { ChevronDown, ChevronLeft, GripHorizontal, RotateCcw, X } from 'lucide-react'
4
15
  import Image from 'next/image'
5
16
  import {
17
+ type ComponentType,
6
18
  createContext,
19
+ lazy,
20
+ Suspense,
7
21
  useCallback,
8
22
  useContext,
23
+ useEffect,
9
24
  useLayoutEffect,
25
+ useMemo,
10
26
  useRef,
11
27
  useState,
12
28
  } from 'react'
13
29
  import { useIsMobile } from '../../../hooks/use-mobile'
30
+ import {
31
+ resolveActiveExtension,
32
+ toggleCard,
33
+ toggleExtension,
34
+ } from '../../../lib/inspector-card-mode'
14
35
  import { cn } from '../../../lib/utils'
36
+ import { PanelSection } from '../controls/panel-section'
37
+ import { ErrorBoundary } from '../primitives/error-boundary'
15
38
 
16
39
  const DRAG_MARGIN = 8
17
40
  // Pointer travel (px) below which a header press is treated as a click
@@ -92,6 +115,36 @@ export function PanelWrapper({
92
115
 
93
116
  const panelRef = useRef<HTMLDivElement>(null)
94
117
 
118
+ // ── Plugin inspector extensions ────────────────────────────────────
119
+ // The wrapper self-resolves the selected node instead of taking a prop:
120
+ // kind-owned `customPanel`s (wall, slab, …) render their own
121
+ // <PanelWrapper>, so this is the one spot every inspector card flows
122
+ // through. Extensions only apply to a single-node selection.
123
+ const registryVersion = useRegistryVersion()
124
+ const selectedId = useViewer((s) =>
125
+ s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
126
+ ) as AnyNodeId | undefined
127
+ // Subscribe to the selected node's *type* only — a string primitive that
128
+ // doesn't change as fields are edited (same trick as ParametricInspector).
129
+ const selectedType = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
130
+ const installedPlugins = useScene((s) => s.installedPlugins)
131
+ const extensions = useMemo(() => {
132
+ // re-derive when plugin extensions register after mount (async plugin load)
133
+ void registryVersion
134
+ if (!selectedType) return []
135
+ return getInspectorExtensions(selectedType).filter(
136
+ (extension) => !extension.pluginId || installedPlugins.includes(extension.pluginId),
137
+ )
138
+ }, [selectedType, installedPlugins, registryVersion])
139
+
140
+ // Which extension's content fills the card body (extension mode). The two
141
+ // expanded modes are EITHER/OR: extension mode replaces the regular
142
+ // controls; null shows the regular controls with no extension sections.
143
+ // See `lib/inspector-card-mode.ts` for the transition table.
144
+ const [activeExtensionId, setActiveExtensionId] = useState<string | null>(null)
145
+ // Stale ids (kind changed, plugin gated off) fall back to regular mode.
146
+ const activeExtension = resolveActiveExtension(activeExtensionId, extensions)
147
+
95
148
  // The whole panel is collapsed to just its header by default; the chevron
96
149
  // expands it to reveal the inspector body. Keep the desktop value shared
97
150
  // across inspector swaps (roof ↔ segment, etc.) so navigating between
@@ -109,6 +162,26 @@ export function PanelWrapper({
109
162
  [],
110
163
  )
111
164
 
165
+ const applyMode = useCallback(
166
+ (next: { collapsed: boolean; activeExtensionId: string | null }) => {
167
+ setCollapsed(next.collapsed)
168
+ setActiveExtensionId(next.activeExtensionId)
169
+ },
170
+ [setCollapsed],
171
+ )
172
+
173
+ // Chevron / header press — collapsed → regular, regular → collapsed,
174
+ // extension mode → regular (exit the extension first, stay expanded).
175
+ const handleCardToggle = useCallback(() => {
176
+ applyMode(toggleCard({ collapsed, activeExtensionId }))
177
+ }, [applyMode, collapsed, activeExtensionId])
178
+
179
+ // Folding the card forgets the active extension — extension mode is a
180
+ // one-shot affordance of the header icon, not sticky panel state.
181
+ useEffect(() => {
182
+ if (collapsed) setActiveExtensionId(null)
183
+ }, [collapsed])
184
+
112
185
  // Drag-to-reposition from the header. `offset` is a translation applied on
113
186
  // top of the default `top-20 right-4` anchor; null until first dragged.
114
187
  // Dragging is clamped so no edge of the panel leaves the viewport.
@@ -173,15 +246,19 @@ export function PanelWrapper({
173
246
  setOffset({ x: drag.baseX + (left - drag.rectLeft), y: drag.baseY + (top - drag.rectTop) })
174
247
  }, [])
175
248
 
176
- const handleHeaderPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
177
- const drag = dragRef.current
178
- if (!drag) return
179
- dragRef.current = null
180
- setIsDragging(false)
181
- e.currentTarget.releasePointerCapture(e.pointerId)
182
- // A press that never turned into a drag is a click → toggle collapse.
183
- if (!drag.moved) setCollapsed((c) => !c)
184
- }, [])
249
+ const handleHeaderPointerUp = useCallback(
250
+ (e: React.PointerEvent<HTMLDivElement>) => {
251
+ const drag = dragRef.current
252
+ if (!drag) return
253
+ dragRef.current = null
254
+ setIsDragging(false)
255
+ e.currentTarget.releasePointerCapture(e.pointerId)
256
+ // A press that never turned into a drag is a click → same mode toggle
257
+ // as the chevron.
258
+ if (!drag.moved) handleCardToggle()
259
+ },
260
+ [handleCardToggle],
261
+ )
185
262
 
186
263
  // Expanding can grow the panel past an edge if it was dragged there while
187
264
  // collapsed — nudge it back inside the viewer bounds.
@@ -278,11 +355,39 @@ export function PanelWrapper({
278
355
  <RotateCcw className="h-4 w-4" />
279
356
  </button>
280
357
  )}
358
+ {/* Extension mode buttons — one icon per registered inspector
359
+ extension, left of the chevron. Click swaps the card body to
360
+ ONLY that extension's content (either/or with the regular
361
+ controls); the active icon (highlighted) or the chevron
362
+ returns to the regular controls. */}
363
+ {extensions.map((extension) => {
364
+ const isActive = !collapsed && activeExtensionId === extension.id
365
+ return (
366
+ <button
367
+ aria-label={isActive ? `Close ${extension.title}` : `Open ${extension.title}`}
368
+ aria-pressed={isActive}
369
+ className={cn(
370
+ 'flex h-7 w-7 items-center justify-center rounded-md transition-colors',
371
+ isActive
372
+ ? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30'
373
+ : 'bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
374
+ )}
375
+ key={extension.id}
376
+ onClick={() =>
377
+ applyMode(toggleExtension({ collapsed, activeExtensionId }, extension.id))
378
+ }
379
+ title={extension.title}
380
+ type="button"
381
+ >
382
+ {renderExtensionIcon(extension.icon)}
383
+ </button>
384
+ )
385
+ })}
281
386
  <button
282
387
  aria-expanded={!collapsed}
283
388
  aria-label={collapsed ? 'Expand panel' : 'Collapse panel'}
284
389
  className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
285
- onClick={() => setCollapsed((c) => !c)}
390
+ onClick={handleCardToggle}
286
391
  type="button"
287
392
  >
288
393
  <ChevronDown
@@ -302,9 +407,37 @@ export function PanelWrapper({
302
407
  </div>
303
408
  )}
304
409
 
305
- {/* Content — hidden while the panel is collapsed (desktop). */}
410
+ {/* Content — hidden while the panel is collapsed (desktop). The two
411
+ expanded modes are EITHER/OR: extension mode renders ONLY that
412
+ extension's content; regular mode renders ONLY the kind's own
413
+ controls (`children`). A stale extension id falls back to regular
414
+ via `resolveActiveExtension`. */}
306
415
  {!(collapsed && !isMobile) && (
307
- <div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
416
+ <div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">
417
+ {!isMobile && selectedId && activeExtension ? (
418
+ <InspectorExtensionSection
419
+ extension={activeExtension}
420
+ key={activeExtension.id}
421
+ nodeId={selectedId}
422
+ />
423
+ ) : (
424
+ <>
425
+ {children}
426
+ {/* Mobile sheet has no header icons to swap modes — keep the
427
+ plugin sections appended after the kind's controls there. */}
428
+ {isMobile &&
429
+ selectedId &&
430
+ extensions.map((extension) => (
431
+ <InspectorExtensionSection
432
+ defaultExpanded={false}
433
+ extension={extension}
434
+ key={extension.id}
435
+ nodeId={selectedId}
436
+ />
437
+ ))}
438
+ </>
439
+ )}
440
+ </div>
308
441
  )}
309
442
 
310
443
  {resolvedFooter && !(collapsed && !isMobile) && (
@@ -313,3 +446,86 @@ export function PanelWrapper({
313
446
  </div>
314
447
  )
315
448
  }
449
+
450
+ // ─── Plugin inspector extensions ──────────────────────────────────────
451
+
452
+ /** 16px icon for an extension's header button — mirrors the parametric
453
+ * inspector's `renderIcon` (plain <img> so no next/image server deps). */
454
+ function renderExtensionIcon(ref: IconRef): React.ReactNode {
455
+ if (ref.kind === 'url') {
456
+ return <img alt="" className="h-4 w-4 shrink-0 object-contain" src={ref.src} />
457
+ }
458
+ if (ref.kind === 'iconify') {
459
+ return <Icon height={16} icon={ref.name} width={16} />
460
+ }
461
+ if (ref.kind === 'svg') {
462
+ return (
463
+ <svg height={16} viewBox={ref.viewBox} width={16}>
464
+ <path d={ref.path} fill="currentColor" />
465
+ </svg>
466
+ )
467
+ }
468
+ const LazyIcon = lazy(ref.module)
469
+ return (
470
+ <Suspense fallback={null}>
471
+ <LazyIcon />
472
+ </Suspense>
473
+ )
474
+ }
475
+
476
+ // `React.lazy` once per loader so the resolved component keeps a stable
477
+ // identity across renders — same WeakMap pattern as `resolveCustomPanel`
478
+ // in parametric-inspector.tsx.
479
+ const extensionComponentCache = new WeakMap<
480
+ InspectorExtension['component'],
481
+ ComponentType<{ node: AnyNode }>
482
+ >()
483
+
484
+ function resolveExtensionComponent(
485
+ extension: InspectorExtension,
486
+ ): ComponentType<{ node: AnyNode }> {
487
+ const cached = extensionComponentCache.get(extension.component)
488
+ if (cached) return cached
489
+ // `LazyComponent` is prop-agnostic; the inspector-extension contract is
490
+ // that the default export accepts `{ node }` (see the type's docs).
491
+ const Comp = lazy(extension.component) as unknown as ComponentType<{ node: AnyNode }>
492
+ extensionComponentCache.set(extension.component, Comp)
493
+ return Comp
494
+ }
495
+
496
+ /**
497
+ * One plugin-contributed inspector section. Subscribes to the full node —
498
+ * the section body reflects any edit — and hands it to the extension's
499
+ * lazy component inside its own error boundary so a crashing plugin
500
+ * section can't take down the inspector card. On desktop this is the
501
+ * card's SOLE body while its extension is active (either/or with the
502
+ * regular controls); the mobile sheet appends it after them instead.
503
+ */
504
+ function InspectorExtensionSection({
505
+ defaultExpanded = true,
506
+ extension,
507
+ nodeId,
508
+ }: {
509
+ defaultExpanded?: boolean
510
+ extension: InspectorExtension
511
+ nodeId: AnyNodeId
512
+ }) {
513
+ const node = useScene((s) => s.nodes[nodeId])
514
+ if (!node) return null
515
+ const Extension = resolveExtensionComponent(extension)
516
+ return (
517
+ <PanelSection defaultExpanded={defaultExpanded} title={extension.title}>
518
+ <ErrorBoundary
519
+ fallback={
520
+ <p className="p-1 text-muted-foreground text-xs">
521
+ “{extension.title}” hit an error and was unloaded for this session.
522
+ </p>
523
+ }
524
+ >
525
+ <Suspense fallback={null}>
526
+ <Extension node={node} />
527
+ </Suspense>
528
+ </ErrorBoundary>
529
+ </PanelSection>
530
+ )
531
+ }