worldorbit 2.5.16 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +81 -15
  2. package/dist/browser/core/dist/index.js +1228 -110
  3. package/dist/browser/editor/dist/index.js +1896 -180
  4. package/dist/browser/markdown/dist/index.js +1071 -99
  5. package/dist/browser/viewer/dist/index.js +1127 -113
  6. package/dist/unpkg/core/dist/index.js +1228 -110
  7. package/dist/unpkg/editor/dist/index.js +1896 -180
  8. package/dist/unpkg/markdown/dist/index.js +1071 -99
  9. package/dist/unpkg/viewer/dist/index.js +1127 -113
  10. package/dist/unpkg/worldorbit-core.min.js +12 -12
  11. package/dist/unpkg/worldorbit-editor.min.js +295 -203
  12. package/dist/unpkg/worldorbit-markdown.min.js +66 -58
  13. package/dist/unpkg/worldorbit-viewer.min.js +84 -76
  14. package/dist/unpkg/worldorbit.js +1304 -124
  15. package/dist/unpkg/worldorbit.min.js +88 -80
  16. package/package.json +1 -1
  17. package/packages/core/dist/atlas-edit.js +75 -1
  18. package/packages/core/dist/atlas-validate.js +211 -8
  19. package/packages/core/dist/draft-parse.js +401 -22
  20. package/packages/core/dist/draft.d.ts +5 -2
  21. package/packages/core/dist/draft.js +103 -8
  22. package/packages/core/dist/format.js +99 -6
  23. package/packages/core/dist/load.js +9 -2
  24. package/packages/core/dist/normalize.js +1 -0
  25. package/packages/core/dist/scene.js +400 -64
  26. package/packages/core/dist/types.d.ts +60 -4
  27. package/packages/editor/dist/editor.js +702 -65
  28. package/packages/editor/dist/types.d.ts +3 -1
  29. package/packages/viewer/dist/atlas-state.js +11 -2
  30. package/packages/viewer/dist/atlas-viewer.js +19 -7
  31. package/packages/viewer/dist/render.js +31 -2
  32. package/packages/viewer/dist/theme.js +1 -0
  33. package/packages/viewer/dist/tooltip.js +9 -0
  34. package/packages/viewer/dist/types.d.ts +12 -2
  35. package/packages/viewer/dist/viewer.js +28 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worldorbit",
3
- "version": "2.5.16",
3
+ "version": "2.6.0",
4
4
  "description": "A text-based DSL and parser pipeline for orbital worldbuilding",
5
5
  "type": "module",
6
6
  "main": "./dist/unpkg/worldorbit.esm.js",
@@ -1,5 +1,5 @@
1
1
  import { collectAtlasDiagnostics } from "./atlas-validate.js";
2
- export function createEmptyAtlasDocument(systemId = "WorldOrbit", version = "2.0") {
2
+ export function createEmptyAtlasDocument(systemId = "WorldOrbit", version = "2.5") {
3
3
  return {
4
4
  format: "worldorbit",
5
5
  version,
@@ -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, groupIds, eventIds, sourceSchemaVersion, diagnostics, objectMap);
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, document.system, objectMap, diagnostics);
48
+ }
44
49
  return diagnostics;
45
50
  }
46
51
  function validateRelation(relation, objectMap, diagnostics) {
@@ -60,15 +65,24 @@ 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(viewpoint, groupIds, eventIds, sourceSchemaVersion, diagnostics, objectMap) {
69
+ const filter = viewpoint.filter;
70
+ if (sourceSchemaVersion === "2.1" || sourceSchemaVersion === "2.5") {
71
+ if (filter) {
72
+ for (const groupId of filter.groupIds) {
73
+ if (!groupIds.has(groupId)) {
74
+ diagnostics.push(warn("validate.viewpoint.group.unknown", `Unknown group "${groupId}" in viewpoint "${viewpoint.id}".`, undefined, `viewpoint.${viewpoint.id}.groups`));
75
+ }
76
+ }
77
+ }
78
+ for (const eventId of viewpoint.events ?? []) {
79
+ if (!eventIds.has(eventId)) {
80
+ diagnostics.push(warn("validate.viewpoint.event.unknown", `Unknown event "${eventId}" in viewpoint "${viewpoint.id}".`, undefined, `viewpoint.${viewpoint.id}.events`));
81
+ }
70
82
  }
71
83
  }
84
+ validateProjection(viewpoint.projection, diagnostics, `viewpoint.${viewpoint.id}.projection`, viewpoint.id);
85
+ validateCamera(viewpoint.camera, viewpoint.projection, viewpoint.rotationDeg, diagnostics, viewpoint.id, viewpoint.focusObjectId, viewpoint.selectedObjectId, filter, objectMap);
72
86
  }
73
87
  function validateObject(object, system, objectMap, groupIds, diagnostics) {
74
88
  const placement = object.placement;
@@ -81,6 +95,12 @@ function validateObject(object, system, objectMap, groupIds, diagnostics) {
81
95
  }
82
96
  }
83
97
  }
98
+ if (typeof object.epoch === "string" && !object.epoch.trim()) {
99
+ diagnostics.push(warn("validate.epoch.empty", `Object "${object.id}" defines an empty epoch string.`, object.id, "epoch"));
100
+ }
101
+ if (typeof object.referencePlane === "string" && !object.referencePlane.trim()) {
102
+ diagnostics.push(warn("validate.referencePlane.empty", `Object "${object.id}" defines an empty reference plane string.`, object.id, "referencePlane"));
103
+ }
84
104
  if (orbitPlacement) {
85
105
  if (!objectMap.has(orbitPlacement.target)) {
86
106
  diagnostics.push(error("validate.orbit.target.unknown", `Unknown placement target "${orbitPlacement.target}" on "${object.id}".`, object.id, "orbit"));
@@ -156,6 +176,128 @@ function validateObject(object, system, objectMap, groupIds, diagnostics) {
156
176
  }
157
177
  }
158
178
  }
179
+ function validateEvent(event, system, objectMap, diagnostics) {
180
+ const fieldPrefix = `event.${event.id}`;
181
+ const referencedIds = new Set();
182
+ if (!event.kind.trim()) {
183
+ diagnostics.push(error("validate.event.kind.required", `Event "${event.id}" is missing a "kind" value.`, undefined, `${fieldPrefix}.kind`));
184
+ }
185
+ if (typeof event.epoch === "string" && !event.epoch.trim()) {
186
+ diagnostics.push(warn("validate.event.epoch.empty", `Event "${event.id}" defines an empty epoch string.`, undefined, `${fieldPrefix}.epoch`));
187
+ }
188
+ if (typeof event.referencePlane === "string" && !event.referencePlane.trim()) {
189
+ diagnostics.push(warn("validate.event.referencePlane.empty", `Event "${event.id}" defines an empty reference plane string.`, undefined, `${fieldPrefix}.referencePlane`));
190
+ }
191
+ if (!event.targetObjectId && event.participantObjectIds.length === 0) {
192
+ diagnostics.push(error("validate.event.references.required", `Event "${event.id}" must define a "target" or at least one participant.`, undefined, `${fieldPrefix}.participants`));
193
+ }
194
+ if (event.targetObjectId) {
195
+ referencedIds.add(event.targetObjectId);
196
+ if (!objectMap.has(event.targetObjectId)) {
197
+ diagnostics.push(error("validate.event.target.unknown", `Unknown event target "${event.targetObjectId}" on "${event.id}".`, undefined, `${fieldPrefix}.target`));
198
+ }
199
+ }
200
+ const seenParticipants = new Set();
201
+ for (const participantId of event.participantObjectIds) {
202
+ referencedIds.add(participantId);
203
+ if (seenParticipants.has(participantId)) {
204
+ diagnostics.push(warn("validate.event.participants.duplicate", `Event "${event.id}" repeats participant "${participantId}".`, undefined, `${fieldPrefix}.participants`));
205
+ continue;
206
+ }
207
+ seenParticipants.add(participantId);
208
+ if (!objectMap.has(participantId)) {
209
+ diagnostics.push(error("validate.event.participants.unknown", `Unknown event participant "${participantId}" on "${event.id}".`, undefined, `${fieldPrefix}.participants`));
210
+ }
211
+ }
212
+ if (event.targetObjectId &&
213
+ event.participantObjectIds.length > 0 &&
214
+ !event.participantObjectIds.includes(event.targetObjectId)) {
215
+ diagnostics.push(warn("validate.event.target.notParticipant", `Event "${event.id}" defines a target outside its participants list.`, undefined, `${fieldPrefix}.target`));
216
+ }
217
+ if (event.positions.length === 0) {
218
+ diagnostics.push(warn("validate.event.positions.missing", `Event "${event.id}" has no positions block and cannot drive a scene snapshot.`, undefined, `${fieldPrefix}.positions`));
219
+ }
220
+ if (/(?:^|[-_])(solar-eclipse|lunar-eclipse|transit|occultation)(?:$|[-_])/.test(event.kind) && referencedIds.size < 3) {
221
+ 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`));
222
+ }
223
+ const poseIds = new Set();
224
+ for (const pose of event.positions) {
225
+ const poseFieldPrefix = `${fieldPrefix}.pose.${pose.objectId}`;
226
+ if (poseIds.has(pose.objectId)) {
227
+ diagnostics.push(error("validate.event.pose.duplicate", `Event "${event.id}" defines "${pose.objectId}" more than once in positions.`, undefined, poseFieldPrefix));
228
+ continue;
229
+ }
230
+ poseIds.add(pose.objectId);
231
+ const object = objectMap.get(pose.objectId);
232
+ if (!object) {
233
+ diagnostics.push(error("validate.event.pose.object.unknown", `Unknown event pose object "${pose.objectId}" on "${event.id}".`, undefined, poseFieldPrefix));
234
+ continue;
235
+ }
236
+ if (!referencedIds.has(pose.objectId)) {
237
+ diagnostics.push(warn("validate.event.pose.unreferenced", `Event pose "${pose.objectId}" on "${event.id}" is not listed in target/participants.`, undefined, poseFieldPrefix));
238
+ }
239
+ validateEventPose(pose, object, event, system, objectMap, diagnostics, poseFieldPrefix, event.id);
240
+ }
241
+ const missingPoseIds = [...referencedIds].filter((objectId) => !poseIds.has(objectId));
242
+ if (event.positions.length > 0 && missingPoseIds.length > 0) {
243
+ diagnostics.push(warn("validate.event.positions.partial", `Event "${event.id}" leaves ${missingPoseIds.length} referenced object(s) on their base placement.`, undefined, `${fieldPrefix}.positions`));
244
+ }
245
+ }
246
+ function validateEventPose(pose, object, event, system, objectMap, diagnostics, fieldPrefix, eventId) {
247
+ const placement = pose.placement;
248
+ if (!placement) {
249
+ diagnostics.push(error("validate.event.pose.placement.required", `Event "${eventId}" pose "${pose.objectId}" is missing a placement mode.`, undefined, fieldPrefix));
250
+ return;
251
+ }
252
+ if (placement.mode === "orbit") {
253
+ if (!objectMap.has(placement.target)) {
254
+ diagnostics.push(error("validate.event.pose.orbit.target.unknown", `Unknown event orbit target "${placement.target}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.orbit`));
255
+ }
256
+ if (placement.distance && placement.semiMajor) {
257
+ diagnostics.push(error("validate.event.pose.orbit.distanceConflict", `Event "${eventId}" pose "${pose.objectId}" cannot declare both "distance" and "semiMajor".`, undefined, `${fieldPrefix}.distance`));
258
+ }
259
+ if (placement.phase && !resolveEffectiveEpoch(system, object, event, pose)) {
260
+ diagnostics.push(warn("validate.event.pose.phase.epochMissing", `Event "${eventId}" pose "${pose.objectId}" sets "phase" without an effective epoch.`, undefined, `${fieldPrefix}.phase`));
261
+ }
262
+ if (placement.inclination && !resolveEffectiveReferencePlane(system, object, event, pose)) {
263
+ diagnostics.push(warn("validate.event.pose.inclination.referencePlaneMissing", `Event "${eventId}" pose "${pose.objectId}" sets "inclination" without an effective reference plane.`, undefined, `${fieldPrefix}.inclination`));
264
+ }
265
+ if (placement.period && !massInSolar(objectMap.get(placement.target)?.properties.mass)) {
266
+ diagnostics.push(warn("validate.event.pose.period.massMissing", `Event "${eventId}" pose "${pose.objectId}" sets "period" but its central mass cannot be derived.`, undefined, `${fieldPrefix}.period`));
267
+ }
268
+ return;
269
+ }
270
+ if (placement.mode === "surface") {
271
+ const target = objectMap.get(placement.target);
272
+ if (!target) {
273
+ diagnostics.push(error("validate.event.pose.surface.target.unknown", `Unknown event surface target "${placement.target}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.surface`));
274
+ }
275
+ else if (!SURFACE_TARGET_TYPES.has(target.type)) {
276
+ 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`));
277
+ }
278
+ return;
279
+ }
280
+ if (placement.mode === "at") {
281
+ if (object.type !== "structure" && object.type !== "phenomenon") {
282
+ 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`));
283
+ }
284
+ const reference = placement.reference;
285
+ if (reference.kind === "named" && !objectMap.has(reference.name)) {
286
+ diagnostics.push(error("validate.event.pose.at.target.unknown", `Unknown event at-reference target "${placement.target}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.at`));
287
+ }
288
+ else if (reference.kind === "anchor" && !objectMap.has(reference.objectId)) {
289
+ diagnostics.push(error("validate.event.pose.anchor.target.unknown", `Unknown event anchor target "${reference.objectId}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.at`));
290
+ }
291
+ else if (reference.kind === "lagrange") {
292
+ if (!objectMap.has(reference.primary)) {
293
+ diagnostics.push(error("validate.event.pose.lagrange.primary.unknown", `Unknown event Lagrange target "${reference.primary}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.at`));
294
+ }
295
+ else if (reference.secondary && !objectMap.has(reference.secondary)) {
296
+ diagnostics.push(error("validate.event.pose.lagrange.secondary.unknown", `Unknown event Lagrange target "${reference.secondary}" on "${eventId}:${pose.objectId}".`, undefined, `${fieldPrefix}.at`));
297
+ }
298
+ }
299
+ }
300
+ }
159
301
  function validateAtTarget(object, objectMap, diagnostics) {
160
302
  const reference = object.placement?.mode === "at" ? object.placement.reference : null;
161
303
  if (!reference) {
@@ -261,6 +403,67 @@ function durationInDays(value) {
261
403
  return null;
262
404
  }
263
405
  }
406
+ function validateProjection(projection, diagnostics, field, viewpointId) {
407
+ if (projection !== "topdown" &&
408
+ projection !== "isometric" &&
409
+ projection !== "orthographic" &&
410
+ projection !== "perspective") {
411
+ diagnostics.push(error("validate.viewpoint.projection.invalid", `Unknown projection "${String(projection)}" in viewpoint "${viewpointId}".`, undefined, field));
412
+ }
413
+ }
414
+ function validateCamera(camera, projection, rotationDeg, diagnostics, viewpointId, focusObjectId, selectedObjectId, filter, objectMap) {
415
+ if (!camera) {
416
+ return;
417
+ }
418
+ const prefix = `viewpoint.${viewpointId}.camera`;
419
+ for (const [key, value] of [
420
+ ["azimuth", camera.azimuth],
421
+ ["elevation", camera.elevation],
422
+ ["roll", camera.roll],
423
+ ["distance", camera.distance],
424
+ ]) {
425
+ if (value !== null && (!Number.isFinite(value) || (key === "distance" && value <= 0))) {
426
+ diagnostics.push(error("validate.viewpoint.camera.invalid", `Invalid camera ${key} "${String(value)}" in viewpoint "${viewpointId}".`, undefined, `${prefix}.${key}`));
427
+ }
428
+ }
429
+ if (camera.distance !== null && projection !== "perspective") {
430
+ diagnostics.push(warn("validate.viewpoint.camera.distance.partialEffect", `Camera "distance" only has a semantic effect in perspective viewpoints; "${viewpointId}" uses "${projection}".`, undefined, `${prefix}.distance`));
431
+ }
432
+ if (projection === "topdown" &&
433
+ (camera.elevation !== null || camera.roll !== null)) {
434
+ diagnostics.push(warn("validate.viewpoint.camera.topdownPartial", `Camera elevation/roll on topdown viewpoint "${viewpointId}" are currently stored for future 3D use and only partially affect 2D rendering.`, undefined, prefix));
435
+ }
436
+ if (projection === "isometric" &&
437
+ camera.elevation !== null) {
438
+ diagnostics.push(info("validate.viewpoint.camera.isometricStored", `Camera elevation on isometric viewpoint "${viewpointId}" is preserved semantically for future 3D rendering.`, undefined, `${prefix}.elevation`));
439
+ }
440
+ if (camera.azimuth !== null && camera.azimuth !== 0 && rotationDeg !== 0) {
441
+ diagnostics.push(warn("validate.viewpoint.rotation.cameraOverlap", `Viewpoint "${viewpointId}" uses camera.azimuth; keep "rotation" only for 2D screen rotation to avoid ambiguity.`, undefined, `${prefix}.azimuth`));
442
+ }
443
+ const hasAnchor = (focusObjectId !== null && objectMap.has(focusObjectId)) ||
444
+ (selectedObjectId !== null && objectMap.has(selectedObjectId)) ||
445
+ !!filter;
446
+ if (!hasAnchor) {
447
+ diagnostics.push(info("validate.viewpoint.camera.anchorMissing", `Viewpoint "${viewpointId}" stores camera settings without a focus object, selection, or filter anchor.`, undefined, prefix));
448
+ }
449
+ }
450
+ function resolveEffectiveEpoch(system, object, event, pose) {
451
+ return normalizeOptionalContextString(pose?.epoch) ??
452
+ normalizeOptionalContextString(event?.epoch) ??
453
+ normalizeOptionalContextString(object.epoch) ??
454
+ normalizeOptionalContextString(system?.epoch) ??
455
+ null;
456
+ }
457
+ function resolveEffectiveReferencePlane(system, object, event, pose) {
458
+ return normalizeOptionalContextString(pose?.referencePlane) ??
459
+ normalizeOptionalContextString(event?.referencePlane) ??
460
+ normalizeOptionalContextString(object.referencePlane) ??
461
+ normalizeOptionalContextString(system?.referencePlane) ??
462
+ null;
463
+ }
464
+ function normalizeOptionalContextString(value) {
465
+ return typeof value === "string" && value.trim() ? value.trim() : null;
466
+ }
264
467
  function toleranceForField(object, field) {
265
468
  const tolerance = object.tolerances?.find((entry) => entry.field === field)?.value;
266
469
  if (typeof tolerance === "number") {