323 lines
11 KiB
JavaScript
323 lines
11 KiB
JavaScript
/**
|
||
* 曲线离散化与二维仿射变换工具。
|
||
*
|
||
* 约定:
|
||
* - 变换矩阵一律用扁平数组 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
|
||
}
|