perf(document): index layer read paths
This commit is contained in:
105
editor/document-indexes.test.ts
Normal file
105
editor/document-indexes.test.ts
Normal 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
164
editor/document-indexes.ts
Normal 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user