// ==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`,所以图标是内联