From 4d1e42a38b0a59ad8fae22acd5753d6dfc4cc66e Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Wed, 11 Dec 2024 15:11:14 -0500 Subject: [PATCH] Paint bucket! --- src/main.js | 244 ++++++++++++++++++++++++++++++++++++++++++++---- src/quadtree.js | 31 ++++-- src/utils.js | 165 +++++++++++++++++++++++++++++++- 3 files changed, 416 insertions(+), 24 deletions(-) diff --git a/src/main.js b/src/main.js index bd9c640..3c1826a 100644 --- a/src/main.js +++ b/src/main.js @@ -3,7 +3,7 @@ import * as fitCurve from '/fit-curve.js'; import { Bezier } from "/bezier.js"; import { Quadtree } from './quadtree.js'; import { createNewFileDialog, showNewFileDialog, closeDialog } from './newfile.js'; -import { titleCase, getMousePositionFraction, getKeyframesSurrounding, invertPixels, lerpColor, lerp, camelToWords, generateWaveform } from './utils.js'; +import { titleCase, getMousePositionFraction, getKeyframesSurrounding, invertPixels, lerpColor, lerp, camelToWords, generateWaveform, floodFillRegion, getShapeAtPoint } from './utils.js'; const { writeTextFile: writeTextFile, readTextFile: readTextFile, writeFile: writeFile, readFile: readFile }= window.__TAURI__.fs; const { open: openFileDialog, @@ -31,6 +31,10 @@ forwardConsole('info', info); forwardConsole('warn', warn); forwardConsole('error', error); +// Debug flags +const debugQuadtree =false +const debugPaintbucket = false + const macOS = navigator.userAgent.includes('Macintosh') let simplifyPolyline = simplify @@ -41,6 +45,9 @@ let rootPane; let canvases = []; +let debugCurves = []; +let debugPoints = []; + let mode = "select" let minSegmentSize = 5; @@ -113,6 +120,7 @@ let mouseEvent; let context = { mouseDown: false, + mousePos: {x: 0, y: 0}, swatches: [ "#000000", "#FFFFFF", @@ -162,18 +170,27 @@ let startProps = {} let actions = { addShape: { - create: (parent, shape) => { + create: (parent, shape, ctx) => { if (shape.curves.length==0) return; redoStack.length = 0; // Clear redo stack let serializableCurves = [] for (let curve of shape.curves) { serializableCurves.push({ points: curve.points, color: curve.color }) } + let c = { + ...context, + ...ctx + } let action = { parent: parent.idx, curves: serializableCurves, startx: shape.startx, starty: shape.starty, + context: { + fillShape: c.fillShape, + strokeShape: c.strokeShape, + fillStyle: c.fillStyle + }, uuid: uuidv4() } undoStack.push({name: "addShape", action: action}) @@ -183,7 +200,11 @@ let actions = { execute: (action) => { let object = pointerList[action.parent] let curvesList = action.curves - let shape = new Shape(action.startx, action.starty, context, action.uuid) + let cxt = { + ...context, + ...action.context + } + let shape = new Shape(action.startx, action.starty, cxt, action.uuid) for (let curve of curvesList) { shape.addCurve(new Bezier( curve.points[0].x, curve.points[0].y, @@ -1363,6 +1384,9 @@ class BaseShape { } else { ctx.fillStyle = this.fillStyle } + if (context.debugColor) { + ctx.fillStyle = context.debugColor + } if (this.curves.length > 0) { ctx.moveTo(this.curves[0].points[0].x, this.curves[0].points[0].y) for (let curve of this.curves) { @@ -1373,7 +1397,7 @@ class BaseShape { } ctx.fill() } - if (this.stroked) { + if (this.stroked && !context.debugColor) { for (let curve of this.curves) { ctx.strokeStyle = curve.color ctx.beginPath() @@ -1392,7 +1416,9 @@ class BaseShape { } } // Debug, show quadtree - // this.quadtree.draw(ctx) + if (debugQuadtree && this.quadtree && !context.debugColor) { + this.quadtree.draw(ctx) + } } } @@ -1458,6 +1484,7 @@ class Shape extends BaseShape { midpoint.x, midpoint.y, x, y) curve.color = context.strokeStyle + this.quadtree.insert(curve, this.curves.length - 1) this.curves.push(curve) } bbox() { @@ -1494,6 +1521,20 @@ class Shape extends BaseShape { return newShape } + fromPoints(points, error=30) { + console.log(error) + this.curves = [] + let curves = fitCurve.fitCurve(points, error) + for (let curve of curves) { + let bezier = new Bezier(curve[0][0], curve[0][1], + curve[1][0],curve[1][1], + curve[2][0], curve[2][1], + curve[3][0], curve[3][1]) + this.curves.push(bezier) + this.quadtree.insert(bezier, this.curves.length - 1) + } + return this + } simplify(mode="corners") { this.quadtree.clear() this.inProgress = false @@ -1524,17 +1565,7 @@ class Shape extends BaseShape { for (let curve of this.curves) { points.push([curve.points[3].x, curve.points[3].y]) } - this.curves = [] - let curves = fitCurve.fitCurve(points, error) - for (let curve of curves) { - let bezier = new Bezier(curve[0][0], curve[0][1], - curve[1][0],curve[1][1], - curve[2][0], curve[2][1], - curve[3][0], curve[3][1]) - this.curves.push(bezier) - this.quadtree.insert(bezier, this.curves.length - 1) - - } + this.fromPoints(points, error) } let epsilon = 0.01 let newCurves = [] @@ -2184,6 +2215,8 @@ function _newFile(width, height, fps) { fileWidth = width fileHeight = height fileFps = fps + undoStack = [] + redoStack = [] for (let stage of document.querySelectorAll(".stage")) { stage.width = width stage.height = height @@ -2695,6 +2728,139 @@ function stage() { break; case "paint_bucket": let line = {p1: mouse, p2: {x: mouse.x + 3000, y: mouse.y}} + debugCurves = [] + debugPoints = [] + let epsilon = 5; + let min_x = Infinity; + let curveB = undefined + let point = undefined + let regionPoints + + // First, see if there's an existing shape to change the color of + const startTime = performance.now() + let pointShape = getShapeAtPoint(mouse, context.activeObject.currentFrame.shapes) + const endTime = performance.now() + + console.log(pointShape) + console.log(`getShapeAtPoint took ${endTime - startTime} milliseconds.`) + + if (pointShape) { + actions.colorShape.create(pointShape, context.fillStyle); + break; + } + + // We didn't find an existing region to paintbucket, see if we can make one + try { + regionPoints = floodFillRegion(mouse,epsilon,fileWidth,fileHeight,context, debugPoints, debugPaintbucket) + } catch (e) { + updateUI() + throw e; + + } + console.log(regionPoints.length) + if (regionPoints.length>0 && regionPoints.length < 10) { + // probably a very small area, rerun with minimum epsilon + regionPoints = floodFillRegion(mouse,1,fileWidth,fileHeight,context, debugPoints) + } + let points = [] + for (let point of regionPoints) { + points.push([point.x, point.y]) + } + let cxt = { + ...context, + fillShape: true, + strokeShape: false, + } + let shape = new Shape(regionPoints[0].x, regionPoints[0].y, cxt) + shape.fromPoints(points, 1) + actions.addShape.create(context.activeObject, shape, cxt) + /* + for (let i in context.activeObject.currentFrame.shapes) { + let shape = context.activeObject.currentFrame.shapes[i] + for (let curve of shape.curves) { + let intersects = curve.intersects(line) + for (let intersect of intersects) { + let pos = curve.compute(intersect) + if (pos.x < min_x) { + curveB = curve + min_x = pos.x + point = intersect + } + } + } + } + let previousCurves = []; + let previousCurveIds = []; + if (curveB) { + previousCurves.push(curveB.toString()) + previousCurveIds.push(curveB.toString()) + let derivative = curveB.derivative(point) + // if curve is moving downward at intersection + let splitCurve = curveB.split(point) + let curveA; + if (derivative.y > 0) { + curveA = splitCurve.right + } else { + curveA = splitCurve.left.reverse() + } + // debugCurves.push(curveA) + for (let i=0; i<2; i++) { + + let min_intersect = Infinity + let nextCurve = undefined + for (let i in context.activeObject.currentFrame.shapes) { + let shape = context.activeObject.currentFrame.shapes[i] + for (let j of shape.quadtree.query(curveA.bbox())) { + let curve = shape.curves[j] + console.log(previousCurveIds) + console.log(curve.toString()) + console.log(curve==(previousCurves.length?previousCurves[0]:undefined)) + console.log(curve.toString()==(previousCurves.length?previousCurves[0].toString():undefined)) + if (previousCurves.indexOf(curve.toString()) != -1) { + console.log("skipping") + continue; + } + let intersects = curveA.intersects(curve) + // if (intersects.length > 4) continue; + console.log(intersects) + console.log(curve) + for (let intersect of intersects) { + intersect = intersect.split('/') + let intersect_a = parseFloat(intersect[0]) + let intersect_b = parseFloat(intersect[1]) + if (intersect_a < min_intersect) { + // console.log(curve) + min_intersect = intersect_a + nextCurve = curve + point = intersect_b + } + } + } + } + curveB = nextCurve + if (curveB) { + // debugCurves.push(curveB) + console.log(min_intersect) + let splitCurve = curveB.split(point) + let d_A = curveA.derivative(min_intersect) + let d_B = curveB.derivative(point) + curveA = curveA.split(min_intersect).left + debugCurves.push(curveA) + if ((d_A.x * d_B.y - d_A.y * d_B.x) < 0) { + curveA = splitCurve.left.reverse() + } else { + curveA = splitCurve.right + } + // debugCurves.push(curveB) + } else { + break + } + } + + // for (let) + } + */ + break // Loop labels in JS! shapeLoop: // Iterate in reverse so we paintbucket the frontmost shape @@ -3286,6 +3452,52 @@ function updateUI() { context.activeShape.draw(context) } + // Debug rendering + if (debugQuadtree) { + + ctx.fillStyle = "rgba(255,255,255,0.5)" + ctx.fillRect(0,0,fileWidth,fileHeight) + const ep = 2.5 + const bbox = { + x: { min: context.mousePos.x - ep, max: context.mousePos.x + ep }, + y: { min: context.mousePos.y - ep, max: context.mousePos.y + ep } + }; + debugCurves = [] + for (let shape of context.activeObject.currentFrame.shapes) { + for (let i of shape.quadtree.query(bbox)) { + debugCurves.push(shape.curves[i]) + } + } + } + // let i=4; + for (let curve of debugCurves) { + ctx.beginPath() + // ctx.strokeStyle = `#ff${i}${i}${i}${i}` + // i = (i+3)%10 + ctx.strokeStyle = '#'+(Math.random()*0xFFFFFF<<0).toString(16); + ctx.moveTo(curve.points[0].x, curve.points[0].y) + ctx.bezierCurveTo( + curve.points[1].x, curve.points[1].y, + curve.points[2].x, curve.points[2].y, + curve.points[3].x, curve.points[3].y + ) + ctx.stroke() + ctx.beginPath() + let bbox = curve.bbox() + ctx.rect(bbox.x.min,bbox.y.min,bbox.x.max-bbox.x.min,bbox.y.max-bbox.y.min) + ctx.stroke() + } + let i=0; + for (let point of debugPoints) { + ctx.beginPath() + let j = i.toString(16).padStart(2, '0'); + ctx.fillStyle = `#${j}ff${j}` + i+=1 + i %= 255 + ctx.arc(point.x, point.y, 3, 0, 2*Math.PI) + ctx.fill() + } + } for (let selectionRect of document.querySelectorAll(".selectionRect")) { selectionRect.style.display = "none" diff --git a/src/quadtree.js b/src/quadtree.js index 68df8e0..6eaaba5 100644 --- a/src/quadtree.js +++ b/src/quadtree.js @@ -41,7 +41,11 @@ class Quadtree { insert (curve, curveIdx) { const bbox = curve.bbox() - if (!this.intersects(curve.bbox())) { + const isOutside = bbox.x.min < this.boundary.x.min || + bbox.x.max > this.boundary.x.max || + bbox.y.min < this.boundary.y.min || + bbox.y.max > this.boundary.y.max; + if (isOutside) { let newNode = new Quadtree(this.boundary, this.capacity) newNode.curveIndexes = this.curveIndexes; newNode.curves = this.curves; @@ -84,6 +88,10 @@ class Quadtree { // Insert a curve into the quadtree, subdividing if necessary _insert(curve, curveIdx) { + if (curve.points[0].x==381.703125) { + console.log(this.intersects(curve.bbox())) + console.log(this.boundary) + } // If the curve's bounding box doesn't intersect this node's boundary, do nothing if (!this.intersects(curve.bbox())) { return false; @@ -91,22 +99,31 @@ class Quadtree { // If the node has space, insert the curve here if (this.curves.length < this.capacity) { + + if (curve.points[0].x==381.703125) { + "inserting" + } this.curves.push(curve); this.curveIndexes.push(curveIdx) return true; } + + if (curve.points[0].x==381.703125) { + console.log("going down a level") + } // Otherwise, subdivide and insert the curve into the appropriate quadrant if (!this.divided) { this.subdivide(); } - return ( - this.nw._insert(curve, curveIdx) || - this.ne._insert(curve, curveIdx) || - this.sw._insert(curve, curveIdx) || - this.se._insert(curve, curveIdx) - ); + const resultNw = this.nw._insert(curve, curveIdx); + const resultNe = this.ne._insert(curve, curveIdx); + const resultSw = this.sw._insert(curve, curveIdx); + const resultSe = this.se._insert(curve, curveIdx); + + // Return true if any of the insert operations returned true + return resultNw || resultNe || resultSw || resultSe; } // Query all curves that intersect with a given bounding box diff --git a/src/utils.js b/src/utils.js index 47b4337..c206f2a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -200,6 +200,167 @@ function generateWaveform(img, buffer, imgHeight, frameWidth, framesPerSecond) { img.src = dataUrl; } +function floodFillRegion(startPoint, epsilon, fileWidth, fileHeight, context, debugPoints, debugPaintbucket) { + // Helper function to check if the point is at the boundary of the region + function isBoundaryPoint(point) { + return point.x <= 0 || point.x >= fileWidth || point.y <= 0 || point.y >= fileHeight; + } + let halfEpsilon = epsilon/2 + + // Helper function to check if a point is near any curve in the shape + function isNearCurve(point, shape) { + // Generate bounding box around the point for quadtree query + const bbox = { + x: { min: point.x - halfEpsilon, max: point.x + halfEpsilon }, + y: { min: point.y - halfEpsilon, max: point.y + halfEpsilon } + }; + // Get the list of curve indices that are near the point + const nearbyCurveIndices = shape.quadtree.query(bbox); + // const nearbyCurveIndices = shape.curves.keys() + // Check if any of the curves are close enough to the point + for (const idx of nearbyCurveIndices) { + const curve = shape.curves[idx]; + const projection = curve.project(point); + if (projection.d < epsilon) { + return projection; + } + } + return false; + } + + const shapes = context.activeObject.currentFrame.shapes; + const visited = new Set(); + const stack = [startPoint]; + const regionPoints = []; + + // Begin the flood fill process + while (stack.length > 0) { + const currentPoint = stack.pop(); + + // If we reach the boundary of the region, throw an exception + if (isBoundaryPoint(currentPoint)) { + throw new Error("Flood fill reached the boundary of the area."); + } + + // If the current point is already visited, skip it + const pointKey = `${currentPoint.x},${currentPoint.y}`; + if (visited.has(pointKey)) { + continue; + } + visited.add(pointKey); + if (debugPaintbucket) { + debugPoints.push(currentPoint) + } + + let isNearAnyCurve = false; + for (const shape of shapes) { + let projection = isNearCurve(currentPoint, shape) + if (projection) { + isNearAnyCurve = true; + regionPoints.push(projection) + break; + } + } + + // Skip the points that are near curves, to prevent jumping past them + if (!isNearAnyCurve) { + const neighbors = [ + { x: currentPoint.x - epsilon, y: currentPoint.y }, + { x: currentPoint.x + epsilon, y: currentPoint.y }, + { x: currentPoint.x, y: currentPoint.y - epsilon }, + { x: currentPoint.x, y: currentPoint.y + epsilon } + ]; + // Add unvisited neighbors to the stack + for (const neighbor of neighbors) { + const neighborKey = `${neighbor.x},${neighbor.y}`; + if (!visited.has(neighborKey)) { + stack.push(neighbor); + } + } + } + } + + // Return the region points in connected order + return sortPointsByProximity(regionPoints) +} + +function sortPointsByProximity(points) { + if (points.length <= 1) return points; + + // Start with the first point as the initial sorted point + const sortedPoints = [points[0]]; + points.splice(0, 1); // Remove the first point from the original list + + // Iterate through the remaining points and find the nearest neighbor + while (points.length > 0) { + const lastPoint = sortedPoints[sortedPoints.length - 1]; + + // Find the closest point to the last point + let closestIndex = -1; + let closestDistance = Infinity; + + for (let i = 0; i < points.length; i++) { + const currentPoint = points[i]; + const distance = Math.sqrt(Math.pow(currentPoint.x - lastPoint.x, 2) + Math.pow(currentPoint.y - lastPoint.y, 2)); + + if (distance < closestDistance) { + closestDistance = distance; + closestIndex = i; + } + } + + // Add the closest point to the sorted points + sortedPoints.push(points[closestIndex]); + points.splice(closestIndex, 1); // Remove the closest point from the original list + } + + return sortedPoints; +} + +function getShapeAtPoint(point, shapes) { + // Create a 1x1 off-screen canvas and translate so it is in the first pixel + const offscreenCanvas = document.createElement('canvas'); + offscreenCanvas.width = 1; + offscreenCanvas.height = 1; + const ctx = offscreenCanvas.getContext('2d'); + + ctx.translate(-point.x, -point.y); + const colorToShapeMap = {}; + + // Generate a unique color for each shape (start from #000001 and increment) + let colorIndex = 1; + + // Draw all shapes to the off-screen canvas with their unique colors + shapes.forEach(shape => { + // Generate a unique color for this shape + const debugColor = intToHexColor(colorIndex); + colorToShapeMap[debugColor] = shape; + + const context = { + ctx: ctx, + debugColor: debugColor + }; + shape.draw(context); + colorIndex++; + }); + + const pixel = ctx.getImageData(0, 0, 1, 1).data; + const sampledColor = rgbToHex(pixel[0], pixel[1], pixel[2]); + return colorToShapeMap[sampledColor] || null; +} + +// Helper function to convert a number (0-16777215) to a hex color code +function intToHexColor(value) { + // Ensure the value is between 0 and 16777215 (0xFFFFFF) + value = value & 0xFFFFFF; + return '#' + value.toString(16).padStart(6, '0').toUpperCase(); +} + +// Helper function to convert RGB to hex (for sampling) +function rgbToHex(r, g, b) { + return '#' + (1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1).toUpperCase(); +} + export { titleCase, @@ -209,5 +370,7 @@ export { lerp, lerpColor, camelToWords, - generateWaveform + generateWaveform, + floodFillRegion, + getShapeAtPoint }; \ No newline at end of file