@pixerate/editor 0.97.0 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/THIRD_PARTY_NOTICES.md +4 -2
  2. package/dist/animation-editor/index.cjs +55 -53
  3. package/dist/animation-editor/index.cjs.map +1 -1
  4. package/dist/animation-editor/index.d.cts +3 -2
  5. package/dist/animation-editor/index.d.ts +3 -2
  6. package/dist/animation-editor/index.js +11 -9
  7. package/dist/animation-editor/index.js.map +1 -1
  8. package/dist/chunk-DWGZUSES.cjs +694 -0
  9. package/dist/chunk-DWGZUSES.cjs.map +1 -0
  10. package/dist/chunk-GX332W2C.js +181 -0
  11. package/dist/chunk-GX332W2C.js.map +1 -0
  12. package/dist/{chunk-7MD7C6XQ.cjs → chunk-JF5PXN7W.cjs} +96 -744
  13. package/dist/chunk-JF5PXN7W.cjs.map +1 -0
  14. package/dist/chunk-KKREERP2.js +694 -0
  15. package/dist/chunk-KKREERP2.js.map +1 -0
  16. package/dist/chunk-MUUXNRU4.cjs +181 -0
  17. package/dist/chunk-MUUXNRU4.cjs.map +1 -0
  18. package/dist/{chunk-OEVSVA6V.js → chunk-WIR377F6.js} +788 -22
  19. package/dist/chunk-WIR377F6.js.map +1 -0
  20. package/dist/{chunk-4AB5BHYK.cjs → chunk-WT6ZAWJP.cjs} +788 -22
  21. package/dist/chunk-WT6ZAWJP.cjs.map +1 -0
  22. package/dist/{chunk-4JLB2USO.js → chunk-YGZNGDIT.js} +45 -693
  23. package/dist/chunk-YGZNGDIT.js.map +1 -0
  24. package/dist/image-editor/index.cjs +1798 -333
  25. package/dist/image-editor/index.cjs.map +1 -1
  26. package/dist/image-editor/index.d.cts +482 -6
  27. package/dist/image-editor/index.d.ts +482 -6
  28. package/dist/image-editor/index.js +1488 -23
  29. package/dist/image-editor/index.js.map +1 -1
  30. package/dist/raster/index.cjs +23 -2
  31. package/dist/raster/index.cjs.map +1 -1
  32. package/dist/raster/index.d.cts +98 -2
  33. package/dist/raster/index.d.ts +98 -2
  34. package/dist/raster/index.js +23 -2
  35. package/dist/{svg-ymShkNtu.d.cts → svg-C9r284x8.d.cts} +6 -9
  36. package/dist/{svg-Dh17jCyA.d.ts → svg-MVKBqwD4.d.ts} +6 -9
  37. package/dist/types-CwMPoRMU.d.cts +40 -0
  38. package/dist/types-CwMPoRMU.d.ts +40 -0
  39. package/dist/vector/index.cjs +490 -554
  40. package/dist/vector/index.cjs.map +1 -1
  41. package/dist/vector/index.d.cts +154 -292
  42. package/dist/vector/index.d.ts +154 -292
  43. package/dist/vector/index.js +91 -155
  44. package/dist/vector/index.js.map +1 -1
  45. package/dist/warp-B8rAguzs.d.ts +165 -0
  46. package/dist/warp-nYs2DPrx.d.cts +165 -0
  47. package/package.json +1 -1
  48. package/skills/pixerate-image-editor/SKILL.md +49 -3
  49. package/dist/chunk-4AB5BHYK.cjs.map +0 -1
  50. package/dist/chunk-4JLB2USO.js.map +0 -1
  51. package/dist/chunk-7MD7C6XQ.cjs.map +0 -1
  52. package/dist/chunk-OEVSVA6V.js.map +0 -1
@@ -7,12 +7,15 @@ import {
7
7
  import {
8
8
  DEFAULT_GLOBAL_LIGHT,
9
9
  DIRECT_GAUSSIAN_MAX_SIGMA,
10
+ INTERPOLATIONS,
10
11
  LAYER_EFFECT_KINDS,
11
12
  LayerEffectError,
12
13
  MAX_EFFECT_REACH,
14
+ MAX_TRANSFORM_PIXELS,
13
15
  PatternPlacement,
14
16
  PatternTile,
15
17
  RasterCompositor,
18
+ TransformTooLargeError,
16
19
  ZlibError,
17
20
  adler32,
18
21
  base64ToBytes,
@@ -21,6 +24,7 @@ import {
21
24
  builtinPatternIds,
22
25
  bytesToBase64,
23
26
  changedTiles,
27
+ compositeSurfaceOver,
24
28
  curveLut,
25
29
  decodePng,
26
30
  defaultEffectGradient,
@@ -41,9 +45,11 @@ import {
41
45
  inflate,
42
46
  inflateRaw,
43
47
  insertLayer,
48
+ isInterpolation,
44
49
  isLayerEffectKind,
45
50
  isLayerLocked,
46
51
  isPng,
52
+ keysWeight,
47
53
  layerEffectFields,
48
54
  layerEffectLabel,
49
55
  mapLayer,
@@ -53,12 +59,27 @@ import {
53
59
  parseDataUrl,
54
60
  parseEffectColor,
55
61
  pngDataUrl,
62
+ reduceSurface,
56
63
  renderLayerEffects,
57
64
  scaleLayerEffect,
65
+ transformSurface,
66
+ transformedBounds,
58
67
  validateEffectGradient,
59
68
  validateLayerEffect,
60
- validateLayerEffects
61
- } from "../chunk-OEVSVA6V.js";
69
+ validateLayerEffects,
70
+ warpSurface
71
+ } from "../chunk-WIR377F6.js";
72
+ import {
73
+ affineFromHomography,
74
+ applyHomography,
75
+ boxQuad,
76
+ homographyFromAffine,
77
+ homographyFromPoints,
78
+ homographyKeepsQuad,
79
+ invertHomography,
80
+ multiplyHomographies,
81
+ rectToQuad
82
+ } from "../chunk-GX332W2C.js";
62
83
  import {
63
84
  CoverageMap,
64
85
  DEFAULT_BRUSH,
@@ -136,6 +157,28 @@ import {
136
157
  uniformTile,
137
158
  writeRegion
138
159
  } from "../chunk-HEW6KDNI.js";
160
+ import {
161
+ WARP_STYLES,
162
+ WARP_STYLE_LABELS,
163
+ applyMatrix,
164
+ dragWarpMeshPoint,
165
+ evalWarpMesh,
166
+ identityWarpMesh,
167
+ invertMatrix,
168
+ isValidWarpMesh,
169
+ matrixAbout,
170
+ multiplyMatrices,
171
+ pullWarpMesh,
172
+ resizeWarpMesh,
173
+ rotateMatrix,
174
+ translateMatrix,
175
+ warpMeshColumns,
176
+ warpMeshEditPoints,
177
+ warpMeshGridCurves,
178
+ warpMeshParamAt,
179
+ warpMeshRows,
180
+ warpPresetMesh
181
+ } from "../chunk-KKREERP2.js";
139
182
 
140
183
  // src/image-editor/color.ts
141
184
  var LEGACY_PROCESS_VERSION = 1;
@@ -3150,6 +3193,44 @@ var ImageEditorRenderer = class {
3150
3193
  }
3151
3194
  ctx.restore();
3152
3195
  }
3196
+ if (options.freeTransform) {
3197
+ const t = options.freeTransform;
3198
+ ctx.save();
3199
+ try {
3200
+ const scr = (p) => projectImagePoint(p, projection);
3201
+ const outline = t.outline.map(scr);
3202
+ ctx.beginPath();
3203
+ outline.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
3204
+ ctx.closePath();
3205
+ ctx.lineWidth = 3;
3206
+ ctx.strokeStyle = "rgba(0,0,0,0.5)";
3207
+ ctx.stroke();
3208
+ ctx.lineWidth = 1;
3209
+ ctx.strokeStyle = "#ffffff";
3210
+ ctx.stroke();
3211
+ for (const h of t.handles.map(scr)) {
3212
+ ctx.fillStyle = "#ffffff";
3213
+ ctx.strokeStyle = "#1e293b";
3214
+ ctx.fillRect(h.x - 4, h.y - 4, 8, 8);
3215
+ ctx.strokeRect(h.x - 4, h.y - 4, 8, 8);
3216
+ }
3217
+ const r = scr(t.reference);
3218
+ ctx.beginPath();
3219
+ ctx.arc(r.x, r.y, 5, 0, Math.PI * 2);
3220
+ ctx.moveTo(r.x - 8, r.y);
3221
+ ctx.lineTo(r.x + 8, r.y);
3222
+ ctx.moveTo(r.x, r.y - 8);
3223
+ ctx.lineTo(r.x, r.y + 8);
3224
+ ctx.lineWidth = 3;
3225
+ ctx.strokeStyle = "rgba(0,0,0,0.5)";
3226
+ ctx.stroke();
3227
+ ctx.lineWidth = 1;
3228
+ ctx.strokeStyle = "#ffffff";
3229
+ ctx.stroke();
3230
+ } catch {
3231
+ }
3232
+ ctx.restore();
3233
+ }
3153
3234
  if (isActivelyCropping) {
3154
3235
  const activeCrop = options.draftCrop !== void 0 && options.draftCrop !== null ? options.draftCrop : crop ?? { x: 0, y: 0, width: naturalWidth, height: naturalHeight };
3155
3236
  this.drawCropOverlay(ctx, getViewportMetrics(geoState, vpWidth, vpHeight, true, activeCrop));
@@ -3958,7 +4039,7 @@ function removeAnnotationLayers(layers) {
3958
4039
  function polygonCoverage(points, width, height, antiAlias = true) {
3959
4040
  const m = new Float32Array(width * height);
3960
4041
  if (points.length < 3 || width <= 0 || height <= 0) return m;
3961
- const sub = antiAlias ? 4 : 1;
4042
+ const sub2 = antiAlias ? 4 : 1;
3962
4043
  const edges = [];
3963
4044
  for (let i = 0; i < points.length; i++) {
3964
4045
  const a = points[i];
@@ -3973,8 +4054,8 @@ function polygonCoverage(points, width, height, antiAlias = true) {
3973
4054
  const xs = [];
3974
4055
  for (let py = 0; py < height; py++) {
3975
4056
  const row = py * width;
3976
- for (let s = 0; s < sub; s++) {
3977
- const y = py + (s + 0.5) / sub;
4057
+ for (let s = 0; s < sub2; s++) {
4058
+ const y = py + (s + 0.5) / sub2;
3978
4059
  while (next < edges.length && edges[next].top <= y) active.push(edges[next++]);
3979
4060
  if (active.length) active = active.filter((e) => e.bottom > y);
3980
4061
  if (!active.length) continue;
@@ -3996,7 +4077,7 @@ function polygonCoverage(points, width, height, antiAlias = true) {
3996
4077
  const c = cx + 0.5;
3997
4078
  cov = c >= x0 && c < x1 ? 1 : 0;
3998
4079
  }
3999
- if (cov > 0) m[row + cx] += cov / sub;
4080
+ if (cov > 0) m[row + cx] += cov / sub2;
4000
4081
  }
4001
4082
  }
4002
4083
  }
@@ -4584,6 +4665,7 @@ function serializeMask(mask, hint, ctx) {
4584
4665
  if (mask.density !== void 0 && mask.density !== 1) out.density = mask.density;
4585
4666
  if (mask.feather) out.feather = mask.feather;
4586
4667
  if (mask.enabled === false) out.enabled = false;
4668
+ if (mask.linked === false) out.linked = false;
4587
4669
  return out;
4588
4670
  }
4589
4671
  function serializeLayer(layer, ctx) {
@@ -4720,6 +4802,7 @@ function readMask(raw, ctx, what) {
4720
4802
  if (typeof r.density === "number" && r.density !== 1) mask.density = num(r.density, 0, 1, 1);
4721
4803
  if (typeof r.feather === "number" && r.feather > 0) mask.feather = num(r.feather, 0, 1e3, 0);
4722
4804
  if (r.enabled === false) mask.enabled = false;
4805
+ if (r.linked === false) mask.linked = false;
4723
4806
  if (r.source && typeof r.source === "object") {
4724
4807
  const s = { ...DEFAULT_DEPTH_MASK_VALUES, ...r.source };
4725
4808
  mask.source = { ...s, depthRange: [...s.depthRange] };
@@ -4989,7 +5072,12 @@ function offsetLayer(layers, id, offset) {
4989
5072
  requireUnlocked(loc.layer, "position", "move it");
4990
5073
  if (!Number.isFinite(offset.x) || !Number.isFinite(offset.y)) fail("x and y must be numbers.");
4991
5074
  const layer = loc.layer;
4992
- return mapLayer(layers, id, () => ({ ...layer, offset: { x: Math.round(offset.x), y: Math.round(offset.y) } }));
5075
+ const next = { ...layer, offset: { x: Math.round(offset.x), y: Math.round(offset.y) } };
5076
+ const mask = layer.mask;
5077
+ if (mask && mask.linked === false && !mask.source) {
5078
+ next.mask = { ...mask, surface: translateSurface(mask.surface, layer.offset.x - next.offset.x, layer.offset.y - next.offset.y) };
5079
+ }
5080
+ return mapLayer(layers, id, () => next);
4993
5081
  }
4994
5082
  function setLayerMask(layers, id, mask) {
4995
5083
  const loc = requireLayer(layers, id);
@@ -6175,6 +6263,7 @@ var CHANNEL_USER_MASK = -2;
6175
6263
  var CHANNEL_REAL_USER_MASK = -3;
6176
6264
  var LAYER_FLAG_TRANSPARENCY_PROTECTED = 1;
6177
6265
  var LAYER_FLAG_HIDDEN = 2;
6266
+ var MASK_FLAG_UNLINKED = 1;
6178
6267
  var MASK_FLAG_DISABLED = 2;
6179
6268
  var MASK_FLAG_PARAMETERS = 16;
6180
6269
  var SECTION_OTHER = 0;
@@ -7604,6 +7693,7 @@ function recordMask(ctx, rec, name) {
7604
7693
  if (m.userDensity !== void 0 && m.userDensity < 255) mask.density = percent(m.userDensity);
7605
7694
  if (m.userFeather !== void 0 && m.userFeather > 0) mask.feather = Math.min(1e3, m.userFeather);
7606
7695
  if (flags & MASK_FLAG_DISABLED) mask.enabled = false;
7696
+ if (flags & MASK_FLAG_UNLINKED) mask.linked = false;
7607
7697
  return mask;
7608
7698
  }
7609
7699
  function baseProps(ctx, rec, name, notes) {
@@ -7890,7 +7980,8 @@ function maskChannel(mask, ox, oy, psb) {
7890
7980
  if (mask.source) throw new PsdError("A parametric mask must be baked before export.");
7891
7981
  const bounds = contentBounds(mask.surface);
7892
7982
  const def = mask.defaultValue;
7893
- const m = { rect: EMPTY_RECT, defaultColor: def, flags: mask.enabled === false ? MASK_FLAG_DISABLED : 0 };
7983
+ const flags = (mask.enabled === false ? MASK_FLAG_DISABLED : 0) | (mask.linked === false ? MASK_FLAG_UNLINKED : 0);
7984
+ const m = { rect: EMPTY_RECT, defaultColor: def, flags };
7894
7985
  if ((mask.density ?? 1) < 1) m.userDensity = Math.round(Math.max(0, mask.density ?? 1) * 255);
7895
7986
  if ((mask.feather ?? 0) > 0) m.userFeather = mask.feather;
7896
7987
  if (!bounds) return { channel: emptyChannel(CHANNEL_USER_MASK), mask: m };
@@ -8087,6 +8178,499 @@ function exportPsd(input) {
8087
8178
  return { bytes, psb, records: records.length, warnings };
8088
8179
  }
8089
8180
 
8181
+ // src/image-editor/transform.ts
8182
+ var FREE_TRANSFORM_MODES = ["scale", "rotate", "skew", "distort", "perspective", "warp"];
8183
+ var TRANSFORM_ANCHORS = ["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"];
8184
+ var ANCHOR_FRACTION = {
8185
+ "top-left": [0, 0],
8186
+ top: [0.5, 0],
8187
+ "top-right": [1, 0],
8188
+ left: [0, 0.5],
8189
+ center: [0.5, 0.5],
8190
+ right: [1, 0.5],
8191
+ "bottom-left": [0, 1],
8192
+ bottom: [0.5, 1],
8193
+ "bottom-right": [1, 1]
8194
+ };
8195
+ function isTransformAnchor(value) {
8196
+ return typeof value === "string" && value in ANCHOR_FRACTION;
8197
+ }
8198
+ function identityTransform(box) {
8199
+ return { box: { ...box }, corners: boxQuad(box) };
8200
+ }
8201
+ function cornersFromMatrix(box, m) {
8202
+ const q2 = boxQuad(box);
8203
+ return [applyMatrix(m, q2[0]), applyMatrix(m, q2[1]), applyMatrix(m, q2[2]), applyMatrix(m, q2[3])];
8204
+ }
8205
+ function matrixFromCorners(box, corners, eps = 1e-6) {
8206
+ const [tl, tr, br, bl] = corners;
8207
+ const size = 1 + Math.max(box.width, box.height);
8208
+ if (Math.abs(tl.x + br.x - tr.x - bl.x) > eps * size || Math.abs(tl.y + br.y - tr.y - bl.y) > eps * size) return null;
8209
+ const h = rectToQuad(box, corners);
8210
+ return h ? affineFromHomography(h, 1e-9) : null;
8211
+ }
8212
+ function transformHomography(geometry) {
8213
+ const h = rectToQuad(geometry.box, geometry.corners);
8214
+ if (!h) return null;
8215
+ return homographyKeepsQuad(h, boxQuad(geometry.box)) ? h : null;
8216
+ }
8217
+ function hasWarp(geometry) {
8218
+ const w = geometry.warp;
8219
+ if (!w) return false;
8220
+ const id = identityWarpMesh(geometry.box, w.us.length - 1, w.vs.length - 1);
8221
+ if (id.points.length !== w.points.length || id.us.some((u, i) => Math.abs(u - w.us[i]) > 1e-12) || id.vs.some((v, i) => Math.abs(v - w.vs[i]) > 1e-12)) return true;
8222
+ return w.points.some((p, i) => Math.abs(p.x - id.points[i].x) > 1e-6 || Math.abs(p.y - id.points[i].y) > 1e-6);
8223
+ }
8224
+ function isIdentityTransform(geometry) {
8225
+ if (hasWarp(geometry)) return false;
8226
+ const q2 = boxQuad(geometry.box);
8227
+ return geometry.corners.every((c, i) => Math.abs(c.x - q2[i].x) < 1e-6 && Math.abs(c.y - q2[i].y) < 1e-6);
8228
+ }
8229
+ function transformMap(geometry) {
8230
+ const h = transformHomography(geometry);
8231
+ if (!h) return null;
8232
+ const { box } = geometry;
8233
+ const warp = hasWarp(geometry) ? geometry.warp : null;
8234
+ if (!warp) return (p) => applyHomography(h, p);
8235
+ return (p) => applyHomography(h, evalWarpMesh(warp, (p.x - box.x) / box.width, (p.y - box.y) / box.height));
8236
+ }
8237
+ function transformedPoint(geometry, fx, fy) {
8238
+ const f = transformMap(geometry);
8239
+ const { box } = geometry;
8240
+ return f ? f({ x: box.x + fx * box.width, y: box.y + fy * box.height }) : null;
8241
+ }
8242
+ function anchorPoint(geometry, anchor) {
8243
+ const [fx, fy] = ANCHOR_FRACTION[anchor];
8244
+ const h = rectToQuad(geometry.box, geometry.corners);
8245
+ const p = { x: geometry.box.x + fx * geometry.box.width, y: geometry.box.y + fy * geometry.box.height };
8246
+ return (h && applyHomography(h, p)) ?? p;
8247
+ }
8248
+ function transformedBox(geometry) {
8249
+ const f = transformMap(geometry);
8250
+ if (!f) return null;
8251
+ const { box } = geometry;
8252
+ const pts = [];
8253
+ const n = hasWarp(geometry) ? 16 : 1;
8254
+ for (let i = 0; i <= n; i++) {
8255
+ const t = i / n;
8256
+ for (const [fx, fy] of [[t, 0], [1, t], [1 - t, 1], [0, 1 - t]]) {
8257
+ const p = f({ x: box.x + fx * box.width, y: box.y + fy * box.height });
8258
+ if (!p) return null;
8259
+ pts.push(p);
8260
+ }
8261
+ }
8262
+ const xs = pts.map((p) => p.x);
8263
+ const ys = pts.map((p) => p.y);
8264
+ const x = Math.min(...xs);
8265
+ const y = Math.min(...ys);
8266
+ return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
8267
+ }
8268
+ function referencePoint(box, reference) {
8269
+ if (reference && typeof reference === "object") return { x: reference.x, y: reference.y };
8270
+ const [fx, fy] = ANCHOR_FRACTION[reference ?? "center"];
8271
+ return { x: box.x + fx * box.width, y: box.y + fy * box.height };
8272
+ }
8273
+ function matrixFromParams(box, params) {
8274
+ const sx = (params.scaleX ?? 100) / 100;
8275
+ const sy = (params.scaleY ?? 100) / 100;
8276
+ const tx = Math.tan((params.skewX ?? 0) * Math.PI / 180);
8277
+ const ty = Math.tan((params.skewY ?? 0) * Math.PI / 180);
8278
+ const ref = referencePoint(box, params.reference);
8279
+ const linear = multiplyMatrices(multiplyMatrices([1, ty, tx, 1, 0, 0], [sx, 0, 0, sy, 0, 0]), rotateMatrix(params.rotate ?? 0));
8280
+ return multiplyMatrices(matrixAbout(linear, ref), translateMatrix(params.dx ?? 0, params.dy ?? 0));
8281
+ }
8282
+ function describeTransform(geometry, reference = "center") {
8283
+ const m = hasWarp(geometry) ? null : matrixFromCorners(geometry.box, geometry.corners);
8284
+ const ref = referencePoint(geometry.box, reference);
8285
+ const bounds = transformedBox(geometry);
8286
+ if (!m) {
8287
+ const h = rectToQuad(geometry.box, geometry.corners);
8288
+ const at2 = (h && applyHomography(h, ref)) ?? ref;
8289
+ return { affine: false, scaleX: 100, scaleY: 100, rotate: 0, skewX: 0, x: at2.x, y: at2.y, bounds, corners: geometry.corners };
8290
+ }
8291
+ const [a, b, c, d] = m;
8292
+ const sxAbs = Math.hypot(a, b);
8293
+ const rot = Math.atan2(b, a) * 180 / Math.PI;
8294
+ const shear = sxAbs ? (a * c + b * d) / sxAbs : 0;
8295
+ const sy = sxAbs ? (a * d - b * c) / sxAbs : 0;
8296
+ const skew = sxAbs ? Math.atan(shear / sxAbs) * 180 / Math.PI : 0;
8297
+ const at = applyMatrix(m, ref);
8298
+ const clean = (v) => Math.abs(v) < 1e-9 ? 0 : v;
8299
+ return {
8300
+ affine: true,
8301
+ scaleX: clean(sxAbs * 100),
8302
+ scaleY: clean(sy * 100),
8303
+ rotate: clean(rot === -180 ? 180 : rot),
8304
+ skewX: clean(skew),
8305
+ x: at.x,
8306
+ y: at.y,
8307
+ bounds,
8308
+ corners: geometry.corners
8309
+ };
8310
+ }
8311
+ function applyParams(geometry, params) {
8312
+ const ref = params.reference && typeof params.reference === "object" ? params.reference : anchorPoint(geometry, params.reference ?? "center");
8313
+ const m = matrixFromParams({ x: ref.x, y: ref.y, width: 0, height: 0 }, { ...params, reference: ref });
8314
+ const c = geometry.corners;
8315
+ return [applyMatrix(m, c[0]), applyMatrix(m, c[1]), applyMatrix(m, c[2]), applyMatrix(m, c[3])];
8316
+ }
8317
+ function flipCorners(geometry, axis) {
8318
+ const c = anchorPoint(geometry, "center");
8319
+ const m = axis === "horizontal" ? [-1, 0, 0, 1, 2 * c.x, 0] : [1, 0, 0, -1, 0, 2 * c.y];
8320
+ const q2 = geometry.corners;
8321
+ return [applyMatrix(m, q2[0]), applyMatrix(m, q2[1]), applyMatrix(m, q2[2]), applyMatrix(m, q2[3])];
8322
+ }
8323
+ function boxPointOf(geometry, p) {
8324
+ const m = matrixFromCorners(geometry.box, geometry.corners);
8325
+ if (m) return applyMatrix(invertMatrix(m), p);
8326
+ return null;
8327
+ }
8328
+ function presetWarp(box, preset) {
8329
+ return warpPresetMesh(preset, box);
8330
+ }
8331
+ function isValidWarp(mesh) {
8332
+ return isValidWarpMesh(mesh);
8333
+ }
8334
+ function geometryHomography(geometry) {
8335
+ const m = matrixFromCorners(geometry.box, geometry.corners);
8336
+ return m ? homographyFromAffine(m) : transformHomography(geometry);
8337
+ }
8338
+
8339
+ // src/image-editor/free-transform.ts
8340
+ var FREE_TRANSFORM_CONTENTS = ["auto", "layer", "selection", "selection-copy", "selection-outline"];
8341
+ var fail2 = (message) => {
8342
+ throw new LayerOperationError(message);
8343
+ };
8344
+ var T = (x, y) => [1, 0, x, 0, 1, y, 0, 0, 1];
8345
+ function previewStep(scale) {
8346
+ if (!scale || !(scale > 0) || scale >= 1) return 1;
8347
+ return Math.min(8, 2 ** Math.floor(Math.log2(1 / scale)));
8348
+ }
8349
+ function toDoc(r, o) {
8350
+ return r ? { x: r.x + o.x, y: r.y + o.y, width: r.width, height: r.height } : null;
8351
+ }
8352
+ function selectionSurface(sel) {
8353
+ return surfaceFromPixels({ width: sel.width, height: sel.height, data: sel.data }, 1, 0, 0, 0);
8354
+ }
8355
+ function liftPixels(surface, sel, o) {
8356
+ const sb = sel.bounds;
8357
+ const area = { x: sb.x - o.x, y: sb.y - o.y, width: sb.width, height: sb.height };
8358
+ const px2 = readRegion(surface, area);
8359
+ const kept = new Uint8ClampedArray(px2);
8360
+ for (let y = 0; y < area.height; y++) {
8361
+ for (let x = 0; x < area.width; x++) {
8362
+ const k = sel.data[(sb.y + y) * sel.width + sb.x + x] / 255;
8363
+ const i = (y * area.width + x) * 4;
8364
+ if (k <= 0) {
8365
+ px2[i] = px2[i + 1] = px2[i + 2] = px2[i + 3] = 0;
8366
+ continue;
8367
+ }
8368
+ px2[i + 3] = px2[i + 3] * k;
8369
+ if (!px2[i + 3]) px2[i] = px2[i + 1] = px2[i + 2] = 0;
8370
+ kept[i + 3] = kept[i + 3] * (1 - k);
8371
+ if (!kept[i + 3]) kept[i] = kept[i + 1] = kept[i + 2] = 0;
8372
+ }
8373
+ }
8374
+ return { lifted: surfaceFromPixels({ width: area.width, height: area.height, data: px2 }, 4, area.x, area.y), rest: writeRegion(surface, area, kept) };
8375
+ }
8376
+ function liftMask(mask, sel, o) {
8377
+ const sb = sel.bounds;
8378
+ const area = { x: sb.x - o.x, y: sb.y - o.y, width: sb.width, height: sb.height };
8379
+ const v = readRegion(mask, area);
8380
+ const def = mask.defaultValue;
8381
+ const lifted = new Uint8ClampedArray(area.width * area.height * 4);
8382
+ const rest = new Uint8ClampedArray(v.length);
8383
+ for (let y = 0; y < area.height; y++) {
8384
+ for (let x = 0; x < area.width; x++) {
8385
+ const i = y * area.width + x;
8386
+ const k = sel.data[(sb.y + y) * sel.width + sb.x + x] / 255;
8387
+ const g = v[i];
8388
+ if (k > 0) {
8389
+ lifted[i * 4] = lifted[i * 4 + 1] = lifted[i * 4 + 2] = g;
8390
+ lifted[i * 4 + 3] = k * 255;
8391
+ }
8392
+ rest[i] = g * (1 - k) + def * k;
8393
+ }
8394
+ }
8395
+ return { lifted: surfaceFromPixels({ width: area.width, height: area.height, data: lifted }, 4, area.x, area.y), rest: writeRegion(mask, area, rest) };
8396
+ }
8397
+ var FreeTransformSession = class {
8398
+ constructor(host, init = {}) {
8399
+ this.host = host;
8400
+ if (init.interpolation !== void 0 && !isInterpolation(init.interpolation)) fail2(`Unknown interpolation "${String(init.interpolation)}"; use nearest, bilinear or bicubic.`);
8401
+ this.interp = init.interpolation ?? "bicubic";
8402
+ const sel = host.selection();
8403
+ const selected = Boolean(sel?.bounds);
8404
+ let content = init.content ?? "auto";
8405
+ if (!["auto", "layer", "selection", "selection-copy", "selection-outline"].includes(content)) {
8406
+ fail2(`Unknown content "${String(content)}"; use auto, layer, selection, selection-copy or selection-outline.`);
8407
+ }
8408
+ if (content === "auto") content = selected ? "selection" : "layer";
8409
+ this.content = content;
8410
+ if (content !== "layer" && !selected) fail2(`Nothing is selected; ${content} needs a selection. Select first, or use content layer.`);
8411
+ const layers = host.layers();
8412
+ this.startLayers = layers;
8413
+ this.startSelection = sel;
8414
+ this.startActive = host.activeLayerId();
8415
+ this.before = host.capture();
8416
+ const moves = [];
8417
+ let box = null;
8418
+ if (content === "selection-outline") {
8419
+ this.layerId = null;
8420
+ this.parts.push({ what: "selection", offset: { x: 0, y: 0 }, moving: selectionSurface(sel), base: null, reduced: /* @__PURE__ */ new Map() });
8421
+ moves.push("selection");
8422
+ box = { ...sel.bounds };
8423
+ } else {
8424
+ const id = init.layerId ?? host.activeLayerId();
8425
+ if (!id) fail2("No layer given and none is active; pass layerId (see list_layers).");
8426
+ const layer = requireLayer(layers, id).layer;
8427
+ this.layerId = layer.id;
8428
+ const mask = layer.mask && !layer.mask.source ? layer.mask : void 0;
8429
+ const channel = init.channel ?? (layer.id === host.activeLayerId() ? host.activeTarget() : "pixels");
8430
+ if (channel !== "pixels" && channel !== "mask") fail2(`Unknown channel "${String(channel)}"; use pixels or mask.`);
8431
+ const linked = mask?.linked !== false;
8432
+ const maskAlone = channel === "mask" && (!linked || layer.type !== "pixel");
8433
+ if (channel === "mask" && !mask) fail2(layer.mask?.source ? `Layer "${layer.name || id}" has a depth mask, which is generated, not transformed.` : `Layer "${layer.name || id}" has no mask to transform; add one with add_layer_mask, or transform its pixels.`);
8434
+ if (!maskAlone && layer.type !== "pixel") fail2(`Layer "${layer.name || id}" is a ${layer.type} layer; Free Transform works on pixel layers, layer masks (channel mask) and selections.`);
8435
+ if (!layer.visible && content !== "selection-copy") fail2(`Layer "${layer.name || id}" is hidden; show it before you transform it.`);
8436
+ if (content !== "selection-copy") {
8437
+ requireUnlocked(layer, "position", "transform it");
8438
+ if (!maskAlone) requireUnlocked(layer, "pixels", "transform it");
8439
+ }
8440
+ const o = layer.type === "pixel" ? layer.offset : { x: 0, y: 0 };
8441
+ const pixels = layer.type === "pixel" ? host.pixelsOf(layer) : null;
8442
+ if (maskAlone) {
8443
+ if (content === "selection-copy") fail2("Layer via Copy copies pixels; transform a mask with content layer or selection.");
8444
+ if (content === "selection") {
8445
+ const { lifted, rest } = liftMask(mask.surface, sel, o);
8446
+ this.parts.push({ what: "mask", offset: o, moving: lifted, base: rest, reduced: /* @__PURE__ */ new Map() });
8447
+ box = toDoc(contentBounds(lifted), o);
8448
+ } else {
8449
+ this.parts.push({ what: "mask", offset: o, moving: mask.surface, base: null, reduced: /* @__PURE__ */ new Map() });
8450
+ box = toDoc(contentBounds(mask.surface), o);
8451
+ }
8452
+ moves.push("mask");
8453
+ } else if (content === "layer") {
8454
+ this.parts.push({ what: "pixels", offset: o, moving: pixels, base: null, reduced: /* @__PURE__ */ new Map() });
8455
+ moves.push("pixels");
8456
+ box = toDoc(contentBounds(pixels), o);
8457
+ if (mask && linked) {
8458
+ this.parts.push({ what: "mask", offset: o, moving: mask.surface, base: null, reduced: /* @__PURE__ */ new Map() });
8459
+ moves.push("mask");
8460
+ if (!box) box = toDoc(contentBounds(mask.surface), o);
8461
+ }
8462
+ } else if (content === "selection") {
8463
+ const { lifted, rest } = liftPixels(pixels, sel, o);
8464
+ this.parts.push({ what: "pixels", offset: o, moving: lifted, base: rest, reduced: /* @__PURE__ */ new Map() });
8465
+ moves.push("pixels");
8466
+ box = toDoc(contentBounds(lifted), o);
8467
+ if (mask && linked) {
8468
+ const m = liftMask(mask.surface, sel, o);
8469
+ this.parts.push({ what: "mask", offset: o, moving: m.lifted, base: m.rest, reduced: /* @__PURE__ */ new Map() });
8470
+ moves.push("mask");
8471
+ }
8472
+ } else {
8473
+ const { lifted } = liftPixels(pixels, sel, o);
8474
+ box = toDoc(contentBounds(lifted), o);
8475
+ if (box) {
8476
+ const copy = {
8477
+ type: "pixel",
8478
+ id: newLayerId(),
8479
+ name: `${layer.name || "Layer"} copy`,
8480
+ visible: true,
8481
+ opacity: 1,
8482
+ blend: "normal",
8483
+ surface: lifted,
8484
+ offset: { ...o }
8485
+ };
8486
+ this.copyId = copy.id;
8487
+ const loc = findLayer(layers, layer.id);
8488
+ this.startLayers = insertLayer(layers, copy, loc.parent?.id ?? null, loc.index + 1);
8489
+ this.parts.push({ what: "pixels", offset: o, moving: lifted, base: null, reduced: /* @__PURE__ */ new Map() });
8490
+ moves.push("pixels");
8491
+ }
8492
+ }
8493
+ if (content !== "layer") {
8494
+ this.parts.push({ what: "selection", offset: { x: 0, y: 0 }, moving: selectionSurface(sel), base: null, reduced: /* @__PURE__ */ new Map() });
8495
+ moves.push("selection");
8496
+ }
8497
+ }
8498
+ if (!box && moves[0] === "mask") fail2("The mask holds nothing but its default value, so there is nothing to transform; paint it first, or transform the pixels (channel pixels).");
8499
+ if (!box) fail2(content === "layer" ? "The layer has no pixels to transform." : "The selection holds no pixels of this layer; select some, or transform the outline (content selection-outline).");
8500
+ this.box = box;
8501
+ this.moves = moves;
8502
+ this.geometryValue = identityTransform(box);
8503
+ this.shown = this.startLayers;
8504
+ if (this.copyId) host.show(this.startLayers, sel, this.copyId);
8505
+ this.shown = host.layers();
8506
+ }
8507
+ host;
8508
+ content;
8509
+ /** The layer being transformed (`null` for `selection-outline`). */
8510
+ layerId;
8511
+ /** The new layer of a `selection-copy`. */
8512
+ copyId = null;
8513
+ /** The frame (document pixels). */
8514
+ box;
8515
+ /** What moves. */
8516
+ moves;
8517
+ geometryValue;
8518
+ interp;
8519
+ parts = [];
8520
+ before;
8521
+ startLayers;
8522
+ startSelection;
8523
+ startActive;
8524
+ /** The stack this session last showed: anything else means the document changed under it. */
8525
+ shown;
8526
+ closed = false;
8527
+ /** The current geometry. */
8528
+ get geometry() {
8529
+ return this.geometryValue;
8530
+ }
8531
+ get interpolation() {
8532
+ return this.interp;
8533
+ }
8534
+ get isOpen() {
8535
+ return !this.closed;
8536
+ }
8537
+ check() {
8538
+ if (this.closed) fail2("This transform has already been committed or cancelled; begin a new one.");
8539
+ if (this.host.layers() !== this.shown) {
8540
+ this.closed = true;
8541
+ fail2("The document changed while the transform was open, so it was abandoned; begin a new one.");
8542
+ }
8543
+ }
8544
+ /**
8545
+ * Changes the geometry (`corners`, `warp`) or the interpolation and, unless `preview` is `false`,
8546
+ * shows the result live (no history). Returns the geometry.
8547
+ */
8548
+ update(changes, preview = {}) {
8549
+ this.check();
8550
+ if (changes.interpolation !== void 0) {
8551
+ if (!isInterpolation(changes.interpolation)) fail2(`Unknown interpolation "${String(changes.interpolation)}"; use nearest, bilinear or bicubic.`);
8552
+ this.interp = changes.interpolation;
8553
+ }
8554
+ const next = {
8555
+ box: this.box,
8556
+ corners: (changes.corners ?? this.geometryValue.corners).map((p) => ({ x: p.x, y: p.y })),
8557
+ warp: changes.warp === void 0 ? this.geometryValue.warp ?? null : changes.warp
8558
+ };
8559
+ if (next.corners.length !== 4 || !next.corners.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))) fail2("corners must be four finite points: top-left, top-right, bottom-right, bottom-left.");
8560
+ if (!transformMap(next)) fail2("These corners fold the frame over itself or through the horizon; keep the quad convex.");
8561
+ this.geometryValue = next;
8562
+ if (preview !== false) this.render(preview);
8563
+ return next;
8564
+ }
8565
+ /** Resamples every part for the current geometry and shows it (or returns it, for commit). */
8566
+ resample(final, preview = {}) {
8567
+ const g = this.geometryValue;
8568
+ const identity = isIdentityTransform(g);
8569
+ const warp = hasWarp(g);
8570
+ const h = warp ? null : geometryHomography(g);
8571
+ const f = warp ? transformMap(g) : null;
8572
+ const step = final ? 1 : previewStep(preview.scale);
8573
+ const interpolation = final ? this.interp : this.interp === "nearest" ? "nearest" : "bilinear";
8574
+ let layers = this.startLayers;
8575
+ let selection = this.startSelection;
8576
+ const targetId = this.copyId ?? this.layerId;
8577
+ for (const part of this.parts) {
8578
+ const o = part.offset;
8579
+ let moved;
8580
+ if (identity) moved = part.moving;
8581
+ else {
8582
+ let src = part.moving;
8583
+ let k = 1;
8584
+ if (step > 1) {
8585
+ k = step;
8586
+ let r = part.reduced.get(k);
8587
+ if (!r) {
8588
+ r = reduceSurface(part.moving, k);
8589
+ part.reduced.set(k, r);
8590
+ }
8591
+ src = r;
8592
+ }
8593
+ const opts = { interpolation, step: step > 1 ? step : void 0 };
8594
+ if (preview.clip && !final) opts.clip = { x: Math.floor(preview.clip.x - o.x), y: Math.floor(preview.clip.y - o.y), width: Math.ceil(preview.clip.width) + 1, height: Math.ceil(preview.clip.height) + 1 };
8595
+ try {
8596
+ if (f) {
8597
+ moved = warpSurface(src, (p) => {
8598
+ const q2 = f({ x: p.x * k + o.x, y: p.y * k + o.y });
8599
+ return q2 && { x: q2.x - o.x, y: q2.y - o.y };
8600
+ }, opts);
8601
+ } else {
8602
+ const hl = multiplyHomographies(multiplyHomographies(multiplyHomographies([k, 0, 0, 0, k, 0, 0, 0, 1], T(o.x, o.y)), h), T(-o.x, -o.y));
8603
+ moved = transformSurface(src, hl, opts);
8604
+ }
8605
+ } catch (e) {
8606
+ if (e instanceof TransformTooLargeError) fail2(e.message);
8607
+ throw e;
8608
+ }
8609
+ }
8610
+ const result = part.base ? compositeSurfaceOver(part.base, moved) : moved;
8611
+ if (part.what === "selection") {
8612
+ const { width, height } = this.host.canvas();
8613
+ const data = new Uint8Array(readRegion(result, { x: 0, y: 0, width, height }).buffer);
8614
+ const next = createSelectionMask(width, height, data);
8615
+ selection = next.bounds ? next : null;
8616
+ } else if (part.what === "pixels") {
8617
+ layers = mapLayer(layers, targetId, (l) => {
8618
+ const next = { ...l, surface: result };
8619
+ delete next.source;
8620
+ return next;
8621
+ });
8622
+ } else {
8623
+ layers = mapLayer(layers, targetId, (l) => ({ ...l, mask: { ...l.mask, surface: result } }));
8624
+ }
8625
+ }
8626
+ return { layers, selection };
8627
+ }
8628
+ render(preview) {
8629
+ const { layers, selection } = this.resample(false, preview);
8630
+ this.host.show(layers, selection, this.copyId ?? this.startActive);
8631
+ this.shown = this.host.layers();
8632
+ }
8633
+ /** Finishes the transform with full-quality resampling, as one undo step. */
8634
+ commit() {
8635
+ this.check();
8636
+ const g = this.geometryValue;
8637
+ const changed = !isIdentityTransform(g) || Boolean(this.copyId);
8638
+ if (!changed) {
8639
+ this.closed = true;
8640
+ this.host.restore(this.before);
8641
+ return this.result(false, this.startLayers, this.startSelection);
8642
+ }
8643
+ const { layers, selection } = this.resample(true);
8644
+ this.closed = true;
8645
+ this.host.show(layers, selection, this.copyId ?? this.startActive);
8646
+ this.host.record(this.before);
8647
+ return this.result(true, layers, selection);
8648
+ }
8649
+ /** Abandons the transform and puts everything back (no history). */
8650
+ cancel() {
8651
+ if (this.closed) return;
8652
+ this.closed = true;
8653
+ this.host.restore(this.before);
8654
+ }
8655
+ result(changed, layers, selection) {
8656
+ let bounds = null;
8657
+ const id = this.copyId ?? this.layerId;
8658
+ const layer = id ? findLayer(layers, id)?.layer : void 0;
8659
+ if (this.moves.includes("pixels") && layer?.type === "pixel") bounds = toDoc(contentBounds(this.host.pixelsOf(layer)), layer.offset);
8660
+ else if (this.moves.includes("mask") && layer?.mask) bounds = toDoc(contentBounds(layer.mask.surface), layer.type === "pixel" ? layer.offset : { x: 0, y: 0 });
8661
+ else if (this.moves.includes("selection")) bounds = selection?.bounds ? { ...selection.bounds } : null;
8662
+ return {
8663
+ layerId: this.copyId ?? this.layerId,
8664
+ content: this.content,
8665
+ moved: [...this.moves],
8666
+ box: { ...this.box },
8667
+ corners: this.geometryValue.corners,
8668
+ bounds,
8669
+ changed
8670
+ };
8671
+ }
8672
+ };
8673
+
8090
8674
  // src/image-editor/controller.ts
8091
8675
  var IMAGE_EDITOR_SCHEMA_VERSION = LAYERED_SCHEMA_VERSION;
8092
8676
  function paintCoverageInto(base, coverage, apply, rects) {
@@ -9038,7 +9622,7 @@ var ImageEditorController = class {
9038
9622
  if (this.state.activeLayerId === id) this.state.activeTarget = "pixels";
9039
9623
  });
9040
9624
  }
9041
- /** Changes any layer mask's density, feather or enabled flag (pixels kept). Returns the mask. */
9625
+ /** Changes any layer mask's density, feather, enabled or linked flag (pixels kept). Returns the mask. */
9042
9626
  setLayerMaskProps(id, props, commitToHistory = true) {
9043
9627
  const layer = requireLayer(this.state.layers, id).layer;
9044
9628
  if (!layer.mask) throw new LayerOperationError(`Layer "${layer.name || id}" has no mask; add one with add_layer_mask.`);
@@ -9130,6 +9714,134 @@ var ImageEditorController = class {
9130
9714
  });
9131
9715
  return copy.id;
9132
9716
  }
9717
+ // --- Free Transform ---
9718
+ openTransform = null;
9719
+ /** The Free Transform in progress (see `beginFreeTransform`), or `null`. */
9720
+ getFreeTransform() {
9721
+ if (this.openTransform && !this.openTransform.isOpen) this.openTransform = null;
9722
+ return this.openTransform;
9723
+ }
9724
+ transformHost() {
9725
+ return {
9726
+ layers: () => this.state.layers,
9727
+ activeLayerId: () => this.state.activeLayerId,
9728
+ activeTarget: () => this.state.activeTarget,
9729
+ selection: () => this.state.selection?.bounds ? this.state.selection : null,
9730
+ canvas: () => this.naturalSize,
9731
+ pixelsOf: (layer) => this.surfaceOf(layer),
9732
+ capture: () => this.captureHistoryEntry(),
9733
+ show: (layers, selection, active) => {
9734
+ this.state.layers = layers;
9735
+ this.state.selection = selection;
9736
+ if (active !== this.state.activeLayerId) {
9737
+ this.state.activeLayerId = active;
9738
+ this.state.activeTarget = "pixels";
9739
+ }
9740
+ this.notify();
9741
+ },
9742
+ record: (before) => {
9743
+ if (this.isApplyingHistory) return;
9744
+ this.undoStack.push(before);
9745
+ if (this.undoStack.length > this.maxHistoryDepth) this.undoStack.shift();
9746
+ this.redoStack = [];
9747
+ this.trimHistoryBytes();
9748
+ this.notify();
9749
+ },
9750
+ restore: (before) => {
9751
+ const e = before;
9752
+ this.state.layers = e.layers;
9753
+ this.state.selection = e.selection;
9754
+ this.state.activeLayerId = e.activeLayerId;
9755
+ this.state.activeTarget = e.activeTarget;
9756
+ this.notify();
9757
+ }
9758
+ };
9759
+ }
9760
+ /**
9761
+ * Starts a Free Transform (Edit › Free Transform, Transform Selection, or a transform of a Layer
9762
+ * via Copy): of the layer's pixels with its linked mask, an unlinked mask alone, the selected
9763
+ * pixels or just the selection outline (see `FreeTransformInit`). Change the geometry with
9764
+ * `session.update` (live preview, no history), then `commit()` (one undo step) or `cancel()`.
9765
+ * Refused while another transform is open.
9766
+ */
9767
+ beginFreeTransform(init = {}) {
9768
+ if (this.getFreeTransform()) throw new LayerOperationError("A Free Transform is already open; commit or cancel it first.");
9769
+ const session = new FreeTransformSession(this.transformHost(), init);
9770
+ this.openTransform = session;
9771
+ this.notify();
9772
+ return session;
9773
+ }
9774
+ /**
9775
+ * Transforms in one step (agents, scripts): begins a Free Transform for `spec`, sets its corners
9776
+ * from exactly one of `matrix` (document-space affine), `corners` (where the frame's corners go:
9777
+ * distort / perspective) or `params` (scale, rotate, skew and move about a reference point), and
9778
+ * commits it as one undo step. Returns what changed and the new bounds.
9779
+ */
9780
+ transformLayer(spec) {
9781
+ const given = [spec.matrix, spec.corners, spec.params].filter((v) => v !== void 0).length;
9782
+ if (given > 1) throw new LayerOperationError("Give only one of matrix, corners and params.");
9783
+ if (!given && spec.warp === void 0) throw new LayerOperationError("Give matrix, corners or params (or a warp).");
9784
+ if (spec.matrix && !(Array.isArray(spec.matrix) && spec.matrix.length === 6 && spec.matrix.every(Number.isFinite))) {
9785
+ throw new LayerOperationError("matrix must be six finite numbers [a, b, c, d, e, f] (x' = a\xB7x + c\xB7y + e, y' = b\xB7x + d\xB7y + f).");
9786
+ }
9787
+ if (spec.matrix) {
9788
+ const [a, b, c, d] = spec.matrix;
9789
+ if (Math.abs(a * d - b * c) < 1e-9) throw new LayerOperationError("matrix is singular (it collapses the layer to a line); its determinant a\xB7d \u2212 b\xB7c must not be 0.");
9790
+ }
9791
+ const session = this.beginFreeTransform(spec);
9792
+ try {
9793
+ const g = identityTransform(session.box);
9794
+ const corners = spec.matrix ? cornersFromMatrix(session.box, spec.matrix) : spec.corners ? spec.corners : spec.params ? applyParams(g, spec.params) : g.corners;
9795
+ let warp = null;
9796
+ if (spec.warp) {
9797
+ if ("style" in spec.warp) {
9798
+ if (!WARP_STYLES.includes(spec.warp.style)) throw new LayerOperationError(`Unknown warp style "${String(spec.warp.style)}". Styles: ${WARP_STYLES.join(", ")}.`);
9799
+ warp = presetWarp(session.box, spec.warp);
9800
+ } else {
9801
+ if (!isValidWarp(spec.warp)) throw new LayerOperationError("mesh must have us and vs (knots from 0 to 1, increasing) and (3\xB7columns + 1) \xD7 (3\xB7rows + 1) finite points, row by row.");
9802
+ warp = spec.warp;
9803
+ }
9804
+ }
9805
+ session.update({ corners, warp }, false);
9806
+ return session.commit();
9807
+ } catch (e) {
9808
+ session.cancel();
9809
+ throw e;
9810
+ }
9811
+ }
9812
+ /** Flips the layer (or its selected pixels, or the outline) about the frame's centre, exactly (one undo step). */
9813
+ flipLayer(axis, init = {}) {
9814
+ if (axis !== "horizontal" && axis !== "vertical") throw new LayerOperationError(`axis must be horizontal or vertical, got "${String(axis)}".`);
9815
+ const session = this.beginFreeTransform({ interpolation: "nearest", ...init });
9816
+ try {
9817
+ session.update({ corners: flipCorners(identityTransform(session.box), axis) }, false);
9818
+ return session.commit();
9819
+ } catch (e) {
9820
+ session.cancel();
9821
+ throw e;
9822
+ }
9823
+ }
9824
+ /**
9825
+ * Rotates by a quarter turn (90 clockwise, −90 counter-clockwise) or 180 about the frame's
9826
+ * centre, snapped so pixels land on whole pixels: exact, no resampling (one undo step).
9827
+ */
9828
+ rotateLayer90(turn, init = {}) {
9829
+ if (turn !== 90 && turn !== -90 && turn !== 180) throw new LayerOperationError(`turn must be 90, -90 or 180, got ${String(turn)}.`);
9830
+ const session = this.beginFreeTransform({ interpolation: "nearest", ...init });
9831
+ try {
9832
+ const b = session.box;
9833
+ const cx = b.x + b.width / 2;
9834
+ const cy = b.y + b.height / 2;
9835
+ const ox = turn === 180 ? 0 : Math.round(cx - b.height / 2) - (cx - b.height / 2);
9836
+ const oy = turn === 180 ? 0 : Math.round(cy - b.width / 2) - (cy - b.width / 2);
9837
+ const m = turn === 90 ? [0, 1, -1, 0, cx + cy + ox, cy - cx + oy] : turn === -90 ? [0, -1, 1, 0, cx - cy + ox, cy + cx + oy] : [-1, 0, 0, -1, 2 * cx, 2 * cy];
9838
+ session.update({ corners: cornersFromMatrix(b, m) }, false);
9839
+ return session.commit();
9840
+ } catch (e) {
9841
+ session.cancel();
9842
+ throw e;
9843
+ }
9844
+ }
9133
9845
  /** Adds a `develop` or `blur` filter to a pixel layer (one undo step). Returns the layer's filters. */
9134
9846
  addLayerFilter(id, filter) {
9135
9847
  const f = normalizeFilter(filter);
@@ -10506,7 +11218,7 @@ var ImageEditorController = class {
10506
11218
  const canvas = { x: 0, y: 0, width, height };
10507
11219
  const warnings = [];
10508
11220
  const note = /* @__PURE__ */ new Map();
10509
- const add = (msg, name) => note.set(msg, [...note.get(msg) ?? [], name]);
11221
+ const add2 = (msg, name) => note.set(msg, [...note.get(msg) ?? [], name]);
10510
11222
  const canRasterize = this.renderer.canRasterize();
10511
11223
  await this.renderer.prepareLayerAssets(this.state.layers, width, height);
10512
11224
  const sources = this.renderer.compositorSources(width, height);
@@ -10514,7 +11226,7 @@ var ImageEditorController = class {
10514
11226
  const bakeMask = (layer) => {
10515
11227
  const mask = layer.mask;
10516
11228
  if (!mask?.source) return mask;
10517
- add("Parametric (depth) masks were baked into pixel masks", layer.name);
11229
+ add2("Parametric (depth) masks were baked into pixel masks", layer.name);
10518
11230
  const plain3 = { ...layer, mask: { ...mask, density: 1, feather: 0, enabled: true } };
10519
11231
  const ox = layer.type === "pixel" ? layer.offset.x : 0;
10520
11232
  const oy = layer.type === "pixel" ? layer.offset.y : 0;
@@ -10530,7 +11242,7 @@ var ImageEditorController = class {
10530
11242
  };
10531
11243
  const prepare = async (layer) => {
10532
11244
  const p = await prepareLayer(layer);
10533
- if (p?.blend === "pigment") add("The Pigment blend mode has no PSD equivalent and was written as Normal", layer.name);
11245
+ if (p?.blend === "pigment") add2("The Pigment blend mode has no PSD equivalent and was written as Normal", layer.name);
10534
11246
  return p;
10535
11247
  };
10536
11248
  const prepareLayer = async (layer) => {
@@ -10545,10 +11257,10 @@ var ImageEditorController = class {
10545
11257
  if (layer.type === "adjustment") return { ...layer, mask: bakeMask(layer) };
10546
11258
  if (layer.type === "annotation") {
10547
11259
  if (!canRasterize) {
10548
- add("Annotation layers need a canvas (a browser) to rasterize and were left out", layer.name);
11260
+ add2("Annotation layers need a canvas (a browser) to rasterize and were left out", layer.name);
10549
11261
  return null;
10550
11262
  }
10551
- add("Annotation layers were written as pixel layers", layer.name);
11263
+ add2("Annotation layers were written as pixel layers", layer.name);
10552
11264
  const blend = layer.blend === "normal" && layer.annotation.blend ? layer.annotation.blend : layer.blend;
10553
11265
  const content = { ...layer, visible: true, opacity: 1, blend: "normal", annotation: { ...layer.annotation, blend: void 0 } };
10554
11266
  delete content.mask;
@@ -10579,7 +11291,7 @@ var ImageEditorController = class {
10579
11291
  }
10580
11292
  const active = layer.filters?.filter(isActiveFilter) ?? [];
10581
11293
  if (active.length) {
10582
- add("Layer filters (develop, blur) were baked into the pixels", layer.name);
11294
+ add2("Layer filters (develop, blur) were baked into the pixels", layer.name);
10583
11295
  const content = { ...out, visible: true, opacity: 1, blend: "normal" };
10584
11296
  delete content.mask;
10585
11297
  delete content.clipped;
@@ -10588,7 +11300,7 @@ var ImageEditorController = class {
10588
11300
  const data = compositor.composite([content], canvas, sources);
10589
11301
  out = { ...out, surface: surfaceFromPixels({ width, height, data }, 4, -layer.offset.x, -layer.offset.y) };
10590
11302
  }
10591
- if (layer.filters?.some((f) => !f.enabled)) add("Disabled layer filters were left out", layer.name);
11303
+ if (layer.filters?.some((f) => !f.enabled)) add2("Disabled layer filters were left out", layer.name);
10592
11304
  delete out.filters;
10593
11305
  const mask = bakeMask(layer);
10594
11306
  if (mask) out.mask = mask;
@@ -10868,6 +11580,10 @@ function withMaskProps(mask, props) {
10868
11580
  if (props.enabled) delete next.enabled;
10869
11581
  else next.enabled = false;
10870
11582
  }
11583
+ if (props.linked !== void 0) {
11584
+ if (props.linked) delete next.linked;
11585
+ else next.linked = false;
11586
+ }
10871
11587
  return next;
10872
11588
  }
10873
11589
  function resizeLegacyMasks(layers, from, to) {
@@ -11985,6 +12701,7 @@ function maskSummary(mask) {
11985
12701
  const out = {
11986
12702
  kind: mask.source ? "depth" : "raster",
11987
12703
  enabled: mask.enabled !== false,
12704
+ linked: mask.linked !== false,
11988
12705
  density: mask.density ?? 1,
11989
12706
  feather: mask.feather ?? 0
11990
12707
  };
@@ -12361,23 +13078,29 @@ function createImageLayerTools(controller) {
12361
13078
  const maskProps = {
12362
13079
  name: "set_layer_mask_properties",
12363
13080
  title: "Set layer mask properties",
12364
- description: "Changes a layer mask's density (0..1), feather (px) or enabled flag; its pixels are kept. One undo step. Returns the mask summary.",
13081
+ description: "Changes a layer mask's density (0..1), feather (px), enabled flag or linked flag; its pixels are kept. An unlinked mask (linked false) stays in place when the layer is moved, transformed or warped; transform it alone with transform_layer target mask. One undo step. Returns the mask summary.",
12365
13082
  inputSchema: {
12366
13083
  type: "object",
12367
13084
  properties: {
12368
13085
  layerId: LAYER_ID2,
12369
13086
  density: { type: "number", minimum: 0, maximum: 1, description: "Density 0..1." },
12370
13087
  feather: { type: "number", minimum: 0, maximum: 1e3, description: "Feather \u03C3 in px." },
12371
- enabled: { type: "boolean", description: "false ignores the mask but keeps it." }
13088
+ enabled: { type: "boolean", description: "false ignores the mask but keeps it." },
13089
+ linked: { type: "boolean", description: "false unlinks the mask from the layer pixels (it stays put when they move or transform); true links it again." }
12372
13090
  },
12373
13091
  required: ["layerId"],
12374
13092
  additionalProperties: false
12375
13093
  },
12376
13094
  annotations: { title: "Set layer mask properties", ...WRITE_IDEMPOTENT2 },
12377
- handler: ({ layerId, density, feather, enabled }) => {
13095
+ handler: ({ layerId, density, feather, enabled, linked }) => {
12378
13096
  const id = exists(layerId);
12379
- if ([density, feather, enabled].every((v) => v === void 0)) throw new LayerOperationError("Give density, feather or enabled.");
12380
- const mask = controller.setLayerMaskProps(id, { density, feather, enabled });
13097
+ if ([density, feather, enabled, linked].every((v) => v === void 0)) throw new LayerOperationError("Give density, feather, enabled or linked.");
13098
+ const mask = controller.setLayerMaskProps(id, {
13099
+ density,
13100
+ feather,
13101
+ enabled,
13102
+ linked
13103
+ });
12381
13104
  return { layerId: id, mask: maskSummary(mask) };
12382
13105
  }
12383
13106
  };
@@ -12731,7 +13454,7 @@ function createImageEffectTools(controller) {
12731
13454
  annotations: { title: "List layer effects", ...READ3 },
12732
13455
  handler: ({ layerId }) => result(layerArg(layerId), { globalLight: controller.getGlobalLight(), patterns: patternList(controller), kinds: [...LAYER_EFFECT_KINDS] })
12733
13456
  };
12734
- const add = {
13457
+ const add2 = {
12735
13458
  name: "add_layer_effect",
12736
13459
  title: "Add a layer effect",
12737
13460
  description: "Adds a live effect to a pixel, annotation or group layer with Photoshop's defaults, changed by settings: drop-shadow, inner-shadow, outer-glow, inner-glow, bevel-emboss, satin, color-overlay, gradient-overlay, pattern-overlay or stroke. Several of one kind are allowed (the first listed is on top). One undo step. Returns the new effect's id and the layer's effects.",
@@ -12829,7 +13552,7 @@ function createImageEffectTools(controller) {
12829
13552
  return { globalLight: controller.setGlobalLight(next) };
12830
13553
  }
12831
13554
  };
12832
- return [list2, add, update, remove, setEnabled, light];
13555
+ return [list2, add2, update, remove, setEnabled, light];
12833
13556
  }
12834
13557
 
12835
13558
  // src/image-editor/layers/paint-ui.ts
@@ -13238,6 +13961,248 @@ function createImagePsdTools(controller) {
13238
13961
  return [inspect, open, save];
13239
13962
  }
13240
13963
 
13964
+ // src/image-editor/transform-tools.ts
13965
+ var READ6 = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
13966
+ var WRITE5 = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
13967
+ var round4 = (n, digits = 3) => Math.round(n * 10 ** digits) / 10 ** digits;
13968
+ var roundPoint = (p) => ({ x: round4(p.x), y: round4(p.y) });
13969
+ var POINT = {
13970
+ type: "object",
13971
+ properties: { x: { type: "number", description: "Document pixels from the left." }, y: { type: "number", description: "Document pixels from the top." } },
13972
+ required: ["x", "y"],
13973
+ additionalProperties: false
13974
+ };
13975
+ var TARGET2 = {
13976
+ layerId: { type: "string", description: "Layer to transform (from list_layers; default the active layer)." },
13977
+ content: {
13978
+ type: "string",
13979
+ enum: FREE_TRANSFORM_CONTENTS.filter((c) => c !== "selection-outline"),
13980
+ description: "What moves: layer (all its pixels with a linked mask), selection (only the selected pixels, committed back into the layer; the outline moves too), selection-copy (Layer via Copy: the selected pixels on a new layer above, then transformed; the original stays) or auto (default: selection when something is selected, else layer)."
13981
+ },
13982
+ channel: {
13983
+ type: "string",
13984
+ enum: ["pixels", "mask"],
13985
+ description: "pixels (default) moves the pixels and a linked mask; mask moves an unlinked mask alone (a linked mask always moves with its pixels)."
13986
+ },
13987
+ interpolation: { type: "string", enum: INTERPOLATIONS, description: "Resampling: nearest (hard pixels), bilinear or bicubic (default, Photoshop\u2019s Bicubic)." }
13988
+ };
13989
+ var GEOMETRY = {
13990
+ scaleX: { type: "number", description: "Width in percent of the frame (default 100; negative flips horizontally)." },
13991
+ scaleY: { type: "number", description: "Height in percent of the frame (default 100; negative flips vertically)." },
13992
+ rotate: { type: "number", description: "Rotation in degrees, clockwise (default 0)." },
13993
+ skewX: { type: "number", minimum: -89, maximum: 89, description: "Horizontal skew in degrees: vertical edges lean right for positive angles (default 0)." },
13994
+ skewY: { type: "number", minimum: -89, maximum: 89, description: "Vertical skew in degrees: horizontal edges lean down for positive angles (default 0)." },
13995
+ dx: { type: "number", description: "Move right after scaling and rotating, in document pixels (default 0)." },
13996
+ dy: { type: "number", description: "Move down after scaling and rotating, in document pixels (default 0)." },
13997
+ reference: {
13998
+ description: "Fixed point of scale, skew and rotate: an anchor of the frame (top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right; default center) or a document point {x, y}.",
13999
+ anyOf: [{ type: "string", enum: TRANSFORM_ANCHORS }, POINT]
14000
+ },
14001
+ matrix: {
14002
+ type: "array",
14003
+ items: { type: "number" },
14004
+ minItems: 6,
14005
+ maxItems: 6,
14006
+ description: "Instead of the numeric fields: an affine matrix [a, b, c, d, e, f] in document pixels (x' = a\xB7x + c\xB7y + e, y' = b\xB7x + d\xB7y + f)."
14007
+ },
14008
+ corners: {
14009
+ type: "array",
14010
+ items: POINT,
14011
+ minItems: 4,
14012
+ maxItems: 4,
14013
+ description: "Instead of the numeric fields: where the frame\u2019s corners go, [top-left, top-right, bottom-right, bottom-left] in document pixels (Distort; for Perspective move a pair of corners symmetrically). The quad must stay convex. Get the frame and its corners from get_transform_frame."
14014
+ }
14015
+ };
14016
+ var NUMERIC = ["scaleX", "scaleY", "rotate", "skewX", "skewY", "dx", "dy", "reference"];
14017
+ function geometryFrom(args) {
14018
+ const numeric = NUMERIC.filter((k) => args[k] !== void 0);
14019
+ const ways = [numeric.length > 0, args.matrix !== void 0, args.corners !== void 0].filter(Boolean).length;
14020
+ if (ways === 0) throw new LayerOperationError("Give the transform one way: numeric fields (scaleX, scaleY, rotate, skewX, skewY, dx, dy, reference), matrix, or corners.");
14021
+ if (ways > 1) throw new LayerOperationError("Give the transform only one way: numeric fields, matrix or corners, not a mix.");
14022
+ if (args.matrix !== void 0) return { matrix: args.matrix };
14023
+ if (args.corners !== void 0) return { corners: args.corners };
14024
+ const params = {};
14025
+ for (const k of NUMERIC) if (args[k] !== void 0) params[k] = args[k];
14026
+ if (params.scaleX === 0 || params.scaleY === 0) throw new LayerOperationError("scaleX and scaleY must not be 0 (that collapses the layer to a line).");
14027
+ if (typeof params.reference === "string" && !isTransformAnchor(params.reference)) throw new LayerOperationError(`Unknown reference "${params.reference}". Use ${TRANSFORM_ANCHORS.join(", ")} or {x, y}.`);
14028
+ return { params };
14029
+ }
14030
+ function targetFrom(args) {
14031
+ const init = {};
14032
+ if (typeof args.layerId === "string") init.layerId = args.layerId;
14033
+ if (args.content !== void 0) init.content = args.content;
14034
+ init.channel = args.channel ?? "pixels";
14035
+ if (args.interpolation !== void 0) init.interpolation = args.interpolation;
14036
+ return init;
14037
+ }
14038
+ function summary2(res) {
14039
+ return {
14040
+ layerId: res.layerId,
14041
+ content: res.content,
14042
+ moved: res.moved,
14043
+ changed: res.changed,
14044
+ frame: { ...res.box },
14045
+ corners: res.corners.map(roundPoint),
14046
+ bounds: res.bounds
14047
+ };
14048
+ }
14049
+ function createImageTransformTools(controller) {
14050
+ const getFrame = {
14051
+ name: "get_transform_frame",
14052
+ title: "Get the Free Transform frame",
14053
+ description: "Returns what a transform would move and its frame: content (layer, selection, selection-copy or selection-outline), moved (pixels, mask, selection), the frame (document bounds of the content), its corners [top-left, top-right, bottom-right, bottom-left] and centre. Pass meshColumns / meshRows to also get the identity warp mesh over the frame to edit for warp_layer. Changes nothing.",
14054
+ inputSchema: {
14055
+ type: "object",
14056
+ properties: {
14057
+ layerId: TARGET2.layerId,
14058
+ content: { type: "string", enum: FREE_TRANSFORM_CONTENTS, description: `${TARGET2.content.description} selection-outline frames the selection outline alone.` },
14059
+ channel: TARGET2.channel,
14060
+ meshColumns: { type: "integer", minimum: 1, maximum: 16, description: "Patch columns of an identity warp mesh to return (omit for none)." },
14061
+ meshRows: { type: "integer", minimum: 1, maximum: 16, description: "Patch rows of an identity warp mesh to return (default meshColumns)." }
14062
+ },
14063
+ additionalProperties: false
14064
+ },
14065
+ annotations: { title: "Get the Free Transform frame", ...READ6 },
14066
+ handler: (args) => {
14067
+ const init = targetFrom(args);
14068
+ if (init.content === "selection-copy") init.content = "selection";
14069
+ const s = controller.beginFreeTransform(init);
14070
+ const out = {
14071
+ layerId: s.layerId,
14072
+ content: args.content === "selection-copy" ? "selection-copy" : s.content,
14073
+ moved: [...s.moves],
14074
+ frame: { ...s.box },
14075
+ corners: identityTransform(s.box).corners.map(roundPoint),
14076
+ center: roundPoint({ x: s.box.x + s.box.width / 2, y: s.box.y + s.box.height / 2 })
14077
+ };
14078
+ s.cancel();
14079
+ if (args.meshColumns !== void 0) {
14080
+ const cols = args.meshColumns;
14081
+ const rows = args.meshRows ?? cols;
14082
+ const mesh = identityWarpMesh(s.box, cols, rows);
14083
+ out.mesh = { columns: cols, rows, pointsPerRow: warpMeshColumns(mesh), pointRows: warpMeshRows(mesh), points: mesh.points.map(roundPoint) };
14084
+ }
14085
+ return out;
14086
+ }
14087
+ };
14088
+ const transformLayer = {
14089
+ name: "transform_layer",
14090
+ title: "Free Transform a layer",
14091
+ description: "Edit \u203A Free Transform: scales, rotates, skews, moves, distorts or puts in perspective a pixel layer (with its linked mask), the selected pixels, a copy of them on a new layer, or an unlinked mask. Give the transform one way: the numeric fields (as Photoshop\u2019s options bar, about a reference point), a matrix, or the frame\u2019s new corners (distort / perspective; read the frame with get_transform_frame). Resamples once, tile by tile; one undo step. Returns layerId (the new layer for selection-copy), what moved, the frame, the corners and the new document bounds.",
14092
+ inputSchema: {
14093
+ type: "object",
14094
+ properties: { ...TARGET2, ...GEOMETRY },
14095
+ additionalProperties: false
14096
+ },
14097
+ annotations: { title: "Free Transform a layer", ...WRITE5 },
14098
+ handler: (args) => summary2(controller.transformLayer({ ...targetFrom(args), ...geometryFrom(args) }))
14099
+ };
14100
+ const warpLayer = {
14101
+ name: "warp_layer",
14102
+ title: "Warp a layer",
14103
+ description: "Edit \u203A Transform \u203A Warp: bends a pixel layer (with its linked mask), the selected pixels, a copy of them, or an unlinked mask through a preset (style with bend and distortions in percent) or a B\xE9zier mesh over the frame (get the frame and an identity mesh to edit from get_transform_frame with meshColumns). Give style or mesh, not both. One undo step. Returns what moved and the new document bounds.",
14104
+ inputSchema: {
14105
+ type: "object",
14106
+ properties: {
14107
+ ...TARGET2,
14108
+ style: { type: "string", enum: WARP_STYLES, description: "Preset warp (Photoshop\u2019s menu): arc, arcLower, arcUpper, arch, bulge, shellLower, shellUpper, flag, wave, fish, rise, fisheye, inflate, squeeze, twist." },
14109
+ bend: { type: "number", minimum: -100, maximum: 100, description: "Preset bend in percent, \u2212100..100 (default 50)." },
14110
+ horizontal: { type: "number", minimum: -100, maximum: 100, description: "Preset horizontal distortion in percent, \u2212100..100 (default 0)." },
14111
+ vertical: { type: "number", minimum: -100, maximum: 100, description: "Preset vertical distortion in percent, \u2212100..100 (default 0)." },
14112
+ orientation: { type: "string", enum: ["horizontal", "vertical"], description: "Bend along the horizontal (default) or vertical axis." },
14113
+ mesh: {
14114
+ type: "object",
14115
+ description: "A custom warp: patch columns and rows, and the (3\xB7columns + 1) \xD7 (3\xB7rows + 1) control points row by row in document pixels (anchors where both indices are multiples of 3, B\xE9zier handles between). Start from get_transform_frame\u2019s identity mesh.",
14116
+ properties: {
14117
+ columns: { type: "integer", minimum: 1, maximum: 16, description: "Patch columns." },
14118
+ rows: { type: "integer", minimum: 1, maximum: 16, description: "Patch rows." },
14119
+ points: { type: "array", items: POINT, minItems: 16, description: "Control points, row by row, document pixels." }
14120
+ },
14121
+ required: ["columns", "rows", "points"],
14122
+ additionalProperties: false
14123
+ }
14124
+ },
14125
+ additionalProperties: false
14126
+ },
14127
+ annotations: { title: "Warp a layer", ...WRITE5 },
14128
+ handler: (args) => {
14129
+ const hasPreset = ["style", "bend", "horizontal", "vertical", "orientation"].some((k) => args[k] !== void 0);
14130
+ if (hasPreset && args.mesh !== void 0) throw new LayerOperationError("Give a preset (style, bend, horizontal, vertical, orientation) or a mesh, not both.");
14131
+ if (!hasPreset && args.mesh === void 0) throw new LayerOperationError("Give a preset style or a mesh.");
14132
+ let warp;
14133
+ if (hasPreset) {
14134
+ if (args.style === void 0) throw new LayerOperationError(`style is required with a preset. Styles: ${WARP_STYLES.join(", ")}.`);
14135
+ warp = {
14136
+ style: args.style,
14137
+ bend: args.bend ?? 50,
14138
+ horizontal: args.horizontal,
14139
+ vertical: args.vertical,
14140
+ orientation: args.orientation
14141
+ };
14142
+ } else {
14143
+ const m = args.mesh;
14144
+ const want = (3 * m.columns + 1) * (3 * m.rows + 1);
14145
+ if (m.points.length !== want) throw new LayerOperationError(`A ${m.columns} \xD7 ${m.rows} mesh needs (3\xB7${m.columns} + 1) \xD7 (3\xB7${m.rows} + 1) = ${want} points, got ${m.points.length}.`);
14146
+ const knots = (n) => Array.from({ length: n + 1 }, (_, i) => i / n);
14147
+ warp = { us: knots(m.columns), vs: knots(m.rows), points: m.points.map((p) => ({ x: p.x, y: p.y })) };
14148
+ }
14149
+ return summary2(controller.transformLayer({ ...targetFrom(args), warp }));
14150
+ }
14151
+ };
14152
+ const transformSelection = {
14153
+ name: "transform_selection",
14154
+ title: "Transform the selection outline",
14155
+ description: "Select \u203A Transform Selection: scales, rotates, skews, moves or distorts only the selection outline; no pixels change. Give the transform one way: the numeric fields (about a reference point of the selection\u2019s bounds), a matrix, or the new corners of its bounds. Needs a selection. One undo step. Returns the new selection bounds.",
14156
+ inputSchema: {
14157
+ type: "object",
14158
+ properties: { interpolation: { ...TARGET2.interpolation, description: "Resampling of soft edges: nearest, bilinear or bicubic (default)." }, ...GEOMETRY },
14159
+ additionalProperties: false
14160
+ },
14161
+ annotations: { title: "Transform the selection outline", ...WRITE5 },
14162
+ handler: (args) => {
14163
+ const res = controller.transformLayer({ content: "selection-outline", interpolation: args.interpolation, ...geometryFrom(args) });
14164
+ return { changed: res.changed, frame: { ...res.box }, corners: res.corners.map(roundPoint), bounds: res.bounds };
14165
+ }
14166
+ };
14167
+ const flipLayer = {
14168
+ name: "flip_layer",
14169
+ title: "Flip a layer",
14170
+ description: "Edit \u203A Transform \u203A Flip Horizontal or Vertical: mirrors a pixel layer (with its linked mask), the selected pixels or a copy of them about the centre of their frame, exactly (no resampling). To flip the whole image use flip_image. One undo step. Returns the new bounds.",
14171
+ inputSchema: {
14172
+ type: "object",
14173
+ properties: {
14174
+ axis: { type: "string", enum: ["horizontal", "vertical"], description: "horizontal mirrors left \u2194 right, vertical top \u2194 bottom." },
14175
+ layerId: TARGET2.layerId,
14176
+ content: TARGET2.content,
14177
+ channel: TARGET2.channel
14178
+ },
14179
+ required: ["axis"],
14180
+ additionalProperties: false
14181
+ },
14182
+ annotations: { title: "Flip a layer", ...WRITE5 },
14183
+ handler: (args) => summary2(controller.flipLayer(args.axis, targetFrom(args)))
14184
+ };
14185
+ const rotate90 = {
14186
+ name: "rotate_layer_90",
14187
+ title: "Rotate a layer by quarter turns",
14188
+ description: "Edit \u203A Transform \u203A Rotate 90\xB0 / 180\xB0: turns a pixel layer (with its linked mask), the selected pixels or a copy of them about the centre of their frame, exactly (pixels land on whole pixels; no resampling). To rotate the whole image use set_rotation. One undo step. Returns the new bounds.",
14189
+ inputSchema: {
14190
+ type: "object",
14191
+ properties: {
14192
+ turn: { type: "integer", enum: [90, -90, 180], description: "Degrees: 90 clockwise, -90 counter-clockwise, or 180." },
14193
+ layerId: TARGET2.layerId,
14194
+ content: TARGET2.content,
14195
+ channel: TARGET2.channel
14196
+ },
14197
+ required: ["turn"],
14198
+ additionalProperties: false
14199
+ },
14200
+ annotations: { title: "Rotate a layer by quarter turns", ...WRITE5 },
14201
+ handler: (args) => summary2(controller.rotateLayer90(args.turn, targetFrom(args)))
14202
+ };
14203
+ return [getFrame, transformLayer, warpLayer, transformSelection, flipLayer, rotate90];
14204
+ }
14205
+
13241
14206
  // src/image-editor/tools.ts
13242
14207
  var LAYER_PATH = /^layers\.([^.]+)\.(.+)$/;
13243
14208
  function adjustmentsSummary(adjustments) {
@@ -13475,6 +14440,7 @@ function createImageEditorToolProvider(controller, options = {}) {
13475
14440
  ...createImageSelectionTools(controller),
13476
14441
  ...createImageEditTools(controller),
13477
14442
  ...createImageLayerTools(controller),
14443
+ ...createImageTransformTools(controller),
13478
14444
  ...createImageEffectTools(controller),
13479
14445
  ...createImagePaintTools(controller),
13480
14446
  ...createPigmentTools(),
@@ -13851,6 +14817,7 @@ function layerMenu(controller) {
13851
14817
  { action: "apply-mask", label: "Apply Layer Mask", enabled: isPixel && Boolean(active?.mask) },
13852
14818
  { action: "delete-mask", label: "Delete Layer Mask", enabled: Boolean(active?.mask) },
13853
14819
  { action: "toggle-mask", label: active?.mask?.enabled === false ? "Enable Layer Mask" : "Disable Layer Mask", enabled: Boolean(active?.mask) },
14820
+ { action: "toggle-mask-link", label: active?.mask?.linked === false ? "Link Layer Mask" : "Unlink Layer Mask", enabled: Boolean(active?.mask && !active.mask.source) },
13854
14821
  { action: "rasterize", label: "Rasterize Layer", enabled: active?.type === "annotation" || isPixel && Boolean(active?.type === "pixel" && active.filters?.length) },
13855
14822
  { action: "toggle-clip", label: active?.clipped ? "Release Clipping Mask" : "Create Clipping Mask", enabled: Boolean(loc && loc.index > 0) }
13856
14823
  ];
@@ -13906,6 +14873,9 @@ async function runLayerMenuAction(controller, action) {
13906
14873
  case "toggle-mask":
13907
14874
  if (id && active?.mask) controller.setLayerMaskProps(id, { enabled: active.mask.enabled === false });
13908
14875
  break;
14876
+ case "toggle-mask-link":
14877
+ if (id && active?.mask) controller.setLayerMaskProps(id, { linked: active.mask.linked === false });
14878
+ break;
13909
14879
  case "rasterize":
13910
14880
  if (id) await controller.rasterizeLayer(id);
13911
14881
  break;
@@ -14124,6 +15094,445 @@ async function downloadPsd(controller, name = "image") {
14124
15094
  }
14125
15095
  return { fileName, warnings: res.warnings };
14126
15096
  }
15097
+
15098
+ // src/image-editor/layers/transform-ui.ts
15099
+ var CORNERS = ["nw", "ne", "se", "sw"];
15100
+ var SIDES = ["n", "e", "s", "w"];
15101
+ var CORNER_INDEX = { nw: 0, ne: 1, se: 2, sw: 3 };
15102
+ var SIDE_CORNERS = { n: [0, 1], e: [1, 2], s: [2, 3], w: [3, 0] };
15103
+ var HANDLE_FRACTION = { nw: [0, 0], n: [0.5, 0], ne: [1, 0], e: [1, 0.5], se: [1, 1], s: [0.5, 1], sw: [0, 1], w: [0, 0.5] };
15104
+ var ANCHOR_FRACTION2 = {
15105
+ "top-left": [0, 0],
15106
+ top: [0.5, 0],
15107
+ "top-right": [1, 0],
15108
+ left: [0, 0.5],
15109
+ center: [0.5, 0.5],
15110
+ right: [1, 0.5],
15111
+ "bottom-left": [0, 1],
15112
+ bottom: [0.5, 1],
15113
+ "bottom-right": [1, 1]
15114
+ };
15115
+ var FREE_TRANSFORM_MODE_OPTIONS = [
15116
+ { id: "scale", label: "Free", title: "Free Transform: corners scale, outside rotates, Ctrl/Cmd distorts" },
15117
+ { id: "rotate", label: "Rotate", title: "Every handle rotates about the reference point" },
15118
+ { id: "skew", label: "Skew", title: "Sides slide along themselves" },
15119
+ { id: "distort", label: "Distort", title: "Each corner moves freely" },
15120
+ { id: "perspective", label: "Perspective", title: "A corner and its partner move symmetrically" }
15121
+ ];
15122
+ var FREE_TRANSFORM_FIELDS = [
15123
+ { id: "x", label: "X", unit: "px", title: "Reference point X (document pixels)" },
15124
+ { id: "y", label: "Y", unit: "px", title: "Reference point Y (document pixels)" },
15125
+ { id: "scaleX", label: "W", unit: "%", title: "Width in percent of the original" },
15126
+ { id: "scaleY", label: "H", unit: "%", title: "Height in percent of the original" },
15127
+ { id: "rotate", label: "\u2220", unit: "\xB0", title: "Rotation in degrees, clockwise" },
15128
+ { id: "skewX", label: "H skew", unit: "\xB0", title: "Horizontal skew in degrees" },
15129
+ { id: "skewY", label: "V skew", unit: "\xB0", title: "Vertical skew in degrees" }
15130
+ ];
15131
+ var CURSOR = {
15132
+ nw: "nwse-resize",
15133
+ se: "nwse-resize",
15134
+ ne: "nesw-resize",
15135
+ sw: "nesw-resize",
15136
+ n: "ns-resize",
15137
+ s: "ns-resize",
15138
+ e: "ew-resize",
15139
+ w: "ew-resize",
15140
+ reference: "move",
15141
+ inside: "move",
15142
+ outside: "alias"
15143
+ };
15144
+ var mod = (m) => Boolean(m.ctrlKey || m.metaKey);
15145
+ var add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
15146
+ var sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
15147
+ function rotateAbout(p, c, rad) {
15148
+ const cos = Math.cos(rad);
15149
+ const sin = Math.sin(rad);
15150
+ const d = sub(p, c);
15151
+ return { x: c.x + d.x * cos - d.y * sin, y: c.y + d.x * sin + d.y * cos };
15152
+ }
15153
+ function insideQuad(q2, p) {
15154
+ let sign = 0;
15155
+ for (let i = 0; i < 4; i++) {
15156
+ const a = q2[i];
15157
+ const b = q2[(i + 1) % 4];
15158
+ const c = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
15159
+ if (Math.abs(c) < 1e-12) continue;
15160
+ const s = Math.sign(c);
15161
+ if (sign && s !== sign) return false;
15162
+ sign = s;
15163
+ }
15164
+ return true;
15165
+ }
15166
+ var FreeTransformTool = class {
15167
+ constructor(controller, options = {}) {
15168
+ this.controller = controller;
15169
+ this.options = options;
15170
+ }
15171
+ controller;
15172
+ options;
15173
+ sessionRef = null;
15174
+ modeValue = "scale";
15175
+ /** The reference point in the untransformed frame's space (follows the transform). */
15176
+ refBox = { x: 0, y: 0 };
15177
+ corners = null;
15178
+ drag = null;
15179
+ pending = false;
15180
+ viewScale = 1;
15181
+ viewClip;
15182
+ /** Values typed into the options bar since the last drag (V skew is not recovered from the shape). */
15183
+ typed = {};
15184
+ /** Width and height scale together when typed (the chain icon). */
15185
+ proportional = true;
15186
+ error = null;
15187
+ lastResult = null;
15188
+ get active() {
15189
+ return Boolean(this.sessionRef?.isOpen);
15190
+ }
15191
+ get session() {
15192
+ return this.active ? this.sessionRef : null;
15193
+ }
15194
+ get mode() {
15195
+ return this.modeValue;
15196
+ }
15197
+ get interpolation() {
15198
+ return this.sessionRef?.interpolation ?? "bicubic";
15199
+ }
15200
+ listeners = /* @__PURE__ */ new Set();
15201
+ /** Calls `listener` whenever the overlay, readout, mode or error changes. Returns the unsubscribe. */
15202
+ subscribe(listener) {
15203
+ this.listeners.add(listener);
15204
+ return () => {
15205
+ this.listeners.delete(listener);
15206
+ };
15207
+ }
15208
+ changed() {
15209
+ this.options.onChange?.();
15210
+ for (const l of this.listeners) l();
15211
+ }
15212
+ /** Opens a Free Transform (default: the selected pixels of the active layer, or the whole layer). Returns an error message or `null`. */
15213
+ begin(init = {}) {
15214
+ if (this.active) return null;
15215
+ try {
15216
+ if (!init.layerId && init.content !== "selection-outline" && !this.controller.getState().activeLayerId) {
15217
+ const top = [...flattenLayers(this.controller.getState().layers)].reverse().find((l) => l.type === "pixel" && l.visible);
15218
+ if (top) this.controller.setActiveLayer(top.id);
15219
+ }
15220
+ const s = this.controller.beginFreeTransform(init);
15221
+ this.sessionRef = s;
15222
+ this.corners = s.geometry.corners;
15223
+ this.refBox = { x: s.box.x + s.box.width / 2, y: s.box.y + s.box.height / 2 };
15224
+ this.modeValue = "scale";
15225
+ this.typed = {};
15226
+ this.error = null;
15227
+ } catch (e) {
15228
+ this.error = e instanceof Error ? e.message : String(e);
15229
+ }
15230
+ this.changed();
15231
+ return this.error;
15232
+ }
15233
+ setMode(mode) {
15234
+ this.modeValue = mode;
15235
+ this.changed();
15236
+ }
15237
+ /** Sets the reference point to an anchor of the frame (the options bar's locator). */
15238
+ setReference(anchor) {
15239
+ const s = this.session;
15240
+ if (!s) return;
15241
+ const [fx, fy] = ANCHOR_FRACTION2[anchor];
15242
+ this.refBox = { x: s.box.x + fx * s.box.width, y: s.box.y + fy * s.box.height };
15243
+ this.changed();
15244
+ }
15245
+ setInterpolation(interpolation) {
15246
+ const s = this.session;
15247
+ if (!s) return;
15248
+ this.run(() => s.update({ interpolation }, false));
15249
+ }
15250
+ /** Screen pixels per document pixel and the visible document area, for previews. */
15251
+ setView(scale, clip) {
15252
+ this.viewScale = scale > 0 ? scale : 1;
15253
+ this.viewClip = clip;
15254
+ }
15255
+ homography() {
15256
+ const s = this.session;
15257
+ return s && this.corners ? rectToQuad(s.box, this.corners) : null;
15258
+ }
15259
+ /** The reference point in document pixels. */
15260
+ referencePoint() {
15261
+ const h = this.homography();
15262
+ return h ? applyHomography(h, this.refBox) : null;
15263
+ }
15264
+ /** The outline, handles and reference point to draw, or `null` when no transform is open. */
15265
+ overlay() {
15266
+ const s = this.session;
15267
+ const h = this.homography();
15268
+ if (!s || !h || !this.corners) return null;
15269
+ const handles = [...CORNERS, ...SIDES].map((id) => {
15270
+ const [fx, fy] = HANDLE_FRACTION[id];
15271
+ const p = applyHomography(h, { x: s.box.x + fx * s.box.width, y: s.box.y + fy * s.box.height }) ?? { x: 0, y: 0 };
15272
+ return { id, x: p.x, y: p.y };
15273
+ });
15274
+ return { outline: this.corners.map((p) => ({ x: p.x, y: p.y })), handles, reference: this.referencePoint() ?? { x: 0, y: 0 } };
15275
+ }
15276
+ /** What a document point is over (`tolerance` in document pixels: a few screen pixels / scale). */
15277
+ hit(p, tolerance) {
15278
+ const o = this.overlay();
15279
+ if (!o) return null;
15280
+ let best = null;
15281
+ let bestD = tolerance;
15282
+ for (const h of o.handles) {
15283
+ const d = Math.hypot(h.x - p.x, h.y - p.y);
15284
+ if (d <= bestD) {
15285
+ best = h.id;
15286
+ bestD = d;
15287
+ }
15288
+ }
15289
+ if (Math.hypot(o.reference.x - p.x, o.reference.y - p.y) < bestD) best = "reference";
15290
+ if (best) return best;
15291
+ return insideQuad(o.outline, p) ? "inside" : "outside";
15292
+ }
15293
+ /** The CSS cursor for a handle. */
15294
+ cursor(handle) {
15295
+ if (!handle) return "default";
15296
+ if (this.modeValue === "rotate" && handle !== "inside" && handle !== "reference") return CURSOR.outside;
15297
+ return CURSOR[handle];
15298
+ }
15299
+ /** Starts a drag on what `p` is over. */
15300
+ down(p, tolerance) {
15301
+ if (!this.session || !this.corners) return null;
15302
+ const handle = this.hit(p, tolerance);
15303
+ if (!handle) return null;
15304
+ this.drag = { handle, start: { x: p.x, y: p.y }, corners: this.corners, refBox: { ...this.refBox }, ref: this.referencePoint() ?? p };
15305
+ return handle;
15306
+ }
15307
+ /** Continues the drag to `p` with the modifiers held now. */
15308
+ move(p, m = {}) {
15309
+ const d = this.drag;
15310
+ const s = this.session;
15311
+ if (!d || !s) return;
15312
+ if (d.handle === "reference") {
15313
+ const h = rectToQuad(s.box, d.corners);
15314
+ const inv = h && invertHomography(h);
15315
+ const q2 = inv && applyHomography(inv, p);
15316
+ if (q2) this.refBox = q2;
15317
+ this.changed();
15318
+ return;
15319
+ }
15320
+ const corners = this.dragCorners(d, p, m);
15321
+ if (corners) this.setCorners(corners);
15322
+ }
15323
+ /** Ends the drag (the transform stays open). */
15324
+ up() {
15325
+ this.drag = null;
15326
+ }
15327
+ dragCorners(d, p, m) {
15328
+ const s = this.session;
15329
+ const c0 = d.corners;
15330
+ let delta = sub(p, d.start);
15331
+ const map = (f) => c0.map(f);
15332
+ const handle = d.handle;
15333
+ const isCorner = handle in CORNER_INDEX;
15334
+ const isSide = handle in SIDE_CORNERS;
15335
+ const rotating = handle === "outside" || this.modeValue === "rotate" && (isCorner || isSide);
15336
+ if (handle === "inside") {
15337
+ if (m.shiftKey) delta = Math.abs(delta.x) >= Math.abs(delta.y) ? { x: delta.x, y: 0 } : { x: 0, y: delta.y };
15338
+ return map((q3) => add(q3, delta));
15339
+ }
15340
+ if (rotating) {
15341
+ const r = d.ref;
15342
+ let a = Math.atan2(p.y - r.y, p.x - r.x) - Math.atan2(d.start.y - r.y, d.start.x - r.x);
15343
+ if (m.shiftKey) a = Math.round(a / (Math.PI / 12)) * (Math.PI / 12);
15344
+ return map((q3) => rotateAbout(q3, r, a));
15345
+ }
15346
+ const perspective = this.modeValue === "perspective" || mod(m) && m.altKey && m.shiftKey;
15347
+ const distort = this.modeValue === "distort" || mod(m) && !perspective;
15348
+ const skew = this.modeValue === "skew" || mod(m) && isSide;
15349
+ if (isSide && (skew || perspective)) {
15350
+ const [i, j] = SIDE_CORNERS[handle];
15351
+ const dir = sub(c0[j], c0[i]);
15352
+ const len = Math.hypot(dir.x, dir.y) || 1;
15353
+ const t = (delta.x * dir.x + delta.y * dir.y) / len;
15354
+ const v = { x: dir.x / len * t, y: dir.y / len * t };
15355
+ const [oi, oj] = SIDE_CORNERS[{ n: "s", s: "n", e: "w", w: "e" }[handle]];
15356
+ return map((q3, k) => k === i || k === j ? add(q3, v) : m.altKey && (k === oi || k === oj) ? sub(q3, v) : q3);
15357
+ }
15358
+ if (isCorner && perspective) {
15359
+ const k = CORNER_INDEX[handle];
15360
+ if (Math.abs(delta.x) >= Math.abs(delta.y)) {
15361
+ const partner2 = [1, 0, 3, 2][k];
15362
+ return map((q3, i) => i === k ? { x: q3.x + delta.x, y: q3.y } : i === partner2 ? { x: q3.x - delta.x, y: q3.y } : q3);
15363
+ }
15364
+ const partner = [3, 2, 1, 0][k];
15365
+ return map((q3, i) => i === k ? { x: q3.x, y: q3.y + delta.y } : i === partner ? { x: q3.x, y: q3.y - delta.y } : q3);
15366
+ }
15367
+ if (distort || isCorner && this.modeValue === "skew") {
15368
+ if (isCorner) return map((q3, i2) => i2 === CORNER_INDEX[handle] ? add(q3, delta) : q3);
15369
+ const [i, j] = SIDE_CORNERS[handle];
15370
+ return map((q3, k) => k === i || k === j ? add(q3, delta) : q3);
15371
+ }
15372
+ const h = rectToQuad(s.box, c0);
15373
+ const inv = h && invertHomography(h);
15374
+ if (!h || !inv) return null;
15375
+ const q2 = applyHomography(inv, p);
15376
+ const q0 = applyHomography(inv, d.start);
15377
+ if (!q2 || !q0) return null;
15378
+ const b = s.box;
15379
+ const [fx, fy] = HANDLE_FRACTION[handle];
15380
+ const grab = { x: b.x + fx * b.width, y: b.y + fy * b.height };
15381
+ const anchor = m.altKey ? d.refBox : { x: b.x + (1 - fx) * b.width, y: b.y + (1 - fy) * b.height };
15382
+ const target = add(grab, sub(q2, q0));
15383
+ const ax = grab.x - anchor.x;
15384
+ const ay = grab.y - anchor.y;
15385
+ let sx = isCorner || handle === "e" || handle === "w" ? Math.abs(ax) > 1e-9 ? (target.x - anchor.x) / ax : 1 : 1;
15386
+ let sy = isCorner || handle === "n" || handle === "s" ? Math.abs(ay) > 1e-9 ? (target.y - anchor.y) / ay : 1 : 1;
15387
+ if (isCorner && !m.shiftKey) {
15388
+ const k = Math.abs(sx) >= Math.abs(sy) ? sx : sy;
15389
+ sx = k;
15390
+ sy = k;
15391
+ }
15392
+ const scaled = [
15393
+ { x: b.x, y: b.y },
15394
+ { x: b.x + b.width, y: b.y },
15395
+ { x: b.x + b.width, y: b.y + b.height },
15396
+ { x: b.x, y: b.y + b.height }
15397
+ ].map((c) => applyHomography(h, { x: anchor.x + (c.x - anchor.x) * sx, y: anchor.y + (c.y - anchor.y) * sy }));
15398
+ return scaled.every(Boolean) ? scaled : null;
15399
+ }
15400
+ /** Sets new corners and refreshes the preview (scheduled). Invalid shapes keep the last good one. */
15401
+ setCorners(corners) {
15402
+ const s = this.session;
15403
+ if (!s) return;
15404
+ const h = rectToQuad(s.box, corners);
15405
+ if (!h) return;
15406
+ this.corners = corners;
15407
+ this.typed = {};
15408
+ this.changed();
15409
+ this.preview();
15410
+ }
15411
+ preview() {
15412
+ if (this.pending) return;
15413
+ this.pending = true;
15414
+ const run = () => {
15415
+ this.pending = false;
15416
+ const s = this.session;
15417
+ if (!s || !this.corners) return;
15418
+ this.run(() => s.update({ corners: this.corners }, { scale: this.viewScale, clip: this.viewClip }));
15419
+ };
15420
+ if (this.options.schedule) this.options.schedule(run);
15421
+ else run();
15422
+ }
15423
+ run(fn) {
15424
+ try {
15425
+ fn();
15426
+ this.error = null;
15427
+ } catch (e) {
15428
+ this.error = e instanceof Error ? e.message : String(e);
15429
+ if (this.sessionRef && !this.sessionRef.isOpen) this.sessionRef = null;
15430
+ }
15431
+ this.changed();
15432
+ }
15433
+ /** The options bar's numbers for the current shape. */
15434
+ bar() {
15435
+ const s = this.session;
15436
+ if (!s || !this.corners) return null;
15437
+ const read = describeTransform({ box: s.box, corners: this.corners }, this.refBox);
15438
+ return {
15439
+ affine: read.affine,
15440
+ x: this.typed.x ?? read.x,
15441
+ y: this.typed.y ?? read.y,
15442
+ scaleX: this.typed.scaleX ?? read.scaleX,
15443
+ scaleY: this.typed.scaleY ?? read.scaleY,
15444
+ rotate: this.typed.rotate ?? read.rotate,
15445
+ skewX: this.typed.skewX ?? read.skewX,
15446
+ skewY: this.typed.skewY ?? 0
15447
+ };
15448
+ }
15449
+ /** Applies a typed options-bar value (W and H together while `proportional`). */
15450
+ setField(field, value) {
15451
+ const s = this.session;
15452
+ const cur = this.bar();
15453
+ if (!s || !cur || !Number.isFinite(value)) return;
15454
+ const next = { ...cur, [field]: value };
15455
+ if (this.proportional && field === "scaleX") next.scaleY = cur.scaleY === 0 ? value : value / cur.scaleX * cur.scaleY;
15456
+ if (this.proportional && field === "scaleY") next.scaleX = cur.scaleX === 0 ? value : value / cur.scaleY * cur.scaleX;
15457
+ if (!next.scaleX || !next.scaleY) {
15458
+ this.error = "Width and height must not be 0 %.";
15459
+ this.changed();
15460
+ return;
15461
+ }
15462
+ const m = matrixFromParams(s.box, {
15463
+ scaleX: next.scaleX,
15464
+ scaleY: next.scaleY,
15465
+ rotate: next.rotate,
15466
+ skewX: next.skewX,
15467
+ skewY: next.skewY,
15468
+ reference: this.refBox,
15469
+ dx: next.x - this.refBox.x,
15470
+ dy: next.y - this.refBox.y
15471
+ });
15472
+ this.setCorners(cornersFromMatrix(s.box, m));
15473
+ this.typed = { scaleX: next.scaleX, scaleY: next.scaleY, rotate: next.rotate, skewX: next.skewX, skewY: next.skewY, x: next.x, y: next.y };
15474
+ this.changed();
15475
+ }
15476
+ /** Finishes with full-quality resampling (one undo step). */
15477
+ commit() {
15478
+ const s = this.session;
15479
+ if (!s) return null;
15480
+ let res = null;
15481
+ this.pending = false;
15482
+ this.run(() => {
15483
+ if (this.corners) s.update({ corners: this.corners }, false);
15484
+ res = s.commit();
15485
+ });
15486
+ this.lastResult = res;
15487
+ this.sessionRef = null;
15488
+ this.corners = null;
15489
+ this.drag = null;
15490
+ this.changed();
15491
+ return res;
15492
+ }
15493
+ /** Abandons the transform and restores everything. */
15494
+ cancel() {
15495
+ this.sessionRef?.cancel();
15496
+ this.sessionRef = null;
15497
+ this.corners = null;
15498
+ this.drag = null;
15499
+ this.pending = false;
15500
+ this.error = null;
15501
+ this.changed();
15502
+ }
15503
+ /**
15504
+ * Keyboard: Enter commits, Escape cancels, arrows nudge by 1 px (Shift: 10). Ctrl/Cmd+T opens a
15505
+ * Free Transform when none is open. Returns true when the key was used (prevent its default).
15506
+ */
15507
+ key(e) {
15508
+ const t = e.target;
15509
+ const typing = Boolean(t && (t.isContentEditable || t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT"));
15510
+ if (!this.active) {
15511
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === "t" && !typing) {
15512
+ this.begin();
15513
+ return true;
15514
+ }
15515
+ return false;
15516
+ }
15517
+ if (e.key === "Enter") {
15518
+ this.commit();
15519
+ return true;
15520
+ }
15521
+ if (e.key === "Escape") {
15522
+ this.cancel();
15523
+ return true;
15524
+ }
15525
+ if (typing) return false;
15526
+ const step = e.shiftKey ? 10 : 1;
15527
+ const nudge = { ArrowLeft: { x: -step, y: 0 }, ArrowRight: { x: step, y: 0 }, ArrowUp: { x: 0, y: -step }, ArrowDown: { x: 0, y: step } };
15528
+ const v = nudge[e.key];
15529
+ if (v && this.corners) {
15530
+ this.setCorners(this.corners.map((q2) => add(q2, v)));
15531
+ return true;
15532
+ }
15533
+ return false;
15534
+ }
15535
+ };
14127
15536
  export {
14128
15537
  ADJUSTMENT_KEYS,
14129
15538
  ADJUSTMENT_KINDS,
@@ -14152,6 +15561,12 @@ export {
14152
15561
  DabWalker,
14153
15562
  EMPTY_DISTANCE,
14154
15563
  EMPTY_MIXER_LOAD,
15564
+ FREE_TRANSFORM_CONTENTS,
15565
+ FREE_TRANSFORM_FIELDS,
15566
+ FREE_TRANSFORM_MODES,
15567
+ FREE_TRANSFORM_MODE_OPTIONS,
15568
+ FreeTransformSession,
15569
+ FreeTransformTool,
14155
15570
  GUIDED_FILTER_EPS,
14156
15571
  HISTOGRAM_BINS,
14157
15572
  HUE_RANGE_LABELS,
@@ -14159,6 +15574,7 @@ export {
14159
15574
  IMAGE_ADJUSTMENT_CONTROLS,
14160
15575
  IMAGE_EDITOR_SCHEMA_VERSION,
14161
15576
  IMAGE_EDITOR_STATE_VERSION,
15577
+ INTERPOLATIONS,
14162
15578
  ImageEditorController,
14163
15579
  ImageEditorRenderer,
14164
15580
  LATEST_PROCESS_VERSION,
@@ -14173,6 +15589,7 @@ export {
14173
15589
  MAX_BRUSH_SIZE,
14174
15590
  MAX_EFFECT_REACH,
14175
15591
  MAX_KELVIN,
15592
+ MAX_TRANSFORM_PIXELS,
14176
15593
  MIN_KELVIN,
14177
15594
  MIRED_PER_TEMPERATURE_UNIT,
14178
15595
  MIXER_DRAIN,
@@ -14208,8 +15625,12 @@ export {
14208
15625
  TINT_SCALE,
14209
15626
  TONE_LUT_SIZE,
14210
15627
  TONE_RANGE_PRESETS,
15628
+ TRANSFORM_ANCHORS,
14211
15629
  TiledSurface,
15630
+ TransformTooLargeError,
14212
15631
  VIBRANCE_GREY,
15632
+ WARP_STYLES,
15633
+ WARP_STYLE_LABELS,
14213
15634
  XYZ_D50_TO_SRGB,
14214
15635
  ZlibError,
14215
15636
  activeAdjustmentLayers,
@@ -14221,16 +15642,19 @@ export {
14221
15642
  adjustmentNodeAsLayer,
14222
15643
  adjustmentNodeFromLegacy,
14223
15644
  adler32,
15645
+ anchorPoint,
14224
15646
  annotationLayerFor,
14225
15647
  annotationLayerName,
14226
15648
  applyAdjustmentLayers,
14227
15649
  applyAdjustments,
14228
15650
  applyBoxBlur,
14229
15651
  applyDepthMask,
15652
+ applyHomography,
14230
15653
  applyImageProjectionToContext,
14231
15654
  applyLegacyAdjustments,
14232
15655
  applyMatrix3,
14233
15656
  applyOutputStage,
15657
+ applyParams,
14234
15658
  applyProcess2Adjustments,
14235
15659
  applyRegionBlur,
14236
15660
  applyRegionMosaic,
@@ -14248,6 +15672,8 @@ export {
14248
15672
  blurFilter,
14249
15673
  borderCoverage,
14250
15674
  boxMean,
15675
+ boxPointOf,
15676
+ boxQuad,
14251
15677
  boxToCurvePoint,
14252
15678
  bradfordAdaptation,
14253
15679
  buildLayerMaskPlanes,
@@ -14267,12 +15693,14 @@ export {
14267
15693
  colorRange,
14268
15694
  combineSelections,
14269
15695
  compileAdjustmentLayer,
15696
+ compositeSurfaceOver,
14270
15697
  computeHistogram,
14271
15698
  computeSelectionBounds,
14272
15699
  contentBounds,
14273
15700
  contractCoverage,
14274
15701
  controlPosition,
14275
15702
  controlTrackBackground,
15703
+ cornersFromMatrix,
14276
15704
  coverageTileCoord,
14277
15705
  coverageToByte,
14278
15706
  coveringRect,
@@ -14285,6 +15713,7 @@ export {
14285
15713
  createImagePaintTools,
14286
15714
  createImagePsdTools,
14287
15715
  createImageSelectionTools,
15716
+ createImageTransformTools,
14288
15717
  createPigment,
14289
15718
  createPigmentTools,
14290
15719
  createSelectionMask,
@@ -14311,6 +15740,7 @@ export {
14311
15740
  describeLayer,
14312
15741
  describeLayerEffects,
14313
15742
  describeSelection,
15743
+ describeTransform,
14314
15744
  deserializeEffects,
14315
15745
  deserializeLayer,
14316
15746
  deserializeLayers,
@@ -14321,6 +15751,7 @@ export {
14321
15751
  developTarget,
14322
15752
  distanceTransform,
14323
15753
  downloadPsd,
15754
+ dragWarpMeshPoint,
14324
15755
  dropPlacement,
14325
15756
  dropZone,
14326
15757
  duplicateLayer,
@@ -14339,6 +15770,7 @@ export {
14339
15770
  encodeRle,
14340
15771
  encodeSelectionRle,
14341
15772
  encodeSurface,
15773
+ evalWarpMesh,
14342
15774
  evaluateAdjustmentLayers,
14343
15775
  expandCoverage,
14344
15776
  exportPsd,
@@ -14349,9 +15781,11 @@ export {
14349
15781
  findLayerControl,
14350
15782
  finishTile,
14351
15783
  flattenLayers,
15784
+ flipCorners,
14352
15785
  formatControlValue,
14353
15786
  fullSelection,
14354
15787
  gaussianBlur,
15788
+ geometryHomography,
14355
15789
  getAnnotationBounds,
14356
15790
  getControlPath,
14357
15791
  getCropCursor,
@@ -14366,6 +15800,7 @@ export {
14366
15800
  handlePaintShortcut,
14367
15801
  hasActiveEffects,
14368
15802
  hasResampledLegacyMask,
15803
+ hasWarp,
14369
15804
  hashMaskContent,
14370
15805
  hashNoise,
14371
15806
  hexToRgb,
@@ -14373,6 +15808,8 @@ export {
14373
15808
  hitTestAnnotation,
14374
15809
  hitTestCrop,
14375
15810
  hitTestCurvePoint,
15811
+ homographyFromAffine,
15812
+ homographyFromPoints,
14376
15813
  hslToRgb,
14377
15814
  hueRange,
14378
15815
  hueRangeTables,
@@ -14380,6 +15817,8 @@ export {
14380
15817
  hueSaturation,
14381
15818
  identityCurve,
14382
15819
  identityLevels,
15820
+ identityTransform,
15821
+ identityWarpMesh,
14383
15822
  imageEditorStateMigrations,
14384
15823
  imageToCanvasPoint,
14385
15824
  importPsd,
@@ -14387,12 +15826,15 @@ export {
14387
15826
  inflateRaw,
14388
15827
  insertCurvePoint,
14389
15828
  insertLayer,
15829
+ invertHomography,
14390
15830
  invertMatrix3,
14391
15831
  invertSelection,
14392
15832
  isActiveFilter,
14393
15833
  isAdjustmentKind,
14394
15834
  isBlendMode,
14395
15835
  isDefaultDepthMask,
15836
+ isIdentityTransform,
15837
+ isInterpolation,
14396
15838
  isLayerEffectKind,
14397
15839
  isLayerLocked,
14398
15840
  isLegacyShape,
@@ -14404,7 +15846,10 @@ export {
14404
15846
  isSelectionMode,
14405
15847
  isSeparableBlendMode,
14406
15848
  isSpatialFilter,
15849
+ isTransformAnchor,
15850
+ isValidWarp,
14407
15851
  kelvinToTemperature,
15852
+ keysWeight,
14408
15853
  layerBadge,
14409
15854
  layerEffectControls,
14410
15855
  layerEffectFields,
@@ -14436,6 +15881,8 @@ export {
14436
15881
  mapCropHandle,
14437
15882
  mapLayer,
14438
15883
  maskSummary,
15884
+ matrixFromCorners,
15885
+ matrixFromParams,
14439
15886
  mergeLayerEffect,
14440
15887
  migrateSerializedImageEditorState,
14441
15888
  migrateToLayeredState,
@@ -14486,6 +15933,8 @@ export {
14486
15933
  polygonCoverage,
14487
15934
  polygonSelection,
14488
15935
  posterizeValue,
15936
+ presetWarp,
15937
+ previewStep,
14489
15938
  projectImagePoint,
14490
15939
  projectImageRect,
14491
15940
  projectMaskCoverage,
@@ -14494,6 +15943,7 @@ export {
14494
15943
  psdFileName,
14495
15944
  psdPatternToEffectPattern,
14496
15945
  psdToLayers,
15946
+ pullWarpMesh,
14497
15947
  quickMaskPixels,
14498
15948
  rangeLightness,
14499
15949
  rasterMaskFromLegacy,
@@ -14503,6 +15953,9 @@ export {
14503
15953
  readPsd,
14504
15954
  readRegion,
14505
15955
  rectSelection,
15956
+ rectToQuad,
15957
+ reduceSurface,
15958
+ referencePoint,
14506
15959
  refineCoverage,
14507
15960
  refineEdgeHalo,
14508
15961
  refineSelection,
@@ -14518,6 +15971,7 @@ export {
14518
15971
  requireUnlocked,
14519
15972
  resizePixels,
14520
15973
  resizeSelection,
15974
+ resizeWarpMesh,
14521
15975
  resolveExternalAssets,
14522
15976
  resolveMosaicRegionBounds,
14523
15977
  rgbToGray,
@@ -14573,6 +16027,12 @@ export {
14573
16027
  toneCurve,
14574
16028
  toneLuts,
14575
16029
  toneRange,
16030
+ transformHomography,
16031
+ transformMap,
16032
+ transformSurface,
16033
+ transformedBounds,
16034
+ transformedBox,
16035
+ transformedPoint,
14576
16036
  translateSurface,
14577
16037
  ungroupLayer,
14578
16038
  uniformTile,
@@ -14589,6 +16049,11 @@ export {
14589
16049
  vibranceLinear,
14590
16050
  vibrancePixel,
14591
16051
  walkDabs,
16052
+ warpMeshEditPoints,
16053
+ warpMeshGridCurves,
16054
+ warpMeshParamAt,
16055
+ warpPresetMesh,
16056
+ warpSurface,
14592
16057
  wbMatrix,
14593
16058
  whiteBalanceFromSample,
14594
16059
  whiteBalanceMatrix,