852 lines
29 KiB
JavaScript
852 lines
29 KiB
JavaScript
/**
|
||
* 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
|
||
}
|