@pixerate/editor 0.273.0 → 0.275.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 (97) hide show
  1. package/dist/animation-editor/index.cjs +60 -60
  2. package/dist/animation-editor/index.js +4 -4
  3. package/dist/brushes/index.cjs +102 -74
  4. package/dist/brushes/index.js +34 -6
  5. package/dist/brushes/patch.d.cts +1 -1
  6. package/dist/brushes/patch.d.ts +1 -1
  7. package/dist/brushes/preview.d.cts +3 -0
  8. package/dist/brushes/preview.d.ts +3 -0
  9. package/dist/brushes/profiles.d.cts +2 -0
  10. package/dist/brushes/profiles.d.ts +2 -0
  11. package/dist/brushes/schema.d.cts +4 -0
  12. package/dist/brushes/schema.d.ts +4 -0
  13. package/dist/brushes/types.d.cts +47 -0
  14. package/dist/brushes/types.d.ts +47 -0
  15. package/dist/{brushstrokeRender-5YBCLFDQ.cjs → brushstrokeRender-2RQL46WB.cjs} +15 -15
  16. package/dist/{brushstrokeRender-DZGFU3J4.js → brushstrokeRender-WGY4DZHF.js} +3 -3
  17. package/dist/canvas/index.cjs +13 -13
  18. package/dist/canvas/index.js +1 -1
  19. package/dist/{chunk-2XDNF73E.js → chunk-43XJC3PP.js} +1 -1
  20. package/dist/{chunk-GHGBIAWQ.cjs → chunk-6QNDGMK7.cjs} +263 -45
  21. package/dist/{chunk-2F3YDXCN.js → chunk-75SYKUOT.js} +1 -1
  22. package/dist/{chunk-M3RZOF3G.cjs → chunk-C6VB7CPY.cjs} +1 -1
  23. package/dist/{chunk-N5IMDAHM.cjs → chunk-DHC4E2DU.cjs} +89 -37
  24. package/dist/{chunk-NAHJV3O6.js → chunk-G5TCMKX5.js} +28 -11
  25. package/dist/{chunk-DZVRH63Y.js → chunk-IEEY5RJV.js} +1 -1
  26. package/dist/{chunk-7W2EQQBP.cjs → chunk-JDXWLCXF.cjs} +6 -6
  27. package/dist/{chunk-N3SKPQEH.js → chunk-LMG57L5A.js} +62 -10
  28. package/dist/{chunk-5HP2VSAS.js → chunk-MSWLJI4G.js} +1 -1
  29. package/dist/{chunk-SNAFFCAX.cjs → chunk-OLZXXAER.cjs} +221 -196
  30. package/dist/{chunk-YJANF2BL.cjs → chunk-RAPNRK4R.cjs} +86 -86
  31. package/dist/{chunk-CLB2I2FU.js → chunk-RJGW3RQS.js} +2 -2
  32. package/dist/{chunk-FXVCB6YG.cjs → chunk-T6KNJVBP.cjs} +35 -35
  33. package/dist/{chunk-ZEI7ZEA4.js → chunk-Y2ISSVPH.js} +34 -9
  34. package/dist/{chunk-3JKCREEU.cjs → chunk-Y354ETAE.cjs} +76 -59
  35. package/dist/{chunk-TNLKFW7D.cjs → chunk-YYZC2EQO.cjs} +23 -23
  36. package/dist/{chunk-UXYCY3HP.js → chunk-ZM7IKQ4X.js} +239 -21
  37. package/dist/image-editor/index.cjs +789 -789
  38. package/dist/image-editor/index.js +5 -5
  39. package/dist/paint/index.cjs +2 -2
  40. package/dist/paint/index.js +1 -1
  41. package/dist/paint/wet/world.d.cts +15 -2
  42. package/dist/paint/wet/world.d.ts +15 -2
  43. package/dist/paint-editor/brush-controls.d.cts +7 -0
  44. package/dist/paint-editor/brush-controls.d.ts +7 -0
  45. package/dist/paint-editor/brush-model.d.cts +59 -1
  46. package/dist/paint-editor/brush-model.d.ts +59 -1
  47. package/dist/paint-editor/collaboration.d.cts +6 -1
  48. package/dist/paint-editor/collaboration.d.ts +6 -1
  49. package/dist/paint-editor/controller.d.cts +199 -1
  50. package/dist/paint-editor/controller.d.ts +199 -1
  51. package/dist/paint-editor/dab-stroke.d.cts +3 -0
  52. package/dist/paint-editor/dab-stroke.d.ts +3 -0
  53. package/dist/paint-editor/document.d.cts +13 -3
  54. package/dist/paint-editor/document.d.ts +13 -3
  55. package/dist/paint-editor/engine.d.cts +3 -0
  56. package/dist/paint-editor/engine.d.ts +3 -0
  57. package/dist/paint-editor/evolution-tools.d.cts +48 -0
  58. package/dist/paint-editor/evolution-tools.d.ts +48 -0
  59. package/dist/paint-editor/evolution.d.cts +175 -0
  60. package/dist/paint-editor/evolution.d.ts +175 -0
  61. package/dist/paint-editor/heat.d.cts +156 -0
  62. package/dist/paint-editor/heat.d.ts +156 -0
  63. package/dist/paint-editor/index.cjs +4905 -1525
  64. package/dist/paint-editor/index.d.cts +5 -0
  65. package/dist/paint-editor/index.d.ts +5 -0
  66. package/dist/paint-editor/index.js +10952 -7572
  67. package/dist/paint-editor/mutation-tools.d.cts +13 -0
  68. package/dist/paint-editor/mutation-tools.d.ts +13 -0
  69. package/dist/paint-editor/mutation.d.cts +67 -0
  70. package/dist/paint-editor/mutation.d.ts +67 -0
  71. package/dist/paint-editor/presence.d.cts +7 -1
  72. package/dist/paint-editor/presence.d.ts +7 -1
  73. package/dist/paint-editor/procedural.d.cts +3 -0
  74. package/dist/paint-editor/procedural.d.ts +3 -0
  75. package/dist/paint-editor/stroke.d.cts +32 -2
  76. package/dist/paint-editor/stroke.d.ts +32 -2
  77. package/dist/paint-editor/thick.d.cts +48 -7
  78. package/dist/paint-editor/thick.d.ts +48 -7
  79. package/dist/paint-editor/ui.d.cts +36 -1
  80. package/dist/paint-editor/ui.d.ts +36 -1
  81. package/dist/paint-editor/wet.d.cts +8 -1
  82. package/dist/paint-editor/wet.d.ts +8 -1
  83. package/dist/vector/index.cjs +999 -999
  84. package/dist/vector/index.js +5 -5
  85. package/package.json +1 -1
  86. package/skills/README.md +1 -0
  87. package/skills/pixerate-brushes/SKILL.md +3 -0
  88. package/skills/pixerate-paint-editor/SKILL.md +36 -53
  89. package/skills/pixerate-paint-editor/references/brush-facets.md +3 -0
  90. package/skills/pixerate-paint-editor/references/mutation.md +25 -0
  91. package/skills/pixerate-paint-editor/references/oils.md +8 -1
  92. package/skills/pixerate-paint-editor/references/smart-inks.md +51 -0
  93. package/skills/pixerate-paint-mutation/SKILL.md +76 -0
  94. package/skills/pixerate-paint-mutation/evals/decline-unanswered.json +58 -0
  95. package/skills/pixerate-paint-mutation/evals/dry-ink-answer.json +74 -0
  96. package/skills/pixerate-paint-mutation/evals/wet-wash-answer.json +76 -0
  97. package/skills/pixerate-paint-oil-impasto/SKILL.md +2 -1
@@ -776,6 +776,69 @@ var BRUSH_PRESETS = [
776
776
  dynamics: { smoothing: 0.2, pressureSize: false, pressureOpacity: true },
777
777
  colour: { palette: ["#ef4444", "#f97316", "#facc15", "#22c55e", "#3b82f6", "#a855f7", "#111827", "#f8fafc"] }
778
778
  },
779
+ // --- Smart inks: UV-fluorescent and thermochromic ------------------------------------
780
+ // Invisible UV marker: invisible in normal light, glows brightly under the UV blacklight.
781
+ {
782
+ id: "uv-marker",
783
+ name: "UV marker",
784
+ kind: "mark",
785
+ medium: "ink",
786
+ variety: "uv-ink",
787
+ tip: { shape: "round", size: 18, hardness: 0.75, spacing: 0.06, opacity: 0.9 },
788
+ dynamics: { smoothing: 0.3, minSize: 0.5 },
789
+ fluorescence: { amount: 0.9, invisible: true },
790
+ colour: { default: "#22d3ee", palette: ["#22d3ee", "#a855f7", "#4ade80", "#f472b6"] }
791
+ },
792
+ // Thermochromic ink: shifts smoothly toward its hot colour where the canvas heat field warms it.
793
+ {
794
+ id: "thermo-ink",
795
+ name: "Thermochromic ink",
796
+ kind: "mark",
797
+ medium: "ink",
798
+ variety: "thermochromic",
799
+ tip: { shape: "round", size: 12, hardness: 0.85, spacing: 0.05 },
800
+ dynamics: { smoothing: 0.3, minSize: 0.4 },
801
+ thermochromic: { hotColor: "#ff7a18", threshold: 0.45 },
802
+ colour: { default: "#1e3a8a", palette: ["#1e3a8a", "#312e81", "#065f46", "#7f1d1d"] }
803
+ },
804
+ // --- Structural colour (thin-film iridescence) ---------------------------------------
805
+ // Scarab lacquer: thick metallic impasto under a high-index chitin-like thin film.
806
+ {
807
+ id: "scarab-lacquer",
808
+ name: "Scarab lacquer",
809
+ kind: "mark",
810
+ medium: "paint",
811
+ variety: "iridescent",
812
+ tip: { shape: "round", size: 22, hardness: 0.75, spacing: 0.06 },
813
+ dynamics: { smoothing: 0.25, minSize: 0.3 },
814
+ behaviour: { surface: "thick", thick: { thickness: 0.7, gloss: 0.85, metallic: 0.5 } },
815
+ iridescence: { thickness: 480, pressureToThickness: 0.35, ior: 1.65 },
816
+ colour: { default: "#065f46", palette: ["#065f46", "#1e3a8a", "#581c87", "#7c2d12", "#111827"] }
817
+ },
818
+ // Soap film: a soft glaze whose thin watery film shifts across the spectrum with pressure.
819
+ {
820
+ id: "soap-film",
821
+ name: "Soap film",
822
+ kind: "mark",
823
+ medium: "paint",
824
+ variety: "iridescent",
825
+ tip: { shape: "round", size: 32, hardness: 0.4, spacing: 0.06, opacity: 0.85, flow: 0.75 },
826
+ dynamics: { smoothing: 0.35, pressureOpacity: true, minSize: 0.35 },
827
+ iridescence: { thickness: 340, pressureToThickness: 0.8, ior: 1.33 },
828
+ colour: { default: { light: "#0284c7", dark: "#bae6fd" }, palette: [{ light: "#0284c7", dark: "#bae6fd" }, "#f472b6", "#a78bfa", "#34d399"] }
829
+ },
830
+ // Pearl glaze: a flat nacre glaze with gentle interference colour shifts.
831
+ {
832
+ id: "pearl-glaze",
833
+ name: "Pearl glaze",
834
+ kind: "mark",
835
+ medium: "paint",
836
+ variety: "iridescent",
837
+ tip: { shape: "flat", size: 28, hardness: 0.5, spacing: 0.06, angle: 25, roundness: 0.4, opacity: 0.9, flow: 0.85 },
838
+ dynamics: { smoothing: 0.3, rotation: "stroke", minSize: 0.4 },
839
+ iridescence: { thickness: 560, pressureToThickness: 0.25, ior: 1.55 },
840
+ colour: { default: { light: "#475569", dark: "#f1f5f9" }, palette: [{ light: "#475569", dark: "#f1f5f9" }, "#fbcfe8", "#ddd6fe", "#bae6fd", "#fef3c7"] }
841
+ },
779
842
  // --- Ghost paint: wet paint whose colour vanishes, leaving only what it smeared ---
780
843
  {
781
844
  id: "ghost-wash",
@@ -820,6 +883,28 @@ var BRUSH_PRESETS = [
820
883
  dynamics: { smoothing: 0.3, minSize: 0.4 },
821
884
  behaviour: { surface: "wet", wet: { water: 0.45, pigment: 0, rewet: 0.35 } },
822
885
  ghost: { smear: 0.65 }
886
+ },
887
+ // --- Mutation paint: strokes that sink away once claimed, and an agent answers ---
888
+ {
889
+ id: "mutation-wash",
890
+ name: "Mutation wash",
891
+ kind: "mark",
892
+ medium: "paint",
893
+ variety: "watercolor",
894
+ tip: { shape: "round", size: 28, hardness: 0.6, spacing: 0.12 },
895
+ dynamics: { smoothing: 0.25, pressureOpacity: true, minSize: 0.35 },
896
+ behaviour: { surface: "wet", wet: { water: 0.6, rewet: 0.2 } },
897
+ mutation: {}
898
+ },
899
+ {
900
+ id: "mutation-ink",
901
+ name: "Mutation ink",
902
+ kind: "mark",
903
+ medium: "ink",
904
+ variety: "pen",
905
+ tip: { shape: "round", size: 6, hardness: 0.9, spacing: 0.06 },
906
+ dynamics: { smoothing: 0.35, pressureSmoothing: 0.25, minSize: 0.25 },
907
+ mutation: {}
823
908
  }
824
909
  ];
825
910
 
@@ -942,11 +1027,51 @@ var DEFAULT_LIGHT = { amount: 0, pressure: false };
942
1027
  function normalizeLight(raw = {}, base = DEFAULT_LIGHT) {
943
1028
  return { amount: unit2(raw.amount, base.amount), pressure: typeof raw.pressure === "boolean" ? raw.pressure : base.pressure };
944
1029
  }
1030
+ var DEFAULT_FLUORESCENCE = { amount: 0, invisible: false };
1031
+ function normalizeFluorescence(raw = {}, base = DEFAULT_FLUORESCENCE) {
1032
+ if (typeof raw === "number") return { amount: unit2(raw, base.amount), invisible: base.invisible };
1033
+ return {
1034
+ amount: unit2(raw.amount, base.amount),
1035
+ invisible: typeof raw.invisible === "boolean" ? raw.invisible : base.invisible
1036
+ };
1037
+ }
1038
+ var DEFAULT_THERMOCHROMIC = { hotColor: null, threshold: 0.5 };
1039
+ function normalizeThermochromic(raw = {}, base = DEFAULT_THERMOCHROMIC) {
1040
+ const hotColor = raw.hotColor === null ? null : typeof raw.hotColor === "string" && HEX.test(raw.hotColor.trim()) ? raw.hotColor.trim().toLowerCase() : base.hotColor;
1041
+ return {
1042
+ hotColor,
1043
+ threshold: range(raw.threshold, 0.05, 0.95, base.threshold)
1044
+ };
1045
+ }
1046
+ var DEFAULT_IRIDESCENCE = { thickness: 400, pressureToThickness: 0, ior: 1.5 };
1047
+ var withIridescence = (ir) => ir ? { iridescence: ir } : {};
1048
+ function readIridescence(raw, base) {
1049
+ if (raw === null) return null;
1050
+ if (!raw || typeof raw !== "object") return base;
1051
+ const r = raw;
1052
+ const b = base ?? DEFAULT_IRIDESCENCE;
1053
+ return {
1054
+ thickness: range(r.thickness, 100, 1e3, b.thickness),
1055
+ pressureToThickness: unit2(r.pressureToThickness, b.pressureToThickness),
1056
+ ior: range(r.ior, 1.2, 2, b.ior)
1057
+ };
1058
+ }
1059
+ var GHOST_TRIGGERS = ["immediate", "agent"];
945
1060
  var DEFAULT_GHOST = { linger: 4, smear: 0.3 };
946
1061
  function normalizeGhost(raw = {}, base = DEFAULT_GHOST) {
1062
+ const trigger = raw.trigger === "agent" || raw.trigger === "immediate" ? raw.trigger : base.trigger;
947
1063
  return {
948
1064
  linger: range(raw.linger, 0.5, 60, base.linger),
949
- smear: unit2(raw.smear, base.smear)
1065
+ smear: unit2(raw.smear, base.smear),
1066
+ ...trigger === "agent" ? { trigger: "agent" } : {}
1067
+ };
1068
+ }
1069
+ var DEFAULT_MUTATION = { settle: 1.5, patience: 45, fade: 2 };
1070
+ function normalizeMutation(raw = {}, base = DEFAULT_MUTATION) {
1071
+ return {
1072
+ settle: range(raw.settle, 0.5, 10, base.settle),
1073
+ patience: range(raw.patience, 5, 300, base.patience),
1074
+ fade: range(raw.fade, 0.5, 10, base.fade)
950
1075
  };
951
1076
  }
952
1077
  function normalizeImpasto(raw = {}, base = DEFAULT_IMPASTO) {
@@ -1139,8 +1264,12 @@ function normalizePaintBrush(raw = {}, base) {
1139
1264
  behavior = { ...behavior, multicolor: paintToMultiColor(paint) ?? { ...DEFAULT_MULTI_COLOR, colors: [] } };
1140
1265
  const wet = normalizeWetBrush(raw.wet ?? {}, b.wet ?? DEFAULT_WET_BRUSH);
1141
1266
  const wantsLight = typeof raw.light?.amount === "number" && raw.light.amount > 0;
1142
- const ghost = engine === "wet" && wet.tool === "paint" && (raw.ghost !== void 0 ? raw.ghost !== null : !wantsLight && Boolean(b.ghost)) ? normalizeGhost(raw.ghost ?? {}, b.ghost ?? DEFAULT_GHOST) : void 0;
1143
- if (ghost) behavior = { ...behavior, pickup: 0, dirty: false };
1267
+ const canMutate = engine === "dab" || engine === "wet" && wet.tool === "paint" || engine === "impasto" || engine === "particle" || engine === "bristle";
1268
+ const mutation = canMutate && (raw.mutation !== void 0 ? raw.mutation !== null : Boolean(b.mutation)) ? normalizeMutation(raw.mutation ?? {}, b.mutation ?? DEFAULT_MUTATION) : void 0;
1269
+ const hadAutoGhost = Boolean(b.mutation && raw.mutation === null && raw.ghost === void 0);
1270
+ const ghostBase = mutation ? { ...b.ghost ?? DEFAULT_GHOST, linger: mutation.fade, trigger: "agent" } : b.ghost ?? DEFAULT_GHOST;
1271
+ const ghost = engine === "wet" && wet.tool === "paint" && !hadAutoGhost && (raw.ghost !== void 0 ? raw.ghost !== null : Boolean(mutation) || !wantsLight && Boolean(b.ghost)) ? normalizeGhost(raw.ghost ?? {}, ghostBase) : void 0;
1272
+ if (ghost || mutation) behavior = { ...behavior, pickup: 0, dirty: false };
1144
1273
  return {
1145
1274
  id,
1146
1275
  name: typeof raw.name === "string" && raw.name.trim() ? raw.name.trim().slice(0, 80) : b.name,
@@ -1162,7 +1291,11 @@ function normalizePaintBrush(raw = {}, base) {
1162
1291
  bristle: normalizeBristle(raw.bristle ?? {}, b.bristle ?? DEFAULT_BRISTLE),
1163
1292
  sticker: normalizeStickerSettings(raw.sticker ?? {}, b.sticker ?? DEFAULT_STICKER),
1164
1293
  light: ghost && !wantsLight ? { ...DEFAULT_LIGHT } : normalizeLight(raw.light ?? {}, b.light ?? DEFAULT_LIGHT),
1294
+ fluorescence: normalizeFluorescence(raw.fluorescence ?? {}, b.fluorescence ?? DEFAULT_FLUORESCENCE),
1295
+ thermochromic: normalizeThermochromic(raw.thermochromic ?? {}, b.thermochromic ?? DEFAULT_THERMOCHROMIC),
1296
+ ...withIridescence(readIridescence(raw.iridescence, b.iridescence ?? null)),
1165
1297
  ...ghost ? { ghost } : {},
1298
+ ...mutation ? { mutation } : {},
1166
1299
  stamp: readStamp(raw.stamp, b.stamp ?? null),
1167
1300
  spray: readSpray(raw.spray, b.spray ?? null),
1168
1301
  ...withTipGrain(readTipGrain(raw.tipGrain, b.tipGrain ?? null)),
@@ -1191,6 +1324,8 @@ var BASE_BRUSH = {
1191
1324
  bristle: { ...DEFAULT_BRISTLE },
1192
1325
  sticker: { ...DEFAULT_STICKER, stamps: [...DEFAULT_STICKER.stamps] },
1193
1326
  light: { ...DEFAULT_LIGHT },
1327
+ fluorescence: { ...DEFAULT_FLUORESCENCE },
1328
+ thermochromic: { ...DEFAULT_THERMOCHROMIC },
1194
1329
  stamp: null,
1195
1330
  spray: null,
1196
1331
  paint: null
@@ -1200,6 +1335,7 @@ var BASE_BRUSH = {
1200
1335
  var ALL = ["dab", "eraser", "mixer", "blender", "smudge", "wet", "impasto", "particle", "bristle", "sticker"];
1201
1336
  var EXCHANGE = ["dab", "wet", "impasto"];
1202
1337
  var DABS = ["dab", "eraser", "mixer", "blender", "smudge"];
1338
+ var FILM = ["dab", "mixer", "wet", "impasto", "particle", "bristle"];
1203
1339
  var NOT = { toEngine: (v) => !v, toFacet: (v) => !v };
1204
1340
  var EDITOR_STAMPS = {
1205
1341
  toEngine: (v) => v.flatMap((ref) => {
@@ -1229,6 +1365,9 @@ var BRUSH_FIELDS = [
1229
1365
  f("tip.grain", "tipGrain", DABS),
1230
1366
  f("behaviour.grain", "grain"),
1231
1367
  ...each("light", "light", ["amount", "pressure"]),
1368
+ ...each("fluorescence", "fluorescence", ["amount", "invisible"]),
1369
+ ...each("thermochromic", "thermochromic", ["hotColor", "threshold"]),
1370
+ ...each("iridescence", "iridescence", ["thickness", "pressureToThickness", "ior"], FILM),
1232
1371
  // Paint exchange: the dab, wet and impasto engines' behaviour, or the Mixer Brush's settings.
1233
1372
  f("behaviour.exchange.pickup", "behavior.pickup", EXCHANGE),
1234
1373
  f("behaviour.exchange.mix", "behavior.mix", EXCHANGE),
@@ -1251,7 +1390,8 @@ var BRUSH_FIELDS = [
1251
1390
  f("behaviour.strength", "wet.strength", ["wet"]),
1252
1391
  // Surfaces.
1253
1392
  ...each("behaviour.wet", "wet", ["medium", "water", "pigment", "body", "rewet", "load", "thinner"], ["wet"]),
1254
- ...each("ghost", "ghost", ["linger", "smear"], ["wet"]),
1393
+ ...each("ghost", "ghost", ["linger", "smear", "trigger"], ["wet"]),
1394
+ ...each("mutation", "mutation", ["settle", "patience", "fade"], ["dab", "wet", "impasto", "particle", "bristle"]),
1255
1395
  f("behaviour.thick.applicator", "impasto.tool", ["impasto"]),
1256
1396
  ...each("behaviour.thick", "impasto", ["thickness", "gloss", "metallic", "load", "thinner", "drag", "rim", "clarity"], ["impasto"]),
1257
1397
  // Marks.
@@ -1343,12 +1483,14 @@ function resolveBrush(brush, options = {}) {
1343
1483
  if (v !== void 0) setPath(input, field.engine, field.map ? field.map.toEngine(v) : copy(v));
1344
1484
  }
1345
1485
  if (engine === "dab" && brush.marks?.kind === "scatter") input.spray = { ...input.spray ?? {} };
1486
+ if (brush.iridescence && FILM.includes(engine)) input.iridescence = { ...input.iridescence ?? {} };
1346
1487
  if (engine === "wet") {
1347
1488
  setPath(input, "wet.tool", brush.kind === "tool" ? WET_TOOL_OF[brush.tool] : "paint");
1348
1489
  const medium = resolveWetMedium(brush);
1349
1490
  if (medium) setPath(input, "wet.medium", medium);
1350
1491
  if (brush.kind !== "tool" && brush.ghost) input.ghost = { ...input.ghost ?? {} };
1351
1492
  }
1493
+ if (brush.kind !== "tool" && brush.mutation) input.mutation = { ...input.mutation ?? {} };
1352
1494
  if (engine === "blender" || engine === "smudge") input.loadsColor = false;
1353
1495
  const paint = brush.colour?.default;
1354
1496
  if (paint && typeof paint === "object" && "type" in paint && paint.type === "gradient") input.paint = copy(paint);
@@ -1405,32 +1547,34 @@ function tipShape(b) {
1405
1547
  return Math.abs(Math.abs(b.tip.angle % 180) - 45) < 1e-9 || Math.abs(Math.abs(b.tip.angle % 180) - 135) < 1e-9 ? "chisel" : "flat";
1406
1548
  }
1407
1549
  function skeleton(b) {
1408
- const brush = { id: b.id, name: b.name, kind: "mark", tip: { shape: tipShape(b) } };
1409
- if (b.engine === "eraser" || b.engine === "blender" || b.engine === "smudge") return { ...brush, kind: "tool", tool: b.engine };
1550
+ const irid = b.iridescence ? { iridescence: {} } : {};
1551
+ const brush = { id: b.id, name: b.name, kind: "mark", tip: { shape: tipShape(b) }, ...irid };
1552
+ if (b.engine === "eraser" || b.engine === "blender" || b.engine === "smudge") return { id: b.id, name: b.name, kind: "tool", tool: b.engine, tip: { shape: tipShape(b) } };
1410
1553
  if (b.engine === "wet" && b.wet.tool !== "paint") {
1411
1554
  const tool = Object.keys(WET_TOOL_OF).find((t) => WET_TOOL_OF[t] === b.wet.tool);
1412
- return { ...brush, kind: "tool", tool };
1555
+ return { id: b.id, name: b.name, kind: "tool", tool, tip: { shape: tipShape(b) } };
1413
1556
  }
1557
+ const mut = b.mutation ? { mutation: {} } : {};
1414
1558
  switch (b.engine) {
1415
1559
  case "wet":
1416
- return { ...brush, medium: "paint", variety: b.wet.medium, behaviour: { surface: "wet" }, ...b.ghost ? { ghost: {} } : {} };
1560
+ return { ...brush, medium: "paint", variety: b.wet.medium, behaviour: { surface: "wet" }, ...b.ghost && !b.mutation ? { ghost: {} } : {}, ...mut };
1417
1561
  case "impasto":
1418
- return { ...brush, medium: "paint", behaviour: { surface: "thick" } };
1562
+ return { ...brush, medium: "paint", behaviour: { surface: "thick" }, ...mut };
1419
1563
  case "mixer":
1420
- return { ...brush, medium: "paint", variety: "mixer", behaviour: { exchange: { reservoir: {} } } };
1564
+ return { ...brush, medium: "paint", variety: "mixer", behaviour: { exchange: { reservoir: {} } }, ...mut };
1421
1565
  case "bristle":
1422
- return { ...brush, medium: "paint" };
1566
+ return { ...brush, medium: "paint", ...mut };
1423
1567
  case "particle":
1424
- return { ...brush, marks: { kind: "particles" } };
1568
+ return { ...brush, marks: { kind: "particles" }, ...mut };
1425
1569
  case "sticker":
1426
- return { ...brush, marks: { kind: "objects" } };
1570
+ return { id: b.id, name: b.name, kind: "mark", tip: { shape: tipShape(b) }, marks: { kind: "objects" }, ...mut };
1427
1571
  default: {
1428
1572
  const [medium, variety] = CATEGORY_MEDIUM[b.category] ?? [];
1429
- return { ...brush, ...medium ? { medium } : {}, ...variety ? { variety } : {}, ...b.engine === "dab" && b.spray ? { marks: { kind: "scatter" } } : {} };
1573
+ return { ...brush, ...medium ? { medium } : {}, ...variety ? { variety } : {}, ...b.engine === "dab" && b.spray ? { marks: { kind: "scatter" } } : {}, ...mut };
1430
1574
  }
1431
1575
  }
1432
1576
  }
1433
- var ORDER = ["id", "name", "description", "tags", "kind", "medium", "variety", "tool", "tip", "marks", "dynamics", "behaviour", "light", "ghost", "motion", "colour"];
1577
+ var ORDER = ["id", "name", "description", "tags", "kind", "medium", "variety", "tool", "tip", "marks", "dynamics", "behaviour", "light", "fluorescence", "thermochromic", "iridescence", "ghost", "mutation", "motion", "colour"];
1434
1578
  function migrateBrushV1(raw) {
1435
1579
  const b = normalizePaintBrush(raw);
1436
1580
  const brush = skeleton(b);
@@ -1453,6 +1597,7 @@ var BRUSH_MARKS_KINDS = ["continuous", "scatter", "particles", "objects"];
1453
1597
  var BRUSH_SURFACES = ["dry", "wet", "thick"];
1454
1598
  var BRUSH_GRAIN_MODES = ["moving", "texturised"];
1455
1599
  var BRUSH_FLIPS = ["none", "x", "y", "xy"];
1600
+ var GHOST_TRIGGERS2 = ["immediate", "agent"];
1456
1601
 
1457
1602
  // src/brushes/schema.ts
1458
1603
  var MAX_BRUSH_PALETTE = 16;
@@ -1645,9 +1790,28 @@ var LIGHT = object("Light the brush gives off with its colour (glow and spill).
1645
1790
  }),
1646
1791
  glow: bool("Suggest a glow layer effect for the layer (glow stays a layer effect).")
1647
1792
  });
1793
+ var FLUORESCENCE = object("UV-fluorescent ink: glows in its colour under the virtual UV blacklight (set_uv_light); invisible in normal light when invisible is true.", {
1794
+ amount: unit3("Fluorescence intensity under UV blacklight, 0 (none) .. 1."),
1795
+ invisible: bool("Invisible ink in normal light: lays no daylight paint and glows only under UV (default false).")
1796
+ });
1797
+ var THERMOCHROMIC = object("Thermochromic ink: shifts smoothly toward hotColor where the canvas heat field warms it past threshold.", {
1798
+ hotColor: { type: "string", pattern: "^#[0-9a-fA-F]{6}$", description: "Colour (#rrggbb) the ink turns when heated." },
1799
+ threshold: num(0.05, 0.95, "Temperature 0.05..0.95 at which the colour shift is halfway (default 0.5).")
1800
+ });
1801
+ var IRIDESCENCE = object("Structural colour: a thin dielectric film over the paint whose interference hue shifts with the light and view tilt. Missing fields take their defaults.", {
1802
+ thickness: num(100, 1e3, "Film thickness in nanometres, 100..1000 (default 400)."),
1803
+ pressureToThickness: unit3("How much pen pressure shifts the film thickness per dab, 0..1 (default 0: uniform thickness)."),
1804
+ ior: num(1.2, 2, "Refractive index of the film, 1.2..2.0 (default 1.5; water/soap ~1.33, pearl ~1.55, beetle lacquer ~1.65).")
1805
+ });
1648
1806
  var GHOST = object('Ghost paint: wet paint that flows and mixes visibly while wet, then fades away without leaving its colour, keeping only the smear it made in the paint it touched (needs behaviour.surface "wet").', {
1649
1807
  linger: num(0.5, 60, "Seconds of simulated time before the ghost paint has fully faded (default 4)."),
1650
- smear: unit3("How much the ghost paint drags and lifts the paint it passes through: 0 none .. 1 full (default 0.3).")
1808
+ smear: unit3("How much the ghost paint drags and lifts the paint it passes through: 0 none .. 1 full (default 0.3)."),
1809
+ trigger: oneOf2(GHOST_TRIGGERS2, "When the fade starts: immediate (default) counts down as soon as the paint is laid; agent holds the fade until read_mutation claims the stroke.")
1810
+ });
1811
+ var MUTATION = object("Mutation paint: you paint something and pause; once an agent claims it with read_mutation, your paint sinks away and the agent paints its own answer (any wet or dry paint brush that lays its own colour without reading paint beneath).", {
1812
+ settle: num(0.5, 10, "Seconds after the last stroke before it counts as finished and raises a mutation request (default 1.5)."),
1813
+ patience: num(5, 300, "Seconds to wait for an agent before giving up and keeping the paint as ordinary paint (default 45)."),
1814
+ fade: num(0.5, 10, "Seconds the paint takes to sink away once claimed by read_mutation (default 2).")
1651
1815
  });
1652
1816
  var MOTION = object("Animation after painting.", {
1653
1817
  live: bool("Effect paint keeps playing on stroke layers (default false)."),
@@ -1712,7 +1876,11 @@ var BRUSH_JSON_SCHEMA = object(
1712
1876
  dynamics: DYNAMICS,
1713
1877
  behaviour: BEHAVIOUR,
1714
1878
  light: LIGHT,
1879
+ fluorescence: FLUORESCENCE,
1880
+ thermochromic: THERMOCHROMIC,
1881
+ iridescence: IRIDESCENCE,
1715
1882
  ghost: GHOST,
1883
+ mutation: MUTATION,
1716
1884
  motion: MOTION,
1717
1885
  colour: COLOUR
1718
1886
  },
@@ -1725,7 +1893,11 @@ var BRUSH_FACET_SCHEMAS = {
1725
1893
  dynamics: DYNAMICS,
1726
1894
  behaviour: BEHAVIOUR,
1727
1895
  light: LIGHT,
1896
+ fluorescence: FLUORESCENCE,
1897
+ thermochromic: THERMOCHROMIC,
1898
+ iridescence: IRIDESCENCE,
1728
1899
  ghost: GHOST,
1900
+ mutation: MUTATION,
1729
1901
  motion: MOTION,
1730
1902
  colour: COLOUR,
1731
1903
  paint: PAINT
@@ -1968,11 +2140,13 @@ function normalizeBrushSpec(raw, problems = []) {
1968
2140
  else throw new Error(`Brush "${raw.id}" kind ${JSON.stringify(raw.kind)} is not valid: use mark (lays a material) or tool (works on paint already down).`);
1969
2141
  if (kind === "tool" && !tool) throw new Error(`Brush "${raw.id}" is a tool and needs tool: one of ${BRUSH_TOOLS.join(", ")}.`);
1970
2142
  const { colour: rawColour, marks: rawMarks, kind: _k, id: _i, name: _n, tool: _t, ...rest } = raw;
2143
+ if (typeof rest.fluorescence === "number" && Number.isFinite(rest.fluorescence)) rest.fluorescence = { amount: rest.fluorescence };
1971
2144
  const out = readObject(rest, BRUSH_JSON_SCHEMA, "brush", problems);
2145
+ if (out.thermochromic?.hotColor) out.thermochromic.hotColor = out.thermochromic.hotColor.toLowerCase();
1972
2146
  const brush = { id: raw.id, name, ...out, kind, ...tool ? { tool } : {}, tip: out.tip ?? { shape: "round" } };
1973
2147
  const ordered = () => {
1974
- const { id, name: n, description, tags, kind: k, medium, variety, tool: t, tip, marks, dynamics, behaviour, light, ghost, motion, colour } = brush;
1975
- const all = { id, name: n, description, tags, kind: k, medium, variety, tool: t, tip, marks, dynamics, behaviour, light, ghost, motion, colour };
2148
+ const { id, name: n, description, tags, kind: k, medium, variety, tool: t, tip, marks, dynamics, behaviour, light, fluorescence, thermochromic, iridescence, ghost, mutation, motion, colour } = brush;
2149
+ const all = { id, name: n, description, tags, kind: k, medium, variety, tool: t, tip, marks, dynamics, behaviour, light, fluorescence, thermochromic, iridescence, ghost, mutation, motion, colour };
1976
2150
  return Object.fromEntries(Object.entries(all).filter(([, v]) => v !== void 0));
1977
2151
  };
1978
2152
  if (!out.tip) problems.push("tip is missing; a round tip is used.");
@@ -2020,6 +2194,14 @@ function normalizeBrushSpec(raw, problems = []) {
2020
2194
  drop("marks", "tools lay no marks");
2021
2195
  delete brush.marks;
2022
2196
  }
2197
+ if (brush.iridescence) {
2198
+ drop("iridescence", "tools lay no film");
2199
+ delete brush.iridescence;
2200
+ }
2201
+ }
2202
+ if (brush.iridescence && brush.marks?.kind === "objects") {
2203
+ drop("iridescence", "object marks are stickers, not film");
2204
+ delete brush.iridescence;
2023
2205
  }
2024
2206
  if (brush.marks?.kind === "scatter") {
2025
2207
  const surface = brush.behaviour?.surface;
@@ -2135,7 +2317,8 @@ var PAINT_PROFILE = {
2135
2317
  velocity: "full",
2136
2318
  tilt: "full",
2137
2319
  tipGrain: "full",
2138
- dabCount: "full"
2320
+ dabCount: "full",
2321
+ iridescence: "full"
2139
2322
  };
2140
2323
  var IMAGE_PROFILE = {
2141
2324
  id: "image",
@@ -2154,7 +2337,8 @@ var IMAGE_PROFILE = {
2154
2337
  velocity: "ignored",
2155
2338
  tilt: "ignored",
2156
2339
  tipGrain: "ignored",
2157
- dabCount: "ignored"
2340
+ dabCount: "ignored",
2341
+ iridescence: "none"
2158
2342
  };
2159
2343
  var CANVAS_PROFILE = {
2160
2344
  id: "canvas",
@@ -2173,7 +2357,8 @@ var CANVAS_PROFILE = {
2173
2357
  velocity: "ignored",
2174
2358
  tilt: "full",
2175
2359
  tipGrain: "ignored",
2176
- dabCount: "ignored"
2360
+ dabCount: "ignored",
2361
+ iridescence: "none"
2177
2362
  };
2178
2363
  var VECTOR_PROFILE = { ...CANVAS_PROFILE, id: "vector", name: "Vector editor", kinds: ["mark", "tool"], tools: ["blender", "smudge"], surfaces: ["dry", "wet"], reservoir: "full", light: "ignored" };
2179
2364
  var BRUSH_HOST_PROFILES = { paint: PAINT_PROFILE, image: IMAGE_PROFILE, canvas: CANVAS_PROFILE, vector: VECTOR_PROFILE };
@@ -2210,7 +2395,19 @@ function checkBrush(brush, profile) {
2210
2395
  feature(profile.exchange, !ex?.reservoir && (nonZero(ex?.pickup) || !!ex?.dirty), "behaviour.exchange", "picking paint up", "Remove behaviour.exchange.");
2211
2396
  feature(profile.grain, nonZero(brush.behaviour?.grain), "behaviour.grain", "paper grain", "Set behaviour.grain to 0.");
2212
2397
  feature(profile.light, nonZero(brush.light?.amount), "light", "light paint (glow and spill)", "Remove light (or set light.amount to 0).");
2398
+ const smartInks = profile.id === "paint" ? "full" : "none";
2399
+ feature(smartInks, nonZero(brush.fluorescence?.amount), "fluorescence", "UV-fluorescent ink", "Remove fluorescence (or set fluorescence.amount to 0).");
2400
+ feature(smartInks, Boolean(brush.thermochromic?.hotColor), "thermochromic", "thermochromic ink", "Remove thermochromic.");
2401
+ feature(profile.iridescence ?? "none", !!brush.iridescence, "iridescence", "thin-film iridescence", "Remove iridescence.");
2213
2402
  feature(profile.surfaces.includes("wet") && profile.light === "full" ? "full" : "none", !!brush.ghost, "ghost", "ghost paint", "Remove ghost.");
2403
+ if (brush.mutation) {
2404
+ if (profile.id !== "paint") feature("none", true, "mutation", "mutation paint", "Remove mutation.");
2405
+ else if (brush.kind === "tool") add("mutation", "error", "Tools work on paint already down and cannot be mutation brushes.", "Use a mark brush, or remove mutation.");
2406
+ else if (brush.marks?.kind === "objects") add("mutation", "error", "Sticker brushes place objects rather than paint strokes and cannot be mutation brushes.", "Remove marks (or use continuous marks), or remove mutation.");
2407
+ else if (ex?.reservoir) add("mutation", "error", "A Mixer Brush reads paint from the layer beneath and cannot be a mutation brush.", "Remove behaviour.exchange.reservoir, or remove mutation.");
2408
+ else if (nonZero(ex?.pickup) || !!ex?.dirty) add("mutation", "error", "Mutation brushes must lay their own colour without picking up paint from beneath.", "Remove behaviour.exchange (or set pickup to 0 and dirty to false), or remove mutation.");
2409
+ else if (surface === "wet" && nonZero(brush.light?.amount)) add("mutation", "error", "Wet mutation brushes use the wet light channel as their fade tracer.", "Remove light (or set light.amount to 0), or remove mutation.");
2410
+ }
2214
2411
  const m = brush.motion;
2215
2412
  feature(profile.motion, !!m && (!!m.live || nonZero(m.waveSpeed) || m.fps !== void 0), "motion", "animation", "Remove motion.");
2216
2413
  const d = brush.dynamics ?? {};
@@ -2266,6 +2463,9 @@ function degrade(brush, profile, options = {}) {
2266
2463
  case "ghost":
2267
2464
  delete b.ghost;
2268
2465
  break;
2466
+ case "mutation":
2467
+ delete b.mutation;
2468
+ break;
2269
2469
  case "behaviour.exchange.reservoir":
2270
2470
  delete b.behaviour.exchange.reservoir;
2271
2471
  break;
@@ -2284,6 +2484,15 @@ function degrade(brush, profile, options = {}) {
2284
2484
  case "light":
2285
2485
  delete b.light;
2286
2486
  break;
2487
+ case "fluorescence":
2488
+ delete b.fluorescence;
2489
+ break;
2490
+ case "thermochromic":
2491
+ delete b.thermochromic;
2492
+ break;
2493
+ case "iridescence":
2494
+ delete b.iridescence;
2495
+ break;
2287
2496
  case "motion":
2288
2497
  delete b.motion;
2289
2498
  break;
@@ -2661,8 +2870,16 @@ export {
2661
2870
  DEFAULT_IMPASTO,
2662
2871
  DEFAULT_LIGHT,
2663
2872
  normalizeLight,
2873
+ DEFAULT_FLUORESCENCE,
2874
+ normalizeFluorescence,
2875
+ DEFAULT_THERMOCHROMIC,
2876
+ normalizeThermochromic,
2877
+ DEFAULT_IRIDESCENCE,
2878
+ GHOST_TRIGGERS,
2664
2879
  DEFAULT_GHOST,
2665
2880
  normalizeGhost,
2881
+ DEFAULT_MUTATION,
2882
+ normalizeMutation,
2666
2883
  normalizeImpasto,
2667
2884
  MULTI_COLOR_MODES,
2668
2885
  DEFAULT_MULTI_COLOR,
@@ -2704,6 +2921,7 @@ export {
2704
2921
  BRUSH_SURFACES,
2705
2922
  BRUSH_GRAIN_MODES,
2706
2923
  BRUSH_FLIPS,
2924
+ GHOST_TRIGGERS2,
2707
2925
  MAX_BRUSH_PALETTE,
2708
2926
  BRUSH_WORD,
2709
2927
  TIP_GRAIN_PATTERNS,