fix(ui): align all tools to 1px grid

- Offset brush tool by 0.5px when width is odd, ensuring each stroke edge is exactly on a pixel boundary
- Round the rect tool also
This commit is contained in:
psychedelicious 2024-08-01 23:58:52 +10:00
parent 17f88cd5ad
commit 8e1a70b008
3 changed files with 90 additions and 37 deletions

View File

@ -5,6 +5,7 @@ import {
BRUSH_BORDER_OUTER_COLOR, BRUSH_BORDER_OUTER_COLOR,
BRUSH_ERASER_BORDER_WIDTH, BRUSH_ERASER_BORDER_WIDTH,
} from 'features/controlLayers/konva/constants'; } from 'features/controlLayers/konva/constants';
import { alignCoordForTool } from 'features/controlLayers/konva/util';
import Konva from 'konva'; import Konva from 'konva';
export class CanvasTool { export class CanvasTool {
@ -183,12 +184,14 @@ export class CanvasTool {
// No need to render the brush preview if the cursor position or color is missing // No need to render the brush preview if the cursor position or color is missing
if (cursorPos && tool === 'brush') { if (cursorPos && tool === 'brush') {
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.brush.width);
const scale = stage.scaleX(); const scale = stage.scaleX();
// Update the fill circle // Update the fill circle
const radius = toolState.brush.width / 2; const radius = toolState.brush.width / 2;
this.konva.brush.fillCircle.setAttrs({ this.konva.brush.fillCircle.setAttrs({
x: cursorPos.x, x: alignedCursorPos.x,
y: cursorPos.y, y: alignedCursorPos.y,
radius, radius,
fill: isDrawing ? '' : rgbaColorToString(currentFill), fill: isDrawing ? '' : rgbaColorToString(currentFill),
}); });
@ -209,12 +212,14 @@ export class CanvasTool {
this.konva.eraser.group.visible(false); this.konva.eraser.group.visible(false);
// this.rect.group.visible(false); // this.rect.group.visible(false);
} else if (cursorPos && tool === 'eraser') { } else if (cursorPos && tool === 'eraser') {
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.eraser.width);
const scale = stage.scaleX(); const scale = stage.scaleX();
// Update the fill circle // Update the fill circle
const radius = toolState.eraser.width / 2; const radius = toolState.eraser.width / 2;
this.konva.eraser.fillCircle.setAttrs({ this.konva.eraser.fillCircle.setAttrs({
x: cursorPos.x, x: alignedCursorPos.x,
y: cursorPos.y, y: alignedCursorPos.y,
radius, radius,
fill: 'white', fill: 'white',
}); });

View File

@ -1,5 +1,10 @@
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { getObjectId, getScaledFlooredCursorPosition } from 'features/controlLayers/konva/util'; import {
alignCoordForTool,
getObjectId,
getScaledCursorPosition,
offsetCoord,
} from 'features/controlLayers/konva/util';
import type { import type {
CanvasV2State, CanvasV2State,
Coordinate, Coordinate,
@ -22,7 +27,7 @@ import { BRUSH_SPACING_TARGET_SCALE, CANVAS_SCALE_BY, MAX_CANVAS_SCALE, MIN_CANV
* @param setLastCursorPos The callback to store the cursor pos * @param setLastCursorPos The callback to store the cursor pos
*/ */
const updateLastCursorPos = (stage: Konva.Stage, setLastCursorPos: CanvasManager['stateApi']['setLastCursorPos']) => { const updateLastCursorPos = (stage: Konva.Stage, setLastCursorPos: CanvasManager['stateApi']['setLastCursorPos']) => {
const pos = getScaledFlooredCursorPosition(stage); const pos = getScaledCursorPosition(stage);
if (!pos) { if (!pos) {
return null; return null;
} }
@ -177,14 +182,17 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
getIsPrimaryMouseDown(e) getIsPrimaryMouseDown(e)
) { ) {
setLastMouseDownPos(pos); setLastMouseDownPos(pos);
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
if (toolState.selected === 'brush') { if (toolState.selected === 'brush') {
const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity, toolState.selected); const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity, toolState.selected);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width);
if (e.evt.shiftKey && lastLinePoint) { if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point // Create a straight line from the last line point
if (selectedEntityAdapter.getDrawingBuffer()) { if (selectedEntityAdapter.getDrawingBuffer()) {
await selectedEntityAdapter.finalizeDrawingBuffer(); await selectedEntityAdapter.finalizeDrawingBuffer();
} }
await selectedEntityAdapter.setDrawingBuffer({ await selectedEntityAdapter.setDrawingBuffer({
id: getObjectId('brush_line', true), id: getObjectId('brush_line', true),
type: 'brush_line', type: 'brush_line',
@ -192,8 +200,8 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
// The last point of the last line is already normalized to the entity's coordinates // The last point of the last line is already normalized to the entity's coordinates
lastLinePoint.x, lastLinePoint.x,
lastLinePoint.y, lastLinePoint.y,
pos.x - selectedEntity.position.x, alignedPoint.x,
pos.y - selectedEntity.position.y, alignedPoint.y,
], ],
strokeWidth: toolState.brush.width, strokeWidth: toolState.brush.width,
color: getCurrentFill(), color: getCurrentFill(),
@ -206,17 +214,18 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
await selectedEntityAdapter.setDrawingBuffer({ await selectedEntityAdapter.setDrawingBuffer({
id: getObjectId('brush_line', true), id: getObjectId('brush_line', true),
type: 'brush_line', type: 'brush_line',
points: [pos.x - selectedEntity.position.x, pos.y - selectedEntity.position.y], points: [alignedPoint.x, alignedPoint.y],
strokeWidth: toolState.brush.width, strokeWidth: toolState.brush.width,
color: getCurrentFill(), color: getCurrentFill(),
clip: getClip(selectedEntity), clip: getClip(selectedEntity),
}); });
} }
setLastAddedPoint(pos); setLastAddedPoint(alignedPoint);
} }
if (toolState.selected === 'eraser') { if (toolState.selected === 'eraser') {
const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity, toolState.selected); const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity, toolState.selected);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width);
if (e.evt.shiftKey && lastLinePoint) { if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point // Create a straight line from the last line point
if (selectedEntityAdapter.getDrawingBuffer()) { if (selectedEntityAdapter.getDrawingBuffer()) {
@ -229,8 +238,8 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
// The last point of the last line is already normalized to the entity's coordinates // The last point of the last line is already normalized to the entity's coordinates
lastLinePoint.x, lastLinePoint.x,
lastLinePoint.y, lastLinePoint.y,
pos.x - selectedEntity.position.x, alignedPoint.x,
pos.y - selectedEntity.position.y, alignedPoint.y,
], ],
strokeWidth: toolState.eraser.width, strokeWidth: toolState.eraser.width,
clip: getClip(selectedEntity), clip: getClip(selectedEntity),
@ -242,12 +251,12 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
await selectedEntityAdapter.setDrawingBuffer({ await selectedEntityAdapter.setDrawingBuffer({
id: getObjectId('eraser_line', true), id: getObjectId('eraser_line', true),
type: 'eraser_line', type: 'eraser_line',
points: [pos.x - selectedEntity.position.x, pos.y - selectedEntity.position.y], points: [alignedPoint.x, alignedPoint.y],
strokeWidth: toolState.eraser.width, strokeWidth: toolState.eraser.width,
clip: getClip(selectedEntity), clip: getClip(selectedEntity),
}); });
} }
setLastAddedPoint(pos); setLastAddedPoint(alignedPoint);
} }
if (toolState.selected === 'rect') { if (toolState.selected === 'rect') {
@ -257,8 +266,8 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
await selectedEntityAdapter.setDrawingBuffer({ await selectedEntityAdapter.setDrawingBuffer({
id: getObjectId('rect_shape', true), id: getObjectId('rect_shape', true),
type: 'rect_shape', type: 'rect_shape',
x: pos.x - selectedEntity.position.x, x: Math.round(normalizedPoint.x),
y: pos.y - selectedEntity.position.y, y: Math.round(normalizedPoint.y),
width: 0, width: 0,
height: 0, height: 0,
color: getCurrentFill(), color: getCurrentFill(),
@ -340,12 +349,11 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
if (drawingBuffer?.type === 'brush_line') { if (drawingBuffer?.type === 'brush_line') {
const nextPoint = getNextPoint(pos, toolState, getLastPointOfLine(drawingBuffer.points)); const nextPoint = getNextPoint(pos, toolState, getLastPointOfLine(drawingBuffer.points));
if (nextPoint) { if (nextPoint) {
drawingBuffer.points.push( const normalizedPoint = offsetCoord(nextPoint, selectedEntity.position);
nextPoint.x - selectedEntity.position.x, const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width);
nextPoint.y - selectedEntity.position.y drawingBuffer.points.push(alignedPoint.x, alignedPoint.y);
);
await selectedEntityAdapter.setDrawingBuffer(drawingBuffer); await selectedEntityAdapter.setDrawingBuffer(drawingBuffer);
setLastAddedPoint(nextPoint); setLastAddedPoint(alignedPoint);
} }
} else { } else {
await selectedEntityAdapter.setDrawingBuffer(null); await selectedEntityAdapter.setDrawingBuffer(null);
@ -354,15 +362,17 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
if (selectedEntityAdapter.getDrawingBuffer()) { if (selectedEntityAdapter.getDrawingBuffer()) {
await selectedEntityAdapter.finalizeDrawingBuffer(); await selectedEntityAdapter.finalizeDrawingBuffer();
} }
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width);
await selectedEntityAdapter.setDrawingBuffer({ await selectedEntityAdapter.setDrawingBuffer({
id: getObjectId('brush_line', true), id: getObjectId('brush_line', true),
type: 'brush_line', type: 'brush_line',
points: [pos.x - selectedEntity.position.x, pos.y - selectedEntity.position.y], points: [alignedPoint.x, alignedPoint.y],
strokeWidth: toolState.brush.width, strokeWidth: toolState.brush.width,
color: getCurrentFill(), color: getCurrentFill(),
clip: getClip(selectedEntity), clip: getClip(selectedEntity),
}); });
setLastAddedPoint(pos); setLastAddedPoint(alignedPoint);
} }
} }
@ -372,12 +382,11 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
if (drawingBuffer.type === 'eraser_line') { if (drawingBuffer.type === 'eraser_line') {
const nextPoint = getNextPoint(pos, toolState, getLastPointOfLine(drawingBuffer.points)); const nextPoint = getNextPoint(pos, toolState, getLastPointOfLine(drawingBuffer.points));
if (nextPoint) { if (nextPoint) {
drawingBuffer.points.push( const normalizedPoint = offsetCoord(nextPoint, selectedEntity.position);
nextPoint.x - selectedEntity.position.x, const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width);
nextPoint.y - selectedEntity.position.y drawingBuffer.points.push(alignedPoint.x, alignedPoint.y);
);
await selectedEntityAdapter.setDrawingBuffer(drawingBuffer); await selectedEntityAdapter.setDrawingBuffer(drawingBuffer);
setLastAddedPoint(nextPoint); setLastAddedPoint(alignedPoint);
} }
} else { } else {
await selectedEntityAdapter.setDrawingBuffer(null); await selectedEntityAdapter.setDrawingBuffer(null);
@ -386,14 +395,16 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
if (selectedEntityAdapter.getDrawingBuffer()) { if (selectedEntityAdapter.getDrawingBuffer()) {
await selectedEntityAdapter.finalizeDrawingBuffer(); await selectedEntityAdapter.finalizeDrawingBuffer();
} }
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width);
await selectedEntityAdapter.setDrawingBuffer({ await selectedEntityAdapter.setDrawingBuffer({
id: getObjectId('eraser_line', true), id: getObjectId('eraser_line', true),
type: 'eraser_line', type: 'eraser_line',
points: [pos.x - selectedEntity.position.x, pos.y - selectedEntity.position.y], points: [alignedPoint.x, alignedPoint.y],
strokeWidth: toolState.eraser.width, strokeWidth: toolState.eraser.width,
clip: getClip(selectedEntity), clip: getClip(selectedEntity),
}); });
setLastAddedPoint(pos); setLastAddedPoint(alignedPoint);
} }
} }
@ -401,8 +412,9 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
const drawingBuffer = selectedEntityAdapter.getDrawingBuffer(); const drawingBuffer = selectedEntityAdapter.getDrawingBuffer();
if (drawingBuffer) { if (drawingBuffer) {
if (drawingBuffer.type === 'rect_shape') { if (drawingBuffer.type === 'rect_shape') {
drawingBuffer.width = pos.x - selectedEntity.position.x - drawingBuffer.x; const normalizedPoint = offsetCoord(pos, selectedEntity.position);
drawingBuffer.height = pos.y - selectedEntity.position.y - drawingBuffer.y; drawingBuffer.width = Math.round(normalizedPoint.x - drawingBuffer.x);
drawingBuffer.height = Math.round(normalizedPoint.y - drawingBuffer.y);
await selectedEntityAdapter.setDrawingBuffer(drawingBuffer); await selectedEntityAdapter.setDrawingBuffer(drawingBuffer);
} else { } else {
await selectedEntityAdapter.setDrawingBuffer(null); await selectedEntityAdapter.setDrawingBuffer(null);
@ -432,17 +444,20 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
getIsPrimaryMouseDown(e) getIsPrimaryMouseDown(e)
) { ) {
const drawingBuffer = selectedEntityAdapter.getDrawingBuffer(); const drawingBuffer = selectedEntityAdapter.getDrawingBuffer();
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
if (toolState.selected === 'brush' && drawingBuffer?.type === 'brush_line') { if (toolState.selected === 'brush' && drawingBuffer?.type === 'brush_line') {
drawingBuffer.points.push(pos.x - selectedEntity.position.x, pos.y - selectedEntity.position.y); const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width);
drawingBuffer.points.push(alignedPoint.x, alignedPoint.y);
await selectedEntityAdapter.setDrawingBuffer(drawingBuffer); await selectedEntityAdapter.setDrawingBuffer(drawingBuffer);
await selectedEntityAdapter.finalizeDrawingBuffer(); await selectedEntityAdapter.finalizeDrawingBuffer();
} else if (toolState.selected === 'eraser' && drawingBuffer?.type === 'eraser_line') { } else if (toolState.selected === 'eraser' && drawingBuffer?.type === 'eraser_line') {
drawingBuffer.points.push(pos.x - selectedEntity.position.x, pos.y - selectedEntity.position.y); const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width);
drawingBuffer.points.push(alignedPoint.x, alignedPoint.y);
await selectedEntityAdapter.setDrawingBuffer(drawingBuffer); await selectedEntityAdapter.setDrawingBuffer(drawingBuffer);
await selectedEntityAdapter.finalizeDrawingBuffer(); await selectedEntityAdapter.finalizeDrawingBuffer();
} else if (toolState.selected === 'rect' && drawingBuffer?.type === 'rect_shape') { } else if (toolState.selected === 'rect' && drawingBuffer?.type === 'rect_shape') {
drawingBuffer.width = pos.x - selectedEntity.position.x - drawingBuffer.x; drawingBuffer.width = Math.round(normalizedPoint.x - drawingBuffer.x);
drawingBuffer.height = pos.y - selectedEntity.position.y - drawingBuffer.y; drawingBuffer.height = Math.round(normalizedPoint.y - drawingBuffer.y);
await selectedEntityAdapter.setDrawingBuffer(drawingBuffer); await selectedEntityAdapter.setDrawingBuffer(drawingBuffer);
await selectedEntityAdapter.finalizeDrawingBuffer(); await selectedEntityAdapter.finalizeDrawingBuffer();
} }

View File

@ -1,7 +1,7 @@
import { getImageDataTransparency } from 'common/util/arrayBuffer'; import { getImageDataTransparency } from 'common/util/arrayBuffer';
import { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer'; import { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer';
import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import type { GenerationMode, Rect, RenderableObject, RgbaColor } from 'features/controlLayers/store/types'; import type { Coordinate, GenerationMode, Rect, RenderableObject, RgbaColor } from 'features/controlLayers/store/types';
import { isValidLayer } from 'features/nodes/util/graph/generation/addLayers'; import { isValidLayer } from 'features/nodes/util/graph/generation/addLayers';
import Konva from 'konva'; import Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node'; import type { KonvaEventObject } from 'konva/lib/Node';
@ -41,6 +41,39 @@ export const getScaledCursorPosition = (stage: Konva.Stage): Vector2d | null =>
return stageTransform.invert().point(pointerPosition); return stageTransform.invert().point(pointerPosition);
}; };
/**
* Aligns a coordinate to the nearest integer. When the tool width is odd, an offset is added to align the edges
* of the tool to the grid. Without this alignment, the edges of the tool will be 0.5px off.
* @param coord The coordinate to align
* @param toolWidth The width of the tool
* @returns The aligned coordinate
*/
export const alignCoordForTool = (coord: Coordinate, toolWidth: number): Coordinate => {
const roundedX = Math.round(coord.x);
const roundedY = Math.round(coord.y);
const deltaX = coord.x - roundedX;
const deltaY = coord.y - roundedY;
const offset = (toolWidth / 2) % 1;
const point = {
x: roundedX + Math.sign(deltaX) * offset,
y: roundedY + Math.sign(deltaY) * offset,
};
return point;
};
/**
* Offsets a point by the given offset. The offset is subtracted from the point.
* @param coord The coordinate to offset
* @param offset The offset to apply
* @returns
*/
export const offsetCoord = (coord: Coordinate, offset: Coordinate): Coordinate => {
return {
x: coord.x - offset.x,
y: coord.y - offset.y,
};
};
/** /**
* Snaps a position to the edge of the stage if within a threshold of the edge * Snaps a position to the edge of the stage if within a threshold of the edge
* @param pos The position to snap * @param pos The position to snap