/** * 交互工具:对象捕捉、测量、批注。 * * 三者都只操作世界坐标,屏幕换算全部交给 Renderer, * 因此缩放平移后测量线和批注会跟着图纸走,不会漂。 */ import { ellipsePt } from './render.js' const TAU = Math.PI * 2 // ---------------------------------------------------------------- 对象捕捉 const SNAP_LABEL = { end: '端点', mid: '中点', center: '圆心', quad: '象限点', near: '最近点', node: '节点' } export class Snapper { constructor(renderer) { this.r = renderer this.enabled = true this.modes = { end: true, mid: true, center: true, quad: true, near: true } } /** * @param {number} px CSS 像素 * @returns {{x:number,y:number,type:string,shape:number}|null} */ find(px, py, tolPx = 12) { const r = this.r if (!this.enabled || !r.index) return null const w = r.toWorld(px, py) const tol = (tolPx * r.dpr) / r.view.scale const tol2 = tol * tol const box = [w.x - tol, w.y - tol, w.x + tol, w.y + tol] let best = null, bestScore = Infinity for (const i of r.index.query(box)) { const s = r.shapes[i] if (!s || r.hidden.has(s.layer)) continue if (s.kind === 'text' || s.kind === 'mtext') continue const b = s.bbox if (w.x < b[0] - tol || w.x > b[2] + tol || w.y < b[1] - tol || w.y > b[3] + tol) continue const consider = (x, y, type, prio) => { const d = (x - w.x) ** 2 + (y - w.y) ** 2 if (d > tol2) return // 优先级压过距离:端点比最近点更值得捕捉 const score = d + prio * tol2 * 0.35 if (score < bestScore) { bestScore = score; best = { x, y, type, shape: i } } } if (s.kind === 'poly' || s.kind === 'segs') { const p = s.pts const step = s.kind === 'segs' ? 4 : 2 for (let k = 0; k + 1 < p.length; k += step) { if (this.modes.end) consider(p[k], p[k + 1], 'end', 0) } if (s.kind === 'poly' && this.modes.mid) { for (let k = 0; k + 3 < p.length; k += 2) { consider((p[k] + p[k + 2]) / 2, (p[k + 1] + p[k + 3]) / 2, 'mid', 1) } } if (s.kind === 'segs' && this.modes.end) { for (let k = 2; k + 1 < p.length; k += 4) consider(p[k], p[k + 1], 'end', 0) } } else if (s.kind === 'arc') { if (this.modes.center) consider(s.cx, s.cy, 'center', 0) if (this.modes.end) { const a = ellipsePt(s, s.a0), b2 = ellipsePt(s, s.a1) consider(a[0], a[1], 'end', 0) consider(b2[0], b2[1], 'end', 0) } if (this.modes.quad) { for (let q = 0; q < 4; q++) { const t = (q * Math.PI) / 2 if (!angleInArc(t, s.a0, s.a1)) continue const p = ellipsePt(s, t) consider(p[0], p[1], 'quad', 1) } } if (this.modes.mid) { let sweep = s.a1 - s.a0 while (sweep <= 0) sweep += TAU const p = ellipsePt(s, s.a0 + sweep / 2) consider(p[0], p[1], 'mid', 1) } } else if (s.kind === 'point') { consider(s.x, s.y, 'node', 0) } } if (!best && this.modes.near) { const idx = r.pick(px, py, tolPx) if (idx != null) { const p = nearestOnShape(r.shapes[idx], w.x, w.y) if (p) best = { x: p[0], y: p[1], type: 'near', shape: idx } } } return best } static label(type) { return SNAP_LABEL[type] || '' } } function angleInArc(a, a0, a1) { let sweep = a1 - a0 while (sweep <= 0) sweep += TAU if (sweep >= TAU - 1e-9) return true let d = a - a0 while (d < 0) d += TAU while (d > TAU) d -= TAU return d <= sweep } /** 图元上离给定点最近的点 */ export function nearestOnShape(s, x, y) { if (!s) return null if (s.kind === 'poly' || s.kind === 'segs') { const p = s.pts let best = null, bd = Infinity const step = s.kind === 'segs' ? 4 : 2 for (let i = 0; i + 3 < p.length; i += step) { const q = closestOnSeg(x, y, p[i], p[i + 1], p[i + 2], p[i + 3]) const d = (q[0] - x) ** 2 + (q[1] - y) ** 2 if (d < bd) { bd = d; best = q } } if (s.kind === 'poly' && s.closed && p.length >= 4) { const q = closestOnSeg(x, y, p[p.length - 2], p[p.length - 1], p[0], p[1]) const d = (q[0] - x) ** 2 + (q[1] - y) ** 2 if (d < bd) best = q } return best } if (s.kind === 'arc') { const c = Math.cos(-s.rot), sn = Math.sin(-s.rot) const dx = x - s.cx, dy = y - s.cy const lx = dx * c - dy * sn, ly = dx * sn + dy * c let t = Math.atan2(ly / (s.ry || 1e-9), lx / (s.rx || 1e-9)) if (!angleInArc(t, s.a0, s.a1)) t = s.a0 return ellipsePt(s, t) } if (s.kind === 'point') return [s.x, s.y] return null } function closestOnSeg(px, py, x0, y0, x1, y1) { const dx = x1 - x0, dy = y1 - y0 const len = dx * dx + dy * dy let t = len === 0 ? 0 : ((px - x0) * dx + (py - y0) * dy) / len t = t < 0 ? 0 : t > 1 ? 1 : t return [x0 + t * dx, y0 + t * dy] } // ---------------------------------------------------------------- 测量 export const MEASURE_MODES = [ { id: 'coord', name: '坐标', icon: 'view-tool-coordinate.png', pts: 1 }, { id: 'p2p', name: '点到点', icon: 'view-tool-pointToPoint.png', pts: 2 }, { id: 'cont', name: '连续', icon: 'view-tool-continuityLength.png', pts: 0 }, { id: 'seg', name: '线段长', icon: 'view-tool-segmentlength.png', pick: true }, { id: 'arclen', name: '弧长', icon: 'view-tool-arcLength.png', pick: true }, { id: 'p2l', name: '点到线', icon: 'view-tool-pointToLine.png', mixed: true }, { id: 'l2l', name: '线到线', icon: 'view-tool-lineToLine.png', picks: 2 }, { id: 'linear', name: '线性', icon: 'view-tool-LinearMeasurement.png', pts: 2 }, { id: 'angle', name: '角度', icon: 'view-tool-angel.png', pts: 3 }, { id: 'radius', name: '半径', icon: 'view-tool-radius.png', pick: true }, { id: 'area', name: '面积', icon: 'view-tool-Area.png', pts: 0 }, ] export class MeasureTool { constructor(renderer, snapper, onChange) { this.r = renderer this.snap = snapper this.onChange = onChange || (() => {}) this.mode = null this.results = [] // {type, text, geom} this.pending = [] // 已点的世界坐标 this.pendingShapes = [] // 已选的图元下标 this.cursor = null // 当前捕捉点 this.unit = 'mm' this.precision = 3 } setMode(mode) { this.mode = mode; this.reset(); this.onChange() } reset() { this.pending = []; this.pendingShapes = []; } clear() { this.results = []; this.reset(); this.onChange() } remove(i) { this.results.splice(i, 1); this.onChange() } fmt(v) { const n = Math.abs(v) >= 1e6 ? v.toExponential(3) : v.toFixed(this.precision).replace(/\.?0+$/, '') return `${n} ${this.unit}` } fmtAngle(a) { return `${((a * 180) / Math.PI).toFixed(2)}°` } /** 鼠标移动:更新捕捉预览 */ hover(px, py) { const s = this.snap.find(px, py) this.cursor = s || { ...this.r.toWorld(px, py), type: null, shape: null } return this.cursor } /** 左键点击;返回 true 表示消费了这次点击 */ click(px, py) { const def = MEASURE_MODES.find((m) => m.id === this.mode) if (!def) return false const p = this.hover(px, py) if (def.pick || def.picks) { const idx = p.shape != null ? p.shape : this.r.pick(px, py, 8) if (idx == null) return true this.pendingShapes.push(idx) if (this.pendingShapes.length >= (def.picks || 1)) this._finishShapes(def) return true } if (def.mixed) { // 点到线:先点一个点,再点一条线 if (this.pending.length === 0) this.pending.push([p.x, p.y]) else { const idx = p.shape != null ? p.shape : this.r.pick(px, py, 8) if (idx == null) return true this.pendingShapes.push(idx) this._finishShapes(def) } return true } this.pending.push([p.x, p.y]) if (def.pts && this.pending.length >= def.pts) this._finishPoints(def) return true } /** 双击 / 回车:结束连续型测量 */ finish() { const def = MEASURE_MODES.find((m) => m.id === this.mode) if (!def || def.pts !== 0) return if (this.pending.length >= 2) this._finishPoints(def) else this.reset() } cancel() { this.reset(); this.onChange() } /** * 记一条测量结果。 * @param {() => [string, string?]} make 惰性格式化,返回 [列表文字, 图上标签]。 * 之所以存函数而不是定值:改了单位或小数位以后,已有结果要能跟着变。 */ _push(type, make, geom) { const rec = { type, make, geom } this._apply(rec) this.results.push(rec) this.reset() this.onChange() } _apply(rec) { const [text, label] = rec.make() rec.text = text if (rec.geom) rec.geom.label = label === undefined ? text : label } /** 单位 / 小数位变了以后刷新全部结果 */ refresh() { for (const r of this.results) this._apply(r) } _finishPoints(def) { const p = this.pending.slice() switch (def.id) { case 'coord': this._push('坐标', () => [`X=${p[0][0].toFixed(this.precision)} Y=${p[0][1].toFixed(this.precision)}`, ''], { kind: 'pts', pts: p }) break case 'p2p': { const d = Math.hypot(p[1][0] - p[0][0], p[1][1] - p[0][1]) this._push('距离', () => [this.fmt(d)], { kind: 'line', pts: p }) break } case 'linear': { const dx = Math.abs(p[1][0] - p[0][0]), dy = Math.abs(p[1][1] - p[0][1]) this._push('线性', () => [`ΔX=${this.fmt(dx)} ΔY=${this.fmt(dy)}`], { kind: 'linear', pts: p }) break } case 'cont': { let total = 0 for (let i = 1; i < p.length; i++) total += Math.hypot(p[i][0] - p[i - 1][0], p[i][1] - p[i - 1][1]) this._push('连续长度', () => [this.fmt(total)], { kind: 'poly', pts: p }) break } case 'angle': { const a = Math.atan2(p[0][1] - p[1][1], p[0][0] - p[1][0]) const b = Math.atan2(p[2][1] - p[1][1], p[2][0] - p[1][0]) let d = Math.abs(a - b) if (d > Math.PI) d = TAU - d this._push('角度', () => [this.fmtAngle(d)], { kind: 'angle', pts: p }) break } case 'area': { let area = 0, peri = 0 for (let i = 0; i < p.length; i++) { const q = p[(i + 1) % p.length] area += p[i][0] * q[1] - q[0] * p[i][1] peri += Math.hypot(q[0] - p[i][0], q[1] - p[i][1]) } area = Math.abs(area) / 2 this._push('面积', () => [`${area.toFixed(this.precision)} ${this.unit}² 周长 ${this.fmt(peri)}`, `${area.toFixed(Math.min(2, this.precision))} ${this.unit}²`], { kind: 'area', pts: p }) break } default: this.reset() } } _finishShapes(def) { const shapes = this.pendingShapes.map((i) => this.r.shapes[i]) switch (def.id) { case 'seg': { const len = shapeLength(shapes[0]) if (len == null) { this.reset(); this.onChange(); return } this._push('长度', () => [this.fmt(len)], { kind: 'shape', shape: this.pendingShapes[0] }) break } case 'arclen': { const s = shapes[0] if (s.kind !== 'arc') { this.reset(); this.onChange(); return } let sweep = s.a1 - s.a0 while (sweep <= 0) sweep += TAU const len = sweep * ((s.rx + s.ry) / 2) this._push('弧长', () => [this.fmt(len)], { kind: 'shape', shape: this.pendingShapes[0] }) break } case 'radius': { const s = shapes[0] if (s.kind !== 'arc') { this.reset(); this.onChange(); return } const round = Math.abs(s.rx - s.ry) < 1e-6 this._push('半径', () => [round ? `R=${this.fmt(s.rx)} ⌀=${this.fmt(s.rx * 2)}` : `长半轴=${this.fmt(s.rx)} 短半轴=${this.fmt(s.ry)}`, round ? `R${this.fmt(s.rx)}` : this.fmt(s.rx)], { kind: 'shape', shape: this.pendingShapes[0] }) break } case 'p2l': { const pt = this.pending[0] const q = nearestOnShape(shapes[0], pt[0], pt[1]) if (!q) { this.reset(); this.onChange(); return } const d = Math.hypot(q[0] - pt[0], q[1] - pt[1]) this._push('点到线', () => [this.fmt(d)], { kind: 'line', pts: [pt, q] }) break } case 'l2l': { const [a, b] = shapes const da = shapeDir(a), db = shapeDir(b) if (!da || !db) { this.reset(); this.onChange(); return } let ang = Math.abs(Math.atan2(da[1], da[0]) - Math.atan2(db[1], db[0])) if (ang > Math.PI) ang = TAU - ang if (ang > Math.PI / 2) ang = Math.PI - ang if (ang < 1e-3) { // 平行:量垂直距离 const p0 = [a.pts[0], a.pts[1]] const q = nearestOnShape(b, p0[0], p0[1]) const d = q ? Math.hypot(q[0] - p0[0], q[1] - p0[1]) : 0 this._push('线到线', () => [`平行 间距 ${this.fmt(d)}`, this.fmt(d)], { kind: 'line', pts: [p0, q] }) } else { this._push('线到线', () => [`夹角 ${this.fmtAngle(ang)}`, this.fmtAngle(ang)], { kind: 'shapes', shapes: this.pendingShapes.slice() }) } break } default: this.reset() } } // ------------------------------------------------------------ 绘制 draw(ctx, r) { ctx.save() ctx.lineWidth = 1.6 * r.dpr ctx.strokeStyle = '#2b9ae8' ctx.fillStyle = '#2b9ae8' ctx.font = `${12 * r.dpr}px "Microsoft YaHei", sans-serif` for (const res of this.results) this._drawGeom(ctx, r, res.geom, '#2b9ae8') if (this.mode) { // 未完成的测量用橙色预览 const g = { kind: this.mode === 'area' ? 'area' : 'poly', pts: this.pending, open: true } if (this.pending.length) this._drawGeom(ctx, r, g, '#ff9800') for (const i of this.pendingShapes) this._outline(ctx, r, i, '#ff9800') if (this.cursor) this._drawSnap(ctx, r, this.cursor) } ctx.restore() } _drawSnap(ctx, r, c) { // 用 sx/sy 而不是 toScreen:overlay 的画布变换里已经带了视图旋转, // 再用含旋转的 toScreen 会转两次 const x = r.sx(c.x), y = r.sy(c.y) const s = 5 * r.dpr ctx.strokeStyle = '#ff5722' ctx.lineWidth = 1.6 * r.dpr ctx.beginPath() if (c.type === 'end' || c.type === 'node') ctx.rect(x - s, y - s, s * 2, s * 2) else if (c.type === 'mid') { ctx.moveTo(x - s, y + s); ctx.lineTo(x, y - s); ctx.lineTo(x + s, y + s); ctx.closePath() } else if (c.type === 'center') ctx.arc(x, y, s, 0, TAU) else if (c.type === 'quad') { ctx.moveTo(x, y - s); ctx.lineTo(x + s, y); ctx.lineTo(x, y + s); ctx.lineTo(x - s, y); ctx.closePath() } else { ctx.moveTo(x - s, y - s); ctx.lineTo(x + s, y + s); ctx.moveTo(x + s, y - s); ctx.lineTo(x - s, y + s) } ctx.stroke() const lb = Snapper.label(c.type) if (lb) { ctx.fillStyle = '#ff5722' ctx.fillText(lb, x + s + 4 * r.dpr, y - s) } } _outline(ctx, r, idx, color) { const s = r.shapes[idx] if (!s) return ctx.strokeStyle = color ctx.lineWidth = 3 * r.dpr ctx.beginPath() r._path(ctx, s) ctx.stroke() } _drawGeom(ctx, r, g, color) { if (!g) return const S = (p) => [r.sx(p[0]), r.sy(p[1])] ctx.strokeStyle = color ctx.fillStyle = color ctx.lineWidth = 1.6 * r.dpr const pts = g.pts || [] switch (g.kind) { case 'pts': for (const p of pts) mark(ctx, r, S(p), color) break case 'line': case 'poly': { if (pts.length < 1) break ctx.beginPath() const a = S(pts[0]) ctx.moveTo(a[0], a[1]) for (let i = 1; i < pts.length; i++) { const b = S(pts[i]); ctx.lineTo(b[0], b[1]) } ctx.stroke() for (const p of pts) mark(ctx, r, S(p), color) break } case 'linear': { if (pts.length < 2) break const a = S(pts[0]), b = S(pts[1]) ctx.setLineDash([4 * r.dpr, 3 * r.dpr]) ctx.beginPath() ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], a[1]); ctx.lineTo(b[0], b[1]) ctx.stroke() ctx.setLineDash([]) ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke() mark(ctx, r, a, color); mark(ctx, r, b, color) break } case 'angle': { if (pts.length < 3) break const a = S(pts[0]), v = S(pts[1]), b = S(pts[2]) ctx.beginPath() ctx.moveTo(a[0], a[1]); ctx.lineTo(v[0], v[1]); ctx.lineTo(b[0], b[1]) ctx.stroke() const rr = 26 * r.dpr const a0 = Math.atan2(a[1] - v[1], a[0] - v[0]) const a1 = Math.atan2(b[1] - v[1], b[0] - v[0]) ctx.beginPath(); ctx.arc(v[0], v[1], rr, a0, a1); ctx.stroke() for (const p of [a, v, b]) mark(ctx, r, p, color) break } case 'area': { if (pts.length < 2) break ctx.beginPath() const a = S(pts[0]); ctx.moveTo(a[0], a[1]) for (let i = 1; i < pts.length; i++) { const b = S(pts[i]); ctx.lineTo(b[0], b[1]) } if (!g.open) ctx.closePath() ctx.stroke() if (!g.open) { ctx.globalAlpha = 0.14; ctx.fill(); ctx.globalAlpha = 1 } for (const p of pts) mark(ctx, r, S(p), color) break } case 'shape': this._outline(ctx, r, g.shape, color) break case 'shapes': for (const i of g.shapes) this._outline(ctx, r, i, color) break default: break } if (g.label && pts.length) { const c = S(pts[pts.length - 1]) drawLabel(ctx, r, c[0] + 10 * r.dpr, c[1] - 10 * r.dpr, g.label, color) } else if (g.label && g.kind === 'shape') { const s = r.shapes[g.shape] if (s) drawLabel(ctx, r, r.sx((s.bbox[0] + s.bbox[2]) / 2), r.sy(s.bbox[3]) - 10 * r.dpr, g.label, color) } } } function mark(ctx, r, p, color) { const s = 3 * r.dpr ctx.fillStyle = color ctx.fillRect(p[0] - s, p[1] - s, s * 2, s * 2) } function drawLabel(ctx, r, x, y, text, color) { ctx.font = `${12 * r.dpr}px "Microsoft YaHei", sans-serif` const w = ctx.measureText(text).width + 10 * r.dpr const h = 18 * r.dpr ctx.fillStyle = color ctx.globalAlpha = 0.92 roundRect(ctx, x, y - h, w, h, 3 * r.dpr) ctx.fill() ctx.globalAlpha = 1 ctx.fillStyle = '#fff' ctx.textBaseline = 'middle' ctx.fillText(text, x + 5 * r.dpr, y - h / 2) ctx.textBaseline = 'alphabetic' } function roundRect(ctx, x, y, w, h, r) { ctx.beginPath() ctx.moveTo(x + r, y) ctx.arcTo(x + w, y, x + w, y + h, r) ctx.arcTo(x + w, y + h, x, y + h, r) ctx.arcTo(x, y + h, x, y, r) ctx.arcTo(x, y, x + w, y, r) ctx.closePath() } function shapeLength(s) { if (!s) return null if (s.kind === 'poly') { let len = 0 const p = s.pts for (let i = 0; i + 3 < p.length; i += 2) len += Math.hypot(p[i + 2] - p[i], p[i + 3] - p[i + 1]) if (s.closed && p.length >= 4) len += Math.hypot(p[0] - p[p.length - 2], p[1] - p[p.length - 1]) return len } if (s.kind === 'arc') { let sweep = s.a1 - s.a0 while (sweep <= 0) sweep += TAU return sweep * ((s.rx + s.ry) / 2) } return null } function shapeDir(s) { if (!s || s.kind !== 'poly' || s.pts.length < 4) return null const p = s.pts const dx = p[p.length - 2] - p[0], dy = p[p.length - 1] - p[1] const l = Math.hypot(dx, dy) return l > 0 ? [dx / l, dy / l] : null } // ---------------------------------------------------------------- 批注 export const ANNO_MODES = [ { id: 'text', name: '文字', icon: 'view-tool-annoText.png' }, { id: 'free', name: '自由线', icon: 'view-tool-freehandLine.png' }, { id: 'line', name: '直线', icon: 'view-tool-annoLine.png' }, { id: 'arrow', name: '箭头', icon: 'view-tool-annoArrow.png' }, { id: 'rect', name: '矩形', icon: 'view-tool-rectangleAnno.png' }, { id: 'circle', name: '圆', icon: 'view-tool-circleAnno.png' }, { id: 'cloud', name: '云线', icon: 'view-tool-cloudLine.png' }, ] export class AnnoTool { constructor(renderer, onChange) { this.r = renderer this.onChange = onChange || (() => {}) this.mode = null this.items = [] this.color = '#e60012' this.width = 2 this.draft = null } setMode(m) { this.mode = m; this.draft = null; this.onChange() } clear() { this.items = []; this.draft = null; this.onChange() } undo() { this.items.pop(); this.onChange() } down(px, py) { if (!this.mode) return false const w = this.r.toWorld(px, py) if (this.mode === 'text') { const t = window.prompt('批注文字') if (t) this.items.push({ kind: 'text', x: w.x, y: w.y, text: t, color: this.color, size: 14 }) this.onChange() return true } this.draft = { kind: this.mode, pts: [[w.x, w.y], [w.x, w.y]], color: this.color, width: this.width } return true } move(px, py) { if (!this.draft) return false const w = this.r.toWorld(px, py) if (this.draft.kind === 'free') this.draft.pts.push([w.x, w.y]) else this.draft.pts[1] = [w.x, w.y] return true } up() { if (!this.draft) return false const d = this.draft this.draft = null const [a, b] = [d.pts[0], d.pts[d.pts.length - 1]] if (d.kind !== 'free' && Math.hypot(b[0] - a[0], b[1] - a[1]) < 1e-9) return true this.items.push(d) this.onChange() return true } draw(ctx, r) { ctx.save() for (const it of this.items) this._one(ctx, r, it) if (this.draft) this._one(ctx, r, this.draft) ctx.restore() } _one(ctx, r, it) { ctx.strokeStyle = it.color ctx.fillStyle = it.color ctx.lineWidth = (it.width || 2) * r.dpr ctx.lineJoin = 'round' ctx.lineCap = 'round' const S = (p) => [r.sx(p[0]), r.sy(p[1])] if (it.kind === 'text') { const p = S([it.x, it.y]) ctx.font = `${(it.size || 14) * r.dpr}px "Microsoft YaHei", sans-serif` ctx.fillText(it.text, p[0], p[1]) return } const a = S(it.pts[0]), b = S(it.pts[it.pts.length - 1]) ctx.beginPath() switch (it.kind) { case 'free': { const p0 = S(it.pts[0]) ctx.moveTo(p0[0], p0[1]) for (let i = 1; i < it.pts.length; i++) { const p = S(it.pts[i]); ctx.lineTo(p[0], p[1]) } ctx.stroke() return } case 'line': ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke() return case 'arrow': { ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke() const ang = Math.atan2(b[1] - a[1], b[0] - a[0]) const h = 10 * r.dpr ctx.beginPath() ctx.moveTo(b[0], b[1]) ctx.lineTo(b[0] - h * Math.cos(ang - 0.4), b[1] - h * Math.sin(ang - 0.4)) ctx.lineTo(b[0] - h * Math.cos(ang + 0.4), b[1] - h * Math.sin(ang + 0.4)) ctx.closePath(); ctx.fill() return } case 'rect': ctx.rect(Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1])) ctx.stroke() return case 'circle': { const rr = Math.hypot(b[0] - a[0], b[1] - a[1]) ctx.arc(a[0], a[1], rr, 0, TAU); ctx.stroke() return } case 'cloud': { cloudPath(ctx, Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1]), 12 * r.dpr) ctx.stroke() return } default: return } } } /** 修订云线:沿矩形边界画一串外凸圆弧 */ function cloudPath(ctx, x, y, w, h, bulge) { const r = Math.max(6, bulge) const seg = (x0, y0, x1, y1, dir) => { const len = Math.hypot(x1 - x0, y1 - y0) const n = Math.max(1, Math.round(len / (r * 2))) for (let i = 0; i < n; i++) { const t0 = i / n, t1 = (i + 1) / n const ax = x0 + (x1 - x0) * t0, ay = y0 + (y1 - y0) * t0 const bx = x0 + (x1 - x0) * t1, by = y0 + (y1 - y0) * t1 const mx = (ax + bx) / 2, my = (ay + by) / 2 const dx = bx - ax, dy = by - ay const l = Math.hypot(dx, dy) || 1 const nx = (-dy / l) * dir * (l / 3), ny = (dx / l) * dir * (l / 3) ctx.moveTo(ax, ay) ctx.quadraticCurveTo(mx + nx, my + ny, bx, by) } } ctx.beginPath() seg(x, y, x + w, y, -1) seg(x + w, y, x + w, y + h, -1) seg(x + w, y + h, x, y + h, -1) seg(x, y + h, x, y, -1) }