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

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 (73) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  3. package/src/components/editor/editor-layout-v2.tsx +8 -3
  4. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  5. package/src/components/editor/first-person-controls.tsx +2 -45
  6. package/src/components/editor/floating-action-menu.tsx +13 -4
  7. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  8. package/src/components/editor/floorplan-background-selection.ts +27 -7
  9. package/src/components/editor/floorplan-panel.tsx +5 -45
  10. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  11. package/src/components/editor/index.tsx +112 -25
  12. package/src/components/editor/measurement-pill.tsx +12 -12
  13. package/src/components/editor/node-action-menu.tsx +29 -1
  14. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  15. package/src/components/editor/selection-manager.tsx +28 -5
  16. package/src/components/editor/site-edge-labels.tsx +2 -1
  17. package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
  18. package/src/components/editor/thumbnail-generator.tsx +27 -11
  19. package/src/components/editor/wall-measurement-label.tsx +3 -2
  20. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  21. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
  22. package/src/components/tools/item/placement-strategies.test.ts +167 -0
  23. package/src/components/tools/item/placement-strategies.ts +57 -22
  24. package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
  25. package/src/components/tools/shared/placement-box.tsx +9 -4
  26. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  27. package/src/components/tools/wall/wall-drafting.test.ts +207 -2
  28. package/src/components/tools/wall/wall-drafting.ts +84 -400
  29. package/src/components/ui/command-palette/editor-commands.tsx +3 -1
  30. package/src/components/ui/floating-level-selector.tsx +1 -1
  31. package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
  32. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  33. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  34. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
  35. package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
  36. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
  37. package/src/components/ui/sidebar/tab-bar.tsx +7 -1
  38. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  39. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  40. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  41. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  42. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  43. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  44. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  45. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  46. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  47. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  48. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  49. package/src/components/viewer/viewer-stage.tsx +218 -0
  50. package/src/components/viewer-overlay.tsx +12 -8
  51. package/src/hooks/use-auto-save.test.ts +50 -1
  52. package/src/hooks/use-auto-save.ts +44 -9
  53. package/src/hooks/use-keyboard.ts +35 -1
  54. package/src/index.tsx +27 -0
  55. package/src/lib/contextual-help.test.ts +8 -0
  56. package/src/lib/contextual-help.ts +8 -0
  57. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  58. package/src/lib/floorplan/floorplan-readonly.ts +84 -0
  59. package/src/lib/history.test.ts +75 -10
  60. package/src/lib/history.ts +58 -13
  61. package/src/lib/inspector-card-mode.test.ts +92 -0
  62. package/src/lib/inspector-card-mode.ts +60 -0
  63. package/src/lib/measurement-parser.ts +3 -0
  64. package/src/lib/measurements.test.ts +4 -0
  65. package/src/lib/paint-scope.test.ts +58 -2
  66. package/src/lib/paint-scope.ts +8 -2
  67. package/src/lib/selection-routing.test.ts +20 -5
  68. package/src/lib/selection-routing.ts +15 -1
  69. package/src/lib/session-groups.test.ts +88 -0
  70. package/src/lib/session-groups.ts +201 -0
  71. package/src/lib/structured-clone-fallback.test.ts +45 -0
  72. package/src/lib/structured-clone-fallback.ts +24 -0
  73. package/src/store/use-session-groups.ts +103 -0
@@ -33,6 +33,7 @@ import {
33
33
  import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
34
34
  import useEditor from '../../store/use-editor'
35
35
  import useFloorplanMode from '../../store/use-floorplan-mode'
36
+ import useSessionGroups from '../../store/use-session-groups'
36
37
  import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
37
38
  import { CeilingSystem } from '../systems/ceiling/ceiling-system'
38
39
  import { RoofEditSystem } from '../systems/roof/roof-edit-system'
@@ -58,6 +59,8 @@ import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/set
58
59
  import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel'
59
60
  import type { SidebarTab } from '../ui/sidebar/tab-bar'
60
61
  import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
62
+ import { ViewerStage } from '../viewer/viewer-stage'
63
+ import type { ViewerStageMode } from '../viewer/viewer-stage-modes'
61
64
  import { CustomCameraControls } from './custom-camera-controls'
62
65
  import { DeleteConfirmationDialog } from './delete-confirmation-dialog'
63
66
  import { EditorLayoutV2 } from './editor-layout-v2'
@@ -85,6 +88,8 @@ import { WallMoveSideHandles } from './wall-move-side-handles'
85
88
  import { WallOpeningHighlights } from './wall-opening-highlights'
86
89
 
87
90
  const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
91
+ const PREVIEW_STAGE_SWITCHER_POSITION =
92
+ 'top-28 right-4 left-auto translate-x-0 md:top-4 md:right-auto md:left-1/2 md:-translate-x-1/2'
88
93
  const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
89
94
  const DELETE_CURSOR_BADGE_OFFSET_X = 14
90
95
  const DELETE_CURSOR_BADGE_OFFSET_Y = 14
@@ -190,6 +195,13 @@ export interface EditorProps {
190
195
  // Thumbnail
191
196
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
192
197
 
198
+ /**
199
+ * When true, skip the viewer post-FX pipeline (same as `?disable=postFx`).
200
+ * Hosts use this for a stable local "light preview" without relying only on
201
+ * module-load URL flags or shading toggles.
202
+ */
203
+ disablePostFx?: boolean
204
+
193
205
  // Version preview overlays (rendered by host app)
194
206
  sidebarOverlay?: ReactNode
195
207
  viewerBanner?: ReactNode
@@ -961,6 +973,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
961
973
  onThumbnailCapture,
962
974
  viewerSceneSlot,
963
975
  floorplanSceneSlot,
976
+ disablePostFx = false,
964
977
  }: {
965
978
  isVersionPreviewMode: boolean
966
979
  isLoading: boolean
@@ -973,6 +986,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
973
986
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
974
987
  viewerSceneSlot?: ReactNode
975
988
  floorplanSceneSlot?: ReactNode
989
+ disablePostFx?: boolean
976
990
  }) {
977
991
  const viewMode = useEditor((s) => s.viewMode)
978
992
  const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
@@ -1086,9 +1100,11 @@ const ViewerCanvas = memo(function ViewerCanvas({
1086
1100
  <SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
1087
1101
  <Viewer
1088
1102
  defaultRender={EDITOR_DEFAULT_RENDER}
1103
+ disablePostFx={disablePostFx}
1089
1104
  hoverStyles={EDITOR_HOVER_STYLES}
1090
1105
  onSceneReadyChange={onSceneReadyChange}
1091
1106
  renderContext="editor"
1107
+ renderPaused={!show3d && !showLoader}
1092
1108
  sceneReadyKey={sceneReadyKey}
1093
1109
  selectionManager={isFirstPersonMode ? 'default' : 'custom'}
1094
1110
  >
@@ -1108,6 +1124,60 @@ const ViewerCanvas = memo(function ViewerCanvas({
1108
1124
  )
1109
1125
  })
1110
1126
 
1127
+ function PreviewStage({
1128
+ isFirstPersonMode,
1129
+ mode,
1130
+ onModeChange,
1131
+ showLoader,
1132
+ viewerContent,
1133
+ }: {
1134
+ isFirstPersonMode: boolean
1135
+ mode: ViewerStageMode
1136
+ onModeChange: (mode: ViewerStageMode) => void
1137
+ showLoader: boolean
1138
+ viewerContent: ReactNode
1139
+ }) {
1140
+ const hasFloorplan = useScene((state) =>
1141
+ Object.values(state.nodes).some((node) => node.type === 'level'),
1142
+ )
1143
+
1144
+ const handleModeChange = useCallback(
1145
+ (nextMode: ViewerStageMode) => {
1146
+ if (nextMode !== '3d') useEditor.getState().setFirstPersonMode(false)
1147
+ onModeChange(nextMode)
1148
+ },
1149
+ [onModeChange],
1150
+ )
1151
+
1152
+ const stageMode = isFirstPersonMode || !hasFloorplan ? '3d' : mode
1153
+ const stageModes = hasFloorplan && !isFirstPersonMode ? undefined : (['3d'] as const)
1154
+
1155
+ return (
1156
+ <div className="dark relative h-full w-full overflow-hidden bg-neutral-100 text-foreground">
1157
+ {isFirstPersonMode ? (
1158
+ <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
1159
+ ) : (
1160
+ <ViewerOverlay
1161
+ hideBottomBar={stageMode !== '3d'}
1162
+ onBack={() => useEditor.getState().setPreviewMode(false)}
1163
+ />
1164
+ )}
1165
+
1166
+ <ViewerStage
1167
+ className="absolute inset-0"
1168
+ mode={stageMode}
1169
+ modes={stageModes}
1170
+ onModeChange={handleModeChange}
1171
+ showCompass={hasFloorplan && !isFirstPersonMode}
1172
+ showSwitcher={hasFloorplan && !isFirstPersonMode}
1173
+ switcherClassName={`${PREVIEW_STAGE_SWITCHER_POSITION} ${showLoader ? 'z-[70]' : ''}`}
1174
+ >
1175
+ {viewerContent}
1176
+ </ViewerStage>
1177
+ </div>
1178
+ )
1179
+ }
1180
+
1111
1181
  export default function Editor({
1112
1182
  layoutVersion = 'v1',
1113
1183
  appMenuButton,
@@ -1131,6 +1201,7 @@ export default function Editor({
1131
1201
  isLoading = false,
1132
1202
  onLoaderChange,
1133
1203
  onThumbnailCapture,
1204
+ disablePostFx = false,
1134
1205
  sidebarOverlay,
1135
1206
  viewerBanner,
1136
1207
  settingsPanelProps,
@@ -1154,6 +1225,7 @@ export default function Editor({
1154
1225
  const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
1155
1226
  const [sceneReadyKey, setSceneReadyKey] = useState(0)
1156
1227
  const [isViewerSceneReady, setIsViewerSceneReady] = useState(false)
1228
+ const [previewStageMode, setPreviewStageMode] = useState<ViewerStageMode>('3d')
1157
1229
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
1158
1230
  const isCaptureMode = useEditor((s) => s.isCaptureMode)
1159
1231
 
@@ -1197,6 +1269,8 @@ export default function Editor({
1197
1269
  setIsSceneLoading(true)
1198
1270
  useScene.getState().unloadScene()
1199
1271
  useViewer.getState().resetSelection()
1272
+ // Session groups are not scene-graph state — clear on every load/switch.
1273
+ useSessionGroups.getState().clearGroups()
1200
1274
 
1201
1275
  try {
1202
1276
  const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
@@ -1232,6 +1306,9 @@ export default function Editor({
1232
1306
  // Apply preview scene when version preview mode changes
1233
1307
  useEffect(() => {
1234
1308
  if (isVersionPreviewMode && previewScene) {
1309
+ // Drop session groups from the edit session so plain-click expand cannot
1310
+ // pull in members that are not part of the preview graph.
1311
+ useSessionGroups.getState().clearGroups()
1235
1312
  applySceneGraphToEditor(previewScene)
1236
1313
  }
1237
1314
  }, [isVersionPreviewMode, previewScene])
@@ -1244,6 +1321,10 @@ export default function Editor({
1244
1321
  return releaseReadOnly
1245
1322
  }, [isVersionPreviewMode])
1246
1323
 
1324
+ useEffect(() => {
1325
+ if (!isPreviewMode) setPreviewStageMode('3d')
1326
+ }, [isPreviewMode])
1327
+
1247
1328
  useEffect(() => {
1248
1329
  document.body.classList.add('dark')
1249
1330
  return () => {
@@ -1269,10 +1350,19 @@ export default function Editor({
1269
1350
  }, [hasLoadedInitialScene, isLoading, isSceneLoading, isViewerSceneReady, sceneReadyKey])
1270
1351
 
1271
1352
  const showLoader = isLoading || isSceneLoading || !hasLoadedInitialScene || !isViewerSceneReady
1353
+ const visibleLoader =
1354
+ showLoader &&
1355
+ !(
1356
+ isPreviewMode &&
1357
+ previewStageMode === '2d' &&
1358
+ !isLoading &&
1359
+ !isSceneLoading &&
1360
+ hasLoadedInitialScene
1361
+ )
1272
1362
 
1273
1363
  useEffect(() => {
1274
- onLoaderChange?.(showLoader)
1275
- }, [showLoader, onLoaderChange])
1364
+ onLoaderChange?.(visibleLoader)
1365
+ }, [visibleLoader, onLoaderChange])
1276
1366
 
1277
1367
  const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
1278
1368
  const wasFirstPersonModeRef = useRef(isFirstPersonMode)
@@ -1313,6 +1403,7 @@ export default function Editor({
1313
1403
  const previewViewerContent = (
1314
1404
  <Viewer
1315
1405
  defaultRender={EDITOR_DEFAULT_RENDER}
1406
+ disablePostFx={disablePostFx}
1316
1407
  hoverStyles={EDITOR_HOVER_STYLES}
1317
1408
  renderContext="editor"
1318
1409
  selectionManager="default"
@@ -1331,6 +1422,7 @@ export default function Editor({
1331
1422
 
1332
1423
  const viewerCanvas = (
1333
1424
  <ViewerCanvas
1425
+ disablePostFx={disablePostFx}
1334
1426
  hasLoadedInitialScene={hasLoadedInitialScene}
1335
1427
  isFirstPersonMode={isFirstPersonMode}
1336
1428
  isLoading={isLoading}
@@ -1374,12 +1466,13 @@ export default function Editor({
1374
1466
  }
1375
1467
 
1376
1468
  const tabBarTabs = [
1377
- ...(sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon, icon }) => ({
1469
+ ...(sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon, icon, noPanel }) => ({
1378
1470
  id,
1379
1471
  label,
1380
1472
  mobileDefaultSnap,
1381
1473
  mobileIcon,
1382
1474
  icon,
1475
+ noPanel,
1383
1476
  })) ?? []),
1384
1477
  // Host panels appear after the explicit tabs in the rail. The icon
1385
1478
  // doubles as the mobile icon; a half-height sheet is a sensible default.
@@ -1395,23 +1488,20 @@ export default function Editor({
1395
1488
  return (
1396
1489
  <>
1397
1490
  <FloorplanModeCoordinator />
1398
- {showLoader && (
1491
+ {visibleLoader && (
1399
1492
  <div className="fixed inset-0 z-60">
1400
1493
  <SceneLoader className="bg-background" />
1401
1494
  </div>
1402
1495
  )}
1403
1496
 
1404
1497
  {!isLoading && isPreviewMode ? (
1405
- <div className="dark flex h-full w-full flex-col bg-neutral-100 text-foreground">
1406
- {isFirstPersonMode ? (
1407
- <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
1408
- ) : (
1409
- <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
1410
- )}
1411
- <div className="h-full w-full" data-pascal-viewer-3d>
1412
- {previewViewerContent}
1413
- </div>
1414
- </div>
1498
+ <PreviewStage
1499
+ isFirstPersonMode={isFirstPersonMode}
1500
+ mode={previewStageMode}
1501
+ onModeChange={setPreviewStageMode}
1502
+ showLoader={visibleLoader}
1503
+ viewerContent={previewViewerContent}
1504
+ />
1415
1505
  ) : (
1416
1506
  <>
1417
1507
  <EditorLayoutV2
@@ -1471,23 +1561,20 @@ export default function Editor({
1471
1561
  return (
1472
1562
  <div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
1473
1563
  <FloorplanModeCoordinator />
1474
- {showLoader && (
1564
+ {visibleLoader && (
1475
1565
  <div className="fixed inset-0 z-60">
1476
1566
  <SceneLoader className="bg-background" />
1477
1567
  </div>
1478
1568
  )}
1479
1569
 
1480
1570
  {!isLoading && isPreviewMode ? (
1481
- <>
1482
- {isFirstPersonMode ? (
1483
- <FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
1484
- ) : (
1485
- <ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
1486
- )}
1487
- <div className="h-full w-full" data-pascal-viewer-3d>
1488
- {previewViewerContent}
1489
- </div>
1490
- </>
1571
+ <PreviewStage
1572
+ isFirstPersonMode={isFirstPersonMode}
1573
+ mode={previewStageMode}
1574
+ onModeChange={setPreviewStageMode}
1575
+ showLoader={visibleLoader}
1576
+ viewerContent={previewViewerContent}
1577
+ />
1491
1578
  ) : (
1492
1579
  <>
1493
1580
  {/* Sidebar */}
@@ -1,23 +1,17 @@
1
1
  'use client'
2
2
 
3
+ import { useViewer } from '@pascal-app/viewer'
3
4
  import { type ForwardedRef, Fragment, forwardRef } from 'react'
5
+ import { formatLinearMeasurement, type MetricNotation } from '../../lib/measurements'
4
6
 
5
7
  // Canonical in-world dimension formatter — metric metres or imperial
6
8
  // feet/inches. Shared by every measurement readout so they read the same.
7
9
  export function formatMeasurement(
8
10
  value: number,
9
11
  unit: 'metric' | 'imperial',
10
- metricNotation: 'meters' | 'millimeters' = 'meters',
12
+ metricNotation: MetricNotation = 'meters',
11
13
  ): string {
12
- if (unit === 'imperial') {
13
- const feet = value * 3.280_84
14
- const wholeFeet = Math.floor(feet)
15
- const inches = Math.round((feet - wholeFeet) * 12)
16
- if (inches === 12) return `${wholeFeet + 1}'0"`
17
- return `${wholeFeet}'${inches}"`
18
- }
19
- if (metricNotation === 'millimeters') return `${Math.round(value * 1000)}mm`
20
- return `${Number.parseFloat(value.toFixed(2))}m`
14
+ return formatLinearMeasurement(value, unit, metricNotation)
21
15
  }
22
16
 
23
17
  type MeasurePart = 'height' | 'length' | 'thickness'
@@ -55,12 +49,18 @@ export function DimensionPill({
55
49
  primary?: string
56
50
  primaryRef?: ForwardedRef<HTMLSpanElement>
57
51
  }) {
52
+ const metricNotation = useViewer((state) => state.metricNotation)
53
+
58
54
  return (
59
55
  <div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
60
56
  {parts.map((part, index) => {
61
57
  const text = part.signed
62
- ? `${part.value < 0 ? '-' : '+'}${formatMeasurement(Math.abs(part.value), unit)}`
63
- : formatMeasurement(part.value, unit)
58
+ ? `${part.value < 0 ? '-' : '+'}${formatMeasurement(
59
+ Math.abs(part.value),
60
+ unit,
61
+ metricNotation,
62
+ )}`
63
+ : formatMeasurement(part.value, unit, metricNotation)
64
64
  return (
65
65
  <Fragment key={part.key}>
66
66
  {index > 0 ? (
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { Icon } from '@iconify/react'
4
- import { Copy, Move, Search, Spline, Trash2 } from 'lucide-react'
4
+ import { Copy, Group, Move, Search, Spline, Trash2, Ungroup } from 'lucide-react'
5
5
  import type { MouseEventHandler, PointerEventHandler } from 'react'
6
6
 
7
7
  type NodeActionMenuProps = {
@@ -11,6 +11,10 @@ type NodeActionMenuProps = {
11
11
  onDuplicate?: MouseEventHandler<HTMLButtonElement>
12
12
  onMove?: MouseEventHandler<HTMLButtonElement>
13
13
  onCurve?: MouseEventHandler<HTMLButtonElement>
14
+ /** Session group (Ctrl/Cmd+G) — multi-selection floating pill. */
15
+ onGroup?: MouseEventHandler<HTMLButtonElement>
16
+ /** Dissolve session group (Ctrl/Cmd+Shift+G). */
17
+ onUngroup?: MouseEventHandler<HTMLButtonElement>
14
18
  onPointerDown?: PointerEventHandler<HTMLDivElement>
15
19
  onPointerUp?: PointerEventHandler<HTMLDivElement>
16
20
  onPointerEnter?: PointerEventHandler<HTMLDivElement>
@@ -24,6 +28,8 @@ export function NodeActionMenu({
24
28
  onDuplicate,
25
29
  onMove,
26
30
  onCurve,
31
+ onGroup,
32
+ onUngroup,
27
33
  onPointerDown,
28
34
  onPointerUp,
29
35
  onPointerEnter,
@@ -59,6 +65,28 @@ export function NodeActionMenu({
59
65
  <Move className="h-4 w-4" />
60
66
  </button>
61
67
  )}
68
+ {onGroup && (
69
+ <button
70
+ aria-label="Group selection"
71
+ className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
72
+ onClick={onGroup}
73
+ title="Group (Ctrl/Cmd+G)"
74
+ type="button"
75
+ >
76
+ <Group className="h-4 w-4" />
77
+ </button>
78
+ )}
79
+ {onUngroup && (
80
+ <button
81
+ aria-label="Ungroup selection"
82
+ className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
83
+ onClick={onUngroup}
84
+ title="Ungroup (Ctrl/Cmd+Shift+G)"
85
+ type="button"
86
+ >
87
+ <Ungroup className="h-4 w-4" />
88
+ </button>
89
+ )}
62
90
  {onCurve && (
63
91
  <button
64
92
  aria-label="Curve"
@@ -53,17 +53,26 @@ const mid = (a: OpeningGuideVec3, b: OpeningGuideVec3): OpeningGuideVec3 => [
53
53
  export const OpeningGuides3DLayer = memo(function OpeningGuides3DLayer() {
54
54
  const guides = useOpeningGuides((s) => s.guides)
55
55
  const unit = useViewer((s) => s.unit)
56
+ const metricNotation = useViewer((s) => s.metricNotation)
56
57
  if (guides.length === 0) return null
57
58
  return (
58
59
  <>
59
60
  {guides.map((guide) => (
60
- <OpeningGuide guide={guide} key={guide.id} unit={unit} />
61
+ <OpeningGuide guide={guide} key={guide.id} metricNotation={metricNotation} unit={unit} />
61
62
  ))}
62
63
  </>
63
64
  )
64
65
  })
65
66
 
66
- function OpeningGuide({ guide, unit }: { guide: OpeningGuide3D; unit: 'metric' | 'imperial' }) {
67
+ function OpeningGuide({
68
+ guide,
69
+ metricNotation,
70
+ unit,
71
+ }: {
72
+ guide: OpeningGuide3D
73
+ metricNotation: 'meters' | 'millimeters'
74
+ unit: 'metric' | 'imperial'
75
+ }) {
67
76
  if (guide.kind === 'badge') {
68
77
  return (
69
78
  <Html
@@ -76,7 +85,7 @@ function OpeningGuide({ guide, unit }: { guide: OpeningGuide3D; unit: 'metric' |
76
85
  className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-sans font-semibold text-[11px] text-white"
77
86
  style={{ backgroundColor: BADGE_PILL }}
78
87
  >
79
- {`= ${formatMeasurement(guide.value, unit)}`}
88
+ {`= ${formatMeasurement(guide.value, unit, metricNotation)}`}
80
89
  </div>
81
90
  </Html>
82
91
  )
@@ -97,7 +106,7 @@ function OpeningGuide({ guide, unit }: { guide: OpeningGuide3D; unit: 'metric' |
97
106
  className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white"
98
107
  style={{ backgroundColor: DIMENSION_PILL }}
99
108
  >
100
- {formatMeasurement(guide.value, unit)}
109
+ {formatMeasurement(guide.value, unit, metricNotation)}
101
110
  </div>
102
111
  </Html>
103
112
  ) : null}
@@ -23,6 +23,7 @@ import {
23
23
  type StairSurfaceMaterialRole,
24
24
  sceneRegistry,
25
25
  useLiveNodeOverrides,
26
+ useRegistryVersion,
26
27
  useScene,
27
28
  } from '@pascal-app/core'
28
29
 
@@ -77,6 +78,7 @@ import useInteractionScope, {
77
78
  useIsCurveReshape,
78
79
  useMovingNode,
79
80
  } from '../../store/use-interaction-scope'
81
+ import { expandSessionSelectionForNode } from '../../store/use-session-groups'
80
82
  import { boxSelectHandled, suppressBoxSelectForPointer } from '../tools/select/box-select-state'
81
83
  import { armGroupMove3d } from './group-move-3d'
82
84
  import { classifyParticipant } from './group-transform-shared'
@@ -590,6 +592,7 @@ const computeNextIds = (
590
592
  currentSelectedIds: selectedIds,
591
593
  modifierKeys: selectionModifiersFromEvent(event, modifierKeys),
592
594
  nodeId: node.id,
595
+ expandIdsForNode: expandSessionSelectionForNode,
593
596
  })
594
597
  }
595
598
 
@@ -774,11 +777,18 @@ export const SelectionManager = () => {
774
777
  meta: false,
775
778
  ctrl: false,
776
779
  shift: false,
780
+ alt: false,
777
781
  })
778
782
  const clickHandledRef = useRef(false)
779
783
 
780
784
  const movingNode = useMovingNode()
781
785
  const isCurveReshape = useIsCurveReshape()
786
+ // Plugin kinds register AFTER mount (async dynamic-import discovery), so
787
+ // every effect below that snapshots `getSelectableKinds()` into an emitter
788
+ // subscription list depends on this version — a late plugin load re-runs
789
+ // them and picks up the new kinds (hover / click / double-click / paint /
790
+ // pointerdown). Without it, plugin nodes select-but-never-hover in prod.
791
+ const registryVersion = useRegistryVersion()
782
792
 
783
793
  useEffect(() => {
784
794
  const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
@@ -790,6 +800,8 @@ export const SelectionManager = () => {
790
800
  }, [mode, setHoverHighlightMode])
791
801
 
792
802
  useEffect(() => {
803
+ // re-subscribe when plugin kinds register after mount (async plugin load)
804
+ void registryVersion
793
805
  if (mode !== 'material-paint') return
794
806
  if (movingNode || isCurveReshape) return
795
807
 
@@ -1185,25 +1197,28 @@ export const SelectionManager = () => {
1185
1197
  setHoverHighlightMode('default')
1186
1198
  useEditor.getState().setPaintHover(null)
1187
1199
  }
1188
- }, [isCurveReshape, mode, movingNode, setHoverHighlightMode])
1200
+ }, [isCurveReshape, mode, movingNode, setHoverHighlightMode, registryVersion])
1189
1201
 
1190
1202
  useEffect(() => {
1191
1203
  const onKeyDown = (event: KeyboardEvent) => {
1192
1204
  if (event.key === 'Meta') modifierKeysRef.current.meta = true
1193
1205
  if (event.key === 'Control') modifierKeysRef.current.ctrl = true
1194
1206
  if (event.key === 'Shift') modifierKeysRef.current.shift = true
1207
+ if (event.key === 'Alt') modifierKeysRef.current.alt = true
1195
1208
  }
1196
1209
 
1197
1210
  const onKeyUp = (event: KeyboardEvent) => {
1198
1211
  if (event.key === 'Meta') modifierKeysRef.current.meta = false
1199
1212
  if (event.key === 'Control') modifierKeysRef.current.ctrl = false
1200
1213
  if (event.key === 'Shift') modifierKeysRef.current.shift = false
1214
+ if (event.key === 'Alt') modifierKeysRef.current.alt = false
1201
1215
  }
1202
1216
 
1203
1217
  const clearModifiers = () => {
1204
1218
  modifierKeysRef.current.meta = false
1205
1219
  modifierKeysRef.current.ctrl = false
1206
1220
  modifierKeysRef.current.shift = false
1221
+ modifierKeysRef.current.alt = false
1207
1222
  }
1208
1223
 
1209
1224
  window.addEventListener('keydown', onKeyDown)
@@ -1218,6 +1233,8 @@ export const SelectionManager = () => {
1218
1233
  }, [])
1219
1234
 
1220
1235
  useEffect(() => {
1236
+ // re-subscribe when plugin kinds register after mount (async plugin load)
1237
+ void registryVersion
1221
1238
  if (mode !== 'select') return
1222
1239
  if (movingNode || isCurveReshape) return
1223
1240
 
@@ -1357,7 +1374,7 @@ export const SelectionManager = () => {
1357
1374
  emitter.off(`${type}:pointerdown` as any, onPointerDown as any)
1358
1375
  }
1359
1376
  }
1360
- }, [isCurveReshape, mode, movingNode, camera, raycaster, glDomElement])
1377
+ }, [isCurveReshape, mode, movingNode, camera, raycaster, glDomElement, registryVersion])
1361
1378
 
1362
1379
  // Move cursor over the selected movable node: the visual cue that clicking it
1363
1380
  // picks it up (replaces the removed move-cross gizmo). Reacts only when the
@@ -1523,6 +1540,8 @@ export const SelectionManager = () => {
1523
1540
  }, [isCurveReshape, mode, movingNode])
1524
1541
 
1525
1542
  useEffect(() => {
1543
+ // re-subscribe when plugin kinds register after mount (async plugin load)
1544
+ void registryVersion
1526
1545
  if (mode !== 'select') return
1527
1546
  if (movingNode || isCurveReshape) return
1528
1547
 
@@ -1775,10 +1794,12 @@ export const SelectionManager = () => {
1775
1794
  })
1776
1795
  emitter.off('grid:click', onGridClick)
1777
1796
  }
1778
- }, [isCurveReshape, mode, movingNode])
1797
+ }, [isCurveReshape, mode, movingNode, registryVersion])
1779
1798
 
1780
1799
  // Global double-click handler for auto-switching phases and cross-phase hover
1781
1800
  useEffect(() => {
1801
+ // re-subscribe when plugin kinds register after mount (async plugin load)
1802
+ void registryVersion
1782
1803
  if (mode !== 'select') return
1783
1804
  if (movingNode || isCurveReshape) return
1784
1805
 
@@ -1930,10 +1951,12 @@ export const SelectionManager = () => {
1930
1951
  emitter.off(`${type}:double-click` as any, onDoubleClick as any)
1931
1952
  })
1932
1953
  }
1933
- }, [isCurveReshape, mode, movingNode])
1954
+ }, [isCurveReshape, mode, movingNode, registryVersion])
1934
1955
 
1935
1956
  // Delete mode: click-to-delete (sledgehammer tool)
1936
1957
  useEffect(() => {
1958
+ // re-subscribe when plugin kinds register after mount (async plugin load)
1959
+ void registryVersion
1937
1960
  if (mode !== 'delete') return
1938
1961
 
1939
1962
  const onClick = (event: NodeEvent) => {
@@ -2006,7 +2029,7 @@ export const SelectionManager = () => {
2006
2029
  }
2007
2030
  useViewer.setState({ hoveredId: null })
2008
2031
  }
2009
- }, [mode])
2032
+ }, [mode, registryVersion])
2010
2033
 
2011
2034
  return (
2012
2035
  <>
@@ -50,6 +50,7 @@ export function SiteEdgeLabels() {
50
50
  })
51
51
  const activeHandleDrag = useActiveHandleDrag()
52
52
  const unit = useViewer((state) => state.unit)
53
+ const metricNotation = useViewer((state) => state.metricNotation)
53
54
  const cameraMode = useViewer((state) => state.cameraMode)
54
55
  const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
55
56
  const camera = useThree((state) => state.camera)
@@ -132,7 +133,7 @@ export function SiteEdgeLabels() {
132
133
  textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
133
134
  }}
134
135
  >
135
- {formatLinearMeasurement(edge.dist, unit)}
136
+ {formatLinearMeasurement(edge.dist, unit, metricNotation)}
136
137
  </div>
137
138
  </Html>
138
139
  ))}
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { emitter } from '@pascal-app/core'
4
+ import { SNAPSHOT_MAX_EDGE } from '@pascal-app/viewer'
4
5
  import { Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
5
6
  import { useCallback, useEffect, useRef, useState } from 'react'
6
7
  import { useIsMobile } from '../../hooks/use-mobile'
@@ -37,6 +38,14 @@ const STANDARD_SIZES: Record<SnapshotStandardAspect, { w: number; h: number }> =
37
38
  }
38
39
  type StandardAspect = SnapshotStandardAspect
39
40
 
41
+ function clampSnapshotSize(width: number, height: number): { w: number; h: number } {
42
+ const maxEdge = Math.max(width, height)
43
+ if (maxEdge <= SNAPSHOT_MAX_EDGE) return { w: width, h: height }
44
+
45
+ const scale = SNAPSHOT_MAX_EDGE / maxEdge
46
+ return { w: Math.round(width * scale), h: Math.round(height * scale) }
47
+ }
48
+
40
49
  function getResolution(
41
50
  mode: CropMode,
42
51
  overlayEl: HTMLDivElement | null,
@@ -50,14 +59,14 @@ function getResolution(
50
59
  const dpr = Math.min(window.devicePixelRatio, 1.5)
51
60
 
52
61
  if (mode === 'viewport') {
53
- return { w: Math.round(rect.width * dpr), h: Math.round(rect.height * dpr) }
62
+ return clampSnapshotSize(Math.round(rect.width * dpr), Math.round(rect.height * dpr))
54
63
  }
55
64
 
56
65
  if (mode === 'area' && drag) {
57
66
  const w = Math.abs(drag.end.x - drag.start.x)
58
67
  const h = Math.abs(drag.end.y - drag.start.y)
59
68
  if (w < 4 || h < 4) return null
60
- return { w: Math.round(w * dpr), h: Math.round(h * dpr) }
69
+ return clampSnapshotSize(Math.round(w * dpr), Math.round(h * dpr))
61
70
  }
62
71
 
63
72
  return null