# Shape operations

> Union, subtract, intersect and exclude shapes and SVG paths in PixiJS with fill({ op }) and boolean(). Results take strokes, gradients, blur and hit tests.

Source: https://pixi-silk.schmooky.dev/docs/shape-operations/

Shape operations combine several shapes into one: cut a hole in a card, make a ring from two
circles or build an icon from a few primitives.

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 `shape-ops` (source):

```ts
import { conic, type ShapeOp, SilkGraphics } from 'pixi-silk';
import { defineDemo, INK, PALETTE } from './runtime';

// Shape operations: one exact shape from many. Each result is one instance, painted once.
export default defineDemo({
    title: 'Shape operations',
    size: [720, 300],
    controls: {
        op: {
            type: 'select',
            label: 'op',
            options: ['union', 'subtract', 'intersect', 'exclude'],
            value: 'subtract',
        },
        stroke: { type: 'toggle', label: 'stroke', value: true },
        blur: { type: 'range', label: 'blur', min: 0, max: 12, step: 1, value: 0, unit: 'px' },
    },
    setup({ stage, params, tick }) {
        const g = new SilkGraphics();
        const ringPaint = conic([PALETTE.green, PALETTE.yellow, PALETTE.green]);

        stage.addChild(g);
        tick((t) => {
            const op = params.op as ShapeOp;
            const outline = params.stroke ? { width: 3, color: INK.text, alpha: 0.9 } : null;
            const dx = Math.sin(t * 0.8) * 14;

            g.clear();
            // top row: three shapes centred on x 180, 360 and 540
            g.save().translateTransform(80, 0);
            // two circles combined by the picked op, translucent so double painting would show
            g.circle(76, 110, 52)
                .circle(124 + dx, 110, 52)
                .fill({ color: PALETTE.blue, alpha: 0.75, op, blur: params.blur });
            if (outline) g.stroke({ ...outline, op });
            g.restore().save().translateTransform(110, 0);

            // a ring with a notch: a circle minus a smaller circle and a bar
            g.boolean(
                'subtract',
                (g) => g.circle(250, 110, 56),
                (g) => g.circle(250, 110, 34).rect(244, 40, 12, 40),
            ).fill({ gradient: ringPaint, blur: params.blur });
            if (outline) g.stroke(outline);
            g.restore().save().translateTransform(144, 0);

            // an icon: a rounded card minus a cutout keyhole, plus a dot
            g.boolean(
                'union',
                (g) =>
                    g.boolean(
                        'subtract',
                        (g) => g.roundRect(340, 54, 112, 112, 30, 0.6),
                        (g) => g.circle(396, 98, 20).triangle(386, 104, 406, 104, 396, 140, 3),
                    ),
                (g) => g.circle(452, 54, 14),
            ).fill({
                color: PALETTE.pink,
                blur: params.blur,
                innerShadow: { y: 3, blur: 4, color: 0xffffff, alpha: 0.5 },
            });
            if (outline) g.stroke(outline);
            g.restore();

            // bottom row: an SVG path with a star cut out of it, and a path with a hole
            g.save().translateTransform(190, -12);
            g.svgPath('M60 250 C 60 200 140 200 140 250 C 140 280 60 280 60 250 Z')
                .star(100, 240, 5, 18, 8)
                .fill({ color: PALETTE.purple, op: 'subtract', blur: params.blur });
            g.restore().save().translateTransform(220, -12);
            g.svgPath('M180 270 l30 -60 l30 60 z m30 -42 l-12 30 h24 z').fill({
                color: PALETTE.orange,
                blur: params.blur,
            });
            if (outline) g.stroke(outline);
            g.restore();
        });

        return undefined;
    },
});
```

```ts
g.circle(60, 60, 50).circle(60, 60, 30).fill({ color: 0x30d158, op: 'subtract' });
```

That draws a ring: the first circle minus the second. The result is painted once, so a
translucent fill has no darker overlaps.

## Combine the shapes of a path

Add `op` to `fill()` or `stroke()`. It combines every closed shape on the active path.

```ts
g.rect(0, 0, 80, 80).circle(80, 80, 40).fill({ color: 0x0a84ff, op: 'intersect' }); // a quarter disc
g.circle(40, 40, 30).circle(70, 40, 30).fill({ color: 0xff375f, op: 'exclude' });   // two lenses
```

| `op` | Result |
| --- | --- |
| `'union'` | Everything any shape covers. The same as `union: true`. |
| `'subtract'` | The first shape minus every later one |
| `'intersect'` | Only what every shape covers |
| `'exclude'` | What an odd number of shapes cover |

A `stroke()` right after the fill follows the outline of the result. A stroke with its own `op`
strokes the outline without a fill.

```ts
g.roundRect(0, 0, 120, 80, 16)
    .circle(60, 40, 24)
    .fill({ color: 0x1c1c1e, op: 'subtract' })
    .stroke({ width: 2, color: 0xffffff });
```

Open shapes take no part. Arcs and areas in the path stroke on their own.

## Nest operations with boolean()

`boolean(op, a, b)` adds one shape to the active path: what callback `a` draws, combined by `op`
with what callback `b` draws. The shapes inside each callback are merged first. A callback can call
`boolean()` again, so any tree of operations works.

```ts
// a ring with a notch, and a dot in the middle
g.boolean(
    'union',
    (g) =>
        g.boolean(
            'subtract',
            (g) => g.circle(50, 50, 40),
            (g) => g.circle(50, 50, 24).rect(46, 0, 8, 30),
        ),
    (g) => g.circle(50, 50, 8),
).fill(0xff9f0a);
```

The callbacks only add shapes. Do not call `fill()` or `stroke()` inside them. Up to 8 operands
can wait on each other at once. A deeper tree is skipped with a warning.

## Cut with SVG paths

```ts
g.svgPath('M12 2 L22 22 H2 Z').fill(0xffd60a);
g.svgPath('M4 4 H20 V20 H4 Z').circle(12, 12, 4).fill({ color: 0xffffff, op: 'subtract' });
```

`svgPath(d)` reads the `d` attribute of an SVG path into the active path. It supports `M`, `L`,
`H`, `V`, `C`, `S`, `Q`, `T`, `A` and `Z`, absolute and relative. A fill draws it as one even-odd
shape, so the inner square of `M0 0 H60 V60 H0 Z M20 20 V40 H40 V20 Z` is a hole.

In a shape operation, a path from `svgPath()` or `moveTo()` is one operand with all its subpaths.
With `'union'`, each subpath counts on its own.

## Paint, effects and hit tests

Everything that paints a closed shape works on the result: colours, gradients, `blur`, a
progressive blur, `spread`, inner shadows and `blend: 'add'`. Gradients in shape units span the
bounds of the result.

The outline of the result is exact. Inside the shape and far outside it, the combined distance is
a close bound instead. A blur, spread or inner shadow wider than a corner can round that corner a
little more than on a plain shape. Keep blurs in proportion for crisp cutouts.

`containsPoint()` follows the result. A ring is hollow, a cutout lets clicks through and an
intersection only hits where the shapes overlap.

## Cost

A combined shape is one instance. Each pixel evaluates every shape in it once, so ten shapes in an
operation cost about what ten separate shapes cost. A path in an operation costs one step per edge.
