From 9fb3a19912edd802281f0c4b1ffc26231b0cf6b8 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 11 Jul 2026 12:16:33 +0200 Subject: [PATCH] feat: add crop and canvas resize workflows --- commands/document.test.ts | 34 +++++++++ commands/document.ts | 87 ++++++++++++++++++++++ commands/ids.ts | 2 + commands/payloads.ts | 4 + commands/transform-document.ts | 5 +- core/image-layer.ts | 3 + core/raster-layer.ts | 3 + editor/document-indexes.test.ts | 5 ++ editor/document-indexes.ts | 9 ++- editor/transform-targets.test.ts | 7 ++ editor/transform-targets.ts | 9 ++- operations/project/format.test.ts | 11 ++- operations/project/format.ts | 5 +- platform/browser/exportArtboardPng.test.ts | 17 +++++ platform/browser/exportArtboardPng.ts | 24 ++++-- renderer/image-textures.ts | 24 +++--- renderer/layers.ts | 11 +-- renderer/texture-coordinates.test.ts | 20 +++++ renderer/texture-coordinates.ts | 17 +++++ view/bottom-controls/TransformControls.tsx | 50 ++++++++++++- 20 files changed, 312 insertions(+), 35 deletions(-) create mode 100644 platform/browser/exportArtboardPng.test.ts create mode 100644 renderer/texture-coordinates.test.ts create mode 100644 renderer/texture-coordinates.ts diff --git a/commands/document.test.ts b/commands/document.test.ts index 43674c1..cac282e 100644 --- a/commands/document.test.ts +++ b/commands/document.test.ts @@ -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, diff --git a/commands/document.ts b/commands/document.ts index 0e7d341..aca3f9a 100644 --- a/commands/document.ts +++ b/commands/document.ts @@ -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; @@ -189,6 +200,29 @@ export const documentSetArtboardBoundsCommand: Command = { + 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 = { id: commandIds.documentRemoveArtboard, name: "Remove artboard", @@ -424,6 +458,27 @@ export const documentSetLayerOpacityCommand: Command = { + 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 = { id: commandIds.documentDuplicateLayer, name: "Duplicate layer", @@ -665,6 +720,7 @@ export const documentRemoveLayerCommand: Command = { 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[]; + +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 }, + }, + }); +} diff --git a/commands/ids.ts b/commands/ids.ts index 88e8b8d..f6a0cb3 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -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", diff --git a/commands/payloads.ts b/commands/payloads.ts index 83c3950..e7058e0 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -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; diff --git a/commands/transform-document.ts b/commands/transform-document.ts index 49da68d..a50616a 100644 --- a/commands/transform-document.ts +++ b/commands/transform-document.ts @@ -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; }); diff --git a/core/image-layer.ts b/core/image-layer.ts index b874396..f6c9153 100644 --- a/core/image-layer.ts +++ b/core/image-layer.ts @@ -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; }; diff --git a/core/raster-layer.ts b/core/raster-layer.ts index 7a29fec..b995e76 100644 --- a/core/raster-layer.ts +++ b/core/raster-layer.ts @@ -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; }; diff --git a/editor/document-indexes.test.ts b/editor/document-indexes.test.ts index 928674e..44c8e10 100644 --- a/editor/document-indexes.test.ts +++ b/editor/document-indexes.test.ts @@ -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[] = []; diff --git a/editor/document-indexes.ts b/editor/document-indexes.ts index cdd8427..8c0a19e 100644 --- a/editor/document-indexes.ts +++ b/editor/document-indexes.ts @@ -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, }; } } diff --git a/editor/transform-targets.test.ts b/editor/transform-targets.test.ts index aeda4ee..116c6e5 100644 --- a/editor/transform-targets.test.ts +++ b/editor/transform-targets.test.ts @@ -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, diff --git a/editor/transform-targets.ts b/editor/transform-targets.ts index e5893e0..e76fe94 100644 --- a/editor/transform-targets.ts +++ b/editor/transform-targets.ts @@ -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, }; } } diff --git a/operations/project/format.test.ts b/operations/project/format.test.ts index 7291912..49f156d 100644 --- a/operations/project/format.test.ts +++ b/operations/project/format.test.ts @@ -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 } }], }], }; } diff --git a/operations/project/format.ts b/operations/project/format.ts index de2ef00..09a35dd 100644 --- a/operations/project/format.ts +++ b/operations/project/format.ts @@ -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); } diff --git a/platform/browser/exportArtboardPng.test.ts b/platform/browser/exportArtboardPng.test.ts new file mode 100644 index 0000000..e4de366 --- /dev/null +++ b/platform/browser/exportArtboardPng.test.ts @@ -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 }, + }); + }); +}); diff --git a/platform/browser/exportArtboardPng.ts b/platform/browser/exportArtboardPng.ts index ccf8f7c..f7611a5 100644 --- a/platform/browser/exportArtboardPng.ts +++ b/platform/browser/exportArtboardPng.ts @@ -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, 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, diff --git a/renderer/image-textures.ts b/renderer/image-textures.ts index 6a9aa01..8ca9f39 100644 --- a/renderer/image-textures.ts +++ b/renderer/image-textures.ts @@ -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); diff --git a/renderer/layers.ts b/renderer/layers.ts index 125ca04..a469979 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -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; diff --git a/renderer/texture-coordinates.test.ts b/renderer/texture-coordinates.test.ts new file mode 100644 index 0000000..0430a59 --- /dev/null +++ b/renderer/texture-coordinates.test.ts @@ -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); +} diff --git a/renderer/texture-coordinates.ts b/renderer/texture-coordinates.ts new file mode 100644 index 0000000..2d5e96a --- /dev/null +++ b/renderer/texture-coordinates.ts @@ -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]); +} diff --git a/view/bottom-controls/TransformControls.tsx b/view/bottom-controls/TransformControls.tsx index 78aee0e..c1e3825 100644 --- a/view/bottom-controls/TransformControls.tsx +++ b/view/bottom-controls/TransformControls.tsx @@ -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 + {layer.type !== "group" ? ( + + ) : null} {layer.type !== "group" ? ( + {resizeOpen ? 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} ); } +type CropDraft = Record; + +function CropEditor({ draft, setDraft, onApply, onCancel, onReset }: { draft: CropDraft; setDraft: Dispatch>; onApply: () => void; onCancel: () => void; onReset: () => void }) { + return
+ {(["x", "y", "w", "h"] as const).map((field) => )} + +
; +} + +function ArtboardResizeEditor({ draft, setDraft, onApply, onCancel }: { draft: { w: string; h: string; scaleContents: boolean }; setDraft: Dispatch>; onApply: () => void; onCancel: () => void }) { + return
+ {(["w", "h"] as const).map((field) => )} + + Off changes canvas bounds only. + +
; +} + +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 (