Files
AFFiNE-Mirror/blocksuite/framework/global/src/gfx/curve.ts

403 lines
9.7 KiB
TypeScript

// control coords are not relative to start or end
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);
if (!tmpPoint) return false;
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);
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;
}