worldorbit 2.5.17 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worldorbit",
3
- "version": "2.5.17",
3
+ "version": "2.6.0",
4
4
  "description": "A text-based DSL and parser pipeline for orbital worldbuilding",
5
5
  "type": "module",
6
6
  "main": "./dist/unpkg/worldorbit.esm.js",
@@ -1,5 +1,5 @@
1
1
  import { collectAtlasDiagnostics } from "./atlas-validate.js";
2
- export function createEmptyAtlasDocument(systemId = "WorldOrbit", version = "2.0") {
2
+ export function createEmptyAtlasDocument(systemId = "WorldOrbit", version = "2.5") {
3
3
  return {
4
4
  format: "worldorbit",
5
5
  version,
@@ -38,13 +38,13 @@ export function collectAtlasDiagnostics(document, sourceSchemaVersion) {
38
38
  validateRelation(relation, objectMap, diagnostics);
39
39
  }
40
40
  for (const viewpoint of document.system?.viewpoints ?? []) {
41
- validateViewpoint(viewpoint.filter, viewpoint.events ?? [], groupIds, eventIds, sourceSchemaVersion, diagnostics, viewpoint.id);
41
+ validateViewpoint(viewpoint, groupIds, eventIds, sourceSchemaVersion, diagnostics, objectMap);
42
42
  }
43
43
  for (const object of document.objects) {
44
44
  validateObject(object, document.system, objectMap, groupIds, diagnostics);
45
45
  }
46
46
  for (const event of document.events) {
47
- validateEvent(event, objectMap, diagnostics);
47
+ validateEvent(event, document.system, objectMap, diagnostics);
48
48
  }
49
49
  return diagnostics;
50
50
  }
@@ -65,21 +65,24 @@ function validateRelation(relation, objectMap, diagnostics) {
65
65
  diagnostics.push(error("validate.relation.kind.required", `Relation "${relation.id}" is missing a "kind" value.`));
66
66
  }
67
67
  }
68
- function validateViewpoint(filter, eventRefs, groupIds, eventIds, sourceSchemaVersion, diagnostics, viewpointId) {
69
- if (sourceSchemaVersion === "2.1") {
68
+ function validateViewpoint(viewpoint, groupIds, eventIds, sourceSchemaVersion, diagnostics, objectMap) {
69
+ const filter = viewpoint.filter;
70
+ if (sourceSchemaVersion === "2.1" || sourceSchemaVersion === "2.5") {
70
71
  if (filter) {
71
72
  for (const groupId of filter.groupIds) {
72
73
  if (!groupIds.has(groupId)) {
73
- diagnostics.push(warn("validate.viewpoint.group.unknown", `Unknown group "${groupId}" in viewpoint "${viewpointId}".`, undefined, `viewpoint.${viewpointId}.groups`));
74
+ diagnostics.push(warn("validate.viewpoint.group.unknown", `Unknown group "${groupId}" in viewpoint "${viewpoint.id}".`, undefined, `viewpoint.${viewpoint.id}.groups`));
74
75
  }
75
76
  }
76
77
  }
77
- for (const eventId of eventRefs) {
78
+ for (const eventId of viewpoint.events ?? []) {
78
79
  if (!eventIds.has(eventId)) {
79
- diagnostics.push(warn("validate.viewpoint.event.unknown", `Unknown event "${eventId}" in viewpoint "${viewpointId}".`, undefined, `viewpoint.${viewpointId}.events`));
80
+ diagnostics.push(warn("validate.viewpoint.event.unknown", `Unknown event "${eventId}" in viewpoint "${viewpoint.id}".`, undefined, `viewpoint.${viewpoint.id}.events`));
80
81
  }
81
82
  }
82
83
  }
84
+ validateProjection(viewpoint.projection, diagnostics, `viewpoint.${viewpoint.id}.projection`, viewpoint.id);
85
+ validateCamera(viewpoint.camera, viewpoint.projection, viewpoint.rotationDeg, diagnostics, viewpoint.id, viewpoint.focusObjectId, viewpoint.selectedObjectId, filter, objectMap);
83
86
  }
84
87
  function validateObject(object, system, objectMap, groupIds, diagnostics) {
85
88
  const placement = object.placement;
@@ -92,6 +95,12 @@ function validateObject(object, system, objectMap, groupIds, diagnostics) {
92
95
  }
93
96
  }
94
97
  }
98
+ if (typeof object.epoch === "string" && !object.epoch.trim()) {
99
+ diagnostics.push(warn("validate.epoch.empty", `Object "${object.id}" defines an empty epoch string.`, object.id, "epoch"));
100
+ }
101
+ if (typeof object.referencePlane === "string" && !object.referencePlane.trim()) {
102
+ diagnostics.push(warn("validate.referencePlane.empty", `Object "${object.id}" defines an empty reference plane string.`, object.id, "referencePlane"));
103
+ }
95
104
  if (orbitPlacement) {
96
105
  if (!objectMap.has(orbitPlacement.target)) {
97
106
  diagnostics.push(error("validate.orbit.target.unknown", `Unknown placement target "${orbitPlacement.target}" on "${object.id}".`, object.id, "orbit"));
@@ -167,12 +176,18 @@ function validateObject(object, system, objectMap, groupIds, diagnostics) {
167
176
  }
168
177
  }
169
178
  }
170
- function validateEvent(event, objectMap, diagnostics) {
179
+ function validateEvent(event, system, objectMap, diagnostics) {
171
180
  const fieldPrefix = `event.${event.id}`;
172
181
  const referencedIds = new Set();
173
182
  if (!event.kind.trim()) {
174
183
  diagnostics.push(error("validate.event.kind.required", `Event "${event.id}" is missing a "kind" value.`, undefined, `${fieldPrefix}.kind`));
175
184
  }
185
+ if (typeof event.epoch === "string" && !event.epoch.trim()) {
186
+ diagnostics.push(warn("validate.event.epoch.empty", `Event "${event.id}" defines an empty epoch string.`, undefined, `${fieldPrefix}.epoch`));
187
+ }
188
+ if (typeof event.referencePlane === "string" && !event.referencePlane.trim()) {
189
+ diagnostics.push(warn("validate.event.referencePlane.empty", `Event "${event.id}" defines an empty reference plane string.`, undefined, `${fieldPrefix}.referencePlane`));
190
+ }
176
191
  if (!event.targetObjectId && event.participantObjectIds.length === 0) {
177
192
  diagnostics.push(error("validate.event.references.required", `Event "${event.id}" must define a "target" or at least one participant.`, undefined, `${fieldPrefix}.participants`));
178
193
  }
@@ -221,10 +236,14 @@ function validateEvent(event, objectMap, diagnostics) {
221
236
  if (!referencedIds.has(pose.objectId)) {
222
237
  diagnostics.push(warn("validate.event.pose.unreferenced", `Event pose "${pose.objectId}" on "${event.id}" is not listed in target/participants.`, undefined, poseFieldPrefix));
223
238
  }
224
- validateEventPose(pose, object, objectMap, diagnostics, poseFieldPrefix, event.id);
239
+ validateEventPose(pose, object, event, system, objectMap, diagnostics, poseFieldPrefix, event.id);
240
+ }
241
+ const missingPoseIds = [...referencedIds].filter((objectId) => !poseIds.has(objectId));
242
+ if (event.positions.length > 0 && missingPoseIds.length > 0) {
243
+ diagnostics.push(warn("validate.event.positions.partial", `Event "${event.id}" leaves ${missingPoseIds.length} referenced object(s) on their base placement.`, undefined, `${fieldPrefix}.positions`));
225
244
  }
226
245
  }
227
- function validateEventPose(pose, object, objectMap, diagnostics, fieldPrefix, eventId) {
246
+ function validateEventPose(pose, object, event, system, objectMap, diagnostics, fieldPrefix, eventId) {
228
247
  const placement = pose.placement;
229
248
  if (!placement) {
230
249
  diagnostics.push(error("validate.event.pose.placement.required", `Event "${eventId}" pose "${pose.objectId}" is missing a placement mode.`, undefined, fieldPrefix));
@@ -237,6 +256,15 @@ function validateEventPose(pose, object, objectMap, diagnostics, fieldPrefix, ev
237
256
  if (placement.distance && placement.semiMajor) {
238
257
  diagnostics.push(error("validate.event.pose.orbit.distanceConflict", `Event "${eventId}" pose "${pose.objectId}" cannot declare both "distance" and "semiMajor".`, undefined, `${fieldPrefix}.distance`));
239
258
  }
259
+ if (placement.phase && !resolveEffectiveEpoch(system, object, event, pose)) {
260
+ diagnostics.push(warn("validate.event.pose.phase.epochMissing", `Event "${eventId}" pose "${pose.objectId}" sets "phase" without an effective epoch.`, undefined, `${fieldPrefix}.phase`));
261
+ }
262
+ if (placement.inclination && !resolveEffectiveReferencePlane(system, object, event, pose)) {
263
+ diagnostics.push(warn("validate.event.pose.inclination.referencePlaneMissing", `Event "${eventId}" pose "${pose.objectId}" sets "inclination" without an effective reference plane.`, undefined, `${fieldPrefix}.inclination`));
264
+ }
265
+ if (placement.period && !massInSolar(objectMap.get(placement.target)?.properties.mass)) {
266
+ diagnostics.push(warn("validate.event.pose.period.massMissing", `Event "${eventId}" pose "${pose.objectId}" sets "period" but its central mass cannot be derived.`, undefined, `${fieldPrefix}.period`));
267
+ }
240
268
  return;
241
269
  }
242
270
  if (placement.mode === "surface") {
@@ -375,6 +403,67 @@ function durationInDays(value) {
375
403
  return null;
376
404
  }
377
405
  }
406
+ function validateProjection(projection, diagnostics, field, viewpointId) {
407
+ if (projection !== "topdown" &&
408
+ projection !== "isometric" &&
409
+ projection !== "orthographic" &&
410
+ projection !== "perspective") {
411
+ diagnostics.push(error("validate.viewpoint.projection.invalid", `Unknown projection "${String(projection)}" in viewpoint "${viewpointId}".`, undefined, field));
412
+ }
413
+ }
414
+ function validateCamera(camera, projection, rotationDeg, diagnostics, viewpointId, focusObjectId, selectedObjectId, filter, objectMap) {
415
+ if (!camera) {
416
+ return;
417
+ }
418
+ const prefix = `viewpoint.${viewpointId}.camera`;
419
+ for (const [key, value] of [
420
+ ["azimuth", camera.azimuth],
421
+ ["elevation", camera.elevation],
422
+ ["roll", camera.roll],
423
+ ["distance", camera.distance],
424
+ ]) {
425
+ if (value !== null && (!Number.isFinite(value) || (key === "distance" && value <= 0))) {
426
+ diagnostics.push(error("validate.viewpoint.camera.invalid", `Invalid camera ${key} "${String(value)}" in viewpoint "${viewpointId}".`, undefined, `${prefix}.${key}`));
427
+ }
428
+ }
429
+ if (camera.distance !== null && projection !== "perspective") {
430
+ diagnostics.push(warn("validate.viewpoint.camera.distance.partialEffect", `Camera "distance" only has a semantic effect in perspective viewpoints; "${viewpointId}" uses "${projection}".`, undefined, `${prefix}.distance`));
431
+ }
432
+ if (projection === "topdown" &&
433
+ (camera.elevation !== null || camera.roll !== null)) {
434
+ diagnostics.push(warn("validate.viewpoint.camera.topdownPartial", `Camera elevation/roll on topdown viewpoint "${viewpointId}" are currently stored for future 3D use and only partially affect 2D rendering.`, undefined, prefix));
435
+ }
436
+ if (projection === "isometric" &&
437
+ camera.elevation !== null) {
438
+ diagnostics.push(info("validate.viewpoint.camera.isometricStored", `Camera elevation on isometric viewpoint "${viewpointId}" is preserved semantically for future 3D rendering.`, undefined, `${prefix}.elevation`));
439
+ }
440
+ if (camera.azimuth !== null && camera.azimuth !== 0 && rotationDeg !== 0) {
441
+ diagnostics.push(warn("validate.viewpoint.rotation.cameraOverlap", `Viewpoint "${viewpointId}" uses camera.azimuth; keep "rotation" only for 2D screen rotation to avoid ambiguity.`, undefined, `${prefix}.azimuth`));
442
+ }
443
+ const hasAnchor = (focusObjectId !== null && objectMap.has(focusObjectId)) ||
444
+ (selectedObjectId !== null && objectMap.has(selectedObjectId)) ||
445
+ !!filter;
446
+ if (!hasAnchor) {
447
+ diagnostics.push(info("validate.viewpoint.camera.anchorMissing", `Viewpoint "${viewpointId}" stores camera settings without a focus object, selection, or filter anchor.`, undefined, prefix));
448
+ }
449
+ }
450
+ function resolveEffectiveEpoch(system, object, event, pose) {
451
+ return normalizeOptionalContextString(pose?.epoch) ??
452
+ normalizeOptionalContextString(event?.epoch) ??
453
+ normalizeOptionalContextString(object.epoch) ??
454
+ normalizeOptionalContextString(system?.epoch) ??
455
+ null;
456
+ }
457
+ function resolveEffectiveReferencePlane(system, object, event, pose) {
458
+ return normalizeOptionalContextString(pose?.referencePlane) ??
459
+ normalizeOptionalContextString(event?.referencePlane) ??
460
+ normalizeOptionalContextString(object.referencePlane) ??
461
+ normalizeOptionalContextString(system?.referencePlane) ??
462
+ null;
463
+ }
464
+ function normalizeOptionalContextString(value) {
465
+ return typeof value === "string" && value.trim() ? value.trim() : null;
466
+ }
378
467
  function toleranceForField(object, field) {
379
468
  const tolerance = object.tolerances?.find((entry) => entry.field === field)?.value;
380
469
  if (typeof tolerance === "number") {
@@ -88,6 +88,8 @@ const EVENT_POSE_FIELD_KEYS = new Set([
88
88
  "free",
89
89
  "inner",
90
90
  "outer",
91
+ "epoch",
92
+ "referencePlane",
91
93
  ]);
92
94
  export function parseWorldOrbitAtlas(source) {
93
95
  return parseAtlasSource(source);
@@ -132,7 +134,7 @@ function parseAtlasSource(source, forcedOutputVersion) {
132
134
  if (!sawSchemaHeader) {
133
135
  sourceSchemaVersion = assertDraftSchemaHeader(tokens, lineNumber);
134
136
  sawSchemaHeader = true;
135
- if (prepared.comments.length > 0 && sourceSchemaVersion !== "2.1") {
137
+ if (prepared.comments.length > 0 && isSchemaOlderThan(sourceSchemaVersion, "2.1")) {
136
138
  diagnostics.push({
137
139
  code: "parse.schema21.commentCompatibility",
138
140
  severity: "warning",
@@ -207,15 +209,17 @@ function parseAtlasSource(source, forcedOutputVersion) {
207
209
  function assertDraftSchemaHeader(tokens, line) {
208
210
  if (tokens.length !== 2 ||
209
211
  tokens[0].value.toLowerCase() !== "schema" ||
210
- !["2.0-draft", "2.0", "2.1"].includes(tokens[1].value.toLowerCase())) {
211
- throw new WorldOrbitError('Expected atlas header "schema 2.0", "schema 2.1", or legacy "schema 2.0-draft"', line, tokens[0]?.column ?? 1);
212
+ !["2.0-draft", "2.0", "2.1", "2.5"].includes(tokens[1].value.toLowerCase())) {
213
+ throw new WorldOrbitError('Expected atlas header "schema 2.0", "schema 2.1", "schema 2.5", or legacy "schema 2.0-draft"', line, tokens[0]?.column ?? 1);
212
214
  }
213
215
  const version = tokens[1].value.toLowerCase();
214
- return version === "2.1"
215
- ? "2.1"
216
- : version === "2.0-draft"
217
- ? "2.0-draft"
218
- : "2.0";
216
+ return version === "2.5"
217
+ ? "2.5"
218
+ : version === "2.1"
219
+ ? "2.1"
220
+ : version === "2.0-draft"
221
+ ? "2.0-draft"
222
+ : "2.0";
219
223
  }
220
224
  function startTopLevelSection(tokens, line, sourceSchemaVersion, diagnostics, system, objectNodes, groups, relations, events, eventPoseNodes, viewpointIds, annotationIds, groupIds, relationIds, eventIds, flags) {
221
225
  const keyword = tokens[0]?.value.toLowerCase();
@@ -235,6 +239,8 @@ function startTopLevelSection(tokens, line, sourceSchemaVersion, diagnostics, sy
235
239
  return {
236
240
  kind: "defaults",
237
241
  system,
242
+ sourceSchemaVersion,
243
+ diagnostics,
238
244
  seenFields: new Set(),
239
245
  };
240
246
  case "atlas":
@@ -327,6 +333,7 @@ function startViewpointSection(tokens, line, system, viewpointIds, sourceSchemaV
327
333
  preset: system.defaults.preset,
328
334
  zoom: null,
329
335
  rotationDeg: 0,
336
+ camera: null,
330
337
  layers: {},
331
338
  filter: null,
332
339
  };
@@ -341,6 +348,9 @@ function startViewpointSection(tokens, line, system, viewpointIds, sourceSchemaV
341
348
  inFilter: false,
342
349
  filterIndent: null,
343
350
  seenFilterFields: new Set(),
351
+ inCamera: false,
352
+ cameraIndent: null,
353
+ seenCameraFields: new Set(),
344
354
  };
345
355
  }
346
356
  function startAnnotationSection(tokens, line, system, annotationIds) {
@@ -447,6 +457,8 @@ function startEventSection(tokens, line, events, eventPoseNodes, eventIds, sourc
447
457
  participantObjectIds: [],
448
458
  timing: null,
449
459
  visibility: null,
460
+ epoch: null,
461
+ referencePlane: null,
450
462
  tags: [],
451
463
  color: null,
452
464
  hidden: false,
@@ -571,6 +583,12 @@ function applyDefaultsField(section, tokens, line) {
571
583
  const value = joinFieldValue(tokens, line);
572
584
  switch (key) {
573
585
  case "view":
586
+ if (isSchema25Projection(value)) {
587
+ warnIfSchema25Feature(section.sourceSchemaVersion, section.diagnostics, "defaults.view", {
588
+ line,
589
+ column: tokens[0].column,
590
+ });
591
+ }
574
592
  section.system.defaults.view = parseProjectionValue(value, line, tokens[0].column);
575
593
  return;
576
594
  case "scale":
@@ -610,14 +628,36 @@ function applyAtlasField(section, indent, tokens, line) {
610
628
  throw new WorldOrbitError(`Unknown atlas field "${tokens[0].value}"`, line, tokens[0].column);
611
629
  }
612
630
  function applyViewpointField(section, indent, tokens, line) {
631
+ if (section.inCamera && indent <= (section.cameraIndent ?? 0)) {
632
+ section.inCamera = false;
633
+ section.cameraIndent = null;
634
+ }
613
635
  if (section.inFilter && indent <= (section.filterIndent ?? 0)) {
614
636
  section.inFilter = false;
615
637
  section.filterIndent = null;
616
638
  }
639
+ if (section.inCamera) {
640
+ applyViewpointCameraField(section, tokens, line);
641
+ return;
642
+ }
617
643
  if (section.inFilter) {
618
644
  applyViewpointFilterField(section, tokens, line);
619
645
  return;
620
646
  }
647
+ if (tokens.length === 1 && tokens[0].value.toLowerCase() === "camera") {
648
+ warnIfSchema25Feature(section.sourceSchemaVersion, section.diagnostics, "viewpoint.camera", {
649
+ line,
650
+ column: tokens[0].column,
651
+ });
652
+ if (section.seenFields.has("camera")) {
653
+ throw new WorldOrbitError('Duplicate viewpoint field "camera"', line, tokens[0].column);
654
+ }
655
+ section.seenFields.add("camera");
656
+ section.inCamera = true;
657
+ section.cameraIndent = indent;
658
+ section.viewpoint.camera = section.viewpoint.camera ?? createEmptyViewCamera();
659
+ return;
660
+ }
621
661
  if (tokens.length === 1 && tokens[0].value.toLowerCase() === "filter") {
622
662
  if (section.seenFields.has("filter")) {
623
663
  throw new WorldOrbitError('Duplicate viewpoint field "filter"', line, tokens[0].column);
@@ -643,6 +683,12 @@ function applyViewpointField(section, indent, tokens, line) {
643
683
  section.viewpoint.selectedObjectId = value;
644
684
  return;
645
685
  case "projection":
686
+ if (isSchema25Projection(value)) {
687
+ warnIfSchema25Feature(section.sourceSchemaVersion, section.diagnostics, "projection", {
688
+ line,
689
+ column: tokens[0].column,
690
+ });
691
+ }
646
692
  section.viewpoint.projection = parseProjectionValue(value, line, tokens[0].column);
647
693
  return;
648
694
  case "preset":
@@ -654,6 +700,13 @@ function applyViewpointField(section, indent, tokens, line) {
654
700
  case "rotation":
655
701
  section.viewpoint.rotationDeg = parseFiniteNumber(value, line, tokens[0].column, "rotation");
656
702
  return;
703
+ case "camera":
704
+ warnIfSchema25Feature(section.sourceSchemaVersion, section.diagnostics, "viewpoint.camera", {
705
+ line,
706
+ column: tokens[0].column,
707
+ });
708
+ section.viewpoint.camera = parseInlineViewCamera(tokens.slice(1), line, section.viewpoint.camera);
709
+ return;
657
710
  case "layers":
658
711
  section.viewpoint.layers = parseLayerTokens(tokens.slice(1), line, section.sourceSchemaVersion, section.diagnostics);
659
712
  return;
@@ -668,6 +721,28 @@ function applyViewpointField(section, indent, tokens, line) {
668
721
  throw new WorldOrbitError(`Unknown viewpoint field "${tokens[0].value}"`, line, tokens[0].column);
669
722
  }
670
723
  }
724
+ function applyViewpointCameraField(section, tokens, line) {
725
+ const key = requireUniqueField(tokens, section.seenCameraFields, line);
726
+ const value = joinFieldValue(tokens, line);
727
+ const camera = section.viewpoint.camera ?? createEmptyViewCamera();
728
+ switch (key) {
729
+ case "azimuth":
730
+ camera.azimuth = parseFiniteNumber(value, line, tokens[0].column, "camera.azimuth");
731
+ break;
732
+ case "elevation":
733
+ camera.elevation = parseFiniteNumber(value, line, tokens[0].column, "camera.elevation");
734
+ break;
735
+ case "roll":
736
+ camera.roll = parseFiniteNumber(value, line, tokens[0].column, "camera.roll");
737
+ break;
738
+ case "distance":
739
+ camera.distance = parsePositiveNumber(value, line, tokens[0].column, "camera.distance");
740
+ break;
741
+ default:
742
+ throw new WorldOrbitError(`Unknown viewpoint camera field "${tokens[0].value}"`, line, tokens[0].column);
743
+ }
744
+ section.viewpoint.camera = camera;
745
+ }
671
746
  function applyViewpointFilterField(section, tokens, line) {
672
747
  const key = requireUniqueField(tokens, section.seenFilterFields, line);
673
748
  const filter = section.viewpoint.filter ?? createEmptyViewpointFilter();
@@ -778,6 +853,13 @@ function applyEventField(section, indent, tokens, line) {
778
853
  section.positionsIndent = null;
779
854
  }
780
855
  if (section.activePose) {
856
+ if (tokens[0]?.value === "epoch" ||
857
+ tokens[0]?.value === "referencePlane") {
858
+ warnIfSchema25Feature(section.sourceSchemaVersion, section.diagnostics, `pose.${tokens[0].value}`, {
859
+ line,
860
+ column: tokens[0]?.column ?? 1,
861
+ });
862
+ }
781
863
  section.activePose.fields.push(parseEventPoseField(tokens, line, section.activePoseSeenFields));
782
864
  return;
783
865
  }
@@ -832,6 +914,20 @@ function applyEventField(section, indent, tokens, line) {
832
914
  case "visibility":
833
915
  section.event.visibility = joinFieldValue(tokens, line);
834
916
  return;
917
+ case "epoch":
918
+ warnIfSchema25Feature(section.sourceSchemaVersion, section.diagnostics, "event.epoch", {
919
+ line,
920
+ column: tokens[0].column,
921
+ });
922
+ section.event.epoch = joinFieldValue(tokens, line);
923
+ return;
924
+ case "referenceplane":
925
+ warnIfSchema25Feature(section.sourceSchemaVersion, section.diagnostics, "event.referencePlane", {
926
+ line,
927
+ column: tokens[0].column,
928
+ });
929
+ section.event.referencePlane = joinFieldValue(tokens, line);
930
+ return;
835
931
  case "tags":
836
932
  section.event.tags = parseTokenList(tokens.slice(1), line, "tags");
837
933
  return;
@@ -981,11 +1077,18 @@ function parseTokenList(tokens, line, fieldName) {
981
1077
  }
982
1078
  function parseProjectionValue(value, line, column) {
983
1079
  const normalized = value.toLowerCase();
984
- if (normalized !== "topdown" && normalized !== "isometric") {
1080
+ if (normalized !== "topdown" &&
1081
+ normalized !== "isometric" &&
1082
+ normalized !== "orthographic" &&
1083
+ normalized !== "perspective") {
985
1084
  throw new WorldOrbitError(`Unknown projection "${value}"`, line, column);
986
1085
  }
987
1086
  return normalized;
988
1087
  }
1088
+ function isSchema25Projection(value) {
1089
+ const normalized = value.toLowerCase();
1090
+ return normalized === "orthographic" || normalized === "perspective";
1091
+ }
989
1092
  function parsePresetValue(value, line, column) {
990
1093
  const normalized = value.toLowerCase();
991
1094
  if (normalized === "diagram" ||
@@ -1018,6 +1121,48 @@ function createEmptyViewpointFilter() {
1018
1121
  groupIds: [],
1019
1122
  };
1020
1123
  }
1124
+ function createEmptyViewCamera() {
1125
+ return {
1126
+ azimuth: null,
1127
+ elevation: null,
1128
+ roll: null,
1129
+ distance: null,
1130
+ };
1131
+ }
1132
+ function parseInlineViewCamera(tokens, line, current) {
1133
+ if (tokens.length === 0 || tokens.length % 2 !== 0) {
1134
+ throw new WorldOrbitError('Field "camera" expects "<field> <value>" pairs', line, tokens[0]?.column ?? 1);
1135
+ }
1136
+ const camera = current ? { ...current } : createEmptyViewCamera();
1137
+ const seen = new Set();
1138
+ for (let index = 0; index < tokens.length; index += 2) {
1139
+ const fieldToken = tokens[index];
1140
+ const valueToken = tokens[index + 1];
1141
+ const key = fieldToken.value.toLowerCase();
1142
+ if (seen.has(key)) {
1143
+ throw new WorldOrbitError(`Duplicate viewpoint camera field "${fieldToken.value}"`, line, fieldToken.column);
1144
+ }
1145
+ seen.add(key);
1146
+ const value = valueToken.value;
1147
+ switch (key) {
1148
+ case "azimuth":
1149
+ camera.azimuth = parseFiniteNumber(value, line, fieldToken.column, "camera.azimuth");
1150
+ break;
1151
+ case "elevation":
1152
+ camera.elevation = parseFiniteNumber(value, line, fieldToken.column, "camera.elevation");
1153
+ break;
1154
+ case "roll":
1155
+ camera.roll = parseFiniteNumber(value, line, fieldToken.column, "camera.roll");
1156
+ break;
1157
+ case "distance":
1158
+ camera.distance = parsePositiveNumber(value, line, fieldToken.column, "camera.distance");
1159
+ break;
1160
+ default:
1161
+ throw new WorldOrbitError(`Unknown viewpoint camera field "${fieldToken.value}"`, line, fieldToken.column);
1162
+ }
1163
+ }
1164
+ return camera;
1165
+ }
1021
1166
  function parseInlineObjectFields(tokens, line, objectType, sourceSchemaVersion, diagnostics) {
1022
1167
  const fields = [];
1023
1168
  let index = 0;
@@ -1158,7 +1303,7 @@ function normalizeDraftObject(node, sourceSchemaVersion, diagnostics) {
1158
1303
  object.tolerances = tolerances;
1159
1304
  if (typedBlocks && Object.keys(typedBlocks).length > 0)
1160
1305
  object.typedBlocks = typedBlocks;
1161
- if (sourceSchemaVersion !== "2.1") {
1306
+ if (isSchemaOlderThan(sourceSchemaVersion, "2.1")) {
1162
1307
  if (object.groups ||
1163
1308
  object.epoch ||
1164
1309
  object.referencePlane ||
@@ -1184,23 +1329,25 @@ function normalizeDraftEvent(event, rawPoses) {
1184
1329
  };
1185
1330
  }
1186
1331
  function normalizeDraftEventPose(rawPose) {
1187
- const fieldMap = collectDraftFields(rawPose.fields);
1332
+ const fieldMap = collectDraftFields(rawPose.fields, "event-pose");
1188
1333
  const placement = extractPlacementFromFieldMap(fieldMap);
1189
1334
  return {
1190
1335
  objectId: rawPose.objectId,
1191
1336
  placement,
1192
1337
  inner: parseOptionalUnitField(fieldMap.get("inner")?.[0], "inner"),
1193
1338
  outer: parseOptionalUnitField(fieldMap.get("outer")?.[0], "outer"),
1339
+ epoch: parseOptionalJoinedValue(fieldMap.get("epoch")?.[0]),
1340
+ referencePlane: parseOptionalJoinedValue(fieldMap.get("referencePlane")?.[0]),
1194
1341
  };
1195
1342
  }
1196
- function collectDraftFields(fields) {
1343
+ function collectDraftFields(fields, _mode = "object") {
1197
1344
  const grouped = new Map();
1198
1345
  for (const field of fields) {
1199
1346
  const spec = getDraftObjectFieldSpec(field.key);
1200
- if (!spec) {
1347
+ if (!spec && !EVENT_POSE_FIELD_KEYS.has(field.key)) {
1201
1348
  throw WorldOrbitError.fromLocation(`Unknown field "${field.key}"`, field.location);
1202
1349
  }
1203
- if (!spec.allowRepeat && grouped.has(field.key)) {
1350
+ if (!spec?.allowRepeat && grouped.has(field.key)) {
1204
1351
  throw WorldOrbitError.fromLocation(`Duplicate field "${field.key}"`, field.location);
1205
1352
  }
1206
1353
  const existing = grouped.get(field.key) ?? [];
@@ -1378,7 +1525,7 @@ function validateDraftObjectFieldCompatibility(fields, objectType) {
1378
1525
  }
1379
1526
  }
1380
1527
  function warnIfSchema21Feature(sourceSchemaVersion, diagnostics, featureName, location) {
1381
- if (sourceSchemaVersion === "2.1") {
1528
+ if (!isSchemaOlderThan(sourceSchemaVersion, "2.1")) {
1382
1529
  return;
1383
1530
  }
1384
1531
  diagnostics.push({
@@ -1390,6 +1537,34 @@ function warnIfSchema21Feature(sourceSchemaVersion, diagnostics, featureName, lo
1390
1537
  column: location.column,
1391
1538
  });
1392
1539
  }
1540
+ function warnIfSchema25Feature(sourceSchemaVersion, diagnostics, featureName, location) {
1541
+ if (!isSchemaOlderThan(sourceSchemaVersion, "2.5")) {
1542
+ return;
1543
+ }
1544
+ diagnostics.push({
1545
+ code: "parse.schema25.featureCompatibility",
1546
+ severity: "warning",
1547
+ source: "parse",
1548
+ message: `Feature "${featureName}" requires schema 2.5; parsed in compatibility mode because the document header is "schema ${sourceSchemaVersion}".`,
1549
+ line: location.line,
1550
+ column: location.column,
1551
+ });
1552
+ }
1553
+ function isSchemaOlderThan(sourceSchemaVersion, requiredVersion) {
1554
+ return schemaVersionRank(sourceSchemaVersion) < schemaVersionRank(requiredVersion);
1555
+ }
1556
+ function schemaVersionRank(version) {
1557
+ switch (version) {
1558
+ case "2.0-draft":
1559
+ return 0;
1560
+ case "2.0":
1561
+ return 1;
1562
+ case "2.1":
1563
+ return 2;
1564
+ case "2.5":
1565
+ return 3;
1566
+ }
1567
+ }
1393
1568
  function preprocessAtlasSource(source) {
1394
1569
  const chars = [...source];
1395
1570
  const comments = [];
@@ -18,8 +18,8 @@ export function upgradeDocumentToV2(document, options = {}) {
18
18
  }
19
19
  return {
20
20
  format: "worldorbit",
21
- version: "2.0",
22
- schemaVersion: "2.0",
21
+ version: "2.5",
22
+ schemaVersion: "2.5",
23
23
  sourceVersion: document.version,
24
24
  system,
25
25
  groups: structuredClone(document.groups ?? []),
@@ -82,10 +82,15 @@ function createDraftSystem(document, defaults, atlasMetadata, annotations, diagn
82
82
  };
83
83
  }
84
84
  function createDraftDefaults(document, preset, projection) {
85
+ const rawView = typeof document.system?.properties.view === "string"
86
+ ? document.system.properties.view.toLowerCase()
87
+ : null;
85
88
  return {
86
- view: typeof document.system?.properties.view === "string" &&
87
- document.system.properties.view.toLowerCase() === "topdown"
88
- ? "topdown"
89
+ view: rawView === "topdown" ||
90
+ rawView === "isometric" ||
91
+ rawView === "orthographic" ||
92
+ rawView === "perspective"
93
+ ? rawView
89
94
  : projection,
90
95
  scale: typeof document.system?.properties.scale === "string"
91
96
  ? document.system.properties.scale
@@ -204,6 +209,7 @@ function mapSceneViewpointToDraftViewpoint(viewpoint) {
204
209
  preset: viewpoint.preset,
205
210
  zoom: viewpoint.scale,
206
211
  rotationDeg: viewpoint.rotationDeg,
212
+ camera: viewpoint.camera ? { ...viewpoint.camera } : null,
207
213
  layers: { ...viewpoint.layers },
208
214
  filter: viewpoint.filter
209
215
  ? {
@@ -258,6 +264,8 @@ function cloneWorldOrbitEventPose(pose) {
258
264
  placement: clonePlacement(pose.placement),
259
265
  inner: pose.inner ? { ...pose.inner } : undefined,
260
266
  outer: pose.outer ? { ...pose.outer } : undefined,
267
+ epoch: pose.epoch ?? null,
268
+ referencePlane: pose.referencePlane ?? null,
261
269
  };
262
270
  }
263
271
  function clonePlacement(placement) {
@@ -272,23 +280,42 @@ function applyEventPoseOverrides(objects, events, activeEventId) {
272
280
  return;
273
281
  }
274
282
  const objectMap = new Map(objects.map((object) => [object.id, object]));
283
+ const referencedIds = new Set([
284
+ ...(event.targetObjectId ? [event.targetObjectId] : []),
285
+ ...event.participantObjectIds,
286
+ ...event.positions.map((pose) => pose.objectId),
287
+ ]);
288
+ for (const objectId of referencedIds) {
289
+ const object = objectMap.get(objectId);
290
+ if (!object) {
291
+ continue;
292
+ }
293
+ if (event.epoch) {
294
+ object.epoch = event.epoch;
295
+ }
296
+ if (event.referencePlane) {
297
+ object.referencePlane = event.referencePlane;
298
+ }
299
+ }
275
300
  for (const pose of event.positions) {
276
301
  const object = objectMap.get(pose.objectId);
277
302
  if (!object) {
278
303
  continue;
279
304
  }
280
- object.placement = clonePlacement(pose.placement);
305
+ if (pose.placement) {
306
+ object.placement = clonePlacement(pose.placement);
307
+ }
281
308
  if (pose.inner) {
282
309
  object.properties.inner = { ...pose.inner };
283
310
  }
284
- else {
285
- delete object.properties.inner;
286
- }
287
311
  if (pose.outer) {
288
312
  object.properties.outer = { ...pose.outer };
289
313
  }
290
- else {
291
- delete object.properties.outer;
314
+ if (pose.epoch) {
315
+ object.epoch = pose.epoch;
316
+ }
317
+ if (pose.referencePlane) {
318
+ object.referencePlane = pose.referencePlane;
292
319
  }
293
320
  }
294
321
  }
@@ -384,6 +411,18 @@ function materializeDraftSystemInfo(system) {
384
411
  if (viewpoint.rotationDeg !== 0) {
385
412
  info[`${prefix}.rotation`] = String(viewpoint.rotationDeg);
386
413
  }
414
+ if (viewpoint.camera?.azimuth !== null) {
415
+ info[`${prefix}.camera.azimuth`] = String(viewpoint.camera?.azimuth);
416
+ }
417
+ if (viewpoint.camera?.elevation !== null) {
418
+ info[`${prefix}.camera.elevation`] = String(viewpoint.camera?.elevation);
419
+ }
420
+ if (viewpoint.camera?.roll !== null) {
421
+ info[`${prefix}.camera.roll`] = String(viewpoint.camera?.roll);
422
+ }
423
+ if (viewpoint.camera?.distance !== null) {
424
+ info[`${prefix}.camera.distance`] = String(viewpoint.camera?.distance);
425
+ }
387
426
  const serializedLayers = serializeViewpointLayers(viewpoint.layers);
388
427
  if (serializedLayers) {
389
428
  info[`${prefix}.layers`] = serializedLayers;