@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.
- package/package.json +7 -6
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/editor-layout-v2.tsx +8 -3
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/first-person-controls.tsx +2 -45
- package/src/components/editor/floating-action-menu.tsx +13 -4
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +5 -45
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/index.tsx +112 -25
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +29 -1
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +28 -5
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
- package/src/components/editor/thumbnail-generator.tsx +27 -11
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
- package/src/components/tools/item/placement-strategies.test.ts +167 -0
- package/src/components/tools/item/placement-strategies.ts +57 -22
- package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
- package/src/components/tools/shared/placement-box.tsx +9 -4
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/wall/wall-drafting.test.ts +207 -2
- package/src/components/tools/wall/wall-drafting.ts +84 -400
- package/src/components/ui/command-palette/editor-commands.tsx +3 -1
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
- package/src/components/ui/sidebar/tab-bar.tsx +7 -1
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +218 -0
- package/src/components/viewer-overlay.tsx +12 -8
- package/src/hooks/use-auto-save.test.ts +50 -1
- package/src/hooks/use-auto-save.ts +44 -9
- package/src/hooks/use-keyboard.ts +35 -1
- package/src/index.tsx +27 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +84 -0
- package/src/lib/history.test.ts +75 -10
- package/src/lib/history.ts +58 -13
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/selection-routing.test.ts +20 -5
- package/src/lib/selection-routing.ts +15 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- 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?.(
|
|
1275
|
-
}, [
|
|
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
|
-
{
|
|
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
|
-
<
|
|
1406
|
-
{isFirstPersonMode
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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:
|
|
12
|
+
metricNotation: MetricNotation = 'meters',
|
|
11
13
|
): string {
|
|
12
|
-
|
|
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(
|
|
63
|
-
|
|
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({
|
|
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
|
|
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
|
|
69
|
+
return clampSnapshotSize(Math.round(w * dpr), Math.round(h * dpr))
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
return null
|