diff --git a/commands/document.test.ts b/commands/document.test.ts index 794e276..43674c1 100644 --- a/commands/document.test.ts +++ b/commands/document.test.ts @@ -10,6 +10,7 @@ import { documentAddRasterLayerCommand, documentApplyLayerMaskOperationCommand, documentGroupLayersCommand, + documentDuplicateLayerCommand, documentMoveLayerCommand, documentRemoveArtboardCommand, documentRemoveLayerCommand, @@ -21,6 +22,7 @@ import { documentSetArtboardVisibleCommand, documentSetLayerClippingMaskCommand, documentSetLayerLockedCommand, + documentSetLayerOpacityCommand, documentSetLayerVisibleCommand, documentUpdateAssetSourceCommand, documentUngroupLayerCommand, @@ -335,6 +337,46 @@ describe("document commands", () => { expect(locked.document.artboards[0]?.layers[0]?.locked).toBe(true); }); + test("sets normalized opacity and refuses locked layer edits", () => { + const state = documentWithLayers([raster("a", "A")]); + const translucent = documentSetLayerOpacityCommand.execute({ state }, { layerId: "a", opacity: 0.35 }); + const clamped = documentSetLayerOpacityCommand.execute({ state: translucent }, { layerId: "a", opacity: 2 }); + const lockedState = documentSetLayerLockedCommand.execute({ state: clamped }, { layerId: "a", locked: true }); + const ignored = documentSetLayerOpacityCommand.execute({ state: lockedState }, { layerId: "a", opacity: 0 }); + + expect(translucent.document.artboards[0]?.layers[0]?.opacity).toBe(0.35); + expect(clamped.document.artboards[0]?.layers[0]?.opacity).toBe(1); + expect(ignored).toBe(lockedState); + }); + + test("duplicates a nested group with remapped child mask references", () => { + const maskedTarget = { ...raster("target", "Target"), layerMask: { kind: "raster" as const, maskLayerId: "mask", enabled: true, inverted: false } }; + const parent = { ...group("parent", "Parent"), children: [raster("mask", "Mask"), maskedTarget] }; + const state = documentWithLayers([parent]); + const next = documentDuplicateLayerCommand.execute( + { state }, + { layerId: "parent", idByLayerId: { parent: "parent-copy", mask: "mask-copy", target: "target-copy" } }, + ); + + const duplicate = next.document.artboards[0]?.layers[1]; + expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["parent", "parent-copy"]); + expect(duplicate?.type === "group" ? duplicate.children.map((layer) => layer.id) : []).toEqual(["mask-copy", "target-copy"]); + expect(duplicate?.type === "group" ? duplicate.children[1]?.layerMask?.maskLayerId : undefined).toBe("mask-copy"); + expect(next.editor.selection.layerIds).toEqual(["parent-copy"]); + }); + + test("duplicates an attached top-level mask beside its target", () => { + const target = { ...raster("target", "Target"), layerMask: { kind: "raster" as const, maskLayerId: "mask", enabled: true, inverted: false } }; + const state = documentWithLayers([raster("mask", "Mask"), target]); + const next = documentDuplicateLayerCommand.execute( + { state }, + { layerId: "target", idByLayerId: { mask: "mask-copy", target: "target-copy" } }, + ); + + expect(next.document.artboards[0]?.layers.map((layer) => layer.id)).toEqual(["mask", "target", "mask-copy", "target-copy"]); + expect(next.document.artboards[0]?.layers[3]?.layerMask?.maskLayerId).toBe("mask-copy"); + }); + test("sets artboard bounds", () => { const state = documentAddArtboardCommand.execute( { state: createInitialAppState("Test") }, diff --git a/commands/document.ts b/commands/document.ts index 5da8d6e..0e7d341 100644 --- a/commands/document.ts +++ b/commands/document.ts @@ -6,6 +6,7 @@ import type { ArtboardId, AssetId, LayerId } from "@core/id"; import type { ImageLayer } from "@core/image-layer"; import { getLayerMask } from "@core/layer-mask-utils"; import type { RasterLayer } from "@core/raster-layer"; +import type { Layer } from "@core/layer"; import type { LayerGroup } from "@core/layer-group"; import type { Command } from "./command"; import { commandIds } from "./ids"; @@ -98,6 +99,16 @@ export type DocumentSetLayerLockedPayload = { locked: boolean; }; +export type DocumentSetLayerOpacityPayload = { + layerId: LayerId; + opacity: number; +}; + +export type DocumentDuplicateLayerPayload = { + layerId: LayerId; + idByLayerId: Record; +}; + export type DocumentRenameLayerPayload = { layerId: LayerId; name: string; @@ -402,6 +413,56 @@ export const documentSetLayerLockedCommand: Command = { + id: commandIds.documentSetLayerOpacity, + name: "Set layer opacity", + execute({ state }, payload) { + const location = findLayerLocation(state.document, payload.layerId); + if (!location || location.layer.locked || !Number.isFinite(payload.opacity)) return state; + const opacity = Math.min(1, Math.max(0, payload.opacity)); + return { ...state, document: mapLayerInDocument(state.document, payload.layerId, (layer) => ({ ...layer, opacity })) }; + }, +}; + +export const documentDuplicateLayerCommand: Command = { + id: commandIds.documentDuplicateLayer, + name: "Duplicate layer", + execute({ state }, payload) { + const location = findLayerLocation(state.document, payload.layerId); + if (!location || location.layer.locked) return state; + const maskId = getLayerMask(location.layer)?.maskLayerId; + const mask = maskId ? location.siblings.find((layer) => layer.id === maskId) : undefined; + const sourceLayers = mask ? [mask, location.layer] : [location.layer]; + const sourceIds = new Set(sourceLayers.flatMap((layer) => [...collectLayerIds(layer)])); + const mappedIds = [...sourceIds].map((id) => payload.idByLayerId[id]); + if (mappedIds.some((id) => !id) || new Set(mappedIds).size !== mappedIds.length || mappedIds.some((id) => findLayerLocation(state.document, id!))) return state; + + const duplicates = sourceLayers.map((layer) => duplicateLayerTree(layer, payload.idByLayerId)); + const insertionIndex = Math.max(...sourceLayers.map((layer) => location.siblings.findIndex((candidate) => candidate.id === layer.id))) + 1; + const siblings = [...location.siblings.slice(0, insertionIndex), ...duplicates, ...location.siblings.slice(insertionIndex)]; + const duplicatedLayerId = payload.idByLayerId[payload.layerId]; + if (!duplicatedLayerId) return state; + return { + ...state, + document: replaceLayerListInDocument(state.document, location.artboardId, location.parentGroupId, siblings), + editor: { ...state.editor, selection: { artboardId: location.artboardId, layerIds: [duplicatedLayerId] } }, + }; + }, +}; + +function duplicateLayerTree(layer: Layer, idByLayerId: Record): Layer { + const layerMask = getLayerMask(layer); + const duplicatedMaskId = layerMask ? idByLayerId[layerMask.maskLayerId] : undefined; + const duplicated = { + ...layer, + id: idByLayerId[layer.id]!, + name: `${layer.name} copy`, + transform: { ...layer.transform, position: { ...layer.transform.position }, scale: { ...layer.transform.scale } }, + ...(layerMask && duplicatedMaskId ? { layerMask: { ...layerMask, maskLayerId: duplicatedMaskId }, clippingMask: layer.clippingMask ? { maskLayerId: duplicatedMaskId } : undefined } : {}), + }; + return layer.type === "group" ? { ...duplicated, type: "group", children: layer.children.map((child) => duplicateLayerTree(child, idByLayerId)) } : duplicated; +} + export const documentRenameLayerCommand: Command = { id: commandIds.documentRenameLayer, name: "Rename layer", @@ -619,6 +680,8 @@ export const documentCommands = [ documentRemoveLayerCommand, documentSetLayerVisibleCommand, documentSetLayerLockedCommand, + documentSetLayerOpacityCommand, + documentDuplicateLayerCommand, documentRenameLayerCommand, documentSetLayerClippingMaskCommand, documentAddLayerMaskCommand, diff --git a/commands/ids.ts b/commands/ids.ts index 7cd733a..88e8b8d 100644 --- a/commands/ids.ts +++ b/commands/ids.ts @@ -17,6 +17,8 @@ export const commandIds = { documentRemoveLayer: "document.removeLayer", documentSetLayerVisible: "document.setLayerVisible", documentSetLayerLocked: "document.setLayerLocked", + documentSetLayerOpacity: "document.setLayerOpacity", + documentDuplicateLayer: "document.duplicateLayer", documentRenameLayer: "document.renameLayer", documentSetLayerClippingMask: "document.setLayerClippingMask", documentAddLayerMask: "document.addLayerMask", @@ -56,6 +58,7 @@ export const commandIds = { transformBegin: "transform.begin", transformUpdate: "transform.update", transformSetBounds: "transform.setBounds", + transformSetRotation: "transform.setRotation", transformEnd: "transform.end", viewportPan: "viewport.pan", viewportSetZoom: "viewport.setZoom", diff --git a/commands/index.ts b/commands/index.ts index 5e93a82..0ba7f25 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -11,6 +11,7 @@ export { documentApplyLayerMaskOperationCommand, documentCommands, documentGroupLayersCommand, + documentDuplicateLayerCommand, documentMoveLayerCommand, documentRemoveArtboardCommand, documentRemoveLayerCommand, @@ -22,6 +23,7 @@ export { documentSetArtboardVisibleCommand, documentSetLayerClippingMaskCommand, documentSetLayerLockedCommand, + documentSetLayerOpacityCommand, documentSetLayerVisibleCommand, documentUpdateAssetSourceCommand, documentUngroupLayerCommand, @@ -35,6 +37,7 @@ export type { DocumentAddRasterLayerPayload, DocumentApplyLayerMaskOperationPayload, DocumentGroupLayersPayload, + DocumentDuplicateLayerPayload, DocumentMoveLayerPayload, DocumentRemoveArtboardPayload, DocumentRemoveLayerPayload, @@ -46,6 +49,7 @@ export type { DocumentSetArtboardVisiblePayload, DocumentSetLayerClippingMaskPayload, DocumentSetLayerLockedPayload, + DocumentSetLayerOpacityPayload, DocumentSetLayerVisiblePayload, DocumentUpdateAssetSourcePayload, DocumentUngroupLayerPayload, @@ -90,8 +94,8 @@ export { createCommandRegistry } from "./registry"; export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection"; export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; export { toolChooseGenerateIntentCommand, toolCommands, toolEnterMaskEditCommand, toolEnterTemporaryPanCommand, toolExitMaskEditCommand, toolExitTemporaryPanCommand, toolSetActiveCommand, toolSetBrushPreviewCommand, toolSetBrushSettingsCommand, toolSetBrushStrokePreviewCommand, toolSetChromaKeySettingsCommand, toolSetGenerateSettingsCommand, toolSetMagicWandSettingsCommand, toolSetMaskViewModeCommand } from "./tool"; -export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform"; -export type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; +export { transformBeginCommand, transformCommands, transformEndCommand, transformSetBoundsCommand, transformSetRotationCommand, transformUpdateCommand } from "./transform"; +export type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform"; export type { ToolChooseGenerateIntentPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool"; export { viewportCommands, diff --git a/commands/payloads.ts b/commands/payloads.ts index b1518a3..83c3950 100644 --- a/commands/payloads.ts +++ b/commands/payloads.ts @@ -8,6 +8,7 @@ import type { DocumentAddRasterLayerPayload, DocumentApplyLayerMaskOperationPayload, DocumentGroupLayersPayload, + DocumentDuplicateLayerPayload, DocumentMoveLayerPayload, DocumentRemoveArtboardPayload, DocumentRemoveLayerPayload, @@ -19,6 +20,7 @@ import type { DocumentSetArtboardVisiblePayload, DocumentSetLayerClippingMaskPayload, DocumentSetLayerLockedPayload, + DocumentSetLayerOpacityPayload, DocumentSetLayerVisiblePayload, DocumentUpdateAssetSourcePayload, DocumentUngroupLayerPayload, @@ -45,7 +47,7 @@ import type { } from "./palette"; import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection"; import type { ToolChooseGenerateIntentPayload, ToolEnterMaskEditPayload, ToolSetActivePayload, ToolSetBrushPreviewPayload, ToolSetBrushSettingsPayload, ToolSetBrushStrokePreviewPayload, ToolSetChromaKeySettingsPayload, ToolSetGenerateSettingsPayload, ToolSetMagicWandSettingsPayload, ToolSetMaskViewModePayload } from "./tool"; -import type { TransformBeginPayload, TransformSetBoundsPayload, TransformUpdatePayload } from "./transform"; +import type { TransformBeginPayload, TransformSetBoundsPayload, TransformSetRotationPayload, TransformUpdatePayload } from "./transform"; import type { WorkspaceSetPanelPayload } from "./workspace"; import type { EditorSetPointerSessionPayload } from "./editor"; import type { ProjectOpenPayload } from "./project"; @@ -79,6 +81,8 @@ export type CommandPayloads = { [commandIds.documentRemoveLayer]: DocumentRemoveLayerPayload; [commandIds.documentSetLayerVisible]: DocumentSetLayerVisiblePayload; [commandIds.documentSetLayerLocked]: DocumentSetLayerLockedPayload; + [commandIds.documentSetLayerOpacity]: DocumentSetLayerOpacityPayload; + [commandIds.documentDuplicateLayer]: DocumentDuplicateLayerPayload; [commandIds.documentRenameLayer]: DocumentRenameLayerPayload; [commandIds.documentSetLayerClippingMask]: DocumentSetLayerClippingMaskPayload; [commandIds.selectionSet]: SelectionSetPayload; @@ -115,6 +119,7 @@ export type CommandPayloads = { [commandIds.transformBegin]: TransformBeginPayload; [commandIds.transformUpdate]: TransformUpdatePayload; [commandIds.transformSetBounds]: TransformSetBoundsPayload; + [commandIds.transformSetRotation]: TransformSetRotationPayload; [commandIds.transformEnd]: void; [commandIds.viewportPan]: ViewportPanPayload; [commandIds.viewportSetZoom]: ViewportSetZoomPayload; diff --git a/commands/transform.test.ts b/commands/transform.test.ts index 536fa0c..0a7fad0 100644 --- a/commands/transform.test.ts +++ b/commands/transform.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; import { createInitialAppState } from "@editor/initial-state"; import { documentAddArtboardCommand } from "./document"; -import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformUpdateCommand } from "./transform"; +import { transformBeginCommand, transformEndCommand, transformSetBoundsCommand, transformSetRotationCommand, transformUpdateCommand } from "./transform"; +import { documentAddAssetCommand, documentAddRasterLayerCommand, documentAddLayerMaskCommand, documentSetLayerLockedCommand } from "./document"; function artboardState() { return documentAddArtboardCommand.execute( @@ -79,6 +80,19 @@ describe("transform commands", () => { expect(updated.document.artboards[0]?.bounds).toEqual({ x: 4, y: 19, w: 1, h: 1 }); }); + test("sets leaf rotation and keeps its attached mask aligned", () => { + let state = artboardState(); + state = documentAddAssetCommand.execute({ state }, { asset: { id: "asset", name: "Asset", mimeType: "image/png", source: "asset", intrinsicSize: { w: 10, h: 10 } } }); + state = documentAddRasterLayerCommand.execute({ state }, { artboardId: "a1", layer: { id: "layer", type: "raster", name: "Layer", visible: true, locked: false, opacity: 1, assetId: "asset", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } } }); + state = documentAddLayerMaskCommand.execute({ state }, { layerId: "layer", asset: { id: "mask-asset", name: "Mask", mimeType: "image/png", source: "mask", intrinsicSize: { w: 10, h: 10 } }, maskLayer: { id: "mask", type: "raster", name: "Mask", visible: true, locked: false, opacity: 1, assetId: "mask-asset", transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 } } }); + + const rotated = transformSetRotationCommand.execute({ state }, { target: { type: "layer", id: "layer" }, rotation: Math.PI / 2 }); + expect(rotated.document.artboards[0]?.layers.map((layer) => layer.transform.rotation)).toEqual([Math.PI / 2, Math.PI / 2]); + + const locked = documentSetLayerLockedCommand.execute({ state: rotated }, { layerId: "layer", locked: true }); + expect(transformSetRotationCommand.execute({ state: locked }, { target: { type: "layer", id: "layer" }, rotation: 0 })).toBe(locked); + }); + test("ends transform session", () => { const started = transformBeginCommand.execute( { state: artboardState() }, diff --git a/commands/transform.ts b/commands/transform.ts index ad0f595..d811e37 100644 --- a/commands/transform.ts +++ b/commands/transform.ts @@ -1,8 +1,10 @@ import type { Rect, Vec2D } from "@core/geometry"; +import type { Layer } from "@core/layer"; import { applyTransformTargetBounds } from "./transform-document"; import type { TransformHandle, TransformTarget } from "@editor/transform"; import type { Command } from "./command"; import { commandIds } from "./ids"; +import type { AppState } from "@editor/state"; export type TransformBeginPayload = { target: TransformTarget; @@ -21,6 +23,11 @@ export type TransformSetBoundsPayload = { bounds: Rect; }; +export type TransformSetRotationPayload = { + target: TransformTarget; + rotation: number; +}; + export const transformBeginCommand: Command = { id: commandIds.transformBegin, name: "Begin transform", @@ -70,6 +77,7 @@ export const transformSetBoundsCommand: Command = { id: commandIds.transformSetBounds, name: "Set transform bounds", execute({ state }, payload) { + if (isTargetLocked(state, payload.target)) return state; return { ...state, document: applyTransformTargetBounds(state.document, payload.target, normalizeRect(payload.bounds)), @@ -77,6 +85,42 @@ export const transformSetBoundsCommand: Command = { }, }; +export const transformSetRotationCommand: Command = { + id: commandIds.transformSetRotation, + name: "Set transform rotation", + execute({ state }, payload) { + if (payload.target.type !== "layer" || !Number.isFinite(payload.rotation) || isTargetLocked(state, payload.target)) return state; + const location = state.document.artboards.flatMap((artboard) => findLayerInTree(artboard.layers, payload.target.id)).find(Boolean); + if (!location || location.type === "group") return state; + const maskId = "layerMask" in location ? location.layerMask?.maskLayerId : undefined; + const ids = new Set([payload.target.id, ...(maskId ? [maskId] : [])]); + return { + ...state, + document: { + ...state.document, + artboards: state.document.artboards.map((artboard) => ({ ...artboard, layers: mapRotation(artboard.layers, ids, payload.rotation) })), + }, + }; + }, +}; + +function isTargetLocked(state: AppState, target: TransformTarget) { + if (target.type === "artboard") return state.document.artboards.find((artboard) => artboard.id === target.id)?.locked !== false; + return state.document.artboards.flatMap((artboard) => findLayerInTree(artboard.layers, target.id)).find(Boolean)?.locked !== false; +} + +function findLayerInTree(layers: Layer[], id: string): Layer[] { + return layers.flatMap((layer) => layer.id === id ? [layer] : layer.type === "group" ? findLayerInTree(layer.children, id) : []); +} + +function mapRotation(layers: Layer[], ids: ReadonlySet, rotation: number): Layer[] { + return layers.map((layer) => ({ + ...layer, + ...(ids.has(layer.id) ? { transform: { ...layer.transform, rotation } } : {}), + ...(layer.type === "group" ? { children: mapRotation(layer.children, ids, rotation) } : {}), + })) as Layer[]; +} + export const transformEndCommand: Command = { id: commandIds.transformEnd, name: "End transform", @@ -94,7 +138,7 @@ export const transformEndCommand: Command = { }, }; -export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformEndCommand] satisfies Command[]; +export const transformCommands = [transformBeginCommand, transformUpdateCommand, transformSetBoundsCommand, transformSetRotationCommand, transformEndCommand] satisfies Command[]; function transformBounds(bounds: Rect, handle: TransformHandle, delta: Vec2D, constrained = false): Rect { if (handle === "body") { diff --git a/operations/document/layerActions.ts b/operations/document/layerActions.ts index 5d1f246..a54d15c 100644 --- a/operations/document/layerActions.ts +++ b/operations/document/layerActions.ts @@ -143,6 +143,25 @@ export function addLayerMask(documentIndex: DocumentReadIndex, layerInfo: Indexe }); } +export function duplicateLayer(documentIndex: DocumentReadIndex, layerInfo: IndexedLayerInfo, dispatch: AppStore["dispatch"]) { + const ids = collectDuplicateIds(layerInfo.layer, layerInfo.siblings); + dispatch(commandIds.documentDuplicateLayer, { + layerId: layerInfo.layer.id, + idByLayerId: Object.fromEntries(ids.map((id) => [id, crypto.randomUUID()])), + }); +} + +function collectDuplicateIds(layer: Layer, siblings: readonly Layer[]): string[] { + const ids = collectTreeIds(layer); + const maskId = getLayerMask(layer)?.maskLayerId; + const mask = maskId ? siblings.find((candidate) => candidate.id === maskId) : undefined; + return mask ? [...collectTreeIds(mask), ...ids] : ids; +} + +function collectTreeIds(layer: Layer): string[] { + return [layer.id, ...(layer.type === "group" ? layer.children.flatMap(collectTreeIds) : [])]; +} + const emptyLayerIds = new Set(); function createGroup(name: string): Extract { diff --git a/renderer/image-textures.ts b/renderer/image-textures.ts index b9dc7b7..6a9aa01 100644 --- a/renderer/image-textures.ts +++ b/renderer/image-textures.ts @@ -1,14 +1,15 @@ import { createMaskedProgram, createMaskRevealPreviewProgram, createProgram, createTintedProgram, getMaskVisualizationResources, maskVisualizationModeValue, type MaskVisualizationResources } from "./image-texture-programs"; import type { Asset } from "@core/asset"; import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types"; +import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect"; export type MaskVisualizationMode = "blackWhite" | "alpha" | "hiddenOverlay"; export type ImageTextureRenderer = { syncAssets(assets: readonly Asset[]): void; - render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean; - renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number): boolean; - renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number): boolean; + render(asset: Asset, rect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number): boolean; + renderMasked(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, clipRect?: ScreenRect, opacity?: number, rotation?: number): boolean; + renderMaskRevealPreview(asset: Asset, rect: ScreenRect, maskAsset: Asset, maskRect: ScreenRect, opacity: number, clipRect?: ScreenRect, layerOpacity?: number, rotation?: number): boolean; renderMaskVisualization(maskAsset: Asset, maskRect: ScreenRect, mode: MaskVisualizationMode, color?: RgbaColor, clipRect?: ScreenRect): boolean; renderTinted(asset: Asset, rect: ScreenRect, color: RgbaColor, clipRect?: ScreenRect, opacity?: number): boolean; dispose(): void; @@ -87,10 +88,11 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali } } }, - render(asset, rect, clipRect, opacity = 1) { + render(asset, rect, clipRect, opacity = 1, rotation = 0) { const clampedOpacity = clampOpacity(opacity); if (clampedOpacity <= 0) return true; - const drawRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; + const rotatedRect = rotatedRectBounds(rect, rotation); + const drawRect = clipRect ? intersectScreenRects(rotatedRect, clipRect) : rotatedRect; if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed); @@ -108,7 +110,7 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.uniform1f(opacityLocation, clampedOpacity); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); - gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect), gl.DYNAMIC_DRAW); + gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(positionLocation); gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); @@ -121,11 +123,13 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.disable(gl.BLEND); return true; }, - renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1) { + renderMasked(asset, rect, maskAsset, maskRect, clipRect, opacity = 1, rotation = 0) { const clampedOpacity = clampOpacity(opacity); if (clampedOpacity <= 0) return true; - const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; - const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined; + const contentBounds = rotatedRectBounds(rect, rotation); + const maskBounds = rotatedRectBounds(maskRect, rotation); + const clippedRect = clipRect ? intersectScreenRects(contentBounds, clipRect) : contentBounds; + const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskBounds) : undefined; if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed); @@ -148,17 +152,17 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.uniform1f(maskedOpacityLocation, clampedOpacity); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); - gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW); + gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(maskedPositionLocation); gl.vertexAttribPointer(maskedPositionLocation, 2, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); - gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW); + gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(maskedTexCoordLocation); gl.vertexAttribPointer(maskedTexCoordLocation, 2, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer); - gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW); + gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(maskedMaskTexCoordLocation); gl.vertexAttribPointer(maskedMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0); @@ -166,12 +170,14 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.disable(gl.BLEND); return true; }, - renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1) { + renderMaskRevealPreview(asset, rect, maskAsset, maskRect, opacity, clipRect, layerOpacity = 1, rotation = 0) { const clampedOpacity = clampOpacity(opacity) * clampOpacity(layerOpacity); if (clampedOpacity <= 0) return true; - const clippedRect = clipRect ? intersectScreenRects(rect, clipRect) : rect; - const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskRect) : undefined; + const contentBounds = rotatedRectBounds(rect, rotation); + const maskBounds = rotatedRectBounds(maskRect, rotation); + const clippedRect = clipRect ? intersectScreenRects(contentBounds, clipRect) : contentBounds; + const drawRect = clippedRect ? intersectScreenRects(clippedRect, maskBounds) : undefined; if (!drawRect || drawRect.w <= 0 || drawRect.h <= 0) return true; const entry = getTextureEntry(context, textures, asset, invalidate, () => disposed); @@ -194,17 +200,17 @@ export function createImageTextureRenderer(context: WebGlRendererContext, invali gl.uniform1f(maskRevealPreviewOpacityLocation, clampedOpacity); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); - gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, drawRect), gl.DYNAMIC_DRAW); + gl.bufferData(gl.ARRAY_BUFFER, rectVertices(context.canvas, rect, rotation), gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(maskRevealPreviewPositionLocation); gl.vertexAttribPointer(maskRevealPreviewPositionLocation, 2, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer); - gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, rect), gl.DYNAMIC_DRAW); + gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(maskRevealPreviewTexCoordLocation); gl.vertexAttribPointer(maskRevealPreviewTexCoordLocation, 2, gl.FLOAT, false, 0, 0); gl.bindBuffer(gl.ARRAY_BUFFER, maskTexCoordBuffer); - gl.bufferData(gl.ARRAY_BUFFER, texCoordsForRect(drawRect, maskRect), gl.DYNAMIC_DRAW); + gl.bufferData(gl.ARRAY_BUFFER, fullTexCoords(), gl.DYNAMIC_DRAW); gl.enableVertexAttribArray(maskRevealPreviewMaskTexCoordLocation); gl.vertexAttribPointer(maskRevealPreviewMaskTexCoordLocation, 2, gl.FLOAT, false, 0, 0); @@ -422,11 +428,10 @@ function texCoordsForRect(drawRect: ScreenRect, sourceRect: ScreenRect) { return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]); } -function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect) { - const x1 = (rect.x / canvas.width) * 2 - 1; - const x2 = ((rect.x + rect.w) / canvas.width) * 2 - 1; - const y1 = 1 - (rect.y / canvas.height) * 2; - const y2 = 1 - ((rect.y + rect.h) / canvas.height) * 2; - - return new Float32Array([x1, y1, x2, y1, x1, y2, x1, y2, x2, y1, x2, y2]); +function rectVertices(canvas: HTMLCanvasElement, rect: ScreenRect, rotation = 0) { + const [nw, ne, sw, se] = rotatedRectCorners(rect, rotation).map((point) => ({ + x: (point.x / canvas.width) * 2 - 1, + y: 1 - (point.y / canvas.height) * 2, + })); + return new Float32Array([nw!.x, nw!.y, ne!.x, ne!.y, sw!.x, sw!.y, sw!.x, sw!.y, ne!.x, ne!.y, se!.x, se!.y]); } diff --git a/renderer/layers.ts b/renderer/layers.ts index 16c6a92..125ca04 100644 --- a/renderer/layers.ts +++ b/renderer/layers.ts @@ -91,17 +91,17 @@ function renderLeafLayer( if (asset && maskAsset && maskRect && activeMaskTarget) { if (maskViewMode === "blackWhite" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "blackWhite", undefined, effectiveClipRect)) return; if (maskViewMode === "alpha" && imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "alpha", undefined, effectiveClipRect)) return; - if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) { + if (maskViewMode === "overlay" && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) { imageTextureRenderer.renderMaskVisualization(maskAsset, maskRect, "hiddenOverlay", hiddenMaskOverlayColor, effectiveClipRect); return; } } - if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity)) { - if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity); + if (asset && maskAsset && maskRect && imageTextureRenderer.renderMasked(asset, rect, maskAsset, maskRect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) { + if (showMaskRevealPreview) imageTextureRenderer.renderMaskRevealPreview(asset, rect, maskAsset, maskRect, maskRevealPreviewOpacity, effectiveClipRect, effectiveOpacity, layer.transform.rotation); return; } - if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity)) return; + if (asset && imageTextureRenderer.render(asset, rect, effectiveClipRect, effectiveOpacity, layer.transform.rotation)) return; const fallbackRect = intersectScreenRects(rect, effectiveClipRect); if (!fallbackRect) return; diff --git a/renderer/rotated-rect.test.ts b/renderer/rotated-rect.test.ts new file mode 100644 index 0000000..b4b2ebc --- /dev/null +++ b/renderer/rotated-rect.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { rotatedRectBounds, rotatedRectCorners } from "./rotated-rect"; + +describe("rotated rectangle geometry", () => { + test("interprets rotation as radians and keeps the center anchored", () => { + const corners = rotatedRectCorners({ x: 10, y: 20, w: 40, h: 20 }, Math.PI / 2); + + expectPoint(corners[0], { x: 40, y: 10 }); + expectPoint(corners[1], { x: 40, y: 50 }); + expectPoint(corners[2], { x: 20, y: 10 }); + expectPoint(corners[3], { x: 20, y: 50 }); + expect((corners[0].x + corners[3].x) / 2).toBeCloseTo(30); + expect((corners[0].y + corners[3].y) / 2).toBeCloseTo(30); + }); + + test("expands axis-aligned clipping bounds around a rotated quad", () => { + const bounds = rotatedRectBounds({ x: 10, y: 20, w: 40, h: 20 }, Math.PI / 2); + + expect(bounds.x).toBeCloseTo(20); + expect(bounds.y).toBeCloseTo(10); + expect(bounds.w).toBeCloseTo(20); + expect(bounds.h).toBeCloseTo(40); + }); + + test("preserves an unrotated rectangle exactly", () => { + const rect = { x: 10, y: 20, w: 40, h: 20 }; + expect(rotatedRectBounds(rect, 0)).toBe(rect); + expect(rotatedRectCorners(rect, 0)).toEqual([ + { x: 10, y: 20 }, + { x: 50, y: 20 }, + { x: 10, y: 40 }, + { x: 50, y: 40 }, + ]); + }); +}); + +function expectPoint(actual: { x: number; y: number }, expected: { x: number; y: number }) { + expect(actual.x).toBeCloseTo(expected.x); + expect(actual.y).toBeCloseTo(expected.y); +} diff --git a/renderer/rotated-rect.ts b/renderer/rotated-rect.ts new file mode 100644 index 0000000..0fba0fb --- /dev/null +++ b/renderer/rotated-rect.ts @@ -0,0 +1,33 @@ +import type { ScreenRect } from "./types"; + +export type ScreenPoint = { x: number; y: number }; + +/** Returns corners in northwest, northeast, southwest, southeast order. */ +export function rotatedRectCorners(rect: ScreenRect, rotation: number): [ScreenPoint, ScreenPoint, ScreenPoint, ScreenPoint] { + if (!Number.isFinite(rotation)) rotation = 0; + const center = { x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 }; + const cosine = Math.cos(rotation); + const sine = Math.sin(rotation); + const rotate = (point: ScreenPoint): ScreenPoint => { + const x = point.x - center.x; + const y = point.y - center.y; + return { x: center.x + x * cosine - y * sine, y: center.y + x * sine + y * cosine }; + }; + return [ + rotate({ x: rect.x, y: rect.y }), + rotate({ x: rect.x + rect.w, y: rect.y }), + rotate({ x: rect.x, y: rect.y + rect.h }), + rotate({ x: rect.x + rect.w, y: rect.y + rect.h }), + ]; +} + +/** Axis-aligned screen bounds used to constrain WebGL scissoring around a rotated quad. */ +export function rotatedRectBounds(rect: ScreenRect, rotation: number): ScreenRect { + if (rotation === 0) return rect; + const points = rotatedRectCorners(rect, rotation); + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const x = Math.min(...xs); + const y = Math.min(...ys); + return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; +} diff --git a/view/BottomControlsIsland.tsx b/view/BottomControlsIsland.tsx index 4b8eff2..80cd601 100644 --- a/view/BottomControlsIsland.tsx +++ b/view/BottomControlsIsland.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import type { AppStore } from "@editor/store"; import type { ImageDocument } from "@core/document"; import type { GenerationState, MaskViewMode, SelectionState, ViewportState } from "@editor/state"; @@ -13,6 +14,7 @@ import type { Rect } from "@core/geometry"; import type { TransformTarget } from "@editor/transform"; import type { GenerationWorkflow } from "@operations/generation/workflow"; import type { DocumentActions } from "@app/document-actions"; +import { createDocumentReadIndex } from "@editor/document-indexes"; export type BottomControlsAction = "pan" | "zoom"; export type BottomControlsIslandProps = { @@ -39,6 +41,8 @@ export type BottomControlsIslandProps = { }; export function BottomControlsIsland({ document, selection, viewport, visible, action, activeTool, operation, brushSettings, generateSettings, generation, chromaKeySettings, magicWandSettings, editingMask = false, maskViewMode = "composite", transformBounds, transformTarget, brushHint, dispatch, generationWorkflow, documentActions }: BottomControlsIslandProps) { + const documentIndex = useMemo(() => createDocumentReadIndex(document), [document]); + const selectedLayerInfo = selection.layerIds.length === 1 && selection.layerIds[0] ? documentIndex.layerInfoById.get(selection.layerIds[0]) : undefined; const zoomPercent = Math.round(viewport.zoom * 100); const x = Math.round(viewport.center.x); const y = Math.round(viewport.center.y); @@ -61,7 +65,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a ) : activeTool === "magicWand" ? ( ) : transformBounds && transformTarget ? ( - + ) : action === "pan" ? ( ) : ( diff --git a/view/bottom-controls/TransformControls.tsx b/view/bottom-controls/TransformControls.tsx index 6fe6599..78aee0e 100644 --- a/view/bottom-controls/TransformControls.tsx +++ b/view/bottom-controls/TransformControls.tsx @@ -1,27 +1,41 @@ import { useEffect, useState, type Dispatch, type SetStateAction } from "react"; -import { BoundingBox } from "@phosphor-icons/react"; +import { BoundingBox, Copy, MaskHappy } from "@phosphor-icons/react"; import { commandIds } from "@commands/ids"; import type { Rect } from "@core/geometry"; import type { AppStore } from "@editor/store"; import type { TransformTarget } from "@editor/transform"; +import type { DocumentReadIndex, IndexedLayerInfo } from "@editor/document-indexes"; +import { addLayerMask, duplicateLayer } from "@operations/document/layerActions"; +import { getLayerMask } from "@core/layer-mask-utils"; import { BottomControlDivider } from "./Divider"; import { bottomControlFieldClass, bottomControlIconSlotClass, bottomControlInputClass, bottomControlLabelClass, bottomControlMenuClass } from "./styles"; export type TransformControlsProps = { bounds: Rect; target: TransformTarget; + documentIndex: DocumentReadIndex; + layerInfo?: IndexedLayerInfo; dispatch: AppStore["dispatch"]; }; type BoundsField = keyof Rect; -export function TransformControls({ bounds, target, dispatch }: TransformControlsProps) { +export function TransformControls({ bounds, target, documentIndex, layerInfo, dispatch }: TransformControlsProps) { 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 layer = layerInfo?.layer; + const locked = layer?.locked ?? false; + const mask = layer ? getLayerMask(layer) : undefined; + const rotatedMaskEditingUnsupported = Boolean(layer && layer.type !== "group" && layer.transform.rotation !== 0); useEffect(() => { setDraft(draftFromBounds(bounds)); }, [bounds.x, bounds.y, bounds.w, bounds.h]); + useEffect(() => setOpacityDraft(String(Math.round((layer?.opacity ?? 1) * 100))), [layer?.id, layer?.opacity]); + useEffect(() => setRotationDraft(rotationDegrees(layerInfo)), [layer?.id, layer?.transform.rotation]); + const commitField = (field: BoundsField) => { const value = Number.parseFloat(draft[field]); if (!Number.isFinite(value)) { @@ -29,6 +43,7 @@ export function TransformControls({ bounds, target, dispatch }: TransformControl return; } + if (locked) return; dispatch(commandIds.transformSetBounds, { target, bounds: { @@ -44,36 +59,108 @@ export function TransformControls({ bounds, target, dispatch }: TransformControl - - + + - - + + + {layer ? ( + <> + + { + const value = Number.parseFloat(opacityDraft); + if (Number.isFinite(value)) dispatch(commandIds.documentSetLayerOpacity, { layerId: layer.id, opacity: value / 100 }); + else setOpacityDraft(String(Math.round(layer.opacity * 100))); + }} + /> + { + const value = Number.parseFloat(rotationDraft); + if (Number.isFinite(value)) dispatch(commandIds.transformSetRotation, { target, rotation: value * Math.PI / 180 }); + else setRotationDraft(rotationDegrees(layerInfo)); + }} + /> + + + {layer.type !== "group" ? ( + + ) : null} + {locked ? Unlock to edit : null} + + ) : null} ); } +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 ( + + ); +} + +function rotationDegrees(layerInfo?: IndexedLayerInfo) { + return String(Math.round((layerInfo?.layer.transform.rotation ?? 0) * 180 / Math.PI)); +} + +function actionButtonClass() { + return "inline-flex h-10 items-center gap-2 rounded-full px-3 text-xs font-semibold text-white/70 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"; +} + function BoundsInput({ label, field, draft, + disabled, setDraft, commitField, }: { label: string; field: BoundsField; draft: string; + disabled?: boolean; setDraft: Dispatch>>; commitField: (field: BoundsField) => void; }) { return ( -