@pixerate/editor 0.242.0 → 0.244.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 (78) 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-4542T5HP.js} +3 -3
  4. package/dist/{brushstrokeRender-UKADOV5D.cjs → brushstrokeRender-QA5WVIDT.cjs} +5 -5
  5. package/dist/canvas/index.cjs +8 -8
  6. package/dist/canvas/index.js +1 -1
  7. package/dist/{chunk-ILM6GYAX.js → chunk-4EQY4TZJ.js} +1 -1
  8. package/dist/{chunk-5BR7LC24.cjs → chunk-4T47Z4J3.cjs} +68 -6
  9. package/dist/{chunk-ZDPLZDTT.js → chunk-5RFENYO5.js} +1 -1
  10. package/dist/{chunk-NE6OOKWJ.js → chunk-7YQNKSQ7.js} +1 -1
  11. package/dist/{chunk-AFLMXITL.cjs → chunk-BDIH4DYR.cjs} +23 -23
  12. package/dist/{chunk-HGGJ36HR.cjs → chunk-HZ547SGQ.cjs} +1 -1
  13. package/dist/{chunk-KCMV2534.cjs → chunk-IZL5V5RK.cjs} +2 -2
  14. package/dist/{chunk-QP2B3MID.js → chunk-L5PB7EG3.js} +1 -1
  15. package/dist/{chunk-HW72ABGR.cjs → chunk-M6JORA5Z.cjs} +86 -86
  16. package/dist/{chunk-UDBDYZET.js → chunk-OSTKS3YN.js} +1 -1
  17. package/dist/{chunk-HLBTRSQJ.js → chunk-P3NIPIKS.js} +263 -63
  18. package/dist/{chunk-7F4G74UQ.cjs → chunk-SD23XMVJ.cjs} +35 -35
  19. package/dist/{chunk-XBZK47LV.js → chunk-TBJEMELS.js} +67 -5
  20. package/dist/{chunk-XFYLA3ZO.cjs → chunk-VMROIWLL.cjs} +290 -90
  21. package/dist/image-editor/index.cjs +785 -777
  22. package/dist/image-editor/index.js +13 -5
  23. package/dist/kernel-wasm-4KVONDAU.js +9 -0
  24. package/dist/kernel-wasm-TTYRBGAK.cjs +9 -0
  25. package/dist/paint/index.cjs +10 -2
  26. package/dist/paint/index.js +9 -1
  27. package/dist/paint/wet/film.d.cts +19 -1
  28. package/dist/paint/wet/film.d.ts +19 -1
  29. package/dist/paint/wet/gpu-kernels.d.cts +2 -2
  30. package/dist/paint/wet/gpu-kernels.d.ts +2 -2
  31. package/dist/paint/wet/kernel-wasm.d.cts +5 -5
  32. package/dist/paint/wet/kernel-wasm.d.ts +5 -5
  33. package/dist/paint/wet/kernel.d.cts +1 -1
  34. package/dist/paint/wet/kernel.d.ts +1 -1
  35. package/dist/paint/wet/medium.d.cts +3 -1
  36. package/dist/paint/wet/medium.d.ts +3 -1
  37. package/dist/paint/wet/model.d.cts +19 -4
  38. package/dist/paint/wet/model.d.ts +19 -4
  39. package/dist/paint/wet/world.d.cts +34 -8
  40. package/dist/paint/wet/world.d.ts +34 -8
  41. package/dist/paint-editor/backlight.d.cts +77 -0
  42. package/dist/paint-editor/backlight.d.ts +77 -0
  43. package/dist/paint-editor/brushes.d.cts +18 -2
  44. package/dist/paint-editor/brushes.d.ts +18 -2
  45. package/dist/paint-editor/controller.d.cts +39 -0
  46. package/dist/paint-editor/controller.d.ts +39 -0
  47. package/dist/paint-editor/document.d.cts +3 -0
  48. package/dist/paint-editor/document.d.ts +3 -0
  49. package/dist/paint-editor/index.cjs +1335 -529
  50. package/dist/paint-editor/index.d.cts +1 -0
  51. package/dist/paint-editor/index.d.ts +1 -0
  52. package/dist/paint-editor/index.js +856 -50
  53. package/dist/paint-editor/light.d.cts +74 -0
  54. package/dist/paint-editor/light.d.ts +74 -0
  55. package/dist/paint-editor/paper.d.cts +7 -0
  56. package/dist/paint-editor/paper.d.ts +7 -0
  57. package/dist/paint-editor/stroke.d.cts +14 -0
  58. package/dist/paint-editor/stroke.d.ts +14 -0
  59. package/dist/paint-editor/thick-tools.d.cts +2 -2
  60. package/dist/paint-editor/thick-tools.d.ts +2 -2
  61. package/dist/paint-editor/thick.d.cts +5 -1
  62. package/dist/paint-editor/thick.d.ts +5 -1
  63. package/dist/paint-editor/ui.d.cts +2 -0
  64. package/dist/paint-editor/ui.d.ts +2 -0
  65. package/dist/paint-editor/wet.d.cts +9 -0
  66. package/dist/paint-editor/wet.d.ts +9 -0
  67. package/dist/vector/index.cjs +962 -962
  68. package/dist/vector/index.js +4 -4
  69. package/package.json +1 -1
  70. package/skills/pixerate-paint-editor/SKILL.md +11 -11
  71. package/skills/pixerate-paint-editor/references/canvas-hosted.md +8 -0
  72. package/skills/pixerate-paint-editor/references/light-paint.md +48 -0
  73. package/skills/pixerate-paint-oil-impasto/SKILL.md +1 -0
  74. package/skills/pixerate-paint-watercolour/SKILL.md +10 -1
  75. package/skills/pixerate-paint-watercolour/evals/glowing-wash.json +22 -0
  76. package/skills/pixerate-paint-watercolour/evals/stained-glass.json +16 -0
  77. package/dist/kernel-wasm-6FPOLVUZ.cjs +0 -9
  78. package/dist/kernel-wasm-WIMMJUF2.js +0 -9
@@ -7,7 +7,7 @@ import {
7
7
  resolveExternalAssets,
8
8
  serializeLayers,
9
9
  serializeLayersExternal
10
- } from "../chunk-NE6OOKWJ.js";
10
+ } from "../chunk-7YQNKSQ7.js";
11
11
  import "../chunk-3CI7FRMK.js";
12
12
  import "../chunk-ZNA3IQZF.js";
13
13
  import {
@@ -44,7 +44,7 @@ import {
44
44
  pigmentOf,
45
45
  pointerPressure,
46
46
  wetSupport
47
- } from "../chunk-HLBTRSQJ.js";
47
+ } from "../chunk-P3NIPIKS.js";
48
48
  import "../chunk-HVQVHUTV.js";
49
49
  import {
50
50
  writeZip
@@ -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-4EQY4TZJ.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-TBJEMELS.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
  }
@@ -1896,6 +2214,7 @@ function downloadBytes(bytes, fileName, mimeType) {
1896
2214
  // src/paint-editor/wet.ts
1897
2215
  var STROKE_WATER = 0.9;
1898
2216
  var STROKE_MASS = PIGMENT_UNIT * 2.5;
2217
+ var STROKE_LIGHT = 1.5;
1899
2218
  var COVERAGE_STEPS = 4096;
1900
2219
  function union2(a, b) {
1901
2220
  if (!a) return { ...b };
@@ -1927,8 +2246,10 @@ var WetStrokeDriver = class {
1927
2246
  this.medium = new WetMedium(init.world, init.laneId, {
1928
2247
  water: STROKE_WATER * Math.min(1, wet.water * (1 + thin)) * perDab,
1929
2248
  mass: STROKE_MASS * wet.pigment * (1 - 0.6 * thin) * perDab,
1930
- body: wet.body * (1 - thin)
2249
+ body: wet.body * (1 - thin),
2250
+ light: 0
1931
2251
  });
2252
+ this.lightBase = wet.tool === "paint" ? STROKE_LIGHT * brush.light.amount * (1 - 0.6 * thin) * perDab : 0;
1932
2253
  const rates = exchangeRates(brush.behavior.pickup, brush.behavior.mix, wet.load, brush.behavior.mixing);
1933
2254
  this.states = copies.map((c, i) => ({
1934
2255
  walker: new PenDabWalker(brush, init.seed + i * 7919 | 0, c.linear),
@@ -1958,6 +2279,7 @@ var WetStrokeDriver = class {
1958
2279
  paint = [0, 0, 0];
1959
2280
  centre = [0, 0, 0];
1960
2281
  depth;
2282
+ lightBase;
1961
2283
  sampler;
1962
2284
  sample = { rgb: [0, 0, 0], alpha: 1 };
1963
2285
  copies;
@@ -2056,6 +2378,7 @@ var WetStrokeDriver = class {
2056
2378
  switch (wet.tool) {
2057
2379
  case "paint": {
2058
2380
  const ex = st.exchange;
2381
+ this.medium.options.light = this.lightBase * (brush.light.pressure ? d.pressure : 1);
2059
2382
  let flow = 1;
2060
2383
  const effect = this.init.effect;
2061
2384
  if (effect) {
@@ -2244,6 +2567,7 @@ var PaintWetLayers = class {
2244
2567
  const flow = this.settings.get(l.id)?.flow;
2245
2568
  return {
2246
2569
  base: translateSurface(l.base, left, top),
2570
+ ...l.light ? { light: translateSurface(l.light, left, top) } : {},
2247
2571
  flow: l.flow && flow && flow.kind !== "none" ? flowField(flow, width, height) : l.flow
2248
2572
  };
2249
2573
  });
@@ -2283,7 +2607,7 @@ var PaintWetLayers = class {
2283
2607
  if (!w.hasLane(layerId)) {
2284
2608
  const base = this.host.layerSurface(layerId) ?? new TiledSurface(4, /* @__PURE__ */ new Map(), 0);
2285
2609
  const s = this.settingsOf(layerId, medium);
2286
- w.ensureLane(layerId, { params: s.params, base });
2610
+ w.ensureLane(layerId, { params: s.params, base, light: this.host.layerLight(layerId) });
2287
2611
  const { width, height } = this.host.canvas();
2288
2612
  if (s.flow.kind !== "none") w.setFlow(layerId, flowField(s.flow, width, height), s.flow.strength);
2289
2613
  }
@@ -2341,6 +2665,7 @@ var PaintWetLayers = class {
2341
2665
  if (this.host.layerSurface(id) !== d.surface) surfaces.set(id, d.surface);
2342
2666
  rects.push(...d.changed);
2343
2667
  }
2668
+ this.syncLights();
2344
2669
  if (surfaces.size) this.host.updateSurfaces(surfaces, rects);
2345
2670
  return rects;
2346
2671
  }
@@ -2356,9 +2681,21 @@ var PaintWetLayers = class {
2356
2681
  if (this.host.layerSurface(id) !== d.surface) surfaces.set(id, d.surface);
2357
2682
  rects.push(...d.changed);
2358
2683
  }
2684
+ this.syncLights();
2359
2685
  if (surfaces.size) this.host.updateSurfaces(surfaces, rects);
2360
2686
  return rects;
2361
2687
  }
2688
+ /** Shows each wet layer's live light paint (the light under it with the wet light shaded in). */
2689
+ syncLights() {
2690
+ const w = this.world;
2691
+ if (!w) return;
2692
+ const lights = /* @__PURE__ */ new Map();
2693
+ for (const id of w.laneIds()) {
2694
+ const l = w.lightDisplay(id);
2695
+ if (l && this.host.layerLight(id) !== l) lights.set(id, l);
2696
+ }
2697
+ if (lights.size) this.host.updateLights(lights);
2698
+ }
2362
2699
  /** Drops the wet layers whose paint has all dried (they become ordinary pixel layers). */
2363
2700
  retireDry() {
2364
2701
  const w = this.world;
@@ -2366,7 +2703,9 @@ var PaintWetLayers = class {
2366
2703
  for (const id of w.laneIds()) {
2367
2704
  if (w.isActive(id) || this.driver?.init.laneId === id) continue;
2368
2705
  const base = w.getBase(id);
2706
+ const light = w.isLit(id) ? w.getLight(id) : null;
2369
2707
  w.removeLane(id);
2708
+ if (light && this.host.layerLight(id) !== light) this.host.updateLights(/* @__PURE__ */ new Map([[id, light]]));
2370
2709
  this.host.updateSurfaces(/* @__PURE__ */ new Map([[id, base]]), []);
2371
2710
  }
2372
2711
  }
@@ -2485,6 +2824,12 @@ var PaintWetLayers = class {
2485
2824
  this.retireDry();
2486
2825
  return out;
2487
2826
  }
2827
+ /** The layer's light paint with all its wet paint dried (`null` without a wet layer or light), without changing anything. */
2828
+ bakedLight(layerId) {
2829
+ const w = this.world;
2830
+ if (!w?.hasLane(layerId) || !w.isLit(layerId)) return null;
2831
+ return w.bakedLight(layerId);
2832
+ }
2488
2833
  /** The layer's pixels with all its wet paint dried (deterministic), without changing anything. */
2489
2834
  bakedSurface(layerId) {
2490
2835
  const w = this.world;
@@ -2605,6 +2950,7 @@ function paintTestStroke(surface, brush, paper, color, width, height) {
2605
2950
  const [r, g, b] = hexToRgb8(color);
2606
2951
  let result = surface;
2607
2952
  let thick = null;
2953
+ let light = null;
2608
2954
  const session = new PaintStrokeSession(
2609
2955
  {
2610
2956
  update: () => {
@@ -2612,6 +2958,7 @@ function paintTestStroke(surface, brush, paper, color, width, height) {
2612
2958
  commit: (sess, s) => {
2613
2959
  result = s ?? result;
2614
2960
  thick = sess.thickChannels();
2961
+ light = sess.lightChannel();
2615
2962
  }
2616
2963
  },
2617
2964
  {
@@ -2630,12 +2977,14 @@ function paintTestStroke(surface, brush, paper, color, width, height) {
2630
2977
  copies: [IDENTITY_COPY],
2631
2978
  mixerLoad: brushLoadForStroke(brush, EMPTY_MIXER_LOAD, [r / 255, g / 255, b / 255]),
2632
2979
  thick: brush.engine === "impasto" ? { channels: emptyThickChannels() } : void 0,
2633
- sticker: brush.engine === "sticker" ? { nextId: 1 } : void 0
2980
+ sticker: brush.engine === "sticker" ? { nextId: 1 } : void 0,
2981
+ light: brush.light.amount > 0 && brush.engine !== "wet" && brush.engine !== "sticker" ? { surface: emptyLight() } : null
2634
2982
  }
2635
2983
  );
2636
2984
  session.push(thumbnailStroke(width, height));
2637
2985
  session.end();
2638
- return thick ? new LitCache().lit(result, thick, DEFAULT_LIGHTING) : result;
2986
+ const lit = thick ? new LitCache().lit(result, thick, DEFAULT_LIGHTING) : result;
2987
+ return { surface: light || thick ? new LightCache().glow(lit, light ?? void 0, thick ?? void 0) : lit, light };
2639
2988
  }
2640
2989
  function renderBrushThumbnail(brush, options) {
2641
2990
  const width = Math.max(16, Math.min(512, Math.round(options.width ?? 160)));
@@ -2652,9 +3001,15 @@ function renderBrushThumbnail(brush, options) {
2652
3001
  surface = fillRect(surface, { x: 0, y: 0, width, height: half }, rgba);
2653
3002
  surface = fillRect(surface, { x: 0, y: half, width, height: height - half }, [232, 196, 64, 255]);
2654
3003
  }
2655
- surface = b.engine === "wet" ? paintWetTestStroke(b, options.paper, options.color, width, height) : paintTestStroke(surface, b, options.paper, options.color, width, height);
3004
+ let light = null;
3005
+ if (b.engine === "wet") surface = paintWetTestStroke(b, options.paper, options.color, width, height);
3006
+ else ({ surface, light } = paintTestStroke(surface, b, options.paper, options.color, width, height));
2656
3007
  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 });
3008
+ const paper = light ? { ...options.paper, color: "#141414" } : options.paper;
3009
+ const spill = light ? computeSpill(surface, light, { radius: Math.max(4, height / 6), strength: 1 }) : null;
3010
+ const layers = [paperNode(paper), layer];
3011
+ 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 } });
3012
+ const data = createPaintCompositor().composite(layers, { x: 0, y: 0, width, height }, { width, height });
2658
3013
  return { width, height, data };
2659
3014
  }
2660
3015
 
@@ -3127,6 +3482,7 @@ function liveStrokeRefusal(brush) {
3127
3482
  case "smudge":
3128
3483
  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
3484
  }
3485
+ 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
3486
  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
3487
  return null;
3132
3488
  }
@@ -3148,6 +3504,212 @@ function liveStrokeBounds(stroke) {
3148
3504
  return { x: Math.floor(x0 - pad), y: Math.floor(y0 - pad), width: Math.ceil(x1 - x0 + 2 * pad), height: Math.ceil(y1 - y0 + 2 * pad) };
3149
3505
  }
3150
3506
 
3507
+ // src/paint-editor/backlight.ts
3508
+ var DEFAULT_BACKLIGHT_COLOR = "#fff1d6";
3509
+ var BACKLIGHT_SPILL = { radius: 24, strength: 1.5 };
3510
+ var BLOCK_HEIGHT = 96;
3511
+ var VALLEY_GAIN = 0.35;
3512
+ function backlightOf(paper) {
3513
+ return paper.backlight ?? 0;
3514
+ }
3515
+ var LIN = new Float64Array(256);
3516
+ for (let i = 0; i < 256; i++) LIN[i] = srgbToLinear(i / 255);
3517
+ function needAlpha(e, p) {
3518
+ if (e < p) return 1 - e / p;
3519
+ if (e > p) return (e - p) / (255 - p);
3520
+ return 0;
3521
+ }
3522
+ function tilesUnder(surface, rect, offset, out) {
3523
+ if (!surface) {
3524
+ out.push(null);
3525
+ return;
3526
+ }
3527
+ const x0 = Math.floor((rect.x - offset.x) / TILE);
3528
+ const y0 = Math.floor((rect.y - offset.y) / TILE);
3529
+ const x1 = Math.floor((rect.x - offset.x + rect.width - 1) / TILE);
3530
+ const y1 = Math.floor((rect.y - offset.y + rect.height - 1) / TILE);
3531
+ for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) out.push(surface.tiles.get(tileKey(tx, ty)) ?? null);
3532
+ }
3533
+ var sameRefs = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
3534
+ var BacklightCache = class {
3535
+ tiles = /* @__PURE__ */ new Map();
3536
+ key = "";
3537
+ spillOf = null;
3538
+ out = null;
3539
+ /**
3540
+ * The backlight layer's pixels for `layers` (bottom first) on `paper` over a `width` × `height`
3541
+ * canvas, or `null` when the paper has no backlight. `spillFrom` is the committed painting (the
3542
+ * spill is drawn from it, so a stroke in progress does not redo the blur every frame).
3543
+ */
3544
+ surface(paper, grain, width, height, layers, spillFrom) {
3545
+ const amount = backlightOf(paper);
3546
+ if (!(amount > 0)) return null;
3547
+ const key = JSON.stringify([amount, paper.backlightColor ?? DEFAULT_BACKLIGHT_COLOR, paper.color, paper.depth, grain.flat, width, height, layers.length, layers.map((l) => [l.opacity, l.offset.x, l.offset.y, l.surface.channels])]);
3548
+ if (key !== this.key) {
3549
+ this.tiles.clear();
3550
+ this.key = key;
3551
+ }
3552
+ const light = /* @__PURE__ */ new Map();
3553
+ const color = /* @__PURE__ */ new Map();
3554
+ const painted = /* @__PURE__ */ new Map();
3555
+ const tilesX = Math.ceil(width / TILE);
3556
+ const tilesY = Math.ceil(height / TILE);
3557
+ const refs = [];
3558
+ for (let ty = 0; ty < tilesY; ty++) {
3559
+ for (let tx = 0; tx < tilesX; tx++) {
3560
+ const rect = { x: tx * TILE, y: ty * TILE, width: Math.min(TILE, width - tx * TILE), height: Math.min(TILE, height - ty * TILE) };
3561
+ refs.length = 0;
3562
+ for (const l of layers) {
3563
+ tilesUnder(l.surface, rect, l.offset, refs);
3564
+ tilesUnder(l.thick?.height, rect, l.offset, refs);
3565
+ tilesUnder(l.thick?.material, rect, l.offset, refs);
3566
+ }
3567
+ const k = tileKey(tx, ty);
3568
+ let r = this.tiles.get(k);
3569
+ if (!r || !sameRefs(r.refs, refs)) {
3570
+ r = { refs: [...refs], ...computeTile(paper, grain, amount, rect, layers) };
3571
+ this.tiles.set(k, r);
3572
+ }
3573
+ if (r.light) light.set(k, r.light);
3574
+ if (r.color) color.set(k, r.color);
3575
+ if (r.painted) painted.set(k, r.painted);
3576
+ }
3577
+ }
3578
+ return { light: new TiledSurface(4, light, 0), color: new TiledSurface(4, color, 0), painted: new TiledSurface(1, painted, 0) };
3579
+ }
3580
+ /**
3581
+ * The full Backlight layer: the light through the paint (`current`) plus the spill of the light
3582
+ * through the committed paint (`committed`), both Linear Dodge. Cached on their tiles.
3583
+ */
3584
+ layer(current, committed, amount) {
3585
+ let spill = null;
3586
+ if (committed) {
3587
+ const s = this.spillOf;
3588
+ if (s && s.color.id === committed.color.id && s.painted.id === committed.painted.id) spill = s.out;
3589
+ else {
3590
+ spill = computeSpill(committed.color, committed.painted, { radius: BACKLIGHT_SPILL.radius, strength: BACKLIGHT_SPILL.strength * amount });
3591
+ this.spillOf = { color: committed.color, painted: committed.painted, out: spill };
3592
+ }
3593
+ }
3594
+ if (!spill) return current.light;
3595
+ const parts = [...current.light.tiles.values(), ...spill.tiles.values()];
3596
+ if (this.out && sameRefs(this.out.parts, parts)) return this.out.surface;
3597
+ const keys = /* @__PURE__ */ new Set([...current.light.tiles.keys(), ...spill.tiles.keys()]);
3598
+ const tiles = /* @__PURE__ */ new Map();
3599
+ for (const key of keys) {
3600
+ const a = current.light.tiles.get(key);
3601
+ const b = spill.tiles.get(key);
3602
+ const t = !a ? b : !b ? a : addTiles(a, b);
3603
+ if (t) tiles.set(key, t);
3604
+ }
3605
+ const surface = new TiledSurface(4, tiles, 0);
3606
+ this.out = { parts, surface };
3607
+ return surface;
3608
+ }
3609
+ };
3610
+ var full4 = (t) => {
3611
+ if (t.data.length === TILE * TILE * 4) return t.data;
3612
+ const out = new Uint8ClampedArray(TILE * TILE * 4);
3613
+ const u = t.uniform ?? 0;
3614
+ const bytes = [u >>> 24 & 255, u >>> 16 & 255, u >>> 8 & 255, u & 255];
3615
+ for (let i = 0; i < out.length; i += 4) out.set(bytes, i);
3616
+ return out;
3617
+ };
3618
+ function addTiles(a, b) {
3619
+ const x = full4(a);
3620
+ const y = full4(b);
3621
+ const out = new Uint8ClampedArray(TILE * TILE * 4);
3622
+ for (let i = 0; i < out.length; i += 4) {
3623
+ const ay = y[i + 3] / 255;
3624
+ const r = x[i] + y[i] * ay;
3625
+ const g = x[i + 1] + y[i + 1] * ay;
3626
+ const bl = x[i + 2] + y[i + 2] * ay;
3627
+ if (r < 0.5 && g < 0.5 && bl < 0.5) continue;
3628
+ out[i] = Math.round(r);
3629
+ out[i + 1] = Math.round(g);
3630
+ out[i + 2] = Math.round(bl);
3631
+ out[i + 3] = 255;
3632
+ }
3633
+ return finishTile(out, 4, 0);
3634
+ }
3635
+ function computeTile(paper, grain, amount, rect, layers) {
3636
+ const n = rect.width * rect.height;
3637
+ const [p0, p1, p2] = hexToRgb8(paper.color);
3638
+ const pb = [p0, p1, p2];
3639
+ const pl = [Math.max(1e-4, LIN[p0]), Math.max(1e-4, LIN[p1]), Math.max(1e-4, LIN[p2])];
3640
+ const [c0, c1, c2] = hexToRgb8(paper.backlightColor ?? DEFAULT_BACKLIGHT_COLOR);
3641
+ const t0 = new Float32Array(n).fill(amount * LIN[c0]);
3642
+ const t1 = new Float32Array(n).fill(amount * LIN[c1]);
3643
+ const t2 = new Float32Array(n).fill(amount * LIN[c2]);
3644
+ const depth = grain.flat ? 0 : paper.depth;
3645
+ if (depth > 0) {
3646
+ for (let y = 0, i = 0; y < rect.height; y++)
3647
+ for (let x = 0; x < rect.width; x++, i++) {
3648
+ const g = 1 + VALLEY_GAIN * depth * (0.5 - grain.at(rect.x + x + 0.5, rect.y + y + 0.5));
3649
+ t0[i] *= g;
3650
+ t1[i] *= g;
3651
+ t2[i] *= g;
3652
+ }
3653
+ }
3654
+ const clear = new Float32Array(n).fill(1);
3655
+ for (const l of layers) {
3656
+ const lr = { x: rect.x - l.offset.x, y: rect.y - l.offset.y, width: rect.width, height: rect.height };
3657
+ if (!l.surface.tiles.size) continue;
3658
+ const px = readRegion(l.surface, lr);
3659
+ const h = l.thick?.height.tiles.size ? readRegion(l.thick.height, lr) : null;
3660
+ const m = h && l.thick?.material.tiles.size ? readRegion(l.thick.material, lr) : null;
3661
+ const op = l.opacity;
3662
+ for (let i = 0; i < n; i++) {
3663
+ const a8 = px[i * 4 + 3];
3664
+ if (!a8 && !(h && h[i])) continue;
3665
+ const a = a8 / 255;
3666
+ let aMin = 0;
3667
+ const tr = [1, 1, 1];
3668
+ for (let c = 0; c < 3; c++) {
3669
+ const e = a * px[i * 4 + c] + (1 - a) * pb[c];
3670
+ aMin = Math.max(aMin, needAlpha(e, pb[c]));
3671
+ tr[c] = Math.min(1, LIN[Math.round(e)] / pl[c]);
3672
+ }
3673
+ const hide = aMin < 1 && a > aMin ? (a - aMin) / (1 - aMin) : 0;
3674
+ let k = 1 - hide;
3675
+ if (h && h[i]) {
3676
+ const ma = m ? m[i * 4 + 3] : 0;
3677
+ const clarity = ma ? (255 - ma) / 254 : 0;
3678
+ k *= 1 - Math.min(1, h[i] / BLOCK_HEIGHT) * (1 - clarity);
3679
+ }
3680
+ t0[i] *= 1 - op + op * tr[0] * k;
3681
+ t1[i] *= 1 - op + op * tr[1] * k;
3682
+ t2[i] *= 1 - op + op * tr[2] * k;
3683
+ clear[i] *= 1 - op * Math.max(a, h && h[i] ? 1 : 0);
3684
+ }
3685
+ }
3686
+ const light = new Uint8ClampedArray(TILE * TILE * 4);
3687
+ const color = new Uint8ClampedArray(TILE * TILE * 4);
3688
+ const painted = new Uint8ClampedArray(TILE * TILE);
3689
+ for (let y = 0, i = 0; y < rect.height; y++) {
3690
+ for (let x = 0; x < rect.width; x++, i++) {
3691
+ const o = (y * TILE + x) * 4;
3692
+ const add = [Math.min(1, t0[i]), Math.min(1, t1[i]), Math.min(1, t2[i])];
3693
+ const mx = Math.max(add[0], add[1], add[2]);
3694
+ if (mx > 0.5 / 255) {
3695
+ light[o] = Math.round(add[0] * 255);
3696
+ light[o + 1] = Math.round(add[1] * 255);
3697
+ light[o + 2] = Math.round(add[2] * 255);
3698
+ light[o + 3] = 255;
3699
+ }
3700
+ const pv = Math.round((1 - clear[i]) * 255);
3701
+ if (pv && mx > 0.5 / 255) {
3702
+ painted[y * TILE + x] = pv;
3703
+ color[o] = Math.round(linearToSrgb(add[0]) * 255);
3704
+ color[o + 1] = Math.round(linearToSrgb(add[1]) * 255);
3705
+ color[o + 2] = Math.round(linearToSrgb(add[2]) * 255);
3706
+ color[o + 3] = 255;
3707
+ }
3708
+ }
3709
+ }
3710
+ return { light: finishTile(light, 4, 0), color: finishTile(color, 4, 0), painted: finishTile(painted, 1, 0) };
3711
+ }
3712
+
3151
3713
  // src/paint-editor/replay.ts
3152
3714
  function replayItems(doc) {
3153
3715
  const items = [];
@@ -3535,6 +4097,12 @@ var PaintEditorController = class _PaintEditorController {
3535
4097
  dirty = "all";
3536
4098
  seedCounter = 1;
3537
4099
  strokeCounter = 1;
4100
+ /** Light paint: each layer's spill settings (saved with the document, not undo steps; missing = the defaults). */
4101
+ lightSpills = {};
4102
+ lightCaches = /* @__PURE__ */ new Map();
4103
+ /** Backlit paper: the light through the paint as drawn (live strokes too), and as committed (its spill). */
4104
+ backlightView = new BacklightCache();
4105
+ backlightCommitted = new BacklightCache();
3538
4106
  /** Live effect paint: playing and the frame rate cap (editor state), and the time the view shows. */
3539
4107
  effectPlaybackState = { playing: true, fps: 30 };
3540
4108
  effectTime = 0;
@@ -3596,6 +4164,12 @@ var PaintEditorController = class _PaintEditorController {
3596
4164
  this.doc = { ...this.doc, layers };
3597
4165
  this.emit(rects.length ? rects : "all");
3598
4166
  },
4167
+ layerLight: (id) => this.doc.light?.[id] ?? null,
4168
+ updateLights: (lights) => {
4169
+ let light = this.doc.light;
4170
+ for (const [id, surface] of lights) if (findLayer(this.doc.layers, id)) light = withLayerLight(light, id, surface);
4171
+ this.doc = { ...this.doc, ...light ? { light } : {} };
4172
+ },
3599
4173
  changed: () => {
3600
4174
  }
3601
4175
  },
@@ -4068,7 +4642,7 @@ var PaintEditorController = class _PaintEditorController {
4068
4642
  const layers = mapLayer(this.doc.layers, id, () => null);
4069
4643
  let active = this.doc.activeLayerId;
4070
4644
  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)) });
4645
+ 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
4646
  return { removed: id, activeLayerId: active };
4073
4647
  }
4074
4648
  duplicateLayer(id) {
@@ -4082,8 +4656,11 @@ var PaintEditorController = class _PaintEditorController {
4082
4656
  const thick = { ...this.doc.thick ?? {} };
4083
4657
  const stickers = { ...this.doc.stickers ?? {} };
4084
4658
  const liveStrokes = { ...this.doc.liveStrokes ?? {} };
4659
+ const light = { ...this.doc.light ?? {} };
4085
4660
  const copy = (l) => {
4086
4661
  const id2 = newPaintLayerId();
4662
+ if (light[l.id]) light[id2] = light[l.id];
4663
+ if (this.lightSpills[l.id]) this.lightSpills[id2] = { ...this.lightSpills[l.id] };
4087
4664
  if (thick[l.id]) thick[id2] = thick[l.id];
4088
4665
  if (stickers[l.id]) stickers[id2] = stickers[l.id].map((x) => ({ ...x }));
4089
4666
  if (liveStrokes[l.id]) liveStrokes[id2] = liveStrokes[l.id].map((x) => ({ ...x, id: `s${this.strokeCounter++}`, layerId: id2 }));
@@ -4091,7 +4668,7 @@ var PaintEditorController = class _PaintEditorController {
4091
4668
  };
4092
4669
  const dup = { ...copy(loc.layer), name: `${loc.layer.name} copy` };
4093
4670
  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 } : {} }));
4671
+ 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
4672
  return this.describeLayer(dup);
4096
4673
  }
4097
4674
  /** Moves a layer to `index` (0 = bottom) within `parentId` (default its own parent; `null` = top level). */
@@ -4137,7 +4714,11 @@ var PaintEditorController = class _PaintEditorController {
4137
4714
  }
4138
4715
  const merged = { ...below, surface, offset: { x: 0, y: 0 } };
4139
4716
  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]) }));
4717
+ const toDoc = (t, l) => t ? translateSurface(t, l.offset.x, l.offset.y) : void 0;
4718
+ 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;
4719
+ const lightAfter = this.lightWithout([upper.id, below.id]).light ?? (this.doc.light ? { ...this.doc.light } : void 0);
4720
+ const light = mergedLight ? { ...lightAfter ?? {}, [below.id]: mergedLight } : lightAfter;
4721
+ 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
4722
  return this.describeLayer(merged);
4142
4723
  }
4143
4724
  /** Removes every pixel of a layer. */
@@ -4149,7 +4730,7 @@ var PaintEditorController = class _PaintEditorController {
4149
4730
  this.wet.world.removeLane(l.id);
4150
4731
  }
4151
4732
  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 }));
4733
+ 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
4734
  return { layerId: l.id, bounds };
4154
4735
  }
4155
4736
  /**
@@ -4193,7 +4774,9 @@ var PaintEditorController = class _PaintEditorController {
4193
4774
  offset: l.offset
4194
4775
  };
4195
4776
  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 } }));
4777
+ const lit = this.doc.light?.[l.id];
4778
+ const light = lit ? withLayerLight(this.doc.light, l.id, scaleLight(lit, 1 - opacity)) : void 0;
4779
+ 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
4780
  return { layerId: l.id, bounds: rect };
4198
4781
  }
4199
4782
  applyCoverage(l, coverage, rect, apply) {
@@ -4851,6 +5434,9 @@ var PaintEditorController = class _PaintEditorController {
4851
5434
  })();
4852
5435
  const brush = this.strokeBrush(init);
4853
5436
  const effect = this.strokeEffect(init, brush);
5437
+ if (brush.light.amount > 0) {
5438
+ 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).`);
5439
+ }
4854
5440
  if (doc.liveStrokes?.[layer.id]) {
4855
5441
  const why = liveStrokeRefusal(brush);
4856
5442
  if (why) throw new PaintEditorError(`Layer "${layer.name}" is a stroke layer. ${why}`);
@@ -4947,6 +5533,8 @@ var PaintEditorController = class _PaintEditorController {
4947
5533
  secondary: rgbaOf(this.secondaryColor),
4948
5534
  wet,
4949
5535
  thick: brush.engine === "impasto" ? { channels: doc.thick?.[layer.id] ?? emptyThickChannels() } : void 0,
5536
+ // Light paint: a twin of the stroke paints the light channel (laying, dimming, moving light).
5537
+ light: !wet && brush.engine !== "sticker" && (brush.light.amount > 0 || doc.light?.[layer.id]) ? { surface: doc.light?.[layer.id] ?? emptyLight() } : null,
4950
5538
  sticker: brush.engine === "sticker" ? { nextId: this.nextStickerNumber(doc) } : void 0,
4951
5539
  paint: effect ? null : this.strokeGradient(init, brush, color),
4952
5540
  effect
@@ -5029,7 +5617,9 @@ var PaintEditorController = class _PaintEditorController {
5029
5617
  const thick = ch ? { ...this.doc.thick ?? {}, [l.id]: ch } : this.doc.thick;
5030
5618
  const sprayed = s.stickers();
5031
5619
  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);
5620
+ const wetLight = wetStroke && this.wet.world?.hasLane(l.id) ? this.wet.world.lightDisplay(l.id) : null;
5621
+ const light = withLayerLight(this.doc.light, l.id, s.lightChannel() ?? wetLight);
5622
+ 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
5623
  }
5034
5624
  };
5035
5625
  let session;
@@ -5062,12 +5652,14 @@ var PaintEditorController = class _PaintEditorController {
5062
5652
  const offset = s.init.wet ? { x: 0, y: 0 } : l.offset;
5063
5653
  const ch = s.thickChannels();
5064
5654
  const sprayed = s.stickers();
5655
+ const lit = s.lightChannel() ?? (s.init.wet && this.wet.world?.hasLane(l.id) ? this.wet.world.lightDisplay(l.id) : null);
5065
5656
  out = withLiveStroke({
5066
5657
  ...out,
5067
5658
  layers: mapLayer(out.layers, l.id, () => ({ ...l, surface, offset })),
5068
5659
  strokes: [...out.strokes, stroke],
5069
5660
  ...ch ? { thick: { ...out.thick ?? {}, [l.id]: ch } } : {},
5070
- ...sprayed ? { stickers: { ...out.stickers ?? {}, [l.id]: [...out.stickers?.[l.id] ?? [], ...sprayed] } } : {}
5661
+ ...sprayed ? { stickers: { ...out.stickers ?? {}, [l.id]: [...out.stickers?.[l.id] ?? [], ...sprayed] } } : {},
5662
+ ...lit ? { light: withLayerLight(out.light, l.id, lit) } : {}
5071
5663
  }, stroke);
5072
5664
  }
5073
5665
  };
@@ -5182,6 +5774,46 @@ var PaintEditorController = class _PaintEditorController {
5182
5774
  if (doc !== this.doc) this.commit(strokes.length === 1 && repeat === 1 ? "Replay stroke" : "Replay strokes", doc);
5183
5775
  return { strokeIds: ids, bounds, layerIds: [...layerIds] };
5184
5776
  }
5777
+ // --- Light paint --------------------------------------------------------------------
5778
+ /** The document's `light` without these layers (as a spread: `{}` when unchanged). */
5779
+ lightWithout(ids) {
5780
+ const t = this.doc.light;
5781
+ if (!t || !ids.some((id) => t[id])) return {};
5782
+ const out = { ...t };
5783
+ for (const id of ids) delete out[id];
5784
+ return { light: out };
5785
+ }
5786
+ lightSpillOf(layerId) {
5787
+ return this.lightSpills[layerId] ?? DEFAULT_LIGHT_SPILL;
5788
+ }
5789
+ /** A layer's light paint: whether it has any, where (document pixels), its brightest value 0..255, and its spill. */
5790
+ lightOf(layerId) {
5791
+ const l = this.wetLayer(layerId, "read the light of");
5792
+ const info = lightInfo(this.doc.light?.[l.id]);
5793
+ const bounds = info.bounds ? { ...info.bounds, x: info.bounds.x + l.offset.x, y: info.bounds.y + l.offset.y } : null;
5794
+ return { layerId: l.id, hasLight: info.peak > 0, bounds, peak: info.peak, spill: { ...this.lightSpillOf(l.id) } };
5795
+ }
5796
+ /** 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. */
5797
+ setLightSpill(layerId, update) {
5798
+ const l = this.wetLayer(layerId, "set the light spill of");
5799
+ try {
5800
+ this.lightSpills[l.id] = normalizeLightSpill(update, this.lightSpillOf(l.id));
5801
+ } catch (e) {
5802
+ throw new PaintEditorError(e.message);
5803
+ }
5804
+ this.emit("all");
5805
+ return { ...this.lightSpills[l.id] };
5806
+ }
5807
+ /** Takes a layer's light away; its paint keeps its colour. One undo step. Returns whether there was any. */
5808
+ clearLight(layerId) {
5809
+ this.requireIdle("clearing light");
5810
+ const l = this.paintTarget(layerId, "clear the light of");
5811
+ if (this.wet.bakedLight(l.id)) this.dryLayersFirst([l.id]);
5812
+ const t = this.lightWithout([l.id]);
5813
+ if (!t.light) return { layerId: l.id, cleared: false };
5814
+ this.commit("Clear light", { ...this.doc, light: t.light });
5815
+ return { layerId: l.id, cleared: true };
5816
+ }
5185
5817
  // --- Thick paint and light -------------------------------------------------------
5186
5818
  /** The document's `thick` without these layers (as a spread: `{}` when unchanged). */
5187
5819
  thickWithout(ids) {
@@ -5530,8 +6162,62 @@ var PaintEditorController = class _PaintEditorController {
5530
6162
  }
5531
6163
  return out;
5532
6164
  }
6165
+ /**
6166
+ * Light paint over the (lit) layers: glow, rim and clarity in each layer's pixels, and its spill
6167
+ * as a Linear Dodge (Add) layer directly above it (from the painting as committed, so a stroke in
6168
+ * progress does not redraw the blur every frame).
6169
+ */
6170
+ lightLayers(layers) {
6171
+ const light = this.doc.light ?? {};
6172
+ const ids = /* @__PURE__ */ new Set([...Object.keys(light), ...Object.keys(this.doc.thick ?? {})]);
6173
+ if (!ids.size) return layers;
6174
+ let out = layers;
6175
+ for (const id of ids) {
6176
+ const l = findLayer(out, id)?.layer;
6177
+ if (!isPixelLayer(l)) continue;
6178
+ let cache = this.lightCaches.get(id);
6179
+ if (!cache) this.lightCaches.set(id, cache = new LightCache());
6180
+ const glowed = cache.glow(l.surface, light[id], this.liveThick?.layerId === id ? this.liveThick.channels : this.doc.thick?.[id]);
6181
+ if (glowed !== l.surface) out = mapLayer(out, id, () => ({ ...l, surface: glowed }));
6182
+ const committed = findLayer(this.doc.layers, id)?.layer;
6183
+ const spill = light[id] && isPixelLayer(committed) ? cache.spill(committed.surface, light[id], this.lightSpillOf(id)) : null;
6184
+ 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 } });
6185
+ }
6186
+ return out;
6187
+ }
6188
+ /** Visible pixel layers bottom first, as the backlight reads them (group opacities multiplied in). */
6189
+ backlitLayers(layers, committed, opacity = 1, out = []) {
6190
+ for (const l of layers) {
6191
+ if (!l.visible) continue;
6192
+ if (l.type === "group") this.backlitLayers(l.children, committed, opacity * l.opacity, out);
6193
+ else if (isPixelLayer(l)) {
6194
+ const thick = !committed && this.liveThick?.layerId === l.id ? this.liveThick.channels : this.doc.thick?.[l.id];
6195
+ out.push({ surface: l.surface, offset: l.offset, opacity: opacity * l.opacity, ...thick ? { thick } : {} });
6196
+ }
6197
+ }
6198
+ return out;
6199
+ }
6200
+ /**
6201
+ * Backlit paper: the light that makes it through `base` (the layers as drawn), with the spill of
6202
+ * the light through the committed painting, as a Linear Dodge (Add) layer for the top of the
6203
+ * stack; `null` when the paper has no backlight.
6204
+ */
6205
+ backlightLayer(base) {
6206
+ const paper = this.doc.paper;
6207
+ const amount = backlightOf(paper);
6208
+ if (!(amount > 0)) return null;
6209
+ const { width, height } = this.doc.canvas;
6210
+ const grain = paperGrain(paper);
6211
+ const current = this.backlightView.surface(paper, grain, width, height, this.backlitLayers(base, false), null);
6212
+ const committed = base === this.doc.layers ? current : this.backlightCommitted.surface(paper, grain, width, height, this.backlitLayers(this.doc.layers, true), null);
6213
+ const surface = this.backlightView.layer(current, committed, amount);
6214
+ return { type: "pixel", id: BACKLIGHT_LAYER_ID, name: "Backlight", visible: true, opacity: 1, blend: "linear-dodge", surface, offset: { x: 0, y: 0 } };
6215
+ }
5533
6216
  renderLayers(options, view = false) {
5534
- let layers = this.litLayers((options.wet ?? "display") === "dried" ? this.driedLayers() : this.currentLayers());
6217
+ const base = (options.wet ?? "display") === "dried" ? this.driedLayers() : this.currentLayers();
6218
+ let layers = this.lightLayers(this.litLayers(base));
6219
+ const backlight = options.paper !== false && !options.layerIds ? this.backlightLayer(base) : null;
6220
+ if (backlight) layers = [...layers, backlight];
5535
6221
  if (options.layerIds) {
5536
6222
  const picked = [];
5537
6223
  for (const id of options.layerIds) picked.push(findLayer(layers, id)?.layer ?? this.requireLayer(id));
@@ -5560,13 +6246,13 @@ var PaintEditorController = class _PaintEditorController {
5560
6246
  }
5561
6247
  /** The canvas (or some layers), scaled so the longest side is at most `maxSide`. */
5562
6248
  renderPreview(options = {}) {
5563
- const full = this.compositePixels({ ...options, wet: options.wet ?? "display" });
6249
+ const full2 = this.compositePixels({ ...options, wet: options.wet ?? "display" });
5564
6250
  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) };
6251
+ const k = Math.min(1, side / Math.max(full2.width, full2.height));
6252
+ if (k >= 1) return full2;
6253
+ const w = Math.max(1, Math.round(full2.width * k));
6254
+ const h = Math.max(1, Math.round(full2.height * k));
6255
+ return { width: w, height: h, data: downsampleRgba(full2.data, full2.width, full2.height, w, h) };
5570
6256
  }
5571
6257
  // --- Save, load and export -------------------------------------------------------
5572
6258
  /** The document as JSON with inline PNG assets. */
@@ -5618,6 +6304,17 @@ var PaintEditorController = class _PaintEditorController {
5618
6304
  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
6305
  if (Object.keys(liveStrokes).length) ext.liveStrokes = liveStrokes;
5620
6306
  else delete ext.liveStrokes;
6307
+ const light = {};
6308
+ for (const [id, t] of Object.entries(this.doc.light ?? {})) {
6309
+ if (!findLayer(this.doc.layers, id)) continue;
6310
+ const saved = lightToJSON(this.wet.bakedLight(id) ?? t);
6311
+ if (saved) light[id] = saved;
6312
+ }
6313
+ if (Object.keys(light).length) ext.light = light;
6314
+ else delete ext.light;
6315
+ const spills = Object.fromEntries(Object.entries(this.lightSpills).filter(([id]) => findLayer(this.doc.layers, id)));
6316
+ if (Object.keys(spills).length) ext.lightSpill = spills;
6317
+ else delete ext.lightSpill;
5621
6318
  const effectLayers = effectLayersToJSON(this.doc.effectLayers ?? {}, (id) => isPixelLayer(findLayer(this.doc.layers, id)?.layer));
5622
6319
  if (effectLayers) ext.effectLayers = effectLayers;
5623
6320
  else delete ext.effectLayers;
@@ -5668,6 +6365,29 @@ var PaintEditorController = class _PaintEditorController {
5668
6365
  problems.push(`The effect paper could not be read (${e.message}); the plain paper shows.`);
5669
6366
  }
5670
6367
  }
6368
+ this.lightSpills = {};
6369
+ this.lightCaches.clear();
6370
+ if (ext.lightSpill && typeof ext.lightSpill === "object") {
6371
+ for (const [id, v] of Object.entries(ext.lightSpill)) {
6372
+ try {
6373
+ if (findLayer(state.layers, id)) this.lightSpills[id] = normalizeLightSpill(v ?? {});
6374
+ } catch {
6375
+ problems.push(`The light spill of layer "${id}" could not be read; it takes the defaults.`);
6376
+ }
6377
+ }
6378
+ }
6379
+ if (ext.light && typeof ext.light === "object") {
6380
+ const light = {};
6381
+ for (const [id, v] of Object.entries(ext.light)) {
6382
+ if (!isPixelLayer(findLayer(state.layers, id)?.layer)) continue;
6383
+ try {
6384
+ light[id] = lightFromJSON(v);
6385
+ } catch (e) {
6386
+ problems.push(`The light paint of layer "${id}" could not be read (${e.message}); it loads without light.`);
6387
+ }
6388
+ }
6389
+ if (Object.keys(light).length) state = { ...state, light };
6390
+ }
5671
6391
  const raw = ext.layerChannels;
5672
6392
  if (!raw || typeof raw !== "object") return state;
5673
6393
  const thick = {};
@@ -5709,6 +6429,8 @@ var PaintEditorController = class _PaintEditorController {
5709
6429
  }
5710
6430
  this.docRevision++;
5711
6431
  delete this.extensions.layerChannels;
6432
+ delete this.extensions.light;
6433
+ delete this.extensions.lightSpill;
5712
6434
  delete this.extensions.lighting;
5713
6435
  delete this.extensions.stickers;
5714
6436
  delete this.extensions.liveStrokes;
@@ -5742,13 +6464,13 @@ var PaintEditorController = class _PaintEditorController {
5742
6464
  return encodePng(img.width, img.height, img.data);
5743
6465
  }
5744
6466
  scaled(options) {
5745
- const full = this.compositePixels(options);
6467
+ const full2 = this.compositePixels(options);
5746
6468
  const s = options.scale ?? 1;
5747
6469
  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) };
6470
+ if (s === 1) return full2;
6471
+ const w = Math.max(1, Math.round(full2.width * s));
6472
+ const h = Math.max(1, Math.round(full2.height * s));
6473
+ return { width: w, height: h, data: downsampleRgba(full2.data, full2.width, full2.height, w, h) };
5752
6474
  }
5753
6475
  /**
5754
6476
  * The canvas as PNG, JPEG or WebP bytes. JPEG has no alpha, so it always includes the paper.
@@ -5771,7 +6493,9 @@ var PaintEditorController = class _PaintEditorController {
5771
6493
  * pixels), and the composite as the merged image.
5772
6494
  */
5773
6495
  exportPsd() {
5774
- const dried = this.litLayers(this.driedLayers(this.doc.layers));
6496
+ const driedBase = this.driedLayers(this.doc.layers);
6497
+ const backlight = this.backlightLayer(driedBase);
6498
+ const dried = [...this.lightLayers(this.litLayers(driedBase)), ...backlight ? [backlight] : []];
5775
6499
  const { width, height, dpi } = this.doc.canvas;
5776
6500
  const paperPixels = this.compositePixels({ layerIds: [], paper: true });
5777
6501
  const paperLayer = {
@@ -5802,6 +6526,12 @@ var PaintEditorController = class _PaintEditorController {
5802
6526
  const one = names.length === 1;
5803
6527
  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
6528
  }
6529
+ if (backlight) {
6530
+ result.warnings.push('Backlit paper is written as pixels: the light that comes through the paint is a "Backlight" Linear Dodge (Add) layer at the top. Opening the PSD again gives a plain pixel layer, not a backlight.');
6531
+ }
6532
+ if (Object.values(this.doc.light ?? {}).some((l) => l.tiles.size)) {
6533
+ 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.');
6534
+ }
5805
6535
  return result;
5806
6536
  }
5807
6537
  /** `render_preview` as a base64 PNG. */
@@ -6807,6 +7537,20 @@ var PaintEditorController = class _PaintEditorController {
6807
7537
  var MAX_IMAGE_SIDE = 8192;
6808
7538
  var movePoint = (p, dx, dy) => ({ x: p.x + dx, y: p.y + dy });
6809
7539
  var moveRect = (r, dx, dy) => ({ ...r, x: r.x + dx, y: r.y + dy });
7540
+ var SPILL_SUFFIX = "::light";
7541
+ function withLayerLight(light, layerId, next) {
7542
+ if (!next) return light ? { ...light } : void 0;
7543
+ const out = { ...light ?? {} };
7544
+ if (next.tiles.size) out[layerId] = next;
7545
+ else delete out[layerId];
7546
+ return out;
7547
+ }
7548
+ var BACKLIGHT_LAYER_ID = "__backlight";
7549
+ function insertAfter(layers, id, node) {
7550
+ const i = layers.findIndex((l) => l.id === id);
7551
+ if (i >= 0) return [...layers.slice(0, i + 1), node, ...layers.slice(i + 1)];
7552
+ return layers.map((l) => l.type === "group" ? { ...l, children: insertAfter(l.children, id, node) } : l);
7553
+ }
6810
7554
  function isLiveEffect(s) {
6811
7555
  return s.paint?.type === "effect" && s.paint.live === true;
6812
7556
  }
@@ -7796,7 +8540,7 @@ function createPaintWetTools(c) {
7796
8540
  {
7797
8541
  name: "advance_wet_paint",
7798
8542
  title: "Let the wet paint flow",
7799
- description: "Lets time pass for wet paint: the water spreads, runs downhill on a tilted canvas, granulates, blooms and evaporates, and pigment settles. Give steps (simulation steps; at the default 120 per second, 120 steps is one second) or seconds (default 1). Not an undo step, as time passing never is. Returns the steps run, the clock, stillWet and each wet layer\u2019s state. Paint dries by itself after a few thousand steps; dry_paint dries it at once.",
8543
+ description: "Lets time pass for wet paint: the water spreads, runs downhill on a tilted canvas, granulates, blooms and evaporates, and pigment settles. Give steps (simulation steps; at the default 120 per second, 120 steps is one second) or seconds (default 1). Not an undo step, as time passing never is. Returns the steps run, the clock, stillWet and each wet layer\u2019s state. Paint dries by itself after a few thousand steps; dry_paint dries it at once. Wet light paint (wet brushes with light.amount) flows with the pigment and settles where it dries into the layer\u2019s light (get_light).",
7800
8544
  inputSchema: {
7801
8545
  type: "object",
7802
8546
  properties: {
@@ -7955,6 +8699,39 @@ var WRITE4 = { readOnlyHint: false, destructiveHint: false, idempotentHint: fals
7955
8699
  var LAYER_ID3 = { type: "string", description: "Pixel layer id from list_layers (default the active layer)." };
7956
8700
  function createPaintThickTools(c) {
7957
8701
  return [
8702
+ {
8703
+ name: "get_light",
8704
+ title: "Read a layer\u2019s light paint",
8705
+ 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.",
8706
+ inputSchema: { type: "object", properties: { layerId: LAYER_ID3 }, additionalProperties: false },
8707
+ annotations: { title: "Read a layer\u2019s light paint", ...READ4 },
8708
+ coordinates: { out: ["bounds"] },
8709
+ handler: ({ layerId }) => c.lightOf(layerId)
8710
+ },
8711
+ {
8712
+ name: "set_light_spill",
8713
+ title: "Set how far a layer\u2019s light spills",
8714
+ 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.",
8715
+ inputSchema: {
8716
+ type: "object",
8717
+ properties: {
8718
+ layerId: LAYER_ID3,
8719
+ radius: { type: "number", minimum: 0, maximum: 512, description: "How far the light reaches, px (0 turns spill off; default 24)." },
8720
+ strength: { type: "number", minimum: 0, maximum: 2, description: "How strong the spill is, 0..2 (default 1)." }
8721
+ },
8722
+ additionalProperties: false
8723
+ },
8724
+ annotations: { title: "Set how far a layer\u2019s light spills", ...SET3 },
8725
+ handler: ({ layerId, radius, strength }) => c.setLightSpill(layerId, { radius, strength })
8726
+ },
8727
+ {
8728
+ name: "clear_light",
8729
+ title: "Clear a layer\u2019s light",
8730
+ description: "Takes a layer\u2019s light paint away; its paint keeps its colour (and relief). One undo step. Returns whether anything was cleared.",
8731
+ inputSchema: { type: "object", properties: { layerId: LAYER_ID3 }, additionalProperties: false },
8732
+ annotations: { title: "Clear a layer\u2019s light", ...WRITE4 },
8733
+ handler: ({ layerId }) => c.clearLight(layerId)
8734
+ },
7958
8735
  {
7959
8736
  name: "set_lighting",
7960
8737
  title: "Move the light",
@@ -8205,7 +8982,18 @@ var IMPASTO_BRUSH = {
8205
8982
  metallic: { type: "number", minimum: 0, maximum: 1, description: "Metallic paint: highlights take the paint colour (gold, copper, silver)." },
8206
8983
  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
8984
  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." }
8985
+ drag: { type: "number", minimum: 0, maximum: 1, description: "Knife and brush: how much of the paint under the tool is dragged along." },
8986
+ 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)." },
8987
+ 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." }
8988
+ },
8989
+ additionalProperties: false
8990
+ };
8991
+ var LIGHT_BRUSH = {
8992
+ type: "object",
8993
+ 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, impasto and wet paint brushes take it (wet light flows, pools, granulates and blooms with the pigment until the paint dries, then is ordinary light paint); wet tools other than paint lay none, and sticker brushes refuse light above 0. Missing fields keep their current values.",
8994
+ properties: {
8995
+ amount: { type: "number", minimum: 0, maximum: 1, description: "Light each dab lays, scaled by its coverage and flow (0 none, default; 1 full)." },
8996
+ pressure: { type: "boolean", description: "Pressure scales the light (default false)." }
8209
8997
  },
8210
8998
  additionalProperties: false
8211
8999
  };
@@ -8276,6 +9064,7 @@ var BRUSH_FIELDS = {
8276
9064
  particle: PARTICLE_BRUSH,
8277
9065
  bristle: BRISTLE_BRUSH,
8278
9066
  sticker: STICKER_BRUSH,
9067
+ light: LIGHT_BRUSH,
8279
9068
  paint: BRUSH_PAINT
8280
9069
  };
8281
9070
  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 +9110,7 @@ function brushSummary(b) {
8321
9110
  multicolor: b.behavior.multicolor.mode,
8322
9111
  ...b.engine === "wet" ? { wetTool: b.wet.tool, medium: b.wet.medium } : {},
8323
9112
  ...b.engine === "impasto" ? { impastoTool: b.impasto.tool } : {},
9113
+ ...b.light.amount > 0 ? { light: b.light.amount } : {},
8324
9114
  builtin: Boolean(b.builtin)
8325
9115
  };
8326
9116
  }
@@ -8431,7 +9221,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
8431
9221
  add({
8432
9222
  name: "get_paper",
8433
9223
  title: "Get the paper",
8434
- description: "Reads every setting of the current paper (preset, pattern, seed, colour, scale, tiling, depth, shading, dry and wet tuning, absorption, permeability, roughness).",
9224
+ description: "Reads every setting of the current paper (preset, pattern, seed, colour, scale, tiling, depth, shading, dry and wet tuning, absorption, permeability, roughness, and metallic, backlight and backlightColor when set).",
8435
9225
  inputSchema: EMPTY3,
8436
9226
  annotations: { title: "Get the paper", ...READ6 },
8437
9227
  handler: () => JSON.parse(JSON.stringify(c.document.paper))
@@ -8447,7 +9237,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
8447
9237
  add({
8448
9238
  name: "set_paper",
8449
9239
  title: "Set the paper",
8450
- description: "Changes the paper under the whole painting: a preset from list_papers and / or its settings. Grain depth decides how much grain-responsive brushes (pencil, pastel, charcoal) skip the valleys; changing it re-renders the paper but not strokes already painted. One undo step. Returns the paper.",
9240
+ description: "Changes the paper under the whole painting: a preset from list_papers and / or its settings. Grain depth decides how much grain-responsive brushes (pencil, pastel, charcoal) skip the valleys; changing it re-renders the paper but not strokes already painted. backlight lights the paper from behind (the stained-glass preset has it on). One undo step. Returns the paper.",
8451
9241
  inputSchema: {
8452
9242
  type: "object",
8453
9243
  properties: {
@@ -8465,6 +9255,8 @@ function createPaintEditorToolProvider(controller, options = {}) {
8465
9255
  permeability: { type: "number", minimum: 0, maximum: 1, description: "Wet media: how freely water flows over the fibres (low: more ragged, slower spreading)." },
8466
9256
  roughness: { type: "number", minimum: 0, maximum: 1, description: "Wet media: how much the grain pins wet edges and catches granulating pigment." },
8467
9257
  metallic: { type: "number", minimum: 0, maximum: 1, description: "Metallic paper (gold or silver leaf, pearlescent card): the grain catches the light (see set_lighting). 0 by default." },
9258
+ backlight: { type: "number", minimum: 0, maximum: 1, description: "Backlit paper (a lightbox, stained glass): light from behind the paper, 0 (none, the default) .. 1. Transparent glazes (watercolour, airbrush, thin marker) glow in their own colour; opaque paint (gouache, oil, solid ink) and thick paint block it; the light through paint spills softly around it. Nothing is repainted." },
9259
+ backlightColor: { ...COLOR3, description: "The backlight\u2019s colour #rrggbb (default #fff1d6, warm white)." },
8468
9260
  name: { type: "string", minLength: 1, description: "Display name." }
8469
9261
  },
8470
9262
  additionalProperties: false
@@ -8639,7 +9431,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
8639
9431
  add({
8640
9432
  name: "list_brushes",
8641
9433
  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.",
9434
+ 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, glow wash; 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
9435
  inputSchema: EMPTY3,
8644
9436
  annotations: { title: "List brushes", ...READ6 },
8645
9437
  handler: () => {
@@ -8823,7 +9615,7 @@ function createPaintEditorToolProvider(controller, options = {}) {
8823
9615
  add({
8824
9616
  name: "paint_stroke",
8825
9617
  title: "Paint a brush stroke",
8826
- description: "Paints one stroke through points (document pixels, with optional pressure, time, tilt and barrel rotation) on a pixel layer with a library brush or the active one, optionally changed for this stroke. Grain-responsive brushes follow the paper; the mixer engine (mixer-brush) picks up and mixes paint already on the layer, the blender mixes nearby colours, the smudge drags paint, and dab brushes with behavior.pickup lift and carry paint, and wet brushes (engine wet: watercolour, gouache, add water, dry, blow, wet blend, wet smudge, blot) paint on the layer\u2019s wet layer, where the paint keeps moving until it dries (the result says wet: true while it is; see advance_wet_paint and dry_paint; a dry brush on wet paint dries it first; wet oil brushes blend into the wet oil they meet), impasto brushes (engine impasto: impasto brush, paint tube, palette knife, roller, gloop pen, metallic paint) lay thick paint whose relief is lit by set_lighting, particle and bristle brushes draw procedural marks, and sticker brushes spray editable stickers onto an empty or sticker layer (list_stickers). Guide stencils make strokes that start near them follow them; mask stencils keep paint inside them. The symmetry guide adds its mirror copies; with a tracing image that samples colours, a stroke without color takes the image colour at its first point. With an agent cursor (a director), the stroke is painted live along the points at the pace of their times. One undo step. Returns the layer id, changed bounds, dab count, the recorded strokeId (for replay_strokes), for wet brushes wet (paint still wet) and, for brushes that pick up paint, mixerLoad: what the brush carries after the stroke.",
9618
+ description: "Paints one stroke through points (document pixels, with optional pressure, time, tilt and barrel rotation) on a pixel layer with a library brush or the active one, optionally changed for this stroke. Grain-responsive brushes follow the paper; the mixer engine (mixer-brush) picks up and mixes paint already on the layer, the blender mixes nearby colours, the smudge drags paint, and dab brushes with behavior.pickup lift and carry paint, and wet brushes (engine wet: watercolour, gouache, add water, dry, blow, wet blend, wet smudge, blot) paint on the layer\u2019s wet layer, where the paint keeps moving until it dries (the result says wet: true while it is; see advance_wet_paint and dry_paint; a dry brush on wet paint dries it first; wet oil brushes blend into the wet oil they meet; with light.amount, as glow-wash has, their light flows with the paint), impasto brushes (engine impasto: impasto brush, paint tube, palette knife, roller, gloop pen, metallic paint) lay thick paint whose relief is lit by set_lighting, particle and bristle brushes draw procedural marks, and sticker brushes spray editable stickers onto an empty or sticker layer (list_stickers). Guide stencils make strokes that start near them follow them; mask stencils keep paint inside them. The symmetry guide adds its mirror copies; with a tracing image that samples colours, a stroke without color takes the image colour at its first point. With an agent cursor (a director), the stroke is painted live along the points at the pace of their times. One undo step. Returns the layer id, changed bounds, dab count, the recorded strokeId (for replay_strokes), for wet brushes wet (paint still wet) and, for brushes that pick up paint, mixerLoad: what the brush carries after the stroke.",
8827
9619
  inputSchema: {
8828
9620
  type: "object",
8829
9621
  properties: {
@@ -9545,7 +10337,8 @@ var PAPER_SLIDERS = [
9545
10337
  { path: "absorption", label: "Absorption", min: 0, max: 1, step: 0.01 },
9546
10338
  { path: "permeability", label: "Permeability", min: 0, max: 1, step: 0.01 },
9547
10339
  { path: "roughness", label: "Roughness", min: 0, max: 1, step: 0.01 },
9548
- { path: "metallic", label: "Metallic", min: 0, max: 1, step: 0.01 }
10340
+ { path: "metallic", label: "Metallic", min: 0, max: 1, step: 0.01 },
10341
+ { path: "backlight", label: "Backlight", min: 0, max: 1, step: 0.01 }
9549
10342
  ];
9550
10343
  function readPath(obj, path) {
9551
10344
  let v = obj;
@@ -9594,11 +10387,19 @@ var BLEND_MENU_GROUPS = [
9594
10387
  { label: "Standard", modes: BLEND_MODES.filter((m) => !m.painterly && m.id !== "pigment") },
9595
10388
  { label: "Paint", modes: BLEND_MODES.filter((m) => m.painterly || m.id === "pigment") }
9596
10389
  ];
10390
+ var LIGHT_SLIDER = { path: "light.amount", label: "Light", min: 0, max: 1, step: 0.01 };
9597
10391
  var PICKUP_SLIDERS = [
9598
10392
  { path: "behavior.pickup", label: "Pick-up", min: 0, max: 1, step: 0.01 },
9599
10393
  { path: "behavior.mix", label: "Mix", min: 0, max: 1, step: 0.01 }
9600
10394
  ];
9601
10395
  function brushEngineControls(brush) {
10396
+ const c = engineControls(brush);
10397
+ const wetPaint = brush.engine === "wet" && brush.wet?.tool === "paint";
10398
+ if (!c || !(wetPaint || ["dab", "mixer", "particle", "bristle", "impasto"].includes(brush.engine))) return c;
10399
+ 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 }] : [];
10400
+ return { ...c, sliders: [...c.sliders, LIGHT_SLIDER, ...extra], light: true };
10401
+ }
10402
+ function engineControls(brush) {
9602
10403
  switch (brush.engine) {
9603
10404
  case "particle":
9604
10405
  return {
@@ -10128,6 +10929,7 @@ function createPaintEditorDocument(controller, options = {}) {
10128
10929
  });
10129
10930
  }
10130
10931
  export {
10932
+ BACKLIGHT_SPILL,
10131
10933
  BLEND_MENU_GROUPS,
10132
10934
  BLEND_MODES,
10133
10935
  BRUSH_ID,
@@ -10135,6 +10937,7 @@ export {
10135
10937
  BRUSH_SLIDERS,
10136
10938
  BUILTIN_BRUSHES,
10137
10939
  BristleStroke,
10940
+ DEFAULT_BACKLIGHT_COLOR,
10138
10941
  DEFAULT_BEHAVIOR,
10139
10942
  DEFAULT_BRISTLE,
10140
10943
  DEFAULT_BRUSH_ID,
@@ -10142,6 +10945,7 @@ export {
10142
10945
  DEFAULT_DYNAMICS,
10143
10946
  DEFAULT_FLOW,
10144
10947
  DEFAULT_IMPASTO,
10948
+ DEFAULT_LIGHT,
10145
10949
  DEFAULT_LIGHTING,
10146
10950
  DEFAULT_MULTI_COLOR,
10147
10951
  DEFAULT_PAINT_GRADIENT,
@@ -10259,6 +11063,7 @@ export {
10259
11063
  addPaintGradientStop,
10260
11064
  affineCopy,
10261
11065
  applyAffine,
11066
+ backlightOf,
10262
11067
  brushEngineControls,
10263
11068
  brushLoadColor,
10264
11069
  brushLoadForStroke,
@@ -10340,6 +11145,7 @@ export {
10340
11145
  normalizeFlow,
10341
11146
  normalizeHexColor,
10342
11147
  normalizeImpasto,
11148
+ normalizeLight,
10343
11149
  normalizeLighting,
10344
11150
  normalizeMultiColor,
10345
11151
  normalizePaintBrush,