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';
11import { useSimulationProgressStore } from '@/stores/simulationProgressStore';
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;
18export const BACKEND_BUSY_MESSAGE = 'Backend is busy with another operation';
20interface AntennaPatternData {
27interface AntennaPatternMeshProps {
29 component: PlatformComponent;
32type AntennaPreviewBusyState = {
34 isBackendSyncing: boolean;
35 isSimulating: boolean;
36 isGeneratingKml: boolean;
39type AntennaPreviewErrorAction = {
41 clearPattern: boolean;
42 scheduleRetry: boolean;
45export function isBackendBusyError(error: unknown) {
46 const message = error instanceof Error ? error.message : String(error);
47 return message.includes(BACKEND_BUSY_MESSAGE);
50export function getAntennaPreviewErrorAction(
52): AntennaPreviewErrorAction {
53 const isBusy = isBackendBusyError(error);
56 clearPattern: !isBusy,
57 scheduleRetry: isBusy,
61export function shouldDeferAntennaPreviewFetch({
66}: AntennaPreviewBusyState) {
67 return hasRequest && (isBackendSyncing || isSimulating || isGeneratingKml);
71 * Creates and renders a 3D heatmap mesh from real antenna pattern data
72 * fetched from the `libfers` backend.
74 * @returns A Three.js mesh component representing the antenna gain pattern.
76export function AntennaPatternMesh({
79}: AntennaPatternMeshProps) {
80 const [patternData, setPatternData] = useState<AntennaPatternData | null>(
84 const isSimulating = useSimulationProgressStore(
85 (state) => state.isSimulating
87 const isGeneratingKml = useSimulationProgressStore(
88 (state) => state.isGeneratingKml
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);
96 'waveformId' in component && component.waveformId
97 ? state.waveforms.find((w) => w.id === component.waveformId)
102 isBackendSyncing: state.isBackendSyncing,
106 const { setAntennaPreviewError, clearAntennaPreviewError } =
107 useScenarioStore.getState();
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);
116 // Create a stable hash of the antenna's properties to trigger real-time updates
117 const antennaHash = JSON.stringify(antenna);
119 // Apply dynamic scaling hook
120 useDynamicScale(groupRef, { baseScale: userScale });
122 // Determine the frequency to use for pattern calculation
123 const frequency = useMemo(() => {
124 if (!antenna) return null;
125 if (isFileBackedAntennaPendingFile(antenna)) return null;
127 // 1. If the antenna is frequency-independent, we can render it regardless of waveform.
128 const independentTypes = [
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.
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;
147 // Priority B: Use the 'Design Frequency' from the Antenna Inspector.
148 if (antenna.design_frequency) {
149 return antenna.design_frequency;
152 // 3. If prerequisites are not met, return null to suppress rendering.
154 }, [antenna, waveform]);
156 antennaIdStr && frequency !== null
157 ? `${antennaIdStr}:${frequency}:${antennaHash}`
159 const shouldDeferFetch = shouldDeferAntennaPreviewFetch({
160 hasRequest: requestKey !== null,
168 if (retryTimerRef.current !== null) {
169 clearTimeout(retryTimerRef.current);
170 retryTimerRef.current = null;
176 let isCancelled = false;
178 if (retryTimerRef.current !== null) {
179 clearTimeout(retryTimerRef.current);
180 retryTimerRef.current = null;
183 if (!requestKey || !antennaIdStr || frequency === null) {
184 lastRequestKeyRef.current = null;
185 setPatternData(null);
187 clearAntennaPreviewError(antennaIdStr);
194 const requestKeyChanged = lastRequestKeyRef.current !== requestKey;
195 lastRequestKeyRef.current = requestKey;
196 if (requestKeyChanged) {
197 setPatternData(null);
200 if (shouldDeferFetch) {
201 clearAntennaPreviewError(antennaIdStr);
207 const fetchPattern = async () => {
209 const data = await invoke<AntennaPatternData>(
210 'get_antenna_pattern',
212 antennaId: antennaIdStr,
213 azSamples: AZIMUTH_SEGMENTS + 1,
214 elSamples: ELEVATION_SEGMENTS + 1,
220 clearAntennaPreviewError(antennaIdStr);
221 setPatternData(data);
229 error instanceof Error ? error.message : String(error);
230 const action = getAntennaPreviewErrorAction(error);
232 if (action.clearError) {
233 clearAntennaPreviewError(antennaIdStr);
236 if (action.clearPattern) {
238 `Failed to fetch pattern for antenna ${antennaIdStr}:`,
241 setAntennaPreviewError(antennaIdStr, message);
242 setPatternData(null);
245 if (action.scheduleRetry) {
246 retryTimerRef.current = setTimeout(() => {
247 setRetryNonce((current) => current + 1);
248 }, PREVIEW_RETRY_DELAY_MS);
258 }, [antennaIdStr, frequency, requestKey, retryNonce, shouldDeferFetch]);
260 const geometry = useMemo(() => {
261 if (!patternData) return new THREE.BufferGeometry();
263 const geom = new THREE.BufferGeometry();
264 const vertices: number[] = [];
265 const colors: number[] = [];
266 const { gains, az_count, el_count } = patternData;
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;
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;
276 const gain = gains[i * az_count + j]; // Normalized gain [0, 1]
277 const radius = gain * BASE_MESH_RADIUS;
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);
284 vertices.push(x, y, z);
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);
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;
302 indices.push(a, b, c); // First triangle
303 indices.push(b, d, c); // Second triangle
307 geom.setIndex(indices);
310 new THREE.Float32BufferAttribute(vertices, 3)
312 geom.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
313 geom.computeVertexNormals();
318 if (!patternData) return null;
321 <group ref={groupRef}>
322 <mesh geometry={geometry}>
323 <meshStandardMaterial
327 side={THREE.DoubleSide}
332 polygonOffsetFactor={1}
333 polygonOffsetUnits={1}