@pixerate/editor 0.260.0 → 0.261.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.
@@ -57,4 +57,5 @@ export type { StickerProvider, StickerRef, StickerSummary } from '../stickers/ty
57
57
  export * from './sticker-art.cjs';
58
58
  export * from './collaboration.cjs';
59
59
  export * from './collaboration-tools.cjs';
60
+ export { samplePath, MAX_PATH_SAMPLES, type PathSampleOptions } from './path-samples.cjs';
60
61
  export { paintContracts, PAINT_CONTRACT_FORMAT_VERSION, PAINT_DOCUMENT_JSON_SCHEMA } from './contract.cjs';
@@ -57,4 +57,5 @@ export type { StickerProvider, StickerRef, StickerSummary } from '../stickers/ty
57
57
  export * from './sticker-art.js';
58
58
  export * from './collaboration.js';
59
59
  export * from './collaboration-tools.js';
60
+ export { samplePath, MAX_PATH_SAMPLES, type PathSampleOptions } from './path-samples.js';
60
61
  export { paintContracts, PAINT_CONTRACT_FORMAT_VERSION, PAINT_DOCUMENT_JSON_SCHEMA } from './contract.js';
@@ -150,7 +150,11 @@ import {
150
150
  stickerIdFor
151
151
  } from "../chunk-APX63ILU.js";
152
152
  import "../chunk-M6DNZNVY.js";
153
- import "../chunk-WEIK3UYB.js";
153
+ import {
154
+ bezierPathToSvg,
155
+ flattenChain,
156
+ pathToContours
157
+ } from "../chunk-WEIK3UYB.js";
154
158
  import "../chunk-PWBZH3FK.js";
155
159
  import {
156
160
  brushColourHex
@@ -5221,6 +5225,55 @@ function engineChanges(base, next) {
5221
5225
  return rest;
5222
5226
  }
5223
5227
 
5228
+ // src/paint-editor/path-samples.ts
5229
+ var FLATTEN_TOLERANCE = 0.1;
5230
+ var MAX_PATH_SAMPLES = 2e4;
5231
+ function profileAt(profile, t) {
5232
+ if (profile.length === 1) return profile[0];
5233
+ const at = t * (profile.length - 1);
5234
+ const i = Math.min(profile.length - 2, Math.floor(at));
5235
+ return profile[i] + (profile[i + 1] - profile[i]) * (at - i);
5236
+ }
5237
+ function samplePath(d, options = {}) {
5238
+ const spacing = options.spacing ?? 2;
5239
+ if (!(spacing >= 0.25 && spacing <= 100)) throw new Error(`spacing ${spacing} is out of range; use 0.25..100 document pixels.`);
5240
+ const profile = options.pressure;
5241
+ if (profile && (!profile.length || profile.some((p) => !(p >= 0 && p <= 1)))) throw new Error("pressure needs one or more values from 0 to 1, e.g. [0.2, 1, 0.2] for a taper.");
5242
+ let contours;
5243
+ try {
5244
+ contours = pathToContours(d);
5245
+ } catch (e) {
5246
+ throw new Error(`path is not SVG path data (${e instanceof Error ? e.message : String(e)}); give e.g. "M 10 10 C 40 0 80 60 120 30".`);
5247
+ }
5248
+ const out = [];
5249
+ for (const contour of contours) {
5250
+ if (!contour.segments.length) continue;
5251
+ const line = flattenChain(contour.segments, FLATTEN_TOLERANCE);
5252
+ if (contour.closed && line.length) line.push(line[0]);
5253
+ const lengths = [0];
5254
+ for (let i = 1; i < line.length; i++) lengths.push(lengths[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]));
5255
+ const total = lengths[lengths.length - 1];
5256
+ const n = Math.max(1, Math.ceil(total / spacing));
5257
+ if (n + 1 > MAX_PATH_SAMPLES) throw new Error(`The path is ${Math.round(total)} px long: too many samples at spacing ${spacing}; use a larger spacing (at least ${Math.ceil(total / (MAX_PATH_SAMPLES - 1))}).`);
5258
+ const points = [];
5259
+ let j = 0;
5260
+ for (let k = 0; k <= n; k++) {
5261
+ const at = total * k / n;
5262
+ while (j < line.length - 2 && lengths[j + 1] < at) j++;
5263
+ const span = lengths[j + 1] - lengths[j] || 1;
5264
+ const f = line.length > 1 ? Math.min(1, Math.max(0, (at - lengths[j]) / span)) : 0;
5265
+ const a = line[j];
5266
+ const b = line[Math.min(j + 1, line.length - 1)];
5267
+ const x = Math.round((a[0] + (b[0] - a[0]) * f) * 1e3) / 1e3;
5268
+ const y = Math.round((a[1] + (b[1] - a[1]) * f) * 1e3) / 1e3;
5269
+ points.push(profile ? { x, y, pressure: Math.round(profileAt(profile, k / n) * 1e3) / 1e3 } : { x, y });
5270
+ }
5271
+ out.push(points);
5272
+ }
5273
+ if (!out.length) throw new Error('path has no segments to brush; give SVG path data such as "M 10 10 L 200 80".');
5274
+ return out;
5275
+ }
5276
+
5224
5277
  // src/paint-editor/live-strokes.ts
5225
5278
  var LIVE_STROKE_ENGINES = ["dab", "particle", "bristle", "mixer"];
5226
5279
  function liveStrokeRefusal(brush) {
@@ -7857,6 +7910,47 @@ var PaintEditorController = class _PaintEditorController {
7857
7910
  else this.emit([]);
7858
7911
  return result;
7859
7912
  }
7913
+ /**
7914
+ * Brushes vector path data (SVG `d`, document pixels) with the active brush, or `brushId` /
7915
+ * `brush`, as if drawn by hand: one recorded stroke per subpath, sampled every `spacing` px
7916
+ * with an optional `pressure` profile along each. All of it is one undo step. A given `seed`
7917
+ * seeds the first subpath; the next ones take the following seeds.
7918
+ */
7919
+ strokePath(init) {
7920
+ this.requireIdle("painting");
7921
+ if (typeof init.path !== "string" || !init.path.trim()) throw new PaintEditorError('path is empty; give SVG path data in document pixels, e.g. "M 10 10 C 40 0 80 60 120 30".');
7922
+ let subpaths;
7923
+ try {
7924
+ subpaths = samplePath(init.path, init);
7925
+ } catch (e) {
7926
+ throw new PaintEditorError(e instanceof Error ? e.message : String(e));
7927
+ }
7928
+ const { path: _path, spacing: _spacing, pressure: _pressure, ...stroke } = init;
7929
+ const before = this.doc;
7930
+ let doc = this.doc;
7931
+ let bounds = null;
7932
+ let dabs = 0;
7933
+ const strokeIds = [];
7934
+ let last = null;
7935
+ try {
7936
+ subpaths.forEach((points, i) => {
7937
+ last = this.runStroke(doc, { ...stroke, ...stroke.seed !== void 0 ? { seed: stroke.seed + i } : {}, start: points[0] }, points);
7938
+ doc = last.doc;
7939
+ bounds = unionRect(bounds, last.result.bounds);
7940
+ dabs += last.result.dabs;
7941
+ if (last.result.strokeId) strokeIds.push(last.result.strokeId);
7942
+ });
7943
+ } catch (e) {
7944
+ this.wetOp = null;
7945
+ throw e;
7946
+ }
7947
+ const run = last;
7948
+ if (run?.result.mixerLoad) this.keepLoad(run.brush, run.result.mixerLoad);
7949
+ if (doc !== before || this.wetOp) this.commit(`${run?.brush.name ?? "Brush"} path stroke`, this.withWetEvents(doc), before);
7950
+ else this.emit([]);
7951
+ const layerId = run?.result.layerId ?? stroke.layerId ?? this.doc.activeLayerId ?? "";
7952
+ return { layerId, bounds, dabs, strokeIds, ...run?.result.mixerLoad ? { mixerLoad: run.result.mixerLoad } : {}, ...run?.result.wet !== void 0 ? { wet: run.result.wet } : {} };
7953
+ }
7860
7954
  /** Checks stroke samples (finite x and y, pressure 0..1) and returns them; throws a sentence naming the bad point. */
7861
7955
  checkPoints(points) {
7862
7956
  if (!Array.isArray(points) || !points.length) throw new PaintEditorError("points is empty; give at least one {x, y} in document pixels.");
@@ -11579,6 +11673,41 @@ function createPaintEditorToolProvider(controller, options = {}) {
11579
11673
  return r.mixerLoad ? { ...r, mixerLoad: loadResult(r.mixerLoad) } : r;
11580
11674
  }
11581
11675
  });
11676
+ add({
11677
+ name: "stroke_path",
11678
+ title: "Brush along a path",
11679
+ description: "Brushes vector path data (SVG path d in document pixels: M, L, H, V, C, S, Q, T, A and Z) on a pixel layer with a library brush or the active one, as if drawn by hand along it: the outline of a shape, a lettering guide, a curve from a vector tool. Each subpath becomes one recorded stroke, sampled every spacing px, with an optional pressure profile spread along each subpath (a taper such as [0.2, 1, 0.2]); closed subpaths end where they start. Brushes behave as in paint_stroke (grain, pick-up, wet and thick paint, guides off). One undo step for the whole path. Returns the layer id, changed bounds, dab count and the recorded strokeIds (for replay_strokes, update_stroke).",
11680
+ inputSchema: {
11681
+ type: "object",
11682
+ properties: {
11683
+ path: { type: "string", minLength: 1, description: 'SVG path data in document pixels, e.g. "M 40 200 C 120 40 280 40 360 200". Several subpaths paint several strokes.' },
11684
+ spacing: { type: "number", minimum: 0.25, maximum: 100, description: "Distance between samples along the path in document pixels (default 2); the brush spaces its own dabs, so this only sets how closely curves are followed." },
11685
+ pressure: { type: "array", minItems: 1, maxItems: 64, items: { type: "number", minimum: 0, maximum: 1 }, description: "Pen pressure along each subpath, spread evenly from start to end (default full pressure); [0.2, 1, 0.2] tapers both ends." },
11686
+ layerId: LAYER_ID4,
11687
+ brushId: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." },
11688
+ brush: { type: "object", description: "Facets to change for this stroke only, laid over brushId or the active brush (as set_brush takes them).", properties: BRUSH_PATCH_PROPERTIES, additionalProperties: false },
11689
+ color: { ...COLOR3, description: "Paint colour #rrggbb (default the paint colour)." },
11690
+ paint: { ...PAINT_SOURCE_JSON_SCHEMA, description: "What the strokes paint with instead of the editor's paint: a colour, or gradient paint that changes colour as each stroke goes." },
11691
+ seed: { type: "integer", minimum: 0, description: "Jitter seed of the first subpath, the next ones take the following seeds (default new ones); the same seed and path give the same marks." }
11692
+ },
11693
+ required: ["path"],
11694
+ additionalProperties: false
11695
+ },
11696
+ annotations: { title: "Brush along a path", ...WRITE5 },
11697
+ coordinates: { out: ["bounds"] },
11698
+ handler: async (a) => {
11699
+ await c.wetIdle();
11700
+ const base = a.brushId ? c.getBrush(a.brushId) : c.getState().brush;
11701
+ const brush = a.brush ? c.brushChanges(a.brush, a.brushId) : void 0;
11702
+ if (base) {
11703
+ const refs = brushStickerRefs(normalizePaintBrush({ ...brush ?? {}, id: base.id }, base));
11704
+ if (refs.length) await c.prepareStickers(refs);
11705
+ }
11706
+ const path = options.origin && typeof a.path === "string" && a.path.trim() ? bezierPathToSvg(a.path, { offset: { x: -options.origin.x, y: -options.origin.y } }) : a.path;
11707
+ const r = c.strokePath({ path, spacing: a.spacing, pressure: a.pressure, layerId: a.layerId, brushId: a.brushId, brush, color: a.color, paint: a.paint, seed: a.seed });
11708
+ return r.mixerLoad ? { ...r, mixerLoad: loadResult(r.mixerLoad) } : r;
11709
+ }
11710
+ });
11582
11711
  add({
11583
11712
  name: "get_live_stroke",
11584
11713
  title: "Get the stroke being painted",
@@ -12480,6 +12609,12 @@ function toggleDotTexture(textures, stamp) {
12480
12609
  if (textures.includes(stamp)) return textures.filter((s) => s !== stamp);
12481
12610
  return textures.length < MAX_DOT_TEXTURES ? [...textures, stamp] : [...textures];
12482
12611
  }
12612
+ var PATH_STROKE_TAPERS = [
12613
+ { id: "even", label: "Even" },
12614
+ { id: "both", label: "Taper both ends", pressure: [0.2, 1, 0.2] },
12615
+ { id: "in", label: "Fade in", pressure: [0.2, 1] },
12616
+ { id: "out", label: "Fade out", pressure: [1, 0.2] }
12617
+ ];
12483
12618
  var STROKE_SLIDERS = [
12484
12619
  { path: "tip.size", label: "Size", min: 1, max: 400, step: 1 },
12485
12620
  { path: "tip.opacity", label: "Opacity", min: 0, max: 1, step: 0.01 },
@@ -13270,6 +13405,7 @@ function uiTables() {
13270
13405
  blendMenu: BLEND_MENU_GROUPS,
13271
13406
  layerEffects: LAYER_EFFECT_MENU.map(({ kind, label }) => ({ kind, label, controls: layerEffectControls(defaultLayerEffect(kind, kind)) })),
13272
13407
  sliders: { paper: PAPER_SLIDERS, wet: WET_PARAM_SLIDERS, wetTilt: WET_TILT_SLIDERS, wetSpeed: WET_SPEED_SLIDER, light: LIGHT_SLIDERS, stroke: STROKE_SLIDERS, sticker: STICKER_SLIDERS },
13408
+ pathStrokeTapers: PATH_STROKE_TAPERS,
13273
13409
  padSampleSizes: PAD_SAMPLE_SIZES,
13274
13410
  wetGpu: WET_GPU_OPTIONS,
13275
13411
  labels: {
@@ -13364,6 +13500,7 @@ export {
13364
13500
  MAX_DOT_TEXTURES,
13365
13501
  MAX_IMAGE_SIDE,
13366
13502
  MAX_LIVE_EFFECT_SOURCES,
13503
+ MAX_PATH_SAMPLES,
13367
13504
  MAX_STICKER_STAMPS,
13368
13505
  MAX_ZOOM,
13369
13506
  MIN_CANVAS_SIDE,
@@ -13407,6 +13544,7 @@ export {
13407
13544
  PARTICLE_KIND_LABELS,
13408
13545
  PARTICLE_RENDERS,
13409
13546
  PARTICLE_RENDER_LABELS,
13547
+ PATH_STROKE_TAPERS,
13410
13548
  PaintCanvasPresenter,
13411
13549
  PaintDabStroke,
13412
13550
  PaintDirector,
@@ -13617,6 +13755,7 @@ export {
13617
13755
  sampleArt,
13618
13756
  sampleEffectAt,
13619
13757
  sampleEffectField,
13758
+ samplePath,
13620
13759
  samplePressure,
13621
13760
  scalePaintGradient,
13622
13761
  scaleStencil,
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Pen samples along vector path data, so a path can be brushed like a hand-drawn stroke
3
+ * (`strokePath`, the `stroke_path` tool): each subpath becomes one stroke, sampled at an even
4
+ * spacing, with an optional pressure profile spread along its length (a taper).
5
+ */
6
+ import type { PenPoint } from './engine.cjs';
7
+ /** The most samples one subpath may have (the stroke tools' point limit). */
8
+ export declare const MAX_PATH_SAMPLES = 20000;
9
+ export interface PathSampleOptions {
10
+ /** Distance between samples in document pixels (default 2). */
11
+ spacing?: number;
12
+ /** Pressure along each subpath, spread evenly from its start to its end (default full pressure). */
13
+ pressure?: readonly number[];
14
+ }
15
+ /**
16
+ * Samples SVG path data (document pixels) into pen points, one array per subpath. Closed
17
+ * subpaths end where they start. Throws a sentence when the path has nothing to brush.
18
+ */
19
+ export declare function samplePath(d: string, options?: PathSampleOptions): PenPoint[][];
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Pen samples along vector path data, so a path can be brushed like a hand-drawn stroke
3
+ * (`strokePath`, the `stroke_path` tool): each subpath becomes one stroke, sampled at an even
4
+ * spacing, with an optional pressure profile spread along its length (a taper).
5
+ */
6
+ import type { PenPoint } from './engine.js';
7
+ /** The most samples one subpath may have (the stroke tools' point limit). */
8
+ export declare const MAX_PATH_SAMPLES = 20000;
9
+ export interface PathSampleOptions {
10
+ /** Distance between samples in document pixels (default 2). */
11
+ spacing?: number;
12
+ /** Pressure along each subpath, spread evenly from its start to its end (default full pressure). */
13
+ pressure?: readonly number[];
14
+ }
15
+ /**
16
+ * Samples SVG path data (document pixels) into pen points, one array per subpath. Closed
17
+ * subpaths end where they start. Throws a sentence when the path has nothing to brush.
18
+ */
19
+ export declare function samplePath(d: string, options?: PathSampleOptions): PenPoint[][];
@@ -250,6 +250,12 @@ export declare function toggleStamp(stamps: readonly StampKind[], stamp: StampKi
250
250
  * else adds it while there are fewer than `MAX_DOT_TEXTURES`. Empty means round dots.
251
251
  */
252
252
  export declare function toggleDotTexture(textures: readonly StampKind[], stamp: StampKind): StampKind[];
253
+ /** Pressure profiles the "Brush a path" panel offers for `strokePath` (`pressure` undefined: even). */
254
+ export declare const PATH_STROKE_TAPERS: ReadonlyArray<{
255
+ id: string;
256
+ label: string;
257
+ pressure?: readonly number[];
258
+ }>;
253
259
  /** The sticker panel's sliders for the selected sticker. */
254
260
  /** The brush sliders of a picked stroke on a stroke layer (paths into its brush; see `updateStroke`). */
255
261
  export declare const STROKE_SLIDERS: readonly SliderControl[];
@@ -250,6 +250,12 @@ export declare function toggleStamp(stamps: readonly StampKind[], stamp: StampKi
250
250
  * else adds it while there are fewer than `MAX_DOT_TEXTURES`. Empty means round dots.
251
251
  */
252
252
  export declare function toggleDotTexture(textures: readonly StampKind[], stamp: StampKind): StampKind[];
253
+ /** Pressure profiles the "Brush a path" panel offers for `strokePath` (`pressure` undefined: even). */
254
+ export declare const PATH_STROKE_TAPERS: ReadonlyArray<{
255
+ id: string;
256
+ label: string;
257
+ pressure?: readonly number[];
258
+ }>;
253
259
  /** The sticker panel's sliders for the selected sticker. */
254
260
  /** The brush sliders of a picked stroke on a stroke layer (paths into its brush; see `updateStroke`). */
255
261
  export declare const STROKE_SLIDERS: readonly SliderControl[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor",
3
- "version": "0.260.0",
3
+ "version": "0.261.0",
4
4
  "description": "UI-agnostic rich text and media editing core library",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -109,6 +109,7 @@ the pitfalls, and uses the tools below.
109
109
  - **Give points a time `t`** (milliseconds, about 8..16 ms apart for natural speed) when using an ink or brush pen: velocity dynamics thin fast strokes. Without `t` there is no velocity effect.
110
110
  - **Tilt** (`altitude` near 0.3..0.6 radians with an `azimuth`) turns a pencil into broad side shading; upright points draw a fine line.
111
111
  - **Space points** about a third of the brush size apart or closer; the brush fills in dabs between them. Long smooth curves need more points than straight lines.
112
+ - **Brush geometry with `stroke_path`**: for clean curves, circles, outlines and lettering, give SVG path data and a `pressure` taper instead of hundreds of points; the brush keeps its grain and character. Send the shape as one path with several subpaths to keep it one undo step.
112
113
  - **Use `rotation: "stroke"`** for flat tips (pastel, charcoal, chisel marker) so the mark turns with the path; `fixed` for calligraphic nibs.
113
114
  - **Blending tools**:
114
115
  - `blender`: `strength` 0.3..0.6 for soft transitions, `distance` 0.2..0.5 (fraction of the tip) for how far colours travel. Keep `mixing: "pigment"` for painterly blends; `rgb` blends like a soft blur. Work along an edge in short strokes rather than over everything.
@@ -320,6 +321,7 @@ Listing, saving, sharing and previewing brushes are the brush library's tools (`
320
321
  | Tool | Use |
321
322
  | --- | --- |
322
323
  | `paint_stroke` | Paint one stroke through `points` (document px; `pressure` 0..1, `t` ms, `tiltX` / `tiltY` or `altitude` / `azimuth`, `twist`) with `brushId` (default the active brush), optional per-stroke `brush` facet changes (as `set_brush` takes), `color` or `paint` (a gradient for this stroke), `layerId`, `seed`, `snapToPerspective`. The symmetry guide adds its copies; a sampling tracing image supplies the colour when `color` is missing. Returns `layerId`, `bounds`, `dabs`, `strokeId` and, for brushes that pick up paint, `mixerLoad` (what the brush carries now). Refused on locked, hidden or group layers, with the fix in the message. |
324
+ | `stroke_path` | Brush SVG path data (`path`, document px; M, L, C, Q, A, Z…) with the active brush or `brushId` / `brush`, as if drawn along it by hand: each subpath one recorded stroke, sampled every `spacing` px (default 2), with a `pressure` profile spread along each subpath (`[0.2, 1, 0.2]` tapers both ends). One undo step. Returns `layerId`, `bounds`, `dabs` and `strokeIds` (for `replay_strokes`, `update_stroke`). Use it for clean curves, outlines of shapes and lettering guides instead of working out points; use `paint_stroke` when the marks need time or tilt. |
323
325
  | `list_strokes` | Read the recorded strokes, oldest first (default the last 100): id, layer, brush, colour, point count, seed, bounds, `live`. With `ids`, their points too; with `layerId`, one layer's. With `live: true`, the editable strokes of stroke layers as they are now, bottom first: the ids for the stroke layer tools. |
324
326
  | `get_live_stroke` | Read the stroke the user is painting right now (layer, brush, colour, samples so far; `live: false` when none). For painting along with the user; hosts stream the samples live. |
325
327
  | `stroke_at` | Read the top-most stroke of a stroke layer that painted the pixel at `x`, `y`, or null. Use it to pick one stroke among overlapping ones. |
@@ -39,7 +39,9 @@ This skill is the method. `pixerate-paint-editor` has the full tool reference.
39
39
  reference.
40
40
  **Check:** `list_strokes` shows pencil strokes on the Sketch layer.
41
41
  3. **Ink.** `add_layer` "Ink" above it; `ink-pen` or `brush-pen`, dark colour, long strokes with
42
- points that carry `pressure` (lighter at the ends) and `t` for speed dynamics.
42
+ points that carry `pressure` (lighter at the ends) and `t` for speed dynamics. For clean
43
+ geometric lines (curves, circles, panel borders) use `stroke_path` with SVG path data and a
44
+ `pressure` taper such as `[0.3, 1, 0.3]`.
43
45
  **Check:** `pick_color` on a line is dark.
44
46
  4. **Hide the sketch.** `update_layer` the Sketch layer `visible: false` (or lower its `opacity`
45
47
  while inking, then hide it).
@@ -61,6 +63,7 @@ This skill is the method. `pixerate-paint-editor` has the full tool reference.
61
63
  | --- | --- |
62
64
  | Sketch | `pencil-hb`, light colour, own layer, hidden at the end |
63
65
  | Line art | `ink-pen` (even), `brush-pen` (swelling); own layer on top |
66
+ | Clean geometric lines (curves, circles, borders) | `stroke_path` with SVG path data and a `pressure` taper, on the Ink layer |
64
67
  | Flats | `marker` or a flat brush on a layer under the ink |
65
68
  | Patterned or textured flats (sky, gradient, galaxy) | `set_effect_paint` with a provider generator, then a flat dry brush on the Flats layer; `set_color` after |
66
69
  | Shadows | Clipped layer, `multiply` or `shadow`, a cool colour |
@@ -98,6 +101,7 @@ clipped: true }` → `set_brush` `airbrush` → paint the shadow side.
98
101
  - `set_paper`, `fill_layer`: the ground and backgrounds
99
102
  - `add_layer`, `update_layer`, `move_layer`, `remove_layer`: one layer per pass; hide, clip, blend
100
103
  - `set_brush`, `set_color`, `paint_stroke`, `replay_strokes`: sketch, ink, flats and shading
104
+ - `stroke_path`: clean geometric line art from SVG path data
101
105
  - `search_effects`, `describe_effect`, `set_effect_paint`: flats painted with a provider effect
102
106
  - `set_symmetry`, `set_perspective_guide`, `set_tracing_image`: construction guides
103
107
  - `undo_paint`: take back a step