// ==UserScript== // @name 3D标尺工具 // @version 1.0.0 // @description 在标注界面顶部工具栏注入「尺子」按钮:十字标尺,刻度画在十字线上,精度随缩放自适应,点击开设置(可换色 / 米厘米切换 / 浮层配色跟随平台),N 键开关 // @author CC // @match *://*/pointcloud/* // @include /^https?:\/\/[^/]+\/pointcloud\// // @run-at document-idle // @grant none // ==/UserScript== /* * 开关与设置 * ───────────────────────────────────────────────────────────────────────────── * 开关:键盘 N;或点开设置浮层里的「启用标尺」。 * 设置:点顶部工具栏右侧设置栏里、「点云(点大小)」左边那颗「尺子」按钮, * 在它正下方弹出一个小浮层(点别处或按 Esc 收起),里面是: * 启用标尺 / 十字线 / 延伸网格 / 单位 / 刻度间距 / 颜色 * 颜色是一个调色盘:8 个预设色 + 任意取色 + 一键复位成白色(那颗圆形箭头图标), * 换一次色,十字线、刻度齿、数字、网格会一起变(整把尺子只有一个色相, * 只靠明暗分层区分主刻度 / 次刻度 / 网格)。 * 单位可在「米 / 厘米」之间切(默认米),切换后刻度数字与左上角「1 格 = …」一起换算。 * 浮层自己不写死配色:启动时造一个跟平台弹层同 class 的探针节点量一次 * getComputedStyle,把真实的底色 / 文字色 / 描边 / 圆角 / 阴影 / 字号读出来用 * (见「向平台借配色」一节),所以跟平台其它浮窗是同一套皮肤,换主题也跟着变。 * 按钮是「克隆」平台自己的工具栏按钮再换图标得来的(见「平台工具栏按钮」一节), * 所以 hover / 选中态 / 尺寸跟旁边那排按钮完全一致,不需要自带任何 CSS。 * N 键故意不 preventDefault、也不 stopPropagation,避免打断平台自身的快捷键。 * 关闭标尺时只停绘制,按钮与页面其余部分照常。 * ───────────────────────────────────────────────────────────────────────────── */ (function () { 'use strict'; /* ======================= 页面 window ======================= */ // @grant none 时脚本直接跑在页面上下文,window 即页面 window; // 若改成需要 grant 的写法,则必须经 unsafeWindow 才能读到 window.viewer。 const PAGE = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; /* ======================= 常量 / 状态 ======================= */ const OVERLAY_ID = '__zd_ruler_layer'; const HOST_ID = '__zd_ruler_ui'; const PLACE_MS = 200; // 面板打开时重新定位的间隔 const NDIV = 5; // 每个主刻度分几个小格 const DEFAULT_COLOR = '#ffffff'; // 调色盘预设:第一颗就是默认白 const SWATCHES = ['#ffffff', '#00e5ff', '#4ade80', '#facc15', '#fb923c', '#f87171', '#c084fc', '#60a5fa']; /** '#rrggbb' / '#rgb' → {r,g,b};认不出来返回 null */ function hexToRgb(hex) { let h = String(hex == null ? '' : hex).trim().replace(/^#/, ''); if (h.length === 3) h = h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2); if (!/^[0-9a-fA-F]{6}$/.test(h)) return null; return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) }; } function normHex(hex) { const c = hexToRgb(hex); if (!c) return null; const p = function (v) { return ('0' + v.toString(16)).slice(-2); }; return '#' + p(c.r) + p(c.g) + p(c.b); } /** * 由**一个**基色派生整把标尺的全部颜色。 * 只有明暗分层(主刻度亮、次刻度暗、网格最淡),不引入第二种色相 —— * 所以调色盘换一次,十字线 / 刻度 / 数字 / 网格全部跟着变。 */ function buildColors(hex) { const c = hexToRgb(hex) || { r: 255, g: 255, b: 255 }; const a = function (v) { return 'rgba(' + c.r + ',' + c.g + ',' + c.b + ',' + v + ')'; }; return { cross: a(0.50), // 十字线 crossCore: a(0.98), // 十字中心 tickMajor: a(0.95), tickMinor: a(0.45), tickZero: a(1), // 0 位那一条(最亮 + 齿更长) label: a(0.98), labelShadow: 'rgba(0,0,0,0.9)', chip: 'rgba(0,0,0,0.42)', chipFg: '#ffffff', gridLine: a(0.10) }; } let COLORS = buildColors(DEFAULT_COLOR); // 平台点云是米制,所以只给「米 / 厘米」两档(1 m = 100 cm) const UNITS = [ { id: 'm', label: '米', scale: 1 }, { id: 'cm', label: '厘米', scale: 100 } ]; /* ---- 浮层外观:直接向平台自己的弹层「借」配色 ------------------------------ * 我们的浮层活在 Shadow DOM 里,平台 CSS 够不着它,所以只能主动去量: * 临时造一个跟 Element Plus 弹层同 class 的探针节点插进 body,读它的 computedStyle, * 拿到当前主题真正的底色 / 文字色 / 描边 / 圆角 / 阴影 / 字号,再套回浮层。 * 因此不管平台是深色还是浅色主题,浮层都跟它自家弹窗长得一样。 */ const POP_PROBE = [ 'el-popover el-popper is-light', 'el-popover el-popper', 'el-popper is-light', 'el-popper' ]; // 探针全部失败时的兜底:Element Plus 深色主题的默认弹层配色 const POP_FALLBACK = { bg: '#1d1e1f', fg: '#e5eaf3', line: '#414243', radius: '4px', shadow: '0 0 12px rgba(0,0,0,.5)', accent: '#409eff' }; function cssVar(name) { try { const v = getComputedStyle(document.documentElement).getPropertyValue(name); return (v || '').trim(); } catch (_) { return ''; } } function parseRgb(str) { const m = /rgba?\(([^)]+)\)/.exec(String(str == null ? '' : str)); if (!m) return null; const p = m[1].split(',').map(function (s) { return parseFloat(s); }); if (p.length < 3 || !isFinite(p[0]) || !isFinite(p[1]) || !isFinite(p[2])) return null; return { r: p[0], g: p[1], b: p[2], a: (p.length > 3 && isFinite(p[3])) ? p[3] : 1 }; } /** 把 a 按 t 的比例混到 b 上;用来从底色 + 文字色派生次要文字 / 分隔线 */ function mixRgb(a, b, t) { const f = function (x, y) { return Math.round(x * t + y * (1 - t)); }; return 'rgb(' + f(a.r, b.r) + ',' + f(a.g, b.g) + ',' + f(a.b, b.b) + ')'; } /** 探一个平台弹层的真实外观;探不到返回 null */ function probePopTheme() { if (!document.body) return null; for (let i = 0; i < POP_PROBE.length; i++) { const el = document.createElement('div'); el.className = POP_PROBE[i]; el.style.cssText = 'position:absolute;left:-99999px;top:-99999px;width:200px;height:40px;'; document.body.appendChild(el); let cs = null; try { cs = getComputedStyle(el); } catch (_) {} const bg = cs && cs.backgroundColor; const ok = !!(cs && bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent'); if (!ok) { el.remove(); continue; } const out = { bg: bg, fg: cs.color, line: (cs.borderTopStyle !== 'none' && parseFloat(cs.borderTopWidth) > 0) ? cs.borderTopColor : '', radius: cs.borderTopLeftRadius || cs.borderRadius || '', shadow: (cs.boxShadow && cs.boxShadow !== 'none') ? cs.boxShadow : '', font: cs.fontSize || '' }; el.remove(); return out; } return null; } // 只探一次,之后复用(platform 主题不会中途变) const popTheme = { ready: false }; /** 把探到的平台弹层外观写成浮层上的 CSS 变量 */ function applyPopTheme() { if (!ui || !ui.pop) return; if (!popTheme.ready) { popTheme.ready = true; const p = probePopTheme() || {}; popTheme.bg = p.bg || POP_FALLBACK.bg; popTheme.fg = p.fg || POP_FALLBACK.fg; popTheme.line = p.line || POP_FALLBACK.line; popTheme.radius = p.radius || POP_FALLBACK.radius; popTheme.shadow = p.shadow || POP_FALLBACK.shadow; popTheme.font = p.font || ''; popTheme.accent = cssVar('--el-color-primary') || POP_FALLBACK.accent; const fg = parseRgb(popTheme.fg) || parseRgb(POP_FALLBACK.fg); const bg = parseRgb(popTheme.bg) || parseRgb(POP_FALLBACK.bg); popTheme.dim = mixRgb(fg, bg, 0.62); // 次要文字 popTheme.faint = mixRgb(fg, bg, 0.42); // 提示行 popTheme.sep = mixRgb(fg, bg, 0.16); // 行内分隔线 / 控件描边 popTheme.hover = mixRgb(fg, bg, 0.30); // hover 描边 } const s = ui.pop.style; s.setProperty('--zd-bg', popTheme.bg); s.setProperty('--zd-fg', popTheme.fg); s.setProperty('--zd-dim', popTheme.dim); s.setProperty('--zd-faint', popTheme.faint); s.setProperty('--zd-sep', popTheme.sep); s.setProperty('--zd-hover', popTheme.hover); s.setProperty('--zd-line', popTheme.line); s.setProperty('--zd-radius', popTheme.radius); s.setProperty('--zd-shadow', popTheme.shadow); s.setProperty('--zd-accent', popTheme.accent); if (popTheme.font) s.setProperty('--zd-font', popTheme.font); } const state = { active: true, // 标尺总开关(默认开) panelOpen: false, // 设置浮层是否展开 color: DEFAULT_COLOR, // 标尺基色(调色盘) unit: 'm', scale: 1, // 世界坐标 → 显示值的换算系数 decimals: 3, // 面板里「每格多少」的小数位 targetPx: 100, // 期望的主刻度屏幕间距(px),决定刻度疏密 crosshair: true, // 十字线 grid: false, // 主刻度延伸出的淡网格 mouse: null, // { x, y } client 坐标 wppX: 0, wppY: 0, // 每屏幕像素代表多少世界单位(横 / 纵) stepH: 0, stepV: 0, // 本帧主刻度步长(世界单位) camKind: '' }; const FONT = '"PingFang SC","Microsoft YaHei",system-ui,sans-serif'; let overlay = null, overlayCtx = null, boundCont = null; let ui = null, uiPlaceAt = 0; /* ======================= 日志 ======================= */ const TAG = '[3D标尺]'; function log() { try { console.log(TAG, ...arguments); } catch (_) {} } function warn() { try { console.warn(TAG, ...arguments); } catch (_) {} } /* ======================= 宿主对象 ======================= */ function getCtx() { // 只认 window.viewer —— window.instance.viewer 是懒加载 getter, // 读它可能额外 new 一个 Viewer 出来,所以不去碰。 const V = PAGE.viewer; if (!V || !V.camera) return null; const cont = V.canvasContainer || document.querySelector('#canvas-container'); if (!cont || !cont.isConnected) return null; return { V: V, cont: cont, cam: V.camera }; } /* ======================= 像素 ↔ 世界 ======================= */ /** * 真正承载渲染的那块 canvas。 * 平台的 Viewer.initScene 里: * const n = document.querySelector("#canvas"); * this.domElement = n; * this.render = new WebGL1Renderer({ canvas: n, ... }); * this.render.setSize(n.clientWidth, n.clientHeight, false); * this.render.setPixelRatio(1); * this.camera = new OrthographicCamera(n.clientWidth/-2, n.clientWidth/2, * n.clientHeight/2, n.clientHeight/-2, -1e4, 1e5); * 即正交视锥被铺满整块 #canvas,所以换算比例的分母要用它自己的尺寸。 */ function renderEl(ctx) { const V = ctx.V; const r = V.renderer || V.render; const el = (r && r.domElement) || V.domElement || document.querySelector('#canvas'); return (el && el.isConnected) ? el : null; } /** * 「一个屏幕像素 = 多少世界单位」。 * 正交相机:直接由视锥宽度 / 高度除以画布尺寸得到,精确且与深度无关。 * Viewer 建相机时 right-left = #canvas.clientWidth、top-bottom = #canvas.clientHeight, * 而 setPoseMainCamera() 设 camera.zoom = min(W,H) / 包围球半径 / 2, * 所以 (right-left)/zoom 就是当前屏幕宽度覆盖的世界长度。 * 注意平台 onWindowResize 只改了 camera.aspect(正交相机根本不看 aspect), * 视锥不会跟着窗口变 —— 这里按「当前画布尺寸」算,正好能反映这个拉伸。 * 透视相机:用轨道中心距离当前深粗略估算(标尺会有误差,仅兜底)。 */ function worldPerPixel(ctx) { const cam = ctx.cam; const el = renderEl(ctx) || ctx.cont; const W = el.clientWidth || ctx.cont.clientWidth || 0; const H = el.clientHeight || ctx.cont.clientHeight || 0; if (!(W > 0) || !(H > 0)) return null; if (cam.isOrthographicCamera === true) { const z = cam.zoom || 1; const x = (cam.right - cam.left) / (z * W); const y = (cam.top - cam.bottom) / (z * H); if (isFinite(x) && isFinite(y) && x > 0 && y > 0) { return { x: x, y: y, kind: '正交相机' }; } } if (cam.isPerspectiveCamera === true && cam.position) { const tgt = ctx.V.control && ctx.V.control.target; const dist = tgt ? cam.position.distanceTo(tgt) : cam.position.length(); if (isFinite(dist) && dist > 0) { const k = 2 * Math.tan(cam.fov * Math.PI / 360) * dist / H; if (isFinite(k) && k > 0) return { x: k, y: k, kind: '透视相机(估算)' }; } } return null; } /* ======================= 刻度 ======================= */ /** 取 1/2/5×10ⁿ 里最接近 raw 的一档 */ function niceStep(raw) { if (!isFinite(raw) || raw <= 0) return 0; const e = Math.pow(10, Math.floor(Math.log10(raw))); const m = raw / e; let s; if (m < 1.5) s = 1; else if (m < 3.5) s = 2; else if (m < 7.5) s = 5; else s = 10; return s * e; } /** 刻度标签:按步长自动决定小数位,保证相邻标签不重复 */ function fmtTick(v, step) { const stepScaled = Math.abs(step * state.scale); let d = stepScaled > 0 ? Math.ceil(-Math.log10(stepScaled)) : state.decimals; if (!isFinite(d) || d < 0) d = 0; d = Math.min(6, d); let scaled = v * state.scale; if (Math.abs(scaled) < 1e-12) { scaled = 0; d = 0; } return scaled.toFixed(d); } /** 带正负号的刻度标签 */ function fmtSigned(v, step) { const t = fmtTick(v, step); return (t.charAt(0) === '-' || parseFloat(t) === 0) ? t : '+' + t; } /** 「每格多少」这种给人看的短数字(传入的已经是显示单位下的值) */ function fmtShort(v) { const a = Math.abs(v); if (a === 0) return '0'; // 不用 k 缩写:切到厘米后一万写成 "10k cm" 反而看不懂,直接写全 if (a >= 1000) return String(Math.round(v)); // 位数跟着量级走:100 以上不留小数,10 以上一位,其余两位 if (a >= 1) return v.toFixed(a >= 100 ? 0 : (a >= 10 ? 1 : 2)); if (a >= 0.01) return v.toFixed(3).replace(/0+$/, '').replace(/\.$/, ''); return v.toExponential(1); } /** * 生成一条线上的刻度:{ off: 相对十字中心的像素偏移, major, zero, text } * 中心恒为 0 刻度,两侧对称展开,直到超出画面。 * * dir 是符号方向:屏幕上 x 向右为正、y 向下为正,所以 * 横轴传 +1 → 右边是 +(和数轴一致) * 纵轴传 -1 → 上边是 +(抵消屏幕 y 向下,读起来才是「上为正」) */ function buildTicks(step, wpp, cursorPx, viewportPx, dir) { const out = []; if (!(step > 0) || !(wpp > 0)) return out; const sgn = (dir < 0) ? -1 : 1; const pxPerUnit = 1 / wpp; // 1 世界单位 = 多少像素 const minor = step / NDIV; const showMinor = (minor * pxPerUnit) >= 4; // 太密就只画主刻度 const reach = Math.max(cursorPx, viewportPx - cursorPx); let n = Math.ceil(reach * wpp / minor) + 1; if (!isFinite(n) || n < 0) return out; if (n > 3000) n = 3000; // 兜底,防极端缩放爆量 for (let i = -n; i <= n; i++) { const major = (i % NDIV === 0); if (!major && !showMinor) continue; const v = i * minor; const off = sgn * v * pxPerUnit; if (!isFinite(off) || Math.abs(off) > reach + 2) continue; out.push({ off: off, major: major, zero: i === 0, text: major ? fmtSigned(v, step) : '' }); } return out; } /* ======================= overlay ======================= */ function zIndexOf(el) { const z = parseInt(window.getComputedStyle(el).zIndex, 10); return isNaN(z) ? null : z; } function maxChildZ(cont) { let max = 0; for (let i = 0; i < cont.children.length; i++) { const el = cont.children[i]; if (el === overlay) continue; const z = zIndexOf(el); if (z !== null && z > max) max = z; } return max; } function ensureOverlay(ctx) { if (overlay && overlay.parentElement !== ctx.cont) { if (overlay.parentElement) overlay.parentElement.removeChild(overlay); } if (!overlay) { overlay = document.createElement('canvas'); overlay.id = OVERLAY_ID; overlayCtx = overlay.getContext('2d'); } if (overlay.parentElement !== ctx.cont) { try { if (window.getComputedStyle(ctx.cont).position === 'static') { ctx.cont.style.position = 'relative'; } } catch (_) {} overlay.style.cssText = [ 'position:absolute', 'left:0', 'top:0', 'pointer-events:none', // 全程穿透,绝不抢平台事件 'user-select:none', 'z-index:' + (maxChildZ(ctx.cont) + 5) ].join(';'); ctx.cont.appendChild(overlay); overlay.__w = -1; overlay.__h = -1; boundCont = ctx.cont; } const w = ctx.cont.clientWidth || 0; const h = ctx.cont.clientHeight || 0; if (w !== overlay.__w || h !== overlay.__h) { const dpr = window.devicePixelRatio || 1; overlay.width = Math.max(1, Math.round(w * dpr)); overlay.height = Math.max(1, Math.round(h * dpr)); overlay.style.width = w + 'px'; overlay.style.height = h + 'px'; overlay.__w = w; overlay.__h = h; overlay.__dpr = dpr; } return overlay; } function destroyOverlay() { if (overlay && overlay.parentElement) overlay.parentElement.removeChild(overlay); overlay = null; overlayCtx = null; boundCont = null; } /* ======================= 绘制原语 ======================= */ function chip(c, text, x, y, align, size) { c.save(); c.font = '600 ' + size + 'px ' + FONT; c.textBaseline = 'middle'; const pad = 5, h = size + 8; const tw = c.measureText(text).width; const bw = tw + pad * 2; let bx = x; if (align === 'center') bx = x - bw / 2; else if (align === 'right') bx = x - bw; c.fillStyle = COLORS.chip; c.beginPath(); if (c.roundRect) c.roundRect(bx, y - h / 2, bw, h, 4); else c.rect(bx, y - h / 2, bw, h); c.fill(); c.fillStyle = COLORS.chipFg; c.textAlign = 'left'; c.fillText(text, bx + pad, y + 0.5); c.restore(); } /** 纯十字线(还没拿到缩放信息时用) */ function drawCross(c, WW, HH, x, y) { c.save(); c.strokeStyle = COLORS.cross; c.lineWidth = 1; const px = Math.round(x) + 0.5, py = Math.round(y) + 0.5, gap = 14; c.beginPath(); c.moveTo(0, py); c.lineTo(px - gap, py); c.moveTo(px + gap, py); c.lineTo(WW, py); c.moveTo(px, 0); c.lineTo(px, py - gap); c.moveTo(px, py + gap); c.lineTo(px, HH); c.stroke(); c.restore(); } /** * 十字标尺:十字线 + 直接画在两条线上的刻度。 * 横线上画竖齿,纵线上画横齿;主刻度带数字,数字浮在十字线外侧。 */ function drawCrossRuler(c, WW, HH, cx, cy, hTicks, vTicks, lines) { const px = Math.round(cx) + 0.5, py = Math.round(cy) + 0.5; const NH = 9, NM = 4; // 主 / 次刻度齿的半长 c.save(); c.lineCap = 'butt'; c.lineWidth = 1; // 1) 十字线本体(中心 14px 留白,交给中心标记) if (lines) { c.strokeStyle = COLORS.cross; c.beginPath(); c.moveTo(0, py); c.lineTo(px - 14, py); c.moveTo(px + 14, py); c.lineTo(WW, py); c.moveTo(px, 0); c.lineTo(px, py - 14); c.moveTo(px, py + 14); c.lineTo(px, HH); c.stroke(); } // 2) 刻度齿:按「次 / 主 / 零位」三组批量描边 const groups = [ { color: COLORS.tickMinor, len: NM, w: 1, on: function (t) { return !t.major; } }, { color: COLORS.tickMajor, len: NH, w: 1.4, on: function (t) { return t.major && !t.zero; } }, { color: COLORS.tickZero, len: NH + 2, w: 1.8, on: function (t) { return t.major && t.zero; } } ]; for (let g = 0; g < groups.length; g++) { const G = groups[g]; c.strokeStyle = G.color; c.lineWidth = G.w; c.beginPath(); for (let i = 0; i < hTicks.length; i++) { const t = hTicks[i]; if (!G.on(t)) continue; const x = Math.round(px + t.off) + 0.5; if (x < -4 || x > WW + 4) continue; c.moveTo(x, py - G.len); c.lineTo(x, py + G.len); } for (let i = 0; i < vTicks.length; i++) { const t = vTicks[i]; if (!G.on(t)) continue; const y = Math.round(py + t.off) + 0.5; if (y < -4 || y > HH + 4) continue; c.moveTo(px - G.len, y); c.lineTo(px + G.len, y); } c.stroke(); } // 3) 数字(带描边阴影,压在亮色点云上也看得清;重叠时自动跳过) // 注意:刻度按世界值单调生成,但纵轴的像素偏移被 dir=-1 翻了向, // 数组里的屏幕 y 是递减的;而防重叠判断依赖「从一端扫到另一端」。 // 所以先按屏幕坐标升序排一遍,两种朝向都正确。 c.font = '600 11px ' + FONT; c.textBaseline = 'middle'; c.shadowColor = COLORS.labelShadow; c.shadowBlur = 3; const hLab = []; for (let i = 0; i < hTicks.length; i++) { const t = hTicks[i]; if (!t.major || !t.text) continue; const x = px + t.off; if (x < 10 || x > WW - 10) continue; hLab.push({ p: x, t: t }); } hLab.sort(function (a, b) { return a.p - b.p; }); c.textAlign = 'center'; let rightEdge = -1e9; for (let i = 0; i < hLab.length; i++) { const x = hLab[i].p, t = hLab[i].t; const hw = c.measureText(t.text).width / 2 + 5; if (x - hw < rightEdge) continue; rightEdge = x + hw; c.fillStyle = t.zero ? COLORS.tickZero : COLORS.label; c.fillText(t.text, x, py - NH - 9); } const vLab = []; for (let i = 0; i < vTicks.length; i++) { const t = vTicks[i]; if (!t.major || !t.text) continue; const y = py + t.off; if (y < 10 || y > HH - 10) continue; vLab.push({ p: y, t: t }); } vLab.sort(function (a, b) { return a.p - b.p; }); c.textAlign = 'left'; let bottomEdge = -1e9; for (let i = 0; i < vLab.length; i++) { const y = vLab[i].p, t = vLab[i].t; if (y - 7 < bottomEdge) continue; bottomEdge = y + 7; c.fillStyle = t.zero ? COLORS.tickZero : COLORS.label; c.fillText(t.text, px + NH + 7, y); } c.shadowBlur = 0; // 4) 中心标记 c.strokeStyle = COLORS.crossCore; c.lineWidth = 1.4; c.beginPath(); c.arc(px, py, 3.5, 0, Math.PI * 2); c.moveTo(px - 8, py); c.lineTo(px + 8, py); c.moveTo(px, py - 8); c.lineTo(px, py + 8); c.stroke(); c.restore(); } /* ======================= 主绘制 ======================= */ function draw() { const ctx = getCtx(); if (!ctx) { destroyOverlay(); return; } const cv = ensureOverlay(ctx); const c = overlayCtx || cv.getContext('2d'); const dpr = cv.__dpr || 1; c.setTransform(dpr, 0, 0, dpr, 0, 0); const WW = ctx.cont.clientWidth || 0; const HH = ctx.cont.clientHeight || 0; c.clearRect(0, 0, WW, HH); if (!state.active || WW < 60 || HH < 60) { placePanelTick(); return; } const m = state.mouse; if (!m) { placePanelTick(); return; } const r = ctx.cont.getBoundingClientRect(); const mlx = m.x - r.left; const mly = m.y - r.top; if (!(mlx >= 0 && mly >= 0 && mlx <= WW && mly <= HH)) { placePanelTick(); return; } const wpp = worldPerPixel(ctx); if (!wpp) { state.wppX = 0; state.wppY = 0; state.stepH = 0; state.stepV = 0; state.camKind = ''; if (state.crosshair) drawCross(c, WW, HH, mlx, mly); placePanelTick(); return; } state.wppX = wpp.x; state.wppY = wpp.y; state.camKind = wpp.kind; state.stepH = niceStep(state.targetPx * wpp.x); state.stepV = niceStep(state.targetPx * wpp.y); const hTicks = buildTicks(state.stepH, wpp.x, mlx, WW, +1); const vTicks = buildTicks(state.stepV, wpp.y, mly, HH, -1); // 主刻度延伸出的淡网格(可选) if (state.grid) { c.save(); c.strokeStyle = COLORS.gridLine; c.lineWidth = 1; c.beginPath(); for (let i = 0; i < hTicks.length; i++) { if (!hTicks[i].major) continue; const x = Math.round(mlx + hTicks[i].off) + 0.5; c.moveTo(x, 0); c.lineTo(x, HH); } for (let i = 0; i < vTicks.length; i++) { if (!vTicks[i].major) continue; const y = Math.round(mly + vTicks[i].off) + 0.5; c.moveTo(0, y); c.lineTo(WW, y); } c.stroke(); c.restore(); } drawCrossRuler(c, WW, HH, mlx, mly, hTicks, vTicks, state.crosshair); // 角标:直接写清「每格 = 多少」 const u = ' ' + state.unit; chip(c, '横 1 格 = ' + fmtShort(state.stepH * state.scale) + u, 8, 16, 'left', 10); chip(c, '纵 1 格 = ' + fmtShort(state.stepV * state.scale) + u, 8, 36, 'left', 10); placePanelTick(); } /* ======================= 主循环 ======================= */ function loop() { requestAnimationFrame(loop); try { draw(); } catch (err) { warn('绘制异常', err); } } /* ======================= 交互 ======================= */ function setActive(on) { state.active = !!on; if (ui && ui.cbActive) ui.cbActive.checked = state.active; syncToolbarBtn(); log(state.active ? '已开启' : '已关闭'); } /* ── 设置浮层:点尺子按钮才出现,不再常驻 ───────────────── */ function setPanelOpen(on) { state.panelOpen = !!on; if (ui && ui.pop) ui.pop.classList.toggle('open', state.panelOpen); if (state.panelOpen) placePanel(true); syncToolbarBtn(); } /** 把浮层贴在尺子按钮正下方;越界就翻到上方、左右夹进视口 */ function placePanel(force) { if (!ui || !ui.pop || !state.panelOpen) return; const btn = ui.btn; if (!btn || !btn.isConnected) return; const br = btn.getBoundingClientRect(); const w = ui.pop.offsetWidth || 236; const h = ui.pop.offsetHeight || 190; let left = br.left + br.width / 2 - w / 2; left = Math.max(6, Math.min(window.innerWidth - w - 6, left)); let top = br.bottom + 6; if (top + h > window.innerHeight - 6) top = Math.max(6, br.top - h - 6); if (force || Math.abs(left - ui.lastL) > 0.5 || Math.abs(top - ui.lastT) > 0.5) { ui.lastL = left; ui.lastT = top; ui.pop.style.left = Math.round(left) + 'px'; ui.pop.style.top = Math.round(top) + 'px'; } } /** 主循环里 5Hz 跟一下按钮位置(工具栏按钮会随状态增减而左右移动) */ function placePanelTick() { if (!ui || !ui.pop || !state.panelOpen) return; const now = performance.now(); if (now - uiPlaceAt < PLACE_MS) return; uiPlaceAt = now; placePanel(false); } /** 换基色 —— 整把尺子的颜色(十字线 / 刻度 / 数字 / 网格)一起变 */ function setColor(hex) { const v = normHex(hex); if (!v) return; state.color = v; COLORS = buildColors(v); syncColorUI(); } /** 把当前颜色同步到调色盘的高亮圈与取色器 */ function syncColorUI() { if (!ui) return; const cur = normHex(state.color) || DEFAULT_COLOR; if (ui.picker) ui.picker.value = cur; if (ui.swBtns) { for (let i = 0; i < ui.swBtns.length; i++) { ui.swBtns[i].classList.toggle('on', normHex(SWATCHES[i]) === cur); } } } function unitOf(id) { for (let i = 0; i < UNITS.length; i++) { if (UNITS[i].id === id) return UNITS[i]; } return UNITS[0]; } /** * 换显示单位(米 / 厘米)。 * 世界坐标本身是米,所以厘米就是 scale = 100 —— 刻度数值、每格读数的换算 * 全部由 state.scale 承担(fmtTick / 角标),这里只改这两个字段。 */ function setUnit(id) { const u = unitOf(id); state.unit = u.id; state.scale = u.scale; syncUnitUI(); } function syncUnitUI() { if (!ui || !ui.unitBtns) return; for (let i = 0; i < ui.unitBtns.length; i++) { ui.unitBtns[i].classList.toggle('on', UNITS[i].id === state.unit); } } /** N 键开关;Esc 关浮层。故意不 preventDefault / stopPropagation,避免打断平台自己的快捷键 */ function onKeyDown(e) { if ((e.key || '') === 'Escape' && state.panelOpen) { setPanelOpen(false); return; } const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; if (e.ctrlKey || e.altKey || e.metaKey || e.repeat) return; if ((e.key || '').toLowerCase() !== 'n') return; setActive(!state.active); } /** 点到浮层和尺子按钮以外的地方就收起来 */ function onDocPointerDown(e) { if (!state.panelOpen) return; const t = e.target; if (ui && ui.host && (t === ui.host || ui.host.contains(t))) return; if (t && t.closest && t.closest('#' + BTN_ID)) return; // 尺子按钮自己交给它的 click 处理 if (ui && ui.btn && ui.btn.isConnected && ui.btn.contains(t)) return; setPanelOpen(false); } function onMouseMove(e) { state.mouse = { x: e.clientX, y: e.clientY }; } /* ======================= 面板(Shadow DOM) ======================= */ /* * 颜色一律走 CSS 变量:变量值由 applyPopTheme() 从平台自己的弹层上量出来, * 所以浮层在深色 / 浅色主题下都跟平台其它弹窗一致。括号里是量不到时的兜底。 */ const UI_CSS = ` :host { all: initial; } * { box-sizing: border-box; font-family: "PingFang SC","Microsoft YaHei",system-ui,sans-serif; } .pop { position: fixed; left: 0; top: 0; z-index: 2147483000; width: 238px; padding: 10px; display: none; background: var(--zd-bg, rgba(20,22,26,.96)); color: var(--zd-fg, #e8eaed); border: 1px solid var(--zd-line, rgba(255,255,255,.14)); border-radius: var(--zd-radius, 10px); box-shadow: var(--zd-shadow, 0 10px 30px rgba(0,0,0,.55)); font-size: var(--zd-font, 12px); } .pop.open { display: block; } .hd { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: var(--zd-dim, #c3c9d2); margin-bottom: 9px; } .hd .k { color: var(--zd-faint, #7f8792); font-size: 11px; } .row { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; } .row:last-child { margin-bottom: 0; } .lbl { font-size: 12px; color: var(--zd-dim, #9aa1ab); white-space: nowrap; } .chk { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--zd-fg, #c3c9d2); cursor: pointer; } .chk input { margin: 0; accent-color: var(--zd-accent, #4c8dff); cursor: pointer; } input[type=range] { flex: 1; min-width: 0; accent-color: var(--zd-accent, #4c8dff); cursor: pointer; } .num { font-variant-numeric: tabular-nums; font-size: 12px; color: var(--zd-fg, #e8eaed); min-width: 46px; text-align: right; } .hr { height: 1px; background: var(--zd-sep, rgba(255,255,255,.1)); margin: 9px 0; } .seg { display: inline-flex; flex: 1; overflow: hidden; border: 1px solid var(--zd-hover, rgba(255,255,255,.28)); border-radius: 4px; } .seg .sg { flex: 1; padding: 3px 0; font-size: 12px; text-align: center; cursor: pointer; background: transparent; color: var(--zd-dim, #9aa1ab); border: 0; transition: background-color .15s, color .15s; } .seg .sg + .sg { border-left: 1px solid var(--zd-hover, rgba(255,255,255,.28)); } .seg .sg:hover { color: var(--zd-fg, #e8eaed); } .seg .sg.on { background: var(--zd-accent, #4c8dff); color: #fff; } .sw { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; flex: 1; } .sw .sc { width: 18px; height: 18px; padding: 0; border-radius: 4px; cursor: pointer; border: 1px solid var(--zd-hover, rgba(255,255,255,.28)); outline: 2px solid transparent; outline-offset: 1px; } .sw .sc.on { outline-color: var(--zd-accent, #4c8dff); } .sw .pick { width: 28px; height: 20px; padding: 0; background: transparent; cursor: pointer; border: 1px solid var(--zd-hover, rgba(255,255,255,.28)); border-radius: 4px; } .sw .reset { margin-left: auto; padding: 0; width: 22px; height: 22px; cursor: pointer; background: transparent; color: var(--zd-dim, #c3c9d2); border: 1px solid var(--zd-hover, rgba(255,255,255,.28)); border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; } .sw .reset:hover { color: var(--zd-fg, #fff); border-color: var(--zd-accent, #4c8dff); } .sw .reset svg { display: block; } .hint { font-size: 11px; color: var(--zd-faint, #7f8792); line-height: 1.6; margin-top: 9px; } kbd { background: transparent; color: inherit; font: inherit; border: 1px solid var(--zd-hover, rgba(255,255,255,.28)); border-radius: 3px; padding: 0 4px; } `; function buildUI() { let host = document.getElementById(HOST_ID); if (host && host.__zdBuilt) { ui = host.__zdRefs; return; } if (host) host.remove(); host = document.createElement('div'); host.id = HOST_ID; host.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483000;'; (document.body || document.documentElement).appendChild(host); const shadow = host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = UI_CSS; shadow.appendChild(style); const pop = document.createElement('div'); pop.className = 'pop'; shadow.appendChild(pop); // 浮层内部的按下不要冒到 document,否则会被「点外面就关」的逻辑判成外部点击 pop.addEventListener('pointerdown', function (e) { e.stopPropagation(); }); const hd = document.createElement('div'); hd.className = 'hd'; const hdT = document.createElement('span'); hdT.textContent = '📐 标尺'; const hdK = document.createElement('span'); hdK.className = 'k'; hdK.innerHTML = 'N 开关'; hd.appendChild(hdT); hd.appendChild(hdK); pop.appendChild(hd); // ── 启用标尺(总开关:让鼠标也能开合,不必记快捷键)────── const rAct = document.createElement('div'); rAct.className = 'row'; const cbActive = document.createElement('input'); cbActive.type = 'checkbox'; cbActive.id = 'zd-ruler-active'; cbActive.checked = state.active; cbActive.addEventListener('change', function () { setActive(cbActive.checked); }); const lbAct = document.createElement('label'); lbAct.className = 'chk'; lbAct.htmlFor = 'zd-ruler-active'; const spAct = document.createElement('span'); spAct.textContent = '启用标尺'; lbAct.appendChild(cbActive); lbAct.appendChild(spAct); rAct.appendChild(lbAct); pop.appendChild(rAct); // ── 显示开关 ──────────────────────────────────────────── const mkChk = function (label, key) { const box = document.createElement('label'); box.className = 'chk'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!state[key]; cb.addEventListener('change', function () { state[key] = cb.checked; }); const sp = document.createElement('span'); sp.textContent = label; box.appendChild(cb); box.appendChild(sp); return box; }; const r4 = document.createElement('div'); r4.className = 'row'; r4.appendChild(mkChk('十字线', 'crosshair')); r4.appendChild(mkChk('延伸网格', 'grid')); pop.appendChild(r4); const hr1 = document.createElement('div'); hr1.className = 'hr'; pop.appendChild(hr1); // ── 单位:米 / 厘米(点云本来就是米制,只换显示换算) ──────── const rU = document.createElement('div'); rU.className = 'row'; const lU = document.createElement('span'); lU.className = 'lbl'; lU.textContent = '单位'; const seg = document.createElement('div'); seg.className = 'seg'; const unitBtns = []; for (let i = 0; i < UNITS.length; i++) { (function (U) { const b = document.createElement('button'); b.type = 'button'; b.className = 'sg'; b.textContent = U.label; b.title = '刻度与读数按' + U.label + '显示'; b.addEventListener('click', function () { setUnit(U.id); }); seg.appendChild(b); unitBtns.push(b); })(UNITS[i]); } rU.appendChild(lU); rU.appendChild(seg); pop.appendChild(rU); // ── 刻度间距 ──────────────────────────────────────────── const r3 = document.createElement('div'); r3.className = 'row'; const l3 = document.createElement('span'); l3.className = 'lbl'; l3.textContent = '刻度间距'; const densIn = document.createElement('input'); densIn.type = 'range'; densIn.min = '40'; densIn.max = '220'; densIn.step = '5'; densIn.value = String(state.targetPx); const densV = document.createElement('span'); densV.className = 'num'; densV.textContent = state.targetPx + ' px'; densIn.addEventListener('input', function () { const val = parseInt(densIn.value, 10); if (isFinite(val)) { state.targetPx = val; densV.textContent = val + ' px'; } }); r3.appendChild(l3); r3.appendChild(densIn); r3.appendChild(densV); pop.appendChild(r3); const hr2 = document.createElement('div'); hr2.className = 'hr'; pop.appendChild(hr2); // ── 颜色:调色盘 + 自由取色 + 一键回白 ────────────────── const r5 = document.createElement('div'); r5.className = 'row'; const l5 = document.createElement('span'); l5.className = 'lbl'; l5.textContent = '颜色'; const sw = document.createElement('div'); sw.className = 'sw'; const swBtns = []; const mkSwatch = function (hex, first) { const b = document.createElement('button'); b.type = 'button'; b.className = 'sc'; b.style.background = hex; b.title = hex + (first ? '(默认白色)' : ''); b.addEventListener('click', function () { setColor(hex); }); sw.appendChild(b); swBtns.push(b); }; for (let i = 0; i < SWATCHES.length; i++) mkSwatch(SWATCHES[i], i === 0); const picker = document.createElement('input'); picker.type = 'color'; picker.className = 'pick'; picker.value = state.color; picker.title = '自由取色'; picker.addEventListener('input', function () { setColor(picker.value); }); sw.appendChild(picker); // 复位按钮:只放一个「回退箭头」图标,跟平台那排图标按钮一个风格 const btnReset = document.createElement('button'); btnReset.type = 'button'; btnReset.className = 'reset'; btnReset.title = '重置为白色'; btnReset.setAttribute('aria-label', '重置为白色'); btnReset.innerHTML = '' + '' + ''; btnReset.addEventListener('click', function () { setColor(DEFAULT_COLOR); }); sw.appendChild(btnReset); r5.appendChild(l5); r5.appendChild(sw); pop.appendChild(r5); const hint = document.createElement('div'); hint.className = 'hint'; hint.innerHTML = '刻度画在十字线上,中心就是鼠标位置,读数即「离鼠标多远」。'; pop.appendChild(hint); ui = { host: host, pop: pop, btn: null, selCls: '', cbActive: cbActive, swBtns: swBtns, picker: picker, unitBtns: unitBtns, lastL: NaN, lastT: NaN }; host.__zdBuilt = true; host.__zdRefs = ui; applyPopTheme(); // 先量平台弹层的配色,再套到浮层上 syncColorUI(); syncUnitUI(); } /* ======================= 平台工具栏按钮 ======================= */ /* * 目标:标尺不是「浮在页面上的第二套 UI」,而是顶部工具栏(`#top`)里的一颗原生按钮, * 插在右侧设置栏「点云」那颗按钮的左边。 * * 做法:不 import Vue、不猜平台的 CSS,而是**克隆工具栏里已有的那颗按钮节点**再换图标。 * 平台的 class、data-v-* scoped 属性、:hover / 选中态样式于是全部原样继承。 * cloneNode 不复制事件监听 → 克隆体天然是惰性的,平台自己的 btnClick 不会触发, * 我们可以安全地挂自己的开关逻辑。 * * 平台按钮组件(index.98ddcd26.js:1820351): * __name:"ysButton",props { img, title, bindData, keycode, theme, width, height, ... }, * theme `nor` → class `button-theme-nor`,选中态加 `-se`;图标 class `top-button-icon`。 * img 走动态 import `../assets/img/.svg`,所以图标是内联 ,可以直接替换。 */ const BTN_ID = '__zd_ruler_btn'; // 尺子图标:斜置尺身 + 刻度齿。18×18 坐标空间,实心 #C2C2C2 —— // 和平台设置栏那排图标(assets/img/*.svg,颜色是写死在 svg 文件里的 #C2C2C2)同款配色。 const RULER_ICON = '' + '' + ''; /** * 顶部工具栏容器。平台的界面骨架(index.98ddcd26.js:2319351 `home`)是: * ElContainer#home → ElHeader#header →(唯一的子组件就是 `top`) * → ElContainer[ #left-side / #main / #right-side ] → #footer * 而 `top` 组件(index.98ddcd26.js:2316960)的根节点就是: * const _hoisted_1$a={id:"top",class:"flex-row"}, _sfc_main$c={__name:"top",...} * 它的子节点顺序是: * 标签类型切换组(ElButtonGroup.top-label) → topMark(标绘工具) → topSegmentation * → topClass → 「显示距离」 → 「无需标注」 → topSegSettings → topSettings → topStatus * 其中真正的「一排按钮」都是里面的 ElButtonGroup.top-item。 */ const BAR_SELECTORS = [ '#top', // 标注界面顶部那条工具栏(首选) '.camera-top-buttons', // 3D 相机浮窗顶栏(兜底) '#right-tools', '.frame-toolbar-left', '.header-buttons' ]; function pickToolbar() { for (let i = 0; i < BAR_SELECTORS.length; i++) { const el = document.querySelector(BAR_SELECTORS[i]); if (el && el.isConnected && el.querySelector('button')) return el; } return null; } /** * 注入锚点:`#top` 最右侧那一排设置按钮里的「点云(点大小)」那颗,尺子贴在它左边。 * * 这一排来自 `topSettings` 组件(index.98ddcd26.js:2303301): * createVNode(ElButtonGroup, { class:"top-item" }, { default: () => * renderList(Ji, Bi => ysButton({ class: "top-settings-item-" + Bi, img, bindData, * onBtnClick, title, keycode, disabled })) }) * 也就是 `#top` 里最后一个 `ElButtonGroup.top-item`, * `Ji` 的成员见 index.98ddcd26.js:2281978:attrs / color / pointSize / shortcuts / * markShow / virtual / slice / more / markCompare。 */ const ANCHOR_SELECTORS = [ '.top-settings-item-pointSize', '.top-settings-item-attrs', '[class*="top-settings-item-"]' ]; function pickAnchor(bar) { for (let i = 0; i < ANCHOR_SELECTORS.length; i++) { const el = bar.querySelector(ANCHOR_SELECTORS[i]); if (el && el.isConnected) return el; } return null; } /** 选中态 class 跟着模板按钮的 theme 走(nor / top / camera) */ function selectedClassOf(btn) { const m = /button-theme-(nor|top|camera|ori)/.exec(' ' + btn.className + ' '); return m ? 'button-theme-' + m[1] + '-se' : 'is-active'; } function syncToolbarBtn() { if (!ui || !ui.btn || !ui.btn.isConnected) return; const on = !!state.active; if (ui.selCls) ui.btn.classList.toggle(ui.selCls, on); const ic = ui.btn.querySelector('svg'); if (ic) ic.classList.toggle('top-button-icon-se', on); } function injectToolbarBtn() { if (!ui) return false; // 已经注入且还在文档里 → 只同步状态 const mine = document.getElementById(BTN_ID); if (mine && mine.isConnected) { ui.btn = (mine.tagName === 'BUTTON') ? mine : (mine.querySelector('button') || mine); syncToolbarBtn(); if (state.panelOpen) placePanel(true); return true; } const bar = pickToolbar(); if (!bar) return false; // 优先方案:贴在设置栏「点云」那颗按钮的左边 const anchor = pickAnchor(bar); let node, btn; if (anchor) { // 直接克隆锚点自己 —— 同一组、同样式、同一套 inline 尺寸 try { node = anchor.cloneNode(true); } catch (_) { return false; } btn = (node.tagName === 'BUTTON') ? node : node.querySelector('button'); } else { // 兜底方案:借工具栏里的一颗工具按钮当模板,插到最左侧 const tplBtn = bar.querySelector('.top-item button') || bar.querySelector('.el-button-group button') || bar.querySelector('button'); if (!tplBtn) return false; const pe = tplBtn.parentElement; const tplNode = (pe && pe !== bar && pe.querySelectorAll('button').length === 1) ? pe : tplBtn; try { node = tplNode.cloneNode(true); } catch (_) { return false; } btn = (node.tagName === 'BUTTON') ? node : node.querySelector('button'); } if (!btn) return false; // 抹掉模板带过来的禁用 / 隐藏 / 特殊底色 node.id = BTN_ID; node.removeAttribute('disabled'); btn.removeAttribute('disabled'); btn.classList.remove('is-disabled', 'is-loading'); btn.style.display = 'inline-flex'; btn.style.backgroundColor = ''; // 关键:把克隆体上带过来的 `top-settings-item-*` 摘掉。 // 平台在 index.98ddcd26.js:2281978 用 $(".top-settings-item-pointSize") 这类选择器 // 取「第一个匹配」来给设置弹层定位(er() 里读 position() / width() / height()), // 我们的按钮要是也带这个 class 又排在前面,jQuery 就会抓错元素,那一排弹层会整体跑偏。 btn.className = btn.className.split(/\s+/).filter(function (c) { return c && c.indexOf('top-settings-item-') !== 0; }).join(' '); // 图标换成尺子:沿用模板 svg 的显示尺寸与 class,只把内容换成 18×18 的尺子 const oldSvg = btn.querySelector('svg'); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); svg.setAttribute('viewBox', '0 0 18 18'); svg.setAttribute('width', (oldSvg && oldSvg.getAttribute('width')) || '18'); svg.setAttribute('height', (oldSvg && oldSvg.getAttribute('height')) || '18'); if (oldSvg && oldSvg.getAttribute('class')) svg.setAttribute('class', oldSvg.getAttribute('class')); svg.innerHTML = RULER_ICON; if (oldSvg) oldSvg.replaceWith(svg); else btn.appendChild(svg); btn.removeAttribute('aria-describedby'); // 别继承模板的 tooltip 关联 btn.setAttribute('title', '标尺 · 测距(N)'); btn.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); setPanelOpen(!state.panelOpen); // 点一下开设置浮层(标尺开关在浮层里 / N 键) }); ui.btn = btn; ui.selCls = selectedClassOf(btn); if (anchor && anchor.parentNode) { anchor.parentNode.insertBefore(node, anchor); // 贴在「点云」那颗的左边 // 这一排按钮正是那批设置弹层的定位基准(er() 按按钮矩形给 #top-*-copy 定 top/left), // 插进去会把后面每颗按钮顶开,而平台只在 onMounted 与 window resize 时重算 // (index.98ddcd26.js 的 onMounted(()=>{Or(), window.addEventListener("resize",()=>Or()), ...})), // 所以这里手动补一次 resize,让那些弹层重新对齐。 try { window.dispatchEvent(new Event('resize')); } catch (_) {} } else { bar.insertBefore(node, bar.firstChild); // 兜底:放在最左侧 } syncToolbarBtn(); if (state.panelOpen) placePanel(true); log('标尺按钮已注入工具栏:', anchor ? anchor.parentNode : bar, '选中态 class =', ui.selCls); return true; } /* ======================= 引导 ======================= */ function tryInit() { const ctx = getCtx(); if (!ctx) return false; ensureOverlay(ctx); if (!ui) buildUI(); injectToolbarBtn(); log('已就绪:', ctx.cam, '—— 点工具栏那颗尺子按钮开设置,N 键开关标尺'); return true; } function boot() { if (PAGE.__zdRulerBooted) return; PAGE.__zdRulerBooted = true; document.addEventListener('keydown', onKeyDown, true); document.addEventListener('pointerdown', onDocPointerDown, true); window.addEventListener('mousemove', onMouseMove, { passive: true }); // 视口一动就重新贴一次(工具栏本身也会随平台状态增删按钮而左右移动) window.addEventListener('resize', function () { if (state.panelOpen) placePanel(true); }); window.addEventListener('scroll', function () { if (state.panelOpen) placePanel(true); }, true); let tries = 0; const timer = setInterval(function () { tries++; if (tryInit() || tries > 600) { if (tries > 600) warn('等待 window.viewer 超时'); clearInterval(timer); } }, 100); // 容器 / 视图 / 工具栏被重建时重新挂载(防抖) let moTimer = null; const mo = new MutationObserver(function () { if (moTimer) return; moTimer = setTimeout(function () { moTimer = null; injectToolbarBtn(); // Vue 重渲染会冲掉我们的按钮 const cont = document.querySelector('#canvas-container'); if (!cont) return; if (boundCont !== cont || !overlay || overlay.parentElement !== cont) tryInit(); }, 200); }); mo.observe(document.documentElement, { childList: true, subtree: true }); requestAnimationFrame(loop); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();