refactor(editor): add gfx entry in bs global package (#10612)

This commit is contained in:
Saul-Mirone
2025-03-04 12:46:50 +00:00
parent 5ad3d3c94a
commit 66d9d576e0
216 changed files with 341 additions and 397 deletions

View File

@@ -1,175 +0,0 @@
import { Bound, getIBoundFromPoints, type IBound } from './model/bound.js';
import { type IVec } from './model/vec.js';
function getExpandedBound(a: IBound, b: IBound): IBound {
const minX = Math.min(a.x, b.x);
const minY = Math.min(a.y, b.y);
const maxX = Math.max(a.x + a.w, b.x + b.w);
const maxY = Math.max(a.y + a.h, b.y + b.h);
const width = Math.abs(maxX - minX);
const height = Math.abs(maxY - minY);
return {
x: minX,
y: minY,
w: width,
h: height,
};
}
export function getPointsFromBoundWithRotation(
bounds: IBound,
getPoints: (bounds: IBound) => IVec[] = ({ x, y, w, h }: IBound) => [
// left-top
[x, y],
// right-top
[x + w, y],
// right-bottom
[x + w, y + h],
// left-bottom
[x, y + h],
],
resPadding: [number, number] = [0, 0]
): IVec[] {
const { rotate } = bounds;
let points = getPoints({
x: bounds.x - resPadding[1],
y: bounds.y - resPadding[0],
w: bounds.w + resPadding[1] * 2,
h: bounds.h + resPadding[0] * 2,
});
if (rotate) {
const { x, y, w, h } = bounds;
const cx = x + w / 2;
const cy = y + h / 2;
const m = new DOMMatrix()
.translateSelf(cx, cy)
.rotateSelf(rotate)
.translateSelf(-cx, -cy);
points = points.map(point => {
const { x, y } = new DOMPoint(...point).matrixTransform(m);
return [x, y];
});
}
return points;
}
export function getQuadBoundWithRotation(bounds: IBound): DOMRect {
const { x, y, w, h, rotate } = bounds;
const rect = new DOMRect(x, y, w, h);
if (!rotate) return rect;
return new DOMQuad(
...getPointsFromBoundWithRotation(bounds).map(
point => new DOMPoint(...point)
)
).getBounds();
}
export function getBoundWithRotation(bound: IBound): IBound {
const { x, y, width: w, height: h } = getQuadBoundWithRotation(bound);
return { x, y, w, h };
}
/**
* Returns the common bound of the given bounds.
* The rotation of the bounds is not considered.
* @param bounds
* @returns
*/
export function getCommonBound(bounds: IBound[]): Bound | null {
if (!bounds.length) {
return null;
}
if (bounds.length === 1) {
const { x, y, w, h } = bounds[0];
return new Bound(x, y, w, h);
}
let result = bounds[0];
for (let i = 1; i < bounds.length; i++) {
result = getExpandedBound(result, bounds[i]);
}
return new Bound(result.x, result.y, result.w, result.h);
}
/**
* Like `getCommonBound`, but considers the rotation of the bounds.
* @returns
*/
export function getCommonBoundWithRotation(bounds: IBound[]): Bound {
if (bounds.length === 0) {
return new Bound(0, 0, 0, 0);
}
return bounds.reduce(
(pre, bound) => {
return pre.unite(
bound instanceof Bound ? bound : Bound.from(getBoundWithRotation(bound))
);
},
Bound.from(getBoundWithRotation(bounds[0]))
);
}
export function getBoundFromPoints(points: IVec[]) {
return Bound.from(getIBoundFromPoints(points));
}
export function inflateBound(bound: IBound, delta: number) {
const half = delta / 2;
const newBound = new Bound(
bound.x - half,
bound.y - half,
bound.w + delta,
bound.h + delta
);
if (newBound.w <= 0 || newBound.h <= 0) {
throw new Error('Invalid delta range or bound size.');
}
return newBound;
}
export function transformPointsToNewBound<T extends { x: number; y: number }>(
points: T[],
oldBound: IBound,
oldMargin: number,
newBound: IBound,
newMargin: number
) {
const wholeOldMargin = oldMargin * 2;
const wholeNewMargin = newMargin * 2;
const oldW = Math.max(oldBound.w - wholeOldMargin, 1);
const oldH = Math.max(oldBound.h - wholeOldMargin, 1);
const newW = Math.max(newBound.w - wholeNewMargin, 1);
const newH = Math.max(newBound.h - wholeNewMargin, 1);
const transformedPoints = points.map(p => {
return {
...p,
x: newW * ((p.x - oldMargin) / oldW) + newMargin,
y: newH * ((p.y - oldMargin) / oldH) + newMargin,
} as T;
});
return {
points: transformedPoints,
bound: new Bound(
newBound.x,
newBound.y,
newW + wholeNewMargin,
newH + wholeNewMargin
),
};
}

View File

@@ -1,404 +0,0 @@
// control coords are not relative to start or end
import { assertExists } from './assert.js';
import { CURVETIME_EPSILON, isZero } from './math.js';
import { Bound, type IVec, PointLocation, Vec } from './model/index.js';
export type BezierCurveParameters = [
start: IVec,
control1: IVec,
control2: IVec,
end: IVec,
];
function evaluate(
v: BezierCurveParameters,
t: number,
type: number,
normalized: boolean
): IVec | null {
if (t == null || t < 0 || t > 1) return null;
const x0 = v[0][0],
y0 = v[0][1],
x3 = v[3][0],
y3 = v[3][1];
let x1 = v[1][0],
y1 = v[1][1],
x2 = v[2][0],
y2 = v[2][1];
if (isZero(x1 - x0) && isZero(y1 - y0)) {
x1 = x0;
y1 = y0;
}
if (isZero(x2 - x3) && isZero(y2 - y3)) {
x2 = x3;
y2 = y3;
}
// Calculate the polynomial coefficients.
const cx = 3 * (x1 - x0),
bx = 3 * (x2 - x1) - cx,
ax = x3 - x0 - cx - bx,
cy = 3 * (y1 - y0),
by = 3 * (y2 - y1) - cy,
ay = y3 - y0 - cy - by;
let x, y;
if (type === 0) {
// type === 0: getPoint()
x = t === 0 ? x0 : t === 1 ? x3 : ((ax * t + bx) * t + cx) * t + x0;
y = t === 0 ? y0 : t === 1 ? y3 : ((ay * t + by) * t + cy) * t + y0;
} else {
// type === 1: getTangent()
// type === 2: getNormal()
// type === 3: getCurvature()
const tMin = CURVETIME_EPSILON,
tMax = 1 - tMin;
if (t < tMin) {
x = cx;
y = cy;
} else if (t > tMax) {
x = 3 * (x3 - x2);
y = 3 * (y3 - y2);
} else {
x = (3 * ax * t + 2 * bx) * t + cx;
y = (3 * ay * t + 2 * by) * t + cy;
}
if (normalized) {
if (x === 0 && y === 0 && (t < tMin || t > tMax)) {
x = x2 - x1;
y = y2 - y1;
}
const len = Math.sqrt(x * x + y * y);
if (len) {
x /= len;
y /= len;
}
}
if (type === 3) {
const x2 = 6 * ax * t + 2 * bx,
y2 = 6 * ay * t + 2 * by,
d = Math.pow(x * x + y * y, 3 / 2);
x = d !== 0 ? (x * y2 - y * x2) / d : 0;
y = 0;
}
}
return type === 2 ? [y, -x] : [x, y];
}
export function getBezierPoint(values: BezierCurveParameters, t: number) {
return evaluate(values, t, 0, false);
}
export function getBezierTangent(values: BezierCurveParameters, t: number) {
return evaluate(values, t, 1, true);
}
export function getBezierNormal(values: BezierCurveParameters, t: number) {
return evaluate(values, t, 2, true);
}
export function getBezierCurvature(values: BezierCurveParameters, t: number) {
return evaluate(values, t, 3, false)?.[0];
}
export function getBezierNearestTime(
values: BezierCurveParameters,
point: IVec
) {
const count = 100;
let minDist = Infinity,
minT = 0;
function refine(t: number) {
if (t >= 0 && t <= 1) {
const tmpPoint = getBezierPoint(values, t);
assertExists(tmpPoint);
const dist = Vec.dist2(point, tmpPoint);
if (dist < minDist) {
minDist = dist;
minT = t;
return true;
}
}
return false;
}
for (let i = 0; i <= count; i++) refine(i / count);
let step = 1 / (count * 2);
while (step > CURVETIME_EPSILON) {
if (!refine(minT - step) && !refine(minT + step)) step /= 2;
}
return minT;
}
export function getBezierNearestPoint(
values: BezierCurveParameters,
point: IVec
) {
const t = getBezierNearestTime(values, point);
const pointOnCurve = getBezierPoint(values, t);
assertExists(pointOnCurve);
return pointOnCurve;
}
export function getBezierParameters(
points: PointLocation[]
): BezierCurveParameters {
// Fallback for degenerate Bezier curve (all points are at the same position)
if (points.length === 1) {
const point = points[0];
return [point, point, point, point];
}
return [points[0], points[0].absOut, points[1].absIn, points[1]];
}
// https://stackoverflow.com/questions/2587751/an-algorithm-to-find-bounding-box-of-closed-bezier-curves
export function getBezierCurveBoundingBox(values: BezierCurveParameters) {
const [start, controlPoint1, controlPoint2, end] = values;
const [x0, y0] = start;
const [x1, y1] = controlPoint1;
const [x2, y2] = controlPoint2;
const [x3, y3] = end;
const points = []; // local extremes
const tvalues = []; // t values of local extremes
const bounds: [number[], number[]] = [[], []];
let a;
let b;
let c;
let t;
let t1;
let t2;
let b2ac;
let sqrtb2ac;
for (let i = 0; i < 2; i += 1) {
if (i === 0) {
b = 6 * x0 - 12 * x1 + 6 * x2;
a = -3 * x0 + 9 * x1 - 9 * x2 + 3 * x3;
c = 3 * x1 - 3 * x0;
} else {
b = 6 * y0 - 12 * y1 + 6 * y2;
a = -3 * y0 + 9 * y1 - 9 * y2 + 3 * y3;
c = 3 * y1 - 3 * y0;
}
if (Math.abs(a) < 1e-12) {
if (Math.abs(b) < 1e-12) {
continue;
}
t = -c / b;
if (t > 0 && t < 1) tvalues.push(t);
continue;
}
b2ac = b * b - 4 * c * a;
sqrtb2ac = Math.sqrt(b2ac);
if (b2ac < 0) continue;
t1 = (-b + sqrtb2ac) / (2 * a);
if (t1 > 0 && t1 < 1) tvalues.push(t1);
t2 = (-b - sqrtb2ac) / (2 * a);
if (t2 > 0 && t2 < 1) tvalues.push(t2);
}
let x;
let y;
let mt;
let j = tvalues.length;
const jlen = j;
while (j) {
j -= 1;
t = tvalues[j];
mt = 1 - t;
x =
mt * mt * mt * x0 +
3 * mt * mt * t * x1 +
3 * mt * t * t * x2 +
t * t * t * x3;
bounds[0][j] = x;
y =
mt * mt * mt * y0 +
3 * mt * mt * t * y1 +
3 * mt * t * t * y2 +
t * t * t * y3;
bounds[1][j] = y;
points[j] = { X: x, Y: y };
}
tvalues[jlen] = 0;
tvalues[jlen + 1] = 1;
points[jlen] = { X: x0, Y: y0 };
points[jlen + 1] = { X: x3, Y: y3 };
bounds[0][jlen] = x0;
bounds[1][jlen] = y0;
bounds[0][jlen + 1] = x3;
bounds[1][jlen + 1] = y3;
tvalues.length = jlen + 2;
bounds[0].length = jlen + 2;
bounds[1].length = jlen + 2;
points.length = jlen + 2;
const left = Math.min.apply(null, bounds[0]);
const top = Math.min.apply(null, bounds[1]);
const right = Math.max.apply(null, bounds[0]);
const bottom = Math.max.apply(null, bounds[1]);
return new Bound(left, top, right - left, bottom - top);
}
// https://pomax.github.io/bezierjs/#intersect-line
// MIT Licence
// cube root function yielding real roots
function crt(v: number) {
return v < 0 ? -Math.pow(-v, 1 / 3) : Math.pow(v, 1 / 3);
}
function align(points: BezierCurveParameters, [start, end]: IVec[]) {
const tx = start[0],
ty = start[1],
a = -Math.atan2(end[1] - ty, end[0] - tx),
d = function ([x, y]: IVec) {
return [
(x - tx) * Math.cos(a) - (y - ty) * Math.sin(a),
(x - tx) * Math.sin(a) + (y - ty) * Math.cos(a),
];
};
return points.map(d);
}
function between(v: number, min: number, max: number) {
return (
(min <= v && v <= max) || approximately(v, min) || approximately(v, max)
);
}
function approximately(
a: number,
b: number,
precision?: number,
epsilon = 0.000001
) {
return Math.abs(a - b) <= (precision || epsilon);
}
function roots(points: BezierCurveParameters, line: IVec[]) {
const order = points.length - 1;
const aligned = align(points, line);
const reduce = function (t: number) {
return 0 <= t && t <= 1;
};
if (order === 2) {
const a = aligned[0][1],
b = aligned[1][1],
c = aligned[2][1],
d = a - 2 * b + c;
if (d !== 0) {
const m1 = -Math.sqrt(b * b - a * c),
m2 = -a + b,
v1 = -(m1 + m2) / d,
v2 = -(-m1 + m2) / d;
return [v1, v2].filter(reduce);
} else if (b !== c && d === 0) {
return [(2 * b - c) / (2 * b - 2 * c)].filter(reduce);
}
return [];
}
// see http://www.trans4mind.com/personal_development/mathematics/polynomials/cubicAlgebra.htm
const pa = aligned[0][1],
pb = aligned[1][1],
pc = aligned[2][1],
pd = aligned[3][1];
const d = -pa + 3 * pb - 3 * pc + pd;
let a = 3 * pa - 6 * pb + 3 * pc,
b = -3 * pa + 3 * pb,
c = pa;
if (approximately(d, 0)) {
// this is not a cubic curve.
if (approximately(a, 0)) {
// in fact, this is not a quadratic curve either.
if (approximately(b, 0)) {
// in fact in fact, there are no solutions.
return [];
}
// linear solution:
return [-c / b].filter(reduce);
}
// quadratic solution:
const q = Math.sqrt(b * b - 4 * a * c),
a2 = 2 * a;
return [(q - b) / a2, (-b - q) / a2].filter(reduce);
}
// at this point, we know we need a cubic solution:
a /= d;
b /= d;
c /= d;
const p = (3 * b - a * a) / 3,
p3 = p / 3,
q = (2 * a * a * a - 9 * a * b + 27 * c) / 27,
q2 = q / 2,
discriminant = q2 * q2 + p3 * p3 * p3;
let u1, v1, x1, x2, x3;
if (discriminant < 0) {
const mp3 = -p / 3,
mp33 = mp3 * mp3 * mp3,
r = Math.sqrt(mp33),
t = -q / (2 * r),
cosphi = t < -1 ? -1 : t > 1 ? 1 : t,
phi = Math.acos(cosphi),
crtr = crt(r),
t1 = 2 * crtr;
x1 = t1 * Math.cos(phi / 3) - a / 3;
x2 = t1 * Math.cos((phi + Math.PI * 2) / 3) - a / 3;
x3 = t1 * Math.cos((phi + 2 * Math.PI * 2) / 3) - a / 3;
return [x1, x2, x3].filter(reduce);
} else if (discriminant === 0) {
u1 = q2 < 0 ? crt(-q2) : -crt(q2);
x1 = 2 * u1 - a / 3;
x2 = -u1 - a / 3;
return [x1, x2].filter(reduce);
} else {
const sd = Math.sqrt(discriminant);
u1 = crt(-q2 + sd);
v1 = crt(q2 + sd);
return [u1 - v1 - a / 3].filter(reduce);
}
}
export function curveIntersects(path: PointLocation[], line: [IVec, IVec]) {
const { minX, maxX, minY, maxY } = Bound.fromPoints(line);
const points = getBezierParameters(path);
const intersectedPoints = roots(points, line)
.map(t => getBezierPoint(points, t))
.filter(point =>
point
? between(point[0], minX, maxX) && between(point[1], minY, maxY)
: false
)
.map(point => new PointLocation(point!));
return intersectedPoints.length > 0 ? intersectedPoints : null;
}

View File

@@ -1,18 +1,10 @@
export * from './assert.js';
export * from './bound.js';
export * from './crypto.js';
export * from './curve.js';
export * from './disposable.js';
export * from './function.js';
export * from './iterable.js';
export * from './logger.js';
export * from './math.js';
export * from './model/index.js';
export * from './perfect-freehand/index.js';
export * from './polyline.js';
export * from './signal-watcher.js';
export * from './slot.js';
export * from './types.js';
export * from './with-disposable.js';
export type { SerializedXYWH, XYWH } from './xywh.js';
export { deserializeXYWH, serializeXYWH } from './xywh.js';

View File

@@ -1,538 +0,0 @@
import type { Bound, IBound } from './model/bound.js';
import { PointLocation } from './model/point-location.js';
import { type IVec, Vec } from './model/vec.js';
export const EPSILON = 1e-12;
export const MACHINE_EPSILON = 1.12e-16;
export const PI2 = Math.PI * 2;
export const CURVETIME_EPSILON = 1e-8;
export function randomSeed(): number {
return Math.floor(Math.random() * 2 ** 31);
}
export function lineIntersects(
sp: IVec,
ep: IVec,
sp2: IVec,
ep2: IVec,
infinite = false
): IVec | null {
const v1 = Vec.sub(ep, sp);
const v2 = Vec.sub(ep2, sp2);
const cross = Vec.cpr(v1, v2);
// Avoid divisions by 0, and errors when getting too close to 0
if (almostEqual(cross, 0, MACHINE_EPSILON)) return null;
const d = Vec.sub(sp, sp2);
let u1 = Vec.cpr(v2, d) / cross;
const u2 = Vec.cpr(v1, d) / cross,
// Check the ranges of the u parameters if the line is not
// allowed to extend beyond the definition points, but
// compare with EPSILON tolerance over the [0, 1] bounds.
epsilon = /*#=*/ EPSILON,
uMin = -epsilon,
uMax = 1 + epsilon;
if (infinite || (uMin < u1 && u1 < uMax && uMin < u2 && u2 < uMax)) {
// Address the tolerance at the bounds by clipping to
// the actual range.
if (!infinite) {
u1 = clamp(u1, 0, 1);
}
return Vec.lrp(sp, ep, u1);
}
return null;
}
export function polygonNearestPoint(points: IVec[], point: IVec) {
const len = points.length;
let rst: IVec;
let dis = Infinity;
for (let i = 0; i < len; i++) {
const p = points[i];
const p2 = points[(i + 1) % len];
const temp = Vec.nearestPointOnLineSegment(p, p2, point, true);
const curDis = Vec.dist(temp, point);
if (curDis < dis) {
dis = curDis;
rst = temp;
}
}
return rst!;
}
export function polygonPointDistance(points: IVec[], point: IVec) {
const nearest = polygonNearestPoint(points, point);
return Vec.dist(nearest, point);
}
export function rotatePoints<T extends IVec>(
points: T[],
center: IVec,
rotate: number
): T[] {
const rad = toRadian(rotate);
return points.map(p => Vec.rotWith(p, center, rad)) as T[];
}
export function rotatePoint(
point: [number, number],
center: IVec,
rotate: number
): [number, number] {
const rad = toRadian(rotate);
return Vec.add(center, Vec.rot(Vec.sub(point, center), rad)) as [
number,
number,
];
}
export function toRadian(angle: number) {
return (angle * Math.PI) / 180;
}
export function isPointOnLineSegment(point: IVec, line: IVec[]) {
const [sp, ep] = line;
const v1 = Vec.sub(point, sp);
const v2 = Vec.sub(point, ep);
return almostEqual(Vec.cpr(v1, v2), 0, 0.01) && Vec.dpr(v1, v2) <= 0;
}
export function polygonGetPointTangent(points: IVec[], point: IVec): IVec {
const len = points.length;
for (let i = 0; i < len; i++) {
const p = points[i];
const p2 = points[(i + 1) % len];
if (isPointOnLineSegment(point, [p, p2])) {
return Vec.normalize(Vec.sub(p2, p));
}
}
return [0, 0];
}
export function linePolygonIntersects(
sp: IVec,
ep: IVec,
points: IVec[]
): PointLocation[] | null {
const result: PointLocation[] = [];
const len = points.length;
for (let i = 0; i < len; i++) {
const p = points[i];
const p2 = points[(i + 1) % len];
const rst = lineIntersects(sp, ep, p, p2);
if (rst) {
const v = new PointLocation(rst);
v.tangent = Vec.normalize(Vec.sub(p2, p));
result.push(v);
}
}
return result.length ? result : null;
}
export function linePolylineIntersects(
sp: IVec,
ep: IVec,
points: IVec[]
): PointLocation[] | null {
const result: PointLocation[] = [];
const len = points.length;
for (let i = 0; i < len - 1; i++) {
const p = points[i];
const p2 = points[i + 1];
const rst = lineIntersects(sp, ep, p, p2);
if (rst) {
result.push(new PointLocation(rst, Vec.normalize(Vec.sub(p2, p))));
}
}
return result.length ? result : null;
}
export function polyLineNearestPoint(points: IVec[], point: IVec) {
const len = points.length;
let rst: IVec;
let dis = Infinity;
for (let i = 0; i < len - 1; i++) {
const p = points[i];
const p2 = points[i + 1];
const temp = Vec.nearestPointOnLineSegment(p, p2, point, true);
const curDis = Vec.dist(temp, point);
if (curDis < dis) {
dis = curDis;
rst = temp;
}
}
return rst!;
}
export function isPointOnlines(
element: Bound,
points: readonly [number, number][],
rotate: number,
hitPoint: [number, number],
threshold: number
): boolean {
// credit to Excalidraw hitTestFreeDrawElement
let x: number;
let y: number;
if (rotate === 0) {
x = hitPoint[0] - element.x;
y = hitPoint[1] - element.y;
} else {
// Counter-rotate the point around center before testing
const { minX, minY, maxX, maxY } = element;
const rotatedPoint = rotatePoint(
hitPoint,
[minX + (maxX - minX) / 2, minY + (maxY - minY) / 2],
-rotate
) as [number, number];
x = rotatedPoint[0] - element.x;
y = rotatedPoint[1] - element.y;
}
let [A, B] = points;
let P: readonly [number, number];
// For freedraw dots
if (
distance2d(A[0], A[1], x, y) < threshold ||
distance2d(B[0], B[1], x, y) < threshold
) {
return true;
}
// For freedraw lines
for (let i = 0; i < points.length; i++) {
const delta = [B[0] - A[0], B[1] - A[1]];
const length = Math.hypot(delta[1], delta[0]);
const U = [delta[0] / length, delta[1] / length];
const C = [x - A[0], y - A[1]];
const d = (C[0] * U[0] + C[1] * U[1]) / Math.hypot(U[1], U[0]);
P = [A[0] + U[0] * d, A[1] + U[1] * d];
const da = distance2d(P[0], P[1], A[0], A[1]);
const db = distance2d(P[0], P[1], B[0], B[1]);
P = db < da && da > length ? B : da < db && db > length ? A : P;
if (Math.hypot(y - P[1], x - P[0]) < threshold) {
return true;
}
A = B;
B = points[i + 1];
}
return false;
}
export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
const xd = x2 - x1;
const yd = y2 - y1;
return Math.hypot(xd, yd);
};
function square(num: number) {
return num * num;
}
function sumSqr(v: IVec, w: IVec) {
return square(v[0] - w[0]) + square(v[1] - w[1]);
}
function distToSegmentSquared(p: IVec, v: IVec, w: IVec) {
const l2 = sumSqr(v, w);
if (l2 == 0) return sumSqr(p, v);
let t = ((p[0] - v[0]) * (w[0] - v[0]) + (p[1] - v[1]) * (w[1] - v[1])) / l2;
t = Math.max(0, Math.min(1, t));
return sumSqr(p, [v[0] + t * (w[0] - v[0]), v[1] + t * (w[1] - v[1])]);
}
function distToSegment(p: IVec, v: IVec, w: IVec) {
return Math.sqrt(distToSegmentSquared(p, v, w));
}
export function isPointIn(a: IBound, x: number, y: number): boolean {
return a.x <= x && x <= a.x + a.w && a.y <= y && y <= a.y + a.h;
}
export function intersects(a: IBound, b: IBound): boolean {
return (
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
);
}
export function almostEqual(a: number, b: number, epsilon = 0.0001) {
return Math.abs(a - b) < epsilon;
}
export function isVecZero(v: IVec) {
return v.every(n => isZero(n));
}
export function isZero(x: number) {
return x >= -EPSILON && x <= EPSILON;
}
export function pointAlmostEqual(a: IVec, b: IVec, _epsilon = 0.0001) {
return a.length === b.length && a.every((v, i) => almostEqual(v, b[i]));
}
export function clamp(n: number, min: number, max?: number): number {
return Math.max(min, max !== undefined ? Math.min(n, max) : n);
}
export function pointInEllipse(
A: IVec,
C: IVec,
rx: number,
ry: number,
rotation = 0
): boolean {
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const delta = Vec.sub(A, C);
const tdx = cos * delta[0] + sin * delta[1];
const tdy = sin * delta[0] - cos * delta[1];
return (tdx * tdx) / (rx * rx) + (tdy * tdy) / (ry * ry) <= 1;
}
export function pointInPolygon(p: IVec, points: IVec[]): boolean {
let wn = 0; // winding number
points.forEach((a, i) => {
const b = points[(i + 1) % points.length];
if (a[1] <= p[1]) {
if (b[1] > p[1] && Vec.cross(a, b, p) > 0) {
wn += 1;
}
} else if (b[1] <= p[1] && Vec.cross(a, b, p) < 0) {
wn -= 1;
}
});
return wn !== 0;
}
export function pointOnEllipse(
point: IVec,
rx: number,
ry: number,
threshold: number
): boolean {
// slope of point
const t = point[1] / point[0];
const squaredX =
(square(rx) * square(ry)) / (square(rx) * square(t) + square(ry));
const squaredY =
(square(rx) * square(ry) - square(ry) * squaredX) / square(rx);
return (
Math.abs(
Math.sqrt(square(point[1]) + square(point[0])) -
Math.sqrt(squaredX + squaredY)
) < threshold
);
}
export function pointOnPolygonStoke(
p: IVec,
points: IVec[],
threshold: number
): boolean {
for (let i = 0; i < points.length; ++i) {
const next = i + 1 === points.length ? 0 : i + 1;
if (distToSegment(p, points[i], points[next]) <= threshold) {
return true;
}
}
return false;
}
export function getPolygonPathFromPoints(
points: IVec[],
closed = true
): string {
const len = points.length;
if (len < 2) return ``;
const a = points[0];
const b = points[1];
let res = `M${a[0].toFixed(2)},${a[1].toFixed()}L${b[0].toFixed(2)},${b[1].toFixed()}`;
for (let i = 2; i < len; i++) {
const a = points[i];
res += `L${a[0].toFixed(2)},${a[1].toFixed()}`;
}
if (closed) res += 'Z';
return res;
}
export function getSvgPathFromStroke(points: IVec[], closed = true): string {
const len = points.length;
if (len < 4) {
return ``;
}
let a = points[0];
let b = points[1];
const c = points[2];
let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed(
2
)},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average(
b[1],
c[1]
).toFixed(2)} T`;
for (let i = 2, max = len - 1; i < max; i++) {
a = points[i];
b = points[i + 1];
result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed(
2
)} `;
}
if (closed) {
result += 'Z';
}
return result;
}
function average(a: number, b: number): number {
return (a + b) / 2;
}
//reference https://www.xarg.org/book/computer-graphics/line-segment-ellipse-intersection/
export function lineEllipseIntersects(
A: IVec,
B: IVec,
C: IVec,
rx: number,
ry: number,
rad = 0
) {
A = Vec.rot(Vec.sub(A, C), -rad);
B = Vec.rot(Vec.sub(B, C), -rad);
rx *= rx;
ry *= ry;
const rst: IVec[] = [];
const v = Vec.sub(B, A);
const a = rx * v[1] * v[1] + ry * v[0] * v[0];
const b = 2 * (rx * A[1] * v[1] + ry * A[0] * v[0]);
const c = rx * A[1] * A[1] + ry * A[0] * A[0] - rx * ry;
const D = b * b - 4 * a * c; // Discriminant
if (D >= 0) {
const sqrtD = Math.sqrt(D);
const t1 = (-b + sqrtD) / (2 * a);
const t2 = (-b - sqrtD) / (2 * a);
if (0 <= t1 && t1 <= 1)
rst.push(Vec.add(Vec.rot(Vec.add(Vec.mul(v, t1), A), rad), C));
if (0 <= t2 && t2 <= 1 && Math.abs(t1 - t2) > 1e-16)
rst.push(Vec.add(Vec.rot(Vec.add(Vec.mul(v, t2), A), rad), C));
}
if (rst.length === 0) return null;
return rst.map(v => {
const pl = new PointLocation(v);
const normalVector = Vec.uni(Vec.divV(Vec.sub(v, C), [rx * rx, ry * ry]));
pl.tangent = [-normalVector[1], normalVector[0]];
return pl;
});
}
export function sign(number: number) {
return number > 0 ? 1 : -1;
}
export function getPointFromBoundsWithRotation(
bounds: IBound,
point: IVec
): IVec {
const { x, y, w, h, rotate } = bounds;
if (!rotate) return point;
const cx = x + w / 2;
const cy = y + h / 2;
const m = new DOMMatrix()
.translateSelf(cx, cy)
.rotateSelf(rotate)
.translateSelf(-cx, -cy);
const p = new DOMPoint(...point).matrixTransform(m);
return [p.x, p.y];
}
export function normalizeDegAngle(angle: number) {
if (angle < 0) angle += 360;
angle %= 360;
return angle;
}
export function toDegree(radian: number) {
return (radian * 180) / Math.PI;
}
// 0 means x axis, 1 means y axis
export function isOverlap(
line1: IVec[],
line2: IVec[],
axis: 0 | 1,
strict = true
) {
const less = strict
? (a: number, b: number) => a < b
: (a: number, b: number) => a <= b;
return !(
less(
Math.max(line1[0][axis], line1[1][axis]),
Math.min(line2[0][axis], line2[1][axis])
) ||
less(
Math.max(line2[0][axis], line2[1][axis]),
Math.min(line1[0][axis], line1[1][axis])
)
);
}
export function getCenterAreaBounds(bounds: IBound, ratio: number) {
const { x, y, w, h, rotate } = bounds;
const cx = x + w / 2;
const cy = y + h / 2;
const nw = w * ratio;
const nh = h * ratio;
return {
x: cx - nw / 2,
y: cy - nh / 2,
w: nw,
h: nh,
rotate,
};
}

View File

@@ -1,366 +0,0 @@
import { EPSILON, lineIntersects, polygonPointDistance } from '../math.js';
import type { SerializedXYWH, XYWH } from '../xywh.js';
import { deserializeXYWH, serializeXYWH } from '../xywh.js';
import { type IVec, Vec } from './vec.js';
export function getIBoundFromPoints(
points: IVec[],
rotation = 0
): IBound & {
maxX: number;
maxY: number;
minX: number;
minY: number;
} {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
if (points.length < 1) {
minX = 0;
minY = 0;
maxX = 1;
maxY = 1;
} else {
for (const [x, y] of points) {
minX = Math.min(x, minX);
minY = Math.min(y, minY);
maxX = Math.max(x, maxX);
maxY = Math.max(y, maxY);
}
}
if (rotation !== 0) {
return getIBoundFromPoints(
points.map(pt =>
Vec.rotWith(pt, [(minX + maxX) / 2, (minY + maxY) / 2], rotation)
)
);
}
return {
minX,
minY,
maxX,
maxY,
x: minX,
y: minY,
w: maxX - minX,
h: maxY - minY,
};
}
/**
* Represents the x, y, width, and height of a block that can be easily accessed.
*/
export interface IBound {
x: number;
y: number;
w: number;
h: number;
rotate?: number;
}
export class Bound implements IBound {
h: number;
w: number;
x: number;
y: number;
get bl(): IVec {
return [this.x, this.y + this.h];
}
get br(): IVec {
return [this.x + this.w, this.y + this.h];
}
get center(): IVec {
return [this.x + this.w / 2, this.y + this.h / 2];
}
set center([cx, cy]: IVec) {
const [px, py] = this.center;
this.x += cx - px;
this.y += cy - py;
}
get horizontalLine(): IVec[] {
return [
[this.x, this.y + this.h / 2],
[this.x + this.w, this.y + this.h / 2],
];
}
get leftLine(): IVec[] {
return [
[this.x, this.y],
[this.x, this.y + this.h],
];
}
get lowerLine(): IVec[] {
return [
[this.x, this.y + this.h],
[this.x + this.w, this.y + this.h],
];
}
get maxX() {
return this.x + this.w;
}
get maxY() {
return this.y + this.h;
}
get midPoints(): IVec[] {
return [
[this.x + this.w / 2, this.y],
[this.x + this.w, this.y + this.h / 2],
[this.x + this.w / 2, this.y + this.h],
[this.x, this.y + this.h / 2],
];
}
get minX() {
return this.x;
}
get minY() {
return this.y;
}
get points(): IVec[] {
return [
[this.x, this.y],
[this.x + this.w, this.y],
[this.x + this.w, this.y + this.h],
[this.x, this.y + this.h],
];
}
get rightLine(): IVec[] {
return [
[this.x + this.w, this.y],
[this.x + this.w, this.y + this.h],
];
}
get tl(): IVec {
return [this.x, this.y];
}
get tr(): IVec {
return [this.x + this.w, this.y];
}
get upperLine(): IVec[] {
return [
[this.x, this.y],
[this.x + this.w, this.y],
];
}
get verticalLine(): IVec[] {
return [
[this.x + this.w / 2, this.y],
[this.x + this.w / 2, this.y + this.h],
];
}
constructor(x = 0, y = 0, w = 0, h = 0) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
}
static deserialize(s: string) {
const [x, y, w, h] = deserializeXYWH(s);
return new Bound(x, y, w, h);
}
static from(arg1: IBound) {
return new Bound(arg1.x, arg1.y, arg1.w, arg1.h);
}
static fromCenter(center: IVec, width: number, height: number) {
const [x, y] = center;
return new Bound(x - width / 2, y - height / 2, width, height);
}
static fromDOMRect({ left, top, width, height }: DOMRect) {
return new Bound(left, top, width, height);
}
static fromPoints(points: IVec[]) {
return Bound.from(getIBoundFromPoints(points));
}
static fromXYWH(xywh: XYWH) {
return new Bound(xywh[0], xywh[1], xywh[2], xywh[3]);
}
static serialize(bound: IBound) {
return serializeXYWH(bound.x, bound.y, bound.w, bound.h);
}
clone(): Bound {
return new Bound(this.x, this.y, this.w, this.h);
}
contains(bound: Bound) {
return (
bound.x >= this.x &&
bound.y >= this.y &&
bound.maxX <= this.maxX &&
bound.maxY <= this.maxY
);
}
containsPoint([x, y]: IVec): boolean {
const { minX, minY, maxX, maxY } = this;
return minX <= x && x <= maxX && minY <= y && y <= maxY;
}
expand(margin: [number, number]): Bound;
expand(left: number, top?: number, right?: number, bottom?: number): Bound;
expand(
left: number | [number, number],
top?: number,
right?: number,
bottom?: number
) {
if (Array.isArray(left)) {
const [x, y] = left;
return new Bound(this.x - x, this.y - y, this.w + x * 2, this.h + y * 2);
}
top ??= left;
right ??= left;
bottom ??= top;
return new Bound(
this.x - left,
this.y - top,
this.w + left + right,
this.h + top + bottom
);
}
getRelativePoint([x, y]: IVec): IVec {
return [this.x + x * this.w, this.y + y * this.h];
}
getVerticesAndMidpoints() {
return [...this.points, ...this.midPoints];
}
horizontalDistance(bound: Bound) {
return Math.min(
Math.abs(this.minX - bound.maxX),
Math.abs(this.maxX - bound.minX)
);
}
include(point: IVec) {
const x1 = Math.min(this.x, point[0]),
y1 = Math.min(this.y, point[1]),
x2 = Math.max(this.maxX, point[0]),
y2 = Math.max(this.maxY, point[1]);
return new Bound(x1, y1, x2 - x1, y2 - y1);
}
intersectLine(sp: IVec, ep: IVec, infinite = false) {
const rst: IVec[] = [];
(
[
[this.tl, this.tr],
[this.tl, this.bl],
[this.tr, this.br],
[this.bl, this.br],
] as IVec[][]
).forEach(([p1, p2]) => {
const p = lineIntersects(sp, ep, p1, p2, infinite);
if (p) rst.push(p);
});
return rst.length === 0 ? null : rst;
}
isHorizontalCross(bound: Bound) {
return !(this.maxY < bound.minY || this.minY > bound.maxY);
}
isIntersectWithBound(bound: Bound, epsilon = EPSILON) {
return (
bound.maxX > this.minX - epsilon &&
bound.maxY > this.minY - epsilon &&
bound.minX < this.maxX + epsilon &&
bound.minY < this.maxY + epsilon &&
!this.contains(bound) &&
!bound.contains(this)
);
}
isOverlapWithBound(bound: Bound, epsilon = EPSILON) {
return (
bound.maxX > this.minX - epsilon &&
bound.maxY > this.minY - epsilon &&
bound.minX < this.maxX + epsilon &&
bound.minY < this.maxY + epsilon
);
}
isPointInBound([x, y]: IVec, tolerance = 0.01) {
return (
x > this.minX + tolerance &&
x < this.maxX - tolerance &&
y > this.minY + tolerance &&
y < this.maxY - tolerance
);
}
isPointNearBound([x, y]: IVec, tolerance = 0.01) {
return polygonPointDistance(this.points, [x, y]) < tolerance;
}
isVerticalCross(bound: Bound) {
return !(this.maxX < bound.minX || this.minX > bound.maxX);
}
moveDelta(dx: number, dy: number) {
return new Bound(this.x + dx, this.y + dy, this.w, this.h);
}
serialize(): SerializedXYWH {
return serializeXYWH(this.x, this.y, this.w, this.h);
}
toRelative([x, y]: IVec): IVec {
return [(x - this.x) / this.w, (y - this.y) / this.h];
}
toXYWH(): XYWH {
return [this.x, this.y, this.w, this.h];
}
unite(bound: Bound) {
const x1 = Math.min(this.x, bound.x),
y1 = Math.min(this.y, bound.y),
x2 = Math.max(this.maxX, bound.maxX),
y2 = Math.max(this.maxY, bound.maxY);
return new Bound(x1, y1, x2 - x1, y2 - y1);
}
verticalDistance(bound: Bound) {
return Math.min(
Math.abs(this.minY - bound.maxY),
Math.abs(this.maxY - bound.minY)
);
}
}

View File

@@ -1,4 +0,0 @@
export * from './bound.js';
export * from './point.js';
export * from './point-location.js';
export * from './vec.js';

View File

@@ -1,94 +0,0 @@
import { type IVec, Vec } from './vec.js';
/**
* PointLocation is an implementation of IVec with in/out vectors and tangent.
* This is useful when dealing with path.
*/
export class PointLocation extends Array<number> implements IVec {
_in: IVec = [0, 0];
_out: IVec = [0, 0];
// the tangent belongs to the point on the element outline
_tangent: IVec = [0, 0];
[0]: number;
[1]: number;
get absIn() {
return Vec.add(this, this._in);
}
get absOut() {
return Vec.add(this, this._out);
}
get in() {
return this._in;
}
set in(value: IVec) {
this._in = value;
}
override get length() {
return super.length as 2;
}
get out() {
return this._out;
}
set out(value: IVec) {
this._out = value;
}
get tangent() {
return this._tangent;
}
set tangent(value: IVec) {
this._tangent = value;
}
constructor(
point: IVec = [0, 0],
tangent: IVec = [0, 0],
inVec: IVec = [0, 0],
outVec: IVec = [0, 0]
) {
super(2);
this[0] = point[0];
this[1] = point[1];
this._tangent = tangent;
this._in = inVec;
this._out = outVec;
}
static fromVec(vec: IVec) {
const point = new PointLocation();
point[0] = vec[0];
point[1] = vec[1];
return point;
}
clone() {
return new PointLocation(
this as unknown as IVec,
this._tangent,
this._in,
this._out
);
}
setVec(vec: IVec) {
this[0] = vec[0];
this[1] = vec[1];
return this;
}
toVec(): IVec {
return [this[0], this[1]];
}
}

View File

@@ -1,268 +0,0 @@
import { clamp } from '../math.js';
export interface IPoint {
x: number;
y: number;
}
export class Point {
x: number;
y: number;
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
/**
* Restrict a value to a certain interval.
*/
static clamp(p: Point, min: Point, max: Point) {
return new Point(clamp(p.x, min.x, max.x), clamp(p.y, min.y, max.y));
}
static from(point: IPoint | number[] | number, y?: number) {
if (Array.isArray(point)) {
return new Point(point[0], point[1]);
}
if (typeof point === 'number') {
return new Point(point, y ?? point);
}
return new Point(point.x, point.y);
}
/**
* Compares and returns the maximum of two points.
*/
static max(a: Point, b: Point) {
return new Point(Math.max(a.x, b.x), Math.max(a.y, b.y));
}
/**
* Compares and returns the minimum of two points.
*/
static min(a: Point, b: Point) {
return new Point(Math.min(a.x, b.x), Math.min(a.y, b.y));
}
add(point: IPoint): Point {
return new Point(this.x + point.x, this.y + point.y);
}
/**
* Returns a copy of the point.
*/
clone() {
return new Point(this.x, this.y);
}
cross(point: IPoint): number {
return this.x * point.y - this.y * point.x;
}
equals({ x, y }: Point) {
return this.x === x && this.y === y;
}
lerp(point: IPoint, t: number): Point {
return new Point(
this.x + (point.x - this.x) * t,
this.y + (point.y - this.y) * t
);
}
scale(factor: number): Point {
return new Point(this.x * factor, this.y * factor);
}
set(x: number, y: number) {
this.x = x;
this.y = y;
}
subtract(point: IPoint): Point {
return new Point(this.x - point.x, this.y - point.y);
}
toArray() {
return [this.x, this.y];
}
}
export class Rect {
// `[right, bottom]`
max: Point;
// `[left, top]`
min: Point;
get bottom() {
return this.max.y;
}
set bottom(y: number) {
this.max.y = y;
}
get height() {
return this.max.y - this.min.y;
}
set height(h: number) {
this.max.y = this.min.y + h;
}
get left() {
return this.min.x;
}
set left(x: number) {
this.min.x = x;
}
get right() {
return this.max.x;
}
set right(x: number) {
this.max.x = x;
}
get top() {
return this.min.y;
}
set top(y: number) {
this.min.y = y;
}
get width() {
return this.max.x - this.min.x;
}
set width(w: number) {
this.max.x = this.min.x + w;
}
constructor(left: number, top: number, right: number, bottom: number) {
const [minX, maxX] = left <= right ? [left, right] : [right, left];
const [minY, maxY] = top <= bottom ? [top, bottom] : [bottom, top];
this.min = new Point(minX, minY);
this.max = new Point(maxX, maxY);
}
static fromDOM(dom: Element) {
return Rect.fromDOMRect(dom.getBoundingClientRect());
}
static fromDOMRect({ left, top, right, bottom }: DOMRect) {
return Rect.fromLTRB(left, top, right, bottom);
}
static fromLTRB(left: number, top: number, right: number, bottom: number) {
return new Rect(left, top, right, bottom);
}
static fromLWTH(left: number, width: number, top: number, height: number) {
return new Rect(left, top, left + width, top + height);
}
static fromPoint(point: Point) {
return Rect.fromPoints(point.clone(), point);
}
static fromPoints(start: Point, end: Point) {
const width = Math.abs(end.x - start.x);
const height = Math.abs(end.y - start.y);
const left = Math.min(end.x, start.x);
const top = Math.min(end.y, start.y);
return Rect.fromLWTH(left, width, top, height);
}
static fromXY(x: number, y: number) {
return Rect.fromPoint(new Point(x, y));
}
center() {
return new Point(
(this.left + this.right) / 2,
(this.top + this.bottom) / 2
);
}
clamp(p: Point) {
return Point.clamp(p, this.min, this.max);
}
clone() {
const { left, top, right, bottom } = this;
return new Rect(left, top, right, bottom);
}
contains({ min, max }: Rect) {
return this.isPointIn(min) && this.isPointIn(max);
}
equals({ min, max }: Rect) {
return this.min.equals(min) && this.max.equals(max);
}
extend_with(point: Point) {
this.min = Point.min(this.min, point);
this.max = Point.max(this.max, point);
}
extend_with_x(x: number) {
this.min.x = Math.min(this.min.x, x);
this.max.x = Math.max(this.max.x, x);
}
extend_with_y(y: number) {
this.min.y = Math.min(this.min.y, y);
this.max.y = Math.max(this.max.y, y);
}
intersect(other: Rect) {
return Rect.fromPoints(
Point.max(this.min, other.min),
Point.min(this.max, other.max)
);
}
intersects({ left, top, right, bottom }: Rect) {
return (
this.left <= right &&
left <= this.right &&
this.top <= bottom &&
top <= this.bottom
);
}
isPointDown({ x, y }: Point) {
return this.bottom < y && this.left <= x && this.right >= x;
}
isPointIn({ x, y }: Point) {
return (
this.left <= x && x <= this.right && this.top <= y && y <= this.bottom
);
}
isPointLeft({ x, y }: Point) {
return x < this.left && this.top <= y && this.bottom >= y;
}
isPointRight({ x, y }: Point) {
return x > this.right && this.top <= y && this.bottom >= y;
}
isPointUp({ x, y }: Point) {
return y < this.top && this.left <= x && this.right >= x;
}
toDOMRect() {
const { left, top, width, height } = this;
return new DOMRect(left, top, width, height);
}
}

View File

@@ -1,599 +0,0 @@
// Inlined from https://raw.githubusercontent.com/tldraw/tldraw/24cad6959f59f93e20e556d018c391fd89d4ecca/packages/vec/src/index.ts
// Credits to tldraw
export type IVec = [number, number];
export type IVec3 = [number, number, number];
export class Vec {
/**
* Absolute value of a vector.
* @param A
* @returns
*/
static abs = (A: number[]): number[] => {
return [Math.abs(A[0]), Math.abs(A[1])];
};
/**
* Add vectors.
* @param A
* @param B
*/
static add = (A: number[], B: number[]): IVec => {
return [A[0] + B[0], A[1] + B[1]];
};
/**
* Add scalar to vector.
* @param A
* @param B
*/
static addScalar = (A: number[], n: number): IVec => {
return [A[0] + n, A[1] + n];
};
/**
* Angle between vector A and vector B in radians
* @param A
* @param B
*/
static ang = (A: number[], B: number[]): number => {
return Math.atan2(Vec.cpr(A, B), Vec.dpr(A, B));
};
/**
* Get the angle between the three vectors A, B, and C.
* @param p1
* @param pc
* @param p2
*/
static ang3 = (p1: IVec, pc: IVec, p2: IVec): number => {
// this,
const v1 = Vec.vec(pc, p1);
const v2 = Vec.vec(pc, p2);
return Vec.ang(v1, v2);
};
/**
* Angle between vector A and vector B in radians
* @param A
* @param B
*/
static angle = (A: IVec, B: IVec): number => {
return Math.atan2(B[1] - A[1], B[0] - A[0]);
};
/**
* Get whether p1 is left of p2, relative to pc.
* @param p1
* @param pc
* @param p2
*/
static clockwise = (p1: number[], pc: number[], p2: number[]): boolean => {
return Vec.isLeft(p1, pc, p2) > 0;
};
/**
* Cross product (outer product) | A X B |
* @param A
* @param B
*/
static cpr = (A: number[], B: number[]): number => {
return A[0] * B[1] - B[0] * A[1];
};
/**
* Dist length from A to B
* @param A
* @param B
*/
static dist = (A: number[], B: number[]): number => {
return Math.hypot(A[1] - B[1], A[0] - B[0]);
};
/**
* Dist length from A to B squared.
* @param A
* @param B
*/
static dist2 = (A: IVec, B: IVec): number => {
return Vec.len2(Vec.sub(A, B));
};
/**
* Distance between a point and the nearest point on a bounding box.
* @param bounds The bounding box.
* @param P The point
* @returns
*/
static distanceToBounds = (
bounds: {
minX: number;
minY: number;
maxX: number;
maxY: number;
},
P: number[]
): number => {
return Vec.dist(P, Vec.nearestPointOnBounds(bounds, P));
};
/**
* Distance between a point and the nearest point on a line segment between A and B
* @param A The start of the line segment
* @param B The end of the line segment
* @param P The off-line point
* @param clamp Whether to clamp the point between A and B.
* @returns
*/
static distanceToLineSegment = (
A: IVec,
B: IVec,
P: IVec,
clamp = true
): number => {
return Vec.dist(P, Vec.nearestPointOnLineSegment(A, B, P, clamp));
};
/**
* Distance between a point and a line with a known unit vector that passes through a point.
* @param A Any point on the line
* @param u The unit vector for the line.
* @param P A point not on the line to test.
* @returns
*/
static distanceToLineThroughPoint = (A: IVec, u: IVec, P: IVec): number => {
return Vec.dist(P, Vec.nearestPointOnLineThroughPoint(A, u, P));
};
/**
* Vector division by scalar.
* @param A
* @param n
*/
static div = (A: IVec, n: number): IVec => {
return [A[0] / n, A[1] / n];
};
/**
* Vector division by vector.
* @param A
* @param n
*/
static divV = (A: IVec, B: IVec): IVec => {
return [A[0] / B[0], A[1] / B[1]];
};
/**
* Dot product
* @param A
* @param B
*/
static dpr = (A: number[], B: number[]): number => {
return A[0] * B[0] + A[1] * B[1];
};
/**
* A faster, though less accurate method for testing distances. Maybe faster?
* @param A
* @param B
* @returns
*/
static fastDist = (A: number[], B: number[]): number[] => {
const V = [B[0] - A[0], B[1] - A[1]];
const aV = [Math.abs(V[0]), Math.abs(V[1])];
let r = 1 / Math.max(aV[0], aV[1]);
r = r * (1.29289 - (aV[0] + aV[1]) * r * 0.29289);
return [V[0] * r, V[1] * r];
};
/**
* Interpolate from A to B when curVAL goes fromVAL: number[] => to
* @param A
* @param B
* @param from Starting value
* @param to Ending value
* @param s Strength
*/
static int = (A: IVec, B: IVec, from: number, to: number, s = 1): IVec => {
const t = (Vec.clamp(from, to) - from) / (to - from);
return Vec.add(Vec.mul(A, 1 - t), Vec.mul(B, s));
};
/**
* Check of two vectors are identical.
* @param A
* @param B
*/
static isEqual = (A: number[], B: number[]): boolean => {
return A[0] === B[0] && A[1] === B[1];
};
/**
* Get whether p1 is left of p2, relative to pc.
* @param p1
* @param pc
* @param p2
*/
static isLeft = (p1: number[], pc: number[], p2: number[]): number => {
// isLeft: >0 for counterclockwise
// =0 for none (degenerate)
// <0 for clockwise
return (
(pc[0] - p1[0]) * (p2[1] - p1[1]) - (p2[0] - p1[0]) * (pc[1] - p1[1])
);
};
/**
* Length of the vector
* @param A
*/
static len = (A: number[]): number => {
return Math.hypot(A[0], A[1]);
};
/**
* Length of the vector squared
* @param A
*/
static len2 = (A: number[]): number => {
return A[0] * A[0] + A[1] * A[1];
};
/**
* Interpolate vector A to B with a scalar t
* @param A
* @param B
* @param t scalar
*/
static lrp = (A: IVec, B: IVec, t: number): IVec => {
return Vec.add(A, Vec.mul(Vec.sub(B, A), t));
};
/**
* Get a vector comprised of the maximum of two or more vectors.
*/
static max = (...v: number[][]) => {
return [Math.max(...v.map(a => a[0])), Math.max(...v.map(a => a[1]))];
};
/**
* Mean between two vectors or mid vector between two vectors
* @param A
* @param B
*/
static med = (A: IVec, B: IVec): IVec => {
return Vec.mul(Vec.add(A, B), 0.5);
};
/**
* Get a vector comprised of the minimum of two or more vectors.
*/
static min = (...v: number[][]) => {
return [Math.min(...v.map(a => a[0])), Math.min(...v.map(a => a[1]))];
};
/**
* Vector multiplication by scalar
* @param A
* @param n
*/
static mul = (A: IVec, n: number): IVec => {
return [A[0] * n, A[1] * n];
};
/**
* Multiple two vectors.
* @param A
* @param B
*/
static mulV = (A: IVec, B: IVec): IVec => {
return [A[0] * B[0], A[1] * B[1]];
};
/**
* Get the nearest point on a bounding box to a point P.
* @param bounds The bounding box
* @param P The point point
* @returns
*/
static nearestPointOnBounds = (
bounds: {
minX: number;
minY: number;
maxX: number;
maxY: number;
},
P: number[]
): number[] => {
return [
Vec.clamp(P[0], bounds.minX, bounds.maxX),
Vec.clamp(P[1], bounds.minY, bounds.maxY),
];
};
/**
* Get the nearest point on a line segment between A and B
* @param A The start of the line segment
* @param B The end of the line segment
* @param P The off-line point
* @param clamp Whether to clamp the point between A and B.
* @returns
*/
static nearestPointOnLineSegment = (
A: IVec,
B: IVec,
P: IVec,
clamp = true
): IVec => {
const u = Vec.uni(Vec.sub(B, A));
const C = Vec.add(A, Vec.mul(u, Vec.pry(Vec.sub(P, A), u)));
if (clamp) {
if (C[0] < Math.min(A[0], B[0])) return A[0] < B[0] ? A : B;
if (C[0] > Math.max(A[0], B[0])) return A[0] > B[0] ? A : B;
if (C[1] < Math.min(A[1], B[1])) return A[1] < B[1] ? A : B;
if (C[1] > Math.max(A[1], B[1])) return A[1] > B[1] ? A : B;
}
return C;
};
/**
* Get the nearest point on a line with a known unit vector that passes through point A
* @param A Any point on the line
* @param u The unit vector for the line.
* @param P A point not on the line to test.
* @returns
*/
static nearestPointOnLineThroughPoint = (A: IVec, u: IVec, P: IVec): IVec => {
return Vec.add(A, Vec.mul(u, Vec.pry(Vec.sub(P, A), u)));
};
/**
* Negate a vector.
* @param A
*/
static neg = (A: number[]): number[] => {
return [-A[0], -A[1]];
};
/**
* Get normalized / unit vector.
* @param A
*/
static normalize = (A: IVec): IVec => {
return Vec.uni(A);
};
/**
* Push a point A towards point B by a given distance.
* @param A
* @param B
* @param d
* @returns
*/
static nudge = (A: IVec, B: IVec, d: number): number[] => {
if (Vec.isEqual(A, B)) return A;
return Vec.add(A, Vec.mul(Vec.uni(Vec.sub(B, A)), d));
};
/**
* Push a point in a given angle by a given distance.
* @param A
* @param B
* @param d
*/
static nudgeAtAngle = (A: number[], a: number, d: number): number[] => {
return [Math.cos(a) * d + A[0], Math.sin(a) * d + A[1]];
};
/**
* Perpendicular rotation of a vector A
* @param A
*/
static per = (A: IVec): IVec => {
return [A[1], -A[0]];
};
static pointOffset = (A: IVec, B: IVec, offset: number): IVec => {
let u = Vec.uni(Vec.sub(B, A));
if (Vec.isEqual(A, B)) u = A;
return Vec.add(A, Vec.mul(u, offset));
};
/**
* Get an array of points between two points.
* @param A The first point.
* @param B The second point.
* @param steps The number of points to return.
*/
static pointsBetween = (A: IVec, B: IVec, steps = 6): number[][] => {
return Array.from({ length: steps }).map((_, i) => {
const t = i / (steps - 1);
const k = Math.min(1, 0.5 + Math.abs(0.5 - t));
return [...Vec.lrp(A, B, t), k];
});
};
/**
* Project A over B
* @param A
* @param B
*/
static pry = (A: number[], B: number[]): number => {
return Vec.dpr(A, B) / Vec.len(B);
};
static rescale = (a: number[], n: number): number[] => {
const l = Vec.len(a);
return [(n * a[0]) / l, (n * a[1]) / l];
};
/**
* Vector rotation by r (radians)
* @param A
* @param r rotation in radians
*/
static rot = (A: number[], r = 0): IVec => {
return [
A[0] * Math.cos(r) - A[1] * Math.sin(r),
A[0] * Math.sin(r) + A[1] * Math.cos(r),
];
};
/**
* Rotate a vector around another vector by r (radians)
* @param A vector
* @param C center
* @param r rotation in radians
*/
static rotWith = (A: IVec, C: IVec, r = 0): IVec => {
if (r === 0) return A;
const s = Math.sin(r);
const c = Math.cos(r);
const px = A[0] - C[0];
const py = A[1] - C[1];
const nx = px * c - py * s;
const ny = px * s + py * c;
return [nx + C[0], ny + C[1]];
};
/**
* Get the slope between two points.
* @param A
* @param B
*/
static slope = (A: number[], B: number[]) => {
if (A[0] === B[0]) return NaN;
return (A[1] - B[1]) / (A[0] - B[0]);
};
/**
* Subtract vectors.
* @param A
* @param B
*/
static sub = (A: IVec, B: IVec): IVec => {
return [A[0] - B[0], A[1] - B[1]];
};
/**
* Subtract scalar from vector.
* @param A
* @param B
*/
static subScalar = (A: IVec, n: number): IVec => {
return [A[0] - n, A[1] - n];
};
/**
* Get the tangent between two vectors.
* @param A
* @param B
* @returns
*/
static tangent = (A: IVec, B: IVec): IVec => {
return Vec.uni(Vec.sub(A, B));
};
/**
* Round a vector to two decimal places.
* @param a
*/
static toFixed = (a: number[]): number[] => {
return a.map(v => Math.round(v * 100) / 100);
};
static toPoint = (v: IVec) => {
return {
x: v[0],
y: v[1],
};
};
/**
* Round a vector to a precision length.
* @param a
* @param n
*/
static toPrecision = (a: number[], n = 4): number[] => {
return [+a[0].toPrecision(n), +a[1].toPrecision(n)];
};
static toVec = (v: { x: number; y: number }): IVec => [v.x, v.y];
/**
* Get normalized / unit vector.
* @param A
*/
static uni = (A: IVec): IVec => {
return Vec.div(A, Vec.len(A));
};
/**
* Get the vector from vectors A to B.
* @param A
* @param B
*/
static vec = (A: IVec, B: IVec): IVec => {
// A, B as vectors get the vector from A to B
return [B[0] - A[0], B[1] - A[1]];
};
/**
* Clamp a value into a range.
* @param n
* @param min
*/
static clamp(n: number, min: number): number;
// eslint-disable-next-line @typescript-eslint/unified-signatures
static clamp(n: number, min: number, max: number): number;
static clamp(n: number, min: number, max?: number): number {
return Math.max(min, max !== undefined ? Math.min(n, max) : n);
}
/**
* Clamp a value into a range.
* @param n
* @param min
*/
static clampV(A: number[], min: number): number[];
// eslint-disable-next-line @typescript-eslint/unified-signatures
static clampV(A: number[], min: number, max: number): number[];
static clampV(A: number[], min: number, max?: number): number[] {
return A.map(n =>
max !== undefined ? Vec.clamp(n, min, max) : Vec.clamp(n, min)
);
}
/**
* Cross (for point in polygon)
*
*/
static cross(x: number[], y: number[], z: number[]): number {
return (y[0] - x[0]) * (z[1] - x[1]) - (z[0] - x[0]) * (y[1] - x[1]);
}
/**
* Snap vector to nearest step.
* @param A
* @param step
* @example
* ```ts
* Vec.snap([10.5, 28], 10) // [10, 30]
* ```
*/
static snap(a: number[], step = 1) {
return [Math.round(a[0] / step) * step, Math.round(a[1] / step) * step];
}
}

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 Stephen Ruiz Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,16 +0,0 @@
import type { IVec, IVec3 } from '../model/index.js';
import { getStroke } from './get-stroke.js';
export function getSolidStrokePoints(
points: (IVec | IVec3)[],
lineWidth: number
) {
return getStroke(points, {
size: lineWidth,
thinning: 0.6,
streamline: 0.5,
smoothing: 0.5,
easing: t => Math.sin((t * Math.PI) / 2),
simulatePressure: points[0]?.length === 2,
});
}

View File

@@ -1,398 +0,0 @@
import type { IVec } from '../model/index.js';
import { getStrokeRadius } from './get-stroke-radius.js';
import type { StrokeOptions, StrokePoint } from './types.js';
import {
add,
dist2,
dpr,
lrp,
mul,
neg,
per,
prj,
rotAround,
sub,
uni,
} from './vec.js';
const { min, PI } = Math;
// This is the rate of change for simulated pressure. It could be an option.
const RATE_OF_PRESSURE_CHANGE = 0.275;
// Browser strokes seem to be off if PI is regular, a tiny offset seems to fix it
const FIXED_PI = PI + 0.0001;
/**
* ## getStrokeOutlinePoints
* @description Get an array of points (as `[x, y]`) representing the outline of a stroke.
* @param points An array of StrokePoints as returned from `getStrokePoints`.
* @param options (optional) An object with options.
* @param options.size The base size (diameter) of the stroke.
* @param options.thinning The effect of pressure on the stroke's size.
* @param options.smoothing How much to soften the stroke's edges.
* @param options.easing An easing function to apply to each point's pressure.
* @param options.simulatePressure Whether to simulate pressure based on velocity.
* @param options.start Cap, taper and easing for the start of the line.
* @param options.end Cap, taper and easing for the end of the line.
* @param options.last Whether to handle the points as a completed stroke.
*/
export function getStrokeOutlinePoints(
points: StrokePoint[],
options: Partial<StrokeOptions> = {} as Partial<StrokeOptions>
): IVec[] {
const {
size = 16,
smoothing = 0.5,
thinning = 0.5,
simulatePressure = true,
easing = t => t,
start = {},
end = {},
last: isComplete = false,
} = options;
const { cap: capStart = true, easing: taperStartEase = t => t * (2 - t) } =
start;
const { cap: capEnd = true, easing: taperEndEase = t => --t * t * t + 1 } =
end;
// We can't do anything with an empty array or a stroke with negative size.
if (points.length === 0 || size <= 0) {
return [];
}
// The total length of the line
const totalLength = points[points.length - 1].runningLength;
const taperStart =
start.taper === false
? 0
: start.taper === true
? Math.max(size, totalLength)
: (start.taper as number);
const taperEnd =
end.taper === false
? 0
: end.taper === true
? Math.max(size, totalLength)
: (end.taper as number);
// The minimum allowed distance between points (squared)
const minDistance = Math.pow(size * smoothing, 2);
// Our collected left and right points
const leftPts: IVec[] = [];
const rightPts: IVec[] = [];
// Previous pressure (start with average of first five pressures,
// in order to prevent fat starts for every line. Drawn lines
// almost always start slow!
let prevPressure = points.slice(0, 10).reduce((acc, curr) => {
let pressure = curr.pressure;
if (simulatePressure) {
// Speed of change - how fast should the the pressure changing?
const sp = min(1, curr.distance / size);
// Rate of change - how much of a change is there?
const rp = min(1, 1 - sp);
// Accelerate the pressure
pressure = min(1, acc + (rp - acc) * (sp * RATE_OF_PRESSURE_CHANGE));
}
return (acc + pressure) / 2;
}, points[0].pressure);
// The current radius
let radius = getStrokeRadius(
size,
thinning,
points[points.length - 1].pressure,
easing
);
// The radius of the first saved point
let firstRadius: number | undefined = undefined;
// Previous vector
let prevVector = points[0].vector;
// Previous left and right points
let pl = points[0].point;
let pr = pl;
// Temporary left and right points
let tl = pl;
let tr = pr;
// Keep track of whether the previous point is a sharp corner
// ... so that we don't detect the same corner twice
let isPrevPointSharpCorner = false;
// let short = true
/*
Find the outline's left and right points
Iterating through the points and populate the rightPts and leftPts arrays,
skipping the first and last pointsm, which will get caps later on.
*/
for (let i = 0; i < points.length; i++) {
let { pressure } = points[i];
const { point, vector, distance, runningLength } = points[i];
// Removes noise from the end of the line
if (i < points.length - 1 && totalLength - runningLength < 3) {
continue;
}
/*
Calculate the radius
If not thinning, the current point's radius will be half the size; or
otherwise, the size will be based on the current (real or simulated)
pressure.
*/
if (thinning) {
if (simulatePressure) {
// If we're simulating pressure, then do so based on the distance
// between the current point and the previous point, and the size
// of the stroke. Otherwise, use the input pressure.
const sp = min(1, distance / size);
const rp = min(1, 1 - sp);
pressure = min(
1,
prevPressure + (rp - prevPressure) * (sp * RATE_OF_PRESSURE_CHANGE)
);
}
radius = getStrokeRadius(size, thinning, pressure, easing);
} else {
radius = size / 2;
}
if (firstRadius === undefined) {
firstRadius = radius;
}
/*
Apply tapering
If the current length is within the taper distance at either the
start or the end, calculate the taper strengths. Apply the smaller
of the two taper strengths to the radius.
*/
const ts =
runningLength < taperStart
? taperStartEase(runningLength / taperStart)
: 1;
const te =
totalLength - runningLength < taperEnd
? taperEndEase((totalLength - runningLength) / taperEnd)
: 1;
radius = Math.max(0.01, radius * Math.min(ts, te));
/* Add points to left and right */
/*
Handle sharp corners
Find the difference (dot product) between the current and next vector.
If the next vector is at more than a right angle to the current vector,
draw a cap at the current point.
*/
const nextVector = (i < points.length - 1 ? points[i + 1] : points[i])
.vector;
const nextDpr = i < points.length - 1 ? dpr(vector, nextVector) : 1.0;
const prevDpr = dpr(vector, prevVector);
const isPointSharpCorner = prevDpr < 0 && !isPrevPointSharpCorner;
const isNextPointSharpCorner = nextDpr !== null && nextDpr < 0;
if (isPointSharpCorner || isNextPointSharpCorner) {
// It's a sharp corner. Draw a rounded cap and move on to the next point
// Considering saving these and drawing them later? So that we can avoid
// crossing future points.
const offset = mul(per(prevVector), radius);
for (let step = 1 / 13, t = 0; t <= 1; t += step) {
tl = rotAround(sub(point, offset), point, FIXED_PI * t);
leftPts.push(tl);
tr = rotAround(add(point, offset), point, FIXED_PI * -t);
rightPts.push(tr);
}
pl = tl;
pr = tr;
if (isNextPointSharpCorner) {
isPrevPointSharpCorner = true;
}
continue;
}
isPrevPointSharpCorner = false;
// Handle the last point
if (i === points.length - 1) {
const offset = mul(per(vector), radius);
leftPts.push(sub(point, offset));
rightPts.push(add(point, offset));
continue;
}
/*
Add regular points
Project points to either side of the current point, using the
calculated size as a distance. If a point's distance to the
previous point on that side greater than the minimum distance
(or if the corner is kinda sharp), add the points to the side's
points array.
*/
const offset = mul(per(lrp(nextVector, vector, nextDpr)), radius);
tl = sub(point, offset);
if (i <= 1 || dist2(pl, tl) > minDistance) {
leftPts.push(tl);
pl = tl;
}
tr = add(point, offset);
if (i <= 1 || dist2(pr, tr) > minDistance) {
rightPts.push(tr);
pr = tr;
}
// Set variables for next iteration
prevPressure = pressure;
prevVector = vector;
}
/*
Drawing caps
Now that we have our points on either side of the line, we need to
draw caps at the start and end. Tapered lines don't have caps, but
may have dots for very short lines.
*/
const firstPoint = points[0].point.slice(0, 2) as IVec;
const lastPoint =
points.length > 1
? (points[points.length - 1].point.slice(0, 2) as IVec)
: add(points[0].point, [1, 1]);
const startCap: IVec[] = [];
const endCap: IVec[] = [];
/*
Draw a dot for very short or completed strokes
If the line is too short to gather left or right points and if the line is
not tapered on either side, draw a dot. If the line is tapered, then only
draw a dot if the line is both very short and complete. If we draw a dot,
we can just return those points.
*/
if (points.length === 1) {
if (!(taperStart || taperEnd) || isComplete) {
const start = prj(
firstPoint,
uni(per(sub(firstPoint, lastPoint))),
-(firstRadius || radius)
);
const dotPts: IVec[] = [];
for (let step = 1 / 13, t = step; t <= 1; t += step) {
dotPts.push(rotAround(start, firstPoint, FIXED_PI * 2 * t));
}
return dotPts;
}
} else {
/*
Draw a start cap
Unless the line has a tapered start, or unless the line has a tapered end
and the line is very short, draw a start cap around the first point. Use
the distance between the second left and right point for the cap's radius.
Finally remove the first left and right points. :psyduck:
*/
if (taperStart || (taperEnd && points.length === 1)) {
// The start point is tapered, noop
} else if (capStart) {
// Draw the round cap - add thirteen points rotating the right point around the start point to the left point
for (let step = 1 / 13, t = step; t <= 1; t += step) {
const pt = rotAround(rightPts[0], firstPoint, FIXED_PI * t);
startCap.push(pt);
}
} else {
// Draw the flat cap - add a point to the left and right of the start point
const cornersVector = sub(leftPts[0], rightPts[0]);
const offsetA = mul(cornersVector, 0.5);
const offsetB = mul(cornersVector, 0.51);
startCap.push(
sub(firstPoint, offsetA),
sub(firstPoint, offsetB),
add(firstPoint, offsetB),
add(firstPoint, offsetA)
);
}
/*
Draw an end cap
If the line does not have a tapered end, and unless the line has a tapered
start and the line is very short, draw a cap around the last point. Finally,
remove the last left and right points. Otherwise, add the last point. Note
that This cap is a full-turn-and-a-half: this prevents incorrect caps on
sharp end turns.
*/
const direction = per(neg(points[points.length - 1].vector));
if (taperEnd || (taperStart && points.length === 1)) {
// Tapered end - push the last point to the line
endCap.push(lastPoint);
} else if (capEnd) {
// Draw the round end cap
const start = prj(lastPoint, direction, radius);
for (let step = 1 / 29, t = step; t < 1; t += step) {
endCap.push(rotAround(start, lastPoint, FIXED_PI * 3 * t));
}
} else {
// Draw the flat end cap
endCap.push(
add(lastPoint, mul(direction, radius)),
add(lastPoint, mul(direction, radius * 0.99)),
sub(lastPoint, mul(direction, radius * 0.99)),
sub(lastPoint, mul(direction, radius))
);
}
}
/*
Return the points in the correct winding order: begin on the left side, then
continue around the end cap, then come back along the right side, and finally
complete the start cap.
*/
return leftPts.concat(endCap, rightPts.reverse(), startCap);
}

View File

@@ -1,132 +0,0 @@
import type { IVec, IVec3 } from '../model/index.js';
import type { StrokeOptions, StrokePoint } from './types.js';
import { add, dist, isEqual, lrp, sub, uni } from './vec.js';
/**
* ## getStrokePoints
* @description Get an array of points as objects with an adjusted point, pressure, vector, distance, and runningLength.
* @param points An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases.
* @param options (optional) An object with options.
* @param options.size The base size (diameter) of the stroke.
* @param options.thinning The effect of pressure on the stroke's size.
* @param options.smoothing How much to soften the stroke's edges.
* @param options.easing An easing function to apply to each point's pressure.
* @param options.simulatePressure Whether to simulate pressure based on velocity.
* @param options.start Cap, taper and easing for the start of the line.
* @param options.end Cap, taper and easing for the end of the line.
* @param options.last Whether to handle the points as a completed stroke.
*/
export function getStrokePoints<
T extends IVec | IVec3,
K extends { x: number; y: number; pressure?: number },
>(points: (T | K)[], options = {} as StrokeOptions): StrokePoint[] {
const { streamline = 0.5, size = 16, last: isComplete = false } = options;
// If we don't have any points, return an empty array.
if (points.length === 0) return [];
// Find the interpolation level between points.
const t = 0.15 + (1 - streamline) * 0.85;
// Whatever the input is, make sure that the points are in number[][].
let pts: (IVec3 | IVec)[] = Array.isArray(points[0])
? (points as T[])
: (points as K[]).map(
({ x, y, pressure = 0.5 }) => [x, y, pressure] as IVec3
);
// Add extra points between the two, to help avoid "dash" lines
// for strokes with tapered start and ends. Don't mutate the
// input array!
if (pts.length === 2) {
const last = pts[1];
pts = pts.slice(0, -1);
for (let i = 1; i < 5; i++) {
pts.push(lrp(pts[0] as IVec, last as IVec, i / 4));
}
}
// If there's only one point, add another point at a 1pt offset.
// Don't mutate the input array!
if (pts.length === 1) {
pts = [
...pts,
[...add(pts[0] as IVec, [1, 1]), ...pts[0].slice(2)] as IVec,
];
}
// The strokePoints array will hold the points for the stroke.
// Start it out with the first point, which needs no adjustment.
const strokePoints: StrokePoint[] = [
{
point: [pts[0][0], pts[0][1]],
pressure: (pts[0][2] ?? -1) >= 0 ? pts[0][2]! : 0.25,
vector: [1, 1],
distance: 0,
runningLength: 0,
},
];
// A flag to see whether we've already reached out minimum length
let hasReachedMinimumLength = false;
// We use the runningLength to keep track of the total distance
let runningLength = 0;
// We're set this to the latest point, so we can use it to calculate
// the distance and vector of the next point.
let prev = strokePoints[0];
const max = pts.length - 1;
// Iterate through all of the points, creating StrokePoints.
for (let i = 1; i < pts.length; i++) {
const point =
isComplete && i === max
? // If we're at the last point, and `options.last` is true,
// then add the actual input point.
(pts[i].slice(0, 2) as IVec)
: // Otherwise, using the t calculated from the streamline
// option, interpolate a new point between the previous
// point the current point.
lrp(prev.point, pts[i] as IVec, t);
// If the new point is the same as the previous point, skip ahead.
if (isEqual(prev.point, point)) continue;
// How far is the new point from the previous point?
const distance = dist(point, prev.point);
// Add this distance to the total "running length" of the line.
runningLength += distance;
// At the start of the line, we wait until the new point is a
// certain distance away from the original point, to avoid noise
if (i < max && !hasReachedMinimumLength) {
if (runningLength < size) continue;
hasReachedMinimumLength = true;
// TODO: Backfill the missing points so that tapering works correctly.
}
// Create a new strokepoint (it will be the new "previous" one).
prev = {
// The adjusted point
point,
// The input pressure (or .5 if not specified)
pressure: (pts[i][2] ?? -1) >= 0 ? pts[i][2]! : 0.5,
// The vector from the current point to the previous point
vector: uni(sub(prev.point, point)),
// The distance between the current point and the previous point
distance,
// The total distance so far
runningLength,
};
// Push it to the strokePoints array.
strokePoints.push(prev);
}
// Set the vector of the first point to be the same as the second point.
strokePoints[0].vector = strokePoints[1]?.vector || [0, 0];
return strokePoints;
}

View File

@@ -1,16 +0,0 @@
/**
* Compute a radius based on the pressure.
* @param size
* @param thinning
* @param pressure
* @param easing
* @internal
*/
export function getStrokeRadius(
size: number,
thinning: number,
pressure: number,
easing: (t: number) => number = t => t
) {
return size * easing(0.5 - thinning * (0.5 - pressure));
}

View File

@@ -1,26 +0,0 @@
import type { IVec, IVec3 } from '../model/index.js';
import { getStrokeOutlinePoints } from './get-stroke-outline-points.js';
import { getStrokePoints } from './get-stroke-points.js';
import type { StrokeOptions } from './types.js';
/**
* ## getStroke
* @description Get an array of points describing a polygon that surrounds the input points.
* @param points An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases.
* @param options (optional) An object with options.
* @param options.size The base size (diameter) of the stroke.
* @param options.thinning The effect of pressure on the stroke's size.
* @param options.smoothing How much to soften the stroke's edges.
* @param options.easing An easing function to apply to each point's pressure.
* @param options.simulatePressure Whether to simulate pressure based on velocity.
* @param options.start Cap, taper and easing for the start of the line.
* @param options.end Cap, taper and easing for the end of the line.
* @param options.last Whether to handle the points as a completed stroke.
*/
export function getStroke(
points: (IVec | IVec3 | { x: number; y: number; pressure?: number })[],
options: StrokeOptions = {} as StrokeOptions
) {
return getStrokeOutlinePoints(getStrokePoints(points, options), options);
}

View File

@@ -1,5 +0,0 @@
export * from './get-solid-stroke-points.js';
export * from './get-stroke.js';
export * from './get-stroke-outline-points.js';
export * from './get-stroke-points.js';
export * from './get-stroke-radius.js';

View File

@@ -1,46 +0,0 @@
import type { IVec } from '../model/index.js';
/**
* The options object for `getStroke` or `getStrokePoints`.
* @param points An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases.
* @param options (optional) An object with options.
* @param options.size The base size (diameter) of the stroke.
* @param options.thinning The effect of pressure on the stroke's size.
* @param options.smoothing How much to soften the stroke's edges.
* @param options.easing An easing function to apply to each point's pressure.
* @param options.simulatePressure Whether to simulate pressure based on velocity.
* @param options.start Cap, taper and easing for the start of the line.
* @param options.end Cap, taper and easing for the end of the line.
* @param options.last Whether to handle the points as a completed stroke.
*/
export interface StrokeOptions {
size?: number;
thinning?: number;
smoothing?: number;
streamline?: number;
easing?: (pressure: number) => number;
simulatePressure?: boolean;
start?: {
cap?: boolean;
taper?: number | boolean;
easing?: (distance: number) => number;
};
end?: {
cap?: boolean;
taper?: number | boolean;
easing?: (distance: number) => number;
};
// Whether to handle the points as a completed stroke.
last?: boolean;
}
/**
* The points returned by `getStrokePoints`, and the input for `getStrokeOutlinePoints`.
*/
export interface StrokePoint {
point: IVec;
pressure: number;
distance: number;
vector: IVec;
runningLength: number;
}

View File

@@ -1,178 +0,0 @@
import type { IVec } from '../model/index.js';
/**
* Negate a vector.
* @param A
* @internal
*/
export function neg(A: IVec): IVec {
return [-A[0], -A[1]];
}
/**
* Add vectors.
* @param A
* @param B
* @internal
*/
export function add(A: IVec, B: IVec): IVec {
return [A[0] + B[0], A[1] + B[1]];
}
/**
* Subtract vectors.
* @param A
* @param B
* @internal
*/
export function sub(A: IVec, B: IVec): IVec {
return [A[0] - B[0], A[1] - B[1]];
}
/**
* Vector multiplication by scalar
* @param A
* @param n
* @internal
*/
export function mul(A: IVec, n: number): IVec {
return [A[0] * n, A[1] * n];
}
/**
* Vector division by scalar.
* @param A
* @param n
* @internal
*/
export function div(A: IVec, n: number): IVec {
return [A[0] / n, A[1] / n];
}
/**
* Perpendicular rotation of a vector A
* @param A
* @internal
*/
export function per(A: IVec): IVec {
return [A[1], -A[0]];
}
/**
* Dot product
* @param A
* @param B
* @internal
*/
export function dpr(A: IVec, B: IVec) {
return A[0] * B[0] + A[1] * B[1];
}
/**
* Get whether two vectors are equal.
* @param A
* @param B
* @internal
*/
export function isEqual(A: IVec, B: IVec) {
return A[0] === B[0] && A[1] === B[1];
}
/**
* Length of the vector
* @param A
* @internal
*/
export function len(A: IVec) {
return Math.hypot(A[0], A[1]);
}
/**
* Length of the vector squared
* @param A
* @internal
*/
export function len2(A: IVec) {
return A[0] * A[0] + A[1] * A[1];
}
/**
* Dist length from A to B squared.
* @param A
* @param B
* @internal
*/
export function dist2(A: IVec, B: IVec) {
return len2(sub(A, B));
}
/**
* Get normalized / unit vector.
* @param A
* @internal
*/
export function uni(A: IVec) {
return div(A, len(A));
}
/**
* Dist length from A to B
* @param A
* @param B
* @internal
*/
export function dist(A: IVec, B: IVec) {
return Math.hypot(A[1] - B[1], A[0] - B[0]);
}
/**
* Mean between two vectors or mid vector between two vectors
* @param A
* @param B
* @internal
*/
export function med(A: IVec, B: IVec) {
return mul(add(A, B), 0.5);
}
/**
* Rotate a vector around another vector by r (radians)
* @param A vector
* @param C center
* @param r rotation in radians
* @internal
*/
export function rotAround(A: IVec, C: IVec, r: number): IVec {
const s = Math.sin(r);
const c = Math.cos(r);
const px = A[0] - C[0];
const py = A[1] - C[1];
const nx = px * c - py * s;
const ny = px * s + py * c;
return [nx + C[0], ny + C[1]];
}
/**
* Interpolate vector A to B with a scalar t
* @param A
* @param B
* @param t scalar
* @internal
*/
export function lrp(A: IVec, B: IVec, t: number) {
return add(A, mul(sub(B, A), t));
}
/**
* Project a point A in the direction B by a scalar c
* @param A
* @param B
* @param c
* @internal
*/
export function prj(A: IVec, B: IVec, c: number) {
return add(A, mul(B, c));
}

View File

@@ -1,136 +0,0 @@
import { type IVec, Vec } from './model/index.js';
export class Polyline {
static len(points: IVec[]) {
const n = points.length;
if (n < 2) {
return 0;
}
let i = 0;
let len = 0;
let curr: IVec;
let prev = points[0];
while (++i < n) {
curr = points[i];
len += Vec.dist(prev, curr);
prev = curr;
}
return len;
}
static lenAtPoint(points: IVec[], point: IVec) {
const n = points.length;
let len = n;
for (let i = 0; i < n - 1; i++) {
const a = points[i];
const b = points[i + 1];
// start
if (a[0] === point[0] && a[1] === point[1]) {
return len;
}
const aa = Vec.angle(a, point);
const ba = Vec.angle(b, point);
if ((aa + ba) % Math.PI === 0) {
len += Vec.dist(a, point);
return len;
}
len += Vec.dist(a, b);
// end
if (b[0] === point[0] && b[1] === point[1]) {
return len;
}
}
return len;
}
static nearestPoint(points: IVec[], point: IVec): IVec {
const n = points.length;
const r: IVec = [0, 0];
let len = Infinity;
for (let i = 0; i < n - 1; i++) {
const a = points[i];
const b = points[i + 1];
const p = Vec.nearestPointOnLineSegment(a, b, point, true);
const d = Vec.dist(p, point);
if (d < len) {
len = d;
r[0] = p[0];
r[1] = p[1];
}
}
return r;
}
static pointAt(points: IVec[], ratio: number) {
const n = points.length;
if (n === 0) {
return null;
}
if (n === 1) {
return points[0];
}
if (ratio <= 0) {
return points[0];
}
if (ratio >= 1) {
return points[n - 1];
}
const total = Polyline.len(points);
const len = total * ratio;
return Polyline.pointAtLen(points, len);
}
static pointAtLen(points: IVec[], len: number): IVec | null {
const n = points.length;
if (n === 0) {
return null;
}
if (n === 1) {
return points[0];
}
let fromStart = true;
if (len < 0) {
fromStart = false;
len = -len;
}
let tmp = 0;
for (let j = 0, k = n - 1; j < k; j++) {
const i = fromStart ? j : k - 1 - j;
const a = points[i];
const b = points[i + 1];
const d = Vec.dist(a, b);
if (len <= tmp + d) {
const t = ((fromStart ? 1 : -1) * (len - tmp)) / d;
return Vec.lrp(a, b, t) as IVec;
}
tmp += d;
}
const lastPoint = fromStart ? points[n - 1] : points[0];
return lastPoint;
}
}

View File

@@ -1,29 +0,0 @@
/**
* XYWH represents the x, y, width, and height of an element or block.
*/
export type XYWH = [number, number, number, number];
/**
* SerializedXYWH is a string that represents the x, y, width, and height of a block.
*/
export type SerializedXYWH = `[${number},${number},${number},${number}]`;
export function serializeXYWH(
x: number,
y: number,
w: number,
h: number
): SerializedXYWH {
return `[${x},${y},${w},${h}]`;
}
export function deserializeXYWH(xywh: string): XYWH {
try {
return JSON.parse(xywh) as XYWH;
} catch (e) {
console.error('Failed to deserialize xywh', xywh);
console.error(e);
return [0, 0, 0, 0];
}
}