1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
4import React, { useEffect, useMemo, useRef, memo } from 'react';
5import { MapControls, Environment, Html } from '@react-three/drei';
6import { Vector3 } from 'three';
7import * as THREE from 'three';
11 calculateInterpolatedPosition,
12 calculateInterpolatedRotation,
13} from '@/stores/scenarioStore';
14import { MotionPathLine } from './MotionPathLine';
15import CameraManager from './CameraManager';
16import { type MapControls as MapControlsImpl } from 'three-stdlib';
17import { BoresightArrow } from './BoresightArrow';
18import { VelocityArrow } from './VelocityArrow';
19import { AntennaPatternMesh } from './AntennaPatternMesh';
20import LinkVisualizer from './LinkVisualizer';
21import { fersColors } from '@/theme';
22import { useDynamicScale } from '@/hooks/useDynamicScale';
25 * A wrapper for axesHelper that scales based on camera distance to maintain visibility.
27function ScaledAxesHelper({
34 const ref = useRef<THREE.Group>(null);
36 // Apply dynamic scaling hook
41 <axesHelper args={[size]} visible={visible} />
47 * Custom hook to calculate a platform's position at a given simulation time.
48 * It relies on the pre-fetched path data stored in the Zustand store.
49 * @param {Platform} platform The platform data.
50 * @param {number} currentTime The current global simulation time.
51 * @returns {Vector3} The interpolated position in Three.js coordinates.
53function useInterpolatedPosition(
58 () => calculateInterpolatedPosition(platform, currentTime),
59 [platform, currentTime]
64 * Custom hook to calculate a platform's rotation at a given simulation time.
66function useInterpolatedRotation(platform: Platform, currentTime: number) {
68 () => calculateInterpolatedRotation(platform, currentTime),
69 [platform, currentTime]
74 * Represents a single platform in the 3D world as a sphere with an identifying label.
75 * @param {object} props - The component props.
76 * @param {Platform} props.platform - The platform data from the store.
78const PlatformSphere = memo(function PlatformSphere({
83 const currentTime = useScenarioStore((state) => state.currentTime);
85 const isSelected = useScenarioStore(
86 (state) => state.selectedItemId === platform.id
97 } = useScenarioStore((state) => state.visibility);
99 // Hooks must run unconditionally
100 const position = useInterpolatedPosition(platform, currentTime);
101 const rotation = useInterpolatedRotation(platform, currentTime);
103 // Find all components on this platform that have an antenna
104 const antennaComponents = useMemo(() => {
105 return platform.components.filter(
110 { type: 'monostatic' | 'transmitter' | 'receiver' }
112 (c.type === 'monostatic' ||
113 c.type === 'transmitter' ||
114 c.type === 'receiver') &&
117 }, [platform.components]);
119 // Find all target components to visualize their RCS
120 const targetComponents = useMemo(() => {
121 return platform.components.filter(
122 (c): c is Extract<typeof c, { type: 'target' }> =>
125 }, [platform.components]);
127 const labelData = useMemo(
130 y: -position?.z, // Convert from Three.js Z back to ENU Y
131 altitude: position?.y, // Convert from Three.js Y back to ENU Altitude
137 <group position={position}>
138 <group rotation={rotation}>
139 <mesh visible={showPlatforms}>
140 <sphereGeometry args={[0.5, 32, 32]} />
141 <meshStandardMaterial
144 ? fersColors.platform.selected
145 : fersColors.platform.default
151 ? fersColors.platform.emission
154 emissiveIntensity={isSelected ? 0.4 : 0}
156 {/* Render Body Axes: Red=X (Right), Green=Y (Up), Blue=Z (Rear). */}
157 <ScaledAxesHelper visible={showAxes} size={2} />
160 {/* Visualize Isotropic Static Sphere RCS */}
161 {targetComponents.map((target) => {
163 /* TODO: currently only rendering constant isotropic RCS */
166 target.rcs_type === 'isotropic' &&
170 const radius = Math.sqrt(target.rcs_value / Math.PI);
172 <mesh key={target.id} visible={showPlatforms}>
173 <sphereGeometry args={[radius, 24, 24]} />
175 color={fersColors.physics.rcs}
186 {/* Boresight is lightweight, conditional is fine, but visible is smoother */}
191 antennaComponents.length > 0
197 {/* Show antenna pattern meshes if the global toggle is enabled */}
200 antennaComponents.map((comp) =>
204 antennaId={comp.antennaId}
211 {/* Velocity Arrow */}
212 <group visible={showPlatforms && showVelocities}>
213 <VelocityArrow platform={platform} currentTime={currentTime} />
217 {showPlatforms && showPlatformLabels && (
219 position={[0, 1.2, 0]} // Position label above the sphere
220 center // Center the label on its anchor point
221 zIndexRange={[100, 0]} // Ensure labels render below UI overlays (zIndex 1000)
223 backgroundColor: fersColors.background.overlay,
224 color: fersColors.text.primary,
228 fontFamily: 'monospace',
229 whiteSpace: 'nowrap',
230 pointerEvents: 'none', // Allows camera clicks to pass through
231 userSelect: 'none', // Prevents text selection
232 transition: 'opacity 0.2s, border 0.2s',
233 opacity: isSelected ? 1 : 0.6,
234 border: `1px solid ${
236 ? fersColors.platform.selected
237 : fersColors.text.secondary
239 boxShadow: isSelected
240 ? `0 0 8px ${fersColors.platform.selected}40`
244 <div style={{ fontWeight: 'bold' }}>{platform.name}</div>
245 <div>{`X: ${(labelData?.x ?? 0).toFixed(2)}`}</div>
246 <div>{`Y: ${(labelData?.y ?? 0).toFixed(2)}`}</div>
247 <div>{`Alt: ${(labelData?.altitude ?? 0).toFixed(2)}`}</div>
255 * WorldView represents the 3D scene where simulation elements are visualized.
256 * It provides an interactive camera and renders platforms from the current scenario.
258interface WorldViewProps {
259 controlsRef: React.RefObject<MapControlsImpl | null>;
263 * WorldView represents the 3D scene where simulation elements are visualized.
264 * It provides an interactive camera and renders platforms from the current scenario.
266export default function WorldView({ controlsRef }: WorldViewProps) {
267 const platforms = useScenarioStore((state) => state.platforms);
269 const fetchPlatformPath = useScenarioStore(
270 (state) => state.fetchPlatformPath
273 // Root Level Visibility Toggles
274 const { showLinks, showMotionPaths } = useScenarioStore(
275 (state) => state.visibility
278 // Keep a reference to platforms to access in the effect without adding it to dependencies.
279 // We update this ref in a separate effect to avoid "mutation during render" errors.
280 const platformsRef = useRef(platforms);
283 platformsRef.current = platforms;
286 // Memoize platform dependencies.
287 const platformDeps = useMemo(
292 p.rotation.type === 'path'
293 ? `${p.rotation.interpolation}-${JSON.stringify(
296 : `fixed-${p.rotation.startAzimuth}-${p.rotation.startElevation}-${p.rotation.azimuthRate}-${p.rotation.elevationRate}`;
300 p.motionPath.interpolation,
301 JSON.stringify(p.motionPath.waypoints),
310 // Access the platforms via ref. The dependency array is strictly controlled
311 // by platformDeps, which prevents the infinite loop caused by store updates.
312 platformsRef.current.forEach((platform) => {
313 void fetchPlatformPath(platform.id);
315 }, [platformDeps, fetchPlatformPath]);
319 <CameraManager controlsRef={controlsRef} />
322 <MapControls makeDefault ref={controlsRef} />
325 <ambientLight intensity={0.5} />
326 <directionalLight position={[50, 50, 25]} intensity={2.5} />
328 {/* Environment for realistic reflections and ambient light */}
329 <Environment files="/potsdamer_platz_1k.hdr" />
331 {/* Physics Link Visualization - Conditional Render */}
332 {showLinks && <LinkVisualizer />}
335 {platforms.map((platform) => (
336 <group key={platform.id}>
337 <PlatformSphere platform={platform} />
338 {/* Motion Path Lines */}
339 {showMotionPaths && <MotionPathLine platform={platform} />}