FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
platformSlice.ts
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 { StateCreator } from 'zustand';
6import { createDefaultPlatform } from '../defaults';
7import { generateSimId } from '../idUtils';
8import { createUniqueScenarioName } from '../nameUtils';
9import {
10 cleanObject,
11 serializeComponentInner,
12 serializePlatform,
13} from '../serializers';
14import {
15 enqueueFullSyncDetached,
16 enqueueGranularSyncDetached,
17} from '../syncQueue';
18import {
19 Platform,
20 PlatformActions,
21 PlatformComponent,
22 ScenarioStore,
23} from '../types';
24import { buildScenarioJson } from './backendSlice';
25
26const NUM_PATH_POINTS = 100;
27type InterpolationType = 'static' | 'linear' | 'cubic';
28interface InterpolatedPoint {
29 x: number;
30 y: number;
31 z: number;
32 vx: number;
33 vy: number;
34 vz: number;
35}
36
37interface InterpolatedRotationPoint {
38 azimuth: number;
39 elevation: number;
40}
41
42function syncPlatformGranular(
43 getFn: () => ScenarioStore,
44 platformId: string
45): void {
46 const platform = getFn().platforms.find((p) => p.id === platformId);
47 if (!platform) return;
48 enqueueGranularSyncDetached(
49 'Platform',
50 platform.id,
51 JSON.stringify(cleanObject(serializePlatform(platform)))
52 );
53}
54
55export const createPlatformSlice: StateCreator<
56 ScenarioStore,
57 [['zustand/immer', never]],
58 [],
59 PlatformActions
60> = (set, get) => ({
61 addPlatform: () => {
62 set((state) => {
63 const id = generateSimId('Platform');
64 const newName = `Platform ${state.platforms.length + 1}`;
65 const newPlatform: Platform = {
66 ...createDefaultPlatform(),
67 id,
68 name: createUniqueScenarioName(state, newName),
69 };
70 // Defaults to empty components list
71 state.platforms.push(newPlatform);
72 state.isDirty = true;
73 });
74 // libfers has no granular add API for Platforms — full sync is required.
75 enqueueFullSyncDetached(() => buildScenarioJson(get()));
76 },
77 addPositionWaypoint: (platformId) => {
78 let touched = false;
79 set((state) => {
80 const platform = state.platforms.find((p) => p.id === platformId);
81 if (platform) {
82 platform.motionPath.waypoints.push({
83 id: generateSimId('Platform'),
84 x: 0,
85 y: 0,
86 altitude: 0,
87 time: 0,
88 });
89 state.isDirty = true;
90 touched = true;
91 }
92 });
93 if (touched) syncPlatformGranular(get, platformId);
94 },
95 removePositionWaypoint: (platformId, waypointId) => {
96 let touched = false;
97 set((state) => {
98 const platform = state.platforms.find((p) => p.id === platformId);
99 if (platform && platform.motionPath.waypoints.length > 1) {
100 const index = platform.motionPath.waypoints.findIndex(
101 (wp) => wp.id === waypointId
102 );
103 if (index > -1) {
104 platform.motionPath.waypoints.splice(index, 1);
105 state.isDirty = true;
106 touched = true;
107 }
108 }
109 });
110 if (touched) syncPlatformGranular(get, platformId);
111 },
112 addRotationWaypoint: (platformId) => {
113 let touched = false;
114 set((state) => {
115 const platform = state.platforms.find((p) => p.id === platformId);
116 if (platform?.rotation.type === 'path') {
117 platform.rotation.waypoints.push({
118 id: generateSimId('Platform'),
119 azimuth: 0,
120 elevation: 0,
121 time: 0,
122 });
123 state.isDirty = true;
124 touched = true;
125 }
126 });
127 if (touched) syncPlatformGranular(get, platformId);
128 },
129 removeRotationWaypoint: (platformId, waypointId) => {
130 let touched = false;
131 set((state) => {
132 const platform = state.platforms.find((p) => p.id === platformId);
133 if (
134 platform?.rotation.type === 'path' &&
135 platform.rotation.waypoints.length > 1
136 ) {
137 const index = platform.rotation.waypoints.findIndex(
138 (wp) => wp.id === waypointId
139 );
140 if (index > -1) {
141 platform.rotation.waypoints.splice(index, 1);
142 state.isDirty = true;
143 touched = true;
144 }
145 }
146 });
147 if (touched) syncPlatformGranular(get, platformId);
148 },
149 addPlatformComponent: (platformId, componentType) => {
150 let added = false;
151 set((state) => {
152 const platform = state.platforms.find((p) => p.id === platformId);
153 if (!platform) return;
154
155 const id =
156 componentType === 'transmitter'
157 ? generateSimId('Transmitter')
158 : componentType === 'receiver'
159 ? generateSimId('Receiver')
160 : componentType === 'target'
161 ? generateSimId('Target')
162 : generateSimId('Transmitter');
163 const rxId =
164 componentType === 'monostatic'
165 ? generateSimId('Receiver')
166 : null;
167 const name = `${platform.name} ${
168 componentType.charAt(0).toUpperCase() + componentType.slice(1)
169 }`;
170 const uniqueName = createUniqueScenarioName(state, name);
171 let newComponent: PlatformComponent;
172
173 switch (componentType) {
174 case 'monostatic':
175 newComponent = {
176 id,
177 type: 'monostatic',
178 txId: id,
179 rxId: rxId ?? generateSimId('Receiver'),
180 name: uniqueName,
181 radarType: 'pulsed',
182 window_skip: 0,
183 window_length: 1e-5,
184 prf: 1000,
185 antennaId: null,
186 waveformId: null,
187 timingId: null,
188 noiseTemperature: 290,
189 noDirectPaths: false,
190 noPropagationLoss: false,
191 schedule: [],
192 };
193 break;
194 case 'transmitter':
195 newComponent = {
196 id,
197 type: 'transmitter',
198 name: uniqueName,
199 radarType: 'pulsed',
200 prf: 1000,
201 antennaId: null,
202 waveformId: null,
203 timingId: null,
204 schedule: [],
205 };
206 break;
207 case 'receiver':
208 newComponent = {
209 id,
210 type: 'receiver',
211 name: uniqueName,
212 radarType: 'pulsed',
213 window_skip: 0,
214 window_length: 1e-5,
215 prf: 1000,
216 antennaId: null,
217 timingId: null,
218 noiseTemperature: 290,
219 noDirectPaths: false,
220 noPropagationLoss: false,
221 schedule: [],
222 };
223 break;
224 case 'target':
225 newComponent = {
226 id,
227 type: 'target',
228 name: uniqueName,
229 rcs_type: 'isotropic',
230 rcs_value: 1,
231 rcs_model: 'constant',
232 };
233 break;
234 default:
235 return;
236 }
237 platform.components.push(newComponent);
238 state.isDirty = true;
239 added = true;
240 });
241 if (added) {
242 // Components (Transmitter/Receiver/Target/Monostatic) are independent
243 // backend objects with their own IDs; there is no granular add API.
244 enqueueFullSyncDetached(() => buildScenarioJson(get()));
245 }
246 },
247 removePlatformComponent: (platformId, componentId) => {
248 let removed = false;
249 set((state) => {
250 const platform = state.platforms.find((p) => p.id === platformId);
251 if (platform) {
252 const index = platform.components.findIndex(
253 (c) => c.id === componentId
254 );
255 if (index > -1) {
256 platform.components.splice(index, 1);
257 if (state.selectedComponentId === componentId) {
258 state.selectedComponentId = null;
259 }
260 state.isDirty = true;
261 removed = true;
262 }
263 }
264 });
265 if (removed) {
266 // libfers has no granular remove API — full sync is required.
267 enqueueFullSyncDetached(() => buildScenarioJson(get()));
268 }
269 },
270 setPlatformRcsModel: (platformId, componentId, newModel) => {
271 let touched = false;
272 set((state) => {
273 const platform = state.platforms.find((p) => p.id === platformId);
274 const component = platform?.components.find(
275 (c) => c.id === componentId
276 );
277 if (component?.type === 'target') {
278 component.rcs_model = newModel;
279 if (newModel === 'chisquare' || newModel === 'gamma') {
280 if (typeof component.rcs_k !== 'number') {
281 component.rcs_k = 1.0;
282 }
283 } else {
284 delete component.rcs_k;
285 }
286 state.isDirty = true;
287 touched = true;
288 }
289 });
290 if (touched) {
291 const platform = get().platforms.find((p) => p.id === platformId);
292 const component = platform?.components.find(
293 (c) => c.id === componentId
294 );
295 if (component) {
296 enqueueGranularSyncDetached(
297 'Target',
298 component.id,
299 JSON.stringify(
300 cleanObject(serializeComponentInner(component))
301 )
302 );
303 }
304 }
305 },
306 fetchPlatformPath: async (platformId) => {
307 const { platforms, showError, globalParameters } = get();
308 const platform = platforms.find((p) => p.id === platformId);
309
310 if (!platform) return;
311
312 // 1. Fetch/Calculate Motion Path
313 const { waypoints, interpolation } = platform.motionPath;
314 let newPathPoints: {
315 x: number;
316 y: number;
317 z: number;
318 vx: number;
319 vy: number;
320 vz: number;
321 }[] = [];
322
323 try {
324 if (waypoints.length < 2 || interpolation === 'static') {
325 // Static or single point: Calculate directly on frontend (velocity 0)
326 newPathPoints = waypoints.map((wp) => ({
327 x: wp.x,
328 y: wp.altitude,
329 z: -wp.y, // ENU Y -> Three JS -Z
330 vx: 0,
331 vy: 0,
332 vz: 0,
333 }));
334 } else {
335 // Dynamic: Fetch interpolated points from Backend
336 const points = await invoke<InterpolatedPoint[]>(
337 'get_interpolated_motion_path',
338 {
339 waypoints,
340 interpType: interpolation as InterpolationType,
341 numPoints: NUM_PATH_POINTS,
342 }
343 );
344 // Convert ENU (Backend) to Three.js coordinates
345 // Pos: X->X, Alt->Y, Y->-Z
346 // Vel: Vx->Vx, Vz->Vy, Vy->-Vz
347 newPathPoints = points.map((p) => ({
348 x: p.x,
349 y: p.z,
350 z: -p.y,
351 vx: p.vx,
352 vy: p.vz,
353 vz: -p.vy,
354 }));
355 }
356 } catch (error) {
357 const msg = error instanceof Error ? error.message : String(error);
358 console.error(
359 `Failed to fetch motion path for ${platform.name}:`,
360 msg
361 );
362 showError(`Failed to get motion path for ${platform.name}: ${msg}`);
363 // Fallback to empty to prevent stale data
364 newPathPoints = [];
365 }
366
367 // 2. Fetch/Calculate Rotation Path
368 const { rotation } = platform;
369 let newRotationPoints:
370 | { azimuth: number; elevation: number }[]
371 | undefined = undefined;
372
373 if (rotation.type === 'path') {
374 const rotWaypoints = rotation.waypoints;
375 // Only fetch from backend if dynamic. Static/Single points are handled by the real-time calculator.
376 if (
377 rotWaypoints.length >= 2 &&
378 rotation.interpolation !== 'static'
379 ) {
380 try {
381 const points = await invoke<InterpolatedRotationPoint[]>(
382 'get_interpolated_rotation_path',
383 {
384 waypoints: rotWaypoints,
385 interpType:
386 rotation.interpolation as InterpolationType,
387 angleUnit: globalParameters.rotationAngleUnit,
388 numPoints: NUM_PATH_POINTS,
389 }
390 );
391 newRotationPoints = points.map((p) => ({
392 azimuth: p.azimuth,
393 elevation: p.elevation,
394 }));
395 } catch (error) {
396 const msg =
397 error instanceof Error ? error.message : String(error);
398 console.error(
399 `Failed to fetch rotation path for ${platform.name}:`,
400 msg
401 );
402 // Log error but don't break the whole update; standard calc will fallback to first waypoint
403 }
404 }
405 }
406
407 // 3. Update Store
408 set((state) => {
409 const p = state.platforms.find((p) => p.id === platformId);
410 if (p) {
411 p.pathPoints = newPathPoints;
412 p.rotationPathPoints = newRotationPoints;
413 }
414 });
415 },
416});