1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
4import { Environment, Html, MapControls } from '@react-three/drei';
5import React, { memo, useEffect, useMemo, useRef } from 'react';
6import * as THREE from 'three';
7import { Vector3 } from 'three';
8import { type MapControls as MapControlsImpl } from 'three-stdlib';
9import { useDynamicScale } from '@/hooks/useDynamicScale';
11 calculateInterpolatedPosition,
12 calculateInterpolatedRotation,
15} from '@/stores/scenarioStore';
16import { fersColors } from '@/theme';
17import { AntennaPatternMesh } from './AntennaPatternMesh';
18import { BoresightArrow } from './BoresightArrow';
19import CameraManager from './CameraManager';
20import LinkVisualizer from './LinkVisualizer';
21import { MotionPathLine } from './MotionPathLine';
22import { VelocityArrow } from './VelocityArrow';
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) {
67 const angleUnit = useScenarioStore(
68 (state) => state.globalParameters.rotationAngleUnit
71 () => calculateInterpolatedRotation(platform, currentTime, angleUnit),
72 [platform, currentTime, angleUnit]
77 * Represents a single platform in the 3D world as a sphere with an identifying label.
78 * @param {object} props - The component props.
79 * @param {Platform} props.platform - The platform data from the store.
81const PlatformSphere = memo(function PlatformSphere({
86 const currentTime = useScenarioStore((state) => state.currentTime);
88 const isSelected = useScenarioStore(
89 (state) => state.selectedItemId === platform.id
100 } = useScenarioStore((state) => state.visibility);
102 // Hooks must run unconditionally
103 const position = useInterpolatedPosition(platform, currentTime);
104 const rotation = useInterpolatedRotation(platform, currentTime);
106 // Find all components on this platform that have an antenna
107 const antennaComponents = useMemo(() => {
108 return platform.components.filter(
113 { type: 'monostatic' | 'transmitter' | 'receiver' }
115 (c.type === 'monostatic' ||
116 c.type === 'transmitter' ||
117 c.type === 'receiver') &&
120 }, [platform.components]);
122 // Find all target components to visualize their RCS
123 const targetComponents = useMemo(() => {
124 return platform.components.filter(
125 (c): c is Extract<typeof c, { type: 'target' }> =>
128 }, [platform.components]);
130 const labelData = useMemo(
133 y: -position?.z, // Convert from Three.js Z back to ENU Y
134 altitude: position?.y, // Convert from Three.js Y back to ENU Altitude
140 <group position={position}>
141 <group rotation={rotation}>
142 <mesh visible={showPlatforms}>
143 <sphereGeometry args={[0.5, 32, 32]} />
144 <meshStandardMaterial
147 ? fersColors.platform.selected
148 : fersColors.platform.default
154 ? fersColors.platform.emission
157 emissiveIntensity={isSelected ? 0.4 : 0}
159 {/* Render Body Axes: Red=X (Right), Green=Y (Up), Blue=Z (Rear). */}
160 <ScaledAxesHelper visible={showAxes} size={2} />
163 {/* Visualize Isotropic Static Sphere RCS */}
164 {targetComponents.map((target) => {
166 /* TODO: currently only rendering constant isotropic RCS */
169 target.rcs_type === 'isotropic' &&
173 const radius = Math.sqrt(target.rcs_value / Math.PI);
175 <mesh key={target.id} visible={showPlatforms}>
176 <sphereGeometry args={[radius, 24, 24]} />
178 color={fersColors.physics.rcs}
189 {/* Boresight is lightweight, conditional is fine, but visible is smoother */}
194 antennaComponents.length > 0
200 {/* Show antenna pattern meshes if the global toggle is enabled */}
203 antennaComponents.map((comp) =>
207 antennaId={comp.antennaId}
214 {/* Velocity Arrow */}
215 <group visible={showPlatforms && showVelocities}>
216 <VelocityArrow platform={platform} currentTime={currentTime} />
220 {showPlatforms && showPlatformLabels && (
222 position={[0, 1.2, 0]} // Position label above the sphere
223 center // Center the label on its anchor point
224 zIndexRange={[100, 0]} // Ensure labels render below UI overlays (zIndex 1000)
226 backgroundColor: fersColors.background.overlay,
227 color: fersColors.text.primary,
231 fontFamily: 'monospace',
232 whiteSpace: 'nowrap',
233 pointerEvents: 'none', // Allows camera clicks to pass through
234 userSelect: 'none', // Prevents text selection
235 transition: 'opacity 0.2s, border 0.2s',
236 opacity: isSelected ? 1 : 0.6,
237 border: `1px solid ${
239 ? fersColors.platform.selected
240 : fersColors.text.secondary
242 boxShadow: isSelected
243 ? `0 0 8px ${fersColors.platform.selected}40`
247 <div style={{ fontWeight: 'bold' }}>{platform.name}</div>
248 <div>{`X: ${(labelData?.x ?? 0).toFixed(2)}`}</div>
249 <div>{`Y: ${(labelData?.y ?? 0).toFixed(2)}`}</div>
250 <div>{`Alt: ${(labelData?.altitude ?? 0).toFixed(2)}`}</div>
258 * WorldView represents the 3D scene where simulation elements are visualized.
259 * It provides an interactive camera and renders platforms from the current scenario.
261interface WorldViewProps {
262 controlsRef: React.RefObject<MapControlsImpl | null>;
266 * WorldView represents the 3D scene where simulation elements are visualized.
267 * It provides an interactive camera and renders platforms from the current scenario.
269export default function WorldView({ controlsRef }: WorldViewProps) {
270 const platforms = useScenarioStore((state) => state.platforms);
272 const fetchPlatformPath = useScenarioStore(
273 (state) => state.fetchPlatformPath
276 // Root Level Visibility Toggles
277 const { showLinks, showMotionPaths } = useScenarioStore(
278 (state) => state.visibility
281 // Keep a reference to platforms to access in the effect without adding it to dependencies.
282 // We update this ref in a separate effect to avoid "mutation during render" errors.
283 const platformsRef = useRef(platforms);
286 platformsRef.current = platforms;
289 // Memoize platform dependencies.
290 const platformDeps = useMemo(
295 p.rotation.type === 'path'
296 ? `${p.rotation.interpolation}-${JSON.stringify(
299 : `fixed-${p.rotation.startAzimuth}-${p.rotation.startElevation}-${p.rotation.azimuthRate}-${p.rotation.elevationRate}`;
303 p.motionPath.interpolation,
304 JSON.stringify(p.motionPath.waypoints),
313 // Access the platforms via ref. The dependency array is strictly controlled
314 // by platformDeps, which prevents the infinite loop caused by store updates.
315 platformsRef.current.forEach((platform) => {
316 void fetchPlatformPath(platform.id);
318 }, [platformDeps, fetchPlatformPath]);
322 <CameraManager controlsRef={controlsRef} />
325 <MapControls makeDefault ref={controlsRef} />
328 <ambientLight intensity={0.5} />
329 <directionalLight position={[50, 50, 25]} intensity={2.5} />
331 {/* Environment for realistic reflections and ambient light */}
332 <Environment files="/potsdamer_platz_1k.hdr" />
334 {/* Physics Link Visualization - Conditional Render */}
335 {showLinks && <LinkVisualizer />}
338 {platforms.map((platform) => (
339 <group key={platform.id}>
340 <PlatformSphere platform={platform} />
341 {/* Motion Path Lines */}
342 {showMotionPaths && <MotionPathLine platform={platform} />}