@liquidcars/atlas-layout 0.1.10 → 0.1.13

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.
package/README.md CHANGED
@@ -49,6 +49,11 @@ como alias de compatibilidad. Un contenedor puede no tener geometría o usar una
49
49
  primitiva `geo.*`; las geometrías personalizadas de los packs, como `infra.cloud`,
50
50
  sólo se permiten en entidades hoja.
51
51
 
52
+ Un contenedor puede declarar `open: true` para comenzar expandido. El estado
53
+ cerrado es el valor predeterminado, por lo que `open: false` se normaliza
54
+ omitiendo la propiedad. Declarar `open` en una hoja o usar un valor que no sea
55
+ booleano produce un diagnóstico fatal.
56
+
52
57
  La presentación puede conservar una vista inicial sin introducir datos de
53
58
  cámara en el algoritmo de layout:
54
59
 
@@ -196,4 +201,67 @@ El resultado mantiene `palette`, `theme`, `entities` y `relations`, con `p` y `s
196
201
 
197
202
  El espacio de una celda y el tamaño de la geometría son conceptos distintos. Cuando el layout asigna una celda rectangular a una geometría sin `size` explícito, la figura se centra y se escala uniformemente para caber en el menor volumen compatible; no se estira por separado en `x`, `y` y `z`. Por eso una esfera conserva `s[0] === s[1] === s[2]`, aunque su celda sea rectangular. Un `size` explícito sigue teniendo prioridad y permite al autor solicitar una proporción concreta.
198
203
 
199
- `row`, `grid`/`masonry` y `volume` producen posiciones deterministas. `variant: masonry` desplaza las filas alternas media celda sin exigir una sección `stagger`; `stagger.offset` queda disponible como override explícito. Para conservar legibilidad frontal se recomienda `plane: xy`, y para una composición lateral, `plane: yz`. Las constraints de tamaño y alineación, el empaquetado compacto, los grupos virtuales y la distribución `justify` forman parte del contrato estable de autoría. El algoritmo `graph` sigue reservado: por ahora emite `GRAPH_LAYOUT_FALLBACK` y usa `grid`. La proyección y el enrutado avanzado de relaciones entre contenedores permanecen como líneas de desarrollo posteriores.
204
+ `row`, `grid`/`masonry` y `volume` producen posiciones deterministas. `variant: masonry` desplaza las filas alternas media celda sin exigir una sección `stagger`; `stagger.offset` queda disponible como override explícito. Para conservar legibilidad frontal se recomienda `plane: xy`, y para una composición lateral, `plane: yz`. Las constraints de tamaño y alineación, el empaquetado compacto, los grupos virtuales y la distribución `justify` forman parte del contrato estable de autoría.
205
+
206
+ `auto` delega en el optimizador global la elección de algoritmo, dirección y
207
+ plano para ese propietario de layout. El compilador prueba alternativas de
208
+ `graph`, `row`, `grid` y `volume` mediante una búsqueda determinista y puntúa el modelo
209
+ completo, no cada contenedor de forma aislada. La búsqueda resuelve primero los
210
+ contenidos anidados y después sus propietarios, de modo que la composición global
211
+ se decide con tamaños y afinidades estables. `graph` también aporta variantes
212
+ espaciales internas con varios carriles de profundidad; `auto` puede elegirlas
213
+ sin que el autor tenga que fijar un nuevo algoritmo. Las intersecciones físicas
214
+ 3D y los solapamientos son condiciones estrictas. Entre las soluciones válidas,
215
+ la función perceptiva equilibra los atravesamientos y cruces proyectados con la
216
+ legibilidad frontal, la alineación ortogonal, la longitud media de las relaciones,
217
+ la compacidad volumétrica, la coherencia de orientación y las proporciones
218
+ excesivamente alargadas. Los carriles de profundidad automáticos emplean un paso
219
+ compacto; un layout `graph` explícito conserva el paso completo. Un `direction`, `plane`,
220
+ `gap` o `padding` declarado junto a `auto` queda bloqueado y limita las
221
+ alternativas. Si no se declara `gap`, se utiliza `3`.
222
+
223
+ ```yaml
224
+ layout:
225
+ algorithm: auto
226
+ # direction: z # opcional: bloquea el eje, pero no el algoritmo resuelto
227
+ ```
228
+
229
+ La cámara inicial de `render.camera` es la vista principal de la puntuación. Se
230
+ combina con una vista frontal canónica —que mide cuántos frentes quedan ocultos—
231
+ y varias perspectivas oblicuas para evitar una solución que solo resulte legible
232
+ desde un ángulo. Un host como
233
+ Atlas Studio también puede pasar la vista interactiva con
234
+ `compileAtlasModel(source, { autoLayoutCamera: camera })`. Cada propietario
235
+ automático produce un diagnóstico informativo `AUTO_LAYOUT_RESOLVED` con la
236
+ configuración elegida y las métricas globales, incluidas la oclusión frontal,
237
+ las peores métricas proyectadas y las intersecciones espaciales. `analyzeAtlasLayout(model,
238
+ { camera })` permite puntuar un modelo ya compilado con la misma función
239
+ objetivo.
240
+
241
+ `graph` implementa un layout por capas sensible a las relaciones. Proyecta las
242
+ relaciones de descendientes sobre los hijos inmediatos del contenedor, asigna
243
+ capas según la dirección del flujo y utiliza barridos de baricentro para reducir
244
+ cruces y longitud de conexiones. Los empates conservan el orden de autoría para
245
+ que el resultado sea determinista. Las relaciones con `layout: false` no
246
+ participan. Los ciclos se rompen de forma estable y producen el diagnóstico
247
+ `GRAPH_CYCLE_STABILIZED`. Cuando varios contenedores usan `graph`, una pasada
248
+ jerárquica adicional utiliza las relaciones que cruzan sus límites para alinear
249
+ carriles entre contenedores. De este modo, dos elementos relacionados pueden
250
+ quedar en la misma vertical u horizontal aunque no sean hermanos directos.
251
+
252
+ El `gap` predeterminado de `graph` es `3`; puede reducirse de forma explícita
253
+ para modelos compactos. Las relaciones siguen determinando las capas sobre
254
+ `direction`. Elegir automáticamente entre composiciones alternativas en `x`,
255
+ `y` o `z` pertenece a una fase de optimización asistida, no al compilador
256
+ determinista.
257
+
258
+ ```yaml
259
+ layout:
260
+ algorithm: graph
261
+ direction: y
262
+ plane: xy
263
+ gap: 3
264
+ ```
265
+
266
+ El enrutado avanzado de tubos alrededor de geometrías permanece como una fase
267
+ posterior e independiente de la colocación de nodos.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liquidcars/atlas-layout",
3
- "version": "0.1.10",
3
+ "version": "0.1.13",
4
4
  "description": "Declarative layout compiler for LiquidCars Atlas models",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -47,7 +47,7 @@ subject to sizeMode and diagnostics.
47
47
 
48
48
  Required: id.
49
49
 
50
- Common fields: name, sub, text, url, type, parent, children, geometry, geometryOptions,
50
+ Common fields: name, sub, text, url, type, open, parent, children, geometry, geometryOptions,
51
51
  geometryPalette, style, layout, weight, span, size, sizeMode, p, s, c and radius.
52
52
 
53
53
  An entity with children is a container and must explicitly declare `type: container`.
@@ -55,6 +55,9 @@ A leaf entity may omit `type`, in which case it is normalized as an item. The
55
55
  legacy value `type: shell` remains accepted as a compatibility alias for
56
56
  `container`, but is not emitted by the compiler or suggested by the editor.
57
57
 
58
+ Only a container may declare `open`. `open: true` makes it initially expanded;
59
+ omitting the property (or declaring `false`) means initially closed.
60
+
58
61
  Containers may omit geometry or use a built-in primitive such as `geo.box`.
59
62
  Custom geometry-pack geometries such as `infra.cloud` and `media.image-box` are
60
63
  leaf geometries and cannot be used on containers. The compiler reports these violations
@@ -71,7 +74,37 @@ fit permits assisted layout to use the geometry's canonical size.
71
74
 
72
75
  ## Assisted layout
73
76
 
74
- layout.algorithm accepts row, grid, volume or graph. direction accepts x, y or z.
77
+ layout.algorithm accepts auto, row, grid, volume or graph. direction accepts x,
78
+ y or z. Auto is an explicit permission for the compiler to evaluate graph, row,
79
+ grid, volume and internal spatial-graph alternatives against the whole model.
80
+ Real 3D relation/item intersections and visible traversals have first-class
81
+ priority. Front-face occlusion is measured from the canonical front view; the
82
+ primary and canonical oblique views then contribute relation crossings,
83
+ projected overlap, sibling orientation consistency, elongation, relation length
84
+ and footprint to the deterministic score. An authored direction, plane,
85
+ gap or padding beside auto is locked and narrows the search. Auto uses gap 3
86
+ when none is authored. The selected concrete layout is reported through
87
+ `AUTO_LAYOUT_RESOLVED`; it does not replace `algorithm: auto` in the authored
88
+ source.
89
+
90
+ The initial render.camera is the primary projection used for scoring and is
91
+ combined with the canonical front view and canonical oblique views so the result
92
+ does not overfit one angle. A host may instead supply the current interactive view as the
93
+ `autoLayoutCamera` compile option. `analyzeAtlasLayout` exposes the same metrics
94
+ for already compiled models.
95
+
96
+ Graph uses a deterministic layered layout driven by relations whose `layout`
97
+ field is not false. Relations between descendants are projected onto the
98
+ immediate children of the container being laid out. Atlas orders each layer to
99
+ reduce crossings and edge length while retaining authored order as the stable
100
+ tie-breaker. When two or more graph-layout owners are linked by relations, a
101
+ hierarchical sweep also projects those external relations into normalized lane
102
+ hints. This aligns related children across container boundaries without adding
103
+ authored constraints. When `auto` has not been limited to an authored plane, it
104
+ may also distribute graph nodes across deterministic depth lanes. Directed cycles are broken deterministically and reported
105
+ as `GRAPH_CYCLE_STABILIZED`. Graph uses a default gap of 3 world units; the
106
+ other algorithms retain their existing defaults. Axis and plane selection stay
107
+ declarative and are not changed implicitly by compilation.
75
108
  Grid layouts also accept variant uniform or masonry, optional columns and rows, and
76
109
  a plane of xz (the default), xy or yz. Masonry offsets alternate rows by half a cell
77
110
  unless `stagger.offset` explicitly overrides that distance. Use plane xy for layouts
package/src/index.js CHANGED
@@ -142,6 +142,18 @@ function normalizeEntityTypes(flat, geometryCatalog, diagnostics) {
142
142
  const type = hasChildren || declared === "container" || declared === "shell" ? "container" : "item";
143
143
  entity.type = type;
144
144
 
145
+ if (Object.hasOwn(source, "open") && typeof source.open !== "boolean") {
146
+ diagnostic(diagnostics, "fatal", "INVALID_CONTAINER_OPEN_STATE",
147
+ "Entity '" + entity.id + "' has a non-boolean open value. Use true or omit the property.",
148
+ { entityId: entity.id, open: source.open });
149
+ } else if (Object.hasOwn(source, "open") && type !== "container") {
150
+ diagnostic(diagnostics, "fatal", "OPEN_STATE_REQUIRES_CONTAINER",
151
+ "Entity '" + entity.id + "' declares open but is not a container.",
152
+ { entityId: entity.id, open: source.open });
153
+ }
154
+ if (type === "container" && source.open === true) entity.open = true;
155
+ else delete entity.open;
156
+
145
157
  if (type === "container" && source.geometry && !isPrimitiveGeometry(source.geometry, geometryCatalog)) {
146
158
  diagnostic(diagnostics, "fatal", "CUSTOM_GEOMETRY_ON_CONTAINER",
147
159
  "Container '" + entity.id + "' cannot use non-primitive geometry '" + source.geometry + "'.",
@@ -567,20 +579,20 @@ function compactRowBands(placements, constraints, spec, lockedAxes = new Map())
567
579
  }
568
580
  }
569
581
 
570
- function layoutFunction(spec, diagnostics, ownerId) {
582
+ function layoutFunction(spec, diagnostics, ownerId, graphContext = null) {
571
583
  const algorithm = ALGORITHMS.has(spec?.algorithm) ? spec.algorithm : "grid";
572
584
  if (!ALGORITHMS.has(spec?.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM",
573
585
  `Unknown layout algorithm '${spec?.algorithm}', using grid.`, { entityId: ownerId });
574
- if (algorithm === "graph") diagnostic(diagnostics, "warning", "GRAPH_LAYOUT_FALLBACK",
575
- `Graph layout for '${ownerId || "root"}' is reserved for a later compiler pass; using grid for now.`, { entityId: ownerId });
576
586
  return algorithm === "row"
577
587
  ? rowLayout
578
588
  : algorithm === "volume"
579
589
  ? volumeLayout
580
- : gridLayout;
590
+ : algorithm === "graph"
591
+ ? (items, layoutSpec, layoutDiagnostics) => graphLayout(items, layoutSpec, layoutDiagnostics, graphContext, ownerId)
592
+ : gridLayout;
581
593
  }
582
594
 
583
- function materializeLayoutGroups(items, spec, diagnostics, ownerId) {
595
+ function materializeLayoutGroups(items, spec, diagnostics, ownerId, graphContext = null) {
584
596
  const definitions = Array.isArray(spec?.groups) ? spec.groups : [];
585
597
  if (!definitions.length) return { items, groups: new Map() };
586
598
 
@@ -636,7 +648,9 @@ function materializeLayoutGroups(items, spec, diagnostics, ownerId) {
636
648
  definition,
637
649
  diagnostics,
638
650
  definition.id,
639
- layoutFunction(groupSpec, diagnostics, definition.id)
651
+ layoutFunction(groupSpec, diagnostics, definition.id, graphContext),
652
+ null,
653
+ graphContext
640
654
  );
641
655
  const virtual = {
642
656
  entity: { id: definition.id, type: "layout-group", __atlasVirtualGroup: true },
@@ -702,8 +716,8 @@ function flattenLayoutGroups(placements) {
702
716
  return flattened;
703
717
  }
704
718
 
705
- function constrainedLayout(items, spec, source, diagnostics, ownerId, layout, availableSize = null) {
706
- const grouped = materializeLayoutGroups(items, spec, diagnostics, ownerId);
719
+ function constrainedLayout(items, spec, source, diagnostics, ownerId, layout, availableSize = null, graphContext = null) {
720
+ const grouped = materializeLayoutGroups(items, spec, diagnostics, ownerId, graphContext);
707
721
  const constraints = layoutConstraints(source, spec);
708
722
  applyEqualSizeConstraints(grouped.items, constraints, diagnostics, ownerId);
709
723
  reflowLayoutGroups(grouped.items, diagnostics);
@@ -956,6 +970,180 @@ function volumeLayout(items, spec, diagnostics) {
956
970
  return packedGridLayout(items, spec, diagnostics, 3);
957
971
  }
958
972
 
973
+ function graphAxes(spec = {}) {
974
+ const main = { x: 0, y: 1, z: 2 }[spec.direction] ?? 1;
975
+ const plane = GRID_PLANES[spec.plane] || (main === 2 ? GRID_PLANES.xz : GRID_PLANES.xy);
976
+ const cross = plane.find(axis => axis !== main) ?? [0, 1, 2].find(axis => axis !== main);
977
+ const depth = [0, 1, 2].find(axis => axis !== main && axis !== cross);
978
+ return { main, cross, depth };
979
+ }
980
+
981
+ function graphEndpointItem(endpointId, itemIds, directItemById, byId) {
982
+ let cursor = byId?.get(endpointId);
983
+ const seen = new Set();
984
+ while (cursor && !seen.has(cursor.id)) {
985
+ seen.add(cursor.id);
986
+ const direct = directItemById.get(cursor.id);
987
+ if (direct && itemIds.has(direct)) return direct;
988
+ cursor = cursor.parent ? byId.get(cursor.parent) : null;
989
+ }
990
+ return null;
991
+ }
992
+
993
+ function graphLayout(items, spec, diagnostics, context = null, ownerId = null) {
994
+ const gap = Math.max(0, scalar(spec.gap, 3));
995
+ const padding = Math.max(0, scalar(spec.padding, 1));
996
+ if (!items.length) return boundsOf([], padding);
997
+
998
+ const ids = items.map(item => item.entity.id);
999
+ const itemIds = new Set(ids);
1000
+ const authoredIndex = new Map(ids.map((id, index) => [id, index]));
1001
+ const directItemById = new Map();
1002
+ for (const item of items) {
1003
+ directItemById.set(item.entity.id, item.entity.id);
1004
+ if (item.entity.__atlasVirtualGroup) {
1005
+ for (const member of item.memberItems || []) directItemById.set(member.entity.id, item.entity.id);
1006
+ }
1007
+ }
1008
+
1009
+ const edgeWeights = new Map();
1010
+ for (const relation of context?.relations || []) {
1011
+ if (relation.layout === false) continue;
1012
+ const from = graphEndpointItem(relation.from, itemIds, directItemById, context.byId);
1013
+ const to = graphEndpointItem(relation.to, itemIds, directItemById, context.byId);
1014
+ if (!from || !to || from === to) continue;
1015
+ const key = `${from}\u0000${to}`;
1016
+ edgeWeights.set(key, (edgeWeights.get(key) || 0) + positiveNumber(relation.priority, 1));
1017
+ }
1018
+ const edges = [...edgeWeights].map(([key, weight]) => {
1019
+ const [from, to] = key.split("\u0000");
1020
+ return { from, to, weight };
1021
+ });
1022
+
1023
+ const predecessors = new Map(ids.map(id => [id, []]));
1024
+ const successors = new Map(ids.map(id => [id, []]));
1025
+ const indegree = new Map(ids.map(id => [id, 0]));
1026
+ for (const edge of edges) {
1027
+ predecessors.get(edge.to).push(edge);
1028
+ successors.get(edge.from).push(edge);
1029
+ indegree.set(edge.to, indegree.get(edge.to) + 1);
1030
+ }
1031
+
1032
+ const layerById = new Map(ids.map(id => [id, 0]));
1033
+ const processed = new Set();
1034
+ let cycleReported = false;
1035
+ while (processed.size < ids.length) {
1036
+ let available = ids.filter(id => !processed.has(id) && indegree.get(id) === 0);
1037
+ if (!available.length) {
1038
+ available = ids.filter(id => !processed.has(id)).slice(0, 1);
1039
+ if (!cycleReported) {
1040
+ cycleReported = true;
1041
+ diagnostic(diagnostics, "warning", "GRAPH_CYCLE_STABILIZED",
1042
+ "Graph layout contains a directed cycle; authored order was used to break it deterministically.");
1043
+ }
1044
+ }
1045
+ available.sort((left, right) => authoredIndex.get(left) - authoredIndex.get(right));
1046
+ const id = available[0];
1047
+ const resolvedPredecessors = predecessors.get(id).filter(edge => processed.has(edge.from));
1048
+ layerById.set(id, resolvedPredecessors.length
1049
+ ? Math.max(...resolvedPredecessors.map(edge => layerById.get(edge.from) + 1))
1050
+ : 0);
1051
+ processed.add(id);
1052
+ for (const edge of successors.get(id)) indegree.set(edge.to, Math.max(0, indegree.get(edge.to) - 1));
1053
+ }
1054
+
1055
+ const layers = [];
1056
+ for (const id of ids) {
1057
+ const layer = layerById.get(id);
1058
+ layers[layer] ||= [];
1059
+ layers[layer].push(id);
1060
+ }
1061
+ const order = new Map();
1062
+ const updateOrder = () => layers.forEach(layer => layer.forEach((id, index) => order.set(id, index)));
1063
+ const ownerHints = context?.graphOrderHints?.get(ownerId ?? null);
1064
+ const reorder = (layer, neighbors) => layer.sort((left, right) => {
1065
+ const score = id => {
1066
+ const related = neighbors.get(id).filter(edge => order.has(edge.from === id ? edge.to : edge.from));
1067
+ const external = ownerHints?.get(id);
1068
+ if (!related.length && !external) return null;
1069
+ const internalWeight = related.reduce((sum, edge) => sum + edge.weight, 0);
1070
+ const internalScore = related.reduce((sum, edge) => {
1071
+ const other = edge.from === id ? edge.to : edge.from;
1072
+ const otherLayer = layers[layerById.get(other)] || [];
1073
+ const normalizedOrder = otherLayer.length > 1
1074
+ ? order.get(other) / (otherLayer.length - 1)
1075
+ : .5;
1076
+ return sum + normalizedOrder * Math.max(0, layer.length - 1) * edge.weight;
1077
+ }, 0);
1078
+ const externalWeight = external?.weight || 0;
1079
+ const externalScore = (external?.rank || 0) * Math.max(0, layer.length - 1);
1080
+ const totalWeight = internalWeight + externalWeight;
1081
+ return totalWeight ? (internalScore + externalScore * externalWeight) / totalWeight : null;
1082
+ };
1083
+ const leftScore = score(left);
1084
+ const rightScore = score(right);
1085
+ if (leftScore == null && rightScore == null) return authoredIndex.get(left) - authoredIndex.get(right);
1086
+ if (leftScore == null) return 1;
1087
+ if (rightScore == null) return -1;
1088
+ return leftScore - rightScore || authoredIndex.get(left) - authoredIndex.get(right);
1089
+ });
1090
+
1091
+ updateOrder();
1092
+ if (ownerHints?.size) {
1093
+ const adjacent = new Map(ids.map(id => [id, [...predecessors.get(id), ...successors.get(id)]]));
1094
+ for (const layer of layers) reorder(layer, adjacent);
1095
+ updateOrder();
1096
+ }
1097
+ for (let pass = 0; pass < 6; pass++) {
1098
+ for (let layer = 1; layer < layers.length; layer++) {
1099
+ reorder(layers[layer], predecessors);
1100
+ updateOrder();
1101
+ }
1102
+ for (let layer = layers.length - 2; layer >= 0; layer--) {
1103
+ reorder(layers[layer], successors);
1104
+ updateOrder();
1105
+ }
1106
+ }
1107
+
1108
+ const { main, cross, depth } = graphAxes(spec);
1109
+ const byItemId = new Map(items.map(item => [item.entity.id, item]));
1110
+ const layerExtents = layers.map(layer => Math.max(...layer.map(id => byItemId.get(id).size[main])));
1111
+ const mainPositions = [];
1112
+ let mainCursor = 0;
1113
+ for (let index = 0; index < layers.length; index++) {
1114
+ if (index) mainCursor += layerExtents[index - 1] / 2 + gap * 1.6 + layerExtents[index] / 2;
1115
+ mainPositions[index] = mainCursor;
1116
+ }
1117
+ const directionSign = main === 0 ? 1 : -1;
1118
+ const spatialLayers = spec.spatial === true
1119
+ ? Math.max(2, Math.min(4, Math.floor(positiveNumber(spec.depthLayers, 3))))
1120
+ : 1;
1121
+ const spatialPhase = Math.floor(scalar(spec.spatialPhase, 0));
1122
+ const depthPitch = (Math.max(...items.map(item => item.size[depth]), 1) + gap)
1123
+ * positiveNumber(spec.depthScale, 1);
1124
+ const placements = [];
1125
+ layers.forEach((layer, layerIndex) => {
1126
+ const totalCross = layer.reduce((sum, id) => sum + byItemId.get(id).size[cross], 0)
1127
+ + Math.max(0, layer.length - 1) * gap;
1128
+ let crossCursor = -totalCross / 2;
1129
+ for (let itemIndex = 0; itemIndex < layer.length; itemIndex++) {
1130
+ const id = layer[itemIndex];
1131
+ const item = byItemId.get(id);
1132
+ const position = [0, 0, 0];
1133
+ crossCursor += item.size[cross] / 2;
1134
+ position[main] = mainPositions[layerIndex] * directionSign;
1135
+ position[cross] = crossCursor;
1136
+ if (spatialLayers > 1) {
1137
+ const lane = (layerIndex + itemIndex + spatialPhase) % spatialLayers;
1138
+ position[depth] = (lane - (spatialLayers - 1) / 2) * depthPitch;
1139
+ }
1140
+ crossCursor += item.size[cross] / 2 + gap;
1141
+ placements.push({ ...item, position, graphLayer: layerIndex });
1142
+ }
1143
+ });
1144
+ return boundsOf(placements, padding);
1145
+ }
1146
+
959
1147
  function resolveTheme(source) {
960
1148
  const theme = source.theme || source.styles || {};
961
1149
  const render = source.render || {};
@@ -1046,7 +1234,602 @@ export function compileAtlasProjection(input, visibleIds, options = {}) {
1046
1234
  return compileAtlasModel(projectedAtlasSource(input, visibleIds), options);
1047
1235
  }
1048
1236
 
1049
- export function compileAtlasModel(input, options = {}) {
1237
+ function globalGraphOrderHints(source, flat, relations, byId) {
1238
+ const depthOf = id => {
1239
+ let depth = 0;
1240
+ let cursor = id ? byId.get(id) : null;
1241
+ const seen = new Set();
1242
+ while (cursor?.parent && !seen.has(cursor.id)) {
1243
+ seen.add(cursor.id);
1244
+ depth += 1;
1245
+ cursor = byId.get(cursor.parent);
1246
+ }
1247
+ return depth;
1248
+ };
1249
+ const owners = [];
1250
+ const rootChildren = flat.entities.filter(entity => !entity.parent).map(entity => entity.id);
1251
+ if (source.layout?.algorithm === "graph" && rootChildren.length) {
1252
+ owners.push({ id: null, depth: -1, children: rootChildren });
1253
+ }
1254
+ for (const entity of flat.entities) {
1255
+ const children = flat.childrenById.get(entity.id) || [];
1256
+ const layout = flat.sourceById.get(entity.id)?.layout;
1257
+ if (layout?.algorithm === "graph" && children.length) {
1258
+ owners.push({ id: entity.id, depth: depthOf(entity.id), children: [...children] });
1259
+ }
1260
+ }
1261
+ if (owners.length < 2) return new Map();
1262
+
1263
+ const immediateChild = (endpointId, owner) => {
1264
+ let cursor = byId.get(endpointId);
1265
+ const seen = new Set();
1266
+ while (cursor && !seen.has(cursor.id)) {
1267
+ seen.add(cursor.id);
1268
+ if (owner.id === null && !cursor.parent) return owner.children.includes(cursor.id) ? cursor.id : null;
1269
+ if (cursor.parent === owner.id) return owner.children.includes(cursor.id) ? cursor.id : null;
1270
+ cursor = cursor.parent ? byId.get(cursor.parent) : null;
1271
+ }
1272
+ return null;
1273
+ };
1274
+ const membership = endpointId => owners
1275
+ .map(owner => ({ owner, child: immediateChild(endpointId, owner) }))
1276
+ .filter(item => item.child)
1277
+ .sort((left, right) => right.owner.depth - left.owner.depth)[0] || null;
1278
+
1279
+ const links = new Map(owners.map(owner => [owner.id, new Map(owner.children.map(id => [id, []]))]));
1280
+ for (const relation of relations) {
1281
+ if (relation.layout === false) continue;
1282
+ const from = membership(relation.from);
1283
+ const to = membership(relation.to);
1284
+ if (!from || !to || from.owner.id === to.owner.id) continue;
1285
+ const weight = positiveNumber(relation.priority, 1);
1286
+ links.get(from.owner.id).get(from.child).push({ ownerId: to.owner.id, childId: to.child, weight });
1287
+ links.get(to.owner.id).get(to.child).push({ ownerId: from.owner.id, childId: from.child, weight });
1288
+ }
1289
+
1290
+ const authored = new Map();
1291
+ const ranks = new Map();
1292
+ for (const owner of owners) {
1293
+ const divisor = Math.max(1, owner.children.length - 1);
1294
+ authored.set(owner.id, new Map(owner.children.map((id, index) => [id, index])));
1295
+ ranks.set(owner.id, new Map(owner.children.map((id, index) => [id, index / divisor])));
1296
+ }
1297
+ const sweep = orderedOwners => {
1298
+ for (const owner of orderedOwners) {
1299
+ const ownerLinks = links.get(owner.id);
1300
+ const previousRanks = ranks.get(owner.id);
1301
+ const scored = owner.children.map(id => {
1302
+ const neighbors = ownerLinks.get(id) || [];
1303
+ const total = neighbors.reduce((sum, neighbor) => sum + neighbor.weight, 0);
1304
+ const score = total
1305
+ ? neighbors.reduce((sum, neighbor) => sum + (ranks.get(neighbor.ownerId)?.get(neighbor.childId) ?? .5) * neighbor.weight, 0) / total
1306
+ : previousRanks.get(id);
1307
+ return { id, score, linked: total > 0 };
1308
+ });
1309
+ scored.sort((left, right) => left.score - right.score
1310
+ || Number(right.linked) - Number(left.linked)
1311
+ || authored.get(owner.id).get(left.id) - authored.get(owner.id).get(right.id));
1312
+ const divisor = Math.max(1, scored.length - 1);
1313
+ ranks.set(owner.id, new Map(scored.map((item, index) => [item.id, index / divisor])));
1314
+ }
1315
+ };
1316
+ for (let pass = 0; pass < 8; pass += 1) {
1317
+ sweep(owners);
1318
+ sweep([...owners].reverse());
1319
+ }
1320
+
1321
+ const hints = new Map();
1322
+ for (const owner of owners) {
1323
+ const ownerHints = new Map();
1324
+ for (const childId of owner.children) {
1325
+ const neighbors = links.get(owner.id).get(childId) || [];
1326
+ const total = neighbors.reduce((sum, neighbor) => sum + neighbor.weight, 0);
1327
+ if (!total) continue;
1328
+ const rank = neighbors.reduce((sum, neighbor) =>
1329
+ sum + (ranks.get(neighbor.ownerId)?.get(neighbor.childId) ?? .5) * neighbor.weight, 0) / total;
1330
+ ownerHints.set(childId, { rank, weight: total * 2 });
1331
+ }
1332
+ if (ownerHints.size) hints.set(owner.id, ownerHints);
1333
+ }
1334
+ return hints;
1335
+ }
1336
+
1337
+ const AUTO_LAYOUT_DEFAULT_CAMERA = Object.freeze({
1338
+ position: [12, 8, 14],
1339
+ target: [0, 0, 0]
1340
+ });
1341
+
1342
+ function autoLayoutOwners(source) {
1343
+ const owners = [];
1344
+ const add = (owner, id, root = false) => {
1345
+ if (owner?.layout?.algorithm === "auto") owners.push({ owner, id, root, authoredLayout: structuredClone(owner.layout) });
1346
+ for (const group of owner?.layout?.groups || []) add(group, `${id ?? "root"}::group:${group.id || owners.length}`);
1347
+ };
1348
+ add(source, null, true);
1349
+ const visit = items => {
1350
+ for (const entity of Array.isArray(items) ? items : []) {
1351
+ add(entity, entity.id);
1352
+ visit(entity.children);
1353
+ }
1354
+ };
1355
+ visit(source.entities);
1356
+ return owners;
1357
+ }
1358
+
1359
+ function autoGraphPlanes(direction) {
1360
+ if (direction === "x") return ["xy", "xz"];
1361
+ if (direction === "y") return ["xy", "yz"];
1362
+ return ["xz", "yz"];
1363
+ }
1364
+
1365
+ function autoLayoutCandidates(entry) {
1366
+ const authored = entry.authoredLayout || entry.owner.layout || {};
1367
+ const common = { ...authored, gap: authored.gap ?? 3, padding: authored.padding ?? 1 };
1368
+ delete common.algorithm;
1369
+ const directions = authored.direction ? [authored.direction] : entry.root ? ["y", "x", "z"] : ["z", "x", "y"];
1370
+ const planes = authored.plane ? [authored.plane] : ["xy", "xz", "yz"];
1371
+ const candidates = [];
1372
+ for (const direction of directions) {
1373
+ const graphPlanes = authored.plane ? planes : autoGraphPlanes(direction);
1374
+ for (const plane of graphPlanes) {
1375
+ candidates.push({ ...common, algorithm: "graph", direction, plane });
1376
+ if (!authored.plane) {
1377
+ for (let spatialPhase = 0; spatialPhase < 3; spatialPhase++) {
1378
+ candidates.push({
1379
+ ...common,
1380
+ algorithm: "graph",
1381
+ direction,
1382
+ plane,
1383
+ spatial: true,
1384
+ depthLayers: 3,
1385
+ spatialPhase,
1386
+ // Automatic depth lanes are an escape route for real collisions,
1387
+ // not a reason to inflate the whole scene. Explicit graph layouts
1388
+ // retain the historical full pitch unless they opt into a scale.
1389
+ depthScale: .55
1390
+ });
1391
+ }
1392
+ }
1393
+ }
1394
+ }
1395
+ for (const direction of directions) candidates.push({ ...common, algorithm: "row", direction });
1396
+ if (!authored.direction || authored.plane) {
1397
+ for (const plane of planes) candidates.push({ ...common, algorithm: "grid", plane });
1398
+ }
1399
+ // Volume has no privileged axis or plane. Only offer it when the author has
1400
+ // not locked either of those decisions; otherwise choosing it would silently
1401
+ // ignore an explicit constraint.
1402
+ if (!authored.direction && !authored.plane) candidates.push({ ...common, algorithm: "volume" });
1403
+ const keys = new Set();
1404
+ return candidates.filter(candidate => {
1405
+ const key = JSON.stringify([
1406
+ candidate.algorithm,
1407
+ candidate.direction || "",
1408
+ candidate.plane || "",
1409
+ candidate.columns || "",
1410
+ candidate.rows || "",
1411
+ candidate.layers || "",
1412
+ candidate.spatial || false,
1413
+ candidate.depthLayers || "",
1414
+ candidate.spatialPhase || "",
1415
+ candidate.depthScale || "",
1416
+ candidate.gap,
1417
+ candidate.padding
1418
+ ]);
1419
+ if (keys.has(key)) return false;
1420
+ keys.add(key);
1421
+ return true;
1422
+ });
1423
+ }
1424
+
1425
+ function vectorSubtract(a, b) {
1426
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
1427
+ }
1428
+
1429
+ function vectorDot(a, b) {
1430
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
1431
+ }
1432
+
1433
+ function vectorCross(a, b) {
1434
+ return [
1435
+ a[1] * b[2] - a[2] * b[1],
1436
+ a[2] * b[0] - a[0] * b[2],
1437
+ a[0] * b[1] - a[1] * b[0]
1438
+ ];
1439
+ }
1440
+
1441
+ function vectorNormalize(value, fallback) {
1442
+ const length = Math.hypot(...value);
1443
+ return length > 1e-9 ? value.map(item => item / length) : [...fallback];
1444
+ }
1445
+
1446
+ function autoProjection(render = {}) {
1447
+ const camera = render.camera || AUTO_LAYOUT_DEFAULT_CAMERA;
1448
+ const position = asVector(camera.position, AUTO_LAYOUT_DEFAULT_CAMERA.position);
1449
+ const target = asVector(camera.target, AUTO_LAYOUT_DEFAULT_CAMERA.target);
1450
+ const forward = vectorNormalize(vectorSubtract(target, position), [0, 0, -1]);
1451
+ let right = vectorNormalize(vectorCross(forward, [0, 1, 0]), [1, 0, 0]);
1452
+ if (Math.abs(vectorDot(right, right)) < 1e-9) right = [1, 0, 0];
1453
+ const up = vectorNormalize(vectorCross(right, forward), [0, 1, 0]);
1454
+ return point => {
1455
+ const relative = vectorSubtract(point, target);
1456
+ return [vectorDot(relative, right), vectorDot(relative, up), vectorDot(relative, forward)];
1457
+ };
1458
+ }
1459
+
1460
+ function orientation2d(a, b, c) {
1461
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
1462
+ }
1463
+
1464
+ function strictSegmentIntersection2d(a, b, c, d) {
1465
+ const abC = orientation2d(a, b, c);
1466
+ const abD = orientation2d(a, b, d);
1467
+ const cdA = orientation2d(c, d, a);
1468
+ const cdB = orientation2d(c, d, b);
1469
+ const epsilon = 1e-7;
1470
+ return ((abC > epsilon && abD < -epsilon) || (abC < -epsilon && abD > epsilon))
1471
+ && ((cdA > epsilon && cdB < -epsilon) || (cdA < -epsilon && cdB > epsilon));
1472
+ }
1473
+
1474
+ function segmentIntersectsRect2d(a, b, rect) {
1475
+ const inset = Math.min(rect.maxX - rect.minX, rect.maxY - rect.minY) * .08;
1476
+ const minX = rect.minX + inset;
1477
+ const maxX = rect.maxX - inset;
1478
+ const minY = rect.minY + inset;
1479
+ const maxY = rect.maxY - inset;
1480
+ if (minX >= maxX || minY >= maxY) return false;
1481
+ const inside = point => point[0] > minX && point[0] < maxX && point[1] > minY && point[1] < maxY;
1482
+ if (inside(a) || inside(b)) return true;
1483
+ const corners = [[minX, minY], [maxX, minY], [maxX, maxY], [minX, maxY]];
1484
+ return corners.some((corner, index) => strictSegmentIntersection2d(a, b, corner, corners[(index + 1) % corners.length]));
1485
+ }
1486
+
1487
+ function segmentIntersectsBox3d(a, b, box) {
1488
+ let minimum = 0;
1489
+ let maximum = 1;
1490
+ for (let axis = 0; axis < 3; axis++) {
1491
+ const delta = b[axis] - a[axis];
1492
+ if (Math.abs(delta) < 1e-9) {
1493
+ if (a[axis] <= box.min[axis] || a[axis] >= box.max[axis]) return false;
1494
+ continue;
1495
+ }
1496
+ let near = (box.min[axis] - a[axis]) / delta;
1497
+ let far = (box.max[axis] - a[axis]) / delta;
1498
+ if (near > far) [near, far] = [far, near];
1499
+ minimum = Math.max(minimum, near);
1500
+ maximum = Math.min(maximum, far);
1501
+ if (minimum >= maximum) return false;
1502
+ }
1503
+ return maximum > 1e-7 && minimum < 1 - 1e-7;
1504
+ }
1505
+
1506
+ function boxesOverlap3d(left, right) {
1507
+ return [0, 1, 2].every(axis =>
1508
+ Math.min(left.max[axis], right.max[axis]) > Math.max(left.min[axis], right.min[axis]));
1509
+ }
1510
+
1511
+ function projectedLayoutMetrics(model, world, boxes, camera) {
1512
+ const project = autoProjection({ camera });
1513
+ const points = new Map([...world].map(([id, point]) => [id, project(point)]));
1514
+ const rectangles = new Map();
1515
+ for (const [id, box] of boxes) {
1516
+ const corners = [];
1517
+ for (const x of [box.min[0], box.max[0]]) for (const y of [box.min[1], box.max[1]]) for (const z of [box.min[2], box.max[2]]) {
1518
+ corners.push(project([x, y, z]));
1519
+ }
1520
+ rectangles.set(id, {
1521
+ minX: Math.min(...corners.map(point => point[0])),
1522
+ maxX: Math.max(...corners.map(point => point[0])),
1523
+ minY: Math.min(...corners.map(point => point[1])),
1524
+ maxY: Math.max(...corners.map(point => point[1]))
1525
+ });
1526
+ }
1527
+ const segments = (model.relations || []).filter(relation => relation.layout !== false).flatMap(relation => {
1528
+ const from = points.get(relation.from);
1529
+ const to = points.get(relation.to);
1530
+ return from && to ? [{ relation, from, to }] : [];
1531
+ });
1532
+ let crossings = 0;
1533
+ for (let left = 0; left < segments.length; left++) for (let right = left + 1; right < segments.length; right++) {
1534
+ const a = segments[left];
1535
+ const b = segments[right];
1536
+ if ([a.relation.from, a.relation.to].some(id => id === b.relation.from || id === b.relation.to)) continue;
1537
+ if (strictSegmentIntersection2d(a.from, a.to, b.from, b.to)) crossings += 1;
1538
+ }
1539
+ let traversals = 0;
1540
+ for (const segment of segments) {
1541
+ for (const [id, rect] of rectangles) {
1542
+ if (id === segment.relation.from || id === segment.relation.to) continue;
1543
+ if (segmentIntersectsRect2d(segment.from, segment.to, rect)) traversals += 1;
1544
+ }
1545
+ }
1546
+ let overlaps = 0;
1547
+ let occlusion = 0;
1548
+ const rectangleEntries = [...rectangles];
1549
+ for (let left = 0; left < rectangleEntries.length; left++) for (let right = left + 1; right < rectangleEntries.length; right++) {
1550
+ const a = rectangleEntries[left][1];
1551
+ const b = rectangleEntries[right][1];
1552
+ const overlapWidth = Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX);
1553
+ const overlapHeight = Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY);
1554
+ if (overlapWidth > 0 && overlapHeight > 0) {
1555
+ overlaps += 1;
1556
+ const leftArea = Math.max(1e-9, (a.maxX - a.minX) * (a.maxY - a.minY));
1557
+ const rightArea = Math.max(1e-9, (b.maxX - b.minX) * (b.maxY - b.minY));
1558
+ occlusion += overlapWidth * overlapHeight / Math.min(leftArea, rightArea);
1559
+ }
1560
+ }
1561
+ const projected = [...points.values()];
1562
+ const width = projected.length ? Math.max(...projected.map(point => point[0])) - Math.min(...projected.map(point => point[0])) : 0;
1563
+ const height = projected.length ? Math.max(...projected.map(point => point[1])) - Math.min(...projected.map(point => point[1])) : 0;
1564
+ return { crossings, traversals, overlaps, occlusion, footprint: width * height };
1565
+ }
1566
+
1567
+ const AUTO_LAYOUT_FRONT_CAMERA = Object.freeze({
1568
+ position: [0, 0, 100],
1569
+ target: [0, 0, 0]
1570
+ });
1571
+
1572
+ const AUTO_LAYOUT_CANONICAL_CAMERAS = Object.freeze([
1573
+ Object.freeze({ position: [12, 8, 14], target: [0, 0, 0] }),
1574
+ Object.freeze({ position: [-12, 8, 14], target: [0, 0, 0] }),
1575
+ Object.freeze({ position: [12, 8, -14], target: [0, 0, 0] }),
1576
+ Object.freeze({ position: [-12, 8, -14], target: [0, 0, 0] })
1577
+ ]);
1578
+
1579
+ function cameraDirectionKey(camera) {
1580
+ const position = asVector(camera?.position, AUTO_LAYOUT_DEFAULT_CAMERA.position);
1581
+ const target = asVector(camera?.target, AUTO_LAYOUT_DEFAULT_CAMERA.target);
1582
+ return vectorNormalize(vectorSubtract(target, position), [0, 0, -1]).map(value => value.toFixed(3)).join(":");
1583
+ }
1584
+
1585
+ function autoLayoutCameras(model, options = {}) {
1586
+ const primary = options.camera || model.render?.camera || AUTO_LAYOUT_DEFAULT_CAMERA;
1587
+ const seen = new Set();
1588
+ return [{ camera: primary, weight: 1, primary: true }, ...AUTO_LAYOUT_CANONICAL_CAMERAS.map(camera => ({ camera, weight: .35, primary: false }))]
1589
+ .filter(entry => {
1590
+ const key = cameraDirectionKey(entry.camera);
1591
+ if (seen.has(key)) return false;
1592
+ seen.add(key);
1593
+ return true;
1594
+ });
1595
+ }
1596
+
1597
+ function layoutPlane(layout = {}) {
1598
+ if (["graph", "grid"].includes(layout.algorithm) && ["xy", "xz", "yz"].includes(layout.plane)) return layout.plane;
1599
+ if (layout.algorithm === "row") return { x: "yz", y: "xz", z: "xy" }[layout.direction] || null;
1600
+ return null;
1601
+ }
1602
+
1603
+ function layoutOrientationMismatches(source) {
1604
+ if (!source || typeof source !== "object") return 0;
1605
+ let mismatches = 0;
1606
+ const visit = owner => {
1607
+ const expected = layoutPlane(owner.layout);
1608
+ for (const child of Array.isArray(owner.entities) ? owner.entities : Array.isArray(owner.children) ? owner.children : []) {
1609
+ if (isContainerType(child?.type) || Array.isArray(child?.children)) {
1610
+ const actual = layoutPlane(child.layout);
1611
+ if (expected && actual && expected !== actual) mismatches += 1;
1612
+ }
1613
+ visit(child);
1614
+ }
1615
+ };
1616
+ visit(source);
1617
+ return mismatches;
1618
+ }
1619
+
1620
+ function elongationPenalty(extents) {
1621
+ const ordered = extents.map(value => Math.max(0, value)).sort((left, right) => right - left);
1622
+ const ratio = ordered[0] / Math.max(ordered[1], 1e-6);
1623
+ return { ratio, penalty: Math.max(0, ratio - 3) ** 2 };
1624
+ }
1625
+
1626
+ function median(values, fallback = 1) {
1627
+ if (!values.length) return fallback;
1628
+ const ordered = [...values].sort((left, right) => left - right);
1629
+ const middle = Math.floor(ordered.length / 2);
1630
+ return ordered.length % 2
1631
+ ? ordered[middle]
1632
+ : (ordered[middle - 1] + ordered[middle]) / 2;
1633
+ }
1634
+
1635
+ function autoLayoutMetrics(model, options = {}) {
1636
+ const byId = new Map((model.entities || []).map(entity => [entity.id, entity]));
1637
+ const world = new Map();
1638
+ const resolveWorld = id => {
1639
+ if (world.has(id)) return world.get(id);
1640
+ const entity = byId.get(id);
1641
+ if (!entity) return [0, 0, 0];
1642
+ const position = asVector(entity.p);
1643
+ const resolved = entity.parent ? add(resolveWorld(entity.parent), position) : position;
1644
+ world.set(id, resolved);
1645
+ return resolved;
1646
+ };
1647
+ for (const id of byId.keys()) resolveWorld(id);
1648
+ const boxes = new Map();
1649
+ for (const entity of model.entities || []) {
1650
+ if (isContainerType(entity.type)) continue;
1651
+ const center = world.get(entity.id);
1652
+ const size = asVector(entity.s, [1, 1, 1]);
1653
+ boxes.set(entity.id, {
1654
+ min: center.map((value, axis) => value - size[axis] * .42),
1655
+ max: center.map((value, axis) => value + size[axis] * .42)
1656
+ });
1657
+ }
1658
+ const spatialSegments = (model.relations || []).filter(relation => relation.layout !== false).flatMap(relation => {
1659
+ const from = world.get(relation.from);
1660
+ const to = world.get(relation.to);
1661
+ return from && to ? [{ relation, from, to }] : [];
1662
+ });
1663
+ let spatialTraversals = 0;
1664
+ for (const segment of spatialSegments) {
1665
+ for (const [id, box] of boxes) {
1666
+ if (id === segment.relation.from || id === segment.relation.to) continue;
1667
+ if (segmentIntersectsBox3d(segment.from, segment.to, box)) spatialTraversals += 1;
1668
+ }
1669
+ }
1670
+ let spatialOverlaps = 0;
1671
+ const boxEntries = [...boxes];
1672
+ for (let left = 0; left < boxEntries.length; left++) for (let right = left + 1; right < boxEntries.length; right++) {
1673
+ if (boxesOverlap3d(boxEntries[left][1], boxEntries[right][1])) spatialOverlaps += 1;
1674
+ }
1675
+ const views = autoLayoutCameras(model, options).map(entry => ({
1676
+ ...projectedLayoutMetrics(model, world, boxes, entry.camera),
1677
+ weight: entry.weight,
1678
+ primary: entry.primary
1679
+ }));
1680
+ const front = projectedLayoutMetrics(model, world, boxes, AUTO_LAYOUT_FRONT_CAMERA);
1681
+ const primary = views.find(view => view.primary) || views[0] || { crossings: 0, traversals: 0, overlaps: 0, footprint: 0 };
1682
+ const robustCrossings = views.reduce((sum, view) => sum + view.crossings * view.weight, 0);
1683
+ const robustTraversals = views.reduce((sum, view) => sum + view.traversals * view.weight, 0);
1684
+ const robustOverlaps = views.reduce((sum, view) => sum + view.overlaps * view.weight, 0);
1685
+ const footprint = views.reduce((sum, view) => sum + view.footprint * view.weight, 0);
1686
+ const edgeLength = spatialSegments.reduce((sum, segment) => sum + Math.hypot(
1687
+ segment.to[0] - segment.from[0],
1688
+ segment.to[1] - segment.from[1],
1689
+ segment.to[2] - segment.from[2]
1690
+ ), 0);
1691
+ const bounds = [...boxes.values()];
1692
+ const extents = bounds.length ? [0, 1, 2].map(axis =>
1693
+ Math.max(...bounds.map(box => box.max[axis])) - Math.min(...bounds.map(box => box.min[axis]))) : [0, 0, 0];
1694
+ const elongation = elongationPenalty(extents);
1695
+ const orientationMismatches = layoutOrientationMismatches(options.source);
1696
+ const characteristicSize = median([...boxes.values()].map(box => Math.hypot(
1697
+ box.max[0] - box.min[0],
1698
+ box.max[1] - box.min[1],
1699
+ box.max[2] - box.min[2]
1700
+ )));
1701
+ const relationCount = Math.max(1, spatialSegments.length);
1702
+ const normalizedEdgeLength = edgeLength / relationCount / Math.max(characteristicSize, 1e-6);
1703
+ const sceneVolume = extents.reduce((product, extent) => product * Math.max(extent, 1e-6), 1);
1704
+ const itemVolume = [...boxes.values()].reduce((sum, box) => sum
1705
+ + (box.max[0] - box.min[0])
1706
+ * (box.max[1] - box.min[1])
1707
+ * (box.max[2] - box.min[2]), 0);
1708
+ const volumeRatio = sceneVolume / Math.max(itemVolume, 1e-6);
1709
+ const compactnessPenalty = Math.cbrt(Math.max(1, volumeRatio));
1710
+ const frontProject = autoProjection({ camera: AUTO_LAYOUT_FRONT_CAMERA });
1711
+ const orthogonalDeviation = spatialSegments.reduce((sum, segment) => {
1712
+ const from = frontProject(segment.from);
1713
+ const to = frontProject(segment.to);
1714
+ return sum + Math.min(Math.abs(to[0] - from[0]), Math.abs(to[1] - from[1]))
1715
+ / Math.max(characteristicSize, 1e-6);
1716
+ }, 0) / relationCount;
1717
+ // Formal 3D validity remains effectively absolute. Once candidates are
1718
+ // physically valid, projected defects compete with the qualities people use
1719
+ // to judge a diagram: short relations, compact grouping and orthogonal flow.
1720
+ // This prevents the optimizer from "winning" by moving valid groups ever
1721
+ // farther apart merely to remove a small projected crossing.
1722
+ const aestheticScore = primary.traversals * 120
1723
+ + front.traversals * 100
1724
+ + robustTraversals * 25
1725
+ + front.overlaps * 250
1726
+ + front.occlusion * 600
1727
+ + primary.crossings * 6
1728
+ + front.crossings * 5
1729
+ + robustCrossings * 2
1730
+ + robustOverlaps * 40
1731
+ + orientationMismatches * 50
1732
+ + elongation.penalty * 30
1733
+ + normalizedEdgeLength * 80
1734
+ + compactnessPenalty * 100
1735
+ + orthogonalDeviation * 35;
1736
+ return {
1737
+ crossings: primary.crossings,
1738
+ traversals: primary.traversals,
1739
+ overlaps: primary.overlaps,
1740
+ worstCrossings: Math.max(0, ...views.map(view => view.crossings)),
1741
+ worstTraversals: Math.max(0, ...views.map(view => view.traversals)),
1742
+ frontCrossings: front.crossings,
1743
+ frontTraversals: front.traversals,
1744
+ frontOverlaps: front.overlaps,
1745
+ frontOcclusion: front.occlusion,
1746
+ spatialTraversals,
1747
+ spatialOverlaps,
1748
+ orientationMismatches,
1749
+ elongation: elongation.ratio,
1750
+ elongationPenalty: elongation.penalty,
1751
+ edgeLength,
1752
+ normalizedEdgeLength,
1753
+ sceneVolume,
1754
+ volumeRatio,
1755
+ compactnessPenalty,
1756
+ orthogonalDeviation,
1757
+ footprint,
1758
+ aestheticScore,
1759
+ views: views.map(({ weight, primary: isPrimary, ...metrics }) => ({ ...metrics, weight, primary: isPrimary })),
1760
+ score: spatialTraversals * 1e12
1761
+ + spatialOverlaps * 1e10
1762
+ + aestheticScore
1763
+ };
1764
+ }
1765
+
1766
+ function compareAutoLayoutMetrics(left, right) {
1767
+ // A line or an item may never pass through another item in real 3D space.
1768
+ // Everything after those two feasibility checks is a perceptual trade-off;
1769
+ // comparing each projected metric lexicographically made very sparse scenes
1770
+ // look optimal even when a human would immediately compact them.
1771
+ const fields = [
1772
+ "spatialTraversals",
1773
+ "spatialOverlaps"
1774
+ ];
1775
+ for (const field of fields) {
1776
+ const difference = (left?.[field] || 0) - (right?.[field] || 0);
1777
+ if (Math.abs(difference) > 1e-7) return difference;
1778
+ }
1779
+ const scoreDifference = (left?.aestheticScore || 0) - (right?.aestheticScore || 0);
1780
+ if (Math.abs(scoreDifference) > 1e-7) return scoreDifference;
1781
+ // Stable deterministic tie-breakers retain the authored/candidate order only
1782
+ // after two layouts are perceptually indistinguishable.
1783
+ for (const field of ["frontOcclusion", "frontOverlaps", "normalizedEdgeLength", "compactnessPenalty", "footprint"]) {
1784
+ const difference = (left?.[field] || 0) - (right?.[field] || 0);
1785
+ if (Math.abs(difference) > 1e-7) return difference;
1786
+ }
1787
+ return (left?.score || 0) - (right?.score || 0);
1788
+ }
1789
+
1790
+ export function analyzeAtlasLayout(model, options = {}) {
1791
+ return { ...autoLayoutMetrics(model, options) };
1792
+ }
1793
+
1794
+ function resolveAutomaticLayouts(input, options = {}) {
1795
+ const authored = structuredClone(normalizeRoot(input));
1796
+ const source = structuredClone(authored);
1797
+ const owners = autoLayoutOwners(source);
1798
+ if (!owners.length) return null;
1799
+
1800
+ for (const entry of owners) {
1801
+ const initial = autoLayoutCandidates(entry)[0];
1802
+ entry.owner.layout = initial;
1803
+ }
1804
+ const decisions = new Map();
1805
+ const metricOptions = { camera: options.autoLayoutCamera, source };
1806
+ let metrics = autoLayoutMetrics(compileResolvedAtlasModel(source, { ...options, throwOnFatal: false }), metricOptions);
1807
+ for (let pass = 0; pass < 3; pass++) {
1808
+ let changed = false;
1809
+ // Resolve nested content before its owner. Container dimensions and the
1810
+ // external connection lanes are then stable when the parent chooses its
1811
+ // global arrangement, avoiding a sparse root decision based on provisional
1812
+ // child layouts.
1813
+ for (const entry of [...owners].reverse()) {
1814
+ const previous = entry.owner.layout;
1815
+ let best = { layout: previous, metrics };
1816
+ for (const candidate of autoLayoutCandidates(entry)) {
1817
+ entry.owner.layout = candidate;
1818
+ const candidateModel = compileResolvedAtlasModel(source, { ...options, throwOnFatal: false });
1819
+ const candidateMetrics = autoLayoutMetrics(candidateModel, metricOptions);
1820
+ if (compareAutoLayoutMetrics(candidateMetrics, best.metrics) < 0) best = { layout: candidate, metrics: candidateMetrics };
1821
+ }
1822
+ entry.owner.layout = best.layout;
1823
+ if (JSON.stringify(best.layout) !== JSON.stringify(previous)) changed = true;
1824
+ metrics = best.metrics;
1825
+ decisions.set(entry.id, best.layout);
1826
+ }
1827
+ if (!changed) break;
1828
+ }
1829
+ return { authored, source, owners, decisions, metrics };
1830
+ }
1831
+
1832
+ function compileResolvedAtlasModel(input, options = {}) {
1050
1833
  const diagnostics = [];
1051
1834
  const source = normalizeRoot(input);
1052
1835
  const palette = paletteFrom(source);
@@ -1057,6 +1840,8 @@ export function compileAtlasModel(input, options = {}) {
1057
1840
  const relations = normalizeRelations(source.relations, validIds, diagnostics);
1058
1841
  resolveStyles(flat.entities, flat.sourceById, flat.childrenById, palette, diagnostics);
1059
1842
  const byId = new Map(flat.entities.map(entity => [entity.id, entity]));
1843
+ const graphContext = { relations, byId };
1844
+ graphContext.graphOrderHints = globalGraphOrderHints(source, flat, relations, byId);
1060
1845
  const geometrySizes = { ...DEFAULT_SIZES };
1061
1846
  for (const definition of options.geometryCatalog || []) {
1062
1847
  if (!definition?.id || !Array.isArray(definition.canonicalSize) || definition.canonicalSize.length !== 3) continue;
@@ -1081,15 +1866,11 @@ export function compileAtlasModel(input, options = {}) {
1081
1866
  const spec = sourceEntity.layout || { algorithm: "grid" };
1082
1867
  const algorithm = ALGORITHMS.has(spec.algorithm) ? spec.algorithm : "grid";
1083
1868
  if (!ALGORITHMS.has(spec.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM", `Unknown layout algorithm '${spec.algorithm}', using grid.`, { entityId });
1084
- if (algorithm === "graph") diagnostic(diagnostics, "warning", "GRAPH_LAYOUT_FALLBACK", `Graph layout for '${entityId}' is reserved for a later compiler pass; using grid for now.`, { entityId });
1085
1869
  const declared = Array.isArray(entity.size) && entity.size.length === 3 ? asVector(entity.size) : null;
1086
1870
  const result = constrainedLayout(childItems, spec, sourceEntity, diagnostics, entityId,
1087
- algorithm === "row"
1088
- ? rowLayout
1089
- : algorithm === "volume"
1090
- ? volumeLayout
1091
- : gridLayout,
1092
- declared);
1871
+ layoutFunction(spec, diagnostics, entityId, graphContext),
1872
+ declared,
1873
+ graphContext);
1093
1874
  for (const placement of result.positions) {
1094
1875
  const child = byId.get(placement.entity.id);
1095
1876
  child.p = placement.position;
@@ -1107,13 +1888,10 @@ export function compileAtlasModel(input, options = {}) {
1107
1888
  const rootItems = flat.entities.filter(entity => !entity.parent).map(entity => ({ entity, size: layoutEntity(entity.id) }));
1108
1889
  const rootSpec = source.layout || { algorithm: "row", direction: "y", gap: 1.5, padding: 1 };
1109
1890
  const rootAlgorithm = ALGORITHMS.has(rootSpec.algorithm) ? rootSpec.algorithm : "row";
1110
- if (rootAlgorithm === "graph") diagnostic(diagnostics, "warning", "GRAPH_LAYOUT_FALLBACK", "Graph layout for the root is reserved for a later compiler pass; using grid for now.");
1111
1891
  const rootResult = constrainedLayout(rootItems, rootSpec, source, diagnostics, null,
1112
- rootAlgorithm === "row"
1113
- ? rowLayout
1114
- : rootAlgorithm === "volume"
1115
- ? volumeLayout
1116
- : gridLayout);
1892
+ layoutFunction(rootSpec, diagnostics, null, graphContext),
1893
+ null,
1894
+ graphContext);
1117
1895
  for (const placement of rootResult.positions) {
1118
1896
  const entity = byId.get(placement.entity.id);
1119
1897
  entity.p = placement.position;
@@ -1139,6 +1917,39 @@ export function compileAtlasModel(input, options = {}) {
1139
1917
  return output;
1140
1918
  }
1141
1919
 
1920
+ export function compileAtlasModel(input, options = {}) {
1921
+ const automatic = resolveAutomaticLayouts(input, options);
1922
+ if (!automatic) return compileResolvedAtlasModel(input, options);
1923
+ const model = compileResolvedAtlasModel(automatic.source, options);
1924
+ model.layout = automatic.authored.layout || undefined;
1925
+ for (const entry of automatic.owners) {
1926
+ const resolved = automatic.decisions.get(entry.id) || entry.owner.layout;
1927
+ diagnostic(model.diagnostics, "info", "AUTO_LAYOUT_RESOLVED",
1928
+ `Automatic layout resolved '${entry.id ?? "root"}' to ${resolved.algorithm} on ${resolved.direction || resolved.plane || "default axes"}.`, {
1929
+ ownerId: entry.id,
1930
+ resolvedLayout: { ...resolved },
1931
+ score: automatic.metrics.score,
1932
+ crossings: automatic.metrics.crossings,
1933
+ traversals: automatic.metrics.traversals,
1934
+ worstCrossings: automatic.metrics.worstCrossings,
1935
+ worstTraversals: automatic.metrics.worstTraversals,
1936
+ spatialTraversals: automatic.metrics.spatialTraversals,
1937
+ spatialOverlaps: automatic.metrics.spatialOverlaps,
1938
+ frontCrossings: automatic.metrics.frontCrossings,
1939
+ frontTraversals: automatic.metrics.frontTraversals,
1940
+ frontOverlaps: automatic.metrics.frontOverlaps,
1941
+ frontOcclusion: automatic.metrics.frontOcclusion,
1942
+ orientationMismatches: automatic.metrics.orientationMismatches,
1943
+ elongation: automatic.metrics.elongation,
1944
+ normalizedEdgeLength: automatic.metrics.normalizedEdgeLength,
1945
+ compactnessPenalty: automatic.metrics.compactnessPenalty,
1946
+ orthogonalDeviation: automatic.metrics.orthogonalDeviation,
1947
+ volumeRatio: automatic.metrics.volumeRatio
1948
+ });
1949
+ }
1950
+ return model;
1951
+ }
1952
+
1142
1953
  export function compileAtlasModelWithDiagnostics(input, options = {}) {
1143
1954
  const model = compileAtlasModel(input, options);
1144
1955
  return { model, diagnostics: model.diagnostics || [] };
package/src/spec.js CHANGED
@@ -32,6 +32,7 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
32
32
  text: field("Description shown by the inspector.", null, { type: "string" }),
33
33
  url: field("External documentation opened from the inspector.", null, { type: "url" }),
34
34
  type: field("Structural role. Use container when declaring children; omit it for a leaf item.", ["container"]),
35
+ open: field("Initial expansion state for a container; omitted means closed.", [true, false]),
35
36
  parent: field("Parent entity id.", null, { type: "string", authoring: "absolute" }),
36
37
  children: field("Nested entities.", null, { context: "entity", sequence: true }),
37
38
  geometry: field("Geometry id supplied by Atlas or a registered pack.", null, { valueSource: "geometryCatalog" }),
@@ -70,7 +71,7 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
70
71
  layout: freeze({
71
72
  label: "Layout",
72
73
  properties: freeze({
73
- algorithm: field("Spatial strategy.", ["row", "grid", "volume", "graph"]),
74
+ algorithm: field("Spatial strategy. Auto evaluates the whole model; graph derives layers and sibling order from relations.", ["auto", "row", "grid", "volume", "graph"]),
74
75
  direction: field("Primary semantic axis.", ["x", "y", "z"]),
75
76
  variant: field("Regular cells or masonry rows staggered by half a cell by default.", ["uniform", "masonry"]),
76
77
  plane: field("Axes used by a two-dimensional grid.", ["xz", "xy", "yz"]),
@@ -140,10 +141,17 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
140
141
  selectionMode: field("Selection ownership.", ["internal", "event"]),
141
142
  toolbar: field("Built-in toolbar visibility.", [true, false]),
142
143
  shellLabels: field("Text-label contrast outline.", [true, false]),
144
+ navigation: field("Camera behavior triggered by selection.", null, { context: "navigation" }),
143
145
  focus: field("Selection-driven visual filtering.", null, { context: "focus" }),
144
146
  camera: field("Initial camera snapshot in model-space coordinates.", null, { context: "camera" })
145
147
  })
146
148
  }),
149
+ navigation: freeze({
150
+ label: "Selection navigation",
151
+ properties: freeze({
152
+ onSelect: field("Camera movement after a click or select() call.", ["preserve", "center", "fit"])
153
+ })
154
+ }),
147
155
  focus: freeze({
148
156
  label: "Visual focus",
149
157
  properties: freeze({
@@ -199,6 +207,7 @@ export function atlasLayoutContextForPath(path = []) {
199
207
  ["palette", "palette"],
200
208
  ["theme", "theme"],
201
209
  ["render", "render"],
210
+ ["navigation", "navigation"],
202
211
  ["focus", "focus"],
203
212
  ["camera", "camera"],
204
213
  ["span", "span"],