FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
AntennaPatternMesh.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 { invoke } from '@tauri-apps/api/core';
5import { useEffect, useMemo, useRef, useState } from 'react';
6import * as THREE from 'three';
7import { useShallow } from 'zustand/react/shallow';
8import { useDynamicScale } from '@/hooks/useDynamicScale';
9import { PlatformComponent, useScenarioStore } from '@/stores/scenarioStore';
10import { isFileBackedAntennaPendingFile } from '@/stores/scenarioStore/serializers';
11import { useSimulationProgressStore } from '@/stores/simulationProgressStore';
12
13const AZIMUTH_SEGMENTS = 64; // Resolution for azimuth sampling
14const ELEVATION_SEGMENTS = 32; // Resolution for elevation sampling
15const BASE_MESH_RADIUS = 3; // Base visual scaling factor for the main lobe.
16const PREVIEW_RETRY_DELAY_MS = 250;
17
18export const BACKEND_BUSY_MESSAGE = 'Backend is busy with another operation';
19
20interface AntennaPatternData {
21 gains: number[];
22 az_count: number;
23 el_count: number;
24 max_gain: number;
25}
26
27interface AntennaPatternMeshProps {
28 antennaId: string;
29 component: PlatformComponent;
30}
31
32type AntennaPreviewBusyState = {
33 hasRequest: boolean;
34 isBackendSyncing: boolean;
35 isSimulating: boolean;
36 isGeneratingKml: boolean;
37};
38
39type AntennaPreviewErrorAction = {
40 clearError: boolean;
41 clearPattern: boolean;
42 scheduleRetry: boolean;
43};
44
45export function isBackendBusyError(error: unknown) {
46 const message = error instanceof Error ? error.message : String(error);
47 return message.includes(BACKEND_BUSY_MESSAGE);
48}
49
50export function getAntennaPreviewErrorAction(
51 error: unknown
52): AntennaPreviewErrorAction {
53 const isBusy = isBackendBusyError(error);
54 return {
55 clearError: isBusy,
56 clearPattern: !isBusy,
57 scheduleRetry: isBusy,
58 };
59}
60
61export function shouldDeferAntennaPreviewFetch({
62 hasRequest,
63 isBackendSyncing,
64 isSimulating,
65 isGeneratingKml,
66}: AntennaPreviewBusyState) {
67 return hasRequest && (isBackendSyncing || isSimulating || isGeneratingKml);
68}
69
70/**
71 * Creates and renders a 3D heatmap mesh from real antenna pattern data
72 * fetched from the `libfers` backend.
73 *
74 * @returns A Three.js mesh component representing the antenna gain pattern.
75 */
76export function AntennaPatternMesh({
77 antennaId,
78 component,
79}: AntennaPatternMeshProps) {
80 const [patternData, setPatternData] = useState<AntennaPatternData | null>(
81 null
82 );
83
84 const isSimulating = useSimulationProgressStore(
85 (state) => state.isSimulating
86 );
87 const isGeneratingKml = useSimulationProgressStore(
88 (state) => state.isGeneratingKml
89 );
90
91 // Select antenna, potential waveform, and backend sync state
92 const { antenna, waveform, isBackendSyncing } = useScenarioStore(
93 useShallow((state) => {
94 const ant = state.antennas.find((a) => a.id === antennaId);
95 const wf =
96 'waveformId' in component && component.waveformId
97 ? state.waveforms.find((w) => w.id === component.waveformId)
98 : undefined;
99 return {
100 antenna: ant,
101 waveform: wf,
102 isBackendSyncing: state.isBackendSyncing,
103 };
104 })
105 );
106 const { setAntennaPreviewError, clearAntennaPreviewError } =
107 useScenarioStore.getState();
108
109 const antennaIdStr = antenna?.id;
110 const userScale = antenna?.meshScale ?? 1.0;
111 const groupRef = useRef<THREE.Group>(null!);
112 const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
113 const lastRequestKeyRef = useRef<string | null>(null);
114 const [retryNonce, setRetryNonce] = useState(0);
115
116 // Create a stable hash of the antenna's properties to trigger real-time updates
117 const antennaHash = JSON.stringify(antenna);
118
119 // Apply dynamic scaling hook
120 useDynamicScale(groupRef, { baseScale: userScale });
121
122 // Determine the frequency to use for pattern calculation
123 const frequency = useMemo(() => {
124 if (!antenna) return null;
125 if (isFileBackedAntennaPendingFile(antenna)) return null;
126
127 // 1. If the antenna is frequency-independent, we can render it regardless of waveform.
128 const independentTypes = [
129 'isotropic',
130 'sinc',
131 'gaussian',
132 'xml',
133 'file',
134 ];
135 if (independentTypes.includes(antenna.pattern)) {
136 // Use a dummy frequency (1GHz) if the backend requires non-zero,
137 // effectively ignored by these pattern types.
138 return 1e9;
139 }
140
141 // 2. If the antenna is frequency-dependent (Horn/Parabolic):
142 // Priority A: Use the active Waveform frequency if attached.
143 if (waveform?.carrier_frequency) {
144 return waveform.carrier_frequency;
145 }
146
147 // Priority B: Use the 'Design Frequency' from the Antenna Inspector.
148 if (antenna.design_frequency) {
149 return antenna.design_frequency;
150 }
151
152 // 3. If prerequisites are not met, return null to suppress rendering.
153 return null;
154 }, [antenna, waveform]);
155 const requestKey =
156 antennaIdStr && frequency !== null
157 ? `${antennaIdStr}:${frequency}:${antennaHash}`
158 : null;
159 const shouldDeferFetch = shouldDeferAntennaPreviewFetch({
160 hasRequest: requestKey !== null,
161 isBackendSyncing,
162 isSimulating,
163 isGeneratingKml,
164 });
165
166 useEffect(() => {
167 return () => {
168 if (retryTimerRef.current !== null) {
169 clearTimeout(retryTimerRef.current);
170 retryTimerRef.current = null;
171 }
172 };
173 }, []);
174
175 useEffect(() => {
176 let isCancelled = false;
177
178 if (retryTimerRef.current !== null) {
179 clearTimeout(retryTimerRef.current);
180 retryTimerRef.current = null;
181 }
182
183 if (!requestKey || !antennaIdStr || frequency === null) {
184 lastRequestKeyRef.current = null;
185 setPatternData(null);
186 if (antennaIdStr) {
187 clearAntennaPreviewError(antennaIdStr);
188 }
189 return () => {
190 isCancelled = true;
191 };
192 }
193
194 const requestKeyChanged = lastRequestKeyRef.current !== requestKey;
195 lastRequestKeyRef.current = requestKey;
196 if (requestKeyChanged) {
197 setPatternData(null);
198 }
199
200 if (shouldDeferFetch) {
201 clearAntennaPreviewError(antennaIdStr);
202 return () => {
203 isCancelled = true;
204 };
205 }
206
207 const fetchPattern = async () => {
208 try {
209 const data = await invoke<AntennaPatternData>(
210 'get_antenna_pattern',
211 {
212 antennaId: antennaIdStr,
213 azSamples: AZIMUTH_SEGMENTS + 1,
214 elSamples: ELEVATION_SEGMENTS + 1,
215 frequency,
216 }
217 );
218
219 if (!isCancelled) {
220 clearAntennaPreviewError(antennaIdStr);
221 setPatternData(data);
222 }
223 } catch (error) {
224 if (isCancelled) {
225 return;
226 }
227
228 const message =
229 error instanceof Error ? error.message : String(error);
230 const action = getAntennaPreviewErrorAction(error);
231
232 if (action.clearError) {
233 clearAntennaPreviewError(antennaIdStr);
234 }
235
236 if (action.clearPattern) {
237 console.error(
238 `Failed to fetch pattern for antenna ${antennaIdStr}:`,
239 error
240 );
241 setAntennaPreviewError(antennaIdStr, message);
242 setPatternData(null);
243 }
244
245 if (action.scheduleRetry) {
246 retryTimerRef.current = setTimeout(() => {
247 setRetryNonce((current) => current + 1);
248 }, PREVIEW_RETRY_DELAY_MS);
249 }
250 }
251 };
252
253 void fetchPattern();
254
255 return () => {
256 isCancelled = true;
257 };
258 }, [antennaIdStr, frequency, requestKey, retryNonce, shouldDeferFetch]);
259
260 const geometry = useMemo(() => {
261 if (!patternData) return new THREE.BufferGeometry();
262
263 const geom = new THREE.BufferGeometry();
264 const vertices: number[] = [];
265 const colors: number[] = [];
266 const { gains, az_count, el_count } = patternData;
267
268 for (let i = 0; i < el_count; i++) {
269 // Elevation from -PI/2 to PI/2
270 const elevation = (i / (el_count - 1)) * Math.PI - Math.PI / 2;
271
272 for (let j = 0; j < az_count; j++) {
273 // Azimuth from -PI to PI
274 const azimuth = (j / (az_count - 1)) * 2 * Math.PI - Math.PI;
275
276 const gain = gains[i * az_count + j]; // Normalized gain [0, 1]
277 const radius = gain * BASE_MESH_RADIUS;
278
279 // Convert spherical to Cartesian for a -Z forward orientation
280 const x = radius * Math.cos(elevation) * Math.sin(azimuth);
281 const y = radius * Math.sin(elevation);
282 const z = -radius * Math.cos(elevation) * Math.cos(azimuth);
283
284 vertices.push(x, y, z);
285
286 // Assign color based on gain (heatmap: blue -> green -> red)
287 const color = new THREE.Color();
288 // HSL: Hue from blue (0.66, low gain) to red (0.0, high gain).
289 color.setHSL(0.66 * (1 - gain), 1.0, 0.5);
290 colors.push(color.r, color.g, color.b);
291 }
292 }
293
294 const indices: number[] = [];
295 for (let i = 0; i < ELEVATION_SEGMENTS; i++) {
296 for (let j = 0; j < AZIMUTH_SEGMENTS; j++) {
297 const a = i * (AZIMUTH_SEGMENTS + 1) + j;
298 const b = a + AZIMUTH_SEGMENTS + 1;
299 const c = a + 1;
300 const d = b + 1;
301
302 indices.push(a, b, c); // First triangle
303 indices.push(b, d, c); // Second triangle
304 }
305 }
306
307 geom.setIndex(indices);
308 geom.setAttribute(
309 'position',
310 new THREE.Float32BufferAttribute(vertices, 3)
311 );
312 geom.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
313 geom.computeVertexNormals();
314
315 return geom;
316 }, [patternData]);
317
318 if (!patternData) return null;
319
320 return (
321 <group ref={groupRef}>
322 <mesh geometry={geometry}>
323 <meshStandardMaterial
324 vertexColors
325 transparent
326 opacity={0.5}
327 side={THREE.DoubleSide}
328 roughness={0.7}
329 metalness={0.1}
330 depthWrite={false}
331 polygonOffset
332 polygonOffsetFactor={1}
333 polygonOffsetUnits={1}
334 />
335 </mesh>
336 </group>
337 );
338}