import type { Asset } from "@core/asset"; import type { ImageDocument } from "@core/document"; import type { Rect } from "@core/geometry"; import type { AssetId, LayerId } from "@core/id"; import type { Layer } from "@core/layer"; export type RasterThumbnailModel = { kind: "raster"; source: string; viewBox: Rect; intrinsicWidth: number; intrinsicHeight: number; }; export type LayerThumbnailModel = | RasterThumbnailModel | { kind: "adjustment" } | { kind: "group"; previews: readonly RasterThumbnailModel[] } | { kind: "empty" }; /** Builds a small, read-only preview descriptor without decoding or rerasterizing assets. */ export function resolveLayerThumbnail( layer: Layer, assetById: ReadonlyMap, excludedLayerIds: ReadonlySet = new Set(), ): LayerThumbnailModel { if (layer.type === "adjustment") return { kind: "adjustment" }; if (layer.type !== "group") return resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" }; const previews: RasterThumbnailModel[] = []; const pending = [...layer.children].reverse(); while (pending.length > 0 && previews.length < 3) { const child = pending.pop(); if (!child || excludedLayerIds.has(child.id) || !child.visible) continue; if (child.type === "group") { pending.push(...[...child.children].reverse()); continue; } const preview = resolveRasterThumbnail(child, assetById); if (preview) previews.push(preview); } return { kind: "group", previews }; } /** Resolves every layer once, including group mosaics, for linear tree rendering. */ export function createLayerThumbnailIndex( document: ImageDocument, assetById: ReadonlyMap, excludedLayerIds: ReadonlySet = new Set(), ): ReadonlyMap { const result = new Map(); const pending: Array<{ layer: Layer; visited: boolean }> = []; for (const artboard of document.artboards) for (const layer of artboard.layers) pending.push({ layer, visited: false }); while (pending.length > 0) { const frame = pending.pop(); if (!frame) continue; const { layer } = frame; if (frame.visited || layer.type !== "group") { if (layer.type !== "group") { if (layer.type === "adjustment") { result.set(layer.id, { kind: "adjustment" }); continue; } result.set(layer.id, resolveRasterThumbnail(layer, assetById) ?? { kind: "empty" }); continue; } const previews: RasterThumbnailModel[] = []; for (const child of layer.children) { if (previews.length >= 3) break; if (excludedLayerIds.has(child.id) || !child.visible) continue; const childModel = result.get(child.id); if (childModel?.kind === "raster") previews.push(childModel); if (childModel?.kind === "group") previews.push(...childModel.previews.slice(0, 3 - previews.length)); } result.set(layer.id, { kind: "group", previews }); continue; } pending.push({ layer, visited: true }); for (let index = layer.children.length - 1; index >= 0; index -= 1) { const child = layer.children[index]; if (child) pending.push({ layer: child, visited: false }); } } return result; } function resolveRasterThumbnail(layer: Exclude, assetById: ReadonlyMap): RasterThumbnailModel | undefined { if (layer.type === "adjustment") return undefined; const asset = assetById.get(layer.assetId); if (!asset || !asset.source.trim() || !positiveFinite(asset.intrinsicSize.w) || !positiveFinite(asset.intrinsicSize.h)) return undefined; const fullRect = { x: 0, y: 0, w: asset.intrinsicSize.w, h: asset.intrinsicSize.h }; const viewBox = layer.sourceRect ? intersectRect(layer.sourceRect, fullRect) : fullRect; if (!viewBox) return undefined; return { kind: "raster", source: asset.source, viewBox, intrinsicWidth: asset.intrinsicSize.w, intrinsicHeight: asset.intrinsicSize.h, }; } function intersectRect(rect: Rect, bounds: Rect): Rect | undefined { if (![rect.x, rect.y, rect.w, rect.h].every(Number.isFinite) || rect.w <= 0 || rect.h <= 0) return undefined; const x = Math.max(rect.x, bounds.x); const y = Math.max(rect.y, bounds.y); const right = Math.min(rect.x + rect.w, bounds.x + bounds.w); const bottom = Math.min(rect.y + rect.h, bounds.y + bounds.h); return right > x && bottom > y ? { x, y, w: right - x, h: bottom - y } : undefined; } function positiveFinite(value: number) { return Number.isFinite(value) && value > 0; }