OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
702
dev/DWGViewer/js/tools.js
Normal file
702
dev/DWGViewer/js/tools.js
Normal file
@@ -0,0 +1,702 @@
|
||||
/**
|
||||
* 交互工具:对象捕捉、测量、批注。
|
||||
*
|
||||
* 三者都只操作世界坐标,屏幕换算全部交给 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)
|
||||
}
|
||||
Reference in New Issue
Block a user