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