From 69987a2f00f2273275b6007e77fed9ff81a0a407 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 1 Aug 2024 23:58:52 +1000
Subject: [PATCH] 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
---
 .../controlLayers/konva/CanvasTool.ts         | 13 ++-
 .../features/controlLayers/konva/events.ts    | 79 +++++++++++--------
 .../src/features/controlLayers/konva/util.ts  | 35 +++++++-
 3 files changed, 90 insertions(+), 37 deletions(-)

diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts
index ea7452343c..9d92f70714 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool.ts
@@ -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',
         });
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts
index ebce9006e1..89d0ac6a75 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/events.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/events.ts
@@ -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();
       }
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts
index 71f66151c4..07f8601cce 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/util.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/util.ts
@@ -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