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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smart-select-erase.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 7 additions & 4 deletions packages/pixel-draw-renderer/docs/PixelArtCanvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
14 changes: 9 additions & 5 deletions packages/pixel-draw-renderer/src/PixelArtCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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 }
Expand Down
64 changes: 64 additions & 0 deletions packages/pixel-draw-renderer/src/tools/Select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { color: RGBA; count: number; }>();

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
Expand Down
38 changes: 32 additions & 6 deletions packages/pixel-draw-renderer/src/tools/SelectController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -43,7 +50,7 @@ export class SelectController {
#canvasBuffer: CanvasBuffer;
#renderer: CanvasRenderer;
#selectionOverlay: SelectionOverlay;
#eraseColor: RGBA;
#eraseColor: RGBA | null;
#onCommit: (entry: SelectEditEntry) => void;
#shapeMode = false;

Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -279,7 +304,7 @@ export class SelectController {
newContent: eraseColors,
skipErase: true
});
this.#select.markErased(this.#eraseColor);
this.#select.markErased(eraseColor);

return true;
}
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/pixel-draw-renderer/src/tools/ToolControllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading