FERS 1.0.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
assetTemplates.ts
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
3
4import { z } from 'zod';
5import {
6 AntennaSchema,
7 PlatformSchema,
8 TimingSchema,
9 WaveformSchema,
10} from './scenarioSchema';
11import { generateSimId } from './scenarioStore/idUtils';
12import type {
13 Antenna,
14 Platform,
15 PlatformComponent,
16 ScenarioData,
17 Timing,
18 Waveform,
19} from './scenarioStore/types';
20
21export const ASSET_LIBRARY_SCHEMA_VERSION = 1 as const;
22
23const TemplateBaseSchema = z.object({
24 schemaVersion: z.literal(ASSET_LIBRARY_SCHEMA_VERSION),
25 id: z.string().min(1),
26 name: z.string().min(1),
27 createdAt: z.string().min(1),
28 updatedAt: z.string().min(1),
29 source: z
30 .object({
31 scenarioName: z.string().optional(),
32 itemId: z.string().optional(),
33 })
34 .optional(),
35});
36
37const PlatformDependenciesSchema = z.object({
38 waveforms: z.array(WaveformSchema),
39 timings: z.array(TimingSchema),
40 antennas: z.array(AntennaSchema),
41});
42
43const WaveformTemplateSchema = TemplateBaseSchema.extend({
44 kind: z.literal('waveform'),
45 payload: WaveformSchema,
46});
47
48const TimingTemplateSchema = TemplateBaseSchema.extend({
49 kind: z.literal('timing'),
50 payload: TimingSchema,
51});
52
53const AntennaTemplateSchema = TemplateBaseSchema.extend({
54 kind: z.literal('antenna'),
55 payload: AntennaSchema,
56});
57
58const PlatformTemplateSchema = TemplateBaseSchema.extend({
59 kind: z.literal('platform'),
60 payload: PlatformSchema,
61 dependencies: PlatformDependenciesSchema,
62});
63
64export const AssetLibraryTemplateSchema = z.discriminatedUnion('kind', [
65 WaveformTemplateSchema,
66 TimingTemplateSchema,
67 AntennaTemplateSchema,
68 PlatformTemplateSchema,
69]);
70
71export const AssetLibraryFileSchema = z.object({
72 schemaVersion: z.literal(ASSET_LIBRARY_SCHEMA_VERSION),
73 templates: z.array(AssetLibraryTemplateSchema),
74});
75
76export type AssetTemplateKind = z.infer<
77 typeof AssetLibraryTemplateSchema
78>['kind'];
79export type AssetLibraryTemplate = z.infer<typeof AssetLibraryTemplateSchema>;
80export type AssetLibraryFile = z.infer<typeof AssetLibraryFileSchema>;
81
82export interface AssetTemplateInsertionResult {
83 insertedItemId: string | null;
84 insertedName: string | null;
85 warnings: string[];
86}
87
88type CreateTemplateOptions = {
89 id?: string;
90 timestamp?: string;
91};
92
93let fallbackTemplateCounter = 1;
94
95export function createAssetTemplateId(): string {
96 if (globalThis.crypto?.randomUUID) {
97 return globalThis.crypto.randomUUID();
98 }
99
100 const id = `template-${Date.now()}-${fallbackTemplateCounter}`;
101 fallbackTemplateCounter += 1;
102 return id;
103}
104
105function deepClone<T>(value: T): T {
106 return JSON.parse(JSON.stringify(value)) as T;
107}
108
109function createBaseTemplate(
110 item: Waveform | Timing | Antenna | Platform,
111 scenarioData: ScenarioData,
112 options: CreateTemplateOptions
113) {
114 const timestamp = options.timestamp ?? new Date().toISOString();
115 return {
116 schemaVersion: ASSET_LIBRARY_SCHEMA_VERSION,
117 id: options.id ?? createAssetTemplateId(),
118 name: item.name,
119 createdAt: timestamp,
120 updatedAt: timestamp,
121 source: {
122 scenarioName: scenarioData.globalParameters.simulation_name,
123 itemId: item.id,
124 },
125 };
126}
127
128function sanitizePlatform(platform: Platform): Platform {
129 const cloned = deepClone(platform);
130 delete cloned.pathPoints;
131 delete cloned.rotationPathPoints;
132 return cloned;
133}
134
135function uniqueById<T extends { id: string }>(items: T[]): T[] {
136 const seen = new Set<string>();
137 return items.filter((item) => {
138 if (seen.has(item.id)) {
139 return false;
140 }
141 seen.add(item.id);
142 return true;
143 });
144}
145
146function collectPlatformDependencies(
147 platform: Platform,
148 scenarioData: ScenarioData
149) {
150 const antennaIds = new Set<string>();
151 const waveformIds = new Set<string>();
152 const timingIds = new Set<string>();
153
154 for (const component of platform.components) {
155 if ('antennaId' in component && component.antennaId) {
156 antennaIds.add(component.antennaId);
157 }
158 if ('waveformId' in component && component.waveformId) {
159 waveformIds.add(component.waveformId);
160 }
161 if ('timingId' in component && component.timingId) {
162 timingIds.add(component.timingId);
163 }
164 }
165
166 return {
167 waveforms: uniqueById(
168 scenarioData.waveforms.filter((waveform) =>
169 waveformIds.has(waveform.id)
170 )
171 ).map(deepClone),
172 timings: uniqueById(
173 scenarioData.timings.filter((timing) => timingIds.has(timing.id))
174 ).map(deepClone),
175 antennas: uniqueById(
176 scenarioData.antennas.filter((antenna) =>
177 antennaIds.has(antenna.id)
178 )
179 ).map(deepClone),
180 };
181}
182
183export function createTemplateFromScenarioItem(
184 scenarioData: ScenarioData,
185 itemId: string,
186 options: CreateTemplateOptions = {}
187): AssetLibraryTemplate | null {
188 const waveform = scenarioData.waveforms.find((item) => item.id === itemId);
189 if (waveform) {
190 return {
191 ...createBaseTemplate(waveform, scenarioData, options),
192 kind: 'waveform',
193 payload: deepClone(waveform),
194 };
195 }
196
197 const timing = scenarioData.timings.find((item) => item.id === itemId);
198 if (timing) {
199 return {
200 ...createBaseTemplate(timing, scenarioData, options),
201 kind: 'timing',
202 payload: deepClone(timing),
203 };
204 }
205
206 const antenna = scenarioData.antennas.find((item) => item.id === itemId);
207 if (antenna) {
208 return {
209 ...createBaseTemplate(antenna, scenarioData, options),
210 kind: 'antenna',
211 payload: deepClone(antenna),
212 };
213 }
214
215 const platform = scenarioData.platforms.find((item) => item.id === itemId);
216 if (platform) {
217 return {
218 ...createBaseTemplate(platform, scenarioData, options),
219 kind: 'platform',
220 payload: sanitizePlatform(platform),
221 dependencies: collectPlatformDependencies(platform, scenarioData),
222 };
223 }
224
225 return null;
226}
227
228function createCopyName(
229 baseName: string,
230 existingItems: Array<{ name: string }>
231): string {
232 const existingNames = new Set(existingItems.map((item) => item.name));
233 let candidate = `${baseName} Copy`;
234 let suffix = 2;
235
236 while (existingNames.has(candidate)) {
237 candidate = `${baseName} Copy ${suffix}`;
238 suffix += 1;
239 }
240
241 return candidate;
242}
243
244function cloneWaveform(
245 waveform: Waveform,
246 scenarioData: ScenarioData,
247 baseName = waveform.name
248): Waveform {
249 return {
250 ...deepClone(waveform),
251 id: generateSimId('Waveform'),
252 name: createCopyName(baseName, scenarioData.waveforms),
253 };
254}
255
256function cloneTiming(
257 timing: Timing,
258 scenarioData: ScenarioData,
259 baseName = timing.name
260): Timing {
261 return {
262 ...deepClone(timing),
263 id: generateSimId('Timing'),
264 name: createCopyName(baseName, scenarioData.timings),
265 noiseEntries: timing.noiseEntries.map((entry) => ({
266 ...deepClone(entry),
267 id: generateSimId('Timing'),
268 })),
269 };
270}
271
272function cloneAntenna(
273 antenna: Antenna,
274 scenarioData: ScenarioData,
275 baseName = antenna.name
276): Antenna {
277 return {
278 ...deepClone(antenna),
279 id: generateSimId('Antenna'),
280 name: createCopyName(baseName, scenarioData.antennas),
281 };
282}
283
284function clonePlatformWaypointIds(platform: Platform): Platform {
285 const cloned = sanitizePlatform(platform);
286 cloned.id = generateSimId('Platform');
287 cloned.motionPath.waypoints = cloned.motionPath.waypoints.map(
288 (waypoint) => ({
289 ...waypoint,
290 id: generateSimId('Platform'),
291 })
292 );
293
294 if (cloned.rotation.type === 'path') {
295 cloned.rotation.waypoints = cloned.rotation.waypoints.map(
296 (waypoint) => ({
297 ...waypoint,
298 id: generateSimId('Platform'),
299 })
300 );
301 }
302
303 return cloned;
304}
305
306function remapReference(
307 originalId: string | null,
308 idMap: Map<string, string>,
309 warnings: string[],
310 label: string
311): string | null {
312 if (!originalId) {
313 return null;
314 }
315
316 const remapped = idMap.get(originalId);
317 if (remapped) {
318 return remapped;
319 }
320
321 warnings.push(
322 `Missing ${label} dependency ${originalId}; reference cleared.`
323 );
324 return null;
325}
326
327function cloneComponent(
328 component: PlatformComponent,
329 antennaIdMap: Map<string, string>,
330 timingIdMap: Map<string, string>,
331 waveformIdMap: Map<string, string>,
332 warnings: string[]
333): PlatformComponent {
334 switch (component.type) {
335 case 'monostatic': {
336 const txId = generateSimId('Transmitter');
337 return {
338 ...deepClone(component),
339 id: txId,
340 txId,
341 rxId: generateSimId('Receiver'),
342 antennaId: remapReference(
343 component.antennaId,
344 antennaIdMap,
345 warnings,
346 'antenna'
347 ),
348 timingId: remapReference(
349 component.timingId,
350 timingIdMap,
351 warnings,
352 'timing'
353 ),
354 waveformId: remapReference(
355 component.waveformId,
356 waveformIdMap,
357 warnings,
358 'waveform'
359 ),
360 };
361 }
362 case 'transmitter':
363 return {
364 ...deepClone(component),
365 id: generateSimId('Transmitter'),
366 antennaId: remapReference(
367 component.antennaId,
368 antennaIdMap,
369 warnings,
370 'antenna'
371 ),
372 timingId: remapReference(
373 component.timingId,
374 timingIdMap,
375 warnings,
376 'timing'
377 ),
378 waveformId: remapReference(
379 component.waveformId,
380 waveformIdMap,
381 warnings,
382 'waveform'
383 ),
384 };
385 case 'receiver':
386 return {
387 ...deepClone(component),
388 id: generateSimId('Receiver'),
389 antennaId: remapReference(
390 component.antennaId,
391 antennaIdMap,
392 warnings,
393 'antenna'
394 ),
395 timingId: remapReference(
396 component.timingId,
397 timingIdMap,
398 warnings,
399 'timing'
400 ),
401 };
402 case 'target':
403 return {
404 ...deepClone(component),
405 id: generateSimId('Target'),
406 };
407 }
408}
409
410export function cloneTemplateIntoScenarioData(
411 scenarioData: ScenarioData,
412 template: AssetLibraryTemplate
413): { scenarioData: ScenarioData; result: AssetTemplateInsertionResult } {
414 const nextScenarioData: ScenarioData = {
415 globalParameters: deepClone(scenarioData.globalParameters),
416 waveforms: scenarioData.waveforms.map(deepClone),
417 timings: scenarioData.timings.map(deepClone),
418 antennas: scenarioData.antennas.map(deepClone),
419 platforms: scenarioData.platforms.map(sanitizePlatform),
420 };
421 const warnings: string[] = [];
422
423 switch (template.kind) {
424 case 'waveform': {
425 const waveform = cloneWaveform(
426 template.payload,
427 nextScenarioData,
428 template.name
429 );
430 nextScenarioData.waveforms.push(waveform);
431 return {
432 scenarioData: nextScenarioData,
433 result: {
434 insertedItemId: waveform.id,
435 insertedName: waveform.name,
436 warnings,
437 },
438 };
439 }
440 case 'timing': {
441 const timing = cloneTiming(
442 template.payload,
443 nextScenarioData,
444 template.name
445 );
446 nextScenarioData.timings.push(timing);
447 return {
448 scenarioData: nextScenarioData,
449 result: {
450 insertedItemId: timing.id,
451 insertedName: timing.name,
452 warnings,
453 },
454 };
455 }
456 case 'antenna': {
457 const antenna = cloneAntenna(
458 template.payload,
459 nextScenarioData,
460 template.name
461 );
462 nextScenarioData.antennas.push(antenna);
463 return {
464 scenarioData: nextScenarioData,
465 result: {
466 insertedItemId: antenna.id,
467 insertedName: antenna.name,
468 warnings,
469 },
470 };
471 }
472 case 'platform': {
473 const dependencies = template.dependencies ?? {
474 waveforms: [],
475 timings: [],
476 antennas: [],
477 };
478 const waveformIdMap = new Map<string, string>();
479 const timingIdMap = new Map<string, string>();
480 const antennaIdMap = new Map<string, string>();
481
482 for (const waveformTemplate of dependencies.waveforms) {
483 const waveform = cloneWaveform(
484 waveformTemplate,
485 nextScenarioData
486 );
487 waveformIdMap.set(waveformTemplate.id, waveform.id);
488 nextScenarioData.waveforms.push(waveform);
489 }
490
491 for (const timingTemplate of dependencies.timings) {
492 const timing = cloneTiming(timingTemplate, nextScenarioData);
493 timingIdMap.set(timingTemplate.id, timing.id);
494 nextScenarioData.timings.push(timing);
495 }
496
497 for (const antennaTemplate of dependencies.antennas) {
498 const antenna = cloneAntenna(antennaTemplate, nextScenarioData);
499 antennaIdMap.set(antennaTemplate.id, antenna.id);
500 nextScenarioData.antennas.push(antenna);
501 }
502
503 const platform = clonePlatformWaypointIds(template.payload);
504 platform.name = createCopyName(
505 template.name,
506 nextScenarioData.platforms
507 );
508 platform.components = template.payload.components.map((component) =>
509 cloneComponent(
510 component,
511 antennaIdMap,
512 timingIdMap,
513 waveformIdMap,
514 warnings
515 )
516 );
517 nextScenarioData.platforms.push(platform);
518
519 return {
520 scenarioData: nextScenarioData,
521 result: {
522 insertedItemId: platform.id,
523 insertedName: platform.name,
524 warnings,
525 },
526 };
527 }
528 }
529}
530
531export function createAssetLibraryFile(
532 templates: AssetLibraryTemplate[]
533): AssetLibraryFile {
534 return {
535 schemaVersion: ASSET_LIBRARY_SCHEMA_VERSION,
536 templates,
537 };
538}
539
540export function parseAssetTemplates(data: unknown): AssetLibraryTemplate[] {
541 const fileResult = AssetLibraryFileSchema.safeParse(data);
542 if (fileResult.success) {
543 return fileResult.data.templates;
544 }
545
546 const templateResult = AssetLibraryTemplateSchema.safeParse(data);
547 if (templateResult.success) {
548 return [templateResult.data];
549 }
550
551 throw new Error(
552 'Asset library JSON does not match the v1 template schema.'
553 );
554}
555
556export function prepareTemplatesForCatalog(
557 templates: AssetLibraryTemplate[],
558 timestamp = new Date().toISOString()
559): AssetLibraryTemplate[] {
560 return templates.map((template) => ({
561 ...deepClone(template),
562 id: createAssetTemplateId(),
563 updatedAt: timestamp,
564 }));
565}