Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: hit path graphics stroke with high precision #176

Merged
merged 1 commit into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/src/graphs/graphics/graphics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,14 +281,14 @@
});
}

hitTest(x: number, y: number, padding = 0) {
hitTest(x: number, y: number, tol = 0) {
return isPointInRect(
{ x, y },
{
...this.getSize(),
transform: this.getWorldTransform(),
},
padding + this.getStrokeWidth() / 2,
tol + this.getStrokeWidth() / 2,
);
}

Expand Down Expand Up @@ -756,7 +756,7 @@
return content;
}

protected getSVGTagHead(_offset?: IPoint) {

Check warning on line 759 in packages/core/src/graphs/graphics/graphics.ts

View workflow job for this annotation

GitHub Actions / eslint

'_offset' is defined but never used
return '';
}

Expand Down
66 changes: 32 additions & 34 deletions packages/core/src/graphs/path/path.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { cloneDeep, parseHexToRGBA, parseRGBAStr } from '@suika/common';
import {
GeoPath,
type IMatrixArr,
invertMatrix,
type IPathItem,
Expand All @@ -13,7 +14,6 @@ import {
resizeLine,
resizeRect,
} from '@suika/geo';
import { Bezier } from 'bezier-js';

import { type ImgManager } from '../../Img_manager';
import { type IPaint, PaintType } from '../../paint';
Expand All @@ -30,6 +30,7 @@ export interface PathAttrs extends GraphicsAttrs {

export class SuikaPath extends SuikaGraphics<PathAttrs> {
override type = GraphicsType.Path;
private geoPath: GeoPath | null = null;

constructor(
attrs: Optional<PathAttrs, 'id' | 'transform'>,
Expand All @@ -39,39 +40,8 @@ export class SuikaPath extends SuikaGraphics<PathAttrs> {
}

static computeRect(pathData: IPathItem[]): IRect {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const pathItem of pathData) {
const segs = pathItem.segs;
for (let i = 1; i <= segs.length; i++) {
if (i === segs.length && !pathItem.closed) {
continue;
}
const seg = segs[i % segs.length];
const prevSeg = segs[i - 1];
const bbox = new Bezier(
prevSeg.point,
SuikaPath.getHandleOut(prevSeg),
SuikaPath.getHandleIn(seg),
seg.point,
).bbox();
minX = Math.min(minX, bbox.x.min);
minY = Math.min(minY, bbox.y.min);
maxX = Math.max(maxX, bbox.x.max);
maxY = Math.max(maxY, bbox.y.max);
}
}
if (minX === Infinity) {
return { x: 0, y: 0, width: 100, height: 100 };
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
const geoPath = new GeoPath(pathData);
return geoPath.getBbox();
}

static recomputeAttrs(pathData: IPathItem[], transform: IMatrixArr) {
Expand Down Expand Up @@ -107,6 +77,18 @@ export class SuikaPath extends SuikaGraphics<PathAttrs> {
}
}

protected override clearBboxCache(): void {
super.clearBboxCache();
this.geoPath = null;
}

private getGeoPath() {
if (!this.geoPath) {
this.geoPath = new GeoPath(this.attrs.pathData);
}
return this.geoPath!;
}

/**
* update attributes
* TODO: optimize
Expand Down Expand Up @@ -311,6 +293,22 @@ export class SuikaPath extends SuikaGraphics<PathAttrs> {
ctx.restore();
}

override hitTest(x: number, y: number, tol = 0): boolean {
if (!super.hitTest(x, y, tol)) {
return false;
}
if (this.attrs.fill?.length) {
// TODO: fill hit test
return true;
}

const tf = new Matrix(...this.getWorldTransform());
const point = tf.applyInverse({ x, y });
const geoPath = this.getGeoPath();
const { dist } = geoPath.project(point);
return dist <= tol + this.getStrokeWidth() / 2;
}

override toJSON() {
return {
...super.toJSON(),
Expand Down
1 change: 1 addition & 0 deletions packages/geo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"build": "tsc && vite build"
},
"dependencies": {
"@suika/common": "workspace:^",
"fit-curve": "^0.2.0"
},
"devDependencies": {
Expand Down
67 changes: 67 additions & 0 deletions packages/geo/src/geo/geo_bezier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { type IPoint, type ISegment } from '../type';
import { lerp, pointAdd } from './geo_point';

export const getBezierPoint = (points: IPoint[], t: number) => {
if (points.length === 4) {
const [p1, cp1, cp2, p2] = points;
const t2 = t * t;
const ct = 1 - t;
const ct2 = ct * ct;
const a = ct2 * ct;
const b = 3 * t * ct2;
const c = 3 * t2 * ct;
const d = t2 * t;

return {
x: a * p1.x + b * cp1.x + c * cp2.x + d * p2.x,
y: a * p1.y + b * cp1.y + c * cp2.y + d * p2.y,
};
}

while (points.length > 1) {
const nextPts = [];
for (let i = 0, size = points.length - 1; i < size; i++) {
nextPts.push(lerp(points[i], points[i + 1], t));
}
points = nextPts;
}
return points[0];
};

export const breakSegs = (
seg1: ISegment,
seg2: ISegment,
t: number,
): [ISegment, ISegment, ISegment] => {
const p1 = seg1.point;
const p2 = seg2.point;
const cp1 = pointAdd(seg1.point, seg1.out);
const cp2 = pointAdd(seg2.point, seg2.in);

const a = lerp(p1, cp1, t);
const b = lerp(cp1, cp2, t);
const c = lerp(cp2, p2, t);

const d = lerp(a, b, t);
const e = lerp(b, c, t);

const f = lerp(d, e, t);

return [
{
point: seg1.point,
in: seg1.in,
out: a,
},
{
point: f,
in: d,
out: e,
},
{
point: seg2.point,
in: e,
out: seg2.out,
},
];
};
183 changes: 183 additions & 0 deletions packages/geo/src/geo/geo_bezier_class.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { type IBox, type IPoint } from '../type';
import { getPointsBbox, isPointInBox } from './geo_box';
import { distance } from './geo_point';

type IBezierPoints = [IPoint, IPoint, IPoint, IPoint];

/**
* cubic bezier
*/
export class GeoBezier {
private points: IBezierPoints;
private dpoints: IPoint[] = []; // control points of derivative
private _bbox: IBox | null = null;
private lut: { t: number; pt: IPoint }[] = []; // lookup table

constructor(points: IBezierPoints) {
this.points = points;

this.dpoints[0] = {
x: 3 * (points[1].x - points[0].x),
y: 3 * (points[1].y - points[0].y),
};
this.dpoints[1] = {
x: 3 * (points[2].x - points[1].x),
y: 3 * (points[2].y - points[1].y),
};
this.dpoints[2] = {
x: 3 * (points[3].x - points[2].x),
y: 3 * (points[3].y - points[2].y),
};
}

compute(t: number) {
const t2 = t * t;
const ct = 1 - t;
const ct2 = ct * ct;
const a = ct2 * ct;
const b = 3 * t * ct2;
const c = 3 * t2 * ct;
const d = t2 * t;

const [p1, cp1, cp2, p2] = this.points;

return {
x: a * p1.x + b * cp1.x + c * cp2.x + d * p2.x,
y: a * p1.y + b * cp1.y + c * cp2.y + d * p2.y,
};
}

extrema() {
const dpoints = this.dpoints;
const extrema = [
...getRoot(dpoints[0].x, dpoints[1].x, dpoints[2].x),
...getRoot(dpoints[0].y, dpoints[1].y, dpoints[2].y),
].filter((t) => t >= 0 && t <= 1);
return Array.from(new Set(extrema));
}

getBbox() {
if (!this._bbox) {
const extremaPoints = this.extrema().map((t) => this.compute(t));
this._bbox = getPointsBbox([
...extremaPoints,
this.points[0],
this.points[3],
]);
}
return this._bbox;
}

private checkInBbox(point: IPoint, tol = 0) {
return isPointInBox(this.getBbox(), point, tol);
}

// TODO:
hitTest(point: IPoint, tol: number) {
if (!this.checkInBbox(point, tol)) {
return false;
}
}

getLookupTable() {
if (this.lut.length === 0) {
const count = 100;
for (let i = 0; i <= count; i++) {
const t = i / count;
const pt = this.compute(t);
this.lut[i] = { t, pt };
}
}
return this.lut;
}

project(targetPt: IPoint) {
const lookupTable = this.getLookupTable();

let minDist = Number.MAX_SAFE_INTEGER;
let minIndex = -1;

for (let i = 0; i < lookupTable.length; i++) {
const item = lookupTable[i];
const dist = distance(targetPt, item.pt); // TODO: optimize, no sqrt
if (dist < minDist) {
minDist = dist;
minIndex = i;
if (dist === 0) {
break;
}
}
}

if (minDist === 0) {
const projectPt = this.compute(lookupTable[minIndex].t);
return {
point: projectPt,
t: lookupTable[minIndex].t,
dist: distance(targetPt, projectPt),
};
}

let minT = lookupTable[minIndex].t;

const t1 = minIndex > 0 ? lookupTable[minIndex - 1].t : minT;
const t2 =
minIndex < lookupTable.length - 1 ? lookupTable[minIndex + 1].t : minT;

const step = 0.001;
for (let t = t1; t <= t2; t += step) {
const pt = this.compute(t);
const dist = distance(targetPt, pt); // TODO: optimize, no sqrt
if (dist < minDist) {
minDist = dist;
minT = t;
if (dist === 0) {
break;
}
}
}

if (minT < 0) {
minT = 0;
}
if (minT > 1) {
minT = 1;
}

const projectPt = this.compute(minT);
return {
point: projectPt,
t: minT,
dist: distance(targetPt, projectPt),
};
}
}

const getRoot = (a: number, b: number, c: number) => {
// denominator
const d = a - 2 * b + c;

if (d !== 0) {
// quadratic equation
const deltaSquare = b * b - a * c;
if (deltaSquare < 0) {
// no real roots
return [];
}
const delta = Math.sqrt(deltaSquare);
const m = a - b;
if (delta === 0) {
// one real root
return [(m - delta) / d];
} else {
// two distinct roots
return [(m - delta) / d, (m + delta) / d];
}
} else if (a !== b) {
// linear equation
return [a / (a - b) / 2];
} else {
// no equation
return [];
}
};
9 changes: 9 additions & 0 deletions packages/geo/src/geo/geo_box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { type IBox, type IPoint, type ITransformRect } from '../type';
import { applyMatrix } from './geo_matrix';
import { rectToVertices } from './geo_rect';

export const isPointInBox = (box: IBox, point: IPoint, tol = 0) => {
return (
point.x >= box.minX - tol &&
point.y >= box.minY - tol &&
point.x <= box.maxX + tol &&
point.y <= box.maxY + tol
);
};

/**
* get merged rect from rects
*/
Expand Down
Loading
Loading