worldorbit 2.5.16 → 2.5.17

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 (34) hide show
  1. package/README.md +1 -1
  2. package/dist/browser/core/dist/index.js +750 -73
  3. package/dist/browser/editor/dist/index.js +1303 -135
  4. package/dist/browser/markdown/dist/index.js +631 -72
  5. package/dist/browser/viewer/dist/index.js +658 -77
  6. package/dist/unpkg/core/dist/index.js +750 -73
  7. package/dist/unpkg/editor/dist/index.js +1303 -135
  8. package/dist/unpkg/markdown/dist/index.js +631 -72
  9. package/dist/unpkg/viewer/dist/index.js +658 -77
  10. package/dist/unpkg/worldorbit-core.min.js +12 -12
  11. package/dist/unpkg/worldorbit-editor.min.js +284 -202
  12. package/dist/unpkg/worldorbit-markdown.min.js +66 -58
  13. package/dist/unpkg/worldorbit-viewer.min.js +76 -68
  14. package/dist/unpkg/worldorbit.js +797 -78
  15. package/dist/unpkg/worldorbit.min.js +80 -72
  16. package/package.json +1 -1
  17. package/packages/core/dist/atlas-edit.js +74 -0
  18. package/packages/core/dist/atlas-validate.js +122 -8
  19. package/packages/core/dist/draft-parse.js +212 -8
  20. package/packages/core/dist/draft.d.ts +5 -2
  21. package/packages/core/dist/draft.js +59 -3
  22. package/packages/core/dist/format.js +63 -1
  23. package/packages/core/dist/normalize.js +1 -0
  24. package/packages/core/dist/scene.js +248 -46
  25. package/packages/core/dist/types.d.ts +41 -2
  26. package/packages/editor/dist/editor.js +597 -61
  27. package/packages/editor/dist/types.d.ts +3 -1
  28. package/packages/viewer/dist/atlas-state.js +6 -0
  29. package/packages/viewer/dist/atlas-viewer.js +1 -0
  30. package/packages/viewer/dist/render.js +31 -2
  31. package/packages/viewer/dist/theme.js +1 -0
  32. package/packages/viewer/dist/tooltip.js +9 -0
  33. package/packages/viewer/dist/types.d.ts +8 -1
  34. package/packages/viewer/dist/viewer.js +12 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worldorbit",
3
- "version": "2.5.16",
3
+ "version": "2.5.17",
4
4
  "description": "A text-based DSL and parser pipeline for orbital worldbuilding",
5
5
  "type": "module",
6
6
  "main": "./dist/unpkg/worldorbit.esm.js",
@@ -25,6 +25,7 @@ export function createEmptyAtlasDocument(systemId = "WorldOrbit", version = "2.0
25
25
  },
26
26
  groups: [],
27
27
  relations: [],
28
+ events: [],
28
29
  objects: [],
29
30
  diagnostics: [],
30
31
  };
@@ -51,6 +52,12 @@ export function listAtlasDocumentPaths(document) {
51
52
  for (const relation of [...document.relations].sort(compareIdLike)) {
52
53
  paths.push({ kind: "relation", id: relation.id });
53
54
  }
55
+ for (const event of [...document.events].sort(compareIdLike)) {
56
+ paths.push({ kind: "event", id: event.id });
57
+ for (const pose of [...event.positions].sort(comparePoseObjectId)) {
58
+ paths.push({ kind: "event-pose", id: event.id, key: pose.objectId });
59
+ }
60
+ }
54
61
  for (const object of [...document.objects].sort(compareIdLike)) {
55
62
  paths.push({ kind: "object", id: object.id });
56
63
  }
@@ -66,6 +73,10 @@ export function getAtlasDocumentNode(document, path) {
66
73
  return path.key ? (document.system?.atlasMetadata[path.key] ?? null) : null;
67
74
  case "group":
68
75
  return path.id ? findGroup(document, path.id) : null;
76
+ case "event":
77
+ return path.id ? findEvent(document, path.id) : null;
78
+ case "event-pose":
79
+ return path.id && path.key ? findEventPose(document, path.id, path.key) : null;
69
80
  case "object":
70
81
  return path.id ? findObject(document, path.id) : null;
71
82
  case "viewpoint":
@@ -106,6 +117,18 @@ export function upsertAtlasDocumentNode(document, path, value) {
106
117
  }
107
118
  upsertById(next.groups, value);
108
119
  return next;
120
+ case "event":
121
+ if (!path.id) {
122
+ throw new Error('Event updates require an "id" value.');
123
+ }
124
+ upsertById(next.events, value);
125
+ return next;
126
+ case "event-pose":
127
+ if (!path.id || !path.key) {
128
+ throw new Error('Event pose updates require an event "id" and pose "key" value.');
129
+ }
130
+ upsertEventPose(next.events, path.id, value);
131
+ return next;
109
132
  case "object":
110
133
  if (!path.id) {
111
134
  throw new Error('Object updates require an "id" value.');
@@ -154,6 +177,19 @@ export function removeAtlasDocumentNode(document, path) {
154
177
  next.groups = next.groups.filter((group) => group.id !== path.id);
155
178
  }
156
179
  return next;
180
+ case "event":
181
+ if (path.id) {
182
+ next.events = next.events.filter((event) => event.id !== path.id);
183
+ }
184
+ return next;
185
+ case "event-pose":
186
+ if (path.id && path.key) {
187
+ const event = findEvent(next, path.id);
188
+ if (event) {
189
+ event.positions = event.positions.filter((pose) => pose.objectId !== path.key);
190
+ }
191
+ }
192
+ return next;
157
193
  case "viewpoint":
158
194
  if (path.id) {
159
195
  system.viewpoints = system.viewpoints.filter((viewpoint) => viewpoint.id !== path.id);
@@ -222,6 +258,22 @@ export function resolveAtlasDiagnosticPath(document, diagnostic) {
222
258
  };
223
259
  }
224
260
  }
261
+ if (diagnostic.field?.startsWith("event.")) {
262
+ const parts = diagnostic.field.split(".");
263
+ if (parts[1] && findEvent(document, parts[1])) {
264
+ if (parts[2] === "pose" && parts[3] && findEventPose(document, parts[1], parts[3])) {
265
+ return {
266
+ kind: "event-pose",
267
+ id: parts[1],
268
+ key: parts[3],
269
+ };
270
+ }
271
+ return {
272
+ kind: "event",
273
+ id: parts[1],
274
+ };
275
+ }
276
+ }
225
277
  if (diagnostic.field && diagnostic.field in ensureSystem(document).atlasMetadata) {
226
278
  return {
227
279
  kind: "metadata",
@@ -253,6 +305,12 @@ function findGroup(document, groupId) {
253
305
  function findRelation(document, relationId) {
254
306
  return document.relations.find((relation) => relation.id === relationId) ?? null;
255
307
  }
308
+ function findEvent(document, eventId) {
309
+ return document.events.find((event) => event.id === eventId) ?? null;
310
+ }
311
+ function findEventPose(document, eventId, objectId) {
312
+ return findEvent(document, eventId)?.positions.find((pose) => pose.objectId === objectId) ?? null;
313
+ }
256
314
  function findViewpoint(system, viewpointId) {
257
315
  return system?.viewpoints.find((viewpoint) => viewpoint.id === viewpointId) ?? null;
258
316
  }
@@ -268,6 +326,22 @@ function upsertById(items, value) {
268
326
  }
269
327
  items[index] = value;
270
328
  }
329
+ function upsertEventPose(events, eventId, value) {
330
+ const event = events.find((entry) => entry.id === eventId);
331
+ if (!event) {
332
+ throw new Error(`Unknown event "${eventId}" for pose update.`);
333
+ }
334
+ const index = event.positions.findIndex((entry) => entry.objectId === value.objectId);
335
+ if (index === -1) {
336
+ event.positions.push(value);
337
+ event.positions.sort(comparePoseObjectId);
338
+ return;
339
+ }
340
+ event.positions[index] = value;
341
+ }
271
342
  function compareIdLike(left, right) {
272
343
  return left.id.localeCompare(right.id);
273
344
  }
345
+ function comparePoseObjectId(left, right) {
346
+ return left.objectId.localeCompare(right.objectId);
347
+ }
@@ -11,6 +11,7 @@ export function collectAtlasDiagnostics(document, sourceSchemaVersion) {
11
11
  const diagnostics = [];
12
12
  const objectMap = new Map(document.objects.map((object) => [object.id, object]));
13
13
  const groupIds = new Set(document.groups.map((group) => group.id));
14
+ const eventIds = new Set(document.events.map((event) => event.id));
14
15
  if (!document.system) {
15
16
  diagnostics.push(error("validate.system.required", "Atlas documents must declare exactly one system."));
16
17
  }
@@ -20,6 +21,7 @@ export function collectAtlasDiagnostics(document, sourceSchemaVersion) {
20
21
  ["viewpoint", document.system?.viewpoints.map((viewpoint) => viewpoint.id) ?? []],
21
22
  ["annotation", document.system?.annotations.map((annotation) => annotation.id) ?? []],
22
23
  ["relation", document.relations.map((relation) => relation.id)],
24
+ ["event", document.events.map((event) => event.id)],
23
25
  ["object", document.objects.map((object) => object.id)],
24
26
  ]) {
25
27
  for (const id of ids) {
@@ -36,11 +38,14 @@ export function collectAtlasDiagnostics(document, sourceSchemaVersion) {
36
38
  validateRelation(relation, objectMap, diagnostics);
37
39
  }
38
40
  for (const viewpoint of document.system?.viewpoints ?? []) {
39
- validateViewpointFilter(viewpoint.filter, groupIds, sourceSchemaVersion, diagnostics, viewpoint.id);
41
+ validateViewpoint(viewpoint.filter, viewpoint.events ?? [], groupIds, eventIds, sourceSchemaVersion, diagnostics, viewpoint.id);
40
42
  }
41
43
  for (const object of document.objects) {
42
44
  validateObject(object, document.system, objectMap, groupIds, diagnostics);
43
45
  }
46
+ for (const event of document.events) {
47
+ validateEvent(event, objectMap, diagnostics);
48
+ }
44
49
  return diagnostics;
45
50
  }
46
51
  function validateRelation(relation, objectMap, diagnostics) {
@@ -60,13 +65,19 @@ function validateRelation(relation, objectMap, diagnostics) {
60
65
  diagnostics.push(error("validate.relation.kind.required", `Relation "${relation.id}" is missing a "kind" value.`));
61
66
  }
62
67
  }
63
- function validateViewpointFilter(filter, groupIds, sourceSchemaVersion, diagnostics, viewpointId) {
64
- if (!filter || sourceSchemaVersion !== "2.1") {
65
- return;
66
- }
67
- for (const groupId of filter.groupIds) {
68
- if (!groupIds.has(groupId)) {
69
- diagnostics.push(warn("validate.viewpoint.group.unknown", `Unknown group "${groupId}" in viewpoint "${viewpointId}".`));
68
+ function validateViewpoint(filter, eventRefs, groupIds, eventIds, sourceSchemaVersion, diagnostics, viewpointId) {
69
+ if (sourceSchemaVersion === "2.1") {
70
+ if (filter) {
71
+ for (const groupId of filter.groupIds) {
72
+ if (!groupIds.has(groupId)) {
73
+ diagnostics.push(warn("validate.viewpoint.group.unknown", `Unknown group "${groupId}" in viewpoint "${viewpointId}".`, undefined, `viewpoint.${viewpointId}.groups`));
74
+ }
75
+ }
76
+ }
77
+ for (const eventId of eventRefs) {
78
+ if (!eventIds.has(eventId)) {
79
+ diagnostics.push(warn("validate.viewpoint.event.unknown", `Unknown event "${eventId}" in viewpoint "${viewpointId}".`, undefined, `viewpoint.${viewpointId}.events`));
80
+ }
70
81
  }
71
82
  }
72
83
  }
@@ -156,6 +167,109 @@ function validateObject(object, system, objectMap, groupIds, diagnostics) {
156
167
  }
157
168
  }
158
169
  }
170
+ function validateEvent(event, objectMap, diagnostics) {
171
+ const fieldPrefix = `event.${event.id}`;
172
+ const referencedIds = new Set();
173
+ if (!event.kind.trim()) {
174
+ diagnostics.push(error("validate.event.kind.required", `Event "${event.id}" is missing a "kind" value.`, undefined, `${fieldPrefix}.kind`));
175
+ }
176
+ if (!event.targetObjectId && event.participantObjectIds.length === 0) {
177
+ diagnostics.push(error("validate.event.references.required", `Event "${event.id}" must define a "target" or at least one participant.`, undefined, `${fieldPrefix}.participants`));
178
+ }
179
+ if (event.targetObjectId) {
180
+ referencedIds.add(event.targetObjectId);
181
+ if (!objectMap.has(event.targetObjectId)) {
182
+ diagnostics.push(error("validate.event.target.unknown", `Unknown event target "${event.targetObjectId}" on "${event.id}".`, undefined, `${fieldPrefix}.target`));
183
+ }
184
+ }
185
+ const seenParticipants = new Set();
186
+ for (const participantId of event.participantObjectIds) {
187
+ referencedIds.add(participantId);
188
+ if (seenParticipants.has(participantId)) {
189
+ diagnostics.push(warn("validate.event.participants.duplicate", `Event "${event.id}" repeats participant "${participantId}".`, undefined, `${fieldPrefix}.participants`));
190
+ continue;
191
+ }
192
+ seenParticipants.add(participantId);
193
+ if (!objectMap.has(participantId)) {
194
+ diagnostics.push(error("validate.event.participants.unknown", `Unknown event participant "${participantId}" on "${event.id}".`, undefined, `${fieldPrefix}.participants`));
195
+ }
196
+ }
197
+ if (event.targetObjectId &&
198
+ event.participantObjectIds.length > 0 &&
199
+ !event.participantObjectIds.includes(event.targetObjectId)) {
200
+ diagnostics.push(warn("validate.event.target.notParticipant", `Event "${event.id}" defines a target outside its participants list.`, undefined, `${fieldPrefix}.target`));
201
+ }
202
+ if (event.positions.length === 0) {
203
+ diagnostics.push(warn("validate.event.positions.missing", `Event "${event.id}" has no positions block and cannot drive a scene snapshot.`, undefined, `${fieldPrefix}.positions`));
204
+ }
205
+ if (/(?:^|[-_])(solar-eclipse|lunar-eclipse|transit|occultation)(?:$|[-_])/.test(event.kind) && referencedIds.size < 3) {
206
+ diagnostics.push(warn("validate.event.kind.participants", `Event "${event.id}" looks like an eclipse or transit but references fewer than three bodies.`, undefined, `${fieldPrefix}.participants`));
207
+ }
208
+ const poseIds = new Set();
209
+ for (const pose of event.positions) {
210
+ const poseFieldPrefix = `${fieldPrefix}.pose.${pose.objectId}`;
211
+ if (poseIds.has(pose.objectId)) {
212
+ diagnostics.push(error("validate.event.pose.duplicate", `Event "${event.id}" defines "${pose.objectId}" more than once in positions.`, undefined, poseFieldPrefix));
213
+ continue;
214
+ }
215
+ poseIds.add(pose.objectId);
216
+ const object = objectMap.get(pose.objectId);
217
+ if (!object) {
218
+ diagnostics.push(error("validate.event.pose.object.unknown", `Unknown event pose object "${pose.objectId}" on "${event.id}".`, undefined, poseFieldPrefix));
219
+ continue;
220
+ }
221
+ if (!referencedIds.has(pose.objectId)) {
222
+ diagnostics.push(warn("validate.event.pose.unreferenced", `Event pose "${pose.objectId}" on "${event.id}" is not listed in target/participants.`, undefined, poseFieldPrefix));
223
+ }
224
+ validateEventPose(pose, object, objectMap, diagnostics, poseFieldPrefix, event.id);
225
+ }
226
+ }
227
+ function validateEventPose(pose, object, objectMap, diagnostics, fieldPrefix, eventId) {
228
+ const placement = pose.placement;
229
+ if (!placement) {
230
+ diagnostics.push(error("validate.event.pose.placement.required", `Event "${eventId}" pose "${pose.objectId}" is missing a placement mode.`, undefined, fieldPrefix));
231
+ return;
232
+ }
233
+ if (placement.mode === "orbit") {
234
+ if (!objectMap.has(placement.target)) {
235
+ diagnostics.push(error("validate.event.pose.orbit.target.unknown", `Unknown event orbit target "${placement.target}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.orbit`));
236
+ }
237
+ if (placement.distance && placement.semiMajor) {
238
+ diagnostics.push(error("validate.event.pose.orbit.distanceConflict", `Event "${eventId}" pose "${pose.objectId}" cannot declare both "distance" and "semiMajor".`, undefined, `${fieldPrefix}.distance`));
239
+ }
240
+ return;
241
+ }
242
+ if (placement.mode === "surface") {
243
+ const target = objectMap.get(placement.target);
244
+ if (!target) {
245
+ diagnostics.push(error("validate.event.pose.surface.target.unknown", `Unknown event surface target "${placement.target}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.surface`));
246
+ }
247
+ else if (!SURFACE_TARGET_TYPES.has(target.type)) {
248
+ diagnostics.push(error("validate.event.pose.surface.target.invalid", `Event surface target "${placement.target}" on "${eventId}:${pose.objectId}" is not surface-capable.`, undefined, `${fieldPrefix}.surface`));
249
+ }
250
+ return;
251
+ }
252
+ if (placement.mode === "at") {
253
+ if (object.type !== "structure" && object.type !== "phenomenon") {
254
+ diagnostics.push(error("validate.event.pose.at.objectType", `Only structures and phenomena may use "at" placement in events; found "${object.type}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.at`));
255
+ }
256
+ const reference = placement.reference;
257
+ if (reference.kind === "named" && !objectMap.has(reference.name)) {
258
+ diagnostics.push(error("validate.event.pose.at.target.unknown", `Unknown event at-reference target "${placement.target}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.at`));
259
+ }
260
+ else if (reference.kind === "anchor" && !objectMap.has(reference.objectId)) {
261
+ diagnostics.push(error("validate.event.pose.anchor.target.unknown", `Unknown event anchor target "${reference.objectId}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.at`));
262
+ }
263
+ else if (reference.kind === "lagrange") {
264
+ if (!objectMap.has(reference.primary)) {
265
+ diagnostics.push(error("validate.event.pose.lagrange.primary.unknown", `Unknown event Lagrange target "${reference.primary}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.at`));
266
+ }
267
+ else if (reference.secondary && !objectMap.has(reference.secondary)) {
268
+ diagnostics.push(error("validate.event.pose.lagrange.secondary.unknown", `Unknown event Lagrange target "${reference.secondary}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.at`));
269
+ }
270
+ }
271
+ }
272
+ }
159
273
  function validateAtTarget(object, objectMap, diagnostics) {
160
274
  const reference = object.placement?.mode === "at" ? object.placement.reference : null;
161
275
  if (!reference) {
@@ -74,6 +74,21 @@ for (const spec of [
74
74
  });
75
75
  }
76
76
  const DRAFT_OBJECT_FIELD_KEYS = new Set(DRAFT_OBJECT_FIELD_SPECS.keys());
77
+ const EVENT_POSE_FIELD_KEYS = new Set([
78
+ "orbit",
79
+ "distance",
80
+ "semiMajor",
81
+ "eccentricity",
82
+ "period",
83
+ "angle",
84
+ "inclination",
85
+ "phase",
86
+ "at",
87
+ "surface",
88
+ "free",
89
+ "inner",
90
+ "outer",
91
+ ]);
77
92
  export function parseWorldOrbitAtlas(source) {
78
93
  return parseAtlasSource(source);
79
94
  }
@@ -91,12 +106,15 @@ function parseAtlasSource(source, forcedOutputVersion) {
91
106
  const objectNodes = [];
92
107
  const groups = [];
93
108
  const relations = [];
109
+ const events = [];
110
+ const eventPoseNodes = new Map();
94
111
  let sawDefaults = false;
95
112
  let sawAtlas = false;
96
113
  const viewpointIds = new Set();
97
114
  const annotationIds = new Set();
98
115
  const groupIds = new Set();
99
116
  const relationIds = new Set();
117
+ const eventIds = new Set();
100
118
  for (let index = 0; index < lines.length; index++) {
101
119
  const rawLine = lines[index];
102
120
  const lineNumber = index + 1;
@@ -127,7 +145,7 @@ function parseAtlasSource(source, forcedOutputVersion) {
127
145
  continue;
128
146
  }
129
147
  if (indent === 0) {
130
- section = startTopLevelSection(tokens, lineNumber, sourceSchemaVersion, diagnostics, system, objectNodes, groups, relations, viewpointIds, annotationIds, groupIds, relationIds, { sawDefaults, sawAtlas });
148
+ section = startTopLevelSection(tokens, lineNumber, sourceSchemaVersion, diagnostics, system, objectNodes, groups, relations, events, eventPoseNodes, viewpointIds, annotationIds, groupIds, relationIds, eventIds, { sawDefaults, sawAtlas });
131
149
  if (section.kind === "system") {
132
150
  system = section.system;
133
151
  }
@@ -148,6 +166,7 @@ function parseAtlasSource(source, forcedOutputVersion) {
148
166
  throw new WorldOrbitError('Missing required atlas schema header "schema 2.0"');
149
167
  }
150
168
  const objects = objectNodes.map((node) => normalizeDraftObject(node, sourceSchemaVersion, diagnostics));
169
+ const normalizedEvents = events.map((event) => normalizeDraftEvent(event, eventPoseNodes.get(event.id) ?? []));
151
170
  const outputVersion = forcedOutputVersion ??
152
171
  (sourceSchemaVersion === "2.0-draft" ? "2.0" : sourceSchemaVersion);
153
172
  const baseDocument = {
@@ -156,6 +175,7 @@ function parseAtlasSource(source, forcedOutputVersion) {
156
175
  system,
157
176
  groups,
158
177
  relations,
178
+ events: normalizedEvents,
159
179
  objects,
160
180
  diagnostics,
161
181
  };
@@ -197,7 +217,7 @@ function assertDraftSchemaHeader(tokens, line) {
197
217
  ? "2.0-draft"
198
218
  : "2.0";
199
219
  }
200
- function startTopLevelSection(tokens, line, sourceSchemaVersion, diagnostics, system, objectNodes, groups, relations, viewpointIds, annotationIds, groupIds, relationIds, flags) {
220
+ function startTopLevelSection(tokens, line, sourceSchemaVersion, diagnostics, system, objectNodes, groups, relations, events, eventPoseNodes, viewpointIds, annotationIds, groupIds, relationIds, eventIds, flags) {
201
221
  const keyword = tokens[0]?.value.toLowerCase();
202
222
  switch (keyword) {
203
223
  case "system":
@@ -234,7 +254,7 @@ function startTopLevelSection(tokens, line, sourceSchemaVersion, diagnostics, sy
234
254
  if (!system) {
235
255
  throw new WorldOrbitError('Atlas section "viewpoint" requires a preceding system declaration', line, tokens[0].column);
236
256
  }
237
- return startViewpointSection(tokens, line, system, viewpointIds);
257
+ return startViewpointSection(tokens, line, system, viewpointIds, sourceSchemaVersion, diagnostics);
238
258
  case "annotation":
239
259
  if (!system) {
240
260
  throw new WorldOrbitError('Atlas section "annotation" requires a preceding system declaration', line, tokens[0].column);
@@ -246,6 +266,9 @@ function startTopLevelSection(tokens, line, sourceSchemaVersion, diagnostics, sy
246
266
  case "relation":
247
267
  warnIfSchema21Feature(sourceSchemaVersion, diagnostics, "relation", { line, column: tokens[0].column });
248
268
  return startRelationSection(tokens, line, relations, relationIds);
269
+ case "event":
270
+ warnIfSchema21Feature(sourceSchemaVersion, diagnostics, "event", { line, column: tokens[0].column });
271
+ return startEventSection(tokens, line, events, eventPoseNodes, eventIds, sourceSchemaVersion, diagnostics);
249
272
  case "object":
250
273
  return startObjectSection(tokens, line, sourceSchemaVersion, diagnostics, objectNodes);
251
274
  default:
@@ -282,7 +305,7 @@ function startSystemSection(tokens, line, sourceSchemaVersion, diagnostics) {
282
305
  seenFields: new Set(),
283
306
  };
284
307
  }
285
- function startViewpointSection(tokens, line, system, viewpointIds) {
308
+ function startViewpointSection(tokens, line, system, viewpointIds, sourceSchemaVersion, diagnostics) {
286
309
  if (tokens.length !== 2) {
287
310
  throw new WorldOrbitError("Invalid viewpoint declaration", line, tokens[0]?.column ?? 1);
288
311
  }
@@ -299,6 +322,7 @@ function startViewpointSection(tokens, line, system, viewpointIds) {
299
322
  summary: "",
300
323
  focusObjectId: null,
301
324
  selectedObjectId: null,
325
+ events: [],
302
326
  projection: system.defaults.view,
303
327
  preset: system.defaults.preset,
304
328
  zoom: null,
@@ -311,6 +335,8 @@ function startViewpointSection(tokens, line, system, viewpointIds) {
311
335
  return {
312
336
  kind: "viewpoint",
313
337
  viewpoint,
338
+ sourceSchemaVersion,
339
+ diagnostics,
314
340
  seenFields: new Set(),
315
341
  inFilter: false,
316
342
  filterIndent: null,
@@ -401,6 +427,49 @@ function startRelationSection(tokens, line, relations, relationIds) {
401
427
  seenFields: new Set(),
402
428
  };
403
429
  }
430
+ function startEventSection(tokens, line, events, eventPoseNodes, eventIds, sourceSchemaVersion, diagnostics) {
431
+ if (tokens.length !== 2) {
432
+ throw new WorldOrbitError("Invalid event declaration", line, tokens[0]?.column ?? 1);
433
+ }
434
+ const id = normalizeIdentifier(tokens[1].value);
435
+ if (!id) {
436
+ throw new WorldOrbitError("Event id must not be empty", line, tokens[1].column);
437
+ }
438
+ if (eventIds.has(id)) {
439
+ throw new WorldOrbitError(`Duplicate event id "${id}"`, line, tokens[1].column);
440
+ }
441
+ const event = {
442
+ id,
443
+ kind: "",
444
+ label: humanizeIdentifier(id),
445
+ summary: null,
446
+ targetObjectId: null,
447
+ participantObjectIds: [],
448
+ timing: null,
449
+ visibility: null,
450
+ tags: [],
451
+ color: null,
452
+ hidden: false,
453
+ positions: [],
454
+ };
455
+ const rawPoses = [];
456
+ events.push(event);
457
+ eventPoseNodes.set(id, rawPoses);
458
+ eventIds.add(id);
459
+ return {
460
+ kind: "event",
461
+ event,
462
+ sourceSchemaVersion,
463
+ diagnostics,
464
+ seenFields: new Set(),
465
+ rawPoses,
466
+ inPositions: false,
467
+ positionsIndent: null,
468
+ activePose: null,
469
+ poseIndent: null,
470
+ activePoseSeenFields: new Set(),
471
+ };
472
+ }
404
473
  function startObjectSection(tokens, line, sourceSchemaVersion, diagnostics, objectNodes) {
405
474
  if (tokens.length < 3) {
406
475
  throw new WorldOrbitError("Invalid atlas object declaration", line, tokens[0]?.column ?? 1);
@@ -457,6 +526,9 @@ function handleSectionLine(section, indent, tokens, line) {
457
526
  case "relation":
458
527
  applyRelationField(section, tokens, line);
459
528
  return;
529
+ case "event":
530
+ applyEventField(section, indent, tokens, line);
531
+ return;
460
532
  case "object":
461
533
  applyObjectField(section, indent, tokens, line);
462
534
  return;
@@ -583,7 +655,14 @@ function applyViewpointField(section, indent, tokens, line) {
583
655
  section.viewpoint.rotationDeg = parseFiniteNumber(value, line, tokens[0].column, "rotation");
584
656
  return;
585
657
  case "layers":
586
- section.viewpoint.layers = parseLayerTokens(tokens.slice(1), line);
658
+ section.viewpoint.layers = parseLayerTokens(tokens.slice(1), line, section.sourceSchemaVersion, section.diagnostics);
659
+ return;
660
+ case "events":
661
+ warnIfSchema21Feature(section.sourceSchemaVersion, section.diagnostics, "viewpoint.events", {
662
+ line,
663
+ column: tokens[0].column,
664
+ });
665
+ section.viewpoint.events = parseTokenList(tokens.slice(1), line, "events");
587
666
  return;
588
667
  default:
589
668
  throw new WorldOrbitError(`Unknown viewpoint field "${tokens[0].value}"`, line, tokens[0].column);
@@ -688,6 +767,106 @@ function applyRelationField(section, tokens, line) {
688
767
  throw new WorldOrbitError(`Unknown relation field "${tokens[0].value}"`, line, tokens[0].column);
689
768
  }
690
769
  }
770
+ function applyEventField(section, indent, tokens, line) {
771
+ if (section.activePose && indent <= (section.poseIndent ?? 0)) {
772
+ section.activePose = null;
773
+ section.poseIndent = null;
774
+ section.activePoseSeenFields.clear();
775
+ }
776
+ if (!section.activePose && section.inPositions && indent <= (section.positionsIndent ?? 0)) {
777
+ section.inPositions = false;
778
+ section.positionsIndent = null;
779
+ }
780
+ if (section.activePose) {
781
+ section.activePose.fields.push(parseEventPoseField(tokens, line, section.activePoseSeenFields));
782
+ return;
783
+ }
784
+ if (section.inPositions) {
785
+ if (tokens.length !== 2 || tokens[0].value.toLowerCase() !== "pose") {
786
+ throw new WorldOrbitError(`Unknown event positions field "${tokens[0].value}"`, line, tokens[0]?.column ?? 1);
787
+ }
788
+ const objectId = tokens[1].value;
789
+ if (!objectId.trim()) {
790
+ throw new WorldOrbitError("Event pose object id must not be empty", line, tokens[1].column);
791
+ }
792
+ const rawPose = {
793
+ objectId,
794
+ fields: [],
795
+ location: { line, column: tokens[0].column },
796
+ };
797
+ section.rawPoses.push(rawPose);
798
+ section.activePose = rawPose;
799
+ section.poseIndent = indent;
800
+ section.activePoseSeenFields = new Set();
801
+ return;
802
+ }
803
+ if (tokens.length === 1 && tokens[0].value.toLowerCase() === "positions") {
804
+ if (section.seenFields.has("positions")) {
805
+ throw new WorldOrbitError('Duplicate event field "positions"', line, tokens[0].column);
806
+ }
807
+ section.seenFields.add("positions");
808
+ section.inPositions = true;
809
+ section.positionsIndent = indent;
810
+ return;
811
+ }
812
+ const key = requireUniqueField(tokens, section.seenFields, line);
813
+ switch (key) {
814
+ case "kind":
815
+ section.event.kind = joinFieldValue(tokens, line);
816
+ return;
817
+ case "label":
818
+ section.event.label = joinFieldValue(tokens, line);
819
+ return;
820
+ case "summary":
821
+ section.event.summary = joinFieldValue(tokens, line);
822
+ return;
823
+ case "target":
824
+ section.event.targetObjectId = joinFieldValue(tokens, line);
825
+ return;
826
+ case "participants":
827
+ section.event.participantObjectIds = parseTokenList(tokens.slice(1), line, "participants");
828
+ return;
829
+ case "timing":
830
+ section.event.timing = joinFieldValue(tokens, line);
831
+ return;
832
+ case "visibility":
833
+ section.event.visibility = joinFieldValue(tokens, line);
834
+ return;
835
+ case "tags":
836
+ section.event.tags = parseTokenList(tokens.slice(1), line, "tags");
837
+ return;
838
+ case "color":
839
+ section.event.color = joinFieldValue(tokens, line);
840
+ return;
841
+ case "hidden":
842
+ section.event.hidden = parseAtlasBoolean(joinFieldValue(tokens, line), "hidden", {
843
+ line,
844
+ column: tokens[0].column,
845
+ });
846
+ return;
847
+ default:
848
+ throw new WorldOrbitError(`Unknown event field "${tokens[0].value}"`, line, tokens[0].column);
849
+ }
850
+ }
851
+ function parseEventPoseField(tokens, line, seenFields) {
852
+ if (tokens.length < 2) {
853
+ throw new WorldOrbitError("Invalid event pose field line", line, tokens[0]?.column ?? 1);
854
+ }
855
+ const key = tokens[0].value;
856
+ if (!EVENT_POSE_FIELD_KEYS.has(key)) {
857
+ throw new WorldOrbitError(`Unknown event pose field "${key}"`, line, tokens[0].column);
858
+ }
859
+ if (seenFields.has(key)) {
860
+ throw new WorldOrbitError(`Duplicate event pose field "${key}"`, line, tokens[0].column);
861
+ }
862
+ seenFields.add(key);
863
+ return {
864
+ type: "field",
865
+ key,
866
+ values: tokens.slice(1).map((token) => token.value),
867
+ location: { line, column: tokens[0].column },
868
+ };
869
+ }
691
870
  function applyObjectField(section, indent, tokens, line) {
692
871
  if (section.activeBlock && indent <= (section.blockIndent ?? 0)) {
693
872
  section.activeBlock = null;
@@ -760,7 +939,7 @@ function parseObjectTypeTokens(tokens, line) {
760
939
  value === "structure" ||
761
940
  value === "phenomenon");
762
941
  }
763
- function parseLayerTokens(tokens, line) {
942
+ function parseLayerTokens(tokens, line, sourceSchemaVersion, diagnostics) {
764
943
  const layers = {};
765
944
  for (const token of parseTokenList(tokens, line, "layers")) {
766
945
  const enabled = !token.startsWith("-") && !token.startsWith("!");
@@ -775,9 +954,16 @@ function parseLayerTokens(tokens, line) {
775
954
  raw === "orbits-back" ||
776
955
  raw === "orbits-front" ||
777
956
  raw === "relations" ||
957
+ raw === "events" ||
778
958
  raw === "objects" ||
779
959
  raw === "labels" ||
780
960
  raw === "metadata") {
961
+ if (raw === "events" && sourceSchemaVersion && diagnostics) {
962
+ warnIfSchema21Feature(sourceSchemaVersion, diagnostics, "layers.events", {
963
+ line,
964
+ column: tokens[0]?.column ?? 1,
965
+ });
966
+ }
781
967
  layers[raw] = enabled;
782
968
  }
783
969
  }
@@ -921,7 +1107,7 @@ function parseInfoLikeEntry(tokens, line, errorMessage) {
921
1107
  }
922
1108
  function normalizeDraftObject(node, sourceSchemaVersion, diagnostics) {
923
1109
  const fieldMap = collectDraftFields(node.fields);
924
- const placement = extractDraftPlacement(node.objectType, fieldMap);
1110
+ const placement = extractPlacementFromFieldMap(fieldMap);
925
1111
  const properties = normalizeDraftProperties(node.objectType, fieldMap);
926
1112
  const groups = parseOptionalTokenList(fieldMap.get("groups")?.[0]);
927
1113
  const epoch = parseOptionalJoinedValue(fieldMap.get("epoch")?.[0]);
@@ -989,6 +1175,24 @@ function normalizeDraftObject(node, sourceSchemaVersion, diagnostics) {
989
1175
  }
990
1176
  return object;
991
1177
  }
1178
+ function normalizeDraftEvent(event, rawPoses) {
1179
+ return {
1180
+ ...event,
1181
+ participantObjectIds: [...new Set(event.participantObjectIds)],
1182
+ tags: [...new Set(event.tags)],
1183
+ positions: rawPoses.map((pose) => normalizeDraftEventPose(pose)),
1184
+ };
1185
+ }
1186
+ function normalizeDraftEventPose(rawPose) {
1187
+ const fieldMap = collectDraftFields(rawPose.fields);
1188
+ const placement = extractPlacementFromFieldMap(fieldMap);
1189
+ return {
1190
+ objectId: rawPose.objectId,
1191
+ placement,
1192
+ inner: parseOptionalUnitField(fieldMap.get("inner")?.[0], "inner"),
1193
+ outer: parseOptionalUnitField(fieldMap.get("outer")?.[0], "outer"),
1194
+ };
1195
+ }
992
1196
  function collectDraftFields(fields) {
993
1197
  const grouped = new Map();
994
1198
  for (const field of fields) {
@@ -1005,7 +1209,7 @@ function collectDraftFields(fields) {
1005
1209
  }
1006
1210
  return grouped;
1007
1211
  }
1008
- function extractDraftPlacement(objectType, fieldMap) {
1212
+ function extractPlacementFromFieldMap(fieldMap) {
1009
1213
  const orbitField = fieldMap.get("orbit")?.[0];
1010
1214
  const atField = fieldMap.get("at")?.[0];
1011
1215
  const surfaceField = fieldMap.get("surface")?.[0];