1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
4import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
13} from '@mui/material';
14import { Canvas } from '@react-three/fiber';
15import React, { useEffect, useRef, useState } from 'react';
18 type GroupImperativeHandle,
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';
34 getWebGLSupportReport,
35 resetWebGLSupportReportCache,
36 type WebGLSupportReport,
37} from '@/utils/webglSupport';
40 * A styled resize handle for the resizable panels.
42function ResizeHandle() {
49 backgroundColor: 'divider',
50 transition: 'background-color 0.2s ease-in-out',
51 '&[data-resize-handle-state="drag"]': {
52 backgroundColor: 'primary.main',
55 backgroundColor: 'primary.light',
69 report: WebGLSupportReport;
73 report: WebGLSupportReport;
76 phase: 'renderer-error';
77 report: WebGLSupportReport | null;
81function formatProbeSummary(report: WebGLSupportReport): string {
83 .map((probe) => `${probe.mode}: ${probe.reason}`)
87function ScenarioViewportFallback({
98 severity?: 'warning' | 'error';
106 alignItems: 'center',
107 justifyContent: 'center',
108 backgroundColor: fersColors.background.canvas,
114 width: 'min(720px, 100%)',
116 backgroundColor: fersColors.background.paper,
119 <Typography variant="h6" gutterBottom>
122 <Alert severity={severity} sx={{ mb: 2 }}>
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.
138 backgroundColor: 'rgba(2, 4, 8, 0.7)',
139 color: 'text.secondary',
140 fontFamily: 'monospace',
143 whiteSpace: 'pre-wrap',
150 sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}
152 <Button variant="outlined" onClick={onRetry}>
162 * ScenarioView is the primary workbench for building and visualizing 3D scenes.
164export const ScenarioView = React.memo(function ScenarioView({
169 const isSimulating = useSimulationProgressStore(
170 (state) => state.isSimulating
172 const [isInspectorCollapsed, setIsInspectorCollapsed] = useState(false);
173 const panelGroupRef = useRef<GroupImperativeHandle>(null);
174 const [viewportState, setViewportState] = useState<ViewportState>({
177 const [viewportResetKey, setViewportResetKey] = useState(0);
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);
185 let cancelled = false;
187 setViewportState({ phase: 'checking' });
189 void getWebGLSupportReport()
195 if (!report.rendererSupported) {
197 'Scenario view disabled because WebGL2 startup checks failed.',
201 phase: 'unsupported',
218 error instanceof Error
220 : 'Unknown WebGL startup error.';
223 'Scenario view disabled because WebGL startup checks threw an unexpected error.',
227 phase: 'renderer-error',
236 }, [viewportResetKey]);
238 const handleExpandInspector = () => {
239 // Restore panels to their default sizes: [SceneTree, Main, Inspector]
240 panelGroupRef.current?.setLayout({
243 'property-inspector': 25,
247 const handleViewportRetry = () => {
248 resetWebGLSupportReportCache();
249 setViewportResetKey((current) => current + 1);
252 const handleViewportError = (error: Error) => {
253 console.error('Scenario view renderer startup failed.', error);
254 setViewportState((current) => ({
255 phase: 'renderer-error',
257 current.phase === 'ready' || current.phase === 'unsupported'
260 errorMessage: error.message,
264 const fallbackDiagnostics =
265 viewportState.phase === 'unsupported'
266 ? `${viewportState.report.summary}\n${formatProbeSummary(
269 : viewportState.phase === 'renderer-error'
271 viewportState.report?.summary,
273 ? formatProbeSummary(viewportState.report)
275 `renderer: ${viewportState.errorMessage}`,
287 position: 'relative', // Establish positioning context
288 pointerEvents: isSimulating ? 'none' : 'auto',
289 opacity: isSimulating ? 0.5 : 1,
291 WebkitUserSelect: 'none',
295 orientation="horizontal"
296 groupRef={panelGroupRef}
300 'property-inspector': 25,
302 style={{ height: '100%', width: '100%' }}
304 <Panel id="scene-tree" defaultSize={25} minSize={20}>
310 <Panel id="main-content" minSize={30}>
315 flexDirection: 'column',
316 minWidth: 0, // Allow flex item to shrink below content size
317 overflow: 'hidden', // Prevent overflow
323 position: 'relative',
324 minHeight: 0, // Allow flex item to shrink
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}
335 ) : viewportState.phase === 'unsupported' ? (
336 <ScenarioViewportFallback
337 title="3D Scenario View Unavailable"
338 summary={viewportState.report.summary}
339 diagnostics={fallbackDiagnostics}
340 onRetry={handleViewportRetry}
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}
352 <ViewportErrorBoundary
353 resetKey={viewportResetKey}
354 onError={handleViewportError}
357 key={viewportResetKey}
360 isActive ? 'always' : 'never'
363 position: [100, 100, 100],
369 logarithmicDepthBuffer: true,
375 fersColors.background
379 {/* Pass controlsRef down to WorldView */}
381 controlsRef={controlsRef}
384 {/* Logic-only component for Scale Bar calculations */}
386 controlsRef={controlsRef}
387 labelRef={scaleLabelRef}
391 </ViewportErrorBoundary>
393 {/* View Controls (Top-Left) */}
396 position: 'absolute',
405 {/* Scale Bar Overlay (Bottom-Left) */}
408 position: 'absolute',
412 pointerEvents: 'none',
414 flexDirection: 'column',
415 alignItems: 'center',
417 '0px 1px 2px rgba(0,0,0,0.8)',
423 color: fersColors.text.primary,
428 'Roboto, sans-serif',
437 border: `1px solid ${fersColors.text.primary}`,
451 borderColor: 'divider',
462 id="property-inspector"
466 setIsInspectorCollapsed(size.asPercentage === 0)
471 <PropertyInspector />
475 {isInspectorCollapsed && (
476 <Tooltip title="Show Properties">
478 onClick={handleExpandInspector}
480 position: 'absolute',
483 transform: 'translateY(-50%)',
485 bgcolor: 'background.paper',
487 borderColor: 'divider',
489 borderTopRightRadius: 0,
490 borderBottomRightRadius: 0,
492 bgcolor: 'action.hover',