FERS 1.0.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
WorldView.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 React, { useEffect, useMemo, useRef, memo } from 'react';
5import { MapControls, Environment, Html } from '@react-three/drei';
6import { Vector3 } from 'three';
7import * as THREE from 'three';
8import {
9 useScenarioStore,
10 Platform,
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';
23
24/**
25 * A wrapper for axesHelper that scales based on camera distance to maintain visibility.
26 */
27function ScaledAxesHelper({
28 visible,
29 size = 2,
30}: {
31 visible: boolean;
32 size?: number;
33}) {
34 const ref = useRef<THREE.Group>(null);
35
36 // Apply dynamic scaling hook
37 useDynamicScale(ref);
38
39 return (
40 <group ref={ref}>
41 <axesHelper args={[size]} visible={visible} />
42 </group>
43 );
44}
45
46/**
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.
52 */
53function useInterpolatedPosition(
54 platform: Platform,
55 currentTime: number
56): Vector3 {
57 return useMemo(
58 () => calculateInterpolatedPosition(platform, currentTime),
59 [platform, currentTime]
60 );
61}
62
63/**
64 * Custom hook to calculate a platform's rotation at a given simulation time.
65 */
66function useInterpolatedRotation(platform: Platform, currentTime: number) {
67 return useMemo(
68 () => calculateInterpolatedRotation(platform, currentTime),
69 [platform, currentTime]
70 );
71}
72
73/**
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.
77 */
78const PlatformSphere = memo(function PlatformSphere({
79 platform,
80}: {
81 platform: Platform;
82}) {
83 const currentTime = useScenarioStore((state) => state.currentTime);
84
85 const isSelected = useScenarioStore(
86 (state) => state.selectedItemId === platform.id
87 );
88
89 // Visibility Toggles
90 const {
91 showPlatforms,
92 showPlatformLabels,
93 showAxes,
94 showBoresights,
95 showPatterns,
96 showVelocities,
97 } = useScenarioStore((state) => state.visibility);
98
99 // Hooks must run unconditionally
100 const position = useInterpolatedPosition(platform, currentTime);
101 const rotation = useInterpolatedRotation(platform, currentTime);
102
103 // Find all components on this platform that have an antenna
104 const antennaComponents = useMemo(() => {
105 return platform.components.filter(
106 (
107 c
108 ): c is Extract<
109 typeof c,
110 { type: 'monostatic' | 'transmitter' | 'receiver' }
111 > =>
112 (c.type === 'monostatic' ||
113 c.type === 'transmitter' ||
114 c.type === 'receiver') &&
115 c.antennaId !== null
116 );
117 }, [platform.components]);
118
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' }> =>
123 c.type === 'target'
124 );
125 }, [platform.components]);
126
127 const labelData = useMemo(
128 () => ({
129 x: position?.x,
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
132 }),
133 [position]
134 );
135
136 return (
137 <group position={position}>
138 <group rotation={rotation}>
139 <mesh visible={showPlatforms}>
140 <sphereGeometry args={[0.5, 32, 32]} />
141 <meshStandardMaterial
142 color={
143 isSelected
144 ? fersColors.platform.selected
145 : fersColors.platform.default
146 }
147 roughness={0.2}
148 metalness={0.8}
149 emissive={
150 isSelected
151 ? fersColors.platform.emission
152 : '#000000'
153 }
154 emissiveIntensity={isSelected ? 0.4 : 0}
155 />
156 {/* Render Body Axes: Red=X (Right), Green=Y (Up), Blue=Z (Rear). */}
157 <ScaledAxesHelper visible={showAxes} size={2} />
158 </mesh>
159
160 {/* Visualize Isotropic Static Sphere RCS */}
161 {targetComponents.map((target) => {
162 {
163 /* TODO: currently only rendering constant isotropic RCS */
164 }
165 if (
166 target.rcs_type === 'isotropic' &&
167 target.rcs_value &&
168 target.rcs_value > 0
169 ) {
170 const radius = Math.sqrt(target.rcs_value / Math.PI);
171 return (
172 <mesh key={target.id} visible={showPlatforms}>
173 <sphereGeometry args={[radius, 24, 24]} />
174 <meshBasicMaterial
175 color={fersColors.physics.rcs}
176 wireframe
177 transparent
178 opacity={0.15}
179 />
180 </mesh>
181 );
182 }
183 return null;
184 })}
185
186 {/* Boresight is lightweight, conditional is fine, but visible is smoother */}
187 <group
188 visible={
189 showPlatforms &&
190 showBoresights &&
191 antennaComponents.length > 0
192 }
193 >
194 <BoresightArrow />
195 </group>
196
197 {/* Show antenna pattern meshes if the global toggle is enabled */}
198 {showPlatforms &&
199 showPatterns &&
200 antennaComponents.map((comp) =>
201 comp.antennaId ? (
202 <AntennaPatternMesh
203 key={comp.id}
204 antennaId={comp.antennaId}
205 component={comp}
206 />
207 ) : null
208 )}
209 </group>
210
211 {/* Velocity Arrow */}
212 <group visible={showPlatforms && showVelocities}>
213 <VelocityArrow platform={platform} currentTime={currentTime} />
214 </group>
215
216 {/* Label */}
217 {showPlatforms && showPlatformLabels && (
218 <Html
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)
222 style={{
223 backgroundColor: fersColors.background.overlay,
224 color: fersColors.text.primary,
225 padding: '4px 8px',
226 borderRadius: '4px',
227 fontSize: '12px',
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 ${
235 isSelected
236 ? fersColors.platform.selected
237 : fersColors.text.secondary
238 }`,
239 boxShadow: isSelected
240 ? `0 0 8px ${fersColors.platform.selected}40`
241 : 'none',
242 }}
243 >
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>
248 </Html>
249 )}
250 </group>
251 );
252});
253
254/**
255 * WorldView represents the 3D scene where simulation elements are visualized.
256 * It provides an interactive camera and renders platforms from the current scenario.
257 */
258interface WorldViewProps {
259 controlsRef: React.RefObject<MapControlsImpl | null>;
260}
261
262/**
263 * WorldView represents the 3D scene where simulation elements are visualized.
264 * It provides an interactive camera and renders platforms from the current scenario.
265 */
266export default function WorldView({ controlsRef }: WorldViewProps) {
267 const platforms = useScenarioStore((state) => state.platforms);
268
269 const fetchPlatformPath = useScenarioStore(
270 (state) => state.fetchPlatformPath
271 );
272
273 // Root Level Visibility Toggles
274 const { showLinks, showMotionPaths } = useScenarioStore(
275 (state) => state.visibility
276 );
277
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);
281
282 useEffect(() => {
283 platformsRef.current = platforms;
284 }, [platforms]);
285
286 // Memoize platform dependencies.
287 const platformDeps = useMemo(
288 () =>
289 platforms
290 .map((p) => {
291 const rotKey =
292 p.rotation.type === 'path'
293 ? `${p.rotation.interpolation}-${JSON.stringify(
294 p.rotation.waypoints
295 )}`
296 : `fixed-${p.rotation.startAzimuth}-${p.rotation.startElevation}-${p.rotation.azimuthRate}-${p.rotation.elevationRate}`;
297
298 return [
299 p.id,
300 p.motionPath.interpolation,
301 JSON.stringify(p.motionPath.waypoints),
302 rotKey,
303 ].join('|');
304 })
305 .join(';'),
306 [platforms]
307 );
308
309 useEffect(() => {
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);
314 });
315 }, [platformDeps, fetchPlatformPath]);
316
317 return (
318 <>
319 <CameraManager controlsRef={controlsRef} />
320
321 {/* Controls */}
322 <MapControls makeDefault ref={controlsRef} />
323
324 {/* Lighting */}
325 <ambientLight intensity={0.5} />
326 <directionalLight position={[50, 50, 25]} intensity={2.5} />
327
328 {/* Environment for realistic reflections and ambient light */}
329 <Environment files="/potsdamer_platz_1k.hdr" />
330
331 {/* Physics Link Visualization - Conditional Render */}
332 {showLinks && <LinkVisualizer />}
333
334 {/* Objects */}
335 {platforms.map((platform) => (
336 <group key={platform.id}>
337 <PlatformSphere platform={platform} />
338 {/* Motion Path Lines */}
339 {showMotionPaths && <MotionPathLine platform={platform} />}
340 </group>
341 ))}
342 </>
343 );
344}