@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
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNode,
|
|
5
|
+
type AnyNodeId,
|
|
6
|
+
type FloorplanGeometry,
|
|
7
|
+
type FloorplanPalette,
|
|
8
|
+
type GeometryContext,
|
|
9
|
+
isNodeKindEnabled,
|
|
10
|
+
nodeRegistry,
|
|
11
|
+
useScene,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { AnyNode as AnyNodeSchema } from '@pascal-app/core/schema'
|
|
14
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
15
|
+
import { Maximize2, Minus, Plus } from 'lucide-react'
|
|
16
|
+
import {
|
|
17
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
18
|
+
type PointerEvent as ReactPointerEvent,
|
|
19
|
+
type WheelEvent as ReactWheelEvent,
|
|
20
|
+
useCallback,
|
|
21
|
+
useEffect,
|
|
22
|
+
useId,
|
|
23
|
+
useLayoutEffect,
|
|
24
|
+
useMemo,
|
|
25
|
+
useRef,
|
|
26
|
+
useState,
|
|
27
|
+
} from 'react'
|
|
28
|
+
import { createPortal } from 'react-dom'
|
|
29
|
+
import {
|
|
30
|
+
FLOORPLAN_VIEW_ROTATION_DEG,
|
|
31
|
+
floorplanLocalToWorldPoint,
|
|
32
|
+
worldToFloorplanLocalPoint,
|
|
33
|
+
} from '../../lib/floorplan'
|
|
34
|
+
import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
|
|
35
|
+
import { buildFloorplanContext, floorplanLayerRank } from '../../lib/floorplan/floorplan-readonly'
|
|
36
|
+
import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
|
|
37
|
+
import useEditor, { type NavigationSyncPose } from '../../store/use-editor'
|
|
38
|
+
import {
|
|
39
|
+
subscribeFloorplanCameraNavigation,
|
|
40
|
+
useFloorplanCameraSyncBridge,
|
|
41
|
+
} from '../editor/floorplan-camera-sync'
|
|
42
|
+
import {
|
|
43
|
+
createFloorplanNavigationSyncScheduler,
|
|
44
|
+
setFloorplanCompassRotation,
|
|
45
|
+
} from '../editor/floorplan-navigation-presentation'
|
|
46
|
+
import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer'
|
|
47
|
+
import { FloorplanCompassButton } from './floorplan-compass-button'
|
|
48
|
+
import {
|
|
49
|
+
type FloorplanBounds,
|
|
50
|
+
type FloorplanViewBox,
|
|
51
|
+
getFloorplanBounds,
|
|
52
|
+
padFloorplanBounds,
|
|
53
|
+
panFloorplanViewBox,
|
|
54
|
+
scaleFloorplanViewBox,
|
|
55
|
+
scaleFloorplanViewBoxBetweenClients,
|
|
56
|
+
} from './floorplan-preview-geometry'
|
|
57
|
+
import {
|
|
58
|
+
cameraAzimuthFromFloorplanRotation,
|
|
59
|
+
floorplanRotationFromCameraAzimuth,
|
|
60
|
+
floorplanViewBoxFromNavigationPose,
|
|
61
|
+
nearestEquivalentDegrees,
|
|
62
|
+
rotateFloorplanPoint,
|
|
63
|
+
visibleFloorplanViewWidth,
|
|
64
|
+
} from './floorplan-preview-navigation'
|
|
65
|
+
|
|
66
|
+
const READ_ONLY_PALETTE: FloorplanPalette = {
|
|
67
|
+
selectedStroke: '#4f46e5',
|
|
68
|
+
selectedFill: '#e0e7ff',
|
|
69
|
+
selectedHatch: '#818cf8',
|
|
70
|
+
wallHoverStroke: '#64748b',
|
|
71
|
+
endpointHandleFill: '#ffffff',
|
|
72
|
+
endpointHandleStroke: '#f97316',
|
|
73
|
+
endpointHandleHoverStroke: '#fb923c',
|
|
74
|
+
endpointHandleActiveFill: '#fed7aa',
|
|
75
|
+
endpointHandleActiveStroke: '#ea580c',
|
|
76
|
+
curveHandleFill: '#ffffff',
|
|
77
|
+
curveHandleStroke: '#0d9488',
|
|
78
|
+
curveHandleHoverStroke: '#14b8a6',
|
|
79
|
+
measurementStroke: '#475569',
|
|
80
|
+
measurementLabelBackground: '#ffffff',
|
|
81
|
+
measurementLabelText: '#0f172a',
|
|
82
|
+
}
|
|
83
|
+
const EMPTY_PREVIEW_NODES: Record<string, AnyNode> = {}
|
|
84
|
+
const EMPTY_INSTALLED_PLUGINS: readonly string[] = []
|
|
85
|
+
|
|
86
|
+
export type FloorplanPreviewScene = {
|
|
87
|
+
nodes: Record<string, AnyNode>
|
|
88
|
+
installedPlugins?: readonly string[]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type FloorplanPreviewProps = {
|
|
92
|
+
className?: string
|
|
93
|
+
compassHost?: Element | null
|
|
94
|
+
levelId?: string | null
|
|
95
|
+
navigationVisible?: boolean
|
|
96
|
+
onLevelChange?: (levelId: string) => void
|
|
97
|
+
scene?: FloorplanPreviewScene | null
|
|
98
|
+
showCompass?: boolean
|
|
99
|
+
showLevelSelector?: boolean
|
|
100
|
+
synchronizeNavigation?: boolean
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type PointerPoint = { x: number; y: number }
|
|
104
|
+
type DragState = {
|
|
105
|
+
mode: 'pan' | 'rotate'
|
|
106
|
+
pointerId: number
|
|
107
|
+
point: PointerPoint
|
|
108
|
+
rotationDeg: number
|
|
109
|
+
viewBox: FloorplanViewBox
|
|
110
|
+
}
|
|
111
|
+
type PinchState = {
|
|
112
|
+
pointerIds: [number, number]
|
|
113
|
+
midpoint: PointerPoint
|
|
114
|
+
distance: number
|
|
115
|
+
viewBox: FloorplanViewBox
|
|
116
|
+
}
|
|
117
|
+
type FloorplanRenderEntry = {
|
|
118
|
+
id: string
|
|
119
|
+
geometry: FloorplanGeometry
|
|
120
|
+
includeInInitialFit: boolean
|
|
121
|
+
}
|
|
122
|
+
type FloorplanSourceEntry = {
|
|
123
|
+
node: AnyNode
|
|
124
|
+
contextOverrides?: Pick<GeometryContext, 'children' | 'siblings' | 'parent'>
|
|
125
|
+
}
|
|
126
|
+
type MeasuredFit = { key: string; bounds: FloorplanBounds }
|
|
127
|
+
|
|
128
|
+
const useClientLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
|
|
129
|
+
|
|
130
|
+
function FloorplanCameraSyncMount() {
|
|
131
|
+
useFloorplanCameraSyncBridge()
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function boundsToViewBox(bounds: FloorplanBounds): FloorplanViewBox {
|
|
136
|
+
return {
|
|
137
|
+
x: bounds.minX,
|
|
138
|
+
y: bounds.minY,
|
|
139
|
+
width: Math.max(bounds.maxX - bounds.minX, 1),
|
|
140
|
+
height: Math.max(bounds.maxY - bounds.minY, 1),
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function levelLabel(level: AnyNode): string {
|
|
145
|
+
const named = (level as { name?: string }).name?.trim()
|
|
146
|
+
if (named) return named
|
|
147
|
+
const ordinal = (level as { level?: number }).level ?? 0
|
|
148
|
+
if (ordinal === 0) return 'Ground floor'
|
|
149
|
+
if (ordinal < 0) return `Basement ${Math.abs(ordinal)}`
|
|
150
|
+
return `Level ${ordinal}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function collectLevelTree(root: AnyNode, nodes: Record<string, AnyNode>): AnyNode[] {
|
|
154
|
+
const result: AnyNode[] = [root]
|
|
155
|
+
const seen = new Set<string>()
|
|
156
|
+
const queue = [...((root as { children?: AnyNodeId[] }).children ?? [])]
|
|
157
|
+
let index = 0
|
|
158
|
+
while (index < queue.length) {
|
|
159
|
+
const id = queue[index++]
|
|
160
|
+
if (!id || seen.has(id)) continue
|
|
161
|
+
seen.add(id)
|
|
162
|
+
const node = nodes[id]
|
|
163
|
+
if (!node) continue
|
|
164
|
+
result.push(node)
|
|
165
|
+
queue.push(...((node as { children?: AnyNodeId[] }).children ?? []))
|
|
166
|
+
}
|
|
167
|
+
return result
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function normalizeFloorplanPreviewNodes(
|
|
171
|
+
nodes: Record<string, unknown>,
|
|
172
|
+
): Record<string, AnyNode> {
|
|
173
|
+
const normalized: Record<string, AnyNode> = {}
|
|
174
|
+
for (const [id, node] of Object.entries(nodes)) {
|
|
175
|
+
const builtin = AnyNodeSchema.safeParse(node)
|
|
176
|
+
if (builtin.success) {
|
|
177
|
+
normalized[builtin.data.id] = builtin.data
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
const type =
|
|
181
|
+
node && typeof node === 'object' && !Array.isArray(node)
|
|
182
|
+
? (node as { type?: unknown }).type
|
|
183
|
+
: null
|
|
184
|
+
const registered =
|
|
185
|
+
typeof type === 'string' ? nodeRegistry.get(type)?.schema.safeParse(node) : null
|
|
186
|
+
if (registered?.success) {
|
|
187
|
+
const parsed = registered.data as AnyNode
|
|
188
|
+
normalized[parsed.id] = parsed
|
|
189
|
+
} else {
|
|
190
|
+
console.warn(`[floorplan-preview] Skipping invalid node ${id}`, builtin.error.issues)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return normalized
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isVisibleInFloorplan(node: AnyNode, nodes: Record<string, AnyNode>): boolean {
|
|
197
|
+
const seen = new Set<string>()
|
|
198
|
+
let current: AnyNode | undefined = node
|
|
199
|
+
while (current) {
|
|
200
|
+
if (seen.has(current.id)) return true
|
|
201
|
+
seen.add(current.id)
|
|
202
|
+
if (current.visible === false) return false
|
|
203
|
+
current = current.parentId ? nodes[current.parentId] : undefined
|
|
204
|
+
}
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function buildFloorplanGeometries(
|
|
209
|
+
nodes: Record<string, AnyNode>,
|
|
210
|
+
installedPlugins: readonly string[] | undefined,
|
|
211
|
+
level: AnyNode,
|
|
212
|
+
unit: 'metric' | 'imperial',
|
|
213
|
+
metricNotation: 'meters' | 'millimeters',
|
|
214
|
+
): FloorplanRenderEntry[] {
|
|
215
|
+
const building = level.parentId ? nodes[level.parentId] : undefined
|
|
216
|
+
const levelTree = collectLevelTree(level, nodes)
|
|
217
|
+
const entries: FloorplanSourceEntry[] = levelTree.map((node) => ({ node }))
|
|
218
|
+
const entryIds = new Set(entries.map((entry) => entry.node.id))
|
|
219
|
+
if (building) {
|
|
220
|
+
for (const candidate of Object.values(nodes)) {
|
|
221
|
+
const definition = nodeRegistry.get(candidate.type)
|
|
222
|
+
if (
|
|
223
|
+
definition?.floorplanScope === 'building' &&
|
|
224
|
+
candidate.parentId === building.id &&
|
|
225
|
+
!entryIds.has(candidate.id)
|
|
226
|
+
) {
|
|
227
|
+
entries.push({
|
|
228
|
+
node: candidate,
|
|
229
|
+
contextOverrides: { children: [], siblings: [], parent: level },
|
|
230
|
+
})
|
|
231
|
+
entryIds.add(candidate.id)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const candidate of Object.values(nodes)) {
|
|
236
|
+
const definition = nodeRegistry.get(candidate.type)
|
|
237
|
+
const linkedLevelIds = getFloorplanNodeExtension(definition)?.linkedLevelIds
|
|
238
|
+
if (
|
|
239
|
+
definition?.floorplan &&
|
|
240
|
+
linkedLevelIds?.(candidate).includes(level.id) &&
|
|
241
|
+
!entryIds.has(candidate.id)
|
|
242
|
+
) {
|
|
243
|
+
const childIds = (candidate as { children?: AnyNodeId[] }).children
|
|
244
|
+
const children = Array.isArray(childIds)
|
|
245
|
+
? childIds.map((id) => nodes[id]).filter((child): child is AnyNode => child !== undefined)
|
|
246
|
+
: []
|
|
247
|
+
entries.push({
|
|
248
|
+
node: candidate,
|
|
249
|
+
contextOverrides: { children, siblings: [], parent: level },
|
|
250
|
+
})
|
|
251
|
+
entryIds.add(candidate.id)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const renderable = entries
|
|
256
|
+
.filter((entry) => isVisibleInFloorplan(entry.node, nodes))
|
|
257
|
+
.filter((entry) => isNodeKindEnabled(entry.node.type, installedPlugins))
|
|
258
|
+
.filter((entry) => nodeRegistry.get(entry.node.type)?.floorplan)
|
|
259
|
+
.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type))
|
|
260
|
+
const byType = new Map<string, AnyNode[]>()
|
|
261
|
+
for (const node of levelTree) {
|
|
262
|
+
if (!isNodeKindEnabled(node.type, installedPlugins)) continue
|
|
263
|
+
if (!nodeRegistry.get(node.type)?.computeFloorplanLevelData) continue
|
|
264
|
+
const siblings = byType.get(node.type) ?? []
|
|
265
|
+
siblings.push(node)
|
|
266
|
+
byType.set(node.type, siblings)
|
|
267
|
+
}
|
|
268
|
+
const levelData = new Map<string, unknown>()
|
|
269
|
+
for (const [type, siblings] of byType) {
|
|
270
|
+
const definition = nodeRegistry.get(type)
|
|
271
|
+
if (definition?.computeFloorplanLevelData) {
|
|
272
|
+
levelData.set(type, definition.computeFloorplanLevelData({ siblings, nodes }))
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const geometries: FloorplanRenderEntry[] = []
|
|
277
|
+
for (const { node, contextOverrides } of renderable) {
|
|
278
|
+
const definition = nodeRegistry.get(node.type)
|
|
279
|
+
if (!definition?.floorplan) continue
|
|
280
|
+
const baseContext = buildFloorplanContext(
|
|
281
|
+
node,
|
|
282
|
+
nodes,
|
|
283
|
+
{
|
|
284
|
+
automaticDimensions: false,
|
|
285
|
+
selected: false,
|
|
286
|
+
unit,
|
|
287
|
+
metricNotation,
|
|
288
|
+
purpose: 'document',
|
|
289
|
+
highlighted: false,
|
|
290
|
+
hovered: false,
|
|
291
|
+
moving: false,
|
|
292
|
+
palette: READ_ONLY_PALETTE,
|
|
293
|
+
},
|
|
294
|
+
levelData.get(node.type),
|
|
295
|
+
)
|
|
296
|
+
const context = contextOverrides ? { ...baseContext, ...contextOverrides } : baseContext
|
|
297
|
+
try {
|
|
298
|
+
const geometry = definition.floorplan(node as never, context)
|
|
299
|
+
if (geometry) {
|
|
300
|
+
geometries.push({
|
|
301
|
+
id: node.id,
|
|
302
|
+
geometry,
|
|
303
|
+
includeInInitialFit: definition.category !== 'furnish',
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
} catch (error) {
|
|
307
|
+
console.error(`[floorplan-preview] Failed to render ${node.type}:${node.id}`, error)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return geometries
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function FloorplanPreview({
|
|
314
|
+
className,
|
|
315
|
+
compassHost,
|
|
316
|
+
levelId,
|
|
317
|
+
navigationVisible = true,
|
|
318
|
+
onLevelChange,
|
|
319
|
+
scene,
|
|
320
|
+
showCompass = true,
|
|
321
|
+
showLevelSelector = true,
|
|
322
|
+
synchronizeNavigation = false,
|
|
323
|
+
}: FloorplanPreviewProps) {
|
|
324
|
+
const storeNodes = useScene((state) => (scene ? EMPTY_PREVIEW_NODES : state.nodes))
|
|
325
|
+
const storeInstalledPlugins = useScene((state) =>
|
|
326
|
+
scene ? EMPTY_INSTALLED_PLUGINS : state.installedPlugins,
|
|
327
|
+
)
|
|
328
|
+
const unit = useViewer((state) => state.unit)
|
|
329
|
+
const metricNotation = useViewer((state) => state.metricNotation)
|
|
330
|
+
const externalNodes = useMemo(
|
|
331
|
+
() => (scene?.nodes ? normalizeFloorplanPreviewNodes(scene.nodes) : null),
|
|
332
|
+
[scene?.nodes],
|
|
333
|
+
)
|
|
334
|
+
const nodes = externalNodes ?? storeNodes
|
|
335
|
+
const installedPlugins = scene ? scene.installedPlugins : storeInstalledPlugins
|
|
336
|
+
const levels = useMemo(
|
|
337
|
+
() =>
|
|
338
|
+
Object.values(nodes)
|
|
339
|
+
.filter((node) => node.type === 'level')
|
|
340
|
+
.sort(
|
|
341
|
+
(a, b) => ((a as { level?: number }).level ?? 0) - ((b as { level?: number }).level ?? 0),
|
|
342
|
+
),
|
|
343
|
+
[nodes],
|
|
344
|
+
)
|
|
345
|
+
const [internalLevelId, setInternalLevelId] = useState<string | null>(null)
|
|
346
|
+
const activeLevelId =
|
|
347
|
+
(levelId && levels.some((level) => level.id === levelId) ? levelId : null) ??
|
|
348
|
+
(internalLevelId && levels.some((level) => level.id === internalLevelId)
|
|
349
|
+
? internalLevelId
|
|
350
|
+
: null) ??
|
|
351
|
+
levels[0]?.id ??
|
|
352
|
+
null
|
|
353
|
+
const activeLevel = activeLevelId ? nodes[activeLevelId as AnyNodeId] : undefined
|
|
354
|
+
const activeBuilding = activeLevel?.parentId
|
|
355
|
+
? nodes[activeLevel.parentId as AnyNodeId]
|
|
356
|
+
: undefined
|
|
357
|
+
const buildingPosition = useMemo<[number, number, number]>(
|
|
358
|
+
() => (activeBuilding?.type === 'building' ? activeBuilding.position : [0, 0, 0]),
|
|
359
|
+
[activeBuilding],
|
|
360
|
+
)
|
|
361
|
+
const buildingRotationY = activeBuilding?.type === 'building' ? activeBuilding.rotation[1] : 0
|
|
362
|
+
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
|
|
363
|
+
const renderEntries = useMemo(
|
|
364
|
+
() =>
|
|
365
|
+
activeLevel
|
|
366
|
+
? buildFloorplanGeometries(nodes, installedPlugins, activeLevel, unit, metricNotation)
|
|
367
|
+
: [],
|
|
368
|
+
[activeLevel, installedPlugins, metricNotation, nodes, unit],
|
|
369
|
+
)
|
|
370
|
+
const framingEntries = useMemo(() => {
|
|
371
|
+
const structural = renderEntries.filter((entry) => entry.includeInInitialFit)
|
|
372
|
+
return structural.length > 0 ? structural : renderEntries
|
|
373
|
+
}, [renderEntries])
|
|
374
|
+
const fitKey = `${activeLevelId ?? 'none'}:${framingEntries.map((entry) => entry.id).join('|')}`
|
|
375
|
+
const geometricFitBounds = useMemo(
|
|
376
|
+
() => getFloorplanBounds(framingEntries.map((entry) => entry.geometry)),
|
|
377
|
+
[framingEntries],
|
|
378
|
+
)
|
|
379
|
+
const [measuredFit, setMeasuredFit] = useState<MeasuredFit | null>(null)
|
|
380
|
+
const fittedViewBox = useMemo(() => {
|
|
381
|
+
const bounds = measuredFit?.key === fitKey ? measuredFit.bounds : geometricFitBounds
|
|
382
|
+
return bounds
|
|
383
|
+
? boundsToViewBox(padFloorplanBounds(bounds))
|
|
384
|
+
: { x: -5, y: -5, width: 10, height: 10 }
|
|
385
|
+
}, [fitKey, geometricFitBounds, measuredFit])
|
|
386
|
+
const [viewBox, setViewBox] = useState<FloorplanViewBox>(fittedViewBox)
|
|
387
|
+
const viewBoxRef = useRef(viewBox)
|
|
388
|
+
const interactionRef = useRef<HTMLDivElement | null>(null)
|
|
389
|
+
const svgRef = useRef<SVGSVGElement | null>(null)
|
|
390
|
+
const gridRef = useRef<SVGRectElement | null>(null)
|
|
391
|
+
const sceneRef = useRef<SVGGElement | null>(null)
|
|
392
|
+
const compassNeedleRef = useRef<SVGSVGElement | null>(null)
|
|
393
|
+
const fitElementRefs = useRef(new Map<string, SVGGElement>())
|
|
394
|
+
const pointersRef = useRef(new Map<number, PointerPoint>())
|
|
395
|
+
const dragRef = useRef<DragState | null>(null)
|
|
396
|
+
const pinchRef = useRef<PinchState | null>(null)
|
|
397
|
+
const [isPanning, setIsPanning] = useState(false)
|
|
398
|
+
const [isRotating, setIsRotating] = useState(false)
|
|
399
|
+
const [viewportSize, setViewportSize] = useState({ width: 1000, height: 1000 })
|
|
400
|
+
const [rotationDeg, setRotationDeg] = useState(0)
|
|
401
|
+
const rotationDegRef = useRef(rotationDeg)
|
|
402
|
+
const latestNavigationPoseRef = useRef<NavigationSyncPose | null>(null)
|
|
403
|
+
const gridPatternId = useId().replaceAll(':', '')
|
|
404
|
+
|
|
405
|
+
const updateViewBox = useCallback(
|
|
406
|
+
(update: FloorplanViewBox | ((current: FloorplanViewBox) => FloorplanViewBox)) => {
|
|
407
|
+
setViewBox((current) => {
|
|
408
|
+
const next = typeof update === 'function' ? update(current) : update
|
|
409
|
+
viewBoxRef.current = next
|
|
410
|
+
return next
|
|
411
|
+
})
|
|
412
|
+
},
|
|
413
|
+
[],
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
const presentViewBox = useCallback((next: FloorplanViewBox) => {
|
|
417
|
+
viewBoxRef.current = next
|
|
418
|
+
const value = `${next.x} ${next.y} ${next.width} ${next.height}`
|
|
419
|
+
svgRef.current?.setAttribute('viewBox', value)
|
|
420
|
+
const grid = gridRef.current
|
|
421
|
+
if (grid) {
|
|
422
|
+
grid.setAttribute('x', String(next.x))
|
|
423
|
+
grid.setAttribute('y', String(next.y))
|
|
424
|
+
grid.setAttribute('width', String(next.width))
|
|
425
|
+
grid.setAttribute('height', String(next.height))
|
|
426
|
+
}
|
|
427
|
+
}, [])
|
|
428
|
+
|
|
429
|
+
const presentRotation = useCallback(
|
|
430
|
+
(nextRotationDeg: number) => {
|
|
431
|
+
rotationDegRef.current = nextRotationDeg
|
|
432
|
+
const sceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg
|
|
433
|
+
const sceneElement = sceneRef.current
|
|
434
|
+
if (sceneElement) {
|
|
435
|
+
if (sceneRotationDeg === 0) sceneElement.removeAttribute('transform')
|
|
436
|
+
else sceneElement.setAttribute('transform', `rotate(${sceneRotationDeg})`)
|
|
437
|
+
}
|
|
438
|
+
setFloorplanCompassRotation(compassNeedleRef.current, nextRotationDeg)
|
|
439
|
+
},
|
|
440
|
+
[buildingRotationDeg],
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
const commitPresentation = useCallback(
|
|
444
|
+
(nextViewBox: FloorplanViewBox, nextRotationDeg: number) => {
|
|
445
|
+
presentViewBox(nextViewBox)
|
|
446
|
+
presentRotation(nextRotationDeg)
|
|
447
|
+
setViewBox(nextViewBox)
|
|
448
|
+
setRotationDeg(nextRotationDeg)
|
|
449
|
+
},
|
|
450
|
+
[presentRotation, presentViewBox],
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
const publishNavigation = useCallback(
|
|
454
|
+
(nextViewBox: FloorplanViewBox, nextRotationDeg: number) => {
|
|
455
|
+
if (!synchronizeNavigation) return
|
|
456
|
+
const sceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg
|
|
457
|
+
const displayedCenter = {
|
|
458
|
+
x: nextViewBox.x + nextViewBox.width / 2,
|
|
459
|
+
y: nextViewBox.y + nextViewBox.height / 2,
|
|
460
|
+
}
|
|
461
|
+
const localCenter = rotateFloorplanPoint(displayedCenter, -sceneRotationDeg)
|
|
462
|
+
const worldCenter = floorplanLocalToWorldPoint(
|
|
463
|
+
localCenter,
|
|
464
|
+
buildingPosition,
|
|
465
|
+
buildingRotationY,
|
|
466
|
+
)
|
|
467
|
+
useEditor.getState().publishNavigationSyncPose({
|
|
468
|
+
source: '2d',
|
|
469
|
+
target: [
|
|
470
|
+
worldCenter.x,
|
|
471
|
+
latestNavigationPoseRef.current?.target[1] ?? buildingPosition[1],
|
|
472
|
+
worldCenter.z,
|
|
473
|
+
],
|
|
474
|
+
azimuth: cameraAzimuthFromFloorplanRotation(nextRotationDeg),
|
|
475
|
+
viewWidth: visibleFloorplanViewWidth(nextViewBox, viewportSize),
|
|
476
|
+
})
|
|
477
|
+
},
|
|
478
|
+
[buildingPosition, buildingRotationDeg, buildingRotationY, synchronizeNavigation, viewportSize],
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
const applyNavigationPresentationRef = useRef<(pose: NavigationSyncPose) => void>(() => {})
|
|
482
|
+
const commitNavigationPresentationRef = useRef<(pose: NavigationSyncPose) => void>(() => {})
|
|
483
|
+
const navigationSchedulerRef = useRef<ReturnType<
|
|
484
|
+
typeof createFloorplanNavigationSyncScheduler<NavigationSyncPose>
|
|
485
|
+
> | null>(null)
|
|
486
|
+
if (!navigationSchedulerRef.current) {
|
|
487
|
+
navigationSchedulerRef.current = createFloorplanNavigationSyncScheduler<NavigationSyncPose>({
|
|
488
|
+
applyPresentation: (pose) => applyNavigationPresentationRef.current(pose),
|
|
489
|
+
commit: (pose) => commitNavigationPresentationRef.current(pose),
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
applyNavigationPresentationRef.current = (pose) => {
|
|
494
|
+
latestNavigationPoseRef.current = pose
|
|
495
|
+
const nextRotationDeg = floorplanRotationFromCameraAzimuth(pose.azimuth, rotationDegRef.current)
|
|
496
|
+
presentRotation(nextRotationDeg)
|
|
497
|
+
if (!navigationVisible) return
|
|
498
|
+
const localCenter = worldToFloorplanLocalPoint(
|
|
499
|
+
pose.target[0],
|
|
500
|
+
pose.target[2],
|
|
501
|
+
buildingPosition,
|
|
502
|
+
buildingRotationY,
|
|
503
|
+
)
|
|
504
|
+
presentViewBox(
|
|
505
|
+
floorplanViewBoxFromNavigationPose(
|
|
506
|
+
pose,
|
|
507
|
+
localCenter,
|
|
508
|
+
FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg,
|
|
509
|
+
viewportSize,
|
|
510
|
+
),
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
commitNavigationPresentationRef.current = (_pose) => {
|
|
515
|
+
if (!navigationVisible) return
|
|
516
|
+
setViewBox(viewBoxRef.current)
|
|
517
|
+
setRotationDeg(rotationDegRef.current)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
useClientLayoutEffect(() => {
|
|
521
|
+
let bounds: FloorplanBounds | null = null
|
|
522
|
+
for (const entry of framingEntries) {
|
|
523
|
+
const element = fitElementRefs.current.get(entry.id)
|
|
524
|
+
if (!element || typeof element.getBBox !== 'function') continue
|
|
525
|
+
let box: DOMRect
|
|
526
|
+
try {
|
|
527
|
+
box = element.getBBox()
|
|
528
|
+
} catch {
|
|
529
|
+
continue
|
|
530
|
+
}
|
|
531
|
+
if (![box.x, box.y, box.width, box.height].every(Number.isFinite)) continue
|
|
532
|
+
if (box.width === 0 && box.height === 0) continue
|
|
533
|
+
const next = {
|
|
534
|
+
minX: box.x,
|
|
535
|
+
minY: box.y,
|
|
536
|
+
maxX: box.x + box.width,
|
|
537
|
+
maxY: box.y + box.height,
|
|
538
|
+
}
|
|
539
|
+
bounds = bounds
|
|
540
|
+
? {
|
|
541
|
+
minX: Math.min(bounds.minX, next.minX),
|
|
542
|
+
minY: Math.min(bounds.minY, next.minY),
|
|
543
|
+
maxX: Math.max(bounds.maxX, next.maxX),
|
|
544
|
+
maxY: Math.max(bounds.maxY, next.maxY),
|
|
545
|
+
}
|
|
546
|
+
: next
|
|
547
|
+
}
|
|
548
|
+
if (bounds) setMeasuredFit({ key: fitKey, bounds })
|
|
549
|
+
}, [fitKey, framingEntries])
|
|
550
|
+
|
|
551
|
+
useClientLayoutEffect(() => {
|
|
552
|
+
if (synchronizeNavigation && latestNavigationPoseRef.current) return
|
|
553
|
+
updateViewBox(fittedViewBox)
|
|
554
|
+
pointersRef.current.clear()
|
|
555
|
+
dragRef.current = null
|
|
556
|
+
pinchRef.current = null
|
|
557
|
+
setIsPanning(false)
|
|
558
|
+
setIsRotating(false)
|
|
559
|
+
}, [fittedViewBox, synchronizeNavigation, updateViewBox])
|
|
560
|
+
|
|
561
|
+
useEffect(() => {
|
|
562
|
+
const svg = svgRef.current
|
|
563
|
+
if (!svg) return
|
|
564
|
+
const updateSize = () => {
|
|
565
|
+
const rect = svg.getBoundingClientRect()
|
|
566
|
+
setViewportSize({ width: Math.max(rect.width, 1), height: Math.max(rect.height, 1) })
|
|
567
|
+
}
|
|
568
|
+
updateSize()
|
|
569
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
570
|
+
window.addEventListener('resize', updateSize)
|
|
571
|
+
return () => window.removeEventListener('resize', updateSize)
|
|
572
|
+
}
|
|
573
|
+
const observer = new ResizeObserver(updateSize)
|
|
574
|
+
observer.observe(svg)
|
|
575
|
+
return () => observer.disconnect()
|
|
576
|
+
}, [])
|
|
577
|
+
|
|
578
|
+
useClientLayoutEffect(() => {
|
|
579
|
+
presentRotation(rotationDegRef.current)
|
|
580
|
+
}, [presentRotation])
|
|
581
|
+
|
|
582
|
+
useEffect(() => {
|
|
583
|
+
if (!synchronizeNavigation) return
|
|
584
|
+
const scheduler = navigationSchedulerRef.current
|
|
585
|
+
if (!scheduler) return
|
|
586
|
+
const receivePose = (pose: NavigationSyncPose) => {
|
|
587
|
+
latestNavigationPoseRef.current = pose
|
|
588
|
+
if (navigationVisible) scheduler.update(pose)
|
|
589
|
+
else applyNavigationPresentationRef.current(pose)
|
|
590
|
+
}
|
|
591
|
+
const unsubscribeCamera = subscribeFloorplanCameraNavigation(receivePose)
|
|
592
|
+
const unsubscribeStored = subscribeNavigationSyncPose((pose) => {
|
|
593
|
+
if (pose.source === '2d' && !navigationVisible) receivePose(pose)
|
|
594
|
+
})
|
|
595
|
+
return () => {
|
|
596
|
+
unsubscribeCamera()
|
|
597
|
+
unsubscribeStored()
|
|
598
|
+
scheduler.discard()
|
|
599
|
+
}
|
|
600
|
+
}, [navigationVisible, synchronizeNavigation])
|
|
601
|
+
|
|
602
|
+
useEffect(() => {
|
|
603
|
+
if (!(synchronizeNavigation && navigationVisible && latestNavigationPoseRef.current)) return
|
|
604
|
+
navigationSchedulerRef.current?.update(latestNavigationPoseRef.current)
|
|
605
|
+
}, [navigationVisible, synchronizeNavigation])
|
|
606
|
+
|
|
607
|
+
const chooseLevel = useCallback(
|
|
608
|
+
(nextLevelId: string) => {
|
|
609
|
+
setInternalLevelId(nextLevelId)
|
|
610
|
+
onLevelChange?.(nextLevelId)
|
|
611
|
+
},
|
|
612
|
+
[onLevelChange],
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
const updateLocalViewBox = useCallback(
|
|
616
|
+
(
|
|
617
|
+
update: FloorplanViewBox | ((current: FloorplanViewBox) => FloorplanViewBox),
|
|
618
|
+
commit = true,
|
|
619
|
+
) => {
|
|
620
|
+
const current = viewBoxRef.current
|
|
621
|
+
const next = typeof update === 'function' ? update(current) : update
|
|
622
|
+
presentViewBox(next)
|
|
623
|
+
if (commit) setViewBox(next)
|
|
624
|
+
publishNavigation(next, rotationDegRef.current)
|
|
625
|
+
return next
|
|
626
|
+
},
|
|
627
|
+
[presentViewBox, publishNavigation],
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
const zoom = useCallback(
|
|
631
|
+
(factor: number, anchorX = 0.5, anchorY = 0.5) => {
|
|
632
|
+
updateLocalViewBox((current) => scaleFloorplanViewBox(current, factor, anchorX, anchorY))
|
|
633
|
+
},
|
|
634
|
+
[updateLocalViewBox],
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
const onWheel = useCallback(
|
|
638
|
+
(event: ReactWheelEvent<SVGSVGElement>) => {
|
|
639
|
+
event.preventDefault()
|
|
640
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
641
|
+
updateLocalViewBox((current) =>
|
|
642
|
+
scaleFloorplanViewBoxBetweenClients(
|
|
643
|
+
current,
|
|
644
|
+
event.deltaY > 0 ? 1.12 : 0.88,
|
|
645
|
+
rect,
|
|
646
|
+
[event.clientX, event.clientY],
|
|
647
|
+
[event.clientX, event.clientY],
|
|
648
|
+
),
|
|
649
|
+
)
|
|
650
|
+
},
|
|
651
|
+
[updateLocalViewBox],
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
const onPointerDown = useCallback((event: ReactPointerEvent<SVGSVGElement>) => {
|
|
655
|
+
if (event.pointerType === 'mouse' && event.button !== 0 && event.button !== 2) return
|
|
656
|
+
event.preventDefault()
|
|
657
|
+
interactionRef.current?.focus()
|
|
658
|
+
try {
|
|
659
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
660
|
+
} catch {
|
|
661
|
+
// Pointer capture is an enhancement; the gesture still works while events stay over the SVG.
|
|
662
|
+
}
|
|
663
|
+
const point = { x: event.clientX, y: event.clientY }
|
|
664
|
+
pointersRef.current.set(event.pointerId, point)
|
|
665
|
+
const pointers = Array.from(pointersRef.current.entries())
|
|
666
|
+
if (pointers.length === 1) {
|
|
667
|
+
dragRef.current = {
|
|
668
|
+
mode: event.pointerType === 'mouse' && event.button === 2 ? 'rotate' : 'pan',
|
|
669
|
+
pointerId: event.pointerId,
|
|
670
|
+
point,
|
|
671
|
+
rotationDeg: rotationDegRef.current,
|
|
672
|
+
viewBox: viewBoxRef.current,
|
|
673
|
+
}
|
|
674
|
+
pinchRef.current = null
|
|
675
|
+
} else {
|
|
676
|
+
const [first, second] = pointers
|
|
677
|
+
if (!first || !second) return
|
|
678
|
+
const dx = second[1].x - first[1].x
|
|
679
|
+
const dy = second[1].y - first[1].y
|
|
680
|
+
pinchRef.current = {
|
|
681
|
+
pointerIds: [first[0], second[0]],
|
|
682
|
+
midpoint: { x: (first[1].x + second[1].x) / 2, y: (first[1].y + second[1].y) / 2 },
|
|
683
|
+
distance: Math.max(Math.hypot(dx, dy), 1),
|
|
684
|
+
viewBox: viewBoxRef.current,
|
|
685
|
+
}
|
|
686
|
+
dragRef.current = null
|
|
687
|
+
}
|
|
688
|
+
setIsPanning(true)
|
|
689
|
+
setIsRotating(dragRef.current?.mode === 'rotate')
|
|
690
|
+
}, [])
|
|
691
|
+
|
|
692
|
+
const onPointerMove = useCallback(
|
|
693
|
+
(event: ReactPointerEvent<SVGSVGElement>) => {
|
|
694
|
+
if (!pointersRef.current.has(event.pointerId)) return
|
|
695
|
+
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
|
696
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
697
|
+
const pinch = pinchRef.current
|
|
698
|
+
if (pinch) {
|
|
699
|
+
const first = pointersRef.current.get(pinch.pointerIds[0])
|
|
700
|
+
const second = pointersRef.current.get(pinch.pointerIds[1])
|
|
701
|
+
if (!(first && second)) return
|
|
702
|
+
const dx = second.x - first.x
|
|
703
|
+
const dy = second.y - first.y
|
|
704
|
+
const midpoint: PointerPoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }
|
|
705
|
+
updateLocalViewBox(
|
|
706
|
+
scaleFloorplanViewBoxBetweenClients(
|
|
707
|
+
pinch.viewBox,
|
|
708
|
+
pinch.distance / Math.max(Math.hypot(dx, dy), 1),
|
|
709
|
+
rect,
|
|
710
|
+
[pinch.midpoint.x, pinch.midpoint.y],
|
|
711
|
+
[midpoint.x, midpoint.y],
|
|
712
|
+
),
|
|
713
|
+
false,
|
|
714
|
+
)
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
const drag = dragRef.current
|
|
718
|
+
if (!drag || drag.pointerId !== event.pointerId) return
|
|
719
|
+
if (drag.mode === 'rotate') {
|
|
720
|
+
const nextRotationDeg = drag.rotationDeg + (event.clientX - drag.point.x) * 0.35
|
|
721
|
+
const initialSceneRotationDeg =
|
|
722
|
+
FLOORPLAN_VIEW_ROTATION_DEG + drag.rotationDeg - buildingRotationDeg
|
|
723
|
+
const nextSceneRotationDeg =
|
|
724
|
+
FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg
|
|
725
|
+
const displayedCenter = {
|
|
726
|
+
x: drag.viewBox.x + drag.viewBox.width / 2,
|
|
727
|
+
y: drag.viewBox.y + drag.viewBox.height / 2,
|
|
728
|
+
}
|
|
729
|
+
const localCenter = rotateFloorplanPoint(displayedCenter, -initialSceneRotationDeg)
|
|
730
|
+
const nextCenter = rotateFloorplanPoint(localCenter, nextSceneRotationDeg)
|
|
731
|
+
const nextViewBox = {
|
|
732
|
+
...drag.viewBox,
|
|
733
|
+
x: nextCenter.x - drag.viewBox.width / 2,
|
|
734
|
+
y: nextCenter.y - drag.viewBox.height / 2,
|
|
735
|
+
}
|
|
736
|
+
presentRotation(nextRotationDeg)
|
|
737
|
+
presentViewBox(nextViewBox)
|
|
738
|
+
publishNavigation(nextViewBox, nextRotationDeg)
|
|
739
|
+
return
|
|
740
|
+
}
|
|
741
|
+
updateLocalViewBox(
|
|
742
|
+
panFloorplanViewBox(
|
|
743
|
+
drag.viewBox,
|
|
744
|
+
rect,
|
|
745
|
+
[drag.point.x, drag.point.y],
|
|
746
|
+
[event.clientX, event.clientY],
|
|
747
|
+
),
|
|
748
|
+
false,
|
|
749
|
+
)
|
|
750
|
+
},
|
|
751
|
+
[buildingRotationDeg, presentRotation, presentViewBox, publishNavigation, updateLocalViewBox],
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
const onPointerUp = useCallback((event: ReactPointerEvent<SVGSVGElement>) => {
|
|
755
|
+
pointersRef.current.delete(event.pointerId)
|
|
756
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
757
|
+
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
758
|
+
}
|
|
759
|
+
pinchRef.current = null
|
|
760
|
+
const [remaining] = pointersRef.current.entries()
|
|
761
|
+
if (remaining) {
|
|
762
|
+
dragRef.current = {
|
|
763
|
+
mode: 'pan',
|
|
764
|
+
pointerId: remaining[0],
|
|
765
|
+
point: remaining[1],
|
|
766
|
+
rotationDeg: rotationDegRef.current,
|
|
767
|
+
viewBox: viewBoxRef.current,
|
|
768
|
+
}
|
|
769
|
+
setIsRotating(false)
|
|
770
|
+
} else {
|
|
771
|
+
dragRef.current = null
|
|
772
|
+
setIsPanning(false)
|
|
773
|
+
setIsRotating(false)
|
|
774
|
+
setViewBox(viewBoxRef.current)
|
|
775
|
+
setRotationDeg(rotationDegRef.current)
|
|
776
|
+
}
|
|
777
|
+
}, [])
|
|
778
|
+
|
|
779
|
+
const onKeyDown = useCallback(
|
|
780
|
+
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
781
|
+
const panStep = 0.08
|
|
782
|
+
switch (event.key) {
|
|
783
|
+
case '+':
|
|
784
|
+
case '=':
|
|
785
|
+
event.preventDefault()
|
|
786
|
+
zoom(0.8)
|
|
787
|
+
return
|
|
788
|
+
case '-':
|
|
789
|
+
case '_':
|
|
790
|
+
event.preventDefault()
|
|
791
|
+
zoom(1.2)
|
|
792
|
+
return
|
|
793
|
+
case '0':
|
|
794
|
+
case 'f':
|
|
795
|
+
case 'F':
|
|
796
|
+
event.preventDefault()
|
|
797
|
+
updateLocalViewBox(fittedViewBox)
|
|
798
|
+
return
|
|
799
|
+
case 'ArrowLeft':
|
|
800
|
+
event.preventDefault()
|
|
801
|
+
updateLocalViewBox((current) => ({
|
|
802
|
+
...current,
|
|
803
|
+
x: current.x - current.width * panStep,
|
|
804
|
+
}))
|
|
805
|
+
return
|
|
806
|
+
case 'ArrowRight':
|
|
807
|
+
event.preventDefault()
|
|
808
|
+
updateLocalViewBox((current) => ({
|
|
809
|
+
...current,
|
|
810
|
+
x: current.x + current.width * panStep,
|
|
811
|
+
}))
|
|
812
|
+
return
|
|
813
|
+
case 'ArrowUp':
|
|
814
|
+
event.preventDefault()
|
|
815
|
+
updateLocalViewBox((current) => ({
|
|
816
|
+
...current,
|
|
817
|
+
y: current.y - current.height * panStep,
|
|
818
|
+
}))
|
|
819
|
+
return
|
|
820
|
+
case 'ArrowDown':
|
|
821
|
+
event.preventDefault()
|
|
822
|
+
updateLocalViewBox((current) => ({
|
|
823
|
+
...current,
|
|
824
|
+
y: current.y + current.height * panStep,
|
|
825
|
+
}))
|
|
826
|
+
}
|
|
827
|
+
},
|
|
828
|
+
[fittedViewBox, updateLocalViewBox, zoom],
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
const alignToNorth = useCallback(() => {
|
|
832
|
+
const currentRotationDeg = rotationDegRef.current
|
|
833
|
+
const nextRotationDeg = nearestEquivalentDegrees(0, currentRotationDeg)
|
|
834
|
+
if (!navigationVisible) {
|
|
835
|
+
const pose = latestNavigationPoseRef.current
|
|
836
|
+
if (!pose) return
|
|
837
|
+
useEditor.getState().publishNavigationSyncPose({
|
|
838
|
+
source: '2d',
|
|
839
|
+
target: [...pose.target],
|
|
840
|
+
azimuth: cameraAzimuthFromFloorplanRotation(nextRotationDeg),
|
|
841
|
+
viewWidth: pose.viewWidth,
|
|
842
|
+
})
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const currentViewBox = viewBoxRef.current
|
|
847
|
+
const currentSceneRotationDeg =
|
|
848
|
+
FLOORPLAN_VIEW_ROTATION_DEG + currentRotationDeg - buildingRotationDeg
|
|
849
|
+
const nextSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG + nextRotationDeg - buildingRotationDeg
|
|
850
|
+
const currentCenter = {
|
|
851
|
+
x: currentViewBox.x + currentViewBox.width / 2,
|
|
852
|
+
y: currentViewBox.y + currentViewBox.height / 2,
|
|
853
|
+
}
|
|
854
|
+
const localCenter = rotateFloorplanPoint(currentCenter, -currentSceneRotationDeg)
|
|
855
|
+
const nextCenter = rotateFloorplanPoint(localCenter, nextSceneRotationDeg)
|
|
856
|
+
const nextViewBox = {
|
|
857
|
+
...currentViewBox,
|
|
858
|
+
x: nextCenter.x - currentViewBox.width / 2,
|
|
859
|
+
y: nextCenter.y - currentViewBox.height / 2,
|
|
860
|
+
}
|
|
861
|
+
commitPresentation(nextViewBox, nextRotationDeg)
|
|
862
|
+
publishNavigation(nextViewBox, nextRotationDeg)
|
|
863
|
+
}, [buildingRotationDeg, commitPresentation, navigationVisible, publishNavigation])
|
|
864
|
+
|
|
865
|
+
const screenUnitsPerPixel = Math.max(
|
|
866
|
+
viewBox.width / Math.max(viewportSize.width, 1),
|
|
867
|
+
viewBox.height / Math.max(viewportSize.height, 1),
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
const compassControl = (
|
|
871
|
+
<FloorplanCompassButton
|
|
872
|
+
needleRef={compassNeedleRef}
|
|
873
|
+
northRotationDeg={rotationDeg}
|
|
874
|
+
onAlignNorth={alignToNorth}
|
|
875
|
+
/>
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
if (levels.length === 0) {
|
|
879
|
+
return (
|
|
880
|
+
<div
|
|
881
|
+
className={className}
|
|
882
|
+
style={{ display: 'grid', placeItems: 'center', background: '#f8fafc', color: '#64748b' }}
|
|
883
|
+
>
|
|
884
|
+
No floor plans are available for this scene.
|
|
885
|
+
</div>
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
return (
|
|
890
|
+
<div
|
|
891
|
+
className={className}
|
|
892
|
+
style={{ position: 'relative', overflow: 'hidden', background: '#f8fafc' }}
|
|
893
|
+
>
|
|
894
|
+
{synchronizeNavigation ? <FloorplanCameraSyncMount /> : null}
|
|
895
|
+
<div
|
|
896
|
+
aria-keyshortcuts="ArrowUp ArrowDown ArrowLeft ArrowRight + - 0 F"
|
|
897
|
+
aria-label={`${activeLevel ? levelLabel(activeLevel) : 'Floor plan'} 2D view`}
|
|
898
|
+
onKeyDown={onKeyDown}
|
|
899
|
+
role="application"
|
|
900
|
+
ref={interactionRef}
|
|
901
|
+
style={{ width: '100%', height: '100%' }}
|
|
902
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: The plan canvas supports keyboard pan, zoom, and fit controls.
|
|
903
|
+
tabIndex={0}
|
|
904
|
+
>
|
|
905
|
+
<svg
|
|
906
|
+
aria-hidden="true"
|
|
907
|
+
data-floorplan-preview=""
|
|
908
|
+
onContextMenu={(event) => event.preventDefault()}
|
|
909
|
+
onPointerCancel={onPointerUp}
|
|
910
|
+
onPointerDown={onPointerDown}
|
|
911
|
+
onPointerMove={onPointerMove}
|
|
912
|
+
onPointerUp={onPointerUp}
|
|
913
|
+
onWheel={onWheel}
|
|
914
|
+
preserveAspectRatio="xMidYMid meet"
|
|
915
|
+
ref={svgRef}
|
|
916
|
+
style={{
|
|
917
|
+
width: '100%',
|
|
918
|
+
height: '100%',
|
|
919
|
+
cursor: isRotating ? 'ew-resize' : isPanning ? 'grabbing' : 'grab',
|
|
920
|
+
touchAction: 'none',
|
|
921
|
+
}}
|
|
922
|
+
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
|
|
923
|
+
>
|
|
924
|
+
<defs>
|
|
925
|
+
<pattern height="1" id={gridPatternId} patternUnits="userSpaceOnUse" width="1">
|
|
926
|
+
<path
|
|
927
|
+
d="M 1 0 L 0 0 0 1"
|
|
928
|
+
fill="none"
|
|
929
|
+
stroke="#cbd5e1"
|
|
930
|
+
strokeOpacity="0.32"
|
|
931
|
+
strokeWidth="0.012"
|
|
932
|
+
/>
|
|
933
|
+
</pattern>
|
|
934
|
+
</defs>
|
|
935
|
+
<rect
|
|
936
|
+
fill={`url(#${gridPatternId})`}
|
|
937
|
+
height={viewBox.height}
|
|
938
|
+
ref={gridRef}
|
|
939
|
+
width={viewBox.width}
|
|
940
|
+
x={viewBox.x}
|
|
941
|
+
y={viewBox.y}
|
|
942
|
+
/>
|
|
943
|
+
<g
|
|
944
|
+
pointerEvents="none"
|
|
945
|
+
ref={sceneRef}
|
|
946
|
+
transform={
|
|
947
|
+
FLOORPLAN_VIEW_ROTATION_DEG + rotationDeg - buildingRotationDeg === 0
|
|
948
|
+
? undefined
|
|
949
|
+
: `rotate(${FLOORPLAN_VIEW_ROTATION_DEG + rotationDeg - buildingRotationDeg})`
|
|
950
|
+
}
|
|
951
|
+
>
|
|
952
|
+
{renderEntries.map((entry) => (
|
|
953
|
+
<g
|
|
954
|
+
key={entry.id}
|
|
955
|
+
ref={(element) => {
|
|
956
|
+
if (element) fitElementRefs.current.set(entry.id, element)
|
|
957
|
+
else fitElementRefs.current.delete(entry.id)
|
|
958
|
+
}}
|
|
959
|
+
>
|
|
960
|
+
<FloorplanGeometryRenderer
|
|
961
|
+
geometry={entry.geometry}
|
|
962
|
+
pointerEventsOverride="none"
|
|
963
|
+
screenUnitsPerPixel={screenUnitsPerPixel}
|
|
964
|
+
/>
|
|
965
|
+
</g>
|
|
966
|
+
))}
|
|
967
|
+
</g>
|
|
968
|
+
</svg>
|
|
969
|
+
</div>
|
|
970
|
+
|
|
971
|
+
{showLevelSelector && levels.length > 1 ? (
|
|
972
|
+
<label
|
|
973
|
+
style={{
|
|
974
|
+
position: 'absolute',
|
|
975
|
+
bottom: 16,
|
|
976
|
+
left: 64,
|
|
977
|
+
display: 'grid',
|
|
978
|
+
gap: 4,
|
|
979
|
+
color: '#475569',
|
|
980
|
+
fontSize: 11,
|
|
981
|
+
fontWeight: 600,
|
|
982
|
+
}}
|
|
983
|
+
>
|
|
984
|
+
Floor
|
|
985
|
+
<select
|
|
986
|
+
aria-label="Floor"
|
|
987
|
+
onChange={(event) => chooseLevel(event.target.value)}
|
|
988
|
+
style={{
|
|
989
|
+
border: '1px solid rgba(148,163,184,.55)',
|
|
990
|
+
borderRadius: 999,
|
|
991
|
+
background: 'rgba(255,255,255,.94)',
|
|
992
|
+
padding: '8px 30px 8px 12px',
|
|
993
|
+
color: '#0f172a',
|
|
994
|
+
boxShadow: '0 8px 24px rgba(15,23,42,.10)',
|
|
995
|
+
}}
|
|
996
|
+
value={activeLevelId ?? ''}
|
|
997
|
+
>
|
|
998
|
+
{levels.map((level) => (
|
|
999
|
+
<option key={level.id} value={level.id}>
|
|
1000
|
+
{levelLabel(level)}
|
|
1001
|
+
</option>
|
|
1002
|
+
))}
|
|
1003
|
+
</select>
|
|
1004
|
+
</label>
|
|
1005
|
+
) : null}
|
|
1006
|
+
|
|
1007
|
+
{showCompass
|
|
1008
|
+
? compassHost
|
|
1009
|
+
? createPortal(compassControl, compassHost)
|
|
1010
|
+
: compassControl
|
|
1011
|
+
: null}
|
|
1012
|
+
|
|
1013
|
+
<div
|
|
1014
|
+
style={{
|
|
1015
|
+
position: 'absolute',
|
|
1016
|
+
right: 16,
|
|
1017
|
+
bottom: 16,
|
|
1018
|
+
display: 'flex',
|
|
1019
|
+
gap: 4,
|
|
1020
|
+
border: '1px solid rgba(148,163,184,.45)',
|
|
1021
|
+
borderRadius: 999,
|
|
1022
|
+
background: 'rgba(255,255,255,.94)',
|
|
1023
|
+
padding: 4,
|
|
1024
|
+
boxShadow: '0 8px 24px rgba(15,23,42,.10)',
|
|
1025
|
+
}}
|
|
1026
|
+
>
|
|
1027
|
+
<button
|
|
1028
|
+
aria-label="Zoom out"
|
|
1029
|
+
onClick={() => zoom(1.2)}
|
|
1030
|
+
style={controlStyle}
|
|
1031
|
+
title="Zoom out"
|
|
1032
|
+
type="button"
|
|
1033
|
+
>
|
|
1034
|
+
<Minus size={16} />
|
|
1035
|
+
</button>
|
|
1036
|
+
<button
|
|
1037
|
+
aria-label="Fit floor plan"
|
|
1038
|
+
onClick={() => updateLocalViewBox(fittedViewBox)}
|
|
1039
|
+
style={controlStyle}
|
|
1040
|
+
title="Fit floor plan"
|
|
1041
|
+
type="button"
|
|
1042
|
+
>
|
|
1043
|
+
<Maximize2 size={15} />
|
|
1044
|
+
</button>
|
|
1045
|
+
<button
|
|
1046
|
+
aria-label="Zoom in"
|
|
1047
|
+
onClick={() => zoom(0.8)}
|
|
1048
|
+
style={controlStyle}
|
|
1049
|
+
title="Zoom in"
|
|
1050
|
+
type="button"
|
|
1051
|
+
>
|
|
1052
|
+
<Plus size={16} />
|
|
1053
|
+
</button>
|
|
1054
|
+
</div>
|
|
1055
|
+
</div>
|
|
1056
|
+
)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const controlStyle = {
|
|
1060
|
+
display: 'grid',
|
|
1061
|
+
width: 32,
|
|
1062
|
+
height: 32,
|
|
1063
|
+
placeItems: 'center',
|
|
1064
|
+
border: 0,
|
|
1065
|
+
borderRadius: 999,
|
|
1066
|
+
background: 'transparent',
|
|
1067
|
+
color: '#334155',
|
|
1068
|
+
cursor: 'pointer',
|
|
1069
|
+
} as const
|