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: optimize freehand curve #59

Closed
wants to merge 7 commits into from
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export class FreehandGenerator extends Generator<Freehand> {
protected draw(element: Freehand): SVGGElement | undefined {
const option: Options = { ...DefaultFreehand };
const g = PlaitBoard.getRoughSVG(this.board).curve(
gaussianSmooth(element.points, 1.2, 4),
gaussianSmooth(element.points, 1, 3),
option
);
setStrokeLinecap(g, 'round');
Expand Down
256 changes: 256 additions & 0 deletions packages/drawnix/src/plugins/freehand/smoother.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { Point } from '@plait/core';

interface StrokePoint {
point: Point;
pressure?: number;
timestamp: number;
tiltX?: number;
tiltY?: number;
}

export interface SmootherOptions {
smoothing?: number;
velocityWeight?: number;
curvatureWeight?: number;
minDistance?: number;
maxPoints?: number;
pressureSensitivity?: number;
tiltSensitivity?: number;
velocityThreshold?: number;
samplingRate?: number;
}

export class FreehandSmoother {
private readonly defaultOptions: Required<SmootherOptions> = {
smoothing: 0.65,
velocityWeight: 0.2,
curvatureWeight: 0.3,
minDistance: 0.2, // 降低最小距离阈值
maxPoints: 8,
pressureSensitivity: 0.5,
tiltSensitivity: 0.3,
velocityThreshold: 800,
samplingRate: 5, // 降低采样间隔
};

private options: Required<SmootherOptions>;
private points: StrokePoint[] = [];
private lastProcessedTime = 0;
private movingAverageVelocity: number[] = [];
private readonly velocityWindowSize = 3;

constructor(options: SmootherOptions = {}) {
this.options = { ...this.defaultOptions, ...options };
}

process(
point: Point,
data: Partial<Omit<StrokePoint, 'point'>> = {}
): Point | null {
const timestamp = data.timestamp ?? Date.now();

// 第一个点直接返回
if (this.points.length === 0) {
const strokePoint: StrokePoint = { point, timestamp, ...data };
this.points.push(strokePoint);
this.lastProcessedTime = timestamp;
return point;
}

// 采样率控制 - 确保不会卡住
if (timestamp - this.lastProcessedTime < this.options.samplingRate) {
const timeDiff = timestamp - this.lastProcessedTime;
if (timeDiff < 2) {
// 如果时间间隔太小,跳过
return null;
}
}

const strokePoint: StrokePoint = {
point,
timestamp,
...data,
};

// 距离检查 - 添加最小距离的动态调整
const distanceOk = this.checkDistance(point);
if (!distanceOk && this.points.length > 1) {
// 如果距离太近,但时间间隔较大,仍然处理该点
const timeDiff = timestamp - this.lastProcessedTime;
if (timeDiff < 32) {
// 32ms ≈ 30fps
return null;
}
}

// 更新历史点
this.updatePoints(strokePoint);

// 计算动态参数
const dynamicParams = this.calculateDynamicParameters(strokePoint);

// 应用平滑
const smoothedPoint = this.smooth(point, dynamicParams);

this.lastProcessedTime = timestamp;
return smoothedPoint;
}

reset(): void {
this.points = [];
this.lastProcessedTime = 0;
this.movingAverageVelocity = [];
}

private updatePoints(point: StrokePoint): void {
this.points.push(point);
if (this.points.length > this.options.maxPoints) {
this.points.shift();
}
}

private checkDistance(point: Point): boolean {
if (this.points.length === 0) return true;

const lastPoint = this.points[this.points.length - 1].point;
const distance = this.getDistance(lastPoint, point);

// 动态最小距离:根据当前速度调整
let minDistance = this.options.minDistance;
if (this.movingAverageVelocity.length > 0) {
const avgVelocity = this.getAverageVelocity();
minDistance *= Math.max(0.5, Math.min(1.5, avgVelocity / 200));
}

return distance >= minDistance;
}

private calculateDynamicParameters(strokePoint: StrokePoint) {
const velocity = this.calculateVelocity(strokePoint);
this.updateMovingAverage(velocity);
const avgVelocity = this.getAverageVelocity();

const params = { ...this.options };

// 压力适应 - 更温和的压力响应
if (strokePoint.pressure !== undefined) {
const pressureWeight = Math.pow(strokePoint.pressure, 1.2);
params.smoothing *= 1 - pressureWeight * params.pressureSensitivity * 0.8;
}

// 速度适应 - 更平滑的过渡
const velocityFactor = Math.min(avgVelocity / params.velocityThreshold, 1);
params.velocityWeight = 0.2 + velocityFactor * 0.3;
params.smoothing *= 1 + velocityFactor * 0.2;

// 倾斜适应 - 更温和的响应
if (strokePoint.tiltX !== undefined && strokePoint.tiltY !== undefined) {
const tiltFactor =
Math.sqrt(strokePoint.tiltX ** 2 + strokePoint.tiltY ** 2) / 90;
params.smoothing *= 1 + tiltFactor * params.tiltSensitivity * 0.7;
}

return params;
}

private smooth(point: Point, params: Required<SmootherOptions>): Point {
if (this.points.length < 2) return point;

const weights = this.calculateWeights(params);
const totalWeight = weights.reduce((sum, w) => sum + w, 0);

if (totalWeight === 0) return point;

const smoothedPoint: Point = [0, 0];
for (let i = 0; i < this.points.length; i++) {
const weight = weights[i] / totalWeight;
smoothedPoint[0] += this.points[i].point[0] * weight;
smoothedPoint[1] += this.points[i].point[1] * weight;
}

return smoothedPoint;
}

private calculateWeights(params: Required<SmootherOptions>): number[] {
const weights: number[] = [];
const lastIndex = this.points.length - 1;

for (let i = 0; i < this.points.length; i++) {
// 基础权重 - 使用更温和的衰减
let weight = Math.pow(params.smoothing, (lastIndex - i) * 0.8);

// 速度权重 - 更平滑的过渡
if (i < lastIndex) {
const velocity = this.getPointVelocity(i);
weight *= 1 + velocity * params.velocityWeight * 0.8;
}

// 曲率权重 - 更温和的影响
if (i > 0 && i < lastIndex) {
const curvature = this.getPointCurvature(i);
weight *= 1 + curvature * params.curvatureWeight * 0.7;
}

weights.push(weight);
}

return weights;
}

// 工具方法保持不变
private getDistance(p1: Point, p2: Point): number {
const dx = p2[0] - p1[0];
const dy = p2[1] - p1[1];
return Math.sqrt(dx * dx + dy * dy);
}

private calculateVelocity(point: StrokePoint): number {
if (this.points.length < 2) return 0;

const prevPoint = this.points[this.points.length - 1];
const distance = this.getDistance(prevPoint.point, point.point);
const timeDiff = point.timestamp - prevPoint.timestamp;
return timeDiff > 0 ? distance / timeDiff : 0;
}

private updateMovingAverage(velocity: number): void {
this.movingAverageVelocity.push(velocity);
if (this.movingAverageVelocity.length > this.velocityWindowSize) {
this.movingAverageVelocity.shift();
}
}

private getAverageVelocity(): number {
if (this.movingAverageVelocity.length === 0) return 0;
return (
this.movingAverageVelocity.reduce((a, b) => a + b) /
this.movingAverageVelocity.length
);
}

private getPointVelocity(index: number): number {
if (index >= this.points.length - 1) return 0;

const p1 = this.points[index];
const p2 = this.points[index + 1];
const distance = this.getDistance(p1.point, p2.point);
const timeDiff = p2.timestamp - p1.timestamp;
return timeDiff > 0 ? distance / timeDiff : 0;
}

private getPointCurvature(index: number): number {
if (index <= 0 || index >= this.points.length - 1) return 0;

const p1 = this.points[index - 1].point;
const p2 = this.points[index].point;
const p3 = this.points[index + 1].point;

const a = this.getDistance(p1, p2);
const b = this.getDistance(p2, p3);
const c = this.getDistance(p1, p3);

const s = (a + b + c) / 2;
const area = Math.sqrt(Math.max(0, s * (s - a) * (s - b) * (s - c)));
return (4 * area) / (a * b * c + 0.0001); // 避免除零
}
}
56 changes: 29 additions & 27 deletions packages/drawnix/src/plugins/freehand/with-freehand-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { isDrawingMode } from '@plait/common';
import { createFreehandElement, getFreehandPointers } from './utils';
import { Freehand, FreehandShape } from './type';
import { FreehandGenerator } from './freehand.generator';
import { FreehandSmoother } from './smoother';

export const withFreehandCreate = (board: PlaitBoard) => {
const { pointerDown, pointerMove, pointerUp, globalPointerUp } = board;
Expand All @@ -21,9 +22,12 @@ export const withFreehandCreate = (board: PlaitBoard) => {

const generator = new FreehandGenerator(board);

let temporaryElement: Freehand | null = null;
const smoother = new FreehandSmoother({
smoothing: 0.7,
pressureSensitivity: 0.6,
});

let previousScreenPoint: Point | null = null;
let temporaryElement: Freehand | null = null;

const complete = (cancel?: boolean) => {
if (isDrawing) {
Expand All @@ -37,45 +41,43 @@ export const withFreehandCreate = (board: PlaitBoard) => {
temporaryElement = null;
isDrawing = false;
points = [];
previousScreenPoint = null;
smoother.reset();
};

board.pointerDown = (event: PointerEvent) => {
const freehandPointers = getFreehandPointers();
const isFreehandPointer = PlaitBoard.isInPointer(board, freehandPointers);
if (isFreehandPointer && isDrawingMode(board)) {
isDrawing = true;
const point = toViewBoxPoint(board, toHostPoint(board, event.x, event.y));
const originPoint: Point = [event.x, event.y];
const smoothingPoint = smoother.process(originPoint) as Point;
const point = toViewBoxPoint(
board,
toHostPoint(board, smoothingPoint[0], smoothingPoint[1])
);
points.push(point);
previousScreenPoint = [event.x, event.y];
}
pointerDown(event);
};

board.pointerMove = (event: PointerEvent) => {
if (isDrawing && previousScreenPoint) {
const distance = distanceBetweenPointAndPoint(
previousScreenPoint[0],
previousScreenPoint[1],
event.x,
event.y
);
if (distance <= 0.5) {
return;
if (isDrawing) {
const originPoint: Point = [event.x, event.y];
const smoothingPoint = smoother.process(originPoint);
if (smoothingPoint) {
generator?.destroy();
const newPoint = toViewBoxPoint(
board,
toHostPoint(board, smoothingPoint[0], smoothingPoint[1])
);
points.push(newPoint);
const pointer = PlaitBoard.getPointer(board) as FreehandShape;
temporaryElement = createFreehandElement(pointer, points);
generator.processDrawing(
temporaryElement,
PlaitBoard.getElementActiveHost(board)
);
}
previousScreenPoint = [event.x, event.y];
generator?.destroy();
const newPoint = toViewBoxPoint(
board,
toHostPoint(board, event.x, event.y)
);
points.push(newPoint);
const pointer = PlaitBoard.getPointer(board) as FreehandShape;
temporaryElement = createFreehandElement(pointer, points);
generator.processDrawing(
temporaryElement,
PlaitBoard.getElementActiveHost(board)
);
return;
}

Expand Down
Loading