feat: enhance layer mask handling and opacity management

- Introduced `getLayerMask` utility to streamline layer mask retrieval.
- Updated layer rendering logic to incorporate layer masks and opacity adjustments.
- Added functionality to prevent dropping a group into its descendants.
- Enhanced image texture rendering to support opacity parameters across various rendering functions.
- Implemented group layer bounds application for better scaling and positioning.
- Added tests to ensure correct behavior when handling layer masks and group layers.
- Created new types for asset generation provenance to track generated assets more effectively.
This commit is contained in:
syntaxbullet
2026-07-05 16:06:23 +02:00
parent a83024c35c
commit 5eaf37ba28
33 changed files with 635 additions and 88 deletions

View File

@@ -22,7 +22,10 @@ const document: ImageDocument = {
locked: false,
layers: [
raster("mask", "Mask", "asset-mask"),
{ ...raster("target", "Target", "asset-target", { x: 10, y: 20 }, { x: 0.5, y: 0.5 }), clippingMask: { maskLayerId: "mask" } },
{
...raster("target", "Target", "asset-target", { x: 10, y: 20 }, { x: 0.5, y: 0.5 }),
layerMask: { kind: "raster", maskLayerId: "mask", enabled: true, inverted: false },
},
group("group", "Group", [
raster("nested-mask", "Nested Mask", "asset-mask"),
{ ...raster("nested-target", "Nested Target", "asset-nested", { x: 80, y: 10 }, { x: 2, y: 3 }), clippingMask: { maskLayerId: "nested-mask" } },

View File

@@ -3,6 +3,7 @@ import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
import type { ArtboardId, AssetId, LayerId } from "@core/id";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
export type IndexedLayerInfo = {
artboardId: ArtboardId;
@@ -113,9 +114,10 @@ function indexLayerTree(options: {
index,
});
if (layer.clippingMask) {
options.documentMaskLayerIds.add(layer.clippingMask.maskLayerId);
layerListMaskLayerIds.add(layer.clippingMask.maskLayerId);
const layerMask = getLayerMask(layer);
if (layerMask) {
options.documentMaskLayerIds.add(layerMask.maskLayerId);
layerListMaskLayerIds.add(layerMask.maskLayerId);
}
if (layer.type === "group") {

View File

@@ -15,6 +15,7 @@ export const initialEditorState: EditorState = {
generation: {
candidates: [],
selectedCandidateId: undefined,
compareMode: "result",
},
transformSession: undefined,
maskEdit: undefined,

View File

@@ -79,9 +79,12 @@ export type GenerationCandidate = {
};
};
export type GenerationCompareMode = "result" | "before" | "split";
export type GenerationState = {
candidates: GenerationCandidate[];
selectedCandidateId?: string;
compareMode: GenerationCompareMode;
};
export type EditorState = {

View File

@@ -44,6 +44,10 @@ describe("transform targets", () => {
expect(resolveTransformTargetBounds(document, { type: "layer", id: "l1" })).toEqual({ x: 10, y: 20, w: 100, h: 200 });
});
test("resolves group bounds from descendant layers", () => {
expect(resolveTransformTargetBounds(groupDocument(), { type: "layer", id: "g1" })).toEqual({ x: 10, y: 20, w: 300, h: 200 });
});
test("applies image layer bounds to transform", () => {
const next = applyTransformTargetBounds(document, { type: "layer", id: "l1" }, { x: 30, y: 40, w: 400, h: 50 });
const layer = next.artboards[0]?.layers[0];
@@ -81,8 +85,49 @@ describe("transform targets", () => {
expect(next.artboards[0]?.layers[1]?.transform).toEqual({ position: { x: 30, y: 40 }, scale: { x: 2, y: 0.5 }, rotation: 0 });
});
test("applies group bounds to descendant layer transforms", () => {
const next = applyTransformTargetBounds(groupDocument(), { type: "layer", id: "g1" }, { x: 20, y: 40, w: 600, h: 100 });
const group = next.artboards[0]?.layers[0];
const first = group?.type === "group" ? group.children[0] : undefined;
const second = group?.type === "group" ? group.children[1] : undefined;
expect(first?.transform).toEqual({ position: { x: 20, y: 40 }, scale: { x: 1, y: 1 }, rotation: 0 });
expect(second?.transform).toEqual({ position: { x: 420, y: 90 }, scale: { x: 1, y: 0.5 }, rotation: 0 });
});
test("applies artboard bounds", () => {
const next = applyTransformTargetBounds(document, { type: "artboard", id: "a1" }, { x: 10, y: 20, w: 200, h: 160 });
expect(next.artboards[0]?.bounds).toEqual({ x: 10, y: 20, w: 200, h: 160 });
});
});
function groupDocument(): ImageDocument {
return {
...document,
artboards: [
{
...document.artboards[0]!,
layers: [
{
id: "g1",
type: "group",
name: "Group",
visible: true,
locked: false,
opacity: 1,
transform: { position: { x: 0, y: 0 }, scale: { x: 1, y: 1 }, rotation: 0 },
children: [
document.artboards[0]!.layers[0]!,
{
...document.artboards[0]!.layers[0]!,
id: "l2",
name: "Second Layer",
transform: { position: { x: 210, y: 120 }, scale: { x: 0.5, y: 1 }, rotation: 0 },
},
],
},
],
},
],
};
}

View File

@@ -1,6 +1,7 @@
import type { ImageDocument } from "@core/document";
import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer";
import { getLayerMask } from "@core/layer-mask-utils";
import type { ArtboardId, LayerId } from "@core/id";
import type { TransformTarget } from "./transform";
@@ -35,7 +36,11 @@ export function selectedTransformTarget(document: ImageDocument, selection: { ar
function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rect): ImageDocument {
const layer = findLayer(document, layerId);
const targetLayerIds = layer?.clippingMask ? [layerId, layer.clippingMask.maskLayerId] : [layerId];
if (!layer) return document;
if (layer.type === "group") return applyGroupLayerBounds(document, layer.id, bounds);
const layerMask = getLayerMask(layer);
const targetLayerIds = layerMask ? [layerId, layerMask.maskLayerId] : [layerId];
return targetLayerIds.reduce(
(nextDocument, targetLayerId) => ({
@@ -49,6 +54,67 @@ function applyLayerBounds(document: ImageDocument, layerId: LayerId, bounds: Rec
);
}
function applyGroupLayerBounds(document: ImageDocument, groupId: LayerId, bounds: Rect): ImageDocument {
const group = findLayer(document, groupId);
if (!group || group.type !== "group") return document;
const initialBounds = resolveLayerBounds(document, group);
if (!initialBounds || initialBounds.w === 0 || initialBounds.h === 0) return document;
const scale = {
x: bounds.w / initialBounds.w,
y: bounds.h / initialBounds.h,
};
return {
...document,
artboards: document.artboards.map((artboard) => ({
...artboard,
layers: applyGroupLayerBoundsInTree(document, artboard.layers, groupId, initialBounds, bounds, scale),
})),
};
}
function applyGroupLayerBoundsInTree(document: ImageDocument, layers: Layer[], groupId: LayerId, initialBounds: Rect, bounds: Rect, scale: { x: number; y: number }): Layer[] {
return layers.map((layer) => {
if (layer.type === "group" && layer.id === groupId) {
return {
...layer,
children: layer.children.map((child) => scaleLayerSubtree(document, child, initialBounds, bounds, scale)),
};
}
if (layer.type === "group") return { ...layer, children: applyGroupLayerBoundsInTree(document, layer.children, groupId, initialBounds, bounds, scale) };
return layer;
});
}
function scaleLayerSubtree(document: ImageDocument, layer: Layer, initialBounds: Rect, bounds: Rect, scale: { x: number; y: number }): Layer {
if (layer.type === "group") {
return {
...layer,
children: layer.children.map((child) => scaleLayerSubtree(document, child, initialBounds, bounds, scale)),
};
}
const asset = document.assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) return layer;
return {
...layer,
transform: {
...layer.transform,
position: {
x: bounds.x + (layer.transform.position.x - initialBounds.x) * scale.x,
y: bounds.y + (layer.transform.position.y - initialBounds.y) * scale.y,
},
scale: {
x: layer.transform.scale.x * scale.x,
y: layer.transform.scale.y * scale.y,
},
},
};
}
function applyLayerBoundsInTree(document: ImageDocument, layers: Layer[], layerId: LayerId, bounds: Rect): Layer[] {
return layers.map((layer) => {
if (layer.id === layerId && (layer.type === "image" || layer.type === "raster")) {