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
@@ -64,7 +64,11 @@ const OBJECT_NUMBER_FIELDS = ["albedo"];
64
64
  const FIELD_HELP = {
65
65
  "defaults-view": {
66
66
  description: "Sets the default camera projection for the atlas.",
67
- references: ["Topdown = map-like", "Isometric = angled overview"],
67
+ references: [
68
+ "Topdown = map-like",
69
+ "Isometric = angled overview",
70
+ "Orthographic/Perspective = 3D-ready semantic views",
71
+ ],
68
72
  },
69
73
  "defaults-scale": {
70
74
  description: "Chooses the overall spacing/style preset used by the renderer.",
@@ -76,15 +80,71 @@ const FIELD_HELP = {
76
80
  },
77
81
  "viewpoint-projection": {
78
82
  description: "Overrides the projection for this saved viewpoint.",
79
- references: ["Topdown = flat orbital map", "Isometric = angled scene"],
83
+ references: [
84
+ "Topdown = flat orbital map",
85
+ "Isometric = angled scene",
86
+ "Orthographic/Perspective = stored with current 2D fallback",
87
+ ],
80
88
  },
81
89
  "viewpoint-zoom": {
82
90
  description: "Controls how closely this viewpoint frames the system.",
83
91
  references: ["1 = scene fit", "2+ = close-up"],
84
92
  },
85
93
  "viewpoint-rotation": {
86
- description: "Rotates the saved camera angle in degrees.",
87
- references: ["90deg = quarter turn", "180deg = flip"],
94
+ description: "Legacy 2D screen rotation. This is separate from the Schema 2.5 camera block.",
95
+ references: ["90deg = quarter turn", "Use camera.azimuth for semantic view direction"],
96
+ },
97
+ "viewpoint-camera-azimuth": {
98
+ description: "Horizontal camera direction in degrees for Schema 2.5 viewpoints.",
99
+ references: ["0 = forward/default", "90 = quarter orbit around the scene"],
100
+ },
101
+ "viewpoint-camera-elevation": {
102
+ description: "Vertical camera tilt in degrees for 3D-ready viewpoints.",
103
+ references: ["0 = level", "30 = gentle look down"],
104
+ },
105
+ "viewpoint-camera-roll": {
106
+ description: "Rolls the camera around its forward axis.",
107
+ references: ["0 = upright", "15 = slight bank"],
108
+ },
109
+ "viewpoint-camera-distance": {
110
+ description: "Semantic camera distance for perspective viewpoints.",
111
+ references: ["4 = close", "12 = wide framing"],
112
+ },
113
+ "viewpoint-events": {
114
+ description: "Lists event IDs that this viewpoint should feature in its detail panel.",
115
+ references: ["solar-eclipse-naar", "transit-window conjunction"],
116
+ },
117
+ "event-kind": {
118
+ description: "Short semantic event type for tooling and viewer overlays.",
119
+ references: ["solar-eclipse", "lunar-eclipse", "transit"],
120
+ },
121
+ "event-target": {
122
+ description: "Primary object this event is centered on.",
123
+ references: ["Naar", "Seyra"],
124
+ },
125
+ "event-participants": {
126
+ description: "Objects that participate in the event snapshot or description.",
127
+ references: ["Iyath Naar Seyra", "Naar Seyra Orun"],
128
+ },
129
+ "event-timing": {
130
+ description: "Free-text timing note for the event.",
131
+ references: ['"Every late bloom season"', '"At local midyear"'],
132
+ },
133
+ "event-visibility": {
134
+ description: "Notes where or how the event is visible.",
135
+ references: ['"Visible from Naar"', '"Southern hemisphere only"'],
136
+ },
137
+ "event-epoch": {
138
+ description: "Optional event-wide epoch that event poses inherit unless they override it.",
139
+ references: ['"JY-0001.0"', '"Naar bloom cycle year 18"'],
140
+ },
141
+ "event-referencePlane": {
142
+ description: "Optional event-wide reference plane for all poses in this snapshot.",
143
+ references: ["ecliptic", "naar-equatorial"],
144
+ },
145
+ "event-viewpoints": {
146
+ description: "Viewpoint IDs that should list this event prominently.",
147
+ references: ["naar-system", "overview inner-system"],
88
148
  },
89
149
  "placement-target": {
90
150
  description: "Names the body or reference this object is attached to.",
@@ -122,6 +182,14 @@ const FIELD_HELP = {
122
182
  description: "Starting position of the object along its orbit.",
123
183
  references: ["0deg = start position", "180deg = opposite side"],
124
184
  },
185
+ "pose-epoch": {
186
+ description: "Overrides the effective epoch for this pose only.",
187
+ references: ['"JY-0001.0"', "Falls back to event, object, then system"],
188
+ },
189
+ "pose-referencePlane": {
190
+ description: "Overrides the effective reference plane for this pose only.",
191
+ references: ["naar-equatorial", "Falls back to event, object, then system"],
192
+ },
125
193
  "prop-radius": {
126
194
  description: "Visual body size or real-world-inspired radius value.",
127
195
  references: ["1re = Earth radius", "1sol = Sun radius"],
@@ -206,12 +274,25 @@ export function createWorldOrbitEditor(container, options = {}) {
206
274
  minimap: true,
207
275
  tooltipMode: "hover",
208
276
  onSelectionChange(selectedObject) {
277
+ const activeEventId = selection ? selectionEventId(selection) : null;
209
278
  if (ignoreViewerSelection || !selectedObject) {
210
- if (!ignoreViewerSelection && selection?.kind === "object") {
211
- setSelection(null, false, true);
279
+ if (!ignoreViewerSelection) {
280
+ if (selection?.kind === "event-pose" && selection.id) {
281
+ setSelection({ kind: "event", id: selection.id }, false, true);
282
+ }
283
+ else if (selection?.kind === "object") {
284
+ setSelection(activeEventId ? { kind: "event", id: activeEventId } : null, false, true);
285
+ }
286
+ else if (selection?.kind === "event" && selection.id) {
287
+ setSelection({ kind: "event", id: selection.id }, false, true);
288
+ }
212
289
  }
213
290
  return;
214
291
  }
292
+ if (activeEventId && findEventPose(atlasDocument, activeEventId, selectedObject.objectId)) {
293
+ setSelection({ kind: "event-pose", id: activeEventId, key: selectedObject.objectId }, false, true);
294
+ return;
295
+ }
215
296
  setSelection({ kind: "object", id: selectedObject.objectId }, false, true);
216
297
  },
217
298
  onViewChange() {
@@ -221,6 +302,7 @@ export function createWorldOrbitEditor(container, options = {}) {
221
302
  toolbar.addEventListener("click", handleToolbarClick);
222
303
  outline.addEventListener("click", handleOutlineClick);
223
304
  overlay.addEventListener("pointerdown", handleOverlayPointerDown);
305
+ inspector?.addEventListener("click", handleInspectorClick);
224
306
  inspector?.addEventListener("input", handleInspectorInput);
225
307
  inspector?.addEventListener("change", handleInspectorChange);
226
308
  sourcePane?.addEventListener("input", handleSourceInput);
@@ -295,6 +377,30 @@ export function createWorldOrbitEditor(container, options = {}) {
295
377
  replaceAtlasDocument(nextDocument, true, { kind: "object", id });
296
378
  return id;
297
379
  },
380
+ addEvent() {
381
+ const id = createUniqueId("event", atlasDocument.events.map((event) => event.id));
382
+ const created = {
383
+ id,
384
+ kind: "",
385
+ label: humanizeIdentifier(id),
386
+ summary: null,
387
+ targetObjectId: null,
388
+ participantObjectIds: [],
389
+ timing: null,
390
+ visibility: null,
391
+ epoch: null,
392
+ referencePlane: null,
393
+ tags: [],
394
+ color: null,
395
+ hidden: false,
396
+ positions: [],
397
+ };
398
+ const nextDocument = cloneAtlasDocument(atlasDocument);
399
+ nextDocument.events.push(created);
400
+ nextDocument.events.sort(compareEvents);
401
+ replaceAtlasDocument(nextDocument, true, { kind: "event", id });
402
+ return id;
403
+ },
298
404
  addViewpoint() {
299
405
  const id = createUniqueId("viewpoint", atlasDocument.system?.viewpoints.map((viewpoint) => viewpoint.id) ?? []);
300
406
  const created = {
@@ -303,10 +409,12 @@ export function createWorldOrbitEditor(container, options = {}) {
303
409
  summary: "",
304
410
  focusObjectId: null,
305
411
  selectedObjectId: null,
412
+ events: [],
306
413
  projection: atlasDocument.system?.defaults.view ?? "topdown",
307
414
  preset: atlasDocument.system?.defaults.preset ?? null,
308
415
  zoom: null,
309
416
  rotationDeg: 0,
417
+ camera: null,
310
418
  layers: {},
311
419
  filter: null,
312
420
  };
@@ -363,6 +471,7 @@ export function createWorldOrbitEditor(container, options = {}) {
363
471
  toolbar.removeEventListener("click", handleToolbarClick);
364
472
  outline.removeEventListener("click", handleOutlineClick);
365
473
  overlay.removeEventListener("pointerdown", handleOverlayPointerDown);
474
+ inspector?.removeEventListener("click", handleInspectorClick);
366
475
  inspector?.removeEventListener("input", handleInspectorInput);
367
476
  inspector?.removeEventListener("change", handleInspectorChange);
368
477
  sourcePane?.removeEventListener("input", handleSourceInput);
@@ -408,7 +517,7 @@ export function createWorldOrbitEditor(container, options = {}) {
408
517
  }
409
518
  function getCurrentSourceForExport() {
410
519
  if (dragState?.changed) {
411
- return formatDocument(atlasDocument, { schema: "2.0" });
520
+ return formatAtlasSource(atlasDocument);
412
521
  }
413
522
  return canonicalSource;
414
523
  }
@@ -447,7 +556,7 @@ export function createWorldOrbitEditor(container, options = {}) {
447
556
  }
448
557
  clearSourceInputTimer();
449
558
  atlasDocument = cloneAtlasDocument(nextDocument);
450
- canonicalSource = formatDocument(atlasDocument, { schema: "2.0" });
559
+ canonicalSource = formatAtlasSource(atlasDocument);
451
560
  if (!preserveSourceText) {
452
561
  sourceText = canonicalSource;
453
562
  }
@@ -488,10 +597,10 @@ export function createWorldOrbitEditor(container, options = {}) {
488
597
  if (commitHistory) {
489
598
  history.push(createHistoryEntry());
490
599
  future.length = 0;
491
- sourceText = formatDocument(nextDocument, { schema: "2.0" });
600
+ sourceText = formatAtlasSource(nextDocument);
492
601
  }
493
602
  atlasDocument = cloneAtlasDocument(nextDocument);
494
- canonicalSource = formatDocument(atlasDocument, { schema: "2.0" });
603
+ canonicalSource = formatAtlasSource(atlasDocument);
495
604
  diagnostics = mergeDiagnostics(loadedDiagnostics, collectDocumentDiagnostics(atlasDocument));
496
605
  selection = normalizeSelection(selection);
497
606
  syncViewer({
@@ -510,11 +619,15 @@ export function createWorldOrbitEditor(container, options = {}) {
510
619
  const previousState = viewer.getState();
511
620
  const currentRenderOptions = viewer.getRenderOptions();
512
621
  const nextPreset = atlasDocument.system?.defaults.preset ?? "atlas-card";
622
+ const nextActiveEventId = selection ? selectionEventId(selection) : null;
513
623
  ignoreViewerSelection = true;
514
- if (currentRenderOptions.preset !== nextPreset || currentRenderOptions.projection !== "document") {
624
+ if (currentRenderOptions.preset !== nextPreset ||
625
+ currentRenderOptions.projection !== "document" ||
626
+ (currentRenderOptions.activeEventId ?? null) !== nextActiveEventId) {
515
627
  viewer.setRenderOptions({
516
628
  preset: nextPreset,
517
629
  projection: "document",
630
+ activeEventId: nextActiveEventId,
518
631
  });
519
632
  }
520
633
  viewer.setDocument(materializeAtlasDocument(atlasDocument));
@@ -524,12 +637,19 @@ export function createWorldOrbitEditor(container, options = {}) {
524
637
  else if (options.preserveCamera !== false) {
525
638
  viewer.setState({
526
639
  ...previousState,
527
- selectedObjectId: selection?.kind === "object" ? selection.id ?? null : null,
640
+ selectedObjectId: selection?.kind === "object"
641
+ ? selection.id ?? null
642
+ : selection?.kind === "event-pose"
643
+ ? selection.key ?? null
644
+ : null,
528
645
  });
529
646
  }
530
647
  else if (selection?.kind === "object" && selection.id) {
531
648
  viewer.focusObject(selection.id);
532
649
  }
650
+ else if (selection?.kind === "event-pose" && selection.key) {
651
+ viewer.focusObject(selection.key);
652
+ }
533
653
  ignoreViewerSelection = false;
534
654
  }
535
655
  function emitSnapshot() {
@@ -547,9 +667,13 @@ export function createWorldOrbitEditor(container, options = {}) {
547
667
  selection = normalizeSelection(nextSelection);
548
668
  if (syncViewerSelection) {
549
669
  ignoreViewerSelection = true;
670
+ viewer.setRenderOptions({ activeEventId: selection ? selectionEventId(selection) : null });
550
671
  if (selection?.kind === "object" && selection.id) {
551
672
  viewer.focusObject(selection.id);
552
673
  }
674
+ else if (selection?.kind === "event-pose" && selection.key) {
675
+ viewer.focusObject(selection.key);
676
+ }
553
677
  else if (selection?.kind === "viewpoint" && selection.id) {
554
678
  viewer.goToViewpoint(selection.id);
555
679
  }
@@ -604,6 +728,7 @@ export function createWorldOrbitEditor(container, options = {}) {
604
728
  ${OBJECT_TYPES.map((type) => `<option value="${escapeHtml(type)}"${type === objectType ? " selected" : ""}>${escapeHtml(humanizeIdentifier(type))}</option>`).join("")}
605
729
  </select>
606
730
  <button type="button" data-editor-action="add-object">Add object</button>
731
+ <button type="button" data-editor-action="add-event">Add event</button>
607
732
  <button type="button" data-editor-action="add-viewpoint">Add viewpoint</button>
608
733
  <button type="button" data-editor-action="add-annotation">Add annotation</button>
609
734
  <button type="button" data-editor-action="add-metadata">Add metadata</button>
@@ -650,6 +775,14 @@ export function createWorldOrbitEditor(container, options = {}) {
650
775
  .join("")
651
776
  : `<p class="wo-editor-empty">No annotations yet.</p>`}
652
777
  </div>
778
+ <div class="wo-editor-outline-section">
779
+ <h3>Events</h3>
780
+ ${atlasDocument.events.length > 0
781
+ ? atlasDocument.events
782
+ .map((eventEntry) => renderEventOutlineItems(eventEntry, activeKey, diagnosticBuckets))
783
+ .join("")
784
+ : `<p class="wo-editor-empty">No events yet.</p>`}
785
+ </div>
653
786
  <div class="wo-editor-outline-section">
654
787
  <h3>Objects</h3>
655
788
  ${atlasDocument.objects.length > 0
@@ -701,6 +834,7 @@ export function createWorldOrbitEditor(container, options = {}) {
701
834
  selection: selection ? { path: { ...selection } } : null,
702
835
  system: atlasDocument.system,
703
836
  viewpoints: atlasDocument.system?.viewpoints ?? [],
837
+ events: atlasDocument.events,
704
838
  objects: atlasDocument.objects,
705
839
  };
706
840
  if (!selection) {
@@ -729,6 +863,17 @@ export function createWorldOrbitEditor(container, options = {}) {
729
863
  applyInspectorSectionState(inspector, inspectorSectionState);
730
864
  decorateInspectorDiagnostics(selection, diagnostics);
731
865
  return;
866
+ case "event":
867
+ inspector.innerHTML = diagnosticSummary + renderEventInspector(formState, selection.id ?? "");
868
+ applyInspectorSectionState(inspector, inspectorSectionState);
869
+ decorateInspectorDiagnostics(selection, diagnostics);
870
+ return;
871
+ case "event-pose":
872
+ inspector.innerHTML =
873
+ diagnosticSummary + renderEventPoseInspector(formState, selection.id ?? "", selection.key ?? "");
874
+ applyInspectorSectionState(inspector, inspectorSectionState);
875
+ decorateInspectorDiagnostics(selection, diagnostics);
876
+ return;
732
877
  case "annotation":
733
878
  inspector.innerHTML = diagnosticSummary + renderAnnotationInspector(formState, selection.id ?? "");
734
879
  applyInspectorSectionState(inspector, inspectorSectionState);
@@ -761,10 +906,15 @@ export function createWorldOrbitEditor(container, options = {}) {
761
906
  return;
762
907
  }
763
908
  overlay.innerHTML = "";
764
- if (selection?.kind !== "object" || !selection.id) {
909
+ const selectedObjectId = selection?.kind === "object"
910
+ ? selection.id ?? null
911
+ : selection?.kind === "event-pose"
912
+ ? selection.key ?? null
913
+ : null;
914
+ if (!selectedObjectId) {
765
915
  return;
766
916
  }
767
- const details = viewer.getObjectDetails(selection.id);
917
+ const details = viewer.getObjectDetails(selectedObjectId);
768
918
  if (!details) {
769
919
  return;
770
920
  }
@@ -883,6 +1033,9 @@ export function createWorldOrbitEditor(container, options = {}) {
883
1033
  case "add-viewpoint":
884
1034
  api.addViewpoint();
885
1035
  return;
1036
+ case "add-event":
1037
+ api.addEvent();
1038
+ return;
886
1039
  case "add-annotation":
887
1040
  api.addAnnotation();
888
1041
  return;
@@ -917,6 +1070,35 @@ export function createWorldOrbitEditor(container, options = {}) {
917
1070
  key: button.dataset.pathKey || undefined,
918
1071
  }, true, true);
919
1072
  }
1073
+ function handleInspectorClick(event) {
1074
+ const pathButton = event.target?.closest("[data-path-kind]");
1075
+ if (pathButton) {
1076
+ setSelection({
1077
+ kind: pathButton.dataset.pathKind,
1078
+ id: pathButton.dataset.pathId || undefined,
1079
+ key: pathButton.dataset.pathKey || undefined,
1080
+ }, true, true);
1081
+ return;
1082
+ }
1083
+ const actionButton = event.target?.closest("[data-editor-action]");
1084
+ if (!actionButton) {
1085
+ return;
1086
+ }
1087
+ if (actionButton.dataset.editorAction === "add-event-pose") {
1088
+ const eventId = actionButton.dataset.editorEventId ||
1089
+ (selection?.kind === "event" || selection?.kind === "event-pose" ? selection.id ?? "" : "");
1090
+ if (!eventId) {
1091
+ return;
1092
+ }
1093
+ const nextDocument = addEventPose(atlasDocument, eventId);
1094
+ const createdEvent = nextDocument.events.find((entry) => entry.id === eventId);
1095
+ const createdPose = createdEvent?.positions.at(-1) ?? createdEvent?.positions[0];
1096
+ replaceAtlasDocument(nextDocument, true, createdPose
1097
+ ? { kind: "event-pose", id: eventId, key: createdPose.objectId }
1098
+ : { kind: "event", id: eventId });
1099
+ return;
1100
+ }
1101
+ }
920
1102
  function handleInspectorInput() {
921
1103
  applyInspectorState(false);
922
1104
  }
@@ -940,6 +1122,12 @@ export function createWorldOrbitEditor(container, options = {}) {
940
1122
  case "viewpoint":
941
1123
  replaceAtlasDocument(buildViewpointDocumentFromInspector(selection.id ?? ""), commitHistory, selection, false);
942
1124
  return;
1125
+ case "event":
1126
+ replaceAtlasDocument(buildEventDocumentFromInspector(selection.id ?? ""), commitHistory, selection, false);
1127
+ return;
1128
+ case "event-pose":
1129
+ replaceAtlasDocument(buildEventPoseDocumentFromInspector(selection.id ?? "", selection.key ?? ""), commitHistory, selection, false);
1130
+ return;
943
1131
  case "annotation":
944
1132
  replaceAtlasDocument(buildAnnotationDocumentFromInspector(selection.id ?? ""), commitHistory, selection, false);
945
1133
  return;
@@ -1010,6 +1198,7 @@ export function createWorldOrbitEditor(container, options = {}) {
1010
1198
  kind,
1011
1199
  objectId,
1012
1200
  pointerId: event.pointerId,
1201
+ path: selection ? { ...selection } : { kind: "object", id: objectId },
1013
1202
  startedFrom: createHistoryEntry(),
1014
1203
  changed: false,
1015
1204
  orbitRadiusContext: kind === "orbit-radius" && details
@@ -1022,8 +1211,8 @@ export function createWorldOrbitEditor(container, options = {}) {
1022
1211
  function handleWindowPointerMove(event) {
1023
1212
  if (!dragState ||
1024
1213
  dragState.pointerId !== event.pointerId ||
1025
- selection?.kind !== "object" ||
1026
- selection.id !== dragState.objectId) {
1214
+ !selection ||
1215
+ selectionKey(selection) !== selectionKey(dragState.path)) {
1027
1216
  return;
1028
1217
  }
1029
1218
  const details = viewer.getObjectDetails(dragState.objectId);
@@ -1035,27 +1224,27 @@ export function createWorldOrbitEditor(container, options = {}) {
1035
1224
  switch (dragState.kind) {
1036
1225
  case "orbit-phase":
1037
1226
  if (details.object.placement?.mode === "orbit" && details.orbit) {
1038
- nextDocument = updateOrbitPhase(atlasDocument, dragState.objectId, details, pointer);
1227
+ nextDocument = updateOrbitPhase(atlasDocument, dragState.path, dragState.objectId, details, pointer);
1039
1228
  }
1040
1229
  break;
1041
1230
  case "orbit-radius":
1042
1231
  if (details.object.placement?.mode === "orbit" && details.orbit) {
1043
- nextDocument = updateOrbitRadius(atlasDocument, dragState.objectId, details, pointer, dragState.orbitRadiusContext ?? null);
1232
+ nextDocument = updateOrbitRadius(atlasDocument, dragState.path, dragState.objectId, details, pointer, dragState.orbitRadiusContext ?? null);
1044
1233
  }
1045
1234
  break;
1046
1235
  case "at-reference":
1047
1236
  if (details.object.placement?.mode === "at") {
1048
- nextDocument = updateAtReference(atlasDocument, dragState.objectId, viewer.getScene(), pointer);
1237
+ nextDocument = updateAtReference(atlasDocument, dragState.path, dragState.objectId, viewer.getScene(), pointer);
1049
1238
  }
1050
1239
  break;
1051
1240
  case "surface-target":
1052
1241
  if (details.object.placement?.mode === "surface") {
1053
- nextDocument = updateSurfaceTarget(atlasDocument, dragState.objectId, viewer.getScene(), pointer);
1242
+ nextDocument = updateSurfaceTarget(atlasDocument, dragState.path, dragState.objectId, viewer.getScene(), pointer);
1054
1243
  }
1055
1244
  break;
1056
1245
  case "free-distance":
1057
1246
  if (details.object.placement?.mode === "free") {
1058
- nextDocument = updateFreeDistance(atlasDocument, dragState.objectId, viewer.getScene(), details, pointer);
1247
+ nextDocument = updateFreeDistance(atlasDocument, dragState.path, dragState.objectId, viewer.getScene(), details, pointer);
1059
1248
  }
1060
1249
  break;
1061
1250
  }
@@ -1087,7 +1276,7 @@ export function createWorldOrbitEditor(container, options = {}) {
1087
1276
  }
1088
1277
  history.push(dragState.startedFrom);
1089
1278
  future.length = 0;
1090
- canonicalSource = formatDocument(atlasDocument, { schema: "2.0" });
1279
+ canonicalSource = formatAtlasSource(atlasDocument);
1091
1280
  sourceText = canonicalSource;
1092
1281
  dragState = null;
1093
1282
  renderAll();
@@ -1189,15 +1378,18 @@ export function createWorldOrbitEditor(container, options = {}) {
1189
1378
  null,
1190
1379
  zoom: parseNullableNumber(readOptionalTextInput(form, "viewpoint-zoom")),
1191
1380
  rotationDeg: parseNullableNumber(readOptionalTextInput(form, "viewpoint-rotation")) ?? 0,
1381
+ camera: buildViewCameraFromForm(form),
1192
1382
  layers: {
1193
1383
  background: readCheckbox(form, "layer-background"),
1194
1384
  guides: readCheckbox(form, "layer-guides"),
1195
1385
  "orbits-back": readCheckbox(form, "layer-orbits-back"),
1196
1386
  "orbits-front": readCheckbox(form, "layer-orbits-front"),
1387
+ events: readCheckbox(form, "layer-events"),
1197
1388
  objects: readCheckbox(form, "layer-objects"),
1198
1389
  labels: readCheckbox(form, "layer-labels"),
1199
1390
  metadata: readCheckbox(form, "layer-metadata"),
1200
1391
  },
1392
+ events: splitTokens(readOptionalTextInput(form, "viewpoint-events")),
1201
1393
  filter: {
1202
1394
  query: readOptionalTextInput(form, "filter-query"),
1203
1395
  objectTypes: parseObjectTypes(readOptionalTextInput(form, "filter-object-types")),
@@ -1214,6 +1406,77 @@ export function createWorldOrbitEditor(container, options = {}) {
1214
1406
  }
1215
1407
  return nextDocument;
1216
1408
  }
1409
+ function buildEventDocumentFromInspector(currentId) {
1410
+ const nextDocument = cloneAtlasDocument(atlasDocument);
1411
+ const form = inspector?.querySelector("form[data-editor-form='event']");
1412
+ const current = nextDocument.events.find((entry) => entry.id === currentId);
1413
+ if (!form || !current) {
1414
+ return nextDocument;
1415
+ }
1416
+ const nextId = readTextInput(form, "event-id") || current.id;
1417
+ const replacement = {
1418
+ ...current,
1419
+ id: nextId,
1420
+ kind: readTextInput(form, "event-kind"),
1421
+ label: readTextInput(form, "event-label") || current.label,
1422
+ summary: readOptionalTextInput(form, "event-summary"),
1423
+ targetObjectId: readOptionalTextInput(form, "event-target"),
1424
+ participantObjectIds: splitTokens(readOptionalTextInput(form, "event-participants")),
1425
+ timing: readOptionalTextInput(form, "event-timing"),
1426
+ visibility: readOptionalTextInput(form, "event-visibility"),
1427
+ epoch: readOptionalTextInput(form, "event-epoch"),
1428
+ referencePlane: readOptionalTextInput(form, "event-referencePlane"),
1429
+ tags: splitTokens(readOptionalTextInput(form, "event-tags")),
1430
+ color: readOptionalTextInput(form, "event-color"),
1431
+ hidden: readCheckbox(form, "event-hidden"),
1432
+ };
1433
+ nextDocument.events = nextDocument.events
1434
+ .filter((entry) => entry.id !== current.id)
1435
+ .concat(replacement)
1436
+ .sort(compareEvents);
1437
+ syncEventViewpointReferences(nextDocument, current.id, replacement.id, splitTokens(readOptionalTextInput(form, "event-viewpoints")));
1438
+ if (current.id !== replacement.id) {
1439
+ selection = { kind: "event", id: replacement.id };
1440
+ }
1441
+ return nextDocument;
1442
+ }
1443
+ function buildEventPoseDocumentFromInspector(eventId, objectId) {
1444
+ const nextDocument = cloneAtlasDocument(atlasDocument);
1445
+ const form = inspector?.querySelector("form[data-editor-form='event-pose']");
1446
+ const eventEntry = nextDocument.events.find((entry) => entry.id === eventId);
1447
+ const currentPose = eventEntry?.positions.find((entry) => entry.objectId === objectId);
1448
+ if (!form || !eventEntry || !currentPose) {
1449
+ return nextDocument;
1450
+ }
1451
+ const nextObjectId = readTextInput(form, "pose-object-id") || currentPose.objectId;
1452
+ const replacement = {
1453
+ objectId: nextObjectId,
1454
+ placement: buildPlacementFromPoseForm(form, currentPose),
1455
+ epoch: readOptionalTextInput(form, "pose-epoch"),
1456
+ referencePlane: readOptionalTextInput(form, "pose-referencePlane"),
1457
+ };
1458
+ const inner = parseOptionalUnit(readOptionalTextInput(form, "prop-inner"));
1459
+ const outer = parseOptionalUnit(readOptionalTextInput(form, "prop-outer"));
1460
+ if (inner) {
1461
+ replacement.inner = inner;
1462
+ }
1463
+ if (outer) {
1464
+ replacement.outer = outer;
1465
+ }
1466
+ eventEntry.positions = eventEntry.positions
1467
+ .filter((entry) => entry.objectId !== currentPose.objectId)
1468
+ .concat(replacement)
1469
+ .sort(compareEventPoses);
1470
+ if (eventEntry.targetObjectId !== replacement.objectId &&
1471
+ !eventEntry.participantObjectIds.includes(replacement.objectId)) {
1472
+ eventEntry.participantObjectIds.push(replacement.objectId);
1473
+ eventEntry.participantObjectIds.sort((left, right) => left.localeCompare(right));
1474
+ }
1475
+ if (currentPose.objectId !== replacement.objectId) {
1476
+ selection = { kind: "event-pose", id: eventId, key: replacement.objectId };
1477
+ }
1478
+ return nextDocument;
1479
+ }
1217
1480
  function buildAnnotationDocumentFromInspector(currentId) {
1218
1481
  const nextDocument = cloneAtlasDocument(atlasDocument);
1219
1482
  const form = inspector?.querySelector("form[data-editor-form='annotation']");
@@ -1421,7 +1684,7 @@ function resolveInitialEditorState(options) {
1421
1684
  const atlasDocument = cloneAtlasDocument(options.atlasDocument);
1422
1685
  return {
1423
1686
  atlasDocument,
1424
- source: formatDocument(atlasDocument, { schema: "2.0" }),
1687
+ source: formatAtlasSource(atlasDocument),
1425
1688
  diagnostics: collectDocumentDiagnostics(atlasDocument),
1426
1689
  };
1427
1690
  }
@@ -1431,7 +1694,7 @@ function resolveInitialEditorState(options) {
1431
1694
  const atlasDocument = loaded.value.atlasDocument ?? upgradeDocumentToV2(loaded.value.document);
1432
1695
  return {
1433
1696
  atlasDocument,
1434
- source: formatDocument(atlasDocument, { schema: "2.0" }),
1697
+ source: formatAtlasSource(atlasDocument),
1435
1698
  diagnostics: mergeDiagnostics(resolveAtlasDiagnostics(atlasDocument, loaded.diagnostics), collectDocumentDiagnostics(atlasDocument)),
1436
1699
  };
1437
1700
  }
@@ -1439,10 +1702,13 @@ function resolveInitialEditorState(options) {
1439
1702
  const atlasDocument = createEmptyAtlasDocument("WorldOrbit");
1440
1703
  return {
1441
1704
  atlasDocument,
1442
- source: formatDocument(atlasDocument, { schema: "2.0" }),
1705
+ source: formatAtlasSource(atlasDocument),
1443
1706
  diagnostics: collectDocumentDiagnostics(atlasDocument),
1444
1707
  };
1445
1708
  }
1709
+ function formatAtlasSource(document) {
1710
+ return formatDocument(document, { schema: document.version });
1711
+ }
1446
1712
  function buildEditorMarkup() {
1447
1713
  const previewOpen = shouldPreviewSectionBeOpenByDefault();
1448
1714
  return `<section class="wo-editor-shell">
@@ -1563,6 +1829,17 @@ function renderOutlineButton(path, label, activeKey, diagnosticBuckets) {
1563
1829
  : "";
1564
1830
  return `<button type="button" class="wo-editor-outline-item${key === activeKey ? " is-active" : ""}" data-path-kind="${escapeHtml(path.kind)}"${path.id ? ` data-path-id="${escapeHtml(path.id)}"` : ""}${path.key ? ` data-path-key="${escapeHtml(path.key)}"` : ""}><span>${escapeHtml(label)}</span>${badge}</button>`;
1565
1831
  }
1832
+ function renderEventOutlineItems(eventEntry, activeKey, diagnosticBuckets) {
1833
+ return `<div class="wo-editor-outline-group">
1834
+ ${renderOutlineButton({ kind: "event", id: eventEntry.id }, eventEntry.label || eventEntry.id, activeKey, diagnosticBuckets)}
1835
+ ${eventEntry.positions.length > 0
1836
+ ? `<div class="wo-editor-outline-children">${[...eventEntry.positions]
1837
+ .sort(compareEventPoses)
1838
+ .map((pose) => renderOutlineButton({ kind: "event-pose", id: eventEntry.id, key: pose.objectId }, pose.objectId, activeKey, diagnosticBuckets))
1839
+ .join("")}</div>`
1840
+ : ""}
1841
+ </div>`;
1842
+ }
1566
1843
  function renderSystemInspector(formState) {
1567
1844
  return `<form class="wo-editor-form" data-editor-form="system">
1568
1845
  <h2>System</h2>
@@ -1577,6 +1854,8 @@ function renderDefaultsInspector(formState) {
1577
1854
  ${renderInspectorSection("defaults", "basics", "Basics", `${renderSelectField("Projection", "defaults-view", [
1578
1855
  ["topdown", "Topdown"],
1579
1856
  ["isometric", "Isometric"],
1857
+ ["orthographic", "Orthographic"],
1858
+ ["perspective", "Perspective"],
1580
1859
  ], defaults?.view ?? "topdown")}
1581
1860
  ${renderTextField("Scale preset", "defaults-scale", defaults?.scale ?? "")}
1582
1861
  ${renderTextField("Units", "defaults-units", defaults?.units ?? "")}
@@ -1613,6 +1892,8 @@ function renderViewpointInspector(formState, id) {
1613
1892
  ${renderSelectField("Projection", "viewpoint-projection", [
1614
1893
  ["topdown", "Topdown"],
1615
1894
  ["isometric", "Isometric"],
1895
+ ["orthographic", "Orthographic"],
1896
+ ["perspective", "Perspective"],
1616
1897
  ], viewpoint.projection)}
1617
1898
  ${renderSelectField("Preset", "viewpoint-preset", [
1618
1899
  ["", "Document default"],
@@ -1623,12 +1904,18 @@ function renderViewpointInspector(formState, id) {
1623
1904
  ], viewpoint.preset ?? "")}
1624
1905
  ${renderTextField("Zoom", "viewpoint-zoom", viewpoint.zoom === null ? "" : String(viewpoint.zoom))}
1625
1906
  ${renderTextField("Rotation", "viewpoint-rotation", String(viewpoint.rotationDeg))}`, true)}
1907
+ ${renderInspectorSection("viewpoint", "camera", "Camera", `${renderTextField("Azimuth", "viewpoint-camera-azimuth", viewpoint.camera?.azimuth === null || viewpoint.camera?.azimuth === undefined ? "" : String(viewpoint.camera.azimuth))}
1908
+ ${renderTextField("Elevation", "viewpoint-camera-elevation", viewpoint.camera?.elevation === null || viewpoint.camera?.elevation === undefined ? "" : String(viewpoint.camera.elevation))}
1909
+ ${renderTextField("Roll", "viewpoint-camera-roll", viewpoint.camera?.roll === null || viewpoint.camera?.roll === undefined ? "" : String(viewpoint.camera.roll))}
1910
+ ${renderTextField("Distance", "viewpoint-camera-distance", viewpoint.camera?.distance === null || viewpoint.camera?.distance === undefined ? "" : String(viewpoint.camera.distance))}
1911
+ <p class="wo-editor-inline-note">Rotation stays a 2D screen-rotation hint. The camera block stores Schema 2.5 view direction and framing.</p>`)}
1626
1912
  ${renderInspectorSection("viewpoint", "layers", "Layers", `<fieldset class="wo-editor-fieldset">
1627
1913
  <legend>Layers</legend>
1628
1914
  ${renderCheckboxField("Background", "layer-background", viewpoint.layers.background !== false)}
1629
1915
  ${renderCheckboxField("Guides", "layer-guides", viewpoint.layers.guides !== false)}
1630
1916
  ${renderCheckboxField("Orbits back", "layer-orbits-back", viewpoint.layers["orbits-back"] !== false)}
1631
1917
  ${renderCheckboxField("Orbits front", "layer-orbits-front", viewpoint.layers["orbits-front"] !== false)}
1918
+ ${renderCheckboxField("Events", "layer-events", viewpoint.layers.events !== false)}
1632
1919
  ${renderCheckboxField("Objects", "layer-objects", viewpoint.layers.objects !== false)}
1633
1920
  ${renderCheckboxField("Labels", "layer-labels", viewpoint.layers.labels !== false)}
1634
1921
  ${renderCheckboxField("Metadata", "layer-metadata", viewpoint.layers.metadata !== false)}
@@ -1636,7 +1923,88 @@ function renderViewpointInspector(formState, id) {
1636
1923
  ${renderInspectorSection("viewpoint", "filter", "Filter", `${renderTextField("Filter query", "filter-query", viewpoint.filter?.query ?? "")}
1637
1924
  ${renderTextField("Filter object types", "filter-object-types", viewpoint.filter?.objectTypes.join(" ") ?? "")}
1638
1925
  ${renderTextField("Filter tags", "filter-tags", viewpoint.filter?.tags.join(" ") ?? "")}
1639
- ${renderTextField("Filter groups", "filter-groups", viewpoint.filter?.groupIds.join(" ") ?? "")}`)}
1926
+ ${renderTextField("Filter groups", "filter-groups", viewpoint.filter?.groupIds.join(" ") ?? "")}
1927
+ ${renderTextField("Events", "viewpoint-events", viewpoint.events.join(" "))}`)}
1928
+ </form>`;
1929
+ }
1930
+ function renderEventInspector(formState, id) {
1931
+ const eventEntry = formState.events.find((entry) => entry.id === id);
1932
+ if (!eventEntry) {
1933
+ return `<p class="wo-editor-empty">Event not found.</p>`;
1934
+ }
1935
+ const linkedViewpoints = formState.viewpoints
1936
+ .filter((viewpoint) => viewpoint.events.includes(eventEntry.id))
1937
+ .map((viewpoint) => viewpoint.id)
1938
+ .join(" ");
1939
+ return `<form class="wo-editor-form" data-editor-form="event">
1940
+ <h2>Event</h2>
1941
+ ${renderInspectorSection("event", "basics", "Basics", `${renderTextField("ID", "event-id", eventEntry.id)}
1942
+ ${renderTextField("Kind", "event-kind", eventEntry.kind)}
1943
+ ${renderTextField("Label", "event-label", eventEntry.label)}
1944
+ ${renderTextAreaField("Summary", "event-summary", eventEntry.summary ?? "")}
1945
+ ${renderTextField("Target object", "event-target", eventEntry.targetObjectId ?? "")}
1946
+ ${renderTextField("Participants", "event-participants", eventEntry.participantObjectIds.join(" "))}
1947
+ ${renderTextField("Timing", "event-timing", eventEntry.timing ?? "")}
1948
+ ${renderTextField("Visibility", "event-visibility", eventEntry.visibility ?? "")}
1949
+ ${renderTextField("Epoch", "event-epoch", eventEntry.epoch ?? "")}
1950
+ ${renderTextField("Reference plane", "event-referencePlane", eventEntry.referencePlane ?? "")}
1951
+ ${renderTextField("Tags", "event-tags", eventEntry.tags.join(" "))}
1952
+ ${renderTextField("Color", "event-color", eventEntry.color ?? "")}
1953
+ ${renderCheckboxField("Hidden", "event-hidden", eventEntry.hidden === true)}`, true)}
1954
+ ${renderInspectorSection("event", "viewpoints", "Viewpoints", `${renderTextField("Viewpoints", "event-viewpoints", linkedViewpoints)}`)}
1955
+ ${renderInspectorSection("event", "positions", "Positions", `${eventEntry.positions.length > 0
1956
+ ? `<div class="wo-editor-inline-list">${eventEntry.positions
1957
+ .map((pose) => renderOutlineButton({ kind: "event-pose", id: eventEntry.id, key: pose.objectId }, pose.objectId, null, new Map()))
1958
+ .join("")}</div>`
1959
+ : `<p class="wo-editor-empty">No event poses yet.</p>`}
1960
+ <div class="wo-editor-inline-actions">
1961
+ <button type="button" data-editor-action="add-event-pose" data-editor-event-id="${escapeHtml(eventEntry.id)}">Add pose</button>
1962
+ </div>`)}
1963
+ </form>`;
1964
+ }
1965
+ function renderEventPoseInspector(formState, eventId, objectId) {
1966
+ const eventEntry = formState.events.find((entry) => entry.id === eventId);
1967
+ const pose = eventEntry?.positions.find((entry) => entry.objectId === objectId);
1968
+ if (!eventEntry || !pose) {
1969
+ return `<p class="wo-editor-empty">Event pose not found.</p>`;
1970
+ }
1971
+ const placementMode = pose.placement?.mode ?? "";
1972
+ const placementTarget = pose.placement?.mode === "orbit" || pose.placement?.mode === "surface" || pose.placement?.mode === "at"
1973
+ ? pose.placement.target
1974
+ : "";
1975
+ const freeValue = pose.placement?.mode === "free"
1976
+ ? pose.placement.distance
1977
+ ? formatUnitValue(pose.placement.distance)
1978
+ : pose.placement.descriptor ?? ""
1979
+ : "";
1980
+ return `<form class="wo-editor-form" data-editor-form="event-pose">
1981
+ <h2>Event Pose</h2>
1982
+ <p class="wo-editor-inline-note">Event <strong>${escapeHtml(eventEntry.label || eventEntry.id)}</strong></p>
1983
+ ${renderInspectorSection("event-pose", "identity", "Identity", `${renderTextField("Object", "pose-object-id", pose.objectId)}
1984
+ <div class="wo-editor-inline-actions">
1985
+ <button type="button" data-path-kind="event" data-path-id="${escapeHtml(eventEntry.id)}">Select event</button>
1986
+ </div>`, true)}
1987
+ ${renderInspectorSection("event-pose", "placement", "Placement", `${renderSelectField("Placement mode", "placement-mode", [
1988
+ ["", "None"],
1989
+ ["orbit", "Orbit"],
1990
+ ["at", "At"],
1991
+ ["surface", "Surface"],
1992
+ ["free", "Free"],
1993
+ ], placementMode)}
1994
+ ${renderTextField("Placement target", "placement-target", placementTarget)}
1995
+ ${renderTextField("Free value", "placement-free", freeValue)}
1996
+ ${renderTextField("Distance", "placement-distance", pose.placement?.mode === "orbit" && pose.placement.distance ? formatUnitValue(pose.placement.distance) : "")}
1997
+ ${renderTextField("Semi-major", "placement-semiMajor", pose.placement?.mode === "orbit" && pose.placement.semiMajor ? formatUnitValue(pose.placement.semiMajor) : "")}
1998
+ ${renderTextField("Eccentricity", "placement-eccentricity", pose.placement?.mode === "orbit" && pose.placement.eccentricity !== undefined ? String(pose.placement.eccentricity) : "")}
1999
+ ${renderTextField("Period", "placement-period", pose.placement?.mode === "orbit" && pose.placement.period ? formatUnitValue(pose.placement.period) : "")}
2000
+ ${renderTextField("Angle", "placement-angle", pose.placement?.mode === "orbit" && pose.placement.angle ? formatUnitValue(pose.placement.angle) : "")}
2001
+ ${renderTextField("Inclination", "placement-inclination", pose.placement?.mode === "orbit" && pose.placement.inclination ? formatUnitValue(pose.placement.inclination) : "")}
2002
+ ${renderTextField("Phase", "placement-phase", pose.placement?.mode === "orbit" && pose.placement.phase ? formatUnitValue(pose.placement.phase) : "")}
2003
+ ${renderTextField("Inner", "prop-inner", pose.inner ? formatUnitValue(pose.inner) : "")}
2004
+ ${renderTextField("Outer", "prop-outer", pose.outer ? formatUnitValue(pose.outer) : "")}`, true)}
2005
+ ${renderInspectorSection("event-pose", "context", "Context", `${renderTextField("Epoch", "pose-epoch", pose.epoch ?? "")}
2006
+ ${renderTextField("Reference plane", "pose-referencePlane", pose.referencePlane ?? "")}
2007
+ <p class="wo-editor-inline-note">Falls back to event, then object, then system context when left empty.</p>`)}
1640
2008
  </form>`;
1641
2009
  }
1642
2010
  function renderAnnotationInspector(formState, id) {
@@ -1749,6 +2117,12 @@ function readCheckbox(form, name) {
1749
2117
  return form.elements.namedItem(name)?.checked ?? false;
1750
2118
  }
1751
2119
  function buildPlacementFromForm(form, current) {
2120
+ return buildPlacementFromValues(form, current.placement, current.id);
2121
+ }
2122
+ function buildPlacementFromPoseForm(form, current) {
2123
+ return buildPlacementFromValues(form, current.placement, current.objectId);
2124
+ }
2125
+ function buildPlacementFromValues(form, currentPlacement, fallbackTarget) {
1752
2126
  const mode = readTextInput(form, "placement-mode");
1753
2127
  const target = readOptionalTextInput(form, "placement-target");
1754
2128
  switch (mode) {
@@ -1756,9 +2130,9 @@ function buildPlacementFromForm(form, current) {
1756
2130
  return {
1757
2131
  mode,
1758
2132
  target: target ??
1759
- (current.placement?.mode === "orbit"
1760
- ? current.placement.target
1761
- : current.id),
2133
+ (currentPlacement?.mode === "orbit"
2134
+ ? currentPlacement.target
2135
+ : fallbackTarget),
1762
2136
  distance: parseOptionalUnit(readOptionalTextInput(form, "placement-distance")),
1763
2137
  semiMajor: parseOptionalUnit(readOptionalTextInput(form, "placement-semiMajor")),
1764
2138
  eccentricity: parseNullableNumber(readOptionalTextInput(form, "placement-eccentricity")) ?? undefined,
@@ -1770,13 +2144,13 @@ function buildPlacementFromForm(form, current) {
1770
2144
  case "at":
1771
2145
  return {
1772
2146
  mode,
1773
- target: target ?? current.id,
1774
- reference: parseAtReferenceString(target ?? current.id),
2147
+ target: target ?? fallbackTarget,
2148
+ reference: parseAtReferenceString(target ?? fallbackTarget),
1775
2149
  };
1776
2150
  case "surface":
1777
2151
  return {
1778
2152
  mode,
1779
- target: target ?? current.id,
2153
+ target: target ?? fallbackTarget,
1780
2154
  };
1781
2155
  case "free": {
1782
2156
  const freeValue = readOptionalTextInput(form, "placement-free");
@@ -1826,6 +2200,20 @@ function parseNullableNumber(value) {
1826
2200
  const parsed = Number(value);
1827
2201
  return Number.isFinite(parsed) ? parsed : null;
1828
2202
  }
2203
+ function buildViewCameraFromForm(form) {
2204
+ const camera = {
2205
+ azimuth: parseNullableNumber(readOptionalTextInput(form, "viewpoint-camera-azimuth")),
2206
+ elevation: parseNullableNumber(readOptionalTextInput(form, "viewpoint-camera-elevation")),
2207
+ roll: parseNullableNumber(readOptionalTextInput(form, "viewpoint-camera-roll")),
2208
+ distance: parseNullableNumber(readOptionalTextInput(form, "viewpoint-camera-distance")),
2209
+ };
2210
+ return camera.azimuth !== null ||
2211
+ camera.elevation !== null ||
2212
+ camera.roll !== null ||
2213
+ camera.distance !== null
2214
+ ? camera
2215
+ : null;
2216
+ }
1829
2217
  function parseObjectTypes(value) {
1830
2218
  const tokens = splitTokens(value);
1831
2219
  return tokens.filter((token) => OBJECT_TYPES.includes(token));
@@ -1920,9 +2308,48 @@ function renameObjectReferences(document, fromId, toId) {
1920
2308
  annotation.sourceObjectId = toId;
1921
2309
  }
1922
2310
  }
2311
+ for (const eventEntry of document.events) {
2312
+ if (eventEntry.targetObjectId === fromId) {
2313
+ eventEntry.targetObjectId = toId;
2314
+ }
2315
+ eventEntry.participantObjectIds = eventEntry.participantObjectIds.map((entry) => entry === fromId ? toId : entry);
2316
+ for (const pose of eventEntry.positions) {
2317
+ if (pose.objectId === fromId) {
2318
+ pose.objectId = toId;
2319
+ }
2320
+ if (pose.placement?.mode === "orbit" && pose.placement.target === fromId) {
2321
+ pose.placement.target = toId;
2322
+ }
2323
+ if (pose.placement?.mode === "surface" && pose.placement.target === fromId) {
2324
+ pose.placement.target = toId;
2325
+ }
2326
+ if (pose.placement?.mode === "at") {
2327
+ const reference = pose.placement.reference;
2328
+ if (reference.kind === "anchor" && reference.objectId === fromId) {
2329
+ reference.objectId = toId;
2330
+ }
2331
+ if (reference.kind === "lagrange") {
2332
+ if (reference.primary === fromId) {
2333
+ reference.primary = toId;
2334
+ }
2335
+ if (reference.secondary === fromId) {
2336
+ reference.secondary = toId;
2337
+ }
2338
+ }
2339
+ pose.placement.target = formatAtReference(reference);
2340
+ }
2341
+ }
2342
+ eventEntry.positions.sort(compareEventPoses);
2343
+ }
1923
2344
  }
1924
2345
  function removeSelectedNode(document, selection) {
1925
2346
  const next = removeAtlasDocumentNode(document, selection);
2347
+ if (selection.kind === "event" && selection.id) {
2348
+ for (const viewpoint of next.system?.viewpoints ?? []) {
2349
+ viewpoint.events = viewpoint.events.filter((eventId) => eventId !== selection.id);
2350
+ }
2351
+ return next;
2352
+ }
1926
2353
  if (selection.kind !== "object" || !selection.id) {
1927
2354
  return next;
1928
2355
  }
@@ -1959,9 +2386,47 @@ function removeSelectedNode(document, selection) {
1959
2386
  annotation.sourceObjectId = null;
1960
2387
  }
1961
2388
  }
2389
+ for (const eventEntry of next.events) {
2390
+ if (eventEntry.targetObjectId === selection.id) {
2391
+ eventEntry.targetObjectId = null;
2392
+ }
2393
+ eventEntry.participantObjectIds = eventEntry.participantObjectIds.filter((entry) => entry !== selection.id);
2394
+ eventEntry.positions = eventEntry.positions.filter((entry) => entry.objectId !== selection.id);
2395
+ for (const pose of eventEntry.positions) {
2396
+ if (pose.placement?.mode === "orbit" && pose.placement.target === selection.id) {
2397
+ pose.placement = null;
2398
+ }
2399
+ if (pose.placement?.mode === "surface" && pose.placement.target === selection.id) {
2400
+ pose.placement = null;
2401
+ }
2402
+ if (pose.placement?.mode === "at") {
2403
+ const reference = pose.placement.reference;
2404
+ const touchesSelection = (reference.kind === "anchor" && reference.objectId === selection.id) ||
2405
+ (reference.kind === "lagrange" &&
2406
+ (reference.primary === selection.id || reference.secondary === selection.id));
2407
+ if (touchesSelection) {
2408
+ pose.placement = null;
2409
+ }
2410
+ }
2411
+ }
2412
+ }
1962
2413
  return next;
1963
2414
  }
1964
- function updateOrbitPhase(document, objectId, details, pointer) {
2415
+ function findEditablePlacementOwner(document, path, objectId) {
2416
+ if (path.kind === "event-pose" && path.id && path.key) {
2417
+ const pose = findEventPose(document, path.id, path.key);
2418
+ if (pose?.placement) {
2419
+ return { placement: pose.placement };
2420
+ }
2421
+ return null;
2422
+ }
2423
+ const object = findObject(document, objectId);
2424
+ if (object?.placement) {
2425
+ return { placement: object.placement };
2426
+ }
2427
+ return null;
2428
+ }
2429
+ function updateOrbitPhase(document, path, objectId, details, pointer) {
1965
2430
  const orbit = details.orbit;
1966
2431
  if (!orbit || details.object.placement?.mode !== "orbit") {
1967
2432
  return document;
@@ -1972,17 +2437,17 @@ function updateOrbitPhase(document, objectId, details, pointer) {
1972
2437
  const radians = Math.atan2((unrotated.y - orbit.cy) / Math.max(ry, 1), (unrotated.x - orbit.cx) / Math.max(rx, 1));
1973
2438
  const phaseDeg = normalizeDegrees((radians * 180) / Math.PI);
1974
2439
  const next = cloneAtlasDocument(document);
1975
- const object = next.objects.find((entry) => entry.id === objectId);
1976
- if (!object || object.placement?.mode !== "orbit") {
2440
+ const placementOwner = findEditablePlacementOwner(next, path, objectId);
2441
+ if (!placementOwner || placementOwner.placement.mode !== "orbit") {
1977
2442
  return document;
1978
2443
  }
1979
- object.placement.phase = {
2444
+ placementOwner.placement.phase = {
1980
2445
  value: roundNumber(phaseDeg, 2),
1981
2446
  unit: "deg",
1982
2447
  };
1983
2448
  return next;
1984
2449
  }
1985
- function updateOrbitRadius(document, objectId, details, pointer, dragContext) {
2450
+ function updateOrbitRadius(document, path, objectId, details, pointer, dragContext) {
1986
2451
  const orbit = details.orbit;
1987
2452
  if (!orbit || details.object.placement?.mode !== "orbit" || !dragContext) {
1988
2453
  return document;
@@ -1992,49 +2457,49 @@ function updateOrbitRadius(document, objectId, details, pointer, dragContext) {
1992
2457
  const nextBaseRadius = Math.max(nextDisplayedRadius - dragContext.radiusOffsetPx, dragContext.innerPx);
1993
2458
  const nextMetric = orbitRadiusPxToMetric(nextBaseRadius, dragContext.innerPx, dragContext.stepPx);
1994
2459
  const next = cloneAtlasDocument(document);
1995
- const object = next.objects.find((entry) => entry.id === objectId);
1996
- if (!object || object.placement?.mode !== "orbit") {
2460
+ const placementOwner = findEditablePlacementOwner(next, path, objectId);
2461
+ if (!placementOwner || placementOwner.placement.mode !== "orbit") {
1997
2462
  return document;
1998
2463
  }
1999
- const currentValue = object.placement.semiMajor ??
2000
- object.placement.distance ?? {
2464
+ const currentValue = placementOwner.placement.semiMajor ??
2465
+ placementOwner.placement.distance ?? {
2001
2466
  value: 1,
2002
2467
  unit: "au",
2003
2468
  };
2004
2469
  const scaled = distanceMetricToUnitValue(Math.max(nextMetric, 0), dragContext.preferredUnit ?? currentValue.unit);
2005
- if (object.placement.semiMajor) {
2006
- object.placement.semiMajor = scaled;
2470
+ if (placementOwner.placement.semiMajor) {
2471
+ placementOwner.placement.semiMajor = scaled;
2007
2472
  }
2008
2473
  else {
2009
- object.placement.distance = scaled;
2474
+ placementOwner.placement.distance = scaled;
2010
2475
  }
2011
2476
  return next;
2012
2477
  }
2013
- function updateAtReference(document, objectId, scene, pointer) {
2478
+ function updateAtReference(document, path, objectId, scene, pointer) {
2014
2479
  const candidate = findNearestAtCandidate(scene, objectId, pointer);
2015
2480
  if (!candidate) {
2016
2481
  return document;
2017
2482
  }
2018
2483
  const next = cloneAtlasDocument(document);
2019
- const object = next.objects.find((entry) => entry.id === objectId);
2020
- if (!object || object.placement?.mode !== "at") {
2484
+ const placementOwner = findEditablePlacementOwner(next, path, objectId);
2485
+ if (!placementOwner || placementOwner.placement.mode !== "at") {
2021
2486
  return document;
2022
2487
  }
2023
- object.placement.reference = candidate.reference;
2024
- object.placement.target = formatAtReference(candidate.reference);
2488
+ placementOwner.placement.reference = candidate.reference;
2489
+ placementOwner.placement.target = formatAtReference(candidate.reference);
2025
2490
  return next;
2026
2491
  }
2027
- function updateSurfaceTarget(document, objectId, scene, pointer) {
2492
+ function updateSurfaceTarget(document, path, objectId, scene, pointer) {
2028
2493
  const target = findNearestSceneObject(scene, objectId, pointer, (entry) => SURFACE_TARGET_TYPES.has(entry.object.type));
2029
2494
  if (!target) {
2030
2495
  return document;
2031
2496
  }
2032
2497
  const next = cloneAtlasDocument(document);
2033
- const object = next.objects.find((entry) => entry.id === objectId);
2034
- if (!object || object.placement?.mode !== "surface") {
2498
+ const placementOwner = findEditablePlacementOwner(next, path, objectId);
2499
+ if (!placementOwner || placementOwner.placement.mode !== "surface") {
2035
2500
  return document;
2036
2501
  }
2037
- object.placement.target = target.objectId;
2502
+ placementOwner.placement.target = target.objectId;
2038
2503
  return next;
2039
2504
  }
2040
2505
  function createOrbitRadiusDragContext(document, scene, details) {
@@ -2042,8 +2507,9 @@ function createOrbitRadiusDragContext(document, scene, details) {
2042
2507
  return null;
2043
2508
  }
2044
2509
  const targetId = details.object.placement.target;
2045
- const siblingCount = document.objects.filter((entry) => entry.placement?.mode === "orbit" &&
2046
- entry.placement.target === targetId).length;
2510
+ const siblingCount = scene.objects.filter((entry) => entry.object.placement?.mode === "orbit" &&
2511
+ entry.object.placement.target === targetId &&
2512
+ !entry.hidden).length;
2047
2513
  const spacingFactor = layoutPresetSpacingForScene(scene.layoutPreset);
2048
2514
  const stepPx = (siblingCount > 2 ? 54 : 64) * spacingFactor * scene.scaleModel.orbitDistanceMultiplier;
2049
2515
  const innerPx = details.parent.radius +
@@ -2059,28 +2525,28 @@ function createOrbitRadiusDragContext(document, scene, details) {
2059
2525
  preferredUnit: currentValue?.unit ?? null,
2060
2526
  };
2061
2527
  }
2062
- function updateFreeDistance(document, objectId, scene, details, pointer) {
2528
+ function updateFreeDistance(document, path, objectId, scene, details, pointer) {
2063
2529
  if (details.object.placement?.mode !== "free") {
2064
2530
  return document;
2065
2531
  }
2066
2532
  const railX = scene.width - scene.padding - 140;
2067
2533
  const offsetPx = Math.max(0, railX - pointer.x);
2068
2534
  const next = cloneAtlasDocument(document);
2069
- const object = next.objects.find((entry) => entry.id === objectId);
2070
- if (!object || object.placement?.mode !== "free") {
2535
+ const placementOwner = findEditablePlacementOwner(next, path, objectId);
2536
+ if (!placementOwner || placementOwner.placement.mode !== "free") {
2071
2537
  return document;
2072
2538
  }
2073
- const preferredUnit = normalizeFreeDistanceUnit(object.placement.distance?.unit ?? null);
2539
+ const preferredUnit = normalizeFreeDistanceUnit(placementOwner.placement.distance?.unit ?? null);
2074
2540
  const metric = offsetPx / Math.max(FREE_DISTANCE_PIXEL_FACTOR * scene.scaleModel.freePlacementMultiplier, 1);
2075
2541
  if (metric < 0.01) {
2076
- object.placement.distance = undefined;
2077
- if (!object.placement.descriptor) {
2078
- delete object.placement.descriptor;
2542
+ placementOwner.placement.distance = undefined;
2543
+ if (!placementOwner.placement.descriptor) {
2544
+ delete placementOwner.placement.descriptor;
2079
2545
  }
2080
2546
  return next;
2081
2547
  }
2082
- object.placement.distance = distanceMetricToUnitValue(metric, preferredUnit);
2083
- delete object.placement.descriptor;
2548
+ placementOwner.placement.distance = distanceMetricToUnitValue(metric, preferredUnit);
2549
+ delete placementOwner.placement.descriptor;
2084
2550
  return next;
2085
2551
  }
2086
2552
  function findNearestSceneObject(scene, selectedObjectId, pointer, predicate = () => true) {
@@ -2356,9 +2822,85 @@ function mapDiagnosticFieldToInputNames(selection, field) {
2356
2822
  return ["viewpoint-zoom"];
2357
2823
  case "rotationDeg":
2358
2824
  return ["viewpoint-rotation"];
2825
+ case "camera":
2826
+ return [
2827
+ "viewpoint-camera-azimuth",
2828
+ "viewpoint-camera-elevation",
2829
+ "viewpoint-camera-roll",
2830
+ "viewpoint-camera-distance",
2831
+ ];
2832
+ case "camera.azimuth":
2833
+ return ["viewpoint-camera-azimuth"];
2834
+ case "camera.elevation":
2835
+ return ["viewpoint-camera-elevation"];
2836
+ case "camera.roll":
2837
+ return ["viewpoint-camera-roll"];
2838
+ case "camera.distance":
2839
+ return ["viewpoint-camera-distance"];
2840
+ case "events":
2841
+ return ["viewpoint-events"];
2842
+ default:
2843
+ return [];
2844
+ }
2845
+ case "event":
2846
+ switch (field) {
2847
+ case "id":
2848
+ return ["event-id"];
2849
+ case "kind":
2850
+ return ["event-kind"];
2851
+ case "label":
2852
+ return ["event-label"];
2853
+ case "summary":
2854
+ return ["event-summary"];
2855
+ case "targetObjectId":
2856
+ case "target":
2857
+ return ["event-target"];
2858
+ case "participantObjectIds":
2859
+ case "participants":
2860
+ return ["event-participants"];
2861
+ case "timing":
2862
+ return ["event-timing"];
2863
+ case "visibility":
2864
+ return ["event-visibility"];
2865
+ case "epoch":
2866
+ return ["event-epoch"];
2867
+ case "referencePlane":
2868
+ return ["event-referencePlane"];
2869
+ case "tags":
2870
+ return ["event-tags"];
2871
+ case "color":
2872
+ return ["event-color"];
2873
+ case "hidden":
2874
+ return ["event-hidden"];
2359
2875
  default:
2360
2876
  return [];
2361
2877
  }
2878
+ case "event-pose":
2879
+ if (field === "objectId") {
2880
+ return ["pose-object-id"];
2881
+ }
2882
+ if (field === "placement") {
2883
+ return ["placement-mode"];
2884
+ }
2885
+ if (field === "reference" || field === "target") {
2886
+ return ["placement-target"];
2887
+ }
2888
+ if (field === "descriptor") {
2889
+ return ["placement-free"];
2890
+ }
2891
+ if (PLACEMENT_DIAGNOSTIC_FIELDS.has(field)) {
2892
+ return [`placement-${field}`];
2893
+ }
2894
+ if (field === "inner" || field === "outer") {
2895
+ return [`prop-${field}`];
2896
+ }
2897
+ if (field === "epoch") {
2898
+ return ["pose-epoch"];
2899
+ }
2900
+ if (field === "referencePlane") {
2901
+ return ["pose-referencePlane"];
2902
+ }
2903
+ return [];
2362
2904
  case "annotation":
2363
2905
  switch (field) {
2364
2906
  case "id":
@@ -2444,6 +2986,10 @@ function describePath(path) {
2444
2986
  return `Metadata: ${path.key ?? ""}`;
2445
2987
  case "group":
2446
2988
  return `Group: ${path.id ?? ""}`;
2989
+ case "event":
2990
+ return `Event: ${path.id ?? ""}`;
2991
+ case "event-pose":
2992
+ return `Event Pose: ${path.id ?? ""} / ${path.key ?? ""}`;
2447
2993
  case "object":
2448
2994
  return `Object: ${path.id ?? ""}`;
2449
2995
  case "viewpoint":
@@ -2455,11 +3001,72 @@ function describePath(path) {
2455
3001
  }
2456
3002
  }
2457
3003
  function selectionKey(path) {
2458
- return path ? `${path.kind}:${path.id ?? path.key ?? ""}` : null;
3004
+ return path ? `${path.kind}:${path.id ?? ""}:${path.key ?? ""}` : null;
3005
+ }
3006
+ function selectionEventId(path) {
3007
+ if (!path) {
3008
+ return null;
3009
+ }
3010
+ return path.kind === "event" || path.kind === "event-pose" ? path.id ?? null : null;
2459
3011
  }
2460
3012
  function compareObjects(left, right) {
2461
3013
  return left.id.localeCompare(right.id);
2462
3014
  }
3015
+ function compareEvents(left, right) {
3016
+ return left.id.localeCompare(right.id);
3017
+ }
3018
+ function compareEventPoses(left, right) {
3019
+ return left.objectId.localeCompare(right.objectId);
3020
+ }
3021
+ function findEvent(document, eventId) {
3022
+ return document.events.find((entry) => entry.id === eventId) ?? null;
3023
+ }
3024
+ function findEventPose(document, eventId, objectId) {
3025
+ return findEvent(document, eventId)?.positions.find((entry) => entry.objectId === objectId) ?? null;
3026
+ }
3027
+ function findObject(document, objectId) {
3028
+ return document.objects.find((entry) => entry.id === objectId) ?? null;
3029
+ }
3030
+ function addEventPose(document, eventId) {
3031
+ const next = cloneAtlasDocument(document);
3032
+ const eventEntry = next.events.find((entry) => entry.id === eventId);
3033
+ if (!eventEntry) {
3034
+ return document;
3035
+ }
3036
+ const baseObject = next.objects.find((object) => !eventEntry.positions.some((pose) => pose.objectId === object.id)) ??
3037
+ next.objects[0];
3038
+ if (!baseObject) {
3039
+ return document;
3040
+ }
3041
+ if (eventEntry.targetObjectId !== baseObject.id &&
3042
+ !eventEntry.participantObjectIds.includes(baseObject.id)) {
3043
+ eventEntry.participantObjectIds.push(baseObject.id);
3044
+ eventEntry.participantObjectIds.sort((left, right) => left.localeCompare(right));
3045
+ }
3046
+ eventEntry.positions.push(createEventPoseFromObject(baseObject));
3047
+ eventEntry.positions.sort(compareEventPoses);
3048
+ return next;
3049
+ }
3050
+ function createEventPoseFromObject(object) {
3051
+ return {
3052
+ objectId: object.id,
3053
+ placement: object.placement ? structuredClone(object.placement) : null,
3054
+ inner: readUnitValue(object.properties.inner),
3055
+ outer: readUnitValue(object.properties.outer),
3056
+ };
3057
+ }
3058
+ function syncEventViewpointReferences(document, previousEventId, nextEventId, viewpointIds) {
3059
+ const desired = new Set(viewpointIds);
3060
+ for (const viewpoint of document.system?.viewpoints ?? []) {
3061
+ const currentIds = new Set(viewpoint.events);
3062
+ currentIds.delete(previousEventId);
3063
+ currentIds.delete(nextEventId);
3064
+ if (desired.has(viewpoint.id)) {
3065
+ currentIds.add(nextEventId);
3066
+ }
3067
+ viewpoint.events = [...currentIds].sort((left, right) => left.localeCompare(right));
3068
+ }
3069
+ }
2463
3070
  function createUniqueId(prefix, existing) {
2464
3071
  const safePrefix = prefix.trim() || "item";
2465
3072
  let counter = 1;
@@ -2494,6 +3101,9 @@ function readUnitProperty(value) {
2494
3101
  ? formatUnitValue(value)
2495
3102
  : "";
2496
3103
  }
3104
+ function readUnitValue(value) {
3105
+ return value && typeof value === "object" && "value" in value ? value : undefined;
3106
+ }
2497
3107
  function readNumberProperty(value) {
2498
3108
  return typeof value === "number" ? String(value) : "";
2499
3109
  }
@@ -2803,6 +3413,12 @@ function installEditorStyles() {
2803
3413
  .wo-editor-overlay-diagnostic-warning { border: 1px solid rgba(240, 180, 100, 0.24); }
2804
3414
  .wo-editor-outline { display: grid; gap: 14px; }
2805
3415
  .wo-editor-outline-section { display: grid; gap: 8px; }
3416
+ .wo-editor-outline-group { display: grid; gap: 6px; }
3417
+ .wo-editor-outline-children {
3418
+ display: grid;
3419
+ gap: 6px;
3420
+ padding-left: 16px;
3421
+ }
2806
3422
  .wo-editor-outline-section h3 {
2807
3423
  margin: 0;
2808
3424
  color: rgba(237,246,255,0.68);
@@ -2842,6 +3458,27 @@ function installEditorStyles() {
2842
3458
  background: rgba(255, 120, 120, 0.18);
2843
3459
  color: #ffb2b2;
2844
3460
  }
3461
+ .wo-editor-inline-list { display: grid; gap: 8px; }
3462
+ .wo-editor-inline-actions {
3463
+ display: flex;
3464
+ flex-wrap: wrap;
3465
+ gap: 10px;
3466
+ margin-top: 12px;
3467
+ }
3468
+ .wo-editor-inline-actions button {
3469
+ border: 1px solid rgba(255,255,255,0.14);
3470
+ border-radius: 999px;
3471
+ background: rgba(255,255,255,0.06);
3472
+ color: #edf6ff;
3473
+ cursor: pointer;
3474
+ font: 600 12px/1.2 "Segoe UI Variable", "Segoe UI", sans-serif;
3475
+ padding: 8px 12px;
3476
+ }
3477
+ .wo-editor-inline-note {
3478
+ margin: 0 0 12px;
3479
+ color: rgba(237,246,255,0.72);
3480
+ font: 500 12px/1.5 "Segoe UI Variable", "Segoe UI", sans-serif;
3481
+ }
2845
3482
  .wo-editor-diagnostics { display: grid; gap: 10px; }
2846
3483
  .wo-editor-diagnostic {
2847
3484
  display: grid;