feat: add crop and canvas resize workflows

This commit is contained in:
syntaxbullet
2026-07-11 12:16:33 +02:00
parent 4d7358af4b
commit 9fb3a19912
20 changed files with 312 additions and 35 deletions

View File

@@ -18,11 +18,13 @@ import {
documentRenameArtboardCommand,
documentRenameLayerCommand,
documentSetArtboardBoundsCommand,
documentResizeArtboardCommand,
documentSetArtboardLockedCommand,
documentSetArtboardVisibleCommand,
documentSetLayerClippingMaskCommand,
documentSetLayerLockedCommand,
documentSetLayerOpacityCommand,
documentSetLayerSourceRectCommand,
documentSetLayerVisibleCommand,
documentUpdateAssetSourceCommand,
documentUngroupLayerCommand,
@@ -59,6 +61,32 @@ describe("document commands", () => {
]);
});
test("crops a raster layer non-destructively and clamps the crop to its asset", () => {
const state = documentWithRaster();
const next = documentSetLayerSourceRectCommand.execute({ state }, { layerId: "r1", sourceRect: { x: 20, y: 10, w: 200, h: 100 } });
expect(next.document.artboards[0]?.layers[0]).toMatchObject({ sourceRect: { x: 20, y: 10, w: 80, h: 40 } });
expect(next.document.assets[0]?.intrinsicSize).toEqual({ w: 100, h: 50 });
const reset = documentSetLayerSourceRectCommand.execute({ state: next }, { layerId: "r1" });
expect(reset.document.artboards[0]?.layers[0]).not.toHaveProperty("sourceRect");
});
test("resizes artboard bounds without scaling contents", () => {
const state = documentWithRaster();
const next = documentResizeArtboardCommand.execute({ state }, { id: "a1", bounds: { x: 5, y: 10, w: 640, h: 480 }, scaleContents: false });
expect(next.document.artboards[0]?.bounds).toEqual({ x: 5, y: 10, w: 640, h: 480 });
expect(next.document.artboards[0]?.layers[0]?.transform).toEqual(state.document.artboards[0]?.layers[0]?.transform);
});
test("resizes an artboard and scales nested contents including masks", () => {
const state = documentWithRaster();
const leaf = state.document.artboards[0]!.layers[0]!;
state.document.artboards[0]!.layers = [{ ...group("g", "Group"), children: [leaf] }];
const next = documentResizeArtboardCommand.execute({ state }, { id: "a1", bounds: { x: 10, y: 20, w: 640, h: 120 }, scaleContents: true });
const nested = next.document.artboards[0]?.layers[0];
expect(nested?.type === "group" ? nested.children[0]?.transform : undefined).toEqual({ position: { x: 30, y: 30 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
});
test("updates asset sources", () => {
const state = documentAddAssetCommand.execute(
{ state: createInitialAppState("Test") },
@@ -403,6 +431,12 @@ function documentWithLayers(layers: Layer[]) {
};
}
function documentWithRaster() {
const state = documentWithLayers([{ ...raster("r1", "Raster", "asset-1"), transform: { position: { x: 10, y: 20 }, scale: { x: 1, y: 1 }, rotation: 0 } }]);
state.document.assets = [{ id: "asset-1", name: "Raster", mimeType: "image/png", source: "asset://raster", intrinsicSize: { w: 100, h: 50 } }];
return state;
}
function group(id: string, name: string) {
return {
id,

View File

@@ -22,6 +22,12 @@ export type DocumentSetArtboardBoundsPayload = {
bounds: Rect;
};
export type DocumentResizeArtboardPayload = {
id: ArtboardId;
bounds: Rect;
scaleContents: boolean;
};
export type DocumentRemoveArtboardPayload = {
id: ArtboardId;
};
@@ -104,6 +110,11 @@ export type DocumentSetLayerOpacityPayload = {
opacity: number;
};
export type DocumentSetLayerSourceRectPayload = {
layerId: LayerId;
sourceRect?: Rect;
};
export type DocumentDuplicateLayerPayload = {
layerId: LayerId;
idByLayerId: Record<LayerId, LayerId>;
@@ -189,6 +200,29 @@ export const documentSetArtboardBoundsCommand: Command<DocumentSetArtboardBounds
},
};
export const documentResizeArtboardCommand: Command<DocumentResizeArtboardPayload> = {
id: commandIds.documentResizeArtboard,
name: "Resize artboard",
execute({ state }, payload) {
const artboard = state.document.artboards.find((candidate) => candidate.id === payload.id);
const bounds = validRect(payload.bounds);
if (!artboard || artboard.locked || !bounds) return state;
const scaleX = bounds.w / artboard.bounds.w;
const scaleY = bounds.h / artboard.bounds.h;
return {
...state,
document: {
...state.document,
artboards: state.document.artboards.map((candidate) => candidate.id !== payload.id ? candidate : {
...candidate,
bounds,
layers: payload.scaleContents ? scaleLayerTree(candidate.layers, artboard.bounds, bounds, scaleX, scaleY) : candidate.layers,
}),
},
};
},
};
export const documentRemoveArtboardCommand: Command<DocumentRemoveArtboardPayload> = {
id: commandIds.documentRemoveArtboard,
name: "Remove artboard",
@@ -424,6 +458,27 @@ export const documentSetLayerOpacityCommand: Command<DocumentSetLayerOpacityPayl
},
};
export const documentSetLayerSourceRectCommand: Command<DocumentSetLayerSourceRectPayload> = {
id: commandIds.documentSetLayerSourceRect,
name: "Crop layer",
execute({ state }, payload) {
const location = findLayerLocation(state.document, payload.layerId);
if (!location || location.layer.type === "group" || location.layer.locked || location.layer.transform.rotation !== 0) return state;
const leaf = location.layer;
const asset = state.document.assets.find((candidate) => candidate.id === leaf.assetId);
if (!asset) return state;
const sourceRect = payload.sourceRect ? clampSourceRect(payload.sourceRect, asset.intrinsicSize.w, asset.intrinsicSize.h) : undefined;
if (payload.sourceRect && !sourceRect) return state;
return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => {
if (layer.type === "group") return layer;
if (sourceRect) return { ...layer, sourceRect };
const uncropped = { ...layer };
delete uncropped.sourceRect;
return uncropped;
}) };
},
};
export const documentDuplicateLayerCommand: Command<DocumentDuplicateLayerPayload> = {
id: commandIds.documentDuplicateLayer,
name: "Duplicate layer",
@@ -665,6 +720,7 @@ export const documentRemoveLayerCommand: Command<DocumentRemoveLayerPayload> = {
export const documentCommands = [
documentAddArtboardCommand,
documentSetArtboardBoundsCommand,
documentResizeArtboardCommand,
documentRemoveArtboardCommand,
documentSetArtboardVisibleCommand,
documentSetArtboardLockedCommand,
@@ -681,6 +737,7 @@ export const documentCommands = [
documentSetLayerVisibleCommand,
documentSetLayerLockedCommand,
documentSetLayerOpacityCommand,
documentSetLayerSourceRectCommand,
documentDuplicateLayerCommand,
documentRenameLayerCommand,
documentSetLayerClippingMaskCommand,
@@ -688,3 +745,33 @@ export const documentCommands = [
documentApplyLayerMaskOperationCommand,
documentRemoveLayerMaskCommand,
] satisfies Command<unknown>[];
function validRect(rect: Rect): Rect | undefined {
return [rect.x, rect.y, rect.w, rect.h].every(Number.isFinite) && rect.w >= 1 && rect.h >= 1 ? { ...rect } : undefined;
}
function clampSourceRect(rect: Rect, width: number, height: number): Rect | undefined {
if (![rect.x, rect.y, rect.w, rect.h].every(Number.isFinite)) return undefined;
const x = Math.max(0, Math.min(width - 1, rect.x));
const y = Math.max(0, Math.min(height - 1, rect.y));
const w = Math.min(width - x, rect.w);
const h = Math.min(height - y, rect.h);
return w >= 1 && h >= 1 ? { x, y, w, h } : undefined;
}
function scaleLayerTree(layers: Layer[], before: Rect, after: Rect, scaleX: number, scaleY: number): Layer[] {
return layers.map((layer) => layer.type === "group" ? {
...layer,
children: scaleLayerTree(layer.children, before, after, scaleX, scaleY),
} : {
...layer,
transform: {
...layer.transform,
position: {
x: after.x + (layer.transform.position.x - before.x) * scaleX,
y: after.y + (layer.transform.position.y - before.y) * scaleY,
},
scale: { x: layer.transform.scale.x * scaleX, y: layer.transform.scale.y * scaleY },
},
});
}

View File

@@ -2,6 +2,7 @@ export const commandIds = {
projectOpen: "project.open",
documentAddArtboard: "document.addArtboard",
documentSetArtboardBounds: "document.setArtboardBounds",
documentResizeArtboard: "document.resizeArtboard",
documentRemoveArtboard: "document.removeArtboard",
documentSetArtboardVisible: "document.setArtboardVisible",
documentSetArtboardLocked: "document.setArtboardLocked",
@@ -18,6 +19,7 @@ export const commandIds = {
documentSetLayerVisible: "document.setLayerVisible",
documentSetLayerLocked: "document.setLayerLocked",
documentSetLayerOpacity: "document.setLayerOpacity",
documentSetLayerSourceRect: "document.setLayerSourceRect",
documentDuplicateLayer: "document.duplicateLayer",
documentRenameLayer: "document.renameLayer",
documentSetLayerClippingMask: "document.setLayerClippingMask",

View File

@@ -16,11 +16,13 @@ import type {
DocumentRenameArtboardPayload,
DocumentRenameLayerPayload,
DocumentSetArtboardBoundsPayload,
DocumentResizeArtboardPayload,
DocumentSetArtboardLockedPayload,
DocumentSetArtboardVisiblePayload,
DocumentSetLayerClippingMaskPayload,
DocumentSetLayerLockedPayload,
DocumentSetLayerOpacityPayload,
DocumentSetLayerSourceRectPayload,
DocumentSetLayerVisiblePayload,
DocumentUpdateAssetSourcePayload,
DocumentUngroupLayerPayload,
@@ -63,6 +65,7 @@ export type CommandPayloads = {
[commandIds.projectOpen]: ProjectOpenPayload;
[commandIds.documentAddArtboard]: DocumentAddArtboardPayload;
[commandIds.documentSetArtboardBounds]: DocumentSetArtboardBoundsPayload;
[commandIds.documentResizeArtboard]: DocumentResizeArtboardPayload;
[commandIds.documentRemoveArtboard]: DocumentRemoveArtboardPayload;
[commandIds.documentSetArtboardVisible]: DocumentSetArtboardVisiblePayload;
[commandIds.documentSetArtboardLocked]: DocumentSetArtboardLockedPayload;
@@ -82,6 +85,7 @@ export type CommandPayloads = {
[commandIds.documentSetLayerVisible]: DocumentSetLayerVisiblePayload;
[commandIds.documentSetLayerLocked]: DocumentSetLayerLockedPayload;
[commandIds.documentSetLayerOpacity]: DocumentSetLayerOpacityPayload;
[commandIds.documentSetLayerSourceRect]: DocumentSetLayerSourceRectPayload;
[commandIds.documentDuplicateLayer]: DocumentDuplicateLayerPayload;
[commandIds.documentRenameLayer]: DocumentRenameLayerPayload;
[commandIds.documentSetLayerClippingMask]: DocumentSetLayerClippingMaskPayload;

View File

@@ -47,7 +47,10 @@ function mapLeafBounds(document: ImageDocument, layers: Layer[], layerId: LayerI
return layers.map((layer) => {
if (layer.id === layerId && layer.type !== "group") {
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
return asset ? { ...layer, transform: { ...layer.transform, position: { x: bounds.x, y: bounds.y }, scale: { x: bounds.w / asset.intrinsicSize.w, y: bounds.h / asset.intrinsicSize.h } } } : layer;
if (!asset) return layer;
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
const scale = { x: bounds.w / source.w, y: bounds.h / source.h };
return { ...layer, transform: { ...layer.transform, position: { x: bounds.x - source.x * scale.x, y: bounds.y - source.y * scale.y }, scale } };
}
return layer.type === "group" ? { ...layer, children: mapLeafBounds(document, layer.children, layerId, bounds) } : layer;
});

View File

@@ -1,7 +1,10 @@
import type { BaseLayer } from "./base-layer";
import type { AssetId } from "./id";
import type { Rect } from "./geometry";
export type ImageLayer = BaseLayer & {
type: "image";
assetId: AssetId;
/** Non-destructive source pixels displayed by this layer. Absence means the full asset. */
sourceRect?: Rect;
};

View File

@@ -1,7 +1,10 @@
import type { BaseLayer } from "./base-layer";
import type { AssetId } from "./id";
import type { Rect } from "./geometry";
export type RasterLayer = BaseLayer & {
type: "raster";
assetId: AssetId;
/** Non-destructive source pixels displayed by this layer. Absence means the full asset. */
sourceRect?: Rect;
};

View File

@@ -58,6 +58,11 @@ describe("document read indexes", () => {
expect(resolveIndexedLayerBounds(index, raster("missing", "Missing", "missing-asset"))).toBeUndefined();
});
test("resolves a cropped layer from its retained source-pixel position", () => {
const cropped = { ...document, artboards: [{ ...document.artboards[0]!, layers: [{ ...raster("cropped", "Cropped", "asset-target", { x: 10, y: 20 }, { x: 2, y: 3 }), sourceRect: { x: 5, y: 4, w: 20, h: 10 } }] }] };
expect(resolveIndexedLayerBounds(createDocumentReadIndex(cropped), "cropped")).toEqual({ x: 20, y: 32, w: 40, h: 30 });
});
test("visits layers back to front without mutating source order", () => {
const layers = document.artboards[0]!.layers;
const visited: string[] = [];

View File

@@ -79,12 +79,13 @@ export function resolveIndexedLayerBounds(index: DocumentReadIndex, layerOrId: L
case "raster": {
const asset = index.assetById.get(layer.assetId);
if (!asset) return undefined;
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
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,
x: layer.transform.position.x + source.x * layer.transform.scale.x,
y: layer.transform.position.y + source.y * layer.transform.scale.y,
w: source.w * layer.transform.scale.x,
h: source.h * layer.transform.scale.y,
};
}
}

View File

@@ -56,6 +56,13 @@ describe("transform targets", () => {
expect(layer?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
});
test("applies visible bounds to a cropped layer while retaining its source crop", () => {
const cropped = { ...document, artboards: [{ ...document.artboards[0]!, layers: [{ ...document.artboards[0]!.layers[0]!, sourceRect: { x: 10, y: 5, w: 40, h: 20 } }] }] };
const next = applyTransformTargetBounds(cropped, { type: "layer", id: "l1" }, { x: 100, y: 80, w: 200, h: 60 });
expect(next.artboards[0]?.layers[0]).toMatchObject({ sourceRect: { x: 10, y: 5, w: 40, h: 20 }, transform: { position: { x: 50, y: 65 }, scale: { x: 5, y: 3 } } });
expect(resolveTransformTargetBounds(next, { type: "layer", id: "l1" })).toEqual({ x: 100, y: 80, w: 200, h: 60 });
});
test("keeps attached mask bounds in sync with transformed layers", () => {
const maskedDocument: ImageDocument = {
...document,

View File

@@ -53,12 +53,13 @@ function resolveLayerBounds(document: ImageDocument, layer: Layer): Rect | undef
case "raster": {
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return undefined;
const source = layer.sourceRect ?? { x: 0, y: 0, ...asset.intrinsicSize };
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,
x: layer.transform.position.x + source.x * layer.transform.scale.x,
y: layer.transform.position.y + source.y * layer.transform.scale.y,
w: source.w * layer.transform.scale.x,
h: source.h * layer.transform.scale.y,
};
}
}

View File

@@ -33,6 +33,15 @@ describe("project format", () => {
expect(() => serializeProject(missingAsset)).toThrow("references missing asset");
});
test("rejects invalid persisted layer crops", () => {
const document = projectDocument();
const layer = document.artboards[0]!.layers[0]!;
if (layer.type === "group") throw new Error("Expected image layer");
layer.sourceRect = { x: 0, y: 0, w: 0, h: 10 };
const source = JSON.stringify({ format: "image-studio-project", version: 1, savedAt: "2026-07-10T12:00:00.000Z", document });
expect(() => parseProject(source)).toThrow("invalid source crop");
});
test("creates safe project file names", () => {
expect(projectFileName(" Summer / Study ")).toBe("Summer-Study.image-studio.json");
expect(projectFileName("***")).toBe("untitled.image-studio.json");
@@ -52,7 +61,7 @@ function projectDocument(): ImageDocument {
backgroundColor: "transparent",
visible: true,
locked: false,
layers: [{ id: "layer-1", type: "image", name: "Pixels", visible: true, locked: false, opacity: 1, assetId: "asset-1", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } }],
layers: [{ id: "layer-1", type: "image", name: "Pixels", visible: true, locked: false, opacity: 1, assetId: "asset-1", sourceRect: { x: 2, y: 3, w: 6, h: 12 }, transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } }],
}],
};
}

View File

@@ -1,5 +1,6 @@
import type { ImageDocument } from "@core/document";
import type { Layer } from "@core/layer";
import type { Rect } from "@core/geometry";
export const PROJECT_FORMAT = "image-studio-project";
export const CURRENT_PROJECT_VERSION = 1;
@@ -112,6 +113,8 @@ function assertLayers(value: unknown[]): asserts value is Layer[] {
assertLayers(layer.children);
} else if ((layer.type === "image" || layer.type === "raster") && typeof layer.assetId !== "string") {
throw new Error("A project layer is missing its asset reference.");
} else if ((layer.type === "image" || layer.type === "raster") && layer.sourceRect !== undefined && (!isRect(layer.sourceRect) || layer.sourceRect.w < 1 || layer.sourceRect.h < 1)) {
throw new Error("A project layer contains an invalid source crop.");
} else if (layer.type !== "image" && layer.type !== "raster") {
throw new Error(`Unsupported layer type: ${layer.type}.`);
}
@@ -137,7 +140,7 @@ function isSize(value: unknown): boolean {
return isRecord(value) && isFiniteNumber(value.w) && isFiniteNumber(value.h) && value.w >= 0 && value.h >= 0;
}
function isRect(value: unknown): boolean {
function isRect(value: unknown): value is Rect {
return isRecord(value) && isFiniteNumber(value.x) && isFiniteNumber(value.y) && isFiniteNumber(value.w) && isFiniteNumber(value.h);
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test";
import { resolveLayerDrawImage } from "./exportArtboardPng";
describe("artboard PNG export", () => {
test("draws only cropped source pixels at their retained document position", () => {
const draw = resolveLayerDrawImage({
id: "layer", type: "raster", name: "Layer", visible: true, locked: false, opacity: 1, assetId: "asset",
sourceRect: { x: 10, y: 5, w: 40, h: 20 },
transform: { position: { x: 100, y: 50 }, scale: { x: 2, y: 3 }, rotation: 0 },
}, { w: 200, h: 100 });
expect(draw).toEqual({
source: { x: 10, y: 5, w: 40, h: 20 },
destination: { x: 120, y: 65, w: 80, h: 60 },
});
});
});

View File

@@ -2,6 +2,7 @@ import type { Artboard } from "@core/artboard";
import type { Asset } from "@core/asset";
import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer";
import type { Size } from "@core/geometry";
import { getLayerMask } from "@core/layer-mask-utils";
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
@@ -66,16 +67,25 @@ async function drawLayer(
}
const image = await loadImage(asset.source);
context.drawImage(
image,
layer.transform.position.x,
layer.transform.position.y,
asset.intrinsicSize.w * layer.transform.scale.x,
asset.intrinsicSize.h * layer.transform.scale.y,
);
const draw = resolveLayerDrawImage(layer, asset.intrinsicSize);
context.drawImage(image, draw.source.x, draw.source.y, draw.source.w, draw.source.h,
draw.destination.x, draw.destination.y, draw.destination.w, draw.destination.h);
context.restore();
}
export function resolveLayerDrawImage(layer: Exclude<Layer, { type: "group" }>, intrinsicSize: Size) {
const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize };
return {
source,
destination: {
x: layer.transform.position.x + source.x * layer.transform.scale.x,
y: layer.transform.position.y + source.y * layer.transform.scale.y,
w: source.w * layer.transform.scale.x,
h: source.h * layer.transform.scale.y,
},
};
}
async function drawMaskedLayer(
context: CanvasRenderingContext2D,
layer: Layer,

View File

@@ -1,15 +1,17 @@
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 type { Rect } from "@core/geometry";
import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect";
import { textureCoordinatesForCrop, textureCoordinatesForRect } from "./texture-coordinates";
export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay";
export type ImageTextureRenderer = {
syncAssets(assets: readonly Asset[]): void;
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;
render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number, sourceRect?: Rect): boolean;
renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number, sourceRect?: Rect): boolean;
renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number, rotation?: number, sourceRect?: Rect): 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;
@@ -88,7 +90,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
}
}
},
render(asset, rect, clipRect, opacity = 1, rotation = 0) {
render(asset, rect, clipRect, opacity = 1, rotation = 0, sourceRect) {
const clampedOpacity = clampOpacity(opacity);
if (clampedOpacity <= 0) return true;
const rotatedRect = rotatedRectBounds(rect, rotation);
@@ -115,7 +117,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForCrop(asset.intrinsicSize, sourceRect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(texCoordLocation);
gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
@@ -123,7 +125,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND);
return true;
},
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1, rotation = 0) {
renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1, rotation = 0, sourceRect) {
const clampedOpacity = clampOpacity(opacity);
if (clampedOpacity <= 0) return true;
const contentBounds = rotatedRectBounds(rect, rotation);
@@ -157,12 +159,12 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.vertexAttribPointer(maskedPositionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForCrop(asset.intrinsicSize, sourceRect), 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, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForRect(rect, maskRect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskedMaskTexCoordLocation);
gl.vertexAttribPointer(maskedMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);
@@ -170,7 +172,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.disable(gl.BLEND);
return true;
},
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1, rotation = 0) {
renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1, rotation = 0, sourceRect) {
const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity);
if (clampedOpacity <= 0) return true;
@@ -205,12 +207,12 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali
gl.vertexAttribPointer(maskRevealPreviewPositionLocation, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForCrop(asset.intrinsicSize, sourceRect), 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, fullTexCoords(), gl.DYNAMIC_DRAW);
gl.bufferData(gl.ARRAY_BUFFER, textureCoordinatesForRect(rect, maskRect), gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(maskRevealPreviewMaskTexCoordLocation);
gl.vertexAttribPointer(maskRevealPreviewMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0);

View File

@@ -91,17 +91,18 @@ 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, layer.transform.rotation)) {
imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect);
if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) {
const contentClipRect = intersectScreenRects(effectiveClipRect, rect);
if (contentClipRect) imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, contentClipRect);
return;
}
}
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);
if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) {
if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect);
return;
}
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) return;
if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation, layer.sourceRect)) return;
const fallbackRect = intersectScreenRects(rect, effectiveClipRect);
if (!fallbackRect) return;

View File

@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { textureCoordinatesForCrop, textureCoordinatesForRect } from "./texture-coordinates";
describe("texture coordinates", () => {
test("maps an asset-pixel crop to normalized UVs", () => {
expect(rounded(textureCoordinatesForCrop({ w: 200, h: 100 }, { x: 50, y: 10, w: 100, h: 40 }))).toEqual([
0.25, 0.1, 0.75, 0.1, 0.25, 0.5, 0.25, 0.5, 0.75, 0.1, 0.75, 0.5,
]);
});
test("maps cropped content onto the corresponding region of an aligned full-size mask", () => {
expect(rounded(textureCoordinatesForRect({ x: 50, y: 20, w: 100, h: 40 }, { x: 0, y: 0, w: 200, h: 100 }))).toEqual([
0.25, 0.2, 0.75, 0.2, 0.25, 0.6, 0.25, 0.6, 0.75, 0.2, 0.75, 0.6,
]);
});
});
function rounded(values: Float32Array) {
return [...values].map((value) => Math.round(value * 1_000) / 1_000);
}

View File

@@ -0,0 +1,17 @@
import type { Rect, Size } from "@core/geometry";
const fullCoordinates = [0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1] as const;
export function textureCoordinatesForCrop(size: Size, crop?: Rect): Float32Array {
if (!crop) return new Float32Array(fullCoordinates);
return textureCoordinatesForRect(crop, { x: 0, y: 0, w: size.w, h: size.h });
}
/** Maps a drawn sub-rectangle onto the texture represented by sourceRect. */
export function textureCoordinatesForRect(drawRect: Rect, sourceRect: Rect): Float32Array {
const x1 = (drawRect.x - sourceRect.x) / sourceRect.w;
const x2 = (drawRect.x + drawRect.w - sourceRect.x) / sourceRect.w;
const y1 = (drawRect.y - sourceRect.y) / sourceRect.h;
const y2 = (drawRect.y + drawRect.h - sourceRect.y) / sourceRect.h;
return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { BoundingBox, Copy, MaskHappy } from "@phosphor-icons/react";
import { ArrowsOut, BoundingBox, Copy, Crop, MaskHappy } from "@phosphor-icons/react";
import { commandIds } from "@commands/ids";
import type { Rect } from "@core/geometry";
import type { AppStore } from "@editor/store";
@@ -24,6 +24,10 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
const [draft, setDraft] = useState(() => draftFromBounds(bounds));
const [opacityDraft, setOpacityDraft] = useState(() => String(Math.round((layerInfo?.layer.opacity ?? 1) * 100)));
const [rotationDraft, setRotationDraft] = useState(() => rotationDegrees(layerInfo));
const [cropOpen, setCropOpen] = useState(false);
const [cropDraft, setCropDraft] = useState(() => cropDraftFor(layerInfo, documentIndex));
const [resizeOpen, setResizeOpen] = useState(false);
const [resizeDraft, setResizeDraft] = useState(() => ({ w: String(Math.round(bounds.w)), h: String(Math.round(bounds.h)), scaleContents: false }));
const layer = layerInfo?.layer;
const locked = layer?.locked ?? false;
const mask = layer ? getLayerMask(layer) : undefined;
@@ -35,6 +39,8 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
useEffect(() => setOpacityDraft(String(Math.round((layer?.opacity ?? 1) * 100))), [layer?.id, layer?.opacity]);
useEffect(() => setRotationDraft(rotationDegrees(layerInfo)), [layer?.id, layer?.transform.rotation]);
useEffect(() => setCropDraft(cropDraftFor(layerInfo, documentIndex)), [documentIndex, layer?.id, layer?.type === "group" ? undefined : layer?.sourceRect]);
useEffect(() => setResizeDraft((current) => ({ ...current, w: String(Math.round(bounds.w)), h: String(Math.round(bounds.h)) })), [bounds.w, bounds.h, target]);
const commitField = (field: BoundsField) => {
const value = Number.parseFloat(draft[field]);
@@ -96,6 +102,11 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
<button type="button" className={actionButtonClass()} disabled={locked} title={locked ? "Unlock the layer to duplicate it" : "Duplicate layer"} onClick={() => duplicateLayer(documentIndex, layerInfo!, dispatch)}>
<Copy size={20} /> Duplicate
</button>
{layer.type !== "group" ? (
<button type="button" className={actionButtonClass()} disabled={locked || layer.transform.rotation !== 0} title={layer.transform.rotation !== 0 ? "Reset rotation before cropping" : "Crop visible source pixels"} onClick={() => setCropOpen((open) => !open)}>
<Crop size={20} /> Crop
</button>
) : null}
{layer.type !== "group" ? (
<button
type="button"
@@ -111,12 +122,49 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
</button>
) : null}
{locked ? <span className="text-xs text-amber-200/70">Unlock to edit</span> : null}
{cropOpen && layer.type !== "group" ? <CropEditor draft={cropDraft} setDraft={setCropDraft} onCancel={() => { setCropDraft(cropDraftFor(layerInfo, documentIndex)); setCropOpen(false); }} onReset={() => { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id }); setCropOpen(false); }} onApply={() => { const sourceRect = parseRectDraft(cropDraft); if (sourceRect) { dispatch(commandIds.documentSetLayerSourceRect, { layerId: layer.id, sourceRect }); setCropOpen(false); } }} /> : null}
</>
) : target.type === "artboard" ? (
<>
<BottomControlDivider />
<button type="button" className={actionButtonClass()} disabled={locked} onClick={() => setResizeOpen((open) => !open)}><ArrowsOut size={20} /> Resize canvas</button>
{resizeOpen ? <ArtboardResizeEditor draft={resizeDraft} setDraft={setResizeDraft} onCancel={() => setResizeOpen(false)} onApply={() => { const w = Number.parseFloat(resizeDraft.w); const h = Number.parseFloat(resizeDraft.h); if (Number.isFinite(w) && Number.isFinite(h) && w >= 1 && h >= 1) { dispatch(commandIds.documentResizeArtboard, { id: target.id, bounds: { ...bounds, w, h }, scaleContents: resizeDraft.scaleContents }); setResizeOpen(false); } }} /> : null}
</>
) : null}
</div>
);
}
type CropDraft = Record<BoundsField, string>;
function CropEditor({ draft, setDraft, onApply, onCancel, onReset }: { draft: CropDraft; setDraft: Dispatch<SetStateAction<CropDraft>>; onApply: () => void; onCancel: () => void; onReset: () => void }) {
return <div className="flex items-center gap-2 rounded-2xl bg-black/25 px-3 py-2" aria-label="Crop source pixels">
{(["x", "y", "w", "h"] as const).map((field) => <label key={field} className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{field.toUpperCase()}</span><input className={bottomControlInputClass()} inputMode="decimal" value={draft[field]} aria-label={`Crop ${field}`} onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))} onKeyDown={(event) => event.stopPropagation()} /></label>)}
<button type="button" className={actionButtonClass()} onClick={onApply}>Apply</button><button type="button" className={actionButtonClass()} onClick={onCancel}>Cancel</button><button type="button" className={actionButtonClass()} onClick={onReset}>Reset</button>
</div>;
}
function ArtboardResizeEditor({ draft, setDraft, onApply, onCancel }: { draft: { w: string; h: string; scaleContents: boolean }; setDraft: Dispatch<SetStateAction<{ w: string; h: string; scaleContents: boolean }>>; onApply: () => void; onCancel: () => void }) {
return <div className="flex items-center gap-2 rounded-2xl bg-black/25 px-3 py-2" aria-label="Resize artboard canvas">
{(["w", "h"] as const).map((field) => <label key={field} className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{field.toUpperCase()}</span><input className={bottomControlInputClass()} inputMode="decimal" value={draft[field]} aria-label={`Canvas ${field}`} onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))} onKeyDown={(event) => event.stopPropagation()} /></label>)}
<label className="flex items-center gap-2 text-xs text-white/70"><input type="checkbox" checked={draft.scaleContents} onChange={(event) => setDraft((current) => ({ ...current, scaleContents: event.target.checked }))} />Scale contents</label>
<span className="max-w-40 text-[10px] text-white/45">Off changes canvas bounds only.</span>
<button type="button" className={actionButtonClass()} onClick={onApply}>Apply</button><button type="button" className={actionButtonClass()} onClick={onCancel}>Cancel</button>
</div>;
}
function cropDraftFor(layerInfo: IndexedLayerInfo | undefined, index: DocumentReadIndex): CropDraft {
if (!layerInfo || layerInfo.layer.type === "group") return { x: "0", y: "0", w: "1", h: "1" };
const asset = index.assetById.get(layerInfo.layer.assetId);
const rect = layerInfo.layer.sourceRect ?? { x: 0, y: 0, w: asset?.intrinsicSize.w ?? 1, h: asset?.intrinsicSize.h ?? 1 };
return draftFromBounds(rect);
}
function parseRectDraft(draft: CropDraft): Rect | undefined {
const rect = { x: Number.parseFloat(draft.x), y: Number.parseFloat(draft.y), w: Number.parseFloat(draft.w), h: Number.parseFloat(draft.h) };
return Object.values(rect).every(Number.isFinite) && rect.w >= 1 && rect.h >= 1 ? rect : undefined;
}
function SimpleInput({ label, suffix, value, disabled, title, onChange, onCommit }: { label: string; suffix: string; value: string; disabled?: boolean; title?: string; onChange: (value: string) => void; onCommit: () => void }) {
return (
<label className={`${bottomControlFieldClass()} ${disabled ? "opacity-40" : ""}`} title={title}>