import { FXNODE_VIEW_LIMITS } from "../browser/view-limits.js"; export interface AtlasSize { readonly width: number; readonly height: number; } export interface AtlasRect extends AtlasSize { readonly x: number; readonly y: number; } export interface AtlasItem extends AtlasSize { readonly id: string; } export interface AtlasLayout extends AtlasSize { readonly items: ReadonlyMap; readonly regions: ReadonlyMap; readonly free: readonly AtlasRect[]; } export type AtlasPlanKind = "allocated" | "resized-in-place" | "relocated" | "repacked" | "grown"; export type AtlasPlan = | { readonly ok: true; readonly kind: AtlasPlanKind; readonly layout: AtlasLayout; readonly movedIds: readonly string[]; } | { readonly ok: false; readonly code: "atlas.dimension" | "atlas.capacity" }; const BLOCK = 256; const area = ({ width, height }: AtlasSize) => width * height; const freeOrder = (left: AtlasRect, right: AtlasRect) => left.y - right.y || left.x - right.x || left.height - right.height || left.width - right.width; const validItem = ({ width, height }: AtlasSize) => Number.isSafeInteger(width) && Number.isSafeInteger(height) && width > 0 && height > 0 && width <= FXNODE_VIEW_LIMITS.maxDeviceDimension && height <= FXNODE_VIEW_LIMITS.maxDeviceDimension && width * height <= FXNODE_VIEW_LIMITS.maxDevicePixelsPerView; const freezeRect = (rect: AtlasRect): AtlasRect => Object.freeze(rect); const makeLayout = ( width: number, height: number, items: ReadonlyMap, regions: ReadonlyMap, free: readonly AtlasRect[], ): AtlasLayout => Object.freeze({ width, height, items: new Map(items), regions: new Map(regions), free: Object.freeze(free.map(freezeRect).sort(freeOrder)), }); function coalesce(rectangles: readonly AtlasRect[]): AtlasRect[] { const result = rectangles.map((rect) => ({ ...rect })); for (;;) { let merged = false; outer: for (let i = 0; i < result.length; i++) for (let j = i + 1; j < result.length; j++) { const a = result[i]!, b = result[j]!; let next: AtlasRect | undefined; if (a.y === b.y && a.height === b.height && (a.x + a.width === b.x || b.x + b.width === a.x)) next = { x: Math.min(a.x, b.x), y: a.y, width: a.width + b.width, height: a.height }; else if (a.x === b.x && a.width === b.width && (a.y + a.height === b.y || b.y + b.height === a.y)) next = { x: a.x, y: Math.min(a.y, b.y), width: a.width, height: a.height + b.height }; if (next) { result.splice(j, 1); result.splice(i, 1, next); merged = true; break outer; } } if (!merged) return result.sort(freeOrder); } } function insert(layout: AtlasLayout, item: AtlasItem): AtlasLayout | undefined { const candidates = layout.free .map((rect, index) => ({ rect, index })) .filter(({ rect }) => item.width <= rect.width && item.height <= rect.height) .sort(({ rect: left }, { rect: right }) => { const ldw = left.width - item.width, ldh = left.height - item.height, rdw = right.width - item.width, rdh = right.height - item.height; return ( area(left) - area(item) - (area(right) - area(item)) || Math.min(ldw, ldh) - Math.min(rdw, rdh) || Math.max(ldw, ldh) - Math.max(rdw, rdh) || freeOrder(left, right) ); }); const selected = candidates[0]; if (!selected) return; const { rect, index } = selected, region = { x: rect.x, y: rect.y, width: item.width, height: item.height }, dw = rect.width - item.width, dh = rect.height - item.height, remainder: AtlasRect[] = []; if (dw > dh) { if (dw) remainder.push({ x: rect.x + item.width, y: rect.y, width: dw, height: rect.height }); if (dh) remainder.push({ x: rect.x, y: rect.y + item.height, width: item.width, height: dh }); } else { if (dh) remainder.push({ x: rect.x, y: rect.y + item.height, width: rect.width, height: dh }); if (dw) remainder.push({ x: rect.x + item.width, y: rect.y, width: dw, height: item.height }); } const items = new Map(layout.items), regions = new Map(layout.regions), free = layout.free.slice(); items.set(item.id, Object.freeze({ width: item.width, height: item.height })); regions.set(item.id, freezeRect(region)); free.splice(index, 1, ...remainder); return makeLayout(layout.width, layout.height, items, regions, free); } function emptyLayout(width: number, height: number): AtlasLayout { return makeLayout(width, height, new Map(), new Map(), [{ x: 0, y: 0, width, height }]); } function sortedItems(items: ReadonlyMap): AtlasItem[] { return [...items] .map(([id, size]) => ({ id, ...size })) .sort((left, right) => { const side = Math.max(right.width, right.height) - Math.max(left.width, left.height); return ( side || area(right) - area(left) || right.height - left.height || right.width - left.width || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) ); }); } function pack(items: readonly AtlasItem[], width: number, height: number): AtlasLayout | undefined { let layout = emptyLayout(width, height); for (const item of items) { const next = insert(layout, item); if (!next) return; layout = next; } return layout; } function candidates(items: readonly AtlasItem[], minimumArea: number): AtlasSize[] { const largestWidth = Math.max(...items.map((item) => item.width)), largestHeight = Math.max(...items.map((item) => item.height)), result: AtlasSize[] = []; for (let width = BLOCK; width <= FXNODE_VIEW_LIMITS.maxAtlasDimension; width += BLOCK) for (let height = BLOCK; height <= FXNODE_VIEW_LIMITS.maxAtlasDimension; height += BLOCK) { const pixels = width * height; if ( width >= largestWidth && height >= largestHeight && pixels >= minimumArea && pixels <= FXNODE_VIEW_LIMITS.maxAtlasPixels ) result.push({ width, height }); } return result.sort( (left, right) => area(left) - area(right) || Math.abs(left.width - left.height) - Math.abs(right.width - right.height) || left.width - right.width || left.height - right.height, ); } function repack( itemsMap: ReadonlyMap, current?: AtlasLayout, compact = false, ): AtlasLayout | undefined { const items = sortedItems(itemsMap), activeArea = items.reduce((sum, item) => sum + area(item), 0); if (current && !compact) { const same = pack(items, current.width, current.height); if (same) return same; } const floor = current && !compact ? Math.min(FXNODE_VIEW_LIMITS.maxAtlasPixels, Math.max(activeArea, area(current) * 2)) : activeArea; for (const size of candidates(items, floor)) { const layout = pack(items, size.width, size.height); if (layout) return layout; } } function movedIds(before: AtlasLayout | undefined, after: AtlasLayout): string[] { return [...after.regions] .filter(([id, rect]) => { const previous = before?.regions.get(id); return !previous || previous.x !== rect.x || previous.y !== rect.y; }) .map(([id]) => id) .sort(); } export function planAtlasUpsert(current: AtlasLayout | undefined, item: AtlasItem): AtlasPlan { if (!validItem(item)) return { ok: false, code: "atlas.dimension" }; const items = new Map(current?.items); items.set(item.id, { width: item.width, height: item.height }); if ([...items.values()].reduce((sum, value) => sum + area(value), 0) > FXNODE_VIEW_LIMITS.maxActiveDevicePixels) return { ok: false, code: "atlas.capacity" }; const previousSize = current?.items.get(item.id), previousRegion = current?.regions.get(item.id); if ( current && previousSize && previousRegion && item.width <= previousRegion.width && item.height <= previousRegion.height ) { const nextItems = new Map(current.items); nextItems.set(item.id, Object.freeze({ width: item.width, height: item.height })); return { ok: true, kind: "resized-in-place", layout: makeLayout(current.width, current.height, nextItems, current.regions, current.free), movedIds: [], }; } let base = current; if (current && previousRegion) base = removeAtlasItem(current, item.id); const incremental = base && insert(base, item); if (incremental) return { ok: true, kind: previousSize ? "relocated" : "allocated", layout: incremental, movedIds: movedIds(current, incremental), }; const layout = repack(items, current); if (!layout) return { ok: false, code: "atlas.capacity" }; return { ok: true, kind: current && layout.width === current.width && layout.height === current.height ? "repacked" : "grown", layout, movedIds: movedIds(current, layout), }; } export function removeAtlasItem(current: AtlasLayout, id: string): AtlasLayout | undefined { const region = current.regions.get(id); if (!region) return current; const items = new Map(current.items), regions = new Map(current.regions); items.delete(id); regions.delete(id); if (!items.size) return; return makeLayout(current.width, current.height, items, regions, coalesce([...current.free, region])); } export function planAtlasCompaction(current: AtlasLayout): AtlasPlan | undefined { const active = [...current.items.values()].reduce((sum, item) => sum + area(item), 0); if (active / area(current) > 0.25) return; const layout = repack(current.items, current, true); if (!layout || area(layout) > area(current) / 2) return; return { ok: true, kind: "repacked", layout, movedIds: movedIds(current, layout) }; }