feat(editor): LaserPointer based freedraw (#11507)
Introduces constant width freedraw mode, keeping the original variable mode as default. --------- Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from "./state";
|
||||
export type { Point } from "./math";
|
||||
@@ -0,0 +1,105 @@
|
||||
export type Point = [x: number, y: number, r: number];
|
||||
|
||||
export function add([ax, ay, ar]: Point, [bx, by, br]: Point): Point {
|
||||
return [ax + bx, ay + by, ar + br];
|
||||
}
|
||||
|
||||
export function sub([ax, ay, ar]: Point, [bx, by, br]: Point): Point {
|
||||
return [ax - bx, ay - by, ar - br];
|
||||
}
|
||||
|
||||
export function smul([x, y, r]: Point, s: number): Point {
|
||||
return [x * s, y * s, r * s];
|
||||
}
|
||||
|
||||
export function norm([x, y, r]: Point): Point {
|
||||
return [x / Math.sqrt(x ** 2 + y ** 2), y / Math.sqrt(x ** 2 + y ** 2), r];
|
||||
}
|
||||
|
||||
export function rot([x, y, r]: Point, rad: number): Point {
|
||||
return [
|
||||
Math.cos(rad) * x - Math.sin(rad) * y,
|
||||
Math.sin(rad) * x + Math.cos(rad) * y,
|
||||
r,
|
||||
];
|
||||
}
|
||||
|
||||
export function plerp(a: Point, b: Point, t: number): Point {
|
||||
return add(a, smul(sub(b, a), t));
|
||||
}
|
||||
|
||||
export function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
export function angle(p: Point, p1: Point, p2: Point) {
|
||||
return (
|
||||
Math.atan2(p2[1] - p[1], p2[0] - p[0]) -
|
||||
Math.atan2(p1[1] - p[1], p1[0] - p[0])
|
||||
);
|
||||
}
|
||||
|
||||
export function normAngle(a: number) {
|
||||
return Math.atan2(Math.sin(a), Math.cos(a));
|
||||
}
|
||||
|
||||
export function mag([x, y]: Point) {
|
||||
return Math.sqrt(x ** 2 + y ** 2);
|
||||
}
|
||||
|
||||
export function dist([ax, ay]: Point, [bx, by]: Point): number {
|
||||
return Math.sqrt((bx - ax) ** 2 + (by - ay) ** 2);
|
||||
}
|
||||
|
||||
export function getCircleAndPerpendicularLineIntersectionsAtPoint(
|
||||
point: Point,
|
||||
direction: Point,
|
||||
radius: number,
|
||||
): [Point, Point] {
|
||||
return [
|
||||
add(point, smul(norm(rot(direction, Math.PI / 2)), radius)),
|
||||
add(point, smul(norm(rot(direction, -Math.PI / 2)), radius)),
|
||||
];
|
||||
}
|
||||
|
||||
export function runLength(ps: Point[]): number {
|
||||
if (ps.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let len = 0;
|
||||
|
||||
for (let i = 1; i <= ps.length - 1; i++) {
|
||||
len += dist(ps[i - 1], ps[i]);
|
||||
}
|
||||
|
||||
len += dist(ps[ps.length - 2], ps[ps.length - 1]);
|
||||
|
||||
return len;
|
||||
}
|
||||
|
||||
export const clamp = (v: number, min: number, max: number) =>
|
||||
Math.max(min, Math.min(max, v));
|
||||
|
||||
export function distancePointToSegment(p3: Point, p1: Point, p2: Point) {
|
||||
const sMag = dist(p1, p2);
|
||||
|
||||
if (sMag === 0) {
|
||||
return dist(p3, p1);
|
||||
}
|
||||
|
||||
const u = clamp(
|
||||
((p3[0] - p1[0]) * (p2[0] - p1[0]) + (p3[1] - p1[1]) * (p2[1] - p1[1])) /
|
||||
sMag ** 2,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
|
||||
const pi: Point = [
|
||||
p1[0] + u * (p2[0] - p1[0]),
|
||||
p1[1] + u * (p2[1] - p1[1]),
|
||||
p3[2],
|
||||
];
|
||||
|
||||
return dist(pi, p3);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type Point, distancePointToSegment } from "./math";
|
||||
|
||||
export function douglasPeucker(points: Point[], epsilon: number): Point[] {
|
||||
if (epsilon === 0) {
|
||||
return points;
|
||||
}
|
||||
|
||||
if (points.length <= 2) {
|
||||
return points;
|
||||
}
|
||||
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
|
||||
const [maxDistance, maxIndex] = points.reduce(
|
||||
([maxDistance, maxIndex], point, index) => {
|
||||
const distance = distancePointToSegment(point, first, last);
|
||||
|
||||
return distance > maxDistance
|
||||
? [distance, index]
|
||||
: [maxDistance, maxIndex];
|
||||
},
|
||||
[0, -1],
|
||||
);
|
||||
|
||||
if (maxDistance >= epsilon) {
|
||||
const maxIndexPoint = points[maxIndex];
|
||||
|
||||
return [
|
||||
...douglasPeucker(
|
||||
[first, ...points.slice(1, maxIndex), maxIndexPoint],
|
||||
epsilon,
|
||||
).slice(0, -1),
|
||||
maxIndexPoint,
|
||||
...douglasPeucker(
|
||||
[maxIndexPoint, ...points.slice(maxIndex, -1), last],
|
||||
epsilon,
|
||||
).slice(1),
|
||||
];
|
||||
}
|
||||
return [first, last];
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import * as m from "./math";
|
||||
import { douglasPeucker } from "./simplify";
|
||||
|
||||
import type { Point } from "./math";
|
||||
|
||||
export type SizeMappingDetails = {
|
||||
pressure: number;
|
||||
runningLength: number;
|
||||
currentIndex: number;
|
||||
totalLength: number;
|
||||
};
|
||||
|
||||
export type LaserPointerOptions = {
|
||||
size: number;
|
||||
|
||||
streamline: number;
|
||||
simplify: number;
|
||||
simplifyPhase: "tail" | "output" | "input";
|
||||
|
||||
keepHead: boolean;
|
||||
|
||||
sizeMapping: (details: SizeMappingDetails) => number;
|
||||
};
|
||||
|
||||
export class LaserPointer {
|
||||
static defaults: LaserPointerOptions = {
|
||||
size: 2,
|
||||
streamline: 0.45,
|
||||
simplify: 0.1,
|
||||
simplifyPhase: "output",
|
||||
keepHead: false,
|
||||
|
||||
sizeMapping: () => 1,
|
||||
};
|
||||
|
||||
static constants = {
|
||||
cornerDetectionMaxAngle: 75,
|
||||
cornerDetectionVariance: (s: number) => (s > 35 ? 0.5 : 1),
|
||||
maxTailLength: 50,
|
||||
};
|
||||
|
||||
options: LaserPointerOptions;
|
||||
constructor(options: Partial<LaserPointerOptions>) {
|
||||
this.options = Object.assign({}, LaserPointer.defaults, options);
|
||||
}
|
||||
|
||||
originalPoints: Point[] = [];
|
||||
|
||||
private stablePoints: Point[] = [];
|
||||
private tailPoints: Point[] = [];
|
||||
|
||||
private isFresh = true;
|
||||
|
||||
private get lastPoint(): Point {
|
||||
return (
|
||||
this.tailPoints[this.tailPoints.length - 1] ??
|
||||
this.stablePoints[this.stablePoints.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
addPoint(point: Point) {
|
||||
const lastPoint = this.originalPoints[this.originalPoints.length - 1];
|
||||
|
||||
if (lastPoint && lastPoint[0] === point[0] && lastPoint[1] === point[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.originalPoints.push(point);
|
||||
|
||||
if (this.isFresh) {
|
||||
this.isFresh = false;
|
||||
this.stablePoints.push(point);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.streamline > 0) {
|
||||
point = m.plerp(this.lastPoint, point, 1 - this.options.streamline);
|
||||
}
|
||||
|
||||
this.tailPoints.push(point);
|
||||
|
||||
if (m.runLength(this.tailPoints) > LaserPointer.constants.maxTailLength) {
|
||||
this.stabilizeTail();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.stabilizeTail();
|
||||
}
|
||||
|
||||
stabilizeTail() {
|
||||
if (this.options.simplify > 0 && this.options.simplifyPhase === "tail") {
|
||||
throw new Error("Not implemented yet");
|
||||
} else {
|
||||
this.stablePoints.push(...this.tailPoints);
|
||||
this.tailPoints = [];
|
||||
}
|
||||
}
|
||||
|
||||
private getSize(
|
||||
sizeOverride: number | undefined,
|
||||
pressure: number,
|
||||
index: number,
|
||||
totalLength: number,
|
||||
runningLength: number,
|
||||
) {
|
||||
return (
|
||||
(sizeOverride ?? this.options.size) *
|
||||
this.options.sizeMapping({
|
||||
pressure,
|
||||
runningLength,
|
||||
currentIndex: index,
|
||||
totalLength,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getStrokeOutline(sizeOverride?: number | undefined): Point[] {
|
||||
if (this.isFresh) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let points = [...this.stablePoints, ...this.tailPoints];
|
||||
|
||||
if (this.options.simplify > 0 && this.options.simplifyPhase === "input") {
|
||||
points = douglasPeucker(points, this.options.simplify);
|
||||
}
|
||||
|
||||
const len = points.length;
|
||||
|
||||
if (len === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (len === 1) {
|
||||
const c = points[0];
|
||||
|
||||
const size = this.getSize(sizeOverride, c[2], 0, len, 0);
|
||||
|
||||
if (size < 0.5) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ps: Point[] = [];
|
||||
|
||||
for (let theta = 0; theta <= Math.PI * 2; theta += Math.PI / 16) {
|
||||
ps.push(m.add(c, m.smul(m.rot([1, 0, 0] as Point, theta), size)));
|
||||
}
|
||||
|
||||
ps.push(
|
||||
m.add(
|
||||
c,
|
||||
m.smul(
|
||||
[1, 0, 0] as Point,
|
||||
this.getSize(sizeOverride, c[2], 0, len, 0),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return ps;
|
||||
}
|
||||
|
||||
if (len === 2) {
|
||||
const c = points[0];
|
||||
const n = points[1];
|
||||
|
||||
const cSize = this.getSize(sizeOverride, c[2], 0, len, 0);
|
||||
const nSize = this.getSize(sizeOverride, n[2], 0, len, 0);
|
||||
|
||||
if (cSize < 0.5 || nSize < 0.5) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ps: Point[] = [];
|
||||
|
||||
const pAngle = m.angle(c, [c[0], c[1] - 100, c[2]] as Point, n);
|
||||
|
||||
for (
|
||||
let theta = pAngle;
|
||||
theta <= Math.PI + pAngle;
|
||||
theta += Math.PI / 16
|
||||
) {
|
||||
ps.push(m.add(c, m.smul(m.rot([1, 0, 0] as Point, theta), cSize)));
|
||||
}
|
||||
|
||||
for (
|
||||
let theta = Math.PI + pAngle;
|
||||
theta <= Math.PI * 2 + pAngle;
|
||||
theta += Math.PI / 16
|
||||
) {
|
||||
ps.push(m.add(n, m.smul(m.rot([1, 0, 0] as Point, theta), nSize)));
|
||||
}
|
||||
|
||||
ps.push(ps[0]);
|
||||
|
||||
return ps;
|
||||
}
|
||||
|
||||
const forwardPoints: Point[] = [];
|
||||
const backwardPoints: Point[] = [];
|
||||
|
||||
let speed = 0;
|
||||
let prevSpeed = 0;
|
||||
|
||||
let visibleStartIndex = 0;
|
||||
let runningLength = 0;
|
||||
|
||||
for (let i = 1; i < len - 1; i++) {
|
||||
const p = points[i - 1];
|
||||
const c = points[i];
|
||||
const n = points[i + 1];
|
||||
|
||||
const pressure = c[2];
|
||||
|
||||
const d = m.dist(p, c);
|
||||
runningLength += d;
|
||||
speed = prevSpeed + (d - prevSpeed) * 0.2;
|
||||
|
||||
const cSize = this.getSize(sizeOverride, pressure, i, len, runningLength);
|
||||
|
||||
if (cSize === 0) {
|
||||
visibleStartIndex = i + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const dirPC = m.norm(m.sub(p, c));
|
||||
const dirNC = m.norm(m.sub(n, c));
|
||||
const p1dirPC = m.rot(dirPC, Math.PI / 2);
|
||||
const p2dirPC = m.rot(dirPC, -Math.PI / 2);
|
||||
const p1dirNC = m.rot(dirNC, Math.PI / 2);
|
||||
const p2dirNC = m.rot(dirNC, -Math.PI / 2);
|
||||
|
||||
const p1PC = m.add(c, m.smul(p1dirPC, cSize));
|
||||
const p2PC = m.add(c, m.smul(p2dirPC, cSize));
|
||||
const p1NC = m.add(c, m.smul(p1dirNC, cSize));
|
||||
const p2NC = m.add(c, m.smul(p2dirNC, cSize));
|
||||
|
||||
const ftdir = m.add(p1dirPC, p2dirNC);
|
||||
const btdir = m.add(p2dirPC, p1dirNC);
|
||||
|
||||
const paPC = m.add(
|
||||
c,
|
||||
m.smul(m.mag(ftdir) === 0 ? dirPC : m.norm(ftdir), cSize),
|
||||
);
|
||||
const paNC = m.add(
|
||||
c,
|
||||
m.smul(m.mag(btdir) === 0 ? dirNC : m.norm(btdir), cSize),
|
||||
);
|
||||
|
||||
const cAngle = m.normAngle(m.angle(c, p, n));
|
||||
const D_ANGLE =
|
||||
(LaserPointer.constants.cornerDetectionMaxAngle / 180) *
|
||||
Math.PI *
|
||||
LaserPointer.constants.cornerDetectionVariance(speed);
|
||||
|
||||
if (Math.abs(cAngle) < D_ANGLE) {
|
||||
const tAngle = Math.abs(m.normAngle(Math.PI - cAngle)); // turn angle
|
||||
|
||||
if (tAngle === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cAngle < 0) {
|
||||
backwardPoints.push(p2PC, paNC);
|
||||
|
||||
for (let theta = 0; theta <= tAngle; theta += tAngle / 4) {
|
||||
forwardPoints.push(m.add(c, m.rot(m.smul(p1dirPC, cSize), theta)));
|
||||
}
|
||||
|
||||
for (let theta = tAngle; theta >= 0; theta -= tAngle / 4) {
|
||||
backwardPoints.push(m.add(c, m.rot(m.smul(p1dirPC, cSize), theta)));
|
||||
}
|
||||
|
||||
backwardPoints.push(paNC, p1NC);
|
||||
} else {
|
||||
forwardPoints.push(p1PC, paPC);
|
||||
|
||||
for (let theta = 0; theta <= tAngle; theta += tAngle / 4) {
|
||||
backwardPoints.push(
|
||||
m.add(c, m.rot(m.smul(p1dirPC, -cSize), -theta)),
|
||||
);
|
||||
}
|
||||
|
||||
for (let theta = tAngle; theta >= 0; theta -= tAngle / 4) {
|
||||
forwardPoints.push(
|
||||
m.add(c, m.rot(m.smul(p1dirPC, -cSize), -theta)),
|
||||
);
|
||||
}
|
||||
forwardPoints.push(paPC, p2NC);
|
||||
}
|
||||
} else {
|
||||
forwardPoints.push(paPC);
|
||||
backwardPoints.push(paNC);
|
||||
}
|
||||
|
||||
prevSpeed = speed;
|
||||
}
|
||||
|
||||
if (visibleStartIndex >= len - 2) {
|
||||
if (this.options.keepHead) {
|
||||
const c = points[len - 1];
|
||||
|
||||
const ps: Point[] = [];
|
||||
|
||||
for (let theta = 0; theta <= Math.PI * 2; theta += Math.PI / 16) {
|
||||
ps.push(
|
||||
m.add(
|
||||
c,
|
||||
m.smul(m.rot([1, 0, 0] as Point, theta), this.options.size),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ps.push(m.add(c, m.smul([1, 0, 0] as Point, this.options.size)));
|
||||
|
||||
return ps;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const first = points[visibleStartIndex];
|
||||
const second = points[visibleStartIndex + 1];
|
||||
const penultimate = points[len - 2];
|
||||
const ultimate = points[len - 1];
|
||||
|
||||
const dirFS = m.norm(m.sub(second, first));
|
||||
const dirPU = m.norm(m.sub(penultimate, ultimate));
|
||||
|
||||
const ppdirFS = m.rot(dirFS, -Math.PI / 2);
|
||||
const ppdirPU = m.rot(dirPU, Math.PI / 2);
|
||||
|
||||
const startCapSize = this.getSize(sizeOverride, first[2], 0, len, 0);
|
||||
const startCap: Point[] = [];
|
||||
|
||||
const endCapSize = this.options.keepHead
|
||||
? this.options.size
|
||||
: this.getSize(sizeOverride, penultimate[2], len - 2, len, runningLength);
|
||||
|
||||
const endCap: Point[] = [];
|
||||
|
||||
// Lowered threshold to 0.1,
|
||||
// ensuring virtually all strokes get proper rounded caps for visual consistency.
|
||||
if (startCapSize > 0.1) {
|
||||
for (let theta = 0; theta <= Math.PI; theta += Math.PI / 16) {
|
||||
startCap.unshift(
|
||||
m.add(first, m.rot(m.smul(ppdirFS, startCapSize), -theta)),
|
||||
);
|
||||
}
|
||||
|
||||
startCap.unshift(m.add(first, m.smul(ppdirFS, -startCapSize)));
|
||||
} else {
|
||||
startCap.push(first);
|
||||
}
|
||||
|
||||
for (let theta = 0; theta <= Math.PI * 3; theta += Math.PI / 16) {
|
||||
endCap.push(m.add(ultimate, m.rot(m.smul(ppdirPU, -endCapSize), -theta)));
|
||||
}
|
||||
|
||||
const strokeOutline = [
|
||||
...startCap,
|
||||
...forwardPoints,
|
||||
...endCap.reverse(),
|
||||
...backwardPoints.reverse(),
|
||||
];
|
||||
|
||||
if (startCap.length > 0) {
|
||||
strokeOutline.push(startCap[0]);
|
||||
}
|
||||
|
||||
if (this.options.simplify > 0 && this.options.simplifyPhase === "output") {
|
||||
return douglasPeucker(strokeOutline, this.options.simplify);
|
||||
}
|
||||
|
||||
return strokeOutline;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user