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:
+260
@@ -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
@@ -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;
|
||||
}
|
||||
+2689
File diff suppressed because it is too large
Load Diff
+292
@@ -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;
|
||||
});
|
||||
}
|
||||
Vendored
+103
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
Vendored
+151
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user