@pixerate/editor 0.276.0 → 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 (61) 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 +50 -1
  38. package/dist/paint-editor/controller.d.ts +50 -1
  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 +1878 -831
  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 +1184 -137
  49. package/dist/paint-editor/stroke.d.cts +14 -0
  50. package/dist/paint-editor/stroke.d.ts +14 -0
  51. package/dist/paint-editor/thick.d.cts +15 -2
  52. package/dist/paint-editor/thick.d.ts +15 -2
  53. package/dist/paint-editor/ui.d.cts +2 -0
  54. package/dist/paint-editor/ui.d.ts +2 -0
  55. package/dist/vector/index.cjs +998 -998
  56. package/dist/vector/index.js +4 -4
  57. package/package.json +1 -1
  58. package/skills/pixerate-brushes/SKILL.md +1 -0
  59. package/skills/pixerate-paint-editor/SKILL.md +11 -29
  60. package/skills/pixerate-paint-editor/references/brush-facets.md +1 -0
  61. package/skills/pixerate-paint-editor/references/ferrofluid.md +36 -0
@@ -8,7 +8,7 @@ import {
8
8
  parseOpenTypeFont,
9
9
  removeAnchorsKeepingShape,
10
10
  traceField
11
- } from "../chunk-OS6OG7DN.js";
11
+ } from "../chunk-N4MCWQZ7.js";
12
12
  import {
13
13
  ADJUSTMENT_KEYS,
14
14
  ADJUSTMENT_KINDS,
@@ -409,7 +409,7 @@ import {
409
409
  writePsd,
410
410
  xyToTempTint,
411
411
  xyToUv
412
- } from "../chunk-XMSH66VU.js";
412
+ } from "../chunk-FNIWHSLI.js";
413
413
  import {
414
414
  flattenPath,
415
415
  rasterizeCoverage
@@ -683,14 +683,14 @@ import {
683
683
  warpMeshParamAt,
684
684
  warpMeshRows,
685
685
  warpPresetMesh
686
- } from "../chunk-4EM5NGCL.js";
686
+ } from "../chunk-HHX7ST6Z.js";
687
687
  import {
688
688
  IMAGE_PROFILE,
689
689
  checkBrush,
690
690
  closest,
691
691
  createBrushLibrary,
692
692
  resolveBrush
693
- } from "../chunk-XVPQNX7O.js";
693
+ } from "../chunk-PPDUCZZV.js";
694
694
  import "../chunk-JL6YH5WD.js";
695
695
  import "../chunk-RWDSSK2I.js";
696
696
  import {
@@ -149,6 +149,17 @@ export interface MutationSettings {
149
149
  }
150
150
  export declare const DEFAULT_MUTATION: Readonly<MutationSettings>;
151
151
  export declare function normalizeMutation(raw?: Partial<MutationSettings>, base?: MutationSettings): MutationSettings;
152
+ /** Ferrofluid paint: how much liquid ferrofluid a brush lays, and its Rosensweig spike settings (see `doc.ferro`). */
153
+ export interface FerroSettings {
154
+ /** How much liquid ferrofluid each dab lays, scaled by coverage and flow (0..1, default 0: none). */
155
+ amount: number;
156
+ /** Capillary wavelength (spike-to-spike spacing) in pixels (4..64, default 16). */
157
+ wavelength: number;
158
+ /** Critical magnetic field |B| threshold where spikes begin to rise (0.01..0.95, default 0.12). */
159
+ threshold: number;
160
+ }
161
+ export declare const DEFAULT_FERRO: Readonly<FerroSettings>;
162
+ export declare function normalizeFerro(raw?: number | Partial<FerroSettings> | null | undefined, base?: FerroSettings): FerroSettings;
152
163
  export declare function normalizeImpasto(raw?: Partial<ImpastoSettings>, base?: ImpastoSettings): ImpastoSettings;
153
164
  /** Multi-coloured brush: `tip` lays the colours across the tip, `dab` changes colour from dab to dab. */
154
165
  export type MultiColorMode = 'off' | 'tip' | 'dab';
@@ -283,6 +294,8 @@ export interface PaintBrush {
283
294
  ghost?: GhostSettings;
284
295
  /** Mutation paint settings (missing when not a mutation brush). */
285
296
  mutation?: MutationSettings;
297
+ /** Ferrofluid paint: liquid ferrofluid it lays and its Rosensweig spike settings (amount 0: none). */
298
+ ferro: FerroSettings;
286
299
  /**
287
300
  * Dab engine: a stamp tip. Each dab (and each spray droplet) is this sticker's shape, an editor
288
301
  * stamp (`editor:star`) or any sticker ref from the sticker library, laid in the paint colour;
@@ -321,7 +334,7 @@ export interface SpraySettings {
321
334
  size: number;
322
335
  }
323
336
  export declare const DEFAULT_SPRAY: Readonly<SpraySettings>;
324
- export type PaintBrushInput = Partial<Omit<PaintBrush, 'tip' | 'dynamics' | 'mixer' | 'behavior' | 'blender' | 'smudge' | 'wet' | 'impasto' | 'particle' | 'bristle' | 'sticker' | 'paint' | 'light' | 'fluorescence' | 'thermochromic' | 'iridescence' | 'ghost' | 'mutation' | 'spray' | 'tipGrain' | 'livePatterns'>> & {
337
+ export type PaintBrushInput = Partial<Omit<PaintBrush, 'tip' | 'dynamics' | 'mixer' | 'behavior' | 'blender' | 'smudge' | 'wet' | 'impasto' | 'particle' | 'bristle' | 'sticker' | 'paint' | 'light' | 'fluorescence' | 'thermochromic' | 'iridescence' | 'ghost' | 'mutation' | 'ferro' | 'spray' | 'tipGrain' | 'livePatterns'>> & {
325
338
  spray?: Partial<SpraySettings> | null;
326
339
  /** Tip grain (`null` removes it). */
327
340
  tipGrain?: Partial<TipGrainSettings> | null;
@@ -336,6 +349,7 @@ export type PaintBrushInput = Partial<Omit<PaintBrush, 'tip' | 'dynamics' | 'mix
336
349
  thermochromic?: Partial<ThermochromicSettings>;
337
350
  /** Thin-film iridescence (`null` removes it). */
338
351
  iridescence?: Partial<IridescenceSettings> | null;
352
+ ferro?: number | Partial<FerroSettings>;
339
353
  /** A gradient (or `null` for the editor's paint); a plain colour source clears it. */
340
354
  paint?: PaintSource | null;
341
355
  tip?: Partial<BrushSettings>;
@@ -149,6 +149,17 @@ export interface MutationSettings {
149
149
  }
150
150
  export declare const DEFAULT_MUTATION: Readonly<MutationSettings>;
151
151
  export declare function normalizeMutation(raw?: Partial<MutationSettings>, base?: MutationSettings): MutationSettings;
152
+ /** Ferrofluid paint: how much liquid ferrofluid a brush lays, and its Rosensweig spike settings (see `doc.ferro`). */
153
+ export interface FerroSettings {
154
+ /** How much liquid ferrofluid each dab lays, scaled by coverage and flow (0..1, default 0: none). */
155
+ amount: number;
156
+ /** Capillary wavelength (spike-to-spike spacing) in pixels (4..64, default 16). */
157
+ wavelength: number;
158
+ /** Critical magnetic field |B| threshold where spikes begin to rise (0.01..0.95, default 0.12). */
159
+ threshold: number;
160
+ }
161
+ export declare const DEFAULT_FERRO: Readonly<FerroSettings>;
162
+ export declare function normalizeFerro(raw?: number | Partial<FerroSettings> | null | undefined, base?: FerroSettings): FerroSettings;
152
163
  export declare function normalizeImpasto(raw?: Partial<ImpastoSettings>, base?: ImpastoSettings): ImpastoSettings;
153
164
  /** Multi-coloured brush: `tip` lays the colours across the tip, `dab` changes colour from dab to dab. */
154
165
  export type MultiColorMode = 'off' | 'tip' | 'dab';
@@ -283,6 +294,8 @@ export interface PaintBrush {
283
294
  ghost?: GhostSettings;
284
295
  /** Mutation paint settings (missing when not a mutation brush). */
285
296
  mutation?: MutationSettings;
297
+ /** Ferrofluid paint: liquid ferrofluid it lays and its Rosensweig spike settings (amount 0: none). */
298
+ ferro: FerroSettings;
286
299
  /**
287
300
  * Dab engine: a stamp tip. Each dab (and each spray droplet) is this sticker's shape, an editor
288
301
  * stamp (`editor:star`) or any sticker ref from the sticker library, laid in the paint colour;
@@ -321,7 +334,7 @@ export interface SpraySettings {
321
334
  size: number;
322
335
  }
323
336
  export declare const DEFAULT_SPRAY: Readonly<SpraySettings>;
324
- export type PaintBrushInput = Partial<Omit<PaintBrush, 'tip' | 'dynamics' | 'mixer' | 'behavior' | 'blender' | 'smudge' | 'wet' | 'impasto' | 'particle' | 'bristle' | 'sticker' | 'paint' | 'light' | 'fluorescence' | 'thermochromic' | 'iridescence' | 'ghost' | 'mutation' | 'spray' | 'tipGrain' | 'livePatterns'>> & {
337
+ export type PaintBrushInput = Partial<Omit<PaintBrush, 'tip' | 'dynamics' | 'mixer' | 'behavior' | 'blender' | 'smudge' | 'wet' | 'impasto' | 'particle' | 'bristle' | 'sticker' | 'paint' | 'light' | 'fluorescence' | 'thermochromic' | 'iridescence' | 'ghost' | 'mutation' | 'ferro' | 'spray' | 'tipGrain' | 'livePatterns'>> & {
325
338
  spray?: Partial<SpraySettings> | null;
326
339
  /** Tip grain (`null` removes it). */
327
340
  tipGrain?: Partial<TipGrainSettings> | null;
@@ -336,6 +349,7 @@ export type PaintBrushInput = Partial<Omit<PaintBrush, 'tip' | 'dynamics' | 'mix
336
349
  thermochromic?: Partial<ThermochromicSettings>;
337
350
  /** Thin-film iridescence (`null` removes it). */
338
351
  iridescence?: Partial<IridescenceSettings> | null;
352
+ ferro?: number | Partial<FerroSettings>;
339
353
  /** A gradient (or `null` for the editor's paint); a plain colour source clears it. */
340
354
  paint?: PaintSource | null;
341
355
  tip?: Partial<BrushSettings>;
@@ -63,6 +63,7 @@ import type { PaintDirector } from './presence.cjs';
63
63
  import type { PresenceActorInfo } from '../animation/presence.cjs';
64
64
  import { type PaintProposals } from './collaboration.cjs';
65
65
  import { type BreedStrokesResult, type ProposalGenomeResult, type StrokeMutationRates } from './evolution.cjs';
66
+ import { type PaintMagnet } from './ferro.cjs';
66
67
  export declare class PaintEditorError extends Error {
67
68
  constructor(message: string);
68
69
  }
@@ -548,6 +549,9 @@ export declare class PaintEditorController {
548
549
  private readonly litCaches;
549
550
  /** The live impasto stroke's channels. */
550
551
  private liveThick;
552
+ /** The live ferrofluid stroke's channel. */
553
+ private liveFerro;
554
+ private nextMagnetId;
551
555
  /** The UV / blacklight setting (saved with the document, not undo steps). */
552
556
  private uvLight;
553
557
  private readonly uvCaches;
@@ -1286,6 +1290,51 @@ export declare class PaintEditorController {
1286
1290
  layerId: string;
1287
1291
  flattened: boolean;
1288
1292
  };
1293
+ /** The document's `ferro` and `ferroSettings` without these layers. */
1294
+ private ferroWithout;
1295
+ /** Runs deterministic advection on all pixel layers with liquid ferrofluid toward `magnets`. */
1296
+ private advectDocumentFerro;
1297
+ /** Lists the magnets on the canvas. */
1298
+ listMagnets(): PaintMagnet[];
1299
+ /** Places a dipole magnet on the canvas and advects liquid ferrofluid toward it (one undo step). */
1300
+ addMagnet(input: {
1301
+ x: number;
1302
+ y: number;
1303
+ strength?: number;
1304
+ radius?: number;
1305
+ }): PaintMagnet;
1306
+ /** Moves or retunes a magnet and advects liquid ferrofluid toward the magnets (one undo step). */
1307
+ updateMagnet(id: string, update: {
1308
+ x?: number;
1309
+ y?: number;
1310
+ strength?: number;
1311
+ radius?: number;
1312
+ }, commitToHistory?: boolean): PaintMagnet;
1313
+ /** Removes magnets by id, or all magnets when `ids` is omitted or empty (one undo step). Returns the removed ids. */
1314
+ removeMagnets(ids?: readonly string[]): string[];
1315
+ /** Replaces the canvas magnets (used by replay). */
1316
+ replaceMagnets(magnets: readonly PaintMagnet[], advect?: boolean): void;
1317
+ /** Reads a layer's liquid ferrofluid coverage and active Rosensweig spike relief under the canvas magnets. */
1318
+ getFerrofluid(layerId?: string): {
1319
+ layerId: string;
1320
+ ferro: boolean;
1321
+ bounds: RasterRect | null;
1322
+ peak: number;
1323
+ coverage: number;
1324
+ activeSpikeArea: number;
1325
+ maxSpikeHeight: number;
1326
+ magnetCount: number;
1327
+ wavelength: number;
1328
+ threshold: number;
1329
+ };
1330
+ /** Cures a layer's liquid ferrofluid: bakes the current Rosensweig spikes and glossy metallic material into `doc.thick` and clears `doc.ferro` (one undo step). */
1331
+ cureFerrofluid(layerId?: string): {
1332
+ layerId: string;
1333
+ cured: boolean;
1334
+ bounds: RasterRect | null;
1335
+ maxSpikeHeight: number;
1336
+ activeSpikeArea: number;
1337
+ };
1289
1338
  /**
1290
1339
  * A fresh, headless editor for replaying this painting at `scale` (canvas, dpi and paper grain
1291
1340
  * scaled; every pixel layer empty; the same brushes, lighting and wet options). Dispose of it
@@ -1477,7 +1526,7 @@ export declare class PaintEditorController {
1477
1526
  * Linear Dodge (`::uv`) spill layer directly above it.
1478
1527
  */
1479
1528
  private uvLayers;
1480
- /** The layers with thick paint lit (each thick layer's pixels replaced by its lit tiles). */
1529
+ /** The layers with thick paint and ferrofluid spikes lit (each thick or ferrofluid layer's pixels replaced by its lit tiles). */
1481
1530
  private litLayers;
1482
1531
  /**
1483
1532
  * Light paint over the (lit) layers: glow, rim and clarity in each layer's pixels, and its spill
@@ -63,6 +63,7 @@ import type { PaintDirector } from './presence.js';
63
63
  import type { PresenceActorInfo } from '../animation/presence.js';
64
64
  import { type PaintProposals } from './collaboration.js';
65
65
  import { type BreedStrokesResult, type ProposalGenomeResult, type StrokeMutationRates } from './evolution.js';
66
+ import { type PaintMagnet } from './ferro.js';
66
67
  export declare class PaintEditorError extends Error {
67
68
  constructor(message: string);
68
69
  }
@@ -548,6 +549,9 @@ export declare class PaintEditorController {
548
549
  private readonly litCaches;
549
550
  /** The live impasto stroke's channels. */
550
551
  private liveThick;
552
+ /** The live ferrofluid stroke's channel. */
553
+ private liveFerro;
554
+ private nextMagnetId;
551
555
  /** The UV / blacklight setting (saved with the document, not undo steps). */
552
556
  private uvLight;
553
557
  private readonly uvCaches;
@@ -1286,6 +1290,51 @@ export declare class PaintEditorController {
1286
1290
  layerId: string;
1287
1291
  flattened: boolean;
1288
1292
  };
1293
+ /** The document's `ferro` and `ferroSettings` without these layers. */
1294
+ private ferroWithout;
1295
+ /** Runs deterministic advection on all pixel layers with liquid ferrofluid toward `magnets`. */
1296
+ private advectDocumentFerro;
1297
+ /** Lists the magnets on the canvas. */
1298
+ listMagnets(): PaintMagnet[];
1299
+ /** Places a dipole magnet on the canvas and advects liquid ferrofluid toward it (one undo step). */
1300
+ addMagnet(input: {
1301
+ x: number;
1302
+ y: number;
1303
+ strength?: number;
1304
+ radius?: number;
1305
+ }): PaintMagnet;
1306
+ /** Moves or retunes a magnet and advects liquid ferrofluid toward the magnets (one undo step). */
1307
+ updateMagnet(id: string, update: {
1308
+ x?: number;
1309
+ y?: number;
1310
+ strength?: number;
1311
+ radius?: number;
1312
+ }, commitToHistory?: boolean): PaintMagnet;
1313
+ /** Removes magnets by id, or all magnets when `ids` is omitted or empty (one undo step). Returns the removed ids. */
1314
+ removeMagnets(ids?: readonly string[]): string[];
1315
+ /** Replaces the canvas magnets (used by replay). */
1316
+ replaceMagnets(magnets: readonly PaintMagnet[], advect?: boolean): void;
1317
+ /** Reads a layer's liquid ferrofluid coverage and active Rosensweig spike relief under the canvas magnets. */
1318
+ getFerrofluid(layerId?: string): {
1319
+ layerId: string;
1320
+ ferro: boolean;
1321
+ bounds: RasterRect | null;
1322
+ peak: number;
1323
+ coverage: number;
1324
+ activeSpikeArea: number;
1325
+ maxSpikeHeight: number;
1326
+ magnetCount: number;
1327
+ wavelength: number;
1328
+ threshold: number;
1329
+ };
1330
+ /** Cures a layer's liquid ferrofluid: bakes the current Rosensweig spikes and glossy metallic material into `doc.thick` and clears `doc.ferro` (one undo step). */
1331
+ cureFerrofluid(layerId?: string): {
1332
+ layerId: string;
1333
+ cured: boolean;
1334
+ bounds: RasterRect | null;
1335
+ maxSpikeHeight: number;
1336
+ activeSpikeArea: number;
1337
+ };
1289
1338
  /**
1290
1339
  * A fresh, headless editor for replaying this painting at `scale` (canvas, dpi and paper grain
1291
1340
  * scaled; every pixel layer empty; the same brushes, lighting and wet options). Dispose of it
@@ -1477,7 +1526,7 @@ export declare class PaintEditorController {
1477
1526
  * Linear Dodge (`::uv`) spill layer directly above it.
1478
1527
  */
1479
1528
  private uvLayers;
1480
- /** The layers with thick paint lit (each thick layer's pixels replaced by its lit tiles). */
1529
+ /** The layers with thick paint and ferrofluid spikes lit (each thick or ferrofluid layer's pixels replaced by its lit tiles). */
1481
1530
  private litLayers;
1482
1531
  /**
1483
1532
  * Light paint over the (lit) layers: glow, rim and clarity in each layer's pixels, and its spill
@@ -13,6 +13,7 @@ import { type PaperSettings } from './paper.cjs';
13
13
  import type { RecordedStroke } from './strokes.cjs';
14
14
  import type { WetEvent } from './wet.cjs';
15
15
  import type { ThickChannels } from './thick.cjs';
16
+ import type { FerroSettings, PaintMagnet } from './ferro.cjs';
16
17
  import type { Sticker } from './stickers.cjs';
17
18
  import type { PaintEffectSource, PaintPaperEffect } from './effects.cjs';
18
19
  import type { HeatField } from './heat.cjs';
@@ -61,6 +62,12 @@ export interface PaintDocumentState {
61
62
  thermo?: Readonly<Record<string, TiledSurface>>;
62
63
  /** Coarse document-level heat field (1/4 resolution) for thermochromic ink and wet-paint drying. See `heat.ts`. */
63
64
  heat?: HeatField;
65
+ /** Ferrofluid paint: each layer's liquid ferrofluid channel, 0..255 per pixel in layer space (layers without ferrofluid are missing). See `ferro.ts`. */
66
+ ferro?: Readonly<Record<string, TiledSurface>>;
67
+ /** Per-layer Rosensweig spike settings (`wavelength`, `threshold`) from the last ferrofluid stroke on that layer. */
68
+ ferroSettings?: Readonly<Record<string, FerroSettings>>;
69
+ /** Dipole magnets placed on the canvas (document pixel coordinates). */
70
+ magnets?: PaintMagnet[];
64
71
  /** Thick paint: each layer's height and material channels (layers without relief are missing). */
65
72
  thick?: Readonly<Record<string, ThickChannels>>;
66
73
  /** Effect layers: the provider effect each one is drawn from (layers that are gone are ignored). */
@@ -79,7 +86,7 @@ export interface PaintDocumentState {
79
86
  /** A change a replay repeats besides strokes, recorded with how many strokes came before it. */
80
87
  export interface PaintEvent {
81
88
  at: number;
82
- kind: 'fill' | 'fill-region' | 'clear' | 'flatten' | 'stickers' | 'live-strokes';
89
+ kind: 'fill' | 'fill-region' | 'clear' | 'flatten' | 'stickers' | 'live-strokes' | 'magnets' | 'cure-ferrofluid';
83
90
  layerId: string;
84
91
  /** The fill's options (colour, opacity, grain, pressure, blend), or the layer's stickers or live strokes after an edit. */
85
92
  data?: Record<string, unknown>;
@@ -13,6 +13,7 @@ import { type PaperSettings } from './paper.js';
13
13
  import type { RecordedStroke } from './strokes.js';
14
14
  import type { WetEvent } from './wet.js';
15
15
  import type { ThickChannels } from './thick.js';
16
+ import type { FerroSettings, PaintMagnet } from './ferro.js';
16
17
  import type { Sticker } from './stickers.js';
17
18
  import type { PaintEffectSource, PaintPaperEffect } from './effects.js';
18
19
  import type { HeatField } from './heat.js';
@@ -61,6 +62,12 @@ export interface PaintDocumentState {
61
62
  thermo?: Readonly<Record<string, TiledSurface>>;
62
63
  /** Coarse document-level heat field (1/4 resolution) for thermochromic ink and wet-paint drying. See `heat.ts`. */
63
64
  heat?: HeatField;
65
+ /** Ferrofluid paint: each layer's liquid ferrofluid channel, 0..255 per pixel in layer space (layers without ferrofluid are missing). See `ferro.ts`. */
66
+ ferro?: Readonly<Record<string, TiledSurface>>;
67
+ /** Per-layer Rosensweig spike settings (`wavelength`, `threshold`) from the last ferrofluid stroke on that layer. */
68
+ ferroSettings?: Readonly<Record<string, FerroSettings>>;
69
+ /** Dipole magnets placed on the canvas (document pixel coordinates). */
70
+ magnets?: PaintMagnet[];
64
71
  /** Thick paint: each layer's height and material channels (layers without relief are missing). */
65
72
  thick?: Readonly<Record<string, ThickChannels>>;
66
73
  /** Effect layers: the provider effect each one is drawn from (layers that are gone are ignored). */
@@ -79,7 +86,7 @@ export interface PaintDocumentState {
79
86
  /** A change a replay repeats besides strokes, recorded with how many strokes came before it. */
80
87
  export interface PaintEvent {
81
88
  at: number;
82
- kind: 'fill' | 'fill-region' | 'clear' | 'flatten' | 'stickers' | 'live-strokes';
89
+ kind: 'fill' | 'fill-region' | 'clear' | 'flatten' | 'stickers' | 'live-strokes' | 'magnets' | 'cure-ferrofluid';
83
90
  layerId: string;
84
91
  /** The fill's options (colour, opacity, grain, pressure, blend), or the layer's stickers or live strokes after an edit. */
85
92
  data?: Record<string, unknown>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Agent tools for ferrofluid paint and magnets: ferrofluid strokes are painted with `paint_stroke`
3
+ * and a brush whose `ferro.amount` is above 0 (`ferrofluid`, `ferro-ink`); these tools place,
4
+ * move and remove magnets (`add_magnet`, `update_magnet`, `remove_magnets`, `list_magnets`), read
5
+ * a layer's liquid ferrofluid and live Rosensweig spike stats (`get_ferrofluid`), and cure the
6
+ * liquid spikes into solid impasto relief (`cure_ferrofluid`).
7
+ */
8
+ import type { PaintAgentTool } from './hosted.cjs';
9
+ import type { PaintEditorController } from './controller.cjs';
10
+ export declare function createFerroTools(c: PaintEditorController): PaintAgentTool[];
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Agent tools for ferrofluid paint and magnets: ferrofluid strokes are painted with `paint_stroke`
3
+ * and a brush whose `ferro.amount` is above 0 (`ferrofluid`, `ferro-ink`); these tools place,
4
+ * move and remove magnets (`add_magnet`, `update_magnet`, `remove_magnets`, `list_magnets`), read
5
+ * a layer's liquid ferrofluid and live Rosensweig spike stats (`get_ferrofluid`), and cure the
6
+ * liquid spikes into solid impasto relief (`cure_ferrofluid`).
7
+ */
8
+ import type { PaintAgentTool } from './hosted.js';
9
+ import type { PaintEditorController } from './controller.js';
10
+ export declare function createFerroTools(c: PaintEditorController): PaintAgentTool[];
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Ferrofluid paint: a per-layer liquid ferrofluid channel (`doc.ferro`, one byte 0..255 per pixel
3
+ * in layer space, like `doc.light`) and document-space magnets (`doc.magnets`).
4
+ *
5
+ * Where the analytical dipole magnetic field `|B|` exceeds the critical threshold `Bc` and
6
+ * `ferro > 0`, the liquid rises into a hexagonal Rosensweig spike relief field whose spacing
7
+ * follows the capillary wavelength, whose amplitude scales as `sqrt(1 - Bc / |B|) * ferro`, and
8
+ * whose peaks tilt along `B`. Adding or moving a magnet runs deterministic advection steps on
9
+ * `doc.ferro` so the liquid gathers toward the magnets.
10
+ *
11
+ * At render time, the spike height combines with `doc.thick` height and defaults to a dark,
12
+ * glossy metallic material where `ferro > 0`, so `lightTile` shades the spikes with the canvas
13
+ * lighting. `cure_ferrofluid` bakes the current spikes into `doc.thick` and clears `doc.ferro`.
14
+ */
15
+ import { TiledSurface, type RasterRect } from '../raster/surface.cjs';
16
+ import { DEFAULT_FERRO, normalizeFerro as normalizeFerroSettings, type FerroSettings, type PaintBrush } from './brushes.cjs';
17
+ import { type ThickChannels } from './thick.cjs';
18
+ export { DEFAULT_FERRO, normalizeFerroSettings, type FerroSettings };
19
+ /** A dipole magnet on the canvas (document pixel coordinates). */
20
+ export interface PaintMagnet {
21
+ id: string;
22
+ x: number;
23
+ y: number;
24
+ /** Dipole strength and polarity (-1 .. 1). */
25
+ strength: number;
26
+ /** Characteristic dipole falloff radius in canvas pixels (8 .. 2000). */
27
+ radius: number;
28
+ }
29
+ export declare const DEFAULT_MAGNET_STRENGTH = 0.75;
30
+ export declare const DEFAULT_MAGNET_RADIUS = 72;
31
+ /** Dark carrier fluid sRGB colour (`#111827`) for advected ferrofluid outside existing pigment. */
32
+ export declare const FERRO_CARRIER_RGB: readonly [number, number, number];
33
+ /** Default glossy metallic material for ferrofluid (`gloss ≈ 0.88`, `metallic ≈ 0.65`, `rim ≈ 0.25`). */
34
+ export declare const FERRO_DEFAULT_GLOSS = 224;
35
+ export declare const FERRO_DEFAULT_METALLIC = 166;
36
+ export declare const FERRO_DEFAULT_RIM = 64;
37
+ /** Validates and normalizes a magnet; throws a descriptive Error when a field is out of range. */
38
+ export declare function normalizeMagnet(raw: Partial<PaintMagnet> & {
39
+ id: string;
40
+ x: number;
41
+ y: number;
42
+ }, base?: PaintMagnet): PaintMagnet;
43
+ export declare function emptyFerro(): TiledSurface;
44
+ /** The ferrofluid channel as grey paint for a stroke's twin: white, with alpha = ferro. */
45
+ export declare function ferroToShadow(ferro: TiledSurface): TiledSurface;
46
+ /** A twin's grey paint back as ferrofluid: grey × alpha. */
47
+ export declare function shadowToFerro(shadow: TiledSurface): TiledSurface;
48
+ /** The brush a stroke's ferrofluid twin paints with: same geometry, normal blend, RGB mixing. */
49
+ export declare function ferroTwinBrush(brush: PaintBrush): PaintBrush;
50
+ /** Ferrofluid scaled by `k` (0..1), e.g. under a fill that covers it. */
51
+ export declare function scaleFerro(ferro: TiledSurface, k: number): TiledSurface;
52
+ /** Ferrofluid of an upper layer merged down onto the one below (both in lower layer's space). */
53
+ export declare function mergeFerro(lower: TiledSurface | undefined, upper: TiledSurface | undefined, upperColor: TiledSurface): TiledSurface | undefined;
54
+ export interface FerroFieldSample {
55
+ bx: number;
56
+ by: number;
57
+ bz: number;
58
+ magnitude: number;
59
+ }
60
+ /**
61
+ * Analytical 3D magnetic dipole field `B = (bx, by, bz)` at canvas point `(x, y)` from a set of
62
+ * dipole magnets placed on the canvas plane (`z = -radius`).
63
+ */
64
+ export declare function ferroFieldAt(x: number, y: number, magnets: readonly PaintMagnet[]): FerroFieldSample;
65
+ /**
66
+ * Hexagonal Rosensweig instability spike height (0..255) at canvas coordinate `(x, y)` for a pixel
67
+ * with liquid ferrofluid byte `ferroByte` (0..255).
68
+ *
69
+ * Where `|B| > threshold` and `ferroByte > 0`, spikes rise with amplitude `∝ sqrt(1 - Bc / |B|) * ferro`
70
+ * at the capillary `wavelength` and tilt along the tangential field `(bx, by)`.
71
+ */
72
+ export declare function ferroSpikeHeightAt(x: number, y: number, ferroByte: number, magnets: readonly PaintMagnet[], settings?: FerroSettings): number;
73
+ /**
74
+ * Runs deterministic mass-gathering advection steps on a layer's `ferro` surface toward `magnets`.
75
+ * Each step transports liquid ferrofluid along the dipole attraction field via bilinear splatting.
76
+ */
77
+ export declare function advectFerro(ferro: TiledSurface, magnets: readonly PaintMagnet[], offset?: {
78
+ x: number;
79
+ y: number;
80
+ }, steps?: number): TiledSurface;
81
+ export interface FerroStats {
82
+ bounds: RasterRect | null;
83
+ peak: number;
84
+ liquidPixels: number;
85
+ maxSpikeHeight: number;
86
+ spikePixels: number;
87
+ magnetCount: number;
88
+ }
89
+ /**
90
+ * Inspects a layer's liquid ferrofluid channel and active Rosensweig spike relief under `magnets`.
91
+ */
92
+ export declare function ferroInfo(ferro: TiledSurface | undefined, magnets?: readonly PaintMagnet[], settings?: FerroSettings, offset?: {
93
+ x: number;
94
+ y: number;
95
+ }): FerroStats;
96
+ export interface FerroLayerInput {
97
+ surface: TiledSurface;
98
+ magnets: readonly PaintMagnet[];
99
+ settings: FerroSettings;
100
+ offset: {
101
+ x: number;
102
+ y: number;
103
+ };
104
+ }
105
+ export declare function ferroCacheKey(ferro: FerroLayerInput | null | undefined): string;
106
+ /**
107
+ * Bakes a layer's liquid ferrofluid and active Rosensweig spikes into permanent `ThickChannels`
108
+ * (and ensures carrier colour covers advected ferrofluid pixels) for `cure_ferrofluid`.
109
+ */
110
+ export declare function bakeFerroIntoThick(color: TiledSurface, ferro: TiledSurface, thick: ThickChannels | undefined, magnets: readonly PaintMagnet[], settings?: FerroSettings, offset?: {
111
+ x: number;
112
+ y: number;
113
+ }): {
114
+ color: TiledSurface;
115
+ thick: ThickChannels;
116
+ maxSpikeHeight: number;
117
+ spikePixels: number;
118
+ curedPixels: number;
119
+ };
120
+ /** A layer's liquid ferrofluid as saved: its rect (layer space), grey PNG, and spike settings. */
121
+ export interface SavedFerro {
122
+ rect: RasterRect;
123
+ ferro: string;
124
+ wavelength?: number;
125
+ threshold?: number;
126
+ }
127
+ export declare function ferroToJSON(ferro: TiledSurface, settings?: FerroSettings): SavedFerro | null;
128
+ /** Saved form → ferrofluid surface and settings; throws a sentence when it cannot be read. */
129
+ export declare function ferroFromJSON(raw: unknown): {
130
+ surface: TiledSurface;
131
+ settings: FerroSettings;
132
+ };
133
+ /** Saved magnets array → validated `PaintMagnet[]`. */
134
+ export declare function magnetsFromJSON(raw: unknown): PaintMagnet[];
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Ferrofluid paint: a per-layer liquid ferrofluid channel (`doc.ferro`, one byte 0..255 per pixel
3
+ * in layer space, like `doc.light`) and document-space magnets (`doc.magnets`).
4
+ *
5
+ * Where the analytical dipole magnetic field `|B|` exceeds the critical threshold `Bc` and
6
+ * `ferro > 0`, the liquid rises into a hexagonal Rosensweig spike relief field whose spacing
7
+ * follows the capillary wavelength, whose amplitude scales as `sqrt(1 - Bc / |B|) * ferro`, and
8
+ * whose peaks tilt along `B`. Adding or moving a magnet runs deterministic advection steps on
9
+ * `doc.ferro` so the liquid gathers toward the magnets.
10
+ *
11
+ * At render time, the spike height combines with `doc.thick` height and defaults to a dark,
12
+ * glossy metallic material where `ferro > 0`, so `lightTile` shades the spikes with the canvas
13
+ * lighting. `cure_ferrofluid` bakes the current spikes into `doc.thick` and clears `doc.ferro`.
14
+ */
15
+ import { TiledSurface, type RasterRect } from '../raster/surface.js';
16
+ import { DEFAULT_FERRO, normalizeFerro as normalizeFerroSettings, type FerroSettings, type PaintBrush } from './brushes.js';
17
+ import { type ThickChannels } from './thick.js';
18
+ export { DEFAULT_FERRO, normalizeFerroSettings, type FerroSettings };
19
+ /** A dipole magnet on the canvas (document pixel coordinates). */
20
+ export interface PaintMagnet {
21
+ id: string;
22
+ x: number;
23
+ y: number;
24
+ /** Dipole strength and polarity (-1 .. 1). */
25
+ strength: number;
26
+ /** Characteristic dipole falloff radius in canvas pixels (8 .. 2000). */
27
+ radius: number;
28
+ }
29
+ export declare const DEFAULT_MAGNET_STRENGTH = 0.75;
30
+ export declare const DEFAULT_MAGNET_RADIUS = 72;
31
+ /** Dark carrier fluid sRGB colour (`#111827`) for advected ferrofluid outside existing pigment. */
32
+ export declare const FERRO_CARRIER_RGB: readonly [number, number, number];
33
+ /** Default glossy metallic material for ferrofluid (`gloss ≈ 0.88`, `metallic ≈ 0.65`, `rim ≈ 0.25`). */
34
+ export declare const FERRO_DEFAULT_GLOSS = 224;
35
+ export declare const FERRO_DEFAULT_METALLIC = 166;
36
+ export declare const FERRO_DEFAULT_RIM = 64;
37
+ /** Validates and normalizes a magnet; throws a descriptive Error when a field is out of range. */
38
+ export declare function normalizeMagnet(raw: Partial<PaintMagnet> & {
39
+ id: string;
40
+ x: number;
41
+ y: number;
42
+ }, base?: PaintMagnet): PaintMagnet;
43
+ export declare function emptyFerro(): TiledSurface;
44
+ /** The ferrofluid channel as grey paint for a stroke's twin: white, with alpha = ferro. */
45
+ export declare function ferroToShadow(ferro: TiledSurface): TiledSurface;
46
+ /** A twin's grey paint back as ferrofluid: grey × alpha. */
47
+ export declare function shadowToFerro(shadow: TiledSurface): TiledSurface;
48
+ /** The brush a stroke's ferrofluid twin paints with: same geometry, normal blend, RGB mixing. */
49
+ export declare function ferroTwinBrush(brush: PaintBrush): PaintBrush;
50
+ /** Ferrofluid scaled by `k` (0..1), e.g. under a fill that covers it. */
51
+ export declare function scaleFerro(ferro: TiledSurface, k: number): TiledSurface;
52
+ /** Ferrofluid of an upper layer merged down onto the one below (both in lower layer's space). */
53
+ export declare function mergeFerro(lower: TiledSurface | undefined, upper: TiledSurface | undefined, upperColor: TiledSurface): TiledSurface | undefined;
54
+ export interface FerroFieldSample {
55
+ bx: number;
56
+ by: number;
57
+ bz: number;
58
+ magnitude: number;
59
+ }
60
+ /**
61
+ * Analytical 3D magnetic dipole field `B = (bx, by, bz)` at canvas point `(x, y)` from a set of
62
+ * dipole magnets placed on the canvas plane (`z = -radius`).
63
+ */
64
+ export declare function ferroFieldAt(x: number, y: number, magnets: readonly PaintMagnet[]): FerroFieldSample;
65
+ /**
66
+ * Hexagonal Rosensweig instability spike height (0..255) at canvas coordinate `(x, y)` for a pixel
67
+ * with liquid ferrofluid byte `ferroByte` (0..255).
68
+ *
69
+ * Where `|B| > threshold` and `ferroByte > 0`, spikes rise with amplitude `∝ sqrt(1 - Bc / |B|) * ferro`
70
+ * at the capillary `wavelength` and tilt along the tangential field `(bx, by)`.
71
+ */
72
+ export declare function ferroSpikeHeightAt(x: number, y: number, ferroByte: number, magnets: readonly PaintMagnet[], settings?: FerroSettings): number;
73
+ /**
74
+ * Runs deterministic mass-gathering advection steps on a layer's `ferro` surface toward `magnets`.
75
+ * Each step transports liquid ferrofluid along the dipole attraction field via bilinear splatting.
76
+ */
77
+ export declare function advectFerro(ferro: TiledSurface, magnets: readonly PaintMagnet[], offset?: {
78
+ x: number;
79
+ y: number;
80
+ }, steps?: number): TiledSurface;
81
+ export interface FerroStats {
82
+ bounds: RasterRect | null;
83
+ peak: number;
84
+ liquidPixels: number;
85
+ maxSpikeHeight: number;
86
+ spikePixels: number;
87
+ magnetCount: number;
88
+ }
89
+ /**
90
+ * Inspects a layer's liquid ferrofluid channel and active Rosensweig spike relief under `magnets`.
91
+ */
92
+ export declare function ferroInfo(ferro: TiledSurface | undefined, magnets?: readonly PaintMagnet[], settings?: FerroSettings, offset?: {
93
+ x: number;
94
+ y: number;
95
+ }): FerroStats;
96
+ export interface FerroLayerInput {
97
+ surface: TiledSurface;
98
+ magnets: readonly PaintMagnet[];
99
+ settings: FerroSettings;
100
+ offset: {
101
+ x: number;
102
+ y: number;
103
+ };
104
+ }
105
+ export declare function ferroCacheKey(ferro: FerroLayerInput | null | undefined): string;
106
+ /**
107
+ * Bakes a layer's liquid ferrofluid and active Rosensweig spikes into permanent `ThickChannels`
108
+ * (and ensures carrier colour covers advected ferrofluid pixels) for `cure_ferrofluid`.
109
+ */
110
+ export declare function bakeFerroIntoThick(color: TiledSurface, ferro: TiledSurface, thick: ThickChannels | undefined, magnets: readonly PaintMagnet[], settings?: FerroSettings, offset?: {
111
+ x: number;
112
+ y: number;
113
+ }): {
114
+ color: TiledSurface;
115
+ thick: ThickChannels;
116
+ maxSpikeHeight: number;
117
+ spikePixels: number;
118
+ curedPixels: number;
119
+ };
120
+ /** A layer's liquid ferrofluid as saved: its rect (layer space), grey PNG, and spike settings. */
121
+ export interface SavedFerro {
122
+ rect: RasterRect;
123
+ ferro: string;
124
+ wavelength?: number;
125
+ threshold?: number;
126
+ }
127
+ export declare function ferroToJSON(ferro: TiledSurface, settings?: FerroSettings): SavedFerro | null;
128
+ /** Saved form → ferrofluid surface and settings; throws a sentence when it cannot be read. */
129
+ export declare function ferroFromJSON(raw: unknown): {
130
+ surface: TiledSurface;
131
+ settings: FerroSettings;
132
+ };
133
+ /** Saved magnets array → validated `PaintMagnet[]`. */
134
+ export declare function magnetsFromJSON(raw: unknown): PaintMagnet[];