OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
322
dev/DWGViewer/js/curves.js
Normal file
322
dev/DWGViewer/js/curves.js
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* 曲线离散化与二维仿射变换工具。
|
||||
*
|
||||
* 约定:
|
||||
* - 变换矩阵一律用扁平数组 m = [a, b, c, d, e, f],
|
||||
* 对应 x' = a*x + c*y + e,y' = b*x + d*y + f(与 canvas setTransform 同序)。
|
||||
* - 点集一律用扁平 Float64Array [x0,y0,x1,y1,...],世界坐标,忽略 Z。
|
||||
*/
|
||||
|
||||
export const TAU = Math.PI * 2
|
||||
|
||||
/** 相对弦高容差:段数按 r*EPS 的弦高计算,保证放大后仍圆滑 */
|
||||
const CHORD_EPS = 1 / 2000
|
||||
const MIN_SEG = 6
|
||||
const MAX_SEG = 512
|
||||
|
||||
// ---------------------------------------------------------------- 仿射变换
|
||||
|
||||
export const IDENTITY = [1, 0, 0, 1, 0, 0]
|
||||
|
||||
/** m2 之后再作用 m1(即先 m2 后 m1 的复合,等价于矩阵乘 m1·m2) */
|
||||
export function mul(m1, m2) {
|
||||
return [
|
||||
m1[0] * m2[0] + m1[2] * m2[1],
|
||||
m1[1] * m2[0] + m1[3] * m2[1],
|
||||
m1[0] * m2[2] + m1[2] * m2[3],
|
||||
m1[1] * m2[2] + m1[3] * m2[3],
|
||||
m1[0] * m2[4] + m1[2] * m2[5] + m1[4],
|
||||
m1[1] * m2[4] + m1[3] * m2[5] + m1[5],
|
||||
]
|
||||
}
|
||||
|
||||
/** 由插入点/缩放/旋转构造矩阵(DWG INSERT 的常规组合:先缩放后旋转再平移) */
|
||||
export function makeInsertMatrix(tx, ty, sx, sy, rot) {
|
||||
const c = Math.cos(rot), s = Math.sin(rot)
|
||||
return [c * sx, s * sx, -s * sy, c * sy, tx, ty]
|
||||
}
|
||||
|
||||
export function applyX(m, x, y) { return m[0] * x + m[2] * y + m[4] }
|
||||
export function applyY(m, x, y) { return m[1] * x + m[3] * y + m[5] }
|
||||
|
||||
export function isIdentity(m) {
|
||||
return m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断矩阵是否为「共形」变换(等比缩放 + 旋转,可含镜像)。
|
||||
* 共形时圆仍是圆、圆弧角度只需整体旋转,可以交给 canvas 的 arc/ellipse 精确绘制。
|
||||
*/
|
||||
export function conformal(m) {
|
||||
const l1 = Math.hypot(m[0], m[1]) // 列 1 长度
|
||||
const l2 = Math.hypot(m[2], m[3]) // 列 2 长度
|
||||
if (l1 < 1e-12 || l2 < 1e-12) return null
|
||||
const dot = (m[0] * m[2] + m[1] * m[3]) / (l1 * l2)
|
||||
if (Math.abs(dot) > 1e-6) return null // 两轴不正交 → 有错切
|
||||
if (Math.abs(l1 - l2) > 1e-9 * Math.max(l1, l2)) return null // 非等比
|
||||
const det = m[0] * m[3] - m[1] * m[2]
|
||||
return { scale: l1, rotation: Math.atan2(m[1], m[0]), mirror: det < 0 }
|
||||
}
|
||||
|
||||
/** 变换的「平均缩放」,用于线宽、文字高度等标量的换算 */
|
||||
export function matrixScale(m) {
|
||||
return Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])) || Math.hypot(m[0], m[1]) || 1
|
||||
}
|
||||
|
||||
/** 原地变换扁平点集 */
|
||||
export function transformPoints(pts, m) {
|
||||
if (isIdentity(m)) return pts
|
||||
const out = new Float64Array(pts.length)
|
||||
for (let i = 0; i < pts.length; i += 2) {
|
||||
const x = pts[i], y = pts[i + 1]
|
||||
out[i] = m[0] * x + m[2] * y + m[4]
|
||||
out[i + 1] = m[1] * x + m[3] * y + m[5]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 离散化
|
||||
|
||||
/** 按弦高容差算圆弧分段数 */
|
||||
export function arcSegCount(sweep) {
|
||||
const step = 2 * Math.acos(Math.max(-1, 1 - CHORD_EPS))
|
||||
const n = Math.ceil(Math.abs(sweep) / step)
|
||||
return Math.min(MAX_SEG, Math.max(MIN_SEG, n))
|
||||
}
|
||||
|
||||
/**
|
||||
* 圆弧采样。角度为弧度,逆时针从 a0 到 a1(DWG/DXF 的圆弧一律逆时针)。
|
||||
*/
|
||||
export function arcPoints(cx, cy, r, a0, a1) {
|
||||
let sweep = a1 - a0
|
||||
while (sweep <= 0) sweep += TAU
|
||||
while (sweep > TAU) sweep -= TAU
|
||||
const n = arcSegCount(sweep)
|
||||
const out = new Float64Array((n + 1) * 2)
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const a = a0 + (sweep * i) / n
|
||||
out[i * 2] = cx + r * Math.cos(a)
|
||||
out[i * 2 + 1] = cy + r * Math.sin(a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function circlePoints(cx, cy, r) {
|
||||
const n = arcSegCount(TAU)
|
||||
const out = new Float64Array((n + 1) * 2)
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const a = (TAU * i) / n
|
||||
out[i * 2] = cx + r * Math.cos(a)
|
||||
out[i * 2 + 1] = cy + r * Math.sin(a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 椭圆(弧)采样。majX/majY 是长轴端点相对圆心的向量,ratio 为短轴/长轴。
|
||||
* a0/a1 是 DXF 的「参数角」(不是几何角)。
|
||||
*/
|
||||
export function ellipsePoints(cx, cy, majX, majY, ratio, a0 = 0, a1 = TAU) {
|
||||
const rx = Math.hypot(majX, majY)
|
||||
const ry = rx * ratio
|
||||
const rot = Math.atan2(majY, majX)
|
||||
let sweep = a1 - a0
|
||||
if (Math.abs(sweep) < 1e-12) sweep = TAU
|
||||
while (sweep <= 0) sweep += TAU
|
||||
while (sweep > TAU + 1e-9) sweep -= TAU
|
||||
const n = arcSegCount(sweep)
|
||||
const cr = Math.cos(rot), sr = Math.sin(rot)
|
||||
const out = new Float64Array((n + 1) * 2)
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const t = a0 + (sweep * i) / n
|
||||
const x = rx * Math.cos(t), y = ry * Math.sin(t)
|
||||
out[i * 2] = cx + x * cr - y * sr
|
||||
out[i * 2 + 1] = cy + x * sr + y * cr
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 多段线凸度(bulge)段:bulge = tan(圆心角/4),正为逆时针。
|
||||
* 返回不含起点、含终点的采样点(便于拼接)。
|
||||
*/
|
||||
export function bulgeArcPoints(x0, y0, x1, y1, bulge) {
|
||||
const dx = x1 - x0, dy = y1 - y0
|
||||
const chord = Math.hypot(dx, dy)
|
||||
if (chord < 1e-12 || Math.abs(bulge) < 1e-12) return [x1, y1]
|
||||
const theta = 4 * Math.atan(bulge) // 圆心角(带符号)
|
||||
const r = chord / (2 * Math.sin(Math.abs(theta) / 2))
|
||||
// 圆心:弦中点沿法线偏移
|
||||
const h = r * Math.cos(theta / 2) // 带符号的中点到圆心距离
|
||||
const mx = (x0 + x1) / 2, my = (y0 + y1) / 2
|
||||
const nx = -dy / chord, ny = dx / chord
|
||||
const cx = mx + nx * h, cy = my + ny * h
|
||||
const a0 = Math.atan2(y0 - cy, x0 - cx)
|
||||
const n = arcSegCount(theta)
|
||||
const out = []
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const a = a0 + (theta * i) / n
|
||||
out.push(cx + r * Math.cos(a), cy + r * Math.sin(a))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* NURBS / B 样条求值(de Boor 算法),支持有理(权重)样条。
|
||||
* controlPoints: [{x,y,weight?}],knots: number[]
|
||||
*/
|
||||
export function splinePoints(degree, controlPoints, knots, closed = false) {
|
||||
const n = controlPoints.length
|
||||
if (n < 2) return new Float64Array(0)
|
||||
if (n === 2 || degree < 1) {
|
||||
const out = new Float64Array(n * 2)
|
||||
controlPoints.forEach((p, i) => { out[i * 2] = p.x; out[i * 2 + 1] = p.y })
|
||||
return out
|
||||
}
|
||||
const p = Math.min(degree, n - 1)
|
||||
let kn = knots
|
||||
if (!kn || kn.length !== n + p + 1) kn = uniformKnots(n, p)
|
||||
|
||||
// 采样密度:按控制多边形长度自适应,段数上限保证性能
|
||||
let polyLen = 0
|
||||
for (let i = 1; i < n; i++) {
|
||||
polyLen += Math.hypot(controlPoints[i].x - controlPoints[i - 1].x, controlPoints[i].y - controlPoints[i - 1].y)
|
||||
}
|
||||
const steps = Math.min(MAX_SEG, Math.max(MIN_SEG * 2, (n - p) * 12))
|
||||
|
||||
const t0 = kn[p], t1 = kn[n]
|
||||
if (!(t1 > t0)) return new Float64Array(0)
|
||||
const out = new Float64Array((steps + 1) * 2)
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = t0 + ((t1 - t0) * i) / steps
|
||||
const pt = deBoor(p, controlPoints, kn, i === steps ? t1 - 1e-12 : t)
|
||||
out[i * 2] = pt[0]
|
||||
out[i * 2 + 1] = pt[1]
|
||||
}
|
||||
if (closed) { /* 闭合样条由调用方补首尾 */ }
|
||||
return out
|
||||
}
|
||||
|
||||
function uniformKnots(n, p) {
|
||||
const kn = []
|
||||
for (let i = 0; i < n + p + 1; i++) {
|
||||
if (i <= p) kn.push(0)
|
||||
else if (i >= n) kn.push(n - p)
|
||||
else kn.push(i - p)
|
||||
}
|
||||
return kn
|
||||
}
|
||||
|
||||
function deBoor(p, cps, kn, t) {
|
||||
const n = cps.length
|
||||
// 定位区间
|
||||
let k = p
|
||||
while (k < n - 1 && kn[k + 1] <= t) k++
|
||||
const dx = [], dy = [], dw = []
|
||||
for (let j = 0; j <= p; j++) {
|
||||
const cp = cps[k - p + j] || cps[n - 1]
|
||||
const w = cp.weight == null ? 1 : cp.weight
|
||||
dx.push(cp.x * w); dy.push(cp.y * w); dw.push(w)
|
||||
}
|
||||
for (let r = 1; r <= p; r++) {
|
||||
for (let j = p; j >= r; j--) {
|
||||
const i = k - p + j
|
||||
const den = kn[i + p - r + 1] - kn[i]
|
||||
const a = den === 0 ? 0 : (t - kn[i]) / den
|
||||
dx[j] = (1 - a) * dx[j - 1] + a * dx[j]
|
||||
dy[j] = (1 - a) * dy[j - 1] + a * dy[j]
|
||||
dw[j] = (1 - a) * dw[j - 1] + a * dw[j]
|
||||
}
|
||||
}
|
||||
const w = dw[p] || 1
|
||||
return [dx[p] / w, dy[p] / w]
|
||||
}
|
||||
|
||||
/** 拟合点样条:用 Catmull-Rom 过点插值近似 */
|
||||
export function fitPointCurve(fitPoints, closed = false) {
|
||||
const n = fitPoints.length
|
||||
if (n < 2) return new Float64Array(0)
|
||||
if (n === 2) return new Float64Array([fitPoints[0].x, fitPoints[0].y, fitPoints[1].x, fitPoints[1].y])
|
||||
const segs = 12
|
||||
const pts = []
|
||||
const at = (i) => {
|
||||
if (closed) return fitPoints[((i % n) + n) % n]
|
||||
return fitPoints[Math.min(n - 1, Math.max(0, i))]
|
||||
}
|
||||
const last = closed ? n : n - 1
|
||||
pts.push(fitPoints[0].x, fitPoints[0].y)
|
||||
for (let i = 0; i < last; i++) {
|
||||
const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2)
|
||||
for (let s = 1; s <= segs; s++) {
|
||||
const t = s / segs, t2 = t * t, t3 = t2 * t
|
||||
pts.push(
|
||||
0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3),
|
||||
0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3),
|
||||
)
|
||||
}
|
||||
}
|
||||
return new Float64Array(pts)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 包围盒
|
||||
|
||||
/**
|
||||
* 椭圆弧的紧包围盒。
|
||||
*
|
||||
* 不能直接用整圆的外接方框:机械图里常见半径几万、只画几度的过渡圆弧,
|
||||
* 按整圆算会把图纸范围撑大几个数量级,导致「全图」后什么都看不见。
|
||||
* 这里取两个端点,再加上落在弧段内的 x/y 极值参数角。
|
||||
*/
|
||||
export function arcBBox(cx, cy, rx, ry, rot, a0, a1) {
|
||||
const cr = Math.cos(rot), sr = Math.sin(rot)
|
||||
const at = (t) => {
|
||||
const x = rx * Math.cos(t), y = ry * Math.sin(t)
|
||||
return [cx + x * cr - y * sr, cy + x * sr + y * cr]
|
||||
}
|
||||
let sweep = a1 - a0
|
||||
while (sweep <= 0) sweep += TAU
|
||||
if (sweep > TAU) sweep = TAU
|
||||
const inArc = (t) => {
|
||||
if (sweep >= TAU - 1e-9) return true
|
||||
let d = t - a0
|
||||
while (d < 0) d += TAU
|
||||
while (d > TAU) d -= TAU
|
||||
return d <= sweep
|
||||
}
|
||||
const b = [Infinity, Infinity, -Infinity, -Infinity]
|
||||
const add = (p) => {
|
||||
if (p[0] < b[0]) b[0] = p[0]
|
||||
if (p[1] < b[1]) b[1] = p[1]
|
||||
if (p[0] > b[2]) b[2] = p[0]
|
||||
if (p[1] > b[3]) b[3] = p[1]
|
||||
}
|
||||
add(at(a0))
|
||||
add(at(a0 + sweep))
|
||||
// dx/dt = 0 与 dy/dt = 0 的参数角
|
||||
const tx = Math.atan2(-ry * sr, rx * cr)
|
||||
const ty = Math.atan2(ry * cr, rx * sr)
|
||||
for (const t of [tx, tx + Math.PI, ty, ty + Math.PI]) {
|
||||
if (inArc(t)) add(at(t))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
export function bboxOfPoints(pts, box) {
|
||||
const b = box || [Infinity, Infinity, -Infinity, -Infinity]
|
||||
for (let i = 0; i < pts.length; i += 2) {
|
||||
const x = pts[i], y = pts[i + 1]
|
||||
if (x < b[0]) b[0] = x
|
||||
if (y < b[1]) b[1] = y
|
||||
if (x > b[2]) b[2] = x
|
||||
if (y > b[3]) b[3] = y
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
export function growBox(b, o) {
|
||||
if (o[0] < b[0]) b[0] = o[0]
|
||||
if (o[1] < b[1]) b[1] = o[1]
|
||||
if (o[2] > b[2]) b[2] = o[2]
|
||||
if (o[3] > b[3]) b[3] = o[3]
|
||||
return b
|
||||
}
|
||||
265
dev/DWGViewer/js/dxfHatch.js
Normal file
265
dev/DWGViewer/js/dxfHatch.js
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* DXF 的 HATCH 补丁解析器。
|
||||
*
|
||||
* dxf-parser(MIT)不认 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
517
dev/DWGViewer/js/flatten.js
Normal 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
272
dev/DWGViewer/js/hatch.js
Normal 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 arc;end 是长轴端点相对圆心的向量
|
||||
if (!e.center || !e.end) break
|
||||
const p = ellipsePoints(e.center.x, e.center.y, e.end.x, e.end.y,
|
||||
e.lengthOfMinorAxis, e.startAngle || 0, e.endAngle || 0)
|
||||
push(e.isCCW === false ? reverse(p) : p)
|
||||
break
|
||||
}
|
||||
case 4: { // Spline
|
||||
const cps = (e.controlPoints || []).map((c) => ({ x: c.x, y: c.y, weight: c.weight }))
|
||||
if (cps.length) push(splinePoints(e.degree || 3, cps, e.knots))
|
||||
else if (e.fitDatum && e.fitDatum.length) {
|
||||
const arr = []
|
||||
for (const p of e.fitDatum) arr.push(p.x, p.y)
|
||||
push(arr)
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function reverse(pts) {
|
||||
const out = new Float64Array(pts.length)
|
||||
for (let i = 0, n = pts.length / 2; i < n; i++) {
|
||||
out[i * 2] = pts[(n - 1 - i) * 2]
|
||||
out[i * 2 + 1] = pts[(n - 1 - i) * 2 + 1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 环集合的包围盒 */
|
||||
export function loopsBBox(loops) {
|
||||
const b = [Infinity, Infinity, -Infinity, -Infinity]
|
||||
for (const l of loops) {
|
||||
for (let i = 0; i < l.length; i += 2) {
|
||||
if (l[i] < b[0]) b[0] = l[i]
|
||||
if (l[i + 1] < b[1]) b[1] = l[i + 1]
|
||||
if (l[i] > b[2]) b[2] = l[i]
|
||||
if (l[i + 1] > b[3]) b[3] = l[i + 1]
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成图案线,返回扁平线段数组 [x0,y0,x1,y1, x0,y0,x1,y1, ...]。
|
||||
* 用奇偶规则裁剪:把每条候选直线与所有边界求交,排序后取奇数区间。
|
||||
*/
|
||||
export function hatchPatternSegments(loops, defLines) {
|
||||
const out = []
|
||||
if (!loops.length || !defLines || !defLines.length) return out
|
||||
const bb = loopsBBox(loops)
|
||||
if (!isFinite(bb[0])) return out
|
||||
const diag = Math.hypot(bb[2] - bb[0], bb[3] - bb[1])
|
||||
if (!(diag > 0)) return out
|
||||
|
||||
for (const dl of defLines) {
|
||||
const ang = dl.angle || 0
|
||||
const ux = Math.cos(ang), uy = Math.sin(ang)
|
||||
const px = -uy, py = ux // 垂直方向
|
||||
const ox = dl.base ? dl.base.x : 0
|
||||
const oy = dl.base ? dl.base.y : 0
|
||||
// offset 是世界坐标系下「相邻两条线的位移向量」,不是定义线自身坐标系的分量
|
||||
const offX = dl.offset ? dl.offset.x : 0
|
||||
const offY = dl.offset ? dl.offset.y : 0
|
||||
const perp = offX * px + offY * py // 位移在法线方向的分量 = 真正的行距
|
||||
if (Math.abs(perp) < 1e-9) continue // 位移与线平行,画不出一族线
|
||||
const spacing = Math.abs(perp)
|
||||
if (diag / spacing > MAX_PATTERN_LINES) continue // 行距过密,跳过(多半是比例异常)
|
||||
|
||||
// 计算需要覆盖的 n 范围:把包围盒四角投影到法线方向
|
||||
let nMin = Infinity, nMax = -Infinity
|
||||
for (const [cx, cy] of [[bb[0], bb[1]], [bb[2], bb[1]], [bb[0], bb[3]], [bb[2], bb[3]]]) {
|
||||
const n = ((cx - ox) * px + (cy - oy) * py) / perp
|
||||
if (n < nMin) nMin = n
|
||||
if (n > nMax) nMax = n
|
||||
}
|
||||
nMin = Math.floor(nMin) - 1
|
||||
nMax = Math.ceil(nMax) + 1
|
||||
if (!isFinite(nMin) || !isFinite(nMax)) continue
|
||||
|
||||
const dashes = (dl.dashLengths || []).filter((d) => isFinite(d))
|
||||
const patLen = dashes.reduce((s, d) => s + Math.abs(d), 0)
|
||||
|
||||
for (let n = nMin; n <= nMax; n++) {
|
||||
// 该条线上的一个点
|
||||
const bx = ox + n * offX
|
||||
const by = oy + n * offY
|
||||
const hits = lineLoopIntersections(loops, bx, by, ux, uy)
|
||||
if (hits.length < 2) continue
|
||||
hits.sort((a, b) => a - b)
|
||||
for (let k = 0; k + 1 < hits.length; k += 2) {
|
||||
const t0 = hits[k], t1 = hits[k + 1]
|
||||
if (t1 - t0 < 1e-9) continue
|
||||
if (patLen > 1e-9) emitDashed(out, bx, by, ux, uy, t0, t1, dashes, patLen)
|
||||
else out.push(bx + ux * t0, by + uy * t0, bx + ux * t1, by + uy * t1)
|
||||
if (out.length > MAX_SEGMENTS * 4) return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 在 [t0,t1] 区间内按虚线定义切段(负值为空白) */
|
||||
function emitDashed(out, bx, by, ux, uy, t0, t1, dashes, patLen) {
|
||||
// 从图案原点对齐:找到 t0 所在的图案相位
|
||||
let t = t0 - ((t0 % patLen) + patLen) % patLen
|
||||
let guard = 0
|
||||
while (t < t1 && guard++ < 4000) {
|
||||
for (const d of dashes) {
|
||||
const len = Math.abs(d) < 1e-9 ? 1e-9 : Math.abs(d)
|
||||
const a = t, b = t + len
|
||||
if (d >= 0) {
|
||||
const s = Math.max(a, t0), e = Math.min(b, t1)
|
||||
if (e > s) out.push(bx + ux * s, by + uy * s, bx + ux * e, by + uy * e)
|
||||
}
|
||||
t = b
|
||||
if (t > t1) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 直线 (b + t*u) 与所有边界环的交点参数 t */
|
||||
function lineLoopIntersections(loops, bx, by, ux, uy) {
|
||||
const ts = []
|
||||
const px = -uy, py = ux
|
||||
for (const l of loops) {
|
||||
const n = l.length / 2
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
const x0 = l[i * 2], y0 = l[i * 2 + 1]
|
||||
const x1 = l[j * 2], y1 = l[j * 2 + 1]
|
||||
// 到直线的有符号距离
|
||||
const d0 = (x0 - bx) * px + (y0 - by) * py
|
||||
const d1 = (x1 - bx) * px + (y1 - by) * py
|
||||
if ((d0 > 0 && d1 > 0) || (d0 < 0 && d1 < 0)) continue
|
||||
if (d0 === 0 && d1 === 0) continue // 共线,忽略
|
||||
// 顶点正好落在线上时只算一次(用半开区间规则避免重复计数)
|
||||
if (d1 === 0) continue
|
||||
if (d0 === 0) {
|
||||
ts.push((x0 - bx) * ux + (y0 - by) * uy)
|
||||
continue
|
||||
}
|
||||
const s = d0 / (d0 - d1)
|
||||
const ix = x0 + (x1 - x0) * s
|
||||
const iy = y0 + (y1 - y0) * s
|
||||
ts.push((ix - bx) * ux + (iy - by) * uy)
|
||||
}
|
||||
}
|
||||
return ts
|
||||
}
|
||||
163
dev/DWGViewer/js/mtext.js
Normal file
163
dev/DWGViewer/js/mtext.js
Normal 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
851
dev/DWGViewer/js/render.js
Normal 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
328
dev/DWGViewer/js/source.js
Normal 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.0(LibreDWG)',
|
||||
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: 'MIT(dxf-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
702
dev/DWGViewer/js/tools.js
Normal 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 而不是 toScreen:overlay 的画布变换里已经带了视图旋转,
|
||||
// 再用含旋转的 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)
|
||||
}
|
||||
Reference in New Issue
Block a user