@pixerate/editor 0.239.0 → 0.240.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.
@@ -1519,7 +1519,8 @@ function variationAffine(v, origin) {
1519
1519
  return composeAffine(back, composeAffine(linear, toOrigin));
1520
1520
  }
1521
1521
  function varyStroke(stroke, m, v) {
1522
- const s = Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2]));
1522
+ const det = Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2]));
1523
+ const s = Math.abs(det - 1) < 1e-9 ? 1 : det;
1523
1524
  const ps = v.pressureScale ?? 1;
1524
1525
  const points = stroke.points.map((p) => {
1525
1526
  const q = mapPenPoint(m, p);
@@ -3085,6 +3086,43 @@ var MixingPad = class {
3085
3086
  }
3086
3087
  };
3087
3088
 
3089
+ // src/paint-editor/live-strokes.ts
3090
+ var LIVE_STROKE_ENGINES = ["dab", "particle", "bristle", "mixer"];
3091
+ function liveStrokeRefusal(brush) {
3092
+ switch (brush.engine) {
3093
+ case "wet":
3094
+ return `The ${brush.name} brush paints wet media, which a stroke layer can't redraw (the paint depends on the simulation's history); paint it on a pixel layer (add_layer).`;
3095
+ case "impasto":
3096
+ return `The ${brush.name} brush paints thick paint with relief, which a stroke layer doesn't keep; paint it on a pixel layer (add_layer).`;
3097
+ case "sticker":
3098
+ return `The ${brush.name} brush sprays stickers, which are their own objects; spray them on an empty pixel layer (add_layer).`;
3099
+ case "eraser":
3100
+ return `Erasers don't paint on a stroke layer; delete strokes instead (remove_strokes), or rasterize the layer first (rasterize_stroke_layer).`;
3101
+ case "blender":
3102
+ case "smudge":
3103
+ return `The ${brush.name} brush moves the paint under it, so it can't be redrawn on its own; rasterize the layer first (rasterize_stroke_layer) or blend on a pixel layer.`;
3104
+ }
3105
+ if (brush.behavior.pickup > 0) return `The ${brush.name} brush picks up the paint under it (pickup ${brush.behavior.pickup}), so it can't be redrawn on its own; set behavior.pickup to 0 (set_brush) or paint on a pixel layer.`;
3106
+ return null;
3107
+ }
3108
+ function liveStrokeBounds(stroke) {
3109
+ let x0 = Infinity;
3110
+ let y0 = Infinity;
3111
+ let x1 = -Infinity;
3112
+ let y1 = -Infinity;
3113
+ for (const m of [null, ...stroke.copies ?? []]) {
3114
+ for (const p of stroke.points) {
3115
+ const q = m ? applyAffine(m, p.x, p.y) : p;
3116
+ if (q.x < x0) x0 = q.x;
3117
+ if (q.y < y0) y0 = q.y;
3118
+ if (q.x > x1) x1 = q.x;
3119
+ if (q.y > y1) y1 = q.y;
3120
+ }
3121
+ }
3122
+ const pad = stroke.brush.tip.size * (stroke.brush.engine === "particle" ? 2 : 1) + 2;
3123
+ return { x: Math.floor(x0 - pad), y: Math.floor(y0 - pad), width: Math.ceil(x1 - x0 + 2 * pad), height: Math.ceil(y1 - y0 + 2 * pad) };
3124
+ }
3125
+
3088
3126
  // src/paint-editor/replay.ts
3089
3127
  function replayItems(doc) {
3090
3128
  const items = [];
@@ -3126,7 +3164,15 @@ async function replayPainting(source, target, options = {}) {
3126
3164
  if (!target.getLayer(e.layerId)) continue;
3127
3165
  if (e.kind === "fill") target.fillLayer({ layerId: e.layerId, ...e.data });
3128
3166
  else if (e.kind === "clear") target.clearLayer(e.layerId);
3129
- else if (e.kind === "stickers") {
3167
+ else if (e.kind === "live-strokes") {
3168
+ const d = e.data;
3169
+ if (d?.rasterized) target.replaceLiveStrokes(e.layerId, null);
3170
+ else {
3171
+ const list = (d?.strokes ?? []).map((st2) => s === 1 ? st2 : { ...varyStroke(st2, scaleM, { scale: s }), ...st2.stencils ? { stencils: st2.stencils.map((x) => scaleStencil(x, s)) } : {} });
3172
+ await target.prepareEffectPaints(list.flatMap((st2) => st2.paint?.type === "effect" ? [st2.paint] : []));
3173
+ target.replaceLiveStrokes(e.layerId, list);
3174
+ }
3175
+ } else if (e.kind === "stickers") {
3130
3176
  const list = (e.data?.stickers ?? []).map((x) => s === 1 ? x : { ...x, x: x.x * s, y: x.y * s, size: x.size * s });
3131
3177
  target.replaceStickers(e.layerId, list);
3132
3178
  } else target.flattenRelief(e.layerId);
@@ -3450,6 +3496,7 @@ var PaintEditorController = class _PaintEditorController {
3450
3496
  mixerLoad = EMPTY_MIXER_LOAD;
3451
3497
  view = { ...DEFAULT_VIEW, pan: { ...DEFAULT_VIEW.pan } };
3452
3498
  hand;
3499
+ selectedStroke = null;
3453
3500
  selectedSticker = null;
3454
3501
  /** A sticker drag in progress (`previewSticker`): the document before it, so the drop undoes in one step, and the one it showed last. */
3455
3502
  stickerPreview = null;
@@ -3463,6 +3510,8 @@ var PaintEditorController = class _PaintEditorController {
3463
3510
  dirty = "all";
3464
3511
  seedCounter = 1;
3465
3512
  strokeCounter = 1;
3513
+ /** The area each stroke painted (document pixels; `null`: nothing), learnt as it is painted or drawn, so a stroke layer edit redraws only where it changes (see `drawLiveStrokes`). */
3514
+ paintedBounds = /* @__PURE__ */ new WeakMap();
3466
3515
  guides;
3467
3516
  swatches = DEFAULT_SWATCHES.map((s) => ({ ...s }));
3468
3517
  pad = new MixingPad();
@@ -3549,6 +3598,7 @@ var PaintEditorController = class _PaintEditorController {
3549
3598
  mixerLoad: this.mixerLoad,
3550
3599
  view: this.view,
3551
3600
  hand: this.hand,
3601
+ selectedStroke: this.selectedStroke,
3552
3602
  selectedSticker: this.selectedSticker,
3553
3603
  canUndo: this.undoStack.length > 0 && !this.session,
3554
3604
  canRedo: this.redoStack.length > 0 && !this.session,
@@ -3897,6 +3947,7 @@ var PaintEditorController = class _PaintEditorController {
3897
3947
  locks: { ...l.locks ?? {} },
3898
3948
  active: l.id === this.doc.activeLayerId,
3899
3949
  bounds,
3950
+ ...this.doc.liveStrokes?.[l.id] ? { liveStrokes: this.doc.liveStrokes[l.id].length } : {},
3900
3951
  parentId,
3901
3952
  depth
3902
3953
  };
@@ -3923,11 +3974,12 @@ var PaintEditorController = class _PaintEditorController {
3923
3974
  this.emit();
3924
3975
  }
3925
3976
  /** Adds an empty pixel layer or group above `aboveId` (default: the active layer) and makes it active. */
3977
+ /** Adds a layer above the active one: `pixel` (default), `group`, or `strokes`, a live stroke layer whose strokes stay editable. */
3926
3978
  addLayer(options = {}) {
3927
3979
  this.requireIdle("adding a layer");
3928
3980
  const count = flattenLayers(this.doc.layers).length;
3929
3981
  const type = options.type ?? "pixel";
3930
- const name = options.name?.trim() || (type === "group" ? `Group ${count + 1}` : `Layer ${count + 1}`);
3982
+ const name = options.name?.trim() || (type === "group" ? `Group ${count + 1}` : type === "strokes" ? `Strokes ${count + 1}` : `Layer ${count + 1}`);
3931
3983
  const layer = type === "group" ? { type: "group", id: newPaintLayerId(), name, visible: true, opacity: 1, blend: "pass-through", children: [] } : createPaintLayer(name);
3932
3984
  let parentId = null;
3933
3985
  let index;
@@ -3943,7 +3995,8 @@ var PaintEditorController = class _PaintEditorController {
3943
3995
  index = loc.index + 1;
3944
3996
  }
3945
3997
  const layers = insertLayer(this.doc.layers, layer, parentId, index);
3946
- this.commit(type === "group" ? "Add group" : "Add layer", { ...this.doc, layers, activeLayerId: layer.id });
3998
+ const live = type === "strokes" ? { liveStrokes: { ...this.doc.liveStrokes ?? {}, [layer.id]: [] } } : {};
3999
+ this.commit(type === "group" ? "Add group" : type === "strokes" ? "Add stroke layer" : "Add layer", { ...this.doc, layers, activeLayerId: layer.id, ...live });
3947
4000
  return this.describeLayer(layer);
3948
4001
  }
3949
4002
  updateLayer(id, update) {
@@ -3980,7 +4033,7 @@ var PaintEditorController = class _PaintEditorController {
3980
4033
  const layers = mapLayer(this.doc.layers, id, () => null);
3981
4034
  let active = this.doc.activeLayerId;
3982
4035
  if (active && !findLayer(layers, active)) active = flattenLayers(layers).filter((x) => x.type === "pixel").pop()?.id ?? null;
3983
- this.commit("Remove layer", { ...this.doc, layers, activeLayerId: active, ...this.thickWithout(flattenLayers([l]).map((x) => x.id)), ...this.stickersWithout(flattenLayers([l]).map((x) => x.id)) });
4036
+ this.commit("Remove layer", { ...this.doc, layers, activeLayerId: active, ...this.thickWithout(flattenLayers([l]).map((x) => x.id)), ...this.stickersWithout(flattenLayers([l]).map((x) => x.id)), ...this.liveStrokesWithout(flattenLayers([l]).map((x) => x.id)) });
3984
4037
  return { removed: id, activeLayerId: active };
3985
4038
  }
3986
4039
  duplicateLayer(id) {
@@ -3993,15 +4046,17 @@ var PaintEditorController = class _PaintEditorController {
3993
4046
  if (!loc) throw new PaintEditorError(`Unknown layer "${id}". Call list_layers for the ids.`);
3994
4047
  const thick = { ...this.doc.thick ?? {} };
3995
4048
  const stickers = { ...this.doc.stickers ?? {} };
4049
+ const liveStrokes = { ...this.doc.liveStrokes ?? {} };
3996
4050
  const copy = (l) => {
3997
4051
  const id2 = newPaintLayerId();
3998
4052
  if (thick[l.id]) thick[id2] = thick[l.id];
3999
4053
  if (stickers[l.id]) stickers[id2] = stickers[l.id].map((x) => ({ ...x }));
4054
+ if (liveStrokes[l.id]) liveStrokes[id2] = liveStrokes[l.id].map((x) => ({ ...x, id: `s${this.strokeCounter++}`, layerId: id2 }));
4000
4055
  return l.type === "group" ? { ...l, id: id2, children: l.children.map(copy) } : { ...l, id: id2 };
4001
4056
  };
4002
4057
  const dup = { ...copy(loc.layer), name: `${loc.layer.name} copy` };
4003
4058
  const layers = insertLayer(this.doc.layers, dup, loc.parent?.id ?? null, loc.index + 1);
4004
- this.commit("Duplicate layer", this.withWetEvents({ ...this.doc, layers, activeLayerId: dup.id, ...this.doc.thick ? { thick } : {}, ...this.doc.stickers ? { stickers } : {} }));
4059
+ this.commit("Duplicate layer", this.withWetEvents({ ...this.doc, layers, activeLayerId: dup.id, ...this.doc.thick ? { thick } : {}, ...this.doc.stickers ? { stickers } : {}, ...this.doc.liveStrokes ? { liveStrokes } : {} }));
4005
4060
  return this.describeLayer(dup);
4006
4061
  }
4007
4062
  /** Moves a layer to `index` (0 = bottom) within `parentId` (default its own parent; `null` = top level). */
@@ -4047,7 +4102,7 @@ var PaintEditorController = class _PaintEditorController {
4047
4102
  }
4048
4103
  const merged = { ...below, surface, offset: { x: 0, y: 0 } };
4049
4104
  const layers = mapLayer(mapLayer(this.doc.layers, upper.id, () => null), below.id, () => merged);
4050
- this.commit("Merge down", this.withWetEvents({ ...this.doc, layers, activeLayerId: below.id, ...this.thickWithout([upper.id]), ...this.stickersWithout([upper.id, below.id]) }));
4105
+ this.commit("Merge down", this.withWetEvents({ ...this.doc, layers, activeLayerId: below.id, ...this.thickWithout([upper.id]), ...this.stickersWithout([upper.id, below.id]), ...this.liveStrokesWithout([upper.id, below.id]) }));
4051
4106
  return this.describeLayer(merged);
4052
4107
  }
4053
4108
  /** Removes every pixel of a layer. */
@@ -4059,7 +4114,7 @@ var PaintEditorController = class _PaintEditorController {
4059
4114
  this.wet.world.removeLane(l.id);
4060
4115
  }
4061
4116
  const bounds = this.describeLayer(l).bounds;
4062
- this.commit("Clear layer", this.withPaintEvent({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface: emptySurface(4) })), ...this.thickWithout([l.id]), ...this.stickersWithout([l.id]) }, { kind: "clear", layerId: l.id }));
4117
+ this.commit("Clear layer", this.withPaintEvent({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface: emptySurface(4) })), ...this.thickWithout([l.id]), ...this.stickersWithout([l.id]), ...this.doc.liveStrokes?.[l.id] ? { liveStrokes: { ...this.doc.liveStrokes, [l.id]: [] } } : {} }, { kind: "clear", layerId: l.id }));
4063
4118
  return { layerId: l.id, bounds };
4064
4119
  }
4065
4120
  /**
@@ -4070,6 +4125,7 @@ var PaintEditorController = class _PaintEditorController {
4070
4125
  this.requireIdle("filling");
4071
4126
  const fillTarget = this.paintTarget(options.layerId, "fill");
4072
4127
  if (this.doc.stickers?.[fillTarget.id]?.length) throw new PaintEditorError(`Layer "${fillTarget.name}" is a sticker layer; fill another layer (add_layer).`);
4128
+ 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).`);
4073
4129
  this.dryLayersFirst([options.layerId ?? this.doc.activeLayerId ?? ""]);
4074
4130
  const l = this.paintTarget(options.layerId, "fill");
4075
4131
  const color = options.color === void 0 ? this.color : parseColor(options.color);
@@ -4181,6 +4237,7 @@ var PaintEditorController = class _PaintEditorController {
4181
4237
  effectTarget(layerId, what) {
4182
4238
  const l = this.paintTarget(layerId, what);
4183
4239
  if (this.doc.stickers?.[l.id]?.length) throw new PaintEditorError(`Layer "${l.name}" is a sticker layer; ${what} another layer (add_layer).`);
4240
+ if (this.doc.liveStrokes?.[l.id]) throw new PaintEditorError(`Layer "${l.name}" is a stroke layer (its pixels are drawn from its strokes); ${what} another layer (add_layer), or rasterize it first (rasterize_stroke_layer).`);
4184
4241
  if (this.wet.wetLayerIds().includes(l.id)) throw new PaintEditorError(`Layer "${l.name}" holds wet paint; effects go on dried paint, so dry it first (dry_paint) and try again.`);
4185
4242
  return l;
4186
4243
  }
@@ -4754,6 +4811,10 @@ var PaintEditorController = class _PaintEditorController {
4754
4811
  })();
4755
4812
  const brush = this.strokeBrush(init);
4756
4813
  const effect = this.strokeEffect(init, brush);
4814
+ if (doc.liveStrokes?.[layer.id]) {
4815
+ const why = liveStrokeRefusal(brush);
4816
+ if (why) throw new PaintEditorError(`Layer "${layer.name}" is a stroke layer. ${why}`);
4817
+ }
4757
4818
  const stickerLayer = Boolean(doc.stickers?.[layer.id]?.length);
4758
4819
  if (brush.engine === "sticker") {
4759
4820
  if (!stickerLayer && contentBounds(layer.surface)) throw new PaintEditorError(`Layer "${layer.name}" already has paint; spray stickers onto an empty layer (add_layer) so they stay editable.`);
@@ -4908,6 +4969,7 @@ var PaintEditorController = class _PaintEditorController {
4908
4969
  }
4909
4970
  const stroke = this.record(s);
4910
4971
  s.result.strokeId = stroke.id;
4972
+ this.paintedBounds.set(stroke, s.result?.bounds ?? null);
4911
4973
  if (wetStroke && this.wet.instantDry) {
4912
4974
  this.wet.dry(s.layerId);
4913
4975
  this.logWet({ kind: "dry", layerId: s.layerId, at: this.doc.strokes.length + 1 });
@@ -4919,7 +4981,7 @@ var PaintEditorController = class _PaintEditorController {
4919
4981
  const thick = ch ? { ...this.doc.thick ?? {}, [l.id]: ch } : this.doc.thick;
4920
4982
  const sprayed = s.stickers();
4921
4983
  const stickers = sprayed ? { ...this.doc.stickers ?? {}, [l.id]: [...this.doc.stickers?.[l.id] ?? [], ...sprayed] } : this.doc.stickers;
4922
- this.commit(`${brush.name} stroke`, this.withWetEvents({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })), strokes: [...this.doc.strokes, stroke], ...thick ? { thick } : {}, ...stickers ? { stickers } : {} }), wetStroke ? before : this.doc);
4984
+ this.commit(`${brush.name} stroke`, this.withWetEvents(withLiveStroke({ ...this.doc, layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })), strokes: [...this.doc.strokes, stroke], ...thick ? { thick } : {}, ...stickers ? { stickers } : {} }, stroke)), wetStroke ? before : this.doc);
4923
4985
  }
4924
4986
  };
4925
4987
  let session;
@@ -4947,17 +5009,18 @@ var PaintEditorController = class _PaintEditorController {
4947
5009
  if (cancelled || !surface) return;
4948
5010
  const stroke = this.record(s);
4949
5011
  s.result.strokeId = stroke.id;
5012
+ this.paintedBounds.set(stroke, s.result?.bounds ?? null);
4950
5013
  const l = findLayer(out.layers, s.layerId).layer;
4951
5014
  const offset = s.init.wet ? { x: 0, y: 0 } : l.offset;
4952
5015
  const ch = s.thickChannels();
4953
5016
  const sprayed = s.stickers();
4954
- out = {
5017
+ out = withLiveStroke({
4955
5018
  ...out,
4956
5019
  layers: mapLayer(out.layers, l.id, () => ({ ...l, surface, offset })),
4957
5020
  strokes: [...out.strokes, stroke],
4958
5021
  ...ch ? { thick: { ...out.thick ?? {}, [l.id]: ch } } : {},
4959
5022
  ...sprayed ? { stickers: { ...out.stickers ?? {}, [l.id]: [...out.stickers?.[l.id] ?? [], ...sprayed] } } : {}
4960
- };
5023
+ }, stroke);
4961
5024
  }
4962
5025
  };
4963
5026
  const session = this.sessionFor(doc, init, host);
@@ -5501,6 +5564,10 @@ var PaintEditorController = class _PaintEditorController {
5501
5564
  for (const [id, list] of Object.entries(this.doc.stickers ?? {})) if (list.length && findLayer(this.doc.layers, id)) stickers[id] = list.map((x) => ({ ...x }));
5502
5565
  if (Object.keys(stickers).length) ext.stickers = stickers;
5503
5566
  else delete ext.stickers;
5567
+ const liveStrokes = {};
5568
+ for (const [id, list] of Object.entries(this.doc.liveStrokes ?? {})) if (findLayer(this.doc.layers, id)) liveStrokes[id] = list.map((x) => JSON.parse(JSON.stringify(x)));
5569
+ if (Object.keys(liveStrokes).length) ext.liveStrokes = liveStrokes;
5570
+ else delete ext.liveStrokes;
5504
5571
  const effectLayers = effectLayersToJSON(this.doc.effectLayers ?? {}, (id) => isPixelLayer(findLayer(this.doc.layers, id)?.layer));
5505
5572
  if (effectLayers) ext.effectLayers = effectLayers;
5506
5573
  else delete ext.effectLayers;
@@ -5524,6 +5591,16 @@ var PaintEditorController = class _PaintEditorController {
5524
5591
  }
5525
5592
  if (Object.keys(stickers).length) state = { ...state, stickers };
5526
5593
  }
5594
+ if (ext.liveStrokes && typeof ext.liveStrokes === "object") {
5595
+ const liveStrokes = {};
5596
+ for (const [id, list] of Object.entries(ext.liveStrokes)) {
5597
+ if (!isPixelLayer(findLayer(state.layers, id)?.layer) || !Array.isArray(list)) continue;
5598
+ const ok = list.map(normalizeBrushStroke).filter((x) => x !== null).map((x) => ({ ...x, layerId: id }));
5599
+ if (ok.length !== list.length) problems.push(`Some strokes of stroke layer "${id}" could not be read; they were dropped (the layer's pixels are kept until a stroke is edited).`);
5600
+ liveStrokes[id] = ok;
5601
+ }
5602
+ if (Object.keys(liveStrokes).length) state = { ...state, liveStrokes };
5603
+ }
5527
5604
  if (ext.effectLayers && typeof ext.effectLayers === "object") {
5528
5605
  const effectLayers = {};
5529
5606
  for (const [id, v] of Object.entries(ext.effectLayers)) {
@@ -5574,10 +5651,17 @@ var PaintEditorController = class _PaintEditorController {
5574
5651
  this.undoStack = [];
5575
5652
  this.redoStack = [];
5576
5653
  this.doc = this.readThick(loaded.state, loaded.extensions, loaded.problems);
5654
+ for (const list of Object.values(this.doc.liveStrokes ?? {})) {
5655
+ for (const s of list) {
5656
+ const n = Number(/^s(\d+)$/.exec(s.id)?.[1] ?? 0);
5657
+ if (n >= this.strokeCounter) this.strokeCounter = n + 1;
5658
+ }
5659
+ }
5577
5660
  this.docRevision++;
5578
5661
  delete this.extensions.layerChannels;
5579
5662
  delete this.extensions.lighting;
5580
5663
  delete this.extensions.stickers;
5664
+ delete this.extensions.liveStrokes;
5581
5665
  delete this.extensions.effectLayers;
5582
5666
  delete this.extensions.paperEffect;
5583
5667
  this.viewCompositor.clear();
@@ -6042,6 +6126,278 @@ var PaintEditorController = class _PaintEditorController {
6042
6126
  const l = this.wetLayer(layerId, "set the stickers of");
6043
6127
  this.commitStickers("Stickers", l, stickers.map((s) => ({ ...s })));
6044
6128
  }
6129
+ // --- Live stroke layers ---------------------------------------------------------
6130
+ /** The document's `liveStrokes` without these layers (as a spread: `{}` when unchanged). */
6131
+ liveStrokesWithout(ids) {
6132
+ const t = this.doc.liveStrokes;
6133
+ if (!t || !ids.some((id) => t[id])) return {};
6134
+ const out = { ...t };
6135
+ for (const id of ids) delete out[id];
6136
+ return { liveStrokes: out };
6137
+ }
6138
+ liveStrokeLayer(layerId, what) {
6139
+ const l = this.wetLayer(layerId, what);
6140
+ const strokes = this.doc.liveStrokes?.[l.id];
6141
+ if (!strokes) throw new PaintEditorError(`Layer "${l.name}" is not a stroke layer; add one with add_layer {type: "strokes"} (or convert_to_stroke_layer on an empty layer), then paint on it.`);
6142
+ return { layer: l, strokes };
6143
+ }
6144
+ /** Whether a layer is a live stroke layer. */
6145
+ isLiveStrokeLayer(layerId) {
6146
+ return Boolean(this.doc.liveStrokes?.[layerId]);
6147
+ }
6148
+ /** Makes an empty pixel layer a live stroke layer (one undo step). */
6149
+ convertToLiveStrokes(layerId) {
6150
+ this.requireIdle("converting a layer");
6151
+ const l = this.paintTarget(layerId, "convert");
6152
+ if (this.doc.liveStrokes?.[l.id]) return this.describeLayer(l);
6153
+ if (this.doc.stickers?.[l.id]?.length) throw new PaintEditorError(`Layer "${l.name}" is a sticker layer; add a stroke layer instead (add_layer {type: "strokes"}).`);
6154
+ if (contentBounds(l.surface) || this.wet.wetLayerIds().includes(l.id)) throw new PaintEditorError(`Layer "${l.name}" already has paint, which isn't kept as strokes; convert an empty layer or add a stroke layer (add_layer {type: "strokes"}).`);
6155
+ this.commit("Convert to stroke layer", { ...this.doc, liveStrokes: { ...this.doc.liveStrokes ?? {}, [l.id]: [] } });
6156
+ return this.describeLayer(this.getLayer(l.id));
6157
+ }
6158
+ /** A stroke layer's strokes, bottom first. */
6159
+ listLiveStrokes(layerId) {
6160
+ return this.liveStrokeLayer(layerId, "list the strokes of").strokes.map((x) => ({ ...x }));
6161
+ }
6162
+ /** The stroke layer holding a live stroke, and where. */
6163
+ findLiveStroke(id) {
6164
+ for (const [layerId, strokes] of Object.entries(this.doc.liveStrokes ?? {})) {
6165
+ const index = strokes.findIndex((x) => x.id === id);
6166
+ const layer = findLayer(this.doc.layers, layerId)?.layer;
6167
+ if (index >= 0 && isPixelLayer(layer)) return { layer, strokes, index };
6168
+ }
6169
+ const baked = this.doc.strokes.some((x) => x.id === id);
6170
+ throw new PaintEditorError(
6171
+ baked ? `Stroke "${id}" was painted on a pixel layer, so its pixels are baked; only strokes on a stroke layer can be edited (list_strokes {live: true}). Replay it onto a stroke layer (replay_strokes layerId) to edit the copy.` : `Unknown stroke "${id}". Call list_strokes {live: true} for the ids of editable strokes.`
6172
+ );
6173
+ }
6174
+ /**
6175
+ * Changes a live stroke's brush (a preset by `brushId`, keeping the stroke's size, and/or brush
6176
+ * settings), colour, paint or seed, and redraws its layer (one undo step). A new colour replaces
6177
+ * gradient or effect paint. Returns the stroke and the area that changed.
6178
+ */
6179
+ updateStroke(id, update) {
6180
+ this.requireIdle("editing a stroke");
6181
+ const { layer, strokes, index } = this.findLiveStroke(id);
6182
+ const was = strokes[index];
6183
+ let brush = was.brush;
6184
+ if (update.brushId !== void 0) {
6185
+ const b = this.requireBrush(update.brushId);
6186
+ brush = normalizePaintBrush({ tip: { ...b.tip, size: was.brush.tip.size } }, b);
6187
+ }
6188
+ if (update.brush !== void 0) {
6189
+ if (!update.brush || typeof update.brush !== "object") throw new PaintEditorError("brush must be an object of brush settings (as set_brush takes).");
6190
+ brush = normalizePaintBrush({ ...update.brush, id: brush.id }, brush);
6191
+ }
6192
+ const why = liveStrokeRefusal(brush);
6193
+ if (why) throw new PaintEditorError(why);
6194
+ const next = { ...was, brush: brushToJSON(brush) };
6195
+ if (update.seed !== void 0) {
6196
+ if (!Number.isInteger(update.seed) || update.seed < 0) throw new PaintEditorError(`seed ${update.seed} must be a whole number of 0 or more.`);
6197
+ next.seed = update.seed;
6198
+ }
6199
+ if (update.color !== void 0) {
6200
+ next.color = parseColor(update.color);
6201
+ delete next.paint;
6202
+ }
6203
+ if (update.paint !== void 0) {
6204
+ if (update.paint === null) delete next.paint;
6205
+ else if (isPaintEffectPaint(update.paint)) {
6206
+ if (!this.effectPaintImage(update.paint)) throw new PaintEditorError(`The effect "${update.paint.effect}" is not rendered yet; render it first (prepareEffectPaints; the update_stroke tool does this).`);
6207
+ next.paint = JSON.parse(JSON.stringify(update.paint));
6208
+ } else {
6209
+ const p = this.readStrokePaint(update.paint);
6210
+ if (p.type === "color") {
6211
+ next.color = p.color;
6212
+ delete next.paint;
6213
+ } else next.paint = JSON.parse(JSON.stringify(p));
6214
+ }
6215
+ }
6216
+ const rgb = hexToRgb8(next.color).map((v) => v / 255);
6217
+ const load = brushLoadForStroke(brush, EMPTY_MIXER_LOAD, rgb);
6218
+ if (load.reservoir || load.pickup) next.load = load;
6219
+ else delete next.load;
6220
+ const list = strokes.map((x, i) => i === index ? next : x);
6221
+ this.commitLiveStrokes("Edit stroke", layer, list, this.strokeAreas([was]));
6222
+ return { stroke: { ...next }, bounds: unionRect(this.strokeArea(was), this.strokeArea(next)) ?? { x: 0, y: 0, width: 0, height: 0 } };
6223
+ }
6224
+ /**
6225
+ * Replaces a live stroke's points, or moves, scales, rotates or flips them (`transform`, about
6226
+ * the stroke's centre unless an origin is given; the brush size scales too), and redraws its
6227
+ * layer (one undo step). Returns the stroke and the area that changed.
6228
+ */
6229
+ editStrokePoints(id, edit) {
6230
+ this.requireIdle("editing a stroke");
6231
+ if (edit.points === void 0 === (edit.transform === void 0)) throw new PaintEditorError("Pass either points (the new pen samples) or transform (dx, dy, scale, rotate, flipX), not both.");
6232
+ const { layer, strokes, index } = this.findLiveStroke(id);
6233
+ const was = strokes[index];
6234
+ let next;
6235
+ if (edit.points !== void 0) {
6236
+ const points = this.checkPoints(edit.points);
6237
+ next = { ...was, points: points.map((p) => ({ ...p })) };
6238
+ } else {
6239
+ const t = edit.transform;
6240
+ if (t.scale !== void 0 && !(t.scale > 0 && t.scale <= 20)) throw new PaintEditorError(`scale ${t.scale} is out of range; use above 0 up to 20.`);
6241
+ for (const k of ["dx", "dy", "rotate"]) if (t[k] !== void 0 && !Number.isFinite(t[k])) throw new PaintEditorError(`${k} must be a number.`);
6242
+ const b = strokeBounds(was.points);
6243
+ const m = variationAffine(t, t.origin ?? { x: b.x + b.width / 2, y: b.y + b.height / 2 });
6244
+ next = varyStroke(was, m, {});
6245
+ if (was.stencils) next.stencils = was.stencils;
6246
+ }
6247
+ const list = strokes.map((x, i) => i === index ? next : x);
6248
+ this.commitLiveStrokes("Edit stroke points", layer, list, this.strokeAreas([was]));
6249
+ return { stroke: { ...next }, bounds: unionRect(this.strokeArea(was), this.strokeArea(next)) ?? { x: 0, y: 0, width: 0, height: 0 } };
6250
+ }
6251
+ /** Deletes live strokes (one undo step). Returns the ids removed and the area that changed. */
6252
+ removeStrokes(ids) {
6253
+ this.requireIdle("removing strokes");
6254
+ if (!ids.length) throw new PaintEditorError("Pass at least one stroke id (list_strokes {live: true}).");
6255
+ const found = ids.map((id) => this.findLiveStroke(id));
6256
+ let doc = this.doc;
6257
+ let bounds = null;
6258
+ const layers = /* @__PURE__ */ new Map();
6259
+ for (const f of found) layers.set(f.layer.id, f.layer);
6260
+ for (const [layerId, layer] of layers) {
6261
+ const gone = (doc.liveStrokes?.[layerId] ?? []).filter((x) => ids.includes(x.id));
6262
+ for (const x of gone) bounds = unionRect(bounds, this.strokeArea(x));
6263
+ doc = this.withLiveStrokes(doc, layer, (doc.liveStrokes?.[layerId] ?? []).filter((x) => !ids.includes(x.id)), this.strokeAreas(gone));
6264
+ }
6265
+ this.commit(ids.length === 1 ? "Remove stroke" : "Remove strokes", doc);
6266
+ return { removed: [...ids], bounds };
6267
+ }
6268
+ /** Moves a live stroke to `index` in its layer (0 = bottom) and redraws (one undo step). Returns the new order. */
6269
+ reorderStroke(id, index) {
6270
+ this.requireIdle("reordering strokes");
6271
+ const { layer, strokes, index: from } = this.findLiveStroke(id);
6272
+ if (!Number.isInteger(index) || index < 0 || index >= strokes.length) throw new PaintEditorError(`index ${index} is out of range; the layer has ${strokes.length} strokes, so use 0..${strokes.length - 1} (0 = bottom).`);
6273
+ const list = [...strokes];
6274
+ const [moved] = list.splice(from, 1);
6275
+ list.splice(index, 0, moved);
6276
+ if (from !== index) this.commitLiveStrokes("Reorder stroke", layer, list, this.strokeAreas([moved]));
6277
+ return list.map((x) => x.id);
6278
+ }
6279
+ /** Turns a stroke layer into a plain pixel layer with the same pixels (one undo step). */
6280
+ rasterizeLiveStrokes(layerId) {
6281
+ this.requireIdle("rasterizing");
6282
+ const { layer } = this.liveStrokeLayer(layerId, "rasterize");
6283
+ this.commit("Rasterize stroke layer", this.withPaintEvent({ ...this.doc, ...this.liveStrokesWithout([layer.id]) }, { kind: "live-strokes", layerId: layer.id, data: { rasterized: true } }));
6284
+ return this.describeLayer(this.getLayer(layer.id));
6285
+ }
6286
+ /** Sets a layer's live strokes and redraws it (replays). `null` makes it a plain pixel layer. */
6287
+ replaceLiveStrokes(layerId, strokes) {
6288
+ const l = this.wetLayer(layerId, "set the strokes of");
6289
+ if (strokes === null) {
6290
+ this.commit("Rasterize stroke layer", { ...this.doc, ...this.liveStrokesWithout([l.id]) });
6291
+ return;
6292
+ }
6293
+ this.commitLiveStrokes("Strokes", l, strokes.map((x) => ({ ...x, layerId: l.id })));
6294
+ }
6295
+ /**
6296
+ * The top-most stroke of a stroke layer (default the active layer) that painted the pixel at
6297
+ * (x, y) document pixels, or `null`. Strokes whose area holds the point are drawn alone, top
6298
+ * first, until one covers it.
6299
+ */
6300
+ liveStrokeAt(layerId, x, y) {
6301
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new PaintEditorError("x and y must be numbers of document pixels.");
6302
+ const { layer, strokes } = this.liveStrokeLayer(layerId, "pick a stroke on");
6303
+ const plain = { ...layer, visible: true, locks: void 0, offset: { x: 0, y: 0 } };
6304
+ const doc = { ...this.doc, liveStrokes: void 0, stickers: void 0 };
6305
+ const px = Math.floor(x);
6306
+ const py = Math.floor(y);
6307
+ for (let i = strokes.length - 1; i >= 0; i--) {
6308
+ const area = this.strokeArea(strokes[i]);
6309
+ if (!area || px < area.x || py < area.y || px >= area.x + area.width || py >= area.y + area.height) continue;
6310
+ const drawn = this.drawLiveStroke(doc, plain, emptySurface(4), strokes[i]).surface;
6311
+ if (readPixel(drawn, px, py)[3] > 24) return { ...strokes[i] };
6312
+ }
6313
+ return null;
6314
+ }
6315
+ /** Picks a stroke for the stroke panel and canvas (`null` for none). Editor state, not an undo step. */
6316
+ selectStroke(id) {
6317
+ if (id === this.selectedStroke) return;
6318
+ this.selectedStroke = id;
6319
+ this.emit([]);
6320
+ }
6321
+ /** Where a live stroke painted (document pixels), or `null`. */
6322
+ liveStrokeArea(id) {
6323
+ return this.strokeArea(this.findLiveStroke(id).strokes.find((s) => s.id === id));
6324
+ }
6325
+ /** Commits a layer's new stroke list with its redrawn pixels, recorded for replays (one undo step). */
6326
+ commitLiveStrokes(label, layer, list, dirty) {
6327
+ this.commit(label, this.withLiveStrokes(this.doc, layer, list, dirty));
6328
+ }
6329
+ withLiveStrokes(doc, layer, list, dirty) {
6330
+ if (isLayerLocked(layer, "pixels")) throw new PaintEditorError(`Layer "${layer.name}" has its pixels locked; unlock it (update_layer locks) first.`);
6331
+ const current = findLayer(doc.layers, layer.id)?.layer;
6332
+ const surface = this.drawLiveStrokes(current ?? layer, list, dirty);
6333
+ const next = { ...doc, layers: mapLayer(doc.layers, layer.id, (l) => ({ ...l, surface, offset: { x: 0, y: 0 } })), liveStrokes: { ...doc.liveStrokes ?? {}, [layer.id]: [...list] } };
6334
+ return this.withPaintEvent(next, { kind: "live-strokes", layerId: layer.id, data: { strokes: list.map((x) => JSON.parse(JSON.stringify(x))) } });
6335
+ }
6336
+ /** Where a stroke painted (document pixels): as learnt when it was painted or drawn, else an estimate from its points and brush. */
6337
+ strokeArea(s) {
6338
+ return this.paintedBounds.has(s) ? this.paintedBounds.get(s) : liveStrokeBounds(s);
6339
+ }
6340
+ /** The areas strokes painted, or `null` when one is not known (a full redraw then). */
6341
+ strokeAreas(strokes) {
6342
+ const out = [];
6343
+ for (const s of strokes) {
6344
+ if (!this.paintedBounds.has(s)) return null;
6345
+ const b = this.paintedBounds.get(s);
6346
+ if (b) out.push(b);
6347
+ }
6348
+ return out;
6349
+ }
6350
+ /** Draws one stroke onto `surface` of a plain copy of the layer; returns the result and the area it painted. */
6351
+ drawLiveStroke(doc, plain, surface, st) {
6352
+ let out = surface;
6353
+ const host = {
6354
+ update: () => {
6355
+ },
6356
+ commit: (_s, drawn, cancelled) => {
6357
+ if (!cancelled && drawn) out = drawn;
6358
+ }
6359
+ };
6360
+ const init = { layerId: plain.id, color: st.color, brush: st.brush, seed: st.seed, copies: st.copies ?? [], ...st.load ? { load: st.load } : {}, ...st.paint ? { paint: st.paint } : {}, snapToPerspective: false, stencils: false, maskStencils: st.stencils ?? [] };
6361
+ const session = this.sessionFor({ ...doc, layers: mapLayer(doc.layers, plain.id, () => ({ ...plain, surface })) }, init, host);
6362
+ session.push(st.points);
6363
+ const result = session.end();
6364
+ this.paintedBounds.set(st, result.bounds);
6365
+ return { surface: out, bounds: result.bounds };
6366
+ }
6367
+ /**
6368
+ * A stroke layer's pixels drawn from its strokes, bottom first. With `dirty` (the areas the old
6369
+ * strokes that changed had painted), only the tiles there and under any stroke whose area isn't
6370
+ * known yet are drawn again, from the strokes that reach them (a pixel depends only on the
6371
+ * strokes over it); the other tiles are kept. Without it, or with `null`, every stroke is drawn.
6372
+ */
6373
+ drawLiveStrokes(layer, list, dirty) {
6374
+ const plain = { ...layer, visible: true, locks: void 0, offset: { x: 0, y: 0 } };
6375
+ const doc = { ...this.doc, liveStrokes: void 0, stickers: void 0 };
6376
+ const unknown = list.filter((s) => !this.paintedBounds.has(s));
6377
+ const partial = dirty && !layer.offset.x && !layer.offset.y && unknown.length <= 4;
6378
+ if (!partial) {
6379
+ let surface2 = emptySurface(4);
6380
+ for (const st of list) surface2 = this.drawLiveStroke(doc, plain, surface2, st).surface;
6381
+ return surface2;
6382
+ }
6383
+ const areas = [...dirty];
6384
+ for (const st of unknown) {
6385
+ const b = this.drawLiveStroke(doc, plain, emptySurface(4), st).bounds;
6386
+ if (b) areas.push(b);
6387
+ }
6388
+ const { width, height } = this.doc.canvas;
6389
+ const rects = areas.map((r) => clipRect(r, width, height)).filter((r) => r !== null);
6390
+ if (!rects.length) return layer.surface;
6391
+ const overlaps = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
6392
+ let drawn = emptySurface(4);
6393
+ for (const st of list) {
6394
+ const b = this.paintedBounds.get(st);
6395
+ if (b && rects.some((r) => overlaps(r, b))) drawn = this.drawLiveStroke(doc, plain, drawn, st).surface;
6396
+ }
6397
+ let surface = layer.surface;
6398
+ for (const r of rects) surface = writeRegion(surface, r, readRegion(drawn, r));
6399
+ return surface;
6400
+ }
6045
6401
  // --- Stencils --------------------------------------------------------------------
6046
6402
  stencilCache = null;
6047
6403
  nextStencilId = 1;
@@ -6259,6 +6615,17 @@ var PaintEditorController = class _PaintEditorController {
6259
6615
  var MAX_IMAGE_SIDE = 8192;
6260
6616
  var movePoint = (p, dx, dy) => ({ x: p.x + dx, y: p.y + dy });
6261
6617
  var moveRect = (r, dx, dy) => ({ ...r, x: r.x + dx, y: r.y + dy });
6618
+ function clipRect(r, width, height) {
6619
+ const x0 = Math.max(0, Math.floor(r.x));
6620
+ const y0 = Math.max(0, Math.floor(r.y));
6621
+ const x1 = Math.min(width, Math.ceil(r.x + r.width));
6622
+ const y1 = Math.min(height, Math.ceil(r.y + r.height));
6623
+ return x1 > x0 && y1 > y0 ? { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } : null;
6624
+ }
6625
+ function withLiveStroke(doc, stroke) {
6626
+ const list = doc.liveStrokes?.[stroke.layerId];
6627
+ return list ? { ...doc, liveStrokes: { ...doc.liveStrokes, [stroke.layerId]: [...list, stroke] } } : doc;
6628
+ }
6262
6629
  function shiftDocument(doc, dx, dy) {
6263
6630
  if (!dx && !dy) return doc;
6264
6631
  let layers = doc.layers;
@@ -6275,11 +6642,18 @@ function shiftDocument(doc, dx, dy) {
6275
6642
  return s.stencils ? { ...moved, stencils: s.stencils.map((x) => translateStencil(x, dx, dy)) } : moved;
6276
6643
  })
6277
6644
  };
6645
+ const moveStroke = (s) => {
6646
+ const moved = varyStroke(s, m, {});
6647
+ return s.stencils ? { ...moved, stencils: s.stencils.map((x) => translateStencil(x, dx, dy)) } : moved;
6648
+ };
6278
6649
  if (doc.stickers) out.stickers = Object.fromEntries(Object.entries(doc.stickers).map(([id, list]) => [id, moveStickers(list)]));
6650
+ if (doc.liveStrokes) out.liveStrokes = Object.fromEntries(Object.entries(doc.liveStrokes).map(([id, list]) => [id, list.map(moveStroke)]));
6279
6651
  if (doc.paintEvents) {
6280
6652
  out.paintEvents = doc.paintEvents.map((e) => {
6281
6653
  const list = e.kind === "stickers" ? e.data?.stickers : void 0;
6282
- return list ? { ...e, data: { ...e.data, stickers: moveStickers(list) } } : e;
6654
+ if (list) return { ...e, data: { ...e.data, stickers: moveStickers(list) } };
6655
+ const strokes = e.kind === "live-strokes" ? e.data?.strokes : void 0;
6656
+ return strokes ? { ...e, data: { ...e.data, strokes: strokes.map(moveStroke) } } : e;
6283
6657
  });
6284
6658
  }
6285
6659
  if (doc.wetEvents) {
@@ -7899,12 +8273,12 @@ function createPaintEditorToolProvider(controller, options = {}) {
7899
8273
  add({
7900
8274
  name: "add_layer",
7901
8275
  title: "Add a layer",
7902
- description: "Adds an empty pixel layer (or a group) above the active layer, or above aboveId, or at the top of parentId; it becomes the active layer. One undo step. Returns the layer.",
8276
+ description: "Adds an empty pixel layer, a group, or a stroke layer (type strokes: its strokes stay editable with update_stroke and edit_stroke_points) above the active layer, or above aboveId, or at the top of parentId; it becomes the active layer. One undo step. Returns the layer (liveStrokes: its stroke count, for a stroke layer).",
7903
8277
  inputSchema: {
7904
8278
  type: "object",
7905
8279
  properties: {
7906
8280
  name: { type: "string", minLength: 1, description: 'Layer name (default "Layer n").' },
7907
- type: { type: "string", enum: ["pixel", "group"], description: "pixel (default) or group." },
8281
+ type: { type: "string", enum: ["pixel", "group", "strokes"], description: "pixel (default), group, or strokes: a live stroke layer whose pixels are drawn from its strokes, so each stays editable. It takes dab, particle, bristle and Mixer Brush strokes that pick up no paint." },
7908
8282
  aboveId: { type: "string", description: "Put it directly above this layer (default the active layer)." },
7909
8283
  parentId: { type: "string", description: "Put it at the top of this group instead." }
7910
8284
  },
@@ -8266,44 +8640,156 @@ function createPaintEditorToolProvider(controller, options = {}) {
8266
8640
  add({
8267
8641
  name: "list_strokes",
8268
8642
  title: "List recorded strokes",
8269
- description: "Lists the recorded strokes, oldest first (every stroke is kept as data): id, layer, brush id and name, colour, point count, bounds and seed. With ids, also the points. Use the ids with replay_strokes.",
8643
+ description: "Lists the recorded strokes, oldest first (every stroke is kept as data): id, layer, brush id and name, colour, point count, bounds, seed and whether it is live. With ids, also the points. Use the ids with replay_strokes. With live true, lists the strokes of stroke layers as they are now, bottom first: the ones update_stroke, edit_stroke_points, reorder_stroke and remove_strokes change.",
8270
8644
  inputSchema: {
8271
8645
  type: "object",
8272
8646
  properties: {
8273
8647
  ids: { type: "array", items: { type: "string", description: "Stroke id." }, description: "Only these strokes, with their points." },
8274
- last: { type: "integer", minimum: 1, maximum: 1e3, description: "Only the most recent n strokes (default 100)." }
8648
+ last: { type: "integer", minimum: 1, maximum: 1e3, description: "Only the most recent n strokes (default 100)." },
8649
+ live: { type: "boolean", description: "Only the editable strokes of stroke layers, as they are now, bottom first (default false: the whole recorded history)." },
8650
+ layerId: { type: "string", description: "Only strokes of this layer." }
8275
8651
  },
8276
8652
  additionalProperties: false
8277
8653
  },
8278
8654
  annotations: { title: "List recorded strokes", ...READ6 },
8279
8655
  coordinates: { out: ["strokes[].bounds", "strokes[].points[]"] },
8280
- handler: ({ ids, last }) => {
8281
- const all = c.getStrokes();
8656
+ handler: ({ ids, last, live, layerId }) => {
8657
+ if (layerId !== void 0 && !c.getLayer(layerId)) throw new Error(`Unknown layer "${layerId}". Call list_layers for the ids.`);
8658
+ const liveAll = Object.values(c.document.liveStrokes ?? {}).flat();
8659
+ const liveIds = new Set(liveAll.map((s) => s.id));
8660
+ let all = live ? liveAll : c.getStrokes();
8661
+ if (layerId !== void 0) all = all.filter((s) => s.layerId === layerId);
8282
8662
  const picked = ids ? all.filter((s) => ids.includes(s.id)) : all.slice(-(last ?? 100));
8283
8663
  if (ids) {
8284
8664
  for (const id of ids) if (!all.some((s) => s.id === id)) throw new Error(`Unknown stroke "${id}". Call list_strokes without ids for the ids.`);
8285
8665
  }
8286
- return {
8287
- total: all.length,
8288
- strokes: picked.map((s) => {
8289
- const xs = s.points.map((p) => p.x);
8290
- const ys = s.points.map((p) => p.y);
8291
- const pad = s.brush.tip.size / 2;
8292
- return {
8293
- id: s.id,
8294
- layerId: s.layerId,
8295
- brushId: s.brush.id,
8296
- brushName: s.brush.name,
8297
- color: s.color,
8298
- points: ids ? s.points : s.points.length,
8299
- seed: s.seed,
8300
- copies: s.copies?.length ?? 0,
8301
- bounds: { x: Math.floor(Math.min(...xs) - pad), y: Math.floor(Math.min(...ys) - pad), width: Math.ceil(Math.max(...xs) - Math.min(...xs) + 2 * pad), height: Math.ceil(Math.max(...ys) - Math.min(...ys) + 2 * pad) }
8302
- };
8303
- })
8304
- };
8666
+ return { total: all.length, strokes: picked.map((s) => strokeSummary(s, Boolean(ids), live ? true : liveIds.has(s.id) && c.document.liveStrokes?.[s.layerId]?.includes(s) === true)) };
8305
8667
  }
8306
8668
  });
8669
+ add({
8670
+ name: "stroke_at",
8671
+ title: "Find the stroke at a point",
8672
+ description: "Returns the top-most stroke of a stroke layer that painted the pixel at a point (document pixels), or stroke null when none did, so you can pick one stroke among overlapping ones before update_stroke, edit_stroke_points or remove_strokes. Changes nothing.",
8673
+ inputSchema: {
8674
+ type: "object",
8675
+ properties: {
8676
+ layerId: { type: "string", description: "Stroke layer id from list_layers (default the active layer)." },
8677
+ x: { type: "number", description: "Point x in document pixels." },
8678
+ y: { type: "number", description: "Point y in document pixels." }
8679
+ },
8680
+ required: ["x", "y"],
8681
+ additionalProperties: false
8682
+ },
8683
+ annotations: { title: "Find the stroke at a point", ...READ6 },
8684
+ coordinates: { in: [""], out: ["stroke.bounds"] },
8685
+ handler: ({ layerId, x, y }) => {
8686
+ const s = c.liveStrokeAt(layerId, x, y);
8687
+ return { stroke: s ? strokeSummary(s, false, true) : null };
8688
+ }
8689
+ });
8690
+ add({
8691
+ name: "update_stroke",
8692
+ title: "Change a stroke",
8693
+ description: "Changes a stroke on a stroke layer after it was painted: another brush preset (brushId, keeping the stroke size), brush settings (as set_brush takes), colour (replaces gradient paint), paint, or seed, then redraws the layer exactly. Ids come from list_strokes {live: true}. Refuses brushes a stroke layer cannot redraw (wet, impasto, sticker, eraser, smudge, blender, pick-up). One undo step. Returns the stroke and the area that changed.",
8694
+ inputSchema: {
8695
+ type: "object",
8696
+ properties: {
8697
+ id: { type: "string", description: "Stroke id from list_strokes {live: true}." },
8698
+ brushId: { type: "string", description: "Brush preset id from list_brushes; the stroke keeps its size." },
8699
+ brush: { type: "object", properties: BRUSH_FIELDS, additionalProperties: false, description: "Brush settings to change on top of brushId or the stroke\u2019s brush; missing fields keep their values." },
8700
+ color: { type: "string", pattern: "^#[0-9a-fA-F]{6}$", description: "New colour #rrggbb (replaces gradient paint)." },
8701
+ paint: { anyOf: [PAINT_SOURCE_JSON_SCHEMA, { type: "null", description: "Back to the stroke\u2019s plain colour." }], description: "A colour or gradient paint for the stroke; null drops its gradient." },
8702
+ seed: { type: "integer", minimum: 0, description: "Another jitter seed: particles, scatter and texture fall differently." }
8703
+ },
8704
+ required: ["id"],
8705
+ additionalProperties: false
8706
+ },
8707
+ annotations: { title: "Change a stroke", ...WRITE5 },
8708
+ coordinates: { out: ["bounds", "stroke.bounds"] },
8709
+ handler: ({ id, ...update }) => {
8710
+ if (!Object.keys(update).length) throw new Error("Pass at least one change: brushId, brush, color, paint or seed.");
8711
+ const r = c.updateStroke(id, update);
8712
+ return { stroke: strokeSummary(r.stroke, false, true), bounds: r.bounds };
8713
+ }
8714
+ });
8715
+ add({
8716
+ name: "edit_stroke_points",
8717
+ title: "Move or reshape a stroke",
8718
+ description: "Replaces a stroke\u2019s points (points, document pixels), or moves, scales, rotates or flips it (transform, about its centre unless origin is given; the brush size scales with it), then redraws its stroke layer exactly. Pass one of points or transform. One undo step. Returns the stroke with its points and the area that changed.",
8719
+ inputSchema: {
8720
+ type: "object",
8721
+ properties: {
8722
+ id: { type: "string", description: "Stroke id from list_strokes {live: true}." },
8723
+ points: POINTS,
8724
+ transform: {
8725
+ type: "object",
8726
+ description: "A move, scale, rotation or flip of the whole stroke.",
8727
+ properties: {
8728
+ dx: { type: "number", description: "Move right by this many pixels (default 0)." },
8729
+ dy: { type: "number", description: "Move down by this many pixels (default 0)." },
8730
+ scale: { type: "number", exclusiveMinimum: 0, maximum: 20, description: "Scale about the origin; brush size scales too (default 1)." },
8731
+ rotate: { type: "number", minimum: -360, maximum: 360, description: "Rotate about the origin, degrees clockwise (default 0)." },
8732
+ flipX: { type: "boolean", description: "Mirror left to right about the origin (default false)." },
8733
+ origin: { type: "object", description: "Centre for scale, rotate and flip (default the centre of the stroke).", properties: { x: { type: "number", description: "Document x." }, y: { type: "number", description: "Document y." } }, required: ["x", "y"], additionalProperties: false }
8734
+ },
8735
+ additionalProperties: false
8736
+ }
8737
+ },
8738
+ required: ["id"],
8739
+ additionalProperties: false
8740
+ },
8741
+ annotations: { title: "Move or reshape a stroke", ...WRITE5 },
8742
+ coordinates: { in: ["points[]", "transform.origin"], out: ["bounds", "stroke.bounds", "stroke.points[]"] },
8743
+ handler: ({ id, points, transform }) => {
8744
+ const r = c.editStrokePoints(id, { points, transform });
8745
+ return { stroke: strokeSummary(r.stroke, true, true), bounds: r.bounds };
8746
+ }
8747
+ });
8748
+ add({
8749
+ name: "reorder_stroke",
8750
+ title: "Move a stroke up or down",
8751
+ description: "Moves a stroke to index in its stroke layer (0 = bottom, drawn first) and redraws the layer. One undo step. Returns the new order of ids, bottom first.",
8752
+ inputSchema: {
8753
+ type: "object",
8754
+ properties: { id: { type: "string", description: "Stroke id from list_strokes {live: true}." }, index: { type: "integer", minimum: 0, description: "New position, 0 = bottom." } },
8755
+ required: ["id", "index"],
8756
+ additionalProperties: false
8757
+ },
8758
+ annotations: { title: "Move a stroke up or down", ...WRITE5 },
8759
+ handler: ({ id, index }) => ({ order: c.reorderStroke(id, index) })
8760
+ });
8761
+ add({
8762
+ name: "remove_strokes",
8763
+ title: "Delete strokes",
8764
+ description: "Deletes strokes from their stroke layers (the eraser of a stroke layer) and redraws them. All checked first; one undo step. Returns the ids removed and the area that changed.",
8765
+ inputSchema: {
8766
+ type: "object",
8767
+ properties: { ids: { type: "array", minItems: 1, items: { type: "string", description: "Stroke id." }, description: "Stroke ids from list_strokes {live: true}." } },
8768
+ required: ["ids"],
8769
+ additionalProperties: false
8770
+ },
8771
+ annotations: { title: "Delete strokes", ...DESTRUCTIVE3 },
8772
+ coordinates: { out: ["bounds"] },
8773
+ handler: ({ ids }) => c.removeStrokes(ids)
8774
+ });
8775
+ add({
8776
+ name: "convert_to_stroke_layer",
8777
+ title: "Make a layer a stroke layer",
8778
+ description: 'Makes an empty pixel layer a stroke layer, so strokes painted on it stay editable. Refuses a layer with paint (add_layer {type: "strokes"} instead). One undo step. Returns the layer.',
8779
+ inputSchema: { type: "object", properties: { layerId: { type: "string", description: "Layer id from list_layers (default the active layer)." } }, additionalProperties: false },
8780
+ annotations: { title: "Make a layer a stroke layer", ...WRITE5 },
8781
+ coordinates: { out: ["bounds"] },
8782
+ handler: ({ layerId }) => c.convertToLiveStrokes(layerId)
8783
+ });
8784
+ add({
8785
+ name: "rasterize_stroke_layer",
8786
+ title: "Rasterize a stroke layer",
8787
+ description: "Turns a stroke layer into a plain pixel layer with the same pixels: its strokes stop being editable, and erasers, smudges and fills work on it. One undo step. Returns the layer.",
8788
+ inputSchema: { type: "object", properties: { layerId: { type: "string", description: "Stroke layer id from list_layers (default the active layer)." } }, additionalProperties: false },
8789
+ annotations: { title: "Rasterize a stroke layer", ...DESTRUCTIVE3 },
8790
+ coordinates: { out: ["bounds"] },
8791
+ handler: ({ layerId }) => c.rasterizeLiveStrokes(layerId)
8792
+ });
8307
8793
  add({
8308
8794
  name: "replay_strokes",
8309
8795
  title: "Replay strokes, varied",
@@ -8479,12 +8965,30 @@ function hostTool({ coordinates, ...tool }, { origin, onChange }) {
8479
8965
  }
8480
8966
  };
8481
8967
  }
8968
+ function strokeSummary(s, withPoints, live) {
8969
+ const xs = s.points.map((p) => p.x);
8970
+ const ys = s.points.map((p) => p.y);
8971
+ const pad = s.brush.tip.size / 2;
8972
+ return {
8973
+ id: s.id,
8974
+ layerId: s.layerId,
8975
+ brushId: s.brush.id,
8976
+ brushName: s.brush.name,
8977
+ color: s.color,
8978
+ points: withPoints ? s.points : s.points.length,
8979
+ seed: s.seed,
8980
+ copies: s.copies?.length ?? 0,
8981
+ live,
8982
+ bounds: { x: Math.floor(Math.min(...xs) - pad), y: Math.floor(Math.min(...ys) - pad), width: Math.ceil(Math.max(...xs) - Math.min(...xs) + 2 * pad), height: Math.ceil(Math.max(...ys) - Math.min(...ys) + 2 * pad) }
8983
+ };
8984
+ }
8482
8985
 
8483
8986
  // src/paint-editor/ui.ts
8484
8987
  var PAINT_TOOLS = [
8485
8988
  { id: "brush", label: "Brush", key: "b" },
8486
8989
  { id: "eyedropper", label: "Eyedropper", key: "i" },
8487
8990
  { id: "sticker", label: "Stickers", key: "s" },
8991
+ { id: "stroke", label: "Select stroke", key: "v" },
8488
8992
  { id: "pan", label: "Pan", key: "h" },
8489
8993
  { id: "rotate", label: "Rotate view", key: "r" }
8490
8994
  ];
@@ -8533,6 +9037,7 @@ var PaintGesture = class {
8533
9037
  this.mode = { kind: "rotate", pointerId: e.pointerId, angle: Math.atan2(e.clientY - b.top - b.height / 2, e.clientX - b.left - b.width / 2), rotation: this.controller.getState().view.rotation };
8534
9038
  return true;
8535
9039
  }
9040
+ if (tool === "stroke") return this.strokeDown(e);
8536
9041
  if (tool === "eyedropper" || e.altKey) {
8537
9042
  this.pick(e);
8538
9043
  return false;
@@ -8549,6 +9054,34 @@ var PaintGesture = class {
8549
9054
  return false;
8550
9055
  }
8551
9056
  }
9057
+ /**
9058
+ * The Select stroke tool, on a stroke layer: pressing a stroke picks it and dragging moves it
9059
+ * (one undo step at the drop); pressing where no stroke painted clears the pick.
9060
+ */
9061
+ strokeDown(e) {
9062
+ const p = this.doc(e);
9063
+ const s = this.controller.getState();
9064
+ const layerId = s.document.activeLayerId;
9065
+ if (!layerId || !s.document.liveStrokes?.[layerId]) {
9066
+ this.error = "Pick a stroke layer in the Layers panel (or add one) to select its strokes.";
9067
+ return false;
9068
+ }
9069
+ try {
9070
+ const hit = this.controller.liveStrokeAt(layerId, p.x, p.y);
9071
+ this.controller.selectStroke(hit?.id ?? null);
9072
+ if (!hit) return false;
9073
+ this.mode = { kind: "move-stroke", pointerId: e.pointerId, id: hit.id, from: p, to: p };
9074
+ return true;
9075
+ } catch (err) {
9076
+ this.error = err instanceof Error ? err.message : String(err);
9077
+ return false;
9078
+ }
9079
+ }
9080
+ /** While a stroke is dragged: how far it has moved (document pixels), to draw its outline there. */
9081
+ get strokeDrag() {
9082
+ const m = this.mode;
9083
+ return m?.kind === "move-stroke" ? { id: m.id, dx: m.to.x - m.from.x, dy: m.to.y - m.from.y } : null;
9084
+ }
8552
9085
  /**
8553
9086
  * The Stickers tool: pressing a sticker picks it and dragging moves it (one undo step at the
8554
9087
  * drop); pressing anywhere else places the active brush's first stamp there, at its tip size and
@@ -8594,6 +9127,10 @@ var PaintGesture = class {
8594
9127
  this.controller.setView({ rotation: r });
8595
9128
  return;
8596
9129
  }
9130
+ if (m.kind === "move-stroke") {
9131
+ m.to = this.doc(e);
9132
+ return;
9133
+ }
8597
9134
  if (m.kind === "sticker") {
8598
9135
  const p = this.doc(e);
8599
9136
  m.at = { x: round2(p.x + m.dx), y: round2(p.y + m.dy) };
@@ -8618,6 +9155,17 @@ var PaintGesture = class {
8618
9155
  this.mode = null;
8619
9156
  this.predicted = [];
8620
9157
  if (m.kind === "stroke") this.controller.activeStroke?.end();
9158
+ if (m.kind === "move-stroke") {
9159
+ const dx = Math.round((this.doc(e).x - m.from.x) * 100) / 100;
9160
+ const dy = Math.round((this.doc(e).y - m.from.y) * 100) / 100;
9161
+ if (dx || dy) {
9162
+ try {
9163
+ this.controller.editStrokePoints(m.id, { transform: { dx, dy } });
9164
+ } catch (err) {
9165
+ this.error = err instanceof Error ? err.message : String(err);
9166
+ }
9167
+ }
9168
+ }
8621
9169
  if (m.kind === "sticker" && m.at) {
8622
9170
  try {
8623
9171
  this.controller.updateSticker(void 0, m.id, m.at);
@@ -8726,11 +9274,24 @@ var PaintCanvasPresenter = class {
8726
9274
  return drawn;
8727
9275
  }
8728
9276
  };
8729
- function guideOverlay(controller) {
9277
+ function guideOverlay(controller, drag) {
8730
9278
  const s = controller.getState();
8731
9279
  const size = s.document.canvas;
8732
9280
  const out = [];
8733
9281
  const layerId = s.document.activeLayerId;
9282
+ const strokePicked = layerId && s.selectedStroke ? s.document.liveStrokes?.[layerId]?.some((x) => x.id === s.selectedStroke) : false;
9283
+ const area = strokePicked ? controller.liveStrokeArea(s.selectedStroke) : null;
9284
+ if (area) {
9285
+ const dx = drag && drag.id === s.selectedStroke ? drag.dx : 0;
9286
+ const dy = drag && drag.id === s.selectedStroke ? drag.dy : 0;
9287
+ const box = [
9288
+ { x: area.x + dx, y: area.y + dy },
9289
+ { x: area.x + area.width + dx, y: area.y + dy },
9290
+ { x: area.x + area.width + dx, y: area.y + area.height + dy },
9291
+ { x: area.x + dx, y: area.y + area.height + dy }
9292
+ ];
9293
+ for (let i = 0; i < 4; i++) out.push({ from: box[i], to: box[(i + 1) % 4], kind: "stroke" });
9294
+ }
8734
9295
  const picked = layerId && s.selectedSticker ? s.document.stickers?.[layerId]?.find((x) => x.id === s.selectedSticker) : void 0;
8735
9296
  if (picked) {
8736
9297
  const r = picked.size / 2 + 2;
@@ -9060,6 +9621,11 @@ function toggleStamp(stamps, stamp) {
9060
9621
  const next = stamps.includes(stamp) ? stamps.filter((s) => s !== stamp) : [...stamps, stamp];
9061
9622
  return next.length ? next : [stamp];
9062
9623
  }
9624
+ var STROKE_SLIDERS = [
9625
+ { path: "tip.size", label: "Size", min: 1, max: 400, step: 1 },
9626
+ { path: "tip.opacity", label: "Opacity", min: 0, max: 1, step: 0.01 },
9627
+ { path: "tip.flow", label: "Flow", min: 0, max: 1, step: 0.01 }
9628
+ ];
9063
9629
  var STICKER_SLIDERS = [
9064
9630
  { path: "size", label: "Size", min: 2, max: 400, step: 1 },
9065
9631
  { path: "rotation", label: "Rotation", min: -180, max: 180, step: 1 },
@@ -9357,6 +9923,7 @@ export {
9357
9923
  IMPASTO_TOOLS,
9358
9924
  IMPASTO_TOOL_LABELS,
9359
9925
  LIGHT_SLIDERS,
9926
+ LIVE_STROKE_ENGINES,
9360
9927
  LitCache,
9361
9928
  MAX_BRUSH_SIZE,
9362
9929
  MAX_CANVAS_SIDE,
@@ -9425,6 +9992,7 @@ export {
9425
9992
  STENCIL_KINDS,
9426
9993
  STENCIL_SHAPES,
9427
9994
  STICKER_SLIDERS,
9995
+ STROKE_SLIDERS,
9428
9996
  SYMMETRY_MODES,
9429
9997
  StencilSnapper,
9430
9998
  StickerSpray,
@@ -9505,6 +10073,8 @@ export {
9505
10073
  isWetCapable,
9506
10074
  lightTile,
9507
10075
  lightingKey,
10076
+ liveStrokeBounds,
10077
+ liveStrokeRefusal,
9508
10078
  mapPenPoint,
9509
10079
  mix32,
9510
10080
  mixPixels,