@pixerate/editor 0.272.0 → 0.273.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/agent/index.cjs +34 -32
  2. package/dist/agent/index.js +10 -8
  3. package/dist/chart/contract.d.cts +95 -0
  4. package/dist/chart/contract.d.ts +95 -0
  5. package/dist/chart/controller.d.cts +4 -0
  6. package/dist/chart/controller.d.ts +4 -0
  7. package/dist/chart/index.cjs +492 -26
  8. package/dist/chart/index.d.cts +1 -0
  9. package/dist/chart/index.d.ts +1 -0
  10. package/dist/chart/index.js +476 -10
  11. package/dist/chart/tools.d.cts +3 -0
  12. package/dist/chart/tools.d.ts +3 -0
  13. package/dist/chart-maps/index.cjs +4 -64
  14. package/dist/chart-maps/index.js +3 -63
  15. package/dist/chunk-2A5IKZMU.js +557 -0
  16. package/dist/{chunk-NMZQMPFZ.js → chunk-4LZQO5Y7.js} +2 -0
  17. package/dist/chunk-5UGRMVNE.js +328 -0
  18. package/dist/{chunk-X4FTNFIA.js → chunk-7MGPW2OV.js} +1 -1
  19. package/dist/{chunk-DGITFN73.cjs → chunk-BWJBZAUE.cjs} +8 -8
  20. package/dist/chunk-CHZYVNXZ.js +336 -0
  21. package/dist/chunk-DRMZ5R3S.cjs +67 -0
  22. package/dist/chunk-EI5GFTKG.js +67 -0
  23. package/dist/chunk-JV7DKLMX.js +1073 -0
  24. package/dist/chunk-MFBUQC67.cjs +328 -0
  25. package/dist/{chunk-IBXEQOGI.js → chunk-PYHB2EHE.js} +2 -324
  26. package/dist/chunk-QEUI6OGE.cjs +336 -0
  27. package/dist/{chunk-6ZSFRRJI.js → chunk-RSF6BT34.js} +1 -1
  28. package/dist/chunk-T2HUQBRC.cjs +1073 -0
  29. package/dist/{chunk-GSLOH2IN.cjs → chunk-VF6ODSYX.cjs} +2 -0
  30. package/dist/{chunk-NQUKTRKN.cjs → chunk-WZLNNGG3.cjs} +45 -45
  31. package/dist/{chunk-NRBGAEFI.cjs → chunk-Y3RUYOOW.cjs} +8 -330
  32. package/dist/chunk-YH6SKC7P.cjs +557 -0
  33. package/dist/commands/index.cjs +7 -323
  34. package/dist/commands/index.js +5 -321
  35. package/dist/extensions/index.cjs +4 -4
  36. package/dist/extensions/index.js +3 -3
  37. package/dist/index.cjs +9 -9
  38. package/dist/index.js +3 -3
  39. package/dist/paint-editor/controller.d.cts +86 -0
  40. package/dist/paint-editor/controller.d.ts +86 -0
  41. package/dist/paint-editor/document.d.cts +1 -1
  42. package/dist/paint-editor/document.d.ts +1 -1
  43. package/dist/paint-editor/index.cjs +559 -166
  44. package/dist/paint-editor/index.d.cts +1 -0
  45. package/dist/paint-editor/index.d.ts +1 -0
  46. package/dist/paint-editor/index.js +397 -4
  47. package/dist/paint-editor/presence.d.cts +5 -1
  48. package/dist/paint-editor/presence.d.ts +5 -1
  49. package/dist/paint-editor/ui.d.cts +5 -2
  50. package/dist/paint-editor/ui.d.ts +5 -2
  51. package/dist/persistence/index.cjs +24 -1042
  52. package/dist/persistence/index.js +22 -1040
  53. package/dist/raster/fill.d.cts +85 -0
  54. package/dist/raster/fill.d.ts +85 -0
  55. package/dist/raster/index.cjs +9 -1
  56. package/dist/raster/index.d.cts +1 -0
  57. package/dist/raster/index.d.ts +1 -0
  58. package/dist/raster/index.js +8 -0
  59. package/dist/tools/index.cjs +5 -3
  60. package/dist/tools/index.js +8 -6
  61. package/package.json +1 -1
  62. package/skills/pixerate-paint-editor/SKILL.md +19 -43
  63. package/skills/pixerate-paint-editor/references/evolution.md +27 -0
  64. package/skills/pixerate-paint-sketch-to-finish/SKILL.md +16 -5
  65. package/skills/pixerate-paint-sketch-to-finish/evals/flats-under-ink.json +2 -3
@@ -61,3 +61,4 @@ export * from './collaboration.cjs';
61
61
  export * from './collaboration-tools.cjs';
62
62
  export { samplePath, MAX_PATH_SAMPLES, type PathSampleOptions } from './path-samples.cjs';
63
63
  export { createPaintConformanceHost, paintContracts, PAINT_CONTRACT_FORMAT_VERSION, PAINT_DOCUMENT_JSON_SCHEMA } from './contract.cjs';
64
+ export { squaredDistanceTransform, fillRegionMask, findUnfilledRegionsMask, type FillRegionPoint, type FillRegionSummary, type FillRegionMaskOptions, type FillRegionMaskResult, type UnfilledRegion, type FindUnfilledRegionsMaskOptions, } from '../raster/fill.cjs';
@@ -61,3 +61,4 @@ export * from './collaboration.js';
61
61
  export * from './collaboration-tools.js';
62
62
  export { samplePath, MAX_PATH_SAMPLES, type PathSampleOptions } from './path-samples.js';
63
63
  export { createPaintConformanceHost, paintContracts, PAINT_CONTRACT_FORMAT_VERSION, PAINT_DOCUMENT_JSON_SCHEMA } from './contract.js';
64
+ export { squaredDistanceTransform, fillRegionMask, findUnfilledRegionsMask, type FillRegionPoint, type FillRegionSummary, type FillRegionMaskOptions, type FillRegionMaskResult, type UnfilledRegion, type FindUnfilledRegionsMaskOptions, } from '../raster/fill.js';
@@ -27,6 +27,11 @@ import {
27
27
  import {
28
28
  ToolRegistry
29
29
  } from "../chunk-K4BGRCSH.js";
30
+ import {
31
+ fillRegionMask,
32
+ findUnfilledRegionsMask,
33
+ squaredDistanceTransform
34
+ } from "../chunk-2A5IKZMU.js";
30
35
  import {
31
36
  BRUSH_OPTION_CONTROLS,
32
37
  createLayerEffectTools,
@@ -221,9 +226,11 @@ import {
221
226
  DEFAULT_FLOW,
222
227
  DEFAULT_WET_PARAMS,
223
228
  MAX_WET_LANES,
229
+ PIGMENT_UNIT,
224
230
  SRGB_TO_LINEAR,
225
231
  WET_FLOW_KINDS,
226
232
  WET_PARAM_KEYS,
233
+ WetMedium,
227
234
  WetWorld,
228
235
  linearToSrgb8,
229
236
  normalizeFlow,
@@ -2927,7 +2934,7 @@ function deserializePaintDocument(raw) {
2927
2934
  const wetEvents = Array.isArray(r.wetEvents) ? r.wetEvents.filter((e) => e && typeof e === "object" && Number.isInteger(e.clock) && typeof e.kind === "string") : [];
2928
2935
  const state = { canvas, paper, layers, activeLayerId, strokes: readStrokes(r.strokes, problems) };
2929
2936
  if (wetEvents.length) state.wetEvents = wetEvents;
2930
- const paintEvents = Array.isArray(r.paintEvents) ? r.paintEvents.filter((e) => e && typeof e === "object" && Number.isInteger(e.at) && typeof e.layerId === "string" && ["fill", "clear", "flatten", "stickers"].includes(e.kind)) : [];
2937
+ const paintEvents = Array.isArray(r.paintEvents) ? r.paintEvents.filter((e) => e && typeof e === "object" && Number.isInteger(e.at) && typeof e.layerId === "string" && ["fill", "fill-region", "clear", "flatten", "stickers", "live-strokes"].includes(e.kind)) : [];
2931
2938
  if (paintEvents.length) state.paintEvents = paintEvents;
2932
2939
  const o = r.origin;
2933
2940
  if (o && typeof o === "object" && Number.isFinite(o.x) && Number.isFinite(o.y) && (o.x || o.y)) state.origin = { x: Math.round(o.x), y: Math.round(o.y) };
@@ -4115,7 +4122,18 @@ async function replayPainting(source, target, options = {}) {
4115
4122
  const e = item.event;
4116
4123
  if (!target.getLayer(e.layerId)) continue;
4117
4124
  if (e.kind === "fill") target.fillLayer({ layerId: e.layerId, ...e.data });
4118
- else if (e.kind === "clear") target.clearLayer(e.layerId);
4125
+ else if (e.kind === "fill-region") {
4126
+ const d = e.data ?? {};
4127
+ if (d.wet) await target.wetIdle();
4128
+ const pts = d.points ? d.points.map((p) => ({ x: p.x * s, y: p.y * s })) : d.x !== void 0 && d.y !== void 0 ? [{ x: d.x * s, y: d.y * s }] : [];
4129
+ target.fillRegion({
4130
+ ...d,
4131
+ layerId: e.layerId,
4132
+ points: pts,
4133
+ ...d.closeGap !== void 0 ? { closeGap: Math.min(32, d.closeGap * s) } : {},
4134
+ ...d.expand !== void 0 ? { expand: Math.max(-16, Math.min(16, d.expand * s)) } : {}
4135
+ });
4136
+ } else if (e.kind === "clear") target.clearLayer(e.layerId);
4119
4137
  else if (e.kind === "live-strokes") {
4120
4138
  const d = e.data;
4121
4139
  if (d?.rasterized) target.replaceLiveStrokes(e.layerId, null);
@@ -5502,6 +5520,252 @@ var PaintEditorController = class _PaintEditorController {
5502
5520
  this.commit("Fill layer", this.withPaintEvent(this.withWetEvents({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })), ...light ? { light } : {} }), { kind: "fill", layerId: l.id, data: { color, opacity, grain: grainAmount, pressure, blend } }));
5503
5521
  return { layerId: l.id, bounds: rect };
5504
5522
  }
5523
+ /**
5524
+ * Flood-fills one or more contiguous regions on a pixel layer (one undo step), sampling boundaries
5525
+ * from the layer itself (`reference: 'layer'`), all visible layers (`'all'` / `'composite'`), or a
5526
+ * specific reference layer id (e.g. an ink line-art layer above a flats layer). Closes line-art gaps
5527
+ * up to `closeGap` px and expands (`> 0`, bleeding under lines) or contracts (`< 0`) by `expand` px.
5528
+ * With `wet: true`, pours a watercolour wash into the region on the layer's wet layer.
5529
+ */
5530
+ fillRegion(options) {
5531
+ this.requireIdle("filling");
5532
+ const fillTarget = this.paintTarget(options.layerId, "fill");
5533
+ if (this.doc.stickers?.[fillTarget.id]?.length) throw new PaintEditorError(`Layer "${fillTarget.name}" is a sticker layer; fill another layer (add_layer).`);
5534
+ if (this.doc.liveStrokes?.[fillTarget.id]) throw new PaintEditorError(`Layer "${fillTarget.name}" is a stroke layer (its pixels are drawn from its strokes); fill another layer (add_layer), or rasterize it first (rasterize_stroke_layer).`);
5535
+ const { width, height } = this.doc.canvas;
5536
+ const pts = [];
5537
+ if (options.points !== void 0) {
5538
+ if (!Array.isArray(options.points) || !options.points.length) {
5539
+ throw new PaintEditorError("points is empty; give at least one {x, y} in document pixels.");
5540
+ }
5541
+ for (let i = 0; i < options.points.length; i++) {
5542
+ const p = options.points[i];
5543
+ if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) {
5544
+ throw new PaintEditorError(`points[${i}] needs finite x and y in document pixels.`);
5545
+ }
5546
+ pts.push({ x: p.x, y: p.y });
5547
+ }
5548
+ }
5549
+ if (options.x !== void 0 || options.y !== void 0) {
5550
+ if (!Number.isFinite(options.x) || !Number.isFinite(options.y)) {
5551
+ throw new PaintEditorError("x and y must be finite numbers in document pixels.");
5552
+ }
5553
+ if (!options.points) pts.push({ x: options.x, y: options.y });
5554
+ }
5555
+ if (!pts.length) {
5556
+ throw new PaintEditorError("Pass x and y (or points) in document pixels to fill a region.");
5557
+ }
5558
+ for (const p of pts) {
5559
+ const px = Math.floor(p.x);
5560
+ const py = Math.floor(p.y);
5561
+ if (px < 0 || py < 0 || px >= width || py >= height) {
5562
+ throw new PaintEditorError(`(${p.x}, ${p.y}) is outside the ${width}\xD7${height} canvas.`);
5563
+ }
5564
+ }
5565
+ const refSpec = options.reference ?? "layer";
5566
+ if (refSpec !== "layer" && refSpec !== "all" && refSpec !== "composite") {
5567
+ this.requireLayer(refSpec);
5568
+ }
5569
+ const color = options.color === void 0 ? this.color : parseColor(options.color);
5570
+ const opacity = options.opacity ?? 1;
5571
+ if (!(Number.isFinite(opacity) && opacity >= 0 && opacity <= 1)) throw new PaintEditorError(`opacity ${opacity} is out of range; use 0..1.`);
5572
+ const blend = options.blend ?? options.blendMode ?? "normal";
5573
+ if (!isBlendMode(blend)) throw new PaintEditorError(`Unknown blend mode "${blend}".`);
5574
+ const tolerance = options.tolerance ?? 32;
5575
+ if (!(Number.isFinite(tolerance) && tolerance >= 0 && tolerance <= 255)) throw new PaintEditorError(`tolerance ${tolerance} is out of range; use 0..255.`);
5576
+ const closeGap = options.closeGap ?? 0;
5577
+ if (!(Number.isFinite(closeGap) && closeGap >= 0 && closeGap <= 32)) throw new PaintEditorError(`closeGap ${closeGap} is out of range; use 0..32 px.`);
5578
+ const expand2 = options.expand ?? 0;
5579
+ if (!(Number.isFinite(expand2) && expand2 >= -16 && expand2 <= 16)) throw new PaintEditorError(`expand ${expand2} is out of range; use -16..16 px.`);
5580
+ const antialias = options.antialias ?? true;
5581
+ if (typeof antialias !== "boolean") throw new PaintEditorError("antialias must be true or false.");
5582
+ const grainAmount = options.grain ?? 0;
5583
+ if (!(Number.isFinite(grainAmount) && grainAmount >= 0 && grainAmount <= 1)) throw new PaintEditorError(`grain ${grainAmount} is out of range; use 0..1.`);
5584
+ const pressure = options.pressure ?? 0.5;
5585
+ if (!(Number.isFinite(pressure) && pressure >= 0 && pressure <= 1)) throw new PaintEditorError(`pressure ${pressure} is out of range; use 0..1.`);
5586
+ const wet = options.wet === true;
5587
+ if (wet && this.previewing) throw new PaintEditorError("Wet paint can't be previewed; suggest a dry fill instead.");
5588
+ if (wet && !this.wet.wetLayerIds().includes(fillTarget.id) && this.wet.wetLayerIds().length >= MAX_WET_LANES) {
5589
+ throw new PaintEditorError(`At most ${MAX_WET_LANES} layers can hold wet paint at once; dry one first (dry_paint).`);
5590
+ }
5591
+ const refPixels = refSpec === "layer" ? this.compositePixels({ layerIds: [fillTarget.id], paper: false, wet: "display" }).data : refSpec === "all" || refSpec === "composite" ? this.compositePixels({ paper: false, wet: "display" }).data : this.compositePixels({ layerIds: [refSpec], paper: false, wet: "display" }).data;
5592
+ const filled = fillRegionMask(refPixels, width, height, {
5593
+ points: pts,
5594
+ tolerance,
5595
+ closeGap,
5596
+ expand: expand2,
5597
+ antialias
5598
+ });
5599
+ const eventData = {
5600
+ points: pts.map((p) => ({ x: p.x, y: p.y })),
5601
+ reference: refSpec,
5602
+ color,
5603
+ opacity,
5604
+ blend,
5605
+ tolerance,
5606
+ closeGap,
5607
+ expand: expand2,
5608
+ antialias,
5609
+ grain: grainAmount,
5610
+ pressure,
5611
+ ...wet ? { wet: true } : {}
5612
+ };
5613
+ if (!filled.bounds || filled.pixelCount === 0) {
5614
+ return {
5615
+ layerId: fillTarget.id,
5616
+ bounds: null,
5617
+ pixelCount: 0,
5618
+ leaked: false,
5619
+ regions: filled.regions,
5620
+ ...wet ? { wet: this.wet.isWet(fillTarget.id) } : {}
5621
+ };
5622
+ }
5623
+ const rect = filled.bounds;
5624
+ const depth = grainAmount * this.doc.paper.depth;
5625
+ const grain = depth > 0 ? paperGrain(this.doc.paper) : null;
5626
+ if (wet) {
5627
+ this.requireWetIdle("filling");
5628
+ const before = this.doc;
5629
+ let l2 = fillTarget;
5630
+ if (l2.offset.x || l2.offset.y) {
5631
+ const moved = { ...l2, surface: translateSurface(l2.surface, l2.offset.x, l2.offset.y), offset: { x: 0, y: 0 } };
5632
+ this.doc = { ...this.doc, layers: mapLayer(this.doc.layers, l2.id, () => moved) };
5633
+ l2 = moved;
5634
+ }
5635
+ this.beginWetOp();
5636
+ const world = this.wet.ensureLane(l2.id);
5637
+ const [r, g, b] = hexToRgb8(color);
5638
+ const medium = new WetMedium(world, l2.id, {
5639
+ water: 0.9 * Math.max(0.25, opacity),
5640
+ mass: PIGMENT_UNIT * 2.5 * opacity,
5641
+ body: 0,
5642
+ light: 0
5643
+ });
5644
+ const subCov = new Float32Array(rect.width * rect.height);
5645
+ for (let yy = 0, k = 0; yy < rect.height; yy++) {
5646
+ const py = rect.y + yy;
5647
+ const row = py * width;
5648
+ for (let xx = 0; xx < rect.width; xx++, k++) {
5649
+ const px = rect.x + xx;
5650
+ let v = filled.mask[row + px];
5651
+ if (v > 0 && grain) v *= grainCatch(grain.at(px + 0.5, py + 0.5), depth, pressure);
5652
+ subCov[k] = Math.round(v * 4096) / 4096;
5653
+ }
5654
+ }
5655
+ medium.deposit(rect, subCov, 1, [r / 255, g / 255, b / 255], null);
5656
+ if (this.wet.instantDry) {
5657
+ this.wet.dry(l2.id);
5658
+ this.logWet({ kind: "dry", layerId: l2.id });
5659
+ } else {
5660
+ this.wet.syncDisplays();
5661
+ }
5662
+ const afterLayer = this.getLayer(l2.id);
5663
+ this.commit(
5664
+ "Fill region",
5665
+ this.withPaintEvent(this.withWetEvents({ ...this.doc, layers: mapLayer(this.doc.layers, l2.id, () => ({ ...afterLayer })) }), {
5666
+ kind: "fill-region",
5667
+ layerId: l2.id,
5668
+ data: eventData
5669
+ }),
5670
+ before
5671
+ );
5672
+ return {
5673
+ layerId: l2.id,
5674
+ bounds: rect,
5675
+ pixelCount: filled.pixelCount,
5676
+ leaked: filled.leaked,
5677
+ regions: filled.regions,
5678
+ wet: this.wet.isWet(l2.id)
5679
+ };
5680
+ }
5681
+ this.dryLayersFirst([fillTarget.id]);
5682
+ const l = this.paintTarget(options.layerId, "fill");
5683
+ const coverage = new CoverageMap();
5684
+ const vals = new Float32Array(rect.width * rect.height);
5685
+ for (let yy = 0, k = 0; yy < rect.height; yy++) {
5686
+ const py = rect.y + yy;
5687
+ const row = py * width;
5688
+ for (let xx = 0; xx < rect.width; xx++, k++) {
5689
+ const px = rect.x + xx;
5690
+ let v = filled.mask[row + px];
5691
+ if (v > 0 && grain) v *= grainCatch(grain.at(px + 0.5, py + 0.5), depth, pressure);
5692
+ vals[k] = v;
5693
+ }
5694
+ }
5695
+ coverage.accumulate(rect, vals, 1);
5696
+ const apply = {
5697
+ mode: "paint",
5698
+ color: rgbaOf(color),
5699
+ background: rgbaOf(this.doc.paper.color),
5700
+ opacity,
5701
+ blend,
5702
+ lockTransparency: isLayerLocked(l, "transparency"),
5703
+ selection: null,
5704
+ offset: l.offset
5705
+ };
5706
+ const surface = this.applyCoverage(l, coverage, rect, apply);
5707
+ this.commit(
5708
+ "Fill region",
5709
+ this.withPaintEvent(this.withWetEvents({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })) }), {
5710
+ kind: "fill-region",
5711
+ layerId: l.id,
5712
+ data: eventData
5713
+ })
5714
+ );
5715
+ return {
5716
+ layerId: l.id,
5717
+ bounds: rect,
5718
+ pixelCount: filled.pixelCount,
5719
+ leaked: filled.leaked,
5720
+ regions: filled.regions
5721
+ };
5722
+ }
5723
+ /**
5724
+ * Finds enclosed unfilled regions from a reference layer or the visible composite (without paper),
5725
+ * closing line-art gaps up to `closeGap` px and returning each region's interior seed `point`,
5726
+ * `centroid`, `bounds`, `pixelCount`, and `touchesEdge` flag.
5727
+ */
5728
+ findUnfilledRegions(options = {}) {
5729
+ if (options.layerId !== void 0) {
5730
+ const l = this.requireLayer(options.layerId);
5731
+ if (!isPixelLayer(l)) throw new PaintEditorError(`Layer "${l.name}" is a group; pass a pixel layer inside it.`);
5732
+ }
5733
+ const refSpec = options.reference ?? "all";
5734
+ if (refSpec !== "layer" && refSpec !== "all" && refSpec !== "composite") {
5735
+ this.requireLayer(refSpec);
5736
+ }
5737
+ const tolerance = options.tolerance ?? 32;
5738
+ if (!(Number.isFinite(tolerance) && tolerance >= 0 && tolerance <= 255)) throw new PaintEditorError(`tolerance ${tolerance} is out of range; use 0..255.`);
5739
+ const closeGap = options.closeGap ?? 0;
5740
+ if (!(Number.isFinite(closeGap) && closeGap >= 0 && closeGap <= 32)) throw new PaintEditorError(`closeGap ${closeGap} is out of range; use 0..32 px.`);
5741
+ const minArea = options.minArea ?? 16;
5742
+ if (!(Number.isInteger(minArea) && minArea >= 1 && minArea <= 1e7)) throw new PaintEditorError(`minArea ${minArea} is out of range; use a whole number of pixels, 1 or more.`);
5743
+ const maxRegions = options.maxRegions ?? 100;
5744
+ if (!(Number.isInteger(maxRegions) && maxRegions >= 1 && maxRegions <= 1e3)) throw new PaintEditorError(`maxRegions ${maxRegions} is out of range; use 1..1000.`);
5745
+ const includeEdge = options.includeEdge ?? false;
5746
+ if (typeof includeEdge !== "boolean") throw new PaintEditorError("includeEdge must be true or false.");
5747
+ const { width, height } = this.doc.canvas;
5748
+ let refPixels;
5749
+ if (refSpec === "all" || refSpec === "composite") {
5750
+ refPixels = this.compositePixels({ paper: false, wet: "display" }).data;
5751
+ } else if (refSpec === "layer") {
5752
+ const targetId = options.layerId ?? this.doc.activeLayerId;
5753
+ if (!targetId) throw new PaintEditorError("There is no active layer; pass layerId or reference.");
5754
+ refPixels = this.compositePixels({ layerIds: [targetId], paper: false, wet: "display" }).data;
5755
+ } else {
5756
+ refPixels = this.compositePixels({ layerIds: [refSpec], paper: false, wet: "display" }).data;
5757
+ }
5758
+ const targetPixels = options.layerId !== void 0 && options.layerId !== refSpec && refSpec !== "layer" ? this.compositePixels({ layerIds: [options.layerId], paper: false, wet: "display" }).data : void 0;
5759
+ const regions = findUnfilledRegionsMask(refPixels, width, height, {
5760
+ target: targetPixels,
5761
+ tolerance,
5762
+ closeGap,
5763
+ minArea,
5764
+ includeEdge,
5765
+ maxRegions
5766
+ });
5767
+ return { regions };
5768
+ }
5505
5769
  applyCoverage(l, coverage, rect, apply) {
5506
5770
  const base = l.surface;
5507
5771
  const tiles = new Map(base.tiles);
@@ -8825,7 +9089,19 @@ function shiftDocument(doc, dx, dy) {
8825
9089
  const list = e.kind === "stickers" ? e.data?.stickers : void 0;
8826
9090
  if (list) return { ...e, data: { ...e.data, stickers: moveStickers(list) } };
8827
9091
  const strokes = e.kind === "live-strokes" ? e.data?.strokes : void 0;
8828
- return strokes ? { ...e, data: { ...e.data, strokes: strokes.map(moveStroke) } } : e;
9092
+ if (strokes) return { ...e, data: { ...e.data, strokes: strokes.map(moveStroke) } };
9093
+ if (e.kind === "fill-region" && e.data) {
9094
+ const d = e.data;
9095
+ return {
9096
+ ...e,
9097
+ data: {
9098
+ ...e.data,
9099
+ ...typeof d.x === "number" && typeof d.y === "number" ? { x: d.x + dx, y: d.y + dy } : {},
9100
+ ...Array.isArray(d.points) ? { points: d.points.map((p) => movePoint(p, dx, dy)) } : {}
9101
+ }
9102
+ };
9103
+ }
9104
+ return e;
8829
9105
  });
8830
9106
  }
8831
9107
  if (doc.wetEvents) {
@@ -9029,6 +9305,17 @@ var PaintDirector = class {
9029
9305
  }
9030
9306
  return c.fillLayer(command.fill);
9031
9307
  }
9308
+ case "fill-region": {
9309
+ await this.say(id, command.note, 500);
9310
+ const pts = command.fill.points?.length ? command.fill.points : command.fill.x !== void 0 && command.fill.y !== void 0 ? [{ x: command.fill.x, y: command.fill.y }] : [];
9311
+ for (const p of pts) {
9312
+ await this.glide(id, p, 240);
9313
+ this.presence.set({ id, pressed: true });
9314
+ await this.clock.wait(this.ms(90));
9315
+ this.presence.set({ id, pressed: false });
9316
+ }
9317
+ return c.fillRegion(command.fill);
9318
+ }
9032
9319
  case "replay": {
9033
9320
  await this.say(id, command.note, 500);
9034
9321
  const source = c.getStrokes().filter((s) => command.replay.strokeIds?.includes(s.id));
@@ -9144,7 +9431,7 @@ function planPaintToolPlayback(tool, _args) {
9144
9431
  if (control) steps.push({ type: "target", ref: control }, { type: "press" }, { type: "release" });
9145
9432
  return steps.length ? steps : null;
9146
9433
  }
9147
- var DIRECTED = /* @__PURE__ */ new Set(["paint_stroke", "stroke_path", "fill_layer", "replay_strokes"]);
9434
+ var DIRECTED = /* @__PURE__ */ new Set(["paint_stroke", "stroke_path", "fill_layer", "fill_region", "replay_strokes"]);
9148
9435
  function withPaintPlayback(provider, options) {
9149
9436
  const plan = (tool) => {
9150
9437
  if (tool.plan || tool.playback !== void 0) return tool;
@@ -10479,6 +10766,77 @@ function createPaintEditorToolProvider(controller, options = {}) {
10479
10766
  coordinates: { out: ["bounds"] },
10480
10767
  handler: (a) => act({ kind: "fill", fill: a }, () => c.fillLayer(a))
10481
10768
  });
10769
+ add({
10770
+ name: "fill_region",
10771
+ title: "Fill a region (bucket / flats)",
10772
+ description: 'Flood-fills one or more enclosed regions on a pixel layer from a seed point (x, y) or a batch of seed points (points). Set reference to a line-art layer id from list_layers (or "all" / "composite" for all visible layers, or "layer" for the target layer itself) to drop flat colours onto a flats layer under the ink without touching the line art. closeGap (0..32 px) bridges small gaps in the line art so flats do not leak; expand (-16..16 px, e.g. 1..2 px) bleeds the fill under anti-aliased line edges so no halo shows; wet pours a watercolour wash into the region on the wet layer. One undo step. Returns layerId, bounds, pixelCount, regions, and leaked (true when the fill touched the canvas border).',
10773
+ inputSchema: {
10774
+ type: "object",
10775
+ properties: {
10776
+ x: { type: "number", description: "Seed point x in document pixels (pass x and y, or points)." },
10777
+ y: { type: "number", description: "Seed point y in document pixels (pass x and y, or points)." },
10778
+ points: {
10779
+ type: "array",
10780
+ minItems: 1,
10781
+ maxItems: 500,
10782
+ description: "Multiple seed points in document pixels, filled together as one undo step.",
10783
+ items: {
10784
+ type: "object",
10785
+ properties: {
10786
+ x: { type: "number", description: "Document x in pixels." },
10787
+ y: { type: "number", description: "Document y in pixels." }
10788
+ },
10789
+ required: ["x", "y"],
10790
+ additionalProperties: false
10791
+ }
10792
+ },
10793
+ layerId: LAYER_ID4,
10794
+ reference: {
10795
+ type: "string",
10796
+ description: 'Which pixels stop the fill: "layer" (default, the target layer itself), "all" or "composite" (all visible layers without paper), or a layer id from list_layers (e.g. an ink line-art layer above the flats layer).'
10797
+ },
10798
+ color: { ...COLOR3, description: "Fill colour #rrggbb (default the paint colour)." },
10799
+ opacity: { type: "number", minimum: 0, maximum: 1, description: "Opacity 0..1 (default 1)." },
10800
+ blend: { type: "string", enum: BLEND_MODES.map((m) => m.id), description: "Blend mode (default normal; see list_blend_modes)." },
10801
+ blendMode: { type: "string", enum: BLEND_MODES.map((m) => m.id), description: "Alias for blend (default normal; see list_blend_modes)." },
10802
+ tolerance: { type: "number", minimum: 0, maximum: 255, description: "Colour and alpha similarity tolerance 0..255 (default 32)." },
10803
+ closeGap: { type: "number", minimum: 0, maximum: 32, description: "Close gaps in line art up to this width in pixels, 0..32 (default 0)." },
10804
+ expand: { type: "number", minimum: -16, maximum: 16, description: "Expand (> 0, bleed under lines to hide anti-aliased halos) or contract (< 0) the filled region in pixels, -16..16 (default 0)." },
10805
+ antialias: { type: "boolean", description: "Soften boundary edges (default true)." },
10806
+ grain: { type: "number", minimum: 0, maximum: 1, description: "Paper grain response 0..1 (default 0: flat)." },
10807
+ pressure: { type: "number", minimum: 0, maximum: 1, description: "With grain: how far into the valleys, 0..1 (default 0.5)." },
10808
+ wet: { type: "boolean", description: "Pour a wet watercolour wash into the region on the wet layer instead of dry pixels (default false)." }
10809
+ },
10810
+ additionalProperties: false
10811
+ },
10812
+ annotations: { title: "Fill a region (bucket / flats)", ...WRITE5 },
10813
+ coordinates: { in: ["", "points[]"], out: ["bounds", "regions[].point", "regions[].bounds"] },
10814
+ handler: (a) => act({ kind: "fill-region", fill: a }, () => c.fillRegion(a))
10815
+ });
10816
+ add({
10817
+ name: "find_unfilled_regions",
10818
+ title: "Find enclosed unfilled regions",
10819
+ description: 'Finds enclosed unfilled regions from a reference line-art layer (reference: layerId) or the visible composite ("all" / "composite", the default), closing line-art gaps up to closeGap px. When layerId is given, regions already painted on that target layer are excluded. Changes nothing. Returns regions sorted largest first, each with an interior seed point {x, y} ready for fill_region, centroid, bounds, pixelCount (area), and touchesEdge.',
10820
+ inputSchema: {
10821
+ type: "object",
10822
+ properties: {
10823
+ layerId: { type: "string", description: "Target flats layer id from list_layers; regions already painted on it are excluded (optional)." },
10824
+ reference: {
10825
+ type: "string",
10826
+ description: 'Which pixels define the line-art barriers: "all" or "composite" (default, all visible layers without paper), "layer" (the target or active layer), or a layer id from list_layers (e.g. an ink line-art layer).'
10827
+ },
10828
+ tolerance: { type: "number", minimum: 0, maximum: 255, description: "Alpha/colour threshold 0..255 above which a reference pixel counts as line art (default 32)." },
10829
+ closeGap: { type: "number", minimum: 0, maximum: 32, description: "Close small gaps in line art up to this width in pixels, 0..32 (default 0)." },
10830
+ minArea: { type: "integer", minimum: 1, maximum: 1e7, description: "Ignore regions smaller than this many pixels (default 16)." },
10831
+ includeEdge: { type: "boolean", description: "Include regions that touch the canvas border (default false: only enclosed shapes)." },
10832
+ maxRegions: { type: "integer", minimum: 1, maximum: 1e3, description: "Most regions to return, largest first (default 100)." }
10833
+ },
10834
+ additionalProperties: false
10835
+ },
10836
+ annotations: { title: "Find enclosed unfilled regions", ...READ6 },
10837
+ coordinates: { out: ["regions[].point", "regions[].centroid", "regions[].bounds"] },
10838
+ handler: (a) => c.findUnfilledRegions(a)
10839
+ });
10482
10840
  add({
10483
10841
  name: "clear_layer",
10484
10842
  title: "Clear a layer",
@@ -11110,6 +11468,7 @@ function createPaintBrushLibraryToolProvider(controller, options = {}) {
11110
11468
  // src/paint-editor/ui.ts
11111
11469
  var PAINT_TOOLS = [
11112
11470
  { id: "brush", label: "Brush", key: "b" },
11471
+ { id: "fill", label: "Fill", key: "g" },
11113
11472
  { id: "eyedropper", label: "Eyedropper", key: "i" },
11114
11473
  { id: "sticker", label: "Stickers", key: "s" },
11115
11474
  { id: "stroke", label: "Select stroke", key: "v" },
@@ -11124,6 +11483,8 @@ var PaintGesture = class {
11124
11483
  controller;
11125
11484
  box;
11126
11485
  tool = "brush";
11486
+ /** Options the Fill tool (`tool === 'fill'`) passes to `fillRegion` (click to fill; drag horizontally to adjust tolerance). */
11487
+ fillOptions = { reference: "all", tolerance: 32, closeGap: 4, expand: 1, antialias: true };
11127
11488
  /** The last refusal (a hidden or locked layer), for the editor to show. */
11128
11489
  error = null;
11129
11490
  /** The predicted tail of the current stroke in document pixels (draw it as a thin preview). */
@@ -11166,6 +11527,7 @@ var PaintGesture = class {
11166
11527
  this.pick(e);
11167
11528
  return false;
11168
11529
  }
11530
+ if (tool === "fill") return this.fillDown(e);
11169
11531
  if (tool === "sticker") return this.stickerDown(e);
11170
11532
  try {
11171
11533
  const p = this.pen(e);
@@ -11235,6 +11597,18 @@ var PaintGesture = class {
11235
11597
  if (p.x < 0 || p.y < 0 || p.x >= width || p.y >= height) return;
11236
11598
  this.controller.setColor(this.controller.pickColor(p.x, p.y).color);
11237
11599
  }
11600
+ fillDown(e) {
11601
+ const p = this.doc(e);
11602
+ const baseTolerance = this.fillOptions.tolerance ?? 32;
11603
+ try {
11604
+ const filled = this.controller.fillRegion({ ...this.fillOptions, tolerance: baseTolerance, x: p.x, y: p.y });
11605
+ this.mode = { kind: "fill", pointerId: e.pointerId, startX: e.clientX, p, baseTolerance, lastTolerance: baseTolerance, committed: filled.bounds !== null };
11606
+ return true;
11607
+ } catch (err) {
11608
+ this.error = err instanceof Error ? err.message : String(err);
11609
+ return false;
11610
+ }
11611
+ }
11238
11612
  move(e) {
11239
11613
  if (e.pointerType === "pen") this.penAt = { x: e.clientX, y: e.clientY };
11240
11614
  const m = this.mode;
@@ -11255,6 +11629,21 @@ var PaintGesture = class {
11255
11629
  m.to = this.doc(e);
11256
11630
  return;
11257
11631
  }
11632
+ if (m.kind === "fill") {
11633
+ const dx = e.clientX - m.startX;
11634
+ if (Math.abs(dx) < 4) return;
11635
+ const nextTol = Math.max(0, Math.min(255, Math.round(m.baseTolerance + dx * 0.5)));
11636
+ if (nextTol === m.lastTolerance) return;
11637
+ m.lastTolerance = nextTol;
11638
+ try {
11639
+ if (m.committed) this.controller.undo();
11640
+ m.committed = false;
11641
+ m.committed = this.controller.fillRegion({ ...this.fillOptions, tolerance: nextTol, x: m.p.x, y: m.p.y }).bounds !== null;
11642
+ } catch (err) {
11643
+ this.error = err instanceof Error ? err.message : String(err);
11644
+ }
11645
+ return;
11646
+ }
11258
11647
  if (m.kind === "sticker") {
11259
11648
  const p = this.doc(e);
11260
11649
  m.at = { x: round2(p.x + m.dx), y: round2(p.y + m.dy) };
@@ -11316,6 +11705,7 @@ var PaintGesture = class {
11316
11705
  this.penDown = false;
11317
11706
  this.predicted = [];
11318
11707
  if (this.mode?.kind === "stroke") this.controller.activeStroke?.cancel();
11708
+ if (this.mode?.kind === "fill" && this.mode.committed) this.controller.undo();
11319
11709
  if (this.mode?.kind === "sticker") this.controller.cancelStickerPreview();
11320
11710
  this.mode = null;
11321
11711
  }
@@ -12686,6 +13076,8 @@ export {
12686
13076
  emptiedLayers,
12687
13077
  emptyThickChannels,
12688
13078
  engineBrushSourceIds,
13079
+ fillRegionMask,
13080
+ findUnfilledRegionsMask,
12689
13081
  fitZoom,
12690
13082
  grainCatch,
12691
13083
  grainHeights,
@@ -12809,6 +13201,7 @@ export {
12809
13201
  serializePaintDocument,
12810
13202
  serializePaintDocumentExternal,
12811
13203
  solidPaint,
13204
+ squaredDistanceTransform,
12812
13205
  stampCoverage,
12813
13206
  standardBlendMode,
12814
13207
  startWetClock,
@@ -1,7 +1,7 @@
1
1
  import { PresenceStore, type PresenceActor, type PresenceActorInfo, type PresenceClock, type PresenceState } from '../animation/presence.cjs';
2
2
  import type { MixerLoad } from '../paint/mixer.cjs';
3
3
  import type { RasterRect } from '../raster/surface.cjs';
4
- import { type FillLayerOptions, type PaintEditorController, type PaintStrokeInit, type ReplayOptions } from './controller.cjs';
4
+ import { type FillLayerOptions, type FillRegionOptions, type PaintEditorController, type PaintStrokeInit, type ReplayOptions } from './controller.cjs';
5
5
  import type { PenPoint } from './engine.cjs';
6
6
  export type PaintPresenceActor = PresenceActor;
7
7
  export type PaintPresenceState = PresenceState;
@@ -35,6 +35,10 @@ export type PaintAgentCommand = {
35
35
  kind: 'fill';
36
36
  fill: FillLayerOptions;
37
37
  note?: string;
38
+ } | {
39
+ kind: 'fill-region';
40
+ fill: FillRegionOptions;
41
+ note?: string;
38
42
  } | {
39
43
  kind: 'replay';
40
44
  replay: ReplayOptions;
@@ -1,7 +1,7 @@
1
1
  import { PresenceStore, type PresenceActor, type PresenceActorInfo, type PresenceClock, type PresenceState } from '../animation/presence.js';
2
2
  import type { MixerLoad } from '../paint/mixer.js';
3
3
  import type { RasterRect } from '../raster/surface.js';
4
- import { type FillLayerOptions, type PaintEditorController, type PaintStrokeInit, type ReplayOptions } from './controller.js';
4
+ import { type FillLayerOptions, type FillRegionOptions, type PaintEditorController, type PaintStrokeInit, type ReplayOptions } from './controller.js';
5
5
  import type { PenPoint } from './engine.js';
6
6
  export type PaintPresenceActor = PresenceActor;
7
7
  export type PaintPresenceState = PresenceState;
@@ -35,6 +35,10 @@ export type PaintAgentCommand = {
35
35
  kind: 'fill';
36
36
  fill: FillLayerOptions;
37
37
  note?: string;
38
+ } | {
39
+ kind: 'fill-region';
40
+ fill: FillRegionOptions;
41
+ note?: string;
38
42
  } | {
39
43
  kind: 'replay';
40
44
  replay: ReplayOptions;
@@ -11,7 +11,7 @@
11
11
  import type { MixerLoad } from '../paint/exchange.cjs';
12
12
  import { type BlendModeInfo } from '../raster/blend.cjs';
13
13
  import type { ImpastoTool, WetMediumKind, WetTool } from './brushes.cjs';
14
- import type { PaintEditorController } from './controller.cjs';
14
+ import type { FillRegionOptions, PaintEditorController } from './controller.cjs';
15
15
  import type { PenPoint } from './engine.cjs';
16
16
  import { type Point2 } from './guides.cjs';
17
17
  import { type StencilInput } from './stencils.cjs';
@@ -35,7 +35,7 @@ export interface PaintPointerEvent extends PointerSample {
35
35
  getCoalescedEvents?(): PaintPointerEvent[];
36
36
  getPredictedEvents?(): PaintPointerEvent[];
37
37
  }
38
- export type PaintTool = 'brush' | 'eyedropper' | 'pan' | 'rotate' | 'sticker' | 'stroke';
38
+ export type PaintTool = 'brush' | 'fill' | 'eyedropper' | 'pan' | 'rotate' | 'sticker' | 'stroke';
39
39
  export declare const PAINT_TOOLS: ReadonlyArray<{
40
40
  id: PaintTool;
41
41
  label: string;
@@ -52,6 +52,8 @@ export declare class PaintGesture {
52
52
  private readonly controller;
53
53
  private readonly box;
54
54
  tool: PaintTool;
55
+ /** Options the Fill tool (`tool === 'fill'`) passes to `fillRegion` (click to fill; drag horizontally to adjust tolerance). */
56
+ fillOptions: Omit<FillRegionOptions, 'x' | 'y' | 'points'>;
55
57
  /** The last refusal (a hidden or locked layer), for the editor to show. */
56
58
  error: string | null;
57
59
  /** The predicted tail of the current stroke in document pixels (draw it as a thin preview). */
@@ -82,6 +84,7 @@ export declare class PaintGesture {
82
84
  */
83
85
  private stickerDown;
84
86
  private pick;
87
+ private fillDown;
85
88
  move(e: PaintPointerEvent): void;
86
89
  up(e: PaintPointerEvent): void;
87
90
  cancel(): void;
@@ -11,7 +11,7 @@
11
11
  import type { MixerLoad } from '../paint/exchange.js';
12
12
  import { type BlendModeInfo } from '../raster/blend.js';
13
13
  import type { ImpastoTool, WetMediumKind, WetTool } from './brushes.js';
14
- import type { PaintEditorController } from './controller.js';
14
+ import type { FillRegionOptions, PaintEditorController } from './controller.js';
15
15
  import type { PenPoint } from './engine.js';
16
16
  import { type Point2 } from './guides.js';
17
17
  import { type StencilInput } from './stencils.js';
@@ -35,7 +35,7 @@ export interface PaintPointerEvent extends PointerSample {
35
35
  getCoalescedEvents?(): PaintPointerEvent[];
36
36
  getPredictedEvents?(): PaintPointerEvent[];
37
37
  }
38
- export type PaintTool = 'brush' | 'eyedropper' | 'pan' | 'rotate' | 'sticker' | 'stroke';
38
+ export type PaintTool = 'brush' | 'fill' | 'eyedropper' | 'pan' | 'rotate' | 'sticker' | 'stroke';
39
39
  export declare const PAINT_TOOLS: ReadonlyArray<{
40
40
  id: PaintTool;
41
41
  label: string;
@@ -52,6 +52,8 @@ export declare class PaintGesture {
52
52
  private readonly controller;
53
53
  private readonly box;
54
54
  tool: PaintTool;
55
+ /** Options the Fill tool (`tool === 'fill'`) passes to `fillRegion` (click to fill; drag horizontally to adjust tolerance). */
56
+ fillOptions: Omit<FillRegionOptions, 'x' | 'y' | 'points'>;
55
57
  /** The last refusal (a hidden or locked layer), for the editor to show. */
56
58
  error: string | null;
57
59
  /** The predicted tail of the current stroke in document pixels (draw it as a thin preview). */
@@ -82,6 +84,7 @@ export declare class PaintGesture {
82
84
  */
83
85
  private stickerDown;
84
86
  private pick;
87
+ private fillDown;
85
88
  move(e: PaintPointerEvent): void;
86
89
  up(e: PaintPointerEvent): void;
87
90
  cancel(): void;