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 { 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';
10import {
11 calculateInterpolatedPosition,
12 calculateInterpolatedRotation,
13 Platform,
14 useScenarioStore,
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';
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 const angleUnit = useScenarioStore(
68 (state) => state.globalParameters.rotationAngleUnit
69 );
70 return useMemo(
71 () => calculateInterpolatedRotation(platform, currentTime, angleUnit),
72 [platform, currentTime, angleUnit]
73 );
74}
75
76/**
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.
80 */
81const PlatformSphere = memo(function PlatformSphere({
82 platform,
83}: {
84 platform: Platform;
85}) {
86 const currentTime = useScenarioStore((state) => state.currentTime);
87
88 const isSelected = useScenarioStore(
89 (state) => state.selectedItemId === platform.id
90 );
91
92 // Visibility Toggles
93 const {
94 showPlatforms,
95 showPlatformLabels,
96 showAxes,
97 showBoresights,
98 showPatterns,
99 showVelocities,
100 } = useScenarioStore((state) => state.visibility);
101
102 // Hooks must run unconditionally
103 const position = useInterpolatedPosition(platform, currentTime);
104 const rotation = useInterpolatedRotation(platform, currentTime);
105
106 // Find all components on this platform that have an antenna
107 const antennaComponents = useMemo(() => {
108 return platform.components.filter(
109 (
110 c
111 ): c is Extract<
112 typeof c,
113 { type: 'monostatic' | 'transmitter' | 'receiver' }
114 > =>
115 (c.type === 'monostatic' ||
116 c.type === 'transmitter' ||
117 c.type === 'receiver') &&
118 c.antennaId !== null
119 );
120 }, [platform.components]);
121
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' }> =>
126 c.type === 'target'
127 );
128 }, [platform.components]);
129
130 const labelData = useMemo(
131 () => ({
132 x: position?.x,
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
135 }),
136 [position]
137 );
138
139 return (
140 <group position={position}>
141 <group rotation={rotation}>
142 <mesh visible={showPlatforms}>
143 <sphereGeometry args={[0.5, 32, 32]} />
144 <meshStandardMaterial
145 color={
146 isSelected
147 ? fersColors.platform.selected
148 : fersColors.platform.default
149 }
150 roughness={0.2}
151 metalness={0.8}
152 emissive={
153 isSelected
154 ? fersColors.platform.emission
155 : '#000000'
156 }
157 emissiveIntensity={isSelected ? 0.4 : 0}
158 />
159 {/* Render Body Axes: Red=X (Right), Green=Y (Up), Blue=Z (Rear). */}
160 <ScaledAxesHelper visible={showAxes} size={2} />
161 </mesh>
162
163 {/* Visualize Isotropic Static Sphere RCS */}
164 {targetComponents.map((target) => {
165 {
166 /* TODO: currently only rendering constant isotropic RCS */
167 }
168 if (
169 target.rcs_type === 'isotropic' &&
170 target.rcs_value &&
171 target.rcs_value > 0
172 ) {
173 const radius = Math.sqrt(target.rcs_value / Math.PI);
174 return (
175 <mesh key={target.id} visible={showPlatforms}>
176 <sphereGeometry args={[radius, 24, 24]} />
177 <meshBasicMaterial
178 color={fersColors.physics.rcs}
179 wireframe
180 transparent
181 opacity={0.15}
182 />
183 </mesh>
184 );
185 }
186 return null;
187 })}
188
189 {/* Boresight is lightweight, conditional is fine, but visible is smoother */}
190 <group
191 visible={
192 showPlatforms &&
193 showBoresights &&
194 antennaComponents.length > 0
195 }
196 >
197 <BoresightArrow />
198 </group>
199
200 {/* Show antenna pattern meshes if the global toggle is enabled */}
201 {showPlatforms &&
202 showPatterns &&
203 antennaComponents.map((comp) =>
204 comp.antennaId ? (
205 <AntennaPatternMesh
206 key={comp.id}
207 antennaId={comp.antennaId}
208 component={comp}
209 />
210 ) : null
211 )}
212 </group>
213
214 {/* Velocity Arrow */}
215 <group visible={showPlatforms && showVelocities}>
216 <VelocityArrow platform={platform} currentTime={currentTime} />
217 </group>
218
219 {/* Label */}
220 {showPlatforms && showPlatformLabels && (
221 <Html
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)
225 style={{
226 backgroundColor: fersColors.background.overlay,
227 color: fersColors.text.primary,
228 padding: '4px 8px',
229 borderRadius: '4px',
230 fontSize: '12px',
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 ${
238 isSelected
239 ? fersColors.platform.selected
240 : fersColors.text.secondary
241 }`,
242 boxShadow: isSelected
243 ? `0 0 8px ${fersColors.platform.selected}40`
244 : 'none',
245 }}
246 >
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>
251 </Html>
252 )}
253 </group>
254 );
255});
256
257/**
258 * WorldView represents the 3D scene where simulation elements are visualized.
259 * It provides an interactive camera and renders platforms from the current scenario.
260 */
261interface WorldViewProps {
262 controlsRef: React.RefObject<MapControlsImpl | null>;
263}
264
265/**
266 * WorldView represents the 3D scene where simulation elements are visualized.
267 * It provides an interactive camera and renders platforms from the current scenario.
268 */
269export default function WorldView({ controlsRef }: WorldViewProps) {
270 const platforms = useScenarioStore((state) => state.platforms);
271
272 const fetchPlatformPath = useScenarioStore(
273 (state) => state.fetchPlatformPath
274 );
275
276 // Root Level Visibility Toggles
277 const { showLinks, showMotionPaths } = useScenarioStore(
278 (state) => state.visibility
279 );
280
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);
284
285 useEffect(() => {
286 platformsRef.current = platforms;
287 }, [platforms]);
288
289 // Memoize platform dependencies.
290 const platformDeps = useMemo(
291 () =>
292 platforms
293 .map((p) => {
294 const rotKey =
295 p.rotation.type === 'path'
296 ? `${p.rotation.interpolation}-${JSON.stringify(
297 p.rotation.waypoints
298 )}`
299 : `fixed-${p.rotation.startAzimuth}-${p.rotation.startElevation}-${p.rotation.azimuthRate}-${p.rotation.elevationRate}`;
300
301 return [
302 p.id,
303 p.motionPath.interpolation,
304 JSON.stringify(p.motionPath.waypoints),
305 rotKey,
306 ].join('|');
307 })
308 .join(';'),
309 [platforms]
310 );
311
312 useEffect(() => {
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);
317 });
318 }, [platformDeps, fetchPlatformPath]);
319
320 return (
321 <>
322 <CameraManager controlsRef={controlsRef} />
323
324 {/* Controls */}
325 <MapControls makeDefault ref={controlsRef} />
326
327 {/* Lighting */}
328 <ambientLight intensity={0.5} />
329 <directionalLight position={[50, 50, 25]} intensity={2.5} />
330
331 {/* Environment for realistic reflections and ambient light */}
332 <Environment files="/potsdamer_platz_1k.hdr" />
333
334 {/* Physics Link Visualization - Conditional Render */}
335 {showLinks && <LinkVisualizer />}
336
337 {/* Objects */}
338 {platforms.map((platform) => (
339 <group key={platform.id}>
340 <PlatformSphere platform={platform} />
341 {/* Motion Path Lines */}
342 {showMotionPaths && <MotionPathLine platform={platform} />}
343 </group>
344 ))}
345 </>
346 );
347}