@pixerate/editor 0.242.0 → 0.243.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 (45) hide show
  1. package/dist/animation-editor/index.cjs +59 -59
  2. package/dist/animation-editor/index.js +3 -3
  3. package/dist/{brushstrokeRender-MOYGEXOC.js → brushstrokeRender-274HGNZR.js} +3 -3
  4. package/dist/{brushstrokeRender-UKADOV5D.cjs → brushstrokeRender-C7RJZPMC.cjs} +5 -5
  5. package/dist/canvas/index.cjs +8 -8
  6. package/dist/canvas/index.js +1 -1
  7. package/dist/{chunk-KCMV2534.cjs → chunk-3WOZEBXZ.cjs} +2 -2
  8. package/dist/{chunk-ZDPLZDTT.js → chunk-4ZHXLFXS.js} +1 -1
  9. package/dist/{chunk-5BR7LC24.cjs → chunk-5FZS7J7C.cjs} +50 -4
  10. package/dist/{chunk-ILM6GYAX.js → chunk-6L3WWIDW.js} +1 -1
  11. package/dist/{chunk-UDBDYZET.js → chunk-H4Z72Z4Z.js} +1 -1
  12. package/dist/{chunk-XBZK47LV.js → chunk-HQZHOPNE.js} +49 -3
  13. package/dist/{chunk-QP2B3MID.js → chunk-IXI5KATU.js} +1 -1
  14. package/dist/{chunk-HGGJ36HR.cjs → chunk-KK2FWMA2.cjs} +1 -1
  15. package/dist/{chunk-AFLMXITL.cjs → chunk-OONFIFEU.cjs} +23 -23
  16. package/dist/{chunk-HW72ABGR.cjs → chunk-R2FVAKFH.cjs} +86 -86
  17. package/dist/{chunk-NE6OOKWJ.js → chunk-SRXWDOUI.js} +1 -1
  18. package/dist/{chunk-7F4G74UQ.cjs → chunk-UV7OOZJQ.cjs} +35 -35
  19. package/dist/image-editor/index.cjs +775 -775
  20. package/dist/image-editor/index.js +4 -4
  21. package/dist/paint-editor/brushes.d.cts +18 -2
  22. package/dist/paint-editor/brushes.d.ts +18 -2
  23. package/dist/paint-editor/controller.d.cts +28 -0
  24. package/dist/paint-editor/controller.d.ts +28 -0
  25. package/dist/paint-editor/document.d.cts +3 -0
  26. package/dist/paint-editor/document.d.ts +3 -0
  27. package/dist/paint-editor/index.cjs +1011 -493
  28. package/dist/paint-editor/index.js +560 -42
  29. package/dist/paint-editor/light.d.cts +74 -0
  30. package/dist/paint-editor/light.d.ts +74 -0
  31. package/dist/paint-editor/stroke.d.cts +14 -0
  32. package/dist/paint-editor/stroke.d.ts +14 -0
  33. package/dist/paint-editor/thick-tools.d.cts +2 -2
  34. package/dist/paint-editor/thick-tools.d.ts +2 -2
  35. package/dist/paint-editor/thick.d.cts +5 -1
  36. package/dist/paint-editor/thick.d.ts +5 -1
  37. package/dist/paint-editor/ui.d.cts +2 -0
  38. package/dist/paint-editor/ui.d.ts +2 -0
  39. package/dist/vector/index.cjs +962 -962
  40. package/dist/vector/index.js +4 -4
  41. package/package.json +1 -1
  42. package/skills/pixerate-paint-editor/SKILL.md +9 -9
  43. package/skills/pixerate-paint-editor/references/canvas-hosted.md +8 -0
  44. package/skills/pixerate-paint-editor/references/light-paint.md +31 -0
  45. package/skills/pixerate-paint-oil-impasto/SKILL.md +1 -0
@@ -7,7 +7,7 @@ import {
7
7
  resolveExternalAssets,
8
8
  serializeLayers,
9
9
  serializeLayersExternal
10
- } from "../chunk-NE6OOKWJ.js";
10
+ } from "../chunk-SRXWDOUI.js";
11
11
  import "../chunk-3CI7FRMK.js";
12
12
  import "../chunk-ZNA3IQZF.js";
13
13
  import {
@@ -55,7 +55,7 @@ import {
55
55
  realPresenceClock,
56
56
  tweenPresenceCursor
57
57
  } from "../chunk-IVSWRVB4.js";
58
- import "../chunk-ILM6GYAX.js";
58
+ import "../chunk-6L3WWIDW.js";
59
59
  import {
60
60
  BRUSH_ID,
61
61
  BRUSH_ROTATIONS,
@@ -66,6 +66,7 @@ import {
66
66
  DEFAULT_BRUSH_ID,
67
67
  DEFAULT_DYNAMICS,
68
68
  DEFAULT_IMPASTO,
69
+ DEFAULT_LIGHT,
69
70
  DEFAULT_MULTI_COLOR,
70
71
  DEFAULT_PAPER,
71
72
  DEFAULT_PARTICLE,
@@ -107,6 +108,7 @@ import {
107
108
  normalizeBristle,
108
109
  normalizeDynamics,
109
110
  normalizeImpasto,
111
+ normalizeLight,
110
112
  normalizeMultiColor,
111
113
  normalizePaintBrush,
112
114
  normalizePaper,
@@ -131,7 +133,7 @@ import {
131
133
  tiltToOrientation,
132
134
  toStrokePoint,
133
135
  usesPaintExchange
134
- } from "../chunk-XBZK47LV.js";
136
+ } from "../chunk-HQZHOPNE.js";
135
137
  import "../chunk-M6DNZNVY.js";
136
138
  import "../chunk-WEIK3UYB.js";
137
139
  import "../chunk-PWBZH3FK.js";
@@ -256,7 +258,10 @@ import {
256
258
  } from "../chunk-BP6KSGVD.js";
257
259
  import "../chunk-6ADHGQ73.js";
258
260
  import "../chunk-JEGJP4OW.js";
259
- import "../chunk-F67XMVDI.js";
261
+ import {
262
+ linearToSrgb,
263
+ srgbToLinear
264
+ } from "../chunk-F67XMVDI.js";
260
265
  import "../chunk-6ONMQ6WF.js";
261
266
 
262
267
  // src/paint-editor/document.ts
@@ -838,7 +843,7 @@ var ThickStrokeDriver = class {
838
843
  if (im.tool === "knife") {
839
844
  st.smudge.dab(this.medium, { x: d.x, y: d.y, radius: d.radius, opacity: k }, rect, tip);
840
845
  this.knifeHeight(st, d, rect, tip, k, im.drag);
841
- this.paintMaterial(rect, tip, k * 0.6, im.gloss, im.metallic);
846
+ this.paintMaterial(rect, tip, k * 0.6, im.gloss, im.metallic, im.rim, im.clarity);
842
847
  return;
843
848
  }
844
849
  const ex = st.exchange;
@@ -878,34 +883,34 @@ var ThickStrokeDriver = class {
878
883
  const u = ((dx + 0.5 - d.x) * nxs + (dy + 0.5 - d.y) * nys) / r;
879
884
  const h0 = this.getH(lx, ly);
880
885
  let h = h0;
881
- const full = 255 * thick * k;
886
+ const full2 = 255 * thick * k;
882
887
  switch (im.tool) {
883
888
  case "brush": {
884
889
  const ridge = 0.6 + 0.4 * Math.sin(u * 9 + hash2(Math.round(u * 6), 3, seed) * 6.28) + 0.15 * (hash2(lx, ly, seed) - 0.5);
885
- const target = full * c * ridge;
890
+ const target = full2 * c * ridge;
886
891
  h = Math.max(h0 * (1 - 0.25 * c * im.drag), Math.min(255, h0 * 0.35 + target));
887
892
  break;
888
893
  }
889
894
  case "tube": {
890
895
  const dome = Math.sqrt(Math.max(0, c));
891
- h = Math.max(h0, Math.min(255, full * dome * 1.1));
896
+ h = Math.max(h0, Math.min(255, full2 * dome * 1.1));
892
897
  break;
893
898
  }
894
899
  case "gloop": {
895
900
  const dome = Math.sqrt(Math.max(0, 1 - Math.min(1, Math.abs(u)) ** 2));
896
- h = Math.max(h0, Math.min(255, full * dome * (c > 0.2 ? 1 : c * 5)));
901
+ h = Math.max(h0, Math.min(255, full2 * dome * (c > 0.2 ? 1 : c * 5)));
897
902
  break;
898
903
  }
899
904
  case "roller": {
900
905
  const stipple = 0.75 + 0.5 * hash2(lx >> 1, ly >> 1, seed);
901
- h = h0 + (Math.min(255, full * 0.6 * stipple) - h0) * Math.min(1, c * 1.5);
906
+ h = h0 + (Math.min(255, full2 * 0.6 * stipple) - h0) * Math.min(1, c * 1.5);
902
907
  break;
903
908
  }
904
909
  }
905
910
  this.setH(lx, ly, Math.round(Math.max(0, Math.min(255, h))));
906
911
  }
907
912
  }
908
- this.paintMaterial(rect, tip, k, im.gloss * (1 - 0.3 * im.thinner), im.metallic);
913
+ this.paintMaterial(rect, tip, k, im.gloss * (1 - 0.3 * im.thinner), im.metallic, im.rim, im.clarity);
909
914
  }
910
915
  /** The knife: flattens the relief under it and carries some of it along (a patch, like a smudge). */
911
916
  knifeHeight(st, d, rect, tip, k, drag) {
@@ -934,7 +939,12 @@ var ThickStrokeDriver = class {
934
939
  for (let p = 0; p < n; p++) pick[p] = Math.max(0, cur[p] - next[p]) + (carried ? carried[p] * (1 - drag) * 0.5 : 0);
935
940
  st.carried = pick;
936
941
  }
937
- paintMaterial(rect, tip, k, gloss, metal) {
942
+ /**
943
+ * Material: R gloss, G metallic, B rim (light paint, 0..255), A opacity for light paint's clarity
944
+ * (255 solid .. 1 clearest; 0 is no material, read as solid).
945
+ */
946
+ paintMaterial(rect, tip, k, gloss, metal, rim = 0, clarity = 0) {
947
+ const opacity = 255 - clarity * 254;
938
948
  const off = this.init.target.offset;
939
949
  for (let yy = 0, p = 0; yy < rect.height; yy++) {
940
950
  for (let xx = 0; xx < rect.width; xx++, p++) {
@@ -946,7 +956,9 @@ var ThickStrokeDriver = class {
946
956
  const i = ((ly & 255) * TILE + (lx & 255)) * 4;
947
957
  t[i] = Math.round(t[i] + (gloss * 255 - t[i]) * c);
948
958
  t[i + 1] = Math.round(t[i + 1] + (metal * 255 - t[i + 1]) * c);
949
- t[i + 3] = 255;
959
+ t[i + 2] = Math.round(t[i + 2] + (rim * 255 - t[i + 2]) * c);
960
+ const a = t[i + 3] || 255;
961
+ t[i + 3] = Math.max(1, Math.round(a + (opacity - a) * c));
950
962
  }
951
963
  }
952
964
  }
@@ -981,6 +993,261 @@ function thickFromJSON(raw) {
981
993
  };
982
994
  }
983
995
 
996
+ // src/paint-editor/light.ts
997
+ var GLOW = 1.5;
998
+ var DEFAULT_LIGHT_SPILL = { radius: 24, strength: 1 };
999
+ function normalizeLightSpill(raw, base = DEFAULT_LIGHT_SPILL) {
1000
+ const radius = raw.radius ?? base.radius;
1001
+ const strength = raw.strength ?? base.strength;
1002
+ if (!(Number.isFinite(radius) && radius >= 0 && radius <= 512)) throw new Error(`radius ${radius} is out of range; use 0..512 px (0 turns spill off).`);
1003
+ if (!(Number.isFinite(strength) && strength >= 0 && strength <= 2)) throw new Error(`strength ${strength} is out of range; use 0..2.`);
1004
+ return { radius, strength };
1005
+ }
1006
+ function emptyLight() {
1007
+ return new TiledSurface(1, /* @__PURE__ */ new Map(), 0);
1008
+ }
1009
+ var full = (t, channels) => {
1010
+ if (t.data.length === TILE * TILE * channels) return t.data;
1011
+ const out = new Uint8ClampedArray(TILE * TILE * channels);
1012
+ const u = t.uniform ?? 0;
1013
+ if (channels === 1) out.fill(u & 255);
1014
+ else {
1015
+ const bytes = [u >>> 24 & 255, u >>> 16 & 255, u >>> 8 & 255, u & 255];
1016
+ for (let i = 0; i < out.length; i += 4) out.set(bytes, i);
1017
+ }
1018
+ return out;
1019
+ };
1020
+ function lightToShadow(light) {
1021
+ const tiles = /* @__PURE__ */ new Map();
1022
+ for (const [key, tile] of light.tiles) {
1023
+ const l = full(tile, 1);
1024
+ const d = new Uint8ClampedArray(TILE * TILE * 4);
1025
+ for (let i = 0, j = 0; i < l.length; i++, j += 4) {
1026
+ if (!l[i]) continue;
1027
+ d[j] = d[j + 1] = d[j + 2] = 255;
1028
+ d[j + 3] = l[i];
1029
+ }
1030
+ const t = finishTile(d, 4, 0);
1031
+ if (t) tiles.set(key, t);
1032
+ }
1033
+ return new TiledSurface(4, tiles, 0);
1034
+ }
1035
+ function shadowToLight(shadow) {
1036
+ const tiles = /* @__PURE__ */ new Map();
1037
+ for (const [key, tile] of shadow.tiles) {
1038
+ const s = full(tile, 4);
1039
+ const d = new Uint8ClampedArray(TILE * TILE);
1040
+ for (let i = 0, j = 0; i < d.length; i++, j += 4) if (s[j + 3]) d[i] = Math.round((s[j] + s[j + 1] + s[j + 2]) / 3 * s[j + 3] / 255);
1041
+ const t = finishTile(d, 1, 0);
1042
+ if (t) tiles.set(key, t);
1043
+ }
1044
+ return new TiledSurface(1, tiles, 0);
1045
+ }
1046
+ function lightTwinBrush(brush) {
1047
+ return normalizePaintBrush(
1048
+ {
1049
+ tip: { ...brush.tip, blend: "normal", pressureOpacity: brush.tip.pressureOpacity || brush.light.pressure },
1050
+ behavior: { ...brush.behavior, mixing: "rgb" },
1051
+ paint: null,
1052
+ ...brush.engine === "mixer" ? { mixer: { ...brush.mixer, mixing: "rgb" } } : {},
1053
+ ...brush.engine === "blender" ? { blender: { ...brush.blender, mixing: "rgb" } } : {},
1054
+ ...brush.engine === "smudge" ? { smudge: { ...brush.smudge, mixing: "rgb" } } : {}
1055
+ },
1056
+ brush
1057
+ );
1058
+ }
1059
+ function lightInfo(light) {
1060
+ if (!light) return { bounds: null, peak: 0 };
1061
+ let peak = 0;
1062
+ for (const t of light.tiles.values()) {
1063
+ if (t.uniform !== null) peak = Math.max(peak, t.uniform & 255);
1064
+ else for (let i = 0; i < t.data.length; i++) if (t.data[i] > peak) peak = t.data[i];
1065
+ }
1066
+ return { bounds: peak ? light.bounds : null, peak };
1067
+ }
1068
+ function scaleLight(light, k) {
1069
+ if (k >= 1) return light;
1070
+ const tiles = /* @__PURE__ */ new Map();
1071
+ if (k > 0) {
1072
+ for (const [key, tile] of light.tiles) {
1073
+ const d = Uint8ClampedArray.from(full(tile, 1), (v) => Math.round(v * k));
1074
+ const t = finishTile(d, 1, 0);
1075
+ if (t) tiles.set(key, t);
1076
+ }
1077
+ }
1078
+ return new TiledSurface(1, tiles, 0);
1079
+ }
1080
+ function mergeLight(lower, upper, upperColor) {
1081
+ if (!upper?.tiles.size) return lower;
1082
+ const keys = /* @__PURE__ */ new Set([...lower?.tiles.keys() ?? [], ...upper.tiles.keys()]);
1083
+ const tiles = /* @__PURE__ */ new Map();
1084
+ for (const key of keys) {
1085
+ const lo = lower?.tiles.get(key);
1086
+ const up = upper.tiles.get(key);
1087
+ const col = upperColor.tiles.get(key);
1088
+ const l = lo ? full(lo, 1) : null;
1089
+ const u = up ? full(up, 1) : null;
1090
+ const c = col ? full(col, 4) : null;
1091
+ const d = new Uint8ClampedArray(TILE * TILE);
1092
+ for (let i = 0; i < d.length; i++) {
1093
+ const a = c ? c[i * 4 + 3] / 255 : 0;
1094
+ d[i] = Math.round((u ? u[i] : 0) + (l ? l[i] : 0) * (1 - a));
1095
+ }
1096
+ const t = finishTile(d, 1, 0);
1097
+ if (t) tiles.set(key, t);
1098
+ }
1099
+ return new TiledSurface(1, tiles, 0);
1100
+ }
1101
+ function facing(h, x, y) {
1102
+ const at = (xx, yy) => h[Math.min(TILE - 1, Math.max(0, yy)) * TILE + Math.min(TILE - 1, Math.max(0, xx))];
1103
+ const k = 7 / 255;
1104
+ const dx = (at(x + 1, y) - at(x - 1, y)) / 2 * k;
1105
+ const dy = (at(x, y + 1) - at(x, y - 1)) / 2 * k;
1106
+ return 1 / Math.hypot(dx, dy, 1);
1107
+ }
1108
+ var LightCache = class {
1109
+ tiles = /* @__PURE__ */ new Map();
1110
+ glow(color, light, thick) {
1111
+ const clear = (m) => !!m && (m.uniform === null ? m.data.some((v, i) => i % 4 === 3 && v > 0 && v < 255) : (m.uniform & 255) > 0 && (m.uniform & 255) < 255);
1112
+ if (!light?.tiles.size && !(thick && [...thick.material.tiles.values()].some(clear))) return color;
1113
+ const out = new Map(color.tiles);
1114
+ const seen = /* @__PURE__ */ new Set();
1115
+ for (const [key, tile] of color.tiles) {
1116
+ const l = light?.tiles.get(key);
1117
+ const m = thick?.material.tiles.get(key);
1118
+ const h = thick?.height.tiles.get(key);
1119
+ if (!l && !clear(m)) continue;
1120
+ seen.add(key);
1121
+ const inputs = [tile, l ?? null, m ?? null, h ?? null];
1122
+ const cached = this.tiles.get(key);
1123
+ if (cached && cached.inputs.every((v, i) => v === inputs[i])) {
1124
+ out.set(key, cached.tile);
1125
+ continue;
1126
+ }
1127
+ const c = full(tile, 4);
1128
+ const ld = l ? full(l, 1) : null;
1129
+ const md = m ? full(m, 4) : null;
1130
+ const hd = h ? full(h, 1) : null;
1131
+ const d = new Uint8ClampedArray(c);
1132
+ for (let y = 0, i = 0; y < TILE; y++) {
1133
+ for (let x = 0; x < TILE; x++, i++) {
1134
+ const j = i * 4;
1135
+ if (!d[j + 3]) continue;
1136
+ const rim = md ? md[j + 2] / 255 : 0;
1137
+ const opacity = md && md[j + 3] ? md[j + 3] : 255;
1138
+ const f = (rim > 0 || opacity < 255) && hd ? facing(hd, x, y) : 1;
1139
+ const g = ld && ld[i] ? ld[i] / 255 * (1 - rim + rim * 2 * (1 - f) ** 3) : 0;
1140
+ if (g > 0) {
1141
+ for (let ch = 0; ch < 3; ch++) d[j + ch] = Math.round(linearToSrgb(Math.min(1, srgbToLinear(d[j + ch] / 255) * (1 + GLOW * g))) * 255);
1142
+ }
1143
+ if (opacity < 255) d[j + 3] = Math.round(d[j + 3] * (1 - (255 - opacity) / 254 * f));
1144
+ }
1145
+ }
1146
+ const lit = { data: d, uniform: null };
1147
+ this.tiles.set(key, { inputs, tile: lit });
1148
+ out.set(key, lit);
1149
+ }
1150
+ for (const key of [...this.tiles.keys()]) if (!seen.has(key)) this.tiles.delete(key);
1151
+ return new TiledSurface(4, out, color.defaultValue);
1152
+ }
1153
+ spillCache = null;
1154
+ /**
1155
+ * The layer's spill (layer space, RGBA for Linear Dodge (Add)): `light × colour` blurred by
1156
+ * `radius` px at a reduced resolution, times `strength`; `null` without light or spill.
1157
+ */
1158
+ spill(color, light, spill) {
1159
+ const inputs = [color, light, spill.radius, spill.strength];
1160
+ if (this.spillCache && this.spillCache.inputs.every((v, i) => v === inputs[i])) return this.spillCache.surface;
1161
+ const surface = computeSpill(color, light, spill);
1162
+ this.spillCache = { inputs, surface };
1163
+ return surface;
1164
+ }
1165
+ };
1166
+ function computeSpill(color, light, spill) {
1167
+ const b = light ? lightInfo(light).bounds : null;
1168
+ if (!light || !b || spill.radius <= 0 || spill.strength <= 0) return null;
1169
+ const r = Math.ceil(spill.radius);
1170
+ const region = { x: b.x - r, y: b.y - r, width: b.width + 2 * r, height: b.height + 2 * r };
1171
+ const col = readRegion(color, region);
1172
+ const lit = readRegion(light, region);
1173
+ const f = Math.max(1, Math.floor(spill.radius / 6));
1174
+ const w = Math.ceil(region.width / f);
1175
+ const h = Math.ceil(region.height / f);
1176
+ const e = [new Float32Array(w * h), new Float32Array(w * h), new Float32Array(w * h)];
1177
+ for (let y = 0; y < region.height; y++) {
1178
+ for (let x = 0; x < region.width; x++) {
1179
+ const i = y * region.width + x;
1180
+ const l = lit[i];
1181
+ if (!l) continue;
1182
+ const j = i * 4;
1183
+ const k = l / 255 * (col[j + 3] / 255) / (f * f);
1184
+ const cell = Math.floor(y / f) * w + Math.floor(x / f);
1185
+ for (let ch = 0; ch < 3; ch++) e[ch][cell] += srgbToLinear(col[j + ch] / 255) * k;
1186
+ }
1187
+ }
1188
+ const box = Math.max(1, Math.round(spill.radius / f / 2.5));
1189
+ for (const plane of e) for (let pass = 0; pass < 3; pass++) boxBlur(plane, w, h, box);
1190
+ const out = new Uint8ClampedArray(region.width * region.height * 4);
1191
+ for (let y = 0; y < region.height; y++) {
1192
+ for (let x = 0; x < region.width; x++) {
1193
+ const fx = Math.min(w - 1, Math.max(0, (x + 0.5) / f - 0.5));
1194
+ const fy = Math.min(h - 1, Math.max(0, (y + 0.5) / f - 0.5));
1195
+ const x0 = Math.floor(fx);
1196
+ const y0 = Math.floor(fy);
1197
+ const x1 = Math.min(w - 1, x0 + 1);
1198
+ const y1 = Math.min(h - 1, y0 + 1);
1199
+ const tx = fx - x0;
1200
+ const ty = fy - y0;
1201
+ const rgb = e.map((p) => (p[y0 * w + x0] * (1 - tx) + p[y0 * w + x1] * tx) * (1 - ty) + (p[y1 * w + x0] * (1 - tx) + p[y1 * w + x1] * tx) * ty);
1202
+ const add = rgb.map((v) => Math.min(1, linearToSrgb(Math.min(1, v * spill.strength))));
1203
+ const a = Math.max(add[0], add[1], add[2]);
1204
+ if (a <= 1 / 255) continue;
1205
+ const o = (y * region.width + x) * 4;
1206
+ out[o] = Math.round(add[0] / a * 255);
1207
+ out[o + 1] = Math.round(add[1] / a * 255);
1208
+ out[o + 2] = Math.round(add[2] / a * 255);
1209
+ out[o + 3] = Math.round(a * 255);
1210
+ }
1211
+ }
1212
+ return surfaceFromPixels({ width: region.width, height: region.height, data: out }, 4, region.x, region.y);
1213
+ }
1214
+ function boxBlur(p, w, h, r) {
1215
+ const tmp = new Float32Array(Math.max(w, h));
1216
+ const n = 2 * r + 1;
1217
+ for (let y = 0; y < h; y++) {
1218
+ let sum = 0;
1219
+ for (let k = -r; k <= r; k++) sum += p[y * w + Math.min(w - 1, Math.max(0, k))];
1220
+ for (let x = 0; x < w; x++) {
1221
+ tmp[x] = sum / n;
1222
+ sum += p[y * w + Math.min(w - 1, x + r + 1)] - p[y * w + Math.max(0, x - r)];
1223
+ }
1224
+ for (let x = 0; x < w; x++) p[y * w + x] = tmp[x];
1225
+ }
1226
+ for (let x = 0; x < w; x++) {
1227
+ let sum = 0;
1228
+ for (let k = -r; k <= r; k++) sum += p[Math.min(h - 1, Math.max(0, k)) * w + x];
1229
+ for (let y = 0; y < h; y++) {
1230
+ tmp[y] = sum / n;
1231
+ sum += p[Math.min(h - 1, y + r + 1) * w + x] - p[Math.max(0, y - r) * w + x];
1232
+ }
1233
+ for (let y = 0; y < h; y++) p[y * w + x] = tmp[y];
1234
+ }
1235
+ }
1236
+ function lightToJSON(light) {
1237
+ const b = lightInfo(light).bounds;
1238
+ if (!b) return null;
1239
+ return { rect: { ...b }, light: pngDataUrl(b.width, b.height, readRegion(light, b), 1) };
1240
+ }
1241
+ function lightFromJSON(raw) {
1242
+ const r = raw;
1243
+ if (!r || typeof r !== "object" || !r.rect || typeof r.light !== "string") throw new Error("not a saved light channel");
1244
+ const parsed = parseDataUrl(r.light);
1245
+ if (!parsed) throw new Error("the light is not a PNG data URL");
1246
+ const img = decodePng(parsed.bytes, { keepGray: true });
1247
+ const data = img.channels === 1 ? img.data : Uint8ClampedArray.from({ length: img.width * img.height }, (_, i) => img.data[i * 4]);
1248
+ return surfaceFromPixels({ width: img.width, height: img.height, data }, 1, r.rect.x, r.rect.y);
1249
+ }
1250
+
984
1251
  // src/paint-editor/stroke.ts
985
1252
  function defaultTile(surface) {
986
1253
  const data = new Uint8ClampedArray(TILE * TILE * surface.channels);
@@ -1000,7 +1267,7 @@ function union(a, b) {
1000
1267
  const y = Math.min(a.y, b.y);
1001
1268
  return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y };
1002
1269
  }
1003
- var PaintStrokeSession = class {
1270
+ var PaintStrokeSession = class _PaintStrokeSession {
1004
1271
  constructor(host, init) {
1005
1272
  this.host = host;
1006
1273
  this.init = init;
@@ -1010,6 +1277,7 @@ var PaintStrokeSession = class {
1010
1277
  this.copies = init.copies.length ? init.copies : [IDENTITY_COPY];
1011
1278
  const brush = init.brush;
1012
1279
  if (init.wet) this.wetDriver = init.wet.driver;
1280
+ if (init.light && !init.wet && !init.sticker) this.lightTwin = this.makeLightTwin(init.light.surface);
1013
1281
  const effect = init.effect?.pixels ?? null;
1014
1282
  this.apply = {
1015
1283
  mode: brush.engine === "eraser" ? "erase" : "paint",
@@ -1060,6 +1328,9 @@ var PaintStrokeSession = class {
1060
1328
  wetDriver = null;
1061
1329
  thickDriver = null;
1062
1330
  stickerSpray = null;
1331
+ /** Light paint: the stroke's twin over the light channel, and what it left there. */
1332
+ lightTwin = null;
1333
+ lightResult = null;
1063
1334
  /** A wet stroke that ended while the simulation was mid-batch: it commits when the driver catches up. */
1064
1335
  wetEndPending = false;
1065
1336
  apply;
@@ -1070,6 +1341,41 @@ var PaintStrokeSession = class {
1070
1341
  result = null;
1071
1342
  /** The mask stencils this stroke was painted with when they are not the editor's (replays). */
1072
1343
  maskStencils = null;
1344
+ /** The stroke's twin over the light channel: the same marks in grey at the brush's light amount (see `light.ts`). */
1345
+ makeLightTwin(light) {
1346
+ const init = this.init;
1347
+ const grey = Math.round(init.brush.light.amount * 255);
1348
+ const g = grey / 255;
1349
+ const hex = "#" + grey.toString(16).padStart(2, "0").repeat(3);
1350
+ const host = {
1351
+ update: () => {
1352
+ },
1353
+ commit: (_s, surface) => {
1354
+ this.lightResult = surface ? shadowToLight(surface) : null;
1355
+ }
1356
+ };
1357
+ const effect = init.effect ? { source: init.effect.source, pixels: { ...init.effect.pixels, data: greyPixels(init.effect.pixels.data, grey) } } : null;
1358
+ const paint = init.paint ? { ...init.paint, stops: init.paint.stops.map((st) => ({ ...st, color: hex })) } : null;
1359
+ return new _PaintStrokeSession(host, {
1360
+ ...init,
1361
+ surface: lightToShadow(light),
1362
+ lockTransparency: false,
1363
+ brush: lightTwinBrush(init.brush),
1364
+ color: [grey, grey, grey, 255],
1365
+ colorHex: hex,
1366
+ secondary: [grey, grey, grey, 255],
1367
+ inputFilter: void 0,
1368
+ mixerLoad: { reservoir: init.mixerLoad.reservoir ? [g, g, g] : null, amount: init.mixerLoad.amount, pickup: null },
1369
+ thick: init.thick,
1370
+ paint,
1371
+ effect,
1372
+ light: null
1373
+ });
1374
+ }
1375
+ /** Light paint: the layer's light channel after the stroke, or `null` when the stroke has no twin or left it unchanged. */
1376
+ lightChannel() {
1377
+ return this.lightResult;
1378
+ }
1073
1379
  tileTarget() {
1074
1380
  return {
1075
1381
  offset: this.init.offset,
@@ -1098,6 +1404,7 @@ var PaintStrokeSession = class {
1098
1404
  if (filter) points = points.map(filter);
1099
1405
  for (const p of points) this.points.push(p);
1100
1406
  this.lastRaw = points[points.length - 1];
1407
+ this.lightTwin?.push(points);
1101
1408
  if (this.wetDriver) {
1102
1409
  this.wetUpdate(this.wetDriver.push(points));
1103
1410
  return;
@@ -1179,6 +1486,7 @@ var PaintStrokeSession = class {
1179
1486
  }
1180
1487
  this.update(this.dry ? this.dry.finish() : this.thickDriver ? this.thickDriver.finish() : this.stickerSpray ? this.stickerSpray.finish() : this.wet.finish());
1181
1488
  this.closed = true;
1489
+ if (this.lightTwin?.open) this.lightTwin.end();
1182
1490
  const ch = this.base.channels;
1183
1491
  const tiles = new Map(this.base.tiles);
1184
1492
  let changed = false;
@@ -1202,9 +1510,19 @@ var PaintStrokeSession = class {
1202
1510
  cancel() {
1203
1511
  if (this.closed) return;
1204
1512
  this.closed = true;
1513
+ if (this.lightTwin?.open) this.lightTwin.cancel();
1514
+ this.lightResult = null;
1205
1515
  this.host.commit(this, null, true);
1206
1516
  }
1207
1517
  };
1518
+ function greyPixels(data, grey) {
1519
+ const out = new Uint8ClampedArray(data.length);
1520
+ for (let i = 0; i < data.length; i += 4) {
1521
+ out[i] = out[i + 1] = out[i + 2] = grey;
1522
+ out[i + 3] = data[i + 3];
1523
+ }
1524
+ return out;
1525
+ }
1208
1526
  function mixerLoadForStroke(brush, current, color) {
1209
1527
  if (!brush.loadsColor) return { reservoir: null, amount: 0, pickup: brush.mixer.cleanEachStroke ? null : current.pickup };
1210
1528
  if (brush.mixer.loadEachStroke || !current.reservoir) return loadMixer(current, color);
@@ -1308,8 +1626,8 @@ function nearestOnStencil(s, p) {
1308
1626
  if (s.kind === "arc") {
1309
1627
  const c = s.center;
1310
1628
  const ang = Math.atan2(p.y - c.y, p.x - c.x);
1311
- const full = Math.abs(s.endAngle - s.startAngle) >= 360;
1312
- if (full) {
1629
+ const full2 = Math.abs(s.endAngle - s.startAngle) >= 360;
1630
+ if (full2) {
1313
1631
  const q = { x: c.x + Math.cos(ang) * s.radius, y: c.y + Math.sin(ang) * s.radius };
1314
1632
  return { point: q, distance: Math.hypot(p.x - q.x, p.y - q.y) };
1315
1633
  }
@@ -2605,6 +2923,7 @@ function paintTestStroke(surface, brush, paper, color, width, height) {
2605
2923
  const [r, g, b] = hexToRgb8(color);
2606
2924
  let result = surface;
2607
2925
  let thick = null;
2926
+ let light = null;
2608
2927
  const session = new PaintStrokeSession(
2609
2928
  {
2610
2929
  update: () => {
@@ -2612,6 +2931,7 @@ function paintTestStroke(surface, brush, paper, color, width, height) {
2612
2931
  commit: (sess, s) => {
2613
2932
  result = s ?? result;
2614
2933
  thick = sess.thickChannels();
2934
+ light = sess.lightChannel();
2615
2935
  }
2616
2936
  },
2617
2937
  {
@@ -2630,12 +2950,14 @@ function paintTestStroke(surface, brush, paper, color, width, height) {
2630
2950
  copies: [IDENTITY_COPY],
2631
2951
  mixerLoad: brushLoadForStroke(brush, EMPTY_MIXER_LOAD, [r / 255, g / 255, b / 255]),
2632
2952
  thick: brush.engine === "impasto" ? { channels: emptyThickChannels() } : void 0,
2633
- sticker: brush.engine === "sticker" ? { nextId: 1 } : void 0
2953
+ sticker: brush.engine === "sticker" ? { nextId: 1 } : void 0,
2954
+ light: brush.light.amount > 0 && brush.engine !== "wet" && brush.engine !== "sticker" ? { surface: emptyLight() } : null
2634
2955
  }
2635
2956
  );
2636
2957
  session.push(thumbnailStroke(width, height));
2637
2958
  session.end();
2638
- return thick ? new LitCache().lit(result, thick, DEFAULT_LIGHTING) : result;
2959
+ const lit = thick ? new LitCache().lit(result, thick, DEFAULT_LIGHTING) : result;
2960
+ return { surface: light || thick ? new LightCache().glow(lit, light ?? void 0, thick ?? void 0) : lit, light };
2639
2961
  }
2640
2962
  function renderBrushThumbnail(brush, options) {
2641
2963
  const width = Math.max(16, Math.min(512, Math.round(options.width ?? 160)));
@@ -2652,9 +2974,15 @@ function renderBrushThumbnail(brush, options) {
2652
2974
  surface = fillRect(surface, { x: 0, y: 0, width, height: half }, rgba);
2653
2975
  surface = fillRect(surface, { x: 0, y: half, width, height: height - half }, [232, 196, 64, 255]);
2654
2976
  }
2655
- surface = b.engine === "wet" ? paintWetTestStroke(b, options.paper, options.color, width, height) : paintTestStroke(surface, b, options.paper, options.color, width, height);
2977
+ let light = null;
2978
+ if (b.engine === "wet") surface = paintWetTestStroke(b, options.paper, options.color, width, height);
2979
+ else ({ surface, light } = paintTestStroke(surface, b, options.paper, options.color, width, height));
2656
2980
  const layer = { type: "pixel", id: "thumbnail", name: "thumbnail", visible: true, opacity: 1, blend: "normal", surface, offset: { x: 0, y: 0 } };
2657
- const data = createPaintCompositor().composite([paperNode(options.paper), layer], { x: 0, y: 0, width, height }, { width, height });
2981
+ const paper = light ? { ...options.paper, color: "#141414" } : options.paper;
2982
+ const spill = light ? computeSpill(surface, light, { radius: Math.max(4, height / 6), strength: 1 }) : null;
2983
+ const layers = [paperNode(paper), layer];
2984
+ if (spill) layers.push({ type: "pixel", id: "thumbnail-light", name: "light", visible: true, opacity: 1, blend: "linear-dodge", surface: spill, offset: { x: 0, y: 0 } });
2985
+ const data = createPaintCompositor().composite(layers, { x: 0, y: 0, width, height }, { width, height });
2658
2986
  return { width, height, data };
2659
2987
  }
2660
2988
 
@@ -3127,6 +3455,7 @@ function liveStrokeRefusal(brush) {
3127
3455
  case "smudge":
3128
3456
  return `The ${brush.name} brush moves the paint under it, so it can't be redrawn on its own; rasterize the layer first (rasterize_stroke_layer) or blend on a pixel layer.`;
3129
3457
  }
3458
+ if (brush.light.amount > 0) return `The ${brush.name} brush lays light, which a stroke layer doesn't redraw yet; paint light on a pixel layer (add_layer), or set light.amount to 0 (set_brush).`;
3130
3459
  if (brush.behavior.pickup > 0) return `The ${brush.name} brush picks up the paint under it (pickup ${brush.behavior.pickup}), so it can't be redrawn on its own; set behavior.pickup to 0 (set_brush) or paint on a pixel layer.`;
3131
3460
  return null;
3132
3461
  }
@@ -3535,6 +3864,9 @@ var PaintEditorController = class _PaintEditorController {
3535
3864
  dirty = "all";
3536
3865
  seedCounter = 1;
3537
3866
  strokeCounter = 1;
3867
+ /** Light paint: each layer's spill settings (saved with the document, not undo steps; missing = the defaults). */
3868
+ lightSpills = {};
3869
+ lightCaches = /* @__PURE__ */ new Map();
3538
3870
  /** Live effect paint: playing and the frame rate cap (editor state), and the time the view shows. */
3539
3871
  effectPlaybackState = { playing: true, fps: 30 };
3540
3872
  effectTime = 0;
@@ -4068,7 +4400,7 @@ var PaintEditorController = class _PaintEditorController {
4068
4400
  const layers = mapLayer(this.doc.layers, id, () => null);
4069
4401
  let active = this.doc.activeLayerId;
4070
4402
  if (active && !findLayer(layers, active)) active = flattenLayers(layers).filter((x) => x.type === "pixel").pop()?.id ?? null;
4071
- this.commit("Remove layer", { ...this.doc, layers, activeLayerId: active, ...this.thickWithout(flattenLayers([l]).map((x) => x.id)), ...this.stickersWithout(flattenLayers([l]).map((x) => x.id)), ...this.liveStrokesWithout(flattenLayers([l]).map((x) => x.id)) });
4403
+ this.commit("Remove layer", { ...this.doc, layers, activeLayerId: active, ...this.thickWithout(flattenLayers([l]).map((x) => x.id)), ...this.stickersWithout(flattenLayers([l]).map((x) => x.id)), ...this.liveStrokesWithout(flattenLayers([l]).map((x) => x.id)), ...this.lightWithout(flattenLayers([l]).map((x) => x.id)) });
4072
4404
  return { removed: id, activeLayerId: active };
4073
4405
  }
4074
4406
  duplicateLayer(id) {
@@ -4082,8 +4414,11 @@ var PaintEditorController = class _PaintEditorController {
4082
4414
  const thick = { ...this.doc.thick ?? {} };
4083
4415
  const stickers = { ...this.doc.stickers ?? {} };
4084
4416
  const liveStrokes = { ...this.doc.liveStrokes ?? {} };
4417
+ const light = { ...this.doc.light ?? {} };
4085
4418
  const copy = (l) => {
4086
4419
  const id2 = newPaintLayerId();
4420
+ if (light[l.id]) light[id2] = light[l.id];
4421
+ if (this.lightSpills[l.id]) this.lightSpills[id2] = { ...this.lightSpills[l.id] };
4087
4422
  if (thick[l.id]) thick[id2] = thick[l.id];
4088
4423
  if (stickers[l.id]) stickers[id2] = stickers[l.id].map((x) => ({ ...x }));
4089
4424
  if (liveStrokes[l.id]) liveStrokes[id2] = liveStrokes[l.id].map((x) => ({ ...x, id: `s${this.strokeCounter++}`, layerId: id2 }));
@@ -4091,7 +4426,7 @@ var PaintEditorController = class _PaintEditorController {
4091
4426
  };
4092
4427
  const dup = { ...copy(loc.layer), name: `${loc.layer.name} copy` };
4093
4428
  const layers = insertLayer(this.doc.layers, dup, loc.parent?.id ?? null, loc.index + 1);
4094
- this.commit("Duplicate layer", this.withWetEvents({ ...this.doc, layers, activeLayerId: dup.id, ...this.doc.thick ? { thick } : {}, ...this.doc.stickers ? { stickers } : {}, ...this.doc.liveStrokes ? { liveStrokes } : {} }));
4429
+ this.commit("Duplicate layer", this.withWetEvents({ ...this.doc, layers, activeLayerId: dup.id, ...this.doc.thick ? { thick } : {}, ...this.doc.stickers ? { stickers } : {}, ...this.doc.liveStrokes ? { liveStrokes } : {}, ...this.doc.light ? { light } : {} }));
4095
4430
  return this.describeLayer(dup);
4096
4431
  }
4097
4432
  /** Moves a layer to `index` (0 = bottom) within `parentId` (default its own parent; `null` = top level). */
@@ -4137,7 +4472,11 @@ var PaintEditorController = class _PaintEditorController {
4137
4472
  }
4138
4473
  const merged = { ...below, surface, offset: { x: 0, y: 0 } };
4139
4474
  const layers = mapLayer(mapLayer(this.doc.layers, upper.id, () => null), below.id, () => merged);
4140
- this.commit("Merge down", this.withWetEvents({ ...this.doc, layers, activeLayerId: below.id, ...this.thickWithout([upper.id]), ...this.stickersWithout([upper.id, below.id]), ...this.liveStrokesWithout([upper.id, below.id]) }));
4475
+ const toDoc = (t, l) => t ? translateSurface(t, l.offset.x, l.offset.y) : void 0;
4476
+ const mergedLight = this.doc.light?.[upper.id] || this.doc.light?.[below.id] ? mergeLight(toDoc(this.doc.light?.[below.id], below), upper.visible ? toDoc(this.doc.light?.[upper.id], upper) : void 0, translateSurface(upper.surface, upper.offset.x, upper.offset.y)) : void 0;
4477
+ const lightAfter = this.lightWithout([upper.id, below.id]).light ?? (this.doc.light ? { ...this.doc.light } : void 0);
4478
+ const light = mergedLight ? { ...lightAfter ?? {}, [below.id]: mergedLight } : lightAfter;
4479
+ this.commit("Merge down", this.withWetEvents({ ...this.doc, layers, activeLayerId: below.id, ...this.thickWithout([upper.id]), ...this.stickersWithout([upper.id, below.id]), ...this.liveStrokesWithout([upper.id, below.id]), ...light ? { light } : {} }));
4141
4480
  return this.describeLayer(merged);
4142
4481
  }
4143
4482
  /** Removes every pixel of a layer. */
@@ -4149,7 +4488,7 @@ var PaintEditorController = class _PaintEditorController {
4149
4488
  this.wet.world.removeLane(l.id);
4150
4489
  }
4151
4490
  const bounds = this.describeLayer(l).bounds;
4152
- this.commit("Clear layer", this.withPaintEvent({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface: emptySurface(4) })), ...this.thickWithout([l.id]), ...this.stickersWithout([l.id]), ...this.doc.liveStrokes?.[l.id] ? { liveStrokes: { ...this.doc.liveStrokes, [l.id]: [] } } : {} }, { kind: "clear", layerId: l.id }));
4491
+ this.commit("Clear layer", this.withPaintEvent({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface: emptySurface(4) })), ...this.thickWithout([l.id]), ...this.stickersWithout([l.id]), ...this.doc.liveStrokes?.[l.id] ? { liveStrokes: { ...this.doc.liveStrokes, [l.id]: [] } } : {}, ...this.lightWithout([l.id]) }, { kind: "clear", layerId: l.id }));
4153
4492
  return { layerId: l.id, bounds };
4154
4493
  }
4155
4494
  /**
@@ -4193,7 +4532,9 @@ var PaintEditorController = class _PaintEditorController {
4193
4532
  offset: l.offset
4194
4533
  };
4195
4534
  const surface = this.applyCoverage(l, coverage, rect, apply);
4196
- this.commit("Fill layer", this.withPaintEvent(this.withWetEvents({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })) }), { kind: "fill", layerId: l.id, data: { color, opacity, grain: grainAmount, pressure, blend } }));
4535
+ const lit = this.doc.light?.[l.id];
4536
+ const light = lit ? withLayerLight(this.doc.light, l.id, scaleLight(lit, 1 - opacity)) : void 0;
4537
+ this.commit("Fill layer", this.withPaintEvent(this.withWetEvents({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })), ...light ? { light } : {} }), { kind: "fill", layerId: l.id, data: { color, opacity, grain: grainAmount, pressure, blend } }));
4197
4538
  return { layerId: l.id, bounds: rect };
4198
4539
  }
4199
4540
  applyCoverage(l, coverage, rect, apply) {
@@ -4851,6 +5192,10 @@ var PaintEditorController = class _PaintEditorController {
4851
5192
  })();
4852
5193
  const brush = this.strokeBrush(init);
4853
5194
  const effect = this.strokeEffect(init, brush);
5195
+ if (brush.light.amount > 0) {
5196
+ if (brush.engine === "wet") throw new PaintEditorError(`The ${brush.name} brush paints wet media, which doesn't take light yet (glowing watercolour is planned in #494); paint light with a dab or impasto light brush (list_brushes category light), or set light.amount to 0 (set_brush).`);
5197
+ if (brush.engine === "sticker") throw new PaintEditorError(`The ${brush.name} brush sprays stickers, which don't take light yet; paint light with a dab or particle light brush (list_brushes category light), or set light.amount to 0 (set_brush).`);
5198
+ }
4854
5199
  if (doc.liveStrokes?.[layer.id]) {
4855
5200
  const why = liveStrokeRefusal(brush);
4856
5201
  if (why) throw new PaintEditorError(`Layer "${layer.name}" is a stroke layer. ${why}`);
@@ -4947,6 +5292,8 @@ var PaintEditorController = class _PaintEditorController {
4947
5292
  secondary: rgbaOf(this.secondaryColor),
4948
5293
  wet,
4949
5294
  thick: brush.engine === "impasto" ? { channels: doc.thick?.[layer.id] ?? emptyThickChannels() } : void 0,
5295
+ // Light paint: a twin of the stroke paints the light channel (laying, dimming, moving light).
5296
+ light: !wet && brush.engine !== "sticker" && (brush.light.amount > 0 || doc.light?.[layer.id]) ? { surface: doc.light?.[layer.id] ?? emptyLight() } : null,
4950
5297
  sticker: brush.engine === "sticker" ? { nextId: this.nextStickerNumber(doc) } : void 0,
4951
5298
  paint: effect ? null : this.strokeGradient(init, brush, color),
4952
5299
  effect
@@ -5029,7 +5376,8 @@ var PaintEditorController = class _PaintEditorController {
5029
5376
  const thick = ch ? { ...this.doc.thick ?? {}, [l.id]: ch } : this.doc.thick;
5030
5377
  const sprayed = s.stickers();
5031
5378
  const stickers = sprayed ? { ...this.doc.stickers ?? {}, [l.id]: [...this.doc.stickers?.[l.id] ?? [], ...sprayed] } : this.doc.stickers;
5032
- this.commit(`${brush.name} stroke`, this.withWetEvents(withLiveStroke({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })), strokes: [...this.doc.strokes, stroke], ...thick ? { thick } : {}, ...stickers ? { stickers } : {} }, stroke)), wetStroke ? before : this.doc);
5379
+ const light = withLayerLight(this.doc.light, l.id, s.lightChannel());
5380
+ this.commit(`${brush.name} stroke`, this.withWetEvents(withLiveStroke({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })), strokes: [...this.doc.strokes, stroke], ...thick ? { thick } : {}, ...stickers ? { stickers } : {}, ...light ? { light } : {} }, stroke)), wetStroke ? before : this.doc);
5033
5381
  }
5034
5382
  };
5035
5383
  let session;
@@ -5067,7 +5415,8 @@ var PaintEditorController = class _PaintEditorController {
5067
5415
  layers: mapLayer(out.layers, l.id, () => ({ ...l, surface, offset })),
5068
5416
  strokes: [...out.strokes, stroke],
5069
5417
  ...ch ? { thick: { ...out.thick ?? {}, [l.id]: ch } } : {},
5070
- ...sprayed ? { stickers: { ...out.stickers ?? {}, [l.id]: [...out.stickers?.[l.id] ?? [], ...sprayed] } } : {}
5418
+ ...sprayed ? { stickers: { ...out.stickers ?? {}, [l.id]: [...out.stickers?.[l.id] ?? [], ...sprayed] } } : {},
5419
+ ...s.lightChannel() ? { light: withLayerLight(out.light, l.id, s.lightChannel()) } : {}
5071
5420
  }, stroke);
5072
5421
  }
5073
5422
  };
@@ -5182,6 +5531,45 @@ var PaintEditorController = class _PaintEditorController {
5182
5531
  if (doc !== this.doc) this.commit(strokes.length === 1 && repeat === 1 ? "Replay stroke" : "Replay strokes", doc);
5183
5532
  return { strokeIds: ids, bounds, layerIds: [...layerIds] };
5184
5533
  }
5534
+ // --- Light paint --------------------------------------------------------------------
5535
+ /** The document's `light` without these layers (as a spread: `{}` when unchanged). */
5536
+ lightWithout(ids) {
5537
+ const t = this.doc.light;
5538
+ if (!t || !ids.some((id) => t[id])) return {};
5539
+ const out = { ...t };
5540
+ for (const id of ids) delete out[id];
5541
+ return { light: out };
5542
+ }
5543
+ lightSpillOf(layerId) {
5544
+ return this.lightSpills[layerId] ?? DEFAULT_LIGHT_SPILL;
5545
+ }
5546
+ /** A layer's light paint: whether it has any, where (document pixels), its brightest value 0..255, and its spill. */
5547
+ lightOf(layerId) {
5548
+ const l = this.wetLayer(layerId, "read the light of");
5549
+ const info = lightInfo(this.doc.light?.[l.id]);
5550
+ const bounds = info.bounds ? { ...info.bounds, x: info.bounds.x + l.offset.x, y: info.bounds.y + l.offset.y } : null;
5551
+ return { layerId: l.id, hasLight: info.peak > 0, bounds, peak: info.peak, spill: { ...this.lightSpillOf(l.id) } };
5552
+ }
5553
+ /** How far a layer's light spills (px; 0 turns it off) and how strongly (0..2). Saved with the document, not an undo step; nothing is repainted. */
5554
+ setLightSpill(layerId, update) {
5555
+ const l = this.wetLayer(layerId, "set the light spill of");
5556
+ try {
5557
+ this.lightSpills[l.id] = normalizeLightSpill(update, this.lightSpillOf(l.id));
5558
+ } catch (e) {
5559
+ throw new PaintEditorError(e.message);
5560
+ }
5561
+ this.emit("all");
5562
+ return { ...this.lightSpills[l.id] };
5563
+ }
5564
+ /** Takes a layer's light away; its paint keeps its colour. One undo step. Returns whether there was any. */
5565
+ clearLight(layerId) {
5566
+ this.requireIdle("clearing light");
5567
+ const l = this.paintTarget(layerId, "clear the light of");
5568
+ const t = this.lightWithout([l.id]);
5569
+ if (!t.light) return { layerId: l.id, cleared: false };
5570
+ this.commit("Clear light", { ...this.doc, light: t.light });
5571
+ return { layerId: l.id, cleared: true };
5572
+ }
5185
5573
  // --- Thick paint and light -------------------------------------------------------
5186
5574
  /** The document's `thick` without these layers (as a spread: `{}` when unchanged). */
5187
5575
  thickWithout(ids) {
@@ -5530,8 +5918,31 @@ var PaintEditorController = class _PaintEditorController {
5530
5918
  }
5531
5919
  return out;
5532
5920
  }
5921
+ /**
5922
+ * Light paint over the (lit) layers: glow, rim and clarity in each layer's pixels, and its spill
5923
+ * as a Linear Dodge (Add) layer directly above it (from the painting as committed, so a stroke in
5924
+ * progress does not redraw the blur every frame).
5925
+ */
5926
+ lightLayers(layers) {
5927
+ const light = this.doc.light ?? {};
5928
+ const ids = /* @__PURE__ */ new Set([...Object.keys(light), ...Object.keys(this.doc.thick ?? {})]);
5929
+ if (!ids.size) return layers;
5930
+ let out = layers;
5931
+ for (const id of ids) {
5932
+ const l = findLayer(out, id)?.layer;
5933
+ if (!isPixelLayer(l)) continue;
5934
+ let cache = this.lightCaches.get(id);
5935
+ if (!cache) this.lightCaches.set(id, cache = new LightCache());
5936
+ const glowed = cache.glow(l.surface, light[id], this.liveThick?.layerId === id ? this.liveThick.channels : this.doc.thick?.[id]);
5937
+ if (glowed !== l.surface) out = mapLayer(out, id, () => ({ ...l, surface: glowed }));
5938
+ const committed = findLayer(this.doc.layers, id)?.layer;
5939
+ const spill = light[id] && isPixelLayer(committed) ? cache.spill(committed.surface, light[id], this.lightSpillOf(id)) : null;
5940
+ if (spill) out = insertAfter(out, id, { type: "pixel", id: `${id}${SPILL_SUFFIX}`, name: `${l.name} light`, visible: l.visible, opacity: l.opacity, blend: "linear-dodge", surface: spill, offset: { ...l.offset } });
5941
+ }
5942
+ return out;
5943
+ }
5533
5944
  renderLayers(options, view = false) {
5534
- let layers = this.litLayers((options.wet ?? "display") === "dried" ? this.driedLayers() : this.currentLayers());
5945
+ let layers = this.lightLayers(this.litLayers((options.wet ?? "display") === "dried" ? this.driedLayers() : this.currentLayers()));
5535
5946
  if (options.layerIds) {
5536
5947
  const picked = [];
5537
5948
  for (const id of options.layerIds) picked.push(findLayer(layers, id)?.layer ?? this.requireLayer(id));
@@ -5560,13 +5971,13 @@ var PaintEditorController = class _PaintEditorController {
5560
5971
  }
5561
5972
  /** The canvas (or some layers), scaled so the longest side is at most `maxSide`. */
5562
5973
  renderPreview(options = {}) {
5563
- const full = this.compositePixels({ ...options, wet: options.wet ?? "display" });
5974
+ const full2 = this.compositePixels({ ...options, wet: options.wet ?? "display" });
5564
5975
  const side = Math.max(1, Math.round(options.maxSide ?? 512));
5565
- const k = Math.min(1, side / Math.max(full.width, full.height));
5566
- if (k >= 1) return full;
5567
- const w = Math.max(1, Math.round(full.width * k));
5568
- const h = Math.max(1, Math.round(full.height * k));
5569
- return { width: w, height: h, data: downsampleRgba(full.data, full.width, full.height, w, h) };
5976
+ const k = Math.min(1, side / Math.max(full2.width, full2.height));
5977
+ if (k >= 1) return full2;
5978
+ const w = Math.max(1, Math.round(full2.width * k));
5979
+ const h = Math.max(1, Math.round(full2.height * k));
5980
+ return { width: w, height: h, data: downsampleRgba(full2.data, full2.width, full2.height, w, h) };
5570
5981
  }
5571
5982
  // --- Save, load and export -------------------------------------------------------
5572
5983
  /** The document as JSON with inline PNG assets. */
@@ -5618,6 +6029,17 @@ var PaintEditorController = class _PaintEditorController {
5618
6029
  for (const [id, list] of Object.entries(this.doc.liveStrokes ?? {})) if (findLayer(this.doc.layers, id)) liveStrokes[id] = list.map((x) => JSON.parse(JSON.stringify(x)));
5619
6030
  if (Object.keys(liveStrokes).length) ext.liveStrokes = liveStrokes;
5620
6031
  else delete ext.liveStrokes;
6032
+ const light = {};
6033
+ for (const [id, t] of Object.entries(this.doc.light ?? {})) {
6034
+ if (!findLayer(this.doc.layers, id)) continue;
6035
+ const saved = lightToJSON(t);
6036
+ if (saved) light[id] = saved;
6037
+ }
6038
+ if (Object.keys(light).length) ext.light = light;
6039
+ else delete ext.light;
6040
+ const spills = Object.fromEntries(Object.entries(this.lightSpills).filter(([id]) => findLayer(this.doc.layers, id)));
6041
+ if (Object.keys(spills).length) ext.lightSpill = spills;
6042
+ else delete ext.lightSpill;
5621
6043
  const effectLayers = effectLayersToJSON(this.doc.effectLayers ?? {}, (id) => isPixelLayer(findLayer(this.doc.layers, id)?.layer));
5622
6044
  if (effectLayers) ext.effectLayers = effectLayers;
5623
6045
  else delete ext.effectLayers;
@@ -5668,6 +6090,29 @@ var PaintEditorController = class _PaintEditorController {
5668
6090
  problems.push(`The effect paper could not be read (${e.message}); the plain paper shows.`);
5669
6091
  }
5670
6092
  }
6093
+ this.lightSpills = {};
6094
+ this.lightCaches.clear();
6095
+ if (ext.lightSpill && typeof ext.lightSpill === "object") {
6096
+ for (const [id, v] of Object.entries(ext.lightSpill)) {
6097
+ try {
6098
+ if (findLayer(state.layers, id)) this.lightSpills[id] = normalizeLightSpill(v ?? {});
6099
+ } catch {
6100
+ problems.push(`The light spill of layer "${id}" could not be read; it takes the defaults.`);
6101
+ }
6102
+ }
6103
+ }
6104
+ if (ext.light && typeof ext.light === "object") {
6105
+ const light = {};
6106
+ for (const [id, v] of Object.entries(ext.light)) {
6107
+ if (!isPixelLayer(findLayer(state.layers, id)?.layer)) continue;
6108
+ try {
6109
+ light[id] = lightFromJSON(v);
6110
+ } catch (e) {
6111
+ problems.push(`The light paint of layer "${id}" could not be read (${e.message}); it loads without light.`);
6112
+ }
6113
+ }
6114
+ if (Object.keys(light).length) state = { ...state, light };
6115
+ }
5671
6116
  const raw = ext.layerChannels;
5672
6117
  if (!raw || typeof raw !== "object") return state;
5673
6118
  const thick = {};
@@ -5709,6 +6154,8 @@ var PaintEditorController = class _PaintEditorController {
5709
6154
  }
5710
6155
  this.docRevision++;
5711
6156
  delete this.extensions.layerChannels;
6157
+ delete this.extensions.light;
6158
+ delete this.extensions.lightSpill;
5712
6159
  delete this.extensions.lighting;
5713
6160
  delete this.extensions.stickers;
5714
6161
  delete this.extensions.liveStrokes;
@@ -5742,13 +6189,13 @@ var PaintEditorController = class _PaintEditorController {
5742
6189
  return encodePng(img.width, img.height, img.data);
5743
6190
  }
5744
6191
  scaled(options) {
5745
- const full = this.compositePixels(options);
6192
+ const full2 = this.compositePixels(options);
5746
6193
  const s = options.scale ?? 1;
5747
6194
  if (!(s > 0 && s <= 1)) throw new PaintEditorError(`scale ${s} is out of range; use above 0 up to 1 (exports never upscale pixels).`);
5748
- if (s === 1) return full;
5749
- const w = Math.max(1, Math.round(full.width * s));
5750
- const h = Math.max(1, Math.round(full.height * s));
5751
- return { width: w, height: h, data: downsampleRgba(full.data, full.width, full.height, w, h) };
6195
+ if (s === 1) return full2;
6196
+ const w = Math.max(1, Math.round(full2.width * s));
6197
+ const h = Math.max(1, Math.round(full2.height * s));
6198
+ return { width: w, height: h, data: downsampleRgba(full2.data, full2.width, full2.height, w, h) };
5752
6199
  }
5753
6200
  /**
5754
6201
  * The canvas as PNG, JPEG or WebP bytes. JPEG has no alpha, so it always includes the paper.
@@ -5771,7 +6218,7 @@ var PaintEditorController = class _PaintEditorController {
5771
6218
  * pixels), and the composite as the merged image.
5772
6219
  */
5773
6220
  exportPsd() {
5774
- const dried = this.litLayers(this.driedLayers(this.doc.layers));
6221
+ const dried = this.lightLayers(this.litLayers(this.driedLayers(this.doc.layers)));
5775
6222
  const { width, height, dpi } = this.doc.canvas;
5776
6223
  const paperPixels = this.compositePixels({ layerIds: [], paper: true });
5777
6224
  const paperLayer = {
@@ -5802,6 +6249,9 @@ var PaintEditorController = class _PaintEditorController {
5802
6249
  const one = names.length === 1;
5803
6250
  result.warnings.push(`${label} blending has no PSD equivalent; ${list} ${one ? "was" : "were"} written as ${std}, the nearest Photoshop mode, so ${one ? "it looks" : "they look"} different in Photoshop (the merged image is exact).`);
5804
6251
  }
6252
+ if (Object.values(this.doc.light ?? {}).some((l) => l.tiles.size)) {
6253
+ result.warnings.push('Light paint is written as pixels: each layer with its glow, and its spill as a "\u2039layer\u203A light" Linear Dodge (Add) layer above it. Opening the PSD again gives plain pixel layers, not editable light.');
6254
+ }
5805
6255
  return result;
5806
6256
  }
5807
6257
  /** `render_preview` as a base64 PNG. */
@@ -6807,6 +7257,19 @@ var PaintEditorController = class _PaintEditorController {
6807
7257
  var MAX_IMAGE_SIDE = 8192;
6808
7258
  var movePoint = (p, dx, dy) => ({ x: p.x + dx, y: p.y + dy });
6809
7259
  var moveRect = (r, dx, dy) => ({ ...r, x: r.x + dx, y: r.y + dy });
7260
+ var SPILL_SUFFIX = "::light";
7261
+ function withLayerLight(light, layerId, next) {
7262
+ if (!next) return light ? { ...light } : void 0;
7263
+ const out = { ...light ?? {} };
7264
+ if (next.tiles.size) out[layerId] = next;
7265
+ else delete out[layerId];
7266
+ return out;
7267
+ }
7268
+ function insertAfter(layers, id, node) {
7269
+ const i = layers.findIndex((l) => l.id === id);
7270
+ if (i >= 0) return [...layers.slice(0, i + 1), node, ...layers.slice(i + 1)];
7271
+ return layers.map((l) => l.type === "group" ? { ...l, children: insertAfter(l.children, id, node) } : l);
7272
+ }
6810
7273
  function isLiveEffect(s) {
6811
7274
  return s.paint?.type === "effect" && s.paint.live === true;
6812
7275
  }
@@ -7955,6 +8418,39 @@ var WRITE4 = { readOnlyHint: false, destructiveHint: false, idempotentHint: fals
7955
8418
  var LAYER_ID3 = { type: "string", description: "Pixel layer id from list_layers (default the active layer)." };
7956
8419
  function createPaintThickTools(c) {
7957
8420
  return [
8421
+ {
8422
+ name: "get_light",
8423
+ title: "Read a layer\u2019s light paint",
8424
+ description: "Reads a layer\u2019s light paint (laid by brushes with light.amount above 0): whether it has any, where it is (bounds, document pixels), its brightest value (peak 0..255) and its spill (radius px, strength). Changes nothing.",
8425
+ inputSchema: { type: "object", properties: { layerId: LAYER_ID3 }, additionalProperties: false },
8426
+ annotations: { title: "Read a layer\u2019s light paint", ...READ4 },
8427
+ coordinates: { out: ["bounds"] },
8428
+ handler: ({ layerId }) => c.lightOf(layerId)
8429
+ },
8430
+ {
8431
+ name: "set_light_spill",
8432
+ title: "Set how far a layer\u2019s light spills",
8433
+ description: "Sets how far a layer\u2019s light paint spills onto the paint and paper around it, in each stroke\u2019s own colour: radius (px, 0 turns spill off, default 24) and strength (0..2, default 1). Nothing is repainted; saved with the document, not an undo step. Returns the layer\u2019s spill.",
8434
+ inputSchema: {
8435
+ type: "object",
8436
+ properties: {
8437
+ layerId: LAYER_ID3,
8438
+ radius: { type: "number", minimum: 0, maximum: 512, description: "How far the light reaches, px (0 turns spill off; default 24)." },
8439
+ strength: { type: "number", minimum: 0, maximum: 2, description: "How strong the spill is, 0..2 (default 1)." }
8440
+ },
8441
+ additionalProperties: false
8442
+ },
8443
+ annotations: { title: "Set how far a layer\u2019s light spills", ...SET3 },
8444
+ handler: ({ layerId, radius, strength }) => c.setLightSpill(layerId, { radius, strength })
8445
+ },
8446
+ {
8447
+ name: "clear_light",
8448
+ title: "Clear a layer\u2019s light",
8449
+ description: "Takes a layer\u2019s light paint away; its paint keeps its colour (and relief). One undo step. Returns whether anything was cleared.",
8450
+ inputSchema: { type: "object", properties: { layerId: LAYER_ID3 }, additionalProperties: false },
8451
+ annotations: { title: "Clear a layer\u2019s light", ...WRITE4 },
8452
+ handler: ({ layerId }) => c.clearLight(layerId)
8453
+ },
7958
8454
  {
7959
8455
  name: "set_lighting",
7960
8456
  title: "Move the light",
@@ -8205,7 +8701,18 @@ var IMPASTO_BRUSH = {
8205
8701
  metallic: { type: "number", minimum: 0, maximum: 1, description: "Metallic paint: highlights take the paint colour (gold, copper, silver)." },
8206
8702
  load: { type: "number", minimum: 0, maximum: 1, description: "Paint loading: 1 never runs out; lower, the stroke thins and fades as the brush empties." },
8207
8703
  thinner: { type: "number", minimum: 0, maximum: 1, description: "Thinner: flatter, more transparent paint." },
8208
- drag: { type: "number", minimum: 0, maximum: 1, description: "Knife and brush: how much of the paint under the tool is dragged along." }
8704
+ drag: { type: "number", minimum: 0, maximum: 1, description: "Knife and brush: how much of the paint under the tool is dragged along." },
8705
+ rim: { type: "number", minimum: 0, maximum: 1, description: "Light paint (with light.amount): the bead's light is stronger where its surface turns away, as on backlit glass (default 0)." },
8706
+ clarity: { type: "number", minimum: 0, maximum: 1, description: "Glassy paint: the middle of a bead lets the layers below show through while its edges stay solid (default 0). flatten_relief clears it." }
8707
+ },
8708
+ additionalProperties: false
8709
+ };
8710
+ var LIGHT_BRUSH = {
8711
+ type: "object",
8712
+ description: "Light paint: the light the brush lays with its colour, glowing in the paint's own colour and spilling onto its surroundings (set_light_spill). Dab, particle, bristle, Mixer Brush and impasto brushes take it; wet and sticker brushes refuse light above 0. Missing fields keep their current values.",
8713
+ properties: {
8714
+ amount: { type: "number", minimum: 0, maximum: 1, description: "Light each dab lays, scaled by its coverage and flow (0 none, default; 1 full)." },
8715
+ pressure: { type: "boolean", description: "Pressure scales the light (default false)." }
8209
8716
  },
8210
8717
  additionalProperties: false
8211
8718
  };
@@ -8276,6 +8783,7 @@ var BRUSH_FIELDS = {
8276
8783
  particle: PARTICLE_BRUSH,
8277
8784
  bristle: BRISTLE_BRUSH,
8278
8785
  sticker: STICKER_BRUSH,
8786
+ light: LIGHT_BRUSH,
8279
8787
  paint: BRUSH_PAINT
8280
8788
  };
8281
8789
  var TIME = { type: "number", minimum: 0, description: "Draw live effect paint (strokes on stroke layers painted with live effect paint) this many ms into its animation (default: as painted, its own time)." };
@@ -8321,6 +8829,7 @@ function brushSummary(b) {
8321
8829
  multicolor: b.behavior.multicolor.mode,
8322
8830
  ...b.engine === "wet" ? { wetTool: b.wet.tool, medium: b.wet.medium } : {},
8323
8831
  ...b.engine === "impasto" ? { impastoTool: b.impasto.tool } : {},
8832
+ ...b.light.amount > 0 ? { light: b.light.amount } : {},
8324
8833
  builtin: Boolean(b.builtin)
8325
8834
  };
8326
8835
  }
@@ -8639,7 +9148,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
8639
9148
  add({
8640
9149
  name: "list_brushes",
8641
9150
  title: "List brushes",
8642
- description: "Lists the brush library: built-in dry media (pencils, pastels, charcoal, airbrush, marker, ink, erasers), paint brushes (dirty round, multi-colour flat, palette dabber), blending tools (blender, smudge, pigment smudge, Mixer Brush) and saved user brushes, with id, name, category, engine, size, grain response, rotation mode, whether it can pick up paint (wetCapable), its pick-up ratio, dirty and multi-colour mode, plus the active brush id.",
9151
+ description: "Lists the brush library: built-in dry media (pencils, pastels, charcoal, airbrush, marker, ink, erasers), paint brushes (dirty round, multi-colour flat, palette dabber), blending tools (blender, smudge, pigment smudge, Mixer Brush), light paint (category light: glow gel, neon marker, light airbrush, spark trail; light is the amount they lay) and saved user brushes, with id, name, category, engine, size, grain response, rotation mode, whether it can pick up paint (wetCapable), its pick-up ratio, dirty and multi-colour mode, plus the active brush id.",
8643
9152
  inputSchema: EMPTY3,
8644
9153
  annotations: { title: "List brushes", ...READ6 },
8645
9154
  handler: () => {
@@ -9594,11 +10103,18 @@ var BLEND_MENU_GROUPS = [
9594
10103
  { label: "Standard", modes: BLEND_MODES.filter((m) => !m.painterly && m.id !== "pigment") },
9595
10104
  { label: "Paint", modes: BLEND_MODES.filter((m) => m.painterly || m.id === "pigment") }
9596
10105
  ];
10106
+ var LIGHT_SLIDER = { path: "light.amount", label: "Light", min: 0, max: 1, step: 0.01 };
9597
10107
  var PICKUP_SLIDERS = [
9598
10108
  { path: "behavior.pickup", label: "Pick-up", min: 0, max: 1, step: 0.01 },
9599
10109
  { path: "behavior.mix", label: "Mix", min: 0, max: 1, step: 0.01 }
9600
10110
  ];
9601
10111
  function brushEngineControls(brush) {
10112
+ const c = engineControls(brush);
10113
+ if (!c || !["dab", "mixer", "particle", "bristle", "impasto"].includes(brush.engine)) return c;
10114
+ const extra = brush.engine === "impasto" ? [{ path: "impasto.rim", label: "Rim", min: 0, max: 1, step: 0.01 }, { path: "impasto.clarity", label: "Clarity", min: 0, max: 1, step: 0.01 }] : [];
10115
+ return { ...c, sliders: [...c.sliders, LIGHT_SLIDER, ...extra], light: true };
10116
+ }
10117
+ function engineControls(brush) {
9602
10118
  switch (brush.engine) {
9603
10119
  case "particle":
9604
10120
  return {
@@ -10142,6 +10658,7 @@ export {
10142
10658
  DEFAULT_DYNAMICS,
10143
10659
  DEFAULT_FLOW,
10144
10660
  DEFAULT_IMPASTO,
10661
+ DEFAULT_LIGHT,
10145
10662
  DEFAULT_LIGHTING,
10146
10663
  DEFAULT_MULTI_COLOR,
10147
10664
  DEFAULT_PAINT_GRADIENT,
@@ -10340,6 +10857,7 @@ export {
10340
10857
  normalizeFlow,
10341
10858
  normalizeHexColor,
10342
10859
  normalizeImpasto,
10860
+ normalizeLight,
10343
10861
  normalizeLighting,
10344
10862
  normalizeMultiColor,
10345
10863
  normalizePaintBrush,