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