273 lines
10 KiB
JavaScript
273 lines
10 KiB
JavaScript
/**
|
||
* HATCH(填充/剖面线)的边界提取与图案线生成。
|
||
*
|
||
* 机械图纸里剖面线占比很高,只画边界会看着很空,所以这里按 AutoCAD 的
|
||
* 图案定义线(definitionLines)真实生成一族平行线,再用奇偶规则裁剪到边界内。
|
||
*/
|
||
|
||
import { arcPoints, bulgeArcPoints, ellipsePoints, splinePoints } from './curves.js'
|
||
|
||
/** 单个填充最多生成多少条图案线(防止比例异常的图案把页面拖死) */
|
||
const MAX_PATTERN_LINES = 1200
|
||
const MAX_SEGMENTS = 20000
|
||
|
||
/**
|
||
* 内置的 AutoCAD 预定义图案表(摘自 acad.pat,单位为英寸、角度为度)。
|
||
*
|
||
* DWG/DXF 对 ANSI31 这类预定义图案只存名字 + 角度 + 比例,定义线要靠 acad.pat 现场展开。
|
||
* 机械图的剖面线基本都是 ANSI31,没有这张表的话整个剖面区域会是空的。
|
||
* 每条定义线:[角度, 基点x, 基点y, 沿线偏移, 行距, ...虚线段]
|
||
*/
|
||
const BUILTIN_PATTERNS = {
|
||
ANSI31: [[45, 0, 0, 0, 0.125]],
|
||
ANSI32: [[45, 0, 0, 0, 0.375], [45, 0.176776695, 0, 0, 0.375]],
|
||
ANSI33: [[45, 0, 0, 0, 0.25], [45, 0.176776695, 0, 0, 0.25, 0.125, -0.0625]],
|
||
ANSI34: [[45, 0, 0, 0, 0.75], [45, 0.176776695, 0, 0, 0.75],
|
||
[45, 0.353553391, 0, 0, 0.75], [45, 0.530330086, 0, 0, 0.75]],
|
||
ANSI35: [[45, 0, 0, 0, 0.25], [45, 0.176776695, 0, 0, 0.25, 0.3125, -0.0625, 0, -0.0625]],
|
||
ANSI36: [[45, 0, 0, 0.21875, 0.125, 0.3125, -0.0625, 0, -0.0625]],
|
||
ANSI37: [[45, 0, 0, 0, 0.125], [135, 0, 0, 0, 0.125]],
|
||
ANSI38: [[45, 0, 0, 0, 0.125], [135, 0, 0, 0, 0.125, 0.3125, -0.1875]],
|
||
LINE: [[0, 0, 0, 0, 0.125]],
|
||
NET: [[0, 0, 0, 0, 0.125], [90, 0, 0, 0, 0.125]],
|
||
CROSS: [[0, 0, 0, 0.125, 0.125, 0.0625, -0.0625], [90, 0, 0, 0.125, 0.125, 0.0625, -0.0625]],
|
||
DOTS: [[0, 0, 0, 0.03125, 0.0625, 0, -0.0625]],
|
||
STEEL: [[45, 0, 0, 0, 0.125], [45, 0.0883883476, 0, 0, 0.125]],
|
||
GRASS: [[0, 0, 0, 0, 0.25]],
|
||
EARTH: [[0, 0, 0, 0.25, 0.25, 0.25, -0.125], [0, 0, 0.0625, 0.25, 0.25, 0.25, -0.125]],
|
||
}
|
||
|
||
const RAD = Math.PI / 180
|
||
|
||
/**
|
||
* 按图案名 + 角度 + 比例展开出定义线。
|
||
* @returns {Array|null} 与文件里 definitionLines 同构的数组
|
||
*/
|
||
export function builtinDefinitionLines(name, angle = 0, scale = 1) {
|
||
if (!name) return null
|
||
const key = String(name).toUpperCase().replace(/^[*_]/, '')
|
||
const def = BUILTIN_PATTERNS[key] || (key === 'SOLID' ? null : BUILTIN_PATTERNS.ANSI31)
|
||
if (!def) return null
|
||
const s = scale > 0 ? scale : 1
|
||
const ca = Math.cos(angle), sa = Math.sin(angle)
|
||
return def.map((d) => {
|
||
const [a, bx, by, dx, dy, ...dashes] = d
|
||
const la = a * RAD + angle // 该定义线的最终角度
|
||
// 基点按图案角度旋转;偏移量按定义线角度旋转 —— 与 AutoCAD 写进
|
||
// DXF 43/44、45/46 的值保持一致(都是世界坐标系下的量)
|
||
const sx = bx * s, sy = by * s
|
||
const cl = Math.cos(la), sl = Math.sin(la)
|
||
const ox = dx * s, oy = dy * s
|
||
return {
|
||
angle: la,
|
||
base: { x: sx * ca - sy * sa, y: sx * sa + sy * ca },
|
||
offset: { x: ox * cl - oy * sl, y: ox * sl + oy * cl },
|
||
numberOfDashLengths: dashes.length,
|
||
dashLengths: dashes.map((v) => v * s),
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 把 HATCH 的边界路径解析成一组闭合环(实体自身坐标系下的扁平点数组)。
|
||
*/
|
||
export function hatchLoops(ent) {
|
||
const loops = []
|
||
for (const path of ent.boundaryPaths || []) {
|
||
if (!path) continue
|
||
const pts = []
|
||
if (path.vertices && path.vertices.length) {
|
||
// 多段线型边界
|
||
const vs = path.vertices
|
||
for (let i = 0; i < vs.length; i++) {
|
||
const v = vs[i]
|
||
if (!v) continue
|
||
if (i === 0) pts.push(v.x, v.y)
|
||
const nx = vs[i + 1] || (path.isClosed ? vs[0] : null)
|
||
if (!nx) break
|
||
if (path.hasBulge && v.bulge) pts.push(...bulgeArcPoints(v.x, v.y, nx.x, nx.y, v.bulge))
|
||
else pts.push(nx.x, nx.y)
|
||
}
|
||
} else if (path.edges) {
|
||
// libredwg 偶尔会给出带空洞的 edges 数组,逐个防一手
|
||
for (const e of path.edges) {
|
||
if (e && e.type != null) appendEdge(pts, e)
|
||
}
|
||
}
|
||
if (pts.length >= 6) loops.push(new Float64Array(pts))
|
||
}
|
||
return loops
|
||
}
|
||
|
||
function appendEdge(pts, e) {
|
||
const push = (arr) => {
|
||
// 去掉与上一点重复的首点,避免断裂
|
||
let s = 0
|
||
if (pts.length >= 2 && arr.length >= 2 &&
|
||
Math.abs(pts[pts.length - 2] - arr[0]) < 1e-9 && Math.abs(pts[pts.length - 1] - arr[1]) < 1e-9) s = 2
|
||
for (let i = s; i < arr.length; i++) pts.push(arr[i])
|
||
}
|
||
switch (e.type) {
|
||
case 1: // Line
|
||
if (!e.start || !e.end) break
|
||
push([e.start.x, e.start.y, e.end.x, e.end.y])
|
||
break
|
||
case 2: { // Circular arc(角度已在解析层统一为弧度)
|
||
if (!e.center || !(e.radius > 0)) break
|
||
const p = arcPoints(e.center.x, e.center.y, e.radius, e.startAngle || 0, e.endAngle || 0)
|
||
push(e.isCCW === false ? reverse(p) : p)
|
||
break
|
||
}
|
||
case 3: { // Elliptic arc;end 是长轴端点相对圆心的向量
|
||
if (!e.center || !e.end) break
|
||
const p = ellipsePoints(e.center.x, e.center.y, e.end.x, e.end.y,
|
||
e.lengthOfMinorAxis, e.startAngle || 0, e.endAngle || 0)
|
||
push(e.isCCW === false ? reverse(p) : p)
|
||
break
|
||
}
|
||
case 4: { // Spline
|
||
const cps = (e.controlPoints || []).map((c) => ({ x: c.x, y: c.y, weight: c.weight }))
|
||
if (cps.length) push(splinePoints(e.degree || 3, cps, e.knots))
|
||
else if (e.fitDatum && e.fitDatum.length) {
|
||
const arr = []
|
||
for (const p of e.fitDatum) arr.push(p.x, p.y)
|
||
push(arr)
|
||
}
|
||
break
|
||
}
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
function reverse(pts) {
|
||
const out = new Float64Array(pts.length)
|
||
for (let i = 0, n = pts.length / 2; i < n; i++) {
|
||
out[i * 2] = pts[(n - 1 - i) * 2]
|
||
out[i * 2 + 1] = pts[(n - 1 - i) * 2 + 1]
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** 环集合的包围盒 */
|
||
export function loopsBBox(loops) {
|
||
const b = [Infinity, Infinity, -Infinity, -Infinity]
|
||
for (const l of loops) {
|
||
for (let i = 0; i < l.length; i += 2) {
|
||
if (l[i] < b[0]) b[0] = l[i]
|
||
if (l[i + 1] < b[1]) b[1] = l[i + 1]
|
||
if (l[i] > b[2]) b[2] = l[i]
|
||
if (l[i + 1] > b[3]) b[3] = l[i + 1]
|
||
}
|
||
}
|
||
return b
|
||
}
|
||
|
||
/**
|
||
* 生成图案线,返回扁平线段数组 [x0,y0,x1,y1, x0,y0,x1,y1, ...]。
|
||
* 用奇偶规则裁剪:把每条候选直线与所有边界求交,排序后取奇数区间。
|
||
*/
|
||
export function hatchPatternSegments(loops, defLines) {
|
||
const out = []
|
||
if (!loops.length || !defLines || !defLines.length) return out
|
||
const bb = loopsBBox(loops)
|
||
if (!isFinite(bb[0])) return out
|
||
const diag = Math.hypot(bb[2] - bb[0], bb[3] - bb[1])
|
||
if (!(diag > 0)) return out
|
||
|
||
for (const dl of defLines) {
|
||
const ang = dl.angle || 0
|
||
const ux = Math.cos(ang), uy = Math.sin(ang)
|
||
const px = -uy, py = ux // 垂直方向
|
||
const ox = dl.base ? dl.base.x : 0
|
||
const oy = dl.base ? dl.base.y : 0
|
||
// offset 是世界坐标系下「相邻两条线的位移向量」,不是定义线自身坐标系的分量
|
||
const offX = dl.offset ? dl.offset.x : 0
|
||
const offY = dl.offset ? dl.offset.y : 0
|
||
const perp = offX * px + offY * py // 位移在法线方向的分量 = 真正的行距
|
||
if (Math.abs(perp) < 1e-9) continue // 位移与线平行,画不出一族线
|
||
const spacing = Math.abs(perp)
|
||
if (diag / spacing > MAX_PATTERN_LINES) continue // 行距过密,跳过(多半是比例异常)
|
||
|
||
// 计算需要覆盖的 n 范围:把包围盒四角投影到法线方向
|
||
let nMin = Infinity, nMax = -Infinity
|
||
for (const [cx, cy] of [[bb[0], bb[1]], [bb[2], bb[1]], [bb[0], bb[3]], [bb[2], bb[3]]]) {
|
||
const n = ((cx - ox) * px + (cy - oy) * py) / perp
|
||
if (n < nMin) nMin = n
|
||
if (n > nMax) nMax = n
|
||
}
|
||
nMin = Math.floor(nMin) - 1
|
||
nMax = Math.ceil(nMax) + 1
|
||
if (!isFinite(nMin) || !isFinite(nMax)) continue
|
||
|
||
const dashes = (dl.dashLengths || []).filter((d) => isFinite(d))
|
||
const patLen = dashes.reduce((s, d) => s + Math.abs(d), 0)
|
||
|
||
for (let n = nMin; n <= nMax; n++) {
|
||
// 该条线上的一个点
|
||
const bx = ox + n * offX
|
||
const by = oy + n * offY
|
||
const hits = lineLoopIntersections(loops, bx, by, ux, uy)
|
||
if (hits.length < 2) continue
|
||
hits.sort((a, b) => a - b)
|
||
for (let k = 0; k + 1 < hits.length; k += 2) {
|
||
const t0 = hits[k], t1 = hits[k + 1]
|
||
if (t1 - t0 < 1e-9) continue
|
||
if (patLen > 1e-9) emitDashed(out, bx, by, ux, uy, t0, t1, dashes, patLen)
|
||
else out.push(bx + ux * t0, by + uy * t0, bx + ux * t1, by + uy * t1)
|
||
if (out.length > MAX_SEGMENTS * 4) return out
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** 在 [t0,t1] 区间内按虚线定义切段(负值为空白) */
|
||
function emitDashed(out, bx, by, ux, uy, t0, t1, dashes, patLen) {
|
||
// 从图案原点对齐:找到 t0 所在的图案相位
|
||
let t = t0 - ((t0 % patLen) + patLen) % patLen
|
||
let guard = 0
|
||
while (t < t1 && guard++ < 4000) {
|
||
for (const d of dashes) {
|
||
const len = Math.abs(d) < 1e-9 ? 1e-9 : Math.abs(d)
|
||
const a = t, b = t + len
|
||
if (d >= 0) {
|
||
const s = Math.max(a, t0), e = Math.min(b, t1)
|
||
if (e > s) out.push(bx + ux * s, by + uy * s, bx + ux * e, by + uy * e)
|
||
}
|
||
t = b
|
||
if (t > t1) break
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 直线 (b + t*u) 与所有边界环的交点参数 t */
|
||
function lineLoopIntersections(loops, bx, by, ux, uy) {
|
||
const ts = []
|
||
const px = -uy, py = ux
|
||
for (const l of loops) {
|
||
const n = l.length / 2
|
||
for (let i = 0; i < n; i++) {
|
||
const j = (i + 1) % n
|
||
const x0 = l[i * 2], y0 = l[i * 2 + 1]
|
||
const x1 = l[j * 2], y1 = l[j * 2 + 1]
|
||
// 到直线的有符号距离
|
||
const d0 = (x0 - bx) * px + (y0 - by) * py
|
||
const d1 = (x1 - bx) * px + (y1 - by) * py
|
||
if ((d0 > 0 && d1 > 0) || (d0 < 0 && d1 < 0)) continue
|
||
if (d0 === 0 && d1 === 0) continue // 共线,忽略
|
||
// 顶点正好落在线上时只算一次(用半开区间规则避免重复计数)
|
||
if (d1 === 0) continue
|
||
if (d0 === 0) {
|
||
ts.push((x0 - bx) * ux + (y0 - by) * uy)
|
||
continue
|
||
}
|
||
const s = d0 / (d0 - d1)
|
||
const ix = x0 + (x1 - x0) * s
|
||
const iy = y0 + (y1 - y0) * s
|
||
ts.push((ix - bx) * ux + (iy - by) * uy)
|
||
}
|
||
}
|
||
return ts
|
||
}
|