worldorbit 3.1.0 → 3.2.1

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 (46) hide show
  1. package/dist/browser/core/dist/atlas-edit.js +1 -1
  2. package/dist/browser/core/dist/atlas-validate.js +1 -1
  3. package/dist/browser/core/dist/draft-parse.js +7 -5
  4. package/dist/browser/core/dist/draft.js +2 -2
  5. package/dist/browser/core/dist/format.js +5 -5
  6. package/dist/browser/core/dist/load.js +7 -3
  7. package/dist/browser/core/dist/scene.js +99 -17
  8. package/dist/browser/core/dist/spatial-scene.js +1 -0
  9. package/dist/browser/core/dist/types.d.ts +4 -1
  10. package/dist/browser/editor/dist/editor.js +55 -12
  11. package/dist/browser/viewer/dist/atlas-state.js +3 -0
  12. package/dist/browser/viewer/dist/types.d.ts +1 -0
  13. package/dist/browser/viewer/dist/viewer.js +2 -0
  14. package/dist/unpkg/core/dist/atlas-edit.js +1 -1
  15. package/dist/unpkg/core/dist/atlas-validate.js +1 -1
  16. package/dist/unpkg/core/dist/draft-parse.js +7 -5
  17. package/dist/unpkg/core/dist/draft.js +2 -2
  18. package/dist/unpkg/core/dist/format.js +5 -5
  19. package/dist/unpkg/core/dist/load.js +7 -3
  20. package/dist/unpkg/core/dist/scene.js +99 -17
  21. package/dist/unpkg/core/dist/spatial-scene.js +1 -0
  22. package/dist/unpkg/core/dist/types.d.ts +4 -1
  23. package/dist/unpkg/editor/dist/editor.js +55 -12
  24. package/dist/unpkg/viewer/dist/atlas-state.js +3 -0
  25. package/dist/unpkg/viewer/dist/types.d.ts +1 -0
  26. package/dist/unpkg/viewer/dist/viewer.js +2 -0
  27. package/dist/unpkg/worldorbit-core.min.js +12 -12
  28. package/dist/unpkg/worldorbit-editor.min.js +313 -313
  29. package/dist/unpkg/worldorbit-markdown.min.js +23 -23
  30. package/dist/unpkg/worldorbit-viewer.min.js +203 -203
  31. package/dist/unpkg/worldorbit.js +119 -37
  32. package/dist/unpkg/worldorbit.min.js +206 -206
  33. package/package.json +1 -1
  34. package/packages/core/dist/atlas-edit.js +1 -1
  35. package/packages/core/dist/atlas-validate.js +1 -1
  36. package/packages/core/dist/draft-parse.js +7 -5
  37. package/packages/core/dist/draft.js +2 -2
  38. package/packages/core/dist/format.js +5 -5
  39. package/packages/core/dist/load.js +7 -3
  40. package/packages/core/dist/scene.js +99 -17
  41. package/packages/core/dist/spatial-scene.js +1 -0
  42. package/packages/core/dist/types.d.ts +4 -1
  43. package/packages/editor/dist/editor.js +55 -12
  44. package/packages/viewer/dist/atlas-state.js +3 -0
  45. package/packages/viewer/dist/types.d.ts +1 -0
  46. package/packages/viewer/dist/viewer.js +2 -0
@@ -210,12 +210,12 @@ function parseAtlasSource(source, forcedOutputVersion) {
210
210
  function assertDraftSchemaHeader(tokens, line) {
211
211
  if (tokens.length !== 2 ||
212
212
  tokens[0].value.toLowerCase() !== "schema" ||
213
- !["2.0-draft", "2.0", "2.1", "2.5", "2.6.1"].includes(tokens[1].value.toLowerCase())) {
214
- throw new WorldOrbitError('Expected atlas header "schema 2.0", "schema 2.1", "schema 2.5", "schema 2.6.1", or legacy "schema 2.0-draft"', line, tokens[0]?.column ?? 1);
213
+ !["2.0-draft", "2.0", "2.1", "2.5", "2.6"].includes(tokens[1].value.toLowerCase())) {
214
+ throw new WorldOrbitError('Expected atlas header "schema 2.0", "schema 2.1", "schema 2.5", "schema 2.6", or legacy "schema 2.0-draft"', line, tokens[0]?.column ?? 1);
215
215
  }
216
216
  const version = tokens[1].value.toLowerCase();
217
- return version === "2.6.1"
218
- ? "2.6.1"
217
+ return version === "2.6"
218
+ ? "2.6"
219
219
  : version === "2.5"
220
220
  ? "2.5"
221
221
  : version === "2.1"
@@ -1566,8 +1566,10 @@ function schemaVersionRank(version) {
1566
1566
  return 2;
1567
1567
  case "2.5":
1568
1568
  return 3;
1569
- case "2.6.1":
1569
+ case "2.6":
1570
1570
  return 4;
1571
+ default:
1572
+ return 5;
1571
1573
  }
1572
1574
  }
1573
1575
  function preprocessAtlasSource(source) {
@@ -18,8 +18,8 @@ export function upgradeDocumentToV2(document, options = {}) {
18
18
  }
19
19
  return {
20
20
  format: "worldorbit",
21
- version: "2.6.1",
22
- schemaVersion: "2.6.1",
21
+ version: "2.6",
22
+ schemaVersion: "2.6",
23
23
  sourceVersion: document.version,
24
24
  theme: document.theme ?? null,
25
25
  system,
@@ -39,18 +39,18 @@ export function formatDocument(document, options = {}) {
39
39
  const useDraft = schema === "2.0" ||
40
40
  schema === "2.1" ||
41
41
  schema === "2.5" ||
42
- schema === "2.6.1" ||
42
+ schema === "2.6" ||
43
43
  schema === "2.0-draft" ||
44
44
  document.version === "2.0" ||
45
45
  document.version === "2.1" ||
46
46
  document.version === "2.5" ||
47
- document.version === "2.6.1" ||
47
+ document.version === "2.6" ||
48
48
  document.version === "2.0-draft";
49
49
  if (useDraft) {
50
50
  if (schema === "2.0-draft") {
51
51
  const legacyDraftDocument = document.version === "2.0-draft"
52
52
  ? document
53
- : document.version === "2.0" || document.version === "2.1" || document.version === "2.5" || document.version === "2.6.1"
53
+ : document.version === "2.0" || document.version === "2.1" || document.version === "2.5" || document.version === "2.6"
54
54
  ? {
55
55
  ...document,
56
56
  version: "2.0-draft",
@@ -59,7 +59,7 @@ export function formatDocument(document, options = {}) {
59
59
  : upgradeDocumentToDraftV2(document);
60
60
  return formatDraftDocument(legacyDraftDocument);
61
61
  }
62
- const atlasDocument = document.version === "2.0" || document.version === "2.1" || document.version === "2.5" || document.version === "2.6.1"
62
+ const atlasDocument = document.version === "2.0" || document.version === "2.1" || document.version === "2.5" || document.version === "2.6"
63
63
  ? document
64
64
  : document.version === "2.0-draft"
65
65
  ? {
@@ -68,7 +68,7 @@ export function formatDocument(document, options = {}) {
68
68
  schemaVersion: "2.0",
69
69
  }
70
70
  : upgradeDocumentToV2(document);
71
- if ((schema === "2.0" || schema === "2.1" || schema === "2.5" || schema === "2.6.1") && atlasDocument.version !== schema) {
71
+ if ((schema === "2.0" || schema === "2.1" || schema === "2.5" || schema === "2.6") && atlasDocument.version !== schema) {
72
72
  return formatAtlasDocument({
73
73
  ...atlasDocument,
74
74
  version: schema,
@@ -5,9 +5,10 @@ import { WorldOrbitError } from "./errors.js";
5
5
  import { normalizeDocument } from "./normalize.js";
6
6
  import { parseWorldOrbit } from "./parse.js";
7
7
  import { validateDocument } from "./validate.js";
8
- const ATLAS_SCHEMA_PATTERN = /^schema\s+2(?:\.0|\.1|\.5)?$/i;
8
+ const ATLAS_SCHEMA_PATTERN = /^schema\s+2(?:\.0|\.1|\.5|\.6)?$/i;
9
9
  const ATLAS_SCHEMA_21_PATTERN = /^schema\s+2\.1$/i;
10
10
  const ATLAS_SCHEMA_25_PATTERN = /^schema\s+2\.5$/i;
11
+ const ATLAS_SCHEMA_26_PATTERN = /^schema\s+2\.6$/i;
11
12
  const LEGACY_DRAFT_SCHEMA_PATTERN = /^schema\s+2\.0-draft$/i;
12
13
  export function detectWorldOrbitSchemaVersion(source) {
13
14
  for (const line of stripCommentsForSchemaDetection(source).split(/\r?\n/)) {
@@ -22,7 +23,10 @@ export function detectWorldOrbitSchemaVersion(source) {
22
23
  return "2.1";
23
24
  }
24
25
  if (ATLAS_SCHEMA_25_PATTERN.test(trimmed)) {
25
- return "2.6.1";
26
+ return "2.5";
27
+ }
28
+ if (ATLAS_SCHEMA_26_PATTERN.test(trimmed)) {
29
+ return "2.6";
26
30
  }
27
31
  if (ATLAS_SCHEMA_PATTERN.test(trimmed)) {
28
32
  return "2.0";
@@ -88,7 +92,7 @@ export function loadWorldOrbitSourceWithDiagnostics(source) {
88
92
  schemaVersion === "2.0-draft" ||
89
93
  schemaVersion === "2.1" ||
90
94
  schemaVersion === "2.5" ||
91
- schemaVersion === "2.6.1") {
95
+ schemaVersion === "2.6") {
92
96
  return loadAtlasSourceWithDiagnostics(source, schemaVersion);
93
97
  }
94
98
  let ast;
@@ -17,11 +17,12 @@ export function renderDocumentToScene(document, options = {}) {
17
17
  const schemaProjection = resolveProjection(document, options.projection);
18
18
  const camera = normalizeViewCamera(options.camera ?? null);
19
19
  const renderProjection = resolveRenderProjection(schemaProjection, camera);
20
- const scaleModel = resolveScaleModel(layoutPreset, options.scaleModel);
21
20
  const spacingFactor = layoutPresetSpacing(layoutPreset);
21
+ const scaleModel = resolveScaleModel(layoutPreset, options.scaleModel, options.bodyScaleMode);
22
22
  const systemId = document.system?.id ?? null;
23
23
  const activeEventId = options.activeEventId ?? null;
24
24
  const effectiveObjects = createEffectiveObjects(document.objects, document.events ?? [], activeEventId);
25
+ const sceneMetricScale = resolveSceneMetricScale(effectiveObjects, width, height, padding, spacingFactor, scaleModel);
25
26
  const objectMap = new Map(effectiveObjects.map((object) => [object.id, object]));
26
27
  const relationships = buildSceneRelationships(effectiveObjects, objectMap);
27
28
  const positions = new Map();
@@ -61,6 +62,7 @@ export function renderDocumentToScene(document, options = {}) {
61
62
  spacingFactor,
62
63
  projection: renderProjection,
63
64
  scaleModel,
65
+ sceneMetricScale,
64
66
  };
65
67
  const primaryRoot = rootObjects.find((object) => object.type === "star") ?? rootObjects[0] ?? null;
66
68
  if (primaryRoot) {
@@ -82,14 +84,14 @@ export function renderDocumentToScene(document, options = {}) {
82
84
  const x = width -
83
85
  padding -
84
86
  140 -
85
- freePlacementOffsetPx(object.placement?.mode === "free" ? object.placement.distance : undefined, scaleModel);
87
+ freePlacementOffsetPx(object.placement?.mode === "free" ? object.placement.distance : undefined, scaleModel, sceneMetricScale);
86
88
  const rowStep = Math.max(76, ((height - padding * 2 - 180) / Math.max(1, freeObjects.length)) * spacingFactor) * scaleModel.freePlacementMultiplier;
87
89
  const y = padding + 92 + index * rowStep;
88
90
  positions.set(object.id, {
89
91
  object,
90
92
  x,
91
93
  y,
92
- radius: visualRadiusFor(object, 0, scaleModel),
94
+ radius: visualRadiusFor(object, 0, scaleModel, sceneMetricScale),
93
95
  sortKey: computeSortKey(x, y, 0),
94
96
  });
95
97
  leaderDrafts.push({
@@ -112,7 +114,7 @@ export function renderDocumentToScene(document, options = {}) {
112
114
  object,
113
115
  x: resolved.x,
114
116
  y: resolved.y,
115
- radius: visualRadiusFor(object, 2, scaleModel),
117
+ radius: visualRadiusFor(object, 2, scaleModel, sceneMetricScale),
116
118
  sortKey: computeSortKey(resolved.x, resolved.y, 2),
117
119
  anchorX: resolved.anchorX,
118
120
  anchorY: resolved.anchorY,
@@ -164,6 +166,7 @@ export function renderDocumentToScene(document, options = {}) {
164
166
  scale: String(document.system?.properties.scale ?? layoutPreset),
165
167
  units: String(document.system?.properties.units ?? "mixed"),
166
168
  preset: frame.preset ?? "custom",
169
+ "body.scaleMode": scaleModel.bodyScaleMode,
167
170
  ...(camera?.azimuth !== null ? { "camera.azimuth": String(camera?.azimuth) } : {}),
168
171
  ...(camera?.elevation !== null ? { "camera.elevation": String(camera?.elevation) } : {}),
169
172
  ...(camera?.roll !== null ? { "camera.roll": String(camera?.roll) } : {}),
@@ -344,10 +347,11 @@ function buildSceneSubtitle(projection, renderProjection, layoutPreset, camera)
344
347
  }
345
348
  return parts.join(" - ");
346
349
  }
347
- function resolveScaleModel(layoutPreset, overrides) {
350
+ function resolveScaleModel(layoutPreset, overrides, bodyScaleMode) {
348
351
  const defaults = defaultScaleModel(layoutPreset);
349
352
  return {
350
353
  ...defaults,
354
+ ...(bodyScaleMode ? { bodyScaleMode } : {}),
351
355
  ...overrides,
352
356
  };
353
357
  }
@@ -362,6 +366,7 @@ function defaultScaleModel(layoutPreset) {
362
366
  ringThicknessMultiplier: 0.92,
363
367
  minBodyRadius: 4,
364
368
  maxBodyRadius: 36,
369
+ bodyScaleMode: "readable",
365
370
  };
366
371
  case "presentation":
367
372
  return {
@@ -372,6 +377,7 @@ function defaultScaleModel(layoutPreset) {
372
377
  ringThicknessMultiplier: 1.16,
373
378
  minBodyRadius: 5,
374
379
  maxBodyRadius: 48,
380
+ bodyScaleMode: "readable",
375
381
  };
376
382
  default:
377
383
  return {
@@ -382,6 +388,7 @@ function defaultScaleModel(layoutPreset) {
382
388
  ringThicknessMultiplier: 1,
383
389
  minBodyRadius: 4,
384
390
  maxBodyRadius: 40,
391
+ bodyScaleMode: "readable",
385
392
  };
386
393
  }
387
394
  }
@@ -395,6 +402,51 @@ function layoutPresetSpacing(layoutPreset) {
395
402
  return 1;
396
403
  }
397
404
  }
405
+ function resolveSceneMetricScale(objects, width, height, padding, spacingFactor, scaleModel) {
406
+ const orbitMetrics = [];
407
+ const bodyMetrics = [];
408
+ for (const object of objects) {
409
+ const radiusMetric = objectRadiusMetric(object);
410
+ if (radiusMetric !== null && radiusMetric > 0) {
411
+ bodyMetrics.push(radiusMetric);
412
+ }
413
+ const placement = object.placement;
414
+ if (!placement) {
415
+ continue;
416
+ }
417
+ if (placement.mode === "orbit") {
418
+ const orbitMetricValue = toDistanceMetric(placement.semiMajor ?? placement.distance ?? null);
419
+ if (orbitMetricValue !== null && orbitMetricValue > 0) {
420
+ orbitMetrics.push(orbitMetricValue);
421
+ }
422
+ continue;
423
+ }
424
+ if (placement.mode === "free") {
425
+ const freeMetric = toDistanceMetric(placement.distance ?? null);
426
+ if (freeMetric !== null && freeMetric > 0) {
427
+ orbitMetrics.push(freeMetric);
428
+ }
429
+ }
430
+ }
431
+ const maxDistanceMetric = Math.max(...orbitMetrics, 0);
432
+ const maxBodyMetric = Math.max(...bodyMetrics, 0);
433
+ const baseMetric = Math.max(maxDistanceMetric, maxBodyMetric * 6, 0);
434
+ const referenceMetric = baseMetric +
435
+ Math.max(Math.sqrt(baseMetric), maxBodyMetric * 2, maxDistanceMetric > 0 ? 0.25 : 0);
436
+ if (referenceMetric <= 0) {
437
+ return {
438
+ pixelsPerMetric: null,
439
+ hasExplicitScale: false,
440
+ };
441
+ }
442
+ const availableRadius = Math.max(Math.min(width, height) / 2 - padding - 24, 120) *
443
+ spacingFactor *
444
+ scaleModel.orbitDistanceMultiplier;
445
+ return {
446
+ pixelsPerMetric: availableRadius / referenceMetric,
447
+ hasExplicitScale: true,
448
+ };
449
+ }
398
450
  function createSceneObject(position, scaleModel, relationships) {
399
451
  const { object, x, y, radius, sortKey, anchorX, anchorY } = position;
400
452
  const renderPriority = object.renderHints?.renderPriority ?? 0;
@@ -1064,7 +1116,7 @@ function parseViewpointGroups(value, document, relationships, objectMap) {
1064
1116
  return splitListValue(value).map((entry) => {
1065
1117
  if (document.schemaVersion === "2.1" ||
1066
1118
  document.schemaVersion === "2.5" ||
1067
- document.schemaVersion === "2.6.1" ||
1119
+ document.schemaVersion === "2.6" ||
1068
1120
  document.groups.some((group) => group.id === entry)) {
1069
1121
  return entry;
1070
1122
  }
@@ -1198,7 +1250,7 @@ function placeObject(object, x, y, depth, positions, orbitDrafts, leaderDrafts,
1198
1250
  object,
1199
1251
  x,
1200
1252
  y,
1201
- radius: visualRadiusFor(object, depth, context.scaleModel),
1253
+ radius: visualRadiusFor(object, depth, context.scaleModel, context.sceneMetricScale),
1202
1254
  sortKey: computeSortKey(x, y, depth),
1203
1255
  });
1204
1256
  placeOrbitingChildren(object, positions, orbitDrafts, leaderDrafts, context, depth + 1);
@@ -1209,7 +1261,7 @@ function placeOrbitingChildren(object, positions, orbitDrafts, leaderDrafts, con
1209
1261
  return;
1210
1262
  }
1211
1263
  const orbiting = [...(context.orbitChildren.get(object.id) ?? [])].sort(compareOrbiting);
1212
- const orbitMetricContext = computeOrbitMetricContext(orbiting, parent.radius, context.spacingFactor, context.scaleModel);
1264
+ const orbitMetricContext = computeOrbitMetricContext(orbiting, parent.radius, context.spacingFactor, context.scaleModel, context.sceneMetricScale);
1213
1265
  const orbitRadiiPx = resolveOrbitRadiiPx(orbiting, orbitMetricContext);
1214
1266
  orbiting.forEach((child, index) => {
1215
1267
  const orbitGeometry = resolveOrbitGeometry(child, index, orbiting.length, parent, orbitMetricContext, orbitRadiiPx[index] ?? orbitMetricContext.innerPx, context);
@@ -1244,7 +1296,7 @@ function placeOrbitingChildren(object, positions, orbitDrafts, leaderDrafts, con
1244
1296
  object: child,
1245
1297
  x,
1246
1298
  y,
1247
- radius: visualRadiusFor(child, depth + 1, context.scaleModel),
1299
+ radius: visualRadiusFor(child, depth + 1, context.scaleModel, context.sceneMetricScale),
1248
1300
  sortKey: computeSortKey(x, y, depth + 1),
1249
1301
  anchorX,
1250
1302
  anchorY,
@@ -1273,10 +1325,13 @@ function compareOrbiting(left, right) {
1273
1325
  return 1;
1274
1326
  return left.id.localeCompare(right.id);
1275
1327
  }
1276
- function computeOrbitMetricContext(objects, parentRadius, spacingFactor, scaleModel) {
1328
+ function computeOrbitMetricContext(objects, parentRadius, spacingFactor, scaleModel, sceneMetricScale) {
1277
1329
  const metrics = objects.map((object) => orbitMetric(object));
1278
1330
  const presentMetrics = metrics.filter((value) => value !== null);
1279
- const innerPx = parentRadius + 56 * spacingFactor * scaleModel.orbitDistanceMultiplier;
1331
+ const minimumGapPx = scaleModel.bodyScaleMode === "strict"
1332
+ ? Math.max(2, 8 * spacingFactor * scaleModel.orbitDistanceMultiplier)
1333
+ : (objects.length > 2 ? 54 : 64) * spacingFactor * scaleModel.orbitDistanceMultiplier * 0.42;
1334
+ const innerPx = parentRadius + Math.max(minimumGapPx * 1.2, 24 * spacingFactor);
1280
1335
  const stepPx = (objects.length > 2 ? 54 : 64) * spacingFactor * scaleModel.orbitDistanceMultiplier;
1281
1336
  if (presentMetrics.length === 0) {
1282
1337
  return {
@@ -1287,7 +1342,8 @@ function computeOrbitMetricContext(objects, parentRadius, spacingFactor, scaleMo
1287
1342
  innerPx,
1288
1343
  stepPx,
1289
1344
  pixelSpread: Math.max(stepPx * Math.max(objects.length - 1, 1), stepPx),
1290
- minimumGapPx: stepPx * 0.42,
1345
+ minimumGapPx,
1346
+ pixelsPerMetric: sceneMetricScale.pixelsPerMetric,
1291
1347
  };
1292
1348
  }
1293
1349
  const minMetric = Math.min(...presentMetrics);
@@ -1301,7 +1357,8 @@ function computeOrbitMetricContext(objects, parentRadius, spacingFactor, scaleMo
1301
1357
  innerPx,
1302
1358
  stepPx,
1303
1359
  pixelSpread: Math.max(stepPx * Math.max(objects.length - 1, 1), stepPx),
1304
- minimumGapPx: stepPx * 0.42,
1360
+ minimumGapPx,
1361
+ pixelsPerMetric: sceneMetricScale.pixelsPerMetric,
1305
1362
  };
1306
1363
  }
1307
1364
  function resolveOrbitGeometry(object, index, count, parent, metricContext, orbitRadiusPx, context) {
@@ -1366,6 +1423,9 @@ function resolveOrbitGeometry(object, index, count, parent, metricContext, orbit
1366
1423
  };
1367
1424
  }
1368
1425
  function resolveOrbitRadiusPx(metric, metricContext) {
1426
+ if (metricContext.pixelsPerMetric !== null) {
1427
+ return metric * metricContext.pixelsPerMetric;
1428
+ }
1369
1429
  return metricContext.innerPx + metricContext.stepPx * log2(Math.max(metric, 0) + 1);
1370
1430
  }
1371
1431
  function resolveOrbitRadiiPx(objects, metricContext) {
@@ -1404,6 +1464,12 @@ function resolveBandThickness(object, orbitRadius, metricContext, scaleModel) {
1404
1464
  const outerMetric = toDistanceMetric(toUnitValue(object.properties.outer));
1405
1465
  if (innerMetric !== null && outerMetric !== null) {
1406
1466
  const thicknessMetric = Math.abs(outerMetric - innerMetric);
1467
+ if (metricContext.pixelsPerMetric !== null) {
1468
+ const thicknessPx = thicknessMetric * metricContext.pixelsPerMetric;
1469
+ return scaleModel.bodyScaleMode === "strict"
1470
+ ? Math.max(thicknessPx * scaleModel.ringThicknessMultiplier, 1)
1471
+ : clampNumber(Math.max(thicknessPx * scaleModel.ringThicknessMultiplier, 8), 8, 54);
1472
+ }
1407
1473
  if (metricContext.metricSpread > 0) {
1408
1474
  return clampNumber((thicknessMetric / metricContext.metricSpread) *
1409
1475
  metricContext.pixelSpread *
@@ -1698,8 +1764,8 @@ function deriveParentAnchor(objectId, positions, objectMap) {
1698
1764
  }
1699
1765
  return positions.get(object.placement.target);
1700
1766
  }
1701
- function visualRadiusFor(object, depth, scaleModel) {
1702
- const explicitRadius = toVisualSizeMetric(object.properties.radius, scaleModel);
1767
+ function visualRadiusFor(object, depth, scaleModel, sceneMetricScale) {
1768
+ const explicitRadius = toVisualSizeMetric(object.properties.radius, scaleModel, sceneMetricScale);
1703
1769
  if (explicitRadius !== null) {
1704
1770
  return explicitRadius;
1705
1771
  }
@@ -1766,18 +1832,31 @@ function toDistanceMetric(value) {
1766
1832
  return value.value;
1767
1833
  }
1768
1834
  }
1769
- function freePlacementOffsetPx(distance, scaleModel) {
1835
+ function freePlacementOffsetPx(distance, scaleModel, sceneMetricScale) {
1770
1836
  const metric = toDistanceMetric(distance ?? null);
1771
1837
  if (metric === null || metric <= 0) {
1772
1838
  return 0;
1773
1839
  }
1840
+ if (sceneMetricScale.pixelsPerMetric !== null) {
1841
+ const scaled = metric * sceneMetricScale.pixelsPerMetric * scaleModel.freePlacementMultiplier;
1842
+ return scaleModel.bodyScaleMode === "strict"
1843
+ ? Math.max(scaled, 0)
1844
+ : clampNumber(scaled, 0, 420);
1845
+ }
1774
1846
  return clampNumber(metric * 96 * scaleModel.freePlacementMultiplier, 0, 420);
1775
1847
  }
1776
- function toVisualSizeMetric(value, scaleModel) {
1848
+ function toVisualSizeMetric(value, scaleModel, sceneMetricScale) {
1777
1849
  const unitValue = toUnitValue(value);
1778
1850
  if (!unitValue) {
1779
1851
  return null;
1780
1852
  }
1853
+ const physicalMetric = toDistanceMetric(unitValue);
1854
+ if (sceneMetricScale.pixelsPerMetric !== null && physicalMetric !== null && physicalMetric > 0) {
1855
+ const scaled = physicalMetric * sceneMetricScale.pixelsPerMetric * scaleModel.bodyRadiusMultiplier;
1856
+ return scaleModel.bodyScaleMode === "strict"
1857
+ ? Math.max(scaled, 0.1)
1858
+ : clampNumber(Math.max(scaled, scaleModel.minBodyRadius), scaleModel.minBodyRadius, scaleModel.maxBodyRadius);
1859
+ }
1781
1860
  let size;
1782
1861
  switch (unitValue.unit) {
1783
1862
  case "sol":
@@ -1795,6 +1874,9 @@ function toVisualSizeMetric(value, scaleModel) {
1795
1874
  }
1796
1875
  return clampNumber(size * scaleModel.bodyRadiusMultiplier, scaleModel.minBodyRadius, scaleModel.maxBodyRadius);
1797
1876
  }
1877
+ function objectRadiusMetric(object) {
1878
+ return toDistanceMetric(toUnitValue(object.properties.radius));
1879
+ }
1798
1880
  function toUnitValue(value) {
1799
1881
  if (!value || typeof value !== "object" || !("value" in value)) {
1800
1882
  return null;
@@ -12,6 +12,7 @@ export function renderDocumentToSpatialScene(document, options = {}) {
12
12
  projection: options.projection,
13
13
  camera: options.camera,
14
14
  scaleModel: options.scaleModel,
15
+ bodyScaleMode: options.bodyScaleMode,
15
16
  activeEventId: options.activeEventId,
16
17
  };
17
18
  const scene = renderDocumentToScene(document, renderOptions);
@@ -2,12 +2,13 @@ export type WorldOrbitObjectType = "system" | "star" | "planet" | "moon" | "belt
2
2
  export type PlacementMode = "orbit" | "at" | "surface" | "free";
3
3
  export type Unit = "au" | "km" | "m" | "ly" | "pc" | "kpc" | "re" | "rj" | "sol" | "me" | "mj" | "s" | "min" | "h" | "d" | "y" | "ky" | "my" | "gy" | "K" | "deg";
4
4
  export type WorldOrbitDocumentVersion = "1.0";
5
- export type WorldOrbitAtlasDocumentVersion = "2.0" | "2.1" | "2.5" | "2.6.1";
5
+ export type WorldOrbitAtlasDocumentVersion = "2.0" | "2.1" | "2.5" | "2.6";
6
6
  export type WorldOrbitDraftDocumentVersion = "2.0-draft";
7
7
  export type WorldOrbitAnyDocumentVersion = WorldOrbitDocumentVersion | WorldOrbitAtlasDocumentVersion | WorldOrbitDraftDocumentVersion;
8
8
  export type ViewProjection = "topdown" | "isometric" | "orthographic" | "perspective";
9
9
  export type RenderProjectionFallback = "topdown" | "isometric";
10
10
  export type RenderPresetName = "diagram" | "presentation" | "atlas-card" | "markdown";
11
+ export type BodyScaleMode = "readable" | "strict";
11
12
  export interface CoordinatePoint {
12
13
  x: number;
13
14
  y: number;
@@ -263,6 +264,7 @@ export interface RenderScaleModel {
263
264
  ringThicknessMultiplier: number;
264
265
  minBodyRadius: number;
265
266
  maxBodyRadius: number;
267
+ bodyScaleMode: BodyScaleMode;
266
268
  }
267
269
  export interface SceneRenderOptions {
268
270
  width?: number;
@@ -272,6 +274,7 @@ export interface SceneRenderOptions {
272
274
  projection?: "document" | ViewProjection;
273
275
  camera?: WorldOrbitViewCamera | null;
274
276
  scaleModel?: Partial<RenderScaleModel>;
277
+ bodyScaleMode?: BodyScaleMode;
275
278
  activeEventId?: string | null;
276
279
  }
277
280
  export interface SpatialScaleModel {
@@ -1,4 +1,4 @@
1
- import { cloneAtlasDocument, createEmptyAtlasDocument, formatDocument, getAtlasDocumentNode, loadWorldOrbitSourceWithDiagnostics, materializeAtlasDocument, removeAtlasDocumentNode, resolveAtlasDiagnostics, rotatePoint, upgradeDocumentToV2, validateAtlasDocumentWithDiagnostics, } from "@worldorbit/core";
1
+ import { cloneAtlasDocument, createEmptyAtlasDocument, formatDocument, getAtlasDocumentNode, loadWorldOrbitSourceWithDiagnostics, materializeAtlasDocument, removeAtlasDocumentNode, renderDocumentToScene, resolveAtlasDiagnostics, rotatePoint, upgradeDocumentToV2, validateAtlasDocumentWithDiagnostics, } from "@worldorbit/core";
2
2
  import { renderWorldOrbitBlock } from "@worldorbit/markdown";
3
3
  import { createInteractiveViewer, } from "@worldorbit/viewer";
4
4
  import { getViewerVisibleBounds, invertViewerPoint } from "@worldorbit/viewer/viewer-state";
@@ -1250,7 +1250,7 @@ export function createWorldOrbitEditor(container, options = {}) {
1250
1250
  break;
1251
1251
  case "orbit-radius":
1252
1252
  if (details.object.placement?.mode === "orbit" && details.orbit) {
1253
- nextDocument = updateOrbitRadius(atlasDocument, dragState.path, dragState.objectId, details, pointer, dragState.orbitRadiusContext ?? null);
1253
+ nextDocument = updateOrbitRadius(atlasDocument, dragState.path, dragState.objectId, viewer.getScene(), details, pointer, dragState.orbitRadiusContext ?? null);
1254
1254
  }
1255
1255
  break;
1256
1256
  case "at-reference":
@@ -2468,7 +2468,7 @@ function updateOrbitPhase(document, path, objectId, details, pointer) {
2468
2468
  };
2469
2469
  return next;
2470
2470
  }
2471
- function updateOrbitRadius(document, path, objectId, details, pointer, dragContext) {
2471
+ function updateOrbitRadius(document, path, objectId, scene, details, pointer, dragContext) {
2472
2472
  const orbit = details.orbit;
2473
2473
  if (!orbit || details.object.placement?.mode !== "orbit" || !dragContext) {
2474
2474
  return document;
@@ -2476,25 +2476,54 @@ function updateOrbitRadius(document, path, objectId, details, pointer, dragConte
2476
2476
  const unrotated = rotatePoint(pointer, { x: orbit.cx, y: orbit.cy }, -orbit.rotationDeg);
2477
2477
  const nextDisplayedRadius = Math.max(Math.abs(unrotated.x - orbit.cx), 24);
2478
2478
  const nextBaseRadius = Math.max(nextDisplayedRadius - dragContext.radiusOffsetPx, dragContext.innerPx);
2479
- const nextMetric = orbitRadiusPxToMetric(nextBaseRadius, dragContext.innerPx, dragContext.stepPx);
2479
+ const nextMetric = orbitRadiusPxToMetric(nextBaseRadius, dragContext.innerPx, dragContext.stepPx, dragContext.mode, dragContext.pixelsPerMetric);
2480
2480
  const next = cloneAtlasDocument(document);
2481
2481
  const placementOwner = findEditablePlacementOwner(next, path, objectId);
2482
2482
  if (!placementOwner || placementOwner.placement.mode !== "orbit") {
2483
2483
  return document;
2484
2484
  }
2485
- const currentValue = placementOwner.placement.semiMajor ??
2486
- placementOwner.placement.distance ?? {
2485
+ const orbitPlacementOwner = placementOwner;
2486
+ const currentValue = orbitPlacementOwner.placement.semiMajor ??
2487
+ orbitPlacementOwner.placement.distance ?? {
2487
2488
  value: 1,
2488
2489
  unit: "au",
2489
2490
  };
2490
2491
  const scaled = distanceMetricToUnitValue(Math.max(nextMetric, 0), dragContext.preferredUnit ?? currentValue.unit);
2492
+ applyOrbitDistanceValue(orbitPlacementOwner, scaled);
2493
+ const targetDisplayedRadius = nextDisplayedRadius;
2494
+ let correctedMetric = nextMetric;
2495
+ for (let iteration = 0; iteration < 3; iteration += 1) {
2496
+ const candidateScene = renderDocumentToScene(materializeAtlasDocument(next), {
2497
+ width: scene.width,
2498
+ height: scene.height,
2499
+ padding: scene.padding,
2500
+ preset: scene.renderPreset ?? undefined,
2501
+ projection: scene.projection,
2502
+ camera: scene.camera,
2503
+ scaleModel: { ...scene.scaleModel },
2504
+ bodyScaleMode: scene.scaleModel.bodyScaleMode,
2505
+ activeEventId: scene.activeEventId,
2506
+ });
2507
+ const renderedOrbit = candidateScene.orbitVisuals.find((entry) => entry.objectId === objectId);
2508
+ const renderedRadius = renderedOrbit?.kind === "circle"
2509
+ ? renderedOrbit.radius ?? 0
2510
+ : renderedOrbit?.rx ?? 0;
2511
+ if (renderedRadius >= targetDisplayedRadius - 1) {
2512
+ break;
2513
+ }
2514
+ const correctionFactor = targetDisplayedRadius / Math.max(renderedRadius, 1);
2515
+ correctedMetric *= Math.max(correctionFactor, 1.02);
2516
+ applyOrbitDistanceValue(orbitPlacementOwner, distanceMetricToUnitValue(Math.max(correctedMetric, 0), dragContext.preferredUnit ?? currentValue.unit));
2517
+ }
2518
+ return next;
2519
+ }
2520
+ function applyOrbitDistanceValue(placementOwner, value) {
2491
2521
  if (placementOwner.placement.semiMajor) {
2492
- placementOwner.placement.semiMajor = scaled;
2522
+ placementOwner.placement.semiMajor = value;
2493
2523
  }
2494
2524
  else {
2495
- placementOwner.placement.distance = scaled;
2525
+ placementOwner.placement.distance = value;
2496
2526
  }
2497
- return next;
2498
2527
  }
2499
2528
  function updateAtReference(document, path, objectId, scene, pointer) {
2500
2529
  const candidate = findNearestAtCandidate(scene, objectId, pointer);
@@ -2533,15 +2562,26 @@ function createOrbitRadiusDragContext(document, scene, details) {
2533
2562
  !entry.hidden).length;
2534
2563
  const spacingFactor = layoutPresetSpacingForScene(scene.layoutPreset);
2535
2564
  const stepPx = (siblingCount > 2 ? 54 : 64) * spacingFactor * scene.scaleModel.orbitDistanceMultiplier;
2565
+ const minimumGapPx = scene.scaleModel.bodyScaleMode === "strict"
2566
+ ? Math.max(2, 8 * spacingFactor * scene.scaleModel.orbitDistanceMultiplier)
2567
+ : stepPx * 0.42;
2536
2568
  const innerPx = details.parent.radius +
2537
- 56 * spacingFactor * scene.scaleModel.orbitDistanceMultiplier;
2569
+ Math.max(minimumGapPx * 1.2, 24 * spacingFactor);
2538
2570
  const currentValue = details.object.placement.semiMajor ?? details.object.placement.distance ?? null;
2539
2571
  const currentMetric = unitValueToDistanceMetric(currentValue);
2540
2572
  const displayedRadius = details.orbit.kind === "circle" ? details.orbit.radius ?? 1 : details.orbit.rx ?? 1;
2541
- const baseRadius = orbitMetricToRadiusPx(currentMetric ?? 0, innerPx, stepPx);
2573
+ const usesLinearScale = currentMetric !== null && currentMetric > 0;
2574
+ const pixelsPerMetric = usesLinearScale
2575
+ ? displayedRadius / Math.max(currentMetric, 0.0001)
2576
+ : null;
2577
+ const baseRadius = usesLinearScale
2578
+ ? currentMetric * Math.max(pixelsPerMetric ?? 0, 0)
2579
+ : orbitMetricToRadiusPx(currentMetric ?? 0, innerPx, stepPx);
2542
2580
  return {
2581
+ mode: usesLinearScale ? "linear" : "log",
2543
2582
  innerPx,
2544
2583
  stepPx,
2584
+ pixelsPerMetric,
2545
2585
  radiusOffsetPx: displayedRadius - baseRadius,
2546
2586
  preferredUnit: currentValue?.unit ?? null,
2547
2587
  };
@@ -3191,7 +3231,10 @@ function distanceMetricToUnitValue(metric, unit) {
3191
3231
  function orbitMetricToRadiusPx(metric, innerPx, stepPx) {
3192
3232
  return innerPx + stepPx * log2(Math.max(metric, 0) + 1);
3193
3233
  }
3194
- function orbitRadiusPxToMetric(radiusPx, innerPx, stepPx) {
3234
+ function orbitRadiusPxToMetric(radiusPx, innerPx, stepPx, mode, pixelsPerMetric) {
3235
+ if (mode === "linear" && pixelsPerMetric !== null && pixelsPerMetric > 0) {
3236
+ return Math.max(radiusPx / pixelsPerMetric, 0);
3237
+ }
3195
3238
  if (radiusPx <= innerPx) {
3196
3239
  return 0;
3197
3240
  }
@@ -72,6 +72,7 @@ export function createAtlasStateSnapshot(viewerState, renderOptions, filter, vie
72
72
  camera: renderOptions.camera ? { ...renderOptions.camera } : null,
73
73
  layers: renderOptions.layers ? { ...renderOptions.layers } : undefined,
74
74
  scaleModel: renderOptions.scaleModel ? { ...renderOptions.scaleModel } : undefined,
75
+ bodyScaleMode: renderOptions.bodyScaleMode,
75
76
  activeEventId: renderOptions.activeEventId ?? null,
76
77
  viewMode: renderOptions.viewMode ?? "2d",
77
78
  },
@@ -102,6 +103,7 @@ export function deserializeViewerAtlasState(serialized) {
102
103
  scaleModel: raw.renderOptions?.scaleModel
103
104
  ? { ...raw.renderOptions.scaleModel }
104
105
  : undefined,
106
+ bodyScaleMode: raw.renderOptions?.bodyScaleMode,
105
107
  activeEventId: raw.activeEventId ?? raw.renderOptions?.activeEventId ?? null,
106
108
  viewMode: raw.renderOptions?.viewMode ?? "2d",
107
109
  },
@@ -126,6 +128,7 @@ export function createViewerBookmark(name, label, atlasState) {
126
128
  scaleModel: atlasState.renderOptions.scaleModel
127
129
  ? { ...atlasState.renderOptions.scaleModel }
128
130
  : undefined,
131
+ bodyScaleMode: atlasState.renderOptions.bodyScaleMode,
129
132
  activeEventId: atlasState.renderOptions.activeEventId ?? null,
130
133
  viewMode: atlasState.renderOptions.viewMode ?? "2d",
131
134
  },
@@ -129,6 +129,7 @@ export interface ViewerAtlasState {
129
129
  camera?: WorldOrbitViewCamera | null;
130
130
  layers?: ViewerLayerOptions;
131
131
  scaleModel?: Partial<RenderScaleModel>;
132
+ bodyScaleMode?: SceneRenderOptions["bodyScaleMode"];
132
133
  activeEventId?: string | null;
133
134
  viewMode?: WorldOrbitViewMode;
134
135
  quality?: WorldOrbit3DQuality;
@@ -44,6 +44,7 @@ export function createInteractiveViewer(container, options) {
44
44
  padding: options.padding,
45
45
  preset: options.preset,
46
46
  projection: options.projection,
47
+ bodyScaleMode: options.bodyScaleMode,
47
48
  viewMode: options.viewMode ?? "2d",
48
49
  quality: options.quality ?? "balanced",
49
50
  style3d: options.style3d ?? "symbolic",
@@ -1595,6 +1596,7 @@ function hasSceneAffectingRenderOptions(options) {
1595
1596
  options.projection !== undefined ||
1596
1597
  options.camera !== undefined ||
1597
1598
  options.scaleModel !== undefined ||
1599
+ options.bodyScaleMode !== undefined ||
1598
1600
  options.activeEventId !== undefined);
1599
1601
  }
1600
1602
  function resolveSourceRenderOptions(loaded, renderOptions) {