From 4acf590116aef8efe28cf1c321f846eb4e907599 Mon Sep 17 00:00:00 2001 From: GENTILHOMME Thomas Date: Sun, 19 Jul 2026 17:50:34 +0200 Subject: [PATCH] fix(pixel-draw-renderer): Selection and eraseColor bug --- .changeset/smart-select-erase.md | 5 ++ .../docs/PixelArtCanvas.md | 11 +-- .../pixel-draw-renderer/src/PixelArtCanvas.ts | 14 ++-- .../pixel-draw-renderer/src/tools/Select.ts | 64 +++++++++++++++++ .../src/tools/SelectController.ts | 38 ++++++++-- .../src/tools/ToolControllers.ts | 2 +- .../test/PixelArtCanvas.select-shape.spec.ts | 8 +-- .../test/PixelArtCanvas.select.spec.ts | 69 +++++++++++++------ .../test/tools/Select.spec.ts | 52 ++++++++++++++ 9 files changed, 222 insertions(+), 41 deletions(-) create mode 100644 .changeset/smart-select-erase.md diff --git a/.changeset/smart-select-erase.md b/.changeset/smart-select-erase.md new file mode 100644 index 0000000..6d03658 --- /dev/null +++ b/.changeset/smart-select-erase.md @@ -0,0 +1,5 @@ +--- +"@jolly-pixel/pixel-draw.renderer": minor +--- + +Fix a select-mode regression where moving, deleting, or rotating/flipping a selection vacated its footprint with a flat erase color (fully transparent by default), leaving a jarring hole the size of the whole selection rectangle instead of just the drawn content. The vacated footprint is now filled with the most common color among its surrounding pixels, blending into the artwork; `select.eraseColor` still works as an explicit override, and falls back to fully transparent only when no in-bounds neighbors exist. diff --git a/packages/pixel-draw-renderer/docs/PixelArtCanvas.md b/packages/pixel-draw-renderer/docs/PixelArtCanvas.md index 7d3e043..05528fc 100644 --- a/packages/pixel-draw-renderer/docs/PixelArtCanvas.md +++ b/packages/pixel-draw-renderer/docs/PixelArtCanvas.md @@ -62,11 +62,14 @@ interface PixelArtCanvasOptions { brush?: BrushOptions; select?: { /** - * Color used to fill the pixels vacated by a Delete, the source side of + * Explicit color for the pixels vacated by a Delete, the source side of * a Move, or the footprint a Rotate/Flip no longer occupies, in - * "select" mode. Accepts a CSS color string or a colorjs.io `Color` - * instance. - * @default fully transparent + * "select" mode — overrides the smart default below. When omitted, the + * vacated area is instead filled with the most common color among its + * neighbors, so it blends into the surrounding artwork, falling back to + * fully transparent when there are no in-bounds neighbors. Accepts a + * CSS color string or a colorjs.io `Color` instance. + * @default dominant neighbor color, transparent as the ultimate fallback */ eraseColor?: ColorInput; }; diff --git a/packages/pixel-draw-renderer/src/PixelArtCanvas.ts b/packages/pixel-draw-renderer/src/PixelArtCanvas.ts index 05a491a..f6b589f 100644 --- a/packages/pixel-draw-renderer/src/PixelArtCanvas.ts +++ b/packages/pixel-draw-renderer/src/PixelArtCanvas.ts @@ -94,8 +94,12 @@ export interface PixelArtCanvasOptions { brush?: BrushOptions; select?: { /** - * Fill color for deleted pixels and moved selections. - * @default transparent + * Explicit fill for deleted pixels and vacated selection footprints + * (Move/Rotate/Flip), overriding the smart default. When omitted, the + * vacated area is instead filled with the most common color among its + * neighbors — so it blends into the surrounding artwork — falling + * back to fully transparent when there are no in-bounds neighbors. + * @default dominant neighbor color, transparent as the ultimate fallback */ eraseColor?: ColorInput; }; @@ -148,9 +152,9 @@ export class PixelArtCanvas { this.#parentHtmlElement = parentHtmlElement; this.#onDrawEnd = options.onDrawEnd; this.#mode = options.defaultMode ?? "paint"; - const eraseColor = toRGBA( - options.select?.eraseColor ?? { r: 0, g: 0, b: 0, a: 0 } - ); + const eraseColor = options.select?.eraseColor === undefined ? + null : + toRGBA(options.select.eraseColor); const textureSize: Vec2 = options.texture?.size ? { x: options.texture.size.x, y: options.texture.size.y ?? options.texture.size.x } diff --git a/packages/pixel-draw-renderer/src/tools/Select.ts b/packages/pixel-draw-renderer/src/tools/Select.ts index ea0222c..4104c0d 100644 --- a/packages/pixel-draw-renderer/src/tools/Select.ts +++ b/packages/pixel-draw-renderer/src/tools/Select.ts @@ -394,6 +394,70 @@ export class Select { }; } + /** + * Samples the ring of pixels immediately surrounding `rect` and returns + * the most common color among them, so a vacated footprint blends into + * its surroundings instead of leaving a flat erase-color hole. Falls + * back to `fallback` when `rect` has no in-bounds neighbors (e.g. it + * covers the whole texture). + */ + static dominantBorderColor( + buffer: DefaultPixelBuffer, + rect: SelectionRect, + fallback: RGBA + ): RGBA { + const size = buffer.size(); + const counts = new Map(); + + function sample( + x: number, + y: number + ): void { + if ( + x < 0 || x >= size.x || + y < 0 || y >= size.y + ) { + return; + } + + const [r, g, b, a] = buffer.samplePixel(x, y); + const key = `${r},${g},${b},${a}`; + const entry = counts.get(key); + if (entry) { + entry.count++; + } + else { + counts.set( + key, + { + color: { r, g, b, a }, + count: 1 + } + ); + } + } + + for (let x = rect.x - 1; x <= rect.x + rect.width; x++) { + sample(x, rect.y - 1); + sample(x, rect.y + rect.height); + } + for (let y = rect.y; y < rect.y + rect.height; y++) { + sample(rect.x - 1, y); + sample(rect.x + rect.width, y); + } + + let best: RGBA | null = null; + let bestCount = 0; + for (const entry of counts.values()) { + if (entry.count > bestCount) { + bestCount = entry.count; + best = entry.color; + } + } + + return best ?? fallback; + } + static captureSnapshot( buffer: DefaultPixelBuffer, rect: SelectionRect diff --git a/packages/pixel-draw-renderer/src/tools/SelectController.ts b/packages/pixel-draw-renderer/src/tools/SelectController.ts index 57b1ae1..d64812d 100644 --- a/packages/pixel-draw-renderer/src/tools/SelectController.ts +++ b/packages/pixel-draw-renderer/src/tools/SelectController.ts @@ -24,11 +24,18 @@ export interface SelectEditEntry { newMask: boolean[]; } +const kTransparent: RGBA = { r: 0, g: 0, b: 0, a: 0 }; + export interface SelectControllerOptions { canvasBuffer: CanvasBuffer; renderer: CanvasRenderer; selectionOverlay: SelectionOverlay; - eraseColor: RGBA; + /** + * Explicit fill for a vacated footprint (Move/Rotate/Flip source, or + * Delete), overriding the smart default below. `null` when not + * configured by the consumer. + */ + eraseColor: RGBA | null; /** * Commits a selection edit. */ @@ -43,7 +50,7 @@ export class SelectController { #canvasBuffer: CanvasBuffer; #renderer: CanvasRenderer; #selectionOverlay: SelectionOverlay; - #eraseColor: RGBA; + #eraseColor: RGBA | null; #onCommit: (entry: SelectEditEntry) => void; #shapeMode = false; @@ -57,6 +64,22 @@ export class SelectController { this.#onCommit = options.onCommit; } + /** + * Resolves the fill for a vacated footprint: the explicit `eraseColor` + * when configured, otherwise the most common color among the pixels + * surrounding `rect` (so it blends into the artwork), falling back to + * fully transparent when `rect` has no in-bounds neighbors. + */ + #resolveEraseColor( + rect: SelectionRect + ): RGBA { + if (this.#eraseColor !== null) { + return this.#eraseColor; + } + + return Select.dominantBorderColor(this.#canvasBuffer, rect, kTransparent); + } + get rect(): SelectionRect | null { return this.#select.rect; } @@ -114,11 +137,12 @@ export class SelectController { const snapshot = this.#select.snapshot; const mask = this.#select.mask; if (rect && snapshot && mask) { + const eraseColor = this.#resolveEraseColor(rect); this.#renderer.floatingSelection.create({ sourceRect: rect, pixels: snapshot, mask, - eraseColor: this.#eraseColor, + eraseColor, blankSource: !this.#select.willSkipErase }); this.#renderer.drawFrame(); @@ -268,9 +292,10 @@ export class SelectController { return false; } + const eraseColor = this.#resolveEraseColor(rect); const eraseColors: RGBA[] = new Array( rect.width * rect.height - ).fill(this.#eraseColor); + ).fill(eraseColor); this.#commitFootprintChange({ oldRect: rect, oldMask: mask, @@ -279,7 +304,7 @@ export class SelectController { newContent: eraseColors, skipErase: true }); - this.#select.markErased(this.#eraseColor); + this.#select.markErased(eraseColor); return true; } @@ -393,9 +418,10 @@ export class SelectController { const beforeColors = this.#canvasBuffer.samplePixels(positions); if (!skipErase) { + const eraseColor = this.#resolveEraseColor(oldRect); this.#canvasBuffer.drawPixels( maskedPositions({ rect: oldRect, mask: oldMask }), - this.#eraseColor + eraseColor ); } this.#canvasBuffer.drawMaskedRegion( diff --git a/packages/pixel-draw-renderer/src/tools/ToolControllers.ts b/packages/pixel-draw-renderer/src/tools/ToolControllers.ts index 84955dc..b11e122 100644 --- a/packages/pixel-draw-renderer/src/tools/ToolControllers.ts +++ b/packages/pixel-draw-renderer/src/tools/ToolControllers.ts @@ -26,7 +26,7 @@ export interface ToolControllersOptions { renderer: CanvasRenderer; linePreview: LinePreviewOverlay; selectionOverlay: SelectionOverlay; - eraseColor: RGBA; + eraseColor: RGBA | null; onStrokeCommit: (pixels: Vec2[], color: RGBA, beforeColors: RGBA[]) => void; onCommitPixels: (pixels: Vec2[]) => void; onFillCommitPixels: (pixels: Vec2[], slot: BrushColorSlot) => void; diff --git a/packages/pixel-draw-renderer/test/PixelArtCanvas.select-shape.spec.ts b/packages/pixel-draw-renderer/test/PixelArtCanvas.select-shape.spec.ts index 4aea4a4..e96e596 100644 --- a/packages/pixel-draw-renderer/test/PixelArtCanvas.select-shape.spec.ts +++ b/packages/pixel-draw-renderer/test/PixelArtCanvas.select-shape.spec.ts @@ -113,8 +113,8 @@ describe("PixelArtCanvas — select mode (shape sub-mode)", () => { click(canvas, 92, 92); window.dispatchEvent(deleteKey()); - assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 0]); - assert.deepStrictEqual(readPixel(manager.texture, { x: 3, y: 2 }, 8), [0, 0, 0, 0]); + assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [255, 255, 255, 255]); + assert.deepStrictEqual(readPixel(manager.texture, { x: 3, y: 2 }, 8), [255, 255, 255, 255]); manager.destroy(); }); @@ -158,8 +158,8 @@ describe("PixelArtCanvas — select mode (shape sub-mode)", () => { for (const pos of [...border, { x: 3, y: 3 }]) { assert.deepStrictEqual( readPixel(manager.texture, pos, 8), - [0, 0, 0, 0], - `(${pos.x},${pos.y}) should be erased` + [255, 255, 255, 255], + `(${pos.x},${pos.y}) should be erased with the dominant (white) surrounding color` ); } manager.destroy(); diff --git a/packages/pixel-draw-renderer/test/PixelArtCanvas.select.spec.ts b/packages/pixel-draw-renderer/test/PixelArtCanvas.select.spec.ts index ed1486e..e57fc00 100644 --- a/packages/pixel-draw-renderer/test/PixelArtCanvas.select.spec.ts +++ b/packages/pixel-draw-renderer/test/PixelArtCanvas.select.spec.ts @@ -89,7 +89,7 @@ describe("PixelArtCanvas — select mode", () => { }); } - test("dragging out a rectangle then Delete replaces it with the erase color (default fully transparent)", () => { + test("dragging out a rectangle then Delete replaces it with the dominant surrounding color (white background)", () => { const manager = makeManager(); const canvas = manager.canvas(); @@ -107,8 +107,25 @@ describe("PixelArtCanvas — select mode", () => { window.dispatchEvent(deleteKey()); - assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 0]); - assert.deepStrictEqual(readPixel(manager.texture, { x: 3, y: 3 }, 8), [0, 0, 0, 0]); + assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [255, 255, 255, 255]); + assert.deepStrictEqual(readPixel(manager.texture, { x: 3, y: 3 }, 8), [255, 255, 255, 255]); + manager.destroy(); + }); + + test("Delete falls back to select.eraseColor when the vacated rect has no in-bounds neighbors", () => { + const manager = makeManager({ select: { eraseColor: "#FF00FF" } }); + const canvas = manager.canvas(); + + manager.commitPixels([{ x: 0, y: 0 }]); + manager.mode = "select"; + // Select the whole 8x8 texture: no ring of neighbors exists outside it. + canvas.dispatchEvent(mouseEvent("mousedown", 84, 84)); + canvas.dispatchEvent(mouseEvent("mousemove", 112, 112)); + canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + + window.dispatchEvent(deleteKey()); + + assert.deepStrictEqual(readPixel(manager.texture, { x: 0, y: 0 }, 8), [255, 0, 255, 255]); manager.destroy(); }); @@ -147,8 +164,8 @@ describe("PixelArtCanvas — select mode", () => { const midDragPixels = (canvas as unknown as MockCanvasElement)._pixels; assert.deepStrictEqual( readPixel(midDragPixels, { x: 2, y: 2 }, canvas.width), - [0, 0, 0, 0], - "source previewed as vacated (erase color) while a real move is in progress" + [255, 255, 255, 255], + "source previewed as vacated (dominant surrounding color) while a real move is in progress" ); canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); @@ -208,7 +225,11 @@ describe("PixelArtCanvas — select mode", () => { canvas.dispatchEvent(mouseEvent("mousemove", 100, 100)); canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); - assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 0], "source vacated"); + assert.deepStrictEqual( + readPixel(manager.texture, { x: 2, y: 2 }, 8), + [255, 255, 255, 255], + "source vacated with the dominant (white) surrounding color" + ); assert.deepStrictEqual( readPixel(manager.texture, { x: 4, y: 4 }, 8), [0, 0, 0, 255], @@ -314,8 +335,8 @@ describe("PixelArtCanvas — select mode", () => { assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 255], "original still untouched"); assert.deepStrictEqual( readPixel(manager.texture, { x: 4, y: 4 }, 8), - [0, 0, 0, 0], - "second move erases the duplicate's now-real previous spot" + [255, 255, 255, 255], + "second move erases the duplicate's now-real previous spot, with the dominant (white) surrounding color" ); assert.deepStrictEqual( readPixel(manager.texture, { x: 6, y: 6 }, 8), @@ -353,7 +374,11 @@ describe("PixelArtCanvas — select mode", () => { window.dispatchEvent(deleteKey()); assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 255], "old selection untouched"); - assert.deepStrictEqual(readPixel(manager.texture, { x: 6, y: 6 }, 8), [0, 0, 0, 0], "new selection erased"); + assert.deepStrictEqual( + readPixel(manager.texture, { x: 6, y: 6 }, 8), + [255, 255, 255, 255], + "new selection erased with the dominant (white) surrounding color" + ); manager.destroy(); }); @@ -396,8 +421,8 @@ describe("PixelArtCanvas — select mode", () => { assert.deepStrictEqual( readPixel(manager.texture, { x: 1, y: 1 }, 8), - [0, 0, 0, 0], - "source erased even though destination landed out of bounds" + [255, 255, 255, 255], + "source erased with the dominant (white) surrounding color even though destination landed out of bounds" ); manager.destroy(); }); @@ -466,8 +491,8 @@ describe("PixelArtCanvas — select mode", () => { assert.deepStrictEqual( readPixel(manager.texture, { x: 2, y: 2 }, 8), - [0, 0, 0, 0], - "old footprint vacated" + [255, 255, 255, 255], + "old footprint vacated with the dominant (white) surrounding color" ); assert.deepStrictEqual( readPixel(manager.texture, { x: 3, y: 2 }, 8), @@ -609,7 +634,7 @@ describe("PixelArtCanvas — select mode", () => { canvas.dispatchEvent(mouseEvent("mousedown", 92, 92)); canvas.dispatchEvent(mouseEvent("mousemove", 100, 100)); canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); - assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 0]); + assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [255, 255, 255, 255]); assert.deepStrictEqual(readPixel(manager.texture, { x: 4, y: 4 }, 8), [0, 0, 0, 255]); window.dispatchEvent(ctrlKey("z")); @@ -621,7 +646,7 @@ describe("PixelArtCanvas — select mode", () => { ); window.dispatchEvent(ctrlKey("y")); - assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 0]); + assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [255, 255, 255, 255]); assert.deepStrictEqual(readPixel(manager.texture, { x: 4, y: 4 }, 8), [0, 0, 0, 255]); manager.destroy(); }); @@ -637,7 +662,7 @@ describe("PixelArtCanvas — select mode", () => { canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); window.dispatchEvent(deleteKey()); - assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 0]); + assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [255, 255, 255, 255]); window.dispatchEvent(ctrlKey("z")); assert.deepStrictEqual( @@ -646,7 +671,7 @@ describe("PixelArtCanvas — select mode", () => { window.dispatchEvent(ctrlKey("y")); assert.deepStrictEqual( - readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 0], "redo re-applies the delete" + readPixel(manager.texture, { x: 2, y: 2 }, 8), [255, 255, 255, 255], "redo re-applies the delete" ); manager.destroy(); }); @@ -668,7 +693,7 @@ describe("PixelArtCanvas — select mode", () => { canvas.dispatchEvent(mouseEvent("mousedown", 92, 92)); canvas.dispatchEvent(mouseEvent("mousemove", 100, 100)); canvas.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); - assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 0]); + assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [255, 255, 255, 255]); window.dispatchEvent(ctrlKey("v")); assert.deepStrictEqual( @@ -677,7 +702,7 @@ describe("PixelArtCanvas — select mode", () => { window.dispatchEvent(ctrlKey("z")); assert.deepStrictEqual( - readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 0], "undo removes the pasted content" + readPixel(manager.texture, { x: 2, y: 2 }, 8), [255, 255, 255, 255], "undo removes the pasted content" ); window.dispatchEvent(ctrlKey("y")); @@ -708,7 +733,7 @@ describe("PixelArtCanvas — select mode", () => { assert.deepStrictEqual(readPixel(manager.texture, { x: 3, y: 3 }, 8), [255, 255, 255, 255]); window.dispatchEvent(ctrlKey("y")); - assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 0]); + assert.deepStrictEqual(readPixel(manager.texture, { x: 2, y: 2 }, 8), [255, 255, 255, 255]); assert.deepStrictEqual(readPixel(manager.texture, { x: 3, y: 2 }, 8), [0, 0, 0, 255]); assert.deepStrictEqual(readPixel(manager.texture, { x: 3, y: 3 }, 8), [255, 0, 0, 255]); manager.destroy(); @@ -735,7 +760,9 @@ describe("PixelArtCanvas — select mode", () => { // was never part of the selection. window.dispatchEvent(rotateKey()); assert.deepStrictEqual( - readPixel(manager.texture, { x: 2, y: 2 }, 8), [0, 0, 0, 0], "the real pre-rotate footprint got erased" + readPixel(manager.texture, { x: 2, y: 2 }, 8), + [255, 255, 255, 255], + "the real pre-rotate footprint got erased with the dominant (white) surrounding color" ); assert.deepStrictEqual(readPixel(manager.texture, { x: 3, y: 2 }, 8), [0, 0, 0, 255]); assert.deepStrictEqual(readPixel(manager.texture, { x: 3, y: 3 }, 8), [255, 0, 0, 255]); diff --git a/packages/pixel-draw-renderer/test/tools/Select.spec.ts b/packages/pixel-draw-renderer/test/tools/Select.spec.ts index dc98152..e6bd9e3 100644 --- a/packages/pixel-draw-renderer/test/tools/Select.spec.ts +++ b/packages/pixel-draw-renderer/test/tools/Select.spec.ts @@ -70,6 +70,58 @@ describe("Select", () => { }); }); + describe("dominantBorderColor (static)", () => { + const kFallback: RGBA = { r: 1, g: 2, b: 3, a: 4 }; + const kWhite: RGBA = { r: 255, g: 255, b: 255, a: 255 }; + + test("returns the canvas's own default fill when the rect's border is untouched", () => { + const buf = new PixelBuffer({ size: { x: 8, y: 8 }, maxSize: kTestMaxSize }); + + assert.deepStrictEqual( + Select.dominantBorderColor(buf, { x: 2, y: 2, width: 2, height: 2 }, kFallback), + kWhite + ); + }); + + test("picks the most frequent color among the surrounding ring, ignoring the rect's own interior", () => { + const buf = new PixelBuffer({ size: { x: 8, y: 8 }, maxSize: kTestMaxSize }); + // Paint the rect's interior red — must be ignored, it's not a neighbor. + buf.drawPixels([{ x: 2, y: 2 }, { x: 3, y: 2 }, { x: 2, y: 3 }, { x: 3, y: 3 }], kRed); + // The ring around a (2,2,2,2) rect has 12 cells; paint 7 of them blue, + // outnumbering the 5 still at the canvas's default white. + const blue: RGBA = { r: 0, g: 0, b: 255, a: 255 }; + buf.drawPixels([ + { x: 1, y: 1 }, { x: 2, y: 1 }, { x: 3, y: 1 }, { x: 4, y: 1 }, + { x: 1, y: 4 }, { x: 2, y: 4 }, { x: 3, y: 4 } + ], blue); + + assert.deepStrictEqual( + Select.dominantBorderColor(buf, { x: 2, y: 2, width: 2, height: 2 }, kFallback), + blue + ); + }); + + test("falls back when the rect has no in-bounds neighbors (it spans the whole texture)", () => { + const buf = new PixelBuffer({ size: { x: 4, y: 4 }, maxSize: kTestMaxSize }); + + assert.deepStrictEqual( + Select.dominantBorderColor(buf, { x: 0, y: 0, width: 4, height: 4 }, kFallback), + kFallback + ); + }); + + test("samples clipped neighbors correctly when the rect touches the texture edge", () => { + const buf = new PixelBuffer({ size: { x: 4, y: 4 }, maxSize: kTestMaxSize }); + + // Rect at the top-left corner: only its right and bottom borders have + // in-bounds neighbors, all still the canvas default (white). + assert.deepStrictEqual( + Select.dominantBorderColor(buf, { x: 0, y: 0, width: 2, height: 2 }, kFallback), + kWhite + ); + }); + }); + describe("rotate/flip transforms (static)", () => { test("rotateRectCW swaps width/height and pivots on the rect's center (rounded)", () => { assert.deepStrictEqual(