// ==UserScript== // @name 3D标注框 Rotation 归零 // @namespace local.annotation.rotation-flat // @version 1.2.0 // @description 修正跨帧立体框微小倾角,并在“显示距离”左侧提供全包角度归零按钮。 // @match http://*/* // @match https://*/* // @run-at document-start // @grant unsafeWindow // ==/UserScript== (function () { 'use strict'; 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 FULL_SCAN_TYPES = new Set([ 'marks/update', 'marks/addVMarks', 'marks/virtual2Real', 'marks/copyAllMarks', 'marks/copyAllMarksWithMovement', 'marks/updateMarksWithMovement', 'marks/copySingleMark', 'marks/computeDifMarks' ]); 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 updateRenderedVolumes(forcePackageReset) { const viewer = pageWindow.instance && pageWindow.instance._viewer; 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(); 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; } 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() === '显示距离') return element; } 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; const installTimer = pageWindow.setInterval(function () { store = store || findStore(); if (store && !store[INSTALL_FLAG]) install(store); if (store) ensureButton(store); if (store && store[INSTALL_FLAG] && pageWindow.document.getElementById(BUTTON_ID)) { pageWindow.clearInterval(installTimer); } }, 100); // Vue 重绘顶部工具栏时自动补回按钮,并持续修正新生成的虚拟预测框。 pageWindow.setInterval(function () { store = store || findStore(); if (store) ensureButton(store); updateRenderedVolumes(false); }, 500); })();