Files
Leantime/dev/DWGViewer/js/tools.js

703 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/**
* 交互工具:对象捕捉、测量、批注。
*
* 三者都只操作世界坐标,屏幕换算全部交给 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 而不是 toScreenoverlay 的画布变换里已经带了视图旋转,
// 再用含旋转的 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)
}