Round selections in pixel art mode for consistency and more clear draw operations

This commit is contained in:
Igor Zinken
2026-04-03 21:23:07 +02:00
parent aef7e7ad0b
commit c42cd0e662
5 changed files with 52 additions and 5 deletions

View File

@@ -24,6 +24,7 @@ import { sprite } from "zcanvas";
import type { Point, Size, Viewport } from "zcanvas";
import type { Document, Layer, Shape, Selection } from "@/definitions/document";
import ToolTypes, { SELECTION_TOOLS } from "@/definitions/tool-types";
import { isPixelArt } from "@/definitions/editor-properties";
import { getRendererForLayer } from "@/factories/renderer-factory";
import { isPointInRange, translatePoints, snapToAngle, rectToCoordinateList } from "@/math/point-math";
import { rotateRectangleToCoordinates, scaleRectangle } from "@/math/rectangle-math";
@@ -38,7 +39,7 @@ import { invertSelection } from "@/store/actions/selection-invert";
import { applySelection } from "@/store/actions/selection-apply";
import { getPixelRatio, isInsideTransparentArea } from "@/utils/canvas-util";
import { createDocumentSnapshot, createLayerSnapshot } from "@/utils/document-util";
import { getLastShape, syncSelection } from "@/utils/selection-util";
import { getLastShape, roundSelection, syncSelection } from "@/utils/selection-util";
import { isShapeClosed, isOverlappingShape, mergeShapes, rectangleToShape, subtractShapes } from "@/utils/shape-util";
export enum InteractionModes {
@@ -237,6 +238,9 @@ export default class InteractionPane extends sprite {
} else {
selectionToSet = activeSelection; // no overlap handling necessary, we can commit the whole active selection to history
}
if ( isPixelArt( this.getActiveDocument() )) {
selectionToSet = roundSelection( selectionToSet );
}
this._selectionClosed = true;
this.canvas.store.commit( "setActiveSelection", [ ...selectionToSet ]);
applySelection( getCanvasInstance().store, this.getActiveDocument(), currentSelection );

View File

@@ -1,7 +1,7 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2021-2025 - https://www.igorski.nl
* Igor Zinken 2021-2026 - https://www.igorski.nl
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
@@ -25,7 +25,7 @@ import { type Rectangle } from "zcanvas";
import { type Shape, Selection } from "@/definitions/document";
import { getRendererForLayer } from "@/factories/renderer-factory";
import { type BitMapperyState } from "@/store";
import { shapeToRectangle, scaleShape } from "@/utils/shape-util";
import { shapeToRectangle, roundShape, scaleShape } from "@/utils/shape-util";
export const selectionToRectangle = ( selection: Selection ): Rectangle => {
if ( selection.length === 1 ) {
@@ -49,6 +49,10 @@ export const selectionToRectangle = ( selection: Selection ): Rectangle => {
};
};
export const roundSelection = ( selection: Selection ): Selection => {
return selection.map(( shape: Shape ) => roundShape( shape ));
};
export const scaleSelection = ( selection: Selection, scale: number ): Selection => {
return selection.map(( shape: Shape ) => scaleShape( shape, scale ));
};

View File

@@ -28,6 +28,7 @@ import {
union
} from "martinez-polygon-clipping";
import type { Point, Rectangle } from "zcanvas";
import { fastRound } from "@/math/unit-math";
import type { Shape } from "@/definitions/document";
export const shapeToRectangle = ( shape: Shape ): Rectangle => {
@@ -62,6 +63,10 @@ export const scaleShape = ( shape: Shape, scale: number ): Shape => {
return shape.map(( point: Point ) => ({ x: point.x * scale, y: point.y * scale }));
};
export const roundShape = ( shape: Shape ): Shape => {
return shape.map(({ x, y }: Point ) => ({ x: fastRound( x ), y: fastRound( y ) }));
};
export const isShapeRectangular = ( shape: Shape ): boolean => {
if ( shape.length !== 5 ) {
return false;

View File

@@ -6,7 +6,7 @@ mockZCanvas();
import DocumentFactory from "@/factories/document-factory";
import LayerFactory from "@/factories/layer-factory";
import { createRendererForLayer, flushRendererCache } from "@/factories/renderer-factory";
import { getLastShape, scaleSelection, selectionToRectangle, syncSelection } from "@/utils/selection-util";
import { getLastShape, roundSelection, scaleSelection, selectionToRectangle, syncSelection } from "@/utils/selection-util";
describe( "Selection utilities", () => {
const selection = [
@@ -47,6 +47,24 @@ describe( "Selection utilities", () => {
});
});
it( "should be able to round the Shape coordinates inside a selection", () => {
expect( roundSelection([
[
{ x: 4.25, y: 5.56 }, { x: 5.55, y: 5.56 }, { x: 5.55, y: 7.2 }, { x: 4.25, y: 5.56 },
],
[
{ x: 2.25, y: 2.56 }, { x: 4.55, y: 5.56 }, { x: 4.55, y: 7.2 }, { x: 2.25, y: 5.56 },
]
])).toEqual([
[
{ x: 4, y: 6 }, { x: 6, y: 6 }, { x: 6, y: 7 }, { x: 4, y: 6 },
],
[
{ x: 2, y: 3 }, { x: 5, y: 6 }, { x: 5, y: 7 }, { x: 2, y: 6 },
]
]);
});
it( "should be able to scale the Shapes inside a selection", () => {
expect( scaleSelection( selection, 1.5 )).toEqual([
[

View File

@@ -1,7 +1,7 @@
import { it, describe, expect } from "vitest";
import {
mergeShapes,
rectangleToShape, scaleShape, shapeToRectangle, subtractShapes,
rectangleToShape, roundShape, scaleShape, shapeToRectangle, subtractShapes,
isOverlappingShape, isShapeRectangular, isShapeClosed
} from "@/utils/shape-util";
@@ -31,6 +31,22 @@ describe( "Shape utilities", () => {
]);
});
it( "should be able to round the values in a Shape to their nearest integer values", () => {
expect( roundShape([
{ x: 4.250559227410698, y: 5.228564005399 },
{ x: 6.846033445918115, y: 5.228564005399 },
{ x: 6.846033445918115, y: 7.598344813601424 },
{ x: 4.250559227410698, y: 7.598344813601424 },
{ x: 4.250559227410698, y: 5.228564005399 }
])).toEqual([
{ x: 4, y: 5 },
{ x: 7, y: 5 },
{ x: 7, y: 8 },
{ x: 4, y: 8 },
{ x: 4, y: 5 },
]);
});
it( "should be able to scale a Shape by given factor", () => {
const shape = [
{ x: 100, y: 150 },