1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
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';
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.
15interface AntennaPatternData {
22interface AntennaPatternMeshProps {
24 component: PlatformComponent;
28 * Creates and renders a 3D heatmap mesh from real antenna pattern data
29 * fetched from the `libfers` backend.
31 * @returns A Three.js mesh component representing the antenna gain pattern.
33export function AntennaPatternMesh({
36}: AntennaPatternMeshProps) {
37 const [patternData, setPatternData] = useState<AntennaPatternData | null>(
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);
46 'waveformId' in component && component.waveformId
47 ? state.waveforms.find((w) => w.id === component.waveformId)
52 backendVersion: state.backendVersion,
57 const antennaName = antenna?.name;
58 const userScale = antenna?.meshScale ?? 1.0;
59 const groupRef = useRef<THREE.Group>(null!);
61 // Apply dynamic scaling hook
62 useDynamicScale(groupRef, { baseScale: userScale });
64 // Determine the frequency to use for pattern calculation
65 const frequency = useMemo(() => {
66 if (!antenna) return null;
68 // 1. If the antenna is frequency-independent, we can render it regardless of waveform.
69 const independentTypes = [
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.
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;
88 // Priority B: Use the 'Design Frequency' from the Antenna Inspector.
89 if (antenna.design_frequency) {
90 return antenna.design_frequency;
93 // 3. If prerequisites are not met, return null to suppress rendering.
95 }, [antenna, waveform]);
98 let isCancelled = false;
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) {
109 const data = await invoke<AntennaPatternData>(
110 'get_antenna_pattern',
112 antennaName: antennaName,
113 azSamples: AZIMUTH_SEGMENTS + 1,
114 elSamples: ELEVATION_SEGMENTS + 1,
115 frequency: frequency,
120 setPatternData(data);
124 `Failed to fetch pattern for antenna ${antennaName}:`,
128 setPatternData(null);
137 // Clear data on cleanup to prevent "stale" patterns flashing when switching configurations
138 setPatternData(null);
140 }, [antennaName, frequency, backendVersion]);
142 const geometry = useMemo(() => {
143 if (!patternData) return new THREE.BufferGeometry();
145 const geom = new THREE.BufferGeometry();
146 const vertices: number[] = [];
147 const colors: number[] = [];
148 const { gains, az_count, el_count } = patternData;
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;
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;
158 const gain = gains[i * az_count + j]; // Normalized gain [0, 1]
159 const radius = gain * BASE_MESH_RADIUS;
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);
166 vertices.push(x, y, z);
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);
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;
184 indices.push(a, b, c); // First triangle
185 indices.push(b, d, c); // Second triangle
189 geom.setIndex(indices);
192 new THREE.Float32BufferAttribute(vertices, 3)
194 geom.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
195 geom.computeVertexNormals();
200 if (!patternData) return null;
203 <group ref={groupRef}>
204 <mesh geometry={geometry}>
205 <meshStandardMaterial
209 side={THREE.DoubleSide}
214 polygonOffsetFactor={1}
215 polygonOffsetUnits={1}