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 0d4b91afe0
commit 2bb74abf31
3 changed files with 90 additions and 37 deletions

View File

@ -5,6 +5,7 @@ import {
BRUSH_BORDER_OUTER_COLOR,
BRUSH_ERASER_BORDER_WIDTH,
} from 'features/controlLayers/konva/constants';
import { alignCoordForTool } from 'features/controlLayers/konva/util';
import Konva from 'konva';
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
if (cursorPos && tool === 'brush') {
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.brush.width);
const scale = stage.scaleX();
// Update the fill circle
const radius = toolState.brush.width / 2;
this.konva.brush.fillCircle.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
fill: isDrawing ? '' : rgbaColorToString(currentFill),
});
@ -209,12 +212,14 @@ export class CanvasTool {
this.konva.eraser.group.visible(false);
// this.rect.group.visible(false);
} else if (cursorPos && tool === 'eraser') {
const alignedCursorPos = alignCoordForTool(cursorPos, toolState.eraser.width);
const scale = stage.scaleX();
// Update the fill circle
const radius = toolState.eraser.width / 2;
this.konva.eraser.fillCircle.setAttrs({
x: cursorPos.x,
y: cursorPos.y,
x: alignedCursorPos.x,
y: alignedCursorPos.y,
radius,
fill: 'white',
});

View File

@ -1,5 +1,10 @@
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 {
CanvasV2State,
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
*/
const updateLastCursorPos = (stage: Konva.Stage, setLastCursorPos: CanvasManager['stateApi']['setLastCursorPos']) => {
const pos = getScaledFlooredCursorPosition(stage);
const pos = getScaledCursorPosition(stage);
if (!pos) {
return null;
}
@ -177,14 +182,17 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
getIsPrimaryMouseDown(e)
) {
setLastMouseDownPos(pos);
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
if (toolState.selected === 'brush') {
const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity, toolState.selected);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width);
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
if (selectedEntityAdapter.getDrawingBuffer()) {
await selectedEntityAdapter.finalizeDrawingBuffer();
}
await selectedEntityAdapter.setDrawingBuffer({
id: getObjectId('brush_line', true),
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
lastLinePoint.x,
lastLinePoint.y,
pos.x - selectedEntity.position.x,
pos.y - selectedEntity.position.y,
alignedPoint.x,
alignedPoint.y,
],
strokeWidth: toolState.brush.width,
color: getCurrentFill(),
@ -206,17 +214,18 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
await selectedEntityAdapter.setDrawingBuffer({
id: getObjectId('brush_line', true),
type: 'brush_line',
points: [pos.x - selectedEntity.position.x, pos.y - selectedEntity.position.y],
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: toolState.brush.width,
color: getCurrentFill(),
clip: getClip(selectedEntity),
});
}
setLastAddedPoint(pos);
setLastAddedPoint(alignedPoint);
}
if (toolState.selected === 'eraser') {
const lastLinePoint = getLastPointOfLastLineOfEntity(selectedEntity, toolState.selected);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width);
if (e.evt.shiftKey && lastLinePoint) {
// Create a straight line from the last line point
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
lastLinePoint.x,
lastLinePoint.y,
pos.x - selectedEntity.position.x,
pos.y - selectedEntity.position.y,
alignedPoint.x,
alignedPoint.y,
],
strokeWidth: toolState.eraser.width,
clip: getClip(selectedEntity),
@ -242,12 +251,12 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
await selectedEntityAdapter.setDrawingBuffer({
id: getObjectId('eraser_line', true),
type: 'eraser_line',
points: [pos.x - selectedEntity.position.x, pos.y - selectedEntity.position.y],
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: toolState.eraser.width,
clip: getClip(selectedEntity),
});
}
setLastAddedPoint(pos);
setLastAddedPoint(alignedPoint);
}
if (toolState.selected === 'rect') {
@ -257,8 +266,8 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
await selectedEntityAdapter.setDrawingBuffer({
id: getObjectId('rect_shape', true),
type: 'rect_shape',
x: pos.x - selectedEntity.position.x,
y: pos.y - selectedEntity.position.y,
x: Math.round(normalizedPoint.x),
y: Math.round(normalizedPoint.y),
width: 0,
height: 0,
color: getCurrentFill(),
@ -340,12 +349,11 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
if (drawingBuffer?.type === 'brush_line') {
const nextPoint = getNextPoint(pos, toolState, getLastPointOfLine(drawingBuffer.points));
if (nextPoint) {
drawingBuffer.points.push(
nextPoint.x - selectedEntity.position.x,
nextPoint.y - selectedEntity.position.y
);
const normalizedPoint = offsetCoord(nextPoint, selectedEntity.position);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width);
drawingBuffer.points.push(alignedPoint.x, alignedPoint.y);
await selectedEntityAdapter.setDrawingBuffer(drawingBuffer);
setLastAddedPoint(nextPoint);
setLastAddedPoint(alignedPoint);
}
} else {
await selectedEntityAdapter.setDrawingBuffer(null);
@ -354,15 +362,17 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
if (selectedEntityAdapter.getDrawingBuffer()) {
await selectedEntityAdapter.finalizeDrawingBuffer();
}
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.brush.width);
await selectedEntityAdapter.setDrawingBuffer({
id: getObjectId('brush_line', true),
type: 'brush_line',
points: [pos.x - selectedEntity.position.x, pos.y - selectedEntity.position.y],
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: toolState.brush.width,
color: getCurrentFill(),
clip: getClip(selectedEntity),
});
setLastAddedPoint(pos);
setLastAddedPoint(alignedPoint);
}
}
@ -372,12 +382,11 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
if (drawingBuffer.type === 'eraser_line') {
const nextPoint = getNextPoint(pos, toolState, getLastPointOfLine(drawingBuffer.points));
if (nextPoint) {
drawingBuffer.points.push(
nextPoint.x - selectedEntity.position.x,
nextPoint.y - selectedEntity.position.y
);
const normalizedPoint = offsetCoord(nextPoint, selectedEntity.position);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width);
drawingBuffer.points.push(alignedPoint.x, alignedPoint.y);
await selectedEntityAdapter.setDrawingBuffer(drawingBuffer);
setLastAddedPoint(nextPoint);
setLastAddedPoint(alignedPoint);
}
} else {
await selectedEntityAdapter.setDrawingBuffer(null);
@ -386,14 +395,16 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
if (selectedEntityAdapter.getDrawingBuffer()) {
await selectedEntityAdapter.finalizeDrawingBuffer();
}
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
const alignedPoint = alignCoordForTool(normalizedPoint, toolState.eraser.width);
await selectedEntityAdapter.setDrawingBuffer({
id: getObjectId('eraser_line', true),
type: 'eraser_line',
points: [pos.x - selectedEntity.position.x, pos.y - selectedEntity.position.y],
points: [alignedPoint.x, alignedPoint.y],
strokeWidth: toolState.eraser.width,
clip: getClip(selectedEntity),
});
setLastAddedPoint(pos);
setLastAddedPoint(alignedPoint);
}
}
@ -401,8 +412,9 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
const drawingBuffer = selectedEntityAdapter.getDrawingBuffer();
if (drawingBuffer) {
if (drawingBuffer.type === 'rect_shape') {
drawingBuffer.width = pos.x - selectedEntity.position.x - drawingBuffer.x;
drawingBuffer.height = pos.y - selectedEntity.position.y - drawingBuffer.y;
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
drawingBuffer.width = Math.round(normalizedPoint.x - drawingBuffer.x);
drawingBuffer.height = Math.round(normalizedPoint.y - drawingBuffer.y);
await selectedEntityAdapter.setDrawingBuffer(drawingBuffer);
} else {
await selectedEntityAdapter.setDrawingBuffer(null);
@ -432,17 +444,20 @@ export const setStageEventHandlers = (manager: CanvasManager): (() => void) => {
getIsPrimaryMouseDown(e)
) {
const drawingBuffer = selectedEntityAdapter.getDrawingBuffer();
const normalizedPoint = offsetCoord(pos, selectedEntity.position);
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.finalizeDrawingBuffer();
} 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.finalizeDrawingBuffer();
} else if (toolState.selected === 'rect' && drawingBuffer?.type === 'rect_shape') {
drawingBuffer.width = pos.x - selectedEntity.position.x - drawingBuffer.x;
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.finalizeDrawingBuffer();
}

View File

@ -1,7 +1,7 @@
import { getImageDataTransparency } from 'common/util/arrayBuffer';
import { CanvasLayer } from 'features/controlLayers/konva/CanvasLayer';
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 Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
@ -41,6 +41,39 @@ export const getScaledCursorPosition = (stage: Konva.Stage): Vector2d | null =>
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
* @param pos The position to snap