# Path editing

> Edit bezier paths in PixiJS: drag anchors and handles, add and delete points, draw new paths with a pen tool, and undo every edit as one step.

Source: https://pixi-silk.schmooky.dev/docs/path-editing/

> **NOTE**
> Experimental in 1.0: the API may change in a minor release.

Demo sources run in the docs demo runner, `defineDemo({ setup({ stage, params, tick }) })`. In your own app, `stage` is `app.stage` (or a container on it), `params` holds the values of the demo controls, and `tick(fn)` is `app.ticker.add`, with `t` and `dt` in seconds.

Live demo `path-editing` (source):

```ts
import { Container, Text } from 'pixi.js';
import { linear, SilkGraphics } from 'pixi-silk';
import { bindHistoryKeys, History } from 'pixi-silk/history';
import { type BezierPath, drawPath, parseSvgPath, pathBounds } from 'pixi-silk/path';
import { watch } from 'pixi-silk/signal';
import { SilkButton } from 'pixi-silk/ui/button';
import { Camera } from 'pixi-silk/ui/camera';
import { SegmentedSwitch } from 'pixi-silk/ui/modes';
import { PathGizmo } from 'pixi-silk/ui/pathgizmo';
import { defineDemo, FONT, INK } from './runtime';

// Drag anchors and handles. Click the outline to add a point, double-click a point to switch it
// between corner and smooth, Alt + drag a handle to break the curve. The pen adds points: click
// for corners, drag for curves, click the first point to close. Cmd or ctrl + Z undoes.
export default defineDemo({
    title: 'Path editing',
    size: [720, 480],
    touchAction: 'none',
    ownsCamera: true,
    controls: {
        snap: { type: 'toggle', label: 'snap to 10', value: false },
    },
    setup({ silk, params }) {
        const app = silk.app;
        const world = new Container();
        const overlay = new Container();
        const camera = new Camera({ world, element: app.canvas });
        const history = new History();
        const shape = new SilkGraphics();
        const [heart] = parseSvgPath(
            'M0 -60 C 0 -140 -140 -140 -140 -40 C -140 60 0 120 0 180 C 0 120 140 60 140 -40 C 140 -140 0 -140 0 -60 Z',
        );
        const gizmo = new PathGizmo({ path: heart, space: world, history, ticker: app.ticker });
        const paint = linear([0xff375f, 0xbf5af2], { from: [0, 0], to: [1, 1] });

        world.addChild(shape);
        overlay.addChild(gizmo);
        app.stage.addChild(world, overlay);
        gizmo.snap = (p) => (params.snap ? { x: Math.round(p.x / 10) * 10, y: Math.round(p.y / 10) * 10 } : p);

        // the shape is drawn from the path: one exact fill, then the stroke
        const draw = (path: BezierPath) => {
            shape.clear();
            if (path.anchors.length < 2) return;
            if (path.closed) drawPath(shape, path).fill(paint);
            drawPath(shape, path).stroke({ width: 2, color: 0xf2f2f7, alpha: path.closed ? 0.35 : 0.9 });
        };
        const stops = [watch(gizmo.state.path, draw)];

        // a toolbar: tools, undo, redo and a fresh path for the pen
        const dark = { fill: 0x2c2c2e, fillHover: 0x3a3a3c, fillPress: 0x1c1c1e, fillDisabled: 0x232325 };
        const tools = new SegmentedSwitch({
            ticker: app.ticker,
            value: 'edit',
            segmentWidth: 52,
            height: 32,
            options: [
                { id: 'edit', label: 'Edit' },
                { id: 'pen', label: 'Pen' },
            ],
        });
        const button = (label: string, onPress: () => void) =>
            new SilkButton({ ticker: app.ticker, label, width: 60, height: 32, theme: dark, onPress });
        const undo = button('Undo', () => history.undo());
        const redo = button('Redo', () => history.redo());
        const fresh = button('New', () => {
            gizmo.edit('New path', () => ({ anchors: [], closed: false }), []);
            gizmo.state.tool.set('pen');
        });
        const hint = new Text({
            text: '',
            style: { fontFamily: FONT, fontSize: 12, fill: INK.muted },
            resolution: 4,
        });

        overlay.addChild(tools, undo, redo, fresh, hint);
        // one row from the left edge, 318 px wide in all: it fits a phone
        tools.position.set(68, 32);
        undo.position.set(156, 32);
        redo.position.set(222, 32);
        fresh.position.set(288, 32);
        const placeHint = () => hint.position.set(16, app.screen.height - 28);

        placeHint();
        app.renderer.on('resize', placeHint);
        stops.push(
            // the switch and the gizmo share one tool
            watch(tools.state.value, (v) => gizmo.state.tool.set(v === 'pen' ? 'pen' : 'edit')),
            watch(gizmo.state.tool, (v) => tools.state.value.set(v)),
            watch(history.state.canUndo, (v) => undo.state.enabled.set(v)),
            watch(history.state.canRedo, (v) => redo.state.enabled.set(v)),
            watch(history.state.undoLabel, (label) => {
                hint.text = label
                    ? `Undo ${label.toLowerCase()} with cmd or ctrl + Z`
                    : 'Edit the heart, or press New to draw';
            }),
            bindHistoryKeys(history),
        );

        const b = pathBounds(heart);

        camera.fit(b, { left: 48, right: 48, top: 72, bottom: 48 }, false);

        return () => {
            for (const stop of stops) stop();
            app.renderer.off('resize', placeHint);
            camera.destroy();
        };
    },
});
```

`pixi-silk/path` holds bezier paths as plain data and the helpers that edit them.
`pixi-silk/ui/pathgizmo` edits a path on screen with anchors, handles and a pen.

## Describe a path

A path is a list of anchors and a `closed` flag. Each anchor has a position, optional `in` and
`out` handles (offsets from the anchor) and a type.

```ts

const drop: BezierPath = {
    closed: true,
    anchors: [
        { x: 0, y: -80, type: 'corner' },
        { x: 50, y: 20, in: { x: 0, y: -40 }, out: { x: 0, y: 40 }, type: 'smooth' },
        { x: -50, y: 20, in: { x: 0, y: 40 }, out: { x: 0, y: -40 }, type: 'smooth' },
    ],
};
```

| Type | Handles |
|---|---|
| `corner` | Each handle moves on its own. With no handles the anchor is a sharp corner. |
| `smooth` | The two handles stay on one line, each with its own length. |
| `mirrored` | The two handles stay on one line and have the same length. |

The path fits in JSON, in a signal and in an undo step as it is.

## Draw it

```ts

declare const drop: BezierPath;

const g = new SilkGraphics();

drawPath(g, drop).fill(linear([0x64d2ff, 0x0a84ff])).stroke({ width: 2, color: 0xffffff });
```

`drawPath(g, path)` calls `moveTo`, `bezierCurveTo` and `closePath` on the graphics and returns
it, so `fill()` and `stroke()` chain on. A free path fills even-odd. See
[Lines and paths](/docs/lines-and-paths/).

## Edit it from code

The helpers never change their input. Each one returns a new path.

| Helper | What it does |
|---|---|
| `insertAnchor(path, segment, t)` | Splits a segment at `t` with de Casteljau's construction. The curve keeps its shape. |
| `removeAnchors`, `moveAnchors` | Delete or move a set of anchors. |
| `setHandle` | Moves one handle and keeps the twin in line on smooth and mirrored anchors. |
| `setAnchorType`, `toggleAnchorType` | Switch between corners and curves. A corner with no handles grows them from its neighbours. |
| `nearestOnPath`, `pathBounds`, `anchorsInRect` | Measure and hit-test. |
| `parseSvgPath`, `pathToSvg` | Read SVG path data, arcs and quadratics included, and write it back. |
| `mapPath` | Moves a path through an affine map. |

## Edit it on screen

```ts

declare const world: Container, overlay: Container, drop: BezierPath;

const history = new History();
const gizmo = new PathGizmo({ path: drop, space: world, history });
const shape = new SilkGraphics();

world.addChild(shape);
overlay.addChild(gizmo);
watch(gizmo.state.path, (path) => drawPath(shape.clear(), path).fill(0x0a84ff));
```

The gizmo does not draw the shape. It edits `state.path`, and the `watch` redraws the shape from
it. Put the gizmo in an overlay and set `space` to the container the path lives in, usually the
camera's world. Anchors and handles then keep their size on screen at every zoom.

These are the gestures:

- Click an anchor to select it, Shift + click to add more, and drag to move them all.
- Drag a handle to bend the curve. Alt + drag breaks the symmetry.
- Double-click an anchor to switch between a corner and a smooth point.
- Click a segment to add an anchor there.
- Delete or Backspace removes the selected anchors. Arrow keys nudge them, Shift by 10.

| Option | Default | What it does |
|---|---|---|
| `space` | the gizmo's parent | The container the path's coordinates are in. |
| `tool` | `'edit'` | `'pen'` starts with the pen. |
| `history` | none | Records every edit as one undo step. |
| `snap` | none | Moves a dragged point, for a grid or smart guides. |
| `keys` | `true` | Delete, Backspace, Escape, Enter and the arrow keys. |
| `onChange` | none | Called on every change, drags included. |
| `onCommit` | none | Called once per finished edit, with its label. |

`selectInRect(rect)` selects the anchors inside a rectangle, the hook for a marquee. `setClosed()`
closes or opens the path.

## Draw with the pen

Set `state.tool` to `'pen'`. Click for a corner, or drag for a curve with mirrored handles. Alt
during a drag moves only the outgoing handle. Click the last anchor to drop its outgoing handle,
and click the first one to close the path. Escape or Enter stops drawing.

`PenTool` is the pen's state machine with no pointer events and no drawing. Feed it presses,
moves and releases to build paths from any input, such as a recorded gesture or a test.

## Undo

With a `history`, a whole drag, an inserted point, a pen click and a run of nudges are one step
each. The gizmo needs only the `HistoryLike` interface, so your own store works too. See
[Gizmos and selection](/docs/gizmos-and-selection/#undo-and-redo).
