FERS 1.0.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
ScenarioView.tsx
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
3
4import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
5import {
6 Alert,
7 Box,
8 Button,
9 IconButton,
10 Paper,
11 Tooltip,
12 Typography,
13} from '@mui/material';
14import { Canvas } from '@react-three/fiber';
15import React, { useEffect, useRef, useState } from 'react';
16import {
17 Group,
18 type GroupImperativeHandle,
19 Panel,
20 Separator,
21} from 'react-resizable-panels';
22import { type MapControls as MapControlsImpl } from 'three-stdlib';
23import PropertyInspector from '@/components/PropertyInspector';
24import { ScaleManager } from '@/components/ScaleManager';
25import SceneTree from '@/components/SceneTree';
26import Timeline from '@/components/Timeline';
27import ViewControls from '@/components/ViewControls';
28import { ViewportErrorBoundary } from '@/components/ViewportErrorBoundary';
29import WorldView from '@/components/WorldView';
30import { useScenarioStore } from '@/stores/scenarioStore';
31import { useSimulationProgressStore } from '@/stores/simulationProgressStore';
32import { fersColors } from '@/theme';
33import {
34 getWebGLSupportReport,
35 resetWebGLSupportReportCache,
36 type WebGLSupportReport,
37} from '@/utils/webglSupport';
38
39/**
40 * A styled resize handle for the resizable panels.
41 */
42function ResizeHandle() {
43 return (
44 <Separator>
45 <Box
46 sx={{
47 width: '2px',
48 height: '100%',
49 backgroundColor: 'divider',
50 transition: 'background-color 0.2s ease-in-out',
51 '&[data-resize-handle-state="drag"]': {
52 backgroundColor: 'primary.main',
53 },
54 '&:hover': {
55 backgroundColor: 'primary.light',
56 },
57 }}
58 />
59 </Separator>
60 );
61}
62
63type ViewportState =
64 | {
65 phase: 'checking';
66 }
67 | {
68 phase: 'ready';
69 report: WebGLSupportReport;
70 }
71 | {
72 phase: 'unsupported';
73 report: WebGLSupportReport;
74 }
75 | {
76 phase: 'renderer-error';
77 report: WebGLSupportReport | null;
78 errorMessage: string;
79 };
80
81function formatProbeSummary(report: WebGLSupportReport): string {
82 return report.probes
83 .map((probe) => `${probe.mode}: ${probe.reason}`)
84 .join('\n');
85}
86
87function ScenarioViewportFallback({
88 title,
89 summary,
90 diagnostics,
91 onRetry,
92 severity = 'warning',
93}: {
94 title: string;
95 summary: string;
96 diagnostics?: string;
97 onRetry: () => void;
98 severity?: 'warning' | 'error';
99}) {
100 return (
101 <Box
102 sx={{
103 height: '100%',
104 width: '100%',
105 display: 'flex',
106 alignItems: 'center',
107 justifyContent: 'center',
108 backgroundColor: fersColors.background.canvas,
109 p: 3,
110 }}
111 >
112 <Paper
113 sx={{
114 width: 'min(720px, 100%)',
115 p: 3,
116 backgroundColor: fersColors.background.paper,
117 }}
118 >
119 <Typography variant="h6" gutterBottom>
120 {title}
121 </Typography>
122 <Alert severity={severity} sx={{ mb: 2 }}>
123 {summary}
124 </Alert>
125 <Typography variant="body2" color="text.secondary">
126 The rest of the FERS UI remains available, but the 3D
127 Scenario view cannot be started on the current
128 browser/runtime/GPU configuration.
129 </Typography>
130 {diagnostics && (
131 <Box
132 component="pre"
133 sx={{
134 mt: 2,
135 mb: 0,
136 p: 2,
137 borderRadius: 1,
138 backgroundColor: 'rgba(2, 4, 8, 0.7)',
139 color: 'text.secondary',
140 fontFamily: 'monospace',
141 fontSize: '0.75rem',
142 overflowX: 'auto',
143 whiteSpace: 'pre-wrap',
144 }}
145 >
146 {diagnostics}
147 </Box>
148 )}
149 <Box
150 sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}
151 >
152 <Button variant="outlined" onClick={onRetry}>
153 Retry 3D Startup
154 </Button>
155 </Box>
156 </Paper>
157 </Box>
158 );
159}
160
161/**
162 * ScenarioView is the primary workbench for building and visualizing 3D scenes.
163 */
164export const ScenarioView = React.memo(function ScenarioView({
165 isActive,
166}: {
167 isActive: boolean;
168}) {
169 const isSimulating = useSimulationProgressStore(
170 (state) => state.isSimulating
171 );
172 const [isInspectorCollapsed, setIsInspectorCollapsed] = useState(false);
173 const panelGroupRef = useRef<GroupImperativeHandle>(null);
174 const [viewportState, setViewportState] = useState<ViewportState>({
175 phase: 'checking',
176 });
177 const [viewportResetKey, setViewportResetKey] = useState(0);
178
179 // 1. Lift refs to this level to bridge Logic (Canvas) and UI (DOM)
180 const controlsRef = useRef<MapControlsImpl>(null);
181 const scaleLabelRef = useRef<HTMLDivElement>(null);
182 const scaleBarRef = useRef<HTMLDivElement>(null);
183
184 useEffect(() => {
185 let cancelled = false;
186
187 setViewportState({ phase: 'checking' });
188
189 void getWebGLSupportReport()
190 .then((report) => {
191 if (cancelled) {
192 return;
193 }
194
195 if (!report.rendererSupported) {
196 console.warn(
197 'Scenario view disabled because WebGL2 startup checks failed.',
198 report
199 );
200 setViewportState({
201 phase: 'unsupported',
202 report,
203 });
204 return;
205 }
206
207 setViewportState({
208 phase: 'ready',
209 report,
210 });
211 })
212 .catch((error) => {
213 if (cancelled) {
214 return;
215 }
216
217 const errorMessage =
218 error instanceof Error
219 ? error.message
220 : 'Unknown WebGL startup error.';
221
222 console.warn(
223 'Scenario view disabled because WebGL startup checks threw an unexpected error.',
224 error
225 );
226 setViewportState({
227 phase: 'renderer-error',
228 report: null,
229 errorMessage,
230 });
231 });
232
233 return () => {
234 cancelled = true;
235 };
236 }, [viewportResetKey]);
237
238 const handleExpandInspector = () => {
239 // Restore panels to their default sizes: [SceneTree, Main, Inspector]
240 panelGroupRef.current?.setLayout({
241 'scene-tree': 25,
242 'main-content': 50,
243 'property-inspector': 25,
244 });
245 };
246
247 const handleViewportRetry = () => {
248 resetWebGLSupportReportCache();
249 setViewportResetKey((current) => current + 1);
250 };
251
252 const handleViewportError = (error: Error) => {
253 console.error('Scenario view renderer startup failed.', error);
254 setViewportState((current) => ({
255 phase: 'renderer-error',
256 report:
257 current.phase === 'ready' || current.phase === 'unsupported'
258 ? current.report
259 : null,
260 errorMessage: error.message,
261 }));
262 };
263
264 const fallbackDiagnostics =
265 viewportState.phase === 'unsupported'
266 ? `${viewportState.report.summary}\n${formatProbeSummary(
267 viewportState.report
268 )}`
269 : viewportState.phase === 'renderer-error'
270 ? [
271 viewportState.report?.summary,
272 viewportState.report
273 ? formatProbeSummary(viewportState.report)
274 : null,
275 `renderer: ${viewportState.errorMessage}`,
276 ]
277 .filter(Boolean)
278 .join('\n')
279 : undefined;
280
281 return (
282 <Box
283 sx={{
284 height: '100%',
285 width: '100%',
286 overflow: 'hidden',
287 position: 'relative', // Establish positioning context
288 pointerEvents: isSimulating ? 'none' : 'auto',
289 opacity: isSimulating ? 0.5 : 1,
290 userSelect: 'none',
291 WebkitUserSelect: 'none',
292 }}
293 >
294 <Group
295 orientation="horizontal"
296 groupRef={panelGroupRef}
297 defaultLayout={{
298 'scene-tree': 25,
299 'main-content': 50,
300 'property-inspector': 25,
301 }}
302 style={{ height: '100%', width: '100%' }}
303 >
304 <Panel id="scene-tree" defaultSize={25} minSize={20}>
305 <SceneTree />
306 </Panel>
307
308 <ResizeHandle />
309
310 <Panel id="main-content" minSize={30}>
311 <Box
312 sx={{
313 height: '100%',
314 display: 'flex',
315 flexDirection: 'column',
316 minWidth: 0, // Allow flex item to shrink below content size
317 overflow: 'hidden', // Prevent overflow
318 }}
319 >
320 <Box
321 sx={{
322 flex: 1,
323 position: 'relative',
324 minHeight: 0, // Allow flex item to shrink
325 overflow: 'hidden',
326 userSelect: 'none',
327 }}
328 >
329 {viewportState.phase === 'checking' ? (
330 <ScenarioViewportFallback
331 title="Checking 3D Renderer"
332 summary="Running WebGL startup checks before loading the Scenario view."
333 onRetry={handleViewportRetry}
334 />
335 ) : viewportState.phase === 'unsupported' ? (
336 <ScenarioViewportFallback
337 title="3D Scenario View Unavailable"
338 summary={viewportState.report.summary}
339 diagnostics={fallbackDiagnostics}
340 onRetry={handleViewportRetry}
341 />
342 ) : viewportState.phase === 'renderer-error' ? (
343 <ScenarioViewportFallback
344 title="3D Scenario View Failed To Start"
345 summary="Renderer startup failed after the initial WebGL probe. The viewport has been disabled to keep the rest of the app usable."
346 diagnostics={fallbackDiagnostics}
347 onRetry={handleViewportRetry}
348 severity="error"
349 />
350 ) : (
351 <>
352 <ViewportErrorBoundary
353 resetKey={viewportResetKey}
354 onError={handleViewportError}
355 >
356 <Canvas
357 key={viewportResetKey}
358 shadows
359 frameloop={
360 isActive ? 'always' : 'never'
361 }
362 camera={{
363 position: [100, 100, 100],
364 fov: 25,
365 near: 0.1,
366 far: 1e8,
367 }}
368 gl={{
369 logarithmicDepthBuffer: true,
370 }}
371 >
372 <color
373 attach="background"
374 args={[
375 fersColors.background
376 .canvas,
377 ]}
378 />
379 {/* Pass controlsRef down to WorldView */}
380 <WorldView
381 controlsRef={controlsRef}
382 />
383
384 {/* Logic-only component for Scale Bar calculations */}
385 <ScaleManager
386 controlsRef={controlsRef}
387 labelRef={scaleLabelRef}
388 barRef={scaleBarRef}
389 />
390 </Canvas>
391 </ViewportErrorBoundary>
392
393 {/* View Controls (Top-Left) */}
394 <Box
395 sx={{
396 position: 'absolute',
397 top: 16,
398 left: 16,
399 zIndex: 1000,
400 }}
401 >
402 <ViewControls />
403 </Box>
404
405 {/* Scale Bar Overlay (Bottom-Left) */}
406 <Box
407 sx={{
408 position: 'absolute',
409 bottom: 16,
410 left: 16,
411 zIndex: 1000,
412 pointerEvents: 'none',
413 display: 'flex',
414 flexDirection: 'column',
415 alignItems: 'center',
416 textShadow:
417 '0px 1px 2px rgba(0,0,0,0.8)',
418 }}
419 >
420 <div
421 ref={scaleLabelRef}
422 style={{
423 color: fersColors.text.primary,
424 fontSize: '11px',
425 fontWeight: 500,
426 marginBottom: '4px',
427 fontFamily:
428 'Roboto, sans-serif',
429 }}
430 >
431 -- m
432 </div>
433 <div
434 ref={scaleBarRef}
435 style={{
436 height: '6px',
437 border: `1px solid ${fersColors.text.primary}`,
438 borderTop: 'none',
439 width: '100px',
440 }}
441 />
442 </Box>
443 </>
444 )}
445 </Box>
446 <Box
447 sx={{
448 height: 100,
449 flexShrink: 0,
450 borderTop: 1,
451 borderColor: 'divider',
452 }}
453 >
454 <Timeline />
455 </Box>
456 </Box>
457 </Panel>
458
459 <ResizeHandle />
460
461 <Panel
462 id="property-inspector"
463 collapsible
464 collapsedSize={0}
465 onResize={(size) =>
466 setIsInspectorCollapsed(size.asPercentage === 0)
467 }
468 defaultSize={25}
469 minSize={5}
470 >
471 <PropertyInspector />
472 </Panel>
473 </Group>
474
475 {isInspectorCollapsed && (
476 <Tooltip title="Show Properties">
477 <IconButton
478 onClick={handleExpandInspector}
479 sx={{
480 position: 'absolute',
481 right: 0,
482 top: '50%',
483 transform: 'translateY(-50%)',
484 zIndex: 1,
485 bgcolor: 'background.paper',
486 border: 1,
487 borderColor: 'divider',
488 borderRight: 'none',
489 borderTopRightRadius: 0,
490 borderBottomRightRadius: 0,
491 '&:hover': {
492 bgcolor: 'action.hover',
493 },
494 }}
495 >
496 <ChevronLeftIcon />
497 </IconButton>
498 </Tooltip>
499 )}
500 </Box>
501 );
502});