Files
Endoleap/src/lib/canvas-curves.ts
T

48 lines
4.1 KiB
TypeScript

/**
* 旧贝塞尔连线绘制与插入控制结点。基础分段复用core几何实现,插入用de Casteljau细分保持原曲线形状,
* 最近位置用有限采样近似,不把编辑辅助采样当作严格命中查询或精确最近点解算。
*/
import {bezierChainSegments} from './canvas-core/geometry.ts';
import type { XYPosition } from "@xyflow/react";
import type { Connector } from "./canvas-geometry";
import type { EdgeData } from "./canvas-v2";
/** 一段三次贝塞尔的起点、两控制点和终点,全部处在同一显示坐标空间。 */
export type Cubic = [XYPosition, XYPosition, XYPosition, XYPosition];
/** 点/向量相加,用于把相对控制向量还原成绝对控制点。 */
export const plus = (a: XYPosition, b: XYPosition) => ({ x: a.x + b.x, y: a.y + b.y });
/** 差向量,用于把细分后的控制点转换回结点相对切线。 */
export const minus = (a: XYPosition, b: XYPosition) => ({ x: a.x - b.x, y: a.y - b.y });
/** 线性插值,供de Casteljau逐层构造等价三次子曲线。 */
const lerp = (a: XYPosition, b: XYPosition, t: number) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
/** 从端点法线和可选贝塞尔链获得统一三次段列表,不单独复制另一套路径解释规则。 */
export function curveSegments(from:Connector,to:Connector,path?:EdgeData["path"]):Cubic[]{return bezierChainSegments(from,to,path) as Cubic[];}
/** 在参数t处用三层线性插值求曲线上一点,不修改原控制点。 */
export function cubicPoint([a, b, c, d]: Cubic, t: number) { const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t); return lerp(lerp(ab, bc, t), lerp(bc, cd, t), t); }
/** 将非空段列表拼成一个M及多个C命令;调用方负责提供有效几何和至少一段。 */
export function curvePath(segments: Cubic[]) { return `M ${segments[0][0].x},${segments[0][0].y} ` + segments.map(([, b, c, d]) => `C ${b.x},${b.y} ${c.x},${c.y} ${d.x},${d.y}`).join(" "); }
/** Split the closest segment without altering its shape. */
/** 给定指针时对每段内部39个采样点选最近者,无指针默认中间段t=.5。
* 选点是近似,按所选t进行的曲线细分本身保持形状;返回新path与新UUID结点,不直接改history。 */
export function insertCurveKnot(from: Connector, to: Connector, path: EdgeData["path"], point?: XYPosition) {
const segments = curveSegments(from, to, path);
let selected = Math.floor(segments.length / 2), t = .5, distance = Infinity;
// 只采内部参数 .025~.975;距离相同时保留先遇到的段/采样点,不会选择端点 t=0 或1。
if (point) segments.forEach((segment, index) => { for (let step = 1; step < 40; step++) {
const p = cubicPoint(segment, step / 40), d = Math.hypot(p.x - point.x, p.y - point.y);
if (d < distance) { distance = d; selected = index; t = step / 40; }
} });
// 逐层插值求左右子曲线,anchor成为新结点,abc/bcd决定其两侧相对切线。
const [a, b, c, d] = segments[selected];
const ab = lerp(a, b, t), bc = lerp(b, c, t), cd = lerp(c, d, t), abc = lerp(ab, bc, t), bcd = lerp(bc, cd, t), anchor = lerp(abc, bcd, t);
// clone 隔离已有 knots;返回的 knot 同时就是 next.knots 中插入的对象,二者并非独立副本。
const next = structuredClone(path || { type: "bezier-chain" as const, knots: [] });
const knot = { id: crypto.randomUUID(), position: anchor, in: minus(abc, anchor), out: minus(bcd, anchor), mode: "smooth" as const };
// 同时调整相邻旧结点或首尾控制向量,否则插入后无法保留原曲线。
if (selected) next.knots[selected - 1].out = minus(ab, a); else next.startControl = minus(ab, a);
if (selected < next.knots.length) next.knots[selected].in = minus(cd, d); else next.endControl = minus(cd, d);
// Splitting one side of an existing symmetric knot can make its handle lengths differ.
// 仅细分一侧会破坏旧对称结点等长约束,降为smooth保留共线而不强行改变曲线。
for (const index of [selected - 1, selected]) if (next.knots[index]?.mode === "symmetric") next.knots[index].mode = "smooth";
next.knots.splice(selected, 0, knot); return { path: next, knot };
}