FERS 1.0.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';
11
12const AZIMUTH_SEGMENTS = 64; // Resolution for azimuth sampling
13const ELEVATION_SEGMENTS = 32; // Resolution for elevation sampling
14const BASE_MESH_RADIUS = 3; // Base visual scaling factor for the main lobe.
15
16interface AntennaPatternData {
17 gains: number[];
18 az_count: number;
19 el_count: number;
20 max_gain: number;
21}
22
23interface AntennaPatternMeshProps {
24 antennaId: string;
25 component: PlatformComponent;
26}
27
28/**
29 * Creates and renders a 3D heatmap mesh from real antenna pattern data
30 * fetched from the `libfers` backend.
31 *
32 * @returns A Three.js mesh component representing the antenna gain pattern.
33 */
34export function AntennaPatternMesh({
35 antennaId,
36 component,
37}: AntennaPatternMeshProps) {
38 const [patternData, setPatternData] = useState<AntennaPatternData | null>(
39 null
40 );
41
42 // Select antenna, potential waveform, and backend version
43 const { antenna, waveform, backendVersion } = useScenarioStore(
44 useShallow((state) => {
45 const ant = state.antennas.find((a) => a.id === antennaId);
46 const wf =
47 'waveformId' in component && component.waveformId
48 ? state.waveforms.find((w) => w.id === component.waveformId)
49 : undefined;
50 return {
51 antenna: ant,
52 waveform: wf,
53 backendVersion: state.backendVersion,
54 };
55 })
56 );
57 const { setAntennaPreviewError, clearAntennaPreviewError } =
58 useScenarioStore.getState();
59
60 const antennaIdStr = antenna?.id;
61 const userScale = antenna?.meshScale ?? 1.0;
62 const groupRef = useRef<THREE.Group>(null!);
63
64 // Create a stable hash of the antenna's properties to trigger real-time updates
65 const antennaHash = JSON.stringify(antenna);
66
67 // Apply dynamic scaling hook
68 useDynamicScale(groupRef, { baseScale: userScale });
69
70 // Determine the frequency to use for pattern calculation
71 const frequency = useMemo(() => {
72 if (!antenna) return null;
73 if (isFileBackedAntennaPendingFile(antenna)) return null;
74
75 // 1. If the antenna is frequency-independent, we can render it regardless of waveform.
76 const independentTypes = [
77 'isotropic',
78 'sinc',
79 'gaussian',
80 'xml',
81 'file',
82 ];
83 if (independentTypes.includes(antenna.pattern)) {
84 // Use a dummy frequency (1GHz) if the backend requires non-zero,
85 // effectively ignored by these pattern types.
86 return 1e9;
87 }
88
89 // 2. If the antenna is frequency-dependent (Horn/Parabolic):
90 // Priority A: Use the active Waveform frequency if attached.
91 if (waveform?.carrier_frequency) {
92 return waveform.carrier_frequency;
93 }
94
95 // Priority B: Use the 'Design Frequency' from the Antenna Inspector.
96 if (antenna.design_frequency) {
97 return antenna.design_frequency;
98 }
99
100 // 3. If prerequisites are not met, return null to suppress rendering.
101 return null;
102 }, [antenna, waveform]);
103
104 useEffect(() => {
105 let isCancelled = false;
106
107 const fetchPattern = async () => {
108 // If we don't have a valid frequency or name, we simply exit.
109 // The cleanup function from the previous run will have already cleared the data,
110 // so the component will render nothing (which is correct).
111 if (!antennaIdStr || frequency === null) {
112 if (antennaIdStr) {
113 clearAntennaPreviewError(antennaIdStr);
114 }
115 return;
116 }
117
118 try {
119 const data = await invoke<AntennaPatternData>(
120 'get_antenna_pattern',
121 {
122 antennaId: antennaIdStr,
123 azSamples: AZIMUTH_SEGMENTS + 1,
124 elSamples: ELEVATION_SEGMENTS + 1,
125 frequency: frequency,
126 }
127 );
128
129 if (!isCancelled) {
130 clearAntennaPreviewError(antennaIdStr);
131 setPatternData(data);
132 }
133 } catch (error) {
134 const message =
135 error instanceof Error ? error.message : String(error);
136 console.error(
137 `Failed to fetch pattern for antenna ${antennaIdStr}:`,
138 error
139 );
140 if (!isCancelled) {
141 setAntennaPreviewError(antennaIdStr, message);
142 setPatternData(null);
143 }
144 }
145 };
146
147 void fetchPattern();
148
149 return () => {
150 isCancelled = true;
151 // Clear data on cleanup to prevent "stale" patterns flashing when switching configurations
152 setPatternData(null);
153 };
154 }, [antennaIdStr, frequency, antennaHash, backendVersion]);
155
156 const geometry = useMemo(() => {
157 if (!patternData) return new THREE.BufferGeometry();
158
159 const geom = new THREE.BufferGeometry();
160 const vertices: number[] = [];
161 const colors: number[] = [];
162 const { gains, az_count, el_count } = patternData;
163
164 for (let i = 0; i < el_count; i++) {
165 // Elevation from -PI/2 to PI/2
166 const elevation = (i / (el_count - 1)) * Math.PI - Math.PI / 2;
167
168 for (let j = 0; j < az_count; j++) {
169 // Azimuth from -PI to PI
170 const azimuth = (j / (az_count - 1)) * 2 * Math.PI - Math.PI;
171
172 const gain = gains[i * az_count + j]; // Normalized gain [0, 1]
173 const radius = gain * BASE_MESH_RADIUS;
174
175 // Convert spherical to Cartesian for a -Z forward orientation
176 const x = radius * Math.cos(elevation) * Math.sin(azimuth);
177 const y = radius * Math.sin(elevation);
178 const z = -radius * Math.cos(elevation) * Math.cos(azimuth);
179
180 vertices.push(x, y, z);
181
182 // Assign color based on gain (heatmap: blue -> green -> red)
183 const color = new THREE.Color();
184 // HSL: Hue from blue (0.66, low gain) to red (0.0, high gain).
185 color.setHSL(0.66 * (1 - gain), 1.0, 0.5);
186 colors.push(color.r, color.g, color.b);
187 }
188 }
189
190 const indices: number[] = [];
191 for (let i = 0; i < ELEVATION_SEGMENTS; i++) {
192 for (let j = 0; j < AZIMUTH_SEGMENTS; j++) {
193 const a = i * (AZIMUTH_SEGMENTS + 1) + j;
194 const b = a + AZIMUTH_SEGMENTS + 1;
195 const c = a + 1;
196 const d = b + 1;
197
198 indices.push(a, b, c); // First triangle
199 indices.push(b, d, c); // Second triangle
200 }
201 }
202
203 geom.setIndex(indices);
204 geom.setAttribute(
205 'position',
206 new THREE.Float32BufferAttribute(vertices, 3)
207 );
208 geom.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
209 geom.computeVertexNormals();
210
211 return geom;
212 }, [patternData]);
213
214 if (!patternData) return null;
215
216 return (
217 <group ref={groupRef}>
218 <mesh geometry={geometry}>
219 <meshStandardMaterial
220 vertexColors
221 transparent
222 opacity={0.5}
223 side={THREE.DoubleSide}
224 roughness={0.7}
225 metalness={0.1}
226 depthWrite={false}
227 polygonOffset
228 polygonOffsetFactor={1}
229 polygonOffsetUnits={1}
230 />
231 </mesh>
232 </group>
233 );
234}