From 7b5a43df9b30155c447ddbaa55e700de95d24427 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 20 Aug 2024 09:44:40 +1000 Subject: [PATCH] feat(ui): add line simplification This fixes some awkward issues where line segments stack up. --- .../controlLayers/store/canvasV2Slice.ts | 42 +++-- .../features/controlLayers/util/simplify.ts | 164 ++++++++++++++++++ 2 files changed, 189 insertions(+), 17 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/util/simplify.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts index 2c19024d68..588932db91 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasV2Slice.ts @@ -16,6 +16,7 @@ import { sessionReducers } from 'features/controlLayers/store/sessionReducers'; import { settingsReducers } from 'features/controlLayers/store/settingsReducers'; import { toolReducers } from 'features/controlLayers/store/toolReducers'; import { getScaledBoundingBoxDimensions } from 'features/controlLayers/util/getScaledBoundingBoxDimensions'; +import { simplifyFlatNumbersArray } from 'features/controlLayers/util/simplify'; import { initialAspectRatioState } from 'features/parameters/components/DocumentSize/constants'; import { getOptimalDimension } from 'features/parameters/util/optimalDimension'; import { isEqual, pick } from 'lodash-es'; @@ -55,11 +56,12 @@ const initialState: CanvasV2State = { type: 'inpaint_mask', fill: { style: 'diagonal', - color: { r: 255, g: 122, b: 0, a: 1 }, // some orange color + color: { r: 255, g: 122, b: 0 }, // some orange color }, rasterizationCache: [], isEnabled: true, objects: [], + opacity: 1, position: { x: 0, y: 0, @@ -292,37 +294,43 @@ export const canvasV2Slice = createSlice({ return; } - if (isDrawableEntity(entity)) { - entity.objects.push(brushLine); - // When adding a brush line, we need to invalidate the rasterization caches. - invalidateRasterizationCaches(entity, state); + if (!isDrawableEntity(entity)) { + assert(false, `Cannot add a brush line to a non-drawable entity of type ${entity.type}`); } + + entity.objects.push({ ...brushLine, points: simplifyFlatNumbersArray(brushLine.points) }); + // When adding a brush line, we need to invalidate the rasterization caches. + invalidateRasterizationCaches(entity, state); }, entityEraserLineAdded: (state, action: PayloadAction) => { const { entityIdentifier, eraserLine } = action.payload; const entity = selectEntity(state, entityIdentifier); if (!entity) { return; - } else if (isDrawableEntity(entity)) { - entity.objects.push(eraserLine); - // When adding an eraser line, we need to invalidate the rasterization caches. - invalidateRasterizationCaches(entity, state); - } else { - assert(false, 'Not implemented'); } + + if (!isDrawableEntity(entity)) { + assert(false, `Cannot add a eraser line to a non-drawable entity of type ${entity.type}`); + } + + entity.objects.push({ ...eraserLine, points: simplifyFlatNumbersArray(eraserLine.points) }); + // When adding an eraser line, we need to invalidate the rasterization caches. + invalidateRasterizationCaches(entity, state); }, entityRectAdded: (state, action: PayloadAction) => { const { entityIdentifier, rect } = action.payload; const entity = selectEntity(state, entityIdentifier); if (!entity) { return; - } else if (isDrawableEntity(entity)) { - entity.objects.push(rect); - // When adding an eraser line, we need to invalidate the rasterization caches. - invalidateRasterizationCaches(entity, state); - } else { - assert(false, 'Not implemented'); } + + if (!isDrawableEntity(entity)) { + assert(false, `Cannot add a rect to a non-drawable entity of type ${entity.type}`); + } + + entity.objects.push(rect); + // When adding an eraser line, we need to invalidate the rasterization caches. + invalidateRasterizationCaches(entity, state); }, entityDeleted: (state, action: PayloadAction) => { const { entityIdentifier } = action.payload; diff --git a/invokeai/frontend/web/src/features/controlLayers/util/simplify.ts b/invokeai/frontend/web/src/features/controlLayers/util/simplify.ts new file mode 100644 index 0000000000..d416e2870c --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/util/simplify.ts @@ -0,0 +1,164 @@ +/** + * Adapted from https://github.com/mourner/simplify-js/ + * + * Copyright (c) 2017, Vladimir Agafonkin + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + * TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import type { Coordinate } from 'features/controlLayers/store/types'; +import { assert } from 'tsafe'; + +// square distance between 2 points +function getSqDist(p1: Coordinate, p2: Coordinate) { + const dx = p1.x - p2.x; + const dy = p1.y - p2.y; + + return dx * dx + dy * dy; +} + +// square distance from a point to a segment +function getSqSegDist(p: Coordinate, p1: Coordinate, p2: Coordinate) { + let x = p1.x; + let y = p1.y; + let dx = p2.x - x; + let dy = p2.y - y; + + if (dx !== 0 || dy !== 0) { + const t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy); + + if (t > 1) { + x = p2.x; + y = p2.y; + } else if (t > 0) { + x += dx * t; + y += dy * t; + } + } + + dx = p.x - x; + dy = p.y - y; + + return dx * dx + dy * dy; +} +// rest of the code doesn't care about point format + +// basic distance-based simplification +function simplifyRadialDist(points: Coordinate[], sqTolerance: number): Coordinate[] { + let prevPoint = points[0]!; + const newPoints = [prevPoint]; + let point: Coordinate; + + for (let i = 1, len = points.length; i < len; i++) { + point = points[i]!; + + if (getSqDist(point, prevPoint!) > sqTolerance) { + newPoints.push(point); + prevPoint = point; + } + } + + if (prevPoint !== point!) { + newPoints.push(point!); + } + + return newPoints; +} + +function simplifyDPStep( + points: Coordinate[], + first: number, + last: number, + sqTolerance: number, + simplified: Coordinate[] +) { + let maxSqDist = sqTolerance; + let index; + + for (let i = first + 1; i < last; i++) { + const sqDist = getSqSegDist(points[i]!, points[first]!, points[last]!); + + if (sqDist > maxSqDist) { + index = i; + maxSqDist = sqDist; + } + } + + if (maxSqDist > sqTolerance) { + if (index! - first > 1) { + simplifyDPStep(points, first, index!, sqTolerance, simplified); + } + simplified.push(points[index!]!); + if (last - index! > 1) { + simplifyDPStep(points, index!, last, sqTolerance, simplified); + } + } +} + +// simplification using Ramer-Douglas-Peucker algorithm +function simplifyDouglasPeucker(points: Coordinate[], sqTolerance: number) { + const last = points.length - 1; + + const simplified = [points[0]!]; + simplifyDPStep(points, 0, last, sqTolerance, simplified); + simplified.push(points[last]!); + + return simplified; +} + +type SimplifyOptions = { + tolerance?: number; + highestQuality?: boolean; +}; + +// both algorithms combined for awesome performance +export function simplifyCoords(points: Coordinate[], options?: SimplifyOptions): Coordinate[] { + const { tolerance, highestQuality } = { ...options, tolerance: 1, highestQuality: false }; + + if (points.length <= 2) { + return points; + } + + const sqTolerance = tolerance * tolerance; + + const firstPassPoints = highestQuality ? points : simplifyRadialDist(points, sqTolerance); + const secondPassPoints = simplifyDouglasPeucker(firstPassPoints, sqTolerance); + + return secondPassPoints; +} + +export function coordsToFlatNumbersArray(coords: Coordinate[]): number[] { + return coords.flatMap((coord) => [coord.x, coord.y]); +} + +export function flatNumbersArrayToCoords(array: number[]): Coordinate[] { + assert(array.length % 2 === 0, 'Array length must be even'); + const coords: Coordinate[] = []; + for (let i = 0; i < array.length; i += 2) { + coords.push({ x: array[i]!, y: array[i + 1]! }); + } + return coords; +} + +export function simplifyFlatNumbersArray(array: number[], options?: SimplifyOptions): number[] { + return coordsToFlatNumbersArray(simplifyCoords(flatNumbersArrayToCoords(array), options)); +}