1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
4import { z } from 'zod';
10} from './scenarioSchema';
11import { generateSimId } from './scenarioStore/idUtils';
19} from './scenarioStore/types';
21export const ASSET_LIBRARY_SCHEMA_VERSION = 1 as const;
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),
31 scenarioName: z.string().optional(),
32 itemId: z.string().optional(),
37const PlatformDependenciesSchema = z.object({
38 waveforms: z.array(WaveformSchema),
39 timings: z.array(TimingSchema),
40 antennas: z.array(AntennaSchema),
43const WaveformTemplateSchema = TemplateBaseSchema.extend({
44 kind: z.literal('waveform'),
45 payload: WaveformSchema,
48const TimingTemplateSchema = TemplateBaseSchema.extend({
49 kind: z.literal('timing'),
50 payload: TimingSchema,
53const AntennaTemplateSchema = TemplateBaseSchema.extend({
54 kind: z.literal('antenna'),
55 payload: AntennaSchema,
58const PlatformTemplateSchema = TemplateBaseSchema.extend({
59 kind: z.literal('platform'),
60 payload: PlatformSchema,
61 dependencies: PlatformDependenciesSchema,
64export const AssetLibraryTemplateSchema = z.discriminatedUnion('kind', [
65 WaveformTemplateSchema,
67 AntennaTemplateSchema,
68 PlatformTemplateSchema,
71export const AssetLibraryFileSchema = z.object({
72 schemaVersion: z.literal(ASSET_LIBRARY_SCHEMA_VERSION),
73 templates: z.array(AssetLibraryTemplateSchema),
76export type AssetTemplateKind = z.infer<
77 typeof AssetLibraryTemplateSchema
79export type AssetLibraryTemplate = z.infer<typeof AssetLibraryTemplateSchema>;
80export type AssetLibraryFile = z.infer<typeof AssetLibraryFileSchema>;
82export interface AssetTemplateInsertionResult {
83 insertedItemId: string | null;
84 insertedName: string | null;
88type CreateTemplateOptions = {
93let fallbackTemplateCounter = 1;
95export function createAssetTemplateId(): string {
96 if (globalThis.crypto?.randomUUID) {
97 return globalThis.crypto.randomUUID();
100 const id = `template-${Date.now()}-${fallbackTemplateCounter}`;
101 fallbackTemplateCounter += 1;
105function deepClone<T>(value: T): T {
106 return JSON.parse(JSON.stringify(value)) as T;
109function createBaseTemplate(
110 item: Waveform | Timing | Antenna | Platform,
111 scenarioData: ScenarioData,
112 options: CreateTemplateOptions
114 const timestamp = options.timestamp ?? new Date().toISOString();
116 schemaVersion: ASSET_LIBRARY_SCHEMA_VERSION,
117 id: options.id ?? createAssetTemplateId(),
119 createdAt: timestamp,
120 updatedAt: timestamp,
122 scenarioName: scenarioData.globalParameters.simulation_name,
128function sanitizePlatform(platform: Platform): Platform {
129 const cloned = deepClone(platform);
130 delete cloned.pathPoints;
131 delete cloned.rotationPathPoints;
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)) {
146function collectPlatformDependencies(
148 scenarioData: ScenarioData
150 const antennaIds = new Set<string>();
151 const waveformIds = new Set<string>();
152 const timingIds = new Set<string>();
154 for (const component of platform.components) {
155 if ('antennaId' in component && component.antennaId) {
156 antennaIds.add(component.antennaId);
158 if ('waveformId' in component && component.waveformId) {
159 waveformIds.add(component.waveformId);
161 if ('timingId' in component && component.timingId) {
162 timingIds.add(component.timingId);
167 waveforms: uniqueById(
168 scenarioData.waveforms.filter((waveform) =>
169 waveformIds.has(waveform.id)
173 scenarioData.timings.filter((timing) => timingIds.has(timing.id))
175 antennas: uniqueById(
176 scenarioData.antennas.filter((antenna) =>
177 antennaIds.has(antenna.id)
183export function createTemplateFromScenarioItem(
184 scenarioData: ScenarioData,
186 options: CreateTemplateOptions = {}
187): AssetLibraryTemplate | null {
188 const waveform = scenarioData.waveforms.find((item) => item.id === itemId);
191 ...createBaseTemplate(waveform, scenarioData, options),
193 payload: deepClone(waveform),
197 const timing = scenarioData.timings.find((item) => item.id === itemId);
200 ...createBaseTemplate(timing, scenarioData, options),
202 payload: deepClone(timing),
206 const antenna = scenarioData.antennas.find((item) => item.id === itemId);
209 ...createBaseTemplate(antenna, scenarioData, options),
211 payload: deepClone(antenna),
215 const platform = scenarioData.platforms.find((item) => item.id === itemId);
218 ...createBaseTemplate(platform, scenarioData, options),
220 payload: sanitizePlatform(platform),
221 dependencies: collectPlatformDependencies(platform, scenarioData),
228function createCopyName(
230 existingItems: Array<{ name: string }>
232 const existingNames = new Set(existingItems.map((item) => item.name));
233 let candidate = `${baseName} Copy`;
236 while (existingNames.has(candidate)) {
237 candidate = `${baseName} Copy ${suffix}`;
244function cloneWaveform(
246 scenarioData: ScenarioData,
247 baseName = waveform.name
250 ...deepClone(waveform),
251 id: generateSimId('Waveform'),
252 name: createCopyName(baseName, scenarioData.waveforms),
258 scenarioData: ScenarioData,
259 baseName = timing.name
262 ...deepClone(timing),
263 id: generateSimId('Timing'),
264 name: createCopyName(baseName, scenarioData.timings),
265 noiseEntries: timing.noiseEntries.map((entry) => ({
267 id: generateSimId('Timing'),
272function cloneAntenna(
274 scenarioData: ScenarioData,
275 baseName = antenna.name
278 ...deepClone(antenna),
279 id: generateSimId('Antenna'),
280 name: createCopyName(baseName, scenarioData.antennas),
284function clonePlatformWaypointIds(platform: Platform): Platform {
285 const cloned = sanitizePlatform(platform);
286 cloned.id = generateSimId('Platform');
287 cloned.motionPath.waypoints = cloned.motionPath.waypoints.map(
290 id: generateSimId('Platform'),
294 if (cloned.rotation.type === 'path') {
295 cloned.rotation.waypoints = cloned.rotation.waypoints.map(
298 id: generateSimId('Platform'),
306function remapReference(
307 originalId: string | null,
308 idMap: Map<string, string>,
316 const remapped = idMap.get(originalId);
322 `Missing ${label} dependency ${originalId}; reference cleared.`
327function cloneComponent(
328 component: PlatformComponent,
329 antennaIdMap: Map<string, string>,
330 timingIdMap: Map<string, string>,
331 waveformIdMap: Map<string, string>,
333): PlatformComponent {
334 switch (component.type) {
336 const txId = generateSimId('Transmitter');
338 ...deepClone(component),
341 rxId: generateSimId('Receiver'),
342 antennaId: remapReference(
348 timingId: remapReference(
354 waveformId: remapReference(
355 component.waveformId,
364 ...deepClone(component),
365 id: generateSimId('Transmitter'),
366 antennaId: remapReference(
372 timingId: remapReference(
378 waveformId: remapReference(
379 component.waveformId,
387 ...deepClone(component),
388 id: generateSimId('Receiver'),
389 antennaId: remapReference(
395 timingId: remapReference(
404 ...deepClone(component),
405 id: generateSimId('Target'),
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),
421 const warnings: string[] = [];
423 switch (template.kind) {
425 const waveform = cloneWaveform(
430 nextScenarioData.waveforms.push(waveform);
432 scenarioData: nextScenarioData,
434 insertedItemId: waveform.id,
435 insertedName: waveform.name,
441 const timing = cloneTiming(
446 nextScenarioData.timings.push(timing);
448 scenarioData: nextScenarioData,
450 insertedItemId: timing.id,
451 insertedName: timing.name,
457 const antenna = cloneAntenna(
462 nextScenarioData.antennas.push(antenna);
464 scenarioData: nextScenarioData,
466 insertedItemId: antenna.id,
467 insertedName: antenna.name,
473 const dependencies = template.dependencies ?? {
478 const waveformIdMap = new Map<string, string>();
479 const timingIdMap = new Map<string, string>();
480 const antennaIdMap = new Map<string, string>();
482 for (const waveformTemplate of dependencies.waveforms) {
483 const waveform = cloneWaveform(
487 waveformIdMap.set(waveformTemplate.id, waveform.id);
488 nextScenarioData.waveforms.push(waveform);
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);
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);
503 const platform = clonePlatformWaypointIds(template.payload);
504 platform.name = createCopyName(
506 nextScenarioData.platforms
508 platform.components = template.payload.components.map((component) =>
517 nextScenarioData.platforms.push(platform);
520 scenarioData: nextScenarioData,
522 insertedItemId: platform.id,
523 insertedName: platform.name,
531export function createAssetLibraryFile(
532 templates: AssetLibraryTemplate[]
535 schemaVersion: ASSET_LIBRARY_SCHEMA_VERSION,
540export function parseAssetTemplates(data: unknown): AssetLibraryTemplate[] {
541 const fileResult = AssetLibraryFileSchema.safeParse(data);
542 if (fileResult.success) {
543 return fileResult.data.templates;
546 const templateResult = AssetLibraryTemplateSchema.safeParse(data);
547 if (templateResult.success) {
548 return [templateResult.data];
552 'Asset library JSON does not match the v1 template schema.'
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,