feat: add contextual layer inspector

This commit is contained in:
syntaxbullet
2026-07-11 12:07:36 +02:00
parent bef2fb3051
commit 4d7358af4b
14 changed files with 405 additions and 42 deletions

View File

@@ -1,14 +1,15 @@
import { createMaskedProgram, createMaskRevealPreviewProgram, createProgram, createTintedProgram, getMaskVisualizationResources, maskVisualizationModeValue, type MaskVisualizationResources } from "./image-texture-programs";
import type { Asset } from "@core/asset";
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
export type ImageTextureRenderer = {
syncAssets(assets: readonly Asset[]): void;
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean;
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number): boolean;
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number): boolean;
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number): boolean;
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number, rotation?: number): boolean;
renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean;
renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect, opacity?: number): boolean;
dispose(): void;
@@ -87,10 +88,11 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
}
}
},
render(asset, rect, clipRect, opacity = 1) {
render(asset, rect, clipRect, opacity = 1, rotation = 0) {
const clampedOpacity = clampOpacity(opacity);
if (clampedOpacity <= 0) return true;
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
const rotatedRect = rotatedRectBounds(rect, rotation);
const drawRect = clipRect ? intersectScreenRects(rotatedRect, clipRect) : rotatedRect;
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
@@ -108,7 +110,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.uniform1f(opacityLocation, clampedOpacity);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
@@ -121,11 +123,13 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND);
return true;
},
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1) {
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1, rotation = 0) {
const clampedOpacity = clampOpacity(opacity);
if (clampedOpacity <= 0) return true;
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
const contentBounds = rotatedRectBounds(rect, rotation);
const maskBounds = rotatedRectBounds(maskRect, rotation);
const clippedRect = clipRect ? intersectScreenRects(contentBounds, clipRect) : contentBounds;
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskBounds) : undefined;
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
@@ -148,17 +152,17 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.uniform1f(maskedOpacityLocation, clampedOpacity);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskedPositionLocation);
gl.vertexAttribPointer(maskedPositionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskedTexCoordLocation);
gl.vertexAttribPointer(maskedTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskedMaskTexCoordLocation);
gl.vertexAttribPointer(maskedMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
@@ -166,12 +170,14 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND);
return true;
},
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1) {
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1, rotation = 0) {
const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity);
if (clampedOpacity <= 0) return true;
const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined;
const contentBounds = rotatedRectBounds(rect, rotation);
const maskBounds = rotatedRectBounds(maskRect, rotation);
const clippedRect = clipRect ? intersectScreenRects(contentBounds, clipRect) : contentBounds;
const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskBounds) : undefined;
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
@@ -194,17 +200,17 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.uniform1f(maskRevealPreviewOpacityLocation, clampedOpacity);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskRevealPreviewPositionLocation);
gl.vertexAttribPointer(maskRevealPreviewPositionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskRevealPreviewTexCoordLocation);
gl.vertexAttribPointer(maskRevealPreviewTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskRevealPreviewMaskTexCoordLocation);
gl.vertexAttribPointer(maskRevealPreviewMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
@@ -422,11 +428,10 @@ function texCoordsForRect(drawRect: ScreenRect, sourceRect: ScreenRect) {
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
}
function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect) {
const x1 = (rect.x / canvas.width) * 2 - 1;
const x2 = ((rect.x + rect.w) / canvas.width) * 2 - 1;
const y1 = 1 - (rect.y / canvas.height) * 2;
const y2 = 1 - ((rect.y + rect.h) / canvas.height) * 2;
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect, rotation = 0) {
const [nw, ne, sw, se] = rotatedRectCorners(rect, rotation).map((point) => ({
x: (point.x / canvas.width) * 2 - 1,
y: 1 - (point.y / canvas.height) * 2,
}));
return new Float32Array([nw!.x, nw!.y, ne!.x, ne!.y, sw!.x, sw!.y, sw!.x, sw!.y, ne!.x, ne!.y, se!.x, se!.y]);
}

View File

@@ -91,17 +91,17 @@ function renderLeafLayer(
if (asset && maskAsset && maskRect && activeMaskTarget) {
if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return;
if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return;
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) {
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) {
imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect);
return;
}
}
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity)) {
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity);
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) {
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity, layer.transform.rotation);
return;
}
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) return;
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) return;
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
if (!fallbackRect) return;

View File

@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
describe("rotated rectangle geometry", () => {
test("interprets rotation as radians and keeps the center anchored", () => {
const corners = rotatedRectCorners({ x: 10, y: 20, w: 40, h: 20 }, Math.PI / 2);
expectPoint(corners[0], { x: 40, y: 10 });
expectPoint(corners[1], { x: 40, y: 50 });
expectPoint(corners[2], { x: 20, y: 10 });
expectPoint(corners[3], { x: 20, y: 50 });
expect((corners[0].x + corners[3].x) / 2).toBeCloseTo(30);
expect((corners[0].y + corners[3].y) / 2).toBeCloseTo(30);
});
test("expands axis-aligned clipping bounds around a rotated quad", () => {
const bounds = rotatedRectBounds({ x: 10, y: 20, w: 40, h: 20 }, Math.PI / 2);
expect(bounds.x).toBeCloseTo(20);
expect(bounds.y).toBeCloseTo(10);
expect(bounds.w).toBeCloseTo(20);
expect(bounds.h).toBeCloseTo(40);
});
test("preserves an unrotated rectangle exactly", () => {
const rect = { x: 10, y: 20, w: 40, h: 20 };
expect(rotatedRectBounds(rect, 0)).toBe(rect);
expect(rotatedRectCorners(rect, 0)).toEqual([
{ x: 10, y: 20 },
{ x: 50, y: 20 },
{ x: 10, y: 40 },
{ x: 50, y: 40 },
]);
});
});
function expectPoint(actual: { x: number; y: number }, expected: { x: number; y: number }) {
expect(actual.x).toBeCloseTo(expected.x);
expect(actual.y).toBeCloseTo(expected.y);
}

33
renderer/rotated-rect.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { ScreenRect } from "./types";
export type ScreenPoint = { x: number; y: number };
/** Returns corners in northwest, northeast, southwest, southeast order. */
export function rotatedRectCorners(rect: ScreenRect, rotation: number): [ScreenPoint, ScreenPoint, ScreenPoint, ScreenPoint] {
if (!Number.isFinite(rotation)) rotation = 0;
const center = { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 };
const cosine = Math.cos(rotation);
const sine = Math.sin(rotation);
const rotate = (point: ScreenPoint): ScreenPoint => {
const x = point.x - center.x;
const y = point.y - center.y;
return { x: center.x + x * cosine - y * sine, y: center.y + x * sine + y * cosine };
};
return [
rotate({ x: rect.x, y: rect.y }),
rotate({ x: rect.x + rect.w, y: rect.y }),
rotate({ x: rect.x, y: rect.y + rect.h }),
rotate({ x: rect.x + rect.w, y: rect.y + rect.h }),
];
}
/** Axis-aligned screen bounds used to constrain WebGL scissoring around a rotated quad. */
export function rotatedRectBounds(rect: ScreenRect, rotation: number): ScreenRect {
if (rotation === 0) return rect;
const points = rotatedRectCorners(rect, rotation);
const xs = points.map((point) => point.x);
const ys = points.map((point) => point.y);
const x = Math.min(...xs);
const y = Math.min(...ys);
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
}