diff --git a/.changeset/pixel-draw-fit-zoom.md b/.changeset/pixel-draw-fit-zoom.md new file mode 100644 index 00000000..471bd35d --- /dev/null +++ b/.changeset/pixel-draw-fit-zoom.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/pixel-draw.renderer": patch +--- + +`PixelArtCanvas`'s default zoom (when `zoom.default` is omitted) now fits the whole texture inside the container's initial size, instead of a flat `4` — a large texture in a small container no longer starts zoomed in past what's visible. Pass an explicit `zoom.default` to opt out. diff --git a/.changeset/pixel-draw-select-cursor.md b/.changeset/pixel-draw-select-cursor.md new file mode 100644 index 00000000..b6be50c6 --- /dev/null +++ b/.changeset/pixel-draw-select-cursor.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/pixel-draw.renderer": patch +--- + +`"select"` mode now shows the same `"grab"`/`"grabbing"` cursor affordance as `"uv"` mode: `"grab"` once a selection exists (idle), `"grabbing"` while it's being dragged to a new position. Drawing a brand-new rectangle keeps the plain cursor, since that isn't a grab motion. diff --git a/.changeset/selection-overlay-undo-fix.md b/.changeset/selection-overlay-undo-fix.md new file mode 100644 index 00000000..6fded62b --- /dev/null +++ b/.changeset/selection-overlay-undo-fix.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/pixel-draw.renderer": patch +--- + +Fix undo/redo reactivating the selection overlay after leaving select mode: a select-edit history entry now only resyncs the selection (and its SVG overlay) when select mode is currently active, while pixels still restore regardless of mode. diff --git a/.changeset/uv-mode.md b/.changeset/uv-mode.md new file mode 100644 index 00000000..fa222ac4 --- /dev/null +++ b/.changeset/uv-mode.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/pixel-draw.renderer": minor +--- + +Add a `"uv"` mode for placing rectangular UV regions on a texture, independently of painting. The canvas cursor is `"grab"`/`"grabbing"` while idle/dragging in this mode. `PixelArtCanvas.uv` exposes a new `UVMap` value object: `create({ width, height })` (API-only, no canvas gesture), `delete(id)`, `move(id, rect)`, and `select(id)`, with a typed event emitter (`region-created`/`region-deleted`/`region-moved`/`region-dragging`/`selection-changed`/`visibility-changed`). `region-dragging` fires continuously while a canvas drag is in progress (via `previewMove`), uncommitted and never recorded/broadcast, so a consumer can mirror the region live instead of only on drop. Regions are hidden by default — visible only when selected or when `showAll` is enabled — and render as solid colored borders. Region create/delete/move participate in undo/redo (`HistoryEntry` gains `"uv-create"`/`"uv-delete"`/`"uv-move"`) and in network sync (`PixelBufferHookEvent` gains `"uv-region-created"`/`"uv-region-deleted"`/`"uv-region-moved"`; `PixelSyncServer` resolves move/delete conflicts per region id, and `PixelBufferSnapshot` now carries `uvRegions` for late-joining clients). See `docs/uv/UVMap.md`. diff --git a/packages/pixel-draw-renderer/README.md b/packages/pixel-draw-renderer/README.md index 77fc0e6d..17ef1f2c 100644 --- a/packages/pixel-draw-renderer/README.md +++ b/packages/pixel-draw-renderer/README.md @@ -16,7 +16,8 @@ Browser-based library for editing pixel-art textures. It provides zoom, pan, pri - **Shift-to-line drawing**: hold `Shift` in paint mode to draw a straight line - **Paint-bucket fill**: flood-fill a connected region of same-colored pixels - **Rectangle select, move, copy, delete**: `Ctrl`/`Cmd`+`C`/`V` to copy/duplicate, `Delete` to erase -- **Undo/redo**: optional bounded history over strokes, resizes, and texture replaces; opt in via `history.enabled` +- **UV regions**: create/move/delete rectangular UV regions independently of painting, via the `uv` value object; hidden by default, revealed by selection or an opt-in "show all" +- **Undo/redo**: optional bounded history over strokes, resizes, texture replaces, and UV region changes; opt in via `history.enabled` - **Zoom & pan**: mouse-wheel zoom with configurable sensitivity and range; middle-click pan in any mode - **Transparency support**: checkerboard background renders beneath transparent pixels @@ -76,7 +77,8 @@ manager.texture = img; - `"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 - `"fill"`: flood-fills the clicked region with `brush.primary`; right-click has no effect -- `"select"`: drag to select/move; `Ctrl`/`Cmd`+`C`/`V` copy/paste, `Delete` erases; right-click has no effect +- `"select"`: drag to select/move; `Ctrl`/`Cmd`+`C`/`V` copy/paste, `Delete` erases; right-click has no effect. Cursor is `grab`/`grabbing` while a selection exists/is being dragged. +- `"uv"`: click a visible UV region to select/drag it; `Delete` deletes the selected region. There's no click-to-create — create regions via `manager.uv.create(...)`. The cursor is `grab`/`grabbing` in this mode. Middle-click pans in any mode. @@ -149,6 +151,7 @@ Open `http://localhost:5173` to see the interactive demo. - [`PixelArtCanvas`](./docs/PixelArtCanvas.md): top-level coordinator, the primary public API - [`Brush`](./docs/tools/Brush.md): brush size, primary/secondary color, opacity, and affected-pixel computation — read/write via `PixelArtCanvas.brush` - [`PixelBuffer`](./docs/buffer/PixelBuffer.md): headless RGBA pixel storage, usable server-side with no DOM +- [`UVMap`](./docs/uv/UVMap.md): UV region create/delete/move, selection, and visibility — read/write via `PixelArtCanvas.uv` - [`HistoryStack`](./docs/history/HistoryStack.md): bounded undo/redo stack backing `PixelArtCanvas.undo()`/`redo()` - [`Keybindings`](./docs/input/Keybindings.md): the `Keybindings` value object, `Keybinding`/`KeybindingsMap` types, `DEFAULT_KEYBINDINGS`, and the errors thrown by `patch()` - [`Network`](./docs/network/index.md): transport-agnostic, server-authoritative multiplayer for `PixelArtCanvas` @@ -163,7 +166,7 @@ type Vec2 = { y: number; }; -type Mode = "paint" | "move" | "fill" | "select"; +type Mode = "paint" | "move" | "fill" | "select" | "uv"; interface SelectionRect { x: number; diff --git a/packages/pixel-draw-renderer/docs/PixelArtCanvas.md b/packages/pixel-draw-renderer/docs/PixelArtCanvas.md index 05528fc1..3955da2b 100644 --- a/packages/pixel-draw-renderer/docs/PixelArtCanvas.md +++ b/packages/pixel-draw-renderer/docs/PixelArtCanvas.md @@ -5,6 +5,7 @@ It wires together a viewport, canvas buffer, renderer, input handling, and SVG overlay (all internal implementation details) and owns: - the [`Brush`](./tools/Brush.md) tool +- the [`UVMap`](./uv/UVMap.md) value object (`"uv"` mode) - internal line/fill/select tools (no public class of their own) - an internal `HistoryController` wrapping a [`HistoryStack`](./history/HistoryStack.md) for undo/redo @@ -101,11 +102,11 @@ interface PixelArtCanvasOptions { } ``` -`Mode` is `"paint" | "move" | "fill" | "select"`. `ColorInput` (`string | Color`, where `Color` is [colorjs.io](https://colorjs.io)'s class) is used throughout the package wherever a color option is accepted: a CSS color string (hex, `rgb()`, `hsl()`, named color, ...) or a `Color` instance. `BrushOptions` is forwarded to the internal `Brush` instance, see [Brush.md](./tools/Brush.md). `PixelBufferHookListener` is described in [buffer/PixelBuffer.md](./buffer/PixelBuffer.md) and [network/index.md](./network/index.md). `KeybindingsMap` is described in [input/Keybindings.md](./input/Keybindings.md). +`Mode` is `"paint" | "move" | "fill" | "select" | "uv"`. `ColorInput` (`string | Color`, where `Color` is [colorjs.io](https://colorjs.io)'s class) is used throughout the package wherever a color option is accepted: a CSS color string (hex, `rgb()`, `hsl()`, named color, ...) or a `Color` instance. `BrushOptions` is forwarded to the internal `Brush` instance, see [Brush.md](./tools/Brush.md). `PixelBufferHookListener` is described in [buffer/PixelBuffer.md](./buffer/PixelBuffer.md) and [network/index.md](./network/index.md). `KeybindingsMap` is described in [input/Keybindings.md](./input/Keybindings.md). `history.enabled` (default `false`) tells the internal `HistoryController` to back itself with a [`HistoryStack`](./history/HistoryStack.md) that records every stroke, resize, and texture replace, enabling `undo()`/`redo()`. Leaving it disabled skips that bookkeeping entirely: there's no per-edit cost paid for a feature that isn't used. -Undocumented defaults: `texture.size` is `{ x: 64, y: 32 }` (`y` falls back to `x` when only `x` is given), `texture.maxSize` is `2048`, `zoom.default` is `4`, `zoom.min`/`zoom.max` are `1`/`32`, `zoom.sensitivity` is `0.1`, `backgroundTransparency.squareSize` is `8`, `backgroundTransparency.colors` is `{ odd: "#999", even: "#666" }`. +Undocumented defaults: `texture.size` is `{ x: 64, y: 32 }` (`y` falls back to `x` when only `x` is given), `texture.maxSize` is `2048`, `zoom.default` fits the texture to the container (see above; falls back to `4` if the container has no measurable size yet), `zoom.min`/`zoom.max` are `1`/`32`, `zoom.sensitivity` is `0.1`, `backgroundTransparency.squareSize` is `8`, `backgroundTransparency.colors` is `{ odd: "#999", even: "#666" }`. The `backgroundColor` option, if given, wins outright. Otherwise it's read from `getComputedStyle(parentHtmlElement).backgroundColor` at construction time, falling back to `#424242` if that's unset or fully transparent. See the `backgroundColor` property below to change it after construction. @@ -127,6 +128,14 @@ readonly viewport: DefaultViewport // { readonly zoom: Zoom; readonly camera: Re Read-only camera/zoom state. `viewport.zoom` is the same `Zoom` value object as the top-level `zoom` accessor below (`.value`, `.min`, `.max`, `.sensitivity`); use `viewport` for coordinate conversions and mutation via the methods below. +### `uv` + +```ts +readonly uv: UVMap +``` + +The `UVMap` value object managing this texture's UV regions (create/delete/move/select, visibility, and a typed event emitter). See [uv/UVMap.md](./uv/UVMap.md). + ## Methods ### `mode` @@ -144,14 +153,16 @@ Reads or sets the current interaction mode. | `"move"` | Pans the camera. | N/A | | `"fill"` | Paint-bucket fill from the clicked pixel with `brush.primary`. | Same fill with `brush.secondary`. | | `"select"` | Drag to select or move a rectangle. | N/A | +| `"uv"` | Click a visible UV region to select/drag it. `Delete` deletes the selected region. | N/A | - `"paint"`: the two buttons are mutually exclusive, a stroke already in progress on one button blocks the other from starting until it ends. - `"fill"`: fills the clicked pixel's contiguous region by default, or every same-colored pixel on the canvas when `fillGlobal` is `true` (see below). A fill click is single-shot and not tracked as a drag, so, unlike `"paint"`, the two buttons aren't mutually exclusive. -- `"select"`: `Ctrl`/`Cmd`+`C`/`V` copies/duplicates, `Delete` erases, `R` rotates the selection 90° clockwise around its center (repeatable: press again for further rotation; no counterclockwise binding), `H`/`V` flips the selection's content horizontally/vertically in place. A drag that never grows past its starting pixel (a plain click) does not create a selection. +- `"select"`: `Ctrl`/`Cmd`+`C`/`V` copies/duplicates, `Delete` erases, `R` rotates the selection 90° clockwise around its center (repeatable: press again for further rotation; no counterclockwise binding), `H`/`V` flips the selection's content horizontally/vertically in place. A drag that never grows past its starting pixel (a plain click) does not create a selection. Like `"uv"`, the cursor is `"grab"` once a selection exists (idle) and `"grabbing"` while it's being dragged to a new position — drawing a brand-new rectangle keeps the plain cursor, since that isn't a grab motion. +- `"uv"`: there is no click-to-create gesture — regions are created via `uv.create(...)` (see [uv/UVMap.md](./uv/UVMap.md)). Only *visible* regions (per `uv.showAll`/`uv.selectedRegionId`) can be hit-tested; clicking empty canvas space (or an invisible region) deselects. The canvas cursor is `"grab"` while idle in this mode and `"grabbing"` while a region is actively being dragged, reverting to the browser default in any other mode. `Delete` only deletes the selected UV region while actually in `"uv"` mode — since a UV selection, unlike a `"select"`-mode selection, persists across mode changes (see the note below), `Delete` pressed in another mode acts only on that mode's own selection, if any, and leaves the UV region alone. > [!IMPORTANT] > - The line/fill/select tools are internal implementation details with no public class of their own. -> - Switching to `"move"` cancels an armed line. Switching away from `"select"` clears any active selection. +> - Switching to `"move"` cancels an armed line. Switching away from `"select"` clears any active selection. Switching away from `"uv"` cancels an in-progress drag but, unlike `"select"`, does **not** clear the UV selection/visibility — see [uv/UVMap.md](./uv/UVMap.md). > - The SVG brush-cursor highlight is active only in `"paint"` and `"fill"`. In `"fill"`, and in `"paint"` while `pickColorArmed` is `true`, the highlight is always a single pixel regardless of `brush`'s configured size, since neither a fill's seed nor a color pick is brush-sized. --- @@ -256,7 +267,7 @@ canUndo(): boolean canRedo(): boolean ``` -Reverts/re-applies the most recent local edit (stroke, resize, or texture replace) via the internal `HistoryController`, which wraps a [`HistoryStack`](./history/HistoryStack.md). +Reverts/re-applies the most recent local edit (stroke, resize, texture replace, or UV region create/delete/move) via the internal `HistoryController`, which wraps a [`HistoryStack`](./history/HistoryStack.md). - `undo()`/`redo()` return `false` and do nothing when `history.enabled` wasn't passed at construction, or when the corresponding stack is empty. - `canUndo()`/`canRedo()` report the same condition without mutating anything. @@ -265,7 +276,7 @@ Reverts/re-applies the most recent local edit (stroke, resize, or texture replac A successful call redraws the canvas, calls `onDrawEnd`, and fires `onHistoryChange`. > [!IMPORTANT] -> - For a history-enabled `PixelArtCanvas` attached to a `PixelSyncSession`, a successful `undo()`/`redo()` also emits the reverted/re-applied state through `onBufferUpdated` so peers converge to the same result (the replayed event's `originTimestamp` keeps that fair under conflict resolution; see [buffer/PixelBuffer.md](./buffer/PixelBuffer.md)). **Exception:** undoing/redoing a `"select"`-mode edit (move/delete/paste/rotate/flip) never emits `onBufferUpdated`, since those edits aren't networked in the first place, so their undo/redo is local-only. +> - For a history-enabled `PixelArtCanvas` attached to a `PixelSyncSession`, a successful `undo()`/`redo()` also emits the reverted/re-applied state through `onBufferUpdated` so peers converge to the same result (the replayed event's `originTimestamp` keeps that fair under conflict resolution; see [buffer/PixelBuffer.md](./buffer/PixelBuffer.md)). **Exception:** undoing/redoing a `"select"`-mode edit (move/delete/paste/rotate/flip) never emits `onBufferUpdated`, since those edits aren't networked in the first place, so their undo/redo is local-only. A UV region create/delete/move **is** networked: undo/redo just calls the matching `UVMap` method, which emits the same event a live change would, and that's what feeds `onBufferUpdated`; see [uv/UVMap.md](./uv/UVMap.md#history--network). > - A remote resize, texture-replace, or snapshot load clears the local history stack (its recorded positions/sizes no longer describe the buffer), so `canUndo()`/`canRedo()` drop to `false` afterward. --- @@ -389,9 +400,9 @@ Tears down `InputController`'s event listeners and removes the canvas and SVG ov ```ts set onBufferUpdated(fn: PixelBufferHookListener | undefined) applyRemoteCommand(event: PixelBufferHookEvent): void -loadSnapshot(size: Vec2, pixels: Uint8ClampedArray): void +loadSnapshot(size: Vec2, pixels: Uint8ClampedArray, uvRegions?: UVRegion[]): void ``` -Network sync hooks, used by `PixelSyncSession`. See [network/index.md](./network/index.md). `onBufferUpdated` fires on every local mutation (stroke, resize, texture replace). `applyRemoteCommand` applies a mutation from a remote peer without re-firing `onBufferUpdated`. `loadSnapshot` hydrates the buffer from a network snapshot; it is never itself broadcast. +Network sync hooks, used by `PixelSyncSession`. See [network/index.md](./network/index.md). `onBufferUpdated` fires on every local mutation (stroke, resize, texture replace, UV region create/delete/move). `applyRemoteCommand` applies a mutation from a remote peer without re-firing `onBufferUpdated`. `loadSnapshot` hydrates the buffer (and, via `uvRegions`, the `UVMap`) from a network snapshot; it is never itself broadcast. There is no manual redraw method: every mutation (stroke, pan, zoom, resize, texture replace) triggers its own repaint internally. diff --git a/packages/pixel-draw-renderer/docs/buffer/PixelBuffer.md b/packages/pixel-draw-renderer/docs/buffer/PixelBuffer.md index 3dda9be2..1ac0003c 100644 --- a/packages/pixel-draw-renderer/docs/buffer/PixelBuffer.md +++ b/packages/pixel-draw-renderer/docs/buffer/PixelBuffer.md @@ -1,6 +1,6 @@ # PixelBuffer -`PixelBuffer` holds raw RGBA pixel data with no DOM dependency, so it can run in a headless environment (server, tests) as well as behind a Canvas2D adapter in the browser (used internally by `PixelArtCanvas`). It's the buffer type used by [`PixelWorld`](../network/PixelWorld.md) for server-side pixel storage. +`PixelBuffer` holds raw RGBA pixel data (and, per-buffer, UV regions) with no DOM dependency, so it can run in a headless environment (server, tests) as well as behind a Canvas2D adapter in the browser (used internally by `PixelArtCanvas`). It's the buffer type used by [`PixelWorld`](../network/PixelWorld.md) for server-side pixel and UV-region storage. It keeps two backing arrays: a `working` buffer at the current texture size, and a `master` buffer pre-allocated at `maxSize × maxSize` that only gets updated on `copyToMaster()`. Growing `working` back up (via `resize`) reads from `master`, so previously committed content beyond a temporarily shrunk size isn't lost. @@ -120,6 +120,16 @@ 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. +--- + +### `uvRegions` + +```ts +readonly uvRegions: UVRegionCollection +``` + +Server-side authoritative storage for this buffer's UV regions (see [uv/UVMap.md](../uv/UVMap.md) and [uv/UVRegionCollection.md](../uv/UVRegionCollection.md)), used by [`PixelCommandApplier`](../network/PixelCommandApplier.md) and included in [`PixelSyncServer.snapshot()`](../network/PixelSyncServer.md#snapshot) (via its `[Symbol.iterator]`) so late-joining clients learn about existing regions. The client-side `PixelArtCanvas`/`UVMap` pairing has no equivalent — it mutates its own `UVMap` directly instead. + ## Hooks ```ts @@ -154,6 +164,28 @@ export type PixelBufferHookEvent = toColor: RGBA; }; originTimestamp?: number; + } + | { + action: "uv-region-created"; + metadata: { + region: UVRegion; + }; + originTimestamp?: number; + } + | { + action: "uv-region-deleted"; + metadata: { + id: string; + }; + originTimestamp?: number; + } + | { + action: "uv-region-moved"; + metadata: { + id: string; + rect: SelectionRect; + }; + originTimestamp?: number; }; type PixelBufferHookAction = PixelBufferHookEvent["action"]; @@ -165,3 +197,4 @@ This is the shape of `PixelArtCanvas`'s `onBufferUpdated` local-mutation hook, a > [!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. +> - `"uv-region-*"` events fire whenever [`UVMap`](../uv/UVMap.md) creates/deletes/moves a region — locally, via undo/redo replay, or via `applyRemoteCommand` (which suppresses re-emission). `"uv-region-created"` carries the full `region` (a remote peer has never seen it); `"uv-region-deleted"`/`"uv-region-moved"` carry only `id` (+ `rect` for a move) since the peer already has the rest. `PixelSyncServer` resolves move/delete conflicts per region id, parallel to `"stroke"`'s per-pixel resolution; see [network/PixelSyncServer.md](../network/PixelSyncServer.md). diff --git a/packages/pixel-draw-renderer/docs/history/HistoryStack.md b/packages/pixel-draw-renderer/docs/history/HistoryStack.md index 2918279f..f5b18fef 100644 --- a/packages/pixel-draw-renderer/docs/history/HistoryStack.md +++ b/packages/pixel-draw-renderer/docs/history/HistoryStack.md @@ -7,7 +7,7 @@ Bounded undo/redo stack over a `DefaultPixelBuffer` (`PixelBuffer` or `CanvasBuf ## Types ```ts -new HistoryStack(buffer: DefaultPixelBuffer, options?: HistoryStackOptions) +new HistoryStack(buffer: DefaultPixelBuffer, uvMap: UVMap, options?: HistoryStackOptions) interface HistoryStackOptions { /** @default 10 */ @@ -48,6 +48,23 @@ type HistoryEntry = newRect: SelectionRect; oldMask: boolean[]; newMask: boolean[]; + } + | { + action: "uv-create"; + timestamp: number; + region: UVRegion; + } + | { + action: "uv-delete"; + timestamp: number; + region: UVRegion; + } + | { + action: "uv-move"; + timestamp: number; + id: string; + oldRect: SelectionRect; + newRect: SelectionRect; }; /** Same as HistoryEntry, minus `timestamp` (stamped by `push()`). */ @@ -61,11 +78,16 @@ type HistoryEntryInput = Omit; | `"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) | +| `"uv-create"` / `"uv-delete"` | n/a (undo calls the inverse `UVMap` method) | `region`: full state, so a delete's undo can `restore()` it exactly | +| `"uv-move"` | `oldRect` | `newRect` | `"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. +The `"uv-*"` entries replay by calling the corresponding [`UVMap`](../uv/UVMap.md) method directly (`delete`/`restore`/`move`) rather than touching `buffer`; see that method's own emitted event to see what a consumer observes during the replay. + > [!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. +> - A `"select-edit"` entry's undo/redo is never broadcast over the network; see [PixelArtCanvas.md](../PixelArtCanvas.md#undo--redo--canundo--canredo) for why. +> - A `"uv-*"` entry's undo/redo **is** broadcast (unlike `"select-edit"`), since UV regions are per-buffer network state; see [uv/UVMap.md](../uv/UVMap.md#history--network). ## Properties diff --git a/packages/pixel-draw-renderer/docs/network/ConflictResolver.md b/packages/pixel-draw-renderer/docs/network/ConflictResolver.md index 98a981c5..2046c522 100644 --- a/packages/pixel-draw-renderer/docs/network/ConflictResolver.md +++ b/packages/pixel-draw-renderer/docs/network/ConflictResolver.md @@ -1,8 +1,8 @@ # 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. +Conflicts are resolved **per pixel** for strokes, 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. +`"stroke"` and `"uv-region-moved"`/`"uv-region-deleted"` go through the same resolver — the latter two keyed **per region id** instead of per pixel, and rejected/accepted as one atomic unit (no partial application, since a region isn't a list of independently-owned cells). `"buffer-added"`, `"buffer-removed"`, `"resized"`, `"texture-replaced"`, `"global-fill"`, and `"uv-region-created"` are always accepted with no 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. diff --git a/packages/pixel-draw-renderer/docs/network/PixelCommandApplier.md b/packages/pixel-draw-renderer/docs/network/PixelCommandApplier.md index 64df1865..b14f88cc 100644 --- a/packages/pixel-draw-renderer/docs/network/PixelCommandApplier.md +++ b/packages/pixel-draw-renderer/docs/network/PixelCommandApplier.md @@ -8,6 +8,8 @@ function applyCommandToWorld(world: PixelWorld, cmd: PixelNetworkCommand): void Applies a single network command to a headless [`PixelWorld`](./PixelWorld.md) instance. Used internally by [`PixelSyncServer`](./PixelSyncServer.md) (Node.js, no DOM), and usable standalone for server-side logic, unit tests, or replaying a command log without a renderer. +`"uv-region-created"` / `"uv-region-deleted"` / `"uv-region-moved"` are applied via the target buffer's `uvRegions.set`/`uvRegions.remove`/`uvRegions.get`+`uvRegions.set` (see [buffer/PixelBuffer.md](../buffer/PixelBuffer.md) and [uv/UVRegionCollection.md](../uv/UVRegionCollection.md)); a move preserves the existing region's `color`, only replacing `rect`. All three are no-ops for an unknown buffer (or, for a move, an unknown region id). + ```ts import { PixelWorld, diff --git a/packages/pixel-draw-renderer/docs/network/PixelSyncServer.md b/packages/pixel-draw-renderer/docs/network/PixelSyncServer.md index d7e1e2dd..f79b3e83 100644 --- a/packages/pixel-draw-renderer/docs/network/PixelSyncServer.md +++ b/packages/pixel-draw-renderer/docs/network/PixelSyncServer.md @@ -85,8 +85,10 @@ receive(cmd: PixelNetworkCommand): void 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. +- `"buffer-removed"`: deletes the buffer and its pixel/region 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. +- `"uv-region-moved"` / `"uv-region-deleted"`: resolves conflicts per region id — the same `PixelConflictResolver` as strokes, keyed `${bufferId}:${regionId}` instead of `${bufferId}:${x},${y}`. Rejected entirely (no partial application; a region is one atomic unit, unlike a stroke's many pixels). +- `"uv-region-created"`: always accepted, applied, and broadcast — ids are unique per creation, so there's nothing to arbitrate (parallel to `"buffer-added"`'s existence check). - `"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. @@ -99,7 +101,7 @@ Commands targeting an unknown buffer (other than `"buffer-added"`) are dropped. snapshot(bufferId: string): PixelBufferSnapshot | undefined ``` -Returns the buffer's current state, or `undefined` if it doesn't exist. +Returns the buffer's current state — pixels and its full current UV region set (`uvRegions`) — or `undefined` if it doesn't exist. ## Example diff --git a/packages/pixel-draw-renderer/docs/network/PixelWorld.md b/packages/pixel-draw-renderer/docs/network/PixelWorld.md index 29e95c8a..480b1dea 100644 --- a/packages/pixel-draw-renderer/docs/network/PixelWorld.md +++ b/packages/pixel-draw-renderer/docs/network/PixelWorld.md @@ -2,6 +2,8 @@ Headless, multi-buffer registry. Used by [`PixelSyncServer`](./PixelSyncServer.md) as the authoritative store for every buffer (texture) shared in a session. Has no DOM/Canvas2D dependency and runs in Node.js / Deno / Bun. +Each registered [`PixelBuffer`](../buffer/PixelBuffer.md) also holds its own UV regions (see [uv/UVMap.md](../uv/UVMap.md)) — there is no separate "UV world". + ## Types ```ts diff --git a/packages/pixel-draw-renderer/docs/network/index.md b/packages/pixel-draw-renderer/docs/network/index.md index 37617ed8..61aac1c0 100644 --- a/packages/pixel-draw-renderer/docs/network/index.md +++ b/packages/pixel-draw-renderer/docs/network/index.md @@ -28,8 +28,9 @@ several `PixelArtCanvas` instances to the same transport connection (e.g. one per open tileset). **Flow:** -1. A local mutation (a paint stroke, fill, resize, or setting `texture`) fires - `PixelArtCanvas.onBufferUpdated` (see [buffer/PixelBuffer.md](../buffer/PixelBuffer.md)). +1. A local mutation (a paint stroke, fill, resize, setting `texture`, or a UV + region create/delete/move) fires `PixelArtCanvas.onBufferUpdated` (see + [buffer/PixelBuffer.md](../buffer/PixelBuffer.md)). 2. `PixelSyncSession` stamps the event with `bufferId` / `clientId` / `seq` / `timestamp` and calls `transport.sendCommand(cmd)`. 3. The transport delivers the command to [`PixelSyncServer.receive()`](./PixelSyncServer.md). diff --git a/packages/pixel-draw-renderer/docs/network/types.md b/packages/pixel-draw-renderer/docs/network/types.md index 723870c6..e0c972b8 100644 --- a/packages/pixel-draw-renderer/docs/network/types.md +++ b/packages/pixel-draw-renderer/docs/network/types.md @@ -47,10 +47,11 @@ interface PixelBufferSnapshot { size: Vec2; /** Base64-encoded RGBA bytes. */ pixels: string; + uvRegions: UVRegion[]; } ``` -`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. +`PixelBufferHookEvent` (the `"stroke"` / `"resized"` / `"texture-replaced"` / `"global-fill"` / `"uv-region-created"` / `"uv-region-deleted"` / `"uv-region-moved"` 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. Nine actions total: `"buffer-added"`, `"buffer-removed"`, `"stroke"`, `"resized"`, `"texture-replaced"`, `"global-fill"`, `"uv-region-created"`, `"uv-region-deleted"`, `"uv-region-moved"`. 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. `PixelBufferSnapshot.uvRegions` carries the buffer's full current UV region set, so a client subscribing mid-session learns about every region that already exists (see [uv/UVMap.md](../uv/UVMap.md)). > [!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. diff --git a/packages/pixel-draw-renderer/docs/uv/UVMap.md b/packages/pixel-draw-renderer/docs/uv/UVMap.md new file mode 100644 index 00000000..1a6194b3 --- /dev/null +++ b/packages/pixel-draw-renderer/docs/uv/UVMap.md @@ -0,0 +1,202 @@ +# UVMap + +`UVMap` owns a texture's set of UV regions — rectangular areas that can be placed, moved, and deleted independently of painting. It's a standalone value object exposed as `PixelArtCanvas.uv` (not a wrapped getter/setter), with its own typed event emitter, so a consumer never needs to grow `PixelArtCanvasOptions` with per-lifecycle callback options to react to region changes. + +Creation is **API-only**: there is no canvas gesture for it (unlike `"select"` mode's drag-to-create). Call `uv.create(...)` directly — e.g. from a toolbar button. Moving an existing region, however, **is** a canvas gesture: switch `PixelArtCanvas.mode` to `"uv"`, click a visible region, and drag. `PixelArtCanvas` reflects this with a `"grab"`/`"grabbing"` cursor on the canvas while in `"uv"` mode (idle vs. actively dragging), instead of the plain arrow. + +> [!IMPORTANT] +> Face assignment (mapping a region onto a specific mesh face), resizing after creation, rotation, and overlap/auto-layout handling are all out of scope for this version — deferred to a follow-up. + +## Types + +```ts +new UVMap(options: UVMapOptions) + +interface UVMapOptions { + /** Reports the current texture/canvas size, for placement clamping. */ + getCanvasSize: () => Vec2; +} + +interface UVRegion { + id: string; + rect: SelectionRect; // { x, y, width, height }, texture-pixel space + /** CSS color string used to render this region's overlay border. */ + color: string; +} + +interface UVRegionCreateOptions { + width: number; + height: number; + /** @default a generated id (crypto.randomUUID()) */ + id?: string; + /** @default the next color in the built-in palette */ + color?: string; +} + +type UVMapEvent = + | { type: "region-created"; region: UVRegion; } + | { type: "region-deleted"; region: UVRegion; } // the region's last-known state, just before removal + | { type: "region-moved"; region: UVRegion; previousRect: SelectionRect; } + | { type: "region-dragging"; id: string; rect: SelectionRect; } // transient, uncommitted + | { type: "selection-changed"; selectedRegionId: string | null; } + | { type: "visibility-changed"; showAll: boolean; }; +``` + +`PixelArtCanvas` constructs `UVMap` internally with `getCanvasSize: () => canvasBuffer.size()`, so placement/move clamping always tracks the texture's current size, including after a `textureSize` resize. + +## Properties + +### `regions` + +```ts +get regions(): IterableIterator +``` + +Every region, in insertion order. A live view over the internal store (no array copy per access) — `UVMap` itself is also directly iterable (`for (const region of uv)`), backed by the same `Symbol.iterator`. Spread it (`[...uv.regions]`) if you need an actual array (e.g. to sort it, or to iterate it more than once). + +--- + +### `selectedRegionId` + +```ts +get selectedRegionId(): string | null +``` + +The one region currently selected, or `null`. There is exactly one selection at a time, shared between two independent triggers: clicking a visible region while `mode === "uv"`, and an external `select(id)` call (e.g. a consumer's own 3D-scene object-picking, clicking a mesh that a region's UVs were applied to). + +--- + +### `showAll` + +```ts +get showAll(): boolean +set showAll(value: boolean) +``` + +`false` by default. See **Visibility** below. + +## Visibility + +A region renders (and can be hit-tested / dragged in `"uv"` mode) only when: + +```ts +showAll || region.id === selectedRegionId +``` + +**No region is visible by default.** This is deliberate: with several regions on one texture, showing all of them at once clutters the canvas. The intended flow is to reveal one at a time by selecting it (e.g. clicking its corresponding 3D mesh), with `showAll = true` as an explicit opt-in (e.g. a "show all UVs" checkbox) when you actually want to manage several at once. + +> [!IMPORTANT] +> Switching `PixelArtCanvas.mode` away from `"uv"` does **not** change `selectedRegionId` or `showAll` — visibility is independent of mode, so a region revealed via `select(id)` stays visible while painting, panning, etc. Only an in-progress drag is cancelled on mode change (see [PixelArtCanvas.md](../PixelArtCanvas.md#mode)). + +## Methods + +### `create` + +```ts +create(options: UVRegionCreateOptions): UVRegion +``` + +Creates a region at a cascading position (each new region is offset from the last by a fixed step, wrapping into further rows), clamped so its bounding box never exceeds the canvas bounds. `width`/`height` are likewise clamped to the canvas size. Emits `"region-created"`. + +--- + +### `delete` + +```ts +delete(id: string): boolean +``` + +Removes a region and emits `"region-deleted"` with its state just before removal. Returns `false` (no-op, no emission) for an unknown id. Clears `selectedRegionId` if the deleted region was selected. + +--- + +### `move` + +```ts +move(id: string, rect: SelectionRect): boolean +``` + +Repositions a region, clamped to canvas bounds. Emits `"region-moved"` with both the new `region` and the `previousRect`. Returns `false` for an unknown id. + +--- + +### `previewMove` + +```ts +previewMove(id: string, rect: SelectionRect): void +``` + +Emits `"region-dragging"` with a clamped, **transient** rect — no store mutation, no history entry, no network broadcast. Used internally by the built-in `"uv"`-mode drag gesture (`UVController`) so a consumer following the region (e.g. a 3D mesh mirroring it) can update live while the drag is in progress, instead of only snapping into place once the drag commits via `move()` on release. Silently ignores an unknown id. + +> [!IMPORTANT] +> The region's actual `rect` is unchanged until `move()` commits it. If a drag is cancelled (e.g. switching `PixelArtCanvas.mode` away from `"uv"` mid-drag), `UVController` calls `previewMove(id, )` once more so a `"region-dragging"` listener snaps back to the committed state instead of staying stuck on the abandoned preview. + +--- + +### `select` + +```ts +select(id: string | null): void +``` + +Sets `selectedRegionId` (or clears it with `null`). Silently ignores an unknown id. Emits `"selection-changed"` only when the value actually changes. + +--- + +### `restore` + +```ts +restore(region: UVRegion): UVRegion +``` + +Re-adds a region exactly as given — no cascading placement, no palette color assignment. Emits `"region-created"` like `create()` does, so consumers don't need separate handling for it. Used internally to replay undo/redo, apply a remote `"uv-region-created"` command, and hydrate a network snapshot's `uvRegions`; rarely called directly. + +--- + +### `clear` + +```ts +clear(): void +``` + +Deletes every region (each triggers its own `"region-deleted"`) and resets cascading placement. + +--- + +### `on` / `off` + +```ts +on(type: T, listener: UVMapListener): void +off(type: T, listener: UVMapListener): void +``` + +Subscribes/unsubscribes a listener for one event type. A small custom typed pub/sub — not a native `EventTarget`/`CustomEvent`, so listeners get full type inference with no casts. + +## History & network + +Undo/redo and network sync both piggyback on the same events `create`/`delete`/`move` already emit, rather than needing separate replay logic: + +- History: `"uv-create"` / `"uv-delete"` / `"uv-move"` entries in [`HistoryStack`](../history/HistoryStack.md). Undoing/redoing calls the corresponding `UVMap` method internally (e.g. undoing a create calls `delete`), which naturally re-emits the matching event — so a consumer's `"region-created"`/`"region-deleted"`/`"region-moved"` listener (e.g. one that spawns/destroys a mesh per region) needs no undo-specific branch. +- Network: `PixelArtCanvas.onBufferUpdated` fires `"uv-region-created"` / `"uv-region-deleted"` / `"uv-region-moved"` (see [buffer/PixelBuffer.md](../buffer/PixelBuffer.md)) whenever a local (non-remote, non-replaying) change occurs. `PixelSyncServer` resolves move/delete conflicts per region id, parallel to how strokes resolve per pixel; see [network/PixelSyncServer.md](../network/PixelSyncServer.md). + +## Example + +```ts +canvas.uv.on("region-created", ({ region }) => spawnCubeFor(region)); +canvas.uv.on("region-deleted", ({ region }) => destroyCubeFor(region.id)); +canvas.uv.on("region-moved", ({ region }) => updateCubeUVs(region.id, region.rect)); +// Live feedback while dragging in "uv" mode — same handler, uncommitted rect. +canvas.uv.on("region-dragging", ({ id, rect }) => updateCubeUVs(id, rect)); + +// Toolbar "Create" button, fixed 16x16: +createButton.onclick = () => canvas.uv.create({ width: 16, height: 16 }); + +// Toolbar "Delete" button, deletes whatever is selected: +deleteButton.onclick = () => { + const id = canvas.uv.selectedRegionId; + if (id) canvas.uv.delete(id); +}; + +// Clicking a mesh in the 3D scene reveals its UV region on the 2D canvas: +onMeshClicked((regionId) => canvas.uv.select(regionId)); +``` diff --git a/packages/pixel-draw-renderer/docs/uv/UVRegionCollection.md b/packages/pixel-draw-renderer/docs/uv/UVRegionCollection.md new file mode 100644 index 00000000..6adc2ee9 --- /dev/null +++ b/packages/pixel-draw-renderer/docs/uv/UVRegionCollection.md @@ -0,0 +1,15 @@ +# UVRegionCollection + +`UVRegionCollection` is a value object wrapping an id-keyed map of `UVRegion`s, used internally by [`PixelBuffer.uvRegions`](../buffer/PixelBuffer.md#uvregions). It exists to enforce that entries are always keyed by `region.id` (never a mismatched key) and that stored regions are copies, immune to later mutation of the object a caller passed in. + +## Methods + +```ts +get(id: string): UVRegion | undefined +set(region: UVRegion): void +remove(id: string): void +``` + +`get`/`remove` look up or delete by id. `set` stores a copy of `region`, upserting by `region.id`. + +It also implements `Symbol.iterator`, yielding every stored region — use `[...collection]` (as `PixelSyncServer.snapshot()` does) rather than a dedicated "get all" method. diff --git a/packages/pixel-draw-renderer/examples/scripts/components/Cube.ts b/packages/pixel-draw-renderer/examples/scripts/components/Cube.ts index 4054e19b..4e0222a5 100644 --- a/packages/pixel-draw-renderer/examples/scripts/components/Cube.ts +++ b/packages/pixel-draw-renderer/examples/scripts/components/Cube.ts @@ -5,13 +5,49 @@ import { } from "@jolly-pixel/engine"; import * as THREE from "three"; +// Import Internal Dependencies +import type { UVRegion } from "../../../src/uv/UVRegion.ts"; +import type { SelectionRect, Vec2 } from "../../../src/types.ts"; + export interface CubeBehaviorOptions { canvasTexture: THREE.CanvasTexture; + region: UVRegion; + textureSize: Vec2; } +/** + * A rotating test cube mapped to one UV region, so region placement/move + * can be visually verified. Face assignment is out of scope for this + * version: the region's rect is applied uniformly to all 6 faces. + */ +// CONSTANTS +const kCubeSize = 1.5; +const kHighlightScale = 1.06; +// Both in rad/sec. +const kRotationSpeedX = 0.3; +const kRotationSpeedY = 0.6; +// Higher = snaps into position faster. Frame-rate independent (see update()). +const kPositionLerpRate = 6; + export class CubeBehavior extends ActorComponent { mesh: THREE.Mesh; canvasTexture: THREE.CanvasTexture; + readonly regionId: string; + + #highlightMesh: THREE.Mesh; + /** + * Snapshot of BoxGeometry's pristine per-vertex UV (every component + * exactly 0 or 1), captured once before the first remap. Every later + * remap (e.g. after a move) derives corner identity from this snapshot, + * never from the geometry's current (already-remapped) UV attribute — + * which no longer contains exact 0/1 values after the first call. + */ + #baseUV: Float32Array; + /** + * Where the actor eases toward each frame (see `setTargetPosition`), + * instead of snapping — set by the demo's grid layout in main.ts. + */ + #targetPosition: THREE.Vector3; constructor( actor: Actor, @@ -22,22 +58,115 @@ export class CubeBehavior extends ActorComponent { typeName: "CubeBehavior" }); - const { canvasTexture } = options; + const { canvasTexture, region, textureSize } = options; + this.regionId = region.id; this.canvasTexture = canvasTexture; + + const geometry = new THREE.BoxGeometry(kCubeSize, kCubeSize, kCubeSize); + this.#baseUV = Float32Array.from( + (geometry.attributes.uv as THREE.BufferAttribute).array + ); + this.mesh = new THREE.Mesh( - new THREE.BoxGeometry(1.5, 1.5, 1.5), + geometry, new THREE.MeshStandardMaterial({ map: canvasTexture, transparent: true }) ); - this.actor.addChildren(this.mesh); + this.mesh.userData.regionId = region.id; + this.#applyRegionUV(region.rect, textureSize); + + // Backface-rendered, slightly larger shell: a cheap, always-visible + // "selected" outline that doesn't depend on the (possibly bright/white) + // texture for contrast, unlike an emissive tint would. Uses the + // region's own color (same one driving its 2D SVG overlay border), so + // a cube visually matches the region it maps — same reasoning as + // UVOverlay in the library itself. + const highlightSize = kCubeSize * kHighlightScale; + this.#highlightMesh = new THREE.Mesh( + new THREE.BoxGeometry(highlightSize, highlightSize, highlightSize), + new THREE.MeshBasicMaterial({ + color: region.color, + side: THREE.BackSide + }) + ); + this.#highlightMesh.visible = false; + + this.actor.addChildren(this.mesh, this.#highlightMesh); + this.#targetPosition = this.actor.object3D.position.clone(); } - update() { - this.mesh.rotation.x += 0.005; - this.mesh.rotation.y += 0.01; + /** + * Eases the actor toward `position` over the next few frames instead of + * snapping, so the demo's grid re-centering (main.ts) reads as a smooth + * reflow rather than a jump cut. + */ + setTargetPosition( + position: THREE.Vector3 + ): void { + this.#targetPosition.copy(position); + } + + /** + * Updates the mapped rect, either the region's committed position (on + * "region-moved") or a live drag preview (on "region-dragging") — both + * are a plain rect, so the same path drives live and committed updates. + */ + updateRect( + rect: SelectionRect, + textureSize: Vec2 + ): void { + this.#applyRegionUV(rect, textureSize); + } + + setSelected( + selected: boolean + ): void { + this.#highlightMesh.visible = selected; + } + + update( + deltaTime: number + ): void { + // Rotate the actor itself (not the mesh directly) so the highlight + // shell, a sibling under the same actor, spins in lockstep. + this.actor.object3D.rotation.x += kRotationSpeedX * deltaTime; + this.actor.object3D.rotation.y += kRotationSpeedY * deltaTime; + + // Exponential ease toward the target — frame-rate independent, unlike + // a flat `lerp(target, constant)` would be. + const alpha = 1 - Math.exp(-kPositionLerpRate * deltaTime); + this.actor.object3D.position.lerp(this.#targetPosition, alpha); + this.canvasTexture.needsUpdate = true; } + + /** + * Remaps BoxGeometry's default per-face 0..1 UV unwrap onto the given + * rect (normalized against `textureSize`), using `#baseUV` (the pristine + * snapshot) to identify each vertex's corner — never the geometry's + * current UV attribute, which no longer holds exact 0/1 values after the + * first remap. Works regardless of vertex order this way, repeatably. V + * is flipped (canvas Y grows downward, texture V grows upward for a + * default-flipY CanvasTexture). + */ + #applyRegionUV( + rect: SelectionRect, + textureSize: Vec2 + ): void { + const uvAttr = this.mesh.geometry.attributes.uv as THREE.BufferAttribute; + const u0 = rect.x / textureSize.x; + const u1 = (rect.x + rect.width) / textureSize.x; + const v0 = 1 - ((rect.y + rect.height) / textureSize.y); + const v1 = 1 - (rect.y / textureSize.y); + + for (let i = 0; i < uvAttr.count; i++) { + const u = this.#baseUV[i * 2]; + const v = this.#baseUV[(i * 2) + 1]; + uvAttr.setXY(i, u === 0 ? u0 : u1, v === 0 ? v0 : v1); + } + uvAttr.needsUpdate = true; + } } diff --git a/packages/pixel-draw-renderer/examples/scripts/components/OrbitControlsBehavior.ts b/packages/pixel-draw-renderer/examples/scripts/components/OrbitControlsBehavior.ts new file mode 100644 index 00000000..0a66dedc --- /dev/null +++ b/packages/pixel-draw-renderer/examples/scripts/components/OrbitControlsBehavior.ts @@ -0,0 +1,60 @@ +// Import Third-party Dependencies +import { + type Actor, + ActorComponent +} from "@jolly-pixel/engine"; +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; + +export interface OrbitControlsBehaviorOptions { + camera: THREE.Camera; + /** @default (0, 0, 0) */ + target?: THREE.Vector3; + /** @default 3 */ + minDistance?: number; + /** @default 30 */ + maxDistance?: number; +} + +/** + * Damped drag-to-orbit / scroll-to-zoom camera control, so every face of + * the test cubes can actually be inspected (the camera used to be static). + * A short, no-movement click still reaches the canvas as a native "click" + * event afterwards, so cube picking (see main.ts) keeps working alongside + * a drag-to-rotate gesture. + */ +export class OrbitControlsBehavior extends ActorComponent { + readonly controls: OrbitControls; + + constructor( + actor: Actor, + options: OrbitControlsBehaviorOptions + ) { + super({ + actor, + typeName: "OrbitControlsBehavior" + }); + + this.controls = new OrbitControls( + options.camera, + actor.world.renderer.canvas + ); + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.08; + this.controls.minDistance = options.minDistance ?? 3; + this.controls.maxDistance = options.maxDistance ?? 30; + if (options.target) { + this.controls.target.copy(options.target); + } + this.controls.update(); + } + + update(): void { + this.controls.update(); + } + + override destroy(): void { + this.controls.dispose(); + super.destroy(); + } +} diff --git a/packages/pixel-draw-renderer/examples/scripts/main.ts b/packages/pixel-draw-renderer/examples/scripts/main.ts index 9459d8f9..2f9027fc 100644 --- a/packages/pixel-draw-renderer/examples/scripts/main.ts +++ b/packages/pixel-draw-renderer/examples/scripts/main.ts @@ -6,6 +6,7 @@ import { ResizeHandle } from "@jolly-pixel/resize-handle"; // Import Internal Dependencies import { CameraBehavior } from "./components/Camera.ts"; import { CubeBehavior } from "./components/Cube.ts"; +import { OrbitControlsBehavior } from "./components/OrbitControlsBehavior.ts"; import { type PixelDrawPanel } from "./ui/PixelDrawPanel.ts"; const runtime = await initRuntime(); @@ -25,27 +26,36 @@ async function initRuntime(): Promise { const { world } = runtime; const scene = world.sceneManager.getSource(); - scene.background = new THREE.Color("#e0f1fa"); + scene.background = new THREE.Color("#eef3f7"); + + const keyLight = new THREE.DirectionalLight(0xffffff, 1.8); + keyLight.position.set(5, 10, 7); + + // Cool-toned fill from the opposite side, so faces facing away from the + // key light aren't flat black — matters now that orbiting lets every + // face be seen (see OrbitControlsBehavior). + const fillLight = new THREE.DirectionalLight(0xaeccff, 0.5); + fillLight.position.set(-6, 3, -4); - const dirLight = new THREE.DirectionalLight(0xffffff, 1.5); - dirLight.position.set(5, 10, 7); scene.add( - new THREE.AmbientLight(0xffffff, 1.5), - dirLight + new THREE.HemisphereLight(0xffffff, 0x3a4750, 1.0), + keyLight, + fillLight ); const drawPanel = document.querySelector("pixel-draw-panel")!; const canvasManager = await drawPanel.initialize({ texture: { size: { - x: 16, - y: 16 + x: 80, + y: 80 } }, defaultMode: "paint", backgroundColor: "#263238", zoom: { - default: 16, + // No `default`: PixelArtCanvas computes one that fits the whole + // texture inside the panel's initial size (see PixelArtCanvas.md). min: 1, max: 32, sensitivity: 1 @@ -62,11 +72,102 @@ async function initRuntime(): Promise { canvasTexture.magFilter = THREE.NearestFilter; canvasTexture.minFilter = THREE.NearestFilter; - world.createActor("camera") - .addComponent(CameraBehavior); + const cameraBehavior = world.createActor("camera") + .addComponentAndGet(CameraBehavior); + + // Drag to orbit, scroll to zoom — lets every face of a cube actually be + // inspected instead of only the two the static camera used to show. + world.createActor("orbit-controls").addComponentAndGet(OrbitControlsBehavior, { + camera: cameraBehavior.camera, + target: new THREE.Vector3(0, 0, 0), + minDistance: 3, + maxDistance: 30 + }); + + // One test cube per UV region, so placement/move can be visually verified. + // Face assignment is out of scope for this version: a region's rect is + // applied uniformly to all 6 faces (see CubeBehavior.applyRegionUV). + const cubes = new Map(); + + // Recomputes every cube's target position as a centered, near-square + // grid — re-run on every create/delete so the cluster (1 cube or many) + // always sits centered on the origin, not just column-centered against + // a fixed column count. CubeBehavior eases toward the new target itself + // (see setTargetPosition), so a reflow reads as a smooth glide. + const kGridSpacing = 2.4; + + function relayoutCubes(): void { + const entries = [...cubes.values()]; + if (entries.length === 0) { + return; + } + + const columns = Math.max(1, Math.ceil(Math.sqrt(entries.length))); + const rows = Math.ceil(entries.length / columns); + const centerCol = (columns - 1) / 2; + const centerRow = (rows - 1) / 2; + + entries.forEach((cube, index) => { + const col = index % columns; + const row = Math.floor(index / columns); + cube.setTargetPosition(new THREE.Vector3( + (col - centerCol) * kGridSpacing, + (centerRow - row) * kGridSpacing, + 0 + )); + }); + } - world.createActor("cube") - .addComponent(CubeBehavior, { canvasTexture }); + canvasManager.uv.on("region-created", ({ region }) => { + const cube = world.createActor(`uv-cube-${region.id}`).addComponentAndGet(CubeBehavior, { + canvasTexture, + region, + textureSize: canvasManager.textureSize + }); + cubes.set(region.id, cube); + relayoutCubes(); + }); + canvasManager.uv.on("region-deleted", ({ region }) => { + cubes.get(region.id)?.actor.destroy(); + cubes.delete(region.id); + relayoutCubes(); + }); + canvasManager.uv.on("region-moved", ({ region }) => { + cubes.get(region.id)?.updateRect(region.rect, canvasManager.textureSize); + }); + // Fires continuously while a drag is in progress (never recorded to + // history or broadcast — see UVMap.previewMove), so the cube's texture + // mapping updates live instead of only snapping into place on drop. + canvasManager.uv.on("region-dragging", ({ id, rect }) => { + cubes.get(id)?.updateRect(rect, canvasManager.textureSize); + }); + canvasManager.uv.on("selection-changed", ({ selectedRegionId }) => { + for (const [regionId, cube] of cubes) { + cube.setSelected(regionId === selectedRegionId); + } + }); + + // Seed one region (and its cube) so there's something to see/test + // immediately, instead of starting from an empty scene. + const initialRegion = canvasManager.uv.create({ width: 16, height: 16 }); + canvasManager.uv.select(initialRegion.id); + + // Clicking a cube in the 3D scene reveals its UV region on the 2D canvas, + // regardless of the canvas's current mode (see uv/UVMap.md — visibility + // is independent of mode). Clicking empty space in the 3D scene deselects, + // mirroring a miss-click on the 2D canvas in "uv" mode. + const raycaster = new THREE.Raycaster(); + const pointerNdc = new THREE.Vector2(); + canvas.addEventListener("click", (event) => { + const bounds = canvas.getBoundingClientRect(); + pointerNdc.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; + pointerNdc.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1; + + raycaster.setFromCamera(pointerNdc, cameraBehavior.camera); + const meshes = [...cubes.values()].map((cube) => cube.mesh); + const [hit] = raycaster.intersectObjects(meshes); + canvasManager.uv.select(hit ? hit.object.userData.regionId as string : null); + }); world.renderer.on("resize", () => { drawPanel.onResize(); diff --git a/packages/pixel-draw-renderer/examples/scripts/ui/PixelDrawPanel.ts b/packages/pixel-draw-renderer/examples/scripts/ui/PixelDrawPanel.ts index 61394c2a..afea03b2 100644 --- a/packages/pixel-draw-renderer/examples/scripts/ui/PixelDrawPanel.ts +++ b/packages/pixel-draw-renderer/examples/scripts/ui/PixelDrawPanel.ts @@ -16,9 +16,14 @@ const kModeItems: { mode: Mode; icon: IconName; label: string; }[] = [ { mode: "move", icon: "move", label: "Move" }, { mode: "paint", icon: "paint", label: "Paint" }, { mode: "fill", icon: "fill", label: "Fill" }, - { mode: "select", icon: "select", label: "Select" } + { mode: "select", icon: "select", label: "Select" }, + { mode: "uv", icon: "uv", label: "UV" } ]; +// Fixed creation size for the demo's "Create" button; the library itself +// takes an arbitrary width/height via `uv.create({ width, height })`. +const kUvCreateSize = { width: 16, height: 16 }; + @customElement("pixel-draw-panel") export class PixelDrawPanel extends LitElement { static override styles = css` @@ -237,6 +242,45 @@ export class PixelDrawPanel extends LitElement { background: #4488ff; border-color: #4488ff; } + + .uv-toolbar { + position: absolute; + bottom: 8px; + left: 50%; + z-index: 2; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px; + border-radius: 12px; + background: rgba(30, 38, 43, 0.85); + color: #eee; + font-size: 11px; + font-family: sans-serif; + user-select: none; + transform: translateX(-50%); + } + + .uv-toolbar button { + padding: 3px 10px; + border: 1px solid #556067; + border-radius: 10px; + background: transparent; + color: #eee; + font-size: 11px; + cursor: pointer; + } + .uv-toolbar button:disabled { + color: #556067; + cursor: default; + } + + .uv-toolbar label { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + } `; // Plain private fields, not @state(): Lit's legacy decorators can't target @@ -251,6 +295,8 @@ export class PixelDrawPanel extends LitElement { #background: ColorChangeDetail = { hex: "#ffffff", opacity: 1 }; #canUndo = false; #canRedo = false; + #uvSelectedRegionId: string | null = null; + #uvShowAll = false; #canvasManager: PixelArtCanvas | null = null; get canvasManager(): PixelArtCanvas | null { @@ -300,6 +346,16 @@ export class PixelDrawPanel extends LitElement { }; this.#canUndo = this.#canvasManager.canUndo(); this.#canRedo = this.#canvasManager.canRedo(); + this.#uvSelectedRegionId = this.#canvasManager.uv.selectedRegionId; + this.#uvShowAll = this.#canvasManager.uv.showAll; + this.#canvasManager.uv.on("selection-changed", ({ selectedRegionId }) => { + this.#uvSelectedRegionId = selectedRegionId; + this.requestUpdate(); + }); + this.#canvasManager.uv.on("visibility-changed", ({ showAll }) => { + this.#uvShowAll = showAll; + this.requestUpdate(); + }); this.requestUpdate(); this.#syncForegroundSwatch(); this.#syncBackgroundSwatch(); @@ -377,6 +433,22 @@ export class PixelDrawPanel extends LitElement { this.requestUpdate(); } + #onUvCreate(): void { + this.#canvasManager?.uv.create(kUvCreateSize); + } + + #onUvDelete(): void { + if (this.#uvSelectedRegionId) { + this.#canvasManager?.uv.delete(this.#uvSelectedRegionId); + } + } + + #onUvShowAllToggle(): void { + if (this.#canvasManager) { + this.#canvasManager.uv.showAll = !this.#canvasManager.uv.showAll; + } + } + #onForegroundChange( event: CustomEvent ): void { @@ -504,6 +576,23 @@ export class PixelDrawPanel extends LitElement { return nothing; } + #renderUVToolbar() { + if (this.#mode !== "uv") { + return nothing; + } + + return html` +
+ + + +
+ `; + } + override render() { return html`
@@ -581,6 +670,7 @@ export class PixelDrawPanel extends LitElement {
${this.#renderToolOptions()} + ${this.#renderUVToolbar()}
`; } diff --git a/packages/pixel-draw-renderer/examples/scripts/ui/icons.ts b/packages/pixel-draw-renderer/examples/scripts/ui/icons.ts index c18a27f5..0ccc1b0a 100644 --- a/packages/pixel-draw-renderer/examples/scripts/ui/icons.ts +++ b/packages/pixel-draw-renderer/examples/scripts/ui/icons.ts @@ -2,7 +2,7 @@ // Import Third-party Dependencies import { svg, type SVGTemplateResult } from "lit"; -export type IconName = "move" | "paint" | "fill" | "select" | "undo" | "redo" | "swap" | "eyedropper"; +export type IconName = "move" | "paint" | "fill" | "select" | "uv" | "undo" | "redo" | "swap" | "eyedropper"; // CONSTANTS const kIcons: Record = { @@ -39,6 +39,13 @@ const kIcons: Record = { `, + // UV grid: a 2x2 quad layout, two cells dashed to suggest placed regions. + uv: svg` + + + + + `, undo: svg` diff --git a/packages/pixel-draw-renderer/src/PixelArtCanvas.ts b/packages/pixel-draw-renderer/src/PixelArtCanvas.ts index f6b589f5..1f3d16ca 100644 --- a/packages/pixel-draw-renderer/src/PixelArtCanvas.ts +++ b/packages/pixel-draw-renderer/src/PixelArtCanvas.ts @@ -17,6 +17,9 @@ import { import { CanvasRenderer } from "./rendering/CanvasRenderer.ts"; +import { + CursorController +} from "./rendering/CursorController.ts"; import { HistoryController, type HistoryState @@ -46,7 +49,10 @@ import type { import { SyncController } from "./sync/SyncController.ts"; +import { UVMap } from "./uv/UVMap.ts"; +import type { UVRegion } from "./uv/UVRegion.ts"; import { toRGBA } from "./utils/colors.ts"; +import { clamp } from "./utils/math.ts"; import type { BrushHighlight, ColorInput, @@ -82,6 +88,12 @@ export interface PixelArtCanvasOptions { maxSize?: number; init?: HTMLCanvasElement; }; + /** + * When `zoom.default` is omitted, it's computed to fit the whole texture + * inside the container's initial size instead of `Zoom`'s own flat + * default of 4 — a large texture in a small container no longer starts + * zoomed in past what's visible. Pass an explicit `default` to opt out. + */ zoom?: ZoomOptions; backgroundTransparency?: { colors: { odd: string; even: string; }; @@ -141,9 +153,11 @@ export class PixelArtCanvas { #history: HistoryController; #mode: Mode; #tools: ToolControllers; + #cursor: CursorController; readonly brush: Brush; readonly viewport: DefaultViewport; + readonly uv: UVMap; constructor( parentHtmlElement: HTMLDivElement, @@ -160,9 +174,16 @@ export class PixelArtCanvas { ? { x: options.texture.size.x, y: options.texture.size.y ?? options.texture.size.x } : { x: 64, y: 32 }; + const initialBounds = parentHtmlElement.getBoundingClientRect(); + const zoomDefault = options.zoom?.default ?? PixelArtCanvas.#computeFitZoom( + initialBounds, + textureSize, + { min: options.zoom?.min, max: options.zoom?.max } + ); + this.#viewport = new Viewport({ textureSize, - zoom: options.zoom?.default, + zoom: zoomDefault, zoomMin: options.zoom?.min, zoomMax: options.zoom?.max, zoomSensitivity: options.zoom?.sensitivity @@ -179,7 +200,11 @@ export class PixelArtCanvas { this.#canvasBuffer.loadTexture(options.texture.init); } - this.#history = new HistoryController(this.#canvasBuffer, { + this.uv = new UVMap({ + getCanvasSize: () => this.#canvasBuffer.size() + }); + + this.#history = new HistoryController(this.#canvasBuffer, this.uv, { enabled: options.history?.enabled, limit: options.history?.limit, onChange: options.onHistoryChange @@ -205,6 +230,7 @@ export class PixelArtCanvas { viewport: this.#viewport, renderer: this.#renderer, history: this.#history, + uvMap: this.uv, onBufferUpdated: options.onBufferUpdated, onDrawEnd: options.onDrawEnd }); @@ -236,7 +262,8 @@ export class PixelArtCanvas { this.#svgManager = new SvgManager({ parent: parentHtmlElement, viewport: viewportRef, - brush: brushAdapter + brush: brushAdapter, + uvMap: this.uv }); this.#tools = new ToolControllers({ @@ -246,6 +273,8 @@ export class PixelArtCanvas { linePreview: this.#svgManager.linePreview, selectionOverlay: this.#svgManager.selection, eraseColor, + uvMap: this.uv, + uvOverlay: this.#svgManager.uvOverlay, onStrokeCommit: (pixels, color, beforeColors) => { this.#sync.recordHistory({ action: "stroke", @@ -283,6 +312,11 @@ export class PixelArtCanvas { } }); + this.#cursor = new CursorController({ + renderer: this.#renderer, + tools: this.#tools + }); + this.#input = new InputController({ canvas: this.#renderer.canvas(), viewport: this.#viewport, @@ -291,6 +325,7 @@ export class PixelArtCanvas { ...createInputActions({ getMode: () => this.#mode, renderer: this.#renderer, + cursor: this.#cursor, svgManager: this.#svgManager, viewport: this.#viewport, tools: this.#tools, @@ -323,6 +358,10 @@ export class PixelArtCanvas { if (mode !== "paint") { this.#tools.brush.pickArmed = false; } + if (mode !== "uv") { + this.#tools.uv.cancelDrag(); + } + this.#cursor.refresh(mode); } /** @@ -500,6 +539,14 @@ export class PixelArtCanvas { this.#renderer.resize(bounds.width, bounds.height); this.#viewport.resizeCanvas(bounds.width, bounds.height); this.#svgManager.resize(bounds.width, bounds.height); + + // resizeCanvas() shifts the camera to keep content centered; every SVG + // overlay computed from the old camera position must redraw itself + // against the new one, same as after a pan/zoom (see createInputActions). + this.#tools.line.refreshPreview(); + this.#tools.select.refreshOverlay(); + this.#svgManager.uvOverlay.refresh(); + this.#renderer.drawFrame(); } @@ -595,13 +642,13 @@ export class PixelArtCanvas { * Reverts the latest local edit. */ undo(): boolean { - const entry = this.#history.undo(); + const entry = this.#sync.runHistoryReplay(() => this.#history.undo()); if (!entry) { return false; } this.#refreshAfterHistoryApply(); - if (entry.action === "select-edit") { + if (entry.action === "select-edit" && this.#mode === "select") { this.#tools.select.syncSelectionAfterHistory( entry.oldRect, entry.oldMask @@ -619,13 +666,16 @@ export class PixelArtCanvas { * Reapplies the latest reverted edit. */ redo(): boolean { - const entry = this.#history.redo(); + const entry = this.#sync.runHistoryReplay(() => this.#history.redo()); if (!entry) { return false; } this.#refreshAfterHistoryApply(); - if (entry.action === "select-edit") { + if ( + entry.action === "select-edit" && + this.#mode === "select" + ) { this.#tools.select.syncSelectionAfterHistory( entry.newRect, entry.newMask @@ -670,9 +720,10 @@ export class PixelArtCanvas { */ loadSnapshot( size: Vec2, - pixels: Uint8ClampedArray + pixels: Uint8ClampedArray, + uvRegions: UVRegion[] = [] ): void { - this.#sync.loadSnapshot(size, pixels); + this.#sync.loadSnapshot(size, pixels, uvRegions); } #refreshAfterHistoryApply(): void { @@ -681,4 +732,34 @@ export class PixelArtCanvas { ); this.#renderer.drawFrame(); } + + /** + * Picks a default zoom that fits the whole texture inside the container's + * initial size, so a large texture in a small container doesn't start + * zoomed in past what's visible. Only used when `zoom.default` is + * omitted; an explicit value always wins. Falls back to `Zoom`'s own + * default (4) when the container has no measurable size yet (e.g. + * `display: none`). + */ + static #computeFitZoom( + containerSize: { width: number; height: number; }, + textureSize: Vec2, + zoomBounds: { min?: number; max?: number; } + ): number { + const zoomMin = zoomBounds.min ?? 1; + const zoomMax = zoomBounds.max ?? 32; + + if (containerSize.width <= 0 || containerSize.height <= 0) { + return clamp(4, zoomMin, zoomMax); + } + + // Leaves a small margin so the texture isn't flush against the edges. + const kFitPadding = 0.9; + const fit = Math.min( + containerSize.width / textureSize.x, + containerSize.height / textureSize.y + ) * kFitPadding; + + return clamp(Math.floor(fit), zoomMin, zoomMax); + } } diff --git a/packages/pixel-draw-renderer/src/buffer/PixelBuffer.ts b/packages/pixel-draw-renderer/src/buffer/PixelBuffer.ts index cc0c7f35..27354875 100644 --- a/packages/pixel-draw-renderer/src/buffer/PixelBuffer.ts +++ b/packages/pixel-draw-renderer/src/buffer/PixelBuffer.ts @@ -2,6 +2,7 @@ import { toRGBA } from "../utils/colors.ts"; +import { UVRegionCollection } from "../uv/UVRegionCollection.ts"; import type { ColorInput, RGBA, @@ -35,7 +36,7 @@ const kDefaultColor: RGBA = { }; /** - * Stores raw RGBA pixel data without DOM APIs. + * Stores raw RGBA pixel data and UV regions without DOM APIs. */ export class PixelBuffer implements DefaultPixelBuffer { #width: number; @@ -44,6 +45,8 @@ export class PixelBuffer implements DefaultPixelBuffer { #master: Uint8ClampedArray; #working: Uint8ClampedArray; + readonly uvRegions = new UVRegionCollection(); + constructor( options: PixelBufferOptions ) { diff --git a/packages/pixel-draw-renderer/src/buffer/hooks.ts b/packages/pixel-draw-renderer/src/buffer/hooks.ts index 9f7c2471..67689c75 100644 --- a/packages/pixel-draw-renderer/src/buffer/hooks.ts +++ b/packages/pixel-draw-renderer/src/buffer/hooks.ts @@ -1,8 +1,10 @@ // Import Internal Dependencies import type { RGBA, + SelectionRect, Vec2 } from "../types.ts"; +import type { UVRegion } from "../uv/UVRegion.ts"; /** * @notes @@ -40,6 +42,28 @@ export type PixelBufferHookEvent = toColor: RGBA; }; originTimestamp?: number; + } + | { + action: "uv-region-created"; + metadata: { + region: UVRegion; + }; + originTimestamp?: number; + } + | { + action: "uv-region-deleted"; + metadata: { + id: string; + }; + originTimestamp?: number; + } + | { + action: "uv-region-moved"; + metadata: { + id: string; + rect: SelectionRect; + }; + originTimestamp?: number; }; export type PixelBufferHookAction = PixelBufferHookEvent["action"]; diff --git a/packages/pixel-draw-renderer/src/history/HistoryController.ts b/packages/pixel-draw-renderer/src/history/HistoryController.ts index 1bf2f7a8..4a14943f 100644 --- a/packages/pixel-draw-renderer/src/history/HistoryController.ts +++ b/packages/pixel-draw-renderer/src/history/HistoryController.ts @@ -3,10 +3,12 @@ import { fromUint8Array } from "js-base64"; // Import Internal Dependencies import { - HistoryStack, - type HistoryEntry, - type HistoryEntryInput + HistoryStack } from "./HistoryStack.ts"; +import type { + HistoryEntry, + HistoryEntryInput +} from "./HistoryStack.types.ts"; import { groupPositionsByColor } from "./utils.ts"; @@ -16,6 +18,7 @@ import type { import type { PixelBufferHookEvent } from "../buffer/hooks.ts"; +import type { UVMap } from "../uv/UVMap.ts"; export interface HistoryState { canUndo: boolean; @@ -140,10 +143,11 @@ export class HistoryController { constructor( buffer: DefaultPixelBuffer, + uvMap: UVMap, options: HistoryControllerOptions = {} ) { if (options.enabled) { - this.#stack = new HistoryStack(buffer, { + this.#stack = new HistoryStack(buffer, uvMap, { limit: options.limit }); } diff --git a/packages/pixel-draw-renderer/src/history/HistoryStack.ts b/packages/pixel-draw-renderer/src/history/HistoryStack.ts index bcda4c20..18495b41 100644 --- a/packages/pixel-draw-renderer/src/history/HistoryStack.ts +++ b/packages/pixel-draw-renderer/src/history/HistoryStack.ts @@ -1,68 +1,18 @@ // Import Internal Dependencies -import type { - RGBA, - SelectionRect, - Vec2 -} from "../types.ts"; import type { DefaultPixelBuffer } from "../buffer/types.ts"; +import type { UVMap } from "../uv/UVMap.ts"; +import type { + HistoryEntry, + HistoryEntryInput +} from "./HistoryStack.types.ts"; import { groupPositionsByColor } from "./utils.ts"; -export interface HistoryStrokeEntry { - action: "stroke"; - timestamp: number; - positions: Vec2[]; - beforeColors: RGBA[]; - afterColor: RGBA; -} - -export interface HistoryResizedEntry { - action: "resized"; - timestamp: number; - beforeSize: Vec2; - beforePixels: Uint8ClampedArray; - afterSize: Vec2; - afterPixels: Uint8ClampedArray; -} - -export interface HistoryTextureReplacedEntry { - action: "texture-replaced"; - timestamp: number; - beforeSize: Vec2; - beforePixels: Uint8ClampedArray; - afterSize: Vec2; - afterPixels: Uint8ClampedArray; -} - -/** - * Stores pixels and selection state before and after a selection edit. - */ -export interface HistorySelectEditEntry { - action: "select-edit"; - timestamp: number; - positions: Vec2[]; - beforeColors: RGBA[]; - afterColors: RGBA[]; - oldRect: SelectionRect; - newRect: SelectionRect; - oldMask: boolean[]; - newMask: boolean[]; -} - -export type HistoryEntry = - | HistoryStrokeEntry - | HistoryResizedEntry - | HistoryTextureReplacedEntry - | HistorySelectEditEntry; - -export type HistoryEntryInput = - | Omit - | Omit - | Omit - | Omit; +// CONSTANTS +const kDefaultLimit = 10; export interface HistoryStackOptions { /** @@ -71,23 +21,23 @@ export interface HistoryStackOptions { limit?: number; } -// CONSTANTS -const kDefaultLimit = 10; - /** * Replays a bounded undo/redo stack against a pixel buffer. */ export class HistoryStack { #buffer: DefaultPixelBuffer; + #uvMap: UVMap; #limit: number; #undoStack: HistoryEntry[] = []; #redoStack: HistoryEntry[] = []; constructor( buffer: DefaultPixelBuffer, + uvMap: UVMap, options: HistoryStackOptions = {} ) { this.#buffer = buffer; + this.#uvMap = uvMap; this.#limit = options.limit ?? kDefaultLimit; } @@ -99,9 +49,6 @@ export class HistoryStack { return this.#redoStack.length > 0; } - /** - * Records an entry with its creation timestamp. - */ push( entry: HistoryEntryInput ): void { @@ -149,23 +96,59 @@ export class HistoryStack { entry: HistoryEntry ): void { switch (entry.action) { - case "stroke": - for (const group of groupPositionsByColor(entry.positions, entry.beforeColors)) { - this.#buffer.drawPixels(group.positions, group.color); + case "stroke": { + const groupedColors = groupPositionsByColor( + entry.positions, + entry.beforeColors + ); + for (const group of groupedColors) { + this.#buffer.drawPixels( + group.positions, + group.color + ); } + this.#buffer.copyToMaster(); break; - - case "select-edit": - for (const group of groupPositionsByColor(entry.positions, entry.beforeColors)) { - this.#buffer.drawPixels(group.positions, group.color); + } + + case "select-edit": { + const groupedColors = groupPositionsByColor( + entry.positions, + entry.beforeColors + ); + for (const group of groupedColors) { + this.#buffer.drawPixels( + group.positions, + group.color + ); } + this.#buffer.copyToMaster(); break; + } case "resized": case "texture-replaced": - this.#buffer.replacePixels(entry.beforePixels, entry.beforeSize); + this.#buffer.replacePixels( + entry.beforePixels, + entry.beforeSize + ); + break; + + case "uv-create": + this.#uvMap.delete(entry.region.id); + break; + + case "uv-delete": + this.#uvMap.restore(entry.region); + break; + + case "uv-move": + this.#uvMap.move( + entry.id, + entry.oldRect + ); break; } } @@ -175,20 +158,50 @@ export class HistoryStack { ): void { switch (entry.action) { case "stroke": - this.#buffer.drawPixels(entry.positions, entry.afterColor); + this.#buffer.drawPixels( + entry.positions, + entry.afterColor + ); this.#buffer.copyToMaster(); break; - case "select-edit": - for (const group of groupPositionsByColor(entry.positions, entry.afterColors)) { - this.#buffer.drawPixels(group.positions, group.color); + case "select-edit": { + const groupedColors = groupPositionsByColor( + entry.positions, + entry.afterColors + ); + for (const group of groupedColors) { + this.#buffer.drawPixels( + group.positions, + group.color + ); } + this.#buffer.copyToMaster(); break; + } case "resized": case "texture-replaced": - this.#buffer.replacePixels(entry.afterPixels, entry.afterSize); + this.#buffer.replacePixels( + entry.afterPixels, + entry.afterSize + ); + break; + + case "uv-create": + this.#uvMap.restore(entry.region); + break; + + case "uv-delete": + this.#uvMap.delete(entry.region.id); + break; + + case "uv-move": + this.#uvMap.move( + entry.id, + entry.newRect + ); break; } } diff --git a/packages/pixel-draw-renderer/src/history/HistoryStack.types.ts b/packages/pixel-draw-renderer/src/history/HistoryStack.types.ts new file mode 100644 index 00000000..a547a39e --- /dev/null +++ b/packages/pixel-draw-renderer/src/history/HistoryStack.types.ts @@ -0,0 +1,86 @@ +// Import Internal Dependencies +import type { + RGBA, + SelectionRect, + Vec2 +} from "../types.ts"; +import type { UVRegion } from "../uv/UVRegion.ts"; + +export interface HistoryStrokeEntry { + action: "stroke"; + timestamp: number; + positions: Vec2[]; + beforeColors: RGBA[]; + afterColor: RGBA; +} + +export interface HistoryResizedEntry { + action: "resized"; + timestamp: number; + beforeSize: Vec2; + beforePixels: Uint8ClampedArray; + afterSize: Vec2; + afterPixels: Uint8ClampedArray; +} + +export interface HistoryTextureReplacedEntry { + action: "texture-replaced"; + timestamp: number; + beforeSize: Vec2; + beforePixels: Uint8ClampedArray; + afterSize: Vec2; + afterPixels: Uint8ClampedArray; +} + +/** + * Stores pixels and selection state before and after a selection edit. + */ +export interface HistorySelectEditEntry { + action: "select-edit"; + timestamp: number; + positions: Vec2[]; + beforeColors: RGBA[]; + afterColors: RGBA[]; + oldRect: SelectionRect; + newRect: SelectionRect; + oldMask: boolean[]; + newMask: boolean[]; +} + +export interface HistoryUvCreateEntry { + action: "uv-create"; + timestamp: number; + region: UVRegion; +} + +export interface HistoryUvDeleteEntry { + action: "uv-delete"; + timestamp: number; + region: UVRegion; +} + +export interface HistoryUvMoveEntry { + action: "uv-move"; + timestamp: number; + id: string; + oldRect: SelectionRect; + newRect: SelectionRect; +} + +export type HistoryEntry = + | HistoryStrokeEntry + | HistoryResizedEntry + | HistoryTextureReplacedEntry + | HistorySelectEditEntry + | HistoryUvCreateEntry + | HistoryUvDeleteEntry + | HistoryUvMoveEntry; + +export type HistoryEntryInput = + | Omit + | Omit + | Omit + | Omit + | Omit + | Omit + | Omit; diff --git a/packages/pixel-draw-renderer/src/index.ts b/packages/pixel-draw-renderer/src/index.ts index 2788939c..857cd192 100644 --- a/packages/pixel-draw-renderer/src/index.ts +++ b/packages/pixel-draw-renderer/src/index.ts @@ -20,13 +20,15 @@ export type { } from "./buffer/hooks.ts"; export { HistoryStack, - type HistoryEntry, - type HistoryEntryInput, - type HistoryResizedEntry, - type HistoryStackOptions, - type HistoryStrokeEntry, - type HistoryTextureReplacedEntry + type HistoryStackOptions } from "./history/HistoryStack.ts"; +export type { + HistoryEntry, + HistoryEntryInput, + HistoryResizedEntry, + HistoryStrokeEntry, + HistoryTextureReplacedEntry +} from "./history/HistoryStack.types.ts"; export type { DefaultViewport } from "./rendering/Viewport.ts"; @@ -48,4 +50,13 @@ export { type KeybindingAction, type KeybindingsMap } from "./input/Keybindings.ts"; +export { + UVMap, + type UVMapEvent, + type UVMapEventType, + type UVMapListener, + type UVMapOptions, + type UVRegionCreateOptions +} from "./uv/UVMap.ts"; +export type { UVRegion } from "./uv/UVRegion.ts"; export * from "./network/index.ts"; diff --git a/packages/pixel-draw-renderer/src/input/Keybindings.ts b/packages/pixel-draw-renderer/src/input/Keybindings.ts index 79d40a25..71e16175 100644 --- a/packages/pixel-draw-renderer/src/input/Keybindings.ts +++ b/packages/pixel-draw-renderer/src/input/Keybindings.ts @@ -170,9 +170,6 @@ function mergeAndValidate( return merged; } -/** - * Stores validated keybindings and matches keyboard events against them. - */ export class Keybindings { #bindings: KeybindingsMap; @@ -186,14 +183,9 @@ export class Keybindings { } get bindings(): Readonly { - return { ...this.#bindings }; + return structuredClone(this.#bindings); } - /** - * Merges patch onto the current bindings. Validates the merged result - * first, so a conflicting or malformed patch leaves the previous - * bindings untouched. - */ patch( patch: Partial ): void { diff --git a/packages/pixel-draw-renderer/src/input/createInputActions.ts b/packages/pixel-draw-renderer/src/input/createInputActions.ts index cc88e1a0..94e3fe51 100644 --- a/packages/pixel-draw-renderer/src/input/createInputActions.ts +++ b/packages/pixel-draw-renderer/src/input/createInputActions.ts @@ -1,6 +1,7 @@ // Import Internal Dependencies import type { ToolControllers } from "../tools/ToolControllers.ts"; import type { CanvasRenderer } from "../rendering/CanvasRenderer.ts"; +import type { CursorController } from "../rendering/CursorController.ts"; import type { SvgManager } from "../rendering/SvgManager.ts"; import type { Viewport } from "../rendering/Viewport.ts"; import type { Mode } from "../types.ts"; @@ -9,6 +10,7 @@ import type { InputActions } from "./InputController.ts"; export interface CreateInputActionsOptions { getMode: () => Mode; renderer: CanvasRenderer; + cursor: CursorController; svgManager: SvgManager; viewport: Viewport; tools: ToolControllers; @@ -26,6 +28,7 @@ export function createInputActions( const { getMode, renderer, + cursor, svgManager, viewport, tools, @@ -66,6 +69,13 @@ export function createInputActions( case "select": tools.select.handleStart({ x: tx, y: ty }); + cursor.refresh("select"); + + return true; + + case "uv": + tools.uv.handleStart({ x: tx, y: ty }); + cursor.refresh("uv"); return true; @@ -120,6 +130,10 @@ export function createInputActions( tools.select.handleMove({ x: tx, y: ty }); break; + case "uv": + tools.uv.handleMove({ x: tx, y: ty }); + break; + default: } }, @@ -131,6 +145,12 @@ export function createInputActions( case "select": tools.select.handleEnd(); + cursor.refresh("select"); + break; + + case "uv": + tools.uv.handleEnd(); + cursor.refresh("uv"); break; default: @@ -144,6 +164,7 @@ export function createInputActions( renderer.drawFrame(); tools.line.refreshPreview(); tools.select.refreshOverlay(); + svgManager.uvOverlay.refresh(); }, onPanEnd: () => { // No-op. The viewport handles panning internally. @@ -153,6 +174,7 @@ export function createInputActions( renderer.drawFrame(); tools.line.refreshPreview(); tools.select.refreshOverlay(); + svgManager.uvOverlay.refresh(); }, onMouseMove: (cx, cy) => { if (cx < 0 || cy < 0) { @@ -207,10 +229,22 @@ export function createInputActions( onBlur: () => { tools.line.shiftHeld = false; tools.line.cancelIfArmed(); + tools.uv.cancelDrag(); + cursor.refresh(getMode()); }, onCopy: () => tools.select.handleCopy(), onPaste: () => tools.select.handlePaste(), - onDelete: () => tools.select.handleDelete(), + onDelete: () => { + const selectHandled = tools.select.handleDelete(); + // Unlike `select`, a UV selection is NOT cleared on mode change (see + // uv/UVMap.md — visibility/selection persists across modes), so it + // must be explicitly mode-gated here, or Delete in any other mode + // would delete a UV region selected earlier (e.g. via a consumer's + // 3D-scene click) as a side effect. + const uvHandled = getMode() === "uv" && tools.uv.handleDelete(); + + return selectHandled || uvHandled; + }, onRotate: () => tools.select.handleRotate(), onFlipHorizontal: () => tools.select.handleFlipHorizontal(), onFlipVertical: () => tools.select.handleFlipVertical() diff --git a/packages/pixel-draw-renderer/src/network/PixelCommandApplier.ts b/packages/pixel-draw-renderer/src/network/PixelCommandApplier.ts index 3877f1d0..29bb6ee3 100644 --- a/packages/pixel-draw-renderer/src/network/PixelCommandApplier.ts +++ b/packages/pixel-draw-renderer/src/network/PixelCommandApplier.ts @@ -23,7 +23,9 @@ export function applyCommandToWorld( }); if (cmd.metadata.pixels) { buffer.replacePixels( - new Uint8ClampedArray(toUint8Array(cmd.metadata.pixels)), + new Uint8ClampedArray( + toUint8Array(cmd.metadata.pixels) + ), cmd.metadata.size ); } @@ -57,7 +59,9 @@ export function applyCommandToWorld( case "texture-replaced": { const buffer = world.getBuffer(cmd.bufferId); buffer?.replacePixels( - new Uint8ClampedArray(toUint8Array(cmd.metadata.pixels)), + new Uint8ClampedArray( + toUint8Array(cmd.metadata.pixels) + ), cmd.metadata.size ); break; @@ -69,10 +73,41 @@ export function applyCommandToWorld( break; } - const positions = Fill.matchAll(buffer, cmd.metadata.fromColor); + const positions = Fill.matchAll( + buffer, + cmd.metadata.fromColor + ); buffer.drawPixels(positions, cmd.metadata.toColor); buffer.copyToMaster(); break; } + + case "uv-region-created": { + const buffer = world.getBuffer(cmd.bufferId); + buffer?.uvRegions.set(cmd.metadata.region); + break; + } + + case "uv-region-deleted": { + const buffer = world.getBuffer(cmd.bufferId); + buffer?.uvRegions.remove(cmd.metadata.id); + break; + } + + case "uv-region-moved": { + const buffer = world.getBuffer(cmd.bufferId); + if (!buffer) { + break; + } + + const existing = buffer.uvRegions.get(cmd.metadata.id); + if (existing) { + buffer.uvRegions.set({ + ...existing, + rect: cmd.metadata.rect + }); + } + break; + } } } diff --git a/packages/pixel-draw-renderer/src/network/PixelSyncServer.ts b/packages/pixel-draw-renderer/src/network/PixelSyncServer.ts index cdb89280..62dc4f81 100644 --- a/packages/pixel-draw-renderer/src/network/PixelSyncServer.ts +++ b/packages/pixel-draw-renderer/src/network/PixelSyncServer.ts @@ -15,6 +15,10 @@ import type { } from "./types.ts"; export type PixelStrokeCommand = Extract; +export type PixelUvRegionCommand = Extract< + PixelNetworkCommand, + { action: "uv-region-moved" | "uv-region-deleted"; } +>; /** * Represents a connected network client. @@ -45,6 +49,7 @@ export class PixelSyncServer { #subscriptions = new Map>(); #resolver: PixelConflictResolver; #lastHeaderByPixel = new Map(); + #lastHeaderByRegion = new Map(); constructor( options: PixelSyncServerOptions = {} @@ -146,6 +151,7 @@ export class PixelSyncServer { applyCommandToWorld(this.world, cmd); this.#subscriptions.delete(cmd.bufferId); this.#clearPixelHistory(cmd.bufferId); + this.#clearRegionHistory(cmd.bufferId); this.#broadcast(cmd); return; @@ -161,6 +167,15 @@ export class PixelSyncServer { return; } + if ( + cmd.action === "uv-region-moved" || + cmd.action === "uv-region-deleted" + ) { + this.#receiveUvRegionCommand(cmd); + + return; + } + applyCommandToWorld(this.world, cmd); this.#broadcast(cmd); } @@ -211,6 +226,41 @@ export class PixelSyncServer { } } + /** + * Resolves move/delete conflicts per region id (parallel to the + * per-pixel resolution strokes use). Create is idempotent by unique id + * and applies unconditionally via the generic path in `receive()`. + */ + #receiveUvRegionCommand( + cmd: PixelUvRegionCommand + ): void { + const key = `${cmd.bufferId}:${cmd.metadata.id}`; + const existing = this.#lastHeaderByRegion.get(key); + const decision = this.#resolver.resolve({ + incoming: cmd, + existing + }); + + if (decision === "reject") { + return; + } + + this.#lastHeaderByRegion.set(key, cmd); + applyCommandToWorld(this.world, cmd); + this.#broadcast(cmd); + } + + #clearRegionHistory( + bufferId: string + ): void { + const prefix = `${bufferId}:`; + for (const key of this.#lastHeaderByRegion.keys()) { + if (key.startsWith(prefix)) { + this.#lastHeaderByRegion.delete(key); + } + } + } + #broadcast( cmd: PixelNetworkCommand ): void { @@ -237,7 +287,10 @@ export class PixelSyncServer { return { size: buffer.size(), - pixels: fromUint8Array(new Uint8Array(buffer.pixels())) + pixels: fromUint8Array( + new Uint8Array(buffer.pixels()) + ), + uvRegions: [...buffer.uvRegions] }; } } diff --git a/packages/pixel-draw-renderer/src/network/PixelSyncSession.ts b/packages/pixel-draw-renderer/src/network/PixelSyncSession.ts index ad376696..a7e4d01b 100644 --- a/packages/pixel-draw-renderer/src/network/PixelSyncSession.ts +++ b/packages/pixel-draw-renderer/src/network/PixelSyncSession.ts @@ -164,7 +164,8 @@ export class PixelSyncSession { ): void { this.#managers.get(bufferId)?.loadSnapshot( snapshot.size, - new Uint8ClampedArray(toUint8Array(snapshot.pixels)) + new Uint8ClampedArray(toUint8Array(snapshot.pixels)), + snapshot.uvRegions ); } diff --git a/packages/pixel-draw-renderer/src/network/types.ts b/packages/pixel-draw-renderer/src/network/types.ts index d54e45b2..161a760f 100644 --- a/packages/pixel-draw-renderer/src/network/types.ts +++ b/packages/pixel-draw-renderer/src/network/types.ts @@ -2,6 +2,7 @@ import type { PixelBufferHookEvent } from "../buffer/hooks.ts"; +import type { UVRegion } from "../uv/UVRegion.ts"; import type { Vec2 } from "../types.ts"; /** @@ -51,4 +52,5 @@ export interface PixelBufferSnapshot { * Base64-encoded RGBA data. */ pixels: string; + uvRegions: UVRegion[]; } diff --git a/packages/pixel-draw-renderer/src/rendering/CanvasRenderer.ts b/packages/pixel-draw-renderer/src/rendering/CanvasRenderer.ts index 7c77ccdd..44df33f0 100644 --- a/packages/pixel-draw-renderer/src/rendering/CanvasRenderer.ts +++ b/packages/pixel-draw-renderer/src/rendering/CanvasRenderer.ts @@ -98,6 +98,16 @@ export class CanvasRenderer { this.drawFrame(); } + get cursor(): string { + return this.#canvas.style.cursor; + } + + set cursor( + value: string + ) { + this.#canvas.style.cursor = value; + } + drawFrame(): void { if ( this.#canvas.width === 0 || diff --git a/packages/pixel-draw-renderer/src/rendering/CursorController.ts b/packages/pixel-draw-renderer/src/rendering/CursorController.ts new file mode 100644 index 00000000..a70e274c --- /dev/null +++ b/packages/pixel-draw-renderer/src/rendering/CursorController.ts @@ -0,0 +1,50 @@ +// Import Internal Dependencies +import type { ToolControllers } from "../tools/ToolControllers.ts"; +import type { Mode } from "../types.ts"; +import type { CanvasRenderer } from "./CanvasRenderer.ts"; + +export interface CursorControllerOptions { + renderer: CanvasRenderer; + tools: ToolControllers; +} + +/** + * Resolves and applies the canvas cursor for a given mode, reflecting + * per-tool drag/selection state (grab/grabbing in "uv" and "select"). + */ +export class CursorController { + #renderer: CanvasRenderer; + #tools: ToolControllers; + + constructor( + options: CursorControllerOptions + ) { + this.#renderer = options.renderer; + this.#tools = options.tools; + } + + refresh( + mode: Mode + ): void { + this.#renderer.cursor = this.#resolve(mode); + } + + #resolve( + mode: Mode + ): string { + switch (mode) { + case "uv": + return this.#tools.uv.isDragging ? "grabbing" : "grab"; + + case "select": + if (this.#tools.select.isDragging) { + return "grabbing"; + } + + return this.#tools.select.hasSelection ? "grab" : ""; + + default: + return ""; + } + } +} diff --git a/packages/pixel-draw-renderer/src/rendering/SvgManager.ts b/packages/pixel-draw-renderer/src/rendering/SvgManager.ts index 3b52bb31..eb06f8fd 100644 --- a/packages/pixel-draw-renderer/src/rendering/SvgManager.ts +++ b/packages/pixel-draw-renderer/src/rendering/SvgManager.ts @@ -3,9 +3,11 @@ import { SVG_NS } from "./constants.ts"; import { BrushHighlightOverlay } from "./overlays/BrushHighlightOverlay.ts"; import { LinePreviewOverlay } from "./overlays/LinePreviewOverlay.ts"; import { SelectionOverlay } from "./overlays/SelectionOverlay.ts"; +import { UVOverlay } from "./overlays/UVOverlay.ts"; import type { DefaultViewport } from "./Viewport.ts"; +import type { UVMap } from "../uv/UVMap.ts"; import type { BrushHighlight } from "../types.ts"; @@ -14,6 +16,7 @@ export interface SvgManagerOptions { parent: HTMLDivElement; viewport: DefaultViewport; brush: BrushHighlight; + uvMap: UVMap; } /** @@ -26,6 +29,7 @@ export class SvgManager { readonly brushHighlight: BrushHighlightOverlay; readonly linePreview: LinePreviewOverlay; readonly selection: SelectionOverlay; + readonly uvOverlay: UVOverlay; constructor( options: SvgManagerOptions @@ -48,6 +52,11 @@ export class SvgManager { options.viewport, options.brush ); + this.uvOverlay = new UVOverlay( + this.#svg, + options.viewport, + options.uvMap + ); } #init(): SVGElement { @@ -93,6 +102,7 @@ export class SvgManager { } destroy(): void { + this.uvOverlay.destroy(); if (this.#svg.parentElement) { this.#svg.remove(); } diff --git a/packages/pixel-draw-renderer/src/rendering/overlays/UVOverlay.ts b/packages/pixel-draw-renderer/src/rendering/overlays/UVOverlay.ts new file mode 100644 index 00000000..9e486f71 --- /dev/null +++ b/packages/pixel-draw-renderer/src/rendering/overlays/UVOverlay.ts @@ -0,0 +1,125 @@ +// Import Internal Dependencies +import { SVG_NS } from "../constants.ts"; +import type { DefaultViewport } from "../Viewport.ts"; +import type { UVMap } from "../../uv/UVMap.ts"; +import type { UVRegion } from "../../uv/UVRegion.ts"; +import type { SelectionRect } from "../../types.ts"; + +/** + * Renders visible UV regions as solid colored border rects, self-updating + * from `UVMap` events (create/delete/move/selection/visibility). + */ +export class UVOverlay { + #viewport: DefaultViewport; + #uvMap: UVMap; + #svg: SVGElement; + #rects = new Map(); + #liveOverride: { id: string; rect: SelectionRect; } | null = null; + + #onRegionCreated = () => this.#render(); + #onRegionDeleted = () => this.#render(); + #onRegionMoved = () => this.#render(); + #onSelectionChanged = () => this.#render(); + #onVisibilityChanged = () => this.#render(); + + constructor( + svg: SVGElement, + viewport: DefaultViewport, + uvMap: UVMap + ) { + this.#svg = svg; + this.#viewport = viewport; + this.#uvMap = uvMap; + + this.#uvMap.on("region-created", this.#onRegionCreated); + this.#uvMap.on("region-deleted", this.#onRegionDeleted); + this.#uvMap.on("region-moved", this.#onRegionMoved); + this.#uvMap.on("selection-changed", this.#onSelectionChanged); + this.#uvMap.on("visibility-changed", this.#onVisibilityChanged); + } + + /** + * Overrides one region's rendered rect during an active drag, without + * mutating `UVMap` state. Pass `null` to clear the override. + */ + setLiveOverride( + id: string, + rect: SelectionRect | null + ): void { + this.#liveOverride = rect ? { id, rect } : null; + this.#render(); + } + + /** + * Re-renders against the current viewport transform (pan/zoom). + */ + refresh(): void { + this.#render(); + } + + destroy(): void { + this.#uvMap.off("region-created", this.#onRegionCreated); + this.#uvMap.off("region-deleted", this.#onRegionDeleted); + this.#uvMap.off("region-moved", this.#onRegionMoved); + this.#uvMap.off("selection-changed", this.#onSelectionChanged); + this.#uvMap.off("visibility-changed", this.#onVisibilityChanged); + + for (const rect of this.#rects.values()) { + rect.remove(); + } + this.#rects.clear(); + } + + #render(): void { + const visible: UVRegion[] = []; + const visibleIds = new Set(); + for (const region of this.#uvMap.regions) { + if (!this.#uvMap.isVisible(region.id)) { + continue; + } + visible.push(region); + visibleIds.add(region.id); + } + + for (const [id, el] of this.#rects) { + if (!visibleIds.has(id)) { + el.remove(); + this.#rects.delete(id); + } + } + + const zoom = this.#viewport.zoom.value; + const camera = this.#viewport.camera; + + for (const region of visible) { + const rect = (this.#liveOverride && this.#liveOverride.id === region.id) ? + this.#liveOverride.rect : + region.rect; + + const el = this.#rects.get(region.id) ?? this.#createRect(region.id); + el.setAttribute("stroke", region.color); + el.setAttribute("x", String(rect.x * zoom + camera.x)); + el.setAttribute("y", String(rect.y * zoom + camera.y)); + el.setAttribute("width", String(rect.width * zoom)); + el.setAttribute("height", String(rect.height * zoom)); + } + } + + #createRect( + id: string + ): SVGRectElement { + const el = document.createElementNS(SVG_NS, "rect"); + + Object.assign(el.style, { + pointerEvents: "none", + fill: "none", + strokeWidth: 2 + }); + el.setAttribute("vector-effect", "non-scaling-stroke"); + + this.#svg.appendChild(el); + this.#rects.set(id, el); + + return el; + } +} diff --git a/packages/pixel-draw-renderer/src/sync/SyncController.ts b/packages/pixel-draw-renderer/src/sync/SyncController.ts index 77fc8a08..c1fb0d34 100644 --- a/packages/pixel-draw-renderer/src/sync/SyncController.ts +++ b/packages/pixel-draw-renderer/src/sync/SyncController.ts @@ -8,10 +8,16 @@ import type { PixelBufferHookListener } from "../buffer/hooks.ts"; import type { HistoryController } from "../history/HistoryController.ts"; -import type { HistoryEntryInput } from "../history/HistoryStack.ts"; +import type { HistoryEntryInput } from "../history/HistoryStack.types.ts"; import type { CanvasRenderer } from "../rendering/CanvasRenderer.ts"; import type { Viewport } from "../rendering/Viewport.ts"; -import type { RGBA, Vec2 } from "../types.ts"; +import type { UVMap } from "../uv/UVMap.ts"; +import type { UVRegion } from "../uv/UVRegion.ts"; +import type { + RGBA, + SelectionRect, + Vec2 +} from "../types.ts"; import { Fill } from "../tools/Fill.ts"; export interface SyncControllerOptions { @@ -19,6 +25,7 @@ export interface SyncControllerOptions { viewport: Viewport; renderer: CanvasRenderer; history: HistoryController; + uvMap: UVMap; onBufferUpdated?: PixelBufferHookListener; /** * Called after a pixel mutation. @@ -34,9 +41,11 @@ export class SyncController { #viewport: Viewport; #renderer: CanvasRenderer; #history: HistoryController; + #uvMap: UVMap; #onBufferUpdated?: PixelBufferHookListener; #onDrawEnd?: () => void; #isApplyingRemote = false; + #isReplayingHistory = false; constructor( options: SyncControllerOptions @@ -45,8 +54,22 @@ export class SyncController { this.#viewport = options.viewport; this.#renderer = options.renderer; this.#history = options.history; + this.#uvMap = options.uvMap; this.#onBufferUpdated = options.onBufferUpdated; this.#onDrawEnd = options.onDrawEnd; + + this.#uvMap.on( + "region-created", + (event) => this.#handleUvCreated(event.region) + ); + this.#uvMap.on( + "region-deleted", + (event) => this.#handleUvDeleted(event.region) + ); + this.#uvMap.on( + "region-moved", + (event) => this.#handleUvMoved(event.region, event.previousRect) + ); } /** @@ -143,11 +166,32 @@ export class SyncController { break; case "global-fill": { - const positions = Fill.matchAll(this.#canvasBuffer, event.metadata.fromColor); - this.applyStroke(event.metadata.toColor, positions); + const positions = Fill.matchAll( + this.#canvasBuffer, + event.metadata.fromColor + ); + this.applyStroke( + event.metadata.toColor, + positions + ); this.#onDrawEnd?.(); break; } + + case "uv-region-created": + this.#uvMap.restore(event.metadata.region); + break; + + case "uv-region-deleted": + this.#uvMap.delete(event.metadata.id); + break; + + case "uv-region-moved": + this.#uvMap.move( + event.metadata.id, + event.metadata.rect + ); + break; } } finally { @@ -160,15 +204,99 @@ export class SyncController { */ loadSnapshot( size: Vec2, - pixels: Uint8ClampedArray + pixels: Uint8ClampedArray, + uvRegions: UVRegion[] = [] ): void { this.#isApplyingRemote = true; try { this.replacePixels(size, pixels); + this.#uvMap.clear(); + for (const region of uvRegions) { + this.#uvMap.restore(region); + } this.#history.clear(); } finally { this.#isApplyingRemote = false; } } + + /** + * Runs `fn` while suppressing local history recording, but not network + * broadcast — used to replay undo/redo of UV region changes without + * re-recording the replay as a new entry. + */ + runHistoryReplay( + fn: () => T + ): T { + this.#isReplayingHistory = true; + try { + return fn(); + } + finally { + this.#isReplayingHistory = false; + } + } + + #handleUvCreated( + region: UVRegion + ): void { + if (this.#isApplyingRemote) { + return; + } + if (!this.#isReplayingHistory) { + this.#history.push({ + action: "uv-create", + region + }); + } + this.#onBufferUpdated?.({ + action: "uv-region-created", + metadata: { region } + }); + } + + #handleUvDeleted( + region: UVRegion + ): void { + if (this.#isApplyingRemote) { + return; + } + if (!this.#isReplayingHistory) { + this.#history.push({ + action: "uv-delete", + region + }); + } + this.#onBufferUpdated?.({ + action: "uv-region-deleted", + metadata: { + id: region.id + } + }); + } + + #handleUvMoved( + region: UVRegion, + previousRect: SelectionRect + ): void { + if (this.#isApplyingRemote) { + return; + } + if (!this.#isReplayingHistory) { + this.#history.push({ + action: "uv-move", + id: region.id, + oldRect: previousRect, + newRect: region.rect + }); + } + this.#onBufferUpdated?.({ + action: "uv-region-moved", + metadata: { + id: region.id, + rect: region.rect + } + }); + } } diff --git a/packages/pixel-draw-renderer/src/tools/SelectController.ts b/packages/pixel-draw-renderer/src/tools/SelectController.ts index d64812d3..a2de6c90 100644 --- a/packages/pixel-draw-renderer/src/tools/SelectController.ts +++ b/packages/pixel-draw-renderer/src/tools/SelectController.ts @@ -84,6 +84,23 @@ export class SelectController { return this.#select.rect; } + /** + * Whether an existing selection is currently being dragged to a new + * position (as opposed to being drawn for the first time). + */ + get isDragging(): boolean { + return this.#select.state === "moving"; + } + + /** + * Whether there's a committed selection to grab — idle ("selected") or + * actively being dragged ("moving"). `false` while a brand-new rectangle + * is still being drawn ("creating") or nothing is selected ("idle"). + */ + get hasSelection(): boolean { + return this.#select.state === "selected" || this.#select.state === "moving"; + } + /** * Whether empty-space clicks create shape selections. */ diff --git a/packages/pixel-draw-renderer/src/tools/ToolControllers.ts b/packages/pixel-draw-renderer/src/tools/ToolControllers.ts index b11e1229..ac690c39 100644 --- a/packages/pixel-draw-renderer/src/tools/ToolControllers.ts +++ b/packages/pixel-draw-renderer/src/tools/ToolControllers.ts @@ -14,6 +14,9 @@ import { SelectController, type SelectEditEntry } from "./SelectController.ts"; +import { UVController } from "../uv/UVController.ts"; +import type { UVMap } from "../uv/UVMap.ts"; +import type { UVOverlay } from "../rendering/overlays/UVOverlay.ts"; import type { CanvasBuffer } from "../buffer/CanvasBuffer.ts"; import type { CanvasRenderer } from "../rendering/CanvasRenderer.ts"; import type { LinePreviewOverlay } from "../rendering/overlays/LinePreviewOverlay.ts"; @@ -27,6 +30,8 @@ export interface ToolControllersOptions { linePreview: LinePreviewOverlay; selectionOverlay: SelectionOverlay; eraseColor: RGBA | null; + uvMap: UVMap; + uvOverlay: UVOverlay; onStrokeCommit: (pixels: Vec2[], color: RGBA, beforeColors: RGBA[]) => void; onCommitPixels: (pixels: Vec2[]) => void; onFillCommitPixels: (pixels: Vec2[], slot: BrushColorSlot) => void; @@ -42,6 +47,7 @@ export class ToolControllers { readonly fill: FillController; readonly line: LineController; readonly select: SelectController; + readonly uv: UVController; constructor( options: ToolControllersOptions @@ -73,5 +79,10 @@ export class ToolControllers { eraseColor: options.eraseColor, onCommit: options.onSelectCommit }); + + this.uv = new UVController({ + uvMap: options.uvMap, + overlay: options.uvOverlay + }); } } diff --git a/packages/pixel-draw-renderer/src/types.ts b/packages/pixel-draw-renderer/src/types.ts index c9f09855..a2a12e25 100644 --- a/packages/pixel-draw-renderer/src/types.ts +++ b/packages/pixel-draw-renderer/src/types.ts @@ -6,7 +6,7 @@ export type Vec2 = { y: number; }; -export type Mode = "paint" | "move" | "fill" | "select"; +export type Mode = "paint" | "move" | "fill" | "select" | "uv"; export interface SelectionRect { x: number; diff --git a/packages/pixel-draw-renderer/src/utils/EventEmitter.ts b/packages/pixel-draw-renderer/src/utils/EventEmitter.ts new file mode 100644 index 00000000..2f5a799e --- /dev/null +++ b/packages/pixel-draw-renderer/src/utils/EventEmitter.ts @@ -0,0 +1,44 @@ +export type EventListener< + TEvent extends { type: string; }, + T extends TEvent["type"] = TEvent["type"] +> = ( + event: Extract +) => void; + +export class TypedEventEmitter { + #listeners = new Map>>(); + + on( + type: T, + listener: EventListener + ): void { + let set = this.#listeners.get(type); + if (!set) { + set = new Set(); + this.#listeners.set(type, set); + } + set.add(listener as unknown as EventListener); + } + + off( + type: T, + listener: EventListener + ): void { + this.#listeners.get(type)?.delete( + listener as unknown as EventListener + ); + } + + protected emit( + event: Extract + ): void { + const set = this.#listeners.get(event.type); + if (!set) { + return; + } + + for (const listener of [...set]) { + (listener as unknown as EventListener)(event); + } + } +} diff --git a/packages/pixel-draw-renderer/src/utils/math.ts b/packages/pixel-draw-renderer/src/utils/math.ts index 03775108..4415e505 100644 --- a/packages/pixel-draw-renderer/src/utils/math.ts +++ b/packages/pixel-draw-renderer/src/utils/math.ts @@ -1,3 +1,6 @@ +// Import Internal Dependencies +import type { SelectionRect, Vec2 } from "../types.ts"; + /** * Restricts `value` to the inclusive range [min, max]. */ @@ -8,3 +11,52 @@ export function clamp( ): number { return Math.max(min, Math.min(max, value)); } + +/** + * Clamps a rect's width/height to `size` (minimum 1), then clamps its + * position so the (possibly shrunk) rect stays within bounds. + */ +export function clampRectSize( + rect: SelectionRect, + size: Vec2 +): SelectionRect { + const width = clamp( + rect.width, 1, Math.max(1, size.x) + ); + const height = clamp( + rect.height, 1, Math.max(1, size.y) + ); + + return { + width, + height, + x: clamp(rect.x, 0, Math.max(0, size.x - width)), + y: clamp(rect.y, 0, Math.max(0, size.y - height)) + }; +} + +/** + * Clamps a rect's position to keep it within `size`, leaving its + * width/height untouched. + */ +export function clampRectPosition( + rect: SelectionRect, + size: Vec2 +): SelectionRect { + return { + ...rect, + x: clamp(rect.x, 0, Math.max(0, size.x - rect.width)), + y: clamp(rect.y, 0, Math.max(0, size.y - rect.height)) + }; +} + +/** + * Whether `pos` falls within `rect` (inclusive min, exclusive max). + */ +export function pointInRect( + pos: Vec2, + rect: SelectionRect +): boolean { + return pos.x >= rect.x && pos.x < rect.x + rect.width && + pos.y >= rect.y && pos.y < rect.y + rect.height; +} diff --git a/packages/pixel-draw-renderer/src/uv/UVController.ts b/packages/pixel-draw-renderer/src/uv/UVController.ts new file mode 100644 index 00000000..18e9fc1c --- /dev/null +++ b/packages/pixel-draw-renderer/src/uv/UVController.ts @@ -0,0 +1,170 @@ +// Import Internal Dependencies +import { + clampRectPosition, + pointInRect +} from "../utils/math.ts"; +import type { UVMap } from "./UVMap.ts"; +import type { UVRegion } from "./UVRegion.ts"; +import type { + UVOverlay +} from "../rendering/overlays/UVOverlay.ts"; +import type { + SelectionRect, + Vec2 +} from "../types.ts"; + +export interface UVControllerOptions { + uvMap: UVMap; + overlay: UVOverlay; +} + +interface DragState { + id: string; + origin: Vec2; + baseRect: SelectionRect; + liveRect: SelectionRect; +} + +/** + * Routes canvas interaction (hit-test, drag-to-move, delete) to a `UVMap`. + * Creation is API-only (see `UVMap.create`) and has no canvas gesture. + */ +export class UVController { + #uvMap: UVMap; + #overlay: UVOverlay; + #drag: DragState | null = null; + + constructor( + options: UVControllerOptions + ) { + this.#uvMap = options.uvMap; + this.#overlay = options.overlay; + } + + /** + * Whether a region is currently being dragged. + */ + get isDragging(): boolean { + return this.#drag !== null; + } + + /** + * Selects the hit region, or deselects on a miss. + */ + handleStart( + pos: Vec2 + ): void { + const hit = this.#hitTest(pos); + if (!hit) { + this.#uvMap.select(null); + + return; + } + + this.#uvMap.select(hit.id); + this.#drag = { + id: hit.id, + origin: pos, + baseRect: { ...hit.rect }, + liveRect: { ...hit.rect } + }; + } + + handleMove( + pos: Vec2 + ): void { + if (!this.#drag) { + return; + } + + const dx = pos.x - this.#drag.origin.x; + const dy = pos.y - this.#drag.origin.y; + const rect = clampRectPosition( + { + ...this.#drag.baseRect, + x: this.#drag.baseRect.x + dx, + y: this.#drag.baseRect.y + dy + }, + this.#uvMap.canvasSize() + ); + + this.#drag.liveRect = rect; + this.#overlay.setLiveOverride( + this.#drag.id, + rect + ); + this.#uvMap.previewMove( + this.#drag.id, + rect + ); + } + + handleEnd(): void { + if (!this.#drag) { + return; + } + + const { id, baseRect, liveRect } = this.#drag; + this.#drag = null; + + if ( + liveRect.x !== baseRect.x || + liveRect.y !== baseRect.y + ) { + this.#uvMap.move(id, liveRect); + } + this.#overlay.setLiveOverride( + id, + null + ); + } + + /** + * Cancels an in-progress drag without committing the move. Reverts any + * live drag-preview back to the region's actual (unchanged) rect, so a + * consumer following `"region-dragging"` doesn't stay stuck showing an + * uncommitted position. + */ + cancelDrag(): void { + if (!this.#drag) { + return; + } + + this.#overlay.setLiveOverride( + this.#drag.id, + null + ); + this.#uvMap.previewMove( + this.#drag.id, + this.#drag.baseRect + ); + this.#drag = null; + } + + /** + * Deletes the selected region. + */ + handleDelete(): boolean { + const id = this.#uvMap.selectedRegionId; + if (id === null) { + return false; + } + + return this.#uvMap.delete(id); + } + + #hitTest( + pos: Vec2 + ): UVRegion | null { + for (const region of this.#uvMap.regions) { + if (!this.#uvMap.isVisible(region.id)) { + continue; + } + if (pointInRect(pos, region.rect)) { + return region; + } + } + + return null; + } +} diff --git a/packages/pixel-draw-renderer/src/uv/UVMap.ts b/packages/pixel-draw-renderer/src/uv/UVMap.ts new file mode 100644 index 00000000..2952469c --- /dev/null +++ b/packages/pixel-draw-renderer/src/uv/UVMap.ts @@ -0,0 +1,311 @@ +// Import Internal Dependencies +import { clamp, clampRectSize } from "../utils/math.ts"; +import { + TypedEventEmitter, + type EventListener +} from "../utils/EventEmitter.ts"; +import type { + SelectionRect, + Vec2 +} from "../types.ts"; +import type { UVRegion } from "./UVRegion.ts"; + +export type UVMapEvent = + | { type: "region-created"; region: UVRegion; } + | { type: "region-deleted"; region: UVRegion; } + | { type: "region-moved"; region: UVRegion; previousRect: SelectionRect; } + | { type: "region-dragging"; id: string; rect: SelectionRect; } + | { type: "selection-changed"; selectedRegionId: string | null; } + | { type: "visibility-changed"; showAll: boolean; }; + +export type UVMapEventType = UVMapEvent["type"]; + +export type UVMapListener = EventListener; + +export interface UVMapOptions { + /** + * Reports the current texture/canvas size, used to clamp region + * placement and size. + */ + getCanvasSize: () => Vec2; +} + +export interface UVRegionCreateOptions { + width: number; + height: number; + /** + * @default a generated id + */ + id?: string; + /** + * @default the next color in the built-in palette + */ + color?: string; +} + +// CONSTANTS +const kPalette = [ + "#f94144", + "#f3722c", + "#f9c74f", + "#90be6d", + "#43aa8b", + "#4d908e", + "#577590", + "#277da1" +]; +const kCascadeStep = 16; + +/** + * Owns a texture's UV regions (create/delete/move) and the + * selection/visibility state that governs which ones render, notifying + * listeners of every change. + */ +export class UVMap extends TypedEventEmitter implements Iterable { + #getCanvasSize: () => Vec2; + #regions = new Map(); + #selectedRegionId: string | null = null; + #showAll = false; + #cascadeIndex = 0; + #paletteIndex = 0; + + constructor( + options: UVMapOptions + ) { + super(); + this.#getCanvasSize = options.getCanvasSize; + } + + /** + * Every region, in insertion order. A live view over the internal store + * (no array copy) — spread it (`[...uv.regions]`) if you need a snapshot. + */ + get regions(): IterableIterator { + return this.#regions.values(); + } + + [Symbol.iterator](): IterableIterator { + return this.#regions.values(); + } + + get selectedRegionId(): string | null { + return this.#selectedRegionId; + } + + get showAll(): boolean { + return this.#showAll; + } + + set showAll( + value: boolean + ) { + if (this.#showAll === value) { + return; + } + + this.#showAll = value; + this.emit({ + type: "visibility-changed", + showAll: value + }); + } + + get( + id: string + ): UVRegion | undefined { + return this.#regions.get(id); + } + + canvasSize(): Vec2 { + return this.#getCanvasSize(); + } + + /** + * Whether a region should currently render. + */ + isVisible( + id: string + ): boolean { + return this.#showAll || this.#selectedRegionId === id; + } + + /** + * Selects a region for editing/visibility, or `null` to deselect. + * Silently ignores unknown ids. + */ + select( + id: string | null + ): void { + if (id !== null && !this.#regions.has(id)) { + return; + } + if (this.#selectedRegionId === id) { + return; + } + + this.#selectedRegionId = id; + this.emit({ + type: "selection-changed", + selectedRegionId: id + }); + } + + /** + * Creates a region at a cascading position, clamped to canvas bounds. + */ + create( + options: UVRegionCreateOptions + ): UVRegion { + const size = this.#getCanvasSize(); + const width = clamp(options.width, 1, Math.max(1, size.x)); + const height = clamp(options.height, 1, Math.max(1, size.y)); + const position = this.#nextCascadePosition(width, height, size); + + const region: UVRegion = { + id: options.id ?? crypto.randomUUID(), + rect: { x: position.x, y: position.y, width, height }, + color: options.color ?? this.#nextPaletteColor() + }; + + this.#regions.set(region.id, region); + this.emit({ + type: "region-created", + region + }); + + return region; + } + + /** + * Re-adds a region exactly as given, without cascading placement or + * palette assignment. Used to replay undo/redo, remote commands, and + * snapshots. + */ + restore( + region: UVRegion + ): UVRegion { + const stored: UVRegion = { + ...region + }; + this.#regions.set(stored.id, stored); + + this.emit({ + type: "region-created", + region: stored + }); + + return stored; + } + + delete( + id: string + ): boolean { + const region = this.#regions.get(id); + if (!region) { + return false; + } + + this.#regions.delete(id); + if (this.#selectedRegionId === id) { + this.#selectedRegionId = null; + } + this.emit({ + type: "region-deleted", + region + }); + + return true; + } + + move( + id: string, + rect: SelectionRect + ): boolean { + const region = this.#regions.get(id); + if (!region) { + return false; + } + + const previousRect = region.rect; + const clamped = clampRectSize( + rect, + this.#getCanvasSize() + ); + const moved: UVRegion = { + ...region, + rect: clamped + }; + this.#regions.set(id, moved); + + this.emit({ + type: "region-moved", + region: moved, + previousRect + }); + + return true; + } + + /** + * Emits a transient drag-preview position for a region: no store + * mutation, no history entry, no network broadcast. Lets a consumer + * (e.g. a 3D mesh mirroring the region) update live while a canvas drag + * is in progress; the region's actual rect only changes once `move()` + * commits it on drag end. Silently ignores an unknown id. + */ + previewMove( + id: string, + rect: SelectionRect + ): void { + if (!this.#regions.has(id)) { + return; + } + + const clamped = clampRectSize( + rect, + this.#getCanvasSize() + ); + this.emit({ + type: "region-dragging", + id, + rect: clamped + }); + } + + /** + * Removes every region and resets cascading placement. + */ + clear(): void { + for (const id of this.#regions.keys()) { + this.delete(id); + } + this.#cascadeIndex = 0; + this.#paletteIndex = 0; + } + + #nextPaletteColor(): string { + const color = kPalette[this.#paletteIndex % kPalette.length]; + this.#paletteIndex++; + + return color; + } + + #nextCascadePosition( + width: number, + height: number, + size: Vec2 + ): Vec2 { + const maxX = Math.max(0, size.x - width); + const maxY = Math.max(0, size.y - height); + const colsPerRow = Math.max(1, Math.floor(maxX / kCascadeStep) + 1); + + const col = this.#cascadeIndex % colsPerRow; + const row = Math.floor(this.#cascadeIndex / colsPerRow); + this.#cascadeIndex++; + + return { + x: clamp(col * kCascadeStep, 0, maxX), + y: clamp(row * kCascadeStep, 0, maxY) + }; + } +} diff --git a/packages/pixel-draw-renderer/src/uv/UVRegion.ts b/packages/pixel-draw-renderer/src/uv/UVRegion.ts new file mode 100644 index 00000000..fde07aad --- /dev/null +++ b/packages/pixel-draw-renderer/src/uv/UVRegion.ts @@ -0,0 +1,10 @@ +// Import Internal Dependencies +import type { + SelectionRect +} from "../types.ts"; + +export interface UVRegion { + id: string; + rect: SelectionRect; + color: string; +} diff --git a/packages/pixel-draw-renderer/src/uv/UVRegionCollection.ts b/packages/pixel-draw-renderer/src/uv/UVRegionCollection.ts new file mode 100644 index 00000000..f1f91acd --- /dev/null +++ b/packages/pixel-draw-renderer/src/uv/UVRegionCollection.ts @@ -0,0 +1,32 @@ +// Import Internal Dependencies +import type { + UVRegion +} from "./UVRegion.ts"; + +export class UVRegionCollection implements Iterable { + #regions = new Map(); + + get( + id: string + ): UVRegion | undefined { + return this.#regions.get(id); + } + + set( + region: UVRegion + ): void { + this.#regions.set(region.id, { + ...region + }); + } + + remove( + id: string + ): void { + this.#regions.delete(id); + } + + [Symbol.iterator](): IterableIterator { + return this.#regions.values(); + } +} diff --git a/packages/pixel-draw-renderer/test/PixelArtCanvas.history.spec.ts b/packages/pixel-draw-renderer/test/PixelArtCanvas.history.spec.ts index dfa42f1d..9b937d71 100644 --- a/packages/pixel-draw-renderer/test/PixelArtCanvas.history.spec.ts +++ b/packages/pixel-draw-renderer/test/PixelArtCanvas.history.spec.ts @@ -103,6 +103,7 @@ describe("PixelArtCanvas — history (undo/redo)", () => { test("undo reverts a painted pixel; redo re-applies it", () => { const manager = new PixelArtCanvas(container, { texture: { maxSize: 32, size: { x: 8, y: 8 } }, + zoom: { default: 4 }, brush: { size: 1, maxSize: 1 }, history: { enabled: true } }); @@ -439,6 +440,7 @@ describe("PixelArtCanvas — history + network collision handling", () => { const server = new PixelSyncServer(); const manager = new PixelArtCanvas(container, { texture: { maxSize: 32, size: { x: 8, y: 8 } }, + zoom: { default: 4 }, brush: { size: 1, maxSize: 1 }, history: { enabled: true } }); @@ -470,6 +472,7 @@ describe("PixelArtCanvas — history + network collision handling", () => { const server = new PixelSyncServer(); const manager = new PixelArtCanvas(container, { texture: { maxSize: 32, size: { x: 8, y: 8 } }, + zoom: { default: 4 }, brush: { size: 1, maxSize: 1 }, history: { enabled: true } }); diff --git a/packages/pixel-draw-renderer/test/PixelArtCanvas.network.spec.ts b/packages/pixel-draw-renderer/test/PixelArtCanvas.network.spec.ts index e167cfab..c87e6c57 100644 --- a/packages/pixel-draw-renderer/test/PixelArtCanvas.network.spec.ts +++ b/packages/pixel-draw-renderer/test/PixelArtCanvas.network.spec.ts @@ -73,6 +73,7 @@ describe("PixelArtCanvas — onBufferUpdated", () => { const events: PixelBufferHookEvent[] = []; const manager = new PixelArtCanvas(container, { texture: { maxSize: 32, size: { x: 8, y: 8 } }, + zoom: { default: 4 }, brush: { size: 1, maxSize: 1 }, onBufferUpdated: (event) => events.push(event) }); diff --git a/packages/pixel-draw-renderer/test/PixelArtCanvas.select.spec.ts b/packages/pixel-draw-renderer/test/PixelArtCanvas.select.spec.ts index e57fc007..17c206a7 100644 --- a/packages/pixel-draw-renderer/test/PixelArtCanvas.select.spec.ts +++ b/packages/pixel-draw-renderer/test/PixelArtCanvas.select.spec.ts @@ -208,6 +208,71 @@ describe("PixelArtCanvas — select mode", () => { manager.destroy(); }); + describe("cursor", () => { + test("drawing a brand-new rectangle keeps the plain cursor (not a grab motion)", () => { + const manager = makeManager(); + const canvas = manager.canvas(); + + manager.mode = "select"; + canvas.dispatchEvent(mouseEvent("mousedown", 92, 92)); + assert.strictEqual(canvas.style.cursor, ""); + + canvas.dispatchEvent(mouseEvent("mousemove", 96, 96)); + assert.strictEqual(canvas.style.cursor, ""); + + manager.destroy(); + }); + + test("a committed selection shows a grab cursor once idle", () => { + const manager = makeManager(); + const canvas = manager.canvas(); + + manager.mode = "select"; + canvas.dispatchEvent(mouseEvent("mousedown", 92, 92)); + canvas.dispatchEvent(mouseEvent("mousemove", 96, 96)); + canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + + assert.strictEqual(canvas.style.cursor, "grab"); + manager.destroy(); + }); + + test("dragging an existing selection switches the cursor to grabbing, and back to grab on release", () => { + const manager = makeManager(); + const canvas = manager.canvas(); + + manager.mode = "select"; + canvas.dispatchEvent(mouseEvent("mousedown", 92, 92)); + canvas.dispatchEvent(mouseEvent("mousemove", 96, 96)); + canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + assert.strictEqual(canvas.style.cursor, "grab"); + + // Second mousedown lands inside the just-created selection -> moving it. + canvas.dispatchEvent(mouseEvent("mousedown", 92, 92)); + assert.strictEqual(canvas.style.cursor, "grabbing"); + + canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + assert.strictEqual(canvas.style.cursor, "grab"); + + manager.destroy(); + }); + + test("leaving select mode resets the cursor", () => { + const manager = makeManager(); + const canvas = manager.canvas(); + + manager.mode = "select"; + canvas.dispatchEvent(mouseEvent("mousedown", 92, 92)); + canvas.dispatchEvent(mouseEvent("mousemove", 96, 96)); + canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + assert.strictEqual(canvas.style.cursor, "grab"); + + manager.mode = "paint"; + assert.strictEqual(canvas.style.cursor, ""); + + manager.destroy(); + }); + }); + test("dragging the selection moves it: source is erased, destination gets the moved pixels", () => { const manager = makeManager(); const canvas = manager.canvas(); @@ -651,6 +716,43 @@ describe("PixelArtCanvas — select mode", () => { manager.destroy(); }); + test("undoing a select-edit outside select mode restores the pixels but does not reactivate the selection", () => { + const manager = makeManager({ history: { enabled: true } }); + const canvas = manager.canvas(); + + manager.commitPixels([{ x: 2, y: 2 }]); + manager.mode = "select"; + canvas.dispatchEvent(mouseEvent("mousedown", 92, 92)); + canvas.dispatchEvent(mouseEvent("mousemove", 96, 92)); + canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + + canvas.dispatchEvent(mouseEvent("mousedown", 92, 92)); + canvas.dispatchEvent(mouseEvent("mousemove", 100, 100)); + canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + + // Leaving select mode clears the active selection. + manager.mode = "paint"; + + window.dispatchEvent(ctrlKey("z")); + assert.deepStrictEqual( + readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 255], "undo restores the source pixels regardless of mode" + ); + assert.deepStrictEqual( + readPixel(manager.texture, { x: 4, y: 4 }, 8), + [255, 255, 255, 255], + "undo removes the destination pixels regardless of mode" + ); + + manager.mode = "select"; + assert.strictEqual( + manager.rotateSelection(), + false, + "an undo that happened outside select mode must not resurrect the old selection once select mode is re-entered" + ); + + manager.destroy(); + }); + test("undo/redo covers a Delete", () => { const manager = makeManager({ history: { enabled: true } }); const canvas = manager.canvas(); diff --git a/packages/pixel-draw-renderer/test/PixelArtCanvas.spec.ts b/packages/pixel-draw-renderer/test/PixelArtCanvas.spec.ts index b08765ce..c5aa5782 100644 --- a/packages/pixel-draw-renderer/test/PixelArtCanvas.spec.ts +++ b/packages/pixel-draw-renderer/test/PixelArtCanvas.spec.ts @@ -104,6 +104,86 @@ describe("PixelArtCanvas", () => { assert.strictEqual(manager.zoom.sensitivity, 0.5); manager.destroy(); }); + + describe("default zoom fits the texture to the container", () => { + function makeSizedContainer( + width: number, + height: number + ): HTMLDivElement { + const div = kEmulatedBrowserWindow.document.createElement("div") as unknown as HTMLDivElement; + (div as any).getBoundingClientRect = () => { + return { + left: 0, top: 0, right: width, bottom: height, width, height + }; + }; + (div as any).style = {}; + (div as any).appendChild = (_child: unknown) => { + // No-op + }; + + return div; + } + + test("computes a fit-to-container zoom when zoom.default is omitted", () => { + // 200x200 container, 8x8 texture: min(200/8, 200/8) * 0.9 = 22.5 -> floor 22. + const manager = new PixelArtCanvas(container, { + texture: { maxSize: 32, size: { x: 8, y: 8 } } + }); + + assert.strictEqual(manager.zoom.value, 22); + manager.destroy(); + }); + + test("an explicit zoom.default always wins over the fit computation", () => { + const manager = new PixelArtCanvas(container, { + texture: { maxSize: 32, size: { x: 8, y: 8 } }, + zoom: { default: 4 } + }); + + assert.strictEqual(manager.zoom.value, 4); + manager.destroy(); + }); + + test("clamps the computed fit zoom to zoomMax for a tiny texture in a large container", () => { + const manager = new PixelArtCanvas(container, { + texture: { maxSize: 32, size: { x: 2, y: 2 } }, + zoom: { max: 5 } + }); + + assert.strictEqual(manager.zoom.value, 5); + manager.destroy(); + }); + + test("clamps the computed fit zoom to zoomMin for a texture much larger than the container", () => { + const manager = new PixelArtCanvas(container, { + texture: { maxSize: 2048, size: { x: 1000, y: 1000 } } + }); + + assert.strictEqual(manager.zoom.value, 1); + manager.destroy(); + }); + + test("falls back to Zoom's own default (4) when the container has no measurable size", () => { + const zeroSizeContainer = makeSizedContainer(0, 0); + const manager = new PixelArtCanvas(zeroSizeContainer, { + texture: { maxSize: 32, size: { x: 8, y: 8 } } + }); + + assert.strictEqual(manager.zoom.value, 4); + manager.destroy(); + }); + + test("scales with a smaller container", () => { + // 100x100 container, 8x8 texture: min(100/8, 100/8) * 0.9 = 11.25 -> floor 11. + const smallContainer = makeSizedContainer(100, 100); + const manager = new PixelArtCanvas(smallContainer, { + texture: { maxSize: 32, size: { x: 8, y: 8 } } + }); + + assert.strictEqual(manager.zoom.value, 11); + manager.destroy(); + }); + }); }); describe("backgroundColor", () => { diff --git a/packages/pixel-draw-renderer/test/PixelArtCanvas.uv.spec.ts b/packages/pixel-draw-renderer/test/PixelArtCanvas.uv.spec.ts new file mode 100644 index 00000000..56343164 --- /dev/null +++ b/packages/pixel-draw-renderer/test/PixelArtCanvas.uv.spec.ts @@ -0,0 +1,410 @@ +// Import Node.js Dependencies +import { describe, test, before, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +// Import Third-party Dependencies +import { Window } from "happy-dom"; + +// Import Internal Dependencies +import { PixelArtCanvas, type PixelArtCanvasOptions } from "../src/PixelArtCanvas.ts"; +import type { PixelBufferHookEvent } from "../src/buffer/hooks.ts"; +import { installCanvasMock } from "./mocks.ts"; + +// CONSTANTS +const kEmulatedBrowserWindow = new Window(); + +before(() => { + globalThis.document = kEmulatedBrowserWindow.document as unknown as Document; + // @ts-expect-error + globalThis.window = kEmulatedBrowserWindow as unknown as Window & typeof globalThis; + // @ts-expect-error + globalThis.getComputedStyle = (_el: unknown) => { + return { backgroundColor: "#555555" }; + }; + installCanvasMock(globalThis.document); + globalThis.MouseEvent = (kEmulatedBrowserWindow as unknown as Record).MouseEvent as typeof MouseEvent; + globalThis.KeyboardEvent = (kEmulatedBrowserWindow as unknown as Record).KeyboardEvent as typeof KeyboardEvent; + globalThis.HTMLElement = (kEmulatedBrowserWindow as unknown as Record).HTMLElement as typeof HTMLElement; + globalThis.Event = (kEmulatedBrowserWindow as unknown as Record).Event as typeof Event; +}); + +function makeContainer(): HTMLDivElement { + const div = kEmulatedBrowserWindow.document.createElement("div") as unknown as HTMLDivElement; + (div as any).getBoundingClientRect = () => { + return { + left: 0, top: 0, right: 200, bottom: 200, width: 200, height: 200 + }; + }; + (div as any).style = {}; + (div as any).appendChild = (_child: unknown) => { + // No-op + }; + + return div; +} + +function mouseEvent( + type: string, + clientX: number, + clientY: number +): MouseEvent { + return new MouseEvent(type, { button: 0, buttons: 1, clientX, clientY, bubbles: true }); +} + +function deleteKey(): KeyboardEvent { + return new KeyboardEvent("keydown", { key: "Delete", code: "Delete", bubbles: true, cancelable: true }); +} + +describe("PixelArtCanvas — uv mode", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = makeContainer(); + }); + + // 200x200 container, 8x8 texture, zoom 4 -> centered camera (84, 84). + // client 84 + n*4 -> texture n. + + function makeManager(options: PixelArtCanvasOptions = {}): PixelArtCanvas { + return new PixelArtCanvas(container, { + texture: { maxSize: 32, size: { x: 8, y: 8 } }, + zoom: { default: 4 }, + history: { enabled: true }, + ...options + }); + } + + test("uv.create() places a region and it is not visible by default", () => { + const manager = makeManager(); + const region = manager.uv.create({ width: 4, height: 4 }); + + assert.strictEqual([...manager.uv.regions].length, 1); + assert.strictEqual(manager.uv.isVisible(region.id), false); + }); + + test("selecting a region makes it draggable in uv mode", () => { + const manager = makeManager(); + manager.mode = "uv"; + const region = manager.uv.create({ width: 4, height: 4 }); + manager.uv.select(region.id); + + const canvas = manager.canvas(); + canvas.dispatchEvent(mouseEvent("mousedown", 84, 84)); + canvas.dispatchEvent(mouseEvent("mousemove", 92, 92)); + canvas.dispatchEvent(mouseEvent("mouseup", 92, 92)); + + assert.deepStrictEqual(manager.uv.get(region.id)!.rect, { x: 2, y: 2, width: 4, height: 4 }); + }); + + test("Delete removes the selected region while in uv mode", () => { + const manager = makeManager(); + manager.mode = "uv"; + const region = manager.uv.create({ width: 4, height: 4 }); + manager.uv.select(region.id); + + // Keyboard shortcuts only dispatch while the canvas is hovered. + manager.canvas().dispatchEvent(mouseEvent("mouseenter", 84, 84)); + globalThis.window.dispatchEvent(deleteKey()); + + assert.strictEqual(manager.uv.get(region.id), undefined); + }); + + test("Delete in select mode does not delete a UV region selected earlier (regression)", () => { + const manager = makeManager(); + + // Select a UV region from outside uv mode (e.g. a consumer's own + // 3D-scene click), then switch to select mode without ever entering + // uv mode. Selection persists across mode changes by design (see + // uv/UVMap.md), so it must NOT be treated as "the active uv delete + // target" once in a different mode. + const region = manager.uv.create({ width: 4, height: 4 }); + manager.uv.select(region.id); + + manager.mode = "select"; + const canvas = manager.canvas(); + canvas.dispatchEvent(mouseEvent("mousedown", 92, 92)); + canvas.dispatchEvent(mouseEvent("mousemove", 96, 96)); + canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + + globalThis.window.dispatchEvent(deleteKey()); + + assert.strictEqual(manager.canUndo(), true, "the select-mode delete should have committed an edit"); + assert.ok(manager.uv.get(region.id), "the UV region must survive a Delete outside uv mode"); + }); + + test("leaving uv mode cancels an in-progress drag without moving the region", () => { + const manager = makeManager(); + manager.mode = "uv"; + const region = manager.uv.create({ width: 4, height: 4 }); + manager.uv.select(region.id); + + const canvas = manager.canvas(); + canvas.dispatchEvent(mouseEvent("mousedown", 84, 84)); + canvas.dispatchEvent(mouseEvent("mousemove", 92, 92)); + manager.mode = "paint"; + canvas.dispatchEvent(mouseEvent("mouseup", 92, 92)); + + assert.deepStrictEqual(manager.uv.get(region.id)!.rect, region.rect); + }); + + test("leaving uv mode does not clear the current selection/visibility", () => { + const manager = makeManager(); + const region = manager.uv.create({ width: 4, height: 4 }); + manager.mode = "uv"; + manager.uv.select(region.id); + + manager.mode = "paint"; + + assert.strictEqual(manager.uv.selectedRegionId, region.id); + assert.strictEqual(manager.uv.isVisible(region.id), true); + }); + + describe("cursor", () => { + test("entering uv mode sets a grab cursor; leaving it resets to default", () => { + const manager = makeManager(); + const canvas = manager.canvas(); + + manager.mode = "uv"; + assert.strictEqual(canvas.style.cursor, "grab"); + + manager.mode = "paint"; + assert.strictEqual(canvas.style.cursor, ""); + }); + + test("dragging a region switches the cursor to grabbing, and back to grab on release", () => { + const manager = makeManager(); + manager.mode = "uv"; + const region = manager.uv.create({ width: 4, height: 4 }); + manager.uv.select(region.id); + + const canvas = manager.canvas(); + canvas.dispatchEvent(mouseEvent("mousedown", 84, 84)); + assert.strictEqual(canvas.style.cursor, "grabbing"); + + canvas.dispatchEvent(mouseEvent("mouseup", 92, 92)); + assert.strictEqual(canvas.style.cursor, "grab"); + }); + + test("clicking empty space (no drag started) keeps the idle grab cursor", () => { + const manager = makeManager(); + manager.mode = "uv"; + + const canvas = manager.canvas(); + canvas.dispatchEvent(mouseEvent("mousedown", 10, 10)); + + assert.strictEqual(canvas.style.cursor, "grab"); + }); + }); + + describe("history", () => { + test("undo/redo a create", () => { + const manager = makeManager(); + const region = manager.uv.create({ width: 4, height: 4 }); + + assert.strictEqual(manager.canUndo(), true); + manager.undo(); + assert.strictEqual(manager.uv.get(region.id), undefined); + + manager.redo(); + assert.deepStrictEqual(manager.uv.get(region.id), region); + }); + + test("undo/redo a delete", () => { + const manager = makeManager(); + const region = manager.uv.create({ width: 4, height: 4 }); + manager.uv.delete(region.id); + + manager.undo(); + assert.deepStrictEqual(manager.uv.get(region.id), region); + + manager.redo(); + assert.strictEqual(manager.uv.get(region.id), undefined); + }); + + test("undo/redo a move", () => { + const manager = makeManager(); + const region = manager.uv.create({ width: 4, height: 4 }); + manager.uv.move(region.id, { x: 3, y: 3, width: 4, height: 4 }); + + manager.undo(); + assert.deepStrictEqual(manager.uv.get(region.id)!.rect, region.rect); + + manager.redo(); + assert.deepStrictEqual(manager.uv.get(region.id)!.rect, { x: 3, y: 3, width: 4, height: 4 }); + }); + + test("undoing a create does not push a new entry (undo stack stays empty after)", () => { + const manager = makeManager(); + manager.uv.create({ width: 4, height: 4 }); + + manager.undo(); + assert.strictEqual(manager.canUndo(), false); + assert.strictEqual(manager.canRedo(), true); + }); + }); + + describe("network hook", () => { + test("create/move/delete each emit exactly one hook event of the matching action", () => { + const events: PixelBufferHookEvent[] = []; + const manager = makeManager({ onBufferUpdated: (e) => events.push(e) }); + + const region = manager.uv.create({ width: 4, height: 4 }); + manager.uv.move(region.id, { x: 1, y: 1, width: 4, height: 4 }); + manager.uv.delete(region.id); + + assert.deepStrictEqual(events.map((e) => e.action), [ + "uv-region-created", + "uv-region-moved", + "uv-region-deleted" + ]); + }); + + test("undo of a move broadcasts the inverse uv-region-moved event", () => { + const events: PixelBufferHookEvent[] = []; + const manager = makeManager({ onBufferUpdated: (e) => events.push(e) }); + + const region = manager.uv.create({ width: 4, height: 4 }); + manager.uv.move(region.id, { x: 5, y: 5, width: 4, height: 4 }); + events.length = 0; + + manager.undo(); + + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].action, "uv-region-moved"); + if (events[0].action === "uv-region-moved") { + assert.deepStrictEqual(events[0].metadata.rect, region.rect); + } + }); + + test("applyRemoteCommand restores a remote region without re-broadcasting or recording history", () => { + const events: PixelBufferHookEvent[] = []; + const manager = makeManager({ onBufferUpdated: (e) => events.push(e) }); + + manager.applyRemoteCommand({ + action: "uv-region-created", + metadata: { + region: { id: "remote-1", rect: { x: 0, y: 0, width: 4, height: 4 }, color: "#f00" } + } + }); + + assert.ok(manager.uv.get("remote-1")); + assert.strictEqual(events.length, 0, "remote application must not re-broadcast"); + assert.strictEqual(manager.canUndo(), false, "remote application must not record local history"); + }); + }); + + describe("snapshot", () => { + test("loadSnapshot restores uv regions from a remote snapshot", () => { + const manager = makeManager(); + manager.uv.create({ width: 4, height: 4 }); + + const remoteRegion = { id: "remote-1", rect: { x: 1, y: 1, width: 2, height: 2 }, color: "#00f" }; + manager.loadSnapshot( + { x: 8, y: 8 }, + new Uint8ClampedArray(8 * 8 * 4), + [remoteRegion] + ); + + assert.strictEqual([...manager.uv.regions].length, 1); + assert.deepStrictEqual(manager.uv.get("remote-1"), remoteRegion); + }); + }); +}); + +describe("PixelArtCanvas — onResize (SVG overlay refresh, regression)", () => { + // resizeCanvas() shifts the camera to keep content centered, so every + // overlay computed from the old camera position must redraw itself + // against the new one — same as after a pan/zoom. onResize() previously + // resized the SVG element itself but never told the overlays to redraw. + + function makeResizableContainer(): { + container: HTMLDivElement; + children: unknown[]; + setSize: (width: number, height: number) => void; + } { + let width = 200; + let height = 200; + const div = kEmulatedBrowserWindow.document.createElement("div") as unknown as HTMLDivElement; + const children: unknown[] = []; + (div as any).getBoundingClientRect = () => { + return { + left: 0, top: 0, right: width, bottom: height, width, height + }; + }; + (div as any).style = {}; + (div as any).appendChild = (child: unknown) => { + children.push(child); + }; + + return { + container: div, + children, + setSize: (w, h) => { + width = w; + height = h; + } + }; + } + + test("the UV overlay follows the camera shift caused by a container resize", () => { + const { container, children, setSize } = makeResizableContainer(); + const manager = new PixelArtCanvas(container, { + texture: { maxSize: 32, size: { x: 8, y: 8 } }, + zoom: { default: 4 } + }); + + // First cascade position -> rect {x:0,y:0,...}. 200x200 container, zoom + // 4 -> centered camera (84, 84), so the overlay starts at screen (84, 84). + manager.uv.create({ width: 4, height: 4 }); + manager.uv.showAll = true; + + // Direct children only: excludes BrushHighlightOverlay's rects, which + // live nested inside their own wrapper. Of the remaining direct + // rects (selection's outline/inline, always present but hidden, and + // this one visible UV region), the UV one is the only one with no + // "visibility" attribute at all (UVOverlay never sets one). + const svg = children.find((c) => !("getContext" in (c as object))) as unknown as SVGElement; + const rect = [...svg.querySelectorAll(":scope > rect")] + .find((el) => !el.hasAttribute("visibility"))!; + assert.strictEqual(rect.getAttribute("x"), "84"); + assert.strictEqual(rect.getAttribute("y"), "84"); + + // Grow the container -> camera shifts by half the size delta (see + // Viewport.resizeCanvas): (300-200)/2 = 50 -> new camera (134, 134). + setSize(300, 300); + manager.onResize(); + + assert.strictEqual(rect.getAttribute("x"), "134", "the overlay must follow the camera shift from resizeCanvas"); + assert.strictEqual(rect.getAttribute("y"), "134"); + manager.destroy(); + }); + + test("the select overlay follows the camera shift caused by a container resize", () => { + const { container, children, setSize } = makeResizableContainer(); + const manager = new PixelArtCanvas(container, { + texture: { maxSize: 32, size: { x: 8, y: 8 } }, + zoom: { default: 4 } + }); + + manager.mode = "select"; + const canvas = children[0] as unknown as { dispatchEvent: (event: Event) => void; }; + canvas.dispatchEvent(new MouseEvent("mousedown", { button: 0, buttons: 1, clientX: 92, clientY: 92, bubbles: true })); + canvas.dispatchEvent(new MouseEvent("mousemove", { buttons: 1, clientX: 96, clientY: 96, bubbles: true })); + canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + + // Direct children only, excluding BrushHighlightOverlay's nested rects; + // the selection outline is the direct rect explicitly marked visible. + const svg = children.find((c) => !("getContext" in (c as object))) as unknown as SVGElement; + const rect = [...svg.querySelectorAll(":scope > rect")] + .find((el) => el.getAttribute("visibility") === "visible")!; + // (2,2) texture -> screen (92, 92) at camera (84, 84). + assert.strictEqual(rect.getAttribute("x"), "92"); + + setSize(300, 300); + manager.onResize(); + + // Camera shifts to (134, 134) -> screen (2*4 + 134) = 142. + assert.strictEqual(rect.getAttribute("x"), "142", "the selection outline must follow the camera shift from resizeCanvas"); + manager.destroy(); + }); +}); diff --git a/packages/pixel-draw-renderer/test/history/HistoryController.spec.ts b/packages/pixel-draw-renderer/test/history/HistoryController.spec.ts index df5ac9c9..4479cbba 100644 --- a/packages/pixel-draw-renderer/test/history/HistoryController.spec.ts +++ b/packages/pixel-draw-renderer/test/history/HistoryController.spec.ts @@ -8,6 +8,7 @@ import { type HistoryState } from "../../src/history/HistoryController.ts"; import { PixelBuffer } from "../../src/buffer/PixelBuffer.ts"; +import { UVMap } from "../../src/uv/UVMap.ts"; import type { RGBA } from "../../src/types.ts"; // CONSTANTS @@ -18,10 +19,16 @@ function makeBuffer(): PixelBuffer { return new PixelBuffer({ size: { x: 4, y: 4 }, defaultColor: kWhite, maxSize: 8 }); } +function makeUvMap(): UVMap { + return new UVMap({ getCanvasSize: () => { + return { x: 4, y: 4 }; + } }); +} + describe("HistoryController", () => { describe("disabled (default)", () => { test("push/undo/redo are no-ops and canUndo/canRedo stay false", () => { - const controller = new HistoryController(makeBuffer()); + const controller = new HistoryController(makeBuffer(), makeUvMap()); controller.push({ action: "stroke", @@ -41,7 +48,7 @@ describe("HistoryController", () => { describe("enabled", () => { test("push records an entry that undo/redo replay against the buffer", () => { const buffer = makeBuffer(); - const controller = new HistoryController(buffer, { enabled: true }); + const controller = new HistoryController(buffer, makeUvMap(), { enabled: true }); buffer.drawPixels([{ x: 0, y: 0 }], kRed); controller.push({ @@ -65,7 +72,7 @@ describe("HistoryController", () => { }); test("clear discards both stacks", () => { - const controller = new HistoryController(makeBuffer(), { enabled: true }); + const controller = new HistoryController(makeBuffer(), makeUvMap(), { enabled: true }); controller.push({ action: "stroke", @@ -83,7 +90,7 @@ describe("HistoryController", () => { test("limit bounds the undo stack", () => { const buffer = makeBuffer(); - const controller = new HistoryController(buffer, { enabled: true, limit: 1 }); + const controller = new HistoryController(buffer, makeUvMap(), { enabled: true, limit: 1 }); controller.push({ action: "stroke", @@ -106,7 +113,7 @@ describe("HistoryController", () => { describe("onChange", () => { test("fires after push, undo, redo, and clear — never on a no-op", () => { const states: HistoryState[] = []; - const controller = new HistoryController(makeBuffer(), { + const controller = new HistoryController(makeBuffer(), makeUvMap(), { enabled: true, onChange: (state) => states.push(state) }); @@ -137,7 +144,7 @@ describe("HistoryController", () => { test("does not fire on a disabled controller", () => { const states: HistoryState[] = []; - const controller = new HistoryController(makeBuffer(), { + const controller = new HistoryController(makeBuffer(), makeUvMap(), { enabled: false, onChange: (state) => states.push(state) }); diff --git a/packages/pixel-draw-renderer/test/history/HistoryStack.spec.ts b/packages/pixel-draw-renderer/test/history/HistoryStack.spec.ts index 3628b937..f903b098 100644 --- a/packages/pixel-draw-renderer/test/history/HistoryStack.spec.ts +++ b/packages/pixel-draw-renderer/test/history/HistoryStack.spec.ts @@ -7,6 +7,7 @@ import { HistoryStack } from "../../src/history/HistoryStack.ts"; import { PixelBuffer } from "../../src/buffer/PixelBuffer.ts"; +import { UVMap } from "../../src/uv/UVMap.ts"; import type { RGBA } from "../../src/types.ts"; // CONSTANTS @@ -18,17 +19,23 @@ function makeBuffer(): PixelBuffer { return new PixelBuffer({ size: { x: 4, y: 4 }, defaultColor: kWhite, maxSize: 8 }); } +function makeUvMap(): UVMap { + return new UVMap({ getCanvasSize: () => { + return { x: 4, y: 4 }; + } }); +} + describe("HistoryStack", () => { describe("canUndo / canRedo", () => { test("both false on a fresh stack", () => { - const stack = new HistoryStack(makeBuffer()); + const stack = new HistoryStack(makeBuffer(), makeUvMap()); assert.strictEqual(stack.canUndo, false); assert.strictEqual(stack.canRedo, false); }); test("canUndo becomes true after a push", () => { - const stack = new HistoryStack(makeBuffer()); + const stack = new HistoryStack(makeBuffer(), makeUvMap()); stack.push({ action: "stroke", @@ -45,7 +52,7 @@ describe("HistoryStack", () => { describe("stroke undo/redo", () => { test("undo restores the before-color; redo re-applies the after-color", () => { const buffer = makeBuffer(); - const stack = new HistoryStack(buffer); + const stack = new HistoryStack(buffer, makeUvMap()); buffer.drawPixels([{ x: 0, y: 0 }], kRed); stack.push({ @@ -69,7 +76,7 @@ describe("HistoryStack", () => { test("undo restores heterogeneous before-colors across multiple positions", () => { const buffer = makeBuffer(); - const stack = new HistoryStack(buffer); + const stack = new HistoryStack(buffer, makeUvMap()); buffer.drawPixels([{ x: 0, y: 0 }], kRed); buffer.drawPixels([{ x: 1, y: 0 }], kBlue); @@ -88,13 +95,13 @@ describe("HistoryStack", () => { }); test("undo() returns null when the stack is empty", () => { - const stack = new HistoryStack(makeBuffer()); + const stack = new HistoryStack(makeBuffer(), makeUvMap()); assert.strictEqual(stack.undo(), null); }); test("redo() returns null when there is nothing to redo", () => { - const stack = new HistoryStack(makeBuffer()); + const stack = new HistoryStack(makeBuffer(), makeUvMap()); assert.strictEqual(stack.redo(), null); }); @@ -103,7 +110,7 @@ describe("HistoryStack", () => { describe("select-edit undo/redo", () => { test("undo restores heterogeneous before-colors; redo re-applies heterogeneous after-colors", () => { const buffer = makeBuffer(); - const stack = new HistoryStack(buffer); + const stack = new HistoryStack(buffer, makeUvMap()); buffer.drawPixels([{ x: 0, y: 0 }], kRed); buffer.drawPixels([{ x: 1, y: 0 }], kBlue); @@ -114,7 +121,9 @@ describe("HistoryStack", () => { beforeColors: [kWhite, kWhite], afterColors: [kRed, kBlue], oldRect: { x: 0, y: 0, width: 2, height: 1 }, - newRect: { x: 0, y: 0, width: 2, height: 1 } + newRect: { x: 0, y: 0, width: 2, height: 1 }, + oldMask: [true, true], + newMask: [true, true] }); stack.undo(); @@ -130,7 +139,7 @@ describe("HistoryStack", () => { describe("resized / texture-replaced undo/redo", () => { test("undo restores the previous size and pixel content", () => { const buffer = makeBuffer(); - const stack = new HistoryStack(buffer); + const stack = new HistoryStack(buffer, makeUvMap()); const beforePixels = Uint8ClampedArray.from(buffer.pixels()); buffer.resize({ x: 2, y: 2 }); @@ -155,7 +164,7 @@ describe("HistoryStack", () => { describe("push", () => { test("clears the redo stack", () => { const buffer = makeBuffer(); - const stack = new HistoryStack(buffer); + const stack = new HistoryStack(buffer, makeUvMap()); stack.push({ action: "stroke", positions: [{ x: 0, y: 0 }], beforeColors: [kWhite], afterColor: kRed @@ -171,7 +180,7 @@ describe("HistoryStack", () => { test("evicts the oldest entry once past the configured limit", () => { const buffer = makeBuffer(); - const stack = new HistoryStack(buffer, { limit: 2 }); + const stack = new HistoryStack(buffer, makeUvMap(), { limit: 2 }); for (let i = 0; i < 3; i++) { stack.push({ @@ -187,7 +196,7 @@ describe("HistoryStack", () => { test("defaults to a limit of 10", () => { const buffer = makeBuffer(); - const stack = new HistoryStack(buffer); + const stack = new HistoryStack(buffer, makeUvMap()); for (let i = 0; i < 11; i++) { stack.push({ @@ -206,7 +215,7 @@ describe("HistoryStack", () => { describe("clear", () => { test("discards both stacks", () => { const buffer = makeBuffer(); - const stack = new HistoryStack(buffer); + const stack = new HistoryStack(buffer, makeUvMap()); stack.push({ action: "stroke", positions: [{ x: 0, y: 0 }], beforeColors: [kWhite], afterColor: kRed diff --git a/packages/pixel-draw-renderer/test/network/PixelCommandApplier.spec.ts b/packages/pixel-draw-renderer/test/network/PixelCommandApplier.spec.ts index c2624a1a..2b51a853 100644 --- a/packages/pixel-draw-renderer/test/network/PixelCommandApplier.spec.ts +++ b/packages/pixel-draw-renderer/test/network/PixelCommandApplier.spec.ts @@ -204,6 +204,104 @@ describe("applyCommandToWorld — global-fill", () => { }); }); +describe("applyCommandToWorld — uv-region-created", () => { + test("stores the region on the buffer", () => { + const world = makeWorld(); + world.addBuffer("tex1", { size: { x: 4, y: 4 } }); + applyCommandToWorld(world, { + ...kHeader, + bufferId: "tex1", + action: "uv-region-created", + metadata: { + region: { id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" } + } + }); + + const buffer = world.getBuffer("tex1")!; + assert.deepStrictEqual(buffer.uvRegions.get("r1"), { + id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" + }); + }); + + test("is a no-op for an unknown buffer", () => { + const world = makeWorld(); + assert.doesNotThrow(() => { + applyCommandToWorld(world, { + ...kHeader, + bufferId: "no-such", + action: "uv-region-created", + metadata: { + region: { id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" } + } + }); + }); + }); +}); + +describe("applyCommandToWorld — uv-region-deleted", () => { + test("removes the region from the buffer", () => { + const world = makeWorld(); + world.addBuffer("tex1", { size: { x: 4, y: 4 } }); + const buffer = world.getBuffer("tex1")!; + buffer.uvRegions.set({ id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" }); + + applyCommandToWorld(world, { + ...kHeader, + bufferId: "tex1", + action: "uv-region-deleted", + metadata: { id: "r1" } + }); + + assert.strictEqual(buffer.uvRegions.get("r1"), undefined); + }); + + test("is a no-op for an unknown region", () => { + const world = makeWorld(); + world.addBuffer("tex1", { size: { x: 4, y: 4 } }); + assert.doesNotThrow(() => { + applyCommandToWorld(world, { + ...kHeader, + bufferId: "tex1", + action: "uv-region-deleted", + metadata: { id: "no-such-region" } + }); + }); + }); +}); + +describe("applyCommandToWorld — uv-region-moved", () => { + test("updates the region's rect, preserving its color", () => { + const world = makeWorld(); + world.addBuffer("tex1", { size: { x: 8, y: 8 } }); + const buffer = world.getBuffer("tex1")!; + buffer.uvRegions.set({ id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" }); + + applyCommandToWorld(world, { + ...kHeader, + bufferId: "tex1", + action: "uv-region-moved", + metadata: { id: "r1", rect: { x: 4, y: 4, width: 2, height: 2 } } + }); + + assert.deepStrictEqual(buffer.uvRegions.get("r1"), { + id: "r1", rect: { x: 4, y: 4, width: 2, height: 2 }, color: "#f00" + }); + }); + + test("is a no-op for an unknown region", () => { + const world = makeWorld(); + world.addBuffer("tex1", { size: { x: 8, y: 8 } }); + assert.doesNotThrow(() => { + applyCommandToWorld(world, { + ...kHeader, + bufferId: "tex1", + action: "uv-region-moved", + metadata: { id: "no-such-region", rect: { x: 0, y: 0, width: 1, height: 1 } } + }); + }); + }); +}); + describe("applyCommandToWorld — all actions compile", () => { test("exhaustive switch: no TypeScript error for any action", () => { const actions: PixelNetworkCommand["action"][] = [ @@ -212,8 +310,11 @@ describe("applyCommandToWorld — all actions compile", () => { "stroke", "resized", "texture-replaced", - "global-fill" + "global-fill", + "uv-region-created", + "uv-region-deleted", + "uv-region-moved" ]; - assert.strictEqual(actions.length, 6); + assert.strictEqual(actions.length, 9); }); }); diff --git a/packages/pixel-draw-renderer/test/network/PixelSyncServer.spec.ts b/packages/pixel-draw-renderer/test/network/PixelSyncServer.spec.ts index 3768899f..33fdf23a 100644 --- a/packages/pixel-draw-renderer/test/network/PixelSyncServer.spec.ts +++ b/packages/pixel-draw-renderer/test/network/PixelSyncServer.spec.ts @@ -70,6 +70,45 @@ function bufferAddedCmd( }; } +function uvCreatedCmd( + opts: { + region: { id: string; rect: { x: number; y: number; width: number; height: number; }; color: string; }; + bufferId?: string; + clientId?: string; + seq?: number; + timestamp?: number; + } +): PixelNetworkCommand { + return { + action: "uv-region-created", + bufferId: opts.bufferId ?? "tex1", + metadata: { region: opts.region }, + clientId: opts.clientId ?? "client-A", + seq: opts.seq ?? 1, + timestamp: opts.timestamp ?? 1000 + }; +} + +function uvMovedCmd( + opts: { + id: string; + rect: { x: number; y: number; width: number; height: number; }; + bufferId?: string; + clientId?: string; + seq?: number; + timestamp?: number; + } +): PixelNetworkCommand { + return { + action: "uv-region-moved", + bufferId: opts.bufferId ?? "tex1", + metadata: { id: opts.id, rect: opts.rect }, + clientId: opts.clientId ?? "client-A", + seq: opts.seq ?? 1, + timestamp: opts.timestamp ?? 1000 + }; +} + // --------------------------------------------------------------------------- // connect / disconnect (presence only) // --------------------------------------------------------------------------- @@ -414,6 +453,189 @@ describe("PixelSyncServer — snapshot", () => { const pixels = toUint8Array(snap.pixels); assert.deepStrictEqual([pixels[0], pixels[1], pixels[2], pixels[3]], [5, 6, 7, 255]); }); + + it("includes the buffer's current UV regions, for late-joining clients", () => { + const server = new PixelSyncServer(); + server.receive(bufferAddedCmd("tex1")); + server.receive(uvCreatedCmd({ + region: { id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" } + })); + + const snap = server.snapshot("tex1")!; + assert.deepStrictEqual(snap.uvRegions, [ + { id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" } + ]); + }); +}); + +// --------------------------------------------------------------------------- +// receive: uv-region-* — per-region LWW conflict resolution +// --------------------------------------------------------------------------- + +describe("PixelSyncServer — receive: uv-region-created", () => { + it("applies unconditionally (idempotent by unique id) and broadcasts", () => { + const server = new PixelSyncServer(); + server.receive(bufferAddedCmd("tex1")); + + const client = createClient("A"); + server.connect(client); + server.subscribe("A", "tex1"); + client.received.length = 0; + + server.receive(uvCreatedCmd({ + region: { id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" } + })); + + const buffer = server.world.getBuffer("tex1")!; + assert.ok(buffer.uvRegions.get("r1")); + assert.strictEqual(client.received.length, 1); + }); +}); + +describe("PixelSyncServer — receive: uv-region-moved / uv-region-deleted conflict resolution", () => { + it("accepts a later-timestamp move over an earlier one for the same region", () => { + const server = new PixelSyncServer(); + server.receive(bufferAddedCmd("tex1")); + server.receive(uvCreatedCmd({ + region: { id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" } + })); + + server.receive(uvMovedCmd({ + id: "r1", rect: { x: 1, y: 1, width: 2, height: 2 }, timestamp: 500, clientId: "A" + })); + server.receive(uvMovedCmd({ + id: "r1", rect: { x: 2, y: 2, width: 2, height: 2 }, timestamp: 900, clientId: "B" + })); + + const buffer = server.world.getBuffer("tex1")!; + assert.deepStrictEqual(buffer.uvRegions.get("r1")!.rect, { x: 2, y: 2, width: 2, height: 2 }); + }); + + it("rejects a stale move for a region already moved by a newer command", () => { + const server = new PixelSyncServer(); + server.receive(bufferAddedCmd("tex1")); + server.receive(uvCreatedCmd({ + region: { id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" } + })); + + server.receive(uvMovedCmd({ + id: "r1", rect: { x: 2, y: 2, width: 2, height: 2 }, timestamp: 900, clientId: "A" + })); + server.receive(uvMovedCmd({ + id: "r1", rect: { x: 1, y: 1, width: 2, height: 2 }, timestamp: 500, clientId: "B" + })); + + const buffer = server.world.getBuffer("tex1")!; + assert.deepStrictEqual(buffer.uvRegions.get("r1")!.rect, { x: 2, y: 2, width: 2, height: 2 }); + }); + + it("a stale delete does not remove a region moved by a newer command", () => { + const server = new PixelSyncServer(); + server.receive(bufferAddedCmd("tex1")); + server.receive(uvCreatedCmd({ + region: { id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" } + })); + + server.receive(uvMovedCmd({ + id: "r1", rect: { x: 2, y: 2, width: 2, height: 2 }, timestamp: 900, clientId: "A" + })); + server.receive({ + action: "uv-region-deleted", + bufferId: "tex1", + metadata: { id: "r1" }, + clientId: "B", + seq: 1, + timestamp: 500 + }); + + const buffer = server.world.getBuffer("tex1")!; + assert.ok(buffer.uvRegions.get("r1"), "region should still exist — the delete was stale"); + }); + + it("a newer delete removes the region and later stale moves are rejected", () => { + const server = new PixelSyncServer(); + server.receive(bufferAddedCmd("tex1")); + server.receive(uvCreatedCmd({ + region: { id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" } + })); + + server.receive({ + action: "uv-region-deleted", + bufferId: "tex1", + metadata: { id: "r1" }, + clientId: "A", + seq: 1, + timestamp: 900 + }); + server.receive(uvMovedCmd({ + id: "r1", rect: { x: 1, y: 1, width: 2, height: 2 }, timestamp: 500, clientId: "B" + })); + + const buffer = server.world.getBuffer("tex1")!; + assert.strictEqual(buffer.uvRegions.get("r1"), undefined); + }); + + it("moves/deletes on different regions of the same buffer never conflict", () => { + const server = new PixelSyncServer(); + server.receive(bufferAddedCmd("tex1")); + server.receive(uvCreatedCmd({ + region: { id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" } + })); + server.receive(uvCreatedCmd({ + region: { id: "r2", rect: { x: 4, y: 4, width: 2, height: 2 }, color: "#00f" }, clientId: "B" + })); + + server.receive(uvMovedCmd({ + id: "r1", rect: { x: 1, y: 1, width: 2, height: 2 }, timestamp: 900, clientId: "A" + })); + server.receive(uvMovedCmd({ + id: "r2", rect: { x: 5, y: 5, width: 2, height: 2 }, timestamp: 100, clientId: "B" + })); + + const buffer = server.world.getBuffer("tex1")!; + assert.deepStrictEqual(buffer.uvRegions.get("r1")!.rect, { x: 1, y: 1, width: 2, height: 2 }); + assert.deepStrictEqual(buffer.uvRegions.get("r2")!.rect, { x: 5, y: 5, width: 2, height: 2 }); + }); + + it("clears region conflict-tracking state when the buffer is removed", () => { + const server = new PixelSyncServer(); + server.receive(bufferAddedCmd("tex1")); + server.receive(uvCreatedCmd({ + region: { id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" } + })); + server.receive(uvMovedCmd({ + id: "r1", rect: { x: 1, y: 1, width: 2, height: 2 }, timestamp: 900, clientId: "A" + })); + + server.receive({ + action: "buffer-removed", + bufferId: "tex1", + metadata: {}, + clientId: "A", + seq: 1, + timestamp: 1000 + }); + server.receive(bufferAddedCmd("tex1", "B")); + + // A stale-looking move (lower timestamp than the earlier session's 900) + // is accepted because the buffer's history was cleared on removal. + server.receive(uvCreatedCmd({ + region: { id: "r1", rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" }, clientId: "B" + })); + server.receive(uvMovedCmd({ + id: "r1", rect: { x: 3, y: 3, width: 2, height: 2 }, timestamp: 1, clientId: "B" + })); + + const buffer = server.world.getBuffer("tex1")!; + assert.deepStrictEqual(buffer.uvRegions.get("r1")!.rect, { x: 3, y: 3, width: 2, height: 2 }); + }); + + it("commands targeting an unknown buffer are dropped", () => { + const server = new PixelSyncServer(); + assert.doesNotThrow(() => { + server.receive(uvMovedCmd({ id: "r1", rect: { x: 0, y: 0, width: 1, height: 1 }, bufferId: "no-such" })); + }); + }); }); describe("PixelSyncServer — custom world", () => { diff --git a/packages/pixel-draw-renderer/test/network/PixelSyncSession.spec.ts b/packages/pixel-draw-renderer/test/network/PixelSyncSession.spec.ts index 65d878a9..f9e5a7d8 100644 --- a/packages/pixel-draw-renderer/test/network/PixelSyncSession.spec.ts +++ b/packages/pixel-draw-renderer/test/network/PixelSyncSession.spec.ts @@ -338,7 +338,7 @@ describe("PixelSyncSession — snapshot loading", () => { const pixels = new Uint8ClampedArray([1, 2, 3, 255]); const base64 = Buffer.from(pixels).toString("base64"); - transport.simulateSnapshot("tex1", { size: { x: 1, y: 1 }, pixels: base64 }); + transport.simulateSnapshot("tex1", { size: { x: 1, y: 1 }, pixels: base64, uvRegions: [] }); assert.strictEqual(manager.loadedSnapshots.length, 1); assert.deepStrictEqual(manager.loadedSnapshots[0].size, { x: 1, y: 1 }); @@ -350,7 +350,7 @@ describe("PixelSyncSession — snapshot loading", () => { new PixelSyncSession({ transport }); assert.doesNotThrow(() => { - transport.simulateSnapshot("unknown", { size: { x: 1, y: 1 }, pixels: "" }); + transport.simulateSnapshot("unknown", { size: { x: 1, y: 1 }, pixels: "", uvRegions: [] }); }); }); }); diff --git a/packages/pixel-draw-renderer/test/rendering/SvgManager.spec.ts b/packages/pixel-draw-renderer/test/rendering/SvgManager.spec.ts index b3e42ace..a5f85f3d 100644 --- a/packages/pixel-draw-renderer/test/rendering/SvgManager.spec.ts +++ b/packages/pixel-draw-renderer/test/rendering/SvgManager.spec.ts @@ -10,7 +10,9 @@ import { SvgManager } from "../../src/rendering/SvgManager.ts"; import { BrushHighlightOverlay } from "../../src/rendering/overlays/BrushHighlightOverlay.ts"; import { LinePreviewOverlay } from "../../src/rendering/overlays/LinePreviewOverlay.ts"; import { SelectionOverlay } from "../../src/rendering/overlays/SelectionOverlay.ts"; +import { UVOverlay } from "../../src/rendering/overlays/UVOverlay.ts"; import { Zoom } from "../../src/rendering/Zoom.ts"; +import { UVMap } from "../../src/uv/UVMap.ts"; import type { DefaultViewport } from "../../src/rendering/Viewport.ts"; // CONSTANTS @@ -55,18 +57,26 @@ function makeBrush() { }; } +function makeUvMap(): UVMap { + return new UVMap({ getCanvasSize: () => { + return { x: 64, y: 32 }; + } }); +} + describe("SvgManager", () => { describe("overlays", () => { test("exposes the brush highlight, line preview, and selection overlays", () => { const svgMgr = new SvgManager({ parent: makeParent(), viewport: makeViewport(), - brush: makeBrush() + brush: makeBrush(), + uvMap: makeUvMap() }); assert.ok(svgMgr.brushHighlight instanceof BrushHighlightOverlay); assert.ok(svgMgr.linePreview instanceof LinePreviewOverlay); assert.ok(svgMgr.selection instanceof SelectionOverlay); + assert.ok(svgMgr.uvOverlay instanceof UVOverlay); }); }); @@ -77,7 +87,8 @@ describe("SvgManager", () => { const svgMgr = new SvgManager({ parent, viewport: makeViewport(), - brush: makeBrush() + brush: makeBrush(), + uvMap: makeUvMap() }); // Before destroy: parent should have child SVG elements @@ -96,7 +107,8 @@ describe("SvgManager", () => { const svgMgr = new SvgManager({ parent, viewport: makeViewport(), - brush: makeBrush() + brush: makeBrush(), + uvMap: makeUvMap() }); svgMgr.destroy(); diff --git a/packages/pixel-draw-renderer/test/rendering/overlays/UVOverlay.spec.ts b/packages/pixel-draw-renderer/test/rendering/overlays/UVOverlay.spec.ts new file mode 100644 index 00000000..0e9e5628 --- /dev/null +++ b/packages/pixel-draw-renderer/test/rendering/overlays/UVOverlay.spec.ts @@ -0,0 +1,164 @@ +// Import Node.js Dependencies +import { describe, test, before } from "node:test"; +import assert from "node:assert/strict"; + +// Import Third-party Dependencies +import { Window } from "happy-dom"; + +// Import Internal Dependencies +import { UVOverlay } from "../../../src/rendering/overlays/UVOverlay.ts"; +import { UVMap } from "../../../src/uv/UVMap.ts"; +import { SVG_NS } from "../../../src/rendering/constants.ts"; +import { Zoom } from "../../../src/rendering/Zoom.ts"; +import type { DefaultViewport } from "../../../src/rendering/Viewport.ts"; + +// CONSTANTS +const kEmulatedBrowserWindow = new Window(); + +before(() => { + globalThis.document = kEmulatedBrowserWindow.document as unknown as Document; +}); + +function makeSvg(): SVGElement { + return kEmulatedBrowserWindow.document.createElementNS(SVG_NS, "svg") as unknown as SVGElement; +} + +function makeViewport(): DefaultViewport { + return { + zoom: new Zoom({ default: 4 }), + camera: { x: 0, y: 0 } + }; +} + +function makeUvMap(): UVMap { + return new UVMap({ getCanvasSize: () => { + return { x: 64, y: 64 }; + } }); +} + +describe("UVOverlay — visibility follows UVMap state", () => { + test("renders nothing for a region that isn't selected or shown", () => { + const svg = makeSvg(); + const map = makeUvMap(); + + new UVOverlay(svg, makeViewport(), map); + + map.create({ width: 4, height: 4 }); + + assert.strictEqual(svg.querySelectorAll("rect").length, 0); + }); + + test("renders a solid border rect once its region is selected", () => { + const svg = makeSvg(); + const map = makeUvMap(); + + new UVOverlay(svg, makeViewport(), map); + + const region = map.create({ width: 2, height: 3, id: "r1", color: "#123456" }); + map.select(region.id); + + const rects = svg.querySelectorAll("rect"); + assert.strictEqual(rects.length, 1); + // zoom 4, camera (0,0): rect (0,0,2,3) -> x=0, y=0, width=8, height=12 + assert.strictEqual(rects[0].getAttribute("x"), "0"); + assert.strictEqual(rects[0].getAttribute("y"), "0"); + assert.strictEqual(rects[0].getAttribute("width"), "8"); + assert.strictEqual(rects[0].getAttribute("height"), "12"); + assert.strictEqual(rects[0].getAttribute("stroke"), "#123456"); + assert.strictEqual(rects[0].hasAttribute("stroke-dasharray"), false, "solid, not dashed"); + }); + + test("showAll renders every region regardless of selection", () => { + const svg = makeSvg(); + const map = makeUvMap(); + + new UVOverlay(svg, makeViewport(), map); + + map.create({ width: 2, height: 2 }); + map.create({ width: 2, height: 2 }); + map.showAll = true; + + assert.strictEqual(svg.querySelectorAll("rect").length, 2); + }); + + test("removes the rect once its region is deselected", () => { + const svg = makeSvg(); + const map = makeUvMap(); + + new UVOverlay(svg, makeViewport(), map); + + const region = map.create({ width: 2, height: 2 }); + map.select(region.id); + assert.strictEqual(svg.querySelectorAll("rect").length, 1); + + map.select(null); + assert.strictEqual(svg.querySelectorAll("rect").length, 0); + }); + + test("removes the rect once its region is deleted", () => { + const svg = makeSvg(); + const map = makeUvMap(); + + new UVOverlay(svg, makeViewport(), map); + + const region = map.create({ width: 2, height: 2 }); + map.showAll = true; + assert.strictEqual(svg.querySelectorAll("rect").length, 1); + + map.delete(region.id); + assert.strictEqual(svg.querySelectorAll("rect").length, 0); + }); + + test("moving a visible region updates its screen position", () => { + const svg = makeSvg(); + const map = makeUvMap(); + + new UVOverlay(svg, makeViewport(), map); + + const region = map.create({ width: 2, height: 2 }); + map.showAll = true; + map.move(region.id, { x: 5, y: 5, width: 2, height: 2 }); + + const rect = svg.querySelector("rect")!; + assert.strictEqual(rect.getAttribute("x"), "20"); + assert.strictEqual(rect.getAttribute("y"), "20"); + }); +}); + +describe("UVOverlay — setLiveOverride", () => { + test("renders the override rect instead of the stored one", () => { + const svg = makeSvg(); + const map = makeUvMap(); + const overlay = new UVOverlay(svg, makeViewport(), map); + + const region = map.create({ width: 2, height: 2 }); + map.showAll = true; + + overlay.setLiveOverride(region.id, { x: 9, y: 9, width: 2, height: 2 }); + + const rect = svg.querySelector("rect")!; + assert.strictEqual(rect.getAttribute("x"), "36"); + assert.strictEqual(rect.getAttribute("y"), "36"); + + overlay.setLiveOverride(region.id, null); + assert.strictEqual(rect.getAttribute("x"), "0"); + }); +}); + +describe("UVOverlay — destroy", () => { + test("stops reacting to UVMap events and removes its rects", () => { + const svg = makeSvg(); + const map = makeUvMap(); + const overlay = new UVOverlay(svg, makeViewport(), map); + + const region = map.create({ width: 2, height: 2 }); + map.showAll = true; + assert.strictEqual(svg.querySelectorAll("rect").length, 1); + + overlay.destroy(); + assert.strictEqual(svg.querySelectorAll("rect").length, 0); + + map.move(region.id, { x: 1, y: 1, width: 2, height: 2 }); + assert.strictEqual(svg.querySelectorAll("rect").length, 0); + }); +}); diff --git a/packages/pixel-draw-renderer/test/utils/EventEmitter.spec.ts b/packages/pixel-draw-renderer/test/utils/EventEmitter.spec.ts new file mode 100644 index 00000000..3aa3c46f --- /dev/null +++ b/packages/pixel-draw-renderer/test/utils/EventEmitter.spec.ts @@ -0,0 +1,110 @@ +// Import Node.js Dependencies +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { TypedEventEmitter } from "../../src/utils/EventEmitter.ts"; + +type TestEvent = + | { type: "foo"; value: number; } + | { type: "bar"; label: string; }; + +/** + * `emit` is protected on TypedEventEmitter (only an owning subclass should + * trigger its own events), so tests drive it through this thin subclass. + */ +class TestEmitter extends TypedEventEmitter { + trigger( + event: Extract + ): void { + this.emit(event); + } +} + +describe("TypedEventEmitter", () => { + test("invokes a listener registered for the emitted event type", () => { + const emitter = new TestEmitter(); + const events: TestEvent[] = []; + emitter.on("foo", (e) => events.push(e)); + + emitter.trigger({ type: "foo", value: 1 }); + + assert.deepStrictEqual(events, [{ type: "foo", value: 1 }]); + }); + + test("does not invoke listeners registered for a different event type", () => { + const emitter = new TestEmitter(); + const events: TestEvent[] = []; + emitter.on("bar", (e) => events.push(e)); + + emitter.trigger({ type: "foo", value: 1 }); + + assert.strictEqual(events.length, 0); + }); + + test("supports multiple listeners for the same event type", () => { + const emitter = new TestEmitter(); + let firstCalls = 0; + let secondCalls = 0; + emitter.on("foo", () => { + firstCalls++; + }); + emitter.on("foo", () => { + secondCalls++; + }); + + emitter.trigger({ type: "foo", value: 1 }); + + assert.strictEqual(firstCalls, 1); + assert.strictEqual(secondCalls, 1); + }); + + test("off() stops a listener from receiving further events", () => { + const emitter = new TestEmitter(); + const events: TestEvent[] = []; + function listener( + e: Extract + ): void { + events.push(e); + } + + emitter.on("foo", listener); + emitter.trigger({ type: "foo", value: 1 }); + emitter.off("foo", listener); + emitter.trigger({ type: "foo", value: 2 }); + + assert.strictEqual(events.length, 1); + }); + + test("off() on an unregistered listener is a no-op", () => { + const emitter = new TestEmitter(); + + function listener(): void { + // never registered, never expected to run + } + + assert.doesNotThrow(() => emitter.off("foo", listener)); + }); + + test("emit() with no listeners registered is a no-op", () => { + const emitter = new TestEmitter(); + + assert.doesNotThrow(() => emitter.trigger({ type: "foo", value: 1 })); + }); + + test("a listener removing itself mid-emit does not affect the current dispatch", () => { + const emitter = new TestEmitter(); + const events: TestEvent[] = []; + function listener( + e: Extract + ): void { + events.push(e); + emitter.off("foo", listener); + } + + emitter.on("foo", listener); + emitter.trigger({ type: "foo", value: 1 }); + + assert.strictEqual(events.length, 1); + }); +}); diff --git a/packages/pixel-draw-renderer/test/utils/math.spec.ts b/packages/pixel-draw-renderer/test/utils/math.spec.ts index e03be73b..a6bc02c2 100644 --- a/packages/pixel-draw-renderer/test/utils/math.spec.ts +++ b/packages/pixel-draw-renderer/test/utils/math.spec.ts @@ -3,7 +3,7 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; // Import Internal Dependencies -import { clamp } from "../../src/utils/math.ts"; +import { clamp, clampRectPosition, clampRectSize, pointInRect } from "../../src/utils/math.ts"; describe("clamp", () => { test("returns the value unchanged when within range", () => { @@ -30,3 +30,77 @@ describe("clamp", () => { assert.strictEqual(clamp(-20, -10, -1), -10); }); }); + +describe("clampRectSize", () => { + test("leaves a rect unchanged when it already fits", () => { + const rect = { x: 2, y: 3, width: 4, height: 5 }; + assert.deepStrictEqual(clampRectSize(rect, { x: 10, y: 10 }), rect); + }); + + test("shrinks width/height to fit within size", () => { + const rect = { x: 0, y: 0, width: 20, height: 30 }; + assert.deepStrictEqual( + clampRectSize(rect, { x: 10, y: 10 }), + { x: 0, y: 0, width: 10, height: 10 } + ); + }); + + test("clamps position once size is shrunk so the rect stays in bounds", () => { + const rect = { x: 8, y: 8, width: 20, height: 20 }; + assert.deepStrictEqual( + clampRectSize(rect, { x: 10, y: 10 }), + { x: 0, y: 0, width: 10, height: 10 } + ); + }); + + test("never produces a width/height below 1", () => { + const rect = { x: 0, y: 0, width: -5, height: 0 }; + assert.deepStrictEqual( + clampRectSize(rect, { x: 10, y: 10 }), + { x: 0, y: 0, width: 1, height: 1 } + ); + }); +}); + +describe("clampRectPosition", () => { + test("leaves a rect unchanged when it already fits", () => { + const rect = { x: 2, y: 3, width: 4, height: 5 }; + assert.deepStrictEqual(clampRectPosition(rect, { x: 10, y: 10 }), rect); + }); + + test("clamps position to keep the rect within bounds without resizing it", () => { + const rect = { x: 8, y: 9, width: 5, height: 5 }; + assert.deepStrictEqual( + clampRectPosition(rect, { x: 10, y: 10 }), + { x: 5, y: 5, width: 5, height: 5 } + ); + }); + + test("clamps negative position to 0", () => { + const rect = { x: -5, y: -5, width: 4, height: 4 }; + assert.deepStrictEqual( + clampRectPosition(rect, { x: 10, y: 10 }), + { x: 0, y: 0, width: 4, height: 4 } + ); + }); +}); + +describe("pointInRect", () => { + const rect = { x: 2, y: 3, width: 4, height: 5 }; + + test("returns true for a point inside the rect", () => { + assert.strictEqual(pointInRect({ x: 3, y: 4 }, rect), true); + }); + + test("is inclusive of the top-left edge", () => { + assert.strictEqual(pointInRect({ x: 2, y: 3 }, rect), true); + }); + + test("is exclusive of the bottom-right edge", () => { + assert.strictEqual(pointInRect({ x: 6, y: 8 }, rect), false); + }); + + test("returns false for a point outside the rect", () => { + assert.strictEqual(pointInRect({ x: 0, y: 0 }, rect), false); + }); +}); diff --git a/packages/pixel-draw-renderer/test/uv/UVController.spec.ts b/packages/pixel-draw-renderer/test/uv/UVController.spec.ts new file mode 100644 index 00000000..6d365eb8 --- /dev/null +++ b/packages/pixel-draw-renderer/test/uv/UVController.spec.ts @@ -0,0 +1,233 @@ +// Import Node.js Dependencies +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { UVMap, type UVMapEvent } from "../../src/uv/UVMap.ts"; +import { UVController } from "../../src/uv/UVController.ts"; +import type { SelectionRect } from "../../src/types.ts"; + +class FakeOverlay { + overrides: { id: string; rect: SelectionRect | null; }[] = []; + + setLiveOverride( + id: string, + rect: SelectionRect | null + ): void { + this.overrides.push({ id, rect }); + } +} + +function makeSetup( + size = { x: 32, y: 32 } +): { map: UVMap; overlay: FakeOverlay; controller: UVController; } { + const map = new UVMap({ getCanvasSize: () => size }); + const overlay = new FakeOverlay(); + const controller = new UVController({ uvMap: map, overlay: overlay as any }); + + return { map, overlay, controller }; +} + +describe("UVController — hit-test / select on miss", () => { + it("selects a visible region hit by handleStart", () => { + const { map, controller } = makeSetup(); + const region = map.create({ width: 8, height: 8 }); + map.showAll = true; + + controller.handleStart({ x: 2, y: 2 }); + + assert.strictEqual(map.selectedRegionId, region.id); + }); + + it("cannot hit an invisible region", () => { + const { map, controller } = makeSetup(); + map.create({ width: 8, height: 8 }); + + controller.handleStart({ x: 2, y: 2 }); + + assert.strictEqual(map.selectedRegionId, null); + }); + + it("deselects on a miss", () => { + const { map, controller } = makeSetup(); + const region = map.create({ width: 8, height: 8 }); + map.showAll = true; + controller.handleStart({ x: 2, y: 2 }); + assert.strictEqual(map.selectedRegionId, region.id); + + controller.handleStart({ x: 20, y: 20 }); + + assert.strictEqual(map.selectedRegionId, null); + }); +}); + +describe("UVController — drag to move", () => { + it("does not commit the move until handleEnd", () => { + const { map, controller } = makeSetup(); + const region = map.create({ width: 8, height: 8 }); + map.showAll = true; + + controller.handleStart({ x: 2, y: 2 }); + controller.handleMove({ x: 6, y: 6 }); + + assert.deepStrictEqual(map.get(region.id)!.rect, region.rect); + }); + + it("commits the accumulated delta on handleEnd", () => { + const { map, controller } = makeSetup(); + const region = map.create({ width: 8, height: 8 }); + map.showAll = true; + + controller.handleStart({ x: 2, y: 2 }); + controller.handleMove({ x: 6, y: 6 }); + controller.handleEnd(); + + assert.deepStrictEqual(map.get(region.id)!.rect, { x: 4, y: 4, width: 8, height: 8 }); + }); + + it("does not move the region for a click without dragging", () => { + const { map, controller } = makeSetup(); + const region = map.create({ width: 8, height: 8 }); + map.showAll = true; + + controller.handleStart({ x: 2, y: 2 }); + controller.handleEnd(); + + assert.deepStrictEqual(map.get(region.id)!.rect, region.rect); + }); + + it("clamps the live drag preview to canvas bounds", () => { + const { map, overlay, controller } = makeSetup({ x: 16, y: 16 }); + map.create({ width: 8, height: 8 }); + map.showAll = true; + + controller.handleStart({ x: 2, y: 2 }); + controller.handleMove({ x: 100, y: 100 }); + + const last = overlay.overrides.at(-1)!; + assert.deepStrictEqual(last.rect, { x: 8, y: 8, width: 8, height: 8 }); + }); + + it("clears the live override on handleEnd", () => { + const { controller, overlay, map } = makeSetup(); + map.create({ width: 8, height: 8 }); + map.showAll = true; + + controller.handleStart({ x: 2, y: 2 }); + controller.handleMove({ x: 6, y: 6 }); + controller.handleEnd(); + + assert.strictEqual(overlay.overrides.at(-1)!.rect, null); + }); + + it("cancelDrag discards the in-progress drag without committing", () => { + const { map, controller, overlay } = makeSetup(); + const region = map.create({ width: 8, height: 8 }); + map.showAll = true; + + controller.handleStart({ x: 2, y: 2 }); + controller.handleMove({ x: 6, y: 6 }); + controller.cancelDrag(); + controller.handleEnd(); + + assert.deepStrictEqual(map.get(region.id)!.rect, region.rect); + assert.strictEqual(overlay.overrides.at(-1)!.rect, null); + }); + + it("handleMove/handleEnd are no-ops without an active drag", () => { + const { controller } = makeSetup(); + + assert.doesNotThrow(() => { + controller.handleMove({ x: 1, y: 1 }); + controller.handleEnd(); + }); + }); + + describe("live drag preview (region-dragging)", () => { + it("handleMove emits a live preview via UVMap on every move, without committing", () => { + const { map, controller } = makeSetup(); + const region = map.create({ width: 8, height: 8 }); + map.showAll = true; + const events: Extract[] = []; + map.on("region-dragging", (e) => events.push(e)); + + controller.handleStart({ x: 2, y: 2 }); + controller.handleMove({ x: 6, y: 6 }); + controller.handleMove({ x: 7, y: 7 }); + + assert.deepStrictEqual(events.map((e) => e.rect), [ + { x: 4, y: 4, width: 8, height: 8 }, + { x: 5, y: 5, width: 8, height: 8 } + ]); + assert.deepStrictEqual(map.get(region.id)!.rect, region.rect, "not committed yet"); + }); + + it("cancelDrag reverts the preview to the region's actual (unchanged) rect", () => { + const { map, controller } = makeSetup(); + const region = map.create({ width: 8, height: 8 }); + map.showAll = true; + const events: Extract[] = []; + map.on("region-dragging", (e) => events.push(e)); + + controller.handleStart({ x: 2, y: 2 }); + controller.handleMove({ x: 6, y: 6 }); + controller.cancelDrag(); + + assert.deepStrictEqual(events.at(-1)!.rect, region.rect); + }); + }); +}); + +describe("UVController — isDragging", () => { + it("is false until a drag starts, true while dragging, false again after handleEnd", () => { + const { map, controller } = makeSetup(); + map.create({ width: 8, height: 8 }); + map.showAll = true; + + assert.strictEqual(controller.isDragging, false); + + controller.handleStart({ x: 2, y: 2 }); + assert.strictEqual(controller.isDragging, true); + + controller.handleEnd(); + assert.strictEqual(controller.isDragging, false); + }); + + it("stays false when handleStart misses (no drag to track)", () => { + const { controller } = makeSetup(); + + controller.handleStart({ x: 2, y: 2 }); + + assert.strictEqual(controller.isDragging, false); + }); + + it("is false again after cancelDrag", () => { + const { map, controller } = makeSetup(); + map.create({ width: 8, height: 8 }); + map.showAll = true; + + controller.handleStart({ x: 2, y: 2 }); + controller.cancelDrag(); + + assert.strictEqual(controller.isDragging, false); + }); +}); + +describe("UVController — handleDelete", () => { + it("deletes the selected region and returns true", () => { + const { map, controller } = makeSetup(); + const region = map.create({ width: 8, height: 8 }); + map.select(region.id); + + const result = controller.handleDelete(); + + assert.strictEqual(result, true); + assert.strictEqual(map.get(region.id), undefined); + }); + + it("returns false when nothing is selected", () => { + const { controller } = makeSetup(); + + assert.strictEqual(controller.handleDelete(), false); + }); +}); diff --git a/packages/pixel-draw-renderer/test/uv/UVMap.spec.ts b/packages/pixel-draw-renderer/test/uv/UVMap.spec.ts new file mode 100644 index 00000000..9a26ffbb --- /dev/null +++ b/packages/pixel-draw-renderer/test/uv/UVMap.spec.ts @@ -0,0 +1,301 @@ +// Import Node.js Dependencies +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { UVMap, type UVMapEvent } from "../../src/uv/UVMap.ts"; +import type { Vec2 } from "../../src/types.ts"; + +function makeMap( + size: Vec2 = { x: 32, y: 32 } +): UVMap { + return new UVMap({ getCanvasSize: () => size }); +} + +describe("UVMap — create", () => { + it("creates a region with the requested size at the origin, with a palette color", () => { + const map = makeMap(); + const region = map.create({ width: 8, height: 8 }); + + assert.deepStrictEqual(region.rect, { x: 0, y: 0, width: 8, height: 8 }); + assert.strictEqual(typeof region.color, "string"); + assert.strictEqual([...map.regions].length, 1); + }); + + it("clamps width/height to the canvas size", () => { + const map = makeMap({ x: 4, y: 4 }); + const region = map.create({ width: 100, height: 100 }); + + assert.deepStrictEqual(region.rect, { x: 0, y: 0, width: 4, height: 4 }); + }); + + it("cascades subsequent regions instead of stacking them at the origin", () => { + const map = makeMap(); + const a = map.create({ width: 8, height: 8 }); + const b = map.create({ width: 8, height: 8 }); + + assert.notDeepStrictEqual(a.rect, b.rect); + }); + + it("assigns distinct colors from the palette to successive regions", () => { + const map = makeMap(); + const a = map.create({ width: 4, height: 4 }); + const b = map.create({ width: 4, height: 4 }); + + assert.notStrictEqual(a.color, b.color); + }); + + it("accepts an explicit id and color", () => { + const map = makeMap(); + const region = map.create({ width: 4, height: 4, id: "custom-id", color: "#123456" }); + + assert.strictEqual(region.id, "custom-id"); + assert.strictEqual(region.color, "#123456"); + }); + + it("emits a region-created event", () => { + const map = makeMap(); + const events: UVMapEvent[] = []; + map.on("region-created", (e) => events.push(e)); + + const region = map.create({ width: 4, height: 4 }); + + assert.strictEqual(events.length, 1); + assert.deepStrictEqual(events[0], { type: "region-created", region }); + }); +}); + +describe("UVMap — restore", () => { + it("re-adds a region exactly as given and emits region-created", () => { + const map = makeMap(); + const events: UVMapEvent[] = []; + map.on("region-created", (e) => events.push(e)); + + const region = { id: "r1", rect: { x: 3, y: 3, width: 2, height: 2 }, color: "#abcdef" }; + const stored = map.restore(region); + + assert.deepStrictEqual(stored, region); + assert.deepStrictEqual(map.get("r1"), region); + assert.strictEqual(events.length, 1); + }); + + it("does not affect cascading placement of subsequent create() calls", () => { + const map = makeMap(); + map.restore({ id: "r1", rect: { x: 10, y: 10, width: 2, height: 2 }, color: "#000" }); + const created = map.create({ width: 2, height: 2 }); + + assert.deepStrictEqual(created.rect, { x: 0, y: 0, width: 2, height: 2 }); + }); +}); + +describe("UVMap — delete", () => { + it("removes the region and emits region-deleted with its last-known state", () => { + const map = makeMap(); + const region = map.create({ width: 4, height: 4 }); + const events: UVMapEvent[] = []; + map.on("region-deleted", (e) => events.push(e)); + + const result = map.delete(region.id); + + assert.strictEqual(result, true); + assert.strictEqual(map.get(region.id), undefined); + assert.deepStrictEqual(events[0], { type: "region-deleted", region }); + }); + + it("returns false for an unknown id and does not emit", () => { + const map = makeMap(); + const events: UVMapEvent[] = []; + map.on("region-deleted", (e) => events.push(e)); + + assert.strictEqual(map.delete("no-such"), false); + assert.strictEqual(events.length, 0); + }); + + it("clears selectedRegionId when the selected region is deleted", () => { + const map = makeMap(); + const region = map.create({ width: 4, height: 4 }); + map.select(region.id); + + map.delete(region.id); + + assert.strictEqual(map.selectedRegionId, null); + }); +}); + +describe("UVMap — move", () => { + it("updates the rect and emits region-moved with the previous rect", () => { + const map = makeMap(); + const region = map.create({ width: 4, height: 4 }); + const events: UVMapEvent[] = []; + map.on("region-moved", (e) => events.push(e)); + + const result = map.move(region.id, { x: 10, y: 10, width: 4, height: 4 }); + + assert.strictEqual(result, true); + assert.deepStrictEqual(map.get(region.id)!.rect, { x: 10, y: 10, width: 4, height: 4 }); + assert.deepStrictEqual(events[0], { + type: "region-moved", + region: { ...region, rect: { x: 10, y: 10, width: 4, height: 4 } }, + previousRect: { x: 0, y: 0, width: 4, height: 4 } + }); + }); + + it("clamps the destination rect to canvas bounds", () => { + const map = makeMap({ x: 16, y: 16 }); + const region = map.create({ width: 4, height: 4 }); + + map.move(region.id, { x: 100, y: 100, width: 4, height: 4 }); + + assert.deepStrictEqual(map.get(region.id)!.rect, { x: 12, y: 12, width: 4, height: 4 }); + }); + + it("returns false for an unknown id", () => { + const map = makeMap(); + assert.strictEqual(map.move("no-such", { x: 0, y: 0, width: 1, height: 1 }), false); + }); +}); + +describe("UVMap — previewMove", () => { + it("emits region-dragging with the clamped rect, without mutating the stored region", () => { + const map = makeMap({ x: 16, y: 16 }); + const region = map.create({ width: 4, height: 4 }); + const events: UVMapEvent[] = []; + map.on("region-dragging", (e) => events.push(e)); + + map.previewMove(region.id, { x: 100, y: 100, width: 4, height: 4 }); + + assert.deepStrictEqual(events, [ + { type: "region-dragging", id: region.id, rect: { x: 12, y: 12, width: 4, height: 4 } } + ]); + assert.deepStrictEqual(map.get(region.id)!.rect, region.rect, "stored rect must be unchanged"); + }); + + it("does not record history or affect move()'s previousRect bookkeeping", () => { + const map = makeMap(); + const region = map.create({ width: 4, height: 4 }); + const moveEvents: Extract[] = []; + map.on("region-moved", (e) => moveEvents.push(e)); + + map.previewMove(region.id, { x: 5, y: 5, width: 4, height: 4 }); + map.previewMove(region.id, { x: 6, y: 6, width: 4, height: 4 }); + map.move(region.id, { x: 6, y: 6, width: 4, height: 4 }); + + assert.strictEqual(moveEvents.length, 1); + assert.deepStrictEqual(moveEvents[0].previousRect, region.rect); + }); + + it("is a no-op for an unknown id", () => { + const map = makeMap(); + const events: UVMapEvent[] = []; + map.on("region-dragging", (e) => events.push(e)); + + map.previewMove("no-such", { x: 0, y: 0, width: 1, height: 1 }); + + assert.strictEqual(events.length, 0); + }); +}); + +describe("UVMap — select / isVisible / showAll", () => { + it("nothing is visible by default", () => { + const map = makeMap(); + const region = map.create({ width: 4, height: 4 }); + + assert.strictEqual(map.isVisible(region.id), false); + }); + + it("a selected region becomes visible", () => { + const map = makeMap(); + const region = map.create({ width: 4, height: 4 }); + + map.select(region.id); + assert.strictEqual(map.isVisible(region.id), true); + }); + + it("showAll makes every region visible regardless of selection", () => { + const map = makeMap(); + const a = map.create({ width: 4, height: 4 }); + const b = map.create({ width: 4, height: 4 }); + + map.showAll = true; + assert.strictEqual(map.isVisible(a.id), true); + assert.strictEqual(map.isVisible(b.id), true); + }); + + it("ignores selecting an unknown id", () => { + const map = makeMap(); + const region = map.create({ width: 4, height: 4 }); + map.select(region.id); + + map.select("no-such"); + + assert.strictEqual(map.selectedRegionId, region.id); + }); + + it("select(null) deselects", () => { + const map = makeMap(); + const region = map.create({ width: 4, height: 4 }); + map.select(region.id); + + map.select(null); + + assert.strictEqual(map.selectedRegionId, null); + }); + + it("emits selection-changed only when the selection actually changes", () => { + const map = makeMap(); + const region = map.create({ width: 4, height: 4 }); + const events: UVMapEvent[] = []; + map.on("selection-changed", (e) => events.push(e)); + + map.select(region.id); + map.select(region.id); + map.select(null); + + assert.strictEqual(events.length, 2); + }); + + it("emits visibility-changed only when showAll actually changes", () => { + const map = makeMap(); + const events: UVMapEvent[] = []; + map.on("visibility-changed", (e) => events.push(e)); + + map.showAll = true; + map.showAll = true; + map.showAll = false; + + assert.strictEqual(events.length, 2); + }); +}); + +describe("UVMap — clear", () => { + it("removes every region and resets cascading placement", () => { + const map = makeMap(); + map.create({ width: 4, height: 4 }); + map.create({ width: 4, height: 4 }); + + map.clear(); + assert.strictEqual([...map.regions].length, 0); + + const region = map.create({ width: 4, height: 4 }); + assert.deepStrictEqual(region.rect, { x: 0, y: 0, width: 4, height: 4 }); + }); +}); + +describe("UVMap — on/off", () => { + it("off() stops a listener from receiving further events", () => { + const map = makeMap(); + const events: UVMapEvent[] = []; + function listener( + e: Extract + ): void { + events.push(e); + } + + map.on("region-created", listener); + map.create({ width: 4, height: 4 }); + map.off("region-created", listener); + map.create({ width: 4, height: 4 }); + + assert.strictEqual(events.length, 1); + }); +}); diff --git a/packages/pixel-draw-renderer/test/uv/UVRegionCollection.spec.ts b/packages/pixel-draw-renderer/test/uv/UVRegionCollection.spec.ts new file mode 100644 index 00000000..fbb3e485 --- /dev/null +++ b/packages/pixel-draw-renderer/test/uv/UVRegionCollection.spec.ts @@ -0,0 +1,60 @@ +// Import Node.js Dependencies +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { UVRegionCollection } from "../../src/uv/UVRegionCollection.ts"; +import type { UVRegion } from "../../src/uv/UVRegion.ts"; + +function makeRegion( + id: string +): UVRegion { + return { id, rect: { x: 0, y: 0, width: 2, height: 2 }, color: "#f00" }; +} + +describe("UVRegionCollection", () => { + it("get returns undefined for an unknown id", () => { + const collection = new UVRegionCollection(); + + assert.strictEqual(collection.get("r1"), undefined); + }); + + it("set stores a copy, keyed by region.id", () => { + const collection = new UVRegionCollection(); + const region = makeRegion("r1"); + collection.set(region); + + assert.deepStrictEqual(collection.get("r1"), region); + assert.notStrictEqual(collection.get("r1"), region); + }); + + it("set upserts an existing id", () => { + const collection = new UVRegionCollection(); + collection.set(makeRegion("r1")); + collection.set({ ...makeRegion("r1"), color: "#00f" }); + + assert.strictEqual(collection.get("r1")!.color, "#00f"); + }); + + it("remove deletes an existing region", () => { + const collection = new UVRegionCollection(); + collection.set(makeRegion("r1")); + collection.remove("r1"); + + assert.strictEqual(collection.get("r1"), undefined); + }); + + it("remove is a no-op for an unknown id", () => { + const collection = new UVRegionCollection(); + + assert.doesNotThrow(() => collection.remove("no-such")); + }); + + it("is iterable, yielding every stored region", () => { + const collection = new UVRegionCollection(); + collection.set(makeRegion("r1")); + collection.set(makeRegion("r2")); + + assert.deepStrictEqual([...collection], [makeRegion("r1"), makeRegion("r2")]); + }); +});