OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

322
dev/DWGViewer/js/curves.js Normal file
View File

@@ -0,0 +1,322 @@
/**
* 曲线离散化与二维仿射变换工具。
*
* 约定:
* - 变换矩阵一律用扁平数组 m = [a, b, c, d, e, f]
* 对应 x' = a*x + c*y + ey' = 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 到 a1DWG/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
}
/**
* 多段线凸度bulgebulge = 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
}

View File

@@ -0,0 +1,265 @@
/**
* DXF 的 HATCH 补丁解析器。
*
* dxf-parserMIT不认 HATCH而机械图纸里剖面线随处可见缺了整张图会显得很空。
* 这里独立扫一遍 DXF 组码,只挑 HATCH 出来,产出与 libredwg 一致的结构,
* 这样 hatch.js / flatten.js 两边共用同一套渲染逻辑。
*
* 角度统一转成弧度DXF 里 50/51/53 都是度)。
*/
const RAD = Math.PI / 180
/**
* @param {string} text DXF 原文
* @param {Doc} doc 已归一化的文档,会就地把 HATCH 追加进模型空间/对应块
*/
export function parseDxfHatches(text, doc) {
const pairs = tokenize(text)
let section = null
let block = null
let count = 0
for (let i = 0; i < pairs.length; i++) {
const [code, value] = pairs[i]
if (code !== 0) continue
if (value === 'SECTION') {
const nx = pairs[i + 1]
section = nx && nx[0] === 2 ? nx[1] : null
continue
}
if (value === 'ENDSEC') { section = null; block = null; continue }
if (section !== 'ENTITIES' && section !== 'BLOCKS') continue
if (value === 'BLOCK') { block = findValue(pairs, i + 1, 2); continue }
if (value === 'ENDBLK') { block = null; continue }
if (value !== 'HATCH') continue
// 收集到下一个 0 组码为止
let j = i + 1
const body = []
while (j < pairs.length && pairs[j][0] !== 0) { body.push(pairs[j]); j++ }
const ent = parseHatch(body)
if (ent && ent.boundaryPaths.length) {
const target = block ? doc.blocks.get(block) : null
if (target) target.entities.push(ent)
else if (!block) doc.modelEntities.push(ent)
count++
}
i = j - 1
}
return count
}
function findValue(pairs, from, code) {
for (let i = from; i < pairs.length && i < from + 20; i++) {
if (pairs[i][0] === 0) return null
if (pairs[i][0] === code) return pairs[i][1]
}
return null
}
/** DXF 是「一行组码、一行值」的纯文本 */
function tokenize(text) {
const lines = text.split(/\r\n|\r|\n/)
const out = []
for (let i = 0; i + 1 < lines.length; i += 2) {
const c = parseInt(lines[i], 10)
if (!isFinite(c)) { i -= 1; continue } // 行错位时向前挪一行重新对齐
out.push([c, lines[i + 1]])
}
return out
}
function parseHatch(p) {
const ent = {
type: 'HATCH', layer: '0', colorIndex: 256, lineTypeScale: 1, isVisible: true,
patternName: '', solidFill: 0, boundaryPaths: [], definitionLines: [], xdata: [],
}
let i = 0
while (i < p.length) {
const [c, v] = p[i]
switch (c) {
case 8: ent.layer = v; i++; break
case 62: ent.colorIndex = int(v); i++; break
case 420: ent.trueColor = int(v) & 0xffffff; i++; break
case 6: ent.lineType = v; i++; break
case 370: ent.lineweight = int(v); i++; break
case 48: ent.lineTypeScale = num(v) || 1; i++; break
case 60: ent.isVisible = int(v) === 0; i++; break
case 2: ent.patternName = v; i++; break
case 70: ent.solidFill = int(v); i++; break
case 71: ent.associativity = int(v); i++; break
case 75: ent.hatchStyle = int(v); i++; break
case 76: ent.patternType = int(v); i++; break
case 52: ent.patternAngle = num(v) * RAD; i++; break
case 41: ent.patternScale = num(v); i++; break
case 91: i = readPaths(p, i + 1, int(v), ent); break
case 78: i = readDefLines(p, i + 1, int(v), ent); break
default: i++
}
}
return ent
}
const num = (v) => { const n = parseFloat(v); return isFinite(n) ? n : 0 }
const int = (v) => { const n = parseInt(v, 10); return isFinite(n) ? n : 0 }
function seek(p, i, code) {
while (i < p.length && p[i][0] !== code) i++
return i
}
function readPaths(p, i, n, ent) {
for (let k = 0; k < n; k++) {
i = seek(p, i, 92)
if (i >= p.length) break
const flag = int(p[i][1]); i++
if (flag & 2) {
// 多段线边界
const path = { boundaryPathTypeFlag: flag, hasBulge: false, isClosed: false, numberOfVertices: 0, vertices: [] }
let guard = 0
while (i < p.length && guard++ < 8) {
const c = p[i][0]
if (c === 72) { path.hasBulge = int(p[i][1]) !== 0; i++ }
else if (c === 73) { path.isClosed = int(p[i][1]) !== 0; i++ }
else if (c === 93) {
const nv = int(p[i][1]); i++
for (let t = 0; t < nv && i < p.length; t++) {
i = seek(p, i, 10)
if (i >= p.length) break
const x = num(p[i][1]); i++
const y = p[i] && p[i][0] === 20 ? num(p[i][1]) : 0
if (p[i] && p[i][0] === 20) i++
let bulge = 0
if (p[i] && p[i][0] === 42) { bulge = num(p[i][1]); i++ }
path.vertices.push({ x, y, bulge })
}
break
} else i++
}
path.numberOfVertices = path.vertices.length
if (path.vertices.length >= 2) ent.boundaryPaths.push(path)
} else {
// 边(直线/圆弧/椭圆弧/样条)组成的边界
i = seek(p, i, 93)
if (i >= p.length) break
const ne = int(p[i][1]); i++
const path = { boundaryPathTypeFlag: flag, numberOfEdges: ne, edges: [] }
for (let t = 0; t < ne && i < p.length; t++) {
i = seek(p, i, 72)
if (i >= p.length) break
const et = int(p[i][1]); i++
const r = readEdge(p, i, et)
i = r.i
if (r.edge) path.edges.push(r.edge)
}
if (path.edges.length) ent.boundaryPaths.push(path)
}
// 跳过尾部的源对象引用97 + 330...
}
return i
}
/** 顺序读取若干组码,遇到不属于本边的组码就停 */
function readEdge(p, i, type) {
const g = {}
const take = new Set(
type === 1 ? [10, 20, 11, 21]
: type === 2 ? [10, 20, 40, 50, 51, 73]
: type === 3 ? [10, 20, 11, 21, 40, 50, 51, 73]
: [94, 73, 74, 95, 96, 40, 10, 20, 42, 97, 11, 21, 12, 22, 13, 23],
)
if (type === 4) return readSplineEdge(p, i)
while (i < p.length && take.has(p[i][0])) {
const [c, v] = p[i]
if (g[c] !== undefined && (c === 10 || c === 40)) break // 下一条边开始了
g[c] = v
i++
}
switch (type) {
case 1:
return { i, edge: { type: 1, start: { x: num(g[10]), y: num(g[20]) }, end: { x: num(g[11]), y: num(g[21]) } } }
case 2:
return {
i,
edge: {
type: 2, center: { x: num(g[10]), y: num(g[20]) }, radius: num(g[40]),
startAngle: num(g[50]) * RAD, endAngle: num(g[51]) * RAD, isCCW: g[73] == null || int(g[73]) !== 0,
},
}
case 3:
return {
i,
edge: {
type: 3, center: { x: num(g[10]), y: num(g[20]) }, end: { x: num(g[11]), y: num(g[21]) },
lengthOfMinorAxis: num(g[40]), startAngle: num(g[50]) * RAD, endAngle: num(g[51]) * RAD,
isCCW: g[73] == null || int(g[73]) !== 0,
},
}
default:
return { i, edge: null }
}
}
function readSplineEdge(p, i) {
const edge = { type: 4, degree: 3, knots: [], controlPoints: [], fitDatum: [], numberOfKnots: 0, numberOfControlPoints: 0, numberOfFitData: 0 }
// 94 degree, 73 rational, 74 periodic, 95 numKnots, 96 numCtrl
let rational = false
while (i < p.length && [94, 73, 74, 95, 96].includes(p[i][0])) {
const [c, v] = p[i]; i++
if (c === 94) edge.degree = int(v)
else if (c === 73) rational = int(v) !== 0
else if (c === 95) edge.numberOfKnots = int(v)
else if (c === 96) edge.numberOfControlPoints = int(v)
}
for (let k = 0; k < edge.numberOfKnots && i < p.length; k++) {
if (p[i][0] !== 40) break
edge.knots.push(num(p[i][1])); i++
}
for (let k = 0; k < edge.numberOfControlPoints && i < p.length; k++) {
i = seek(p, i, 10)
if (i >= p.length) break
const x = num(p[i][1]); i++
const y = p[i] && p[i][0] === 20 ? num(p[i][1]) : 0
if (p[i] && p[i][0] === 20) i++
let w
if (rational && p[i] && p[i][0] === 42) { w = num(p[i][1]); i++ }
edge.controlPoints.push(w != null ? { x, y, weight: w } : { x, y })
}
if (i < p.length && p[i][0] === 97) {
const nf = int(p[i][1]); i++
for (let k = 0; k < nf && i < p.length; k++) {
if (p[i][0] !== 11) break
const x = num(p[i][1]); i++
const y = p[i] && p[i][0] === 21 ? num(p[i][1]) : 0
if (p[i] && p[i][0] === 21) i++
edge.fitDatum.push({ x, y })
}
edge.numberOfFitData = edge.fitDatum.length
}
return { i, edge }
}
function readDefLines(p, i, n, ent) {
for (let k = 0; k < n; k++) {
i = seek(p, i, 53)
if (i >= p.length) break
const dl = { angle: num(p[i][1]) * RAD, base: { x: 0, y: 0 }, offset: { x: 0, y: 0 }, numberOfDashLengths: 0, dashLengths: [] }
i++
while (i < p.length && [43, 44, 45, 46, 79].includes(p[i][0])) {
const [c, v] = p[i]; i++
if (c === 43) dl.base.x = num(v)
else if (c === 44) dl.base.y = num(v)
else if (c === 45) dl.offset.x = num(v)
else if (c === 46) dl.offset.y = num(v)
else if (c === 79) {
const nd = int(v)
for (let t = 0; t < nd && i < p.length && p[i][0] === 49; t++) { dl.dashLengths.push(num(p[i][1])); i++ }
}
}
dl.numberOfDashLengths = dl.dashLengths.length
ent.definitionLines.push(dl)
}
return i
}

517
dev/DWGViewer/js/flatten.js Normal file
View File

@@ -0,0 +1,517 @@
/**
* 把归一化后的图纸文档Doc展平成渲染用的「显示列表」。
*
* 展平只做一次(加载时),之后缩放平移都不再重算几何。
* INSERT 会递归展开并把变换烘焙进坐标,因此显示列表里的坐标全是世界坐标。
*/
import ACI from '../libs/dxf-parser/AutoCadColorIndex.js'
import {
applyX, applyY, arcBBox, arcPoints, bboxOfPoints, bulgeArcPoints, circlePoints, conformal,
ellipsePoints, fitPointCurve, growBox, IDENTITY, makeInsertMatrix, matrixScale, mul,
splinePoints, TAU, transformPoints,
} from './curves.js'
import { builtinDefinitionLines, hatchLoops, hatchPatternSegments } from './hatch.js'
import { parseMText, parseSimpleText } from './mtext.js'
const MAX_INSERT_DEPTH = 12
const MAX_ARRAY_CELLS = 2000
/** DWG 线宽枚举(值 = 1/100 mm其余为特殊值 */
const LW_TABLE = new Set([0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211])
const LW_BYLAYER = -1, LW_BYBLOCK = -2, LW_DEFAULT = -3
function normLineweight(v) {
if (v == null) return LW_BYLAYER
if (v < 0) return v >= -3 ? v : LW_BYLAYER
if (v === 28) return LW_BYLAYER
if (v === 29) return LW_BYBLOCK
if (v === 30 || v === 31) return LW_DEFAULT
return LW_TABLE.has(v) ? v : LW_DEFAULT
}
/** ACI 索引 → 0xRRGGBB */
export function aciColor(i) {
if (i == null || i < 0 || i > 255) return 0x000000
const c = ACI[i]
return typeof c === 'number' ? c : 0x000000
}
/**
* 解析实体最终颜色。
* @returns {number} 0xRRGGBB
*/
function resolveColor(ent, layer, inherited) {
if (typeof ent.trueColor === 'number') return ent.trueColor & 0xffffff
const ci = ent.colorIndex
if (ci === 0) return inherited != null ? inherited : (layer ? layerColor(layer) : 0x000000) // BYBLOCK
if (ci == null || ci === 256) return layer ? layerColor(layer) : 0x000000 // BYLAYER
if (ci > 0 && ci < 256) return aciColor(ci)
return 0x000000
}
function layerColor(layer) {
if (typeof layer.trueColor === 'number') return layer.trueColor & 0xffffff
if (layer.colorIndex != null && layer.colorIndex !== 0) return aciColor(Math.abs(layer.colorIndex))
if (typeof layer.color === 'number' && layer.color !== 16777215) return layer.color & 0xffffff
return 0x000000
}
// ---------------------------------------------------------------- 主入口
/**
* @param {Doc} doc 归一化文档
* @param {object} opt { space: 'model' | layout 名 }
* @returns {{shapes: Array, bbox: number[], stats: object}}
*/
export function flatten(doc, opt = {}) {
const shapes = []
const bbox = [Infinity, Infinity, -Infinity, -Infinity]
const stats = { entities: 0, shapes: 0, skipped: 0, byType: {} }
const ctx = { doc, shapes, bbox, stats, blockStack: new Set() }
const entities = opt.entities || doc.modelEntities || []
for (const ent of entities) {
emitEntity(ctx, ent, IDENTITY, null, null, 0)
}
stats.shapes = shapes.length
return { shapes, bbox, stats }
}
function emitEntity(ctx, ent, m, inheritColor, inheritLayer, depth) {
if (!ent || ent.isVisible === false) return
const type = ent.type
ctx.stats.entities++
ctx.stats.byType[type] = (ctx.stats.byType[type] || 0) + 1
// 图层:块内实体用 "0" 图层时继承外层图层AutoCAD 规则)
let layerName = ent.layer || '0'
if (layerName === '0' && inheritLayer) layerName = inheritLayer
const layer = ctx.doc.layers.get(layerName)
const base = {
layer: layerName,
color: resolveColor(ent, layer, inheritColor),
lw: normLineweight(ent.lineweight),
lt: resolveLineType(ent, layer),
ltScale: ent.lineTypeScale || 1,
ent,
}
switch (type) {
case 'LINE': {
const p = ent.startPoint, q = ent.endPoint
if (!p || !q) return
addPoly(ctx, base, new Float64Array([
applyX(m, p.x, p.y), applyY(m, p.x, p.y),
applyX(m, q.x, q.y), applyY(m, q.x, q.y),
]), false)
break
}
case 'CIRCLE':
addCircleLike(ctx, base, m, ent.center, ent.radius, ent.radius, 0, 0, TAU)
break
case 'ARC': {
const a0 = ang(ent.startAngle), a1 = ang(ent.endAngle)
addCircleLike(ctx, base, m, ent.center, ent.radius, ent.radius, 0, a0, a1)
break
}
case 'ELLIPSE': {
const mj = ent.majorAxisEndPoint || { x: 1, y: 0 }
const rx = Math.hypot(mj.x, mj.y)
const ry = rx * (ent.axisRatio || 1)
const rot = Math.atan2(mj.y, mj.x)
let a0 = ent.startAngle || 0, a1 = ent.endAngle
if (a1 == null || Math.abs((a1 - a0) % TAU) < 1e-9) { a0 = 0; a1 = TAU }
addCircleLike(ctx, base, m, ent.center, rx, ry, rot, a0, a1)
break
}
case 'LWPOLYLINE': {
const pts = lwPolyPoints(ent)
if (pts.length >= 4) addPoly(ctx, base, transformPoints(pts, m), isClosed(ent.flag))
break
}
case 'POLYLINE':
case 'POLYLINE2D':
case 'POLYLINE3D': {
const pts = polyPoints(ent)
if (pts.length >= 4) addPoly(ctx, base, transformPoints(pts, m), isClosed(ent.flag))
break
}
case 'SPLINE': {
const pts = splineEntityPoints(ent)
if (pts.length >= 4) addPoly(ctx, base, transformPoints(pts, m), !!(ent.flag & 1))
break
}
case 'POINT': {
const p = ent.position || ent.startPoint || ent.center
if (!p) return
push(ctx, { ...base, kind: 'point', x: applyX(m, p.x, p.y), y: applyY(m, p.x, p.y) },
[applyX(m, p.x, p.y), applyY(m, p.x, p.y), applyX(m, p.x, p.y), applyY(m, p.x, p.y)])
break
}
case 'SOLID':
case 'TRACE': {
const cs = [ent.corner1, ent.corner2, ent.corner4, ent.corner3].filter(Boolean)
if (cs.length < 3) return
const pts = new Float64Array(cs.length * 2)
cs.forEach((c, i) => { pts[i * 2] = applyX(m, c.x, c.y); pts[i * 2 + 1] = applyY(m, c.x, c.y) })
push(ctx, { ...base, kind: 'fill', loops: [pts] }, bboxOfPoints(pts))
break
}
case '3DFACE': {
const cs = [ent.corner1 || ent.vertices?.[0], ent.corner2 || ent.vertices?.[1],
ent.corner3 || ent.vertices?.[2], ent.corner4 || ent.vertices?.[3]].filter(Boolean)
if (cs.length < 3) return
const pts = new Float64Array(cs.length * 2)
cs.forEach((c, i) => { pts[i * 2] = applyX(m, c.x, c.y); pts[i * 2 + 1] = applyY(m, c.x, c.y) })
addPoly(ctx, base, pts, true)
break
}
case 'HATCH':
emitHatch(ctx, base, ent, m)
break
case 'TEXT':
case 'ATTRIB':
case 'ATTDEF':
emitText(ctx, base, ent, m)
break
case 'MTEXT':
emitMText(ctx, base, ent, m)
break
case 'DIMENSION':
case 'ARC_DIMENSION':
emitBlockRef(ctx, base, ent, ent.name, m, inheritColor, layerName, depth, ent.definitionPoint)
break
case 'LEADER':
emitLeader(ctx, base, ent, m)
break
case 'MULTILEADER':
case 'MLEADER':
emitMLeader(ctx, base, ent, m)
break
case 'INSERT':
emitInsert(ctx, base, ent, m, inheritColor, layerName, depth)
break
case 'VIEWPORT':
case 'SEQEND':
case 'VERTEX':
case 'BLOCK':
case 'ENDBLK':
break
default:
ctx.stats.skipped++
break
}
}
// ---------------------------------------------------------------- 各类实体
function resolveLineType(ent, layer) {
let lt = ent.lineType || ent.lineTypeName
if (!lt || lt === 'ByLayer' || lt === 'BYLAYER') lt = layer ? layer.lineType : null
if (!lt || lt === 'ByBlock' || lt === 'BYBLOCK') return null
if (/^continuous$/i.test(lt)) return null
return lt
}
const isClosed = (flag) => !!(flag & 1)
/**
* 文档内的角度约定:一律弧度。
* libredwg 本身就给弧度dxf-parser 混用圆弧是弧度、rotation 是度),
* 已在 source.js 的归一化里统一,这里不再换算。
*/
const ang = (a) => a || 0
function lwPolyPoints(ent) {
const vs = ent.vertices || []
if (!vs.length) return new Float64Array(0)
const out = []
const closed = isClosed(ent.flag)
const n = vs.length
out.push(vs[0].x, vs[0].y)
for (let i = 0; i < (closed ? n : n - 1); i++) {
const a = vs[i], b = vs[(i + 1) % n]
if (a.bulge) out.push(...bulgeArcPoints(a.x, a.y, b.x, b.y, a.bulge))
else out.push(b.x, b.y)
}
return new Float64Array(out)
}
function polyPoints(ent) {
const vs = (ent.vertices || []).filter((v) => v && v.x != null)
if (!vs.length) return new Float64Array(0)
// 曲线拟合的多段线flag & 4 = spline-fit顶点已是拟合点直接连
const out = []
const closed = isClosed(ent.flag)
const n = vs.length
out.push(vs[0].x, vs[0].y)
for (let i = 0; i < (closed ? n : n - 1); i++) {
const a = vs[i], b = vs[(i + 1) % n]
if (a.bulge) out.push(...bulgeArcPoints(a.x, a.y, b.x, b.y, a.bulge))
else out.push(b.x, b.y)
}
return new Float64Array(out)
}
function splineEntityPoints(ent) {
const cps = ent.controlPoints || []
if (cps.length >= 2) {
const weights = ent.weights
const pts = cps.map((c, i) => ({ x: c.x, y: c.y, weight: weights ? weights[i] : c.weight }))
return splinePoints(ent.degree || 3, pts, ent.knots, !!(ent.flag & 1))
}
const fps = ent.fitPoints || []
if (fps.length >= 2) return fitPointCurve(fps, !!(ent.flag & 1))
return new Float64Array(0)
}
function addPoly(ctx, base, pts, closed) {
if (pts.length < 4) return
push(ctx, { ...base, kind: 'poly', pts, closed }, bboxOfPoints(pts))
}
/**
* 圆/圆弧/椭圆:变换若是共形的(等比缩放+旋转),保留解析形式交给
* canvas 的 ellipse() 精确绘制;否则退化成折线。
*/
function addCircleLike(ctx, base, m, center, rx, ry, rot, a0, a1) {
if (!center) return
if (!(rx > 0)) return
const full = Math.abs(a1 - a0) >= TAU - 1e-9
const cf = conformal(m)
if (cf) {
// 共形变换下圆仍是圆、椭圆仍是椭圆,保留解析形式让 canvas 画出无锯齿的曲线。
// 镜像会让参数角反向a → -a且弧的走向翻转故起止角对调。
const s = cf.scale, phi = cf.rotation
const R = cf.mirror ? phi - rot : rot + phi
const A0 = cf.mirror ? -a1 : a0
const A1 = cf.mirror ? -a0 : a1
const cx = applyX(m, center.x, center.y)
const cy = applyY(m, center.x, center.y)
const RX = rx * s, RY = ry * s
push(ctx, { ...base, kind: 'arc', cx, cy, rx: RX, ry: RY, rot: R, a0: A0, a1: A1 },
arcBBox(cx, cy, RX, RY, R, A0, A1))
return
}
const pts = rx === ry
? (full ? circlePoints(center.x, center.y, rx) : arcPoints(center.x, center.y, rx, a0, a1))
: ellipsePoints(center.x, center.y, rx * Math.cos(rot), rx * Math.sin(rot), ry / rx, a0, a1)
addPoly(ctx, base, transformPoints(pts, m), full)
}
function emitHatch(ctx, base, ent, m) {
const loops = hatchLoops(ent)
if (!loops.length) return
const solid = ent.solidFill === 1 || /^solid$/i.test(ent.patternName || '')
if (solid) {
const world = loops.map((l) => transformPoints(l, m))
const bb = [Infinity, Infinity, -Infinity, -Infinity]
world.forEach((l) => growBox(bb, bboxOfPoints(l)))
push(ctx, { ...base, kind: 'fill', loops: world }, bb)
return
}
// 文件里没带定义线时(预定义图案只存名字),从内置图案表展开
let defs = ent.definitionLines
if (!defs || !defs.length) {
defs = builtinDefinitionLines(ent.patternName, ent.patternAngle || 0, ent.patternScale || 1)
}
const segs = hatchPatternSegments(loops, defs)
if (!segs.length) {
// 没有图案定义线(例如未知图案):至少把边界画出来
for (const l of loops) addPoly(ctx, base, transformPoints(l, m), true)
return
}
const arr = transformPoints(new Float64Array(segs), m)
push(ctx, { ...base, kind: 'segs', pts: arr }, bboxOfPoints(arr))
}
function emitText(ctx, base, ent, m) {
const raw = ent.text
if (raw == null || raw === '') return
const txt = parseSimpleText(raw)
if (!txt) return
const style = ctx.doc.styles.get(ent.styleName) || null
const sc = matrixScale(m)
const h = (ent.textHeight || (style && style.fixedTextHeight) || 2.5) * sc
if (!(h > 0)) return
// TEXT 的对齐点halign/valign 非默认时用 endPoint第二对齐点
const useAlt = (ent.halign && ent.halign !== 0) || (ent.valign && ent.valign !== 0)
const p = (useAlt && ent.endPoint && (ent.endPoint.x || ent.endPoint.y)) ? ent.endPoint : (ent.startPoint || ent.position)
if (!p) return
const cf = conformal(m)
const rot = ang(ent.rotation) + (cf ? cf.rotation : 0)
push(ctx, {
...base,
kind: 'text',
x: applyX(m, p.x, p.y),
y: applyY(m, p.x, p.y),
h,
rot,
wFactor: ent.xScale || (style && style.widthFactor) || 1,
oblique: ang(ent.obliqueAngle || (style && style.obliqueAngle) || 0),
halign: ent.halign || 0,
valign: ent.valign || 0,
mirrorX: !!(ent.generationFlag & 2),
mirrorY: !!(ent.generationFlag & 4),
font: style && style.font,
runs: [{ text: txt, hFactor: 1, wFactor: 1, rise: 0 }],
plain: txt,
}, textBox(applyX(m, p.x, p.y), applyY(m, p.x, p.y),
charWidth(txt) * h * (ent.xScale || 1), h,
ent.halign === 1 || ent.halign === 4 ? 0.5 : ent.halign === 2 ? 1 : 0,
ent.valign === 3 ? 1 : ent.valign === 2 || ent.halign === 4 ? 0.5 : 0,
rot))
}
/** 估算字符串宽度(以字高为单位):中日韩按 1 个字宽,西文按 0.55 */
function charWidth(t) {
let w = 0
for (const c of t) w += c.charCodeAt(0) > 0x2e80 ? 1 : 0.55
return w
}
/**
* 文字包围盒。
* @param ax 0=左 0.5=中 1=右ay 0=基线/底 0.5=中 1=顶
*/
function textBox(x, y, w, h, ax, ay, rot) {
const x0 = -w * ax, x1 = x0 + w
const y1 = h * (1 - ay) + h * 0.25 // 多留一点给下伸部
const y0 = y1 - h * 1.25
const c = Math.cos(rot || 0), s = Math.sin(rot || 0)
const b = [Infinity, Infinity, -Infinity, -Infinity]
for (const [lx, ly] of [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]) {
const px = x + lx * c - ly * s
const py = y + lx * s + ly * c
if (px < b[0]) b[0] = px
if (py < b[1]) b[1] = py
if (px > b[2]) b[2] = px
if (py > b[3]) b[3] = py
}
return b
}
/** MTEXT 的 9 种对齐点 */
const MT_H = { 1: 0, 2: 0.5, 3: 1, 4: 0, 5: 0.5, 6: 1, 7: 0, 8: 0.5, 9: 1 }
const MT_V = { 1: 1, 2: 1, 3: 1, 4: 0.5, 5: 0.5, 6: 0.5, 7: 0, 8: 0, 9: 0 }
function emitMText(ctx, base, ent, m) {
const { lines } = parseMText(ent.text)
const plain = lines.map((l) => l.map((r) => r.text).join('')).join('\n')
if (!plain.trim()) return
const style = ctx.doc.styles.get(ent.styleName) || null
const sc = matrixScale(m)
const h = (ent.textHeight || 2.5) * sc
const p = ent.insertionPoint
if (!p || !(h > 0)) return
const cf = conformal(m)
let rot = 0
if (ent.direction && (ent.direction.x || ent.direction.y)) rot = Math.atan2(ent.direction.y, ent.direction.x)
else rot = ang(ent.rotation)
rot += cf ? cf.rotation : 0
const ap = ent.attachmentPoint || 1
const lineGap = (ent.lineSpacing || 1) * 1.667 // AutoCAD 默认行距系数
const ax = MT_H[ap] ?? 0
const ay = MT_V[ap] ?? 1
const width = (ent.rectWidth || 0) * sc
const X = applyX(m, p.x, p.y), Y = applyY(m, p.x, p.y)
// 包围盒:有 rectWidth 就按框宽算(正文会在这个宽度内折行),
// 否则按最长一行估。行数同样按折行后估,否则长段落的盒子会短一截。
const longest = Math.max(...lines.map((l) => charWidth(l.map((r) => r.text).join(''))), 1)
const boxW = width > 0 ? width : longest * h
const nLines = width > 0
? lines.reduce((n, l) => n + Math.max(1, Math.ceil((charWidth(l.map((r) => r.text).join('')) * h) / width)), 0)
: lines.length
const boxH = Math.max(1, nLines) * h * lineGap
push(ctx, {
...base, kind: 'mtext', x: X, y: Y, h, rot, lines, lineGap, ax, ay, width,
font: style && style.font, plain,
}, textBox(X, Y, boxW, boxH, ax, ay, rot))
}
function emitLeader(ctx, base, ent, m) {
const vs = ent.vertices || ent.points || []
if (vs.length < 2) return
const pts = new Float64Array(vs.length * 2)
vs.forEach((v, i) => { pts[i * 2] = applyX(m, v.x, v.y); pts[i * 2 + 1] = applyY(m, v.x, v.y) })
addPoly(ctx, base, pts, false)
}
function emitMLeader(ctx, base, ent, m) {
const ctxData = ent.contextData || ent.context || {}
const leaders = ctxData.leaders || ent.leaders || []
for (const ld of leaders) {
for (const line of ld.leaderLines || ld.lines || []) {
const vs = line.vertices || line.points || []
if (vs.length < 2) continue
const pts = new Float64Array(vs.length * 2)
vs.forEach((v, i) => { pts[i * 2] = applyX(m, v.x, v.y); pts[i * 2 + 1] = applyY(m, v.x, v.y) })
addPoly(ctx, base, pts, false)
}
}
const t = ctxData.text || ctxData.textLabel
if (t && ctxData.textLocation) {
emitMText(ctx, base, {
text: t, insertionPoint: ctxData.textLocation, textHeight: ctxData.textHeight || ent.textHeight,
rotation: 0, attachmentPoint: ctxData.textAttachmentPoint || 1, styleName: ctxData.textStyleName,
}, m)
}
}
/** DIMENSION真正的图形放在一个匿名块里直接展开那个块 */
function emitBlockRef(ctx, base, ent, blockName, m, inheritColor, layerName, depth, fallbackPt) {
const blk = blockName && ctx.doc.blocks.get(blockName)
if (!blk) return
if (depth > MAX_INSERT_DEPTH) return
const bp = blk.basePoint || { x: 0, y: 0 }
const bm = mul(m, [1, 0, 0, 1, -bp.x, -bp.y])
for (const e of blk.entities || []) {
emitEntity(ctx, e, bm, base.color, layerName, depth + 1)
}
}
function emitInsert(ctx, base, ent, m, inheritColor, layerName, depth) {
if (depth > MAX_INSERT_DEPTH) return
const blk = ctx.doc.blocks.get(ent.name)
// ATTRIB 无论块在不在都要画
for (const a of ent.attribs || []) {
if (a && a.isVisible !== false) emitEntity(ctx, a, m, base.color, layerName, depth + 1)
}
if (!blk) { ctx.stats.skipped++; return }
const p = ent.insertionPoint || { x: 0, y: 0 }
const sx = ent.xScale === 0 || ent.xScale == null ? 1 : ent.xScale
const sy = ent.yScale === 0 || ent.yScale == null ? 1 : ent.yScale
const rot = ang(ent.rotation)
const bp = blk.basePoint || { x: 0, y: 0 }
const cols = Math.max(1, ent.columnCount || 1)
const rows = Math.max(1, ent.rowCount || 1)
const cSp = ent.columnSpacing || 0
const rSp = ent.rowSpacing || 0
if (cols * rows > MAX_ARRAY_CELLS) { ctx.stats.skipped++; return }
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const local = makeInsertMatrix(p.x + c * cSp, p.y + r * rSp, sx, sy, rot)
// 先把块基点移到原点,再套插入变换
const bm = mul(mul(m, local), [1, 0, 0, 1, -bp.x, -bp.y])
for (const e of blk.entities || []) {
emitEntity(ctx, e, bm, base.color, layerName, depth + 1)
}
}
}
}
// ---------------------------------------------------------------- 输出
function push(ctx, shape, bb) {
if (!bb || !isFinite(bb[0]) || !isFinite(bb[2])) return
shape.bbox = bb
shape.i = ctx.shapes.length
ctx.shapes.push(shape)
growBox(ctx.bbox, bb)
}

272
dev/DWGViewer/js/hatch.js Normal file
View File

@@ -0,0 +1,272 @@
/**
* 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 arcend 是长轴端点相对圆心的向量
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
}

163
dev/DWGViewer/js/mtext.js Normal file
View File

@@ -0,0 +1,163 @@
/**
* MTEXT / TEXT 的文字内容解析。
*
* MTEXT 的正文里混着 AutoCAD 的格式码(\P 换行、\H 字高、\S 堆叠、{} 分组……),
* 直接画出来会满屏乱码,所以这里把它拆成「行 → 文字段(run)」的结构,
* 每个 run 带自己的字高倍数、宽度因子、倾斜、粗斜体、颜色和下划线。
*/
/** %%d 之类的控制码TEXT 与 MTEXT 通用) */
function replaceSpecial(s) {
return s
.replace(/%%[dD]/g, '°') // 度
.replace(/%%[cC]/g, '∅') // 直径 ⌀
.replace(/%%[pP]/g, '±') // 正负
.replace(/%%%/g, '%')
}
/** \U+00B0 形式的 Unicode 转义 */
function replaceUnicode(s) {
return s.replace(/\\U\+([0-9A-Fa-f]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
}
const BASE_STYLE = {
hFactor: 1, // 相对于实体 textHeight 的倍数
wFactor: 1,
oblique: 0,
bold: false,
italic: false,
font: null,
color: null, // ACI 索引或 0xRRGGBB带 trueColor 标记)
underline: false,
overline: false,
strike: false,
rise: 0, // 相对基线的抬升(堆叠分数用),单位为字高倍数
}
/**
* 解析 MTEXT 正文。
* @returns {{lines: Array<Array<Run>>, columnBreaks: number[]}}
*/
export function parseMText(raw) {
const lines = [[]]
if (!raw) return { lines }
const text = replaceUnicode(raw)
const stack = []
let st = { ...BASE_STYLE }
let buf = ''
const flush = () => {
if (!buf) return
lines[lines.length - 1].push({ ...st, text: replaceSpecial(buf) })
buf = ''
}
const newLine = () => { flush(); lines.push([]) }
// 读到分号为止的参数
let i = 0
const readArg = () => {
let out = ''
while (i < text.length && text[i] !== ';') { out += text[i]; i++ }
i++ // 吃掉 ;
return out
}
while (i < text.length) {
const ch = text[i]
if (ch === '\\') {
const c = text[i + 1]
i += 2
switch (c) {
case 'P': newLine(); break
case 'X': newLine(); break
case '~': buf += ' '; break
case '\\': buf += '\\'; break
case '{': buf += '{'; break
case '}': buf += '}'; break
case 'L': flush(); st = { ...st, underline: true }; break
case 'l': flush(); st = { ...st, underline: false }; break
case 'O': flush(); st = { ...st, overline: true }; break
case 'o': flush(); st = { ...st, overline: false }; break
case 'K': flush(); st = { ...st, strike: true }; break
case 'k': flush(); st = { ...st, strike: false }; break
case 'H': {
flush()
const a = readArg()
const rel = /x$/i.test(a)
const v = parseFloat(a)
if (isFinite(v)) st = { ...st, hFactor: rel ? st.hFactor * v : v }
break
}
case 'W': { flush(); const v = parseFloat(readArg()); if (isFinite(v) && v > 0) st = { ...st, wFactor: v }; break }
case 'Q': { flush(); const v = parseFloat(readArg()); if (isFinite(v)) st = { ...st, oblique: (v * Math.PI) / 180 }; break }
case 'T': { readArg(); break } // 字间距,忽略
case 'A': { readArg(); break } // 对齐,交由整体对齐处理
case 'p': { readArg(); break } // 段落属性,忽略
case 'C': { flush(); const v = parseInt(readArg(), 10); if (isFinite(v)) st = { ...st, color: { aci: v } }; break }
case 'c': { flush(); const v = parseInt(readArg(), 10); if (isFinite(v)) st = { ...st, color: { rgb: bgrToRgb(v) } }; break }
case 'F':
case 'f': {
flush()
const a = readArg()
const parts = a.split('|')
const next = { ...st, font: parts[0] || null }
for (const p of parts.slice(1)) {
if (/^b1$/i.test(p)) next.bold = true
else if (/^b0$/i.test(p)) next.bold = false
else if (/^i1$/i.test(p)) next.italic = true
else if (/^i0$/i.test(p)) next.italic = false
}
st = next
break
}
case 'S': {
// 堆叠分数: 上^下; 上/下; 上#下;
flush()
let a = ''
while (i < text.length && text[i] !== ';') { a += text[i]; i++ }
i++
const m = a.match(/^(.*?)([\^\/#])(.*)$/)
if (m) {
const upper = replaceSpecial(m[1]), lower = replaceSpecial(m[3])
const small = { ...st, hFactor: st.hFactor * 0.65 }
const arr = lines[lines.length - 1]
if (upper) arr.push({ ...small, text: upper, rise: 0.55 })
if (m[2] !== '^' && upper && lower) arr.push({ ...small, text: '/', rise: 0 })
if (lower) arr.push({ ...small, text: lower, rise: -0.25 })
} else if (a) {
buf += replaceSpecial(a)
}
break
}
default:
// 未知转义,原样保留字符
if (c != null) buf += c
}
} else if (ch === '{') {
flush(); stack.push({ ...st }); i++
} else if (ch === '}') {
flush(); if (stack.length) st = stack.pop(); i++
} else if (ch === '\n') {
newLine(); i++
} else if (ch === '\r') {
i++
} else {
buf += ch; i++
}
}
flush()
return { lines }
}
/** DXF 的 true color 在 MTEXT 里是 BGR 顺序 */
function bgrToRgb(v) {
const b = (v >> 16) & 0xff, g = (v >> 8) & 0xff, r = v & 0xff
return (r << 16) | (g << 8) | b
}
/** 单行 TEXT / ATTRIB 的内容处理 */
export function parseSimpleText(raw) {
if (!raw) return ''
return replaceSpecial(replaceUnicode(String(raw)))
.replace(/%%[uUoO]/g, '') // 下划线/上划线开关,画不出来就去掉标记
}

851
dev/DWGViewer/js/render.js Normal file
View File

@@ -0,0 +1,851 @@
/**
* Canvas2D 渲染器。
*
* 几个刻意的取舍:
* - 不用 ctx.setTransform 承载世界→屏幕变换,而是自己算屏幕坐标。
* CAD 图纸的世界坐标动辄上万,放大几百倍后交给 canvas 变换会掉精度、线会抖;
* 自己算是双精度,缩放到什么倍数都稳。
* - 画布按 devicePixelRatio 放大,所有尺寸都用设备像素,线条不发虚。
* - 同一批样式(颜色+线宽+线型)合并成一次 beginPath/stroke
* 3 万实体的图纸一帧只需几十次 stroke 调用。
*/
const HAIRLINE = 1 // 关闭线宽时的固定线宽(设备像素)
const MIN_TEXT_PX = 3.5 // 小于这个高度的文字不画(看不清且很费)
const MIN_SHAPE_PX = 0.6 // 包围盒小于这个尺寸的图元跳过
/** 三种背景,取值与新迪 2D 查看器一致(默认浅灰) */
export const BACKGROUNDS = [
{ id: 'grey', name: '浅灰', color: '#f2f2f2' },
{ id: 'beige', name: '米白', color: '#fffdf5' },
{ id: 'black', name: '黑色', color: '#000000' },
]
export class Renderer {
constructor(container) {
this.container = container
this.canvas = document.createElement('canvas')
this.canvas.style.display = 'block'
this.canvas.style.width = '100%'
this.canvas.style.height = '100%'
container.appendChild(this.canvas)
this.ctx = this.canvas.getContext('2d', { alpha: false })
this.dpr = Math.min(window.devicePixelRatio || 1, 2)
this.doc = null
this.shapes = []
this.bbox = [0, 0, 1, 1]
// rot整张图的显示旋转角弧度逆时针。只影响显示不改几何。
this.view = { cx: 0, cy: 0, scale: 1, rot: 0 }
this.hidden = new Set() // 隐藏的图层名
this.opts = {
bg: BACKGROUNDS[0].color, // 与新迪 2D 一致:默认浅灰
lineWeight: false, // 是否按真实线宽绘制
showText: true,
showHatch: true,
autoContrast: true, // 过暗/过亮的线色按背景自动拉到可读区间
lineWeightScale: 1,
}
this._colorCache = new Map()
this._colorSig = ''
this.selection = new Set()
this.highlight = null
this.overlay = null // (ctx, renderer) => void供工具画临时图形
this.index = null
this._pending = false
this._lastDrawMs = 0
this._resize()
}
// -------------------------------------------------------------- 基础
setDocument(doc, shapes, bbox) {
this.doc = doc
this.shapes = shapes
this.bbox = isFinite(bbox[0]) ? bbox.slice() : [0, 0, 1, 1]
this.hidden.clear()
this.selection.clear()
this.highlight = null
this.index = buildIndex(shapes, this.bbox)
this._styleCache = new Map()
}
_resize() {
const r = this.container.getBoundingClientRect()
const w = Math.max(1, Math.round(r.width * this.dpr))
const h = Math.max(1, Math.round(r.height * this.dpr))
if (this.canvas.width !== w || this.canvas.height !== h) {
this.canvas.width = w
this.canvas.height = h
return true
}
return false
}
resize() { if (this._resize()) this.draw() }
get width() { return this.canvas.width }
get height() { return this.canvas.height }
/**
* 世界坐标 → 设备像素(未含视图旋转)。
*
* 旋转不在这里做,而是在 draw() 里用 canvas 变换绕画布中心转一次:
* 这样 sx/sy 仍是「单参数、双精度」的,图纸放大几百倍也不掉精度,
* 而旋转作用在已经是屏幕量级的坐标上,精度绰绰有余。
*/
sx(x) { return (x - this.view.cx) * this.view.scale + this.width / 2 }
sy(y) { return this.height / 2 - (y - this.view.cy) * this.view.scale }
/** 给 canvas 上的旋转:绕画布中心转 -rot屏幕 Y 轴朝下,故取负) */
_applyViewRotation(ctx) {
const r = this.view.rot
if (!r) return
ctx.translate(this.width / 2, this.height / 2)
ctx.rotate(-r)
ctx.translate(-this.width / 2, -this.height / 2)
}
/** CSS 像素(鼠标事件坐标)→ 世界坐标 */
toWorld(px, py) {
let x = px * this.dpr, y = py * this.dpr
const r = this.view.rot
if (r) {
// 反向转回未旋转的视图系
const dx = x - this.width / 2, dy = y - this.height / 2
const c = Math.cos(r), s = Math.sin(r)
x = this.width / 2 + dx * c - dy * s
y = this.height / 2 + dx * s + dy * c
}
return {
x: (x - this.width / 2) / this.view.scale + this.view.cx,
y: (this.height / 2 - y) / this.view.scale + this.view.cy,
}
}
/** 世界坐标 → CSS 像素(含视图旋转) */
toScreen(x, y) {
let sx = this.sx(x), sy = this.sy(y)
const r = this.view.rot
if (r) {
const dx = sx - this.width / 2, dy = sy - this.height / 2
const c = Math.cos(-r), s = Math.sin(-r)
sx = this.width / 2 + dx * c - dy * s
sy = this.height / 2 + dx * s + dy * c
}
return { x: sx / this.dpr, y: sy / this.dpr }
}
/**
* 当前是不是「全图」状态(缩放倍数接近铺满视口的倍数)。
* 旋转时用它决定要不要重新适配:全图状态下转 90° 应该继续铺满,
* 但用户已经放大到某个细节时,重新适配会把他辛苦找到的位置弄丢。
*/
isFitted(tol = 0.03) {
const b = this.bbox
let w = Math.abs(b[2] - b[0]) || 1
let h = Math.abs(b[3] - b[1]) || 1
const r = this.view.rot
if (r) {
const c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r))
const w2 = w * c + h * s
h = w * s + h * c
w = w2
}
const fit = Math.min(this.width / w, this.height / h) / 1.06
return Math.abs(this.view.scale - fit) / fit < tol
}
/** 旋转视图delta 为弧度,逆时针为正 */
rotateBy(delta) { this.setRotation(this.view.rot + delta) }
setRotation(rad) {
let r = rad % (Math.PI * 2)
if (r > Math.PI) r -= Math.PI * 2
if (r < -Math.PI) r += Math.PI * 2
this.view.rot = Math.abs(r) < 1e-9 ? 0 : r
}
zoomExtents(pad = 1.06) {
const b = this.bbox
if (!(b[2] - b[0] > 0) && !(b[3] - b[1] > 0)) {
this.view.cx = b[0] || 0; this.view.cy = b[1] || 0; this.view.scale = 1
return
}
this._fit(b, pad)
}
zoomToBox(box, pad = 1.05) {
if (Math.abs(box[2] - box[0]) < 1e-12 || Math.abs(box[3] - box[1]) < 1e-12) return
this._fit(box, pad)
}
/** 把一个世界矩形铺满视口;视图有旋转时按旋转后的外接尺寸算 */
_fit(box, pad) {
let w = Math.abs(box[2] - box[0]) || 1
let h = Math.abs(box[3] - box[1]) || 1
const r = this.view.rot
if (r) {
const c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r))
const w2 = w * c + h * s
const h2 = w * s + h * c
w = w2; h = h2
}
this.view.scale = Math.min(this.width / w, this.height / h) / pad
this.view.cx = (box[0] + box[2]) / 2
this.view.cy = (box[1] + box[3]) / 2
}
/** 以某个屏幕点为锚点缩放 */
zoomAt(px, py, factor) {
const before = this.toWorld(px, py)
this.view.scale = clamp(this.view.scale * factor, 1e-9, 1e12)
const after = this.toWorld(px, py)
this.view.cx += before.x - after.x
this.view.cy += before.y - after.y
}
panByPixels(dx, dy) {
let x = dx * this.dpr, y = dy * this.dpr
const r = this.view.rot
if (r) {
// 拖动位移是屏幕量,要先转回未旋转的视图系,图才会跟着鼠标走
const c = Math.cos(r), s = Math.sin(r)
const nx = x * c - y * s
y = x * s + y * c
x = nx
}
this.view.cx -= x / this.view.scale
this.view.cy += y / this.view.scale
}
/** 合并同一帧内的多次重绘请求 */
requestDraw() {
if (this._pending) return
this._pending = true
requestAnimationFrame(() => { this._pending = false; this.draw() })
}
// -------------------------------------------------------------- 绘制
draw() {
const t0 = performance.now()
const ctx = this.ctx
const W = this.width, H = this.height
// 背景或对比度设置变了就丢掉颜色缓存
const sig = `${this.opts.bg}|${this.opts.autoContrast}`
if (this._colorSig !== sig) { this._colorSig = sig; this._colorCache = new Map() }
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.fillStyle = this.opts.bg
ctx.fillRect(0, 0, W, H)
this._applyViewRotation(ctx)
if (!this.shapes.length) { this._lastDrawMs = performance.now() - t0; this._drawOverlay(); return }
// 可视世界范围:旋转后四角都要算,否则转 45° 时边角会被裁掉
const view = [Infinity, Infinity, -Infinity, -Infinity]
for (const [px, py] of [[0, 0], [W / this.dpr, 0], [0, H / this.dpr], [W / this.dpr, H / this.dpr]]) {
const p = this.toWorld(px, py)
if (p.x < view[0]) view[0] = p.x
if (p.y < view[1]) view[1] = p.y
if (p.x > view[2]) view[2] = p.x
if (p.y > view[3]) view[3] = p.y
}
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
const batches = new Map() // styleKey -> {color, width, dash, items[]}
const texts = []
const fills = []
let drawn = 0
const candidates = this.index ? this.index.query(view) : rangeAll(this.shapes.length)
for (const i of candidates) {
const s = this.shapes[i]
if (!s || this.hidden.has(s.layer)) continue
const b = s.bbox
if (b[2] < view[0] || b[0] > view[2] || b[3] < view[1] || b[1] > view[3]) continue
if (s.kind === 'fill') {
if (this.opts.showHatch) fills.push(s)
continue
}
if (s.kind === 'text' || s.kind === 'mtext') {
if (this.opts.showText) texts.push(s)
continue
}
if (s.kind === 'segs' && !this.opts.showHatch) continue
// 太小的图元直接跳过
const px = (b[2] - b[0]) * this.view.scale, py = (b[3] - b[1]) * this.view.scale
if (px < MIN_SHAPE_PX && py < MIN_SHAPE_PX && s.kind !== 'point') continue
const st = this._styleOf(s)
let bat = batches.get(st.key)
if (!bat) { bat = { ...st, items: [] }; batches.set(st.key, bat) }
bat.items.push(s)
drawn++
}
// 填充画在最下面,避免盖住线
for (const s of fills) this._drawFill(ctx, s)
for (const bat of batches.values()) {
ctx.strokeStyle = bat.css
ctx.lineWidth = bat.width
if (bat.dash) ctx.setLineDash(bat.dash); else ctx.setLineDash([])
ctx.beginPath()
for (const s of bat.items) this._path(ctx, s)
ctx.stroke()
}
ctx.setLineDash([])
for (const s of texts) this._drawText(ctx, s)
// 选中与高亮画在最上层
if (this.selection.size || this.highlight != null) this._drawEmphasis(ctx)
this._lastDrawMs = performance.now() - t0
this._drawnCount = drawn
this._drawOverlay()
}
_drawOverlay() {
if (!this.overlay) return
const ctx = this.ctx
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.setLineDash([])
// 测量线、批注跟着图纸一起转;框选矩形是屏幕物件,工具里自己 resetTransform
this._applyViewRotation(ctx)
this.overlay(ctx, this)
ctx.restore()
}
_styleOf(s) {
const lw = this.opts.lineWeight ? this._lineWidthOf(s) : HAIRLINE
const dash = this._dashOf(s)
const color = this._colorOf(s)
const key = `${color}|${lw.toFixed(2)}|${dash ? dash.join(',') : ''}`
return { key, css: rgbCss(color), width: lw, dash }
}
_colorOf(s) {
const c = s.color
let v = this._colorCache.get(c)
if (v === undefined) { v = this._adjustColor(c); this._colorCache.set(c, v) }
return v
}
/**
* 按背景调整线色。
*
* 图纸的配色是冲着打印白纸来的直接放到黑底上ACI 250 这种 #333333
* 会彻底看不见;反过来白底上的纯黄也一样。所以:
* - 纯黑/纯白按背景直接对调;
* - 其余过暗/过亮的颜色按亮度等比拉到可读区间,色相不变。
* 不想要这个调整可以在「设置」里关掉,那就完全按文件里的颜色画。
*/
_adjustColor(c) {
if (this.isDarkBg) {
if (c === 0x000000) return 0xffffff
if (!this.opts.autoContrast) return c
return rescale(c, 0.22, 0.38, true)
}
if (c === 0xffffff) return 0x000000
if (!this.opts.autoContrast) return c
return rescale(c, 0.82, 0.62, false)
}
/** 背景是不是深色(决定线色往哪边调) */
get isDarkBg() {
const c = parseInt(String(this.opts.bg).replace('#', ''), 16) || 0
return luma((c >> 16) & 0xff, (c >> 8) & 0xff, c & 0xff) < 0.5
}
_lineWidthOf(s) {
let lw = s.lw
if (lw < 0) {
const layer = this.doc && this.doc.layers.get(s.layer)
lw = layer && layer.lineweight != null && layer.lineweight >= 0 ? layer.lineweight : 25
}
if (!(lw > 0)) lw = 25 // 1/100 mm
// 线宽是「打印宽度」,与缩放无关:按 mm→像素固定换算
const px = (lw / 100) * (96 / 25.4) * this.dpr * this.opts.lineWeightScale
return Math.max(HAIRLINE, px)
}
_dashOf(s) {
if (!s.lt || !this.doc) return null
const cacheKey = `${s.lt}|${s.ltScale}`
let pat = this._styleCache.get(cacheKey)
if (pat === undefined) {
const lt = this.doc.lineTypes.get(s.lt)
pat = lt && lt.pattern && lt.pattern.length > 1 ? lt.pattern : null
this._styleCache.set(cacheKey, pat)
}
if (!pat) return null
const gs = (this.doc.header && this.doc.header.LTSCALE) || 1
const k = this.view.scale * (s.ltScale || 1) * gs
const dash = []
let total = 0
for (const d of pat) {
const v = Math.abs(d) * k
dash.push(v < 0.1 ? 0.1 : v)
total += v
}
if (dash.length % 2) dash.push(dash[dash.length - 1])
// 图案过密(一屏几千个点)或过疏(整条线一个 dash就当实线
if (total < 2 || total > 4000) return null
return dash
}
_path(ctx, s) {
switch (s.kind) {
case 'poly': {
const p = s.pts
ctx.moveTo(this.sx(p[0]), this.sy(p[1]))
for (let i = 2; i < p.length; i += 2) ctx.lineTo(this.sx(p[i]), this.sy(p[i + 1]))
if (s.closed) ctx.closePath()
break
}
case 'segs': {
const p = s.pts
for (let i = 0; i + 3 < p.length; i += 4) {
ctx.moveTo(this.sx(p[i]), this.sy(p[i + 1]))
ctx.lineTo(this.sx(p[i + 2]), this.sy(p[i + 3]))
}
break
}
case 'arc': {
const cx = this.sx(s.cx), cy = this.sy(s.cy)
const rx = s.rx * this.view.scale, ry = s.ry * this.view.scale
if (rx < 0.2 && ry < 0.2) { ctx.moveTo(cx, cy); ctx.lineTo(cx + 0.3, cy); break }
// 屏幕 Y 轴朝下:角度取负、方向反转
ctx.moveTo(...arcStart(cx, cy, rx, ry, -s.rot, s.a0))
ctx.ellipse(cx, cy, rx, ry, -s.rot, -s.a0, -s.a1, true)
break
}
case 'point': {
const x = this.sx(s.x), y = this.sy(s.y)
ctx.moveTo(x - 2, y)
ctx.lineTo(x + 2, y)
ctx.moveTo(x, y - 2)
ctx.lineTo(x, y + 2)
break
}
default: break
}
}
_drawFill(ctx, s) {
ctx.fillStyle = rgbCss(this._colorOf(s))
ctx.beginPath()
for (const l of s.loops) {
if (l.length < 6) continue
ctx.moveTo(this.sx(l[0]), this.sy(l[1]))
for (let i = 2; i < l.length; i += 2) ctx.lineTo(this.sx(l[i]), this.sy(l[i + 1]))
ctx.closePath()
}
ctx.fill('evenodd')
}
// -------------------------------------------------------------- 文字
_fontFamily(fontFile) {
if (!fontFile) return DEFAULT_FONT
const f = String(fontFile).toLowerCase().replace(/\.(shx|ttf|ttc|otf)$/, '')
return FONT_MAP[f] || DEFAULT_FONT
}
_drawText(ctx, s) {
const px = s.h * this.view.scale
if (px < MIN_TEXT_PX) return
ctx.save()
ctx.translate(this.sx(s.x), this.sy(s.y))
if (s.rot) ctx.rotate(-s.rot)
ctx.fillStyle = rgbCss(this._colorOf(s))
if (s.kind === 'text') this._drawSingleText(ctx, s, px)
else this._drawMText(ctx, s, px)
ctx.restore()
}
_drawSingleText(ctx, s, px) {
const fam = this._fontFamily(s.font)
// DXF 的字高是「大写字母高度」canvas 的 font-size 是 em 高,约差 0.72
const fs = px / CAP_RATIO
ctx.font = `${fs}px ${fam}`
ctx.textBaseline = 'alphabetic'
ctx.textAlign = s.halign === 1 || s.halign === 4 ? 'center' : s.halign === 2 ? 'right' : 'left'
let dy = 0
if (s.valign === 2 || s.halign === 4) dy = px / 2
else if (s.valign === 3) dy = px
else if (s.valign === 1) dy = 0
const w = s.wFactor || 1
const ob = s.oblique || 0
if (w !== 1 || ob) ctx.transform(w, 0, Math.tan(-ob), 1, 0, 0)
ctx.fillText(s.plain, 0, dy)
if (s.underline) {
const tw = ctx.measureText(s.plain).width
ctx.fillRect(0, dy + px * 0.16, tw, Math.max(1, px * 0.06))
}
}
_drawMText(ctx, s, px) {
const fam = this._fontFamily(s.font)
const gap = px * (s.lineGap || 1.667)
const lines = this._mtextLayout(ctx, s, px, fam)
const total = (lines.length - 1) * gap + px
// ay: 1=顶 0.5=中 0=底。y 轴向下,基线比「顶」低一个字高。
const y0 = px - (s.ay === 1 ? 0 : s.ay === 0.5 ? total / 2 : total)
const baseColor = rgbCss(this._colorOf(s))
for (let li = 0; li < lines.length; li++) {
const { runs, w: lineW } = lines[li]
let x = -lineW * s.ax
const y = y0 + li * gap
for (const r of runs) {
ctx.font = fontOf(r, px, fam)
ctx.fillStyle = r.color && r.color.rgb != null ? rgbCss(r.color.rgb) : baseColor
const wf = r.wFactor || 1
ctx.save()
if (wf !== 1 || r.oblique) ctx.transform(wf, 0, Math.tan(-(r.oblique || 0)), 1, 0, 0)
ctx.fillText(r.text, x / wf, y - (r.rise || 0) * px)
ctx.restore()
if (r.underline) ctx.fillRect(x, y + px * 0.16, r.w, Math.max(1, px * 0.06))
if (r.strike) ctx.fillRect(x, y - px * 0.32, r.w, Math.max(1, px * 0.06))
x += r.w
}
}
ctx.fillStyle = baseColor
}
/**
* MTEXT 排版:按 rectWidth 折行并量出每行宽度。
*
* 结果按字号缓存在图元上——缩放时字号才会变,平移不用重排。
* 折行规则:西文优先在空格处断,中日韩逐字断(和 AutoCAD 一致)。
*/
_mtextLayout(ctx, s, px, fam) {
const key = Math.round(px * 4)
if (s._layout && s._layout.key === key) return s._layout.lines
const maxW = s.width > 0 ? s.width * this.view.scale : 0
const out = []
for (const runs of s.lines) {
// 展平成字符流,每个字符记住自己属于哪个 run
const chars = []
for (const r of runs) {
ctx.font = fontOf(r, px, fam)
const wf = r.wFactor || 1
for (const ch of r.text) chars.push({ ch, r, w: ctx.measureText(ch).width * wf })
}
if (!chars.length) { out.push({ runs: [], w: 0 }); continue }
if (!maxW) { out.push(packLine(chars)); continue }
let start = 0, w = 0, lastSpace = -1
for (let i = 0; i < chars.length; i++) {
const c = chars[i]
if (c.ch === ' ') lastSpace = i
if (w + c.w > maxW && i > start) {
const cut = lastSpace > start ? lastSpace : i
out.push(packLine(chars.slice(start, cut)))
start = lastSpace > start ? cut + 1 : cut
lastSpace = -1
w = 0
for (let k = start; k <= i; k++) w += chars[k].w
} else {
w += c.w
}
}
if (start < chars.length) out.push(packLine(chars.slice(start)))
}
s._layout = { key, lines: out }
return out
}
// -------------------------------------------------------------- 选中高亮
_drawEmphasis(ctx) {
ctx.save()
ctx.setLineDash([])
const paint = (idx, color, width) => {
const s = this.shapes[idx]
if (!s || this.hidden.has(s.layer)) return
ctx.strokeStyle = color
ctx.lineWidth = width
ctx.beginPath()
if (s.kind === 'fill') { for (const l of s.loops) polyPath(ctx, this, l, true) } else this._path(ctx, s)
ctx.stroke()
if (s.kind === 'text' || s.kind === 'mtext') {
const b = s.bbox
ctx.strokeRect(this.sx(b[0]), this.sy(b[3]), (b[2] - b[0]) * this.view.scale, (b[3] - b[1]) * this.view.scale)
}
}
for (const i of this.selection) paint(i, '#2b9ae8', 3 * this.dpr)
if (this.highlight != null && !this.selection.has(this.highlight)) paint(this.highlight, '#ff9800', 2.5 * this.dpr)
ctx.restore()
}
// -------------------------------------------------------------- 拾取
/**
* 在屏幕坐标附近找最近的图元。
* @returns {number|null} shape 下标
*/
pick(px, py, tolPx = 6) {
if (!this.index) return null
const w = this.toWorld(px, py)
const tol = (tolPx * this.dpr) / this.view.scale
const box = [w.x - tol, w.y - tol, w.x + tol, w.y + tol]
let best = null, bestD = tol * tol
for (const i of this.index.query(box)) {
const s = this.shapes[i]
if (!s || this.hidden.has(s.layer)) continue
if (!this.opts.showText && (s.kind === 'text' || s.kind === 'mtext')) continue
if (!this.opts.showHatch && (s.kind === 'fill' || s.kind === 'segs')) continue
const b = s.bbox
if (w.x < b[0] - tol || w.x > b[2] + tol || w.y < b[1] - tol || w.y > b[3] + tol) continue
const d = distToShape(s, w.x, w.y)
if (d < bestD) { bestD = d; best = i }
}
return best
}
/** 框选:返回与矩形相交的图元下标 */
pickBox(box, crossing = true) {
const out = []
if (!this.index) return out
for (const i of this.index.query(box)) {
const s = this.shapes[i]
if (!s || this.hidden.has(s.layer)) continue
const b = s.bbox
if (crossing) {
if (b[2] < box[0] || b[0] > box[2] || b[3] < box[1] || b[1] > box[3]) continue
} else if (!(b[0] >= box[0] && b[2] <= box[2] && b[1] >= box[1] && b[3] <= box[3])) continue
out.push(i)
}
return out
}
}
// ---------------------------------------------------------------- 工具函数
const CAP_RATIO = 0.72
const DEFAULT_FONT = '"Microsoft YaHei", SimSun, "PingFang SC", Arial, sans-serif'
const FONT_MAP = {
simsun: 'SimSun, serif', song: 'SimSun, serif', simhei: 'SimHei, sans-serif',
simkai: 'KaiTi, serif', kaiti: 'KaiTi, serif', fangsong: 'FangSong, serif',
msyh: '"Microsoft YaHei", sans-serif', arial: 'Arial, sans-serif',
times: '"Times New Roman", serif', isocp: 'Arial, sans-serif', isocpeur: 'Arial, sans-serif',
txt: 'Arial, sans-serif', simplex: 'Arial, sans-serif', romans: 'Arial, sans-serif',
romand: 'Arial, sans-serif', gbcbig: 'SimSun, serif', hztxt: 'SimSun, serif',
gbenor: 'Arial, sans-serif', gbeitc: 'Arial, sans-serif',
}
/** 把字符流按所属 run 合并回文字段,顺便算出整行宽度 */
function packLine(chars) {
const runs = []
let cur = null, total = 0
for (const c of chars) {
if (!cur || cur.src !== c.r) {
cur = { ...c.r, src: c.r, text: '', w: 0 }
runs.push(cur)
}
cur.text += c.ch
cur.w += c.w
total += c.w
}
return { runs, w: total }
}
function fontOf(run, px, fam) {
const size = px * (run.hFactor || 1) / CAP_RATIO
const style = run.italic ? 'italic ' : ''
const weight = run.bold ? '700 ' : ''
return `${style}${weight}${size}px ${fam}`
}
function rgbCss(c) {
return `#${((c >>> 0) & 0xffffff).toString(16).padStart(6, '0')}`
}
const luma = (r, g, b) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
/**
* 亮度越界时把三个通道等比缩放到 target色相保持不变。
* @param up true=提亮深色背景false=压暗(浅色背景)
*/
function rescale(c, limit, target, up) {
const r = (c >> 16) & 0xff, g = (c >> 8) & 0xff, b = c & 0xff
const l = luma(r, g, b)
if (up ? l >= limit : l <= limit) return c
const k = target / Math.max(l, 0.02)
const cl = (v) => Math.max(0, Math.min(255, Math.round(v * k)))
return (cl(r) << 16) | (cl(g) << 8) | cl(b)
}
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v)
function arcStart(cx, cy, rx, ry, rot, a) {
const c = Math.cos(rot), s = Math.sin(rot)
const x = rx * Math.cos(a), y = ry * Math.sin(a)
return [cx + x * c - y * s, cy + x * s + y * c]
}
function polyPath(ctx, r, l, close) {
ctx.moveTo(r.sx(l[0]), r.sy(l[1]))
for (let i = 2; i < l.length; i += 2) ctx.lineTo(r.sx(l[i]), r.sy(l[i + 1]))
if (close) ctx.closePath()
}
function* rangeAll(n) { for (let i = 0; i < n; i++) yield i }
// ---------------------------------------------------------------- 空间索引
/** 均匀网格索引CAD 图元分布不均,但网格足够快且构建成本低 */
function buildIndex(shapes, bbox) {
const w = bbox[2] - bbox[0], h = bbox[3] - bbox[1]
if (!(w > 0) || !(h > 0) || !shapes.length) return null
const target = Math.max(16, Math.min(256, Math.ceil(Math.sqrt(shapes.length / 4))))
const nx = target, ny = target
const cw = w / nx, ch = h / ny
const cells = new Array(nx * ny)
const big = [] // 跨越太多格子的图元单独存
for (let i = 0; i < shapes.length; i++) {
const b = shapes[i].bbox
let x0 = Math.floor((b[0] - bbox[0]) / cw), x1 = Math.floor((b[2] - bbox[0]) / cw)
let y0 = Math.floor((b[1] - bbox[1]) / ch), y1 = Math.floor((b[3] - bbox[1]) / ch)
x0 = clamp(x0, 0, nx - 1); x1 = clamp(x1, 0, nx - 1)
y0 = clamp(y0, 0, ny - 1); y1 = clamp(y1, 0, ny - 1)
if ((x1 - x0 + 1) * (y1 - y0 + 1) > 64) { big.push(i); continue }
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const k = y * nx + x
;(cells[k] || (cells[k] = [])).push(i)
}
}
}
return {
query(box) {
const out = new Set(big)
let x0 = Math.floor((box[0] - bbox[0]) / cw), x1 = Math.floor((box[2] - bbox[0]) / cw)
let y0 = Math.floor((box[1] - bbox[1]) / ch), y1 = Math.floor((box[3] - bbox[1]) / ch)
x0 = clamp(x0, 0, nx - 1); x1 = clamp(x1, 0, nx - 1)
y0 = clamp(y0, 0, ny - 1); y1 = clamp(y1, 0, ny - 1)
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const c = cells[y * nx + x]
if (c) for (const i of c) out.add(i)
}
}
return out
},
}
}
// ---------------------------------------------------------------- 距离计算
/** 点到图元的平方距离(世界坐标) */
export function distToShape(s, x, y) {
switch (s.kind) {
case 'poly': return distToPolyline(s.pts, x, y, s.closed)
case 'segs': {
let best = Infinity
const p = s.pts
for (let i = 0; i + 3 < p.length; i += 4) {
const d = distToSeg(x, y, p[i], p[i + 1], p[i + 2], p[i + 3])
if (d < best) best = d
}
return best
}
case 'arc': return distToArc(s, x, y)
case 'point': return (x - s.x) ** 2 + (y - s.y) ** 2
case 'fill': {
let best = Infinity
for (const l of s.loops) {
const d = distToPolyline(l, x, y, true)
if (d < best) best = d
}
// 落在填充内部也算命中
if (pointInLoops(s.loops, x, y)) return 0
return best
}
case 'text':
case 'mtext': {
const b = s.bbox
if (x >= b[0] && x <= b[2] && y >= b[1] && y <= b[3]) return 0
const dx = Math.max(b[0] - x, 0, x - b[2])
const dy = Math.max(b[1] - y, 0, y - b[3])
return dx * dx + dy * dy
}
default: return Infinity
}
}
export function distToPolyline(p, x, y, closed) {
let best = Infinity
for (let i = 0; i + 3 < p.length; i += 2) {
const d = distToSeg(x, y, p[i], p[i + 1], p[i + 2], p[i + 3])
if (d < best) best = d
}
if (closed && p.length >= 4) {
const d = distToSeg(x, y, p[p.length - 2], p[p.length - 1], p[0], p[1])
if (d < best) best = d
}
return best
}
export function distToSeg(px, py, x0, y0, x1, y1) {
const dx = x1 - x0, dy = y1 - y0
const len = dx * dx + dy * dy
let t = len === 0 ? 0 : ((px - x0) * dx + (py - y0) * dy) / len
t = t < 0 ? 0 : t > 1 ? 1 : t
const ex = x0 + t * dx - px, ey = y0 + t * dy - py
return ex * ex + ey * ey
}
function distToArc(s, x, y) {
// 椭圆用逆变换到单位圆的近似距离,够拾取用
const c = Math.cos(-s.rot), sn = Math.sin(-s.rot)
const dx = x - s.cx, dy = y - s.cy
const lx = dx * c - dy * sn, ly = dx * sn + dy * c
const rx = s.rx || 1e-9, ry = s.ry || 1e-9
const ang = Math.atan2(ly / ry, lx / rx)
if (!angleInArc(ang, s.a0, s.a1)) {
const p0 = ellipsePt(s, s.a0), p1 = ellipsePt(s, s.a1)
return Math.min((x - p0[0]) ** 2 + (y - p0[1]) ** 2, (x - p1[0]) ** 2 + (y - p1[1]) ** 2)
}
const px = rx * Math.cos(ang), py = ry * Math.sin(ang)
const ex = px - lx, ey = py - ly
return ex * ex + ey * ey
}
export function ellipsePt(s, t) {
const c = Math.cos(s.rot), sn = Math.sin(s.rot)
const x = s.rx * Math.cos(t), y = s.ry * Math.sin(t)
return [s.cx + x * c - y * sn, s.cy + x * sn + y * c]
}
function angleInArc(a, a0, a1) {
const TAU = Math.PI * 2
let sweep = a1 - a0
while (sweep <= 0) sweep += TAU
if (sweep >= TAU - 1e-9) return true
let d = a - a0
while (d < 0) d += TAU
while (d > TAU) d -= TAU
return d <= sweep
}
function pointInLoops(loops, x, y) {
let inside = false
for (const l of loops) {
const n = l.length / 2
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = l[i * 2], yi = l[i * 2 + 1], xj = l[j * 2], yj = l[j * 2 + 1]
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside
}
}
return inside
}

328
dev/DWGViewer/js/source.js Normal file
View File

@@ -0,0 +1,328 @@
/**
* 文件 → 统一文档模型Doc
*
* 两条互不依赖的解析路径:
* .dxf → dxf-parser MIT纯 JS随页面一起加载
* .dwg → libredwg-web WASM GPL-3.0,只有真的打开 dwg 时才动态 import
* 约 10MB不打开 dwg 的用户完全不会下载)
*
* 这样拆分是为了让 GPL 依赖是可替换的一块:将来换成商业 SDK 或服务端转换,
* 只需要替换 loadDwg(),其余代码不受影响。
*
* 统一约定Doc 里所有角度都是**弧度**。
*/
import { parseDxfHatches } from './dxfHatch.js'
const RAD = Math.PI / 180
/** @typedef {{name:string, format:string, layers:Map, lineTypes:Map, styles:Map, blocks:Map, modelEntities:Array, layouts:Array, header:object, license:string}} Doc */
export function extOf(name) {
const m = /\.([a-z0-9]+)$/i.exec(name || '')
return m ? m[1].toLowerCase() : ''
}
/**
* @param {string} name 文件名(用于判断格式)
* @param {ArrayBuffer} buffer
* @param {(msg:string)=>void} [onProgress]
*/
export async function loadDrawing(name, buffer, onProgress = () => {}) {
const ext = extOf(name)
if (ext === 'dwg') return loadDwg(name, buffer, onProgress)
if (ext === 'dxf') return loadDxf(name, buffer, onProgress)
// 没有扩展名时按内容嗅探DWG 以 "AC10xx" 开头
const head = new TextDecoder('latin1').decode(new Uint8Array(buffer, 0, Math.min(6, buffer.byteLength)))
if (/^AC10\d\d/.test(head)) return loadDwg(name, buffer, onProgress)
return loadDxf(name, buffer, onProgress)
}
// ---------------------------------------------------------------- DWG
let libredwgPromise = null
/** 动态加载 GPL 模块;只在第一次打开 dwg 时触发 */
function getLibreDwg() {
if (!libredwgPromise) {
libredwgPromise = import('../libs/libredwg/dist/libredwg-web.js').then(async (mod) => {
const inst = await mod.LibreDwg.create()
return { mod, inst }
})
}
return libredwgPromise
}
async function loadDwg(name, buffer, onProgress) {
onProgress('正在加载 DWG 解析模块(首次约 10MB...')
const { mod, inst } = await getLibreDwg()
onProgress('正在解析 DWG...')
const dwg = inst.dwg_read_data(buffer, mod.Dwg_File_Type.DWG)
if (!dwg) throw new Error('DWG 解析失败:文件可能已损坏或版本过新')
let db
try {
db = inst.convert(dwg)
} finally {
try { inst.dwg_free(dwg) } catch { /* 释放失败不影响已转换的数据 */ }
}
return normalizeDwg(name, db)
}
function tableEntries(db, key) {
const t = db.tables && db.tables[key]
if (!t) return []
const e = t.entries
if (Array.isArray(e)) return e
if (e && typeof e === 'object') return Object.values(e)
return []
}
function normalizeDwg(name, db) {
const layers = new Map()
for (const l of tableEntries(db, 'LAYER')) {
layers.set(l.name, {
name: l.name,
colorIndex: l.colorIndex,
trueColor: colorFieldToTrueColor(l),
lineType: l.lineType,
lineweight: l.lineweight,
off: !!l.off || (typeof l.colorIndex === 'number' && l.colorIndex < 0),
frozen: !!l.frozen,
locked: !!l.locked,
plot: l.plotFlag !== 0,
})
}
if (!layers.has('0')) layers.set('0', { name: '0', colorIndex: 7, lineType: 'Continuous', off: false, frozen: false })
const lineTypes = new Map()
for (const lt of tableEntries(db, 'LTYPE')) {
lineTypes.set(lt.name, {
name: lt.name,
patternLength: lt.totalPatternLength || 0,
pattern: normalizeLtPattern(lt.pattern),
})
}
const styles = new Map()
for (const s of tableEntries(db, 'STYLE')) {
styles.set(s.name, {
name: s.name,
font: s.font,
bigFont: s.bigFont,
widthFactor: s.widthFactor || 1,
obliqueAngle: s.obliqueAngle || 0,
fixedTextHeight: s.fixedTextHeight || 0,
})
}
// 块表:*Model_Space / *Paper_Space 是布局,其余是可被 INSERT 引用的块
const blocks = new Map()
let modelEntities = []
const layouts = []
for (const br of tableEntries(db, 'BLOCK_RECORD')) {
const ents = br.entities || []
const nm = br.name || ''
if (/^\*model_space$/i.test(nm)) modelEntities = ents
else if (/^\*paper_space/i.test(nm)) layouts.push({ name: nm, entities: ents })
blocks.set(nm, { name: nm, basePoint: br.basePoint || { x: 0, y: 0 }, entities: ents })
}
// 有些文件模型空间实体只出现在顶层 entities 里
if (!modelEntities.length && Array.isArray(db.entities)) modelEntities = db.entities
return {
name, format: 'DWG', license: 'GPL-3.0LibreDWG',
layers, lineTypes, styles, blocks, modelEntities, layouts,
header: db.header || {},
}
}
/** libredwg 用 0xFFFFFF 表示「没有真彩色」 */
function colorFieldToTrueColor(o) {
if (typeof o.trueColor === 'number') return o.trueColor & 0xffffff
if (typeof o.color === 'number' && o.color !== 16777215 && o.color !== -1) return o.color & 0xffffff
return undefined
}
function normalizeLtPattern(pattern) {
if (!Array.isArray(pattern)) return []
return pattern.map((p) => {
if (typeof p === 'number') return p
if (p && typeof p === 'object') return p.length != null ? p.length : (p.dashLength != null ? p.dashLength : 0)
return 0
}).filter((n) => isFinite(n))
}
// ---------------------------------------------------------------- DXF
async function loadDxf(name, buffer, onProgress) {
const bytes = new Uint8Array(buffer)
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 22))
if (head.startsWith('AutoCAD Binary DXF')) {
throw new Error('这是二进制 DXF请在 CAD 里另存为 ASCII DXF或直接用 DWG')
}
onProgress('正在解析 DXF...')
const text = decodeDxfText(bytes)
const { default: DxfParser } = await import('../libs/dxf-parser/index.js')
const parsed = new DxfParser().parseSync(text)
const doc = normalizeDxf(name, parsed)
// dxf-parser 不支持 HATCH用独立的扫描器补上剖面线
try {
parseDxfHatches(text, doc)
} catch (e) {
console.warn('HATCH 解析失败,已跳过:', e)
}
return doc
}
/** DXF 可能是 UTF-8也可能是 GBK国内图纸常见。用 $DWGCODEPAGE 之外的启发式判断 */
function decodeDxfText(bytes) {
const utf8 = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
// U+FFFD 过多说明不是 UTF-8退回 GBK
let bad = 0
for (let i = 0; i < utf8.length; i += 97) if (utf8.charCodeAt(i) === 0xfffd) bad++
if (bad > 2) {
try { return new TextDecoder('gbk').decode(bytes) } catch { /* 浏览器不支持 gbk 就用 utf8 */ }
}
return utf8
}
function normalizeDxf(name, dxf) {
const layers = new Map()
const src = (dxf.tables && dxf.tables.layer && dxf.tables.layer.layers) || {}
for (const [k, l] of Object.entries(src)) {
layers.set(k, {
name: k,
colorIndex: l.colorIndex,
trueColor: typeof l.color === 'number' ? l.color : undefined,
lineType: l.lineType || l.lineTypeName,
lineweight: l.lineweight,
off: l.visible === false || (typeof l.colorIndex === 'number' && l.colorIndex < 0),
frozen: !!(l.frozen || (l.flags & 1)),
locked: !!(l.flags & 4),
plot: true,
})
}
if (!layers.has('0')) layers.set('0', { name: '0', colorIndex: 7, lineType: 'Continuous', off: false, frozen: false })
const lineTypes = new Map()
const lts = (dxf.tables && dxf.tables.lineType && dxf.tables.lineType.lineTypes) || {}
for (const [k, lt] of Object.entries(lts)) {
lineTypes.set(k, {
name: k,
patternLength: lt.patternLength || 0,
pattern: Array.isArray(lt.pattern) ? lt.pattern.filter((n) => typeof n === 'number') : [],
})
}
const styles = new Map()
const sts = (dxf.tables && dxf.tables.style && dxf.tables.style.styles) || {}
for (const [k, s] of Object.entries(sts)) {
styles.set(k, {
name: k, font: s.fontFileName || s.font, bigFont: s.bigFontFileName,
widthFactor: s.widthFactor || 1, obliqueAngle: (s.obliqueAngle || 0) * RAD,
fixedTextHeight: s.fixedTextHeight || 0,
})
}
const blocks = new Map()
for (const [k, b] of Object.entries(dxf.blocks || {})) {
const ents = (b.entities || []).map(dxfEntity)
blocks.set(k, { name: k, basePoint: b.position || { x: 0, y: 0 }, entities: ents })
}
const modelEntities = (dxf.entities || []).map(dxfEntity)
return {
name, format: 'DXF', license: 'MITdxf-parser',
layers, lineTypes, styles, blocks, modelEntities, layouts: [],
header: dxf.header || {},
}
}
/**
* dxf-parser 的实体 → 内部约定。
* 主要做三件事:字段改名、角度统一成弧度、顶点结构统一。
*/
function dxfEntity(e) {
const o = { ...e }
o.colorIndex = e.colorIndex != null ? e.colorIndex : 256
if (typeof e.color === 'number' && e.colorIndex == null) o.trueColor = e.color
o.lineType = e.lineType
o.lineweight = e.lineweight
o.lineTypeScale = e.lineTypeScale || 1
o.isVisible = e.visible !== false
switch (e.type) {
case 'LINE':
o.startPoint = e.vertices && e.vertices[0]
o.endPoint = e.vertices && e.vertices[1]
break
case 'LWPOLYLINE':
case 'POLYLINE':
o.flag = (e.shape ? 1 : 0) | (e.flag || 0)
o.vertices = e.vertices || []
break
case 'SPLINE':
o.degree = e.degreeOfSplineCurve
o.knots = e.knotValues
o.controlPoints = e.controlPoints
o.fitPoints = e.fitPoints
o.flag = e.flag || (e.closed ? 1 : 0)
break
case 'ELLIPSE':
// dxf-parser 已把 startAngle/endAngle 转成弧度
break
case 'ARC':
case 'CIRCLE':
// 同上
break
case 'TEXT':
case 'ATTRIB':
case 'ATTDEF':
o.startPoint = e.startPoint
o.endPoint = e.endPoint
o.textHeight = e.textHeight
o.rotation = (e.rotation || 0) * RAD
o.obliqueAngle = (e.obliqueAngle || 0) * RAD
o.xScale = e.xScale || 1
o.styleName = e.styleName || e.textStyle
o.generationFlag = e.textGenerationFlag || 0
break
case 'MTEXT':
o.insertionPoint = e.position
o.textHeight = e.height
o.rectWidth = e.width
o.rotation = (e.rotation || 0) * RAD
o.attachmentPoint = e.attachmentPoint
o.styleName = e.styleName || e.textStyle
o.direction = e.directionVector
break
case 'INSERT':
o.insertionPoint = e.position
o.rotation = (e.rotation || 0) * RAD
o.xScale = e.xScale == null ? 1 : e.xScale
o.yScale = e.yScale == null ? 1 : e.yScale
o.columnCount = e.columnCount
o.rowCount = e.rowCount
o.columnSpacing = e.columnSpacing
o.rowSpacing = e.rowSpacing
o.attribs = (e.attributes || e.attribs || []).map(dxfEntity)
break
case 'DIMENSION':
o.name = e.block // 尺寸的实际图形所在的匿名块
o.rotation = (e.rotation || 0) * RAD
break
case 'SOLID':
case 'TRACE':
case '3DFACE': {
const p = e.points || e.vertices || []
o.corner1 = p[0]; o.corner2 = p[1]; o.corner3 = p[2]; o.corner4 = p[3]
break
}
case 'POINT':
o.position = e.position
break
default:
break
}
return o
}

702
dev/DWGViewer/js/tools.js Normal file
View File

@@ -0,0 +1,702 @@
/**
* 交互工具:对象捕捉、测量、批注。
*
* 三者都只操作世界坐标,屏幕换算全部交给 Renderer
* 因此缩放平移后测量线和批注会跟着图纸走,不会漂。
*/
import { ellipsePt } from './render.js'
const TAU = Math.PI * 2
// ---------------------------------------------------------------- 对象捕捉
const SNAP_LABEL = { end: '端点', mid: '中点', center: '圆心', quad: '象限点', near: '最近点', node: '节点' }
export class Snapper {
constructor(renderer) {
this.r = renderer
this.enabled = true
this.modes = { end: true, mid: true, center: true, quad: true, near: true }
}
/**
* @param {number} px CSS 像素
* @returns {{x:number,y:number,type:string,shape:number}|null}
*/
find(px, py, tolPx = 12) {
const r = this.r
if (!this.enabled || !r.index) return null
const w = r.toWorld(px, py)
const tol = (tolPx * r.dpr) / r.view.scale
const tol2 = tol * tol
const box = [w.x - tol, w.y - tol, w.x + tol, w.y + tol]
let best = null, bestScore = Infinity
for (const i of r.index.query(box)) {
const s = r.shapes[i]
if (!s || r.hidden.has(s.layer)) continue
if (s.kind === 'text' || s.kind === 'mtext') continue
const b = s.bbox
if (w.x < b[0] - tol || w.x > b[2] + tol || w.y < b[1] - tol || w.y > b[3] + tol) continue
const consider = (x, y, type, prio) => {
const d = (x - w.x) ** 2 + (y - w.y) ** 2
if (d > tol2) return
// 优先级压过距离:端点比最近点更值得捕捉
const score = d + prio * tol2 * 0.35
if (score < bestScore) { bestScore = score; best = { x, y, type, shape: i } }
}
if (s.kind === 'poly' || s.kind === 'segs') {
const p = s.pts
const step = s.kind === 'segs' ? 4 : 2
for (let k = 0; k + 1 < p.length; k += step) {
if (this.modes.end) consider(p[k], p[k + 1], 'end', 0)
}
if (s.kind === 'poly' && this.modes.mid) {
for (let k = 0; k + 3 < p.length; k += 2) {
consider((p[k] + p[k + 2]) / 2, (p[k + 1] + p[k + 3]) / 2, 'mid', 1)
}
}
if (s.kind === 'segs' && this.modes.end) {
for (let k = 2; k + 1 < p.length; k += 4) consider(p[k], p[k + 1], 'end', 0)
}
} else if (s.kind === 'arc') {
if (this.modes.center) consider(s.cx, s.cy, 'center', 0)
if (this.modes.end) {
const a = ellipsePt(s, s.a0), b2 = ellipsePt(s, s.a1)
consider(a[0], a[1], 'end', 0)
consider(b2[0], b2[1], 'end', 0)
}
if (this.modes.quad) {
for (let q = 0; q < 4; q++) {
const t = (q * Math.PI) / 2
if (!angleInArc(t, s.a0, s.a1)) continue
const p = ellipsePt(s, t)
consider(p[0], p[1], 'quad', 1)
}
}
if (this.modes.mid) {
let sweep = s.a1 - s.a0
while (sweep <= 0) sweep += TAU
const p = ellipsePt(s, s.a0 + sweep / 2)
consider(p[0], p[1], 'mid', 1)
}
} else if (s.kind === 'point') {
consider(s.x, s.y, 'node', 0)
}
}
if (!best && this.modes.near) {
const idx = r.pick(px, py, tolPx)
if (idx != null) {
const p = nearestOnShape(r.shapes[idx], w.x, w.y)
if (p) best = { x: p[0], y: p[1], type: 'near', shape: idx }
}
}
return best
}
static label(type) { return SNAP_LABEL[type] || '' }
}
function angleInArc(a, a0, a1) {
let sweep = a1 - a0
while (sweep <= 0) sweep += TAU
if (sweep >= TAU - 1e-9) return true
let d = a - a0
while (d < 0) d += TAU
while (d > TAU) d -= TAU
return d <= sweep
}
/** 图元上离给定点最近的点 */
export function nearestOnShape(s, x, y) {
if (!s) return null
if (s.kind === 'poly' || s.kind === 'segs') {
const p = s.pts
let best = null, bd = Infinity
const step = s.kind === 'segs' ? 4 : 2
for (let i = 0; i + 3 < p.length; i += step) {
const q = closestOnSeg(x, y, p[i], p[i + 1], p[i + 2], p[i + 3])
const d = (q[0] - x) ** 2 + (q[1] - y) ** 2
if (d < bd) { bd = d; best = q }
}
if (s.kind === 'poly' && s.closed && p.length >= 4) {
const q = closestOnSeg(x, y, p[p.length - 2], p[p.length - 1], p[0], p[1])
const d = (q[0] - x) ** 2 + (q[1] - y) ** 2
if (d < bd) best = q
}
return best
}
if (s.kind === 'arc') {
const c = Math.cos(-s.rot), sn = Math.sin(-s.rot)
const dx = x - s.cx, dy = y - s.cy
const lx = dx * c - dy * sn, ly = dx * sn + dy * c
let t = Math.atan2(ly / (s.ry || 1e-9), lx / (s.rx || 1e-9))
if (!angleInArc(t, s.a0, s.a1)) t = s.a0
return ellipsePt(s, t)
}
if (s.kind === 'point') return [s.x, s.y]
return null
}
function closestOnSeg(px, py, x0, y0, x1, y1) {
const dx = x1 - x0, dy = y1 - y0
const len = dx * dx + dy * dy
let t = len === 0 ? 0 : ((px - x0) * dx + (py - y0) * dy) / len
t = t < 0 ? 0 : t > 1 ? 1 : t
return [x0 + t * dx, y0 + t * dy]
}
// ---------------------------------------------------------------- 测量
export const MEASURE_MODES = [
{ id: 'coord', name: '坐标', icon: 'view-tool-coordinate.png', pts: 1 },
{ id: 'p2p', name: '点到点', icon: 'view-tool-pointToPoint.png', pts: 2 },
{ id: 'cont', name: '连续', icon: 'view-tool-continuityLength.png', pts: 0 },
{ id: 'seg', name: '线段长', icon: 'view-tool-segmentlength.png', pick: true },
{ id: 'arclen', name: '弧长', icon: 'view-tool-arcLength.png', pick: true },
{ id: 'p2l', name: '点到线', icon: 'view-tool-pointToLine.png', mixed: true },
{ id: 'l2l', name: '线到线', icon: 'view-tool-lineToLine.png', picks: 2 },
{ id: 'linear', name: '线性', icon: 'view-tool-LinearMeasurement.png', pts: 2 },
{ id: 'angle', name: '角度', icon: 'view-tool-angel.png', pts: 3 },
{ id: 'radius', name: '半径', icon: 'view-tool-radius.png', pick: true },
{ id: 'area', name: '面积', icon: 'view-tool-Area.png', pts: 0 },
]
export class MeasureTool {
constructor(renderer, snapper, onChange) {
this.r = renderer
this.snap = snapper
this.onChange = onChange || (() => {})
this.mode = null
this.results = [] // {type, text, geom}
this.pending = [] // 已点的世界坐标
this.pendingShapes = [] // 已选的图元下标
this.cursor = null // 当前捕捉点
this.unit = 'mm'
this.precision = 3
}
setMode(mode) { this.mode = mode; this.reset(); this.onChange() }
reset() { this.pending = []; this.pendingShapes = []; }
clear() { this.results = []; this.reset(); this.onChange() }
remove(i) { this.results.splice(i, 1); this.onChange() }
fmt(v) {
const n = Math.abs(v) >= 1e6 ? v.toExponential(3) : v.toFixed(this.precision).replace(/\.?0+$/, '')
return `${n} ${this.unit}`
}
fmtAngle(a) { return `${((a * 180) / Math.PI).toFixed(2)}°` }
/** 鼠标移动:更新捕捉预览 */
hover(px, py) {
const s = this.snap.find(px, py)
this.cursor = s || { ...this.r.toWorld(px, py), type: null, shape: null }
return this.cursor
}
/** 左键点击;返回 true 表示消费了这次点击 */
click(px, py) {
const def = MEASURE_MODES.find((m) => m.id === this.mode)
if (!def) return false
const p = this.hover(px, py)
if (def.pick || def.picks) {
const idx = p.shape != null ? p.shape : this.r.pick(px, py, 8)
if (idx == null) return true
this.pendingShapes.push(idx)
if (this.pendingShapes.length >= (def.picks || 1)) this._finishShapes(def)
return true
}
if (def.mixed) {
// 点到线:先点一个点,再点一条线
if (this.pending.length === 0) this.pending.push([p.x, p.y])
else {
const idx = p.shape != null ? p.shape : this.r.pick(px, py, 8)
if (idx == null) return true
this.pendingShapes.push(idx)
this._finishShapes(def)
}
return true
}
this.pending.push([p.x, p.y])
if (def.pts && this.pending.length >= def.pts) this._finishPoints(def)
return true
}
/** 双击 / 回车:结束连续型测量 */
finish() {
const def = MEASURE_MODES.find((m) => m.id === this.mode)
if (!def || def.pts !== 0) return
if (this.pending.length >= 2) this._finishPoints(def)
else this.reset()
}
cancel() { this.reset(); this.onChange() }
/**
* 记一条测量结果。
* @param {() => [string, string?]} make 惰性格式化,返回 [列表文字, 图上标签]。
* 之所以存函数而不是定值:改了单位或小数位以后,已有结果要能跟着变。
*/
_push(type, make, geom) {
const rec = { type, make, geom }
this._apply(rec)
this.results.push(rec)
this.reset()
this.onChange()
}
_apply(rec) {
const [text, label] = rec.make()
rec.text = text
if (rec.geom) rec.geom.label = label === undefined ? text : label
}
/** 单位 / 小数位变了以后刷新全部结果 */
refresh() {
for (const r of this.results) this._apply(r)
}
_finishPoints(def) {
const p = this.pending.slice()
switch (def.id) {
case 'coord':
this._push('坐标',
() => [`X=${p[0][0].toFixed(this.precision)} Y=${p[0][1].toFixed(this.precision)}`, ''],
{ kind: 'pts', pts: p })
break
case 'p2p': {
const d = Math.hypot(p[1][0] - p[0][0], p[1][1] - p[0][1])
this._push('距离', () => [this.fmt(d)], { kind: 'line', pts: p })
break
}
case 'linear': {
const dx = Math.abs(p[1][0] - p[0][0]), dy = Math.abs(p[1][1] - p[0][1])
this._push('线性', () => [`ΔX=${this.fmt(dx)} ΔY=${this.fmt(dy)}`], { kind: 'linear', pts: p })
break
}
case 'cont': {
let total = 0
for (let i = 1; i < p.length; i++) total += Math.hypot(p[i][0] - p[i - 1][0], p[i][1] - p[i - 1][1])
this._push('连续长度', () => [this.fmt(total)], { kind: 'poly', pts: p })
break
}
case 'angle': {
const a = Math.atan2(p[0][1] - p[1][1], p[0][0] - p[1][0])
const b = Math.atan2(p[2][1] - p[1][1], p[2][0] - p[1][0])
let d = Math.abs(a - b)
if (d > Math.PI) d = TAU - d
this._push('角度', () => [this.fmtAngle(d)], { kind: 'angle', pts: p })
break
}
case 'area': {
let area = 0, peri = 0
for (let i = 0; i < p.length; i++) {
const q = p[(i + 1) % p.length]
area += p[i][0] * q[1] - q[0] * p[i][1]
peri += Math.hypot(q[0] - p[i][0], q[1] - p[i][1])
}
area = Math.abs(area) / 2
this._push('面积',
() => [`${area.toFixed(this.precision)} ${this.unit}² 周长 ${this.fmt(peri)}`,
`${area.toFixed(Math.min(2, this.precision))} ${this.unit}²`],
{ kind: 'area', pts: p })
break
}
default: this.reset()
}
}
_finishShapes(def) {
const shapes = this.pendingShapes.map((i) => this.r.shapes[i])
switch (def.id) {
case 'seg': {
const len = shapeLength(shapes[0])
if (len == null) { this.reset(); this.onChange(); return }
this._push('长度', () => [this.fmt(len)], { kind: 'shape', shape: this.pendingShapes[0] })
break
}
case 'arclen': {
const s = shapes[0]
if (s.kind !== 'arc') { this.reset(); this.onChange(); return }
let sweep = s.a1 - s.a0
while (sweep <= 0) sweep += TAU
const len = sweep * ((s.rx + s.ry) / 2)
this._push('弧长', () => [this.fmt(len)], { kind: 'shape', shape: this.pendingShapes[0] })
break
}
case 'radius': {
const s = shapes[0]
if (s.kind !== 'arc') { this.reset(); this.onChange(); return }
const round = Math.abs(s.rx - s.ry) < 1e-6
this._push('半径',
() => [round ? `R=${this.fmt(s.rx)} ⌀=${this.fmt(s.rx * 2)}`
: `长半轴=${this.fmt(s.rx)} 短半轴=${this.fmt(s.ry)}`,
round ? `R${this.fmt(s.rx)}` : this.fmt(s.rx)],
{ kind: 'shape', shape: this.pendingShapes[0] })
break
}
case 'p2l': {
const pt = this.pending[0]
const q = nearestOnShape(shapes[0], pt[0], pt[1])
if (!q) { this.reset(); this.onChange(); return }
const d = Math.hypot(q[0] - pt[0], q[1] - pt[1])
this._push('点到线', () => [this.fmt(d)], { kind: 'line', pts: [pt, q] })
break
}
case 'l2l': {
const [a, b] = shapes
const da = shapeDir(a), db = shapeDir(b)
if (!da || !db) { this.reset(); this.onChange(); return }
let ang = Math.abs(Math.atan2(da[1], da[0]) - Math.atan2(db[1], db[0]))
if (ang > Math.PI) ang = TAU - ang
if (ang > Math.PI / 2) ang = Math.PI - ang
if (ang < 1e-3) {
// 平行:量垂直距离
const p0 = [a.pts[0], a.pts[1]]
const q = nearestOnShape(b, p0[0], p0[1])
const d = q ? Math.hypot(q[0] - p0[0], q[1] - p0[1]) : 0
this._push('线到线', () => [`平行 间距 ${this.fmt(d)}`, this.fmt(d)], { kind: 'line', pts: [p0, q] })
} else {
this._push('线到线', () => [`夹角 ${this.fmtAngle(ang)}`, this.fmtAngle(ang)],
{ kind: 'shapes', shapes: this.pendingShapes.slice() })
}
break
}
default: this.reset()
}
}
// ------------------------------------------------------------ 绘制
draw(ctx, r) {
ctx.save()
ctx.lineWidth = 1.6 * r.dpr
ctx.strokeStyle = '#2b9ae8'
ctx.fillStyle = '#2b9ae8'
ctx.font = `${12 * r.dpr}px "Microsoft YaHei", sans-serif`
for (const res of this.results) this._drawGeom(ctx, r, res.geom, '#2b9ae8')
if (this.mode) {
// 未完成的测量用橙色预览
const g = { kind: this.mode === 'area' ? 'area' : 'poly', pts: this.pending, open: true }
if (this.pending.length) this._drawGeom(ctx, r, g, '#ff9800')
for (const i of this.pendingShapes) this._outline(ctx, r, i, '#ff9800')
if (this.cursor) this._drawSnap(ctx, r, this.cursor)
}
ctx.restore()
}
_drawSnap(ctx, r, c) {
// 用 sx/sy 而不是 toScreenoverlay 的画布变换里已经带了视图旋转,
// 再用含旋转的 toScreen 会转两次
const x = r.sx(c.x), y = r.sy(c.y)
const s = 5 * r.dpr
ctx.strokeStyle = '#ff5722'
ctx.lineWidth = 1.6 * r.dpr
ctx.beginPath()
if (c.type === 'end' || c.type === 'node') ctx.rect(x - s, y - s, s * 2, s * 2)
else if (c.type === 'mid') { ctx.moveTo(x - s, y + s); ctx.lineTo(x, y - s); ctx.lineTo(x + s, y + s); ctx.closePath() }
else if (c.type === 'center') ctx.arc(x, y, s, 0, TAU)
else if (c.type === 'quad') { ctx.moveTo(x, y - s); ctx.lineTo(x + s, y); ctx.lineTo(x, y + s); ctx.lineTo(x - s, y); ctx.closePath() }
else { ctx.moveTo(x - s, y - s); ctx.lineTo(x + s, y + s); ctx.moveTo(x + s, y - s); ctx.lineTo(x - s, y + s) }
ctx.stroke()
const lb = Snapper.label(c.type)
if (lb) {
ctx.fillStyle = '#ff5722'
ctx.fillText(lb, x + s + 4 * r.dpr, y - s)
}
}
_outline(ctx, r, idx, color) {
const s = r.shapes[idx]
if (!s) return
ctx.strokeStyle = color
ctx.lineWidth = 3 * r.dpr
ctx.beginPath()
r._path(ctx, s)
ctx.stroke()
}
_drawGeom(ctx, r, g, color) {
if (!g) return
const S = (p) => [r.sx(p[0]), r.sy(p[1])]
ctx.strokeStyle = color
ctx.fillStyle = color
ctx.lineWidth = 1.6 * r.dpr
const pts = g.pts || []
switch (g.kind) {
case 'pts':
for (const p of pts) mark(ctx, r, S(p), color)
break
case 'line':
case 'poly': {
if (pts.length < 1) break
ctx.beginPath()
const a = S(pts[0])
ctx.moveTo(a[0], a[1])
for (let i = 1; i < pts.length; i++) { const b = S(pts[i]); ctx.lineTo(b[0], b[1]) }
ctx.stroke()
for (const p of pts) mark(ctx, r, S(p), color)
break
}
case 'linear': {
if (pts.length < 2) break
const a = S(pts[0]), b = S(pts[1])
ctx.setLineDash([4 * r.dpr, 3 * r.dpr])
ctx.beginPath()
ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], a[1]); ctx.lineTo(b[0], b[1])
ctx.stroke()
ctx.setLineDash([])
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke()
mark(ctx, r, a, color); mark(ctx, r, b, color)
break
}
case 'angle': {
if (pts.length < 3) break
const a = S(pts[0]), v = S(pts[1]), b = S(pts[2])
ctx.beginPath()
ctx.moveTo(a[0], a[1]); ctx.lineTo(v[0], v[1]); ctx.lineTo(b[0], b[1])
ctx.stroke()
const rr = 26 * r.dpr
const a0 = Math.atan2(a[1] - v[1], a[0] - v[0])
const a1 = Math.atan2(b[1] - v[1], b[0] - v[0])
ctx.beginPath(); ctx.arc(v[0], v[1], rr, a0, a1); ctx.stroke()
for (const p of [a, v, b]) mark(ctx, r, p, color)
break
}
case 'area': {
if (pts.length < 2) break
ctx.beginPath()
const a = S(pts[0]); ctx.moveTo(a[0], a[1])
for (let i = 1; i < pts.length; i++) { const b = S(pts[i]); ctx.lineTo(b[0], b[1]) }
if (!g.open) ctx.closePath()
ctx.stroke()
if (!g.open) { ctx.globalAlpha = 0.14; ctx.fill(); ctx.globalAlpha = 1 }
for (const p of pts) mark(ctx, r, S(p), color)
break
}
case 'shape':
this._outline(ctx, r, g.shape, color)
break
case 'shapes':
for (const i of g.shapes) this._outline(ctx, r, i, color)
break
default: break
}
if (g.label && pts.length) {
const c = S(pts[pts.length - 1])
drawLabel(ctx, r, c[0] + 10 * r.dpr, c[1] - 10 * r.dpr, g.label, color)
} else if (g.label && g.kind === 'shape') {
const s = r.shapes[g.shape]
if (s) drawLabel(ctx, r, r.sx((s.bbox[0] + s.bbox[2]) / 2), r.sy(s.bbox[3]) - 10 * r.dpr, g.label, color)
}
}
}
function mark(ctx, r, p, color) {
const s = 3 * r.dpr
ctx.fillStyle = color
ctx.fillRect(p[0] - s, p[1] - s, s * 2, s * 2)
}
function drawLabel(ctx, r, x, y, text, color) {
ctx.font = `${12 * r.dpr}px "Microsoft YaHei", sans-serif`
const w = ctx.measureText(text).width + 10 * r.dpr
const h = 18 * r.dpr
ctx.fillStyle = color
ctx.globalAlpha = 0.92
roundRect(ctx, x, y - h, w, h, 3 * r.dpr)
ctx.fill()
ctx.globalAlpha = 1
ctx.fillStyle = '#fff'
ctx.textBaseline = 'middle'
ctx.fillText(text, x + 5 * r.dpr, y - h / 2)
ctx.textBaseline = 'alphabetic'
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r)
ctx.arcTo(x + w, y + h, x, y + h, r)
ctx.arcTo(x, y + h, x, y, r)
ctx.arcTo(x, y, x + w, y, r)
ctx.closePath()
}
function shapeLength(s) {
if (!s) return null
if (s.kind === 'poly') {
let len = 0
const p = s.pts
for (let i = 0; i + 3 < p.length; i += 2) len += Math.hypot(p[i + 2] - p[i], p[i + 3] - p[i + 1])
if (s.closed && p.length >= 4) len += Math.hypot(p[0] - p[p.length - 2], p[1] - p[p.length - 1])
return len
}
if (s.kind === 'arc') {
let sweep = s.a1 - s.a0
while (sweep <= 0) sweep += TAU
return sweep * ((s.rx + s.ry) / 2)
}
return null
}
function shapeDir(s) {
if (!s || s.kind !== 'poly' || s.pts.length < 4) return null
const p = s.pts
const dx = p[p.length - 2] - p[0], dy = p[p.length - 1] - p[1]
const l = Math.hypot(dx, dy)
return l > 0 ? [dx / l, dy / l] : null
}
// ---------------------------------------------------------------- 批注
export const ANNO_MODES = [
{ id: 'text', name: '文字', icon: 'view-tool-annoText.png' },
{ id: 'free', name: '自由线', icon: 'view-tool-freehandLine.png' },
{ id: 'line', name: '直线', icon: 'view-tool-annoLine.png' },
{ id: 'arrow', name: '箭头', icon: 'view-tool-annoArrow.png' },
{ id: 'rect', name: '矩形', icon: 'view-tool-rectangleAnno.png' },
{ id: 'circle', name: '圆', icon: 'view-tool-circleAnno.png' },
{ id: 'cloud', name: '云线', icon: 'view-tool-cloudLine.png' },
]
export class AnnoTool {
constructor(renderer, onChange) {
this.r = renderer
this.onChange = onChange || (() => {})
this.mode = null
this.items = []
this.color = '#e60012'
this.width = 2
this.draft = null
}
setMode(m) { this.mode = m; this.draft = null; this.onChange() }
clear() { this.items = []; this.draft = null; this.onChange() }
undo() { this.items.pop(); this.onChange() }
down(px, py) {
if (!this.mode) return false
const w = this.r.toWorld(px, py)
if (this.mode === 'text') {
const t = window.prompt('批注文字')
if (t) this.items.push({ kind: 'text', x: w.x, y: w.y, text: t, color: this.color, size: 14 })
this.onChange()
return true
}
this.draft = { kind: this.mode, pts: [[w.x, w.y], [w.x, w.y]], color: this.color, width: this.width }
return true
}
move(px, py) {
if (!this.draft) return false
const w = this.r.toWorld(px, py)
if (this.draft.kind === 'free') this.draft.pts.push([w.x, w.y])
else this.draft.pts[1] = [w.x, w.y]
return true
}
up() {
if (!this.draft) return false
const d = this.draft
this.draft = null
const [a, b] = [d.pts[0], d.pts[d.pts.length - 1]]
if (d.kind !== 'free' && Math.hypot(b[0] - a[0], b[1] - a[1]) < 1e-9) return true
this.items.push(d)
this.onChange()
return true
}
draw(ctx, r) {
ctx.save()
for (const it of this.items) this._one(ctx, r, it)
if (this.draft) this._one(ctx, r, this.draft)
ctx.restore()
}
_one(ctx, r, it) {
ctx.strokeStyle = it.color
ctx.fillStyle = it.color
ctx.lineWidth = (it.width || 2) * r.dpr
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
const S = (p) => [r.sx(p[0]), r.sy(p[1])]
if (it.kind === 'text') {
const p = S([it.x, it.y])
ctx.font = `${(it.size || 14) * r.dpr}px "Microsoft YaHei", sans-serif`
ctx.fillText(it.text, p[0], p[1])
return
}
const a = S(it.pts[0]), b = S(it.pts[it.pts.length - 1])
ctx.beginPath()
switch (it.kind) {
case 'free': {
const p0 = S(it.pts[0])
ctx.moveTo(p0[0], p0[1])
for (let i = 1; i < it.pts.length; i++) { const p = S(it.pts[i]); ctx.lineTo(p[0], p[1]) }
ctx.stroke()
return
}
case 'line':
ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke()
return
case 'arrow': {
ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke()
const ang = Math.atan2(b[1] - a[1], b[0] - a[0])
const h = 10 * r.dpr
ctx.beginPath()
ctx.moveTo(b[0], b[1])
ctx.lineTo(b[0] - h * Math.cos(ang - 0.4), b[1] - h * Math.sin(ang - 0.4))
ctx.lineTo(b[0] - h * Math.cos(ang + 0.4), b[1] - h * Math.sin(ang + 0.4))
ctx.closePath(); ctx.fill()
return
}
case 'rect':
ctx.rect(Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1]))
ctx.stroke()
return
case 'circle': {
const rr = Math.hypot(b[0] - a[0], b[1] - a[1])
ctx.arc(a[0], a[1], rr, 0, TAU); ctx.stroke()
return
}
case 'cloud': {
cloudPath(ctx, Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1]), 12 * r.dpr)
ctx.stroke()
return
}
default: return
}
}
}
/** 修订云线:沿矩形边界画一串外凸圆弧 */
function cloudPath(ctx, x, y, w, h, bulge) {
const r = Math.max(6, bulge)
const seg = (x0, y0, x1, y1, dir) => {
const len = Math.hypot(x1 - x0, y1 - y0)
const n = Math.max(1, Math.round(len / (r * 2)))
for (let i = 0; i < n; i++) {
const t0 = i / n, t1 = (i + 1) / n
const ax = x0 + (x1 - x0) * t0, ay = y0 + (y1 - y0) * t0
const bx = x0 + (x1 - x0) * t1, by = y0 + (y1 - y0) * t1
const mx = (ax + bx) / 2, my = (ay + by) / 2
const dx = bx - ax, dy = by - ay
const l = Math.hypot(dx, dy) || 1
const nx = (-dy / l) * dir * (l / 3), ny = (dx / l) * dir * (l / 3)
ctx.moveTo(ax, ay)
ctx.quadraticCurveTo(mx + nx, my + ny, bx, by)
}
}
ctx.beginPath()
seg(x, y, x + w, y, -1)
seg(x + w, y, x + w, y + h, -1)
seg(x + w, y + h, x, y + h, -1)
seg(x, y + h, x, y, -1)
}