@pixerate/editor 0.275.1 → 0.277.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 (81) hide show
  1. package/dist/animation-editor/index.cjs +62 -62
  2. package/dist/animation-editor/index.js +6 -6
  3. package/dist/brushes/index.cjs +84 -71
  4. package/dist/brushes/index.js +20 -7
  5. package/dist/brushes/patch.d.cts +4 -4
  6. package/dist/brushes/patch.d.ts +4 -4
  7. package/dist/brushes/profiles.d.cts +4 -0
  8. package/dist/brushes/profiles.d.ts +4 -0
  9. package/dist/brushes/schema.d.cts +2 -0
  10. package/dist/brushes/schema.d.ts +2 -0
  11. package/dist/brushes/types.d.cts +32 -1
  12. package/dist/brushes/types.d.ts +32 -1
  13. package/dist/{brushstrokeRender-2RQL46WB.cjs → brushstrokeRender-7HOMKZNO.cjs} +21 -21
  14. package/dist/{brushstrokeRender-WGY4DZHF.js → brushstrokeRender-EIK3625T.js} +5 -5
  15. package/dist/canvas/index.cjs +13 -13
  16. package/dist/canvas/index.js +1 -1
  17. package/dist/{chunk-T6KNJVBP.cjs → chunk-4NZTK7LM.cjs} +35 -35
  18. package/dist/{chunk-LMG57L5A.js → chunk-CTHWXW5M.js} +121 -8
  19. package/dist/{chunk-43XJC3PP.js → chunk-DWPFGWW5.js} +1 -1
  20. package/dist/{chunk-MSWLJI4G.js → chunk-FNIWHSLI.js} +2 -2
  21. package/dist/{chunk-JDXWLCXF.cjs → chunk-H3EME3S5.cjs} +2 -2
  22. package/dist/{chunk-Y2ISSVPH.js → chunk-HHX7ST6Z.js} +167 -9
  23. package/dist/{chunk-6QNDGMK7.cjs → chunk-HRUK5TFW.cjs} +187 -45
  24. package/dist/{chunk-NIBFKXMD.cjs → chunk-IFWLZZCW.cjs} +619 -2
  25. package/dist/{chunk-W6JDUXP3.js → chunk-J23JOHPR.js} +1 -1
  26. package/dist/{chunk-HVO6XCI4.js → chunk-JPB65KQ6.js} +1 -1
  27. package/dist/{chunk-Y354ETAE.cjs → chunk-JVFGK3DK.cjs} +69 -58
  28. package/dist/{chunk-ACTHSQOI.js → chunk-LDZOXESA.js} +618 -1
  29. package/dist/{chunk-IEEY5RJV.js → chunk-LZE2QZCT.js} +1 -1
  30. package/dist/{chunk-DHC4E2DU.cjs → chunk-MITQGHN3.cjs} +121 -8
  31. package/dist/{chunk-75SYKUOT.js → chunk-N4MCWQZ7.js} +1 -1
  32. package/dist/{chunk-G5TCMKX5.js → chunk-NJLPW4MM.js} +17 -6
  33. package/dist/{chunk-STLTOISW.cjs → chunk-NPPMKMCU.cjs} +17 -17
  34. package/dist/{chunk-OLZXXAER.cjs → chunk-PEQWARCP.cjs} +408 -250
  35. package/dist/{chunk-ZM7IKQ4X.js → chunk-PPDUCZZV.js} +152 -10
  36. package/dist/{chunk-RAPNRK4R.cjs → chunk-QBZEC2JJ.cjs} +88 -88
  37. package/dist/{chunk-RJGW3RQS.js → chunk-ROURHJYT.js} +1 -1
  38. package/dist/{chunk-62MFUHSI.cjs → chunk-UQXULAMQ.cjs} +16 -16
  39. package/dist/{chunk-YYZC2EQO.cjs → chunk-VNF7A2JA.cjs} +23 -23
  40. package/dist/{chunk-C6VB7CPY.cjs → chunk-WFSYZO5Z.cjs} +1 -1
  41. package/dist/image-editor/index.cjs +880 -852
  42. package/dist/image-editor/index.js +36 -8
  43. package/dist/paint/index.cjs +33 -5
  44. package/dist/paint/index.d.cts +1 -0
  45. package/dist/paint/index.d.ts +1 -0
  46. package/dist/paint/index.js +32 -4
  47. package/dist/paint/live-patterns.d.cts +149 -0
  48. package/dist/paint/live-patterns.d.ts +149 -0
  49. package/dist/paint/wet/world.d.cts +13 -0
  50. package/dist/paint/wet/world.d.ts +13 -0
  51. package/dist/paint-editor/brush-model.d.cts +21 -1
  52. package/dist/paint-editor/brush-model.d.ts +21 -1
  53. package/dist/paint-editor/controller.d.cts +102 -1
  54. package/dist/paint-editor/controller.d.ts +102 -1
  55. package/dist/paint-editor/document.d.cts +8 -1
  56. package/dist/paint-editor/document.d.ts +8 -1
  57. package/dist/paint-editor/ferro-tools.d.cts +10 -0
  58. package/dist/paint-editor/ferro-tools.d.ts +10 -0
  59. package/dist/paint-editor/ferro.d.cts +134 -0
  60. package/dist/paint-editor/ferro.d.ts +134 -0
  61. package/dist/paint-editor/index.cjs +2291 -904
  62. package/dist/paint-editor/index.d.cts +2 -0
  63. package/dist/paint-editor/index.d.ts +2 -0
  64. package/dist/paint-editor/index.js +1533 -146
  65. package/dist/paint-editor/stroke.d.cts +14 -0
  66. package/dist/paint-editor/stroke.d.ts +14 -0
  67. package/dist/paint-editor/thick.d.cts +15 -2
  68. package/dist/paint-editor/thick.d.ts +15 -2
  69. package/dist/paint-editor/ui.d.cts +7 -1
  70. package/dist/paint-editor/ui.d.ts +7 -1
  71. package/dist/paint-editor/wet-tools.d.cts +42 -0
  72. package/dist/paint-editor/wet-tools.d.ts +42 -0
  73. package/dist/paint-editor/wet.d.cts +22 -1
  74. package/dist/paint-editor/wet.d.ts +22 -1
  75. package/dist/vector/index.cjs +1002 -1002
  76. package/dist/vector/index.js +7 -7
  77. package/package.json +1 -1
  78. package/skills/pixerate-brushes/SKILL.md +1 -0
  79. package/skills/pixerate-paint-editor/SKILL.md +11 -29
  80. package/skills/pixerate-paint-editor/references/brush-facets.md +1 -0
  81. package/skills/pixerate-paint-editor/references/ferrofluid.md +36 -0
@@ -9,17 +9,21 @@ import {
9
9
  import {
10
10
  DEFAULT_BLENDER,
11
11
  DEFAULT_BRUSH,
12
+ DEFAULT_LIVE_PATTERNS,
12
13
  DEFAULT_MIXER,
13
14
  DEFAULT_SMUDGE,
14
15
  GRAIN_PATTERNS,
16
+ LIVE_PATTERN_KINDS,
17
+ LIVE_PATTERN_PRESETS,
15
18
  MAX_BRUSH_SIZE,
16
19
  PAINT_GRADIENT_JSON_SCHEMA,
17
20
  normalizeBlender,
18
21
  normalizeBrush,
22
+ normalizeLivePatterns,
19
23
  normalizeMixer,
20
24
  normalizeSmudge,
21
25
  readPaintSource
22
- } from "./chunk-ACTHSQOI.js";
26
+ } from "./chunk-LDZOXESA.js";
23
27
  import {
24
28
  BLEND_MODES
25
29
  } from "./chunk-IRSTHLQQ.js";
@@ -278,6 +282,29 @@ var BRUSH_PRESETS = [
278
282
  behaviour: { surface: "wet", exchange: { pickup: 0.4, mix: 0.4 }, wet: { water: 0.8, pigment: 0.45, rewet: 0.2 } },
279
283
  colour: { default: { type: "gradient", stops: [{ offset: 0, color: "paint" }, { offset: 1, color: "secondary" }], space: "pigment", map: { by: "distance", length: 500, repeat: "mirror", unit: "px" } } }
280
284
  },
285
+ // Live patterns: Gray-Scott reaction-diffusion that grows inside the wet wash and freezes as it dries.
286
+ {
287
+ id: "turing-wash",
288
+ name: "Turing wash",
289
+ kind: "mark",
290
+ medium: "paint",
291
+ variety: "watercolor",
292
+ tip: { shape: "round", size: 36, hardness: 0.5, spacing: 0.12 },
293
+ dynamics: { smoothing: 0.25, pressureOpacity: true, minSize: 0.35 },
294
+ behaviour: { surface: "wet", exchange: { pickup: 0.45 }, wet: { water: 0.75, pigment: 0.55, rewet: 0.2 } },
295
+ livePatterns: { pattern: "spots", contrast: 1.2, secondColor: "#0ea5e9", relief: 0.25 }
296
+ },
297
+ {
298
+ id: "coral-ink",
299
+ name: "Coral ink",
300
+ kind: "mark",
301
+ medium: "ink",
302
+ variety: "watercolor",
303
+ tip: { shape: "round", size: 22, hardness: 0.65, spacing: 0.12 },
304
+ dynamics: { smoothing: 0.3, pressureOpacity: true, minSize: 0.3 },
305
+ behaviour: { surface: "wet", exchange: { pickup: 0.35 }, wet: { water: 0.7, pigment: 0.75, rewet: 0.15 } },
306
+ livePatterns: { pattern: "coral", scale: 1.2, contrast: 1.3 }
307
+ },
281
308
  {
282
309
  id: "watercolor-detail",
283
310
  name: "Watercolour rigger",
@@ -905,6 +932,29 @@ var BRUSH_PRESETS = [
905
932
  tip: { shape: "round", size: 6, hardness: 0.9, spacing: 0.06 },
906
933
  dynamics: { smoothing: 0.35, pressureSmoothing: 0.25, minSize: 0.25 },
907
934
  mutation: {}
935
+ },
936
+ // --- Ferrofluid paint: liquid magnetic paint that rises into lit Rosensweig spikes near magnets ---
937
+ {
938
+ id: "ferrofluid",
939
+ name: "Ferrofluid",
940
+ kind: "mark",
941
+ medium: "paint",
942
+ variety: "ferrofluid",
943
+ tip: { shape: "round", size: 28, hardness: 0.75, flow: 0.9 },
944
+ dynamics: { smoothing: 0.35 },
945
+ ferro: { amount: 0.85 },
946
+ colour: { default: "#111827", palette: ["#111827", "#1e293b", "#312e81", "#0f3d3e", "#3b0764", "#451a03"] }
947
+ },
948
+ {
949
+ id: "ferro-ink",
950
+ name: "Ferro ink",
951
+ kind: "mark",
952
+ medium: "ink",
953
+ variety: "ferrofluid",
954
+ tip: { shape: "round", size: 12, spacing: 0.08 },
955
+ dynamics: { smoothing: 0.45, minSize: 0.25, velocitySize: -0.25 },
956
+ ferro: { amount: 0.7, wavelength: 12, threshold: 0.15 },
957
+ colour: { default: "#0f172a" }
908
958
  }
909
959
  ];
910
960
 
@@ -1074,6 +1124,18 @@ function normalizeMutation(raw = {}, base = DEFAULT_MUTATION) {
1074
1124
  fade: range(raw.fade, 0.5, 10, base.fade)
1075
1125
  };
1076
1126
  }
1127
+ var DEFAULT_FERRO = { amount: 0, wavelength: 16, threshold: 0.12 };
1128
+ function normalizeFerro(raw = {}, base = DEFAULT_FERRO) {
1129
+ if (typeof raw === "number") {
1130
+ return { amount: unit2(raw, base.amount), wavelength: base.wavelength, threshold: base.threshold };
1131
+ }
1132
+ const r = raw ?? {};
1133
+ return {
1134
+ amount: unit2(r.amount, base.amount),
1135
+ wavelength: range(r.wavelength, 4, 64, base.wavelength),
1136
+ threshold: range(r.threshold, 0.01, 0.95, base.threshold)
1137
+ };
1138
+ }
1077
1139
  function normalizeImpasto(raw = {}, base = DEFAULT_IMPASTO) {
1078
1140
  return {
1079
1141
  tool: IMPASTO_TOOLS.includes(raw.tool) ? raw.tool : base.tool,
@@ -1152,6 +1214,12 @@ function countAndFlip(raw, base) {
1152
1214
  return { ...n > 1 ? { count: n } : {}, ...flip !== "none" ? { flip } : {} };
1153
1215
  }
1154
1216
  var withTipGrain = (g) => g ? { tipGrain: g } : {};
1217
+ var withLivePatterns = (lp) => lp ? { livePatterns: lp } : {};
1218
+ function readLivePatterns(raw, base) {
1219
+ if (raw === null) return null;
1220
+ if (!raw || typeof raw !== "object") return base;
1221
+ return normalizeLivePatterns(raw, base ?? DEFAULT_LIVE_PATTERNS);
1222
+ }
1155
1223
  function readTipGrain(raw, base) {
1156
1224
  if (raw === null) return null;
1157
1225
  if (!raw || typeof raw !== "object") return base;
@@ -1296,9 +1364,11 @@ function normalizePaintBrush(raw = {}, base) {
1296
1364
  ...withIridescence(readIridescence(raw.iridescence, b.iridescence ?? null)),
1297
1365
  ...ghost ? { ghost } : {},
1298
1366
  ...mutation ? { mutation } : {},
1367
+ ferro: normalizeFerro(raw.ferro, b.ferro ?? DEFAULT_FERRO),
1299
1368
  stamp: readStamp(raw.stamp, b.stamp ?? null),
1300
1369
  spray: readSpray(raw.spray, b.spray ?? null),
1301
1370
  ...withTipGrain(readTipGrain(raw.tipGrain, b.tipGrain ?? null)),
1371
+ ...withLivePatterns(readLivePatterns(raw.livePatterns, b.livePatterns ?? null)),
1302
1372
  paint,
1303
1373
  ...raw.builtin ? { builtin: true } : {}
1304
1374
  };
@@ -1326,6 +1396,7 @@ var BASE_BRUSH = {
1326
1396
  light: { ...DEFAULT_LIGHT },
1327
1397
  fluorescence: { ...DEFAULT_FLUORESCENCE },
1328
1398
  thermochromic: { ...DEFAULT_THERMOCHROMIC },
1399
+ ferro: { ...DEFAULT_FERRO },
1329
1400
  stamp: null,
1330
1401
  spray: null,
1331
1402
  paint: null
@@ -1352,6 +1423,23 @@ var ONE_STAMP = {
1352
1423
  toEngine: (v) => v[0] ?? null,
1353
1424
  toFacet: (v) => v ? [v] : void 0
1354
1425
  };
1426
+ var LIVE_PATTERNS_MAP = {
1427
+ toEngine: (v) => v !== null && typeof v === "object" ? JSON.parse(JSON.stringify(v)) : v,
1428
+ toFacet: (v) => {
1429
+ if (!v || typeof v !== "object") return void 0;
1430
+ const lp = v;
1431
+ const preset = LIVE_PATTERN_PRESETS[lp.pattern] ?? LIVE_PATTERN_PRESETS.spots;
1432
+ return {
1433
+ pattern: lp.pattern,
1434
+ ...lp.feed !== preset.feed ? { feed: lp.feed } : {},
1435
+ ...lp.kill !== preset.kill ? { kill: lp.kill } : {},
1436
+ ...lp.scale !== DEFAULT_LIVE_PATTERNS.scale ? { scale: lp.scale } : {},
1437
+ ...lp.contrast !== DEFAULT_LIVE_PATTERNS.contrast ? { contrast: lp.contrast } : {},
1438
+ ...lp.secondColor !== null ? { secondColor: lp.secondColor } : {},
1439
+ ...lp.relief !== DEFAULT_LIVE_PATTERNS.relief ? { relief: lp.relief } : {}
1440
+ };
1441
+ }
1442
+ };
1355
1443
  var f = (facet, engine, engines = ALL, extra = {}) => ({ facet, engine, engines, ...extra });
1356
1444
  var each = (facetPrefix, enginePrefix, keys, engines) => keys.map((k) => f(`${facetPrefix}.${k}`, `${enginePrefix}.${k}`, engines));
1357
1445
  var BRUSH_FIELDS = [
@@ -1368,6 +1456,7 @@ var BRUSH_FIELDS = [
1368
1456
  ...each("fluorescence", "fluorescence", ["amount", "invisible"]),
1369
1457
  ...each("thermochromic", "thermochromic", ["hotColor", "threshold"]),
1370
1458
  ...each("iridescence", "iridescence", ["thickness", "pressureToThickness", "ior"], FILM),
1459
+ ...each("ferro", "ferro", ["amount", "wavelength", "threshold"]),
1371
1460
  // Paint exchange: the dab, wet and impasto engines' behaviour, or the Mixer Brush's settings.
1372
1461
  f("behaviour.exchange.pickup", "behavior.pickup", EXCHANGE),
1373
1462
  f("behaviour.exchange.mix", "behavior.mix", EXCHANGE),
@@ -1390,6 +1479,7 @@ var BRUSH_FIELDS = [
1390
1479
  f("behaviour.strength", "wet.strength", ["wet"]),
1391
1480
  // Surfaces.
1392
1481
  ...each("behaviour.wet", "wet", ["medium", "water", "pigment", "body", "rewet", "load", "thinner"], ["wet"]),
1482
+ f("livePatterns", "livePatterns", ["wet"], { map: LIVE_PATTERNS_MAP }),
1393
1483
  ...each("ghost", "ghost", ["linger", "smear", "trigger"], ["wet"]),
1394
1484
  ...each("mutation", "mutation", ["settle", "patience", "fade"], ["dab", "wet", "impasto", "particle", "bristle"]),
1395
1485
  f("behaviour.thick.applicator", "impasto.tool", ["impasto"]),
@@ -1486,6 +1576,7 @@ function resolveBrush(brush, options = {}) {
1486
1576
  if (brush.iridescence && FILM.includes(engine)) input.iridescence = { ...input.iridescence ?? {} };
1487
1577
  if (engine === "wet") {
1488
1578
  setPath(input, "wet.tool", brush.kind === "tool" ? WET_TOOL_OF[brush.tool] : "paint");
1579
+ if (brush.kind === "tool") delete input.livePatterns;
1489
1580
  const medium = resolveWetMedium(brush);
1490
1581
  if (medium) setPath(input, "wet.medium", medium);
1491
1582
  if (brush.kind !== "tool" && brush.ghost) input.ghost = { ...input.ghost ?? {} };
@@ -1574,7 +1665,7 @@ function skeleton(b) {
1574
1665
  }
1575
1666
  }
1576
1667
  }
1577
- var ORDER = ["id", "name", "description", "tags", "kind", "medium", "variety", "tool", "tip", "marks", "dynamics", "behaviour", "light", "fluorescence", "thermochromic", "iridescence", "ghost", "mutation", "motion", "colour"];
1668
+ var ORDER = ["id", "name", "description", "tags", "kind", "medium", "variety", "tool", "tip", "marks", "dynamics", "behaviour", "light", "fluorescence", "thermochromic", "iridescence", "ghost", "mutation", "ferro", "motion", "livePatterns", "colour"];
1578
1669
  function migrateBrushV1(raw) {
1579
1670
  const b = normalizePaintBrush(raw);
1580
1671
  const brush = skeleton(b);
@@ -1813,11 +1904,29 @@ var MUTATION = object("Mutation paint: you paint something and pause; once an ag
1813
1904
  patience: num(5, 300, "Seconds to wait for an agent before giving up and keeping the paint as ordinary paint (default 45)."),
1814
1905
  fade: num(0.5, 10, "Seconds the paint takes to sink away once claimed by read_mutation (default 2).")
1815
1906
  });
1907
+ var FERRO = object("Liquid ferrofluid paint: rises into glossy Rosensweig spikes near magnets until cured.", {
1908
+ amount: unit3("How much liquid ferrofluid the brush lays per dab (default 0: none)."),
1909
+ wavelength: num(4, 64, "Capillary wavelength (spike-to-spike spacing) in pixels (default 16)."),
1910
+ threshold: num(0.01, 0.95, "Critical magnetic field |B| threshold where spikes begin to rise (default 0.12).")
1911
+ });
1816
1912
  var MOTION = object("Animation after painting.", {
1817
1913
  live: bool("Effect paint keeps playing on stroke layers (default false)."),
1818
1914
  fps: int(1, 60, "Suggested playback rate in frames per second."),
1819
1915
  waveSpeed: unit3("Particle waves: how fast they run by stroke time; 0 they stand still (default 0), 1 about four a second.")
1820
1916
  });
1917
+ var LIVE_PATTERNS = object("Gray-Scott reaction-diffusion patterns (spots, stripes, coral, maze, worms) that grow inside wet paint (behaviour.surface: wet) and freeze where the paint dries.", {
1918
+ pattern: oneOf2(LIVE_PATTERN_KINDS, "Named Pearson feed/kill preset: spots (default, dividing mitosis spots), stripes (labyrinthine stripes), coral (branching dendrites), maze (winding channels) or worms (vermicular tracks)."),
1919
+ feed: num(0.01, 0.1, "Gray-Scott feed rate F (0.01..0.1; default from pattern)."),
1920
+ kill: num(0.03, 0.08, "Gray-Scott kill rate k (0.03..0.08; default from pattern)."),
1921
+ scale: num(0.5, 4, "Spatial scale of the pattern (diffusion multiplier, 0.5..4, default 1)."),
1922
+ contrast: num(0, 2, "Visual contrast of the V concentration field (0..2, default 1)."),
1923
+ secondColor: {
1924
+ type: ["string", "null"],
1925
+ pattern: "^#[0-9a-fA-F]{6}$",
1926
+ description: "Optional second colour (#rrggbb) that V blends the wet paint towards; null or omitted modulates pigment density/contrast."
1927
+ },
1928
+ relief: unit3("Impasto relief height written to the layer thick map as patterns grow (0..1, default 0).")
1929
+ });
1821
1930
  var SOLID = {
1822
1931
  type: "string",
1823
1932
  pattern: "^(#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(,\\s*(0|1|0?\\.\\d+)\\s*)?\\))$",
@@ -1881,7 +1990,9 @@ var BRUSH_JSON_SCHEMA = object(
1881
1990
  iridescence: IRIDESCENCE,
1882
1991
  ghost: GHOST,
1883
1992
  mutation: MUTATION,
1993
+ ferro: FERRO,
1884
1994
  motion: MOTION,
1995
+ livePatterns: LIVE_PATTERNS,
1885
1996
  colour: COLOUR
1886
1997
  },
1887
1998
  ["id", "name", "kind", "tip"]
@@ -1898,7 +2009,9 @@ var BRUSH_FACET_SCHEMAS = {
1898
2009
  iridescence: IRIDESCENCE,
1899
2010
  ghost: GHOST,
1900
2011
  mutation: MUTATION,
2012
+ ferro: FERRO,
1901
2013
  motion: MOTION,
2014
+ livePatterns: LIVE_PATTERNS,
1902
2015
  colour: COLOUR,
1903
2016
  paint: PAINT
1904
2017
  };
@@ -2047,17 +2160,19 @@ function read(value, schema, path, problems) {
2047
2160
  problems.push(`${path} must be ${JSON.stringify(schema.const)}; dropped.`);
2048
2161
  return void 0;
2049
2162
  }
2050
- switch (schema.type) {
2163
+ if (value === null && Array.isArray(schema.type) && schema.type.includes("null")) return null;
2164
+ const schemaType = Array.isArray(schema.type) ? schema.type[0] : schema.type;
2165
+ switch (schemaType) {
2051
2166
  case "number":
2052
2167
  case "integer": {
2053
2168
  if (typeof value !== "number" || !Number.isFinite(value)) {
2054
2169
  problems.push(`${path} must be a number; dropped.`);
2055
2170
  return void 0;
2056
2171
  }
2057
- let n = schema.type === "integer" ? Math.round(value) : value;
2172
+ let n = schemaType === "integer" ? Math.round(value) : value;
2058
2173
  if (schema.minimum !== void 0 && n < schema.minimum) n = schema.minimum;
2059
2174
  if (schema.maximum !== void 0 && n > schema.maximum) n = schema.maximum;
2060
- if (n !== value) problems.push(`${path} ${value} is out of range (${schema.minimum}..${schema.maximum}${schema.type === "integer" ? ", whole" : ""}); set to ${n}.`);
2175
+ if (n !== value) problems.push(`${path} ${value} is out of range (${schema.minimum}..${schema.maximum}${schemaType === "integer" ? ", whole" : ""}); set to ${n}.`);
2061
2176
  return n;
2062
2177
  }
2063
2178
  case "boolean":
@@ -2070,7 +2185,8 @@ function read(value, schema, path, problems) {
2070
2185
  problems.push(`${path} ${JSON.stringify(value)} is not valid${schema.enum ? `; use one of ${schema.enum.join(", ")}` : ""}; dropped.`);
2071
2186
  return void 0;
2072
2187
  }
2073
- return schema.maxLength !== void 0 ? value.slice(0, schema.maxLength) : value;
2188
+ const s = schema.pattern === "^#[0-9a-fA-F]{6}$" ? value.toLowerCase() : value;
2189
+ return schema.maxLength !== void 0 ? s.slice(0, schema.maxLength) : s;
2074
2190
  }
2075
2191
  case "array": {
2076
2192
  if (!Array.isArray(value)) {
@@ -2139,14 +2255,14 @@ function normalizeBrushSpec(raw, problems = []) {
2139
2255
  else if (raw.kind === void 0) kind = tool ? "tool" : "mark";
2140
2256
  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).`);
2141
2257
  if (kind === "tool" && !tool) throw new Error(`Brush "${raw.id}" is a tool and needs tool: one of ${BRUSH_TOOLS.join(", ")}.`);
2142
- const { colour: rawColour, marks: rawMarks, kind: _k, id: _i, name: _n, tool: _t, ...rest } = raw;
2258
+ const { colour: rawColour, marks: rawMarks, ferro: rawFerro, kind: _k, id: _i, name: _n, tool: _t, ...rest } = raw;
2143
2259
  if (typeof rest.fluorescence === "number" && Number.isFinite(rest.fluorescence)) rest.fluorescence = { amount: rest.fluorescence };
2144
2260
  const out = readObject(rest, BRUSH_JSON_SCHEMA, "brush", problems);
2145
2261
  if (out.thermochromic?.hotColor) out.thermochromic.hotColor = out.thermochromic.hotColor.toLowerCase();
2146
2262
  const brush = { id: raw.id, name, ...out, kind, ...tool ? { tool } : {}, tip: out.tip ?? { shape: "round" } };
2147
2263
  const ordered = () => {
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 };
2264
+ const { id, name: n, description, tags, kind: k, medium, variety, tool: t, tip, marks, dynamics, behaviour, light, fluorescence, thermochromic, iridescence, ghost, mutation, ferro, motion, livePatterns, colour } = brush;
2265
+ const all = { id, name: n, description, tags, kind: k, medium, variety, tool: t, tip, marks, dynamics, behaviour, light, fluorescence, thermochromic, iridescence, ghost, mutation, ferro, motion, livePatterns, colour };
2150
2266
  return Object.fromEntries(Object.entries(all).filter(([, v]) => v !== void 0));
2151
2267
  };
2152
2268
  if (!out.tip) problems.push("tip is missing; a round tip is used.");
@@ -2160,6 +2276,11 @@ function normalizeBrushSpec(raw, problems = []) {
2160
2276
  if (!schema) problems.push(`marks.kind ${JSON.stringify(k)} is not valid; use continuous, scatter, particles or objects. marks dropped.`);
2161
2277
  else brush.marks = readObject(rawMarks, schema, "brush.marks", problems);
2162
2278
  }
2279
+ if (rawFerro !== void 0) {
2280
+ const ferroObj = typeof rawFerro === "number" ? { amount: rawFerro } : rawFerro;
2281
+ const f2 = readObject(ferroObj, BRUSH_FACET_SCHEMAS.ferro, "brush.ferro", problems);
2282
+ if (f2 && Object.keys(f2).length) brush.ferro = f2;
2283
+ }
2163
2284
  if (rawColour !== void 0) {
2164
2285
  const c = readObject(rawColour, { ...BRUSH_FACET_SCHEMAS.colour, properties: { own: BRUSH_FACET_SCHEMAS.colour.properties.own, default: {}, palette: {} } }, "brush.colour", problems);
2165
2286
  if (c) {
@@ -2194,6 +2315,10 @@ function normalizeBrushSpec(raw, problems = []) {
2194
2315
  drop("marks", "tools lay no marks");
2195
2316
  delete brush.marks;
2196
2317
  }
2318
+ if (brush.ferro) {
2319
+ drop("ferro", "tools lay no ferrofluid");
2320
+ delete brush.ferro;
2321
+ }
2197
2322
  if (brush.iridescence) {
2198
2323
  drop("iridescence", "tools lay no film");
2199
2324
  delete brush.iridescence;
@@ -2279,6 +2404,10 @@ function normalizeBrushSpec(raw, problems = []) {
2279
2404
  delete b.exchange;
2280
2405
  }
2281
2406
  }
2407
+ if (brush.livePatterns && (kind !== "mark" || brush.behaviour?.surface !== "wet" || brush.marks?.kind === "particles" || brush.marks?.kind === "objects" || brush.tip.shape === "bristle")) {
2408
+ drop("livePatterns", "only wet paint brushes grow live patterns (set behaviour.surface to wet)");
2409
+ delete brush.livePatterns;
2410
+ }
2282
2411
  if (brush.ghost) {
2283
2412
  if (kind === "tool" || brush.behaviour?.surface !== "wet") {
2284
2413
  drop("ghost", "it needs a mark brush with behaviour.surface wet");
@@ -2318,7 +2447,9 @@ var PAINT_PROFILE = {
2318
2447
  tilt: "full",
2319
2448
  tipGrain: "full",
2320
2449
  dabCount: "full",
2321
- iridescence: "full"
2450
+ livePatterns: "full",
2451
+ iridescence: "full",
2452
+ ferro: "full"
2322
2453
  };
2323
2454
  var IMAGE_PROFILE = {
2324
2455
  id: "image",
@@ -2395,6 +2526,7 @@ function checkBrush(brush, profile) {
2395
2526
  feature(profile.exchange, !ex?.reservoir && (nonZero(ex?.pickup) || !!ex?.dirty), "behaviour.exchange", "picking paint up", "Remove behaviour.exchange.");
2396
2527
  feature(profile.grain, nonZero(brush.behaviour?.grain), "behaviour.grain", "paper grain", "Set behaviour.grain to 0.");
2397
2528
  feature(profile.light, nonZero(brush.light?.amount), "light", "light paint (glow and spill)", "Remove light (or set light.amount to 0).");
2529
+ feature(profile.livePatterns ?? "none", Boolean(brush.livePatterns), "livePatterns", "live reaction-diffusion patterns", "Remove livePatterns.");
2398
2530
  const smartInks = profile.id === "paint" ? "full" : "none";
2399
2531
  feature(smartInks, nonZero(brush.fluorescence?.amount), "fluorescence", "UV-fluorescent ink", "Remove fluorescence (or set fluorescence.amount to 0).");
2400
2532
  feature(smartInks, Boolean(brush.thermochromic?.hotColor), "thermochromic", "thermochromic ink", "Remove thermochromic.");
@@ -2408,6 +2540,7 @@ function checkBrush(brush, profile) {
2408
2540
  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
2541
  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
2542
  }
2543
+ feature(profile.ferro ?? "none", nonZero(brush.ferro?.amount), "ferro", "ferrofluid spikes and magnets", "Remove ferro (or set ferro.amount to 0).");
2411
2544
  const m = brush.motion;
2412
2545
  feature(profile.motion, !!m && (!!m.live || nonZero(m.waveSpeed) || m.fps !== void 0), "motion", "animation", "Remove motion.");
2413
2546
  const d = brush.dynamics ?? {};
@@ -2458,8 +2591,12 @@ function degrade(brush, profile, options = {}) {
2458
2591
  delete b.behaviour.surface;
2459
2592
  delete b.behaviour.wet;
2460
2593
  delete b.behaviour.thick;
2594
+ delete b.livePatterns;
2461
2595
  delete b.ghost;
2462
2596
  break;
2597
+ case "livePatterns":
2598
+ delete b.livePatterns;
2599
+ break;
2463
2600
  case "ghost":
2464
2601
  delete b.ghost;
2465
2602
  break;
@@ -2493,6 +2630,9 @@ function degrade(brush, profile, options = {}) {
2493
2630
  case "iridescence":
2494
2631
  delete b.iridescence;
2495
2632
  break;
2633
+ case "ferro":
2634
+ delete b.ferro;
2635
+ break;
2496
2636
  case "motion":
2497
2637
  delete b.motion;
2498
2638
  break;
@@ -2880,6 +3020,8 @@ export {
2880
3020
  normalizeGhost,
2881
3021
  DEFAULT_MUTATION,
2882
3022
  normalizeMutation,
3023
+ DEFAULT_FERRO,
3024
+ normalizeFerro,
2883
3025
  normalizeImpasto,
2884
3026
  MULTI_COLOR_MODES,
2885
3027
  DEFAULT_MULTI_COLOR,