Files
Leantime/dev/DWGViewer/js/render.js

852 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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