// ==UserScript== // @name 3D标注框 Rotation 归零 // @namespace local.annotation.rotation-flat // @version 1.3.0 // @description 标注时实时按住立体框倾角;提交/复制时自动修正微小倾角;并在“显示距离”左侧提供全包角度归零按钮。 // @match http://*/* // @match https://*/* // @run-at document-start // @grant unsafeWindow // ==/UserScript== // 本文件由 dev/make-userscript.py 从 rotation-flat-extension/content.js 生成,请勿手工编辑; // 改逻辑请改 content.js 后重新生成,两边的自动修正/实时修复/按钮行为保持一致。 // 与扩展的唯一差异:这里的 window 是油猴沙箱,页面 window 需要走 unsafeWindow。 (function () { 'use strict'; // 扩展版以 world:"MAIN" 运行在页面主世界,window 即页面 window; // 油猴脚本运行在沙箱里,必须取 unsafeWindow 才能访问 window.instance 与应用实例。 const pageWindow = typeof unsafeWindow === 'undefined' ? window : unsafeWindow; const SNAP_EPSILON = 0.001; const BUTTON_ID = 'rotation-flat-package-button'; const INSTALL_FLAG = Symbol.for('scriptcat.rotationFlat.installed'); const ORIGINAL_COMMIT = Symbol.for('scriptcat.rotationFlat.originalCommit'); const DISTANCE_LABEL = '显示距离'; const FULL_SCAN_TYPES = new Set([ 'marks/update', 'marks/addVMarks', 'marks/virtual2Real', 'marks/copyAllMarks', 'marks/copyAllMarksWithMovement', 'marks/updateMarksWithMovement', 'marks/copySingleMark', 'marks/computeDifMarks' ]); // ---- 标注过程中的实时修复 ---- // 标注时正在编辑的那个框,pitch/roll(rotation[0]、rotation[1])会被每帧按住为 0: // 应用在拖动/提交时会把场景对象的 rotation 写回 mark(modifyMark3D),所以按住对象 // 就等于让应用自己写出干净数据,无需我们额外提交 mutation(不会污染撤销栈)。 const PITCH_ROLL_HANDLES = new Set(['rotation.x', 'rotation.y']); // 实时按住的角度上限(弧度)。Infinity = 任意角度都按住;改小(如 0.05)可变成只修微小倾角。 const LIVE_FLATTEN_LIMIT = Infinity; // 用户正在拖 x/y 旋转手柄时不动手,避免手柄被立刻清零而“拖不动”。 const LIVE_RESPECT_ROTATE_HANDLE = true; // 用户用 x/y 手柄刻意转出的倾角,对该框豁免后续自动修复(点“全包角度归零”可清除豁免)。 const LIVE_EXEMPT_AFTER_MANUAL_ROTATE = true; function isNearZero(value) { const number = Number(value); return Number.isFinite(number) && Math.abs(number) <= SNAP_EPSILON; } function flattenMark(mark) { if (!mark || mark.type !== 'volume' || !Array.isArray(mark.rotation)) return false; if (!isNearZero(mark.rotation[0]) || !isNearZero(mark.rotation[1])) return false; if (Object.is(mark.rotation[0], 0) && Object.is(mark.rotation[1], 0)) return false; mark.rotation[0] = 0; mark.rotation[1] = 0; return true; } function flattenPayload(type, payload) { if (!Array.isArray(payload)) return; if (type === 'marks/addOrReplaceMark3D') flattenMark(payload[1]); if (type === 'marks/addVMarks' && payload[0]) flattenMark(payload[0]['3d']); if (type === 'marks/virtual2Real' && payload[2]) flattenMark(payload[2]['3d']); } function eachPackageVolume(store, callback) { const tracks = store && store.state && store.state.marks && store.state.marks.marks; if (!tracks || typeof tracks !== 'object') return 0; let count = 0; for (const track of Object.values(tracks)) { const marks3d = track && track['3d']; if (!marks3d || typeof marks3d !== 'object') continue; for (const mark of Object.values(marks3d)) { if (!mark || mark.type !== 'volume' || !Array.isArray(mark.rotation)) continue; count += 1; callback(mark); } } return count; } function eachVirtualVolume(store, callback) { const virtualTracks = store && store.state && store.state.marks && store.state.marks.curVMarks; if (!virtualTracks || typeof virtualTracks !== 'object') return; for (const track of Object.values(virtualTracks)) { const mark = track && track['3d']; if (mark && mark.type === 'volume' && Array.isArray(mark.rotation)) callback(mark); } } function flattenStore(store) { const apply = function () { eachPackageVolume(store, flattenMark); eachVirtualVolume(store, flattenMark); }; if (typeof store._withCommit === 'function') store._withCommit(apply); else apply(); } function getViewer() { const instance = pageWindow.instance; // _viewer 是 Instance 上的私有字段;有的版本里它由 getter 懒创建,取不到时退回 getter。 return (instance && (instance._viewer || instance.viewer)) || null; } function updateRenderedVolumes(forcePackageReset) { const viewer = getViewer(); if (!viewer) return; let changed = false; for (const scene of [viewer.volumeScene, viewer.compareScene]) { if (!scene || typeof scene.traverse !== 'function') continue; scene.traverse(function (object) { if (!object || !object.mark || object.mark.type !== 'volume') return; if (forcePackageReset) { if (Array.isArray(object.mark.rotation)) { object.mark.rotation[0] = 0; object.mark.rotation[1] = 0; } } else { flattenMark(object.mark); } const rotation = object.rotation; const shouldResetObject = rotation && (forcePackageReset || (isNearZero(rotation.x) && isNearZero(rotation.y))); if (!shouldResetObject) return; if (Object.is(rotation.x, 0) && Object.is(rotation.y, 0)) return; rotation.x = 0; rotation.y = 0; if (typeof object.updateMatrix === 'function') object.updateMatrix(); if (typeof object.updateMatrixWorld === 'function') object.updateMatrixWorld(true); changed = true; }); } if (changed && typeof viewer.renderer === 'function') viewer.renderer(); } function resetWholePackage(store) { let changedCount = 0; let totalCount = 0; const apply = function () { totalCount = eachPackageVolume(store, function (mark) { if (!Object.is(mark.rotation[0], 0) || !Object.is(mark.rotation[1], 0)) { mark.rotation[0] = 0; mark.rotation[1] = 0; changedCount += 1; } }); eachVirtualVolume(store, function (mark) { mark.rotation[0] = 0; mark.rotation[1] = 0; }); }; if (typeof store._withCommit === 'function') store._withCommit(apply); else apply(); exemptTrackIds.clear(); // 显式全包归零后,恢复对所有框的实时修复 if (changedCount > 0) store.commit('marks/changeMarkVersion'); updateRenderedVolumes(true); return { changedCount, totalCount }; } function findStore() { const appElement = pageWindow.document.querySelector('#app'); const app = appElement && appElement.__vue_app__; return app && app.config && app.config.globalProperties ? app.config.globalProperties.$store : null; } /* ---------- 标注过程中的实时修复 ---------- */ const exemptTrackIds = new Set(); let activePitchRollHandle = null; let activePitchRollTrackId = null; function currentSelectedVolume(handler) { if (typeof handler.getSelectedVolume === 'function') { const selected = handler.getSelectedVolume(); if (selected) return selected; } const selection = handler.selection; if (Array.isArray(selection) && selection.length > 0) return selection[0]; return null; } function hasTilt(rotation, limit) { if (!Array.isArray(rotation)) return false; return Math.abs(rotation[0]) > limit || Math.abs(rotation[1]) > limit; } // 把当前选中框的 pitch/roll 按住为 0:同步改场景对象(视觉与后续写回)、mark(数据)。 function flattenSelectedVolume() { const viewer = getViewer(); const handler = viewer && viewer.inputHandler; if (!handler || !handler.selection) return; const object = currentSelectedVolume(handler); const trackId = object ? object.trackId : null; const activeName = handler.activeHandle ? handler.activeHandle.name : null; const rotatingPitchRoll = typeof activeName === 'string' && PITCH_ROLL_HANDLES.has(activeName); if (rotatingPitchRoll) { if (LIVE_RESPECT_ROTATE_HANDLE) { activePitchRollHandle = activeName; activePitchRollTrackId = trackId; return; } } else if (activePitchRollHandle) { // 刚结束一次 x/y 旋转拖动:只有真转出了倾角才豁免这个框, // 避免误碰手柄就让该框永久失去自动修复。 const rotatedMark = findMarkByTrackId(viewer, activePitchRollTrackId); if (LIVE_EXEMPT_AFTER_MANUAL_ROTATE && activePitchRollTrackId != null && hasTilt(rotatedMark && rotatedMark.rotation, SNAP_EPSILON)) { exemptTrackIds.add(activePitchRollTrackId); } activePitchRollHandle = null; activePitchRollTrackId = null; } if (!object || trackId == null || exemptTrackIds.has(trackId)) return; if (!object.mark || object.mark.type !== 'volume') return; // 手势进行中(画框、平移、缩放、旋转)不插手:应用的拖动计算每帧都在写这些值, // 此时介入只会互相打架;松手后下一帧立刻按平。 if (handler.moveStart || handler.dragging) return; let changed = false; const rotation = object.rotation; if (rotation && (rotation.x !== 0 || rotation.y !== 0)) { const objectTilt = Math.max(Math.abs(rotation.x), Math.abs(rotation.y)); if (objectTilt <= LIVE_FLATTEN_LIMIT) { rotation.x = 0; rotation.y = 0; if (typeof object.updateMatrix === 'function') object.updateMatrix(); if (typeof object.updateMatrixWorld === 'function') object.updateMatrixWorld(true); changed = true; } } const markRotation = object.mark.rotation; if (Array.isArray(markRotation) && (markRotation[0] !== 0 || markRotation[1] !== 0) && !hasTilt(markRotation, LIVE_FLATTEN_LIMIT)) { markRotation[0] = 0; markRotation[1] = 0; changed = true; } // 视图是按需渲染的,改完变换要主动触发一次,否则要等下一次交互才看得到。 if (changed && typeof viewer.renderer === 'function') viewer.renderer(); return changed; } function findMarkByTrackId(viewer, trackId) { if (typeof viewer.getVolume === 'function') { const volume = viewer.getVolume(trackId); if (volume && volume.mark) return volume.mark; } const children = viewer.volumeScene ? viewer.volumeScene.children : null; if (Array.isArray(children)) { for (const child of children) { if (child && child.trackId === trackId && child.mark) return child.mark; } } return null; } function findDistanceControl() { const topBar = pageWindow.document.querySelector('#top'); if (!topBar) return null; const candidates = topBar.querySelectorAll('label.el-checkbox, label, .el-checkbox'); for (const element of candidates) { if (element.textContent.trim() === DISTANCE_LABEL) return element; } // 兜底:不同版本的 Element Plus 可能把文字渲染在内层节点上。 for (const element of topBar.querySelectorAll('span, div')) { if (element.textContent.trim() !== DISTANCE_LABEL) continue; if (element.children.length > 0) continue; const host = element.closest('label') || element.parentElement; if (host) return host; } return null; } function createButton(store) { const button = pageWindow.document.createElement('button'); button.id = BUTTON_ID; button.type = 'button'; button.textContent = '全包角度归零'; button.title = '将全包所有3D立体框的 rotation[0]、rotation[1] 设为0,保留 rotation[2]'; button.setAttribute('aria-label', button.title); button.style.cssText = [ 'height:24px', 'margin:0 12px 0 0', 'padding:0 9px', 'color:#e5e7eb', 'background:#263244', 'border:1px solid #64748b', 'border-radius:3px', 'font:600 12px/22px "Microsoft YaHei",sans-serif', 'white-space:nowrap', 'cursor:pointer', 'vertical-align:middle' ].join(';'); button.addEventListener('mouseenter', function () { if (!button.disabled) button.style.background = '#334155'; }); button.addEventListener('mouseleave', function () { if (!button.disabled) button.style.background = '#263244'; }); button.addEventListener('focus', function () { button.style.outline = '2px solid #60a5fa'; button.style.outlineOffset = '2px'; }); button.addEventListener('blur', function () { button.style.outline = 'none'; }); button.addEventListener('click', function (event) { event.stopPropagation(); button.disabled = true; button.style.cursor = 'wait'; button.textContent = '处理中…'; try { const result = resetWholePackage(store); button.textContent = result.totalCount === 0 ? '没有立体框' : `已归零 ${result.changedCount} 框`; button.style.background = result.totalCount === 0 ? '#7c2d12' : '#166534'; } catch (error) { console.error('[rotation-flat] 全包角度归零失败', error); button.textContent = '处理失败'; button.style.background = '#991b1b'; } pageWindow.setTimeout(function () { button.disabled = false; button.style.cursor = 'pointer'; button.style.background = '#263244'; button.textContent = '全包角度归零'; }, 1800); }); return button; } function ensureButton(store) { if (pageWindow.document.getElementById(BUTTON_ID)) return true; const distanceControl = findDistanceControl(); if (!distanceControl || !distanceControl.parentNode) return false; distanceControl.parentNode.insertBefore(createButton(store), distanceControl); return true; } function install(store) { if (!store || store[INSTALL_FLAG]) return false; const originalCommit = store.commit; if (typeof originalCommit !== 'function') return false; Object.defineProperty(store, INSTALL_FLAG, { value: true }); Object.defineProperty(store, ORIGINAL_COMMIT, { value: originalCommit }); store.commit = function (type, payload, options) { flattenPayload(type, payload); const result = originalCommit.call(this, type, payload, options); if (FULL_SCAN_TYPES.has(type)) flattenStore(this); pageWindow.queueMicrotask(function () { updateRenderedVolumes(false); }); return result; }; flattenStore(store); updateRenderedVolumes(false); ensureButton(store); console.info('[rotation-flat] Rotation 归零工具已启用'); return true; } let store = null; let warned = false; let tick = 0; const installTimer = pageWindow.setInterval(function () { tick += 1; // 扩展匹配所有站点:页面加载完仍看不到应用迹象时降到约 2s 轮询一次, // 避免在无关网站上持续做无谓的检查(应用一出现即恢复 100ms 探测)。 const appPresent = pageWindow.instance || pageWindow.document.querySelector('#app'); if (!appPresent && pageWindow.document.readyState === 'complete' && tick % 20 !== 0) return; store = store || findStore(); if (store && !store[INSTALL_FLAG]) install(store); if (store) ensureButton(store); // 目标应用已经在跑却始终找不到 store,通常是主世界注入没生效,提示一次便于排查。 if (!store && !warned && pageWindow.instance) { warned = true; console.warn('[rotation-flat] 检测到应用实例但未找到 store;' + '请确认浏览器支持 content_scripts 的 world:"MAIN"(Chrome/Edge 111+、Firefox 128+)。'); } if (store && store[INSTALL_FLAG] && pageWindow.document.getElementById(BUTTON_ID)) { pageWindow.clearInterval(installTimer); } }, 100); // Vue 重绘顶部工具栏时自动补回按钮,并持续修正新生成的虚拟预测框。 pageWindow.setInterval(function () { store = store || findStore(); if (!store) return; ensureButton(store); updateRenderedVolumes(false); }, 500); // 标注过程中的实时修复:跟随绘制帧执行,拿不到 requestAnimationFrame 时退化为 100ms 轮询。 // 空闲时(没有选中框、倾角已是 0)几乎零开销。 if (typeof pageWindow.requestAnimationFrame === 'function') { const onFrame = function () { try { flattenSelectedVolume(); } catch (error) { console.warn('[rotation-flat] 实时归零异常', error); } pageWindow.requestAnimationFrame(onFrame); }; pageWindow.requestAnimationFrame(onFrame); } else { pageWindow.setInterval(function () { try { flattenSelectedVolume(); } catch (error) { console.warn('[rotation-flat] 实时归零异常', error); } }, 100); } })();