Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/khaki-moles-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@jolly-pixel/pixel-draw.renderer": minor
---

Update API documentation and codebase comments
21 changes: 15 additions & 6 deletions packages/pixel-draw-renderer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ $ yarn add @jolly-pixel/pixel-draw.renderer
## 👀 Usage Example

```ts
import { PixelArtCanvas } from "@jolly-pixel/pixel-draw.renderer";
import {
PixelArtCanvas
} from "@jolly-pixel/pixel-draw.renderer";

const container = document.getElementById("editor-container")!;
const manager = new PixelArtCanvas(container, {
Expand All @@ -50,12 +52,12 @@ const manager = new PixelArtCanvas(container, {
manager.onResize();
manager.centerTexture();

manager.brush.primary.set("#FF6600"); // CSS string or a colorjs.io `Color` instance
manager.brush.primary.set("#FF6600");
manager.brush.primary.opacity = 0.8;
manager.brush.secondary.set("#3366FF");
manager.brush.size = 3;

manager.mode = "fill"; // "paint" | "move" | "fill" | "select", see Modes below
manager.mode = "fill";
```

Loading an existing texture:
Expand All @@ -69,7 +71,7 @@ manager.texture = img;

### Modes

`mode` selects how left-click/drag is interpreted. Read [PixelArtCanvas.md](./docs/PixelArtCanvas.md#mode) for the full behavior:
`mode` selects how left-click/drag is interpreted.

- `"paint"`: left-click draws with `brush.primary`, right-click draws with `brush.secondary` (mutually exclusive — one button's stroke blocks the other from starting); hold `Shift` for a straight line (always `primary`); `Ctrl`+right-click eyedroppers a color into `brush.primary`
- `"move"`: pans the camera
Expand All @@ -78,6 +80,9 @@ manager.texture = img;

Middle-click pans in any mode.

> [!TIP]
> Read [PixelArtCanvas.md](./docs/PixelArtCanvas.md#mode) for the full behavior.

### Keybinds

Copy/paste/undo/redo/delete are configurable; Shift (line-tool arm/disarm) is not. Defaults:
Expand All @@ -94,10 +99,14 @@ Override at construction, or live via `patchKeybindings()`:

```ts
const manager = new PixelArtCanvas(container, {
keybindings: { undo: "alt+u" } // unspecified actions keep their default
keybindings: {
undo: "alt+u"
} // unspecified actions keep their default
});

manager.patchKeybindings({ redo: "alt+shift+u" });
manager.patchKeybindings({
redo: "alt+shift+u"
});
```

> [!TIP]
Expand Down
95 changes: 74 additions & 21 deletions packages/pixel-draw-renderer/docs/PixelArtCanvas.md

Large diffs are not rendered by default.

26 changes: 24 additions & 2 deletions packages/pixel-draw-renderer/docs/buffer/PixelBuffer.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ Writes a rectangular block of per-pixel colors (row-major, `rect.width * rect.he

---

### `drawMaskedRegion`

```ts
drawMaskedRegion(rect: SelectionRect, pixels: RGBA[], mask: boolean[]): void
```

Same as `drawRegion`, but skips any cell whose row-major `mask` entry is `false`. Used to paint a non-rectangular (e.g. shape-selected) region without touching the cells outside its mask. Out-of-bounds positions are skipped, same as `drawPixels`/`drawRegion`.

---

### `copyToMaster`

```ts
Expand All @@ -100,6 +110,16 @@ samplePixel(x: number, y: number): [number, number, number, number]

Returns the `[r, g, b, a]` of the working buffer at `(x, y)`. Out-of-bounds reads return `0` for each component rather than throwing.

---

### `samplePixels`

```ts
samplePixels(positions: Vec2[]): RGBA[]
```

Batch form of `samplePixel`: returns one `RGBA` per position, in order. Out-of-bounds positions sample as fully transparent black (`{ r: 0, g: 0, b: 0, a: 0 }`) rather than being skipped or throwing.

## Hooks

```ts
Expand Down Expand Up @@ -140,6 +160,8 @@ type PixelBufferHookAction = PixelBufferHookEvent["action"];
type PixelBufferHookListener = (event: PixelBufferHookEvent) => void;
```

This is the shape of `PixelArtCanvas`'s `onBufferUpdated` local-mutation hook, and the vocabulary the [network layer](../network/index.md) is built onevery event is a valid network command payload once stamped with routing metadata. `"stroke"` covers a whole paint stroke or `commitPixels` call, not one event per brush stamp. `originTimestamp`, set only when `PixelArtCanvas.undo()`/`redo()` replay an edit, carries that edit's original timestamp so the network [conflict resolver](../network/ConflictResolver.md) re-races the replay fairly instead of it always winning by virtue of being freshly stamped; it's stripped before the command is sent over the wire.
This is the shape of `PixelArtCanvas`'s `onBufferUpdated` local-mutation hook, and the vocabulary the [network layer](../network/index.md) is built on: every event is a valid network command payload once stamped with routing metadata. `"stroke"` covers a whole paint stroke or `commitPixels` call, not one event per brush stamp.

`"global-fill"` (emitted by `PixelArtCanvas`'s fill tool when `setFillGlobal(true)`) is deliberately compact — no position list — since it can touch a large fraction of the canvas. Every applier (a remote peer via `applyRemoteCommand`, or [`PixelCommandApplier`](../network/PixelCommandApplier.md) on the server) recomputes the affected pixels itself by scanning its own buffer for `fromColor` and repainting them `toColor`, which is only correct because peers apply commands in the same order against an already-synced buffer. It also bypasses per-pixel conflict resolution (unlike `"stroke"`) — see [network/ConflictResolver.md](../network/ConflictResolver.md). Undoing/redoing a global fill locally still replays as an ordinary full-position `"stroke"` event, since exact undo requires knowing exactly which pixels were touched.
> [!IMPORTANT]
> - `originTimestamp` is set only when `PixelArtCanvas.undo()`/`redo()` replay an edit. It carries the edit's original timestamp so the network [conflict resolver](../network/ConflictResolver.md) re-races the replay fairly instead of it always winning by virtue of being freshly stamped; it's stripped before the command is sent over the wire.
> - `"global-fill"` (emitted by the fill tool when `setFillGlobal(true)`) is deliberately compact, with no position list, since it can touch a large fraction of the canvas. Every applier (a remote peer via `applyRemoteCommand`, or [`PixelCommandApplier`](../network/PixelCommandApplier.md) on the server) recomputes the affected pixels itself by scanning its own buffer for `fromColor` and repainting them `toColor`; this is only correct because peers apply commands in the same order against an already-synced buffer. It also bypasses per-pixel conflict resolution, unlike `"stroke"` (see [network/ConflictResolver.md](../network/ConflictResolver.md)). Undoing/redoing a global fill locally still replays as an ordinary full-position `"stroke"` event, since exact undo requires knowing exactly which pixels were touched.
27 changes: 21 additions & 6 deletions packages/pixel-draw-renderer/docs/history/HistoryStack.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# HistoryStack

Bounded undo/redo stack over a `DefaultPixelBuffer` (`PixelBuffer` or `CanvasBuffer`) no DOM or network dependency, so it runs identically headless or in the browser. `PixelArtCanvas`'s internal `HistoryController` owns one when constructed with `history.enabled: true` (see [PixelArtCanvas.md](../PixelArtCanvas.md#undo--redo--canundo--canredo)); most consumers drive undo/redo through `PixelArtCanvas.undo()`/`redo()` rather than this class directly.
Bounded undo/redo stack over a `DefaultPixelBuffer` (`PixelBuffer` or `CanvasBuffer`), with no DOM or network dependency, so it runs identically headless or in the browser. `PixelArtCanvas`'s internal `HistoryController` owns one when constructed with `history.enabled: true` (see [PixelArtCanvas.md](../PixelArtCanvas.md#undo--redo--canundo--canredo)); most consumers drive undo/redo through `PixelArtCanvas.undo()`/`redo()` rather than this class directly.

`HistoryStack` only owns the stack and replays before/after data against its buffer capturing that before/after data on each edit is the caller's job (`PixelArtCanvas` does this internally for strokes, resizes, and texture replaces).
`HistoryStack` only owns the stack and replays before/after data against its buffer; capturing that before/after data on each edit is the caller's job (`PixelArtCanvas` does this internally for strokes, resizes, and texture replaces).

## Types

Expand Down Expand Up @@ -44,13 +44,28 @@ type HistoryEntry =
positions: Vec2[];
beforeColors: RGBA[];
afterColors: RGBA[];
oldRect: SelectionRect;
newRect: SelectionRect;
oldMask: boolean[];
newMask: boolean[];
};

/** Same as HistoryEntry, minus `timestamp` stamped by `push()`. */
/** Same as HistoryEntry, minus `timestamp` (stamped by `push()`). */
type HistoryEntryInput = Omit<HistoryEntry, "timestamp">;
```

`limit` bounds the undo stack: pushing past it silently drops the oldest entry. A `"stroke"` entry's `beforeColors` is per-position (a stroke can cross pixels of different colors); `afterColor` is a single color since a stroke always paints one uniform color. `"resized"`/`"texture-replaced"` instead snapshot the whole buffer (`beforePixels`/`afterPixels`) since there's no cheaper diff to keep. `"select-edit"` covers every `"select"`-mode edit (move/delete/paste/rotate/flip) with a single entry shape: unlike `"stroke"`, both `beforeColors` and `afterColors` are per-position, since these operations paint heterogeneous, multi-colored regions rather than one uniform color. `positions` is the union of whatever footprint(s) the edit touched (e.g. a Move's source and destination, or a Rotate's pre/post footprint when a non-square selection's dimensions swap) — see [PixelArtCanvas.md](../PixelArtCanvas.md#undo--redo--canundo--canredo) for the network-sync caveat specific to this entry type.
`limit` bounds the undo stack: pushing past it silently drops the oldest entry.

| Action | Before | After |
|---|---|---|
| `"stroke"` | `beforeColors`: per-position (a stroke can cross pixels of different colors) | `afterColor`: single color (a stroke always paints one uniform color) |
| `"resized"` / `"texture-replaced"` | `beforePixels`: whole-buffer snapshot | `afterPixels`: whole-buffer snapshot |
| `"select-edit"` | `beforeColors`: per-position | `afterColors`: per-position (unlike `"stroke"`, since these operations paint heterogeneous, multi-colored regions) |

`"resized"`/`"texture-replaced"` snapshot the whole buffer since there's no cheaper diff to keep. `"select-edit"` covers every `"select"`-mode edit (move/delete/paste/rotate/flip) with a single entry shape. `positions` is the union of whatever footprint(s) the edit touched (e.g. a Move's source and destination, or a Rotate's pre/post footprint when a non-square selection's dimensions swap). `oldRect`/`oldMask` and `newRect`/`newMask` capture the active selection's rectangle and shape mask before/after the edit, so undo/redo can restore the selection itself, not just the pixels.

> [!IMPORTANT]
> A `"select-edit"` entry's undo/redo is never broadcast over the network; see [PixelArtCanvas.md](../PixelArtCanvas.md#undo--redo--canundo--canredo) for why.

## Properties

Expand All @@ -71,7 +86,7 @@ Whether there's an entry to undo/redo.
push(entry: HistoryEntryInput): void
```

Stamps the entry with the current time (`Date.now()`) and pushes it onto the undo stack, clearing the redo stack. Drops the oldest undo entry once `limit` is exceeded. The timestamp is preserved across future undo/redo replays see [buffer/PixelBuffer.md](../buffer/PixelBuffer.md) for why this matters over the network.
Stamps the entry with the current time (`Date.now()`) and pushes it onto the undo stack, clearing the redo stack. Drops the oldest undo entry once `limit` is exceeded. The timestamp is preserved across future undo/redo replays; see [buffer/PixelBuffer.md](../buffer/PixelBuffer.md) for why this matters over the network.

---

Expand Down Expand Up @@ -101,4 +116,4 @@ Re-applies the most recently undone entry (applying its `after*` data to the buf
clear(): void
```

Discards every recorded entry, both stacks. Call when the buffer is replaced wholesale from outside the stack's knowledge (e.g. a remote resize/texture-replace/snapshot `PixelArtCanvas` does this automatically in `applyRemoteCommand`/`loadSnapshot`).
Discards every recorded entry, both stacks. Call when the buffer is replaced wholesale from outside the stack's knowledge (e.g. a remote resize/texture-replace/snapshot; `PixelArtCanvas` does this automatically in `applyRemoteCommand`/`loadSnapshot`).
9 changes: 7 additions & 2 deletions packages/pixel-draw-renderer/docs/network/ConflictResolver.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# ConflictResolver

Conflicts are resolved **per pixel**, not per command. A single stroke command can touch thousands of pixels, so [`PixelSyncServer`](./PixelSyncServer.md) splits a command: pixels that lose the race are dropped from the applied/broadcast copy, the rest are applied normally. `"buffer-added"`, `"buffer-removed"`, `"resized"`, `"texture-replaced"`, and `"global-fill"` are always accepted with no per-pixel arbitration; only `"stroke"` goes through a resolver. `"global-fill"` carries no position list to arbitrate against (see [buffer/PixelBuffer.md](../buffer/PixelBuffer.md)) — it's applied by recomputing matching pixels against the server's own authoritative buffer at receive-time, which is self-consistent as long as commands are applied in the order the server processes them.
Conflicts are resolved **per pixel**, not per command. A single stroke command can touch thousands of pixels, so [`PixelSyncServer`](./PixelSyncServer.md) splits a command: pixels that lose the race are dropped from the applied/broadcast copy, the rest are applied normally.

Only `"stroke"` goes through a resolver. `"buffer-added"`, `"buffer-removed"`, `"resized"`, `"texture-replaced"`, and `"global-fill"` are always accepted with no per-pixel arbitration.

> [!IMPORTANT]
> `"global-fill"` carries no position list to arbitrate against (see [buffer/PixelBuffer.md](../buffer/PixelBuffer.md)). It's applied by recomputing matching pixels against the server's own authoritative buffer at receive-time, which is self-consistent only because commands are applied in the order the server processes them.

## Types

Expand All @@ -19,7 +24,7 @@ interface PixelConflictContext {
* given the last known command header at the same pixel.
*
* Only the header is tracked (not the full stroke command) since a single
* stroke can touch thousands of pixels keeping a full command per pixel
* stroke can touch thousands of pixels; keeping a full command per pixel
* would be wasteful.
*/
interface PixelConflictResolver {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Processes an incoming command:
- `"buffer-added"`: creates the buffer if it doesn't already exist, then broadcasts.
- `"buffer-removed"`: deletes the buffer and its conflict-tracking state, then broadcasts.
- `"stroke"`: resolves conflicts per-pixel (see [ConflictResolver](./ConflictResolver.md)); applies and broadcasts only the accepted pixels. Dropped entirely (no broadcast) if nothing was accepted.
- `"resized"` / `"texture-replaced"` / `"global-fill"`: always accepted, applied, and broadcast `"global-fill"` carries no position list, so it can't be arbitrated per pixel; see [ConflictResolver](./ConflictResolver.md).
- `"resized"` / `"texture-replaced"` / `"global-fill"`: always accepted, applied, and broadcast. `"global-fill"` carries no position list, so it can't be arbitrated per pixel; see [ConflictResolver](./ConflictResolver.md).

Commands targeting an unknown buffer (other than `"buffer-added"`) are dropped.

Expand Down
2 changes: 1 addition & 1 deletion packages/pixel-draw-renderer/docs/network/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ implementation: this package has no dependency on voxel-renderer.
┌───────────────┐ onBufferUpdated ┌──────────────────┐ sendCommand ┌─────────────┐
│ PixelArtCanvas │───────────────────▶│ PixelSyncSession │────────────────▶│ Transport │
│ (per buffer) │ │ (multi-buffer) │◀────────────────│ (WebSocket, │
│ │◀──applyRemote──────│ │ onCommand │ WebRTC, ) │
│ │◀──applyRemote──────│ │ onCommand │ WebRTC, ...) │
└───────────────┘ └──────────────────┘ └──────┬──────┘
│ wire
Expand Down
9 changes: 7 additions & 2 deletions packages/pixel-draw-renderer/docs/network/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ Wire-format types for the [network sync layer](./index.md).

```ts
/**
* Buffer create/destroy events. A PixelArtCanvas has no concept of a bufferId
* so these are never emitted from a PixelArtCanvas's onBufferUpdated hook
* Buffer create/destroy events. A PixelArtCanvas has no concept of a bufferId,
* so these are never emitted from a PixelArtCanvas's onBufferUpdated hook;
* they are constructed directly by PixelSyncSession.createBuffer/removeBuffer.
*/
type PixelLifecycleEvent =
Expand All @@ -18,10 +18,12 @@ type PixelLifecycleEvent =
/** Base64-encoded RGBA bytes for the buffer's initial content, if any. */
pixels?: string;
};
originTimestamp?: number;
}
| {
action: "buffer-removed";
metadata: Record<string, never>;
originTimestamp?: number;
};

type PixelNetworkEvent = PixelBufferHookEvent | PixelLifecycleEvent;
Expand Down Expand Up @@ -49,3 +51,6 @@ interface PixelBufferSnapshot {
```

`PixelBufferHookEvent` (the `"stroke"` / `"resized"` / `"texture-replaced"` / `"global-fill"` local-mutation events) is defined in [buffer/PixelBuffer.md](../buffer/PixelBuffer.md); a `PixelNetworkCommand` is that same event shape plus `PixelLifecycleEvent`, enriched with the header fields. Six actions total: `"buffer-added"`, `"buffer-removed"`, `"stroke"`, `"resized"`, `"texture-replaced"`, `"global-fill"`. All pixel payloads (`stroke` positions and `global-fill`'s colors excepted) are raw RGBA bytes, base64-encoded via `js-base64`: no image codec dependency, so `PixelSyncServer` stays headless. Commands are plain JSON-serializable objects.

> [!IMPORTANT]
> `PixelLifecycleEvent`'s `originTimestamp` exists only for structural symmetry with `PixelBufferHookEvent` (so the stamping logic in [`PixelSyncSession`](./PixelSyncSession.md) can handle every event uniformly). `"buffer-added"`/`"buffer-removed"` are never replayed through undo/redo, so it's always `undefined` in practice for these two actions.
2 changes: 1 addition & 1 deletion packages/pixel-draw-renderer/docs/tools/Brush.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ readonly primary: BrushColor
readonly secondary: BrushColor
```

Each is a `BrushColor` value object a color+opacity pair:
Each is a `BrushColor` value object, a color+opacity pair:

```ts
set(color: ColorInput, opacity?: number): void
Expand Down
Loading
Loading