Skip to content

Commit

Permalink
feat: Derive accurate bounds for bezier spline
Browse files Browse the repository at this point in the history
  • Loading branch information
miyanokomiya committed Nov 21, 2023
1 parent 6ca94f9 commit d105b99
Show file tree
Hide file tree
Showing 3 changed files with 136 additions and 3 deletions.
9 changes: 6 additions & 3 deletions src/shapes/line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { applyFillStyle, createFillStyle } from "../utils/fillStyle";
import {
ISegment,
expandRect,
getBezierSplineBounds,
getRectPoints,
getRelativePointOnBezierPath,
getRelativePointOnPath,
Expand Down Expand Up @@ -170,15 +171,17 @@ export const struct: ShapeStruct<LineShape> = {
}
},
getWrapperRect(shape, _, includeBounds) {
let rect = getOuterRectangle([getLinePath(shape)]);
const path = getLinePath(shape);
let rect = isCurveLine(shape) ? getBezierSplineBounds(path, shape.curves) : getOuterRectangle([path]);

if (includeBounds) {
// FIXME: This expanding isn't perfect nor deals with heads.
rect = expandRect(rect, getStrokeWidth(shape.stroke) / 2);
rect = expandRect(rect, getStrokeWidth(shape.stroke) / 1.9);
}
return rect;
},
getLocalRectPolygon(shape) {
return getRectPoints(getOuterRectangle([getLinePath(shape)]));
return getRectPoints(struct.getWrapperRect(shape));
},
isPointOn(shape, p, shapeContext) {
const edges = getEdges(shape);
Expand Down
38 changes: 38 additions & 0 deletions src/utils/geometry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ import {
isPointCloseToBezierSegment,
getRelativePointOnBezierPath,
getSegments,
getBezierMinValue,
getBezierMaxValue,
getBezierSplineBounds,
} from "./geometry";
import { IRectangle } from "okageo";

Expand Down Expand Up @@ -745,3 +748,38 @@ describe("measurePointAndRect", () => {
expect(getDistanceBetweenPointAndRect({ x: 10, y: 20 }, rect)).toBeCloseTo(0);
});
});

describe("getBezierSplineBounds", () => {
test("should return the bounds of the bezier spline", () => {
const points = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
];
const controls = [
{ c1: { x: 2.5, y: -5 }, c2: { x: 7.5, y: -5 } },
{ c1: { x: 15, y: 2.5 }, c2: { x: 15, y: 7.5 } },
];
const ret0 = getBezierSplineBounds(points, controls);
expect(ret0.x).toBeCloseTo(0, 3);
expect(ret0.y).toBeCloseTo(-3.75, 3);
expect(ret0.width).toBeCloseTo(13.75, 3);
expect(ret0.height).toBeCloseTo(13.75, 3);
});
});

describe("getBezierMinValue", () => {
test("should return minimum value on the supplied cubic bezier", () => {
expect(getBezierMinValue(0, 10, 2, 8)).toBeCloseTo(0, 3);
expect(getBezierMinValue(0, 0, -10, -10)).toBeCloseTo(-7.5, 3);
expect(getBezierMinValue(10, 10, 0, 0)).toBeCloseTo(10 - 7.5, 3);
});
});

describe("getBezierMaxValue", () => {
test("should return maximum value on the supplied cubic bezier", () => {
expect(getBezierMaxValue(0, 10, 2, 8)).toBeCloseTo(10, 3);
expect(getBezierMaxValue(0, 0, 10, 10)).toBeCloseTo(7.5, 3);
expect(getBezierMaxValue(10, 10, 20, 20)).toBeCloseTo(17.5, 3);
});
});
92 changes: 92 additions & 0 deletions src/utils/geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ import {
vec,
} from "okageo";
import { CurveControl } from "../models";
import { pickMinItem } from "./commons";

export const BEZIER_APPROX_SIZE = 10;

export type ISegment = [IVec2, IVec2];

export type IRange = [min: number, max: number];

export type RotatedRectPath = [path: IVec2[], rotation: number];

export const TAU = Math.PI * 2;
Expand Down Expand Up @@ -558,3 +561,92 @@ export function getRotationAffines(rotation: number, origin: IVec2) {
]),
};
}

export function getBezierSplineBounds(points: IVec2[], controls: CurveControl[]): IRectangle {
const segments = getSegments(points);
const segmentRangeList = segments.map<{ x: IRange; y: IRange; p1: IVec2; p2: IVec2; c1: IVec2; c2: IVec2 }>(
([p1, p2], i) => {
const { c1, c2 } = controls[i];
return {
x: [Math.min(p1.x, p2.x, c1.x, c2.x), Math.max(p1.x, p2.x, c1.x, c2.x)],
y: [Math.min(p1.y, p2.y, c1.y, c2.y), Math.max(p1.y, p2.y, c1.y, c2.y)],
p1,
p2,
c1,
c2,
};
},
);

const candidatesMinX = segmentRangeList.reduce((list, item) => {
return list.filter((c) => c.x[0] <= item.x[1]);
}, segmentRangeList);
const candidatesMaxX = segmentRangeList.reduce((list, item) => {
return list.filter((c) => item.x[0] <= c.x[1]);
}, segmentRangeList);
const candidatesMinY = segmentRangeList.reduce((list, item) => {
return list.filter((c) => c.y[0] <= item.y[1]);
}, segmentRangeList);
const candidatesMaxY = segmentRangeList.reduce((list, item) => {
return list.filter((c) => item.y[0] <= c.y[1]);
}, segmentRangeList);

const minX = pickMinItem(
candidatesMinX.map((c) => getBezierMinValue(c.p1.x, c.p2.x, c.c1.x, c.c2.x)),
(v) => v,
)!;
const maxX = pickMinItem(
candidatesMaxX.map((c) => getBezierMaxValue(c.p1.x, c.p2.x, c.c1.x, c.c2.x)),
(v) => -v,
)!;
const minY = pickMinItem(
candidatesMinY.map((c) => getBezierMinValue(c.p1.y, c.p2.y, c.c1.y, c.c2.y)),
(v) => v,
)!;
const maxY = pickMinItem(
candidatesMaxY.map((c) => getBezierMaxValue(c.p1.y, c.p2.y, c.c1.y, c.c2.y)),
(v) => -v,
)!;
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}

export function getBezierMinValue(v1: number, v2: number, c1: number, c2: number): number {
const minV = Math.min(v1, v2);
if (minV <= c1 && minV <= c2) {
// The target point is at a vertex.
return minV;
} else {
// The target point is on the curve.
const [a, b, c] = getBezierDerivative(v1, v2, c1, c2);
const valued = solveEquationOrder2(a, b, c).filter((v) => 0 < v && v < 1);
return Math.min(minV, ...valued.map((v) => getBezierValue(v1, v2, c1, c2, v)));
}
}

export function getBezierMaxValue(v1: number, v2: number, c1: number, c2: number): number {
const maxV = Math.max(v1, v2);
if (c1 <= maxV && c2 <= maxV) {
// The target point is at a vertex.
return maxV;
} else {
// The target point is on the curve.
const [a, b, c] = getBezierDerivative(v1, v2, c1, c2);
const valued = solveEquationOrder2(a, b, c).filter((v) => 0 < v && v < 1);
return Math.max(maxV, ...valued.map((v) => getBezierValue(v1, v2, c1, c2, v)));
}
}

function getBezierDerivative(v1: number, v2: number, c1: number, c2: number): [a: number, b: number, c: number] {
const d1 = c1 - v1;
const d2 = c2 - c1;
const d3 = v2 - c2;
const a = 3 * d1 - 6 * d2 + 3 * d3;
const b = -6 * d1 + 6 * d2;
const c = 3 * d1;
return [a, b, c];
}

function getBezierValue(v1: number, v2: number, c1: number, c2: number, t: number): number {
const nt = 1 - t;
return v1 * nt * nt * nt + 3 * c1 * t * nt * nt + 3 * c2 * t * t * nt + v2 * t * t * t;
}

0 comments on commit d105b99

Please sign in to comment.