worldorbit 2.5.13 → 2.5.16

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.
Files changed (41) hide show
  1. package/README.md +37 -11
  2. package/dist/browser/core/dist/index.js +1811 -386
  3. package/dist/browser/editor/dist/index.js +10534 -0
  4. package/dist/browser/markdown/dist/index.js +1477 -221
  5. package/dist/browser/viewer/dist/index.js +1569 -230
  6. package/dist/unpkg/core/dist/index.js +1814 -389
  7. package/dist/unpkg/editor/dist/index.js +10559 -0
  8. package/dist/unpkg/markdown/dist/index.js +1480 -224
  9. package/dist/unpkg/viewer/dist/index.js +1572 -233
  10. package/dist/unpkg/worldorbit-core.min.js +12 -5
  11. package/dist/unpkg/worldorbit-editor.min.js +812 -0
  12. package/dist/unpkg/worldorbit-markdown.min.js +32 -23
  13. package/dist/unpkg/worldorbit-viewer.min.js +55 -41
  14. package/dist/unpkg/worldorbit.js +1713 -231
  15. package/dist/unpkg/worldorbit.min.js +58 -44
  16. package/package.json +3 -2
  17. package/packages/core/README.md +5 -1
  18. package/packages/core/dist/atlas-edit.d.ts +2 -2
  19. package/packages/core/dist/atlas-edit.js +70 -7
  20. package/packages/core/dist/atlas-utils.d.ts +22 -0
  21. package/packages/core/dist/atlas-utils.js +189 -0
  22. package/packages/core/dist/atlas-validate.d.ts +2 -0
  23. package/packages/core/dist/atlas-validate.js +285 -0
  24. package/packages/core/dist/draft-parse.js +786 -153
  25. package/packages/core/dist/draft.d.ts +3 -0
  26. package/packages/core/dist/draft.js +47 -3
  27. package/packages/core/dist/format.js +165 -9
  28. package/packages/core/dist/load.js +58 -13
  29. package/packages/core/dist/normalize.js +7 -0
  30. package/packages/core/dist/scene.js +66 -13
  31. package/packages/core/dist/types.d.ts +97 -3
  32. package/packages/editor/dist/editor.js +44 -0
  33. package/packages/markdown/README.md +1 -1
  34. package/packages/viewer/README.md +2 -1
  35. package/packages/viewer/dist/atlas-state.js +7 -1
  36. package/packages/viewer/dist/atlas-viewer.js +35 -1
  37. package/packages/viewer/dist/render.js +16 -7
  38. package/packages/viewer/dist/theme.js +4 -0
  39. package/packages/viewer/dist/tooltip.js +35 -0
  40. package/packages/viewer/dist/types.d.ts +7 -0
  41. package/packages/viewer/dist/viewer.js +4 -0
@@ -4,6 +4,7 @@
4
4
  var DEFAULT_LAYERS = {
5
5
  background: true,
6
6
  guides: true,
7
+ relations: true,
7
8
  orbits: true,
8
9
  objects: true,
9
10
  labels: true,
@@ -18,6 +19,7 @@
18
19
  backgroundGlow: "rgba(240, 180, 100, 0.18)",
19
20
  panel: "rgba(7, 17, 27, 0.9)",
20
21
  panelLine: "rgba(168, 207, 242, 0.18)",
22
+ relation: "rgba(240, 180, 100, 0.42)",
21
23
  orbit: "rgba(163, 209, 255, 0.24)",
22
24
  orbitBand: "rgba(255, 190, 120, 0.28)",
23
25
  guide: "rgba(255, 255, 255, 0.04)",
@@ -40,6 +42,7 @@
40
42
  backgroundGlow: "rgba(120, 255, 215, 0.16)",
41
43
  panel: "rgba(7, 20, 30, 0.9)",
42
44
  panelLine: "rgba(120, 255, 215, 0.16)",
45
+ relation: "rgba(156, 231, 255, 0.42)",
43
46
  orbit: "rgba(120, 255, 215, 0.2)",
44
47
  orbitBand: "rgba(137, 185, 255, 0.24)",
45
48
  guide: "rgba(255, 255, 255, 0.035)",
@@ -62,6 +65,7 @@
62
65
  backgroundGlow: "rgba(255, 127, 95, 0.18)",
63
66
  panel: "rgba(24, 9, 13, 0.9)",
64
67
  panelLine: "rgba(255, 166, 149, 0.16)",
68
+ relation: "rgba(255, 178, 125, 0.42)",
65
69
  orbit: "rgba(255, 188, 164, 0.22)",
66
70
  orbitBand: "rgba(255, 214, 139, 0.24)",
67
71
  guide: "rgba(255, 255, 255, 0.03)",
@@ -214,6 +218,7 @@
214
218
  return {
215
219
  background: viewpoint.layers.background,
216
220
  guides: viewpoint.layers.guides,
221
+ relations: viewpoint.layers.relations,
217
222
  orbits: viewpoint.layers["orbits-front"] === void 0 && viewpoint.layers["orbits-back"] === void 0 ? void 0 : viewpoint.layers["orbits-front"] !== false || viewpoint.layers["orbits-back"] !== false,
218
223
  objects: viewpoint.layers.objects,
219
224
  labels: viewpoint.layers.labels,
@@ -251,7 +256,11 @@
251
256
  return false;
252
257
  }
253
258
  if (filter.groupIds?.length && (!object.groupId || !filter.groupIds.includes(object.groupId))) {
254
- return false;
259
+ const hasSemanticMatch = object.semanticGroupIds.length > 0 && filter.groupIds.some((groupId) => object.semanticGroupIds.includes(groupId));
260
+ const hasLegacyMatch = Boolean(object.groupId && filter.groupIds.includes(object.groupId));
261
+ if (!hasSemanticMatch && !hasLegacyMatch) {
262
+ return false;
263
+ }
255
264
  }
256
265
  if (filter.tags?.length) {
257
266
  const objectTags = Array.isArray(object.object.properties.tags) ? object.object.properties.tags.filter((entry) => typeof entry === "string") : [];
@@ -618,13 +627,13 @@
618
627
  function unitFamilyAllowsUnit(family, unit) {
619
628
  switch (family) {
620
629
  case "distance":
621
- return unit === null || ["au", "km", "re", "sol"].includes(unit);
630
+ return unit === null || ["au", "km", "m", "ly", "pc", "kpc", "re", "sol"].includes(unit);
622
631
  case "radius":
623
- return unit === null || ["km", "re", "sol"].includes(unit);
632
+ return unit === null || ["km", "m", "re", "rj", "sol"].includes(unit);
624
633
  case "mass":
625
- return unit === null || ["me", "sol"].includes(unit);
634
+ return unit === null || ["me", "mj", "sol"].includes(unit);
626
635
  case "duration":
627
- return unit === null || ["h", "d", "y"].includes(unit);
636
+ return unit === null || ["s", "min", "h", "d", "y", "ky", "my", "gy"].includes(unit);
628
637
  case "angle":
629
638
  return unit === null || unit === "deg";
630
639
  case "generic":
@@ -828,7 +837,7 @@
828
837
  }
829
838
 
830
839
  // packages/core/dist/normalize.js
831
- var UNIT_PATTERN = /^(-?\d+(?:\.\d+)?)(au|km|re|sol|me|d|y|h|deg)?$/;
840
+ var UNIT_PATTERN = /^(-?\d+(?:\.\d+)?)(kpc|min|mj|rj|ky|my|gy|au|km|me|re|pc|ly|deg|sol|K|m|s|h|d|y)?$/;
832
841
  var BOOLEAN_VALUES = /* @__PURE__ */ new Map([
833
842
  ["true", true],
834
843
  ["false", false],
@@ -853,7 +862,10 @@
853
862
  return {
854
863
  format: "worldorbit",
855
864
  version: "1.0",
865
+ schemaVersion: "1.0",
856
866
  system,
867
+ groups: [],
868
+ relations: [],
857
869
  objects
858
870
  };
859
871
  }
@@ -863,13 +875,17 @@
863
875
  const fieldMap = collectFields(mergedFields);
864
876
  const placement = extractPlacement(node.objectType, fieldMap);
865
877
  const properties = normalizeProperties(fieldMap);
866
- const info = normalizeInfo(node.infoEntries);
878
+ const info2 = normalizeInfo(node.infoEntries);
867
879
  if (node.objectType === "system") {
868
880
  return {
869
881
  type: "system",
870
882
  id: node.name,
883
+ title: typeof properties.title === "string" ? properties.title : null,
884
+ description: null,
885
+ epoch: null,
886
+ referencePlane: null,
871
887
  properties,
872
- info
888
+ info: info2
873
889
  };
874
890
  }
875
891
  return {
@@ -877,7 +893,7 @@
877
893
  id: node.name,
878
894
  properties,
879
895
  placement,
880
- info
896
+ info: info2
881
897
  };
882
898
  }
883
899
  function validateFieldCompatibility(objectType, fields) {
@@ -1007,14 +1023,14 @@
1007
1023
  }
1008
1024
  }
1009
1025
  function normalizeInfo(entries) {
1010
- const info = {};
1026
+ const info2 = {};
1011
1027
  for (const entry of entries) {
1012
- if (entry.key in info) {
1028
+ if (entry.key in info2) {
1013
1029
  throw WorldOrbitError.fromLocation(`Duplicate info key "${entry.key}"`, entry.location);
1014
1030
  }
1015
- info[entry.key] = entry.value;
1031
+ info2[entry.key] = entry.value;
1016
1032
  }
1017
- return info;
1033
+ return info2;
1018
1034
  }
1019
1035
  function parseAtReference(target, location) {
1020
1036
  if (/^[A-Za-z0-9._-]+-[A-Za-z0-9._-]+:L\d+$/i.test(target)) {
@@ -1185,37 +1201,41 @@
1185
1201
  }
1186
1202
 
1187
1203
  // packages/core/dist/diagnostics.js
1188
- function diagnosticFromError(error, source, code = `${source}.failed`) {
1189
- if (error instanceof WorldOrbitError) {
1204
+ function diagnosticFromError(error2, source, code = `${source}.failed`) {
1205
+ if (error2 instanceof WorldOrbitError) {
1190
1206
  return {
1191
1207
  code,
1192
1208
  severity: "error",
1193
1209
  source,
1194
- message: error.message,
1195
- line: error.line,
1196
- column: error.column
1210
+ message: error2.message,
1211
+ line: error2.line,
1212
+ column: error2.column
1197
1213
  };
1198
1214
  }
1199
- if (error instanceof Error) {
1215
+ if (error2 instanceof Error) {
1200
1216
  return {
1201
1217
  code,
1202
1218
  severity: "error",
1203
1219
  source,
1204
- message: error.message
1220
+ message: error2.message
1205
1221
  };
1206
1222
  }
1207
1223
  return {
1208
1224
  code,
1209
1225
  severity: "error",
1210
1226
  source,
1211
- message: String(error)
1227
+ message: String(error2)
1212
1228
  };
1213
1229
  }
1214
1230
 
1215
1231
  // packages/core/dist/scene.js
1216
1232
  var AU_IN_KM = 1495978707e-1;
1217
1233
  var EARTH_RADIUS_IN_KM = 6371;
1234
+ var JUPITER_RADIUS_IN_KM = 71492;
1218
1235
  var SOLAR_RADIUS_IN_KM = 695700;
1236
+ var LY_IN_AU = 63241.077;
1237
+ var PC_IN_AU = 206264.806;
1238
+ var KPC_IN_AU = 206264806;
1219
1239
  var ISO_FLATTENING = 0.68;
1220
1240
  var MIN_ISO_MINOR_SCALE = 0.2;
1221
1241
  var ARC_SAMPLE_COUNT = 28;
@@ -1335,8 +1355,10 @@
1335
1355
  const orbitVisuals = orbitDrafts.map((draft) => createOrbitVisual(draft, relationships.groupIds.get(draft.object.id) ?? null));
1336
1356
  const leaders = leaderDrafts.map((draft) => createLeaderLine(draft));
1337
1357
  const labels = createSceneLabels(objects, height, scaleModel.labelMultiplier);
1338
- const layers = createSceneLayers(orbitVisuals, leaders, objects, labels);
1358
+ const relations = createSceneRelations(document2, objects);
1359
+ const layers = createSceneLayers(orbitVisuals, relations, leaders, objects, labels);
1339
1360
  const groups = createSceneGroups(objects, orbitVisuals, leaders, labels, relationships);
1361
+ const semanticGroups = createSceneSemanticGroups(document2, objects);
1340
1362
  const viewpoints = createSceneViewpoints(document2, projection, frame.preset, relationships, objectMap);
1341
1363
  const contentBounds = calculateContentBounds(width, height, objects, orbitVisuals, leaders, labels);
1342
1364
  return {
@@ -1346,7 +1368,7 @@
1346
1368
  renderPreset: frame.preset,
1347
1369
  projection,
1348
1370
  scaleModel,
1349
- title: String(document2.system?.properties.title ?? document2.system?.id ?? "WorldOrbit") || "WorldOrbit",
1371
+ title: String(document2.system?.title ?? document2.system?.properties.title ?? document2.system?.id ?? "WorldOrbit") || "WorldOrbit",
1350
1372
  subtitle: `${capitalizeLabel(projection)} view - ${capitalizeLabel(layoutPreset)} layout`,
1351
1373
  systemId,
1352
1374
  viewMode: projection,
@@ -1362,9 +1384,11 @@
1362
1384
  contentBounds,
1363
1385
  layers,
1364
1386
  groups,
1387
+ semanticGroups,
1365
1388
  viewpoints,
1366
1389
  objects,
1367
1390
  orbitVisuals,
1391
+ relations,
1368
1392
  leaders,
1369
1393
  labels
1370
1394
  };
@@ -1474,6 +1498,7 @@
1474
1498
  }
1475
1499
  function createSceneObject(position, scaleModel, relationships) {
1476
1500
  const { object, x, y, radius, sortKey, anchorX, anchorY } = position;
1501
+ const renderPriority = object.renderHints?.renderPriority ?? 0;
1477
1502
  return {
1478
1503
  renderId: createRenderId(object.id),
1479
1504
  objectId: object.id,
@@ -1482,11 +1507,12 @@
1482
1507
  ancestorIds: relationships.ancestorIds.get(object.id) ?? [],
1483
1508
  childIds: relationships.childIds.get(object.id) ?? [],
1484
1509
  groupId: relationships.groupIds.get(object.id) ?? null,
1510
+ semanticGroupIds: [...object.groups ?? []],
1485
1511
  x,
1486
1512
  y,
1487
1513
  radius,
1488
1514
  visualRadius: visualExtentForObject(object, radius, scaleModel),
1489
- sortKey,
1515
+ sortKey: sortKey + renderPriority * 1e-3,
1490
1516
  anchorX,
1491
1517
  anchorY,
1492
1518
  label: object.id,
@@ -1503,6 +1529,7 @@
1503
1529
  object: draft.object,
1504
1530
  parentId: draft.parentId,
1505
1531
  groupId,
1532
+ semanticGroupIds: [...draft.object.groups ?? []],
1506
1533
  kind: draft.kind,
1507
1534
  cx: draft.cx,
1508
1535
  cy: draft.cy,
@@ -1514,7 +1541,7 @@
1514
1541
  bandThickness: draft.bandThickness,
1515
1542
  frontArcPath: draft.frontArcPath,
1516
1543
  backArcPath: draft.backArcPath,
1517
- hidden: draft.object.properties.hidden === true
1544
+ hidden: draft.object.properties.hidden === true || draft.object.renderHints?.renderOrbit === false
1518
1545
  };
1519
1546
  }
1520
1547
  function createLeaderLine(draft) {
@@ -1523,6 +1550,7 @@
1523
1550
  objectId: draft.object.id,
1524
1551
  object: draft.object,
1525
1552
  groupId: draft.groupId,
1553
+ semanticGroupIds: [...draft.object.groups ?? []],
1526
1554
  x1: draft.x1,
1527
1555
  y1: draft.y1,
1528
1556
  x2: draft.x2,
@@ -1534,7 +1562,7 @@
1534
1562
  function createSceneLabels(objects, sceneHeight, labelMultiplier) {
1535
1563
  const labels = [];
1536
1564
  const occupied = [];
1537
- const visibleObjects = [...objects].filter((object) => !object.hidden).sort((left, right) => left.sortKey - right.sortKey);
1565
+ const visibleObjects = [...objects].filter((object) => !object.hidden && object.object.renderHints?.renderLabel !== false).sort((left, right) => left.sortKey - right.sortKey);
1538
1566
  for (const object of visibleObjects) {
1539
1567
  const direction = object.y > sceneHeight * 0.62 ? -1 : 1;
1540
1568
  const labelHalfWidth = estimateLabelHalfWidth(object, labelMultiplier);
@@ -1554,6 +1582,7 @@
1554
1582
  objectId: object.objectId,
1555
1583
  object: object.object,
1556
1584
  groupId: object.groupId,
1585
+ semanticGroupIds: [...object.semanticGroupIds],
1557
1586
  label: object.label,
1558
1587
  secondaryLabel: object.secondaryLabel,
1559
1588
  x: object.x,
@@ -1566,7 +1595,7 @@
1566
1595
  }
1567
1596
  return labels;
1568
1597
  }
1569
- function createSceneLayers(orbitVisuals, leaders, objects, labels) {
1598
+ function createSceneLayers(orbitVisuals, relations, leaders, objects, labels) {
1570
1599
  const backOrbitIds = orbitVisuals.filter((visual) => !visual.hidden && Boolean(visual.backArcPath)).map((visual) => visual.renderId);
1571
1600
  const frontOrbitIds = orbitVisuals.filter((visual) => !visual.hidden).map((visual) => visual.renderId);
1572
1601
  return [
@@ -1577,6 +1606,10 @@
1577
1606
  },
1578
1607
  { id: "orbits-back", renderIds: backOrbitIds },
1579
1608
  { id: "orbits-front", renderIds: frontOrbitIds },
1609
+ {
1610
+ id: "relations",
1611
+ renderIds: relations.filter((relation) => !relation.hidden).map((relation) => relation.renderId)
1612
+ },
1580
1613
  {
1581
1614
  id: "objects",
1582
1615
  renderIds: objects.filter((object) => !object.hidden).map((object) => object.renderId)
@@ -1641,6 +1674,36 @@
1641
1674
  }
1642
1675
  return [...groups.values()].sort((left, right) => left.label.localeCompare(right.label));
1643
1676
  }
1677
+ function createSceneSemanticGroups(document2, objects) {
1678
+ return [...document2.groups].map((group) => ({
1679
+ id: group.id,
1680
+ label: group.label,
1681
+ summary: group.summary,
1682
+ color: group.color,
1683
+ tags: [...group.tags],
1684
+ hidden: group.hidden,
1685
+ objectIds: objects.filter((object) => !object.hidden && object.semanticGroupIds.includes(group.id)).map((object) => object.objectId)
1686
+ })).sort((left, right) => left.label.localeCompare(right.label));
1687
+ }
1688
+ function createSceneRelations(document2, objects) {
1689
+ const objectMap = new Map(objects.map((object) => [object.objectId, object]));
1690
+ return document2.relations.map((relation) => {
1691
+ const from = objectMap.get(relation.from);
1692
+ const to = objectMap.get(relation.to);
1693
+ return {
1694
+ renderId: `${createRenderId(relation.id)}-relation`,
1695
+ relationId: relation.id,
1696
+ relation,
1697
+ fromObjectId: relation.from,
1698
+ toObjectId: relation.to,
1699
+ x1: from?.x ?? 0,
1700
+ y1: from?.y ?? 0,
1701
+ x2: to?.x ?? 0,
1702
+ y2: to?.y ?? 0,
1703
+ hidden: relation.hidden || !from || !to || from.hidden || to.hidden
1704
+ };
1705
+ }).sort((left, right) => left.relation.id.localeCompare(right.relation.id));
1706
+ }
1644
1707
  function createSceneViewpoints(document2, projection, preset, relationships, objectMap) {
1645
1708
  const generatedOverview = createGeneratedOverviewViewpoint(document2, projection, preset);
1646
1709
  const drafts = /* @__PURE__ */ new Map();
@@ -1658,7 +1721,7 @@
1658
1721
  }
1659
1722
  const field = fieldParts.join(".").toLowerCase();
1660
1723
  const draft = drafts.get(id) ?? { id };
1661
- applyViewpointField(draft, field, value, projection, preset, relationships, objectMap);
1724
+ applyViewpointField(draft, field, value, document2, projection, preset, relationships, objectMap);
1662
1725
  drafts.set(id, draft);
1663
1726
  }
1664
1727
  const viewpoints = [...drafts.values()].map((draft) => finalizeViewpointDraft(draft, projection, preset, objectMap)).filter(Boolean);
@@ -1686,7 +1749,8 @@
1686
1749
  });
1687
1750
  }
1688
1751
  function createGeneratedOverviewViewpoint(document2, projection, preset) {
1689
- const label = document2.system?.properties.title ? `${String(document2.system.properties.title)} Overview` : "Overview";
1752
+ const title = document2.system?.title ?? document2.system?.properties.title;
1753
+ const label = title ? `${String(title)} Overview` : "Overview";
1690
1754
  return {
1691
1755
  id: "overview",
1692
1756
  label,
@@ -1702,7 +1766,7 @@
1702
1766
  generated: true
1703
1767
  };
1704
1768
  }
1705
- function applyViewpointField(draft, field, value, projection, preset, relationships, objectMap) {
1769
+ function applyViewpointField(draft, field, value, document2, projection, preset, relationships, objectMap) {
1706
1770
  const normalizedValue = value.trim();
1707
1771
  switch (field) {
1708
1772
  case "label":
@@ -1769,7 +1833,7 @@
1769
1833
  case "groups":
1770
1834
  draft.filter = {
1771
1835
  ...draft.filter ?? createEmptyViewpointFilter(),
1772
- groupIds: parseViewpointGroups(normalizedValue, relationships, objectMap)
1836
+ groupIds: parseViewpointGroups(normalizedValue, document2, relationships, objectMap)
1773
1837
  };
1774
1838
  return;
1775
1839
  }
@@ -1842,7 +1906,7 @@
1842
1906
  next["orbits-front"] = enabled;
1843
1907
  continue;
1844
1908
  }
1845
- if (rawLayer === "background" || rawLayer === "guides" || rawLayer === "orbits-back" || rawLayer === "orbits-front" || rawLayer === "objects" || rawLayer === "labels" || rawLayer === "metadata") {
1909
+ if (rawLayer === "background" || rawLayer === "guides" || rawLayer === "orbits-back" || rawLayer === "orbits-front" || rawLayer === "relations" || rawLayer === "objects" || rawLayer === "labels" || rawLayer === "metadata") {
1846
1910
  next[rawLayer] = enabled;
1847
1911
  }
1848
1912
  }
@@ -1851,8 +1915,11 @@
1851
1915
  function parseViewpointObjectTypes(value) {
1852
1916
  return splitListValue(value).filter((entry) => entry === "star" || entry === "planet" || entry === "moon" || entry === "belt" || entry === "asteroid" || entry === "comet" || entry === "ring" || entry === "structure" || entry === "phenomenon");
1853
1917
  }
1854
- function parseViewpointGroups(value, relationships, objectMap) {
1918
+ function parseViewpointGroups(value, document2, relationships, objectMap) {
1855
1919
  return splitListValue(value).map((entry) => {
1920
+ if (document2.schemaVersion === "2.1" || document2.groups.some((group) => group.id === entry)) {
1921
+ return entry;
1922
+ }
1856
1923
  if (entry.startsWith("wo-") && entry.endsWith("-group")) {
1857
1924
  return entry;
1858
1925
  }
@@ -1983,8 +2050,9 @@
1983
2050
  }
1984
2051
  const orbiting = [...context.orbitChildren.get(object.id) ?? []].sort(compareOrbiting);
1985
2052
  const orbitMetricContext = computeOrbitMetricContext(orbiting, parent.radius, context.spacingFactor, context.scaleModel);
2053
+ const orbitRadiiPx = resolveOrbitRadiiPx(orbiting, orbitMetricContext);
1986
2054
  orbiting.forEach((child, index) => {
1987
- const orbitGeometry = resolveOrbitGeometry(child, index, orbiting.length, parent, orbitMetricContext, context);
2055
+ const orbitGeometry = resolveOrbitGeometry(child, index, orbiting.length, parent, orbitMetricContext, orbitRadiiPx[index] ?? orbitMetricContext.innerPx, context);
1988
2056
  orbitDrafts.push({
1989
2057
  object: child,
1990
2058
  parentId: object.id,
@@ -2058,7 +2126,8 @@
2058
2126
  metricSpread: 0,
2059
2127
  innerPx,
2060
2128
  stepPx,
2061
- pixelSpread: Math.max(stepPx * Math.max(objects.length - 1, 1), stepPx)
2129
+ pixelSpread: Math.max(stepPx * Math.max(objects.length - 1, 1), stepPx),
2130
+ minimumGapPx: stepPx * 0.42
2062
2131
  };
2063
2132
  }
2064
2133
  const minMetric = Math.min(...presentMetrics);
@@ -2071,10 +2140,11 @@
2071
2140
  metricSpread,
2072
2141
  innerPx,
2073
2142
  stepPx,
2074
- pixelSpread: Math.max(stepPx * Math.max(objects.length - 1, 1), stepPx)
2143
+ pixelSpread: Math.max(stepPx * Math.max(objects.length - 1, 1), stepPx),
2144
+ minimumGapPx: stepPx * 0.42
2075
2145
  };
2076
2146
  }
2077
- function resolveOrbitGeometry(object, index, count, parent, metricContext, context) {
2147
+ function resolveOrbitGeometry(object, index, count, parent, metricContext, orbitRadiusPx, context) {
2078
2148
  const placement = object.placement;
2079
2149
  const band = object.type === "belt" || object.type === "ring";
2080
2150
  if (!placement || placement.mode !== "orbit") {
@@ -2092,7 +2162,7 @@
2092
2162
  };
2093
2163
  }
2094
2164
  const eccentricity = clampNumber(typeof placement.eccentricity === "number" ? placement.eccentricity : 0, 0, 0.92);
2095
- const semiMajor = resolveOrbitRadiusPx(object, index, metricContext);
2165
+ const semiMajor = orbitRadiusPx;
2096
2166
  const baseMinor = Math.max(semiMajor * Math.sqrt(1 - eccentricity * eccentricity), semiMajor * 0.18);
2097
2167
  const inclinationDeg = unitValueToDegrees(placement.inclination) ?? 0;
2098
2168
  const inclinationScale = context.projection === "isometric" ? Math.max(MIN_ISO_MINOR_SCALE, Math.cos(degreesToRadians(inclinationDeg))) * ISO_FLATTENING : 1;
@@ -2122,15 +2192,19 @@
2122
2192
  objectY: objectPoint.y
2123
2193
  };
2124
2194
  }
2125
- function resolveOrbitRadiusPx(object, index, metricContext) {
2126
- const metric = orbitMetric(object);
2127
- if (metric === null) {
2128
- return metricContext.innerPx + index * metricContext.stepPx;
2129
- }
2130
- if (metricContext.metricSpread > 0) {
2131
- return metricContext.innerPx + (metric - metricContext.minMetric) / metricContext.metricSpread * metricContext.pixelSpread;
2132
- }
2133
- return metricContext.innerPx + Math.log10(metric + 1) * metricContext.stepPx;
2195
+ function resolveOrbitRadiusPx(metric, metricContext) {
2196
+ return metricContext.innerPx + metricContext.stepPx * log2(Math.max(metric, 0) + 1);
2197
+ }
2198
+ function resolveOrbitRadiiPx(objects, metricContext) {
2199
+ const radii = [];
2200
+ objects.forEach((object, index) => {
2201
+ const metric = orbitMetric(object);
2202
+ const fallbackRadius = metricContext.innerPx + index * metricContext.stepPx;
2203
+ const baseRadius = metric === null ? fallbackRadius : resolveOrbitRadiusPx(metric, metricContext);
2204
+ const minimumRadius = index === 0 ? metricContext.innerPx : (radii[index - 1] ?? metricContext.innerPx) + metricContext.minimumGapPx;
2205
+ radii.push(Math.max(baseRadius, minimumRadius));
2206
+ });
2207
+ return radii;
2134
2208
  }
2135
2209
  function orbitMetric(object) {
2136
2210
  if (!object.placement || object.placement.mode !== "orbit") {
@@ -2138,6 +2212,9 @@
2138
2212
  }
2139
2213
  return toDistanceMetric(object.placement.semiMajor ?? object.placement.distance ?? null);
2140
2214
  }
2215
+ function log2(value) {
2216
+ return Math.log(value) / Math.log(2);
2217
+ }
2141
2218
  function resolveOrbitPhase(phase, index, count) {
2142
2219
  const degreeValue = phase ? unitValueToDegrees(phase) : null;
2143
2220
  if (degreeValue !== null) {
@@ -2461,8 +2538,18 @@
2461
2538
  return value.value;
2462
2539
  case "km":
2463
2540
  return value.value / AU_IN_KM;
2541
+ case "m":
2542
+ return value.value / 1e3 / AU_IN_KM;
2543
+ case "ly":
2544
+ return value.value * LY_IN_AU;
2545
+ case "pc":
2546
+ return value.value * PC_IN_AU;
2547
+ case "kpc":
2548
+ return value.value * KPC_IN_AU;
2464
2549
  case "re":
2465
2550
  return value.value * EARTH_RADIUS_IN_KM / AU_IN_KM;
2551
+ case "rj":
2552
+ return value.value * JUPITER_RADIUS_IN_KM / AU_IN_KM;
2466
2553
  case "sol":
2467
2554
  return value.value * SOLAR_RADIUS_IN_KM / AU_IN_KM;
2468
2555
  default:
@@ -2602,19 +2689,37 @@
2602
2689
  const system = document2.system ? {
2603
2690
  type: "system",
2604
2691
  id: document2.system.id,
2692
+ title: document2.system.title,
2693
+ description: document2.system.description,
2694
+ epoch: document2.system.epoch,
2695
+ referencePlane: document2.system.referencePlane,
2605
2696
  properties: materializeDraftSystemProperties(document2.system),
2606
2697
  info: materializeDraftSystemInfo(document2.system)
2607
2698
  } : null;
2608
2699
  return {
2609
2700
  format: "worldorbit",
2610
2701
  version: "1.0",
2702
+ schemaVersion: document2.version,
2611
2703
  system,
2704
+ groups: structuredClone(document2.groups ?? []),
2705
+ relations: structuredClone(document2.relations ?? []),
2612
2706
  objects: document2.objects.map(cloneWorldOrbitObject)
2613
2707
  };
2614
2708
  }
2615
2709
  function cloneWorldOrbitObject(object) {
2616
2710
  return {
2617
2711
  ...object,
2712
+ groups: object.groups ? [...object.groups] : void 0,
2713
+ resonance: object.resonance ? { ...object.resonance } : object.resonance,
2714
+ renderHints: object.renderHints ? { ...object.renderHints } : object.renderHints,
2715
+ deriveRules: object.deriveRules ? object.deriveRules.map((rule) => ({ ...rule })) : void 0,
2716
+ validationRules: object.validationRules ? object.validationRules.map((rule) => ({ ...rule })) : void 0,
2717
+ lockedFields: object.lockedFields ? [...object.lockedFields] : void 0,
2718
+ tolerances: object.tolerances ? object.tolerances.map((entry) => ({
2719
+ field: entry.field,
2720
+ value: entry.value && typeof entry.value === "object" && "value" in entry.value ? { value: entry.value.value, unit: entry.value.unit } : Array.isArray(entry.value) ? [...entry.value] : entry.value
2721
+ })) : void 0,
2722
+ typedBlocks: object.typedBlocks ? Object.fromEntries(Object.entries(object.typedBlocks).map(([key, block]) => [key, { ...block ?? {} }])) : void 0,
2618
2723
  properties: cloneProperties(object.properties),
2619
2724
  placement: object.placement ? structuredClone(object.placement) : null,
2620
2725
  info: { ...object.info }
@@ -2650,71 +2755,80 @@
2650
2755
  if (system.defaults.units) {
2651
2756
  properties.units = system.defaults.units;
2652
2757
  }
2758
+ if (system.description) {
2759
+ properties.description = system.description;
2760
+ }
2761
+ if (system.epoch) {
2762
+ properties.epoch = system.epoch;
2763
+ }
2764
+ if (system.referencePlane) {
2765
+ properties.referencePlane = system.referencePlane;
2766
+ }
2653
2767
  return properties;
2654
2768
  }
2655
2769
  function materializeDraftSystemInfo(system) {
2656
- const info = {
2770
+ const info2 = {
2657
2771
  ...system.atlasMetadata
2658
2772
  };
2659
2773
  if (system.defaults.theme) {
2660
- info["atlas.theme"] = system.defaults.theme;
2774
+ info2["atlas.theme"] = system.defaults.theme;
2661
2775
  }
2662
2776
  for (const viewpoint of system.viewpoints) {
2663
2777
  const prefix = `viewpoint.${viewpoint.id}`;
2664
- info[`${prefix}.label`] = viewpoint.label;
2778
+ info2[`${prefix}.label`] = viewpoint.label;
2665
2779
  if (viewpoint.summary) {
2666
- info[`${prefix}.summary`] = viewpoint.summary;
2780
+ info2[`${prefix}.summary`] = viewpoint.summary;
2667
2781
  }
2668
2782
  if (viewpoint.focusObjectId) {
2669
- info[`${prefix}.focus`] = viewpoint.focusObjectId;
2783
+ info2[`${prefix}.focus`] = viewpoint.focusObjectId;
2670
2784
  }
2671
2785
  if (viewpoint.selectedObjectId) {
2672
- info[`${prefix}.select`] = viewpoint.selectedObjectId;
2786
+ info2[`${prefix}.select`] = viewpoint.selectedObjectId;
2673
2787
  }
2674
2788
  if (viewpoint.projection) {
2675
- info[`${prefix}.projection`] = viewpoint.projection;
2789
+ info2[`${prefix}.projection`] = viewpoint.projection;
2676
2790
  }
2677
2791
  if (viewpoint.preset) {
2678
- info[`${prefix}.preset`] = viewpoint.preset;
2792
+ info2[`${prefix}.preset`] = viewpoint.preset;
2679
2793
  }
2680
2794
  if (viewpoint.zoom !== null) {
2681
- info[`${prefix}.zoom`] = String(viewpoint.zoom);
2795
+ info2[`${prefix}.zoom`] = String(viewpoint.zoom);
2682
2796
  }
2683
2797
  if (viewpoint.rotationDeg !== 0) {
2684
- info[`${prefix}.rotation`] = String(viewpoint.rotationDeg);
2798
+ info2[`${prefix}.rotation`] = String(viewpoint.rotationDeg);
2685
2799
  }
2686
2800
  const serializedLayers = serializeViewpointLayers(viewpoint.layers);
2687
2801
  if (serializedLayers) {
2688
- info[`${prefix}.layers`] = serializedLayers;
2802
+ info2[`${prefix}.layers`] = serializedLayers;
2689
2803
  }
2690
2804
  if (viewpoint.filter?.query) {
2691
- info[`${prefix}.query`] = viewpoint.filter.query;
2805
+ info2[`${prefix}.query`] = viewpoint.filter.query;
2692
2806
  }
2693
2807
  if ((viewpoint.filter?.objectTypes.length ?? 0) > 0) {
2694
- info[`${prefix}.types`] = viewpoint.filter?.objectTypes.join(" ") ?? "";
2808
+ info2[`${prefix}.types`] = viewpoint.filter?.objectTypes.join(" ") ?? "";
2695
2809
  }
2696
2810
  if ((viewpoint.filter?.tags.length ?? 0) > 0) {
2697
- info[`${prefix}.tags`] = viewpoint.filter?.tags.join(" ") ?? "";
2811
+ info2[`${prefix}.tags`] = viewpoint.filter?.tags.join(" ") ?? "";
2698
2812
  }
2699
2813
  if ((viewpoint.filter?.groupIds.length ?? 0) > 0) {
2700
- info[`${prefix}.groups`] = viewpoint.filter?.groupIds.join(" ") ?? "";
2814
+ info2[`${prefix}.groups`] = viewpoint.filter?.groupIds.join(" ") ?? "";
2701
2815
  }
2702
2816
  }
2703
2817
  for (const annotation of system.annotations) {
2704
2818
  const prefix = `annotation.${annotation.id}`;
2705
- info[`${prefix}.label`] = annotation.label;
2819
+ info2[`${prefix}.label`] = annotation.label;
2706
2820
  if (annotation.targetObjectId) {
2707
- info[`${prefix}.target`] = annotation.targetObjectId;
2821
+ info2[`${prefix}.target`] = annotation.targetObjectId;
2708
2822
  }
2709
- info[`${prefix}.body`] = annotation.body;
2823
+ info2[`${prefix}.body`] = annotation.body;
2710
2824
  if (annotation.tags.length > 0) {
2711
- info[`${prefix}.tags`] = annotation.tags.join(" ");
2825
+ info2[`${prefix}.tags`] = annotation.tags.join(" ");
2712
2826
  }
2713
2827
  if (annotation.sourceObjectId) {
2714
- info[`${prefix}.source`] = annotation.sourceObjectId;
2828
+ info2[`${prefix}.source`] = annotation.sourceObjectId;
2715
2829
  }
2716
2830
  }
2717
- return info;
2831
+ return info2;
2718
2832
  }
2719
2833
  function serializeViewpointLayers(layers) {
2720
2834
  const tokens = [];
@@ -2723,7 +2837,7 @@
2723
2837
  if (orbitFront !== void 0 || orbitBack !== void 0) {
2724
2838
  tokens.push(orbitFront !== false || orbitBack !== false ? "orbits" : "-orbits");
2725
2839
  }
2726
- for (const key of ["background", "guides", "objects", "labels", "metadata"]) {
2840
+ for (const key of ["background", "guides", "relations", "objects", "labels", "metadata"]) {
2727
2841
  if (layers[key] !== void 0) {
2728
2842
  tokens.push(layers[key] ? key : `-${key}`);
2729
2843
  }
@@ -2731,21 +2845,530 @@
2731
2845
  return tokens.join(" ");
2732
2846
  }
2733
2847
 
2848
+ // packages/core/dist/atlas-utils.js
2849
+ var UNIT_PATTERN2 = /^(-?\d+(?:\.\d+)?)(kpc|min|mj|rj|ky|my|gy|au|km|me|re|pc|ly|deg|sol|K|m|s|h|d|y)?$/;
2850
+ var BOOLEAN_VALUES2 = /* @__PURE__ */ new Map([
2851
+ ["true", true],
2852
+ ["false", false],
2853
+ ["yes", true],
2854
+ ["no", false]
2855
+ ]);
2856
+ var URL_SCHEME_PATTERN2 = /^[A-Za-z][A-Za-z0-9+.-]*:/;
2857
+ function normalizeIdentifier(value) {
2858
+ return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
2859
+ }
2860
+ function humanizeIdentifier2(value) {
2861
+ return value.split(/[-_]+/).filter(Boolean).map((segment) => segment[0].toUpperCase() + segment.slice(1)).join(" ");
2862
+ }
2863
+ function parseAtlasUnitValue(input, location, fieldKey) {
2864
+ const match = input.match(UNIT_PATTERN2);
2865
+ if (!match) {
2866
+ throw WorldOrbitError.fromLocation(`Invalid unit value "${input}"`, location);
2867
+ }
2868
+ const unitValue = {
2869
+ value: Number(match[1]),
2870
+ unit: match[2] ?? null
2871
+ };
2872
+ if (fieldKey) {
2873
+ const schema = getFieldSchema(fieldKey);
2874
+ if (schema?.unitFamily && !unitFamilyAllowsUnit(schema.unitFamily, unitValue.unit)) {
2875
+ throw WorldOrbitError.fromLocation(`Unit "${unitValue.unit ?? "none"}" is not valid for "${fieldKey}"`, location);
2876
+ }
2877
+ }
2878
+ return unitValue;
2879
+ }
2880
+ function tryParseAtlasUnitValue(input) {
2881
+ const match = input.match(UNIT_PATTERN2);
2882
+ if (!match) {
2883
+ return null;
2884
+ }
2885
+ return {
2886
+ value: Number(match[1]),
2887
+ unit: match[2] ?? null
2888
+ };
2889
+ }
2890
+ function parseAtlasNumber(input, key, location) {
2891
+ const value = Number(input);
2892
+ if (!Number.isFinite(value)) {
2893
+ throw WorldOrbitError.fromLocation(`Invalid numeric value "${input}" for "${key}"`, location);
2894
+ }
2895
+ return value;
2896
+ }
2897
+ function parseAtlasBoolean(input, key, location) {
2898
+ const parsed = BOOLEAN_VALUES2.get(input.toLowerCase());
2899
+ if (parsed === void 0) {
2900
+ throw WorldOrbitError.fromLocation(`Invalid boolean value "${input}" for "${key}"`, location);
2901
+ }
2902
+ return parsed;
2903
+ }
2904
+ function parseAtlasAtReference(target, location) {
2905
+ if (/^[A-Za-z0-9._-]+-[A-Za-z0-9._-]+:L\d+$/i.test(target)) {
2906
+ throw WorldOrbitError.fromLocation(`Invalid special position "${target}"`, location);
2907
+ }
2908
+ const pairedMatch = target.match(/^([A-Za-z0-9._-]+)-([A-Za-z0-9._-]+):(L[1-5])$/);
2909
+ if (pairedMatch) {
2910
+ return {
2911
+ kind: "lagrange",
2912
+ primary: pairedMatch[1],
2913
+ secondary: pairedMatch[2],
2914
+ point: pairedMatch[3]
2915
+ };
2916
+ }
2917
+ const simpleMatch = target.match(/^([A-Za-z0-9._-]+):(L[1-5])$/);
2918
+ if (simpleMatch) {
2919
+ return {
2920
+ kind: "lagrange",
2921
+ primary: simpleMatch[1],
2922
+ secondary: null,
2923
+ point: simpleMatch[2]
2924
+ };
2925
+ }
2926
+ if (/^[A-Za-z0-9._-]+:L\d+$/i.test(target)) {
2927
+ throw WorldOrbitError.fromLocation(`Invalid special position "${target}"`, location);
2928
+ }
2929
+ const anchorMatch = target.match(/^([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)$/);
2930
+ if (anchorMatch) {
2931
+ return {
2932
+ kind: "anchor",
2933
+ objectId: anchorMatch[1],
2934
+ anchor: anchorMatch[2]
2935
+ };
2936
+ }
2937
+ return {
2938
+ kind: "named",
2939
+ name: target
2940
+ };
2941
+ }
2942
+ function validateAtlasImageSource(value, location) {
2943
+ if (!value) {
2944
+ throw WorldOrbitError.fromLocation('Field "image" must not be empty', location);
2945
+ }
2946
+ if (value.startsWith("//")) {
2947
+ throw WorldOrbitError.fromLocation('Field "image" must use a relative path, root-relative path, or an http/https URL', location);
2948
+ }
2949
+ const schemeMatch = value.match(URL_SCHEME_PATTERN2);
2950
+ if (!schemeMatch) {
2951
+ return;
2952
+ }
2953
+ const scheme = schemeMatch[0].slice(0, -1).toLowerCase();
2954
+ if (scheme !== "http" && scheme !== "https") {
2955
+ throw WorldOrbitError.fromLocation(`Field "image" does not support the "${scheme}" scheme`, location);
2956
+ }
2957
+ }
2958
+ function normalizeLegacyScalarValue(key, values, location) {
2959
+ const schema = getFieldSchema(key);
2960
+ if (!schema) {
2961
+ throw WorldOrbitError.fromLocation(`Unknown field "${key}"`, location);
2962
+ }
2963
+ if (schema.arity === "single" && values.length !== 1) {
2964
+ throw WorldOrbitError.fromLocation(`Field "${key}" expects exactly one value`, location);
2965
+ }
2966
+ switch (schema.kind) {
2967
+ case "list":
2968
+ return values;
2969
+ case "boolean":
2970
+ return parseAtlasBoolean(singleAtlasValue(values, key, location), key, location);
2971
+ case "number":
2972
+ return parseAtlasNumber(singleAtlasValue(values, key, location), key, location);
2973
+ case "unit":
2974
+ return parseAtlasUnitValue(singleAtlasValue(values, key, location), location, key);
2975
+ case "string": {
2976
+ const value = values.join(" ").trim();
2977
+ if (key === "image") {
2978
+ validateAtlasImageSource(value, location);
2979
+ }
2980
+ return value;
2981
+ }
2982
+ }
2983
+ }
2984
+ function ensureAtlasFieldSupported(key, objectType, location) {
2985
+ const schema = getFieldSchema(key);
2986
+ if (!schema) {
2987
+ throw WorldOrbitError.fromLocation(`Unknown field "${key}"`, location);
2988
+ }
2989
+ if (!schema.objectTypes.includes(objectType)) {
2990
+ throw WorldOrbitError.fromLocation(`Field "${key}" is not valid on "${objectType}"`, location);
2991
+ }
2992
+ }
2993
+ function singleAtlasValue(values, key, location) {
2994
+ if (values.length !== 1) {
2995
+ throw WorldOrbitError.fromLocation(`Field "${key}" expects exactly one value`, location);
2996
+ }
2997
+ return values[0];
2998
+ }
2999
+
3000
+ // packages/core/dist/atlas-validate.js
3001
+ var SURFACE_TARGET_TYPES2 = /* @__PURE__ */ new Set(["star", "planet", "moon", "asteroid", "comet"]);
3002
+ var EARTH_MASSES_PER_SOLAR = 332946.0487;
3003
+ var JUPITER_MASSES_PER_SOLAR = 1047.3486;
3004
+ var AU_IN_KM2 = 1495978707e-1;
3005
+ var EARTH_RADIUS_IN_KM2 = 6371;
3006
+ var SOLAR_RADIUS_IN_KM2 = 695700;
3007
+ var LY_IN_AU2 = 63241.077;
3008
+ var PC_IN_AU2 = 206264.806;
3009
+ var KPC_IN_AU2 = 206264806;
3010
+ function collectAtlasDiagnostics(document2, sourceSchemaVersion) {
3011
+ const diagnostics = [];
3012
+ const objectMap = new Map(document2.objects.map((object) => [object.id, object]));
3013
+ const groupIds = new Set(document2.groups.map((group) => group.id));
3014
+ if (!document2.system) {
3015
+ diagnostics.push(error("validate.system.required", "Atlas documents must declare exactly one system."));
3016
+ }
3017
+ const knownIds = /* @__PURE__ */ new Map();
3018
+ for (const [kind, ids] of [
3019
+ ["group", document2.groups.map((group) => group.id)],
3020
+ ["viewpoint", document2.system?.viewpoints.map((viewpoint) => viewpoint.id) ?? []],
3021
+ ["annotation", document2.system?.annotations.map((annotation) => annotation.id) ?? []],
3022
+ ["relation", document2.relations.map((relation) => relation.id)],
3023
+ ["object", document2.objects.map((object) => object.id)]
3024
+ ]) {
3025
+ for (const id of ids) {
3026
+ const previous = knownIds.get(id);
3027
+ if (previous) {
3028
+ diagnostics.push(error("validate.id.duplicate", `Duplicate ${kind} id "${id}" already used by ${previous}.`));
3029
+ } else {
3030
+ knownIds.set(id, kind);
3031
+ }
3032
+ }
3033
+ }
3034
+ for (const relation of document2.relations) {
3035
+ validateRelation(relation, objectMap, diagnostics);
3036
+ }
3037
+ for (const viewpoint of document2.system?.viewpoints ?? []) {
3038
+ validateViewpointFilter(viewpoint.filter, groupIds, sourceSchemaVersion, diagnostics, viewpoint.id);
3039
+ }
3040
+ for (const object of document2.objects) {
3041
+ validateObject(object, document2.system, objectMap, groupIds, diagnostics);
3042
+ }
3043
+ return diagnostics;
3044
+ }
3045
+ function validateRelation(relation, objectMap, diagnostics) {
3046
+ if (!relation.from) {
3047
+ diagnostics.push(error("validate.relation.from.required", `Relation "${relation.id}" is missing a "from" target.`));
3048
+ } else if (!objectMap.has(relation.from)) {
3049
+ diagnostics.push(error("validate.relation.from.unknown", `Unknown relation source "${relation.from}" on "${relation.id}".`));
3050
+ }
3051
+ if (!relation.to) {
3052
+ diagnostics.push(error("validate.relation.to.required", `Relation "${relation.id}" is missing a "to" target.`));
3053
+ } else if (!objectMap.has(relation.to)) {
3054
+ diagnostics.push(error("validate.relation.to.unknown", `Unknown relation target "${relation.to}" on "${relation.id}".`));
3055
+ }
3056
+ if (!relation.kind) {
3057
+ diagnostics.push(error("validate.relation.kind.required", `Relation "${relation.id}" is missing a "kind" value.`));
3058
+ }
3059
+ }
3060
+ function validateViewpointFilter(filter, groupIds, sourceSchemaVersion, diagnostics, viewpointId) {
3061
+ if (!filter || sourceSchemaVersion !== "2.1") {
3062
+ return;
3063
+ }
3064
+ for (const groupId of filter.groupIds) {
3065
+ if (!groupIds.has(groupId)) {
3066
+ diagnostics.push(warn("validate.viewpoint.group.unknown", `Unknown group "${groupId}" in viewpoint "${viewpointId}".`));
3067
+ }
3068
+ }
3069
+ }
3070
+ function validateObject(object, system, objectMap, groupIds, diagnostics) {
3071
+ const placement = object.placement;
3072
+ const orbitPlacement = placement?.mode === "orbit" ? placement : null;
3073
+ const parentObject = placement?.mode === "orbit" ? objectMap.get(placement.target) ?? null : null;
3074
+ if (object.groups) {
3075
+ for (const groupId of object.groups) {
3076
+ if (!groupIds.has(groupId)) {
3077
+ diagnostics.push(warn("validate.group.unknown", `Unknown group "${groupId}" on "${object.id}".`, object.id, "groups"));
3078
+ }
3079
+ }
3080
+ }
3081
+ if (orbitPlacement) {
3082
+ if (!objectMap.has(orbitPlacement.target)) {
3083
+ diagnostics.push(error("validate.orbit.target.unknown", `Unknown placement target "${orbitPlacement.target}" on "${object.id}".`, object.id, "orbit"));
3084
+ }
3085
+ if (orbitPlacement.distance && orbitPlacement.semiMajor) {
3086
+ diagnostics.push(error("validate.orbit.distanceConflict", `Object "${object.id}" cannot declare both "distance" and "semiMajor".`, object.id, "distance"));
3087
+ }
3088
+ if (orbitPlacement.phase && !object.epoch && !system?.epoch) {
3089
+ diagnostics.push(warn("validate.phase.epochMissing", `Object "${object.id}" sets "phase" without an object or system epoch.`, object.id, "phase"));
3090
+ }
3091
+ if (orbitPlacement.inclination && !object.referencePlane && !system?.referencePlane) {
3092
+ diagnostics.push(warn("validate.inclination.referencePlaneMissing", `Object "${object.id}" sets "inclination" without an object or system reference plane.`, object.id, "inclination"));
3093
+ }
3094
+ if (orbitPlacement.period && !massInSolar(parentObject?.properties.mass)) {
3095
+ diagnostics.push(warn("validate.period.massMissing", `Object "${object.id}" sets "period" but its central mass cannot be derived.`, object.id, "period"));
3096
+ }
3097
+ }
3098
+ if (placement?.mode === "surface") {
3099
+ const target = objectMap.get(placement.target);
3100
+ if (!target) {
3101
+ diagnostics.push(error("validate.surface.target.unknown", `Unknown placement target "${placement.target}" on "${object.id}".`, object.id, "surface"));
3102
+ } else if (!SURFACE_TARGET_TYPES2.has(target.type)) {
3103
+ diagnostics.push(error("validate.surface.target.invalid", `Surface target "${placement.target}" on "${object.id}" is not surface-capable.`, object.id, "surface"));
3104
+ }
3105
+ }
3106
+ if (placement?.mode === "at") {
3107
+ if (object.type !== "structure" && object.type !== "phenomenon") {
3108
+ diagnostics.push(error("validate.at.objectType", `Only structures and phenomena may use "at" placement; found "${object.type}" on "${object.id}".`, object.id, "at"));
3109
+ }
3110
+ if (!validateAtTarget(object, objectMap, diagnostics)) {
3111
+ diagnostics.push(error("validate.at.target.unknown", `Unknown at-reference target "${placement.target}" on "${object.id}".`, object.id, "at"));
3112
+ }
3113
+ }
3114
+ if (object.resonance) {
3115
+ const target = objectMap.get(object.resonance.targetObjectId);
3116
+ if (!target) {
3117
+ diagnostics.push(error("validate.resonance.target.unknown", `Unknown resonance target "${object.resonance.targetObjectId}" on "${object.id}".`, object.id, "resonance"));
3118
+ } else if (object.placement?.mode !== "orbit" || target.placement?.mode !== "orbit" || object.placement.target !== target.placement.target) {
3119
+ diagnostics.push(warn("validate.resonance.orbitMismatch", `Resonance target "${object.resonance.targetObjectId}" on "${object.id}" does not share a compatible orbital parent.`, object.id, "resonance"));
3120
+ }
3121
+ }
3122
+ for (const rule of object.deriveRules ?? []) {
3123
+ if (rule.field !== "period" || rule.strategy !== "kepler") {
3124
+ diagnostics.push(warn("validate.derive.unsupported", `Unsupported derive rule "${rule.field} ${rule.strategy}" on "${object.id}".`, object.id, "derive"));
3125
+ continue;
3126
+ }
3127
+ const derivedPeriodDays = keplerPeriodDays(object, parentObject);
3128
+ if (derivedPeriodDays === null) {
3129
+ diagnostics.push(warn("validate.derive.inputsMissing", `Object "${object.id}" requests "derive period kepler" but lacks enough input data.`, object.id, "derive"));
3130
+ continue;
3131
+ }
3132
+ if (!orbitPlacement?.period) {
3133
+ diagnostics.push(info("validate.derive.period.available", `Object "${object.id}" can derive a Kepler period of ${formatDays(derivedPeriodDays)}.`, object.id, "derive"));
3134
+ }
3135
+ }
3136
+ for (const rule of object.validationRules ?? []) {
3137
+ if (rule.rule !== "kepler") {
3138
+ diagnostics.push(warn("validate.rule.unsupported", `Unsupported validation rule "${rule.rule}" on "${object.id}".`, object.id, "validate"));
3139
+ continue;
3140
+ }
3141
+ const actualPeriodDays = durationInDays(orbitPlacement?.period);
3142
+ const derivedPeriodDays = keplerPeriodDays(object, parentObject);
3143
+ if (actualPeriodDays === null || derivedPeriodDays === null) {
3144
+ continue;
3145
+ }
3146
+ const toleranceDays = toleranceForField(object, "period");
3147
+ if (Math.abs(actualPeriodDays - derivedPeriodDays) > toleranceDays) {
3148
+ diagnostics.push(error("validate.kepler.mismatch", `Object "${object.id}" fails Kepler validation for "period".`, object.id, "validate"));
3149
+ }
3150
+ }
3151
+ }
3152
+ function validateAtTarget(object, objectMap, diagnostics) {
3153
+ const reference = object.placement?.mode === "at" ? object.placement.reference : null;
3154
+ if (!reference) {
3155
+ return true;
3156
+ }
3157
+ if (reference.kind === "named") {
3158
+ return objectMap.has(reference.name);
3159
+ }
3160
+ if (reference.kind === "anchor") {
3161
+ if (!objectMap.has(reference.objectId)) {
3162
+ diagnostics.push(error("validate.anchor.target.unknown", `Unknown anchor target "${reference.objectId}" on "${object.id}".`, object.id, "at"));
3163
+ return false;
3164
+ }
3165
+ return true;
3166
+ }
3167
+ if (!objectMap.has(reference.primary)) {
3168
+ diagnostics.push(error("validate.lagrange.primary.unknown", `Unknown Lagrange reference "${reference.primary}" on "${object.id}".`, object.id, "at"));
3169
+ return false;
3170
+ }
3171
+ if (reference.secondary && !objectMap.has(reference.secondary)) {
3172
+ diagnostics.push(error("validate.lagrange.secondary.unknown", `Unknown Lagrange reference "${reference.secondary}" on "${object.id}".`, object.id, "at"));
3173
+ return false;
3174
+ }
3175
+ return true;
3176
+ }
3177
+ function keplerPeriodDays(object, parentObject) {
3178
+ const placement = object.placement;
3179
+ if (!placement || placement.mode !== "orbit") {
3180
+ return null;
3181
+ }
3182
+ const semiMajorAu = distanceInAu(placement.semiMajor ?? placement.distance);
3183
+ const centralMassSolar = massInSolar(parentObject?.properties.mass);
3184
+ if (semiMajorAu === null || centralMassSolar === null || centralMassSolar <= 0) {
3185
+ return null;
3186
+ }
3187
+ const periodYears = Math.sqrt(semiMajorAu ** 3 / centralMassSolar);
3188
+ return periodYears * 365.25;
3189
+ }
3190
+ function distanceInAu(value) {
3191
+ if (!value)
3192
+ return null;
3193
+ switch (value.unit) {
3194
+ case null:
3195
+ case "au":
3196
+ return value.value;
3197
+ case "km":
3198
+ return value.value / AU_IN_KM2;
3199
+ case "m":
3200
+ return value.value / (AU_IN_KM2 * 1e3);
3201
+ case "ly":
3202
+ return value.value * LY_IN_AU2;
3203
+ case "pc":
3204
+ return value.value * PC_IN_AU2;
3205
+ case "kpc":
3206
+ return value.value * KPC_IN_AU2;
3207
+ case "re":
3208
+ return value.value * EARTH_RADIUS_IN_KM2 / AU_IN_KM2;
3209
+ case "sol":
3210
+ return value.value * SOLAR_RADIUS_IN_KM2 / AU_IN_KM2;
3211
+ default:
3212
+ return null;
3213
+ }
3214
+ }
3215
+ function massInSolar(value) {
3216
+ if (!value || typeof value !== "object" || !("value" in value)) {
3217
+ return null;
3218
+ }
3219
+ const unitValue = value;
3220
+ switch (unitValue.unit) {
3221
+ case null:
3222
+ case "sol":
3223
+ return unitValue.value;
3224
+ case "me":
3225
+ return unitValue.value / EARTH_MASSES_PER_SOLAR;
3226
+ case "mj":
3227
+ return unitValue.value / JUPITER_MASSES_PER_SOLAR;
3228
+ default:
3229
+ return null;
3230
+ }
3231
+ }
3232
+ function durationInDays(value) {
3233
+ if (!value)
3234
+ return null;
3235
+ switch (value.unit) {
3236
+ case null:
3237
+ case "d":
3238
+ return value.value;
3239
+ case "s":
3240
+ return value.value / 86400;
3241
+ case "min":
3242
+ return value.value / 1440;
3243
+ case "h":
3244
+ return value.value / 24;
3245
+ case "y":
3246
+ return value.value * 365.25;
3247
+ case "ky":
3248
+ return value.value * 365250;
3249
+ case "my":
3250
+ return value.value * 36525e4;
3251
+ case "gy":
3252
+ return value.value * 36525e7;
3253
+ default:
3254
+ return null;
3255
+ }
3256
+ }
3257
+ function toleranceForField(object, field) {
3258
+ const tolerance = object.tolerances?.find((entry) => entry.field === field)?.value;
3259
+ if (typeof tolerance === "number") {
3260
+ return tolerance;
3261
+ }
3262
+ if (tolerance && typeof tolerance === "object" && "value" in tolerance) {
3263
+ return durationInDays(tolerance) ?? 0;
3264
+ }
3265
+ return 0;
3266
+ }
3267
+ function formatDays(days) {
3268
+ return `${Math.round(days * 100) / 100}d`;
3269
+ }
3270
+ function error(code, message, objectId, field) {
3271
+ return { code, severity: "error", source: "validate", message, objectId, field };
3272
+ }
3273
+ function warn(code, message, objectId, field) {
3274
+ return { code, severity: "warning", source: "validate", message, objectId, field };
3275
+ }
3276
+ function info(code, message, objectId, field) {
3277
+ return { code, severity: "info", source: "validate", message, objectId, field };
3278
+ }
3279
+
2734
3280
  // packages/core/dist/draft-parse.js
3281
+ var STRUCTURED_TYPED_BLOCKS = /* @__PURE__ */ new Set([
3282
+ "climate",
3283
+ "habitability",
3284
+ "settlement"
3285
+ ]);
3286
+ var DRAFT_OBJECT_FIELD_SPECS = /* @__PURE__ */ new Map();
3287
+ for (const key of [
3288
+ "orbit",
3289
+ "distance",
3290
+ "semiMajor",
3291
+ "eccentricity",
3292
+ "period",
3293
+ "angle",
3294
+ "inclination",
3295
+ "phase",
3296
+ "at",
3297
+ "surface",
3298
+ "free",
3299
+ "kind",
3300
+ "class",
3301
+ "culture",
3302
+ "tags",
3303
+ "color",
3304
+ "image",
3305
+ "hidden",
3306
+ "radius",
3307
+ "mass",
3308
+ "density",
3309
+ "gravity",
3310
+ "temperature",
3311
+ "albedo",
3312
+ "atmosphere",
3313
+ "inner",
3314
+ "outer",
3315
+ "on",
3316
+ "source",
3317
+ "cycle"
3318
+ ]) {
3319
+ const schema = getFieldSchema(key);
3320
+ if (schema) {
3321
+ DRAFT_OBJECT_FIELD_SPECS.set(key, {
3322
+ key,
3323
+ version: "2.0",
3324
+ inlineMode: schema.arity === "multiple" ? "multiple" : "single",
3325
+ allowRepeat: false,
3326
+ legacySchema: schema
3327
+ });
3328
+ }
3329
+ }
3330
+ for (const spec of [
3331
+ { key: "groups", inlineMode: "multiple", allowRepeat: false },
3332
+ { key: "epoch", inlineMode: "single", allowRepeat: false },
3333
+ { key: "referencePlane", inlineMode: "single", allowRepeat: false },
3334
+ { key: "tidalLock", inlineMode: "single", allowRepeat: false },
3335
+ { key: "renderLabel", inlineMode: "single", allowRepeat: false },
3336
+ { key: "renderOrbit", inlineMode: "single", allowRepeat: false },
3337
+ { key: "renderPriority", inlineMode: "single", allowRepeat: false },
3338
+ { key: "resonance", inlineMode: "pair", allowRepeat: false },
3339
+ { key: "derive", inlineMode: "pair", allowRepeat: true },
3340
+ { key: "validate", inlineMode: "single", allowRepeat: true },
3341
+ { key: "locked", inlineMode: "multiple", allowRepeat: false },
3342
+ { key: "tolerance", inlineMode: "pair", allowRepeat: true }
3343
+ ]) {
3344
+ DRAFT_OBJECT_FIELD_SPECS.set(spec.key, {
3345
+ key: spec.key,
3346
+ version: "2.1",
3347
+ inlineMode: spec.inlineMode,
3348
+ allowRepeat: spec.allowRepeat
3349
+ });
3350
+ }
3351
+ var DRAFT_OBJECT_FIELD_KEYS = new Set(DRAFT_OBJECT_FIELD_SPECS.keys());
2735
3352
  function parseWorldOrbitAtlas(source) {
2736
- return parseAtlasSource(source, "2.0");
3353
+ return parseAtlasSource(source);
2737
3354
  }
2738
- function parseAtlasSource(source, outputVersion) {
2739
- const lines = source.split(/\r?\n/);
3355
+ function parseAtlasSource(source, forcedOutputVersion) {
3356
+ const prepared = preprocessAtlasSource(source);
3357
+ const lines = prepared.source.split(/\r?\n/);
3358
+ const diagnostics = [];
2740
3359
  let sawSchemaHeader = false;
2741
- let schemaVersion = "2.0";
3360
+ let sourceSchemaVersion = "2.0";
2742
3361
  let system = null;
2743
3362
  let section = null;
2744
3363
  const objectNodes = [];
3364
+ const groups = [];
3365
+ const relations = [];
2745
3366
  let sawDefaults = false;
2746
3367
  let sawAtlas = false;
2747
3368
  const viewpointIds = /* @__PURE__ */ new Set();
2748
3369
  const annotationIds = /* @__PURE__ */ new Set();
3370
+ const groupIds = /* @__PURE__ */ new Set();
3371
+ const relationIds = /* @__PURE__ */ new Set();
2749
3372
  for (let index = 0; index < lines.length; index++) {
2750
3373
  const rawLine = lines[index];
2751
3374
  const lineNumber = index + 1;
@@ -2761,15 +3384,22 @@
2761
3384
  continue;
2762
3385
  }
2763
3386
  if (!sawSchemaHeader) {
2764
- schemaVersion = assertDraftSchemaHeader(tokens, lineNumber);
3387
+ sourceSchemaVersion = assertDraftSchemaHeader(tokens, lineNumber);
2765
3388
  sawSchemaHeader = true;
3389
+ if (prepared.comments.length > 0 && sourceSchemaVersion !== "2.1") {
3390
+ diagnostics.push({
3391
+ code: "parse.schema21.commentCompatibility",
3392
+ severity: "warning",
3393
+ source: "parse",
3394
+ message: `Comments require schema 2.1; parsed in compatibility mode because the document header is "schema ${sourceSchemaVersion}".`,
3395
+ line: prepared.comments[0].line,
3396
+ column: prepared.comments[0].column
3397
+ });
3398
+ }
2766
3399
  continue;
2767
3400
  }
2768
3401
  if (indent === 0) {
2769
- section = startTopLevelSection(tokens, lineNumber, system, objectNodes, viewpointIds, annotationIds, {
2770
- sawDefaults,
2771
- sawAtlas
2772
- });
3402
+ section = startTopLevelSection(tokens, lineNumber, sourceSchemaVersion, diagnostics, system, objectNodes, groups, relations, viewpointIds, annotationIds, groupIds, relationIds, { sawDefaults, sawAtlas });
2773
3403
  if (section.kind === "system") {
2774
3404
  system = section.system;
2775
3405
  } else if (section.kind === "defaults") {
@@ -2787,48 +3417,57 @@
2787
3417
  if (!sawSchemaHeader) {
2788
3418
  throw new WorldOrbitError('Missing required atlas schema header "schema 2.0"');
2789
3419
  }
2790
- const ast = {
2791
- type: "document",
2792
- objects: objectNodes
2793
- };
2794
- const normalizedObjects = normalizeDocument(ast).objects;
2795
- validateDocument({
2796
- format: "worldorbit",
2797
- version: "1.0",
2798
- system: null,
2799
- objects: normalizedObjects
2800
- });
2801
- const diagnostics = schemaVersion === "2.0-draft" && outputVersion === "2.0" ? [
2802
- {
2803
- code: "load.schema.deprecatedDraft",
2804
- severity: "warning",
2805
- source: "upgrade",
2806
- message: 'Source header "schema 2.0-draft" is deprecated; canonical v2 documents now use "schema 2.0".'
2807
- }
2808
- ] : [];
2809
- return {
3420
+ const objects = objectNodes.map((node) => normalizeDraftObject(node, sourceSchemaVersion, diagnostics));
3421
+ const outputVersion = forcedOutputVersion ?? (sourceSchemaVersion === "2.0-draft" ? "2.0" : sourceSchemaVersion);
3422
+ const baseDocument = {
2810
3423
  format: "worldorbit",
2811
- version: outputVersion,
2812
3424
  sourceVersion: "1.0",
2813
3425
  system,
2814
- objects: normalizedObjects,
3426
+ groups,
3427
+ relations,
3428
+ objects,
2815
3429
  diagnostics
2816
3430
  };
3431
+ if (outputVersion === "2.0-draft") {
3432
+ const document3 = {
3433
+ ...baseDocument,
3434
+ version: "2.0-draft",
3435
+ schemaVersion: "2.0-draft"
3436
+ };
3437
+ document3.diagnostics.push(...collectAtlasDiagnostics(document3, sourceSchemaVersion));
3438
+ return document3;
3439
+ }
3440
+ const document2 = {
3441
+ ...baseDocument,
3442
+ version: outputVersion,
3443
+ schemaVersion: outputVersion
3444
+ };
3445
+ if (sourceSchemaVersion === "2.0-draft") {
3446
+ document2.diagnostics.push({
3447
+ code: "load.schema.deprecatedDraft",
3448
+ severity: "warning",
3449
+ source: "upgrade",
3450
+ message: 'Source header "schema 2.0-draft" is deprecated; canonical v2 documents now use "schema 2.0".'
3451
+ });
3452
+ }
3453
+ document2.diagnostics.push(...collectAtlasDiagnostics(document2, sourceSchemaVersion));
3454
+ return document2;
2817
3455
  }
2818
3456
  function assertDraftSchemaHeader(tokens, line) {
2819
- if (tokens.length !== 2 || tokens[0].value.toLowerCase() !== "schema" || tokens[1].value.toLowerCase() !== "2.0-draft" && tokens[1].value.toLowerCase() !== "2.0") {
2820
- throw new WorldOrbitError('Expected atlas header "schema 2.0" or legacy "schema 2.0-draft"', line, tokens[0]?.column ?? 1);
3457
+ if (tokens.length !== 2 || tokens[0].value.toLowerCase() !== "schema" || !["2.0-draft", "2.0", "2.1"].includes(tokens[1].value.toLowerCase())) {
3458
+ throw new WorldOrbitError('Expected atlas header "schema 2.0", "schema 2.1", or legacy "schema 2.0-draft"', line, tokens[0]?.column ?? 1);
2821
3459
  }
2822
- return tokens[1].value.toLowerCase() === "2.0-draft" ? "2.0-draft" : "2.0";
3460
+ const version = tokens[1].value.toLowerCase();
3461
+ return version === "2.1" ? "2.1" : version === "2.0-draft" ? "2.0-draft" : "2.0";
2823
3462
  }
2824
- function startTopLevelSection(tokens, line, system, objectNodes, viewpointIds, annotationIds, flags) {
3463
+ function startTopLevelSection(tokens, line, sourceSchemaVersion, diagnostics, system, objectNodes, groups, relations, viewpointIds, annotationIds, groupIds, relationIds, flags) {
2825
3464
  const keyword = tokens[0]?.value.toLowerCase();
2826
3465
  switch (keyword) {
2827
3466
  case "system":
2828
3467
  if (system) {
2829
3468
  throw new WorldOrbitError('Atlas section "system" may only appear once', line, tokens[0].column);
2830
3469
  }
2831
- return startSystemSection(tokens, line);
3470
+ return startSystemSection(tokens, line, sourceSchemaVersion, diagnostics);
2832
3471
  case "defaults":
2833
3472
  if (!system) {
2834
3473
  throw new WorldOrbitError('Atlas section "defaults" requires a preceding system declaration', line, tokens[0].column);
@@ -2864,13 +3503,19 @@
2864
3503
  throw new WorldOrbitError('Atlas section "annotation" requires a preceding system declaration', line, tokens[0].column);
2865
3504
  }
2866
3505
  return startAnnotationSection(tokens, line, system, annotationIds);
3506
+ case "group":
3507
+ warnIfSchema21Feature(sourceSchemaVersion, diagnostics, "group", { line, column: tokens[0].column });
3508
+ return startGroupSection(tokens, line, groups, groupIds);
3509
+ case "relation":
3510
+ warnIfSchema21Feature(sourceSchemaVersion, diagnostics, "relation", { line, column: tokens[0].column });
3511
+ return startRelationSection(tokens, line, relations, relationIds);
2867
3512
  case "object":
2868
- return startObjectSection(tokens, line, objectNodes);
3513
+ return startObjectSection(tokens, line, sourceSchemaVersion, diagnostics, objectNodes);
2869
3514
  default:
2870
3515
  throw new WorldOrbitError(`Unknown atlas section "${tokens[0]?.value ?? ""}"`, line, tokens[0]?.column ?? 1);
2871
3516
  }
2872
3517
  }
2873
- function startSystemSection(tokens, line) {
3518
+ function startSystemSection(tokens, line, sourceSchemaVersion, diagnostics) {
2874
3519
  if (tokens.length !== 2) {
2875
3520
  throw new WorldOrbitError("Invalid atlas system declaration", line, tokens[0]?.column ?? 1);
2876
3521
  }
@@ -2878,6 +3523,9 @@
2878
3523
  type: "system",
2879
3524
  id: tokens[1].value,
2880
3525
  title: null,
3526
+ description: null,
3527
+ epoch: null,
3528
+ referencePlane: null,
2881
3529
  defaults: {
2882
3530
  view: "topdown",
2883
3531
  scale: null,
@@ -2892,6 +3540,8 @@
2892
3540
  return {
2893
3541
  kind: "system",
2894
3542
  system,
3543
+ sourceSchemaVersion,
3544
+ diagnostics,
2895
3545
  seenFields: /* @__PURE__ */ new Set()
2896
3546
  };
2897
3547
  }
@@ -2957,7 +3607,64 @@
2957
3607
  seenFields: /* @__PURE__ */ new Set()
2958
3608
  };
2959
3609
  }
2960
- function startObjectSection(tokens, line, objectNodes) {
3610
+ function startGroupSection(tokens, line, groups, groupIds) {
3611
+ if (tokens.length !== 2) {
3612
+ throw new WorldOrbitError("Invalid group declaration", line, tokens[0]?.column ?? 1);
3613
+ }
3614
+ const id = normalizeIdentifier(tokens[1].value);
3615
+ if (!id) {
3616
+ throw new WorldOrbitError("Group id must not be empty", line, tokens[1].column);
3617
+ }
3618
+ if (groupIds.has(id)) {
3619
+ throw new WorldOrbitError(`Duplicate group id "${id}"`, line, tokens[1].column);
3620
+ }
3621
+ const group = {
3622
+ id,
3623
+ label: humanizeIdentifier2(id),
3624
+ summary: "",
3625
+ color: null,
3626
+ tags: [],
3627
+ hidden: false
3628
+ };
3629
+ groups.push(group);
3630
+ groupIds.add(id);
3631
+ return {
3632
+ kind: "group",
3633
+ group,
3634
+ seenFields: /* @__PURE__ */ new Set()
3635
+ };
3636
+ }
3637
+ function startRelationSection(tokens, line, relations, relationIds) {
3638
+ if (tokens.length !== 2) {
3639
+ throw new WorldOrbitError("Invalid relation declaration", line, tokens[0]?.column ?? 1);
3640
+ }
3641
+ const id = normalizeIdentifier(tokens[1].value);
3642
+ if (!id) {
3643
+ throw new WorldOrbitError("Relation id must not be empty", line, tokens[1].column);
3644
+ }
3645
+ if (relationIds.has(id)) {
3646
+ throw new WorldOrbitError(`Duplicate relation id "${id}"`, line, tokens[1].column);
3647
+ }
3648
+ const relation = {
3649
+ id,
3650
+ from: "",
3651
+ to: "",
3652
+ kind: "",
3653
+ label: null,
3654
+ summary: null,
3655
+ tags: [],
3656
+ color: null,
3657
+ hidden: false
3658
+ };
3659
+ relations.push(relation);
3660
+ relationIds.add(id);
3661
+ return {
3662
+ kind: "relation",
3663
+ relation,
3664
+ seenFields: /* @__PURE__ */ new Set()
3665
+ };
3666
+ }
3667
+ function startObjectSection(tokens, line, sourceSchemaVersion, diagnostics, objectNodes) {
2961
3668
  if (tokens.length < 3) {
2962
3669
  throw new WorldOrbitError("Invalid atlas object declaration", line, tokens[0]?.column ?? 1);
2963
3670
  }
@@ -2968,12 +3675,11 @@
2968
3675
  throw new WorldOrbitError(`Unknown object type "${objectTypeToken.value}"`, line, objectTypeToken.column);
2969
3676
  }
2970
3677
  const objectNode = {
2971
- type: "object",
2972
3678
  objectType,
2973
- name: idToken.value,
2974
- inlineFields: parseInlineFields2(tokens.slice(3), line),
2975
- blockFields: [],
3679
+ id: idToken.value,
3680
+ fields: parseInlineObjectFields(tokens.slice(3), line, objectType, sourceSchemaVersion, diagnostics),
2976
3681
  infoEntries: [],
3682
+ typedBlockEntries: {},
2977
3683
  location: {
2978
3684
  line,
2979
3685
  column: objectTypeToken.column
@@ -2983,8 +3689,12 @@
2983
3689
  return {
2984
3690
  kind: "object",
2985
3691
  objectNode,
2986
- inInfoBlock: false,
2987
- infoIndent: null
3692
+ sourceSchemaVersion,
3693
+ diagnostics,
3694
+ activeBlock: null,
3695
+ blockIndent: null,
3696
+ seenInfoKeys: /* @__PURE__ */ new Set(),
3697
+ seenTypedBlockKeys: {}
2988
3698
  };
2989
3699
  }
2990
3700
  function handleSectionLine(section, indent, tokens, line) {
@@ -3004,6 +3714,12 @@
3004
3714
  case "annotation":
3005
3715
  applyAnnotationField(section, tokens, line);
3006
3716
  return;
3717
+ case "group":
3718
+ applyGroupField(section, tokens, line);
3719
+ return;
3720
+ case "relation":
3721
+ applyRelationField(section, tokens, line);
3722
+ return;
3007
3723
  case "object":
3008
3724
  applyObjectField(section, indent, tokens, line);
3009
3725
  return;
@@ -3011,10 +3727,35 @@
3011
3727
  }
3012
3728
  function applySystemField(section, tokens, line) {
3013
3729
  const key = requireUniqueField(tokens, section.seenFields, line);
3014
- if (key !== "title") {
3015
- throw new WorldOrbitError(`Unknown system atlas field "${tokens[0].value}"`, line, tokens[0].column);
3730
+ const value = joinFieldValue(tokens, line);
3731
+ switch (key) {
3732
+ case "title":
3733
+ section.system.title = value;
3734
+ return;
3735
+ case "description":
3736
+ warnIfSchema21Feature(section.sourceSchemaVersion, section.diagnostics, key, {
3737
+ line,
3738
+ column: tokens[0].column
3739
+ });
3740
+ section.system.description = value;
3741
+ return;
3742
+ case "epoch":
3743
+ warnIfSchema21Feature(section.sourceSchemaVersion, section.diagnostics, key, {
3744
+ line,
3745
+ column: tokens[0].column
3746
+ });
3747
+ section.system.epoch = value;
3748
+ return;
3749
+ case "referenceplane":
3750
+ warnIfSchema21Feature(section.sourceSchemaVersion, section.diagnostics, "referencePlane", {
3751
+ line,
3752
+ column: tokens[0].column
3753
+ });
3754
+ section.system.referencePlane = value;
3755
+ return;
3756
+ default:
3757
+ throw new WorldOrbitError(`Unknown system atlas field "${tokens[0].value}"`, line, tokens[0].column);
3016
3758
  }
3017
- section.system.title = joinFieldValue(tokens, line);
3018
3759
  }
3019
3760
  function applyDefaultsField(section, tokens, line) {
3020
3761
  const key = requireUniqueField(tokens, section.seenFields, line);
@@ -3045,14 +3786,11 @@
3045
3786
  section.metadataIndent = null;
3046
3787
  }
3047
3788
  if (section.inMetadata) {
3048
- if (tokens.length < 2) {
3049
- throw new WorldOrbitError("Invalid atlas metadata entry", line, tokens[0]?.column ?? 1);
3789
+ const entry = parseInfoLikeEntry(tokens, line, "Invalid atlas metadata entry");
3790
+ if (entry.key in section.system.atlasMetadata) {
3791
+ throw new WorldOrbitError(`Duplicate atlas metadata key "${entry.key}"`, line, tokens[0].column);
3050
3792
  }
3051
- const key = tokens[0].value;
3052
- if (key in section.system.atlasMetadata) {
3053
- throw new WorldOrbitError(`Duplicate atlas metadata key "${key}"`, line, tokens[0].column);
3054
- }
3055
- section.system.atlasMetadata[key] = joinFieldValue(tokens, line);
3793
+ section.system.atlasMetadata[entry.key] = entry.value;
3056
3794
  return;
3057
3795
  }
3058
3796
  if (tokens.length === 1 && tokens[0].value.toLowerCase() === "metadata") {
@@ -3131,44 +3869,125 @@
3131
3869
  filter.groupIds = parseTokenList(tokens.slice(1), line, "groups");
3132
3870
  break;
3133
3871
  default:
3134
- throw new WorldOrbitError(`Unknown viewpoint filter field "${tokens[0].value}"`, line, tokens[0].column);
3872
+ throw new WorldOrbitError(`Unknown viewpoint filter field "${tokens[0].value}"`, line, tokens[0].column);
3873
+ }
3874
+ section.viewpoint.filter = filter;
3875
+ }
3876
+ function applyAnnotationField(section, tokens, line) {
3877
+ const key = requireUniqueField(tokens, section.seenFields, line);
3878
+ switch (key) {
3879
+ case "label":
3880
+ section.annotation.label = joinFieldValue(tokens, line);
3881
+ return;
3882
+ case "target":
3883
+ section.annotation.targetObjectId = joinFieldValue(tokens, line);
3884
+ return;
3885
+ case "body":
3886
+ section.annotation.body = joinFieldValue(tokens, line);
3887
+ return;
3888
+ case "tags":
3889
+ section.annotation.tags = parseTokenList(tokens.slice(1), line, "tags");
3890
+ return;
3891
+ default:
3892
+ throw new WorldOrbitError(`Unknown annotation field "${tokens[0].value}"`, line, tokens[0].column);
3893
+ }
3894
+ }
3895
+ function applyGroupField(section, tokens, line) {
3896
+ const key = requireUniqueField(tokens, section.seenFields, line);
3897
+ switch (key) {
3898
+ case "label":
3899
+ section.group.label = joinFieldValue(tokens, line);
3900
+ return;
3901
+ case "summary":
3902
+ section.group.summary = joinFieldValue(tokens, line);
3903
+ return;
3904
+ case "color":
3905
+ section.group.color = joinFieldValue(tokens, line);
3906
+ return;
3907
+ case "tags":
3908
+ section.group.tags = parseTokenList(tokens.slice(1), line, "tags");
3909
+ return;
3910
+ case "hidden":
3911
+ section.group.hidden = parseAtlasBoolean(joinFieldValue(tokens, line), "hidden", {
3912
+ line,
3913
+ column: tokens[0].column
3914
+ });
3915
+ return;
3916
+ default:
3917
+ throw new WorldOrbitError(`Unknown group field "${tokens[0].value}"`, line, tokens[0].column);
3135
3918
  }
3136
- section.viewpoint.filter = filter;
3137
3919
  }
3138
- function applyAnnotationField(section, tokens, line) {
3920
+ function applyRelationField(section, tokens, line) {
3139
3921
  const key = requireUniqueField(tokens, section.seenFields, line);
3140
3922
  switch (key) {
3141
- case "label":
3142
- section.annotation.label = joinFieldValue(tokens, line);
3923
+ case "from":
3924
+ section.relation.from = joinFieldValue(tokens, line);
3143
3925
  return;
3144
- case "target":
3145
- section.annotation.targetObjectId = joinFieldValue(tokens, line);
3926
+ case "to":
3927
+ section.relation.to = joinFieldValue(tokens, line);
3146
3928
  return;
3147
- case "body":
3148
- section.annotation.body = joinFieldValue(tokens, line);
3929
+ case "kind":
3930
+ section.relation.kind = joinFieldValue(tokens, line);
3931
+ return;
3932
+ case "label":
3933
+ section.relation.label = joinFieldValue(tokens, line);
3934
+ return;
3935
+ case "summary":
3936
+ section.relation.summary = joinFieldValue(tokens, line);
3149
3937
  return;
3150
3938
  case "tags":
3151
- section.annotation.tags = parseTokenList(tokens.slice(1), line, "tags");
3939
+ section.relation.tags = parseTokenList(tokens.slice(1), line, "tags");
3940
+ return;
3941
+ case "color":
3942
+ section.relation.color = joinFieldValue(tokens, line);
3943
+ return;
3944
+ case "hidden":
3945
+ section.relation.hidden = parseAtlasBoolean(joinFieldValue(tokens, line), "hidden", {
3946
+ line,
3947
+ column: tokens[0].column
3948
+ });
3152
3949
  return;
3153
3950
  default:
3154
- throw new WorldOrbitError(`Unknown annotation field "${tokens[0].value}"`, line, tokens[0].column);
3951
+ throw new WorldOrbitError(`Unknown relation field "${tokens[0].value}"`, line, tokens[0].column);
3155
3952
  }
3156
3953
  }
3157
3954
  function applyObjectField(section, indent, tokens, line) {
3158
- if (tokens.length === 1 && tokens[0].value === "info") {
3159
- section.inInfoBlock = true;
3160
- section.infoIndent = indent;
3161
- return;
3162
- }
3163
- if (section.inInfoBlock && indent <= (section.infoIndent ?? 0)) {
3164
- section.inInfoBlock = false;
3165
- section.infoIndent = null;
3955
+ if (section.activeBlock && indent <= (section.blockIndent ?? 0)) {
3956
+ section.activeBlock = null;
3957
+ section.blockIndent = null;
3958
+ }
3959
+ if (tokens.length === 1) {
3960
+ const blockName = tokens[0].value.toLowerCase();
3961
+ if (blockName === "info" || STRUCTURED_TYPED_BLOCKS.has(blockName)) {
3962
+ if (blockName !== "info") {
3963
+ warnIfSchema21Feature(section.sourceSchemaVersion, section.diagnostics, blockName, { line, column: tokens[0].column });
3964
+ }
3965
+ section.activeBlock = blockName;
3966
+ section.blockIndent = indent;
3967
+ return;
3968
+ }
3166
3969
  }
3167
- if (section.inInfoBlock) {
3168
- section.objectNode.infoEntries.push(parseInfoEntry2(tokens, line));
3970
+ if (section.activeBlock) {
3971
+ const entry = parseInfoLikeEntry(tokens, line, `Invalid ${section.activeBlock} entry`);
3972
+ if (section.activeBlock === "info") {
3973
+ if (section.seenInfoKeys.has(entry.key)) {
3974
+ throw new WorldOrbitError(`Duplicate info key "${entry.key}"`, line, tokens[0].column);
3975
+ }
3976
+ section.seenInfoKeys.add(entry.key);
3977
+ section.objectNode.infoEntries.push(entry);
3978
+ return;
3979
+ }
3980
+ const typedBlock = section.activeBlock;
3981
+ const seenKeys = section.seenTypedBlockKeys[typedBlock] ?? (section.seenTypedBlockKeys[typedBlock] = /* @__PURE__ */ new Set());
3982
+ if (seenKeys.has(entry.key)) {
3983
+ throw new WorldOrbitError(`Duplicate ${typedBlock} key "${entry.key}"`, line, tokens[0].column);
3984
+ }
3985
+ seenKeys.add(entry.key);
3986
+ const entries = section.objectNode.typedBlockEntries[typedBlock] ?? (section.objectNode.typedBlockEntries[typedBlock] = []);
3987
+ entries.push(entry);
3169
3988
  return;
3170
3989
  }
3171
- section.objectNode.blockFields.push(parseField2(tokens, line));
3990
+ section.objectNode.fields.push(parseObjectField(tokens, line, section.objectNode.objectType, section.sourceSchemaVersion, section.diagnostics));
3172
3991
  }
3173
3992
  function requireUniqueField(tokens, seenFields, line) {
3174
3993
  if (tokens.length < 2) {
@@ -3188,50 +4007,40 @@
3188
4007
  return tokens.slice(1).map((token) => token.value).join(" ").trim();
3189
4008
  }
3190
4009
  function parseObjectTypeTokens(tokens, line) {
3191
- if (tokens.length === 0) {
3192
- throw new WorldOrbitError("Missing value for atlas field", line);
3193
- }
3194
- return tokens.map((token) => {
3195
- const value = token.value;
3196
- if (value !== "star" && value !== "planet" && value !== "moon" && value !== "belt" && value !== "asteroid" && value !== "comet" && value !== "ring" && value !== "structure" && value !== "phenomenon") {
3197
- throw new WorldOrbitError(`Unknown viewpoint object type "${token.value}"`, line, token.column);
3198
- }
3199
- return value;
3200
- });
3201
- }
3202
- function parseTokenList(tokens, line, field) {
3203
- if (tokens.length === 0) {
3204
- throw new WorldOrbitError(`Missing value for field "${field}"`, line);
3205
- }
3206
- return tokens.map((token) => token.value);
4010
+ return parseTokenList(tokens, line, "objectTypes").filter((value) => value === "star" || value === "planet" || value === "moon" || value === "belt" || value === "asteroid" || value === "comet" || value === "ring" || value === "structure" || value === "phenomenon");
3207
4011
  }
3208
4012
  function parseLayerTokens(tokens, line) {
3209
- if (tokens.length === 0) {
3210
- throw new WorldOrbitError('Missing value for field "layers"', line);
3211
- }
3212
- const next = {};
3213
- for (const token of tokens) {
3214
- const enabled = !token.value.startsWith("-") && !token.value.startsWith("!");
3215
- const rawLayer = token.value.replace(/^[-!]+/, "").toLowerCase();
3216
- if (rawLayer === "orbits") {
3217
- next["orbits-back"] = enabled;
3218
- next["orbits-front"] = enabled;
4013
+ const layers = {};
4014
+ for (const token of parseTokenList(tokens, line, "layers")) {
4015
+ const enabled = !token.startsWith("-") && !token.startsWith("!");
4016
+ const raw = token.replace(/^[-!]+/, "").toLowerCase();
4017
+ if (raw === "orbits") {
4018
+ layers["orbits-back"] = enabled;
4019
+ layers["orbits-front"] = enabled;
3219
4020
  continue;
3220
4021
  }
3221
- if (rawLayer === "background" || rawLayer === "guides" || rawLayer === "orbits-back" || rawLayer === "orbits-front" || rawLayer === "objects" || rawLayer === "labels" || rawLayer === "metadata") {
3222
- next[rawLayer] = enabled;
3223
- continue;
4022
+ if (raw === "background" || raw === "guides" || raw === "orbits-back" || raw === "orbits-front" || raw === "relations" || raw === "objects" || raw === "labels" || raw === "metadata") {
4023
+ layers[raw] = enabled;
3224
4024
  }
3225
- throw new WorldOrbitError(`Unknown layer token "${token.value}"`, line, token.column);
3226
4025
  }
3227
- return next;
4026
+ return layers;
4027
+ }
4028
+ function parseTokenList(tokens, line, fieldName) {
4029
+ if (tokens.length === 0) {
4030
+ throw new WorldOrbitError(`Missing value for atlas field "${fieldName}"`, line, 1);
4031
+ }
4032
+ const values = tokens.map((token) => token.value).filter(Boolean);
4033
+ if (values.length === 0) {
4034
+ throw new WorldOrbitError(`Missing value for atlas field "${fieldName}"`, line, tokens[0]?.column ?? 1);
4035
+ }
4036
+ return values;
3228
4037
  }
3229
4038
  function parseProjectionValue(value, line, column) {
3230
4039
  const normalized = value.toLowerCase();
3231
- if (normalized === "topdown" || normalized === "isometric") {
3232
- return normalized;
4040
+ if (normalized !== "topdown" && normalized !== "isometric") {
4041
+ throw new WorldOrbitError(`Unknown projection "${value}"`, line, column);
3233
4042
  }
3234
- throw new WorldOrbitError(`Unknown projection "${value}"`, line, column);
4043
+ return normalized;
3235
4044
  }
3236
4045
  function parsePresetValue(value, line, column) {
3237
4046
  const normalized = value.toLowerCase();
@@ -3241,16 +4050,16 @@
3241
4050
  throw new WorldOrbitError(`Unknown render preset "${value}"`, line, column);
3242
4051
  }
3243
4052
  function parsePositiveNumber2(value, line, column, field) {
3244
- const parsed = Number(value);
3245
- if (!Number.isFinite(parsed) || parsed <= 0) {
3246
- throw new WorldOrbitError(`Field "${field}" expects a positive number`, line, column);
4053
+ const parsed = parseFiniteNumber2(value, line, column, field);
4054
+ if (parsed <= 0) {
4055
+ throw new WorldOrbitError(`Field "${field}" must be greater than zero`, line, column);
3247
4056
  }
3248
4057
  return parsed;
3249
4058
  }
3250
4059
  function parseFiniteNumber2(value, line, column, field) {
3251
4060
  const parsed = Number(value);
3252
4061
  if (!Number.isFinite(parsed)) {
3253
- throw new WorldOrbitError(`Field "${field}" expects a finite number`, line, column);
4062
+ throw new WorldOrbitError(`Invalid numeric value "${value}" for "${field}"`, line, column);
3254
4063
  }
3255
4064
  return parsed;
3256
4065
  }
@@ -3262,28 +4071,43 @@
3262
4071
  groupIds: []
3263
4072
  };
3264
4073
  }
3265
- function parseInlineFields2(tokens, line) {
4074
+ function parseInlineObjectFields(tokens, line, objectType, sourceSchemaVersion, diagnostics) {
3266
4075
  const fields = [];
3267
4076
  let index = 0;
3268
4077
  while (index < tokens.length) {
3269
4078
  const keyToken = tokens[index];
3270
- const schema = getFieldSchema(keyToken.value);
3271
- if (!schema) {
4079
+ const spec = getDraftObjectFieldSpec(keyToken.value);
4080
+ if (!spec) {
3272
4081
  throw new WorldOrbitError(`Unknown field "${keyToken.value}"`, line, keyToken.column);
3273
4082
  }
4083
+ if (spec.version === "2.1") {
4084
+ warnIfSchema21Feature(sourceSchemaVersion, diagnostics, keyToken.value, {
4085
+ line,
4086
+ column: keyToken.column
4087
+ });
4088
+ }
3274
4089
  index++;
3275
4090
  const valueTokens = [];
3276
- if (schema.arity === "multiple") {
3277
- while (index < tokens.length && !isKnownFieldKey(tokens[index].value)) {
3278
- valueTokens.push(tokens[index]);
3279
- index++;
3280
- }
3281
- } else {
4091
+ if (spec.inlineMode === "single") {
3282
4092
  const nextToken = tokens[index];
3283
4093
  if (nextToken) {
3284
4094
  valueTokens.push(nextToken);
3285
4095
  index++;
3286
4096
  }
4097
+ } else if (spec.inlineMode === "pair") {
4098
+ for (let count = 0; count < 2; count++) {
4099
+ const nextToken = tokens[index];
4100
+ if (!nextToken) {
4101
+ break;
4102
+ }
4103
+ valueTokens.push(nextToken);
4104
+ index++;
4105
+ }
4106
+ } else {
4107
+ while (index < tokens.length && !DRAFT_OBJECT_FIELD_KEYS.has(tokens[index].value)) {
4108
+ valueTokens.push(tokens[index]);
4109
+ index++;
4110
+ }
3287
4111
  }
3288
4112
  if (valueTokens.length === 0) {
3289
4113
  throw new WorldOrbitError(`Missing value for field "${keyToken.value}"`, line, keyToken.column);
@@ -3295,25 +4119,35 @@
3295
4119
  location: { line, column: keyToken.column }
3296
4120
  });
3297
4121
  }
4122
+ validateDraftObjectFieldCompatibility(fields, objectType);
3298
4123
  return fields;
3299
4124
  }
3300
- function parseField2(tokens, line) {
4125
+ function parseObjectField(tokens, line, objectType, sourceSchemaVersion, diagnostics) {
3301
4126
  if (tokens.length < 2) {
3302
4127
  throw new WorldOrbitError("Invalid field line", line, tokens[0]?.column ?? 1);
3303
4128
  }
3304
- if (!getFieldSchema(tokens[0].value)) {
4129
+ const spec = getDraftObjectFieldSpec(tokens[0].value);
4130
+ if (!spec) {
3305
4131
  throw new WorldOrbitError(`Unknown field "${tokens[0].value}"`, line, tokens[0].column);
3306
4132
  }
3307
- return {
4133
+ if (spec.version === "2.1") {
4134
+ warnIfSchema21Feature(sourceSchemaVersion, diagnostics, tokens[0].value, {
4135
+ line,
4136
+ column: tokens[0].column
4137
+ });
4138
+ }
4139
+ const field = {
3308
4140
  type: "field",
3309
4141
  key: tokens[0].value,
3310
4142
  values: tokens.slice(1).map((token) => token.value),
3311
4143
  location: { line, column: tokens[0].column }
3312
4144
  };
4145
+ validateDraftObjectFieldCompatibility([field], objectType);
4146
+ return field;
3313
4147
  }
3314
- function parseInfoEntry2(tokens, line) {
4148
+ function parseInfoLikeEntry(tokens, line, errorMessage) {
3315
4149
  if (tokens.length < 2) {
3316
- throw new WorldOrbitError("Invalid info entry", line, tokens[0]?.column ?? 1);
4150
+ throw new WorldOrbitError(errorMessage, line, tokens[0]?.column ?? 1);
3317
4151
  }
3318
4152
  return {
3319
4153
  type: "info-entry",
@@ -3322,18 +4156,348 @@
3322
4156
  location: { line, column: tokens[0].column }
3323
4157
  };
3324
4158
  }
3325
- function normalizeIdentifier(value) {
3326
- return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
4159
+ function normalizeDraftObject(node, sourceSchemaVersion, diagnostics) {
4160
+ const fieldMap = collectDraftFields(node.fields);
4161
+ const placement = extractDraftPlacement(node.objectType, fieldMap);
4162
+ const properties = normalizeDraftProperties(node.objectType, fieldMap);
4163
+ const groups = parseOptionalTokenList(fieldMap.get("groups")?.[0]);
4164
+ const epoch = parseOptionalJoinedValue(fieldMap.get("epoch")?.[0]);
4165
+ const referencePlane = parseOptionalJoinedValue(fieldMap.get("referencePlane")?.[0]);
4166
+ const tidalLock = fieldMap.has("tidalLock") ? parseAtlasBoolean(singleFieldValue2(fieldMap.get("tidalLock")[0]), "tidalLock", fieldMap.get("tidalLock")[0].location) : void 0;
4167
+ const resonance = fieldMap.has("resonance") ? parseResonanceField(fieldMap.get("resonance")[0]) : void 0;
4168
+ const renderHints = extractRenderHints(fieldMap);
4169
+ const deriveRules = fieldMap.get("derive")?.map((field) => parseDeriveField(field));
4170
+ const validationRules = fieldMap.get("validate")?.map((field) => ({
4171
+ rule: singleFieldValue2(field)
4172
+ }));
4173
+ const lockedFields = fieldMap.has("locked") ? [...new Set(fieldMap.get("locked").flatMap((field) => field.values))] : void 0;
4174
+ const tolerances = fieldMap.get("tolerance")?.map((field) => parseToleranceField(field));
4175
+ const typedBlocks = normalizeTypedBlocks(node.typedBlockEntries);
4176
+ const info2 = normalizeInfoEntries(node.infoEntries, "info");
4177
+ const object = {
4178
+ type: node.objectType,
4179
+ id: node.id,
4180
+ properties,
4181
+ placement,
4182
+ info: info2
4183
+ };
4184
+ if (groups.length > 0)
4185
+ object.groups = groups;
4186
+ if (epoch)
4187
+ object.epoch = epoch;
4188
+ if (referencePlane)
4189
+ object.referencePlane = referencePlane;
4190
+ if (tidalLock !== void 0)
4191
+ object.tidalLock = tidalLock;
4192
+ if (resonance)
4193
+ object.resonance = resonance;
4194
+ if (renderHints)
4195
+ object.renderHints = renderHints;
4196
+ if (deriveRules?.length)
4197
+ object.deriveRules = deriveRules;
4198
+ if (validationRules?.length)
4199
+ object.validationRules = validationRules;
4200
+ if (lockedFields?.length)
4201
+ object.lockedFields = lockedFields;
4202
+ if (tolerances?.length)
4203
+ object.tolerances = tolerances;
4204
+ if (typedBlocks && Object.keys(typedBlocks).length > 0)
4205
+ object.typedBlocks = typedBlocks;
4206
+ if (sourceSchemaVersion !== "2.1") {
4207
+ if (object.groups || object.epoch || object.referencePlane || object.tidalLock !== void 0 || object.resonance || object.renderHints || object.deriveRules?.length || object.validationRules?.length || object.lockedFields?.length || object.tolerances?.length || object.typedBlocks) {
4208
+ warnIfSchema21Feature(sourceSchemaVersion, diagnostics, node.id, node.location);
4209
+ }
4210
+ }
4211
+ return object;
4212
+ }
4213
+ function collectDraftFields(fields) {
4214
+ const grouped = /* @__PURE__ */ new Map();
4215
+ for (const field of fields) {
4216
+ const spec = getDraftObjectFieldSpec(field.key);
4217
+ if (!spec) {
4218
+ throw WorldOrbitError.fromLocation(`Unknown field "${field.key}"`, field.location);
4219
+ }
4220
+ if (!spec.allowRepeat && grouped.has(field.key)) {
4221
+ throw WorldOrbitError.fromLocation(`Duplicate field "${field.key}"`, field.location);
4222
+ }
4223
+ const existing = grouped.get(field.key) ?? [];
4224
+ existing.push(field);
4225
+ grouped.set(field.key, existing);
4226
+ }
4227
+ return grouped;
3327
4228
  }
3328
- function humanizeIdentifier2(value) {
3329
- return value.split(/[-_]+/).filter(Boolean).map((segment) => segment[0].toUpperCase() + segment.slice(1)).join(" ");
4229
+ function extractDraftPlacement(objectType, fieldMap) {
4230
+ const orbitField = fieldMap.get("orbit")?.[0];
4231
+ const atField = fieldMap.get("at")?.[0];
4232
+ const surfaceField = fieldMap.get("surface")?.[0];
4233
+ const freeField = fieldMap.get("free")?.[0];
4234
+ const count = [orbitField, atField, surfaceField, freeField].filter(Boolean).length;
4235
+ if (count > 1) {
4236
+ const conflictingField = orbitField ?? atField ?? surfaceField ?? freeField;
4237
+ throw WorldOrbitError.fromLocation("Object has multiple placement modes", conflictingField?.location);
4238
+ }
4239
+ if (orbitField) {
4240
+ return {
4241
+ mode: "orbit",
4242
+ target: singleFieldValue2(orbitField),
4243
+ distance: parseOptionalUnitField(fieldMap.get("distance")?.[0], "distance"),
4244
+ semiMajor: parseOptionalUnitField(fieldMap.get("semiMajor")?.[0], "semiMajor"),
4245
+ eccentricity: parseOptionalNumberField(fieldMap.get("eccentricity")?.[0], "eccentricity"),
4246
+ period: parseOptionalUnitField(fieldMap.get("period")?.[0], "period"),
4247
+ angle: parseOptionalUnitField(fieldMap.get("angle")?.[0], "angle"),
4248
+ inclination: parseOptionalUnitField(fieldMap.get("inclination")?.[0], "inclination"),
4249
+ phase: parseOptionalUnitField(fieldMap.get("phase")?.[0], "phase")
4250
+ };
4251
+ }
4252
+ if (atField) {
4253
+ const target = singleFieldValue2(atField);
4254
+ return {
4255
+ mode: "at",
4256
+ target,
4257
+ reference: parseAtlasAtReference(target, atField.location)
4258
+ };
4259
+ }
4260
+ if (surfaceField) {
4261
+ return {
4262
+ mode: "surface",
4263
+ target: singleFieldValue2(surfaceField)
4264
+ };
4265
+ }
4266
+ if (freeField) {
4267
+ const raw = singleFieldValue2(freeField);
4268
+ const distance = tryParseAtlasUnitValue(raw);
4269
+ return {
4270
+ mode: "free",
4271
+ distance: distance ?? void 0,
4272
+ descriptor: distance ? void 0 : raw
4273
+ };
4274
+ }
4275
+ return null;
4276
+ }
4277
+ function normalizeDraftProperties(objectType, fieldMap) {
4278
+ const properties = {};
4279
+ for (const [key, fields] of fieldMap.entries()) {
4280
+ const field = fields[0];
4281
+ const spec = getDraftObjectFieldSpec(key);
4282
+ if (!field || !spec?.legacySchema || spec.legacySchema.placement) {
4283
+ continue;
4284
+ }
4285
+ ensureAtlasFieldSupported(key, objectType, field.location);
4286
+ properties[key] = normalizeLegacyScalarValue(key, field.values, field.location);
4287
+ }
4288
+ return properties;
4289
+ }
4290
+ function normalizeInfoEntries(entries, label) {
4291
+ const normalized = {};
4292
+ for (const entry of entries) {
4293
+ if (entry.key in normalized) {
4294
+ throw WorldOrbitError.fromLocation(`Duplicate ${label} key "${entry.key}"`, entry.location);
4295
+ }
4296
+ normalized[entry.key] = entry.value;
4297
+ }
4298
+ return normalized;
4299
+ }
4300
+ function normalizeTypedBlocks(typedBlockEntries) {
4301
+ const typedBlocks = {};
4302
+ for (const blockName of Object.keys(typedBlockEntries)) {
4303
+ const entries = typedBlockEntries[blockName];
4304
+ if (entries?.length) {
4305
+ typedBlocks[blockName] = normalizeInfoEntries(entries, blockName);
4306
+ }
4307
+ }
4308
+ return typedBlocks;
4309
+ }
4310
+ function extractRenderHints(fieldMap) {
4311
+ const renderHints = {};
4312
+ const renderLabelField = fieldMap.get("renderLabel")?.[0];
4313
+ const renderOrbitField = fieldMap.get("renderOrbit")?.[0];
4314
+ const renderPriorityField = fieldMap.get("renderPriority")?.[0];
4315
+ if (renderLabelField) {
4316
+ renderHints.renderLabel = parseAtlasBoolean(singleFieldValue2(renderLabelField), "renderLabel", renderLabelField.location);
4317
+ }
4318
+ if (renderOrbitField) {
4319
+ renderHints.renderOrbit = parseAtlasBoolean(singleFieldValue2(renderOrbitField), "renderOrbit", renderOrbitField.location);
4320
+ }
4321
+ if (renderPriorityField) {
4322
+ renderHints.renderPriority = parseAtlasNumber(singleFieldValue2(renderPriorityField), "renderPriority", renderPriorityField.location);
4323
+ }
4324
+ return Object.keys(renderHints).length > 0 ? renderHints : void 0;
4325
+ }
4326
+ function parseResonanceField(field) {
4327
+ if (field.values.length !== 2) {
4328
+ throw WorldOrbitError.fromLocation('Field "resonance" expects "<targetObjectId> <ratio>"', field.location);
4329
+ }
4330
+ const ratio = field.values[1];
4331
+ if (!/^\d+:\d+$/.test(ratio)) {
4332
+ throw WorldOrbitError.fromLocation(`Invalid resonance ratio "${ratio}"`, field.location);
4333
+ }
4334
+ return {
4335
+ targetObjectId: field.values[0],
4336
+ ratio
4337
+ };
4338
+ }
4339
+ function parseDeriveField(field) {
4340
+ if (field.values.length !== 2) {
4341
+ throw WorldOrbitError.fromLocation('Field "derive" expects "<field> <strategy>"', field.location);
4342
+ }
4343
+ return {
4344
+ field: field.values[0],
4345
+ strategy: field.values[1]
4346
+ };
4347
+ }
4348
+ function parseToleranceField(field) {
4349
+ if (field.values.length !== 2) {
4350
+ throw WorldOrbitError.fromLocation('Field "tolerance" expects "<field> <value>"', field.location);
4351
+ }
4352
+ const rawValue = field.values[1];
4353
+ const unitValue = tryParseAtlasUnitValue(rawValue);
4354
+ const numericValue2 = Number(rawValue);
4355
+ return {
4356
+ field: field.values[0],
4357
+ value: unitValue ?? (Number.isFinite(numericValue2) ? numericValue2 : rawValue)
4358
+ };
4359
+ }
4360
+ function parseOptionalTokenList(field) {
4361
+ return field ? [...new Set(field.values)] : [];
4362
+ }
4363
+ function parseOptionalJoinedValue(field) {
4364
+ if (!field) {
4365
+ return null;
4366
+ }
4367
+ return field.values.join(" ").trim() || null;
4368
+ }
4369
+ function parseOptionalUnitField(field, key) {
4370
+ return field ? parseAtlasUnitValue(singleFieldValue2(field), field.location, key) : void 0;
4371
+ }
4372
+ function parseOptionalNumberField(field, key) {
4373
+ return field ? parseAtlasNumber(singleFieldValue2(field), key, field.location) : void 0;
4374
+ }
4375
+ function singleFieldValue2(field) {
4376
+ return singleAtlasValue(field.values, field.key, field.location);
4377
+ }
4378
+ function getDraftObjectFieldSpec(key) {
4379
+ return DRAFT_OBJECT_FIELD_SPECS.get(key);
4380
+ }
4381
+ function validateDraftObjectFieldCompatibility(fields, objectType) {
4382
+ for (const field of fields) {
4383
+ const spec = getDraftObjectFieldSpec(field.key);
4384
+ if (!spec) {
4385
+ throw WorldOrbitError.fromLocation(`Unknown field "${field.key}"`, field.location);
4386
+ }
4387
+ if (spec.legacySchema) {
4388
+ ensureAtlasFieldSupported(field.key, objectType, field.location);
4389
+ continue;
4390
+ }
4391
+ if ((field.key === "renderLabel" || field.key === "renderOrbit" || field.key === "tidalLock") && field.values.length !== 1) {
4392
+ throw WorldOrbitError.fromLocation(`Field "${field.key}" expects exactly one value`, field.location);
4393
+ }
4394
+ }
4395
+ }
4396
+ function warnIfSchema21Feature(sourceSchemaVersion, diagnostics, featureName, location) {
4397
+ if (sourceSchemaVersion === "2.1") {
4398
+ return;
4399
+ }
4400
+ diagnostics.push({
4401
+ code: "parse.schema21.featureCompatibility",
4402
+ severity: "warning",
4403
+ source: "parse",
4404
+ message: `Feature "${featureName}" requires schema 2.1; parsed in compatibility mode because the document header is "schema ${sourceSchemaVersion}".`,
4405
+ line: location.line,
4406
+ column: location.column
4407
+ });
4408
+ }
4409
+ function preprocessAtlasSource(source) {
4410
+ const chars = [...source];
4411
+ const comments = [];
4412
+ let inString = false;
4413
+ let inBlockComment = false;
4414
+ let blockCommentStart = null;
4415
+ let line = 1;
4416
+ let column = 1;
4417
+ for (let index = 0; index < chars.length; index++) {
4418
+ const ch = chars[index];
4419
+ const next = chars[index + 1];
4420
+ if (inBlockComment) {
4421
+ if (ch === "*" && next === "/") {
4422
+ chars[index] = " ";
4423
+ chars[index + 1] = " ";
4424
+ inBlockComment = false;
4425
+ blockCommentStart = null;
4426
+ index++;
4427
+ column += 2;
4428
+ continue;
4429
+ }
4430
+ if (ch !== "\n" && ch !== "\r") {
4431
+ chars[index] = " ";
4432
+ }
4433
+ if (ch === "\n") {
4434
+ line++;
4435
+ column = 1;
4436
+ } else {
4437
+ column++;
4438
+ }
4439
+ continue;
4440
+ }
4441
+ if (!inString && ch === "/" && next === "*") {
4442
+ comments.push({ kind: "block", line, column });
4443
+ chars[index] = " ";
4444
+ chars[index + 1] = " ";
4445
+ inBlockComment = true;
4446
+ blockCommentStart = { line, column };
4447
+ index++;
4448
+ column += 2;
4449
+ continue;
4450
+ }
4451
+ if (!inString && ch === "#" && !isHexColorLiteral(chars, index)) {
4452
+ comments.push({ kind: "line", line, column });
4453
+ chars[index] = " ";
4454
+ let inner = index + 1;
4455
+ while (inner < chars.length && chars[inner] !== "\n" && chars[inner] !== "\r") {
4456
+ chars[inner] = " ";
4457
+ inner++;
4458
+ }
4459
+ column += inner - index;
4460
+ index = inner - 1;
4461
+ continue;
4462
+ }
4463
+ if (ch === '"' && chars[index - 1] !== "\\") {
4464
+ inString = !inString;
4465
+ }
4466
+ if (ch === "\n") {
4467
+ line++;
4468
+ column = 1;
4469
+ } else {
4470
+ column++;
4471
+ }
4472
+ }
4473
+ if (inBlockComment) {
4474
+ throw WorldOrbitError.fromLocation("Unclosed block comment", blockCommentStart ?? void 0);
4475
+ }
4476
+ return {
4477
+ source: chars.join(""),
4478
+ comments
4479
+ };
4480
+ }
4481
+ function isHexColorLiteral(chars, start) {
4482
+ let index = start + 1;
4483
+ let length = 0;
4484
+ while (index < chars.length && /[0-9a-f]/i.test(chars[index] ?? "")) {
4485
+ index++;
4486
+ length++;
4487
+ }
4488
+ if (![3, 4, 6, 8].includes(length)) {
4489
+ return false;
4490
+ }
4491
+ const next = chars[index];
4492
+ return next === void 0 || next === " " || next === " " || next === "\r" || next === "\n";
3330
4493
  }
3331
4494
 
3332
4495
  // packages/core/dist/load.js
3333
- var ATLAS_SCHEMA_PATTERN = /^schema\s+2(?:\.0)?$/i;
4496
+ var ATLAS_SCHEMA_PATTERN = /^schema\s+2(?:\.0|\.1)?$/i;
4497
+ var ATLAS_SCHEMA_21_PATTERN = /^schema\s+2\.1$/i;
3334
4498
  var LEGACY_DRAFT_SCHEMA_PATTERN = /^schema\s+2\.0-draft$/i;
3335
4499
  function detectWorldOrbitSchemaVersion(source) {
3336
- for (const line of source.split(/\r?\n/)) {
4500
+ for (const line of stripCommentsForSchemaDetection(source).split(/\r?\n/)) {
3337
4501
  const trimmed = line.trim();
3338
4502
  if (!trimmed) {
3339
4503
  continue;
@@ -3341,6 +4505,9 @@
3341
4505
  if (LEGACY_DRAFT_SCHEMA_PATTERN.test(trimmed)) {
3342
4506
  return "2.0-draft";
3343
4507
  }
4508
+ if (ATLAS_SCHEMA_21_PATTERN.test(trimmed)) {
4509
+ return "2.1";
4510
+ }
3344
4511
  if (ATLAS_SCHEMA_PATTERN.test(trimmed)) {
3345
4512
  return "2.0";
3346
4513
  }
@@ -3348,6 +4515,49 @@
3348
4515
  }
3349
4516
  return "1.0";
3350
4517
  }
4518
+ function stripCommentsForSchemaDetection(source) {
4519
+ const chars = [...source];
4520
+ let inString = false;
4521
+ let inBlockComment = false;
4522
+ for (let index = 0; index < chars.length; index++) {
4523
+ const ch = chars[index];
4524
+ const next = chars[index + 1];
4525
+ if (inBlockComment) {
4526
+ if (ch === "*" && next === "/") {
4527
+ chars[index] = " ";
4528
+ chars[index + 1] = " ";
4529
+ inBlockComment = false;
4530
+ index++;
4531
+ continue;
4532
+ }
4533
+ if (ch !== "\n" && ch !== "\r") {
4534
+ chars[index] = " ";
4535
+ }
4536
+ continue;
4537
+ }
4538
+ if (!inString && ch === "/" && next === "*") {
4539
+ chars[index] = " ";
4540
+ chars[index + 1] = " ";
4541
+ inBlockComment = true;
4542
+ index++;
4543
+ continue;
4544
+ }
4545
+ if (!inString && ch === "#") {
4546
+ chars[index] = " ";
4547
+ let inner = index + 1;
4548
+ while (inner < chars.length && chars[inner] !== "\n" && chars[inner] !== "\r") {
4549
+ chars[inner] = " ";
4550
+ inner++;
4551
+ }
4552
+ index = inner - 1;
4553
+ continue;
4554
+ }
4555
+ if (ch === '"' && chars[index - 1] !== "\\") {
4556
+ inString = !inString;
4557
+ }
4558
+ }
4559
+ return chars.join("");
4560
+ }
3351
4561
  function loadWorldOrbitSource(source) {
3352
4562
  const result = loadWorldOrbitSourceWithDiagnostics(source);
3353
4563
  if (!result.ok || !result.value) {
@@ -3358,36 +4568,36 @@
3358
4568
  }
3359
4569
  function loadWorldOrbitSourceWithDiagnostics(source) {
3360
4570
  const schemaVersion = detectWorldOrbitSchemaVersion(source);
3361
- if (schemaVersion === "2.0" || schemaVersion === "2.0-draft") {
4571
+ if (schemaVersion === "2.0" || schemaVersion === "2.0-draft" || schemaVersion === "2.1") {
3362
4572
  return loadAtlasSourceWithDiagnostics(source, schemaVersion);
3363
4573
  }
3364
4574
  let ast;
3365
4575
  try {
3366
4576
  ast = parseWorldOrbit(source);
3367
- } catch (error) {
4577
+ } catch (error2) {
3368
4578
  return {
3369
4579
  ok: false,
3370
4580
  value: null,
3371
- diagnostics: [diagnosticFromError(error, "parse")]
4581
+ diagnostics: [diagnosticFromError(error2, "parse")]
3372
4582
  };
3373
4583
  }
3374
4584
  let document2;
3375
4585
  try {
3376
4586
  document2 = normalizeDocument(ast);
3377
- } catch (error) {
4587
+ } catch (error2) {
3378
4588
  return {
3379
4589
  ok: false,
3380
4590
  value: null,
3381
- diagnostics: [diagnosticFromError(error, "normalize")]
4591
+ diagnostics: [diagnosticFromError(error2, "normalize")]
3382
4592
  };
3383
4593
  }
3384
4594
  try {
3385
4595
  validateDocument(document2);
3386
- } catch (error) {
4596
+ } catch (error2) {
3387
4597
  return {
3388
4598
  ok: false,
3389
4599
  value: null,
3390
- diagnostics: [diagnosticFromError(error, "validate")]
4600
+ diagnostics: [diagnosticFromError(error2, "validate")]
3391
4601
  };
3392
4602
  }
3393
4603
  return {
@@ -3407,30 +4617,29 @@
3407
4617
  let atlasDocument;
3408
4618
  try {
3409
4619
  atlasDocument = parseWorldOrbitAtlas(source);
3410
- } catch (error) {
4620
+ } catch (error2) {
3411
4621
  return {
3412
4622
  ok: false,
3413
4623
  value: null,
3414
- diagnostics: [diagnosticFromError(error, "parse", "load.atlas.failed")]
4624
+ diagnostics: [diagnosticFromError(error2, "parse", "load.atlas.failed")]
3415
4625
  };
3416
4626
  }
3417
- let document2;
3418
- try {
3419
- document2 = materializeAtlasDocument(atlasDocument);
3420
- } catch (error) {
4627
+ const atlasDiagnostics = [...atlasDocument.diagnostics];
4628
+ if (atlasDiagnostics.some((diagnostic) => diagnostic.severity === "error")) {
3421
4629
  return {
3422
4630
  ok: false,
3423
4631
  value: null,
3424
- diagnostics: [diagnosticFromError(error, "normalize", "load.atlas.materialize.failed")]
4632
+ diagnostics: atlasDiagnostics
3425
4633
  };
3426
4634
  }
4635
+ let document2;
3427
4636
  try {
3428
- validateDocument(document2);
3429
- } catch (error) {
4637
+ document2 = materializeAtlasDocument(atlasDocument);
4638
+ } catch (error2) {
3430
4639
  return {
3431
4640
  ok: false,
3432
4641
  value: null,
3433
- diagnostics: [diagnosticFromError(error, "validate", "load.atlas.validate.failed")]
4642
+ diagnostics: [diagnosticFromError(error2, "normalize", "load.atlas.materialize.failed")]
3434
4643
  };
3435
4644
  }
3436
4645
  const loaded = {
@@ -3439,12 +4648,12 @@
3439
4648
  document: document2,
3440
4649
  atlasDocument,
3441
4650
  draftDocument: atlasDocument,
3442
- diagnostics: [...atlasDocument.diagnostics]
4651
+ diagnostics: atlasDiagnostics
3443
4652
  };
3444
4653
  return {
3445
4654
  ok: true,
3446
4655
  value: loaded,
3447
- diagnostics: [...atlasDocument.diagnostics]
4656
+ diagnostics: atlasDiagnostics
3448
4657
  };
3449
4658
  }
3450
4659
 
@@ -3627,6 +4836,7 @@
3627
4836
  const imageDefinitions = buildImageDefinitions(visibleObjects);
3628
4837
  const orbitMarkup = layers.orbits ? renderOrbitLayer(scene, visibleObjectIds, layers.structures) : { back: "", front: "" };
3629
4838
  const leaderMarkup = layers.guides ? scene.leaders.filter((leader) => !leader.hidden).filter((leader) => visibleObjectIds.has(leader.objectId)).filter((leader) => layers.structures || !isStructureLike(leader.object)).map((leader) => `<line class="wo-leader wo-leader-${leader.mode}" x1="${leader.x1}" y1="${leader.y1}" x2="${leader.x2}" y2="${leader.y2}" data-render-id="${escapeXml(leader.renderId)}" data-group-id="${escapeAttribute(leader.groupId ?? "")}" />`).join("") : "";
4839
+ const relationMarkup = layers.relations ? scene.relations.filter((relation) => !relation.hidden).filter((relation) => visibleObjectIds.has(relation.fromObjectId) && visibleObjectIds.has(relation.toObjectId)).map((relation) => `<line class="wo-relation" x1="${relation.x1}" y1="${relation.y1}" x2="${relation.x2}" y2="${relation.y2}" data-render-id="${escapeXml(relation.renderId)}" data-relation-id="${escapeAttribute(relation.relationId)}" />`).join("") : "";
3630
4840
  const objectMarkup = layers.objects ? visibleObjects.map((object) => renderSceneObject(object, options.selectedObjectId ?? null, theme)).join("") : "";
3631
4841
  const labelMarkup = layers.labels ? visibleLabels.map((label) => renderSceneLabel(scene, label, options.selectedObjectId ?? null)).join("") : "";
3632
4842
  const metadataMarkup = layers.metadata ? `<text class="wo-title" x="56" y="64">${escapeXml(scene.title)}</text>
@@ -3661,6 +4871,7 @@
3661
4871
  .wo-orbit-back { opacity: 0.38; stroke-dasharray: 8 6; }
3662
4872
  .wo-orbit-front { opacity: 0.9; }
3663
4873
  .wo-orbit-band { stroke: ${theme.orbitBand}; stroke-linecap: round; }
4874
+ .wo-relation { stroke: ${theme.relation}; stroke-width: 2; stroke-dasharray: 10 6; }
3664
4875
  .wo-leader { stroke: ${theme.leader}; stroke-width: 1.5; stroke-dasharray: 6 5; }
3665
4876
  .wo-label { fill: ${theme.ink}; font-family: ${theme.fontFamily}; font-weight: 600; letter-spacing: 0.02em; }
3666
4877
  .wo-label-secondary { fill: ${theme.muted}; font-family: ${theme.fontFamily}; font-weight: 500; }
@@ -3694,6 +4905,7 @@
3694
4905
  <g data-worldorbit-world-content="true">
3695
4906
  ${layers.orbits ? `<g data-layer-id="orbits-back">${orbitMarkup.back}</g>` : ""}
3696
4907
  ${layers.guides ? `<g data-layer-id="guides">${leaderMarkup}</g>` : ""}
4908
+ ${layers.relations ? `<g data-layer-id="relations">${relationMarkup}</g>` : ""}
3697
4909
  ${layers.objects ? `<g data-layer-id="objects">${objectMarkup}</g>` : ""}
3698
4910
  ${layers.orbits ? `<g data-layer-id="orbits-front">${orbitMarkup.front}</g>` : ""}
3699
4911
  ${layers.labels ? `<g data-layer-id="labels">${labelMarkup}</g>` : ""}
@@ -3754,10 +4966,11 @@
3754
4966
  function renderSceneObject(sceneObject, selectedObjectId, theme) {
3755
4967
  const { object, x, y, radius, visualRadius } = sceneObject;
3756
4968
  const selectionClass = selectedObjectId === sceneObject.objectId ? " wo-object-selected" : "";
4969
+ const kindClass = object.properties.kind ? ` wo-kind-${String(object.properties.kind).toLowerCase().replace(/[^a-z0-9-]/g, "-")}` : "";
3757
4970
  const palette = resolveObjectPalette(sceneObject, theme);
3758
4971
  const imageMarkup = renderObjectImage(sceneObject);
3759
4972
  const outlineMarkup = imageMarkup ? renderObjectBody(object, x, y, radius, palette, { outlineOnly: true }) : "";
3760
- return `<g class="wo-object wo-object-${object.type}${selectionClass}" data-object-id="${escapeXml(sceneObject.objectId)}" data-parent-id="${escapeAttribute(sceneObject.parentId ?? "")}" data-group-id="${escapeAttribute(sceneObject.groupId ?? "")}" data-render-id="${escapeXml(sceneObject.renderId)}" tabindex="0" role="button" aria-label="${escapeXml(`${object.type} ${sceneObject.objectId}`)}">
4973
+ return `<g class="wo-object wo-object-${object.type}${kindClass}${selectionClass}" data-object-id="${escapeXml(sceneObject.objectId)}" data-parent-id="${escapeAttribute(sceneObject.parentId ?? "")}" data-group-id="${escapeAttribute(sceneObject.groupId ?? "")}" data-render-id="${escapeXml(sceneObject.renderId)}" tabindex="0" role="button" aria-label="${escapeXml(`${object.type} ${sceneObject.objectId}`)}">
3761
4974
  <circle class="wo-selection-ring" cx="${x}" cy="${y}" r="${visualRadius + 8}" />
3762
4975
  ${renderAtmosphere(sceneObject, palette)}
3763
4976
  ${renderObjectBody(object, x, y, radius, palette)}
@@ -3791,8 +5004,33 @@
3791
5004
  <circle cx="${x}" cy="${y}" r="${radius}" fill="${fill}" stroke="${palette.stroke}" stroke-width="1.4" />`;
3792
5005
  case "structure":
3793
5006
  return `<polygon points="${diamondPoints(x, y, radius)}" fill="${fill}" stroke="${palette.stroke}" stroke-width="1.4" />`;
3794
- case "phenomenon":
5007
+ case "phenomenon": {
5008
+ const kind = String(object.properties.kind ?? "").toLowerCase().replace(/_/g, "-");
5009
+ if (options.outlineOnly) {
5010
+ if (kind === "black-hole" || kind === "nebula" || kind === "galaxy" || kind === "dwarf-galaxy") {
5011
+ return `<circle cx="${x}" cy="${y}" r="${radius}" fill="transparent" stroke="${palette.stroke}" stroke-width="1.4" />`;
5012
+ }
5013
+ return `<polygon points="${phenomenonPoints(x, y, radius)}" fill="transparent" stroke="${palette.stroke}" stroke-width="1.4" />`;
5014
+ }
5015
+ if (kind === "black-hole") {
5016
+ return `<ellipse cx="${x}" cy="${y}" rx="${radius * 2.4}" ry="${radius * 0.55}" fill="none" stroke="${palette.accentRing ?? palette.stroke}" stroke-width="3.5" />
5017
+ <circle cx="${x}" cy="${y}" r="${radius}" fill="${fill}" stroke="${palette.stroke}" stroke-width="2" />`;
5018
+ }
5019
+ if (kind === "galaxy") {
5020
+ return `<ellipse cx="${x}" cy="${y}" rx="${radius * 2.6}" ry="${radius}" fill="${palette.halo ?? "none"}" stroke="none" />
5021
+ <ellipse cx="${x}" cy="${y}" rx="${radius * 1.5}" ry="${radius * 0.42}" fill="${fill}" stroke="${palette.stroke}" stroke-width="1.2" />
5022
+ <circle cx="${x}" cy="${y}" r="${radius * 0.28}" fill="${palette.core ?? "#fff"}" stroke="none" />`;
5023
+ }
5024
+ if (kind === "dwarf-galaxy") {
5025
+ return `<ellipse cx="${x}" cy="${y}" rx="${radius * 1.6}" ry="${radius * 0.55}" fill="${fill}" stroke="${palette.stroke}" stroke-width="1" />
5026
+ <circle cx="${x}" cy="${y}" r="${radius * 0.25}" fill="${palette.core ?? "#fff"}" stroke="none" />`;
5027
+ }
5028
+ if (kind === "nebula") {
5029
+ return `<circle cx="${x}" cy="${y}" r="${radius * 2.2}" fill="${palette.halo ?? "none"}" stroke="none" />
5030
+ <circle cx="${x}" cy="${y}" r="${radius}" fill="${fill}" stroke="${palette.stroke}" stroke-width="1" />`;
5031
+ }
3795
5032
  return `<polygon points="${phenomenonPoints(x, y, radius)}" fill="${fill}" stroke="${palette.stroke}" stroke-width="1.4" />`;
5033
+ }
3796
5034
  }
3797
5035
  }
3798
5036
  function renderAtmosphere(sceneObject, palette) {
@@ -3861,7 +5099,8 @@
3861
5099
  }
3862
5100
  }
3863
5101
  function resolveObjectPalette(sceneObject, theme) {
3864
- const base = basePaletteForType(sceneObject.object.type, theme);
5102
+ const kind = String(sceneObject.object.properties.kind ?? "").toLowerCase().replace(/_/g, "-");
5103
+ const base = basePaletteForType(sceneObject.object.type, kind, theme);
3865
5104
  const customFill = sceneObject.fillColor && isColorLike(sceneObject.fillColor) ? sceneObject.fillColor : base.fill;
3866
5105
  const albedo = numericValue(sceneObject.object.properties.albedo);
3867
5106
  const temperature = numericValue(sceneObject.object.properties.temperature);
@@ -3877,7 +5116,7 @@
3877
5116
  tail: sceneObject.object.type === "comet" ? rgbaString(mixColors(fill, "#ffffff", 0.5) ?? fill, 0.72) : void 0
3878
5117
  };
3879
5118
  }
3880
- function basePaletteForType(type, theme) {
5119
+ function basePaletteForType(type, kind, theme) {
3881
5120
  switch (type) {
3882
5121
  case "star":
3883
5122
  return {
@@ -3899,8 +5138,26 @@
3899
5138
  case "structure":
3900
5139
  return { fill: theme.accentStrong, stroke: "#fff2ea" };
3901
5140
  case "phenomenon":
3902
- return { fill: "#78ffd7", stroke: "#e9fff7" };
5141
+ return kindPhenomenonPalette(kind);
5142
+ }
5143
+ }
5144
+ function kindPhenomenonPalette(kind) {
5145
+ if (kind === "galaxy") {
5146
+ return { fill: "rgba(165,125,255,0.55)", stroke: "rgba(210,185,255,0.75)", halo: "rgba(160,120,255,0.10)", core: "#ede0ff" };
5147
+ }
5148
+ if (kind === "dwarf-galaxy") {
5149
+ return { fill: "rgba(190,165,255,0.45)", stroke: "rgba(220,205,255,0.75)", core: "#ddd0ff" };
3903
5150
  }
5151
+ if (kind === "black-hole") {
5152
+ return { fill: "#040408", stroke: "#ff6a00", accentRing: "rgba(255,140,20,0.72)" };
5153
+ }
5154
+ if (kind === "nebula") {
5155
+ return { fill: "rgba(105,205,255,0.45)", stroke: "rgba(180,235,255,0.72)", halo: "rgba(100,200,255,0.08)" };
5156
+ }
5157
+ if (kind === "void") {
5158
+ return { fill: "#05080f", stroke: "rgba(130,160,255,0.4)" };
5159
+ }
5160
+ return { fill: "#78ffd7", stroke: "#e9fff7" };
3904
5161
  }
3905
5162
  function applyTemperatureAndAlbedo(baseColor, temperature, albedo, type) {
3906
5163
  let nextColor = baseColor;
@@ -4220,6 +5477,41 @@
4220
5477
  });
4221
5478
  }
4222
5479
  const placement = details.object.placement;
5480
+ if (details.object.groups?.length) {
5481
+ fields.set("groups", {
5482
+ key: "groups",
5483
+ label: "Groups",
5484
+ value: details.object.groups.join(", ")
5485
+ });
5486
+ }
5487
+ if (details.object.epoch) {
5488
+ fields.set("epoch", {
5489
+ key: "epoch",
5490
+ label: "Epoch",
5491
+ value: details.object.epoch
5492
+ });
5493
+ }
5494
+ if (details.object.referencePlane) {
5495
+ fields.set("referencePlane", {
5496
+ key: "referencePlane",
5497
+ label: "Reference Plane",
5498
+ value: details.object.referencePlane
5499
+ });
5500
+ }
5501
+ if (details.object.tidalLock !== void 0) {
5502
+ fields.set("tidalLock", {
5503
+ key: "tidalLock",
5504
+ label: "Tidal Lock",
5505
+ value: details.object.tidalLock ? "true" : "false"
5506
+ });
5507
+ }
5508
+ if (details.object.resonance) {
5509
+ fields.set("resonance", {
5510
+ key: "resonance",
5511
+ label: "Resonance",
5512
+ value: `${details.object.resonance.targetObjectId} ${details.object.resonance.ratio}`
5513
+ });
5514
+ }
4223
5515
  if (placement?.mode === "at") {
4224
5516
  fields.set("placement", {
4225
5517
  key: "placement",
@@ -4389,6 +5681,9 @@
4389
5681
  touchPoints.set(event.pointerId, point);
4390
5682
  if (touchPoints.size === 2) {
4391
5683
  touchGesture = createTouchGestureState(scene, state, touchPoints);
5684
+ } else if (touchPoints.size === 1) {
5685
+ dragDistance = 0;
5686
+ suppressClick = false;
4392
5687
  }
4393
5688
  return;
4394
5689
  }
@@ -4406,7 +5701,9 @@
4406
5701
  if (!behavior.touch || !touchPoints.has(event.pointerId)) {
4407
5702
  return;
4408
5703
  }
4409
- touchPoints.set(event.pointerId, getViewportPointFromClient(event.clientX, event.clientY));
5704
+ const prevPoint = touchPoints.get(event.pointerId);
5705
+ const nextPoint2 = getViewportPointFromClient(event.clientX, event.clientY);
5706
+ touchPoints.set(event.pointerId, nextPoint2);
4410
5707
  if (touchPoints.size === 2) {
4411
5708
  if (!touchGesture) {
4412
5709
  touchGesture = createTouchGestureState(scene, state, touchPoints);
@@ -4417,6 +5714,14 @@
4417
5714
  const deltaX2 = current.center.x - touchGesture.startViewportCenter.x;
4418
5715
  const deltaY2 = current.center.y - touchGesture.startViewportCenter.y;
4419
5716
  updateState(panViewerState(zoomedState, deltaX2, deltaY2));
5717
+ } else if (touchPoints.size === 1) {
5718
+ const deltaX2 = nextPoint2.x - prevPoint.x;
5719
+ const deltaY2 = nextPoint2.y - prevPoint.y;
5720
+ dragDistance += Math.abs(deltaX2) + Math.abs(deltaY2);
5721
+ if (dragDistance > 2) {
5722
+ suppressClick = true;
5723
+ }
5724
+ updateState(panViewerState(state, deltaX2, deltaY2));
4420
5725
  }
4421
5726
  return;
4422
5727
  }
@@ -4881,8 +6186,10 @@
4881
6186
  renderObject,
4882
6187
  label: scene.labels.find((label) => label.objectId === renderObject.objectId && !label.hidden) ?? null,
4883
6188
  group: scene.groups.find((group) => group.renderId === renderObject.groupId) ?? null,
6189
+ semanticGroups: scene.semanticGroups.filter((group) => renderObject.semanticGroupIds.includes(group.id)),
4884
6190
  orbit: scene.orbitVisuals.find((orbit) => orbit.objectId === renderObject.objectId && !orbit.hidden) ?? null,
4885
6191
  relatedOrbits: scene.orbitVisuals.filter((orbit) => !orbit.hidden && (orbit.objectId === renderObject.objectId || renderObject.ancestorIds.includes(orbit.objectId) || renderObject.childIds.includes(orbit.objectId))),
6192
+ relations: scene.relations.filter((relation) => !relation.hidden && (relation.fromObjectId === renderObject.objectId || relation.toObjectId === renderObject.objectId)),
4886
6193
  parent: getObjectById(renderObject.parentId),
4887
6194
  children: renderObject.childIds.map((childId) => getObjectById(childId)).filter(Boolean),
4888
6195
  ancestors: renderObject.ancestorIds.map((ancestorId) => getObjectById(ancestorId)).filter(Boolean),
@@ -5507,6 +6814,7 @@
5507
6814
  const controls = {
5508
6815
  search: options.controls?.search ?? true,
5509
6816
  typeFilter: options.controls?.typeFilter ?? true,
6817
+ groupFilter: options.controls?.groupFilter ?? true,
5510
6818
  viewpointSelect: options.controls?.viewpointSelect ?? true,
5511
6819
  inspector: options.controls?.inspector ?? true,
5512
6820
  bookmarks: options.controls?.bookmarks ?? true
@@ -5516,6 +6824,7 @@
5516
6824
  const toolbar = container.querySelector("[data-atlas-toolbar]");
5517
6825
  const searchInput = container.querySelector("[data-atlas-search]");
5518
6826
  const typeFilterSelect = container.querySelector("[data-atlas-type-filter]");
6827
+ const groupFilterSelect = container.querySelector("[data-atlas-group-filter]");
5519
6828
  const viewpointSelect = container.querySelector("[data-atlas-viewpoint]");
5520
6829
  const bookmarkButton = container.querySelector("[data-atlas-bookmark]");
5521
6830
  const bookmarkList = container.querySelector("[data-atlas-bookmarks]");
@@ -5528,6 +6837,7 @@
5528
6837
  const baseFilter = normalizeViewerFilter(options.initialFilter ?? null);
5529
6838
  let searchQuery = options.initialQuery?.trim() ?? baseFilter?.query ?? "";
5530
6839
  let objectTypeFilter = options.initialObjectType ?? (baseFilter?.objectTypes?.length === 1 ? baseFilter.objectTypes[0] : null);
6840
+ let groupFilter = baseFilter?.groupIds?.[0] ?? null;
5531
6841
  let bookmarks = [];
5532
6842
  let viewer;
5533
6843
  viewer = createInteractiveViewer(stage, {
@@ -5575,6 +6885,7 @@
5575
6885
  });
5576
6886
  applyCurrentFilter();
5577
6887
  populateViewpoints();
6888
+ populateGroups();
5578
6889
  syncControlsFromFilter(viewer.getFilter());
5579
6890
  renderBookmarks();
5580
6891
  updateSearchResults();
@@ -5587,6 +6898,10 @@
5587
6898
  objectTypeFilter = typeFilterSelect.value || null;
5588
6899
  applyCurrentFilter();
5589
6900
  });
6901
+ groupFilterSelect?.addEventListener("change", () => {
6902
+ groupFilter = groupFilterSelect.value || null;
6903
+ applyCurrentFilter();
6904
+ });
5590
6905
  viewpointSelect?.addEventListener("change", () => {
5591
6906
  const activeViewer = requireViewer();
5592
6907
  if (!viewpointSelect.value) {
@@ -5728,6 +7043,7 @@
5728
7043
  return api;
5729
7044
  function refreshAfterInputChange() {
5730
7045
  populateViewpoints();
7046
+ populateGroups();
5731
7047
  applyCurrentFilter();
5732
7048
  renderBookmarks();
5733
7049
  updateSearchResults();
@@ -5744,19 +7060,23 @@
5744
7060
  query: searchQuery || void 0,
5745
7061
  objectTypes: objectTypeFilter ? [objectTypeFilter] : void 0,
5746
7062
  tags: baseFilter?.tags,
5747
- groupIds: baseFilter?.groupIds,
7063
+ groupIds: groupFilter ? [groupFilter] : baseFilter?.groupIds,
5748
7064
  includeAncestors: baseFilter?.includeAncestors ?? true
5749
7065
  });
5750
7066
  }
5751
7067
  function syncControlsFromFilter(filter) {
5752
7068
  searchQuery = filter?.query?.trim() ?? "";
5753
7069
  objectTypeFilter = filter?.objectTypes?.length === 1 ? filter.objectTypes[0] : null;
7070
+ groupFilter = filter?.groupIds?.length === 1 ? filter.groupIds[0] : null;
5754
7071
  if (searchInput && document.activeElement !== searchInput) {
5755
7072
  searchInput.value = searchQuery;
5756
7073
  }
5757
7074
  if (typeFilterSelect) {
5758
7075
  typeFilterSelect.value = objectTypeFilter ?? "";
5759
7076
  }
7077
+ if (groupFilterSelect) {
7078
+ groupFilterSelect.value = groupFilter ?? "";
7079
+ }
5760
7080
  }
5761
7081
  function populateViewpoints() {
5762
7082
  if (!viewpointSelect) {
@@ -5770,6 +7090,17 @@
5770
7090
  ].join("");
5771
7091
  viewpointSelect.value = active;
5772
7092
  }
7093
+ function populateGroups() {
7094
+ if (!groupFilterSelect) {
7095
+ return;
7096
+ }
7097
+ const activeViewer = requireViewer();
7098
+ groupFilterSelect.innerHTML = [
7099
+ `<option value="">All groups</option>`,
7100
+ ...activeViewer.getScene().semanticGroups.map((group) => `<option value="${escapeHtml2(group.id)}">${escapeHtml2(group.label)}</option>`)
7101
+ ].join("");
7102
+ groupFilterSelect.value = groupFilter ?? "";
7103
+ }
5773
7104
  function syncViewpointControl() {
5774
7105
  if (!viewpointSelect) {
5775
7106
  return;
@@ -5803,6 +7134,8 @@
5803
7134
  projection: activeViewer.getScene().projection,
5804
7135
  renderPreset: activeViewer.getScene().renderPreset,
5805
7136
  groupCount: activeViewer.getScene().groups.length,
7137
+ semanticGroupCount: activeViewer.getScene().semanticGroups.length,
7138
+ relationCount: activeViewer.getScene().relations.length,
5806
7139
  viewpointCount: activeViewer.getScene().viewpoints.length
5807
7140
  }
5808
7141
  };
@@ -5835,6 +7168,12 @@
5835
7168
  <option value="phenomenon">Phenomenon</option>
5836
7169
  </select>
5837
7170
  </label>` : "",
7171
+ controls.groupFilter ? `<label class="wo-atlas-field">
7172
+ <span>Group</span>
7173
+ <select data-atlas-group-filter>
7174
+ <option value="">All groups</option>
7175
+ </select>
7176
+ </label>` : "",
5838
7177
  controls.viewpointSelect ? `<label class="wo-atlas-field">
5839
7178
  <span>Viewpoint</span>
5840
7179
  <select data-atlas-viewpoint>