feat(renderer): enhance image rendering with asset synchronization and clipping
This commit is contained in:
@@ -31,6 +31,36 @@ describe("transform commands", () => {
|
|||||||
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 110, h: 100 });
|
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 110, h: 100 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shift constrains body moves to the dominant axis", () => {
|
||||||
|
const started = transformBeginCommand.execute(
|
||||||
|
{ state: artboardState() },
|
||||||
|
{ target: { type: "artboard", id: "a1" }, handle: "body", point: { x: 0, y: 0 }, initialBounds: { x: 0, y: 0, w: 100, h: 80 } },
|
||||||
|
);
|
||||||
|
const updated = transformUpdateCommand.execute({ state: started }, { point: { x: 10, y: 20 }, shiftKey: true });
|
||||||
|
|
||||||
|
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 0, y: 20, w: 100, h: 80 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shift constrains corner resizes to the original proportions", () => {
|
||||||
|
const started = transformBeginCommand.execute(
|
||||||
|
{ state: artboardState() },
|
||||||
|
{ target: { type: "artboard", id: "a1" }, handle: "se", point: { x: 0, y: 0 }, initialBounds: { x: 0, y: 0, w: 100, h: 80 } },
|
||||||
|
);
|
||||||
|
const updated = transformUpdateCommand.execute({ state: started }, { point: { x: 50, y: 1 }, shiftKey: true });
|
||||||
|
|
||||||
|
expect(updated.document.artboards[0]?.bounds).toEqual({ x: 0, y: 0, w: 150, h: 120 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shift keeps the opposite corner anchored during proportional resize", () => {
|
||||||
|
const started = transformBeginCommand.execute(
|
||||||
|
{ state: artboardState() },
|
||||||
|
{ target: { type: "artboard", id: "a1" }, handle: "nw", point: { x: 0, y: 0 }, initialBounds: { x: 0, y: 0, w: 100, h: 80 } },
|
||||||
|
);
|
||||||
|
const updated = transformUpdateCommand.execute({ state: started }, { point: { x: -50, y: -1 }, shiftKey: true });
|
||||||
|
|
||||||
|
expect(updated.document.artboards[0]?.bounds).toEqual({ x: -50, y: -40, w: 150, h: 120 });
|
||||||
|
});
|
||||||
|
|
||||||
test("ends transform session", () => {
|
test("ends transform session", () => {
|
||||||
const started = transformBeginCommand.execute(
|
const started = transformBeginCommand.execute(
|
||||||
{ state: artboardState() },
|
{ state: artboardState() },
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type TransformBeginPayload = {
|
|||||||
|
|
||||||
export type TransformUpdatePayload = {
|
export type TransformUpdatePayload = {
|
||||||
point: Vec2D;
|
point: Vec2D;
|
||||||
|
shiftKey?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
export const transformBeginCommand: Command<TransformBeginPayload> = {
|
||||||
@@ -41,10 +42,15 @@ export const transformUpdateCommand: Command<TransformUpdatePayload> = {
|
|||||||
const session = state.editor.transformSession;
|
const session = state.editor.transformSession;
|
||||||
if (!session) return state;
|
if (!session) return state;
|
||||||
|
|
||||||
const nextBounds = transformBounds(session.initialBounds, session.handle, {
|
const nextBounds = transformBounds(
|
||||||
x: payload.point.x - session.startPoint.x,
|
session.initialBounds,
|
||||||
y: payload.point.y - session.startPoint.y,
|
session.handle,
|
||||||
});
|
{
|
||||||
|
x: payload.point.x - session.startPoint.x,
|
||||||
|
y: payload.point.y - session.startPoint.y,
|
||||||
|
},
|
||||||
|
payload.shiftKey === true,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -71,8 +77,11 @@ export const transformEndCommand: Command = {
|
|||||||
|
|
||||||
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformEndCommand] satisfies Command<unknown>[];
|
export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformEndCommand] satisfies Command<unknown>[];
|
||||||
|
|
||||||
function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D): Rect {
|
function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect {
|
||||||
if (handle === "body") return { ...bounds, x: bounds.x + delta.x, y: bounds.y + delta.y };
|
if (handle === "body") {
|
||||||
|
const constrainedDelta = constrained ? constrainMoveDelta(delta) : delta;
|
||||||
|
return { ...bounds, x: bounds.x + constrainedDelta.x, y: bounds.y + constrainedDelta.y };
|
||||||
|
}
|
||||||
|
|
||||||
let x = bounds.x;
|
let x = bounds.x;
|
||||||
let y = bounds.y;
|
let y = bounds.y;
|
||||||
@@ -90,7 +99,31 @@ function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D): R
|
|||||||
}
|
}
|
||||||
if (handle.includes("s")) h = bounds.h + delta.y;
|
if (handle.includes("s")) h = bounds.h + delta.y;
|
||||||
|
|
||||||
return normalizeRect({ x, y, w, h });
|
const resized = normalizeRect({ x, y, w, h });
|
||||||
|
if (!constrained || !isCornerHandle(handle)) return resized;
|
||||||
|
|
||||||
|
return proportionalCornerResize(bounds, handle, resized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function constrainMoveDelta(delta: Vec2D): Vec2D {
|
||||||
|
return Math.abs(delta.x) >= Math.abs(delta.y) ? { x: delta.x, y: 0 } : { x: 0, y: delta.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCornerHandle(handle: TransformHandle) {
|
||||||
|
return handle.length === 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function proportionalCornerResize(initial: Rect, handle: TransformHandle, resized: Rect): Rect {
|
||||||
|
const aspectRatio = initial.w / initial.h;
|
||||||
|
const widthScale = resized.w / initial.w;
|
||||||
|
const heightScale = resized.h / initial.h;
|
||||||
|
const scale = Math.abs(widthScale - 1) >= Math.abs(heightScale - 1) ? widthScale : heightScale;
|
||||||
|
const w = Math.max(1, initial.w * scale);
|
||||||
|
const h = Math.max(1, w / aspectRatio);
|
||||||
|
const x = handle.includes("w") ? initial.x + initial.w - w : initial.x;
|
||||||
|
const y = handle.includes("n") ? initial.y + initial.h - h : initial.y;
|
||||||
|
|
||||||
|
return { x, y, w, h };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRect(rect: Rect): Rect {
|
function normalizeRect(rect: Rect): Rect {
|
||||||
|
|||||||
@@ -41,13 +41,14 @@ describe("transform controls input", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(controller.pointerDown(pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }))).toBe(true);
|
expect(controller.pointerDown(pointerEvent({ position: { x: 100, y: 100 }, buttons: 1 }))).toBe(true);
|
||||||
expect(controller.pointerMove(pointerEvent({ position: { x: 110, y: 120 }, buttons: 1 }))).toBe(true);
|
expect(controller.pointerMove(pointerEvent({ position: { x: 110, y: 120 }, buttons: 1, shiftKey: true }))).toBe(true);
|
||||||
expect(controller.pointerUp(pointerEvent({ position: { x: 110, y: 120 }, buttons: 0 }))).toBe(true);
|
expect(controller.pointerUp(pointerEvent({ position: { x: 110, y: 120 }, buttons: 0 }))).toBe(true);
|
||||||
expect(dispatched.map((event) => (event as { commandId: string }).commandId)).toEqual([
|
expect(dispatched.map((event) => (event as { commandId: string }).commandId)).toEqual([
|
||||||
commandIds.transformBegin,
|
commandIds.transformBegin,
|
||||||
commandIds.transformUpdate,
|
commandIds.transformUpdate,
|
||||||
commandIds.transformEnd,
|
commandIds.transformEnd,
|
||||||
]);
|
]);
|
||||||
|
expect((dispatched[1] as { payload: { shiftKey: boolean } }).payload.shiftKey).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function createTransformControlsInputController(options: {
|
|||||||
const editor = options.getEditor();
|
const editor = options.getEditor();
|
||||||
if (!editor.transformSession) return false;
|
if (!editor.transformSession) return false;
|
||||||
|
|
||||||
options.dispatch(commandIds.transformUpdate, { point: viewportPointToDocumentPoint(event.position, editor.viewport) });
|
options.dispatch(commandIds.transformUpdate, { point: viewportPointToDocumentPoint(event.position, editor.viewport), shiftKey: event.shiftKey });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
pointerUp() {
|
pointerUp() {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { Asset } from "@core/asset";
|
|||||||
import type { ScreenRect, WebGlRendererContext } from "./types";
|
import type { ScreenRect, WebGlRendererContext } from "./types";
|
||||||
|
|
||||||
export type ImageTextureRenderer = {
|
export type ImageTextureRenderer = {
|
||||||
render(asset: Asset, rect: ScreenRect): boolean;
|
syncAssets(assets: readonly Asset[]): void;
|
||||||
|
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect): boolean;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
const positionBuffer = gl.createBuffer();
|
const positionBuffer = gl.createBuffer();
|
||||||
const texCoordBuffer = gl.createBuffer();
|
const texCoordBuffer = gl.createBuffer();
|
||||||
const textures = new Map<string, TextureEntry>();
|
const textures = new Map<string, TextureEntry>();
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
if (!positionBuffer || !texCoordBuffer || !samplerLocation) throw new Error("Failed to create image texture renderer");
|
if (!positionBuffer || !texCoordBuffer || !samplerLocation) throw new Error("Failed to create image texture renderer");
|
||||||
|
|
||||||
@@ -27,11 +29,24 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]), gl.STATIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]), gl.STATIC_DRAW);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
render(asset, rect) {
|
syncAssets(assets) {
|
||||||
const entry = getTextureEntry(context, textures, asset, invalidate);
|
const activeKeys = new Set(assets.map(textureKey));
|
||||||
|
for (const [key, entry] of textures) {
|
||||||
|
if (!activeKeys.has(key)) {
|
||||||
|
disposeEntry(gl, entry);
|
||||||
|
textures.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
render(asset, rect, clipRect) {
|
||||||
|
const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect;
|
||||||
|
if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true;
|
||||||
|
|
||||||
|
const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed);
|
||||||
if (entry.status !== "ready") return false;
|
if (entry.status !== "ready") return false;
|
||||||
|
|
||||||
gl.disable(gl.SCISSOR_TEST);
|
gl.enable(gl.SCISSOR_TEST);
|
||||||
|
gl.scissor(drawRect.x, context.canvas.height - drawRect.y - drawRect.h, drawRect.w, drawRect.h);
|
||||||
gl.enable(gl.BLEND);
|
gl.enable(gl.BLEND);
|
||||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||||
gl.useProgram(program);
|
gl.useProgram(program);
|
||||||
@@ -51,13 +66,11 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
|
|||||||
|
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
gl.disable(gl.BLEND);
|
gl.disable(gl.BLEND);
|
||||||
gl.enable(gl.SCISSOR_TEST);
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
for (const entry of textures.values()) {
|
disposed = true;
|
||||||
if (entry.status === "ready") gl.deleteTexture(entry.texture);
|
for (const entry of textures.values()) disposeEntry(gl, entry);
|
||||||
}
|
|
||||||
gl.deleteBuffer(positionBuffer);
|
gl.deleteBuffer(positionBuffer);
|
||||||
gl.deleteBuffer(texCoordBuffer);
|
gl.deleteBuffer(texCoordBuffer);
|
||||||
gl.deleteProgram(program);
|
gl.deleteProgram(program);
|
||||||
@@ -70,19 +83,24 @@ function getTextureEntry(
|
|||||||
textures: Map<string, TextureEntry>,
|
textures: Map<string, TextureEntry>,
|
||||||
asset: Asset,
|
asset: Asset,
|
||||||
invalidate: () => void,
|
invalidate: () => void,
|
||||||
|
isDisposed: () => boolean,
|
||||||
): TextureEntry {
|
): TextureEntry {
|
||||||
const cached = textures.get(asset.id);
|
const key = textureKey(asset);
|
||||||
|
const cached = textures.get(key);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
textures.set(asset.id, { status: "loading", image });
|
textures.set(key, { status: "loading", image });
|
||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
|
if (isDisposed()) return;
|
||||||
const texture = createTexture(context.gl, image);
|
const texture = createTexture(context.gl, image);
|
||||||
textures.set(asset.id, { status: "ready", texture });
|
disposeEntry(context.gl, textures.get(key));
|
||||||
|
textures.set(key, { status: "ready", texture });
|
||||||
invalidate();
|
invalidate();
|
||||||
};
|
};
|
||||||
image.onerror = () => {
|
image.onerror = () => {
|
||||||
textures.set(asset.id, { status: "error" });
|
if (isDisposed()) return;
|
||||||
|
textures.set(key, { status: "error" });
|
||||||
invalidate();
|
invalidate();
|
||||||
};
|
};
|
||||||
image.src = asset.source;
|
image.src = asset.source;
|
||||||
@@ -90,6 +108,32 @@ function getTextureEntry(
|
|||||||
return { status: "loading", image };
|
return { status: "loading", image };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function textureKey(asset: Asset) {
|
||||||
|
return `${asset.id}:${asset.source}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeEntry(gl: WebGL2RenderingContext, entry: TextureEntry | undefined) {
|
||||||
|
if (!entry) return;
|
||||||
|
if (entry.status === "ready") {
|
||||||
|
gl.deleteTexture(entry.texture);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.status === "loading") {
|
||||||
|
entry.image.onload = null;
|
||||||
|
entry.image.onerror = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined {
|
||||||
|
const x1 = Math.max(a.x, b.x);
|
||||||
|
const y1 = Math.max(a.y, b.y);
|
||||||
|
const x2 = Math.min(a.x + a.w, b.x + b.w);
|
||||||
|
const y2 = Math.min(a.y + a.h, b.y + b.h);
|
||||||
|
if (x2 <= x1 || y2 <= y1) return undefined;
|
||||||
|
|
||||||
|
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
|
||||||
|
}
|
||||||
|
|
||||||
function createTexture(gl: WebGL2RenderingContext, image: HTMLImageElement) {
|
function createTexture(gl: WebGL2RenderingContext, image: HTMLImageElement) {
|
||||||
const texture = gl.createTexture();
|
const texture = gl.createTexture();
|
||||||
if (!texture) throw new Error("Failed to create image texture");
|
if (!texture) throw new Error("Failed to create image texture");
|
||||||
|
|||||||
@@ -5,22 +5,30 @@ import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
|||||||
import { clearScreenRect } from "./clear-rect";
|
import { clearScreenRect } from "./clear-rect";
|
||||||
import type { ImageTextureRenderer } from "./image-textures";
|
import type { ImageTextureRenderer } from "./image-textures";
|
||||||
import { documentRectToScreenRect } from "./screen-rect";
|
import { documentRectToScreenRect } from "./screen-rect";
|
||||||
import type { RgbaColor, WebGlRendererContext } from "./types";
|
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
|
||||||
|
|
||||||
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
|
const imageLayerColor: RgbaColor = [0.38, 0.42, 0.5, 1];
|
||||||
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1];
|
const imageLayerInsetColor: RgbaColor = [0.48, 0.54, 0.64, 1];
|
||||||
|
|
||||||
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, imageTextureRenderer: ImageTextureRenderer) {
|
export function renderLayers(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, imageTextureRenderer: ImageTextureRenderer) {
|
||||||
for (const artboard of document.artboards) {
|
for (const artboard of document.artboards) {
|
||||||
for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer);
|
const clipRect = documentRectToScreenRect(context.canvas, artboard.bounds, viewport);
|
||||||
|
for (const layer of artboard.layers) renderLayer(context, document, viewport, layer, imageTextureRenderer, clipRect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLayer(context: WebGlRendererContext, document: ImageDocument, viewport: ViewportState, layer: Layer, imageTextureRenderer: ImageTextureRenderer) {
|
function renderLayer(
|
||||||
|
context: WebGlRendererContext,
|
||||||
|
document: ImageDocument,
|
||||||
|
viewport: ViewportState,
|
||||||
|
layer: Layer,
|
||||||
|
imageTextureRenderer: ImageTextureRenderer,
|
||||||
|
clipRect: ScreenRect,
|
||||||
|
) {
|
||||||
if (!layer.visible) return;
|
if (!layer.visible) return;
|
||||||
|
|
||||||
if (layer.type === "group") {
|
if (layer.type === "group") {
|
||||||
for (const child of layer.children) renderLayer(context, document, viewport, child, imageTextureRenderer);
|
for (const child of layer.children) renderLayer(context, document, viewport, child, imageTextureRenderer, clipRect);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +37,21 @@ function renderLayer(context: WebGlRendererContext, document: ImageDocument, vie
|
|||||||
|
|
||||||
const rect = documentRectToScreenRect(context.canvas, bounds, viewport);
|
const rect = documentRectToScreenRect(context.canvas, bounds, viewport);
|
||||||
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
|
||||||
if (asset && imageTextureRenderer.render(asset, rect)) return;
|
if (asset && imageTextureRenderer.render(asset, rect, clipRect)) return;
|
||||||
|
|
||||||
clearScreenRect(context, rect, imageLayerColor);
|
const fallbackRect = intersectScreenRects(rect, clipRect);
|
||||||
clearScreenRect(context, { x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, imageLayerInsetColor);
|
if (!fallbackRect) return;
|
||||||
|
clearScreenRect(context, fallbackRect, imageLayerColor);
|
||||||
|
const insetRect = intersectScreenRects({ x: rect.x + 4, y: rect.y + 4, w: Math.max(0, rect.w - 8), h: Math.max(0, rect.h - 8) }, clipRect);
|
||||||
|
if (insetRect) clearScreenRect(context, insetRect, imageLayerInsetColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersectScreenRects(a: ScreenRect, b: ScreenRect): ScreenRect | undefined {
|
||||||
|
const x1 = Math.max(a.x, b.x);
|
||||||
|
const y1 = Math.max(a.y, b.y);
|
||||||
|
const x2 = Math.min(a.x + a.w, b.x + b.w);
|
||||||
|
const y2 = Math.min(a.y + a.h, b.y + b.h);
|
||||||
|
if (x2 <= x1 || y2 <= y1) return undefined;
|
||||||
|
|
||||||
|
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBacke
|
|||||||
for (const artboard of frame.document.artboards) {
|
for (const artboard of frame.document.artboards) {
|
||||||
renderArtboard(rendererContext, artboard, frame.editor.viewport);
|
renderArtboard(rendererContext, artboard, frame.editor.viewport);
|
||||||
}
|
}
|
||||||
|
imageTextureRenderer.syncAssets(frame.document.assets);
|
||||||
renderLayers(rendererContext, frame.document, frame.editor.viewport, imageTextureRenderer);
|
renderLayers(rendererContext, frame.document, frame.editor.viewport, imageTextureRenderer);
|
||||||
|
|
||||||
renderSelectionOverlay(rendererContext, frame.document, frame.editor);
|
renderSelectionOverlay(rendererContext, frame.document, frame.editor);
|
||||||
|
|||||||
Reference in New Issue
Block a user