Lightningbeam/src/models/shapes.js

753 lines
21 KiB
JavaScript

// Shape models: BaseShape, TempShape, Shape
import { context, pointerList } from '../state.js';
import { Bezier } from '../bezier.js';
import { Quadtree } from '../quadtree.js';
// Helper function for UUID generation
function uuidv4() {
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
(
+c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))
).toString(16),
);
}
// Forward declarations for dependencies that will be injected
let growBoundingBox = null;
let lerp = null;
let lerpColor = null;
let uuidToColor = null;
let simplifyPolyline = null;
let fitCurve = null;
let createMissingTexturePattern = null;
let debugQuadtree = null;
let d3 = null;
// Initialize function to be called from main.js
export function initializeShapeDependencies(deps) {
growBoundingBox = deps.growBoundingBox;
lerp = deps.lerp;
lerpColor = deps.lerpColor;
uuidToColor = deps.uuidToColor;
simplifyPolyline = deps.simplifyPolyline;
fitCurve = deps.fitCurve;
createMissingTexturePattern = deps.createMissingTexturePattern;
debugQuadtree = deps.debugQuadtree;
d3 = deps.d3;
}
class BaseShape {
constructor(startx, starty) {
this.startx = startx;
this.starty = starty;
this.curves = [];
this.regions = [];
this.boundingBox = {
x: { min: startx, max: starty },
y: { min: starty, max: starty },
};
}
recalculateBoundingBox() {
this.boundingBox = undefined;
for (let curve of this.curves) {
if (!this.boundingBox) {
this.boundingBox = curve.bbox();
}
growBoundingBox(this.boundingBox, curve.bbox());
}
}
draw(context) {
let ctx = context.ctx;
ctx.lineWidth = this.lineWidth;
ctx.lineCap = "round";
// Create a repeating pattern for indicating selected shapes
if (!this.patternCanvas) {
this.patternCanvas = document.createElement('canvas');
this.patternCanvas.width = 2;
this.patternCanvas.height = 2;
let patternCtx = this.patternCanvas.getContext('2d');
// Draw the pattern:
// black, transparent,
// transparent, white
patternCtx.fillStyle = 'black';
patternCtx.fillRect(0, 0, 1, 1);
patternCtx.clearRect(1, 0, 1, 1);
patternCtx.clearRect(0, 1, 1, 1);
patternCtx.fillStyle = 'white';
patternCtx.fillRect(1, 1, 1, 1);
}
let pattern = ctx.createPattern(this.patternCanvas, 'repeat'); // repeat the pattern across the canvas
if (this.filled) {
ctx.beginPath();
if (this.fillImage && this.fillImage instanceof Element) {
let pat;
if (this.fillImage instanceof Element ||
Object.keys(this.fillImage).length !== 0) {
pat = ctx.createPattern(this.fillImage, "no-repeat");
} else {
pat = createMissingTexturePattern(ctx)
}
ctx.fillStyle = pat;
} 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) {
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.fill();
if (context.selected) {
ctx.fillStyle = pattern
ctx.fill()
}
}
function drawCurve(curve, selected) {
ctx.strokeStyle = curve.color;
ctx.beginPath();
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();
if (selected) {
ctx.strokeStyle = pattern
ctx.stroke()
}
}
if (this.stroked && !context.debugColor) {
for (let curve of this.curves) {
drawCurve(curve, context.selected)
// // Debug, show curve control points
// ctx.beginPath()
// ctx.arc(curve.points[1].x,curve.points[1].y, 5, 0, 2*Math.PI)
// ctx.arc(curve.points[2].x,curve.points[2].y, 5, 0, 2*Math.PI)
// ctx.arc(curve.points[3].x,curve.points[3].y, 5, 0, 2*Math.PI)
// ctx.fill()
}
}
if (context.activeCurve && this==context.activeCurve.shape) {
drawCurve(context.activeCurve.current, true)
}
if (context.activeVertex && this==context.activeVertex.shape) {
const curves = {
...context.activeVertex.current.startCurves,
...context.activeVertex.current.endCurves
}
for (let i in curves) {
let curve = curves[i]
drawCurve(curve, true)
}
ctx.fillStyle = "#000000aa";
ctx.beginPath();
let vertexSize = 15 / context.zoomLevel;
ctx.rect(
context.activeVertex.current.point.x - vertexSize / 2,
context.activeVertex.current.point.y - vertexSize / 2,
vertexSize,
vertexSize,
);
ctx.fill();
}
// Debug, show quadtree
if (debugQuadtree && this.quadtree && !context.debugColor) {
this.quadtree.draw(ctx);
}
}
lerpShape(shape2, t) {
if (this.curves.length == 0) return this;
let path1 = [
{
type: "M",
x: this.curves[0].points[0].x,
y: this.curves[0].points[0].y,
},
];
for (let curve of this.curves) {
path1.push({
type: "C",
x1: curve.points[1].x,
y1: curve.points[1].y,
x2: curve.points[2].x,
y2: curve.points[2].y,
x: curve.points[3].x,
y: curve.points[3].y,
});
}
let path2 = [];
if (shape2.curves.length > 0) {
path2.push({
type: "M",
x: shape2.curves[0].points[0].x,
y: shape2.curves[0].points[0].y,
});
for (let curve of shape2.curves) {
path2.push({
type: "C",
x1: curve.points[1].x,
y1: curve.points[1].y,
x2: curve.points[2].x,
y2: curve.points[2].y,
x: curve.points[3].x,
y: curve.points[3].y,
});
}
}
const interpolator = d3.interpolatePathCommands(path1, path2);
let current = interpolator(t);
let curves = [];
let start = current.shift();
let { x, y } = start;
let bezier;
for (let curve of current) {
bezier = new Bezier(
x,
y,
curve.x1,
curve.y1,
curve.x2,
curve.y2,
curve.x,
curve.y,
)
bezier.color = lerpColor(this.strokeStyle, shape2.strokeStyle)
curves.push(bezier);
x = curve.x;
y = curve.y;
}
let lineWidth = lerp(this.lineWidth, shape2.lineWidth, t);
let strokeStyle = lerpColor(
this.strokeStyle,
shape2.strokeStyle,
t,
);
let fillStyle;
if (!this.fillImage) {
fillStyle = lerpColor(this.fillStyle, shape2.fillStyle, t);
}
return new TempShape(
start.x,
start.y,
curves,
lineWidth,
this.stroked,
this.filled,
strokeStyle,
fillStyle,
)
}
}
class TempShape extends BaseShape {
constructor(
startx,
starty,
curves,
lineWidth,
stroked,
filled,
strokeStyle,
fillStyle,
) {
super(startx, starty);
this.curves = curves;
this.lineWidth = lineWidth;
this.stroked = stroked;
this.filled = filled;
this.strokeStyle = strokeStyle;
this.fillStyle = fillStyle;
this.inProgress = false;
this.recalculateBoundingBox();
}
}
class Shape extends BaseShape {
constructor(startx, starty, context, parent, uuid = undefined, shapeId = undefined) {
super(startx, starty);
this.parent = parent; // Reference to parent Layer (required)
this.vertices = [];
this.triangles = [];
this.fillStyle = context.fillStyle;
this.fillImage = context.fillImage;
this.strokeStyle = context.strokeStyle;
this.lineWidth = context.lineWidth;
this.filled = context.fillShape;
this.stroked = context.strokeShape;
this.quadtree = new Quadtree(
{ x: { min: 0, max: 500 }, y: { min: 0, max: 500 } },
4,
);
if (!uuid) {
this.idx = uuidv4();
} else {
this.idx = uuid;
}
if (!shapeId) {
this.shapeId = uuidv4();
} else {
this.shapeId = shapeId;
}
this.shapeIndex = 0; // Default shape version index for tweening
pointerList[this.idx] = this;
this.regionIdx = 0;
this.inProgress = true;
// Timeline display settings (Phase 3)
this.showSegment = true // Show segment bar in timeline
this.curvesMode = 'keyframe' // 'segment' | 'keyframe' | 'curve'
this.curvesHeight = 150 // Height in pixels when curves are in curve view
}
static fromJSON(json, parent) {
let fillImage = undefined;
if (json.fillImage && Object.keys(json.fillImage).length !== 0) {
let img = new Image();
img.src = json.fillImage.src
fillImage = img
} else {
fillImage = {}
}
const shape = new Shape(
json.startx,
json.starty,
{
fillStyle: json.fillStyle,
fillImage: fillImage,
strokeStyle: json.strokeStyle,
lineWidth: json.lineWidth,
fillShape: json.filled,
strokeShape: json.stroked,
},
parent,
json.idx,
json.shapeId,
);
for (let curve of json.curves) {
shape.addCurve(Bezier.fromJSON(curve));
}
for (let region of json.regions) {
const curves = [];
for (let curve of region.curves) {
curves.push(Bezier.fromJSON(curve));
}
shape.regions.push({
idx: region.idx,
curves: curves,
fillStyle: region.fillStyle,
filled: region.filled,
});
}
// Load shapeIndex if present (for shape tweening)
if (json.shapeIndex !== undefined) {
shape.shapeIndex = json.shapeIndex;
}
return shape;
}
toJSON(randomizeUuid = false) {
const json = {};
json.type = "Shape";
json.startx = this.startx;
json.starty = this.starty;
json.fillStyle = this.fillStyle;
if (this.fillImage instanceof Element) {
json.fillImage = {
src: this.fillImage.src
}
}
json.strokeStyle = this.fillStyle;
json.lineWidth = this.lineWidth;
json.filled = this.filled;
json.stroked = this.stroked;
if (randomizeUuid) {
json.idx = uuidv4();
} else {
json.idx = this.idx;
}
json.shapeId = this.shapeId;
json.shapeIndex = this.shapeIndex; // For shape tweening
json.curves = [];
for (let curve of this.curves) {
json.curves.push(curve.toJSON(randomizeUuid));
}
json.regions = [];
for (let region of this.regions) {
const curves = [];
for (let curve of region.curves) {
curves.push(curve.toJSON(randomizeUuid));
}
json.regions.push({
idx: region.idx,
curves: curves,
fillStyle: region.fillStyle,
filled: region.filled,
});
}
return json;
}
get segmentColor() {
return uuidToColor(this.idx);
}
addCurve(curve) {
if (curve.color == undefined) {
curve.color = context.strokeStyle;
}
this.curves.push(curve);
this.quadtree.insert(curve, this.curves.length - 1);
growBoundingBox(this.boundingBox, curve.bbox());
}
addLine(x, y) {
let lastpoint;
if (this.curves.length) {
lastpoint = this.curves[this.curves.length - 1].points[3];
} else {
lastpoint = { x: this.startx, y: this.starty };
}
let midpoint = { x: (x + lastpoint.x) / 2, y: (y + lastpoint.y) / 2 };
let curve = new Bezier(
lastpoint.x,
lastpoint.y,
midpoint.x,
midpoint.y,
midpoint.x,
midpoint.y,
x,
y,
);
curve.color = context.strokeStyle;
this.quadtree.insert(curve, this.curves.length - 1);
this.curves.push(curve);
}
bbox() {
return this.boundingBox;
}
clear() {
this.curves = [];
this.quadtree.clear();
}
copy(idx) {
let newShape = new Shape(
this.startx,
this.starty,
{},
this.parent,
idx.slice(0, 8) + this.idx.slice(8),
this.shapeId,
);
newShape.startx = this.startx;
newShape.starty = this.starty;
for (let curve of this.curves) {
let newCurve = new Bezier(
curve.points[0].x,
curve.points[0].y,
curve.points[1].x,
curve.points[1].y,
curve.points[2].x,
curve.points[2].y,
curve.points[3].x,
curve.points[3].y,
);
newCurve.color = curve.color;
newShape.addCurve(newCurve);
}
// TODO
// for (let vertex of this.vertices) {
// }
newShape.updateVertices();
newShape.fillStyle = this.fillStyle;
if (this.fillImage instanceof Element) {
newShape.fillImage = this.fillImage.cloneNode(true)
} else {
newShape.fillImage = this.fillImage;
}
newShape.strokeStyle = this.strokeStyle;
newShape.lineWidth = this.lineWidth;
newShape.filled = this.filled;
newShape.stroked = this.stroked;
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;
// Mode can be corners, smooth or auto
if (mode == "corners") {
let points = [{ x: this.startx, y: this.starty }];
for (let curve of this.curves) {
points.push(curve.points[3]);
}
// points = points.concat(this.curves)
let newpoints = simplifyPolyline(points, 10, false);
this.curves = [];
let lastpoint = newpoints.shift();
let midpoint;
for (let point of newpoints) {
midpoint = {
x: (lastpoint.x + point.x) / 2,
y: (lastpoint.y + point.y) / 2,
};
let bezier = new Bezier(
lastpoint.x,
lastpoint.y,
midpoint.x,
midpoint.y,
midpoint.x,
midpoint.y,
point.x,
point.y,
);
this.curves.push(bezier);
this.quadtree.insert(bezier, this.curves.length - 1);
lastpoint = point;
}
} else if (mode == "smooth") {
let error = 30;
let points = [[this.startx, this.starty]];
for (let curve of this.curves) {
points.push([curve.points[3].x, curve.points[3].y]);
}
this.fromPoints(points, error);
} else if (mode == "verbatim") {
// Just keep existing shape
}
let epsilon = 0.01;
let newCurves = [];
let intersectMap = {};
for (let i = 0; i < this.curves.length - 1; i++) {
// for (let j=i+1; j<this.curves.length; j++) {
for (let j of this.quadtree.query(this.curves[i].bbox())) {
if (i >= j) continue;
let intersects = this.curves[i].intersects(this.curves[j]);
if (intersects.length) {
intersectMap[i] ||= [];
intersectMap[j] ||= [];
for (let intersect of intersects) {
let [t1, t2] = intersect.split("/");
intersectMap[i].push(parseFloat(t1));
intersectMap[j].push(parseFloat(t2));
}
}
}
}
for (let lst in intersectMap) {
for (let i = 1; i < intersectMap[lst].length; i++) {
if (
Math.abs(intersectMap[lst][i] - intersectMap[lst][i - 1]) < epsilon
) {
intersectMap[lst].splice(i, 1);
i--;
}
}
}
for (let i = this.curves.length - 1; i >= 0; i--) {
if (i in intersectMap) {
intersectMap[i].sort().reverse();
let remainingFraction = 1;
let remainingCurve = this.curves[i];
for (let t of intersectMap[i]) {
let split = remainingCurve.split(t / remainingFraction);
remainingFraction = t;
newCurves.push(split.right);
remainingCurve = split.left;
}
newCurves.push(remainingCurve);
} else {
newCurves.push(this.curves[i]);
}
}
for (let curve of newCurves) {
curve.color = context.strokeStyle;
}
newCurves.reverse();
this.curves = newCurves;
}
update() {
this.recalculateBoundingBox();
this.updateVertices();
if (this.curves.length) {
this.startx = this.curves[0].points[0].x;
this.starty = this.curves[0].points[0].y;
}
return [this];
}
getClockwiseCurves(point, otherPoints) {
// Returns array of {x, y, idx, angle}
let points = [];
for (let point of otherPoints) {
points.push({ ...this.vertices[point].point, idx: point });
}
// Add an angle property to each point using tan(angle) = y/x
const angles = points.map(({ x, y, idx }) => {
return {
x,
y,
idx,
angle: (Math.atan2(y - point.y, x - point.x) * 180) / Math.PI,
};
});
// Sort your points by angle
const pointsSorted = angles.sort((a, b) => a.angle - b.angle);
return pointsSorted;
}
translate(x, y) {
this.quadtree.clear()
let j=0;
for (let curve of this.curves) {
for (let i in curve.points) {
const point = curve.points[i];
curve.points[i] = { x: point.x + x, y: point.y + y };
}
this.quadtree.insert(curve, j)
j++;
}
this.update();
}
updateVertices() {
this.vertices = [];
let utils = Bezier.getUtils();
let epsilon = 1.5; // big epsilon whoa
let tooClose;
let i = 0;
let region = {
idx: `${this.idx}-r${this.regionIdx++}`,
curves: [],
fillStyle: context.fillStyle,
filled: context.fillShape,
};
pointerList[region.idx] = region;
this.regions = [region];
for (let curve of this.curves) {
this.regions[0].curves.push(curve);
}
if (this.regions[0].curves.length) {
if (
utils.dist(
this.regions[0].curves[0].points[0],
this.regions[0].curves[this.regions[0].curves.length - 1].points[3],
) < epsilon
) {
this.regions[0].filled = true;
}
}
// Generate vertices
for (let curve of this.curves) {
for (let index of [0, 3]) {
tooClose = false;
for (let vertex of this.vertices) {
if (utils.dist(curve.points[index], vertex.point) < epsilon) {
tooClose = true;
vertex[["startCurves", , , "endCurves"][index]][i] = curve;
break;
}
}
if (!tooClose) {
if (index == 0) {
this.vertices.push({
point: curve.points[index],
startCurves: { [i]: curve },
endCurves: {},
});
} else {
this.vertices.push({
point: curve.points[index],
startCurves: {},
endCurves: { [i]: curve },
});
}
}
}
i++;
}
let shapes = [this];
this.vertices.forEach((vertex, i) => {
for (let i = 0; i < Math.min(10, this.regions.length); i++) {
let region = this.regions[i];
let regionVertexCurves = [];
let vertexCurves = { ...vertex.startCurves, ...vertex.endCurves };
if (Object.keys(vertexCurves).length == 1) {
// endpoint
continue;
} else if (Object.keys(vertexCurves).length == 2) {
// path vertex, don't need to do anything
continue;
} else if (Object.keys(vertexCurves).length == 3) {
// T junction. Region doesn't change but might need to update curves?
// Skip for now.
continue;
} else if (Object.keys(vertexCurves).length == 4) {
// Intersection, split region in 2
for (let i in vertexCurves) {
let curve = vertexCurves[i];
if (region.curves.includes(curve)) {
regionVertexCurves.push(curve);
}
}
let start = region.curves.indexOf(regionVertexCurves[1]);
let end = region.curves.indexOf(regionVertexCurves[3]);
if (end > start) {
let newRegion = {
idx: `${this.idx}-r${this.regionIdx++}`, // TODO: generate this deterministically so that undo/redo works
curves: region.curves.splice(start, end - start),
fillStyle: region.fillStyle,
filled: true,
};
pointerList[newRegion.idx] = newRegion;
this.regions.push(newRegion);
}
} else {
// not sure how to handle vertices with more than 4 curves
console.log(
`Unexpected vertex with ${Object.keys(vertexCurves).length} curves!`,
);
}
}
});
}
}
export { BaseShape, TempShape, Shape };