perf(document): index layer read paths

This commit is contained in:
syntaxbullet
2026-07-04 15:58:38 +02:00
parent 1acfcbe4a5
commit 88208ea1ad
5 changed files with 323 additions and 93 deletions

View File

@@ -0,0 +1,105 @@
import { describe, expect, test } from "bun:test";
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds } from "./document-indexes";
const document: ImageDocument = {
id: "d1",
name: "Indexed Document",
version: 1,
assets: [
{ id: "asset-target", name: "Target", mimeType: "image/png", source: "asset://target", intrinsicSize: { w: 100, h: 50 } },
{ id: "asset-mask", name: "Mask", mimeType: "image/png", source: "asset://mask", intrinsicSize: { w: 100, h: 50 } },
{ id: "asset-nested", name: "Nested", mimeType: "image/png", source: "asset://nested", intrinsicSize: { w: 20, h: 10 } },
],
artboards: [
{
id: "a1",
name: "Artboard",
bounds: { x: 0, y: 0, w: 400, h: 300 },
backgroundColor: "transparent",
visible: true,
locked: false,
layers: [
raster("mask", "Mask", "asset-mask"),
{ ...raster("target", "Target", "asset-target", { x: 10, y: 20 }, { x: 0.5, y: 0.5 }), clippingMask: { maskLayerId: "mask" } },
group("group", "Group", [
raster("nested-mask", "Nested Mask", "asset-mask"),
{ ...raster("nested-target", "Nested Target", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 }), clippingMask: { maskLayerId: "nested-mask" } },
]),
],
},
],
};
describe("document read indexes", () => {
test("indexes assets, layers, layer info, masks, and display counts", () => {
const index = createDocumentReadIndex(document);
expect(index.assetById.get("asset-target")).toBe(document.assets[0]);
expect(index.layerById.get("nested-target")?.name).toBe("Nested Target");
expect(index.layerInfoById.get("target")).toMatchObject({ artboardId: "a1", index: 1 });
expect(index.layerInfoById.get("nested-target")).toMatchObject({ artboardId: "a1", parentGroupId: "group", index: 1 });
expect(index.maskLayerIds).toEqual(new Set(["mask", "nested-mask"]));
expect(index.maskLayerIdsByArtboardId.get("a1")).toEqual(new Set(["mask", "nested-mask"]));
expect(index.maskLayerIdsByLayerList.get(document.artboards[0]!.layers)).toEqual(new Set(["mask", "nested-mask"]));
expect(index.maskLayerIdsByLayerList.get(groupLayer(document, "group").children)).toEqual(new Set(["nested-mask"]));
expect(index.displayLayerCountByArtboardId.get("a1")).toBe(3);
});
test("resolves layer bounds from indexed assets without scanning the document", () => {
const index = createDocumentReadIndex(document);
expect(resolveIndexedLayerBounds(index, "target")).toEqual({ x: 10, y: 20, w: 50, h: 25 });
expect(resolveIndexedLayerBounds(index, "group")).toEqual({ x: 0, y: 0, w: 120, h: 50 });
expect(resolveIndexedLayerBounds(index, raster("missing", "Missing", "missing-asset"))).toBeUndefined();
});
test("visits layers back to front without mutating source order", () => {
const layers = document.artboards[0]!.layers;
const visited: string[] = [];
forEachLayerBackToFront(layers, (layer) => visited.push(layer.id));
expect(visited).toEqual(["group", "target", "mask"]);
expect(layers.map((layer) => layer.id)).toEqual(["mask", "target", "group"]);
});
});
function raster(
id: string,
name: string,
assetId: string,
position = { x: 0, y: 0 },
scale = { x: 1, y: 1 },
): Extract<Layer, { type: "raster" }> {
return {
id,
type: "raster",
name,
visible: true,
locked: false,
opacity: 1,
assetId,
transform: { position, scale, rotation: 0 },
};
}
function group(id: string, name: string, children: Layer[]): Extract<Layer, { type: "group" }> {
return {
id,
type: "group",
name,
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children,
};
}
function groupLayer(document: ImageDocument, id: string): Extract<Layer, { type: "group" }> {
const layer = document.artboards[0]!.layers.find((candidate) => candidate.id === id);
if (!layer || layer.type !== "group") throw new Error(`Missing group ${id}`);
return layer;
}

164
editor/document-indexes.ts Normal file
View File

@@ -0,0 +1,164 @@
import type { Asset } from "@core/asset";
import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
export type IndexedLayerInfo = {
artboardId: ArtboardId;
parentGroupId?: LayerId;
layer: Layer;
siblings: readonly Layer[];
index: number;
};
export type DocumentReadIndex = {
assetById: ReadonlyMap<AssetId, Asset>;
layerById: ReadonlyMap<LayerId, Layer>;
layerInfoById: ReadonlyMap<LayerId, IndexedLayerInfo>;
maskLayerIds: ReadonlySet<LayerId>;
maskLayerIdsByArtboardId: ReadonlyMap<ArtboardId, ReadonlySet<LayerId>>;
maskLayerIdsByLayerList: ReadonlyMap<readonly Layer[], ReadonlySet<LayerId>>;
displayLayerCountByArtboardId: ReadonlyMap<ArtboardId, number>;
};
export function createDocumentReadIndex(document: ImageDocument): DocumentReadIndex {
const assetById = new Map<AssetId, Asset>();
const layerById = new Map<LayerId, Layer>();
const layerInfoById = new Map<LayerId, IndexedLayerInfo>();
const maskLayerIds = new Set<LayerId>();
const maskLayerIdsByArtboardId = new Map<ArtboardId, ReadonlySet<LayerId>>();
const maskLayerIdsByLayerList = new Map<readonly Layer[], ReadonlySet<LayerId>>();
const displayLayerCountByArtboardId = new Map<ArtboardId, number>();
for (const asset of document.assets) assetById.set(asset.id, asset);
for (const artboard of document.artboards) {
const artboardMaskLayerIds = indexLayerTree({
layers: artboard.layers,
artboardId: artboard.id,
layerById,
layerInfoById,
documentMaskLayerIds: maskLayerIds,
maskLayerIdsByLayerList,
});
maskLayerIdsByArtboardId.set(artboard.id, artboardMaskLayerIds);
}
for (const artboard of document.artboards) {
displayLayerCountByArtboardId.set(artboard.id, countDisplayLayers(artboard.layers, maskLayerIds));
}
return {
assetById,
layerById,
layerInfoById,
maskLayerIds,
maskLayerIdsByArtboardId,
maskLayerIdsByLayerList,
displayLayerCountByArtboardId,
};
}
export function forEachLayerBackToFront(layers: readonly Layer[], visit: (layer: Layer) => void) {
for (let index = layers.length - 1; index >= 0; index -= 1) {
const layer = layers[index];
if (layer) visit(layer);
}
}
export function resolveIndexedLayerBounds(index: DocumentReadIndex, layerOrId: Layer | LayerId): Rect | undefined {
const layer = typeof layerOrId === "string" ? index.layerById.get(layerOrId) : layerOrId;
if (!layer) return undefined;
switch (layer.type) {
case "group":
return unionLayerBounds(index, layer.children);
case "image":
case "raster": {
const asset = index.assetById.get(layer.assetId);
if (!asset) return undefined;
return {
x: layer.transform.position.x,
y: layer.transform.position.y,
w: asset.intrinsicSize.w * layer.transform.scale.x,
h: asset.intrinsicSize.h * layer.transform.scale.y,
};
}
}
}
function indexLayerTree(options: {
layers: readonly Layer[];
artboardId: ArtboardId;
parentGroupId?: LayerId;
layerById: Map<LayerId, Layer>;
layerInfoById: Map<LayerId, IndexedLayerInfo>;
documentMaskLayerIds: Set<LayerId>;
maskLayerIdsByLayerList: Map<readonly Layer[], ReadonlySet<LayerId>>;
}): Set<LayerId> {
const layerListMaskLayerIds = new Set<LayerId>();
for (let index = 0; index < options.layers.length; index += 1) {
const layer = options.layers[index];
if (!layer) continue;
options.layerById.set(layer.id, layer);
options.layerInfoById.set(layer.id, {
artboardId: options.artboardId,
parentGroupId: options.parentGroupId,
layer,
siblings: options.layers,
index,
});
if (layer.clippingMask) {
options.documentMaskLayerIds.add(layer.clippingMask.maskLayerId);
layerListMaskLayerIds.add(layer.clippingMask.maskLayerId);
}
if (layer.type === "group") {
const childMaskLayerIds = indexLayerTree({
...options,
layers: layer.children,
parentGroupId: layer.id,
});
for (const maskLayerId of childMaskLayerIds) layerListMaskLayerIds.add(maskLayerId);
}
}
options.maskLayerIdsByLayerList.set(options.layers, layerListMaskLayerIds);
return layerListMaskLayerIds;
}
function countDisplayLayers(layers: readonly Layer[], maskLayerIds: ReadonlySet<LayerId>): number {
let count = 0;
for (const layer of layers) {
if (maskLayerIds.has(layer.id)) continue;
count += 1;
if (layer.type === "group") count += countDisplayLayers(layer.children, maskLayerIds);
}
return count;
}
function unionLayerBounds(index: DocumentReadIndex, layers: readonly Layer[]): Rect | undefined {
let bounds: Rect | undefined;
for (const layer of layers) {
const layerBounds = resolveIndexedLayerBounds(index, layer);
if (!layerBounds) continue;
bounds = bounds ? unionRects(bounds, layerBounds) : layerBounds;
}
return bounds;
}
function unionRects(a: Rect, b: Rect): Rect {
const minX = Math.min(a.x, b.x);
const minY = Math.min(a.y, b.y);
const maxX = Math.max(a.x + a.w, b.x + b.w);
const maxY = Math.max(a.y + a.h, b.y + b.h);
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}

View File

@@ -4,3 +4,5 @@ export { initialToolState } from "./tools";
export { createInitialAppState, initialEditorState } from "./initial-state";
export type { AppStore, StateListener } from "./store";
export { createAppStore } from "./store";
export type { DocumentReadIndex, IndexedLayerInfo } from "./document-indexes";
export { createDocumentReadIndex, forEachLayerBackToFront, resolveIndexedLayerBounds } from "./document-indexes";