@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/README.md +26 -1
- package/package.json +21 -4
- package/spec/ATLAS-LAYOUT-SPEC-v1.md +165 -0
- package/src/index.js +545 -30
- package/src/spec.js +228 -0
- package/test/index.test.mjs +0 -326
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(
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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 = -
|
|
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 +
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
383
|
-
const
|
|
384
|
-
const
|
|
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
|
|
387
|
-
const
|
|
388
|
-
const offset = spec.variant === "masonry" && item.cell[1] % 2 === 1 ? (spec.stagger?.offset === "half-cell" ? (
|
|
389
|
-
const slotSize = [
|
|
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 = [
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
?
|
|
496
|
-
:
|
|
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 =
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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"));
|