@pixerate/editor 0.276.0 → 0.278.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 (65) hide show
  1. package/dist/animation-editor/index.cjs +59 -59
  2. package/dist/animation-editor/index.js +3 -3
  3. package/dist/brushes/index.cjs +69 -67
  4. package/dist/brushes/index.js +6 -4
  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 +2 -0
  8. package/dist/brushes/profiles.d.ts +2 -0
  9. package/dist/brushes/schema.d.cts +1 -0
  10. package/dist/brushes/schema.d.ts +1 -0
  11. package/dist/brushes/types.d.cts +10 -0
  12. package/dist/brushes/types.d.ts +10 -0
  13. package/dist/{brushstrokeRender-I6MYFCOU.cjs → brushstrokeRender-7HOMKZNO.cjs} +15 -15
  14. package/dist/{brushstrokeRender-FK3ASUUJ.js → brushstrokeRender-EIK3625T.js} +3 -3
  15. package/dist/canvas/index.cjs +13 -13
  16. package/dist/canvas/index.js +1 -1
  17. package/dist/{chunk-U26LUXCJ.cjs → chunk-4NZTK7LM.cjs} +35 -35
  18. package/dist/{chunk-CVBTJVRN.js → chunk-DWPFGWW5.js} +1 -1
  19. package/dist/{chunk-XMSH66VU.js → chunk-FNIWHSLI.js} +1 -1
  20. package/dist/{chunk-QNNZA3V5.cjs → chunk-H3EME3S5.cjs} +2 -2
  21. package/dist/{chunk-4EM5NGCL.js → chunk-HHX7ST6Z.js} +1 -1
  22. package/dist/{chunk-Q33V5PEF.cjs → chunk-HRUK5TFW.cjs} +73 -12
  23. package/dist/{chunk-5HR5CSLC.cjs → chunk-JVFGK3DK.cjs} +69 -58
  24. package/dist/{chunk-JNMNM3SL.js → chunk-LZE2QZCT.js} +1 -1
  25. package/dist/{chunk-OS6OG7DN.js → chunk-N4MCWQZ7.js} +1 -1
  26. package/dist/{chunk-IJ6NSWKM.js → chunk-NJLPW4MM.js} +17 -6
  27. package/dist/{chunk-OY24XRYF.cjs → chunk-PEQWARCP.cjs} +12 -12
  28. package/dist/{chunk-XVPQNX7O.js → chunk-PPDUCZZV.js} +66 -5
  29. package/dist/{chunk-X7XBEX3O.cjs → chunk-QBZEC2JJ.cjs} +86 -86
  30. package/dist/{chunk-GHYJAMMS.js → chunk-ROURHJYT.js} +1 -1
  31. package/dist/{chunk-ZIE6EJGR.cjs → chunk-VNF7A2JA.cjs} +23 -23
  32. package/dist/{chunk-ISCAJBPQ.cjs → chunk-WFSYZO5Z.cjs} +1 -1
  33. package/dist/image-editor/index.cjs +788 -788
  34. package/dist/image-editor/index.js +4 -4
  35. package/dist/paint-editor/brush-model.d.cts +15 -1
  36. package/dist/paint-editor/brush-model.d.ts +15 -1
  37. package/dist/paint-editor/controller.d.cts +90 -9
  38. package/dist/paint-editor/controller.d.ts +90 -9
  39. package/dist/paint-editor/document.d.cts +8 -1
  40. package/dist/paint-editor/document.d.ts +8 -1
  41. package/dist/paint-editor/ferro-tools.d.cts +10 -0
  42. package/dist/paint-editor/ferro-tools.d.ts +10 -0
  43. package/dist/paint-editor/ferro.d.cts +134 -0
  44. package/dist/paint-editor/ferro.d.ts +134 -0
  45. package/dist/paint-editor/index.cjs +2138 -872
  46. package/dist/paint-editor/index.d.cts +2 -0
  47. package/dist/paint-editor/index.d.ts +2 -0
  48. package/dist/paint-editor/index.js +1446 -180
  49. package/dist/paint-editor/serialize.d.cts +3 -1
  50. package/dist/paint-editor/serialize.d.ts +3 -1
  51. package/dist/paint-editor/stroke.d.cts +17 -0
  52. package/dist/paint-editor/stroke.d.ts +17 -0
  53. package/dist/paint-editor/strokes.d.cts +11 -1
  54. package/dist/paint-editor/strokes.d.ts +11 -1
  55. package/dist/paint-editor/thick.d.cts +15 -2
  56. package/dist/paint-editor/thick.d.ts +15 -2
  57. package/dist/paint-editor/ui.d.cts +2 -0
  58. package/dist/paint-editor/ui.d.ts +2 -0
  59. package/dist/vector/index.cjs +998 -998
  60. package/dist/vector/index.js +4 -4
  61. package/package.json +1 -1
  62. package/skills/pixerate-brushes/SKILL.md +1 -0
  63. package/skills/pixerate-paint-editor/SKILL.md +12 -30
  64. package/skills/pixerate-paint-editor/references/brush-facets.md +1 -0
  65. package/skills/pixerate-paint-editor/references/ferrofluid.md +36 -0
@@ -5,7 +5,7 @@ import {
5
5
  moveAnchorHandle,
6
6
  subpathSegment,
7
7
  subpathSegmentCount
8
- } from "./chunk-4EM5NGCL.js";
8
+ } from "./chunk-HHX7ST6Z.js";
9
9
  import {
10
10
  segmentPoint
11
11
  } from "./chunk-WEIK3UYB.js";
@@ -2,6 +2,7 @@ import {
2
2
  describeStickerRef
3
3
  } from "./chunk-VE6UKYQ2.js";
4
4
  import {
5
+ BRUSH_FACET_SCHEMAS,
5
6
  BRUSH_HOST_PROFILES,
6
7
  BRUSH_ID,
7
8
  BRUSH_JSON_SCHEMA,
@@ -27,7 +28,7 @@ import {
27
28
  resolveEngine,
28
29
  summarizeBrushSource,
29
30
  withKeptIssues
30
- } from "./chunk-XVPQNX7O.js";
31
+ } from "./chunk-PPDUCZZV.js";
31
32
  import {
32
33
  base64ToBytes
33
34
  } from "./chunk-OTTT5N3K.js";
@@ -42,9 +43,16 @@ var FACETS = BRUSH_JSON_SCHEMA.properties;
42
43
  var BRUSH_PATCH_PROPERTIES = (() => {
43
44
  const { id: _id, name: _name, ...rest } = FACETS;
44
45
  const { required: _r, ...tip } = rest.tip;
45
- return { ...rest, tip: { ...tip, description: `${tip.description} In a patch, shape may be left out to keep it.` } };
46
+ const ferro = {
47
+ description: "Liquid ferrofluid paint: 0..1 (amount), or { amount, wavelength, threshold }. Rises into glossy Rosensweig spikes near magnets until cured.",
48
+ anyOf: [
49
+ { type: "number", minimum: 0, maximum: 1, description: "How much liquid ferrofluid the brush lays per dab (0..1)." },
50
+ BRUSH_FACET_SCHEMAS.ferro
51
+ ]
52
+ };
53
+ return { ...rest, tip: { ...tip, description: `${tip.description} In a patch, shape may be left out to keep it.` }, ferro };
46
54
  })();
47
- var UNSETTABLE_BRUSH_FACETS = ["description", "tags", "medium", "variety", "marks", "dynamics", "behaviour", "light", "fluorescence", "thermochromic", "iridescence", "ghost", "mutation", "motion", "livePatterns", "colour"];
55
+ var UNSETTABLE_BRUSH_FACETS = ["description", "tags", "medium", "variety", "marks", "dynamics", "behaviour", "light", "fluorescence", "thermochromic", "iridescence", "ghost", "mutation", "ferro", "motion", "livePatterns", "colour"];
48
56
  var BRUSH_UNSET_PROPERTY = {
49
57
  type: "array",
50
58
  minItems: 1,
@@ -54,9 +62,11 @@ var BRUSH_UNSET_PROPERTY = {
54
62
  };
55
63
  function mergeBrushPatch(base, patch, path = "") {
56
64
  const out = { ...base };
57
- for (const [k, v] of Object.entries(patch)) {
58
- if (v === void 0) continue;
59
- const old = out[k];
65
+ for (const [k, rawV] of Object.entries(patch)) {
66
+ if (rawV === void 0) continue;
67
+ const v = path === "" && k === "ferro" && typeof rawV === "number" ? { amount: rawV } : rawV;
68
+ const rawOld = out[k];
69
+ const old = path === "" && k === "ferro" && typeof rawOld === "number" ? { amount: rawOld } : rawOld;
60
70
  const replaceMarks = path === "" && k === "marks" && isObject(old) && isObject(v) && old.kind !== v.kind;
61
71
  out[k] = isObject(v) && isObject(old) && !replaceMarks ? mergeBrushPatch(old, v, `${path}${k}.`) : copy(v);
62
72
  }
@@ -728,6 +738,7 @@ function summary(entry, profile, withCheck) {
728
738
  marks: b.marks?.kind ?? "continuous",
729
739
  surface: b.behaviour?.surface ?? "dry",
730
740
  ...b.light?.amount ? { light: b.light.amount } : {},
741
+ ...b.ferro?.amount ? { ferro: b.ferro.amount } : {},
731
742
  ...b.tags?.length ? { tags: b.tags } : {},
732
743
  ...check ? { renderable: check.ok } : {}
733
744
  };
@@ -8,7 +8,7 @@
8
8
 
9
9
 
10
10
 
11
- var _chunkQ33V5PEFcjs = require('./chunk-Q33V5PEF.cjs');
11
+ var _chunkHRUK5TFWcjs = require('./chunk-HRUK5TFW.cjs');
12
12
 
13
13
 
14
14
 
@@ -2796,7 +2796,7 @@ function effectDabField(img, rect, cov, centre, mixed, field, steps = 0, dab = n
2796
2796
  var samplers = /* @__PURE__ */ new WeakMap();
2797
2797
  function stampSampler(ref, art, hardness = 1) {
2798
2798
  const sampler = baseSampler(ref, art);
2799
- if (!sampler || hardness >= 1 || !_chunkQ33V5PEFcjs.isBrushSourceRef.call(void 0, ref)) return sampler;
2799
+ if (!sampler || hardness >= 1 || !_chunkHRUK5TFWcjs.isBrushSourceRef.call(void 0, ref)) return sampler;
2800
2800
  const h = Math.max(0, hardness);
2801
2801
  return (u, v2, seed) => {
2802
2802
  const r = Math.hypot(u, v2) / Math.SQRT2;
@@ -2871,7 +2871,7 @@ function rasterizeStampDab(d, sample, seed, rect, out) {
2871
2871
  }
2872
2872
  function brushStickerRefs(brush) {
2873
2873
  const refs = brush.engine === "sticker" ? [...brush.sticker.stamps] : brush.engine === "particle" ? [...brush.particle.dotTextures] : brush.engine === "dab" && brush.stamp ? [brush.stamp] : [];
2874
- return [...new Set(refs.filter((r) => r.includes(":") && !r.startsWith("editor:") && !_chunkQ33V5PEFcjs.isBrushSourceRef.call(void 0, r)))];
2874
+ return [...new Set(refs.filter((r) => r.includes(":") && !r.startsWith("editor:") && !_chunkHRUK5TFWcjs.isBrushSourceRef.call(void 0, r)))];
2875
2875
  }
2876
2876
  function sprayDroplets(d, spray, seed) {
2877
2877
  const count = 1 + Math.round(spray.density * 31);
@@ -4798,11 +4798,11 @@ function renderBackdropBrushstroke(input, brush, options) {
4798
4798
 
4799
4799
  // src/vector/brushstroke.ts
4800
4800
  var VECTOR_BRUSHSTROKE_SCALE = 2;
4801
- var PRESETS = _chunkQ33V5PEFcjs.listPresets.call(void 0, _chunkQ33V5PEFcjs.VECTOR_PROFILE).map((spec) => _chunkQ33V5PEFcjs.BUILTIN_BRUSHES.find((b) => b.id === spec.id));
4801
+ var PRESETS = _chunkHRUK5TFWcjs.listPresets.call(void 0, _chunkHRUK5TFWcjs.VECTOR_PROFILE).map((spec) => _chunkHRUK5TFWcjs.BUILTIN_BRUSHES.find((b) => b.id === spec.id));
4802
4802
  function admitted(spec) {
4803
4803
  try {
4804
- const brush = _chunkQ33V5PEFcjs.normalizeBrushSpec.call(void 0, spec);
4805
- return _chunkQ33V5PEFcjs.checkBrush.call(void 0, brush, _chunkQ33V5PEFcjs.VECTOR_PROFILE).ok && isVectorBrushstrokeEngine(_chunkQ33V5PEFcjs.resolveBrush.call(void 0, brush)) ? brush : null;
4804
+ const brush = _chunkHRUK5TFWcjs.normalizeBrushSpec.call(void 0, spec);
4805
+ return _chunkHRUK5TFWcjs.checkBrush.call(void 0, brush, _chunkHRUK5TFWcjs.VECTOR_PROFILE).ok && isVectorBrushstrokeEngine(_chunkHRUK5TFWcjs.resolveBrush.call(void 0, brush)) ? brush : null;
4806
4806
  } catch (e6) {
4807
4807
  return null;
4808
4808
  }
@@ -4814,7 +4814,7 @@ function vectorBrushstrokeBrushes(user = []) {
4814
4814
  const out = [...PRESETS];
4815
4815
  for (const raw of user) {
4816
4816
  const brush = admitted(raw);
4817
- if (brush && !out.some((b) => b.id === brush.id)) out.push(_chunkQ33V5PEFcjs.resolveBrush.call(void 0, brush));
4817
+ if (brush && !out.some((b) => b.id === brush.id)) out.push(_chunkHRUK5TFWcjs.resolveBrush.call(void 0, brush));
4818
4818
  }
4819
4819
  return out;
4820
4820
  }
@@ -4824,7 +4824,7 @@ function vectorBrushstrokeBrush(id) {
4824
4824
  function vectorBrushstrokeBrushOf(brush) {
4825
4825
  if (brush.spec) {
4826
4826
  const saved = admitted(brush.spec);
4827
- return saved && saved.id === brush.brushId ? _chunkQ33V5PEFcjs.resolveBrush.call(void 0, saved) : null;
4827
+ return saved && saved.id === brush.brushId ? _chunkHRUK5TFWcjs.resolveBrush.call(void 0, saved) : null;
4828
4828
  }
4829
4829
  return vectorBrushstrokeBrush(brush.brushId);
4830
4830
  }
@@ -4832,14 +4832,14 @@ function vectorBrushstrokeBrushError(id, user = []) {
4832
4832
  if (vectorBrushstrokeBrush(id)) return null;
4833
4833
  const saved = user.find((b) => b.id === id);
4834
4834
  const ids = vectorBrushstrokeBrushes(user).map((b) => b.id);
4835
- const reasons = (b) => _chunkQ33V5PEFcjs.checkBrush.call(void 0, b, _chunkQ33V5PEFcjs.VECTOR_PROFILE).issues.filter((i) => i.severity === "error").map((i) => `${i.reason} ${i.fix}`).join(" ") || "its engine does not paint brushstrokes.";
4835
+ const reasons = (b) => _chunkHRUK5TFWcjs.checkBrush.call(void 0, b, _chunkHRUK5TFWcjs.VECTOR_PROFILE).issues.filter((i) => i.severity === "error").map((i) => `${i.reason} ${i.fix}`).join(" ") || "its engine does not paint brushstrokes.";
4836
4836
  if (saved) return admitted(saved) ? null : `Brush "${id}" (${saved.name}) can't paint a brushstroke: ${reasons(saved)} Use one of: ${ids.join(", ")}.`;
4837
- const preset2 = _chunkQ33V5PEFcjs.BRUSH_PRESETS.find((b) => b.id === id);
4837
+ const preset2 = _chunkHRUK5TFWcjs.BRUSH_PRESETS.find((b) => b.id === id);
4838
4838
  if (preset2) return `Brush "${id}" (${preset2.name}) can't paint a brushstroke: ${reasons(preset2)} Use one of: ${ids.join(", ")}.`;
4839
- return `Unknown brush "${id}". ${_chunkQ33V5PEFcjs.closest.call(void 0, id, ids)}Use one of: ${ids.join(", ")} (vector.listBrushes lists them).`;
4839
+ return `Unknown brush "${id}". ${_chunkHRUK5TFWcjs.closest.call(void 0, id, ids)}Use one of: ${ids.join(", ")} (vector.listBrushes lists them).`;
4840
4840
  }
4841
4841
  function vectorBrushstrokeDefaultColour(id, user = [], scheme = "light") {
4842
- const brush = _nullishCoalesce(_chunkQ33V5PEFcjs.BRUSH_PRESETS.find((b) => b.id === id), () => ( user.find((b) => b.id === id)));
4842
+ const brush = _nullishCoalesce(_chunkHRUK5TFWcjs.BRUSH_PRESETS.find((b) => b.id === id), () => ( user.find((b) => b.id === id)));
4843
4843
  return brush ? _chunkJBZRVLXVcjs.brushColourHex.call(void 0, _optionalChain([brush, 'access', _212 => _212.colour, 'optionalAccess', _213 => _213.default]), scheme) : null;
4844
4844
  }
4845
4845
  function sanitizeVectorBrushstroke(value, user = []) {
@@ -932,6 +932,29 @@ var BRUSH_PRESETS = [
932
932
  tip: { shape: "round", size: 6, hardness: 0.9, spacing: 0.06 },
933
933
  dynamics: { smoothing: 0.35, pressureSmoothing: 0.25, minSize: 0.25 },
934
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" }
935
958
  }
936
959
  ];
937
960
 
@@ -1101,6 +1124,18 @@ function normalizeMutation(raw = {}, base = DEFAULT_MUTATION) {
1101
1124
  fade: range(raw.fade, 0.5, 10, base.fade)
1102
1125
  };
1103
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
+ }
1104
1139
  function normalizeImpasto(raw = {}, base = DEFAULT_IMPASTO) {
1105
1140
  return {
1106
1141
  tool: IMPASTO_TOOLS.includes(raw.tool) ? raw.tool : base.tool,
@@ -1329,6 +1364,7 @@ function normalizePaintBrush(raw = {}, base) {
1329
1364
  ...withIridescence(readIridescence(raw.iridescence, b.iridescence ?? null)),
1330
1365
  ...ghost ? { ghost } : {},
1331
1366
  ...mutation ? { mutation } : {},
1367
+ ferro: normalizeFerro(raw.ferro, b.ferro ?? DEFAULT_FERRO),
1332
1368
  stamp: readStamp(raw.stamp, b.stamp ?? null),
1333
1369
  spray: readSpray(raw.spray, b.spray ?? null),
1334
1370
  ...withTipGrain(readTipGrain(raw.tipGrain, b.tipGrain ?? null)),
@@ -1360,6 +1396,7 @@ var BASE_BRUSH = {
1360
1396
  light: { ...DEFAULT_LIGHT },
1361
1397
  fluorescence: { ...DEFAULT_FLUORESCENCE },
1362
1398
  thermochromic: { ...DEFAULT_THERMOCHROMIC },
1399
+ ferro: { ...DEFAULT_FERRO },
1363
1400
  stamp: null,
1364
1401
  spray: null,
1365
1402
  paint: null
@@ -1419,6 +1456,7 @@ var BRUSH_FIELDS = [
1419
1456
  ...each("fluorescence", "fluorescence", ["amount", "invisible"]),
1420
1457
  ...each("thermochromic", "thermochromic", ["hotColor", "threshold"]),
1421
1458
  ...each("iridescence", "iridescence", ["thickness", "pressureToThickness", "ior"], FILM),
1459
+ ...each("ferro", "ferro", ["amount", "wavelength", "threshold"]),
1422
1460
  // Paint exchange: the dab, wet and impasto engines' behaviour, or the Mixer Brush's settings.
1423
1461
  f("behaviour.exchange.pickup", "behavior.pickup", EXCHANGE),
1424
1462
  f("behaviour.exchange.mix", "behavior.mix", EXCHANGE),
@@ -1627,7 +1665,7 @@ function skeleton(b) {
1627
1665
  }
1628
1666
  }
1629
1667
  }
1630
- var ORDER = ["id", "name", "description", "tags", "kind", "medium", "variety", "tool", "tip", "marks", "dynamics", "behaviour", "light", "fluorescence", "thermochromic", "iridescence", "ghost", "mutation", "motion", "livePatterns", "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"];
1631
1669
  function migrateBrushV1(raw) {
1632
1670
  const b = normalizePaintBrush(raw);
1633
1671
  const brush = skeleton(b);
@@ -1866,6 +1904,11 @@ var MUTATION = object("Mutation paint: you paint something and pause; once an ag
1866
1904
  patience: num(5, 300, "Seconds to wait for an agent before giving up and keeping the paint as ordinary paint (default 45)."),
1867
1905
  fade: num(0.5, 10, "Seconds the paint takes to sink away once claimed by read_mutation (default 2).")
1868
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
+ });
1869
1912
  var MOTION = object("Animation after painting.", {
1870
1913
  live: bool("Effect paint keeps playing on stroke layers (default false)."),
1871
1914
  fps: int(1, 60, "Suggested playback rate in frames per second."),
@@ -1947,6 +1990,7 @@ var BRUSH_JSON_SCHEMA = object(
1947
1990
  iridescence: IRIDESCENCE,
1948
1991
  ghost: GHOST,
1949
1992
  mutation: MUTATION,
1993
+ ferro: FERRO,
1950
1994
  motion: MOTION,
1951
1995
  livePatterns: LIVE_PATTERNS,
1952
1996
  colour: COLOUR
@@ -1965,6 +2009,7 @@ var BRUSH_FACET_SCHEMAS = {
1965
2009
  iridescence: IRIDESCENCE,
1966
2010
  ghost: GHOST,
1967
2011
  mutation: MUTATION,
2012
+ ferro: FERRO,
1968
2013
  motion: MOTION,
1969
2014
  livePatterns: LIVE_PATTERNS,
1970
2015
  colour: COLOUR,
@@ -2210,14 +2255,14 @@ function normalizeBrushSpec(raw, problems = []) {
2210
2255
  else if (raw.kind === void 0) kind = tool ? "tool" : "mark";
2211
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).`);
2212
2257
  if (kind === "tool" && !tool) throw new Error(`Brush "${raw.id}" is a tool and needs tool: one of ${BRUSH_TOOLS.join(", ")}.`);
2213
- 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;
2214
2259
  if (typeof rest.fluorescence === "number" && Number.isFinite(rest.fluorescence)) rest.fluorescence = { amount: rest.fluorescence };
2215
2260
  const out = readObject(rest, BRUSH_JSON_SCHEMA, "brush", problems);
2216
2261
  if (out.thermochromic?.hotColor) out.thermochromic.hotColor = out.thermochromic.hotColor.toLowerCase();
2217
2262
  const brush = { id: raw.id, name, ...out, kind, ...tool ? { tool } : {}, tip: out.tip ?? { shape: "round" } };
2218
2263
  const ordered = () => {
2219
- const { id, name: n, description, tags, kind: k, medium, variety, tool: t, tip, marks, dynamics, behaviour, light, fluorescence, thermochromic, iridescence, ghost, mutation, motion, livePatterns, colour } = brush;
2220
- const all = { id, name: n, description, tags, kind: k, medium, variety, tool: t, tip, marks, dynamics, behaviour, light, fluorescence, thermochromic, iridescence, ghost, mutation, motion, livePatterns, 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 };
2221
2266
  return Object.fromEntries(Object.entries(all).filter(([, v]) => v !== void 0));
2222
2267
  };
2223
2268
  if (!out.tip) problems.push("tip is missing; a round tip is used.");
@@ -2231,6 +2276,11 @@ function normalizeBrushSpec(raw, problems = []) {
2231
2276
  if (!schema) problems.push(`marks.kind ${JSON.stringify(k)} is not valid; use continuous, scatter, particles or objects. marks dropped.`);
2232
2277
  else brush.marks = readObject(rawMarks, schema, "brush.marks", problems);
2233
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
+ }
2234
2284
  if (rawColour !== void 0) {
2235
2285
  const c = readObject(rawColour, { ...BRUSH_FACET_SCHEMAS.colour, properties: { own: BRUSH_FACET_SCHEMAS.colour.properties.own, default: {}, palette: {} } }, "brush.colour", problems);
2236
2286
  if (c) {
@@ -2265,6 +2315,10 @@ function normalizeBrushSpec(raw, problems = []) {
2265
2315
  drop("marks", "tools lay no marks");
2266
2316
  delete brush.marks;
2267
2317
  }
2318
+ if (brush.ferro) {
2319
+ drop("ferro", "tools lay no ferrofluid");
2320
+ delete brush.ferro;
2321
+ }
2268
2322
  if (brush.iridescence) {
2269
2323
  drop("iridescence", "tools lay no film");
2270
2324
  delete brush.iridescence;
@@ -2394,7 +2448,8 @@ var PAINT_PROFILE = {
2394
2448
  tipGrain: "full",
2395
2449
  dabCount: "full",
2396
2450
  livePatterns: "full",
2397
- iridescence: "full"
2451
+ iridescence: "full",
2452
+ ferro: "full"
2398
2453
  };
2399
2454
  var IMAGE_PROFILE = {
2400
2455
  id: "image",
@@ -2485,6 +2540,7 @@ function checkBrush(brush, profile) {
2485
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.");
2486
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.");
2487
2542
  }
2543
+ feature(profile.ferro ?? "none", nonZero(brush.ferro?.amount), "ferro", "ferrofluid spikes and magnets", "Remove ferro (or set ferro.amount to 0).");
2488
2544
  const m = brush.motion;
2489
2545
  feature(profile.motion, !!m && (!!m.live || nonZero(m.waveSpeed) || m.fps !== void 0), "motion", "animation", "Remove motion.");
2490
2546
  const d = brush.dynamics ?? {};
@@ -2574,6 +2630,9 @@ function degrade(brush, profile, options = {}) {
2574
2630
  case "iridescence":
2575
2631
  delete b.iridescence;
2576
2632
  break;
2633
+ case "ferro":
2634
+ delete b.ferro;
2635
+ break;
2577
2636
  case "motion":
2578
2637
  delete b.motion;
2579
2638
  break;
@@ -2961,6 +3020,8 @@ export {
2961
3020
  normalizeGhost,
2962
3021
  DEFAULT_MUTATION,
2963
3022
  normalizeMutation,
3023
+ DEFAULT_FERRO,
3024
+ normalizeFerro,
2964
3025
  normalizeImpasto,
2965
3026
  MULTI_COLOR_MODES,
2966
3027
  DEFAULT_MULTI_COLOR,