@godot-scene-web/layout 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,1162 @@
1
+ import { asBoolean, asNumber, asResourceRef, asString, asVector2 } from "@godot-scene-web/core";
2
+ import { deriveNodeVisuals } from "@godot-scene-web/scene-graph";
3
+ //#region src/anchor-grammar.ts
4
+ const verticals = [
5
+ ["contentbottom", "contentBottom"],
6
+ ["contenttop", "contentTop"],
7
+ ["vcenter", "vcenter"],
8
+ ["bottom", "bottom"],
9
+ ["top", "top"]
10
+ ];
11
+ const horizontals = {
12
+ contentright: "contentRight",
13
+ contentleft: "contentLeft",
14
+ hcenter: "hcenter",
15
+ right: "right",
16
+ left: "left"
17
+ };
18
+ function parseAnchorEdge(token) {
19
+ const normalized = token.toLowerCase();
20
+ if (normalized === "center") return {
21
+ vertical: "vcenter",
22
+ horizontal: "hcenter"
23
+ };
24
+ for (const [key, vertical] of verticals) {
25
+ if (!normalized.startsWith(key)) continue;
26
+ const horizontal = horizontals[normalized.slice(key.length)];
27
+ if (horizontal) return {
28
+ vertical,
29
+ horizontal
30
+ };
31
+ }
32
+ }
33
+ function anchorEdgePoint(token, own, content) {
34
+ const edge = parseAnchorEdge(token);
35
+ if (!edge) return void 0;
36
+ return {
37
+ x: edge.horizontal === "contentRight" ? content.x + content.width : edge.horizontal === "contentLeft" ? content.x : edge.horizontal === "hcenter" ? own.x + own.width / 2 : edge.horizontal === "right" ? own.x + own.width : own.x,
38
+ y: edge.vertical === "contentBottom" ? content.y + content.height : edge.vertical === "contentTop" ? content.y : edge.vertical === "vcenter" ? own.y + own.height / 2 : edge.vertical === "bottom" ? own.y + own.height : own.y
39
+ };
40
+ }
41
+ //#endregion
42
+ //#region src/anchors.ts
43
+ const edgePoint = anchorEdgePoint;
44
+ /** Union of a node's visible direct children's rendered rects (its content box). */
45
+ function contentExtent(node, layoutByPath) {
46
+ let minX = Infinity;
47
+ let minY = Infinity;
48
+ let maxX = -Infinity;
49
+ let maxY = -Infinity;
50
+ let found = false;
51
+ for (const childPath of node.children) {
52
+ const child = layoutByPath.get(childPath);
53
+ if (!child || child.visible === false) continue;
54
+ const rect = child.renderedRect;
55
+ minX = Math.min(minX, rect.x);
56
+ minY = Math.min(minY, rect.y);
57
+ maxX = Math.max(maxX, rect.x + rect.width);
58
+ maxY = Math.max(maxY, rect.y + rect.height);
59
+ found = true;
60
+ }
61
+ if (!found) {
62
+ const { x, y } = node.renderedRect;
63
+ return {
64
+ x,
65
+ y,
66
+ width: 0,
67
+ height: 0
68
+ };
69
+ }
70
+ return {
71
+ x: minX,
72
+ y: minY,
73
+ width: maxX - minX,
74
+ height: maxY - minY
75
+ };
76
+ }
77
+ /** Translate a node and its whole subtree by `(dx, dy)` in global space. */
78
+ function translateSubtree(root, dx, dy, layoutByPath) {
79
+ const stack = [root.path];
80
+ while (stack.length > 0) {
81
+ const node = layoutByPath.get(stack.pop());
82
+ if (!node) continue;
83
+ node.rect = {
84
+ x: node.rect.x + dx,
85
+ y: node.rect.y + dy,
86
+ width: node.rect.width,
87
+ height: node.rect.height
88
+ };
89
+ node.renderedRect = {
90
+ x: node.renderedRect.x + dx,
91
+ y: node.renderedRect.y + dy,
92
+ width: node.renderedRect.width,
93
+ height: node.renderedRect.height
94
+ };
95
+ if (node.cumulativeTransform) node.cumulativeTransform = {
96
+ ...node.cumulativeTransform,
97
+ tx: node.cumulativeTransform.tx + dx,
98
+ ty: node.cumulativeTransform.ty + dy
99
+ };
100
+ for (const childPath of node.children) stack.push(childPath);
101
+ }
102
+ }
103
+ /**
104
+ * Resolve every declared anchor, translating each anchored node (and its subtree)
105
+ * so its `to` edge lands on its target's `from` edge. Targets that are themselves
106
+ * anchored are resolved first; cycles are broken with a diagnostic.
107
+ */
108
+ function resolveAnchors(layoutByPath, anchors, diagnostics) {
109
+ if (!anchors) return;
110
+ const resolved = /* @__PURE__ */ new Set();
111
+ const inProgress = /* @__PURE__ */ new Set();
112
+ const resolveOne = (path) => {
113
+ if (resolved.has(path)) return;
114
+ const anchor = anchors[path];
115
+ const self = layoutByPath.get(path);
116
+ if (!anchor || !self) {
117
+ resolved.add(path);
118
+ return;
119
+ }
120
+ if (inProgress.has(path)) {
121
+ diagnostics.push({
122
+ severity: "warning",
123
+ code: "anchor-cycle",
124
+ message: `Anchor cycle detected resolving ${path}; leaving it unmoved.`,
125
+ nodePath: path
126
+ });
127
+ return;
128
+ }
129
+ inProgress.add(path);
130
+ if (anchors[anchor.anchorTo]) resolveOne(anchor.anchorTo);
131
+ const target = layoutByPath.get(anchor.anchorTo);
132
+ if (!target) diagnostics.push({
133
+ severity: "warning",
134
+ code: "anchor-target-missing",
135
+ message: `Anchor target '${anchor.anchorTo}' not found for ${path}.`,
136
+ nodePath: path
137
+ });
138
+ else {
139
+ const targetPoint = edgePoint(anchor.from, target.renderedRect, contentExtent(target, layoutByPath));
140
+ const selfPoint = edgePoint(anchor.to, self.renderedRect, contentExtent(self, layoutByPath));
141
+ if (!targetPoint || !selfPoint) diagnostics.push({
142
+ severity: "warning",
143
+ code: "anchor-edge-unparsed",
144
+ message: `Unrecognized anchor edge (from='${anchor.from}', to='${anchor.to}') for ${path}.`,
145
+ nodePath: path
146
+ });
147
+ else {
148
+ const dx = targetPoint.x - selfPoint.x + (anchor.offset?.x ?? 0);
149
+ const dy = targetPoint.y - selfPoint.y + (anchor.offset?.y ?? 0);
150
+ if (dx !== 0 || dy !== 0) translateSubtree(self, dx, dy, layoutByPath);
151
+ }
152
+ }
153
+ inProgress.delete(path);
154
+ resolved.add(path);
155
+ };
156
+ for (const path of Object.keys(anchors)) resolveOne(path);
157
+ }
158
+ //#endregion
159
+ //#region src/flow-wrap.ts
160
+ /**
161
+ * Wraps flow-container entries into lines, mirroring Godot's
162
+ * `FlowContainer::_resort()` wrapping. The cross axis advances by the line's
163
+ * largest cross extent plus the relevant separation.
164
+ */
165
+ function flowWrapLines(rect, entries, vertical, hSeparation, vSeparation) {
166
+ const lines = [];
167
+ let currentLine = [];
168
+ let cursorX = rect.x;
169
+ let cursorY = rect.y;
170
+ let lineCrossSize = 0;
171
+ for (const entry of entries) {
172
+ const size = entry.size;
173
+ if (vertical) {
174
+ if (cursorY > rect.y && cursorY + size.height > rect.y + rect.height) {
175
+ lines.push(currentLine);
176
+ currentLine = [];
177
+ cursorX += lineCrossSize + hSeparation;
178
+ cursorY = rect.y;
179
+ lineCrossSize = 0;
180
+ }
181
+ currentLine.push({
182
+ ...entry,
183
+ x: cursorX,
184
+ y: cursorY
185
+ });
186
+ cursorY += size.height + vSeparation;
187
+ lineCrossSize = Math.max(lineCrossSize, size.width);
188
+ } else {
189
+ if (cursorX > rect.x && cursorX + size.width > rect.x + rect.width) {
190
+ lines.push(currentLine);
191
+ currentLine = [];
192
+ cursorX = rect.x;
193
+ cursorY += lineCrossSize + vSeparation;
194
+ lineCrossSize = 0;
195
+ }
196
+ currentLine.push({
197
+ ...entry,
198
+ x: cursorX,
199
+ y: cursorY
200
+ });
201
+ cursorX += size.width + hSeparation;
202
+ lineCrossSize = Math.max(lineCrossSize, size.height);
203
+ }
204
+ }
205
+ if (currentLine.length > 0) lines.push(currentLine);
206
+ return lines;
207
+ }
208
+ /** Total cross-axis extent of wrapped lines: Σ line cross size + separations. */
209
+ function flowCrossExtent(lines, vertical, hSeparation, vSeparation) {
210
+ const crossSeparation = vertical ? hSeparation : vSeparation;
211
+ return lines.map((line) => line.reduce((max, entry) => Math.max(max, vertical ? entry.size.width : entry.size.height), 0)).reduce((sum, size) => sum + size, 0) + Math.max(0, lines.length - 1) * crossSeparation;
212
+ }
213
+ //#endregion
214
+ //#region src/container-types.ts
215
+ function isBoxContainerType(type) {
216
+ return type === "BoxContainer" || type === "HBoxContainer" || type === "VBoxContainer";
217
+ }
218
+ function boxContainerHorizontal(indexed) {
219
+ if (indexed.node.type === "HBoxContainer") return true;
220
+ if (indexed.node.type === "VBoxContainer") return false;
221
+ return !(asBoolean(indexed.props.vertical) ?? false);
222
+ }
223
+ function isFlowContainerType(type) {
224
+ return type === "FlowContainer" || type === "HFlowContainer" || type === "VFlowContainer";
225
+ }
226
+ function flowContainerVertical(indexed) {
227
+ if (indexed.node.type === "VFlowContainer") return true;
228
+ if (indexed.node.type === "HFlowContainer") return false;
229
+ return asBoolean(indexed.props.vertical) ?? false;
230
+ }
231
+ //#endregion
232
+ //#region src/style-box.ts
233
+ function contentRectForStyleBox(rect, metrics) {
234
+ return normalizeRect({
235
+ x: rect.x + metrics.left,
236
+ y: rect.y + metrics.top,
237
+ width: rect.width - metrics.left - metrics.right,
238
+ height: rect.height - metrics.top - metrics.bottom
239
+ });
240
+ }
241
+ function panelStyleBoxMetrics(indexed, options) {
242
+ return styleBoxMetrics(resolveStyleBox(indexed.props["theme_override_styles/panel"], indexed, options) ?? resolveStyleBox(options.resolveTheme?.(indexed.node, "panel"), indexed, options));
243
+ }
244
+ function resolveStyleBox(value, indexed, options) {
245
+ const ref = asResourceRef(value);
246
+ return ref ? options.resolveResource?.(ref, indexed.node) : value;
247
+ }
248
+ function styleBoxMetrics(resource) {
249
+ const { type, properties } = resourceDocument(resource);
250
+ if (!type || type === "StyleBoxEmpty") return {
251
+ left: 0,
252
+ top: 0,
253
+ right: 0,
254
+ bottom: 0
255
+ };
256
+ const props = properties ?? {};
257
+ if (type === "StyleBoxFlat") {
258
+ const borders = {
259
+ left: numeric(props, "border_width_left") ?? numeric(props, "border_width_all") ?? 0,
260
+ top: numeric(props, "border_width_top") ?? numeric(props, "border_width_all") ?? 0,
261
+ right: numeric(props, "border_width_right") ?? numeric(props, "border_width_all") ?? 0,
262
+ bottom: numeric(props, "border_width_bottom") ?? numeric(props, "border_width_all") ?? 0
263
+ };
264
+ return {
265
+ left: styleBoxMargin(props, "left", borders.left),
266
+ top: styleBoxMargin(props, "top", borders.top),
267
+ right: styleBoxMargin(props, "right", borders.right),
268
+ bottom: styleBoxMargin(props, "bottom", borders.bottom)
269
+ };
270
+ }
271
+ return {
272
+ left: Math.max(0, numeric(props, "content_margin_left") ?? numeric(props, "content_margin_all") ?? 0),
273
+ top: Math.max(0, numeric(props, "content_margin_top") ?? numeric(props, "content_margin_all") ?? 0),
274
+ right: Math.max(0, numeric(props, "content_margin_right") ?? numeric(props, "content_margin_all") ?? 0),
275
+ bottom: Math.max(0, numeric(props, "content_margin_bottom") ?? numeric(props, "content_margin_all") ?? 0)
276
+ };
277
+ }
278
+ function styleBoxMargin(props, side, fallback) {
279
+ const value = numeric(props, `content_margin_${side}`) ?? numeric(props, "content_margin_all");
280
+ return value !== void 0 && value >= 0 ? value : fallback;
281
+ }
282
+ function resourceDocument(resource) {
283
+ if (!resource || typeof resource !== "object") return {};
284
+ const record = resource;
285
+ const directDocument = record.document;
286
+ const document = directDocument && typeof directDocument === "object" && !Array.isArray(directDocument) ? directDocument : void 0;
287
+ const properties = document?.properties ?? (record.properties && typeof record.properties === "object" && !Array.isArray(record.properties) ? record.properties : void 0);
288
+ return {
289
+ type: asString(document?.header?.attributes.type) ?? (typeof record.type === "string" ? record.type : void 0),
290
+ properties
291
+ };
292
+ }
293
+ //#endregion
294
+ //#region src/theme.ts
295
+ /**
296
+ * Godot's built-in default theme constants for container nodes, mirroring
297
+ * `scene/theme/default_theme.cpp` (4.5.1, `set_constant(..., Math::round(4 *
298
+ * scale))` at scale 1). A node that does not carry an explicit
299
+ * `theme_override_constants/*` (and whose project theme provides nothing) still
300
+ * inherits these from the default theme, so the layout engine must fall back to
301
+ * them rather than to 0 — e.g. an HBoxContainer with no separation override lays
302
+ * its children out 4px apart, not flush. MarginContainer margins default to 0 in
303
+ * the default theme, so they intentionally have no entry here.
304
+ */
305
+ const DEFAULT_THEME_CONSTANTS = {
306
+ BoxContainer: { separation: 4 },
307
+ HBoxContainer: { separation: 4 },
308
+ VBoxContainer: { separation: 4 },
309
+ GridContainer: {
310
+ h_separation: 4,
311
+ v_separation: 4
312
+ },
313
+ FlowContainer: {
314
+ h_separation: 4,
315
+ v_separation: 4
316
+ },
317
+ HFlowContainer: {
318
+ h_separation: 4,
319
+ v_separation: 4
320
+ },
321
+ VFlowContainer: {
322
+ h_separation: 4,
323
+ v_separation: 4
324
+ }
325
+ };
326
+ function defaultThemeConstant(indexed, name) {
327
+ const type = indexed.node.type;
328
+ if (!type) return;
329
+ return DEFAULT_THEME_CONSTANTS[type]?.[name];
330
+ }
331
+ function themeNumber(indexed, name, options) {
332
+ return numeric(indexed.props, `theme_override_constants/${name}`) ?? numeric(indexed.props, `theme_constant_${name}`) ?? asNumber(options.resolveTheme?.(indexed.node, name)) ?? defaultThemeConstant(indexed, name);
333
+ }
334
+ //#endregion
335
+ //#region src/minimum-size.ts
336
+ function preferredSize(indexed, byPath, options) {
337
+ return combinedMinimum(indexed, byPath, options) ?? explicitSizeFromProps(indexed) ?? sizeFromOffsets(indexed) ?? {
338
+ width: 0,
339
+ height: 0
340
+ };
341
+ }
342
+ function explicitSizeFromProps(indexed) {
343
+ const size = asVector2(indexed.props.size);
344
+ if (size) return {
345
+ width: size.x,
346
+ height: size.y
347
+ };
348
+ const width = numeric(indexed.props, "size_width");
349
+ const height = numeric(indexed.props, "size_height");
350
+ return width !== void 0 && height !== void 0 ? {
351
+ width,
352
+ height
353
+ } : void 0;
354
+ }
355
+ function sizeFromOffsets(indexed) {
356
+ const left = numeric(indexed.props, "offset_left") ?? 0;
357
+ const top = numeric(indexed.props, "offset_top") ?? 0;
358
+ const right = numeric(indexed.props, "offset_right");
359
+ const bottom = numeric(indexed.props, "offset_bottom");
360
+ if (right === void 0 || bottom === void 0) return;
361
+ return {
362
+ width: Math.max(0, right - left),
363
+ height: Math.max(0, bottom - top)
364
+ };
365
+ }
366
+ function customMinimum(indexed) {
367
+ const vector = asVector2(indexed.props.custom_minimum_size);
368
+ if (vector) return {
369
+ width: vector.x,
370
+ height: vector.y
371
+ };
372
+ const width = numeric(indexed.props, "custom_minimum_width") ?? numeric(indexed.props, "minimum_width");
373
+ const height = numeric(indexed.props, "custom_minimum_height") ?? numeric(indexed.props, "minimum_height");
374
+ return width !== void 0 && height !== void 0 ? {
375
+ width,
376
+ height
377
+ } : void 0;
378
+ }
379
+ function declaredCombinedMinimum(indexed) {
380
+ const vector = asVector2(indexed.props.combined_minimum_size);
381
+ if (vector) return {
382
+ width: vector.x,
383
+ height: vector.y
384
+ };
385
+ const width = numeric(indexed.props, "combined_minimum_width");
386
+ const height = numeric(indexed.props, "combined_minimum_height");
387
+ return width !== void 0 && height !== void 0 ? {
388
+ width,
389
+ height
390
+ } : void 0;
391
+ }
392
+ function combinedMinimum(indexed, byPath, options, currentRect) {
393
+ return maxSize(declaredCombinedMinimum(indexed), maxSize(customMinimum(indexed), maxSize(internalMinimum(indexed, options, currentRect), containerMinimumSize(indexed, byPath, options))));
394
+ }
395
+ function maxSize(a, b) {
396
+ if (!a) return b;
397
+ if (!b) return a;
398
+ return {
399
+ width: Math.max(a.width, b.width),
400
+ height: Math.max(a.height, b.height)
401
+ };
402
+ }
403
+ /**
404
+ * Container analogue of Godot `Container::get_minimum_size()`, memoized on the
405
+ * IndexedNode. Non-container controls return `undefined` (a plain Control/Node
406
+ * does not size to its children). Recurses through `byPath` children, which
407
+ * already include flattened instanced PackedScene content.
408
+ */
409
+ function containerMinimumSize(indexed, byPath, options) {
410
+ if (indexed.minimumSize !== void 0) return indexed.minimumSize ?? void 0;
411
+ const size = computeContainerMinimum(indexed, byPath, options);
412
+ indexed.minimumSize = size ?? null;
413
+ return size;
414
+ }
415
+ function computeContainerMinimum(indexed, byPath, options) {
416
+ switch (indexed.node.type ?? "Node") {
417
+ case "BoxContainer":
418
+ case "HBoxContainer":
419
+ case "VBoxContainer": return boxMinimum(indexed, byPath, options);
420
+ case "GridContainer": return gridMinimum(indexed, byPath, options);
421
+ case "FlowContainer":
422
+ case "HFlowContainer":
423
+ case "VFlowContainer": return flowMinimum(indexed, byPath, options);
424
+ case "MarginContainer": return marginMinimum(indexed, byPath, options);
425
+ case "CenterContainer": return maxChildMinimum(indexed, byPath, options);
426
+ case "AspectRatioContainer": return maxChildMinimum(indexed, byPath, options);
427
+ case "PanelContainer": return panelMinimum(indexed, byPath, options);
428
+ case "ScrollContainer": return scrollMinimum(indexed, byPath, options);
429
+ default: return;
430
+ }
431
+ }
432
+ function visibleChildren(indexed, byPath) {
433
+ return indexed.children.map((path) => byPath.get(path)).filter((child) => Boolean(child) && (asBoolean(child.props.visible) ?? true));
434
+ }
435
+ function childSizes(indexed, byPath, options) {
436
+ return visibleChildren(indexed, byPath).map((child) => combinedMinimum(child, byPath, options) ?? {
437
+ width: 0,
438
+ height: 0
439
+ });
440
+ }
441
+ function boxMinimum(indexed, byPath, options) {
442
+ const horizontal = boxContainerHorizontal(indexed);
443
+ const separation = themeNumber(indexed, "separation", options) ?? 0;
444
+ const sizes = childSizes(indexed, byPath, options);
445
+ let main = 0;
446
+ let cross = 0;
447
+ sizes.forEach((size, index) => {
448
+ const childMain = horizontal ? size.width : size.height;
449
+ const childCross = horizontal ? size.height : size.width;
450
+ main += childMain + (index === 0 ? 0 : separation);
451
+ cross = Math.max(cross, childCross);
452
+ });
453
+ return horizontal ? {
454
+ width: main,
455
+ height: cross
456
+ } : {
457
+ width: cross,
458
+ height: main
459
+ };
460
+ }
461
+ function gridMinimum(indexed, byPath, options) {
462
+ const columns = Math.max(1, Math.floor(numeric(indexed.props, "columns") ?? 1));
463
+ const hSeparation = themeNumber(indexed, "h_separation", options) ?? themeNumber(indexed, "separation", options) ?? 0;
464
+ const vSeparation = themeNumber(indexed, "v_separation", options) ?? themeNumber(indexed, "separation", options) ?? 0;
465
+ const sizes = childSizes(indexed, byPath, options);
466
+ const columnWidths = /* @__PURE__ */ new Map();
467
+ const rowHeights = /* @__PURE__ */ new Map();
468
+ let maxColumn = 0;
469
+ let maxRow = 0;
470
+ sizes.forEach((size, index) => {
471
+ const column = index % columns;
472
+ const row = Math.floor(index / columns);
473
+ columnWidths.set(column, Math.max(columnWidths.get(column) ?? 0, size.width));
474
+ rowHeights.set(row, Math.max(rowHeights.get(row) ?? 0, size.height));
475
+ maxColumn = Math.max(maxColumn, column);
476
+ maxRow = Math.max(maxRow, row);
477
+ });
478
+ return {
479
+ width: [...columnWidths.values()].reduce((sum, value) => sum + value, 0) + hSeparation * maxColumn,
480
+ height: [...rowHeights.values()].reduce((sum, value) => sum + value, 0) + vSeparation * maxRow
481
+ };
482
+ }
483
+ function flowMinimum(indexed, byPath, options) {
484
+ const vertical = flowContainerVertical(indexed);
485
+ const hSeparation = themeNumber(indexed, "h_separation", options) ?? themeNumber(indexed, "separation", options) ?? 0;
486
+ const vSeparation = themeNumber(indexed, "v_separation", options) ?? themeNumber(indexed, "separation", options) ?? 0;
487
+ const sizes = childSizes(indexed, byPath, options);
488
+ const maxWidth = sizes.reduce((max, size) => Math.max(max, size.width), 0);
489
+ const maxHeight = sizes.reduce((max, size) => Math.max(max, size.height), 0);
490
+ const offsetSize = explicitSizeFromProps(indexed) ?? sizeFromOffsets(indexed);
491
+ const offsetMain = vertical ? offsetSize?.height : offsetSize?.width;
492
+ const mainExtent = indexed.flowMainExtent ?? (offsetMain !== void 0 && offsetMain > 0 ? offsetMain : void 0);
493
+ if (mainExtent !== void 0 && mainExtent > 0) {
494
+ const crossExtent = flowCrossExtent(flowWrapLines(vertical ? {
495
+ x: 0,
496
+ y: 0,
497
+ width: maxWidth,
498
+ height: mainExtent
499
+ } : {
500
+ x: 0,
501
+ y: 0,
502
+ width: mainExtent,
503
+ height: maxHeight
504
+ }, sizes.map((size) => ({
505
+ item: size,
506
+ size
507
+ })), vertical, hSeparation, vSeparation), vertical, hSeparation, vSeparation);
508
+ return vertical ? {
509
+ width: crossExtent,
510
+ height: maxHeight
511
+ } : {
512
+ width: maxWidth,
513
+ height: crossExtent
514
+ };
515
+ }
516
+ return {
517
+ width: maxWidth,
518
+ height: maxHeight
519
+ };
520
+ }
521
+ function marginMinimum(indexed, byPath, options) {
522
+ const left = themeNumber(indexed, "margin_left", options) ?? 0;
523
+ const top = themeNumber(indexed, "margin_top", options) ?? 0;
524
+ const right = themeNumber(indexed, "margin_right", options) ?? 0;
525
+ const bottom = themeNumber(indexed, "margin_bottom", options) ?? 0;
526
+ const max = maxChildMinimum(indexed, byPath, options);
527
+ return {
528
+ width: max.width + left + right,
529
+ height: max.height + top + bottom
530
+ };
531
+ }
532
+ function panelMinimum(indexed, byPath, options) {
533
+ const metrics = panelStyleBoxMetrics(indexed, options);
534
+ const max = maxChildMinimum(indexed, byPath, options);
535
+ return {
536
+ width: max.width + metrics.left + metrics.right,
537
+ height: max.height + metrics.top + metrics.bottom
538
+ };
539
+ }
540
+ function scrollMinimum(indexed, byPath, options) {
541
+ const metrics = panelStyleBoxMetrics(indexed, options);
542
+ const largest = maxChildMinimum(indexed, byPath, options);
543
+ const horizontalDisabled = (numeric(indexed.props, "horizontal_scroll_mode") ?? 1) === 0;
544
+ const verticalDisabled = (numeric(indexed.props, "vertical_scroll_mode") ?? 1) === 0;
545
+ return {
546
+ width: metrics.left + metrics.right + (horizontalDisabled ? largest.width : 0),
547
+ height: metrics.top + metrics.bottom + (verticalDisabled ? largest.height : 0)
548
+ };
549
+ }
550
+ function maxChildMinimum(indexed, byPath, options) {
551
+ return childSizes(indexed, byPath, options).reduce((max, size) => ({
552
+ width: Math.max(max.width, size.width),
553
+ height: Math.max(max.height, size.height)
554
+ }), {
555
+ width: 0,
556
+ height: 0
557
+ });
558
+ }
559
+ const TEXT_CONTENT_TYPES = new Set(["Label", "RichTextLabel"]);
560
+ function internalMinimum(indexed, options, currentRect) {
561
+ if (TEXT_CONTENT_TYPES.has(indexed.node.type ?? "")) {
562
+ const size = options.resolveTextContentSize?.(indexed.node, indexed.path, indexed.props, currentRect?.width);
563
+ if (!size || size.width < 0 || size.height < 0) return;
564
+ return size;
565
+ }
566
+ if (indexed.node.type !== "TextureRect") return;
567
+ const textureRef = asResourceRef(indexed.props.texture);
568
+ if (!textureRef) return;
569
+ const textureSize = resourceSize(options.resolveResource?.(textureRef, indexed.node));
570
+ if (!textureSize || textureSize.width <= 0 || textureSize.height <= 0) return;
571
+ const expandMode = numeric(indexed.props, "expand_mode") ?? 0;
572
+ if (expandMode === 0) return textureSize;
573
+ if (expandMode === 2) return {
574
+ width: currentRect?.height ?? 0,
575
+ height: 0
576
+ };
577
+ if (expandMode === 3) return {
578
+ width: (currentRect?.height ?? 0) * textureSize.width / textureSize.height,
579
+ height: 0
580
+ };
581
+ if (expandMode === 4) return {
582
+ width: 0,
583
+ height: currentRect?.width ?? 0
584
+ };
585
+ if (expandMode === 5) return {
586
+ width: 0,
587
+ height: (currentRect?.width ?? 0) * textureSize.height / textureSize.width
588
+ };
589
+ }
590
+ function resourceSize(resource) {
591
+ if (!resource || typeof resource !== "object") return;
592
+ const record = resource;
593
+ const size = record.size;
594
+ if (size && typeof size === "object" && !Array.isArray(size)) {
595
+ const sizeRecord = size;
596
+ const width = typeof sizeRecord.width === "number" ? sizeRecord.width : void 0;
597
+ const height = typeof sizeRecord.height === "number" ? sizeRecord.height : void 0;
598
+ return width !== void 0 && height !== void 0 ? {
599
+ width,
600
+ height
601
+ } : void 0;
602
+ }
603
+ const width = typeof record.width === "number" ? record.width : void 0;
604
+ const height = typeof record.height === "number" ? record.height : void 0;
605
+ return width !== void 0 && height !== void 0 ? {
606
+ width,
607
+ height
608
+ } : void 0;
609
+ }
610
+ function hasExpandFlag(indexed, horizontal) {
611
+ return ((numeric(indexed.props, horizontal ? "size_flags_horizontal" : "size_flags_vertical") ?? 0) & 2) === 2;
612
+ }
613
+ function hasFillFlag(indexed, horizontal) {
614
+ return ((numeric(indexed.props, horizontal ? "size_flags_horizontal" : "size_flags_vertical") ?? 0) & 1) === 1;
615
+ }
616
+ function alignmentOffset(indexed, available, content) {
617
+ const alignment = numeric(indexed.props, "alignment") ?? 0;
618
+ if (alignment === 1) return Math.max(0, (available - content) / 2);
619
+ if (alignment === 2) return Math.max(0, available - content);
620
+ return 0;
621
+ }
622
+ //#endregion
623
+ //#region src/rects.ts
624
+ function rootRect(indexed, viewport, byPath, options) {
625
+ const minimum = combinedMinimum(indexed, byPath, options);
626
+ const width = numeric(indexed.props, "size_width") ?? sizeFromOffsets(indexed)?.width ?? minimum?.width ?? viewport.width;
627
+ const height = numeric(indexed.props, "size_height") ?? sizeFromOffsets(indexed)?.height ?? minimum?.height ?? viewport.height;
628
+ return {
629
+ x: viewport.x,
630
+ y: viewport.y,
631
+ width,
632
+ height
633
+ };
634
+ }
635
+ function controlRect(indexed, parent, byPath, options) {
636
+ const leftAnchor = numeric(indexed.props, "anchor_left") ?? 0;
637
+ const topAnchor = numeric(indexed.props, "anchor_top") ?? 0;
638
+ const rightAnchor = numeric(indexed.props, "anchor_right") ?? leftAnchor;
639
+ const bottomAnchor = numeric(indexed.props, "anchor_bottom") ?? topAnchor;
640
+ const position = asVector2(indexed.props.position);
641
+ const leftOffset = numeric(indexed.props, "offset_left") ?? numeric(indexed.props, "position_x") ?? position?.x ?? 0;
642
+ const topOffset = numeric(indexed.props, "offset_top") ?? numeric(indexed.props, "position_y") ?? position?.y ?? 0;
643
+ const explicitSize = explicitSizeFromProps(indexed);
644
+ const rightOffset = numeric(indexed.props, "offset_right") ?? (explicitSize ? leftOffset + explicitSize.width : 0);
645
+ const bottomOffset = numeric(indexed.props, "offset_bottom") ?? (explicitSize ? topOffset + explicitSize.height : 0);
646
+ const left = parent.x + parent.width * leftAnchor + leftOffset;
647
+ const top = parent.y + parent.height * topAnchor + topOffset;
648
+ const right = parent.x + parent.width * rightAnchor + rightOffset;
649
+ const bottom = parent.y + parent.height * bottomAnchor + bottomOffset;
650
+ return growToMinimum(normalizeRect({
651
+ x: left,
652
+ y: top,
653
+ width: right - left,
654
+ height: bottom - top
655
+ }), indexed, byPath, options);
656
+ }
657
+ /**
658
+ * Expands and repositions a rect when its combined minimum size exceeds the
659
+ * offset-derived size, mirroring `Control::_size_changed`. The grow direction
660
+ * (BEGIN shifts the start edge, BOTH centers, END only expands) applies
661
+ * unconditionally, independent of anchors.
662
+ */
663
+ function growToMinimum(rect, indexed, byPath, options) {
664
+ const minimum = combinedMinimum(indexed, byPath, options, rect);
665
+ if (!minimum) return rect;
666
+ let { x, y, width, height } = rect;
667
+ if (minimum.width > width) {
668
+ const delta = minimum.width - width;
669
+ const grow = numeric(indexed.props, "grow_horizontal") ?? 1;
670
+ if (grow === 0) x -= delta;
671
+ else if (grow === 2) x -= delta / 2;
672
+ width = minimum.width;
673
+ }
674
+ if (minimum.height > height) {
675
+ const delta = minimum.height - height;
676
+ const grow = numeric(indexed.props, "grow_vertical") ?? 1;
677
+ if (grow === 0) y -= delta;
678
+ else if (grow === 2) y -= delta / 2;
679
+ height = minimum.height;
680
+ }
681
+ return {
682
+ x,
683
+ y,
684
+ width,
685
+ height
686
+ };
687
+ }
688
+ function numeric(props, name) {
689
+ return asNumber(props[name]);
690
+ }
691
+ function normalizeRect(rect) {
692
+ return {
693
+ x: rect.x,
694
+ y: rect.y,
695
+ width: Math.max(0, rect.width),
696
+ height: Math.max(0, rect.height)
697
+ };
698
+ }
699
+ /**
700
+ * Apply a node's own `scale` around `pivotOffset` to its layout rect, producing
701
+ * the on-screen global rect. Mirrors the renderer's `transform: scale(...)` with
702
+ * `transform-origin: <pivotOffset>` (see node-style.ts): the pivot is a fixed
703
+ * point, so the top-left corner maps to `rect.pos + pivot·(1 − scale)` and the
704
+ * size scales. Godot's `Control.get_global_rect()` includes scale the same way,
705
+ * which is what the live-game layout-diff compares against. Identity scale
706
+ * returns the rect unchanged.
707
+ */
708
+ function scaledRect(rect, scale, pivotOffset) {
709
+ if (scale.x === 1 && scale.y === 1) return rect;
710
+ return {
711
+ x: rect.x + pivotOffset.x * (1 - scale.x),
712
+ y: rect.y + pivotOffset.y * (1 - scale.y),
713
+ width: rect.width * scale.x,
714
+ height: rect.height * scale.y
715
+ };
716
+ }
717
+ const IDENTITY_AFFINE = {
718
+ a: 1,
719
+ b: 0,
720
+ c: 0,
721
+ d: 1,
722
+ tx: 0,
723
+ ty: 0
724
+ };
725
+ /** Compose two affines (2×3 matrix multiply) so the result maps `x -> outer(inner(x))`. */
726
+ function composeAffine(outer, inner) {
727
+ return {
728
+ a: outer.a * inner.a + outer.c * inner.b,
729
+ b: outer.b * inner.a + outer.d * inner.b,
730
+ c: outer.a * inner.c + outer.c * inner.d,
731
+ d: outer.b * inner.c + outer.d * inner.d,
732
+ tx: outer.a * inner.tx + outer.c * inner.ty + outer.tx,
733
+ ty: outer.b * inner.tx + outer.d * inner.ty + outer.ty
734
+ };
735
+ }
736
+ /**
737
+ * Apply an affine to a rect, producing an axis-aligned `GodotRect` matching Godot
738
+ * `get_global_rect()`: the top-left ORIGIN is transformed through the full
739
+ * (rotation-bearing) matrix, while width/height take the *unrotated* extent — each
740
+ * axis scaled by its column magnitude (`hypot`), NOT inflated to a rotated bounding
741
+ * box. For a pure scale+translate (`b=c=0`) this reduces bit-for-bit to the old
742
+ * `sx·x+tx` behavior.
743
+ */
744
+ function applyAffine(transform, rect) {
745
+ const { a, b, c, d, tx, ty } = transform;
746
+ if (a === 1 && b === 0 && c === 0 && d === 1 && tx === 0 && ty === 0) return rect;
747
+ return {
748
+ x: a * rect.x + c * rect.y + tx,
749
+ y: b * rect.x + d * rect.y + ty,
750
+ width: rect.width * Math.hypot(a, b),
751
+ height: rect.height * Math.hypot(c, d)
752
+ };
753
+ }
754
+ /**
755
+ * The scale+rotation transform a node applies to its own content and all its
756
+ * descendants, in the global (unscaled-layout) coordinate space, pivoting about the
757
+ * node's global pivot `rect.pos + pivotOffset`. The linear part is Godot's
758
+ * `Transform2D(rotation, scale)` basis — x-axis `(cos·sx, sin·sx)`, y-axis
759
+ * `(-sin·sy, cos·sy)` — i.e. rotate∘scale, which is what the live game composes.
760
+ *
761
+ * NOTE: `rotation` is in radians. Correct when the rotation pivot is the rect origin
762
+ * (`pivotOffset` 0, the only case in the captured data and the case the CSS renderer
763
+ * pivots at the origin for); a non-zero pivot combined with self-rotation would also
764
+ * shift the node's own top-left, which `renderedRect` does not model.
765
+ */
766
+ function ownTransformAffine(rect, scale, rotation, pivotOffset) {
767
+ const gx = rect.x + pivotOffset.x;
768
+ const gy = rect.y + pivotOffset.y;
769
+ const cos = Math.cos(rotation);
770
+ const sin = Math.sin(rotation);
771
+ const a = cos * scale.x;
772
+ const b = sin * scale.x;
773
+ const c = -sin * scale.y;
774
+ const d = cos * scale.y;
775
+ return {
776
+ a,
777
+ b,
778
+ c,
779
+ d,
780
+ tx: gx - (a * gx + c * gy),
781
+ ty: gy - (b * gx + d * gy)
782
+ };
783
+ }
784
+ //#endregion
785
+ //#region src/layout-node.ts
786
+ function makeLayoutNode(indexed, rect, parent) {
787
+ const base = deriveNodeVisuals(indexed, parent?.zIndex);
788
+ const rotationDegrees = asNumber(indexed.props.rotation_degrees);
789
+ const rotation = asNumber(indexed.props.rotation) ?? (rotationDegrees === void 0 ? 0 : rotationDegrees * Math.PI / 180);
790
+ const parentTransform = parent?.cumulativeTransform ?? IDENTITY_AFFINE;
791
+ const renderedRect = applyAffine(parentTransform, scaledRect(rect, base.scale, base.pivotOffset));
792
+ const cumulativeTransform = composeAffine(parentTransform, ownTransformAffine(rect, base.scale, rotation, base.pivotOffset));
793
+ return {
794
+ ...base,
795
+ rect,
796
+ renderedRect,
797
+ cumulativeTransform
798
+ };
799
+ }
800
+ //#endregion
801
+ //#region src/container-layout.ts
802
+ function aspectAlignmentFactor(value) {
803
+ const alignment = asNumber(value) ?? 1;
804
+ if (alignment === 0) return 0;
805
+ if (alignment === 2) return 1;
806
+ return .5;
807
+ }
808
+ function layoutBoxContainerChildren(parent, rect, byPath, layoutByPath, diagnostics, options, horizontal, layoutChildren) {
809
+ const separation = themeNumber(parent, "separation", options) ?? 0;
810
+ const children = visibleChildren(parent, byPath);
811
+ const sizes = children.map((child) => preferredSize(child, byPath, options));
812
+ if (!horizontal) children.forEach((child, index) => {
813
+ if (!TEXT_CONTENT_TYPES.has(child.node.type ?? "")) return;
814
+ const reflowed = combinedMinimum(child, byPath, options, {
815
+ x: 0,
816
+ y: 0,
817
+ width: axisPlacement(rect.x, rect.width, sizes[index].width, child, true).size,
818
+ height: 0
819
+ });
820
+ if (reflowed) sizes[index] = {
821
+ width: sizes[index].width,
822
+ height: reflowed.height
823
+ };
824
+ });
825
+ const totalMinimum = sizes.reduce((sum, size) => sum + (horizontal ? size.width : size.height), 0) + Math.max(0, children.length - 1) * separation;
826
+ const available = horizontal ? rect.width : rect.height;
827
+ const remaining = Math.max(0, available - totalMinimum);
828
+ const expandCount = children.filter((child) => hasExpandFlag(child, horizontal)).length;
829
+ let cursor = (horizontal ? rect.x : rect.y) + (expandCount > 0 ? 0 : alignmentOffset(parent, available, totalMinimum));
830
+ children.forEach((child, index) => {
831
+ const minimum = sizes[index] ?? {
832
+ width: 0,
833
+ height: 0
834
+ };
835
+ const extra = expandCount > 0 && hasExpandFlag(child, horizontal) ? remaining / expandCount : 0;
836
+ const fillExtra = hasFillFlag(child, horizontal) ? extra : 0;
837
+ const crossAxis = horizontal ? axisPlacement(rect.y, rect.height, minimum.height, child, false) : axisPlacement(rect.x, rect.width, minimum.width, child, true);
838
+ const childRect = horizontal ? {
839
+ y: crossAxis.position,
840
+ x: cursor,
841
+ width: minimum.width + fillExtra,
842
+ height: crossAxis.size
843
+ } : {
844
+ x: crossAxis.position,
845
+ y: cursor,
846
+ width: crossAxis.size,
847
+ height: minimum.height + fillExtra
848
+ };
849
+ cursor += (horizontal ? minimum.width + extra : minimum.height + extra) + separation;
850
+ const layout = makeLayoutNode(child, childRect, layoutByPath.get(parent.path));
851
+ layoutByPath.set(child.path, layout);
852
+ layoutChildren(child, childRect, byPath, layoutByPath, diagnostics, options);
853
+ });
854
+ }
855
+ function layoutGridContainerChildren(parent, rect, byPath, layoutByPath, diagnostics, options, layoutChildren) {
856
+ const columns = Math.max(1, Math.floor(numeric(parent.props, "columns") ?? 1));
857
+ const hSeparation = themeNumber(parent, "h_separation", options) ?? themeNumber(parent, "separation", options) ?? 0;
858
+ const vSeparation = themeNumber(parent, "v_separation", options) ?? themeNumber(parent, "separation", options) ?? 0;
859
+ const children = visibleChildren(parent, byPath);
860
+ const sizes = children.map((child) => preferredSize(child, byPath, options));
861
+ const columnWidths = Array.from({ length: columns }, (_, column) => Math.max(0, ...sizes.filter((_, index) => index % columns === column).map((size) => size.width)));
862
+ const rowCount = Math.ceil(children.length / columns);
863
+ const rowHeights = Array.from({ length: rowCount }, (_, row) => Math.max(0, ...sizes.slice(row * columns, row * columns + columns).map((size) => size.height)));
864
+ const parentLayout = layoutByPath.get(parent.path);
865
+ children.forEach((child, index) => {
866
+ const column = index % columns;
867
+ const row = Math.floor(index / columns);
868
+ const cell = {
869
+ x: rect.x + columnWidths.slice(0, column).reduce((sum, width) => sum + width, 0) + column * hSeparation,
870
+ y: rect.y + rowHeights.slice(0, row).reduce((sum, height) => sum + height, 0) + row * vSeparation,
871
+ width: columnWidths[column] ?? 0,
872
+ height: rowHeights[row] ?? 0
873
+ };
874
+ const horizontalPlacement = axisPlacement(cell.x, cell.width, sizes[index]?.width ?? 0, child, true);
875
+ const verticalPlacement = axisPlacement(cell.y, cell.height, sizes[index]?.height ?? 0, child, false);
876
+ const childRect = {
877
+ x: horizontalPlacement.position,
878
+ y: verticalPlacement.position,
879
+ width: horizontalPlacement.size,
880
+ height: verticalPlacement.size
881
+ };
882
+ layoutByPath.set(child.path, makeLayoutNode(child, childRect, parentLayout));
883
+ layoutChildren(child, childRect, byPath, layoutByPath, diagnostics, options);
884
+ });
885
+ }
886
+ function layoutFlowContainerChildren(parent, rect, byPath, layoutByPath, diagnostics, options, vertical, layoutChildren) {
887
+ const hSeparation = themeNumber(parent, "h_separation", options) ?? themeNumber(parent, "separation", options) ?? 0;
888
+ const vSeparation = themeNumber(parent, "v_separation", options) ?? themeNumber(parent, "separation", options) ?? 0;
889
+ const lines = flowWrapLines(rect, visibleChildren(parent, byPath).map((child) => ({
890
+ item: child,
891
+ size: preferredSize(child, byPath, options)
892
+ })), vertical, hSeparation, vSeparation);
893
+ for (const line of lines) {
894
+ const crossSize = line.reduce((max, entry) => Math.max(max, vertical ? entry.size.width : entry.size.height), 0);
895
+ for (const entry of line) {
896
+ const childRect = vertical ? {
897
+ x: entry.x,
898
+ y: entry.y,
899
+ width: crossSize,
900
+ height: entry.size.height
901
+ } : {
902
+ x: entry.x,
903
+ y: entry.y,
904
+ width: entry.size.width,
905
+ height: crossSize
906
+ };
907
+ layoutByPath.set(entry.item.path, makeLayoutNode(entry.item, childRect, layoutByPath.get(parent.path)));
908
+ layoutChildren(entry.item, childRect, byPath, layoutByPath, diagnostics, options);
909
+ }
910
+ }
911
+ }
912
+ function layoutAspectRatioContainerChildren(parent, rect, byPath, layoutByPath, diagnostics, options, layoutChildren) {
913
+ const ratio = numeric(parent.props, "ratio") ?? 1;
914
+ const safeRatio = ratio === 0 ? 1 : ratio;
915
+ const stretchMode = numeric(parent.props, "stretch_mode") ?? 2;
916
+ const alignX = aspectAlignmentFactor(parent.props.alignment_horizontal);
917
+ const alignY = aspectAlignmentFactor(parent.props.alignment_vertical);
918
+ const parentLayout = layoutByPath.get(parent.path);
919
+ for (const childPath of parent.children) {
920
+ const child = byPath.get(childPath);
921
+ if (!child) continue;
922
+ const minimum = combinedMinimum(child, byPath, options) ?? {
923
+ width: 0,
924
+ height: 0
925
+ };
926
+ const base = {
927
+ width: safeRatio,
928
+ height: 1
929
+ };
930
+ let scaleFactor;
931
+ if (stretchMode === 0) scaleFactor = rect.width / base.width;
932
+ else if (stretchMode === 1) scaleFactor = rect.height / base.height;
933
+ else if (stretchMode === 3) scaleFactor = Math.max(rect.width / base.width, rect.height / base.height);
934
+ else scaleFactor = Math.min(rect.width / base.width, rect.height / base.height);
935
+ const width = Math.max(minimum.width, base.width * scaleFactor);
936
+ const height = Math.max(minimum.height, base.height * scaleFactor);
937
+ const childRect = {
938
+ x: rect.x + (rect.width - width) * alignX,
939
+ y: rect.y + (rect.height - height) * alignY,
940
+ width,
941
+ height
942
+ };
943
+ layoutByPath.set(child.path, makeLayoutNode(child, childRect, parentLayout));
944
+ layoutChildren(child, childRect, byPath, layoutByPath, diagnostics, options);
945
+ }
946
+ }
947
+ function layoutPanelContainerChildren(parent, rect, byPath, layoutByPath, diagnostics, options, layoutChildren) {
948
+ layoutFitChildren(parent, contentRectForStyleBox(rect, panelStyleBoxMetrics(parent, options)), byPath, layoutByPath, diagnostics, options, layoutChildren);
949
+ }
950
+ function layoutScrollContainerChildren(parent, rect, byPath, layoutByPath, diagnostics, options, layoutChildren) {
951
+ const content = contentRectForStyleBox(rect, panelStyleBoxMetrics(parent, options));
952
+ const scrollX = numeric(parent.props, "scroll_horizontal") ?? 0;
953
+ const scrollY = numeric(parent.props, "scroll_vertical") ?? 0;
954
+ const parentLayout = layoutByPath.get(parent.path);
955
+ for (const childPath of parent.children) {
956
+ const child = byPath.get(childPath);
957
+ if (!child) continue;
958
+ const size = preferredSize(child, byPath, options);
959
+ const childRect = {
960
+ x: content.x - scrollX,
961
+ y: content.y - scrollY,
962
+ width: hasExpandFlag(child, true) ? Math.max(content.width, size.width) : size.width,
963
+ height: hasExpandFlag(child, false) ? Math.max(content.height, size.height) : size.height
964
+ };
965
+ layoutByPath.set(child.path, makeLayoutNode(child, childRect, parentLayout));
966
+ layoutChildren(child, childRect, byPath, layoutByPath, diagnostics, options);
967
+ }
968
+ }
969
+ function layoutMarginChildren(parent, rect, byPath, layoutByPath, diagnostics, options, layoutChildren) {
970
+ const left = themeNumber(parent, "margin_left", options) ?? 0;
971
+ const top = themeNumber(parent, "margin_top", options) ?? 0;
972
+ const right = themeNumber(parent, "margin_right", options) ?? 0;
973
+ const bottom = themeNumber(parent, "margin_bottom", options) ?? 0;
974
+ layoutFitChildren(parent, normalizeRect({
975
+ x: rect.x + left,
976
+ y: rect.y + top,
977
+ width: rect.width - left - right,
978
+ height: rect.height - top - bottom
979
+ }), byPath, layoutByPath, diagnostics, options, layoutChildren);
980
+ }
981
+ function layoutFitChildren(parent, content, byPath, layoutByPath, diagnostics, options, layoutChildren) {
982
+ const parentLayout = layoutByPath.get(parent.path);
983
+ for (const childPath of parent.children) {
984
+ const child = byPath.get(childPath);
985
+ if (!child) continue;
986
+ const minimum = preferredSize(child, byPath, options);
987
+ const horizontal = axisPlacement(content.x, content.width, minimum.width, child, true);
988
+ const vertical = axisPlacement(content.y, content.height, minimum.height, child, false);
989
+ const childRect = {
990
+ x: horizontal.position,
991
+ y: vertical.position,
992
+ width: horizontal.size,
993
+ height: vertical.size
994
+ };
995
+ layoutByPath.set(child.path, makeLayoutNode(child, childRect, parentLayout));
996
+ layoutChildren(child, childRect, byPath, layoutByPath, diagnostics, options);
997
+ }
998
+ }
999
+ function layoutCenterChildren(parent, rect, byPath, layoutByPath, diagnostics, options, layoutChildren) {
1000
+ for (const childPath of parent.children) {
1001
+ const child = byPath.get(childPath);
1002
+ if (!child) continue;
1003
+ const size = preferredSize(child, byPath, options);
1004
+ const childRect = {
1005
+ x: rect.x + (rect.width - size.width) / 2,
1006
+ y: rect.y + (rect.height - size.height) / 2,
1007
+ width: size.width,
1008
+ height: size.height
1009
+ };
1010
+ layoutByPath.set(childPath, makeLayoutNode(child, childRect, layoutByPath.get(parent.path)));
1011
+ layoutChildren(child, childRect, byPath, layoutByPath, diagnostics, options);
1012
+ }
1013
+ }
1014
+ function axisPlacement(start, available, minimum, indexed, horizontalAxis) {
1015
+ const flags = numeric(indexed.props, horizontalAxis ? "size_flags_horizontal" : "size_flags_vertical") ?? 0;
1016
+ const shrinkCenter = (flags & 4) === 4;
1017
+ const shrinkEnd = (flags & 8) === 8;
1018
+ const size = shrinkCenter || shrinkEnd ? minimum : Math.max(available, minimum);
1019
+ return {
1020
+ position: shrinkEnd ? start + Math.max(0, available - size) : shrinkCenter ? start + Math.max(0, (available - size) / 2) : start,
1021
+ size
1022
+ };
1023
+ }
1024
+ //#endregion
1025
+ //#region src/membership.ts
1026
+ /**
1027
+ * The nodes the rect cascade ({@link resolveGodotSceneTree}) would lay out, without
1028
+ * running it. The cascade drops exactly one class of node: an invisible child of a
1029
+ * Box/Grid/Flow container — and its whole subtree — because a hidden control takes
1030
+ * no space in those containers (`visibleChildren` in minimum-size.ts; every other
1031
+ * container type and plain parents lay out all children). Consumers that only need
1032
+ * per-node structural fields (`drawOrder`, `type`, `source`, `properties`) can use
1033
+ * this instead of the cascade and skip all rect math; `membership.test.ts` pins the
1034
+ * equivalence against the cascade.
1035
+ */
1036
+ function flattenSceneGraphNodes(graph) {
1037
+ const byPath = new Map(graph.nodes.map((node) => [node.path, node]));
1038
+ const out = [];
1039
+ const visit = (node) => {
1040
+ out.push(node);
1041
+ const skipsInvisibleChildren = isBoxContainerType(node.type) || isFlowContainerType(node.type) || node.type === "GridContainer";
1042
+ for (const childPath of node.children) {
1043
+ const child = byPath.get(childPath);
1044
+ if (!child) continue;
1045
+ if (skipsInvisibleChildren && !child.visible) continue;
1046
+ visit(child);
1047
+ }
1048
+ };
1049
+ for (const node of graph.nodes) if (node.parentPath === null) visit(node);
1050
+ return out;
1051
+ }
1052
+ //#endregion
1053
+ //#region src/index.ts
1054
+ function isGodotSceneTree(value) {
1055
+ if (typeof value !== "object" || value === null) return false;
1056
+ const tree = value;
1057
+ return isRectLike(tree.viewport) && Array.isArray(tree.nodes) && tree.nodes.every(isTreeNode) && (tree.diagnostics === void 0 || Array.isArray(tree.diagnostics)) && (tree.resourceStatuses === void 0 || Array.isArray(tree.resourceStatuses));
1058
+ }
1059
+ function isRectLike(value) {
1060
+ return typeof value === "object" && value !== null && typeof value.x === "number" && typeof value.y === "number" && typeof value.width === "number" && typeof value.height === "number";
1061
+ }
1062
+ function isTreeNode(value) {
1063
+ if (typeof value !== "object" || value === null) return false;
1064
+ const node = value;
1065
+ return typeof node.path === "string" && typeof node.name === "string" && typeof node.type === "string" && (typeof node.parentPath === "string" || node.parentPath === null) && Array.isArray(node.children) && isRectLike(node.rect) && typeof node.visible === "boolean" && typeof node.zIndex === "number" && typeof node.drawOrder === "number" && typeof node.zAsRelative === "boolean" && typeof node.showBehindParent === "boolean" && typeof node.clipContents === "boolean" && typeof node.properties === "object" && node.properties !== null && Array.isArray(node.resourceRefs);
1066
+ }
1067
+ const DEFAULT_VIEWPORT = {
1068
+ x: 0,
1069
+ y: 0,
1070
+ width: 1280,
1071
+ height: 720
1072
+ };
1073
+ const MAX_LAYOUT_PASSES = 8;
1074
+ function toIndexedNode(node) {
1075
+ return {
1076
+ node: node.source ?? {
1077
+ name: node.name,
1078
+ type: node.type,
1079
+ attributes: {},
1080
+ properties: node.properties
1081
+ },
1082
+ path: node.path,
1083
+ parentPath: node.parentPath,
1084
+ children: [...node.children],
1085
+ props: node.properties,
1086
+ order: node.drawOrder
1087
+ };
1088
+ }
1089
+ function resolveGodotSceneTree(graph, options = {}) {
1090
+ const viewport = {
1091
+ ...DEFAULT_VIEWPORT,
1092
+ ...options.viewport
1093
+ };
1094
+ const indexed = graph.nodes.map(toIndexedNode);
1095
+ const byPath = new Map(indexed.map((node) => [node.path, node]));
1096
+ const roots = indexed.filter((node) => node.parentPath === null);
1097
+ const flows = indexed.filter((node) => isFlowContainerType(node.node.type ?? "Node"));
1098
+ let layoutByPath = /* @__PURE__ */ new Map();
1099
+ let diagnostics = [];
1100
+ for (let pass = 0; pass < MAX_LAYOUT_PASSES; pass++) {
1101
+ layoutByPath = /* @__PURE__ */ new Map();
1102
+ diagnostics = [];
1103
+ for (const node of indexed) node.minimumSize = void 0;
1104
+ for (const root of roots) layoutNode(root.path, viewport, byPath, layoutByPath, diagnostics, options);
1105
+ if (flows.length === 0 || !updateFlowExtents(flows, layoutByPath)) break;
1106
+ if (pass === MAX_LAYOUT_PASSES - 1) diagnostics.push({
1107
+ severity: "warning",
1108
+ code: "flow-layout-unconverged",
1109
+ message: "Flow container layout did not converge within the pass limit; rects may be unsettled."
1110
+ });
1111
+ }
1112
+ resolveAnchors(layoutByPath, options.anchorsByPath, diagnostics);
1113
+ return {
1114
+ viewport,
1115
+ nodes: [...layoutByPath.values()].sort((left, right) => left.zIndex - right.zIndex || left.drawOrder - right.drawOrder),
1116
+ diagnostics,
1117
+ resourceStatuses: graph.resourceStatuses
1118
+ };
1119
+ }
1120
+ /**
1121
+ * Records each flow container's resolved main-axis extent for the next pass.
1122
+ * Returns true if any extent changed (i.e. another pass is warranted).
1123
+ */
1124
+ function updateFlowExtents(flows, layoutByPath) {
1125
+ let changed = false;
1126
+ for (const flow of flows) {
1127
+ const node = layoutByPath.get(flow.path);
1128
+ if (!node) continue;
1129
+ const extent = flowContainerVertical(flow) ? node.rect.height : node.rect.width;
1130
+ if (flow.flowMainExtent === void 0 || Math.abs(flow.flowMainExtent - extent) > 1e-6) {
1131
+ flow.flowMainExtent = extent;
1132
+ changed = true;
1133
+ }
1134
+ }
1135
+ return changed;
1136
+ }
1137
+ function layoutNode(path, parentRect, byPath, layoutByPath, diagnostics, options) {
1138
+ const indexed = byPath.get(path);
1139
+ if (!indexed) return;
1140
+ if (layoutByPath.has(path)) return layoutByPath.get(path);
1141
+ const rect = indexed.parentPath === null ? rootRect(indexed, parentRect, byPath, options) : controlRect(indexed, parentRect, byPath, options);
1142
+ const computedNode = makeLayoutNode(indexed, rect, indexed.parentPath ? layoutByPath.get(indexed.parentPath) : void 0);
1143
+ layoutByPath.set(path, computedNode);
1144
+ layoutNodeChildren(indexed, rect, byPath, layoutByPath, diagnostics, options);
1145
+ return computedNode;
1146
+ }
1147
+ function layoutNodeChildren(indexed, rect, byPath, layoutByPath, diagnostics, options) {
1148
+ const type = indexed.node.type ?? "Node";
1149
+ if (isBoxContainerType(type)) layoutBoxContainerChildren(indexed, rect, byPath, layoutByPath, diagnostics, options, boxContainerHorizontal(indexed), layoutNodeChildren);
1150
+ else if (type === "AspectRatioContainer") layoutAspectRatioContainerChildren(indexed, rect, byPath, layoutByPath, diagnostics, options, layoutNodeChildren);
1151
+ else if (type === "GridContainer") layoutGridContainerChildren(indexed, rect, byPath, layoutByPath, diagnostics, options, layoutNodeChildren);
1152
+ else if (isFlowContainerType(type)) layoutFlowContainerChildren(indexed, rect, byPath, layoutByPath, diagnostics, options, flowContainerVertical(indexed), layoutNodeChildren);
1153
+ else if (type === "PanelContainer") layoutPanelContainerChildren(indexed, rect, byPath, layoutByPath, diagnostics, options, layoutNodeChildren);
1154
+ else if (type === "ScrollContainer") layoutScrollContainerChildren(indexed, rect, byPath, layoutByPath, diagnostics, options, layoutNodeChildren);
1155
+ else if (type === "MarginContainer") layoutMarginChildren(indexed, rect, byPath, layoutByPath, diagnostics, options, layoutNodeChildren);
1156
+ else if (type === "CenterContainer") layoutCenterChildren(indexed, rect, byPath, layoutByPath, diagnostics, options, layoutNodeChildren);
1157
+ else for (const childPath of indexed.children) layoutNode(childPath, rect, byPath, layoutByPath, diagnostics, options);
1158
+ }
1159
+ //#endregion
1160
+ export { anchorEdgePoint, flattenSceneGraphNodes, isGodotSceneTree, parseAnchorEdge, resolveGodotSceneTree };
1161
+
1162
+ //# sourceMappingURL=index.js.map