@liquidcars/atlas-layout 0.1.3 → 0.1.7

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/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,394 @@ 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
+ const relevant = placements.slice(0, index).filter(candidate =>
422
+ [0, 1, 2].every(otherAxis =>
423
+ otherAxis === axis ||
424
+ Math.abs(item.position[otherAxis] - candidate.position[otherAxis]) + 1e-9 <
425
+ (item.size[otherAxis] + candidate.size[otherAxis]) / 2
426
+ )
427
+ );
428
+ item.position = [...item.position];
429
+ item.position[axis] = Math.max(...(relevant.length ? relevant : [blocker]).map(candidate =>
430
+ candidate.position[axis] + (item.size[axis] + candidate.size[axis]) / 2 + gap
431
+ ));
432
+ }
433
+ }
434
+ }
435
+
436
+ function applyAlignmentConstraints(placements, constraints, diagnostics, ownerId = null, spec = {}) {
437
+ const lockedAxes = new Map();
438
+ const requiredIds = new Set();
439
+ const byId = new Map(placements.map(item => [item.entity.id, item]));
440
+ for (const constraint of constraints) {
441
+ const ids = constraintIds(constraint?.align);
442
+ if (!ids.length) continue;
443
+ const selected = ids.map(id => byId.get(id)).filter(Boolean);
444
+ const missing = ids.filter(id => !byId.has(id));
445
+ if (missing.length) diagnostic(diagnostics, constraintLevel(constraint), "UNKNOWN_CONSTRAINT_ENTITY",
446
+ `Alignment constraint references entities outside the sibling group: ${missing.join(", ")}.`,
447
+ { ownerId, entityIds: missing });
448
+ if (selected.length < 2) continue;
449
+ const requested = constraint.edges ?? constraint.edge;
450
+ const edges = Array.isArray(requested) ? requested : [requested];
451
+ const validEdges = edges.filter(edge => ALIGNMENT_EDGES[edge]);
452
+ const invalid = edges.filter(edge => !ALIGNMENT_EDGES[edge]);
453
+ if (invalid.length) diagnostic(diagnostics, constraintLevel(constraint), "UNKNOWN_ALIGNMENT_EDGE",
454
+ `Unknown alignment edge: ${invalid.join(", ")}.`, { ownerId, edges: invalid });
455
+ for (const edge of validEdges) {
456
+ const [axis, side] = ALIGNMENT_EDGES[edge];
457
+ const target = boundaryValue(selected[0], edge);
458
+ for (const item of selected) {
459
+ if (!lockedAxes.has(item.entity.id)) lockedAxes.set(item.entity.id, new Set());
460
+ lockedAxes.get(item.entity.id).add(axis);
461
+ if (constraint.strength === "required") requiredIds.add(item.entity.id);
462
+ }
463
+ for (const item of selected.slice(1)) {
464
+ item.position = [...item.position];
465
+ item.position[axis] = target - side * item.size[axis] / 2;
466
+ }
467
+ }
468
+ }
469
+ separateAlignmentOverlaps(placements, lockedAxes, requiredIds, spec, diagnostics, ownerId);
470
+ return lockedAxes;
471
+ }
472
+
473
+ function compactBandCrossAxis(items, primaryAxis, lockedAxes, gap) {
474
+ if (items.length < 2) return;
475
+ const axis = [0, 1, 2].find(candidate =>
476
+ candidate !== primaryAxis &&
477
+ items.some(item => !(lockedAxes.get(item.entity.id) || new Set()).has(candidate)) &&
478
+ Math.max(...items.map(item => item.position[candidate])) -
479
+ Math.min(...items.map(item => item.position[candidate])) > 1e-9
480
+ );
481
+ if (!Number.isInteger(axis)) return;
482
+
483
+ const ordered = [...items].sort((left, right) =>
484
+ left.position[axis] - right.position[axis] ||
485
+ items.indexOf(left) - items.indexOf(right)
486
+ );
487
+ const locked = item => (lockedAxes.get(item.entity.id) || new Set()).has(axis);
488
+
489
+ for (let index = 1; index < ordered.length; index++) {
490
+ const item = ordered[index];
491
+ if (locked(item)) continue;
492
+ const previous = ordered[index - 1];
493
+ const target = previous.position[axis] + (previous.size[axis] + item.size[axis]) / 2 + gap;
494
+ if (target < item.position[axis]) {
495
+ item.position = [...item.position];
496
+ item.position[axis] = target;
497
+ }
498
+ }
499
+ for (let index = ordered.length - 2; index >= 0; index--) {
500
+ const item = ordered[index];
501
+ if (locked(item)) continue;
502
+ const next = ordered[index + 1];
503
+ const target = next.position[axis] - (next.size[axis] + item.size[axis]) / 2 - gap;
504
+ if (target > item.position[axis]) {
505
+ item.position = [...item.position];
506
+ item.position[axis] = target;
507
+ }
508
+ }
509
+ }
510
+
511
+ function compactRowBands(placements, constraints, spec, lockedAxes = new Map()) {
512
+ if (spec?.packing !== "compact" || spec?.algorithm !== "row" || placements.length < 2) return;
513
+ const axis = { x: 0, y: 1, z: 2 }[spec.direction] ?? 0;
514
+ const parent = placements.map((_, index) => index);
515
+ const byId = new Map(placements.map((item, index) => [item.entity.id, index]));
516
+ const find = index => {
517
+ while (parent[index] !== index) {
518
+ parent[index] = parent[parent[index]];
519
+ index = parent[index];
520
+ }
521
+ return index;
522
+ };
523
+ const union = (left, right) => {
524
+ const leftRoot = find(left);
525
+ const rightRoot = find(right);
526
+ if (leftRoot !== rightRoot) parent[rightRoot] = leftRoot;
527
+ };
528
+
529
+ for (const constraint of constraints) {
530
+ const ids = constraintIds(constraint?.align).filter(id => byId.has(id));
531
+ const requested = constraint?.edges ?? constraint?.edge;
532
+ const edges = Array.isArray(requested) ? requested : [requested];
533
+ if (ids.length < 2 || !edges.some(edge => ALIGNMENT_EDGES[edge]?.[0] === axis)) continue;
534
+ const first = byId.get(ids[0]);
535
+ for (const id of ids.slice(1)) union(first, byId.get(id));
536
+ }
537
+
538
+ const bandsByRoot = new Map();
539
+ placements.forEach((item, index) => {
540
+ const root = find(index);
541
+ if (!bandsByRoot.has(root)) bandsByRoot.set(root, []);
542
+ bandsByRoot.get(root).push(item);
543
+ });
544
+ const bands = [...bandsByRoot.values()].sort((left, right) =>
545
+ placements.indexOf(left[0]) - placements.indexOf(right[0])
546
+ ).map(items => {
547
+ const minimum = Math.min(...items.map(item => item.position[axis] - item.size[axis] / 2));
548
+ const maximum = Math.max(...items.map(item => item.position[axis] + item.size[axis] / 2));
549
+ return { items, center: (minimum + maximum) / 2, length: maximum - minimum };
550
+ });
551
+
552
+ const gap = Math.max(0, scalar(spec.gap, 1));
553
+ for (const band of bands) compactBandCrossAxis(band.items, axis, lockedAxes, gap);
554
+ const total = bands.reduce((sum, band) => sum + band.length, 0) + Math.max(0, bands.length - 1) * gap;
555
+ const orderSign = axis === 0 ? 1 : -1;
556
+ let cursor = -total / 2;
557
+ for (const band of bands) {
558
+ const target = orderSign * (cursor + band.length / 2);
559
+ const delta = target - band.center;
560
+ for (const item of band.items) {
561
+ item.position = [...item.position];
562
+ item.position[axis] += delta;
563
+ }
564
+ cursor += band.length + gap;
565
+ }
566
+ }
567
+
568
+ function layoutFunction(spec, diagnostics, ownerId) {
569
+ const algorithm = ALGORITHMS.has(spec?.algorithm) ? spec.algorithm : "grid";
570
+ if (!ALGORITHMS.has(spec?.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM",
571
+ `Unknown layout algorithm '${spec?.algorithm}', using grid.`, { entityId: ownerId });
572
+ if (algorithm === "graph") diagnostic(diagnostics, "warning", "GRAPH_LAYOUT_FALLBACK",
573
+ `Graph layout for '${ownerId || "root"}' is reserved for a later compiler pass; using grid for now.`, { entityId: ownerId });
574
+ return algorithm === "row"
575
+ ? rowLayout
576
+ : algorithm === "volume"
577
+ ? volumeLayout
578
+ : gridLayout;
579
+ }
580
+
581
+ function materializeLayoutGroups(items, spec, diagnostics, ownerId) {
582
+ const definitions = Array.isArray(spec?.groups) ? spec.groups : [];
583
+ if (!definitions.length) return { items, groups: new Map() };
584
+
585
+ const itemById = new Map(items.map(item => [item.entity.id, item]));
586
+ const claimed = new Map();
587
+ const groups = new Map();
588
+ const validDefinitions = [];
589
+
590
+ for (const definition of definitions) {
591
+ const id = typeof definition?.id === "string" ? definition.id.trim() : "";
592
+ if (!id) {
593
+ diagnostic(diagnostics, "error", "LAYOUT_GROUP_ID_REQUIRED",
594
+ "A layout group requires a non-empty id.", { ownerId });
595
+ continue;
596
+ }
597
+ if (itemById.has(id) || groups.has(id)) {
598
+ diagnostic(diagnostics, "error", "DUPLICATE_LAYOUT_GROUP_ID",
599
+ `Layout group id '${id}' conflicts with another sibling or group.`, { ownerId, groupId: id });
600
+ continue;
601
+ }
602
+ groups.set(id, null);
603
+ validDefinitions.push({ ...definition, id });
604
+ }
605
+
606
+ const virtualByMember = new Map();
607
+ for (const definition of validDefinitions) {
608
+ const rawMembers = Array.isArray(definition.members) ? definition.members : [];
609
+ const members = [...new Set(rawMembers.filter(id => typeof id === "string" && id))];
610
+ const repeated = rawMembers.filter((id, index) => rawMembers.indexOf(id) !== index);
611
+ if (repeated.length) diagnostic(diagnostics, "error", "DUPLICATE_LAYOUT_GROUP_MEMBER",
612
+ `Layout group '${definition.id}' repeats members: ${[...new Set(repeated)].join(", ")}.`,
613
+ { ownerId, groupId: definition.id, entityIds: [...new Set(repeated)] });
614
+ const unknown = members.filter(id => !itemById.has(id));
615
+ if (unknown.length) diagnostic(diagnostics, "error", "UNKNOWN_LAYOUT_GROUP_MEMBER",
616
+ `Layout group '${definition.id}' references entities outside the sibling group: ${unknown.join(", ")}.`,
617
+ { ownerId, groupId: definition.id, entityIds: unknown });
618
+ const available = members.filter(id => itemById.has(id) && !claimed.has(id));
619
+ const duplicateClaims = members.filter(id => claimed.has(id));
620
+ if (duplicateClaims.length) diagnostic(diagnostics, "error", "LAYOUT_GROUP_MEMBER_ALREADY_CLAIMED",
621
+ `Layout group '${definition.id}' reuses members already assigned to another group: ${duplicateClaims.join(", ")}.`,
622
+ { ownerId, groupId: definition.id, entityIds: duplicateClaims });
623
+ if (!available.length) {
624
+ groups.delete(definition.id);
625
+ continue;
626
+ }
627
+ for (const id of available) claimed.set(id, definition.id);
628
+
629
+ const groupSpec = definition.layout || { algorithm: "row", direction: "x", gap: 1, padding: 0 };
630
+ const memberItems = available.map(id => itemById.get(id));
631
+ const groupResult = constrainedLayout(
632
+ memberItems,
633
+ groupSpec,
634
+ definition,
635
+ diagnostics,
636
+ definition.id,
637
+ layoutFunction(groupSpec, diagnostics, definition.id)
638
+ );
639
+ const virtual = {
640
+ entity: { id: definition.id, type: "layout-group", __atlasVirtualGroup: true },
641
+ size: [...groupResult.size],
642
+ intrinsicSize: [...groupResult.size],
643
+ groupResult,
644
+ groupSpec,
645
+ groupSource: definition,
646
+ memberItems
647
+ };
648
+ groups.set(definition.id, virtual);
649
+ for (const id of available) virtualByMember.set(id, virtual);
650
+ }
651
+
652
+ const outerItems = [];
653
+ const inserted = new Set();
654
+ for (const item of items) {
655
+ const virtual = virtualByMember.get(item.entity.id);
656
+ if (!virtual) {
657
+ outerItems.push(item);
658
+ continue;
659
+ }
660
+ if (!inserted.has(virtual.entity.id)) {
661
+ inserted.add(virtual.entity.id);
662
+ outerItems.push(virtual);
663
+ }
664
+ }
665
+ return { items: outerItems, groups };
666
+ }
667
+
668
+ function reflowLayoutGroups(items, diagnostics) {
669
+ for (const item of items) {
670
+ if (!item.entity.__atlasVirtualGroup || item.groupSpec?.algorithm !== "row" ||
671
+ !["start", "end", "space-between", "space-around", "space-evenly"].includes(item.groupSpec?.justify)) continue;
672
+ const expanded = item.size.some((value, axis) => value > item.intrinsicSize[axis] + 1e-9);
673
+ if (!expanded) continue;
674
+ item.groupResult = constrainedLayout(
675
+ item.memberItems,
676
+ item.groupSpec,
677
+ item.groupSource,
678
+ diagnostics,
679
+ item.entity.id,
680
+ layoutFunction(item.groupSpec, diagnostics, item.entity.id),
681
+ item.size
682
+ );
683
+ }
684
+ }
685
+
686
+ function flattenLayoutGroups(placements) {
687
+ const flattened = [];
688
+ for (const placement of placements) {
689
+ if (!placement.entity.__atlasVirtualGroup) {
690
+ flattened.push(placement);
691
+ continue;
692
+ }
693
+ for (const member of placement.groupResult.positions) {
694
+ flattened.push({
695
+ ...member,
696
+ position: member.position.map((value, axis) => value + placement.position[axis])
697
+ });
698
+ }
699
+ }
700
+ return flattened;
701
+ }
702
+
703
+ function constrainedLayout(items, spec, source, diagnostics, ownerId, layout, availableSize = null) {
704
+ const grouped = materializeLayoutGroups(items, spec, diagnostics, ownerId);
705
+ const constraints = layoutConstraints(source, spec);
706
+ applyEqualSizeConstraints(grouped.items, constraints, diagnostics, ownerId);
707
+ reflowLayoutGroups(grouped.items, diagnostics);
708
+ const result = layout(grouped.items, spec, diagnostics, availableSize);
709
+ const lockedAxes = applyAlignmentConstraints(result.positions, constraints, diagnostics, ownerId, spec);
710
+ compactRowBands(result.positions, constraints, spec, lockedAxes);
711
+ const normalized = constraints.some(constraint => constraint?.align) || spec?.packing === "compact"
712
+ ? boundsOf(result.positions, Math.max(0, scalar(spec.padding, 1)))
713
+ : result;
714
+ return {
715
+ ...normalized,
716
+ positions: flattenLayoutGroups(normalized.positions)
717
+ };
718
+ }
719
+
268
720
  function boundsOf(placements, padding) {
269
721
  if (!placements.length) return { positions: [], size: [padding * 2, padding * 2, padding * 2] };
270
722
  const min = [Infinity, Infinity, Infinity];
@@ -283,29 +735,75 @@ function boundsOf(placements, padding) {
283
735
  return { positions, size: max.map((value, axis) => value - min[axis] + padding * 2) };
284
736
  }
285
737
 
286
- function rowLayout(items, spec, diagnostics) {
738
+ function boundsOfAssignedAxis(placements, padding, axis, assignedSize) {
739
+ if (!placements.length) return { positions: [], size: [padding * 2, padding * 2, padding * 2] };
740
+ const min = [Infinity, Infinity, Infinity];
741
+ const max = [-Infinity, -Infinity, -Infinity];
742
+ for (const item of placements) {
743
+ for (let itemAxis = 0; itemAxis < 3; itemAxis++) {
744
+ min[itemAxis] = Math.min(min[itemAxis], item.position[itemAxis] - item.size[itemAxis] / 2);
745
+ max[itemAxis] = Math.max(max[itemAxis], item.position[itemAxis] + item.size[itemAxis] / 2);
746
+ }
747
+ }
748
+ const required = max.map((value, itemAxis) => value - min[itemAxis] + padding * 2);
749
+ if (assignedSize + 1e-9 < required[axis]) return boundsOf(placements, padding);
750
+ const center = min.map((value, itemAxis) => (value + max[itemAxis]) / 2);
751
+ const positions = placements.map(item => ({
752
+ ...item,
753
+ position: item.position.map((value, itemAxis) => {
754
+ const centered = itemAxis === axis ? value : value - center[itemAxis];
755
+ return Object.is(centered, -0) ? 0 : centered;
756
+ })
757
+ }));
758
+ required[axis] = assignedSize;
759
+ return { positions, size: required };
760
+ }
761
+
762
+ function rowLayout(items, spec, diagnostics, availableSize = null) {
287
763
  const direction = ["x", "y", "z"].includes(spec.direction) ? spec.direction : "x";
288
764
  const axis = { x: 0, y: 1, z: 2 }[direction];
289
765
  const gap = Math.max(0, scalar(spec.gap, 1));
290
766
  const padding = Math.max(0, scalar(spec.padding, 1));
767
+ const justify = spec.justify || "center";
768
+ const validJustify = ["start", "center", "end", "space-between", "space-around", "space-evenly"];
769
+ if (!validJustify.includes(justify)) diagnostic(diagnostics, "warning", "UNKNOWN_JUSTIFY", `Unknown row justify '${spec.justify}'.`);
770
+
291
771
  const minimumWeight = Math.min(...items.map(item => positiveNumber(item.entity.weight, 1)));
292
772
  const lengths = items.map(item => item.size[axis] * Math.max(1, positiveNumber(item.entity.weight, 1) / minimumWeight));
293
- const total = lengths.reduce((sum, value) => sum + value, 0) + Math.max(0, items.length - 1) * gap;
773
+ const contentLength = lengths.reduce((sum, value) => sum + value, 0);
774
+ const minimumLength = contentLength + Math.max(0, items.length - 1) * gap;
775
+ const assignedSize = Array.isArray(availableSize) ? Math.max(0, scalar(availableSize[axis], 0)) : 0;
776
+ const availableLength = Math.max(0, assignedSize - padding * 2);
777
+ const allocatedLength = Math.max(minimumLength, availableLength || minimumLength);
778
+ const free = Math.max(0, allocatedLength - minimumLength);
779
+
780
+ let leading = justify === "end" ? free : justify === "center" ? free / 2 : 0;
781
+ let spacing = gap;
782
+ if (justify === "space-between") {
783
+ if (items.length > 1) spacing += free / (items.length - 1);
784
+ else leading = free / 2;
785
+ } else if (justify === "space-around" && items.length) {
786
+ spacing += free / items.length;
787
+ leading = free / (items.length * 2);
788
+ } else if (justify === "space-evenly") {
789
+ spacing += free / (items.length + 1);
790
+ leading = free / (items.length + 1);
791
+ }
792
+
294
793
  const orderSign = axis === 0 ? 1 : -1;
295
- let cursor = -total / 2;
794
+ let cursor = -allocatedLength / 2 + leading;
296
795
  const placements = items.map((item, index) => {
297
796
  const length = lengths[index];
298
797
  cursor += length / 2;
299
798
  const position = [0, 0, 0];
300
799
  position[axis] = cursor * orderSign;
301
- cursor += length / 2 + gap;
800
+ cursor += length / 2 + spacing;
302
801
  const slotSize = item.size.map((value, itemAxis) => itemAxis === axis ? length : value);
303
802
  return { ...item, size: sizeForSlot(item, slotSize), slotSize, position };
304
803
  });
305
- if (spec.justify === "end") for (const placement of placements) placement.position[axis] += total / 2;
306
- if (spec.justify === "start") for (const placement of placements) placement.position[axis] -= total / 2;
307
- if (spec.justify && !["start", "center", "end"].includes(spec.justify)) diagnostic(diagnostics, "warning", "UNKNOWN_JUSTIFY", `Unknown row justify '${spec.justify}'.`);
308
- return boundsOf(placements, padding);
804
+ return assignedSize > 0 && availableLength + 1e-9 >= minimumLength
805
+ ? boundsOfAssignedAxis(placements, padding, axis, assignedSize)
806
+ : boundsOf(placements, padding);
309
807
  }
310
808
 
311
809
  function gridDimensions(items, spec, dimension) {
@@ -351,6 +849,12 @@ function markOccupied(occupied, start, span, dimension) {
351
849
  }
352
850
  }
353
851
 
852
+ const GRID_PLANES = Object.freeze({
853
+ xy: [0, 1, 2],
854
+ xz: [0, 2, 1],
855
+ yz: [2, 1, 0]
856
+ });
857
+
354
858
  function packedGridLayout(items, spec, diagnostics, dimension = 2) {
355
859
  const gap = Math.max(0, scalar(spec.gap, 1));
356
860
  const padding = Math.max(0, scalar(spec.padding, 1));
@@ -379,17 +883,23 @@ function packedGridLayout(items, spec, diagnostics, dimension = 2) {
379
883
  markOccupied(occupied, start, span, 2);
380
884
  placements.push({ ...item, span, cell: start });
381
885
  }
382
- const cellX = Math.max(...placements.map(item => item.size[0] / item.span[0]), 1);
383
- const cellZ = Math.max(...placements.map(item => item.size[2] / item.span[1]), 1);
384
- const cellY = Math.max(...placements.map(item => item.size[1]), 1);
886
+ const [columnAxis, rowAxis, orthogonalAxis] = GRID_PLANES[spec.plane] || GRID_PLANES.xz;
887
+ const columnCell = Math.max(...placements.map(item => item.size[columnAxis] / item.span[0]), 1);
888
+ const rowCell = Math.max(...placements.map(item => item.size[rowAxis] / item.span[1]), 1);
889
+ const orthogonalCell = Math.max(...placements.map(item => item.size[orthogonalAxis]), 1);
385
890
  for (const item of placements) {
386
- const width = item.span[0] * cellX + (item.span[0] - 1) * gap;
387
- const depth = item.span[1] * cellZ + (item.span[1] - 1) * gap;
388
- const offset = spec.variant === "masonry" && item.cell[1] % 2 === 1 ? (spec.stagger?.offset === "half-cell" ? (cellX + gap) / 2 : scalar(spec.stagger?.offset, 0)) : 0;
389
- const slotSize = [width, cellY, depth];
891
+ const columnSize = item.span[0] * columnCell + (item.span[0] - 1) * gap;
892
+ const rowSize = item.span[1] * rowCell + (item.span[1] - 1) * gap;
893
+ const offset = spec.variant === "masonry" && item.cell[1] % 2 === 1 ? (spec.stagger?.offset === "half-cell" ? (columnCell + gap) / 2 : scalar(spec.stagger?.offset, 0)) : 0;
894
+ const slotSize = [0, 0, 0];
895
+ slotSize[columnAxis] = columnSize;
896
+ slotSize[rowAxis] = rowSize;
897
+ slotSize[orthogonalAxis] = orthogonalCell;
390
898
  item.size = sizeForSlot(item, slotSize);
391
899
  item.slotSize = slotSize;
392
- item.position = [item.cell[0] * (cellX + gap) + width / 2 + offset, 0, -(item.cell[1] * (cellZ + gap) + depth / 2)];
900
+ item.position = [0, 0, 0];
901
+ item.position[columnAxis] = item.cell[0] * (columnCell + gap) + columnSize / 2 + offset;
902
+ item.position[rowAxis] = -(item.cell[1] * (rowCell + gap) + rowSize / 2);
393
903
  }
394
904
  return boundsOf(placements, padding);
395
905
  }
@@ -459,6 +969,7 @@ export function compileAtlasModel(input, options = {}) {
459
969
  const source = normalizeRoot(input);
460
970
  const palette = paletteFrom(source);
461
971
  const flat = flattenEntities(source.entities, diagnostics);
972
+ normalizeEntityTypes(flat, options.geometryCatalog || [], diagnostics);
462
973
  if (!flat.entities.length) diagnostic(diagnostics, "fatal", "NO_VALID_ENTITIES", "The model does not contain any valid entities.");
463
974
  const validIds = new Set(flat.entities.map(entity => entity.id));
464
975
  const relations = normalizeRelations(source.relations, validIds, diagnostics);
@@ -489,17 +1000,19 @@ export function compileAtlasModel(input, options = {}) {
489
1000
  const algorithm = ALGORITHMS.has(spec.algorithm) ? spec.algorithm : "grid";
490
1001
  if (!ALGORITHMS.has(spec.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM", `Unknown layout algorithm '${spec.algorithm}', using grid.`, { entityId });
491
1002
  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);
1003
+ const declared = Array.isArray(entity.size) && entity.size.length === 3 ? asVector(entity.size) : null;
1004
+ const result = constrainedLayout(childItems, spec, sourceEntity, diagnostics, entityId,
1005
+ algorithm === "row"
1006
+ ? rowLayout
1007
+ : algorithm === "volume"
1008
+ ? volumeLayout
1009
+ : gridLayout,
1010
+ declared);
497
1011
  for (const placement of result.positions) {
498
1012
  const child = byId.get(placement.entity.id);
499
1013
  child.p = placement.position;
500
1014
  child.s = placement.size;
501
1015
  }
502
- const declared = Array.isArray(entity.size) && entity.size.length === 3 ? asVector(entity.size) : null;
503
1016
  entity.s = declared ? [Math.max(declared[0], result.size[0]), Math.max(declared[1], result.size[1]), Math.max(declared[2], result.size[2])] : result.size;
504
1017
  if (declared && sourceEntity.sizeMode === "fixed" && result.size.some((value, axis) => value > declared[axis])) {
505
1018
  diagnostic(diagnostics, "error", "FIXED_LAYOUT_OVERFLOW", `Content exceeds fixed container '${entity.id}'.`, { entityId: entity.id, declaredSize: declared, requiredSize: result.size });
@@ -513,11 +1026,12 @@ export function compileAtlasModel(input, options = {}) {
513
1026
  const rootSpec = source.layout || { algorithm: "row", direction: "y", gap: 1.5, padding: 1 };
514
1027
  const rootAlgorithm = ALGORITHMS.has(rootSpec.algorithm) ? rootSpec.algorithm : "row";
515
1028
  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);
1029
+ const rootResult = constrainedLayout(rootItems, rootSpec, source, diagnostics, null,
1030
+ rootAlgorithm === "row"
1031
+ ? rowLayout
1032
+ : rootAlgorithm === "volume"
1033
+ ? volumeLayout
1034
+ : gridLayout);
521
1035
  for (const placement of rootResult.positions) {
522
1036
  const entity = byId.get(placement.entity.id);
523
1037
  entity.p = placement.position;
@@ -535,6 +1049,7 @@ export function compileAtlasModel(input, options = {}) {
535
1049
  }),
536
1050
  relations,
537
1051
  layout: source.layout || undefined,
1052
+ constraints: source.constraints || undefined,
538
1053
  diagnostics
539
1054
  };
540
1055
  if (options.throwOnFatal && diagnostics.some(item => item.level === "fatal")) throw new Error(diagnostics.filter(item => item.level === "fatal").map(item => item.message).join("\n"));