Paint bucket!

This commit is contained in:
Skyler Lehmkuhl 2024-12-11 15:11:14 -05:00
parent c27b349668
commit 4d1e42a38b
3 changed files with 416 additions and 24 deletions

View File

@ -3,7 +3,7 @@ import * as fitCurve from '/fit-curve.js';
import { Bezier } from "/bezier.js"; import { Bezier } from "/bezier.js";
import { Quadtree } from './quadtree.js'; import { Quadtree } from './quadtree.js';
import { createNewFileDialog, showNewFileDialog, closeDialog } from './newfile.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 { writeTextFile: writeTextFile, readTextFile: readTextFile, writeFile: writeFile, readFile: readFile }= window.__TAURI__.fs;
const { const {
open: openFileDialog, open: openFileDialog,
@ -31,6 +31,10 @@ forwardConsole('info', info);
forwardConsole('warn', warn); forwardConsole('warn', warn);
forwardConsole('error', error); forwardConsole('error', error);
// Debug flags
const debugQuadtree =false
const debugPaintbucket = false
const macOS = navigator.userAgent.includes('Macintosh') const macOS = navigator.userAgent.includes('Macintosh')
let simplifyPolyline = simplify let simplifyPolyline = simplify
@ -41,6 +45,9 @@ let rootPane;
let canvases = []; let canvases = [];
let debugCurves = [];
let debugPoints = [];
let mode = "select" let mode = "select"
let minSegmentSize = 5; let minSegmentSize = 5;
@ -113,6 +120,7 @@ let mouseEvent;
let context = { let context = {
mouseDown: false, mouseDown: false,
mousePos: {x: 0, y: 0},
swatches: [ swatches: [
"#000000", "#000000",
"#FFFFFF", "#FFFFFF",
@ -162,18 +170,27 @@ let startProps = {}
let actions = { let actions = {
addShape: { addShape: {
create: (parent, shape) => { create: (parent, shape, ctx) => {
if (shape.curves.length==0) return; if (shape.curves.length==0) return;
redoStack.length = 0; // Clear redo stack redoStack.length = 0; // Clear redo stack
let serializableCurves = [] let serializableCurves = []
for (let curve of shape.curves) { for (let curve of shape.curves) {
serializableCurves.push({ points: curve.points, color: curve.color }) serializableCurves.push({ points: curve.points, color: curve.color })
} }
let c = {
...context,
...ctx
}
let action = { let action = {
parent: parent.idx, parent: parent.idx,
curves: serializableCurves, curves: serializableCurves,
startx: shape.startx, startx: shape.startx,
starty: shape.starty, starty: shape.starty,
context: {
fillShape: c.fillShape,
strokeShape: c.strokeShape,
fillStyle: c.fillStyle
},
uuid: uuidv4() uuid: uuidv4()
} }
undoStack.push({name: "addShape", action: action}) undoStack.push({name: "addShape", action: action})
@ -183,7 +200,11 @@ let actions = {
execute: (action) => { execute: (action) => {
let object = pointerList[action.parent] let object = pointerList[action.parent]
let curvesList = action.curves 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) { for (let curve of curvesList) {
shape.addCurve(new Bezier( shape.addCurve(new Bezier(
curve.points[0].x, curve.points[0].y, curve.points[0].x, curve.points[0].y,
@ -1363,6 +1384,9 @@ class BaseShape {
} else { } else {
ctx.fillStyle = this.fillStyle ctx.fillStyle = this.fillStyle
} }
if (context.debugColor) {
ctx.fillStyle = context.debugColor
}
if (this.curves.length > 0) { if (this.curves.length > 0) {
ctx.moveTo(this.curves[0].points[0].x, this.curves[0].points[0].y) ctx.moveTo(this.curves[0].points[0].x, this.curves[0].points[0].y)
for (let curve of this.curves) { for (let curve of this.curves) {
@ -1373,7 +1397,7 @@ class BaseShape {
} }
ctx.fill() ctx.fill()
} }
if (this.stroked) { if (this.stroked && !context.debugColor) {
for (let curve of this.curves) { for (let curve of this.curves) {
ctx.strokeStyle = curve.color ctx.strokeStyle = curve.color
ctx.beginPath() ctx.beginPath()
@ -1392,7 +1416,9 @@ class BaseShape {
} }
} }
// Debug, show quadtree // 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, midpoint.x, midpoint.y,
x, y) x, y)
curve.color = context.strokeStyle curve.color = context.strokeStyle
this.quadtree.insert(curve, this.curves.length - 1)
this.curves.push(curve) this.curves.push(curve)
} }
bbox() { bbox() {
@ -1494,6 +1521,20 @@ class Shape extends BaseShape {
return newShape 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") { simplify(mode="corners") {
this.quadtree.clear() this.quadtree.clear()
this.inProgress = false this.inProgress = false
@ -1524,17 +1565,7 @@ class Shape extends BaseShape {
for (let curve of this.curves) { for (let curve of this.curves) {
points.push([curve.points[3].x, curve.points[3].y]) points.push([curve.points[3].x, curve.points[3].y])
} }
this.curves = [] this.fromPoints(points, error)
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)
}
} }
let epsilon = 0.01 let epsilon = 0.01
let newCurves = [] let newCurves = []
@ -2184,6 +2215,8 @@ function _newFile(width, height, fps) {
fileWidth = width fileWidth = width
fileHeight = height fileHeight = height
fileFps = fps fileFps = fps
undoStack = []
redoStack = []
for (let stage of document.querySelectorAll(".stage")) { for (let stage of document.querySelectorAll(".stage")) {
stage.width = width stage.width = width
stage.height = height stage.height = height
@ -2695,6 +2728,139 @@ function stage() {
break; break;
case "paint_bucket": case "paint_bucket":
let line = {p1: mouse, p2: {x: mouse.x + 3000, y: mouse.y}} 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! // Loop labels in JS!
shapeLoop: shapeLoop:
// Iterate in reverse so we paintbucket the frontmost shape // Iterate in reverse so we paintbucket the frontmost shape
@ -3286,6 +3452,52 @@ function updateUI() {
context.activeShape.draw(context) 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")) { for (let selectionRect of document.querySelectorAll(".selectionRect")) {
selectionRect.style.display = "none" selectionRect.style.display = "none"

View File

@ -41,7 +41,11 @@ class Quadtree {
insert (curve, curveIdx) { insert (curve, curveIdx) {
const bbox = curve.bbox() 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) let newNode = new Quadtree(this.boundary, this.capacity)
newNode.curveIndexes = this.curveIndexes; newNode.curveIndexes = this.curveIndexes;
newNode.curves = this.curves; newNode.curves = this.curves;
@ -84,6 +88,10 @@ class Quadtree {
// Insert a curve into the quadtree, subdividing if necessary // Insert a curve into the quadtree, subdividing if necessary
_insert(curve, curveIdx) { _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 the curve's bounding box doesn't intersect this node's boundary, do nothing
if (!this.intersects(curve.bbox())) { if (!this.intersects(curve.bbox())) {
return false; return false;
@ -91,22 +99,31 @@ class Quadtree {
// If the node has space, insert the curve here // If the node has space, insert the curve here
if (this.curves.length < this.capacity) { if (this.curves.length < this.capacity) {
if (curve.points[0].x==381.703125) {
"inserting"
}
this.curves.push(curve); this.curves.push(curve);
this.curveIndexes.push(curveIdx) this.curveIndexes.push(curveIdx)
return true; 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 // Otherwise, subdivide and insert the curve into the appropriate quadrant
if (!this.divided) { if (!this.divided) {
this.subdivide(); this.subdivide();
} }
return ( const resultNw = this.nw._insert(curve, curveIdx);
this.nw._insert(curve, curveIdx) || const resultNe = this.ne._insert(curve, curveIdx);
this.ne._insert(curve, curveIdx) || const resultSw = this.sw._insert(curve, curveIdx);
this.sw._insert(curve, curveIdx) || const resultSe = this.se._insert(curve, curveIdx);
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 // Query all curves that intersect with a given bounding box

View File

@ -200,6 +200,167 @@ function generateWaveform(img, buffer, imgHeight, frameWidth, framesPerSecond) {
img.src = dataUrl; 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 { export {
titleCase, titleCase,
@ -209,5 +370,7 @@ export {
lerp, lerp,
lerpColor, lerpColor,
camelToWords, camelToWords,
generateWaveform generateWaveform,
floodFillRegion,
getShapeAtPoint
}; };