/** * 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 }