cd514d72d6
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>
378 lines
9.2 KiB
TypeScript
378 lines
9.2 KiB
TypeScript
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;
|
|
}
|
|
}
|