@liquidcars/atlas-layout 0.1.3 → 0.1.6

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
@@ -24,6 +24,12 @@ Cada entrada del catálogo puede declarar `id`, `aliases` y `canonicalSize`.
24
24
  Una geometría externa desconocida conserva el tamaño de fallback de una caja,
25
25
  de modo que la compilación sigue siendo recuperable si falta un pack.
26
26
 
27
+ Las entidades con `children` deben declarar `type: container`. Las hojas pueden
28
+ omitir `type` y se normalizan como items. `type: shell` se acepta únicamente
29
+ como alias de compatibilidad. Un contenedor puede no tener geometría o usar una
30
+ primitiva `geo.*`; las geometrías personalizadas de los packs, como `infra.cloud`,
31
+ sólo se permiten en entidades hoja.
32
+
27
33
  Para compilar una fuente YAML durante el build:
28
34
 
29
35
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liquidcars/atlas-layout",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "Declarative layout compiler for LiquidCars Atlas models",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -21,12 +21,23 @@
21
21
  "./vite": {
22
22
  "import": "./src/vite.js",
23
23
  "default": "./src/vite.js"
24
+ },
25
+ "./spec": {
26
+ "import": "./src/spec.js",
27
+ "default": "./src/spec.js"
24
28
  }
25
29
  },
26
30
  "scripts": {
27
31
  "test": "node --test test/index.test.mjs"
28
32
  },
29
- "keywords": ["liquidcars", "atlas", "layout", "3d", "graph", "web-component"],
33
+ "keywords": [
34
+ "liquidcars",
35
+ "atlas",
36
+ "layout",
37
+ "3d",
38
+ "graph",
39
+ "web-component"
40
+ ],
30
41
  "license": "MIT",
31
42
  "dependencies": {
32
43
  "yaml": "^2.8.1"
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "atlas-layout",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {}
6
+ }
@@ -0,0 +1,132 @@
1
+ # ATLAS-LAYOUT-SPEC-v1
2
+
3
+ This document defines the semantic YAML contract consumed by @liquidcars/atlas-layout.
4
+ The executable projection of this contract is packages/atlas-layout/src/spec.js.
5
+
6
+ ## Root model
7
+
8
+ A model may contain version, palette, theme, render, layout, entities and relations.
9
+ Entities may be declared at the root or nested under children. The compiler normalizes
10
+ nested entities to a flat list with parent references.
11
+
12
+ ## Model coordinate system
13
+
14
+ Atlas model space is independent from the camera and from the host application's view.
15
+ The coordinate convention is fixed:
16
+
17
+ - x: left (-) to right (+)
18
+ - y: bottom (-) to top (+)
19
+ - z: back (-) to front (+)
20
+
21
+ Positions, sizes, layout directions and future alignment rules are interpreted in this
22
+ model space. Camera rotation, zoom, pan and projection never change the meaning of a
23
+ model coordinate. The default front view places the camera on the positive z side,
24
+ looking toward the origin.
25
+
26
+ A container's local origin is the reference point for its children. The compiler resolves
27
+ nested positions into model-space output while preserving the authored hierarchy. The
28
+ authoring aliases left, right, bottom, top, back and front may be used for boundary
29
+ terms; they correspond respectively to minX, maxX, minY, maxY, minZ and maxZ.
30
+
31
+ ## Layout units and intrinsic sizing
32
+
33
+ All dimensions are expressed in Atlas world units: geometry dimensions, positions, layout
34
+ gap, padding and explicit size values use the same unit. There is no universal 1 x 1 x 1
35
+ item size. When a leaf has no explicit size, assisted layout uses the selected geometry's
36
+ canonical size; an aspect style changes proportion, not the geometry's semantic identity.
37
+
38
+ Containers are sized from their laid-out contents by default: child bounds plus gap and
39
+ padding determine the required extent. padding: 1 reserves one world unit on every
40
+ content-to-boundary side, so it contributes approximately two units to each occupied
41
+ dimension. gap: 1.2 reserves 1.2 world units between adjacent siblings along the
42
+ layout axis. Authored size is the exception: it is an explicit dimension constraint,
43
+ subject to sizeMode and diagnostics.
44
+
45
+
46
+ ## Entity fields
47
+
48
+ Required: id.
49
+
50
+ Common fields: name, sub, text, type, parent, children, geometry, geometryOptions,
51
+ geometryPalette, style, layout, weight, span, size, sizeMode, p, s, c and radius.
52
+
53
+ An entity with children is a container and must explicitly declare `type: container`.
54
+ A leaf entity may omit `type`, in which case it is normalized as an item. The
55
+ legacy value `type: shell` remains accepted as a compatibility alias for
56
+ `container`, but is not emitted by the compiler or suggested by the editor.
57
+
58
+ Containers may omit geometry or use a built-in primitive such as `geo.box`.
59
+ Custom geometry-pack geometries such as `infra.cloud` and `media.image-box` are
60
+ leaf geometries and cannot be used on containers. The compiler reports these violations
61
+ as fatal diagnostics instead of silently rendering a fallback box.
62
+
63
+ `children` is the nested authoring form. In a flat model, an entity referenced by
64
+ other entities through `parent` is also a container and follows the same rule.
65
+
66
+ geometry is resolved against built-in primitives and registered geometry packs.
67
+ geometryOptions and geometryPalette are namespaced by the selected geometry definition.
68
+ size is an explicit dimension constraint. p and s are calculated layout output and are
69
+ normally omitted from authored Markdown. sizeMode fixed preserves explicit dimensions;
70
+ fit permits assisted layout to use the geometry's canonical size.
71
+
72
+ ## Assisted layout
73
+
74
+ layout.algorithm accepts row, grid, volume or graph. direction accepts x, y or z.
75
+ Grid layouts also accept variant uniform or masonry and optional columns, rows and
76
+ layers. gap and padding are absolute numbers. justify accepts start, center or end.
77
+
78
+ An entity weight influences its relative allocation. span can explicitly occupy
79
+ columns, rows and layers. A semantic style.aspect may be compact, wide, tall, deep or flat.
80
+
81
+ ## Sibling size and alignment constraints
82
+
83
+ A root model or container may declare `constraints` for its immediate children. Constraint
84
+ references must resolve to siblings in that group; references across parents produce a
85
+ diagnostic and are not applied. Constraints may alternatively be nested under the
86
+ corresponding `layout` object, but sibling-level `constraints` is the canonical form.
87
+
88
+ An equal-size rule lists entity ids under `equal`. `dimensions` accepts one or more of
89
+ `width`, `height` and `depth`; the compiler uses the largest intrinsic extent in the
90
+ group for each requested dimension. The singular `dimension` form is accepted, and
91
+ `edge: width|height|depth` is a compact alias.
92
+
93
+ An alignment rule lists entity ids under `align`. `edge` or `edges` accepts
94
+ `left`, `centerX`, `right`, `bottom`, `centerY`, `top`, `back`,
95
+ `centerZ` and `front`. These names always use model coordinates, never camera
96
+ coordinates. Alignment uses the first listed sibling as the reference.
97
+
98
+ `strength` accepts `preferred` (the default) or `required`. Invalid preferred rules
99
+ produce warnings; invalid required rules produce errors.
100
+
101
+ ```yaml
102
+ constraints:
103
+ - equal: [layer-top, layer-middle, layer-bottom]
104
+ dimensions: [width, height, depth]
105
+ strength: preferred
106
+ - align: [layer-top, layer-middle, layer-bottom]
107
+ edges: [left, front]
108
+ strength: preferred
109
+ ```
110
+
111
+ ## Relations
112
+
113
+ Relations require from and to entity ids. mode is directed, bidirectional or broken.
114
+ The aliases visual, render, type and direction are accepted for compatibility.
115
+ Optional fields include id, label, fromAnchor, toAnchor, priority and layout.
116
+
117
+ ## Rendering
118
+
119
+ palette maps tokens to CSS colours. style.color or c can reference a token or direct
120
+ CSS colour. render can additionally set relationMode, selectionMode, toolbar and
121
+ shellLabels. theme contains background, lighting and ui settings.
122
+
123
+ ## Markdown blocks
124
+
125
+ Atlas Markdown accepts a full atlas or atlas overview fenced block, and split
126
+ atlas-model, atlas-layout, atlas-render and atlas-relations blocks. Named blocks
127
+ use the block name as the model identifier. A document may contain more than one model.
128
+
129
+ ## Compatibility
130
+
131
+ Unknown fields remain available to geometry packs and host applications, but the
132
+ editor only suggests fields defined by this version of the contract.
package/src/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { ATLAS_LAYOUT_SPEC_V1 } from "./spec.js";
2
+
1
3
  const DEFAULT_COLOR = "#b9d8eb";
2
4
 
3
5
  const DEFAULT_SIZES = Object.freeze({
@@ -16,6 +18,8 @@ const DEFAULT_SIZES = Object.freeze({
16
18
  icosahedron: [2.3, 2.3, 2.3]
17
19
  });
18
20
 
21
+ const BUILTIN_GEOMETRY_IDS = new Set(Object.keys(DEFAULT_SIZES));
22
+
19
23
  const ASPECT_FACTORS = Object.freeze({
20
24
  compact: [1, 1, 1],
21
25
  wide: [1.35, 0.86, 0.86],
@@ -24,7 +28,7 @@ const ASPECT_FACTORS = Object.freeze({
24
28
  flat: [1.08, 0.56, 1.08]
25
29
  });
26
30
 
27
- const ALGORITHMS = new Set(["row", "grid", "volume", "graph"]);
31
+ const ALGORITHMS = new Set(ATLAS_LAYOUT_SPEC_V1.contexts.layout.properties.algorithm.values);
28
32
 
29
33
  // Relation modes are part of the declarative model contract. The renderer
30
34
  // accepts a few historical aliases, but the build-time compiler always emits
@@ -86,6 +90,66 @@ function diagnostic(diagnostics, level, code, message, extra = {}) {
86
90
  diagnostics.push({ level, code, message, ...extra, recoverable: level !== "fatal" });
87
91
  }
88
92
 
93
+ function isContainerType(type) {
94
+ return type === "container" || type === "shell";
95
+ }
96
+
97
+ function geometryCatalogDefinition(geometry, geometryCatalog = []) {
98
+ const requested = typeof geometry === "string" ? geometry.trim() : "";
99
+ return geometryCatalog.find(definition =>
100
+ definition?.id === requested || definition?.aliases?.includes(requested)
101
+ ) || null;
102
+ }
103
+
104
+ function isPrimitiveGeometry(geometry, geometryCatalog = []) {
105
+ if (geometry == null || geometry === "") return true;
106
+ const requested = String(geometry).trim();
107
+ const definition = geometryCatalogDefinition(requested, geometryCatalog);
108
+ if (definition) return Boolean(definition.primitive);
109
+ const localId = requested.includes(".") ? requested.split(".").at(-1) : requested;
110
+ return BUILTIN_GEOMETRY_IDS.has(localId)
111
+ && (!requested.includes(".") || requested.toLowerCase().startsWith("geo."));
112
+ }
113
+
114
+ function normalizeEntityTypes(flat, geometryCatalog, diagnostics) {
115
+ for (const entity of flat.entities) {
116
+ const source = flat.sourceById.get(entity.id) || {};
117
+ const declaredValue = source.type;
118
+ const declared = typeof declaredValue === "string" ? declaredValue.trim().toLowerCase() : "";
119
+ const childIds = flat.childrenById.get(entity.id) || [];
120
+ const hasChildren = childIds.length > 0 || (Array.isArray(source.children) && source.children.length > 0);
121
+
122
+ if (declaredValue != null && typeof declaredValue !== "string") {
123
+ diagnostic(diagnostics, "fatal", "UNKNOWN_ENTITY_TYPE",
124
+ "Entity '" + entity.id + "' has a non-string type. Use container or omit type for a leaf item.",
125
+ { entityId: entity.id, type: declaredValue });
126
+ } else if (hasChildren && declared === "item") {
127
+ diagnostic(diagnostics, "fatal", "ITEM_CANNOT_HAVE_CHILDREN",
128
+ "Entity '" + entity.id + "' is declared as an item but contains children.",
129
+ { entityId: entity.id });
130
+ } else if (hasChildren && !["container", "shell"].includes(declared)) {
131
+ diagnostic(diagnostics, "fatal", "CONTAINER_TYPE_REQUIRED_FOR_CHILDREN",
132
+ "Entity '" + entity.id + "' declares children and must set type: container.",
133
+ { entityId: entity.id });
134
+ }
135
+
136
+ if (declared && !["item", "container", "shell"].includes(declared)) {
137
+ diagnostic(diagnostics, "fatal", "UNKNOWN_ENTITY_TYPE",
138
+ "Unknown entity type '" + source.type + "' on '" + entity.id + "'. Use container or omit type for a leaf item.",
139
+ { entityId: entity.id, type: source.type });
140
+ }
141
+
142
+ const type = hasChildren || declared === "container" || declared === "shell" ? "container" : "item";
143
+ entity.type = type;
144
+
145
+ if (type === "container" && source.geometry && !isPrimitiveGeometry(source.geometry, geometryCatalog)) {
146
+ diagnostic(diagnostics, "fatal", "CUSTOM_GEOMETRY_ON_CONTAINER",
147
+ "Container '" + entity.id + "' cannot use non-primitive geometry '" + source.geometry + "'.",
148
+ { entityId: entity.id, geometry: source.geometry });
149
+ }
150
+ }
151
+ }
152
+
89
153
  function flattenEntities(input, diagnostics) {
90
154
  const entities = [];
91
155
  const childrenById = new Map();
@@ -110,7 +174,7 @@ function flattenEntities(input, diagnostics) {
110
174
  delete entity.layout;
111
175
  entity.__atlasExplicitSize = Array.isArray(raw.size) && raw.size.length === 3;
112
176
  entity.parent = parent ?? raw.parent ?? null;
113
- entity.type = children.length || raw.type === "container" || raw.type === "shell" ? "shell" : raw.type;
177
+ entity.type = raw.type;
114
178
  entities.push(entity);
115
179
  sourceById.set(entity.id, raw);
116
180
  childrenById.set(entity.id, children.map(child => child && child.id).filter(Boolean));
@@ -234,7 +298,7 @@ function intrinsicSize(entity, algorithm = "grid", geometrySizes = DEFAULT_SIZES
234
298
 
235
299
  function sizeForSlot(item, slotSize) {
236
300
  const base = item.size;
237
- if (item.entity.type === "shell" || item.entity.__atlasExplicitSize) return [...base];
301
+ if (isContainerType(item.entity.type) || item.entity.__atlasExplicitSize) return [...base];
238
302
  const scale = Math.min(
239
303
  slotSize[0] / Math.max(base[0], 0.0001),
240
304
  slotSize[1] / Math.max(base[1], 0.0001),
@@ -265,6 +329,149 @@ function spanFor(entity, dimension, minimumWeight) {
265
329
  return [columns, rows, Math.max(1, Math.ceil(target / (columns * rows)))];
266
330
  }
267
331
 
332
+ const CONSTRAINT_DIMENSIONS = Object.freeze({ width: 0, height: 1, depth: 2, x: 0, y: 1, z: 2 });
333
+ const ALIGNMENT_EDGES = Object.freeze({
334
+ left: [0, -1],
335
+ centerX: [0, 0],
336
+ right: [0, 1],
337
+ bottom: [1, -1],
338
+ centerY: [1, 0],
339
+ top: [1, 1],
340
+ back: [2, -1],
341
+ centerZ: [2, 0],
342
+ front: [2, 1]
343
+ });
344
+
345
+ function layoutConstraints(source, spec) {
346
+ if (Array.isArray(source?.constraints)) return source.constraints;
347
+ return Array.isArray(spec?.constraints) ? spec.constraints : [];
348
+ }
349
+
350
+ function constraintIds(value) {
351
+ return Array.isArray(value) ? [...new Set(value.filter(id => typeof id === "string" && id))] : [];
352
+ }
353
+
354
+ function constraintLevel(constraint) {
355
+ return constraint?.strength === "required" ? "error" : "warning";
356
+ }
357
+
358
+ function applyEqualSizeConstraints(items, constraints, diagnostics, ownerId = null) {
359
+ const byId = new Map(items.map(item => [item.entity.id, item]));
360
+ for (const constraint of constraints) {
361
+ const ids = constraintIds(constraint?.equal);
362
+ if (!ids.length) continue;
363
+ const selected = ids.map(id => byId.get(id)).filter(Boolean);
364
+ const missing = ids.filter(id => !byId.has(id));
365
+ if (missing.length) diagnostic(diagnostics, constraintLevel(constraint), "UNKNOWN_CONSTRAINT_ENTITY",
366
+ `Equal-size constraint references entities outside the sibling group: ${missing.join(", ")}.`,
367
+ { ownerId, entityIds: missing });
368
+ if (selected.length < 2) continue;
369
+ const requested = constraint.dimensions ?? constraint.dimension ?? constraint.edge ?? ["width", "height", "depth"];
370
+ const names = Array.isArray(requested) ? requested : [requested];
371
+ const axes = [...new Set(names.map(name => CONSTRAINT_DIMENSIONS[name]).filter(Number.isInteger))];
372
+ const invalid = names.filter(name => !Number.isInteger(CONSTRAINT_DIMENSIONS[name]));
373
+ if (invalid.length) diagnostic(diagnostics, constraintLevel(constraint), "UNKNOWN_CONSTRAINT_DIMENSION",
374
+ `Unknown equal-size dimension: ${invalid.join(", ")}.`, { ownerId, dimensions: invalid });
375
+ for (const axis of axes) {
376
+ const extent = Math.max(...selected.map(item => item.size[axis]));
377
+ for (const item of selected) {
378
+ item.size = [...item.size];
379
+ item.size[axis] = extent;
380
+ item.entity.s = [...item.size];
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ function boundaryValue(item, edge) {
387
+ const [axis, side] = ALIGNMENT_EDGES[edge];
388
+ return item.position[axis] + side * item.size[axis] / 2;
389
+ }
390
+
391
+ function placementsOverlap(a, b, epsilon = 1e-9) {
392
+ return [0, 1, 2].every(axis =>
393
+ Math.abs(a.position[axis] - b.position[axis]) + epsilon < (a.size[axis] + b.size[axis]) / 2
394
+ );
395
+ }
396
+
397
+ function separateAlignmentOverlaps(placements, lockedAxes, requiredIds, spec, diagnostics, ownerId) {
398
+ const directionAxis = { x: 0, y: 1, z: 2 }[spec?.direction];
399
+ const priority = [...new Set([directionAxis, 0, 1, 2].filter(Number.isInteger))];
400
+ const gap = Math.max(0, scalar(spec?.gap, 1));
401
+ const warned = new Set();
402
+
403
+ for (let index = 1; index < placements.length; index++) {
404
+ const item = placements[index];
405
+ let passes = 0;
406
+ while (passes++ < placements.length * 3) {
407
+ const blocker = placements.slice(0, index).find(candidate => placementsOverlap(item, candidate));
408
+ if (!blocker) break;
409
+ const locked = lockedAxes.get(item.entity.id) || new Set();
410
+ let axis = priority.find(candidate => !locked.has(candidate));
411
+ if (!Number.isInteger(axis)) {
412
+ axis = priority[0] ?? 0;
413
+ const key = item.entity.id;
414
+ if (!warned.has(key)) {
415
+ warned.add(key);
416
+ diagnostic(diagnostics, requiredIds.has(key) ? "error" : "warning", "ALIGNMENT_COLLISION_AVOIDED",
417
+ `Alignment for '${key}' over-constrains all three axes; non-overlap takes precedence.`,
418
+ { ownerId, entityId: key, axis: ["x", "y", "z"][axis] });
419
+ }
420
+ }
421
+ item.position = [...item.position];
422
+ item.position[axis] = Math.max(...placements.slice(0, index).map(candidate =>
423
+ candidate.position[axis] + (item.size[axis] + candidate.size[axis]) / 2 + gap
424
+ ));
425
+ }
426
+ }
427
+ }
428
+
429
+ function applyAlignmentConstraints(placements, constraints, diagnostics, ownerId = null, spec = {}) {
430
+ const lockedAxes = new Map();
431
+ const requiredIds = new Set();
432
+ const byId = new Map(placements.map(item => [item.entity.id, item]));
433
+ for (const constraint of constraints) {
434
+ const ids = constraintIds(constraint?.align);
435
+ if (!ids.length) continue;
436
+ const selected = ids.map(id => byId.get(id)).filter(Boolean);
437
+ const missing = ids.filter(id => !byId.has(id));
438
+ if (missing.length) diagnostic(diagnostics, constraintLevel(constraint), "UNKNOWN_CONSTRAINT_ENTITY",
439
+ `Alignment constraint references entities outside the sibling group: ${missing.join(", ")}.`,
440
+ { ownerId, entityIds: missing });
441
+ if (selected.length < 2) continue;
442
+ const requested = constraint.edges ?? constraint.edge;
443
+ const edges = Array.isArray(requested) ? requested : [requested];
444
+ const validEdges = edges.filter(edge => ALIGNMENT_EDGES[edge]);
445
+ const invalid = edges.filter(edge => !ALIGNMENT_EDGES[edge]);
446
+ if (invalid.length) diagnostic(diagnostics, constraintLevel(constraint), "UNKNOWN_ALIGNMENT_EDGE",
447
+ `Unknown alignment edge: ${invalid.join(", ")}.`, { ownerId, edges: invalid });
448
+ for (const edge of validEdges) {
449
+ const [axis, side] = ALIGNMENT_EDGES[edge];
450
+ const target = boundaryValue(selected[0], edge);
451
+ for (const item of selected) {
452
+ if (!lockedAxes.has(item.entity.id)) lockedAxes.set(item.entity.id, new Set());
453
+ lockedAxes.get(item.entity.id).add(axis);
454
+ if (constraint.strength === "required") requiredIds.add(item.entity.id);
455
+ }
456
+ for (const item of selected.slice(1)) {
457
+ item.position = [...item.position];
458
+ item.position[axis] = target - side * item.size[axis] / 2;
459
+ }
460
+ }
461
+ }
462
+ separateAlignmentOverlaps(placements, lockedAxes, requiredIds, spec, diagnostics, ownerId);
463
+ }
464
+
465
+ function constrainedLayout(items, spec, source, diagnostics, ownerId, layout) {
466
+ const constraints = layoutConstraints(source, spec);
467
+ applyEqualSizeConstraints(items, constraints, diagnostics, ownerId);
468
+ const result = layout(items, spec, diagnostics);
469
+ applyAlignmentConstraints(result.positions, constraints, diagnostics, ownerId, spec);
470
+ return constraints.some(constraint => constraint?.align)
471
+ ? boundsOf(result.positions, Math.max(0, scalar(spec.padding, 1)))
472
+ : result;
473
+ }
474
+
268
475
  function boundsOf(placements, padding) {
269
476
  if (!placements.length) return { positions: [], size: [padding * 2, padding * 2, padding * 2] };
270
477
  const min = [Infinity, Infinity, Infinity];
@@ -459,6 +666,7 @@ export function compileAtlasModel(input, options = {}) {
459
666
  const source = normalizeRoot(input);
460
667
  const palette = paletteFrom(source);
461
668
  const flat = flattenEntities(source.entities, diagnostics);
669
+ normalizeEntityTypes(flat, options.geometryCatalog || [], diagnostics);
462
670
  if (!flat.entities.length) diagnostic(diagnostics, "fatal", "NO_VALID_ENTITIES", "The model does not contain any valid entities.");
463
671
  const validIds = new Set(flat.entities.map(entity => entity.id));
464
672
  const relations = normalizeRelations(source.relations, validIds, diagnostics);
@@ -489,11 +697,12 @@ export function compileAtlasModel(input, options = {}) {
489
697
  const algorithm = ALGORITHMS.has(spec.algorithm) ? spec.algorithm : "grid";
490
698
  if (!ALGORITHMS.has(spec.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM", `Unknown layout algorithm '${spec.algorithm}', using grid.`, { entityId });
491
699
  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 });
492
- const result = algorithm === "row"
493
- ? rowLayout(childItems, spec, diagnostics)
494
- : algorithm === "volume"
495
- ? volumeLayout(childItems, spec, diagnostics)
496
- : gridLayout(childItems, spec, diagnostics);
700
+ const result = constrainedLayout(childItems, spec, sourceEntity, diagnostics, entityId,
701
+ algorithm === "row"
702
+ ? rowLayout
703
+ : algorithm === "volume"
704
+ ? volumeLayout
705
+ : gridLayout);
497
706
  for (const placement of result.positions) {
498
707
  const child = byId.get(placement.entity.id);
499
708
  child.p = placement.position;
@@ -513,11 +722,12 @@ export function compileAtlasModel(input, options = {}) {
513
722
  const rootSpec = source.layout || { algorithm: "row", direction: "y", gap: 1.5, padding: 1 };
514
723
  const rootAlgorithm = ALGORITHMS.has(rootSpec.algorithm) ? rootSpec.algorithm : "row";
515
724
  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.");
516
- const rootResult = rootAlgorithm === "row"
517
- ? rowLayout(rootItems, rootSpec, diagnostics)
518
- : rootAlgorithm === "volume"
519
- ? volumeLayout(rootItems, rootSpec, diagnostics)
520
- : gridLayout(rootItems, rootSpec, diagnostics);
725
+ const rootResult = constrainedLayout(rootItems, rootSpec, source, diagnostics, null,
726
+ rootAlgorithm === "row"
727
+ ? rowLayout
728
+ : rootAlgorithm === "volume"
729
+ ? volumeLayout
730
+ : gridLayout);
521
731
  for (const placement of rootResult.positions) {
522
732
  const entity = byId.get(placement.entity.id);
523
733
  entity.p = placement.position;
@@ -535,6 +745,7 @@ export function compileAtlasModel(input, options = {}) {
535
745
  }),
536
746
  relations,
537
747
  layout: source.layout || undefined,
748
+ constraints: source.constraints || undefined,
538
749
  diagnostics
539
750
  };
540
751
  if (options.throwOnFatal && diagnostics.some(item => item.level === "fatal")) throw new Error(diagnostics.filter(item => item.level === "fatal").map(item => item.message).join("\n"));
package/src/spec.js ADDED
@@ -0,0 +1,204 @@
1
+ const freeze = value => Object.freeze(value);
2
+
3
+ const field = (description, values = null, extra = {}) => freeze({
4
+ description,
5
+ ...(values ? { values: freeze([...values]) } : {}),
6
+ ...extra
7
+ });
8
+
9
+ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
10
+ version: 1,
11
+ name: "Atlas Layout",
12
+ contexts: freeze({
13
+ root: freeze({
14
+ label: "Atlas model",
15
+ properties: freeze({
16
+ version: field("Semantic model version.", [1]),
17
+ palette: field("Named model colours.", null, { context: "palette" }),
18
+ theme: field("Renderer theme configuration.", null, { context: "theme" }),
19
+ render: field("Renderer configuration.", null, { context: "render" }),
20
+ layout: field("Assisted layout for root entities.", null, { context: "layout" }),
21
+ constraints: field("Equal-size and edge-alignment rules for root siblings.", null, { context: "constraint", sequence: true }),
22
+ entities: field("Top-level entities and containers.", null, { context: "entity", sequence: true }),
23
+ relations: field("Connections between entity ids.", null, { context: "relation", sequence: true })
24
+ })
25
+ }),
26
+ entity: freeze({
27
+ label: "Entity",
28
+ properties: freeze({
29
+ id: field("Stable identifier.", null, { type: "string", required: true }),
30
+ name: field("Visible title.", null, { type: "string" }),
31
+ sub: field("Visible subtitle.", null, { type: "string" }),
32
+ text: field("Description shown by the inspector.", null, { type: "string" }),
33
+ type: field("Structural role. Use container when declaring children; omit it for a leaf item.", ["container"]),
34
+ parent: field("Parent entity id.", null, { type: "string", authoring: "absolute" }),
35
+ children: field("Nested entities.", null, { context: "entity", sequence: true }),
36
+ geometry: field("Geometry id supplied by Atlas or a registered pack.", null, { valueSource: "geometryCatalog" }),
37
+ geometryOptions: field("Typed parameters supplied by the selected geometry.", null, { context: "geometryOptions" }),
38
+ geometryPalette: field("Local palette overrides supplied by the selected geometry.", null, { context: "geometryPalette" }),
39
+ style: field("Appearance and semantic proportions.", null, { context: "style" }),
40
+ layout: field("Assisted layout for this container.", null, { context: "layout" }),
41
+ constraints: field("Equal-size and edge-alignment rules for this container's children.", null, { context: "constraint", sequence: true }),
42
+ weight: field("Relative space used by assisted layout.", null, { type: "number" }),
43
+ span: field("Grid or volume cell span.", null, { context: "span", authoring: "absolute" }),
44
+ size: field("Explicit [x, y, z] dimensions.", null, { type: "vector", authoring: "absolute" }),
45
+ sizeMode: field("Whether explicit dimensions are fixed.", ["fit", "fixed"], { authoring: "absolute" }),
46
+ p: field("Calculated or imported local position.", null, { type: "vector", authoring: "absolute" }),
47
+ s: field("Calculated final bounding-box size.", null, { type: "vector", authoring: "absolute" }),
48
+ c: field("Palette token or CSS colour.", null, { valueSource: "palette" }),
49
+ radius: field("Absolute rounding radius where supported.", null, { type: "number" })
50
+ })
51
+ }),
52
+ relation: freeze({
53
+ label: "Relation",
54
+ properties: freeze({
55
+ id: field("Stable relation identifier.", null, { type: "string" }),
56
+ from: field("Source entity id.", null, { type: "string", required: true }),
57
+ to: field("Target entity id.", null, { type: "string", required: true }),
58
+ label: field("Text shown on the relation.", null, { type: "string" }),
59
+ mode: field("Canonical visual relation mode.", ["directed", "bidirectional", "broken"]),
60
+ visual: field("Historical alias for relation mode.", ["directed", "bidirectional", "broken"]),
61
+ fromAnchor: field("Named source anchor.", null, { type: "string" }),
62
+ toAnchor: field("Named target anchor.", null, { type: "string" }),
63
+ priority: field("Relative routing priority.", null, { type: "number" }),
64
+ layout: field("Include this relation in assisted routing.", [true, false])
65
+ })
66
+ }),
67
+ layout: freeze({
68
+ label: "Layout",
69
+ properties: freeze({
70
+ algorithm: field("Spatial strategy.", ["row", "grid", "volume", "graph"]),
71
+ direction: field("Primary semantic axis.", ["x", "y", "z"]),
72
+ variant: field("Regular or staggered grid cells.", ["uniform", "masonry"]),
73
+ columns: field("Explicit column count.", null, { type: "integer" }),
74
+ rows: field("Explicit row count.", null, { type: "integer" }),
75
+ layers: field("Explicit volume layer count.", null, { type: "integer" }),
76
+ gap: field("Spacing between children.", null, { type: "number" }),
77
+ padding: field("Spacing to the container boundary.", null, { type: "number" }),
78
+ justify: field("Row alignment.", ["start", "center", "end"]),
79
+ stagger: field("Optional masonry offset.", null, { context: "stagger" }),
80
+ constraints: field("Equal-size and edge-alignment rules for this layout's siblings.", null, { context: "constraint", sequence: true })
81
+ })
82
+ }),
83
+ constraint: freeze({
84
+ label: "Sibling constraint",
85
+ properties: freeze({
86
+ equal: field("Sibling entity ids that must share dimensions.", null, { type: "string-list" }),
87
+ align: field("Sibling entity ids whose boundaries or centres must align.", null, { type: "string-list" }),
88
+ dimensions: field("Dimensions shared by an equal-size group.", ["width", "height", "depth"], { multiple: true }),
89
+ edge: field("Single dimension or alignment boundary.", ["width", "height", "depth", "left", "centerX", "right", "bottom", "centerY", "top", "back", "centerZ", "front"]),
90
+ edges: field("Alignment boundaries or centres.", ["left", "centerX", "right", "bottom", "centerY", "top", "back", "centerZ", "front"], { multiple: true }),
91
+ strength: field("Diagnostic strength when the rule cannot be applied.", ["preferred", "required"])
92
+ })
93
+ }),
94
+ style: freeze({
95
+ label: "Style",
96
+ properties: freeze({
97
+ color: field("Palette token or CSS colour.", null, { valueSource: "palette" }),
98
+ aspect: field("Semantic proportion.", ["compact", "wide", "tall", "deep", "flat"]),
99
+ radius: field("Absolute rounding radius.", null, { type: "number" }),
100
+ label: field("Label appearance.", null, { context: "label" }),
101
+ edges: field("Edge appearance.", null, { context: "edges" })
102
+ })
103
+ }),
104
+ palette: freeze({
105
+ label: "Palette",
106
+ properties: freeze({
107
+ default_color: field("Fallback CSS colour.", null, { type: "color" })
108
+ })
109
+ }),
110
+ theme: freeze({
111
+ label: "Theme",
112
+ properties: freeze({
113
+ background: field("Background configuration.", null, { context: "background" }),
114
+ lighting: field("Lighting configuration.", null, { context: "lighting" }),
115
+ ui: field("Built-in component UI visibility.", null, { context: "ui" })
116
+ })
117
+ }),
118
+ render: freeze({
119
+ label: "Render",
120
+ properties: freeze({
121
+ palette: field("Renderer palette overrides.", null, { context: "palette" }),
122
+ theme: field("Renderer theme overrides.", null, { context: "theme" }),
123
+ ui: field("Renderer UI visibility.", null, { context: "ui" }),
124
+ relationMode: field("Visible relation set.", ["all", "selected", "none"]),
125
+ selectionMode: field("Selection ownership.", ["internal", "event"]),
126
+ toolbar: field("Built-in toolbar visibility.", [true, false]),
127
+ shellLabels: field("Container label visibility.", [true, false])
128
+ })
129
+ }),
130
+ span: freeze({
131
+ label: "Span",
132
+ properties: freeze({
133
+ columns: field("Number of columns occupied.", null, { type: "integer" }),
134
+ rows: field("Number of rows occupied.", null, { type: "integer" }),
135
+ layers: field("Number of layers occupied.", null, { type: "integer" })
136
+ })
137
+ }),
138
+ stagger: freeze({
139
+ label: "Stagger",
140
+ properties: freeze({
141
+ offset: field("Masonry row offset.", ["half-cell"], { type: "string" })
142
+ })
143
+ }),
144
+ label: freeze({ label: "Label appearance", properties: freeze({ color: field("Label colour.", null, { valueSource: "palette" }), opacity: field("Label opacity.", null, { type: "number" }) }) }),
145
+ edges: freeze({ label: "Edge appearance", properties: freeze({ color: field("Edge colour.", null, { valueSource: "palette" }), opacity: field("Edge opacity.", null, { type: "number" }) }) }),
146
+ background: freeze({ label: "Background", properties: freeze({ color: field("Background colour.", null, { type: "color" }), glow: field("Background glow colour.", null, { type: "color" }) }) }),
147
+ lighting: freeze({ label: "Lighting", properties: freeze({ ambient: field("Ambient intensity.", null, { type: "number" }), directional: field("Directional intensity.", null, { type: "number" }) }) }),
148
+ ui: freeze({ label: "Component UI", properties: freeze({ panel: field("Show information panel.", [true, false]), toolbar: field("Show camera toolbar.", [true, false]), breadcrumbs: field("Show breadcrumbs.", [true, false]), hint: field("Show interaction hint.", [true, false]) }) }),
149
+ geometryOptions: freeze({ label: "Geometry parameters", properties: freeze({}) }),
150
+ geometryPalette: freeze({ label: "Geometry palette", properties: freeze({}) })
151
+ })
152
+ });
153
+
154
+ export function atlasLayoutContextForPath(path = []) {
155
+ const names = Array.isArray(path) ? path : [];
156
+ const aliases = new Map([
157
+ ["models", "root"],
158
+ ["entities", "entity"],
159
+ ["children", "entity"],
160
+ ["relations", "relation"],
161
+ ["layout", "layout"],
162
+ ["constraints", "constraint"],
163
+ ["style", "style"],
164
+ ["palette", "palette"],
165
+ ["theme", "theme"],
166
+ ["render", "render"],
167
+ ["span", "span"],
168
+ ["stagger", "stagger"],
169
+ ["label", "label"],
170
+ ["edges", "edges"],
171
+ ["background", "background"],
172
+ ["lighting", "lighting"],
173
+ ["ui", "ui"],
174
+ ["geometryOptions", "geometryOptions"],
175
+ ["geometryPalette", "geometryPalette"]
176
+ ]);
177
+ for (let index = names.length - 1; index >= 0; index -= 1) {
178
+ const context = aliases.get(names[index]);
179
+ if (context && ATLAS_LAYOUT_SPEC_V1.contexts[context]) return ATLAS_LAYOUT_SPEC_V1.contexts[context];
180
+ }
181
+ return ATLAS_LAYOUT_SPEC_V1.contexts.root;
182
+ }
183
+
184
+ export function atlasLayoutProperty(context, name) {
185
+ return context?.properties?.[name] || null;
186
+ }
187
+
188
+ const ABSOLUTE_ENTITY_PROPERTIES = new Set(["parent", "span", "size", "sizeMode", "p", "s"]);
189
+
190
+ export function atlasLayoutPropertyKind(context, name) {
191
+ const definition = atlasLayoutProperty(context, name);
192
+ return definition?.authoring || (ABSOLUTE_ENTITY_PROPERTIES.has(name) ? "absolute" : "markup");
193
+ }
194
+
195
+ export function atlasLayoutValues(definition, options = {}) {
196
+ if (!definition) return [];
197
+ if (definition.valueSource === "geometryCatalog") {
198
+ return (options.geometryCatalog || []).map(item => item.id).filter(Boolean);
199
+ }
200
+ if (definition.valueSource === "palette") {
201
+ return Object.keys(options.palette || {}).filter(Boolean);
202
+ }
203
+ return Array.isArray(definition.values) ? [...definition.values] : [];
204
+ }
@@ -11,10 +11,17 @@ function ids(model) {
11
11
  return new Map(model.entities.map(entity => [entity.id, entity]));
12
12
  }
13
13
 
14
+ function entitiesOverlap(a, b) {
15
+ return [0, 1, 2].every(axis =>
16
+ Math.abs(a.p[axis] - b.p[axis]) < (a.s[axis] + b.s[axis]) / 2 - 1e-9
17
+ );
18
+ }
19
+
14
20
  test("normalizes nested entities and inherits colors", () => {
15
21
  const model = compileAtlasModel({
16
22
  entities: [{
17
23
  id: "container",
24
+ type: "container",
18
25
  style: { color: "top" },
19
26
  layout: { algorithm: "grid", columns: 2 },
20
27
  children: [{ id: "a", geometry: "box" }, { id: "b", geometry: "sphere", style: { color: "warm" } }]
@@ -22,6 +29,8 @@ test("normalizes nested entities and inherits colors", () => {
22
29
  render: { palette: { top: "#00f", warm: "#f80", default_color: "#ccc" } }
23
30
  });
24
31
  const byId = ids(model);
32
+ assert.equal(byId.get("container").type, "container");
33
+ assert.equal(byId.get("a").type, "item");
25
34
  assert.equal(byId.get("a").parent, "container");
26
35
  assert.equal(byId.get("a").c, "top");
27
36
  assert.equal(byId.get("b").c, "warm");
@@ -32,17 +41,74 @@ test("normalizes nested entities and inherits colors", () => {
32
41
  test("accepts flat parent references as well as nested children", () => {
33
42
  const model = compileAtlasModel({
34
43
  entities: [
35
- { id: "parent", style: { color: "top" }, layout: { algorithm: "row", direction: "x" } },
44
+ { id: "parent", type: "container", style: { color: "top" }, layout: { algorithm: "row", direction: "x" } },
36
45
  { id: "child", parent: "parent", geometry: "box" }
37
46
  ],
38
47
  render: { palette: { top: "#00f" } }
39
48
  });
40
49
  const byId = ids(model);
50
+ assert.equal(byId.get("parent").type, "container");
51
+ assert.equal(byId.get("child").type, "item");
41
52
  assert.equal(byId.get("child").parent, "parent");
42
53
  assert.equal(byId.get("child").c, "top");
43
54
  assert.equal(model.diagnostics.length, 0);
44
55
  });
45
56
 
57
+
58
+ test("requires an explicit container type for nested children", () => {
59
+ const model = compileAtlasModel({
60
+ entities: [{
61
+ id: "root",
62
+ children: [{ id: "child", geometry: "box" }]
63
+ }]
64
+ });
65
+ assert.equal(model.diagnostics[0].code, "CONTAINER_TYPE_REQUIRED_FOR_CHILDREN");
66
+ assert.equal(model.diagnostics[0].level, "fatal");
67
+ assert.equal(ids(model).get("root").type, "container");
68
+ });
69
+
70
+ test("rejects custom geometry on containers", () => {
71
+ const model = compileAtlasModel({
72
+ entities: [{
73
+ id: "root",
74
+ type: "container",
75
+ geometry: "infra.cloud",
76
+ children: [{ id: "child", geometry: "box" }]
77
+ }]
78
+ }, {
79
+ geometryCatalog: [{ id: "infra.cloud", canonicalSize: [3, 2, 3] }]
80
+ });
81
+ assert.equal(model.diagnostics[0].code, "CUSTOM_GEOMETRY_ON_CONTAINER");
82
+ assert.equal(model.diagnostics[0].level, "fatal");
83
+ });
84
+
85
+ test("accepts primitive geometry on explicit containers", () => {
86
+ const model = compileAtlasModel({
87
+ entities: [{
88
+ id: "root",
89
+ type: "container",
90
+ geometry: "geo.box",
91
+ children: [{ id: "child", geometry: "sphere" }]
92
+ }]
93
+ });
94
+ assert.equal(ids(model).get("root").type, "container");
95
+ assert.equal(model.diagnostics.length, 0);
96
+ });
97
+
98
+ test("normalizes shell as a compatible container alias and infers leaf items", () => {
99
+ const model = compileAtlasModel({
100
+ entities: [{
101
+ id: "root",
102
+ type: "shell",
103
+ children: [{ id: "leaf", geometry: "infra.cloud" }]
104
+ }]
105
+ }, {
106
+ geometryCatalog: [{ id: "infra.cloud", canonicalSize: [3, 2, 3] }]
107
+ });
108
+ assert.equal(ids(model).get("root").type, "container");
109
+ assert.equal(ids(model).get("leaf").type, "item");
110
+ assert.equal(model.diagnostics.length, 0);
111
+ });
46
112
  test("row y places the first item above the next item", () => {
47
113
  const model = compileAtlasModel({
48
114
  layout: { algorithm: "row", direction: "y", gap: 1 },
@@ -126,6 +192,7 @@ test("fixed containers report overflow without hiding children", () => {
126
192
  const model = compileAtlasModel({
127
193
  entities: [{
128
194
  id: "fixed",
195
+ type: "container",
129
196
  size: [1, 1, 1],
130
197
  sizeMode: "fixed",
131
198
  layout: { algorithm: "row", gap: 1, padding: 1 },
@@ -137,6 +204,90 @@ test("fixed containers report overflow without hiding children", () => {
137
204
  assert.equal(ids(model).get("fixed").layoutError, "overflow");
138
205
  });
139
206
 
207
+ test("equal-size constraints use the largest intrinsic sibling dimensions", () => {
208
+ const model = compileAtlasModel({
209
+ constraints: [{
210
+ equal: ["wide", "small", "deep"],
211
+ dimensions: ["width", "height", "depth"],
212
+ strength: "preferred"
213
+ }],
214
+ layout: { algorithm: "row", direction: "y", gap: 1 },
215
+ entities: [
216
+ { id: "wide", type: "container", layout: { algorithm: "row" }, children: [{ id: "a", geometry: "box" }, { id: "b", geometry: "sphere" }] },
217
+ { id: "small", type: "container", children: [{ id: "c", geometry: "box" }] },
218
+ { id: "deep", type: "container", children: [{ id: "d", geometry: "pyramid-square" }] }
219
+ ]
220
+ });
221
+ const byId = ids(model);
222
+ assert.deepEqual(byId.get("wide").s, byId.get("small").s);
223
+ assert.deepEqual(byId.get("small").s, byId.get("deep").s);
224
+ assert.equal(model.diagnostics.length, 0);
225
+ });
226
+
227
+ test("alignment constraints use stable model-space boundary aliases", () => {
228
+ const model = compileAtlasModel({
229
+ constraints: [{
230
+ align: ["a", "b"],
231
+ edges: ["left", "front"],
232
+ strength: "required"
233
+ }],
234
+ layout: { algorithm: "row", direction: "y", gap: 1 },
235
+ entities: [
236
+ { id: "a", geometry: "box", size: [4, 1, 2] },
237
+ { id: "b", geometry: "box", size: [2, 1, 4] }
238
+ ]
239
+ });
240
+ const byId = ids(model);
241
+ assert.equal(byId.get("a").p[0] - byId.get("a").s[0] / 2, byId.get("b").p[0] - byId.get("b").s[0] / 2);
242
+ assert.equal(byId.get("a").p[2] + byId.get("a").s[2] / 2, byId.get("b").p[2] + byId.get("b").s[2] / 2);
243
+ });
244
+
245
+ test("alignment preserves non-overlap when it targets the layout axis", () => {
246
+ const model = compileAtlasModel({
247
+ constraints: [{ align: ["a", "b", "c"], edges: ["centerY"], strength: "preferred" }],
248
+ layout: { algorithm: "row", direction: "y", gap: 1 },
249
+ entities: [
250
+ { id: "a", geometry: "box", size: [4, 2, 2] },
251
+ { id: "b", geometry: "sphere", size: [3, 3, 3] },
252
+ { id: "c", geometry: "box", size: [2, 4, 2] }
253
+ ]
254
+ });
255
+ const byId = ids(model);
256
+ assert.equal(byId.get("a").p[1], byId.get("b").p[1]);
257
+ assert.equal(byId.get("b").p[1], byId.get("c").p[1]);
258
+ assert.equal(entitiesOverlap(byId.get("a"), byId.get("b")), false);
259
+ assert.equal(entitiesOverlap(byId.get("a"), byId.get("c")), false);
260
+ assert.equal(entitiesOverlap(byId.get("b"), byId.get("c")), false);
261
+ });
262
+
263
+ test("changing alignment recompiles from a clean packed layout", () => {
264
+ const source = {
265
+ constraints: [{ align: ["a", "b"], edges: ["centerY"], strength: "preferred" }],
266
+ layout: { algorithm: "row", direction: "y", gap: 1 },
267
+ entities: [
268
+ { id: "a", geometry: "box", size: [4, 2, 2] },
269
+ { id: "b", geometry: "box", size: [2, 2, 2] }
270
+ ]
271
+ };
272
+ const first = compileAtlasModel(source);
273
+ const second = compileAtlasModel({
274
+ ...first,
275
+ constraints: [{ align: ["a", "b"], edges: ["centerX"], strength: "preferred" }]
276
+ });
277
+ const byId = ids(second);
278
+ assert.equal(byId.get("a").p[0], byId.get("b").p[0]);
279
+ assert.equal(entitiesOverlap(byId.get("a"), byId.get("b")), false);
280
+ });
281
+
282
+ test("constraints reject references outside their sibling group", () => {
283
+ const model = compileAtlasModel({
284
+ constraints: [{ equal: ["a", "missing"], edge: "width", strength: "required" }],
285
+ entities: [{ id: "a", geometry: "box" }]
286
+ });
287
+ assert.equal(model.diagnostics[0].code, "UNKNOWN_CONSTRAINT_ENTITY");
288
+ assert.equal(model.diagnostics[0].level, "error");
289
+ });
290
+
140
291
  test("graph layout is reported as a recoverable fallback", () => {
141
292
  const model = compileAtlasModel({
142
293
  entities: [{ id: "a", geometry: "box" }, { id: "b", geometry: "box" }],
@@ -154,6 +305,7 @@ render:
154
305
  top: "#00f"
155
306
  entities:
156
307
  - id: root
308
+ type: container
157
309
  layout:
158
310
  algorithm: row
159
311
  direction: x