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

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

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

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