// ==UserScript== // @name 点云标注 · 相机图「缩放保持」 // @namespace https://dsh.local/pc-camera-delta-keep // @version 1.0.0 // @description 给 3D 点云标注页的相机 2D 图加上「缩放保持」:滚轮缩放 / 右键平移后记住每路相机的视角,换帧、切全屏、刷新页面后自动还原。不改动原页面任何代码。 // @match *://*/pointcloud/* // @match *://*/pointcloud/ // @match *://*/pointcloud // @run-at document-start // @grant none // @license MIT // ==/UserScript== /* * 原理(原页面是编译后的 Vite bundle,CameraView / DragImg 都藏在闭包里拿不到, * 所以这里从「外部可观测点」入手,完全不碰它的源码): * * 1) 观测:hook CanvasRenderingContext2D.prototype.drawImage。 * 相机底图绘制固定是 5 参 drawImage(img, x, y, w, h) 画在 canvas.camera-canvas 上, * 于是每次重绘都能拿到这块画布当前的 {x, y, w, h} 与画布尺寸 —— 相当于拿到了 DragImg 的几何状态。 * * 2) 记录:滚轮 / 右键拖拽(原页面自己已有的交互)发生时,把几何归一化成 * { scale = w / canvas.width, cx = (cw/2 - x)/w, cy = (ch/2 - y)/h } 存进 localStorage。 * * 3) 还原:原页面缩放是纯算术 —— resize(mouse, delta) 里 r = 1 + delta/15, * x' = mx - (mx - x) * r,没有任何钳制。所以只要合成一次 mousewheel 事件、 * 把 wheelDelta 设成 1800 * (r - 1)、把锚点解成 mx = (xt - x*r)/(1 - r), * 就能一步从当前几何精确跳到目标几何(标注/投影会跟着它自己的 resize 一起走)。 * 换帧后 DragImg 是新建的、几何回到 cover 适配,正好触发还原。 * * 不支持:同一 canvas 上多张图叠加、真的需要连比例都被外部改写的情况。 */ (function () { 'use strict'; // __PC_DK_TEST__ 只给自动化测试用:在没有 /pointcloud/ 路径的空白页里手工加载脚本 var FORCED = !!window.__PC_DK_TEST__; if (!FORCED && !/\/pointcloud(\/|$)/i.test(location.pathname)) return; if (!FORCED && window.top !== window.self) return; if (window.__PC_DK_LOADED__) return; window.__PC_DK_LOADED__ = true; var log = function () { if (!window.__PC_DK_DEBUG__) return; console.log.apply(console, ['[PC-DK]'].concat([].slice.call(arguments))); }; var LS_ON = 'pc_delta_keep_on'; var LS_DATA = 'pc_delta_keep_view'; var MAX_TASKS = 30; var EPS_W = 0.6; // 宽度容差(像素) var EPS_X = 1.2; // 位置容差(像素) /* ------------------------------------------------------------------ 存储 */ var taskKey = (function () { var p = new URLSearchParams(location.search); return p.get('id') || p.get('task_id') || location.pathname; })(); function readJSON(k, def) { try { var s = localStorage.getItem(k); if (!s) return def; var v = JSON.parse(s); return (v && typeof v === 'object') ? v : def; } catch (e) { return def; } } function writeJSON(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { log('store failed', e); } } var store = { // 默认开启;用户关掉过就记住 on: localStorage.getItem(LS_ON) !== '0', views: readJSON(LS_DATA, {}), setOn: function (v) { this.on = !!v; try { localStorage.setItem(LS_ON, this.on ? '1' : '0'); } catch (e) {} }, task: function () { var t = this.views[taskKey]; if (!t || typeof t !== 'object') { t = { cameras: {} }; this.views[taskKey] = t; } if (!t.cameras || typeof t.cameras !== 'object') t.cameras = {}; return t; }, get: function (cam) { var v = this.task().cameras[cam]; return (v && isFinite(v.scale) && v.scale > 0 && isFinite(v.cx) && isFinite(v.cy)) ? v : null; }, set: function (cam, v) { this.task().cameras[cam] = v; var keys = Object.keys(this.views); if (keys.length > MAX_TASKS) { for (var i = 0; i < keys.length - MAX_TASKS; i++) delete this.views[keys[i]]; } writeJSON(LS_DATA, this.views); }, clearTask: function () { delete this.views[taskKey]; writeJSON(LS_DATA, this.views); } }; /* ------------------------------------------------------- 每块相机画布的状态 */ var cams = new Map(); // canvas -> state function camState(canvas) { var st = cams.get(canvas); if (!st) { st = { canvas: canvas, key: canvas.getAttribute('data-value') || canvas.id || ('cam' + cams.size), last: null, // 最近一次绘制出来的几何 needRestore: false, tries: 0, until: 0 // 用户交互窗口的截止时间 }; cams.set(canvas, st); log('new camera canvas', st.key); } return st; } /* --------------------------------------------------------------- 观测重绘 */ var proto = CanvasRenderingContext2D.prototype; var origDrawImage = proto.drawImage; proto.drawImage = function (img) { if (arguments.length === 5 && this.canvas && this.canvas.classList && this.canvas.classList.contains('camera-canvas')) { try { onPaint(this.canvas, img, arguments[1], arguments[2], arguments[3], arguments[4]); } catch (e) { log('onPaint error', e); } } return origDrawImage.apply(this, arguments); }; // 默认 cover 适配后应该出现的几何(对应 DragImg._fitCanvas) function defaultFit(cw, ch, iw, ih) { var ratio = (iw / ih > cw / ch) ? (iw / cw) : (ih / ch); var w = iw / ratio, h = ih / ratio; return { x: (cw - w) / 2, y: (ch - h) / 2, w: w, h: h }; } function near(a, b, eps) { return Math.abs(a - b) <= eps; } function onPaint(canvas, img, x, y, w, h) { if (!(img instanceof HTMLImageElement)) return; if (!(w > 0) || !(h > 0)) return; var st = camState(canvas); var cw = canvas.width, ch = canvas.height; var src = img.currentSrc || img.src || ''; var iw = img.naturalWidth || img.width; var ih = img.naturalHeight || img.height; if (!cw || !ch || !iw || !ih) return; var prev = st.last; var isNewImage = !prev || prev.img !== img || prev.src !== src || prev.cw !== cw || prev.ch !== ch; st.last = { img: img, src: src, x: x, y: y, w: w, h: h, cw: cw, ch: ch, iw: iw, ih: ih }; // 用户输入引起的重绘是同步发生的(同一个 task),换帧/重建引起的重绘晚于一个 task。 // 所以用「几十毫秒内」判断,而不是宽松的时间窗 —— 否则刚缩放完紧接着换帧会把默认几何存进去。 var userActive = performance.now() < st.until; if (isNewImage) { // 换帧 / 换相机 / 改尺寸 / 首次加载:原页面必然刚重建过 DragImg,几何回到 cover 适配。 // 这种绘制绝不可能是用户调出来的,所以不参与记录,只准备还原。 st.needRestore = true; st.tries = 0; log('new image', st.key, 'active=' + userActive); } else if (!userActive && prev && store.get(st.key)) { // LRU 命中会复用同一个 Image 元素:靠「从非默认几何跳回默认几何」来识别重建 var df = defaultFit(cw, ch, iw, ih); var nowDefault = near(x, df.x, EPS_X) && near(y, df.y, EPS_X) && near(w, df.w, EPS_W); var prevDefault = near(prev.x, df.x, EPS_X) && near(prev.y, df.y, EPS_X) && near(prev.w, df.w, EPS_W); if (nowDefault && !prevDefault) { st.needRestore = true; st.tries = 0; log('reset detected', st.key); } } if (!store.on) { st.needRestore = false; return; } if (userActive && !isNewImage) { saveView(st); st.needRestore = false; } else if (st.needRestore) { queueMicrotask(function () { tryRestore(st); }); } } /* ------------------------------------------------------------- 记录 / 还原 */ function saveView(st) { var l = st.last; if (!l) return; var scale = l.w / l.cw; var cx = (l.cw / 2 - l.x) / l.w; var cy = (l.ch / 2 - l.y) / l.h; if (!isFinite(scale) || !isFinite(cx) || !isFinite(cy) || scale <= 0) return; var old = store.get(st.key); if (old && near(old.scale, scale, 1e-5) && near(old.cx, cx, 1e-5) && near(old.cy, cy, 1e-5)) return; store.set(st.key, { scale: +scale.toFixed(6), cx: +cx.toFixed(6), cy: +cy.toFixed(6) }); log('saved', st.key, scale.toFixed(4), cx.toFixed(4), cy.toFixed(4)); } function targetGeom(st) { var l = st.last, t = store.get(st.key); if (!l || !t) return null; var wt = t.scale * l.cw; var ht = wt * (l.ih / l.iw); return { w: wt, h: ht, x: l.cw / 2 - t.cx * wt, y: l.ch / 2 - t.cy * ht }; } function tryRestore(st) { if (!store.on || !st.needRestore) return; var l = st.last; var g = targetGeom(st); if (!l || !g) { st.needRestore = false; return; } if (near(l.w, g.w, EPS_W) && near(l.x, g.x, EPS_X) && near(l.y, g.y, EPS_X)) { st.needRestore = false; log('restored', st.key, g.w.toFixed(2), g.x.toFixed(2), g.y.toFixed(2)); return; } if (st.tries++ > 10) { st.needRestore = false; log('give up', st.key, 'w=' + l.w + ' want=' + g.w); return; } var r = g.w / l.w; if (Math.abs(r - 1) > 1e-4) { // 一次锚定缩放同时把位置和比例都摆对 var mx = (g.x - l.x * r) / (1 - r); var my = (g.y - l.y * r) / (1 - r); fireWheel(canvasOf(st), mx, my, 1800 * (r - 1)); } else { // 比例没变、只有位置要挪:两次近似抵消的缩放把平移量做出来 var ra = 1.02; fireWheel(canvasOf(st), l.cw / 2, l.ch / 2, 1800 * (ra - 1)); var l2 = st.last; var rb = 1 / ra; var mx2 = (g.x - l2.x * rb) / (1 - rb); var my2 = (g.y - l2.y * rb) / (1 - rb); fireWheel(canvasOf(st), mx2, my2, 1800 * (rb - 1)); } // 绘制是同步的,但图片没 load 完时原页面会直接 return,稍后再看一次 setTimeout(function () { if (st.needRestore) tryRestore(st); }, 40); } function canvasOf(st) { return st.canvas; } // 合成一次「用户滚轮」:原页面的 handler 只读 wheelDelta 和 clientX/clientY function fireWheel(canvas, mx, my, wheelDelta) { var rect = canvas.getBoundingClientRect(); var ev; try { ev = new MouseEvent('mousewheel', { clientX: rect.left + mx, clientY: rect.top + my, bubbles: false, cancelable: true, view: window }); } catch (e) { ev = document.createEvent('MouseEvents'); ev.initMouseEvent('mousewheel', false, true, window, 0, 0, 0, rect.left + mx, rect.top + my, false, false, false, false, 0, null); } try { Object.defineProperty(ev, 'wheelDelta', { value: wheelDelta, configurable: true }); } catch (e) {} try { Object.defineProperty(ev, '__pcSynth', { value: true, configurable: true }); } catch (e) { ev.__pcSynth = true; } try { canvas.dispatchEvent(ev); } catch (e) { log('dispatch failed', e); } } /* --------------------------------------------------------------- 交互跟踪 */ function markUser(el) { var c = el; while (c && !(c.classList && c.classList.contains('camera-canvas'))) c = c.parentElement; if (!c) return; var st = camState(c); st.until = performance.now() + 40; // 只覆盖「同步重绘」,见 onPaint } ['mousewheel', 'mousedown', 'mousemove', 'mouseup', 'wheel'].forEach(function (type) { document.addEventListener(type, function (e) { if (e.__pcSynth) return; markUser(e.target); }, true); }); /* ------------------------------------------------------------------- UI */ var ICON = ''; var btn = null; var styleDone = false; function injectStyle() { if (styleDone) return; var head = document.head || document.documentElement; if (!head) return; styleDone = true; var s = document.createElement('style'); s.textContent = [ '.pc-dk-btn{width:22px;height:22px;padding:0;margin:0 2px;border:0;border-radius:3px;', 'background:transparent;color:#b7b7b7;display:inline-flex;align-items:center;justify-content:center;', 'cursor:pointer;flex:0 0 auto;line-height:0;vertical-align:middle;transition:background .15s,color .15s}', '.pc-dk-btn:hover{background:rgba(255,255,255,.14);color:#fff}', '.pc-dk-btn.is-on{color:#4ea1ff;background:rgba(78,161,255,.18)}', '.pc-dk-btn.is-on:hover{background:rgba(78,161,255,.3)}', '.pc-dk-toast{position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:2147483000;', 'background:rgba(18,18,18,.92);color:#eee;font:12px/1.5 "Microsoft YaHei",system-ui,sans-serif;', 'padding:6px 14px;border-radius:4px;pointer-events:none;opacity:1;transition:opacity .35s}' ].join(''); head.appendChild(s); } function toast(text) { try { injectStyle(); var d = document.createElement('div'); d.className = 'pc-dk-toast'; d.textContent = text; document.body.appendChild(d); setTimeout(function () { d.style.opacity = '0'; }, 900); setTimeout(function () { if (d.parentNode) d.parentNode.removeChild(d); }, 1400); } catch (e) {} } function paintBtn() { if (!btn) return; btn.classList.toggle('is-on', store.on); btn.setAttribute('aria-pressed', store.on ? 'true' : 'false'); btn.title = '缩放保持:' + (store.on ? '已开启' : '已关闭') + '\n' + '记住每路相机图的缩放与平移,换帧 / 切全屏 / 刷新后自动还原\n' + '左键开关 · 右键清除本题记录'; } function toggle() { store.setOn(!store.on); if (store.on) { cams.forEach(function (st) { if (st.last) saveView(st); }); toast('缩放保持:已开启'); } else { cams.forEach(function (st) { st.needRestore = false; }); toast('缩放保持:已关闭'); } paintBtn(); } function ensureUI() { injectStyle(); if (btn && document.body && document.body.contains(btn)) { paintBtn(); return true; } if (!btn) { btn = document.createElement('button'); btn.type = 'button'; btn.className = 'pc-dk-btn'; btn.innerHTML = ICON; btn.addEventListener('mousedown', function (e) { e.stopPropagation(); }); btn.addEventListener('click', function (e) { e.stopPropagation(); e.preventDefault(); toggle(); }); btn.addEventListener('contextmenu', function (e) { e.preventDefault(); e.stopPropagation(); store.clearTask(); toast('已清除本题的缩放记录'); }); } var host = document.querySelector('.camera-top-buttons') || document.querySelector('#camera-top') || document.querySelector('#camera'); if (!host) return false; host.appendChild(btn); paintBtn(); return true; } // 相机面板是 Vue 渲染的,重渲染可能把节点摘掉 → 观察着补回来 var lastSync = 0; function sync() { var now = Date.now(); if (now - lastSync < 200) return; lastSync = now; if (!btn || !document.body || !document.body.contains(btn)) ensureUI(); } function boot() { injectStyle(); if (!ensureUI()) { var timer = setInterval(function () { if (ensureUI()) clearInterval(timer); }, 400); setTimeout(function () { clearInterval(timer); }, 20000); } var mo = new MutationObserver(sync); mo.observe(document.documentElement, { childList: true, subtree: true }); // 兜底:Ctrl+Alt+K window.addEventListener('keydown', function (e) { if (e.ctrlKey && e.altKey && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); toggle(); } }, true); log('booted, task=' + taskKey + ', on=' + store.on); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();