feat: add render graph driven renderer architecture

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-27 02:53:44 +00:00
co-authored by heaust
parent 8a8369706b
commit d4e8634f67
290 changed files with 48804 additions and 1995 deletions
+260
View File
@@ -0,0 +1,260 @@
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<string, AtlasSize>;
readonly regions: ReadonlyMap<string, AtlasRect>;
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<string, AtlasSize>,
regions: ReadonlyMap<string, AtlasRect>,
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<string, AtlasSize>): 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<string, AtlasSize>,
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) };
}
+80
View File
@@ -0,0 +1,80 @@
import type { FxNodeValueSchema } from "../composition/types.js";
import type { ParameterValue } from "../core/types.js";
import type { LayoutControl } from "../layout/types.js";
export function clampNumber(value: number, schema: FxNodeValueSchema | undefined): number {
if (!schema) return value;
if (schema.type !== "number" && schema.type !== "vector" && schema.type !== "color") return value;
const minimum = schema.type === "color" ? 0 : schema.minimum;
const maximum = schema.type === "color" ? 1 : schema.maximum;
return Math.min(maximum ?? Infinity, Math.max(minimum ?? -Infinity, value));
}
export function snapNumber(value: number, schema: FxNodeValueSchema | undefined): number {
if (schema?.type !== "number") return value;
const step = schema.step ?? (schema.integer ? 1 : undefined);
if (!step) return value;
const origin = schema.minimum ?? 0;
return origin + Math.round((value - origin) / step) * step;
}
export function scrubValue(
control: LayoutControl,
original: ParameterValue,
component: number,
deltaPixels: number,
fine: boolean,
snapping: boolean,
): ParameterValue {
const scale = fine ? 0.01 : 0.1;
const next = (value: number): number => {
const changed = value + deltaPixels * scale;
const snapped = snapping ? snapNumber(changed, control.schema) : changed;
const clamped = clampNumber(snapped, control.schema);
return control.schema?.type === "number" && control.schema.integer ? Math.round(clamped) : clamped;
};
if (original.kind === "number") return { kind: "number", value: next(original.value) };
if (original.kind === "vector") {
const value: [number, number, number] = [...original.value];
value[component] = next(value[component] ?? 0);
return { kind: "vector", value };
}
if (original.kind === "color") {
const value: [number, number, number, number] = [...original.value];
value[component] = Math.min(1, Math.max(0, next(value[component] ?? 0)));
return { kind: "color", value };
}
return original;
}
export function setNumericComponent(
control: LayoutControl,
original: ParameterValue,
component: number,
input: number,
): ParameterValue {
const clamped = clampNumber(input, control.schema);
const next = control.schema?.type === "number" && control.schema.integer ? Math.round(clamped) : clamped;
if (original.kind === "number") return { kind: "number", value: next };
if (original.kind === "vector") {
const value: [number, number, number] = [...original.value];
value[component] = next;
return { kind: "vector", value };
}
if (original.kind === "color") {
const value: [number, number, number, number] = [...original.value];
value[component] = Math.min(1, Math.max(0, next));
return { kind: "color", value };
}
return original;
}
export function numericStep(control: LayoutControl, fine: boolean): number {
const base = control.schema?.type === "number" ? (control.schema.step ?? (control.schema.integer ? 1 : 0.1)) : 0.1;
return fine && control.schema?.type === "number" && !control.schema.integer ? base / 10 : fine ? base / 10 : base;
}
export function cycleEnum(values: readonly string[], current: string, direction: 1 | -1): string {
const index = values.indexOf(current);
return values[(Math.max(0, index) + direction + values.length) % values.length] ?? current;
}
File diff suppressed because it is too large Load Diff
+292
View File
@@ -0,0 +1,292 @@
import { linkId, type GraphLink, type LinkId, type NodeId, type SocketId, type Vec2 } from "../core/types.js";
import type { Command } from "../commands/types.js";
import { viewToWorld } from "../layout/geometry.js";
import { GEOMETRY as G } from "../layout/constants.js";
import {
layoutSocketsCompatible,
type LayoutControl,
type LayoutSnapshot,
type Rect,
type ViewTransform,
} from "../layout/types.js";
import { isColorRamp } from "../widgets/color-ramp.js";
const inRect = (p: Vec2, r: Rect, tolerance = 0): boolean =>
p.x >= r.x - tolerance &&
p.x <= r.x + r.width + tolerance &&
p.y <= r.y + tolerance &&
p.y >= r.y - r.height - tolerance;
export type RampTarget =
| "add"
| "remove"
| "flip"
| "distribute"
| "mode"
| "interpolation"
| "hue"
| "gradient"
| "selector"
| "position"
| "swatch";
export type Hit =
| { readonly kind: "color-wheel"; readonly id: string; readonly region: "plane" | "lightness" }
| {
readonly kind: "ramp";
readonly id: string;
readonly target: RampTarget | "handle";
readonly stopId?: string;
readonly position?: number;
}
| { readonly kind: "control-step"; readonly id: string; readonly component: number; readonly direction: -1 | 1 }
| { readonly kind: "resource"; readonly id: string }
| { readonly kind: "control"; readonly id: string; readonly component: number }
| { readonly kind: "socket"; readonly id: SocketId }
| { readonly kind: "collapse" | "resize" | "node" | "frame-header" | "frame-body"; readonly id: NodeId }
| { readonly kind: "link"; readonly id: LinkId }
| { readonly kind: "canvas" };
export function hitRamp(control: LayoutControl, p: Vec2, tolerance = 0): Extract<Hit, { kind: "ramp" }> | undefined {
const b = control.rampBounds,
v = control.value as { kind?: unknown; value?: unknown };
if (control.kind !== "color-ramp" || !b || v.kind !== "json" || !isColorRamp(v.value)) return;
const ramp = v.value;
if (inRect(p, b.toolbar)) {
const x = (p.x - b.toolbar.x) / b.toolbar.width;
return {
kind: "ramp",
id: control.id,
target: x < 0.12 ? "add" : x < 0.24 ? "remove" : x < 0.62 ? "flip" : "distribute",
};
}
if (inRect(p, b.mode)) return { kind: "ramp", id: control.id, target: "mode" };
if (inRect(p, b.interpolation)) return { kind: "ramp", id: control.id, target: "interpolation" };
if (inRect(p, b.hue)) return { kind: "ramp", id: control.id, target: "hue" };
if (inRect(p, b.handles, tolerance)) {
const position = Math.max(0, Math.min(1, (p.x - b.handles.x) / b.handles.width)),
near = ramp.stops
.filter((s) => Math.abs(s.position - position) * b.handles.width <= Math.max(7, tolerance))
.sort((a, c) => a.position - c.position || a.id.localeCompare(c.id));
if (near.length) return { kind: "ramp", id: control.id, target: "handle", stopId: near[0]!.id, position };
}
if (inRect(p, b.gradient))
return {
kind: "ramp",
id: control.id,
target: "gradient",
position: Math.max(0, Math.min(1, (p.x - b.gradient.x) / b.gradient.width)),
};
if (inRect(p, b.selector)) return { kind: "ramp", id: control.id, target: "selector" };
if (inRect(p, b.position)) return { kind: "ramp", id: control.id, target: "position" };
if (inRect(p, b.color)) return { kind: "ramp", id: control.id, target: "swatch" };
}
const segmentDistance = (p: Vec2, a: Vec2, b: Vec2): number => {
const dx = b.x - a.x,
dy = b.y - a.y,
l = dx * dx + dy * dy;
if (!l) return Math.hypot(p.x - a.x, p.y - a.y);
const q = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l));
return Math.hypot(p.x - a.x - q * dx, p.y - a.y - q * dy);
};
export function hitTest(layout: LayoutSnapshot, view: Vec2, preferredDirection?: "input" | "output"): Hit {
const world = viewToWorld(view, layout.transform),
tolerance = 7 / layout.transform.zoom;
const topNode = layout.drawOrder
.slice()
.reverse()
.map((id) => layout.nodes.get(id))
.find((node) => node !== undefined && node.kind !== "frame" && inRect(world, node.bounds));
for (const control of [...layout.controls.values()].reverse())
if (!control.linked && (!topNode || control.nodeId === topNode.id)) {
if (control.kind === "resource") {
if (
control.resourceBounds &&
(inRect(world, control.resourceBounds.preview) || inRect(world, control.resourceBounds.open))
)
return { kind: "resource", id: control.id };
continue;
}
if (control.colorWheelBounds) {
if (inRect(world, control.colorWheelBounds.plane))
return { kind: "color-wheel", id: control.id, region: "plane" };
if (inRect(world, control.colorWheelBounds.lightness))
return { kind: "color-wheel", id: control.id, region: "lightness" };
continue;
}
const ramp = hitRamp(control, world, tolerance);
if (ramp) return ramp;
for (const field of control.numericFields) {
if (inRect(world, field.decrement))
return { kind: "control-step", id: control.id, component: field.component, direction: -1 };
if (inRect(world, field.increment))
return { kind: "control-step", id: control.id, component: field.component, direction: 1 };
}
if (inRect(world, control.bounds)) {
const component = control.subfields.find((field) => inRect(world, field.bounds))?.index ?? 0;
return { kind: "control", id: control.id, component };
}
}
for (const id of layout.drawOrder.slice().reverse()) {
const node = layout.nodes.get(id);
if (node?.kind !== "reroute") continue;
const center = { x: node.bounds.x + node.bounds.width / 2, y: node.bounds.y - node.bounds.height / 2 },
distance = Math.hypot(world.x - center.x, world.y - center.y),
core = Math.max(G.reroute, 7 / layout.transform.zoom),
halo = core + 8 / layout.transform.zoom;
if (distance > core && distance <= halo) return { kind: "node", id };
}
const socketHits = [...layout.sockets.values()]
.filter(
(socket) =>
(!topNode || socket.nodeId === topNode.id) &&
Math.hypot(world.x - socket.anchor.x, world.y - socket.anchor.y) <= tolerance,
)
.sort((a, b) => {
const an = layout.nodes.get(a.nodeId),
bn = layout.nodes.get(b.nodeId),
rank = (node: typeof an) => (node ? layout.drawOrder.indexOf(node.id) : -1);
return (
rank(bn) - rank(an) || Number(b.direction === preferredDirection) - Number(a.direction === preferredDirection)
);
});
if (socketHits[0]) return { kind: "socket", id: socketHits[0].id };
const regular = layout.drawOrder
.slice()
.reverse()
.map((id) => layout.nodes.get(id))
.filter((n) => n?.kind !== "frame");
for (const node of regular)
if (node && node.kind === "node" && inRect(world, node.collapseHitRect, tolerance))
return { kind: "collapse", id: node.id };
for (const node of regular)
if (node && node.kind === "node" && !node.collapsed && inRect(world, node.resizeHitRect, tolerance))
return { kind: "resize", id: node.id };
for (const node of regular) if (node && inRect(world, node.bounds)) return { kind: "node", id: node.id };
for (const link of [...layout.links.values()].reverse())
if (link.visible && link.points.slice(1).some((p, i) => segmentDistance(world, link.points[i]!, p) <= tolerance))
return { kind: "link", id: link.id };
for (const id of layout.drawOrder.slice().reverse()) {
const n = layout.nodes.get(id);
if (n?.kind === "frame" && inRect(world, n.header)) return { kind: "frame-header", id };
}
for (const id of layout.drawOrder.slice().reverse()) {
const n = layout.nodes.get(id);
if (n?.kind === "frame" && inRect(world, n.bounds)) return { kind: "frame-body", id };
}
return { kind: "canvas" };
}
/** Hit order is stable and all tolerances are expressed in view pixels. */
export function hitNode(layout: LayoutSnapshot, view: Vec2): NodeId | undefined {
const hit = hitTest(layout, view);
return hit.kind === "control" ||
hit.kind === "control-step" ||
hit.kind === "ramp" ||
hit.kind === "color-wheel" ||
hit.kind === "resource"
? layout.controls.get(hit.id)?.nodeId
: hit.kind === "socket"
? layout.sockets.get(hit.id)?.nodeId
: hit.kind === "node" ||
hit.kind === "collapse" ||
hit.kind === "resize" ||
hit.kind === "frame-header" ||
hit.kind === "frame-body"
? hit.id
: undefined;
}
export const boxNodes = (layout: LayoutSnapshot, a: Vec2, b: Vec2): NodeId[] => {
const p = viewToWorld(a, layout.transform),
q = viewToWorld(b, layout.transform),
left = Math.min(p.x, q.x),
right = Math.max(p.x, q.x),
top = Math.max(p.y, q.y),
bottom = Math.min(p.y, q.y);
return layout.drawOrder.filter((id) => {
const n = layout.nodes.get(id);
return (
!!n &&
n.kind !== "frame" &&
n.bounds.x >= left &&
n.bounds.x + n.bounds.width <= right &&
n.bounds.y <= top &&
n.bounds.y - n.bounds.height >= bottom
);
});
};
export const compatibleTargets = (layout: LayoutSnapshot, fromId: SocketId) => {
const from = layout.sockets.get(fromId);
if (!from || from.direction !== "output") return [];
return [...layout.sockets.values()].filter((to) => to.nodeId !== from.nodeId && layoutSocketsCompatible(from, to));
};
export function planLink(
layout: LayoutSnapshot,
fromId: SocketId,
toId: SocketId,
newId: LinkId = linkId(`gesture-${fromId}-${toId}`),
): Command | undefined {
const from = layout.sockets.get(fromId),
to = layout.sockets.get(toId);
if (!from || !to || !compatibleTargets(layout, fromId).some((s) => s.id === toId)) return;
const link: GraphLink = {
id: to.linkIds[0] ?? newId,
fromNodeId: from.nodeId,
fromSocketId: from.id,
toNodeId: to.nodeId,
toSocketId: to.id,
muted: false,
extensions: {},
};
return to.capacity === 1 && to.linkIds.length
? { type: "link.replace", removeId: to.linkIds[0]!, link }
: { type: "link.add", link };
}
export const clampResize = (layout: LayoutSnapshot, id: NodeId, world: Vec2): Vec2 | undefined => {
const n = layout.nodes.get(id);
if (!n || n.kind !== "node") return;
return {
x: Math.min(G.maxWidth, Math.max(n.minimumSize.x, world.x - n.bounds.x)),
y: Math.max(n.minimumSize.y, n.bounds.y - world.y),
};
};
export function frameDropCandidate(layout: LayoutSnapshot, id: NodeId, world: Vec2): NodeId | undefined {
const node = layout.nodes.get(id);
if (!node) return;
const descendants = new Set<NodeId>();
for (const n of layout.nodes.values()) {
let p = n.parentId;
while (p) {
if (p === id) {
descendants.add(n.id);
break;
}
p = layout.nodes.get(p)?.parentId;
}
}
return [...layout.nodes.values()]
.filter((n) => n.kind === "frame" && n.id !== id && !descendants.has(n.id) && inRect(world, n.bounds))
.sort((a, b) => a.bounds.width * a.bounds.height - b.bounds.width * b.bounds.height)[0]?.id;
}
export function zoomAt(transform: ViewTransform, cursor: Vec2, deltaY: number): { center: Vec2; zoom: number } {
const anchor = viewToWorld(cursor, transform);
const zoom = Math.min(4, Math.max(0.1, transform.zoom * Math.exp(-deltaY * 0.0015)));
return {
zoom,
center: {
x: anchor.x - (cursor.x - transform.viewport.x / 2) / zoom,
y: anchor.y + (cursor.y - transform.viewport.y / 2) / zoom,
},
};
}
export function groupRoots(selected: ReadonlySet<NodeId>, layout: LayoutSnapshot): readonly NodeId[] {
return [...selected].filter((id) => {
let parent = layout.nodes.get(id)?.parentId;
while (parent) {
if (selected.has(parent)) return false;
parent = layout.nodes.get(parent)?.parentId;
}
return true;
});
}
+103
View File
@@ -0,0 +1,103 @@
import type { Command, CompatibleFxNodeSaveData, FxNodeReplayCommand, FxNodeSaveData } from "../commands/types.js";
import { FXNODE_SAVE_DATA_LIMITS } from "../commands/save-data.js";
import type { FxNodeCompositionData } from "../composition/types.js";
import { admitStructuredData, cloneJson, deepFreeze, type StructuredDataMetrics } from "../core/json.js";
import type { GraphLayoutV2 } from "../core/types.js";
export interface JournalEntry<C extends FxNodeCompositionData = FxNodeCompositionData> {
readonly command: FxNodeReplayCommand<C>;
readonly metrics: StructuredDataMetrics;
readonly atomicCommands: number;
}
export interface WorkerJournal<C extends FxNodeCompositionData = FxNodeCompositionData> {
readonly baseline: GraphLayoutV2;
readonly applied: readonly JournalEntry<C>[];
readonly redo: readonly JournalEntry<C>[];
readonly values: number;
readonly stringCodeUnits: number;
readonly atomicCommands: number;
readonly depth: number;
}
const metrics = (command: FxNodeReplayCommand): JournalEntry | undefined => {
const admitted = admitStructuredData(command, FXNODE_SAVE_DATA_LIMITS);
if (!admitted.ok) return;
return deepFreeze({
command: admitted.value as FxNodeReplayCommand,
metrics: admitted.metrics,
atomicCommands: command.type === "batch" ? command.commands.length : 1,
});
};
const fold = <C extends FxNodeCompositionData>(
baseline: GraphLayoutV2,
applied: readonly JournalEntry<C>[],
redo: readonly JournalEntry<C>[],
): WorkerJournal<C> =>
deepFreeze({
baseline: cloneJson(baseline),
applied: [...applied],
redo: [...redo],
values: applied.reduce((n, x) => n + x.metrics.values, 0),
stringCodeUnits: applied.reduce((n, x) => n + x.metrics.stringCodeUnits, 0),
atomicCommands: applied.reduce((n, x) => n + x.atomicCommands, 0),
depth: applied.reduce((n, x) => Math.max(n, x.metrics.depth), 0),
});
export const checkpointJournal = <C extends FxNodeCompositionData>(baseline: GraphLayoutV2): WorkerJournal<C> =>
fold(baseline, [], []);
export const importJournal = <C extends FxNodeCompositionData>(
data: CompatibleFxNodeSaveData<C> | FxNodeSaveData<C>,
): WorkerJournal<C> =>
fold(
data.baseline,
data.commands.map((command) => metrics(command) as JournalEntry<C>),
[],
);
export const journalSaveData = <C extends FxNodeCompositionData>(
journal: WorkerJournal<C>,
composition: C,
): FxNodeSaveData<C> =>
cloneJson({
kind: "fxnode.command-log",
schemaVersion: 2,
composition,
baseline: journal.baseline,
commands: journal.applied.map((entry) => entry.command),
} as FxNodeSaveData<C>);
export function advanceJournal<C extends FxNodeCompositionData>(
journal: WorkerJournal<C>,
command: Command<C>,
candidateBaseline: GraphLayoutV2,
strictReplayValid: (baseline: GraphLayoutV2, commands: readonly FxNodeReplayCommand<C>[]) => boolean,
): WorkerJournal<C> {
let applied = journal.applied,
redo = journal.redo;
if (command.type === "undo") {
const entry = applied.at(-1);
if (!entry) return checkpointJournal(candidateBaseline);
applied = applied.slice(0, -1);
redo = [...redo, entry];
} else if (command.type === "redo") {
const entry = redo.at(-1);
if (!entry) return checkpointJournal(candidateBaseline);
applied = [...applied, entry];
redo = redo.slice(0, -1);
} else {
const entry = metrics(command);
if (!entry) return checkpointJournal(candidateBaseline);
applied = [...applied, entry as JournalEntry<C>];
redo = [];
}
const next = fold(journal.baseline, applied, redo),
over =
next.applied.length > FXNODE_SAVE_DATA_LIMITS.maxCommands ||
next.atomicCommands > FXNODE_SAVE_DATA_LIMITS.maxAtomicCommands ||
next.values > FXNODE_SAVE_DATA_LIMITS.maxValues ||
next.stringCodeUnits > FXNODE_SAVE_DATA_LIMITS.maxStringCodeUnits ||
next.depth > FXNODE_SAVE_DATA_LIMITS.maxDepth;
return over ||
!strictReplayValid(
next.baseline,
next.applied.map((entry) => entry.command),
)
? checkpointJournal(candidateBaseline)
: next;
}
+42
View File
@@ -0,0 +1,42 @@
import type { LinkId, Vec2 } from "../core/types.js";
import { worldToView } from "../layout/geometry.js";
import type { LayoutSnapshot } from "../layout/types.js";
export const MAX_KNIFE_POINTS = 256;
export function appendKnifePoint(points: readonly Vec2[], point: Vec2, minimumDistance = 2): readonly Vec2[] {
const last = points.at(-1);
if (last && Math.hypot(last.x - point.x, last.y - point.y) < minimumDistance) return points;
return points.length < MAX_KNIFE_POINTS ? [...points, point] : [...points.slice(1), point];
}
const orient = (a: Vec2, b: Vec2, c: Vec2) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
const between = (a: number, b: number, x: number) => x >= Math.min(a, b) - 1e-7 && x <= Math.max(a, b) + 1e-7;
export function segmentsIntersect(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean {
const abC = orient(a, b, c),
abD = orient(a, b, d),
cdA = orient(c, d, a),
cdB = orient(c, d, b);
if (((abC > 0 && abD < 0) || (abC < 0 && abD > 0)) && ((cdA > 0 && cdB < 0) || (cdA < 0 && cdB > 0))) return true;
return (
(Math.abs(abC) < 1e-7 && between(a.x, b.x, c.x) && between(a.y, b.y, c.y)) ||
(Math.abs(abD) < 1e-7 && between(a.x, b.x, d.x) && between(a.y, b.y, d.y)) ||
(Math.abs(cdA) < 1e-7 && between(c.x, d.x, a.x) && between(c.y, d.y, a.y)) ||
(Math.abs(cdB) < 1e-7 && between(c.x, d.x, b.x) && between(c.y, d.y, b.y))
);
}
export function crossedLinks(layout: LayoutSnapshot, path: readonly Vec2[], includeMuted = false): Set<LinkId> {
const result = new Set<LinkId>();
if (path.length < 2) return result;
const planned = layout as LayoutSnapshot & { candidateLinkIds?: readonly LinkId[] };
for (const id of planned.candidateLinkIds ?? layout.links.keys()) {
const link = layout.links.get(id);
if (!link?.visible || (!includeMuted && link.muted)) continue;
const samples = link.points.map((p) => worldToView(p, layout.transform));
outer: for (let i = 1; i < path.length; i++)
for (let j = 1; j < samples.length; j++)
if (segmentsIntersect(path[i - 1]!, path[i]!, samples[j - 1]!, samples[j]!)) {
result.add(id);
break outer;
}
}
return result;
}
+92
View File
@@ -0,0 +1,92 @@
export const enum DirtyReason {
Scene = 1,
Camera = 2,
Selection = 4,
Preview = 8,
Viewport = 16,
Barrier = 32,
HostInteraction = 64,
}
export interface SchedulerMetrics {
requests: number;
coalesced: number;
frames: number;
maxInFlight: number;
staleAcks: number;
}
type InvalidationTarget = { readonly scheduler: Pick<RenderScheduler, "request"> };
/** Worker-internal bridge from atlas invalidations to repaint requests. */
export function requestViewInvalidations(
viewIds: readonly string[],
views: ReadonlyMap<string, InvalidationTarget>,
): void {
for (const viewId of viewIds) views.get(viewId)?.scheduler.request(0, DirtyReason.Viewport);
}
export class RenderScheduler {
private dirty = 0;
private inFlight: number | undefined;
private scheduled = false;
private running = false;
private latestRenderId = 0;
private nextFrameId = 1;
private poll = () => {};
readonly metrics: SchedulerMetrics = { requests: 0, coalesced: 0, frames: 0, maxInFlight: 0, staleAcks: 0 };
constructor(
private readonly draw: (frameId: number, renderId: number, reasons: number) => void,
private readonly enqueue: (callback: () => void) => void = (callback) => {
const raf = (globalThis as { requestAnimationFrame?: (cb: () => void) => void }).requestAnimationFrame;
raf ? raf(callback) : setTimeout(callback, 16);
},
) {}
start(poll: () => void = () => {}): void {
if (this.running) return;
this.running = true;
this.poll = poll;
this.schedule();
}
stop(): void {
this.running = false;
}
request(renderId = this.latestRenderId, reason: DirtyReason = DirtyReason.Scene): void {
this.metrics.requests++;
this.latestRenderId = Math.max(this.latestRenderId, renderId);
if (this.dirty || this.inFlight !== undefined) this.metrics.coalesced++;
this.dirty |= reason;
}
consumed(frameId: number): void {
if (frameId !== this.inFlight) {
this.metrics.staleAcks++;
return;
}
this.inFlight = undefined;
}
defer(frameId: number, reasons: number): void {
if (frameId !== this.inFlight) return;
this.inFlight = undefined;
this.dirty |= reasons;
}
private schedule(): void {
if (!this.running || this.scheduled) return;
this.scheduled = true;
this.enqueue(() => {
this.scheduled = false;
if (!this.running) return;
try {
this.poll();
if (this.dirty && this.inFlight === undefined) {
const reasons = this.dirty;
this.dirty = 0;
const frameId = this.nextFrameId++;
this.inFlight = frameId;
this.metrics.frames++;
this.metrics.maxInFlight = Math.max(this.metrics.maxInFlight, 1);
this.draw(frameId, this.latestRenderId, reasons);
}
} finally {
this.schedule();
}
});
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { BatchCommand, Command } from "../commands/types.js";
import type { GraphDocument, LinkId, NodeId } from "../core/types.js";
/** Deterministic removal: selected links not incident to selected nodes, then nodes. */
export function planSelectionRemoval(
document: GraphDocument,
selectedNodes: ReadonlySet<NodeId>,
selectedLinks: ReadonlySet<LinkId>,
): Command | null {
const nodes = [...selectedNodes].filter((id) => document.nodes[id]).sort();
const nodeSet = new Set(nodes);
const links = [...selectedLinks]
.filter((id) => {
const link = document.links[id];
return !!link && !nodeSet.has(link.fromNodeId) && !nodeSet.has(link.toNodeId);
})
.sort();
const commands: BatchCommand[] = [
...links.map((id) => ({ type: "link.remove" as const, id })),
...nodes.map((id) => ({ type: "node.remove" as const, id })),
];
return { type: "batch", commands };
}
/** Only known standard nodes can be muted. Omitted desired state toggles uniformly (mixed => mute all). */
export function planSelectionMute(
document: GraphDocument,
selectedNodes: ReadonlySet<NodeId>,
isStandard: (id: NodeId) => boolean,
desired?: boolean,
): Command | null {
const nodes = [...selectedNodes].filter((id) => document.nodes[id]?.known && isStandard(id)).sort();
const value = desired ?? nodes.some((id) => !document.nodes[id]!.muted);
const commands: BatchCommand[] = nodes
.filter((id) => document.nodes[id]!.muted !== value)
.map((id) => ({ type: "node.mute", id, value }));
return { type: "batch", commands };
}
+151
View File
@@ -0,0 +1,151 @@
import type { LinkId, NodeId, ParameterValue, Vec2 } from "../core/types.js";
import type { ColorRamp } from "../widgets/color-ramp.js";
import type { Oklch, Rgba } from "../color/oklab.js";
import type { ColorPickerLayout } from "../layout/types.js";
export interface DragSession {
readonly pointerId: number;
readonly startView: Vec2;
readonly startWorld: Vec2;
readonly origins: ReadonlyMap<NodeId, Vec2>;
moved: boolean;
}
export interface CollapseAnimation {
from: number;
to: 0 | 1;
value: number;
startedAt: number;
durationMs: number;
}
export type ControlEdit =
| { kind: "string"; controlId: string; buffer: string }
| { kind: "number"; controlId: string; component: number; buffer: string; selectAll: boolean };
export interface WorkerSession {
knife?: { pointerId: number; points: readonly Vec2[]; crossed: Set<LinkId>; mode: "remove" | "mute" };
}
export interface WorkerSession {
colorPicker?: {
layout: ColorPickerLayout;
controlId: string;
target: { kind: "control" } | { kind: "ramp-stop"; stopId: string; original: ColorRamp };
model: Oklch;
rgba: Rgba;
hsv: readonly [number, number, number];
edit?: { field: "rgba" | "hsv" | "hex"; index: number; buffer: string; selectAll: boolean; invalid: boolean };
drag?: { pointerId: number; region: "plane" | "lightness" | "alpha" };
};
}
export interface WorkerSession {
colorWheel?: {
controlId: string;
original: ParameterValue;
model: Oklch;
rgba: Rgba;
pointerId: number;
region: "plane" | "lightness";
bounds: { x: number; y: number; width: number; height: number };
};
}
export interface WorkerSession {
cameraCenter: Vec2;
zoom: number;
selectedNodes: Set<NodeId>;
selectedLinks: Set<LinkId>;
activeNode?: NodeId;
hoverNode?: NodeId;
hoveredControl?: string;
focusedControl?: string;
hoveredRampTarget?: string;
focusedRampTarget?: string;
activeRampStopByControl: Map<string, string>;
collapseAnimations: Map<NodeId, CollapseAnimation>;
controlEdit?: ControlEdit;
previewValues: Map<string, ParameterValue>;
scrub?: {
pointerId: number;
controlId: string;
component: number;
startX: number;
original: ParameterValue;
moved: boolean;
};
rampDrag?: { pointerId: number; controlId: string; stopId: string; original: ParameterValue };
reroutePress?: { pointerId: number; nodeId: NodeId; socketId: import("../core/types.js").SocketId; start: Vec2 };
uiOrder: NodeId[];
previewPositions: Map<NodeId, Vec2>;
previewSizes: Map<NodeId, Vec2>;
pointer?: Vec2;
drag?: DragSession;
modalMove?: Omit<DragSession, "pointerId">;
box?: { pointerId: number; start: Vec2; current: Vec2; checkpoint: Set<NodeId>; add: boolean };
linkDrag?: {
pointerId: number;
from: import("../core/types.js").SocketId;
current: Vec2;
candidate?: import("../core/types.js").SocketId;
};
resize?: { pointerId: number; id: NodeId };
parentHighlight?: NodeId;
pan?: { pointerId: number; last: Vec2 };
}
export const createSession = (
camera: { readonly center: Vec2; readonly zoom: number } = {
center: { x: 0, y: 0 },
zoom: 1,
},
): WorkerSession => ({
cameraCenter: { ...camera.center },
zoom: camera.zoom,
selectedNodes: new Set(),
selectedLinks: new Set(),
activeRampStopByControl: new Map(),
collapseAnimations: new Map(),
uiOrder: [],
previewPositions: new Map(),
previewSizes: new Map(),
previewValues: new Map(),
});
function resetDocumentTransients(session: WorkerSession): void {
delete session.hoverNode;
delete session.hoveredControl;
delete session.focusedControl;
delete session.hoveredRampTarget;
delete session.focusedRampTarget;
delete session.knife;
delete session.drag;
delete session.scrub;
delete session.rampDrag;
delete session.reroutePress;
delete session.modalMove;
delete session.box;
delete session.linkDrag;
delete session.resize;
delete session.parentHighlight;
delete session.pan;
delete session.controlEdit;
delete session.colorPicker;
delete session.colorWheel;
session.activeRampStopByControl.clear();
session.collapseAnimations.clear();
session.previewValues.clear();
session.previewPositions.clear();
session.previewSizes.clear();
}
export function resetSessionForGraphReplacement(session: WorkerSession): void {
session.selectedNodes.clear();
session.selectedLinks.clear();
session.uiOrder = [];
delete session.activeNode;
resetDocumentTransients(session);
}
export function resetSessionForCompositionRebind(
session: WorkerSession,
nodeIds: ReadonlySet<NodeId>,
linkIds: ReadonlySet<LinkId>,
retainedUiOrder: readonly NodeId[],
): void {
session.selectedNodes = new Set([...session.selectedNodes].filter((id) => nodeIds.has(id)));
session.selectedLinks = new Set([...session.selectedLinks].filter((id) => linkIds.has(id)));
session.uiOrder = [...retainedUiOrder];
if (session.activeNode && !nodeIds.has(session.activeNode)) delete session.activeNode;
resetDocumentTransients(session);
}
+428
View File
@@ -0,0 +1,428 @@
import {
planAtlasCompaction,
planAtlasUpsert,
removeAtlasItem,
type AtlasLayout,
type AtlasRect,
type AtlasSize,
} from "./atlas-allocator.js";
export type AtlasErrorCode =
| "atlas.dimension"
| "atlas.capacity"
| "atlas.create"
| "atlas.context"
| "atlas.crop"
| "atlas.context-lost";
export class ViewAtlasError extends Error {
override readonly name = "ViewAtlasError";
constructor(
message: string,
readonly code: AtlasErrorCode,
readonly fatal = false,
options?: ErrorOptions,
) {
super(message, options);
}
}
export interface ViewAtlasSlot extends AtlasRect {
readonly viewId: string;
readonly slotGeneration: number;
}
export interface ViewAtlasSurface {
readonly canvas: OffscreenCanvas;
readonly context: OffscreenCanvasRenderingContext2D;
readonly width: number;
readonly height: number;
readonly atlasGeneration: number;
readonly allocationEpoch: number;
}
export interface ViewAtlasMutation {
readonly slot: ViewAtlasSlot;
readonly movedViewIds: readonly string[];
readonly invalidatedViewIds: readonly string[];
readonly atlasGeneration: number;
readonly allocationEpoch: number;
}
export interface ViewAtlasDetachResult {
readonly invalidatedViewIds: readonly string[];
}
export interface ViewAtlasPaintTarget {
readonly context: OffscreenCanvasRenderingContext2D;
readonly deviceX: number;
readonly deviceY: number;
readonly deviceWidth: number;
readonly deviceHeight: number;
}
export interface ViewAtlasPlatform {
createCanvas(width: number, height: number): OffscreenCanvas;
createBitmap(canvas: OffscreenCanvas, x: number, y: number, width: number, height: number): Promise<ImageBitmap>;
}
const browserPlatform: ViewAtlasPlatform = {
createCanvas: (width, height) => new OffscreenCanvas(width, height),
createBitmap: (canvas, x, y, width, height) => createImageBitmap(canvas, x, y, width, height),
};
export class ViewAtlasManager {
private layout: AtlasLayout | undefined;
private canvas: OffscreenCanvas | undefined;
private context: OffscreenCanvasRenderingContext2D | undefined;
private atlasGeneration = 0;
private allocationEpoch = 0;
private readonly slotGenerations = new Map<string, number>();
private queue: Promise<void> = Promise.resolve();
private disposed = false;
private lifecycle = 0;
private lost = false;
private surfaceTransition = false;
private terminalError: ViewAtlasError | undefined;
private contextLossEpoch = 0;
private readonly contextLost = (event: Event) => {
if ("preventDefault" in event) event.preventDefault();
this.contextLossEpoch++;
this.lost = true;
};
private readonly contextRestored = (event: Event) => {
const canvas = this.canvas;
if (!canvas || event.currentTarget !== canvas) return;
const lifecycle = this.lifecycle,
lossEpoch = this.contextLossEpoch;
void this.serial(async () => {
if (this.canvas !== canvas || lossEpoch !== this.contextLossEpoch) return;
const context = this.getContext(canvas);
await this.probe(canvas);
this.ensureLifecycle(lifecycle);
if (this.canvas !== canvas || lossEpoch !== this.contextLossEpoch) return;
this.context = context;
this.lost = false;
this.atlasGeneration++;
this.allocationEpoch++;
for (const id of this.layout?.items.keys() ?? []) this.bumpSlot(id);
}).catch((cause) => {
if (
this.disposed ||
lifecycle !== this.lifecycle ||
this.canvas !== canvas ||
lossEpoch !== this.contextLossEpoch
)
return;
const error = new ViewAtlasError("Unable to restore the view atlas", "atlas.context-lost", true, { cause });
this.terminalError = error;
this.onFatal(error);
});
};
constructor(
private readonly platform: ViewAtlasPlatform = browserPlatform,
private readonly onFatal: (error: ViewAtlasError) => void = () => {},
) {}
surface(): ViewAtlasSurface | undefined {
if (!this.canvas || !this.context || this.lost || this.surfaceTransition || this.terminalError) return;
return Object.freeze({
canvas: this.canvas,
context: this.context,
width: this.canvas.width,
height: this.canvas.height,
atlasGeneration: this.atlasGeneration,
allocationEpoch: this.allocationEpoch,
});
}
slot(viewId: string): ViewAtlasSlot | undefined {
const region = this.layout?.regions.get(viewId),
size = this.layout?.items.get(viewId),
generation = this.slotGenerations.get(viewId);
if (!region || !size || generation === undefined) return;
return Object.freeze({
viewId,
x: region.x,
y: region.y,
width: size.width,
height: size.height,
slotGeneration: generation,
});
}
attach(viewId: string, size: AtlasSize): Promise<ViewAtlasMutation> {
return this.mutate(viewId, size, false);
}
resize(viewId: string, size: AtlasSize): Promise<ViewAtlasMutation> {
return this.mutate(viewId, size, true);
}
renderAndCrop(
viewId: string,
expected: AtlasSize,
paint: (target: ViewAtlasPaintTarget) => void,
): Promise<ImageBitmap | undefined> {
return this.serial(async () => {
const canvas = this.canvas,
context = this.context,
slot = this.slot(viewId),
lifecycle = this.lifecycle,
atlasGeneration = this.atlasGeneration,
lossEpoch = this.contextLossEpoch;
if (
!canvas ||
!context ||
!slot ||
this.lost ||
this.surfaceTransition ||
slot.width !== expected.width ||
slot.height !== expected.height
)
return;
context.save();
try {
context.setTransform(1, 0, 0, 1, 0, 0);
context.beginPath();
context.rect(slot.x, slot.y, slot.width, slot.height);
context.clip();
context.clearRect(slot.x, slot.y, slot.width, slot.height);
paint({
context,
deviceX: slot.x,
deviceY: slot.y,
deviceWidth: slot.width,
deviceHeight: slot.height,
});
} finally {
context.restore();
}
const bitmap = await this.platform.createBitmap(canvas, slot.x, slot.y, slot.width, slot.height);
const current = this.slot(viewId);
if (
this.disposed ||
lifecycle !== this.lifecycle ||
this.canvas !== canvas ||
this.context !== context ||
this.lost ||
lossEpoch !== this.contextLossEpoch ||
atlasGeneration !== this.atlasGeneration ||
!current ||
current.slotGeneration !== slot.slotGeneration ||
bitmap.width !== expected.width ||
bitmap.height !== expected.height
) {
bitmap.close();
return;
}
return bitmap;
});
}
detach(viewId: string): Promise<ViewAtlasDetachResult> {
return this.serial(async () => {
const lifecycle = this.lifecycle;
if (!this.layout?.items.has(viewId)) return Object.freeze({ invalidatedViewIds: Object.freeze([]) });
const previous = this.layout,
next = removeAtlasItem(previous, viewId);
this.slotGenerations.delete(viewId);
this.allocationEpoch++;
if (!next) {
this.unregisterContextEvents(this.canvas);
this.layout = this.canvas = this.context = undefined;
this.lost = false;
this.lifecycle++;
this.atlasGeneration++;
return Object.freeze({ invalidatedViewIds: Object.freeze([]) });
}
this.layout = next;
const compact = planAtlasCompaction(next);
if (compact?.ok && (compact.layout.width !== next.width || compact.layout.height !== next.height))
try {
this.surfaceTransition = true;
await this.resizeSurface(compact.layout.width, compact.layout.height);
this.layout = compact.layout;
for (const id of compact.layout.items.keys()) this.bumpSlot(id);
this.ensureLifecycle(lifecycle);
return Object.freeze({
invalidatedViewIds: Object.freeze([...compact.layout.items.keys()].sort()),
});
} catch (error) {
this.ensureLifecycle(lifecycle);
this.layout = next;
if (error instanceof ViewAtlasError && error.fatal) throw error;
for (const id of next.items.keys()) this.bumpSlot(id);
return Object.freeze({ invalidatedViewIds: Object.freeze([...next.items.keys()].sort()) });
} finally {
this.surfaceTransition = false;
}
this.ensureLifecycle(lifecycle);
return Object.freeze({ invalidatedViewIds: Object.freeze([]) });
});
}
dispose(): void {
this.disposed = true;
this.lifecycle++;
this.unregisterContextEvents(this.canvas);
this.layout = this.canvas = this.context = undefined;
this.slotGenerations.clear();
this.allocationEpoch++;
this.atlasGeneration++;
}
private mutate(viewId: string, size: AtlasSize, mustExist: boolean): Promise<ViewAtlasMutation> {
return this.serial(async () => {
const lifecycle = this.lifecycle;
if (mustExist && !this.layout?.items.has(viewId))
throw new ViewAtlasError("View is not allocated in the atlas", "atlas.capacity");
if (!mustExist && this.layout?.items.has(viewId))
throw new ViewAtlasError("View is already allocated in the atlas", "atlas.capacity");
const previous = this.layout,
plan = planAtlasUpsert(previous, { id: viewId, ...size });
if (!plan.ok)
throw new ViewAtlasError(
plan.code === "atlas.dimension" ? "View dimensions exceed atlas limits" : "View atlas capacity exceeded",
plan.code,
);
const resized =
!this.canvas || this.canvas.width !== plan.layout.width || this.canvas.height !== plan.layout.height;
if (resized) this.surfaceTransition = true;
try {
if (resized) await this.resizeSurface(plan.layout.width, plan.layout.height);
this.ensureLifecycle(lifecycle);
this.layout = plan.layout;
this.allocationEpoch++;
const changed = new Set(plan.movedIds);
if (
!previous ||
previous.items.get(viewId)?.width !== size.width ||
previous.items.get(viewId)?.height !== size.height
)
changed.add(viewId);
if (resized) for (const id of plan.layout.items.keys()) changed.add(id);
for (const id of changed) this.bumpSlot(id);
const slot = this.slot(viewId)!;
return Object.freeze({
slot,
movedViewIds: Object.freeze(plan.movedIds.slice()),
invalidatedViewIds: Object.freeze([...changed].sort()),
atlasGeneration: this.atlasGeneration,
allocationEpoch: this.allocationEpoch,
});
} finally {
if (resized) this.surfaceTransition = false;
}
});
}
private bumpSlot(viewId: string): void {
this.slotGenerations.set(viewId, (this.slotGenerations.get(viewId) ?? 0) + 1);
}
private async resizeSurface(width: number, height: number): Promise<void> {
const lifecycle = this.lifecycle;
if (!this.canvas) {
let canvas: OffscreenCanvas;
try {
canvas = this.platform.createCanvas(width, height);
} catch (cause) {
throw new ViewAtlasError("Unable to create the view atlas", "atlas.create", false, { cause });
}
if (canvas.width !== width || canvas.height !== height)
throw new ViewAtlasError("Atlas dimensions were not accepted", "atlas.create");
this.registerContextEvents(canvas);
const lossEpoch = this.contextLossEpoch;
try {
const context = this.getContext(canvas);
await this.probe(canvas);
this.ensureLifecycle(lifecycle);
if (lossEpoch !== this.contextLossEpoch || this.lost)
throw new ViewAtlasError("View atlas context was lost during creation", "atlas.context-lost");
this.canvas = canvas;
this.context = context;
this.atlasGeneration++;
return;
} catch (error) {
this.unregisterContextEvents(canvas);
this.lost = false;
throw error;
}
}
const canvas = this.canvas,
oldWidth = canvas.width,
oldHeight = canvas.height,
lossEpoch = this.contextLossEpoch;
try {
try {
canvas.width = width;
canvas.height = height;
} catch (cause) {
throw new ViewAtlasError("Unable to resize the view atlas", "atlas.create", false, { cause });
}
if (canvas.width !== width || canvas.height !== height)
throw new ViewAtlasError("Atlas dimensions were not accepted", "atlas.create");
const context = this.getContext(canvas);
await this.probe(canvas);
this.ensureLifecycle(lifecycle);
if (lossEpoch !== this.contextLossEpoch || this.lost)
throw new ViewAtlasError("View atlas context was lost while resizing", "atlas.context-lost");
this.context = context;
this.atlasGeneration++;
} catch (cause) {
if (this.disposed || lifecycle !== this.lifecycle) throw cause;
try {
canvas.width = oldWidth;
canvas.height = oldHeight;
if (canvas.width !== oldWidth || canvas.height !== oldHeight)
throw new ViewAtlasError("Atlas dimensions could not be restored", "atlas.context-lost", true);
this.context = this.getContext(canvas);
await this.probe(canvas);
this.ensureLifecycle(lifecycle);
this.atlasGeneration++;
// Resizing clears the canvas even when the old dimensions are restored.
// Make every surviving slot stale so callers cannot reuse its old pixels.
for (const id of this.layout?.items.keys() ?? []) this.bumpSlot(id);
} catch (restoreCause) {
this.unregisterContextEvents(canvas);
this.canvas = this.context = undefined;
throw new ViewAtlasError("Unable to restore the view atlas", "atlas.context-lost", true, {
cause: restoreCause,
});
}
throw cause;
}
}
private getContext(canvas: OffscreenCanvas): OffscreenCanvasRenderingContext2D {
let context: OffscreenCanvasRenderingContext2D | null;
try {
context = canvas.getContext("2d");
} catch (cause) {
throw new ViewAtlasError("Unable to create the atlas 2D context", "atlas.context", false, { cause });
}
if (!context) throw new ViewAtlasError("Atlas 2D context is unavailable", "atlas.context");
return context;
}
private async probe(canvas: OffscreenCanvas): Promise<void> {
let bitmap: ImageBitmap;
try {
bitmap = await this.platform.createBitmap(canvas, 0, 0, 1, 1);
} catch (cause) {
throw new ViewAtlasError("Cropped atlas bitmaps are unavailable", "atlas.crop", false, { cause });
}
try {
if (bitmap.width !== 1 || bitmap.height !== 1 || typeof bitmap.close !== "function")
throw new ViewAtlasError("Cropped atlas bitmap capability is invalid", "atlas.crop");
} finally {
bitmap.close?.();
}
}
private ensureLifecycle(lifecycle: number): void {
if (this.disposed || lifecycle !== this.lifecycle)
throw new ViewAtlasError("View atlas operation was cancelled", "atlas.context-lost", true);
}
private registerContextEvents(canvas: OffscreenCanvas): void {
canvas.addEventListener?.("contextlost", this.contextLost);
canvas.addEventListener?.("contextrestored", this.contextRestored);
}
private unregisterContextEvents(canvas: OffscreenCanvas | undefined): void {
canvas?.removeEventListener?.("contextlost", this.contextLost);
canvas?.removeEventListener?.("contextrestored", this.contextRestored);
}
private serial<T>(operation: () => Promise<T>): Promise<T> {
const result = this.queue.then(() => {
if (this.terminalError) throw this.terminalError;
if (this.disposed) throw new ViewAtlasError("View atlas has been disposed", "atlas.context-lost", true);
return operation();
});
this.queue = result.then(
() => {},
() => {},
);
return result;
}
}