@pascal-app/editor 1.0.0-beta.4 → 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pascal-app/editor",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.5",
|
|
4
4
|
"description": "Pascal building editor component",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
"./catalog": "./src/components/ui/item-catalog/catalog-items.tsx"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"check-types": "tsgo --noEmit"
|
|
11
|
+
"check-types": "tsgo --noEmit",
|
|
12
|
+
"test": "bun test src"
|
|
12
13
|
},
|
|
13
14
|
"peerDependencies": {
|
|
14
|
-
"@pascal-app/core": "^1.0.0-beta.
|
|
15
|
-
"@pascal-app/viewer": "^1.0.0-beta.
|
|
15
|
+
"@pascal-app/core": "^1.0.0-beta.5",
|
|
16
|
+
"@pascal-app/viewer": "^1.0.0-beta.5",
|
|
16
17
|
"@react-three/drei": "^10",
|
|
17
18
|
"@react-three/fiber": "^9",
|
|
18
19
|
"next": ">=15",
|
|
@@ -56,8 +57,8 @@
|
|
|
56
57
|
"zustand": "^5.0.11"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
59
|
-
"@pascal-app/core": "^1.0.0-beta.
|
|
60
|
-
"@pascal-app/viewer": "^1.0.0-beta.
|
|
60
|
+
"@pascal-app/core": "^1.0.0-beta.5",
|
|
61
|
+
"@pascal-app/viewer": "^1.0.0-beta.5",
|
|
61
62
|
"@pascal/typescript-config": "*",
|
|
62
63
|
"@types/blob-stream": "^0.1.33",
|
|
63
64
|
"@types/bun": "^1.3.0",
|
|
@@ -66,6 +66,7 @@ export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
|
|
|
66
66
|
const guides = useAlignmentGuides((s) => s.guides)
|
|
67
67
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
68
68
|
const unit = useViewer((s) => s.unit)
|
|
69
|
+
const metricNotation = useViewer((s) => s.metricNotation)
|
|
69
70
|
const groupRef = useRef<Group>(null)
|
|
70
71
|
|
|
71
72
|
// Guides carry only XZ in WORLD coords; their Y has to track the active
|
|
@@ -85,16 +86,24 @@ export const Alignment3DGuideLayer = memo(function Alignment3DGuideLayer() {
|
|
|
85
86
|
return (
|
|
86
87
|
<group ref={groupRef}>
|
|
87
88
|
{guides.map((guide, i) => (
|
|
88
|
-
<GuideLine guide={guide} key={i} unit={unit} />
|
|
89
|
+
<GuideLine guide={guide} key={i} metricNotation={metricNotation} unit={unit} />
|
|
89
90
|
))}
|
|
90
91
|
</group>
|
|
91
92
|
)
|
|
92
93
|
})
|
|
93
94
|
|
|
94
|
-
function GuideLine({
|
|
95
|
+
function GuideLine({
|
|
96
|
+
guide,
|
|
97
|
+
metricNotation,
|
|
98
|
+
unit,
|
|
99
|
+
}: {
|
|
100
|
+
guide: AlignmentGuide
|
|
101
|
+
metricNotation: 'meters' | 'millimeters'
|
|
102
|
+
unit: 'metric' | 'imperial'
|
|
103
|
+
}) {
|
|
95
104
|
const { x: fx, z: fz } = guide.from
|
|
96
105
|
const { x: tx, z: tz } = guide.to
|
|
97
|
-
const distLabel = formatMeasurement(guide.distance, unit)
|
|
106
|
+
const distLabel = formatMeasurement(guide.distance, unit, metricNotation)
|
|
98
107
|
|
|
99
108
|
// Lay out the dash centres along the from→to direction. The ribbon
|
|
100
109
|
// stretches the dash period up if the line is long enough to exceed the
|
|
@@ -77,6 +77,11 @@ function LeftColumn({
|
|
|
77
77
|
// up to the minimum so the panel always returns to a usable size.
|
|
78
78
|
const handleRailClick = useCallback(
|
|
79
79
|
(id: string) => {
|
|
80
|
+
// noPanel tabs drive the stage, not the panel — leave collapse state alone.
|
|
81
|
+
if (tabs.find((t) => t.id === id)?.noPanel) {
|
|
82
|
+
setActivePanel(id)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
80
85
|
if (isCollapsed) {
|
|
81
86
|
setIsCollapsed(false)
|
|
82
87
|
if (width < SIDEBAR_MIN_WIDTH) setWidth(SIDEBAR_MIN_WIDTH)
|
|
@@ -89,7 +94,7 @@ function LeftColumn({
|
|
|
89
94
|
}
|
|
90
95
|
setActivePanel(id)
|
|
91
96
|
},
|
|
92
|
-
[isCollapsed, width, activePanel, setIsCollapsed, setWidth, setActivePanel],
|
|
97
|
+
[tabs, isCollapsed, width, activePanel, setIsCollapsed, setWidth, setActivePanel],
|
|
93
98
|
)
|
|
94
99
|
|
|
95
100
|
useEffect(() => {
|
|
@@ -126,7 +131,7 @@ function LeftColumn({
|
|
|
126
131
|
onIconClick={handleRailClick}
|
|
127
132
|
tabs={tabs}
|
|
128
133
|
/>
|
|
129
|
-
{!isCollapsed && (
|
|
134
|
+
{!isCollapsed && !tabs.find((t) => t.id === activePanel)?.noPanel && (
|
|
130
135
|
<div
|
|
131
136
|
className="relative flex h-full flex-col"
|
|
132
137
|
style={{
|
|
@@ -239,7 +244,7 @@ export function EditorLayoutV2({
|
|
|
239
244
|
overlays={overlays}
|
|
240
245
|
renderTabContent={renderTabContent}
|
|
241
246
|
sidebarOverlay={sidebarOverlay}
|
|
242
|
-
sidebarTabs={sidebarTabs}
|
|
247
|
+
sidebarTabs={sidebarTabs.filter((t) => !t.noPanel)}
|
|
243
248
|
viewerContent={viewerContent}
|
|
244
249
|
viewerToolbarLeft={viewerToolbarLeft}
|
|
245
250
|
viewerToolbarRight={viewerToolbarRight}
|
|
@@ -44,6 +44,7 @@ type Vec3 = [number, number, number]
|
|
|
44
44
|
export const Elevation3DGuideLayer = memo(function Elevation3DGuideLayer() {
|
|
45
45
|
const guide = useElevationGuides((state) => state.guide)
|
|
46
46
|
const unit = useViewer((state) => state.unit)
|
|
47
|
+
const metricNotation = useViewer((state) => state.metricNotation)
|
|
47
48
|
const groupRef = useRef<Group>(null)
|
|
48
49
|
|
|
49
50
|
useFrame(() => {
|
|
@@ -76,8 +77,8 @@ export const Elevation3DGuideLayer = memo(function Elevation3DGuideLayer() {
|
|
|
76
77
|
]
|
|
77
78
|
const elevationText =
|
|
78
79
|
Math.abs(guide.elevation) < 1e-6
|
|
79
|
-
? formatMeasurement(0, unit)
|
|
80
|
-
: `${guide.elevation < 0 ? '-' : '+'}${formatMeasurement(Math.abs(guide.elevation), unit)}`
|
|
80
|
+
? formatMeasurement(0, unit, metricNotation)
|
|
81
|
+
: `${guide.elevation < 0 ? '-' : '+'}${formatMeasurement(Math.abs(guide.elevation), unit, metricNotation)}`
|
|
81
82
|
|
|
82
83
|
return (
|
|
83
84
|
<group ref={groupRef}>
|
|
@@ -21,9 +21,8 @@ import {
|
|
|
21
21
|
openElevatorDoor,
|
|
22
22
|
pointInPolygon2D,
|
|
23
23
|
requestElevatorLevel,
|
|
24
|
-
resolveElevatorBuildingLevels,
|
|
25
24
|
resolveElevatorDispatchTarget,
|
|
26
|
-
|
|
25
|
+
resolveElevatorLevels,
|
|
27
26
|
sceneRegistry,
|
|
28
27
|
useInteractive,
|
|
29
28
|
useScene,
|
|
@@ -430,45 +429,6 @@ function isInsideElevatorCab(
|
|
|
430
429
|
)
|
|
431
430
|
}
|
|
432
431
|
|
|
433
|
-
function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
|
|
434
|
-
const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
|
|
435
|
-
const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
|
|
436
|
-
|
|
437
|
-
const baseYByLevelId = new Map<string, number>()
|
|
438
|
-
let cumulativeY = 0
|
|
439
|
-
for (const level of allLevels) {
|
|
440
|
-
const elevation = levelElevations.get(level.id)
|
|
441
|
-
const baseY = elevation?.baseY ?? 0
|
|
442
|
-
baseYByLevelId.set(level.id, baseY)
|
|
443
|
-
cumulativeY = Math.max(cumulativeY, baseY + (elevation?.height ?? 0))
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
|
|
447
|
-
const entries = serviceLevels.map((level) => ({
|
|
448
|
-
baseY: baseYByLevelId.get(level.id) ?? 0,
|
|
449
|
-
id: level.id as AnyNodeId,
|
|
450
|
-
}))
|
|
451
|
-
const firstServedLevel = serviceLevels[0] ?? null
|
|
452
|
-
const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
|
|
453
|
-
const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
|
|
454
|
-
const lastServedIndex = lastServedLevel
|
|
455
|
-
? allLevels.findIndex((level) => level.id === lastServedLevel.id)
|
|
456
|
-
: -1
|
|
457
|
-
const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
|
|
458
|
-
const shaftTopY = nextLevel
|
|
459
|
-
? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
|
|
460
|
-
: lastServedLevel
|
|
461
|
-
? cumulativeY
|
|
462
|
-
: elevator.cabHeight + 0.3
|
|
463
|
-
|
|
464
|
-
return {
|
|
465
|
-
entries,
|
|
466
|
-
shaftBaseY,
|
|
467
|
-
shaftTopY,
|
|
468
|
-
totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
432
|
function createElevatorColliderMesh(
|
|
473
433
|
elevatorId: AnyNodeId,
|
|
474
434
|
kind: ElevatorColliderKind,
|
|
@@ -519,10 +479,7 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
|
|
|
519
479
|
const node = nodes[typedElevatorId]
|
|
520
480
|
if (node?.type !== 'elevator' || node.visible === false) continue
|
|
521
481
|
|
|
522
|
-
const { entries, shaftBaseY, shaftTopY, totalHeight } =
|
|
523
|
-
node,
|
|
524
|
-
nodes,
|
|
525
|
-
)
|
|
482
|
+
const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorLevels(node, nodes)
|
|
526
483
|
const cabWidth = getElevatorCabWidth(node)
|
|
527
484
|
const cabDepth = getElevatorCabDepth(node)
|
|
528
485
|
const shaftWidth = getElevatorShaftWidth(node, cabWidth)
|
|
@@ -294,6 +294,7 @@ export function FloatingActionMenu() {
|
|
|
294
294
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
|
295
295
|
const setSelection = useViewer((s) => s.setSelection)
|
|
296
296
|
const unit = useViewer((s) => s.unit)
|
|
297
|
+
const metricNotation = useViewer((s) => s.metricNotation)
|
|
297
298
|
// Drives the height-drag dimension pill below the menu. `activeHandleDrag`
|
|
298
299
|
// flips only at drag start / end, so subscribing here is cheap — the live
|
|
299
300
|
// height value is written imperatively in the useFrame below.
|
|
@@ -417,7 +418,7 @@ export function FloatingActionMenu() {
|
|
|
417
418
|
? getWallEffectiveHeightForNodes(node, useScene.getState().nodes)
|
|
418
419
|
: FENCE_DEFAULT_HEIGHT
|
|
419
420
|
const liveHeight = override?.height ?? node.height ?? fallbackHeight
|
|
420
|
-
pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}`
|
|
421
|
+
pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit, metricNotation)}`
|
|
421
422
|
}
|
|
422
423
|
|
|
423
424
|
const obj = sceneRegistry.nodes.get(selectedId)
|
|
@@ -884,7 +885,7 @@ export function FloatingActionMenu() {
|
|
|
884
885
|
under it for duct fittings. */}
|
|
885
886
|
{node && hasPorts(node.type) ? (
|
|
886
887
|
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 mb-2 flex flex-col items-center gap-1">
|
|
887
|
-
<SystemSummaryPill nodeId={node.id} unit={unit} />
|
|
888
|
+
<SystemSummaryPill metricNotation={metricNotation} nodeId={node.id} unit={unit} />
|
|
888
889
|
{hasAxisCycling(node.type) ? (
|
|
889
890
|
<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">
|
|
890
891
|
<span className="font-medium text-foreground">
|
|
@@ -918,7 +919,15 @@ export function FloatingActionMenu() {
|
|
|
918
919
|
* subscription it needs (connectivity changes when ANY joint moves) doesn't
|
|
919
920
|
* re-render the always-mounted parent menu on every unrelated scene tick.
|
|
920
921
|
*/
|
|
921
|
-
function SystemSummaryPill({
|
|
922
|
+
function SystemSummaryPill({
|
|
923
|
+
metricNotation,
|
|
924
|
+
nodeId,
|
|
925
|
+
unit,
|
|
926
|
+
}: {
|
|
927
|
+
metricNotation: 'meters' | 'millimeters'
|
|
928
|
+
nodeId: AnyNodeId
|
|
929
|
+
unit: 'metric' | 'imperial'
|
|
930
|
+
}) {
|
|
922
931
|
const allNodes = useScene((s) => s.nodes)
|
|
923
932
|
const summary = useMemo(() => summarizeSystemFor(nodeId, allNodes), [nodeId, allNodes])
|
|
924
933
|
if (!summary) return null
|
|
@@ -935,7 +944,7 @@ function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric'
|
|
|
935
944
|
·
|
|
936
945
|
</span>
|
|
937
946
|
<span className="text-muted-foreground">
|
|
938
|
-
{formatMeasurement(summary.runLengthM, unit)} · {summary.runCount}{' '}
|
|
947
|
+
{formatMeasurement(summary.runLengthM, unit, metricNotation)} · {summary.runCount}{' '}
|
|
939
948
|
{summary.runCount === 1 ? 'run' : 'runs'}
|
|
940
949
|
</span>
|
|
941
950
|
</>
|
|
@@ -7,7 +7,7 @@ const baseArgs = {
|
|
|
7
7
|
currentSelectedIds: ['wall_1'],
|
|
8
8
|
getFloorplanHitIdAtPoint: () => 'door_1',
|
|
9
9
|
isWallBuildActive: false,
|
|
10
|
-
modifierKeys: { meta: false, ctrl: false, shift: false },
|
|
10
|
+
modifierKeys: { meta: false, ctrl: false, shift: false, alt: false },
|
|
11
11
|
planPoint: [0, 0] as [number, number],
|
|
12
12
|
structureLayer: 'elements',
|
|
13
13
|
}
|
|
@@ -16,7 +16,7 @@ describe('resolveFloorplanBackgroundSelection', () => {
|
|
|
16
16
|
test('shift-click on a floorplan node toggles into the current selection', () => {
|
|
17
17
|
const result = resolveFloorplanBackgroundSelection({
|
|
18
18
|
...baseArgs,
|
|
19
|
-
modifierKeys: { meta: false, ctrl: false, shift: true },
|
|
19
|
+
modifierKeys: { meta: false, ctrl: false, shift: true, alt: false },
|
|
20
20
|
})
|
|
21
21
|
|
|
22
22
|
expect(result).toEqual({
|
|
@@ -30,7 +30,7 @@ describe('resolveFloorplanBackgroundSelection', () => {
|
|
|
30
30
|
const result = resolveFloorplanBackgroundSelection({
|
|
31
31
|
...baseArgs,
|
|
32
32
|
currentSelectedIds: ['wall_1', 'door_1'],
|
|
33
|
-
modifierKeys: { meta: false, ctrl: false, shift: true },
|
|
33
|
+
modifierKeys: { meta: false, ctrl: false, shift: true, alt: false },
|
|
34
34
|
})
|
|
35
35
|
|
|
36
36
|
expect(result).toEqual({
|
|
@@ -44,7 +44,7 @@ describe('resolveFloorplanBackgroundSelection', () => {
|
|
|
44
44
|
const result = resolveFloorplanBackgroundSelection({
|
|
45
45
|
...baseArgs,
|
|
46
46
|
getFloorplanHitIdAtPoint: () => null,
|
|
47
|
-
modifierKeys: { meta: false, ctrl: false, shift: true },
|
|
47
|
+
modifierKeys: { meta: false, ctrl: false, shift: true, alt: false },
|
|
48
48
|
})
|
|
49
49
|
|
|
50
50
|
expect(result).toEqual({
|
|
@@ -54,6 +54,33 @@ describe('resolveFloorplanBackgroundSelection', () => {
|
|
|
54
54
|
})
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
+
test('plain click expands a session group', () => {
|
|
58
|
+
const result = resolveFloorplanBackgroundSelection({
|
|
59
|
+
...baseArgs,
|
|
60
|
+
expandIdsForNode: (nodeId) => (nodeId === 'door_1' ? ['door_1', 'wall_2'] : null),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
expect(result).toEqual({
|
|
64
|
+
handled: true,
|
|
65
|
+
kind: 'select-elements',
|
|
66
|
+
selectedIds: ['door_1', 'wall_2'],
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('alt-click selects one member without expanding', () => {
|
|
71
|
+
const result = resolveFloorplanBackgroundSelection({
|
|
72
|
+
...baseArgs,
|
|
73
|
+
expandIdsForNode: () => ['door_1', 'wall_2'],
|
|
74
|
+
modifierKeys: { meta: false, ctrl: false, shift: false, alt: true },
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(result).toEqual({
|
|
78
|
+
handled: true,
|
|
79
|
+
kind: 'select-elements',
|
|
80
|
+
selectedIds: ['door_1'],
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
57
84
|
test('uses the registry hit result for zone selection', () => {
|
|
58
85
|
const result = resolveFloorplanBackgroundSelection({
|
|
59
86
|
...baseArgs,
|
|
@@ -7,12 +7,16 @@ type ModifierKeys = {
|
|
|
7
7
|
meta: boolean
|
|
8
8
|
ctrl: boolean
|
|
9
9
|
shift: boolean
|
|
10
|
+
/** Alt alone: select one session-group member without expanding. */
|
|
11
|
+
alt: boolean
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
type ResolveFloorplanBackgroundSelectionArgs = {
|
|
13
15
|
canSelectElementFloorplanGeometry: boolean
|
|
14
16
|
canSelectFloorplanZones: boolean
|
|
15
17
|
currentSelectedIds: string[]
|
|
18
|
+
/** Session-group expand on plain click (not on modifier/Alt). */
|
|
19
|
+
expandIdsForNode?: (nodeId: string) => string[] | null
|
|
16
20
|
getFloorplanHitIdAtPoint: (planPoint: WallPlanPoint) => string | null
|
|
17
21
|
isWallBuildActive: boolean
|
|
18
22
|
modifierKeys: ModifierKeys
|
|
@@ -20,6 +24,26 @@ type ResolveFloorplanBackgroundSelectionArgs = {
|
|
|
20
24
|
structureLayer: string
|
|
21
25
|
}
|
|
22
26
|
|
|
27
|
+
function hasToggleModifier(modifierKeys: ModifierKeys): boolean {
|
|
28
|
+
return modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveHitSelection(
|
|
32
|
+
hitId: string,
|
|
33
|
+
currentSelectedIds: string[],
|
|
34
|
+
modifierKeys: ModifierKeys,
|
|
35
|
+
expandIdsForNode?: (nodeId: string) => string[] | null,
|
|
36
|
+
): string[] {
|
|
37
|
+
if (hasToggleModifier(modifierKeys)) {
|
|
38
|
+
return currentSelectedIds.includes(hitId)
|
|
39
|
+
? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
|
|
40
|
+
: [...currentSelectedIds, hitId]
|
|
41
|
+
}
|
|
42
|
+
if (modifierKeys.alt) return [hitId]
|
|
43
|
+
const expanded = expandIdsForNode?.(hitId)
|
|
44
|
+
return expanded && expanded.length > 1 ? expanded : [hitId]
|
|
45
|
+
}
|
|
46
|
+
|
|
23
47
|
export type FloorplanBackgroundSelectionResult =
|
|
24
48
|
| {
|
|
25
49
|
handled: true
|
|
@@ -48,6 +72,7 @@ export function resolveFloorplanBackgroundSelection({
|
|
|
48
72
|
canSelectElementFloorplanGeometry,
|
|
49
73
|
canSelectFloorplanZones,
|
|
50
74
|
currentSelectedIds,
|
|
75
|
+
expandIdsForNode,
|
|
51
76
|
getFloorplanHitIdAtPoint,
|
|
52
77
|
isWallBuildActive,
|
|
53
78
|
modifierKeys,
|
|
@@ -71,12 +96,7 @@ export function resolveFloorplanBackgroundSelection({
|
|
|
71
96
|
return {
|
|
72
97
|
handled: true,
|
|
73
98
|
kind: 'select-elements',
|
|
74
|
-
selectedIds:
|
|
75
|
-
modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
|
|
76
|
-
? currentSelectedIds.includes(hitId)
|
|
77
|
-
? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
|
|
78
|
-
: [...currentSelectedIds, hitId]
|
|
79
|
-
: [hitId],
|
|
99
|
+
selectedIds: resolveHitSelection(hitId, currentSelectedIds, modifierKeys, expandIdsForNode),
|
|
80
100
|
}
|
|
81
101
|
}
|
|
82
102
|
}
|
|
@@ -92,7 +112,7 @@ export function resolveFloorplanBackgroundSelection({
|
|
|
92
112
|
return {
|
|
93
113
|
handled: true,
|
|
94
114
|
kind: 'clear-elements',
|
|
95
|
-
preserveSelection: modifierKeys
|
|
115
|
+
preserveSelection: hasToggleModifier(modifierKeys),
|
|
96
116
|
}
|
|
97
117
|
}
|
|
98
118
|
|
|
@@ -115,6 +115,7 @@ import useInteractionScope, {
|
|
|
115
115
|
useReshapingNode,
|
|
116
116
|
} from '../../store/use-interaction-scope'
|
|
117
117
|
import usePlacementPreview from '../../store/use-placement-preview'
|
|
118
|
+
import { expandSessionSelectionForNode } from '../../store/use-session-groups'
|
|
118
119
|
import { useStairBuildPreview } from '../../store/use-stair-build-preview'
|
|
119
120
|
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
|
|
120
121
|
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
|
@@ -193,7 +194,7 @@ import {
|
|
|
193
194
|
} from '../tools/wall/wall-drafting'
|
|
194
195
|
|
|
195
196
|
import { PALETTE_COLORS } from '../ui/primitives/color-dot'
|
|
196
|
-
import {
|
|
197
|
+
import { FloorplanCompassButton } from '../viewer/floorplan-compass-button'
|
|
197
198
|
import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
|
|
198
199
|
import {
|
|
199
200
|
subscribeFloorplanCameraNavigation,
|
|
@@ -505,50 +506,6 @@ type GuideHandleHintAnchor = {
|
|
|
505
506
|
directionY: number
|
|
506
507
|
}
|
|
507
508
|
|
|
508
|
-
function FloorplanCompassButton({
|
|
509
|
-
northRotationDeg,
|
|
510
|
-
onAlignNorth,
|
|
511
|
-
needleRef,
|
|
512
|
-
}: {
|
|
513
|
-
northRotationDeg: number
|
|
514
|
-
onAlignNorth: () => void
|
|
515
|
-
needleRef?: React.RefObject<SVGSVGElement | null>
|
|
516
|
-
}) {
|
|
517
|
-
return (
|
|
518
|
-
<Tooltip>
|
|
519
|
-
<TooltipTrigger asChild>
|
|
520
|
-
<button
|
|
521
|
-
aria-label="Align view to north"
|
|
522
|
-
className="group absolute bottom-3 left-3 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white/85 shadow-sm backdrop-blur-md transition hover:bg-white hover:shadow-md dark:border-white/10 dark:bg-neutral-900/85 dark:hover:bg-neutral-900"
|
|
523
|
-
onClick={(event) => {
|
|
524
|
-
event.preventDefault()
|
|
525
|
-
event.stopPropagation()
|
|
526
|
-
onAlignNorth()
|
|
527
|
-
}}
|
|
528
|
-
onPointerDown={(event) => {
|
|
529
|
-
event.stopPropagation()
|
|
530
|
-
}}
|
|
531
|
-
type="button"
|
|
532
|
-
>
|
|
533
|
-
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
|
|
534
|
-
<svg
|
|
535
|
-
aria-hidden="true"
|
|
536
|
-
className="h-6 w-6"
|
|
537
|
-
ref={needleRef}
|
|
538
|
-
style={{ transform: `rotate(${northRotationDeg}deg)` }}
|
|
539
|
-
viewBox="0 0 48 48"
|
|
540
|
-
>
|
|
541
|
-
<path d="M24 4.5 31.5 25 24 21.5 16.5 25Z" fill="#f15b5b" />
|
|
542
|
-
<path d="M24 43.5 16.5 23 24 26.5 31.5 23Z" fill="#ffffff" />
|
|
543
|
-
</svg>
|
|
544
|
-
</span>
|
|
545
|
-
</button>
|
|
546
|
-
</TooltipTrigger>
|
|
547
|
-
<TooltipContent side="right">Align view to north</TooltipContent>
|
|
548
|
-
</Tooltip>
|
|
549
|
-
)
|
|
550
|
-
}
|
|
551
|
-
|
|
552
509
|
type GuideInteractionState = {
|
|
553
510
|
pointerId: number
|
|
554
511
|
guideId: GuideNode['id']
|
|
@@ -843,11 +800,13 @@ function getSelectionModifierKeys(event?: {
|
|
|
843
800
|
metaKey?: boolean
|
|
844
801
|
ctrlKey?: boolean
|
|
845
802
|
shiftKey?: boolean
|
|
803
|
+
altKey?: boolean
|
|
846
804
|
}) {
|
|
847
805
|
return {
|
|
848
806
|
meta: Boolean(event?.metaKey),
|
|
849
807
|
ctrl: Boolean(event?.ctrlKey),
|
|
850
808
|
shift: Boolean(event?.shiftKey),
|
|
809
|
+
alt: Boolean(event?.altKey),
|
|
851
810
|
}
|
|
852
811
|
}
|
|
853
812
|
|
|
@@ -9978,6 +9937,7 @@ export function FloorplanPanel({
|
|
|
9978
9937
|
canSelectElementFloorplanGeometry,
|
|
9979
9938
|
canSelectFloorplanZones,
|
|
9980
9939
|
currentSelectedIds: useViewer.getState().selection.selectedIds,
|
|
9940
|
+
expandIdsForNode: expandSessionSelectionForNode,
|
|
9981
9941
|
getFloorplanHitIdAtPoint,
|
|
9982
9942
|
isWallBuildActive,
|
|
9983
9943
|
modifierKeys,
|
|
@@ -7,28 +7,27 @@ import { useFrame } from '@react-three/fiber'
|
|
|
7
7
|
import { useCallback, useMemo, useRef } from 'react'
|
|
8
8
|
import * as THREE from 'three'
|
|
9
9
|
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
|
|
10
|
+
import { canCreateSessionGroup, selectionIntersectsSessionGroup } from '../../lib/session-groups'
|
|
10
11
|
import useEditor from '../../store/use-editor'
|
|
11
12
|
import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
|
|
13
|
+
import useSessionGroups, {
|
|
14
|
+
groupCurrentSelection,
|
|
15
|
+
ungroupCurrentSelection,
|
|
16
|
+
} from '../../store/use-session-groups'
|
|
12
17
|
import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from './group-actions'
|
|
13
18
|
import { classifyParticipant, computeGroupBox, expandToComponent } from './group-transform-shared'
|
|
14
19
|
import { NodeActionMenu } from './node-action-menu'
|
|
15
20
|
import { useMeshSettleEpoch } from './use-mesh-settle-epoch'
|
|
16
21
|
|
|
17
|
-
// Matches the single-node FloatingActionMenu's zoom-compensation constants.
|
|
18
22
|
const REF_ORTHO_ZOOM = 50
|
|
19
23
|
const REF_CAMERA_DISTANCE = 12
|
|
20
24
|
const MIN_MENU_SCALE = 0.6
|
|
21
25
|
const MAX_MENU_SCALE = 1.4
|
|
22
|
-
// Clearance above the group's bbox top so the pill doesn't sit on the meshes.
|
|
23
26
|
const MENU_Y_OFFSET = 0.42
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
|
-
* Floating
|
|
27
|
-
*
|
|
28
|
-
* sole-selection only). Anchored above the selection's bounding-box center;
|
|
29
|
-
* every action targets the whole selection: Move picks the group up (it rides
|
|
30
|
-
* the cursor until a click places it), Duplicate clones the selection and
|
|
31
|
-
* picks the clones up, Delete removes everything selected.
|
|
29
|
+
* Floating pill for MULTI-selection in 3D: Move, Group, Ungroup, Duplicate, Delete.
|
|
30
|
+
* Group/Ungroup are session-only (Ctrl/Cmd+G / Ctrl/Cmd+Shift+G).
|
|
32
31
|
*/
|
|
33
32
|
export function GroupFloatingActionMenu() {
|
|
34
33
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
@@ -37,8 +36,7 @@ export function GroupFloatingActionMenu() {
|
|
|
37
36
|
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
|
38
37
|
const movingNode = useMovingNode()
|
|
39
38
|
const nodes = useScene((s) => s.nodes)
|
|
40
|
-
|
|
41
|
-
// pill never competes with the live action — same policy as the 1-node menu.
|
|
39
|
+
const sessionGroups = useSessionGroups((s) => s.groups)
|
|
42
40
|
const scope = useInteractionScope((s) => s.scope)
|
|
43
41
|
const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden'
|
|
44
42
|
|
|
@@ -55,9 +53,18 @@ export function GroupFloatingActionMenu() {
|
|
|
55
53
|
[selectedIds, levelId, nodes],
|
|
56
54
|
)
|
|
57
55
|
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
56
|
+
const liveIds = useMemo(() => new Set(Object.keys(nodes)), [nodes])
|
|
57
|
+
// Always offer Group when multi-select is not already exactly a session group.
|
|
58
|
+
const showGroup = useMemo(
|
|
59
|
+
() => canCreateSessionGroup(sessionGroups, selectedIds, liveIds),
|
|
60
|
+
[sessionGroups, selectedIds, liveIds],
|
|
61
|
+
)
|
|
62
|
+
// Ungroup when any selected member is in a session group.
|
|
63
|
+
const showUngroup = useMemo(
|
|
64
|
+
() => selectionIntersectsSessionGroup(sessionGroups, selectedIds, liveIds),
|
|
65
|
+
[sessionGroups, selectedIds, liveIds],
|
|
66
|
+
)
|
|
67
|
+
|
|
61
68
|
const meshEpoch = useMeshSettleEpoch(nodes)
|
|
62
69
|
const anchor = useMemo(() => {
|
|
63
70
|
void meshEpoch
|
|
@@ -73,8 +80,6 @@ export function GroupFloatingActionMenu() {
|
|
|
73
80
|
}, [participantIds, nodes, levelId, meshEpoch])
|
|
74
81
|
|
|
75
82
|
useFrame((state) => {
|
|
76
|
-
// Scale the HTML pill with camera zoom / distance so it feels anchored to
|
|
77
|
-
// the world — mirrors the single-node menu.
|
|
78
83
|
if (!(menuScaleRef.current && groupRef.current)) return
|
|
79
84
|
const raw =
|
|
80
85
|
state.camera instanceof THREE.OrthographicCamera
|
|
@@ -92,6 +97,14 @@ export function GroupFloatingActionMenu() {
|
|
|
92
97
|
event.stopPropagation()
|
|
93
98
|
startGroupPickUp()
|
|
94
99
|
}, [])
|
|
100
|
+
const handleGroup = useCallback((event: React.MouseEvent) => {
|
|
101
|
+
event.stopPropagation()
|
|
102
|
+
groupCurrentSelection()
|
|
103
|
+
}, [])
|
|
104
|
+
const handleUngroup = useCallback((event: React.MouseEvent) => {
|
|
105
|
+
event.stopPropagation()
|
|
106
|
+
ungroupCurrentSelection()
|
|
107
|
+
}, [])
|
|
95
108
|
const handleDuplicate = useCallback((event: React.MouseEvent) => {
|
|
96
109
|
event.stopPropagation()
|
|
97
110
|
duplicateSelectionAndPickUp()
|
|
@@ -119,9 +132,11 @@ export function GroupFloatingActionMenu() {
|
|
|
119
132
|
<NodeActionMenu
|
|
120
133
|
onDelete={handleDelete}
|
|
121
134
|
onDuplicate={handleDuplicate}
|
|
135
|
+
onGroup={showGroup ? handleGroup : undefined}
|
|
122
136
|
onMove={handleMove}
|
|
123
137
|
onPointerDown={stopPointer}
|
|
124
138
|
onPointerUp={stopPointer}
|
|
139
|
+
onUngroup={showUngroup ? handleUngroup : undefined}
|
|
125
140
|
/>
|
|
126
141
|
</div>
|
|
127
142
|
</Html>
|