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
@@ -86,6 +86,34 @@ const FIELD_HELP = {
86
86
  description: "Rotates the saved camera angle in degrees.",
87
87
  references: ["90deg = quarter turn", "180deg = flip"],
88
88
  },
89
+ "viewpoint-events": {
90
+ description: "Lists event IDs that this viewpoint should feature in its detail panel.",
91
+ references: ["solar-eclipse-naar", "transit-window conjunction"],
92
+ },
93
+ "event-kind": {
94
+ description: "Short semantic event type for tooling and viewer overlays.",
95
+ references: ["solar-eclipse", "lunar-eclipse", "transit"],
96
+ },
97
+ "event-target": {
98
+ description: "Primary object this event is centered on.",
99
+ references: ["Naar", "Seyra"],
100
+ },
101
+ "event-participants": {
102
+ description: "Objects that participate in the event snapshot or description.",
103
+ references: ["Iyath Naar Seyra", "Naar Seyra Orun"],
104
+ },
105
+ "event-timing": {
106
+ description: "Free-text timing note for the event.",
107
+ references: ['"Every late bloom season"', '"At local midyear"'],
108
+ },
109
+ "event-visibility": {
110
+ description: "Notes where or how the event is visible.",
111
+ references: ['"Visible from Naar"', '"Southern hemisphere only"'],
112
+ },
113
+ "event-viewpoints": {
114
+ description: "Viewpoint IDs that should list this event prominently.",
115
+ references: ["naar-system", "overview inner-system"],
116
+ },
89
117
  "placement-target": {
90
118
  description: "Names the body or reference this object is attached to.",
91
119
  references: ["orbit Primary", "surface Homeworld", "at Naar:L4"],
@@ -206,12 +234,25 @@ export function createWorldOrbitEditor(container, options = {}) {
206
234
  minimap: true,
207
235
  tooltipMode: "hover",
208
236
  onSelectionChange(selectedObject) {
237
+ const activeEventId = selection ? selectionEventId(selection) : null;
209
238
  if (ignoreViewerSelection || !selectedObject) {
210
- if (!ignoreViewerSelection && selection?.kind === "object") {
211
- setSelection(null, false, true);
239
+ if (!ignoreViewerSelection) {
240
+ if (selection?.kind === "event-pose" && selection.id) {
241
+ setSelection({ kind: "event", id: selection.id }, false, true);
242
+ }
243
+ else if (selection?.kind === "object") {
244
+ setSelection(activeEventId ? { kind: "event", id: activeEventId } : null, false, true);
245
+ }
246
+ else if (selection?.kind === "event" && selection.id) {
247
+ setSelection({ kind: "event", id: selection.id }, false, true);
248
+ }
212
249
  }
213
250
  return;
214
251
  }
252
+ if (activeEventId && findEventPose(atlasDocument, activeEventId, selectedObject.objectId)) {
253
+ setSelection({ kind: "event-pose", id: activeEventId, key: selectedObject.objectId }, false, true);
254
+ return;
255
+ }
215
256
  setSelection({ kind: "object", id: selectedObject.objectId }, false, true);
216
257
  },
217
258
  onViewChange() {
@@ -221,6 +262,7 @@ export function createWorldOrbitEditor(container, options = {}) {
221
262
  toolbar.addEventListener("click", handleToolbarClick);
222
263
  outline.addEventListener("click", handleOutlineClick);
223
264
  overlay.addEventListener("pointerdown", handleOverlayPointerDown);
265
+ inspector?.addEventListener("click", handleInspectorClick);
224
266
  inspector?.addEventListener("input", handleInspectorInput);
225
267
  inspector?.addEventListener("change", handleInspectorChange);
226
268
  sourcePane?.addEventListener("input", handleSourceInput);
@@ -295,6 +337,28 @@ export function createWorldOrbitEditor(container, options = {}) {
295
337
  replaceAtlasDocument(nextDocument, true, { kind: "object", id });
296
338
  return id;
297
339
  },
340
+ addEvent() {
341
+ const id = createUniqueId("event", atlasDocument.events.map((event) => event.id));
342
+ const created = {
343
+ id,
344
+ kind: "",
345
+ label: humanizeIdentifier(id),
346
+ summary: null,
347
+ targetObjectId: null,
348
+ participantObjectIds: [],
349
+ timing: null,
350
+ visibility: null,
351
+ tags: [],
352
+ color: null,
353
+ hidden: false,
354
+ positions: [],
355
+ };
356
+ const nextDocument = cloneAtlasDocument(atlasDocument);
357
+ nextDocument.events.push(created);
358
+ nextDocument.events.sort(compareEvents);
359
+ replaceAtlasDocument(nextDocument, true, { kind: "event", id });
360
+ return id;
361
+ },
298
362
  addViewpoint() {
299
363
  const id = createUniqueId("viewpoint", atlasDocument.system?.viewpoints.map((viewpoint) => viewpoint.id) ?? []);
300
364
  const created = {
@@ -303,6 +367,7 @@ export function createWorldOrbitEditor(container, options = {}) {
303
367
  summary: "",
304
368
  focusObjectId: null,
305
369
  selectedObjectId: null,
370
+ events: [],
306
371
  projection: atlasDocument.system?.defaults.view ?? "topdown",
307
372
  preset: atlasDocument.system?.defaults.preset ?? null,
308
373
  zoom: null,
@@ -363,6 +428,7 @@ export function createWorldOrbitEditor(container, options = {}) {
363
428
  toolbar.removeEventListener("click", handleToolbarClick);
364
429
  outline.removeEventListener("click", handleOutlineClick);
365
430
  overlay.removeEventListener("pointerdown", handleOverlayPointerDown);
431
+ inspector?.removeEventListener("click", handleInspectorClick);
366
432
  inspector?.removeEventListener("input", handleInspectorInput);
367
433
  inspector?.removeEventListener("change", handleInspectorChange);
368
434
  sourcePane?.removeEventListener("input", handleSourceInput);
@@ -408,7 +474,7 @@ export function createWorldOrbitEditor(container, options = {}) {
408
474
  }
409
475
  function getCurrentSourceForExport() {
410
476
  if (dragState?.changed) {
411
- return formatDocument(atlasDocument, { schema: "2.0" });
477
+ return formatAtlasSource(atlasDocument);
412
478
  }
413
479
  return canonicalSource;
414
480
  }
@@ -447,7 +513,7 @@ export function createWorldOrbitEditor(container, options = {}) {
447
513
  }
448
514
  clearSourceInputTimer();
449
515
  atlasDocument = cloneAtlasDocument(nextDocument);
450
- canonicalSource = formatDocument(atlasDocument, { schema: "2.0" });
516
+ canonicalSource = formatAtlasSource(atlasDocument);
451
517
  if (!preserveSourceText) {
452
518
  sourceText = canonicalSource;
453
519
  }
@@ -488,10 +554,10 @@ export function createWorldOrbitEditor(container, options = {}) {
488
554
  if (commitHistory) {
489
555
  history.push(createHistoryEntry());
490
556
  future.length = 0;
491
- sourceText = formatDocument(nextDocument, { schema: "2.0" });
557
+ sourceText = formatAtlasSource(nextDocument);
492
558
  }
493
559
  atlasDocument = cloneAtlasDocument(nextDocument);
494
- canonicalSource = formatDocument(atlasDocument, { schema: "2.0" });
560
+ canonicalSource = formatAtlasSource(atlasDocument);
495
561
  diagnostics = mergeDiagnostics(loadedDiagnostics, collectDocumentDiagnostics(atlasDocument));
496
562
  selection = normalizeSelection(selection);
497
563
  syncViewer({
@@ -510,11 +576,15 @@ export function createWorldOrbitEditor(container, options = {}) {
510
576
  const previousState = viewer.getState();
511
577
  const currentRenderOptions = viewer.getRenderOptions();
512
578
  const nextPreset = atlasDocument.system?.defaults.preset ?? "atlas-card";
579
+ const nextActiveEventId = selection ? selectionEventId(selection) : null;
513
580
  ignoreViewerSelection = true;
514
- if (currentRenderOptions.preset !== nextPreset || currentRenderOptions.projection !== "document") {
581
+ if (currentRenderOptions.preset !== nextPreset ||
582
+ currentRenderOptions.projection !== "document" ||
583
+ (currentRenderOptions.activeEventId ?? null) !== nextActiveEventId) {
515
584
  viewer.setRenderOptions({
516
585
  preset: nextPreset,
517
586
  projection: "document",
587
+ activeEventId: nextActiveEventId,
518
588
  });
519
589
  }
520
590
  viewer.setDocument(materializeAtlasDocument(atlasDocument));
@@ -524,12 +594,19 @@ export function createWorldOrbitEditor(container, options = {}) {
524
594
  else if (options.preserveCamera !== false) {
525
595
  viewer.setState({
526
596
  ...previousState,
527
- selectedObjectId: selection?.kind === "object" ? selection.id ?? null : null,
597
+ selectedObjectId: selection?.kind === "object"
598
+ ? selection.id ?? null
599
+ : selection?.kind === "event-pose"
600
+ ? selection.key ?? null
601
+ : null,
528
602
  });
529
603
  }
530
604
  else if (selection?.kind === "object" && selection.id) {
531
605
  viewer.focusObject(selection.id);
532
606
  }
607
+ else if (selection?.kind === "event-pose" && selection.key) {
608
+ viewer.focusObject(selection.key);
609
+ }
533
610
  ignoreViewerSelection = false;
534
611
  }
535
612
  function emitSnapshot() {
@@ -547,9 +624,13 @@ export function createWorldOrbitEditor(container, options = {}) {
547
624
  selection = normalizeSelection(nextSelection);
548
625
  if (syncViewerSelection) {
549
626
  ignoreViewerSelection = true;
627
+ viewer.setRenderOptions({ activeEventId: selection ? selectionEventId(selection) : null });
550
628
  if (selection?.kind === "object" && selection.id) {
551
629
  viewer.focusObject(selection.id);
552
630
  }
631
+ else if (selection?.kind === "event-pose" && selection.key) {
632
+ viewer.focusObject(selection.key);
633
+ }
553
634
  else if (selection?.kind === "viewpoint" && selection.id) {
554
635
  viewer.goToViewpoint(selection.id);
555
636
  }
@@ -604,6 +685,7 @@ export function createWorldOrbitEditor(container, options = {}) {
604
685
  ${OBJECT_TYPES.map((type) => `<option value="${escapeHtml(type)}"${type === objectType ? " selected" : ""}>${escapeHtml(humanizeIdentifier(type))}</option>`).join("")}
605
686
  </select>
606
687
  <button type="button" data-editor-action="add-object">Add object</button>
688
+ <button type="button" data-editor-action="add-event">Add event</button>
607
689
  <button type="button" data-editor-action="add-viewpoint">Add viewpoint</button>
608
690
  <button type="button" data-editor-action="add-annotation">Add annotation</button>
609
691
  <button type="button" data-editor-action="add-metadata">Add metadata</button>
@@ -650,6 +732,14 @@ export function createWorldOrbitEditor(container, options = {}) {
650
732
  .join("")
651
733
  : `<p class="wo-editor-empty">No annotations yet.</p>`}
652
734
  </div>
735
+ <div class="wo-editor-outline-section">
736
+ <h3>Events</h3>
737
+ ${atlasDocument.events.length > 0
738
+ ? atlasDocument.events
739
+ .map((eventEntry) => renderEventOutlineItems(eventEntry, activeKey, diagnosticBuckets))
740
+ .join("")
741
+ : `<p class="wo-editor-empty">No events yet.</p>`}
742
+ </div>
653
743
  <div class="wo-editor-outline-section">
654
744
  <h3>Objects</h3>
655
745
  ${atlasDocument.objects.length > 0
@@ -701,6 +791,7 @@ export function createWorldOrbitEditor(container, options = {}) {
701
791
  selection: selection ? { path: { ...selection } } : null,
702
792
  system: atlasDocument.system,
703
793
  viewpoints: atlasDocument.system?.viewpoints ?? [],
794
+ events: atlasDocument.events,
704
795
  objects: atlasDocument.objects,
705
796
  };
706
797
  if (!selection) {
@@ -729,6 +820,17 @@ export function createWorldOrbitEditor(container, options = {}) {
729
820
  applyInspectorSectionState(inspector, inspectorSectionState);
730
821
  decorateInspectorDiagnostics(selection, diagnostics);
731
822
  return;
823
+ case "event":
824
+ inspector.innerHTML = diagnosticSummary + renderEventInspector(formState, selection.id ?? "");
825
+ applyInspectorSectionState(inspector, inspectorSectionState);
826
+ decorateInspectorDiagnostics(selection, diagnostics);
827
+ return;
828
+ case "event-pose":
829
+ inspector.innerHTML =
830
+ diagnosticSummary + renderEventPoseInspector(formState, selection.id ?? "", selection.key ?? "");
831
+ applyInspectorSectionState(inspector, inspectorSectionState);
832
+ decorateInspectorDiagnostics(selection, diagnostics);
833
+ return;
732
834
  case "annotation":
733
835
  inspector.innerHTML = diagnosticSummary + renderAnnotationInspector(formState, selection.id ?? "");
734
836
  applyInspectorSectionState(inspector, inspectorSectionState);
@@ -761,10 +863,15 @@ export function createWorldOrbitEditor(container, options = {}) {
761
863
  return;
762
864
  }
763
865
  overlay.innerHTML = "";
764
- if (selection?.kind !== "object" || !selection.id) {
866
+ const selectedObjectId = selection?.kind === "object"
867
+ ? selection.id ?? null
868
+ : selection?.kind === "event-pose"
869
+ ? selection.key ?? null
870
+ : null;
871
+ if (!selectedObjectId) {
765
872
  return;
766
873
  }
767
- const details = viewer.getObjectDetails(selection.id);
874
+ const details = viewer.getObjectDetails(selectedObjectId);
768
875
  if (!details) {
769
876
  return;
770
877
  }
@@ -883,6 +990,9 @@ export function createWorldOrbitEditor(container, options = {}) {
883
990
  case "add-viewpoint":
884
991
  api.addViewpoint();
885
992
  return;
993
+ case "add-event":
994
+ api.addEvent();
995
+ return;
886
996
  case "add-annotation":
887
997
  api.addAnnotation();
888
998
  return;
@@ -917,6 +1027,35 @@ export function createWorldOrbitEditor(container, options = {}) {
917
1027
  key: button.dataset.pathKey || undefined,
918
1028
  }, true, true);
919
1029
  }
1030
+ function handleInspectorClick(event) {
1031
+ const pathButton = event.target?.closest("[data-path-kind]");
1032
+ if (pathButton) {
1033
+ setSelection({
1034
+ kind: pathButton.dataset.pathKind,
1035
+ id: pathButton.dataset.pathId || undefined,
1036
+ key: pathButton.dataset.pathKey || undefined,
1037
+ }, true, true);
1038
+ return;
1039
+ }
1040
+ const actionButton = event.target?.closest("[data-editor-action]");
1041
+ if (!actionButton) {
1042
+ return;
1043
+ }
1044
+ if (actionButton.dataset.editorAction === "add-event-pose") {
1045
+ const eventId = actionButton.dataset.editorEventId ||
1046
+ (selection?.kind === "event" || selection?.kind === "event-pose" ? selection.id ?? "" : "");
1047
+ if (!eventId) {
1048
+ return;
1049
+ }
1050
+ const nextDocument = addEventPose(atlasDocument, eventId);
1051
+ const createdEvent = nextDocument.events.find((entry) => entry.id === eventId);
1052
+ const createdPose = createdEvent?.positions.at(-1) ?? createdEvent?.positions[0];
1053
+ replaceAtlasDocument(nextDocument, true, createdPose
1054
+ ? { kind: "event-pose", id: eventId, key: createdPose.objectId }
1055
+ : { kind: "event", id: eventId });
1056
+ return;
1057
+ }
1058
+ }
920
1059
  function handleInspectorInput() {
921
1060
  applyInspectorState(false);
922
1061
  }
@@ -940,6 +1079,12 @@ export function createWorldOrbitEditor(container, options = {}) {
940
1079
  case "viewpoint":
941
1080
  replaceAtlasDocument(buildViewpointDocumentFromInspector(selection.id ?? ""), commitHistory, selection, false);
942
1081
  return;
1082
+ case "event":
1083
+ replaceAtlasDocument(buildEventDocumentFromInspector(selection.id ?? ""), commitHistory, selection, false);
1084
+ return;
1085
+ case "event-pose":
1086
+ replaceAtlasDocument(buildEventPoseDocumentFromInspector(selection.id ?? "", selection.key ?? ""), commitHistory, selection, false);
1087
+ return;
943
1088
  case "annotation":
944
1089
  replaceAtlasDocument(buildAnnotationDocumentFromInspector(selection.id ?? ""), commitHistory, selection, false);
945
1090
  return;
@@ -1010,6 +1155,7 @@ export function createWorldOrbitEditor(container, options = {}) {
1010
1155
  kind,
1011
1156
  objectId,
1012
1157
  pointerId: event.pointerId,
1158
+ path: selection ? { ...selection } : { kind: "object", id: objectId },
1013
1159
  startedFrom: createHistoryEntry(),
1014
1160
  changed: false,
1015
1161
  orbitRadiusContext: kind === "orbit-radius" && details
@@ -1022,8 +1168,8 @@ export function createWorldOrbitEditor(container, options = {}) {
1022
1168
  function handleWindowPointerMove(event) {
1023
1169
  if (!dragState ||
1024
1170
  dragState.pointerId !== event.pointerId ||
1025
- selection?.kind !== "object" ||
1026
- selection.id !== dragState.objectId) {
1171
+ !selection ||
1172
+ selectionKey(selection) !== selectionKey(dragState.path)) {
1027
1173
  return;
1028
1174
  }
1029
1175
  const details = viewer.getObjectDetails(dragState.objectId);
@@ -1035,27 +1181,27 @@ export function createWorldOrbitEditor(container, options = {}) {
1035
1181
  switch (dragState.kind) {
1036
1182
  case "orbit-phase":
1037
1183
  if (details.object.placement?.mode === "orbit" && details.orbit) {
1038
- nextDocument = updateOrbitPhase(atlasDocument, dragState.objectId, details, pointer);
1184
+ nextDocument = updateOrbitPhase(atlasDocument, dragState.path, dragState.objectId, details, pointer);
1039
1185
  }
1040
1186
  break;
1041
1187
  case "orbit-radius":
1042
1188
  if (details.object.placement?.mode === "orbit" && details.orbit) {
1043
- nextDocument = updateOrbitRadius(atlasDocument, dragState.objectId, details, pointer, dragState.orbitRadiusContext ?? null);
1189
+ nextDocument = updateOrbitRadius(atlasDocument, dragState.path, dragState.objectId, details, pointer, dragState.orbitRadiusContext ?? null);
1044
1190
  }
1045
1191
  break;
1046
1192
  case "at-reference":
1047
1193
  if (details.object.placement?.mode === "at") {
1048
- nextDocument = updateAtReference(atlasDocument, dragState.objectId, viewer.getScene(), pointer);
1194
+ nextDocument = updateAtReference(atlasDocument, dragState.path, dragState.objectId, viewer.getScene(), pointer);
1049
1195
  }
1050
1196
  break;
1051
1197
  case "surface-target":
1052
1198
  if (details.object.placement?.mode === "surface") {
1053
- nextDocument = updateSurfaceTarget(atlasDocument, dragState.objectId, viewer.getScene(), pointer);
1199
+ nextDocument = updateSurfaceTarget(atlasDocument, dragState.path, dragState.objectId, viewer.getScene(), pointer);
1054
1200
  }
1055
1201
  break;
1056
1202
  case "free-distance":
1057
1203
  if (details.object.placement?.mode === "free") {
1058
- nextDocument = updateFreeDistance(atlasDocument, dragState.objectId, viewer.getScene(), details, pointer);
1204
+ nextDocument = updateFreeDistance(atlasDocument, dragState.path, dragState.objectId, viewer.getScene(), details, pointer);
1059
1205
  }
1060
1206
  break;
1061
1207
  }
@@ -1087,7 +1233,7 @@ export function createWorldOrbitEditor(container, options = {}) {
1087
1233
  }
1088
1234
  history.push(dragState.startedFrom);
1089
1235
  future.length = 0;
1090
- canonicalSource = formatDocument(atlasDocument, { schema: "2.0" });
1236
+ canonicalSource = formatAtlasSource(atlasDocument);
1091
1237
  sourceText = canonicalSource;
1092
1238
  dragState = null;
1093
1239
  renderAll();
@@ -1194,10 +1340,12 @@ export function createWorldOrbitEditor(container, options = {}) {
1194
1340
  guides: readCheckbox(form, "layer-guides"),
1195
1341
  "orbits-back": readCheckbox(form, "layer-orbits-back"),
1196
1342
  "orbits-front": readCheckbox(form, "layer-orbits-front"),
1343
+ events: readCheckbox(form, "layer-events"),
1197
1344
  objects: readCheckbox(form, "layer-objects"),
1198
1345
  labels: readCheckbox(form, "layer-labels"),
1199
1346
  metadata: readCheckbox(form, "layer-metadata"),
1200
1347
  },
1348
+ events: splitTokens(readOptionalTextInput(form, "viewpoint-events")),
1201
1349
  filter: {
1202
1350
  query: readOptionalTextInput(form, "filter-query"),
1203
1351
  objectTypes: parseObjectTypes(readOptionalTextInput(form, "filter-object-types")),
@@ -1214,6 +1362,73 @@ export function createWorldOrbitEditor(container, options = {}) {
1214
1362
  }
1215
1363
  return nextDocument;
1216
1364
  }
1365
+ function buildEventDocumentFromInspector(currentId) {
1366
+ const nextDocument = cloneAtlasDocument(atlasDocument);
1367
+ const form = inspector?.querySelector("form[data-editor-form='event']");
1368
+ const current = nextDocument.events.find((entry) => entry.id === currentId);
1369
+ if (!form || !current) {
1370
+ return nextDocument;
1371
+ }
1372
+ const nextId = readTextInput(form, "event-id") || current.id;
1373
+ const replacement = {
1374
+ ...current,
1375
+ id: nextId,
1376
+ kind: readTextInput(form, "event-kind"),
1377
+ label: readTextInput(form, "event-label") || current.label,
1378
+ summary: readOptionalTextInput(form, "event-summary"),
1379
+ targetObjectId: readOptionalTextInput(form, "event-target"),
1380
+ participantObjectIds: splitTokens(readOptionalTextInput(form, "event-participants")),
1381
+ timing: readOptionalTextInput(form, "event-timing"),
1382
+ visibility: readOptionalTextInput(form, "event-visibility"),
1383
+ tags: splitTokens(readOptionalTextInput(form, "event-tags")),
1384
+ color: readOptionalTextInput(form, "event-color"),
1385
+ hidden: readCheckbox(form, "event-hidden"),
1386
+ };
1387
+ nextDocument.events = nextDocument.events
1388
+ .filter((entry) => entry.id !== current.id)
1389
+ .concat(replacement)
1390
+ .sort(compareEvents);
1391
+ syncEventViewpointReferences(nextDocument, current.id, replacement.id, splitTokens(readOptionalTextInput(form, "event-viewpoints")));
1392
+ if (current.id !== replacement.id) {
1393
+ selection = { kind: "event", id: replacement.id };
1394
+ }
1395
+ return nextDocument;
1396
+ }
1397
+ function buildEventPoseDocumentFromInspector(eventId, objectId) {
1398
+ const nextDocument = cloneAtlasDocument(atlasDocument);
1399
+ const form = inspector?.querySelector("form[data-editor-form='event-pose']");
1400
+ const eventEntry = nextDocument.events.find((entry) => entry.id === eventId);
1401
+ const currentPose = eventEntry?.positions.find((entry) => entry.objectId === objectId);
1402
+ if (!form || !eventEntry || !currentPose) {
1403
+ return nextDocument;
1404
+ }
1405
+ const nextObjectId = readTextInput(form, "pose-object-id") || currentPose.objectId;
1406
+ const replacement = {
1407
+ objectId: nextObjectId,
1408
+ placement: buildPlacementFromPoseForm(form, currentPose),
1409
+ };
1410
+ const inner = parseOptionalUnit(readOptionalTextInput(form, "prop-inner"));
1411
+ const outer = parseOptionalUnit(readOptionalTextInput(form, "prop-outer"));
1412
+ if (inner) {
1413
+ replacement.inner = inner;
1414
+ }
1415
+ if (outer) {
1416
+ replacement.outer = outer;
1417
+ }
1418
+ eventEntry.positions = eventEntry.positions
1419
+ .filter((entry) => entry.objectId !== currentPose.objectId)
1420
+ .concat(replacement)
1421
+ .sort(compareEventPoses);
1422
+ if (eventEntry.targetObjectId !== replacement.objectId &&
1423
+ !eventEntry.participantObjectIds.includes(replacement.objectId)) {
1424
+ eventEntry.participantObjectIds.push(replacement.objectId);
1425
+ eventEntry.participantObjectIds.sort((left, right) => left.localeCompare(right));
1426
+ }
1427
+ if (currentPose.objectId !== replacement.objectId) {
1428
+ selection = { kind: "event-pose", id: eventId, key: replacement.objectId };
1429
+ }
1430
+ return nextDocument;
1431
+ }
1217
1432
  function buildAnnotationDocumentFromInspector(currentId) {
1218
1433
  const nextDocument = cloneAtlasDocument(atlasDocument);
1219
1434
  const form = inspector?.querySelector("form[data-editor-form='annotation']");
@@ -1421,7 +1636,7 @@ function resolveInitialEditorState(options) {
1421
1636
  const atlasDocument = cloneAtlasDocument(options.atlasDocument);
1422
1637
  return {
1423
1638
  atlasDocument,
1424
- source: formatDocument(atlasDocument, { schema: "2.0" }),
1639
+ source: formatAtlasSource(atlasDocument),
1425
1640
  diagnostics: collectDocumentDiagnostics(atlasDocument),
1426
1641
  };
1427
1642
  }
@@ -1431,7 +1646,7 @@ function resolveInitialEditorState(options) {
1431
1646
  const atlasDocument = loaded.value.atlasDocument ?? upgradeDocumentToV2(loaded.value.document);
1432
1647
  return {
1433
1648
  atlasDocument,
1434
- source: formatDocument(atlasDocument, { schema: "2.0" }),
1649
+ source: formatAtlasSource(atlasDocument),
1435
1650
  diagnostics: mergeDiagnostics(resolveAtlasDiagnostics(atlasDocument, loaded.diagnostics), collectDocumentDiagnostics(atlasDocument)),
1436
1651
  };
1437
1652
  }
@@ -1439,10 +1654,13 @@ function resolveInitialEditorState(options) {
1439
1654
  const atlasDocument = createEmptyAtlasDocument("WorldOrbit");
1440
1655
  return {
1441
1656
  atlasDocument,
1442
- source: formatDocument(atlasDocument, { schema: "2.0" }),
1657
+ source: formatAtlasSource(atlasDocument),
1443
1658
  diagnostics: collectDocumentDiagnostics(atlasDocument),
1444
1659
  };
1445
1660
  }
1661
+ function formatAtlasSource(document) {
1662
+ return formatDocument(document, { schema: document.version });
1663
+ }
1446
1664
  function buildEditorMarkup() {
1447
1665
  const previewOpen = shouldPreviewSectionBeOpenByDefault();
1448
1666
  return `<section class="wo-editor-shell">
@@ -1563,6 +1781,17 @@ function renderOutlineButton(path, label, activeKey, diagnosticBuckets) {
1563
1781
  : "";
1564
1782
  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
1783
  }
1784
+ function renderEventOutlineItems(eventEntry, activeKey, diagnosticBuckets) {
1785
+ return `<div class="wo-editor-outline-group">
1786
+ ${renderOutlineButton({ kind: "event", id: eventEntry.id }, eventEntry.label || eventEntry.id, activeKey, diagnosticBuckets)}
1787
+ ${eventEntry.positions.length > 0
1788
+ ? `<div class="wo-editor-outline-children">${[...eventEntry.positions]
1789
+ .sort(compareEventPoses)
1790
+ .map((pose) => renderOutlineButton({ kind: "event-pose", id: eventEntry.id, key: pose.objectId }, pose.objectId, activeKey, diagnosticBuckets))
1791
+ .join("")}</div>`
1792
+ : ""}
1793
+ </div>`;
1794
+ }
1566
1795
  function renderSystemInspector(formState) {
1567
1796
  return `<form class="wo-editor-form" data-editor-form="system">
1568
1797
  <h2>System</h2>
@@ -1629,6 +1858,7 @@ function renderViewpointInspector(formState, id) {
1629
1858
  ${renderCheckboxField("Guides", "layer-guides", viewpoint.layers.guides !== false)}
1630
1859
  ${renderCheckboxField("Orbits back", "layer-orbits-back", viewpoint.layers["orbits-back"] !== false)}
1631
1860
  ${renderCheckboxField("Orbits front", "layer-orbits-front", viewpoint.layers["orbits-front"] !== false)}
1861
+ ${renderCheckboxField("Events", "layer-events", viewpoint.layers.events !== false)}
1632
1862
  ${renderCheckboxField("Objects", "layer-objects", viewpoint.layers.objects !== false)}
1633
1863
  ${renderCheckboxField("Labels", "layer-labels", viewpoint.layers.labels !== false)}
1634
1864
  ${renderCheckboxField("Metadata", "layer-metadata", viewpoint.layers.metadata !== false)}
@@ -1636,7 +1866,83 @@ function renderViewpointInspector(formState, id) {
1636
1866
  ${renderInspectorSection("viewpoint", "filter", "Filter", `${renderTextField("Filter query", "filter-query", viewpoint.filter?.query ?? "")}
1637
1867
  ${renderTextField("Filter object types", "filter-object-types", viewpoint.filter?.objectTypes.join(" ") ?? "")}
1638
1868
  ${renderTextField("Filter tags", "filter-tags", viewpoint.filter?.tags.join(" ") ?? "")}
1639
- ${renderTextField("Filter groups", "filter-groups", viewpoint.filter?.groupIds.join(" ") ?? "")}`)}
1869
+ ${renderTextField("Filter groups", "filter-groups", viewpoint.filter?.groupIds.join(" ") ?? "")}
1870
+ ${renderTextField("Events", "viewpoint-events", viewpoint.events.join(" "))}`)}
1871
+ </form>`;
1872
+ }
1873
+ function renderEventInspector(formState, id) {
1874
+ const eventEntry = formState.events.find((entry) => entry.id === id);
1875
+ if (!eventEntry) {
1876
+ return `<p class="wo-editor-empty">Event not found.</p>`;
1877
+ }
1878
+ const linkedViewpoints = formState.viewpoints
1879
+ .filter((viewpoint) => viewpoint.events.includes(eventEntry.id))
1880
+ .map((viewpoint) => viewpoint.id)
1881
+ .join(" ");
1882
+ return `<form class="wo-editor-form" data-editor-form="event">
1883
+ <h2>Event</h2>
1884
+ ${renderInspectorSection("event", "basics", "Basics", `${renderTextField("ID", "event-id", eventEntry.id)}
1885
+ ${renderTextField("Kind", "event-kind", eventEntry.kind)}
1886
+ ${renderTextField("Label", "event-label", eventEntry.label)}
1887
+ ${renderTextAreaField("Summary", "event-summary", eventEntry.summary ?? "")}
1888
+ ${renderTextField("Target object", "event-target", eventEntry.targetObjectId ?? "")}
1889
+ ${renderTextField("Participants", "event-participants", eventEntry.participantObjectIds.join(" "))}
1890
+ ${renderTextField("Timing", "event-timing", eventEntry.timing ?? "")}
1891
+ ${renderTextField("Visibility", "event-visibility", eventEntry.visibility ?? "")}
1892
+ ${renderTextField("Tags", "event-tags", eventEntry.tags.join(" "))}
1893
+ ${renderTextField("Color", "event-color", eventEntry.color ?? "")}
1894
+ ${renderCheckboxField("Hidden", "event-hidden", eventEntry.hidden === true)}`, true)}
1895
+ ${renderInspectorSection("event", "viewpoints", "Viewpoints", `${renderTextField("Viewpoints", "event-viewpoints", linkedViewpoints)}`)}
1896
+ ${renderInspectorSection("event", "positions", "Positions", `${eventEntry.positions.length > 0
1897
+ ? `<div class="wo-editor-inline-list">${eventEntry.positions
1898
+ .map((pose) => renderOutlineButton({ kind: "event-pose", id: eventEntry.id, key: pose.objectId }, pose.objectId, null, new Map()))
1899
+ .join("")}</div>`
1900
+ : `<p class="wo-editor-empty">No event poses yet.</p>`}
1901
+ <div class="wo-editor-inline-actions">
1902
+ <button type="button" data-editor-action="add-event-pose" data-editor-event-id="${escapeHtml(eventEntry.id)}">Add pose</button>
1903
+ </div>`)}
1904
+ </form>`;
1905
+ }
1906
+ function renderEventPoseInspector(formState, eventId, objectId) {
1907
+ const eventEntry = formState.events.find((entry) => entry.id === eventId);
1908
+ const pose = eventEntry?.positions.find((entry) => entry.objectId === objectId);
1909
+ if (!eventEntry || !pose) {
1910
+ return `<p class="wo-editor-empty">Event pose not found.</p>`;
1911
+ }
1912
+ const placementMode = pose.placement?.mode ?? "";
1913
+ const placementTarget = pose.placement?.mode === "orbit" || pose.placement?.mode === "surface" || pose.placement?.mode === "at"
1914
+ ? pose.placement.target
1915
+ : "";
1916
+ const freeValue = pose.placement?.mode === "free"
1917
+ ? pose.placement.distance
1918
+ ? formatUnitValue(pose.placement.distance)
1919
+ : pose.placement.descriptor ?? ""
1920
+ : "";
1921
+ return `<form class="wo-editor-form" data-editor-form="event-pose">
1922
+ <h2>Event Pose</h2>
1923
+ <p class="wo-editor-inline-note">Event <strong>${escapeHtml(eventEntry.label || eventEntry.id)}</strong></p>
1924
+ ${renderInspectorSection("event-pose", "identity", "Identity", `${renderTextField("Object", "pose-object-id", pose.objectId)}
1925
+ <div class="wo-editor-inline-actions">
1926
+ <button type="button" data-path-kind="event" data-path-id="${escapeHtml(eventEntry.id)}">Select event</button>
1927
+ </div>`, true)}
1928
+ ${renderInspectorSection("event-pose", "placement", "Placement", `${renderSelectField("Placement mode", "placement-mode", [
1929
+ ["", "None"],
1930
+ ["orbit", "Orbit"],
1931
+ ["at", "At"],
1932
+ ["surface", "Surface"],
1933
+ ["free", "Free"],
1934
+ ], placementMode)}
1935
+ ${renderTextField("Placement target", "placement-target", placementTarget)}
1936
+ ${renderTextField("Free value", "placement-free", freeValue)}
1937
+ ${renderTextField("Distance", "placement-distance", pose.placement?.mode === "orbit" && pose.placement.distance ? formatUnitValue(pose.placement.distance) : "")}
1938
+ ${renderTextField("Semi-major", "placement-semiMajor", pose.placement?.mode === "orbit" && pose.placement.semiMajor ? formatUnitValue(pose.placement.semiMajor) : "")}
1939
+ ${renderTextField("Eccentricity", "placement-eccentricity", pose.placement?.mode === "orbit" && pose.placement.eccentricity !== undefined ? String(pose.placement.eccentricity) : "")}
1940
+ ${renderTextField("Period", "placement-period", pose.placement?.mode === "orbit" && pose.placement.period ? formatUnitValue(pose.placement.period) : "")}
1941
+ ${renderTextField("Angle", "placement-angle", pose.placement?.mode === "orbit" && pose.placement.angle ? formatUnitValue(pose.placement.angle) : "")}
1942
+ ${renderTextField("Inclination", "placement-inclination", pose.placement?.mode === "orbit" && pose.placement.inclination ? formatUnitValue(pose.placement.inclination) : "")}
1943
+ ${renderTextField("Phase", "placement-phase", pose.placement?.mode === "orbit" && pose.placement.phase ? formatUnitValue(pose.placement.phase) : "")}
1944
+ ${renderTextField("Inner", "prop-inner", pose.inner ? formatUnitValue(pose.inner) : "")}
1945
+ ${renderTextField("Outer", "prop-outer", pose.outer ? formatUnitValue(pose.outer) : "")}`, true)}
1640
1946
  </form>`;
1641
1947
  }
1642
1948
  function renderAnnotationInspector(formState, id) {
@@ -1749,6 +2055,12 @@ function readCheckbox(form, name) {
1749
2055
  return form.elements.namedItem(name)?.checked ?? false;
1750
2056
  }
1751
2057
  function buildPlacementFromForm(form, current) {
2058
+ return buildPlacementFromValues(form, current.placement, current.id);
2059
+ }
2060
+ function buildPlacementFromPoseForm(form, current) {
2061
+ return buildPlacementFromValues(form, current.placement, current.objectId);
2062
+ }
2063
+ function buildPlacementFromValues(form, currentPlacement, fallbackTarget) {
1752
2064
  const mode = readTextInput(form, "placement-mode");
1753
2065
  const target = readOptionalTextInput(form, "placement-target");
1754
2066
  switch (mode) {
@@ -1756,9 +2068,9 @@ function buildPlacementFromForm(form, current) {
1756
2068
  return {
1757
2069
  mode,
1758
2070
  target: target ??
1759
- (current.placement?.mode === "orbit"
1760
- ? current.placement.target
1761
- : current.id),
2071
+ (currentPlacement?.mode === "orbit"
2072
+ ? currentPlacement.target
2073
+ : fallbackTarget),
1762
2074
  distance: parseOptionalUnit(readOptionalTextInput(form, "placement-distance")),
1763
2075
  semiMajor: parseOptionalUnit(readOptionalTextInput(form, "placement-semiMajor")),
1764
2076
  eccentricity: parseNullableNumber(readOptionalTextInput(form, "placement-eccentricity")) ?? undefined,
@@ -1770,13 +2082,13 @@ function buildPlacementFromForm(form, current) {
1770
2082
  case "at":
1771
2083
  return {
1772
2084
  mode,
1773
- target: target ?? current.id,
1774
- reference: parseAtReferenceString(target ?? current.id),
2085
+ target: target ?? fallbackTarget,
2086
+ reference: parseAtReferenceString(target ?? fallbackTarget),
1775
2087
  };
1776
2088
  case "surface":
1777
2089
  return {
1778
2090
  mode,
1779
- target: target ?? current.id,
2091
+ target: target ?? fallbackTarget,
1780
2092
  };
1781
2093
  case "free": {
1782
2094
  const freeValue = readOptionalTextInput(form, "placement-free");
@@ -1920,9 +2232,48 @@ function renameObjectReferences(document, fromId, toId) {
1920
2232
  annotation.sourceObjectId = toId;
1921
2233
  }
1922
2234
  }
2235
+ for (const eventEntry of document.events) {
2236
+ if (eventEntry.targetObjectId === fromId) {
2237
+ eventEntry.targetObjectId = toId;
2238
+ }
2239
+ eventEntry.participantObjectIds = eventEntry.participantObjectIds.map((entry) => entry === fromId ? toId : entry);
2240
+ for (const pose of eventEntry.positions) {
2241
+ if (pose.objectId === fromId) {
2242
+ pose.objectId = toId;
2243
+ }
2244
+ if (pose.placement?.mode === "orbit" && pose.placement.target === fromId) {
2245
+ pose.placement.target = toId;
2246
+ }
2247
+ if (pose.placement?.mode === "surface" && pose.placement.target === fromId) {
2248
+ pose.placement.target = toId;
2249
+ }
2250
+ if (pose.placement?.mode === "at") {
2251
+ const reference = pose.placement.reference;
2252
+ if (reference.kind === "anchor" && reference.objectId === fromId) {
2253
+ reference.objectId = toId;
2254
+ }
2255
+ if (reference.kind === "lagrange") {
2256
+ if (reference.primary === fromId) {
2257
+ reference.primary = toId;
2258
+ }
2259
+ if (reference.secondary === fromId) {
2260
+ reference.secondary = toId;
2261
+ }
2262
+ }
2263
+ pose.placement.target = formatAtReference(reference);
2264
+ }
2265
+ }
2266
+ eventEntry.positions.sort(compareEventPoses);
2267
+ }
1923
2268
  }
1924
2269
  function removeSelectedNode(document, selection) {
1925
2270
  const next = removeAtlasDocumentNode(document, selection);
2271
+ if (selection.kind === "event" && selection.id) {
2272
+ for (const viewpoint of next.system?.viewpoints ?? []) {
2273
+ viewpoint.events = viewpoint.events.filter((eventId) => eventId !== selection.id);
2274
+ }
2275
+ return next;
2276
+ }
1926
2277
  if (selection.kind !== "object" || !selection.id) {
1927
2278
  return next;
1928
2279
  }
@@ -1959,9 +2310,47 @@ function removeSelectedNode(document, selection) {
1959
2310
  annotation.sourceObjectId = null;
1960
2311
  }
1961
2312
  }
2313
+ for (const eventEntry of next.events) {
2314
+ if (eventEntry.targetObjectId === selection.id) {
2315
+ eventEntry.targetObjectId = null;
2316
+ }
2317
+ eventEntry.participantObjectIds = eventEntry.participantObjectIds.filter((entry) => entry !== selection.id);
2318
+ eventEntry.positions = eventEntry.positions.filter((entry) => entry.objectId !== selection.id);
2319
+ for (const pose of eventEntry.positions) {
2320
+ if (pose.placement?.mode === "orbit" && pose.placement.target === selection.id) {
2321
+ pose.placement = null;
2322
+ }
2323
+ if (pose.placement?.mode === "surface" && pose.placement.target === selection.id) {
2324
+ pose.placement = null;
2325
+ }
2326
+ if (pose.placement?.mode === "at") {
2327
+ const reference = pose.placement.reference;
2328
+ const touchesSelection = (reference.kind === "anchor" && reference.objectId === selection.id) ||
2329
+ (reference.kind === "lagrange" &&
2330
+ (reference.primary === selection.id || reference.secondary === selection.id));
2331
+ if (touchesSelection) {
2332
+ pose.placement = null;
2333
+ }
2334
+ }
2335
+ }
2336
+ }
1962
2337
  return next;
1963
2338
  }
1964
- function updateOrbitPhase(document, objectId, details, pointer) {
2339
+ function findEditablePlacementOwner(document, path, objectId) {
2340
+ if (path.kind === "event-pose" && path.id && path.key) {
2341
+ const pose = findEventPose(document, path.id, path.key);
2342
+ if (pose?.placement) {
2343
+ return { placement: pose.placement };
2344
+ }
2345
+ return null;
2346
+ }
2347
+ const object = findObject(document, objectId);
2348
+ if (object?.placement) {
2349
+ return { placement: object.placement };
2350
+ }
2351
+ return null;
2352
+ }
2353
+ function updateOrbitPhase(document, path, objectId, details, pointer) {
1965
2354
  const orbit = details.orbit;
1966
2355
  if (!orbit || details.object.placement?.mode !== "orbit") {
1967
2356
  return document;
@@ -1972,17 +2361,17 @@ function updateOrbitPhase(document, objectId, details, pointer) {
1972
2361
  const radians = Math.atan2((unrotated.y - orbit.cy) / Math.max(ry, 1), (unrotated.x - orbit.cx) / Math.max(rx, 1));
1973
2362
  const phaseDeg = normalizeDegrees((radians * 180) / Math.PI);
1974
2363
  const next = cloneAtlasDocument(document);
1975
- const object = next.objects.find((entry) => entry.id === objectId);
1976
- if (!object || object.placement?.mode !== "orbit") {
2364
+ const placementOwner = findEditablePlacementOwner(next, path, objectId);
2365
+ if (!placementOwner || placementOwner.placement.mode !== "orbit") {
1977
2366
  return document;
1978
2367
  }
1979
- object.placement.phase = {
2368
+ placementOwner.placement.phase = {
1980
2369
  value: roundNumber(phaseDeg, 2),
1981
2370
  unit: "deg",
1982
2371
  };
1983
2372
  return next;
1984
2373
  }
1985
- function updateOrbitRadius(document, objectId, details, pointer, dragContext) {
2374
+ function updateOrbitRadius(document, path, objectId, details, pointer, dragContext) {
1986
2375
  const orbit = details.orbit;
1987
2376
  if (!orbit || details.object.placement?.mode !== "orbit" || !dragContext) {
1988
2377
  return document;
@@ -1992,49 +2381,49 @@ function updateOrbitRadius(document, objectId, details, pointer, dragContext) {
1992
2381
  const nextBaseRadius = Math.max(nextDisplayedRadius - dragContext.radiusOffsetPx, dragContext.innerPx);
1993
2382
  const nextMetric = orbitRadiusPxToMetric(nextBaseRadius, dragContext.innerPx, dragContext.stepPx);
1994
2383
  const next = cloneAtlasDocument(document);
1995
- const object = next.objects.find((entry) => entry.id === objectId);
1996
- if (!object || object.placement?.mode !== "orbit") {
2384
+ const placementOwner = findEditablePlacementOwner(next, path, objectId);
2385
+ if (!placementOwner || placementOwner.placement.mode !== "orbit") {
1997
2386
  return document;
1998
2387
  }
1999
- const currentValue = object.placement.semiMajor ??
2000
- object.placement.distance ?? {
2388
+ const currentValue = placementOwner.placement.semiMajor ??
2389
+ placementOwner.placement.distance ?? {
2001
2390
  value: 1,
2002
2391
  unit: "au",
2003
2392
  };
2004
2393
  const scaled = distanceMetricToUnitValue(Math.max(nextMetric, 0), dragContext.preferredUnit ?? currentValue.unit);
2005
- if (object.placement.semiMajor) {
2006
- object.placement.semiMajor = scaled;
2394
+ if (placementOwner.placement.semiMajor) {
2395
+ placementOwner.placement.semiMajor = scaled;
2007
2396
  }
2008
2397
  else {
2009
- object.placement.distance = scaled;
2398
+ placementOwner.placement.distance = scaled;
2010
2399
  }
2011
2400
  return next;
2012
2401
  }
2013
- function updateAtReference(document, objectId, scene, pointer) {
2402
+ function updateAtReference(document, path, objectId, scene, pointer) {
2014
2403
  const candidate = findNearestAtCandidate(scene, objectId, pointer);
2015
2404
  if (!candidate) {
2016
2405
  return document;
2017
2406
  }
2018
2407
  const next = cloneAtlasDocument(document);
2019
- const object = next.objects.find((entry) => entry.id === objectId);
2020
- if (!object || object.placement?.mode !== "at") {
2408
+ const placementOwner = findEditablePlacementOwner(next, path, objectId);
2409
+ if (!placementOwner || placementOwner.placement.mode !== "at") {
2021
2410
  return document;
2022
2411
  }
2023
- object.placement.reference = candidate.reference;
2024
- object.placement.target = formatAtReference(candidate.reference);
2412
+ placementOwner.placement.reference = candidate.reference;
2413
+ placementOwner.placement.target = formatAtReference(candidate.reference);
2025
2414
  return next;
2026
2415
  }
2027
- function updateSurfaceTarget(document, objectId, scene, pointer) {
2416
+ function updateSurfaceTarget(document, path, objectId, scene, pointer) {
2028
2417
  const target = findNearestSceneObject(scene, objectId, pointer, (entry) => SURFACE_TARGET_TYPES.has(entry.object.type));
2029
2418
  if (!target) {
2030
2419
  return document;
2031
2420
  }
2032
2421
  const next = cloneAtlasDocument(document);
2033
- const object = next.objects.find((entry) => entry.id === objectId);
2034
- if (!object || object.placement?.mode !== "surface") {
2422
+ const placementOwner = findEditablePlacementOwner(next, path, objectId);
2423
+ if (!placementOwner || placementOwner.placement.mode !== "surface") {
2035
2424
  return document;
2036
2425
  }
2037
- object.placement.target = target.objectId;
2426
+ placementOwner.placement.target = target.objectId;
2038
2427
  return next;
2039
2428
  }
2040
2429
  function createOrbitRadiusDragContext(document, scene, details) {
@@ -2042,8 +2431,9 @@ function createOrbitRadiusDragContext(document, scene, details) {
2042
2431
  return null;
2043
2432
  }
2044
2433
  const targetId = details.object.placement.target;
2045
- const siblingCount = document.objects.filter((entry) => entry.placement?.mode === "orbit" &&
2046
- entry.placement.target === targetId).length;
2434
+ const siblingCount = scene.objects.filter((entry) => entry.object.placement?.mode === "orbit" &&
2435
+ entry.object.placement.target === targetId &&
2436
+ !entry.hidden).length;
2047
2437
  const spacingFactor = layoutPresetSpacingForScene(scene.layoutPreset);
2048
2438
  const stepPx = (siblingCount > 2 ? 54 : 64) * spacingFactor * scene.scaleModel.orbitDistanceMultiplier;
2049
2439
  const innerPx = details.parent.radius +
@@ -2059,28 +2449,28 @@ function createOrbitRadiusDragContext(document, scene, details) {
2059
2449
  preferredUnit: currentValue?.unit ?? null,
2060
2450
  };
2061
2451
  }
2062
- function updateFreeDistance(document, objectId, scene, details, pointer) {
2452
+ function updateFreeDistance(document, path, objectId, scene, details, pointer) {
2063
2453
  if (details.object.placement?.mode !== "free") {
2064
2454
  return document;
2065
2455
  }
2066
2456
  const railX = scene.width - scene.padding - 140;
2067
2457
  const offsetPx = Math.max(0, railX - pointer.x);
2068
2458
  const next = cloneAtlasDocument(document);
2069
- const object = next.objects.find((entry) => entry.id === objectId);
2070
- if (!object || object.placement?.mode !== "free") {
2459
+ const placementOwner = findEditablePlacementOwner(next, path, objectId);
2460
+ if (!placementOwner || placementOwner.placement.mode !== "free") {
2071
2461
  return document;
2072
2462
  }
2073
- const preferredUnit = normalizeFreeDistanceUnit(object.placement.distance?.unit ?? null);
2463
+ const preferredUnit = normalizeFreeDistanceUnit(placementOwner.placement.distance?.unit ?? null);
2074
2464
  const metric = offsetPx / Math.max(FREE_DISTANCE_PIXEL_FACTOR * scene.scaleModel.freePlacementMultiplier, 1);
2075
2465
  if (metric < 0.01) {
2076
- object.placement.distance = undefined;
2077
- if (!object.placement.descriptor) {
2078
- delete object.placement.descriptor;
2466
+ placementOwner.placement.distance = undefined;
2467
+ if (!placementOwner.placement.descriptor) {
2468
+ delete placementOwner.placement.descriptor;
2079
2469
  }
2080
2470
  return next;
2081
2471
  }
2082
- object.placement.distance = distanceMetricToUnitValue(metric, preferredUnit);
2083
- delete object.placement.descriptor;
2472
+ placementOwner.placement.distance = distanceMetricToUnitValue(metric, preferredUnit);
2473
+ delete placementOwner.placement.descriptor;
2084
2474
  return next;
2085
2475
  }
2086
2476
  function findNearestSceneObject(scene, selectedObjectId, pointer, predicate = () => true) {
@@ -2356,9 +2746,60 @@ function mapDiagnosticFieldToInputNames(selection, field) {
2356
2746
  return ["viewpoint-zoom"];
2357
2747
  case "rotationDeg":
2358
2748
  return ["viewpoint-rotation"];
2749
+ case "events":
2750
+ return ["viewpoint-events"];
2359
2751
  default:
2360
2752
  return [];
2361
2753
  }
2754
+ case "event":
2755
+ switch (field) {
2756
+ case "id":
2757
+ return ["event-id"];
2758
+ case "kind":
2759
+ return ["event-kind"];
2760
+ case "label":
2761
+ return ["event-label"];
2762
+ case "summary":
2763
+ return ["event-summary"];
2764
+ case "targetObjectId":
2765
+ case "target":
2766
+ return ["event-target"];
2767
+ case "participantObjectIds":
2768
+ case "participants":
2769
+ return ["event-participants"];
2770
+ case "timing":
2771
+ return ["event-timing"];
2772
+ case "visibility":
2773
+ return ["event-visibility"];
2774
+ case "tags":
2775
+ return ["event-tags"];
2776
+ case "color":
2777
+ return ["event-color"];
2778
+ case "hidden":
2779
+ return ["event-hidden"];
2780
+ default:
2781
+ return [];
2782
+ }
2783
+ case "event-pose":
2784
+ if (field === "objectId") {
2785
+ return ["pose-object-id"];
2786
+ }
2787
+ if (field === "placement") {
2788
+ return ["placement-mode"];
2789
+ }
2790
+ if (field === "reference" || field === "target") {
2791
+ return ["placement-target"];
2792
+ }
2793
+ if (field === "descriptor") {
2794
+ return ["placement-free"];
2795
+ }
2796
+ if (PLACEMENT_DIAGNOSTIC_FIELDS.has(field)) {
2797
+ return [`placement-${field}`];
2798
+ }
2799
+ if (field === "inner" || field === "outer") {
2800
+ return [`prop-${field}`];
2801
+ }
2802
+ return [];
2362
2803
  case "annotation":
2363
2804
  switch (field) {
2364
2805
  case "id":
@@ -2444,6 +2885,10 @@ function describePath(path) {
2444
2885
  return `Metadata: ${path.key ?? ""}`;
2445
2886
  case "group":
2446
2887
  return `Group: ${path.id ?? ""}`;
2888
+ case "event":
2889
+ return `Event: ${path.id ?? ""}`;
2890
+ case "event-pose":
2891
+ return `Event Pose: ${path.id ?? ""} / ${path.key ?? ""}`;
2447
2892
  case "object":
2448
2893
  return `Object: ${path.id ?? ""}`;
2449
2894
  case "viewpoint":
@@ -2455,11 +2900,72 @@ function describePath(path) {
2455
2900
  }
2456
2901
  }
2457
2902
  function selectionKey(path) {
2458
- return path ? `${path.kind}:${path.id ?? path.key ?? ""}` : null;
2903
+ return path ? `${path.kind}:${path.id ?? ""}:${path.key ?? ""}` : null;
2904
+ }
2905
+ function selectionEventId(path) {
2906
+ if (!path) {
2907
+ return null;
2908
+ }
2909
+ return path.kind === "event" || path.kind === "event-pose" ? path.id ?? null : null;
2459
2910
  }
2460
2911
  function compareObjects(left, right) {
2461
2912
  return left.id.localeCompare(right.id);
2462
2913
  }
2914
+ function compareEvents(left, right) {
2915
+ return left.id.localeCompare(right.id);
2916
+ }
2917
+ function compareEventPoses(left, right) {
2918
+ return left.objectId.localeCompare(right.objectId);
2919
+ }
2920
+ function findEvent(document, eventId) {
2921
+ return document.events.find((entry) => entry.id === eventId) ?? null;
2922
+ }
2923
+ function findEventPose(document, eventId, objectId) {
2924
+ return findEvent(document, eventId)?.positions.find((entry) => entry.objectId === objectId) ?? null;
2925
+ }
2926
+ function findObject(document, objectId) {
2927
+ return document.objects.find((entry) => entry.id === objectId) ?? null;
2928
+ }
2929
+ function addEventPose(document, eventId) {
2930
+ const next = cloneAtlasDocument(document);
2931
+ const eventEntry = next.events.find((entry) => entry.id === eventId);
2932
+ if (!eventEntry) {
2933
+ return document;
2934
+ }
2935
+ const baseObject = next.objects.find((object) => !eventEntry.positions.some((pose) => pose.objectId === object.id)) ??
2936
+ next.objects[0];
2937
+ if (!baseObject) {
2938
+ return document;
2939
+ }
2940
+ if (eventEntry.targetObjectId !== baseObject.id &&
2941
+ !eventEntry.participantObjectIds.includes(baseObject.id)) {
2942
+ eventEntry.participantObjectIds.push(baseObject.id);
2943
+ eventEntry.participantObjectIds.sort((left, right) => left.localeCompare(right));
2944
+ }
2945
+ eventEntry.positions.push(createEventPoseFromObject(baseObject));
2946
+ eventEntry.positions.sort(compareEventPoses);
2947
+ return next;
2948
+ }
2949
+ function createEventPoseFromObject(object) {
2950
+ return {
2951
+ objectId: object.id,
2952
+ placement: object.placement ? structuredClone(object.placement) : null,
2953
+ inner: readUnitValue(object.properties.inner),
2954
+ outer: readUnitValue(object.properties.outer),
2955
+ };
2956
+ }
2957
+ function syncEventViewpointReferences(document, previousEventId, nextEventId, viewpointIds) {
2958
+ const desired = new Set(viewpointIds);
2959
+ for (const viewpoint of document.system?.viewpoints ?? []) {
2960
+ const currentIds = new Set(viewpoint.events);
2961
+ currentIds.delete(previousEventId);
2962
+ currentIds.delete(nextEventId);
2963
+ if (desired.has(viewpoint.id)) {
2964
+ currentIds.add(nextEventId);
2965
+ }
2966
+ viewpoint.events = [...currentIds].sort((left, right) => left.localeCompare(right));
2967
+ }
2968
+ }
2463
2969
  function createUniqueId(prefix, existing) {
2464
2970
  const safePrefix = prefix.trim() || "item";
2465
2971
  let counter = 1;
@@ -2494,6 +3000,9 @@ function readUnitProperty(value) {
2494
3000
  ? formatUnitValue(value)
2495
3001
  : "";
2496
3002
  }
3003
+ function readUnitValue(value) {
3004
+ return value && typeof value === "object" && "value" in value ? value : undefined;
3005
+ }
2497
3006
  function readNumberProperty(value) {
2498
3007
  return typeof value === "number" ? String(value) : "";
2499
3008
  }
@@ -2803,6 +3312,12 @@ function installEditorStyles() {
2803
3312
  .wo-editor-overlay-diagnostic-warning { border: 1px solid rgba(240, 180, 100, 0.24); }
2804
3313
  .wo-editor-outline { display: grid; gap: 14px; }
2805
3314
  .wo-editor-outline-section { display: grid; gap: 8px; }
3315
+ .wo-editor-outline-group { display: grid; gap: 6px; }
3316
+ .wo-editor-outline-children {
3317
+ display: grid;
3318
+ gap: 6px;
3319
+ padding-left: 16px;
3320
+ }
2806
3321
  .wo-editor-outline-section h3 {
2807
3322
  margin: 0;
2808
3323
  color: rgba(237,246,255,0.68);
@@ -2842,6 +3357,27 @@ function installEditorStyles() {
2842
3357
  background: rgba(255, 120, 120, 0.18);
2843
3358
  color: #ffb2b2;
2844
3359
  }
3360
+ .wo-editor-inline-list { display: grid; gap: 8px; }
3361
+ .wo-editor-inline-actions {
3362
+ display: flex;
3363
+ flex-wrap: wrap;
3364
+ gap: 10px;
3365
+ margin-top: 12px;
3366
+ }
3367
+ .wo-editor-inline-actions button {
3368
+ border: 1px solid rgba(255,255,255,0.14);
3369
+ border-radius: 999px;
3370
+ background: rgba(255,255,255,0.06);
3371
+ color: #edf6ff;
3372
+ cursor: pointer;
3373
+ font: 600 12px/1.2 "Segoe UI Variable", "Segoe UI", sans-serif;
3374
+ padding: 8px 12px;
3375
+ }
3376
+ .wo-editor-inline-note {
3377
+ margin: 0 0 12px;
3378
+ color: rgba(237,246,255,0.72);
3379
+ font: 500 12px/1.5 "Segoe UI Variable", "Segoe UI", sans-serif;
3380
+ }
2845
3381
  .wo-editor-diagnostics { display: grid; gap: 10px; }
2846
3382
  .wo-editor-diagnostic {
2847
3383
  display: grid;