1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
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';
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.
16interface AntennaPatternData {
23interface AntennaPatternMeshProps {
25 component: PlatformComponent;
29 * Creates and renders a 3D heatmap mesh from real antenna pattern data
30 * fetched from the `libfers` backend.
32 * @returns A Three.js mesh component representing the antenna gain pattern.
34export function AntennaPatternMesh({
37}: AntennaPatternMeshProps) {
38 const [patternData, setPatternData] = useState<AntennaPatternData | null>(
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);
47 'waveformId' in component && component.waveformId
48 ? state.waveforms.find((w) => w.id === component.waveformId)
53 backendVersion: state.backendVersion,
57 const { setAntennaPreviewError, clearAntennaPreviewError } =
58 useScenarioStore.getState();
60 const antennaIdStr = antenna?.id;
61 const userScale = antenna?.meshScale ?? 1.0;
62 const groupRef = useRef<THREE.Group>(null!);
64 // Create a stable hash of the antenna's properties to trigger real-time updates
65 const antennaHash = JSON.stringify(antenna);
67 // Apply dynamic scaling hook
68 useDynamicScale(groupRef, { baseScale: userScale });
70 // Determine the frequency to use for pattern calculation
71 const frequency = useMemo(() => {
72 if (!antenna) return null;
73 if (isFileBackedAntennaPendingFile(antenna)) return null;
75 // 1. If the antenna is frequency-independent, we can render it regardless of waveform.
76 const independentTypes = [
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.
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;
95 // Priority B: Use the 'Design Frequency' from the Antenna Inspector.
96 if (antenna.design_frequency) {
97 return antenna.design_frequency;
100 // 3. If prerequisites are not met, return null to suppress rendering.
102 }, [antenna, waveform]);
105 let isCancelled = false;
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) {
113 clearAntennaPreviewError(antennaIdStr);
119 const data = await invoke<AntennaPatternData>(
120 'get_antenna_pattern',
122 antennaId: antennaIdStr,
123 azSamples: AZIMUTH_SEGMENTS + 1,
124 elSamples: ELEVATION_SEGMENTS + 1,
125 frequency: frequency,
130 clearAntennaPreviewError(antennaIdStr);
131 setPatternData(data);
135 error instanceof Error ? error.message : String(error);
137 `Failed to fetch pattern for antenna ${antennaIdStr}:`,
141 setAntennaPreviewError(antennaIdStr, message);
142 setPatternData(null);
151 // Clear data on cleanup to prevent "stale" patterns flashing when switching configurations
152 setPatternData(null);
154 }, [antennaIdStr, frequency, antennaHash, backendVersion]);
156 const geometry = useMemo(() => {
157 if (!patternData) return new THREE.BufferGeometry();
159 const geom = new THREE.BufferGeometry();
160 const vertices: number[] = [];
161 const colors: number[] = [];
162 const { gains, az_count, el_count } = patternData;
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;
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;
172 const gain = gains[i * az_count + j]; // Normalized gain [0, 1]
173 const radius = gain * BASE_MESH_RADIUS;
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);
180 vertices.push(x, y, z);
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);
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;
198 indices.push(a, b, c); // First triangle
199 indices.push(b, d, c); // Second triangle
203 geom.setIndex(indices);
206 new THREE.Float32BufferAttribute(vertices, 3)
208 geom.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
209 geom.computeVertexNormals();
214 if (!patternData) return null;
217 <group ref={groupRef}>
218 <mesh geometry={geometry}>
219 <meshStandardMaterial
223 side={THREE.DoubleSide}
228 polygonOffsetFactor={1}
229 polygonOffsetUnits={1}