/** * 曲线离散化与二维仿射变换工具。 * * 约定: * - 变换矩阵一律用扁平数组 m = [a, b, c, d, e, f], * 对应 x' = a*x + c*y + e,y' = b*x + d*y + f(与 canvas setTransform 同序)。 * - 点集一律用扁平 Float64Array [x0,y0,x1,y1,...],世界坐标,忽略 Z。 */ export const TAU = Math.PI * 2 /** 相对弦高容差:段数按 r*EPS 的弦高计算,保证放大后仍圆滑 */ const CHORD_EPS = 1 / 2000 const MIN_SEG = 6 const MAX_SEG = 512 // ---------------------------------------------------------------- 仿射变换 export const IDENTITY = [1, 0, 0, 1, 0, 0] /** m2 之后再作用 m1(即先 m2 后 m1 的复合,等价于矩阵乘 m1·m2) */ export function mul(m1, m2) { return [ m1[0] * m2[0] + m1[2] * m2[1], m1[1] * m2[0] + m1[3] * m2[1], m1[0] * m2[2] + m1[2] * m2[3], m1[1] * m2[2] + m1[3] * m2[3], m1[0] * m2[4] + m1[2] * m2[5] + m1[4], m1[1] * m2[4] + m1[3] * m2[5] + m1[5], ] } /** 由插入点/缩放/旋转构造矩阵(DWG INSERT 的常规组合:先缩放后旋转再平移) */ export function makeInsertMatrix(tx, ty, sx, sy, rot) { const c = Math.cos(rot), s = Math.sin(rot) return [c * sx, s * sx, -s * sy, c * sy, tx, ty] } export function applyX(m, x, y) { return m[0] * x + m[2] * y + m[4] } export function applyY(m, x, y) { return m[1] * x + m[3] * y + m[5] } export function isIdentity(m) { return m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0 } /** * 判断矩阵是否为「共形」变换(等比缩放 + 旋转,可含镜像)。 * 共形时圆仍是圆、圆弧角度只需整体旋转,可以交给 canvas 的 arc/ellipse 精确绘制。 */ export function conformal(m) { const l1 = Math.hypot(m[0], m[1]) // 列 1 长度 const l2 = Math.hypot(m[2], m[3]) // 列 2 长度 if (l1 < 1e-12 || l2 < 1e-12) return null const dot = (m[0] * m[2] + m[1] * m[3]) / (l1 * l2) if (Math.abs(dot) > 1e-6) return null // 两轴不正交 → 有错切 if (Math.abs(l1 - l2) > 1e-9 * Math.max(l1, l2)) return null // 非等比 const det = m[0] * m[3] - m[1] * m[2] return { scale: l1, rotation: Math.atan2(m[1], m[0]), mirror: det < 0 } } /** 变换的「平均缩放」,用于线宽、文字高度等标量的换算 */ export function matrixScale(m) { return Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])) || Math.hypot(m[0], m[1]) || 1 } /** 原地变换扁平点集 */ export function transformPoints(pts, m) { if (isIdentity(m)) return pts const out = new Float64Array(pts.length) for (let i = 0; i < pts.length; i += 2) { const x = pts[i], y = pts[i + 1] out[i] = m[0] * x + m[2] * y + m[4] out[i + 1] = m[1] * x + m[3] * y + m[5] } return out } // ---------------------------------------------------------------- 离散化 /** 按弦高容差算圆弧分段数 */ export function arcSegCount(sweep) { const step = 2 * Math.acos(Math.max(-1, 1 - CHORD_EPS)) const n = Math.ceil(Math.abs(sweep) / step) return Math.min(MAX_SEG, Math.max(MIN_SEG, n)) } /** * 圆弧采样。角度为弧度,逆时针从 a0 到 a1(DWG/DXF 的圆弧一律逆时针)。 */ export function arcPoints(cx, cy, r, a0, a1) { let sweep = a1 - a0 while (sweep <= 0) sweep += TAU while (sweep > TAU) sweep -= TAU const n = arcSegCount(sweep) const out = new Float64Array((n + 1) * 2) for (let i = 0; i <= n; i++) { const a = a0 + (sweep * i) / n out[i * 2] = cx + r * Math.cos(a) out[i * 2 + 1] = cy + r * Math.sin(a) } return out } export function circlePoints(cx, cy, r) { const n = arcSegCount(TAU) const out = new Float64Array((n + 1) * 2) for (let i = 0; i <= n; i++) { const a = (TAU * i) / n out[i * 2] = cx + r * Math.cos(a) out[i * 2 + 1] = cy + r * Math.sin(a) } return out } /** * 椭圆(弧)采样。majX/majY 是长轴端点相对圆心的向量,ratio 为短轴/长轴。 * a0/a1 是 DXF 的「参数角」(不是几何角)。 */ export function ellipsePoints(cx, cy, majX, majY, ratio, a0 = 0, a1 = TAU) { const rx = Math.hypot(majX, majY) const ry = rx * ratio const rot = Math.atan2(majY, majX) let sweep = a1 - a0 if (Math.abs(sweep) < 1e-12) sweep = TAU while (sweep <= 0) sweep += TAU while (sweep > TAU + 1e-9) sweep -= TAU const n = arcSegCount(sweep) const cr = Math.cos(rot), sr = Math.sin(rot) const out = new Float64Array((n + 1) * 2) for (let i = 0; i <= n; i++) { const t = a0 + (sweep * i) / n const x = rx * Math.cos(t), y = ry * Math.sin(t) out[i * 2] = cx + x * cr - y * sr out[i * 2 + 1] = cy + x * sr + y * cr } return out } /** * 多段线凸度(bulge)段:bulge = tan(圆心角/4),正为逆时针。 * 返回不含起点、含终点的采样点(便于拼接)。 */ export function bulgeArcPoints(x0, y0, x1, y1, bulge) { const dx = x1 - x0, dy = y1 - y0 const chord = Math.hypot(dx, dy) if (chord < 1e-12 || Math.abs(bulge) < 1e-12) return [x1, y1] const theta = 4 * Math.atan(bulge) // 圆心角(带符号) const r = chord / (2 * Math.sin(Math.abs(theta) / 2)) // 圆心:弦中点沿法线偏移 const h = r * Math.cos(theta / 2) // 带符号的中点到圆心距离 const mx = (x0 + x1) / 2, my = (y0 + y1) / 2 const nx = -dy / chord, ny = dx / chord const cx = mx + nx * h, cy = my + ny * h const a0 = Math.atan2(y0 - cy, x0 - cx) const n = arcSegCount(theta) const out = [] for (let i = 1; i <= n; i++) { const a = a0 + (theta * i) / n out.push(cx + r * Math.cos(a), cy + r * Math.sin(a)) } return out } /** * NURBS / B 样条求值(de Boor 算法),支持有理(权重)样条。 * controlPoints: [{x,y,weight?}],knots: number[] */ export function splinePoints(degree, controlPoints, knots, closed = false) { const n = controlPoints.length if (n < 2) return new Float64Array(0) if (n === 2 || degree < 1) { const out = new Float64Array(n * 2) controlPoints.forEach((p, i) => { out[i * 2] = p.x; out[i * 2 + 1] = p.y }) return out } const p = Math.min(degree, n - 1) let kn = knots if (!kn || kn.length !== n + p + 1) kn = uniformKnots(n, p) // 采样密度:按控制多边形长度自适应,段数上限保证性能 let polyLen = 0 for (let i = 1; i < n; i++) { polyLen += Math.hypot(controlPoints[i].x - controlPoints[i - 1].x, controlPoints[i].y - controlPoints[i - 1].y) } const steps = Math.min(MAX_SEG, Math.max(MIN_SEG * 2, (n - p) * 12)) const t0 = kn[p], t1 = kn[n] if (!(t1 > t0)) return new Float64Array(0) const out = new Float64Array((steps + 1) * 2) for (let i = 0; i <= steps; i++) { const t = t0 + ((t1 - t0) * i) / steps const pt = deBoor(p, controlPoints, kn, i === steps ? t1 - 1e-12 : t) out[i * 2] = pt[0] out[i * 2 + 1] = pt[1] } if (closed) { /* 闭合样条由调用方补首尾 */ } return out } function uniformKnots(n, p) { const kn = [] for (let i = 0; i < n + p + 1; i++) { if (i <= p) kn.push(0) else if (i >= n) kn.push(n - p) else kn.push(i - p) } return kn } function deBoor(p, cps, kn, t) { const n = cps.length // 定位区间 let k = p while (k < n - 1 && kn[k + 1] <= t) k++ const dx = [], dy = [], dw = [] for (let j = 0; j <= p; j++) { const cp = cps[k - p + j] || cps[n - 1] const w = cp.weight == null ? 1 : cp.weight dx.push(cp.x * w); dy.push(cp.y * w); dw.push(w) } for (let r = 1; r <= p; r++) { for (let j = p; j >= r; j--) { const i = k - p + j const den = kn[i + p - r + 1] - kn[i] const a = den === 0 ? 0 : (t - kn[i]) / den dx[j] = (1 - a) * dx[j - 1] + a * dx[j] dy[j] = (1 - a) * dy[j - 1] + a * dy[j] dw[j] = (1 - a) * dw[j - 1] + a * dw[j] } } const w = dw[p] || 1 return [dx[p] / w, dy[p] / w] } /** 拟合点样条:用 Catmull-Rom 过点插值近似 */ export function fitPointCurve(fitPoints, closed = false) { const n = fitPoints.length if (n < 2) return new Float64Array(0) if (n === 2) return new Float64Array([fitPoints[0].x, fitPoints[0].y, fitPoints[1].x, fitPoints[1].y]) const segs = 12 const pts = [] const at = (i) => { if (closed) return fitPoints[((i % n) + n) % n] return fitPoints[Math.min(n - 1, Math.max(0, i))] } const last = closed ? n : n - 1 pts.push(fitPoints[0].x, fitPoints[0].y) for (let i = 0; i < last; i++) { const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2) for (let s = 1; s <= segs; s++) { const t = s / segs, t2 = t * t, t3 = t2 * t pts.push( 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3), 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3), ) } } return new Float64Array(pts) } // ---------------------------------------------------------------- 包围盒 /** * 椭圆弧的紧包围盒。 * * 不能直接用整圆的外接方框:机械图里常见半径几万、只画几度的过渡圆弧, * 按整圆算会把图纸范围撑大几个数量级,导致「全图」后什么都看不见。 * 这里取两个端点,再加上落在弧段内的 x/y 极值参数角。 */ export function arcBBox(cx, cy, rx, ry, rot, a0, a1) { const cr = Math.cos(rot), sr = Math.sin(rot) const at = (t) => { const x = rx * Math.cos(t), y = ry * Math.sin(t) return [cx + x * cr - y * sr, cy + x * sr + y * cr] } let sweep = a1 - a0 while (sweep <= 0) sweep += TAU if (sweep > TAU) sweep = TAU const inArc = (t) => { if (sweep >= TAU - 1e-9) return true let d = t - a0 while (d < 0) d += TAU while (d > TAU) d -= TAU return d <= sweep } const b = [Infinity, Infinity, -Infinity, -Infinity] const add = (p) => { if (p[0] < b[0]) b[0] = p[0] if (p[1] < b[1]) b[1] = p[1] if (p[0] > b[2]) b[2] = p[0] if (p[1] > b[3]) b[3] = p[1] } add(at(a0)) add(at(a0 + sweep)) // dx/dt = 0 与 dy/dt = 0 的参数角 const tx = Math.atan2(-ry * sr, rx * cr) const ty = Math.atan2(ry * cr, rx * sr) for (const t of [tx, tx + Math.PI, ty, ty + Math.PI]) { if (inArc(t)) add(at(t)) } return b } export function bboxOfPoints(pts, box) { const b = box || [Infinity, Infinity, -Infinity, -Infinity] for (let i = 0; i < pts.length; i += 2) { const x = pts[i], y = pts[i + 1] if (x < b[0]) b[0] = x if (y < b[1]) b[1] = y if (x > b[2]) b[2] = x if (y > b[3]) b[3] = y } return b } export function growBox(b, o) { if (o[0] < b[0]) b[0] = o[0] if (o[1] < b[1]) b[1] = o[1] if (o[2] > b[2]) b[2] = o[2] if (o[3] > b[3]) b[3] = o[3] return b }