// ==UserScript== // @name 点云标注 - 线段一分为二(控制面板版) // @namespace dsh.pcd.linesplit // @version 3.2.0 // @description 把选中的 3D 线段一分为二(单键 M)。页面内可拖拽控制面板;自动适配脚本猫沙箱,并提供一键诊断 // @author you // @match *://*/* // @grant unsafeWindow // @grant GM_registerMenuCommand // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @run-at document-idle // @inject-into page // @noframes // ==/UserScript== /* * ───────────────────────────────────────────────────────────────────────────── * v3 关键修复:脚本猫沙箱下拿不到页面的 window * ScriptCat 默认 @inject-into page,此时 page 对象挂在 unsafeWindow 上; * 但如果是 content 模式,unsafeWindow 只等于内容脚本自己的 window, * 页面的 window.viewer 完全看不到 —— 表现就是面板一直显示"未找到 viewer / store"。 * 所以本版把"页面对象"的解析做成多路兜底: * 1) 补丁版产物挂出来的 window.__dshStore(最可靠) * 2) unsafeWindow.viewer / unsafeWindow.__dshStore(page 模式) * 3) 普通 window.viewer(@grant none / 原始模式) * 并在面板上加了「诊断」按钮,一眼看出当前是哪种模式、该改什么。 * * ── 依赖的宿主接口(均在 index.a103ccea.js 中核对过) ──────────────────────── * page.viewer viewer 构造里 window.viewer = this * .sseEditor.addMark(mark,trackId) 3D 标注入场景(线段走 PolyLine) * .sseEditor.lineScene.children 场景线段节点(带 .trackId/.mark) * .rendererMain() 重绘 * Vuex store * getters['marks/getMarks'](trackId,'3d',frameId) / 'marks/classId' / 'marks/avaliableTrackId' * commit('marks/addOrReplaceMark3D',[action,mark,trackId,classId,frameId]) * commit('marks/deleteMarks',[trackId,frameId,'3d']) * ───────────────────────────────────────────────────────────────────────────── */ (function () { 'use strict' const CONFIG = { // 单键 'm':已核对该产物里 keyCode==77 出现 0 次、keycodes 表里也没有 m, // 是唯一干净的空闲单键(n/h/w/j 都有别的用途)。不带修饰键。 KEY: 'm', NEED_CTRL: false, // 单键,不要求 Ctrl/⌘ HOTKEY_ENABLED: true, // 默认开启 MIN_POINTS: 2, // 给「拾取地面高度」加 Ctrl+Q 兜底(脚本级,不依赖产物补丁)。 // 如果产物已经打了补丁(补丁会挂出 window.__dshStore),脚本会自动不启用,避免一下点两次。 PICK_KEY: 'q', PICK_HOTKEY: true, } const MARK_ACTION_ADD = 'add' const TOOL_LINE_3D = 'line_3d' const LS_KEY = 'dsh_line_split_panel_v3' const isMac = /Mac/i.test((navigator && navigator.platform) || '') // 快捷键显示文案(单键时不带修饰键前缀) const HOTKEY_LABEL = CONFIG.NEED_CTRL ? (isMac ? '⌘+' : 'Ctrl+') + CONFIG.KEY.toUpperCase() : CONFIG.KEY.toUpperCase() // ── 页面对象解析(本版核心修复) ─────────────────────────────────────────── const ctx = { win: window, source: 'window', tried: [], sandboxed: false } function isPageLike(w) { if (!w) return false try { if (w.__dshStore) return true if (w.viewer && w.viewer.sseEditor) return true } catch (e) { /* 跨 realm 访问可能抛错 */ } return false } function resolveCtx() { const cands = [] // unsafeWindow 需要先声明 @grant unsafeWindow,这里用 typeof 兜底 let uw = null try { if (typeof unsafeWindow !== 'undefined') uw = unsafeWindow } catch (e) { uw = null } if (uw && uw !== window) cands.push({ w: uw, name: 'unsafeWindow' }) else if (uw) cands.push({ w: uw, name: 'unsafeWindow(同 window)' }) cands.push({ w: window, name: 'window' }) ctx.tried = [] for (const c of cands) { let ok = false, keys = null, err = null try { ok = isPageLike(c.w) keys = { viewer: !!c.w.viewer, __dshStore: !!c.w.__dshStore, vueApp: !!(document.querySelector('#app') && document.querySelector('#app').__vue_app__), } } catch (e) { err = e.message } ctx.tried.push({ name: c.name, ok: ok, keys: keys, err: err }) if (ok) { ctx.win = c.w ctx.source = c.name return c.w } } // 都没命中也先把 unsafeWindow 记下来,方便诊断显示 ctx.win = (uw || window) ctx.source = uw && uw !== window ? 'unsafeWindow(未就绪)' : 'window(未就绪)' return null } const getViewer = () => { try { return ctx.win.viewer || null } catch (e) { return null } } function getStore() { // 1) 补丁版产物挂出来的(最可靠) try { if (ctx.win.__dshStore && ctx.win.__dshStore.commit) return ctx.win.__dshStore } catch (e) { /* 忽略 */ } // 2) 从 Vue3 应用实例上取 const holders = ['#app', '#app > *', 'body > div', 'body'] for (const sel of holders) { let el = null try { el = document.querySelector(sel) } catch (e) { el = null } if (!el) continue let app = null try { app = el.__vue_app__ } catch (e) { app = null } if (app && app.config && app.config.globalProperties && app.config.globalProperties.$store) { return app.config.globalProperties.$store } let comp = null try { comp = el.__vueParentComponent } catch (e) { comp = null } let c = comp while (c) { if (c.proxy && c.proxy.$store) return c.proxy.$store c = c.parent } } // 3) 全页面扫一遍(慢,只在前面都失败时走) try { for (const el of document.querySelectorAll('*')) { const app = el.__vue_app__ if (app && app.config && app.config.globalProperties && app.config.globalProperties.$store) { return app.config.globalProperties.$store } } } catch (e) { /* 忽略 */ } return null } const marksGetter = (name) => { const s = getStore(); return s ? s.getters['marks/' + name] : undefined } const curFrameId = () => { const s = getStore(); return s ? s.state.status.curFrameId : null } const appReady = () => !!(getViewer() && getViewer().sseEditor && getStore()) function shortKeysDisabled() { try { const s = getStore() return !!(s && s.state.status.disabledShortKey) } catch (e) { return true } } // ── 面板状态与动作的绑定(先声明引用,后赋实现) ──────────────────────────── const actions = { split: null, undo: null, diagnose: null, pick: null } // ── 持久化 ───────────────────────────────────────────────────────────────── function loadPrefs() { let raw = null try { if (typeof GM_getValue === 'function') raw = GM_getValue(LS_KEY, null) if (!raw) { const ls = (ctx.win && ctx.win.localStorage) || window.localStorage if (ls) raw = ls.getItem(LS_KEY) } } catch (e) { /* 忽略 */ } if (!raw) return null try { return typeof raw === 'string' ? JSON.parse(raw) : raw } catch (e) { return null } } function savePrefs(obj) { try { const s = JSON.stringify(obj) if (typeof GM_setValue === 'function') GM_setValue(LS_KEY, s) const ls = (ctx.win && ctx.win.localStorage) || window.localStorage if (ls) ls.setItem(LS_KEY, s) } catch (e) { /* 忽略 */ } } const prefs = Object.assign( { visible: true, x: null, y: null, collapsed: false, hotkey: CONFIG.HOTKEY_ENABLED, log: true }, loadPrefs() || {} ) // 快捷键签名:键位/是否带修饰变了,就以新配置为准(老配置里存的是别的键) const HOTKEY_SIG = CONFIG.KEY + '|' + (CONFIG.NEED_CTRL ? 'ctrl' : 'plain') if (prefs.hotkeySig !== HOTKEY_SIG) { prefs.hotkeySig = HOTKEY_SIG prefs.hotkey = CONFIG.HOTKEY_ENABLED prefs.collapsed = false savePrefs(prefs) } const log = (...a) => { if (prefs.log) console.log('%c[线段拆分]', 'color:#3275bc;font-weight:bold', ...a) } const warn = (...a) => console.warn('[线段拆分]', ...a) // ── 「拾取地面高度」按钮的 Ctrl+Q 兜底 ───────────────────────────────────── // 平台源码里这个按钮的结构是: // div#pcd-info > div.pcd-info-content(第2个) > span.pcd-info-left + ElInput + ysButton // 第一个 ysButton 是拾取(title=拾取点云中一个点的Z值作为地面高度),第二个是隐藏地面。 // 它的图标是异步加载的 svg,页面上没有稳定 src,所以按上面这个结构定位。 function findPickGroundButton() { let blocks = [] try { blocks = document.querySelectorAll('#pcd-info .pcd-info-content') } catch (e) { blocks = [] } for (const b of blocks) { const label = b.querySelector ? b.querySelector('span') : null if (!label || String(label.textContent || '').indexOf('地面高度') < 0) continue const btns = b.querySelectorAll ? b.querySelectorAll('button') : [] if (btns.length) return btns[0] // 第 1 个按钮 = 拾取 // 兜底:按图标文件名找(万一渲染成原生 img) const imgs = b.querySelectorAll ? b.querySelectorAll('img') : [] for (const im of imgs) { if (String(im.getAttribute('src') || '').indexOf('straw') >= 0) { const btn = im.closest ? im.closest('button') : null if (btn) return btn } } } return null } /** 触发这个按钮的点击:优先直接跑它自己的 click 监听(不依赖可见性),否则退回原生 click() */ function triggerPickButton(btn) { if (!btn) return false const fns = btn.__dshLsClicks if (fns && fns.length) { for (const f of fns) { try { f({ target: btn, preventDefault() {}, stopPropagation() {} }) } catch (e) { /* 忽略 */ } } return true } try { btn.click(); return true } catch (e) { return false } } /** 因为图标异步加载,挂个 observer 把按钮的 click 监听记下来,同时尽早找到按钮 */ const pickBtnRef = { el: null } function watchPickButton() { const tryFind = () => { const b = findPickGroundButton(); if (b) pickBtnRef.el = b; return b } tryFind() // 记录按钮上的 click 监听(capture 阶段被动监听,不改变原有行为) const remember = (btn) => { if (!btn || btn.__dshLsHooked) return btn.__dshLsHooked = true const orig = btn.addEventListener btn.addEventListener = function (type, fn, opts) { if (type === 'click') { btn.__dshLsClicks = btn.__dshLsClicks || [] btn.__dshLsClicks.push(fn) } return orig.call(this, type, fn, opts) } } remember(pickBtnRef.el) try { const mo = new MutationObserver(() => { const b = tryFind() if (b) { remember(b); mo.disconnect(); log('已接管「拾取地面高度」按钮的 Ctrl+Q') } }) mo.observe(document.body, { childList: true, subtree: true }) setTimeout(() => { try { mo.disconnect() } catch (e) { /* 忽略 */ } }, 120000) } catch (e) { /* 环境不支持 MutationObserver 也不影响其它功能 */ } } /** 若当前焦点在输入框/文本域上,先让它失焦 —— 否则平台会认为"正在输入"而屏蔽快捷键 */ function clampGroundHeight() { try { const a = document.activeElement if (a && isTypingTarget(a) && a.blur) a.blur() } catch (e) { /* 忽略 */ } } /** 产物是否已打补丁(补丁会挂出 window.__dshStore) */ function bundlePatched() { try { return !!(ctx.win.__dshStore && ctx.win.__dshStore.commit) } catch (e) { return false } } function onPickKey(e) { if (!CONFIG.PICK_HOTKEY) return if (String(e.key).toLowerCase() !== CONFIG.PICK_KEY) return if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return if (isTypingTarget(e.target)) return // 先确认脚本真的连上了平台:否则按钮可能是别处同名结构,点了会误操作 if (!appReady()) return if (shortKeysDisabled()) return // 产物已经自己实现了这个键,就交给它,避免点两次把拾取模式开了又关 if (bundlePatched()) return setTimeout(() => { if (e.defaultPrevented) return const btn = pickBtnRef.el || findPickGroundButton() if (!btn) return clampGroundHeight() if (triggerPickButton(btn)) { e.preventDefault() UI.status('Ctrl+Q:已触发「拾取地面高度」,去 3D 视图里点一个点', 'ok') } }, 0) } // ── 选中线段 ─────────────────────────────────────────────────────────────── function getSelected3DLine() { const viewer = getViewer() const s = getStore() if (!viewer || !viewer.sseEditor || !s) return null const info = s.state.status.selectMarkInfo || {} if (info.trackId) { const mark = s.getters['marks/getMarks'](info.trackId, '3d', curFrameId()) if (mark) return { trackId: info.trackId, mark } } try { const sel = viewer.inputHandler && viewer.inputHandler.selection if (sel && sel.length) { for (const node of sel) { if (node && node.trackId != null) { const mark = s.getters['marks/getMarks'](node.trackId, '3d', curFrameId()) if (mark) return { trackId: node.trackId, mark } } } } } catch (e) { /* 忽略 */ } return null } // ── 切分几何 ─────────────────────────────────────────────────────────────── const dist = (a, b) => Math.sqrt( (a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]) + (a[2] - b[2]) * (a[2] - b[2]) ) const midpoint = (a, b) => [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5, (a[2] + b[2]) * 0.5] /** 2 点线正中切开;3 点以上在最长那段的中点插入新顶点 */ function planSplit(points) { const n = points.length if (n < CONFIG.MIN_POINTS) return null if (n === 2) { const cut = midpoint(points[0], points[1]) return { left: [points[0], cut], right: [cut, points[1]], splitIndex: 0, cutAt: cut } } let bestI = 0, bestD = -1 for (let i = 0; i < n - 1; i++) { const d = dist(points[i], points[i + 1]) if (d > bestD) { bestD = d; bestI = i } } const cut = midpoint(points[bestI], points[bestI + 1]) const left = points.slice(0, bestI + 1).concat([cut]) const right = [cut].concat(points.slice(bestI + 1)) if (left.length < CONFIG.MIN_POINTS || right.length < CONFIG.MIN_POINTS) return null return { left, right, splitIndex: bestI, cutAt: cut } } // ── 场景节点 ─────────────────────────────────────────────────────────────── function removeNodesByTrack(trackId) { let n = 0 try { const scene = getViewer().sseEditor.lineScene if (scene && scene.children) { for (const node of scene.children.slice()) { if (node && node.trackId === trackId) { node.removeFromParent(true); n++ } } } } catch (e) { warn('移除场景节点失败:', e) } return n } function addNodes(pairs) { try { for (const pair of pairs) getViewer().sseEditor.addMark(pair[0], pair[1]) getViewer().rendererMain() } catch (e) { warn('补画节点失败(数据已提交,刷新页面即可看到):', e) } } // ── 诊断 ─────────────────────────────────────────────────────────────────── function diagnose() { resolveCtx() const v = getViewer() const s = getStore() const appEl = document.querySelector('#app') const lines = [] lines.push('页面对象来源: ' + ctx.source) lines.push('unsafeWindow: ' + (typeof unsafeWindow === 'undefined' ? '不存在的(说明不是页面模式,或未声明 @grant unsafeWindow)' : (unsafeWindow === window ? '存在,但与 window 相同(说明是 content 沙箱模式)' : '存在且与 window 不同(page 模式,正常)'))) lines.push('window.viewer: ' + (ctx.win.viewer ? '有' : '无')) lines.push('window.__dshStore: ' + (ctx.win.__dshStore ? '有(说明是补丁版产物)' : '无(说明产物没打补丁或还是旧缓存)')) lines.push('#app.__vue_app__: ' + (appEl && appEl.__vue_app__ ? '有' : '无')) lines.push('viewer: ' + (v && v.sseEditor ? '已就绪' : '未就绪')) lines.push('store: ' + (s ? '已就绪' : '未就绪')) if (!s && !ctx.win.__dshStore && !(appEl && appEl.__vue_app__)) { lines.push('') lines.push('结论:脚本处在 content 沙箱里,拿不到页面对象。') lines.push('解决:脚本猫里把这个脚本的注入方式改为 page(脚本头已加 @inject-into page,') lines.push('或右键脚本 → 设置 → 注入到:page),或者让平台用上补丁版产物。') } const report = lines.join('\n') warn(report) return report } // ── 主功能 ───────────────────────────────────────────────────────────────── let lastOp = null function splitSelectedLine() { if (!appReady()) { UI.status('还没连上平台:面板上点「诊断」看原因', 'warn') diagnose() return null } const s = getStore() const picked = getSelected3DLine() if (!picked) { UI.status('请先在 3D 视图里点选一条线段', 'warn'); return null } const trackId = picked.trackId const mark = picked.mark if (!mark || mark.type !== TOOL_LINE_3D) { UI.status('只支持线段,当前是 ' + ((mark && mark.type) || '未知'), 'warn') return null } const points = mark.points if (!Array.isArray(points) || points.length < CONFIG.MIN_POINTS) { UI.status('这条线段点数不足,无法拆', 'warn'); return null } const plan = planSplit(points) if (!plan) { UI.status('算不出切分点', 'warn'); return null } const frameId = curFrameId() const classId = marksGetter('classId')(trackId) if (classId == null || classId === -1) { UI.status('取不到类别信息', 'warn'); return null } const newTrackId = marksGetter('avaliableTrackId') const idA = trackId const idB = (newTrackId != null && newTrackId !== trackId) ? newTrackId : trackId + 1 const build = (tid, pts) => { const m = Object.assign({}, mark) m.points = pts m.id = null m.is_computed = 0 m.point_num = 0 m.trackId = tid return m } const markA = build(idA, plan.left) const markB = build(idB, plan.right) lastOp = { originalMark: JSON.parse(JSON.stringify(mark)), trackId: trackId, classId: classId, frameId: frameId, newTrackIds: [idA, idB], } removeNodesByTrack(trackId) s.commit('marks/deleteMarks', [trackId, frameId, '3d']) s.commit('marks/addOrReplaceMark3D', [MARK_ACTION_ADD, markA, idA, classId, frameId]) s.commit('marks/addOrReplaceMark3D', [MARK_ACTION_ADD, markB, idB, classId, frameId]) addNodes([[markA, idA], [markB, idB]]) const msg = '已一分为二:' + plan.left.length + ' 点 + ' + plan.right.length + ' 点' + ',切点 Z=' + plan.cutAt[2].toFixed(3) log(msg, { 原trackId: trackId, 新trackId: [idA, idB] }) UI.status(msg, 'ok') return { trackId: trackId, newTrackIds: [idA, idB], left: plan.left, right: plan.right, cutAt: plan.cutAt } } actions.split = splitSelectedLine function undoLastSplit() { if (!lastOp) { UI.status('没有可回退的拆分', 'warn'); return false } if (!appReady()) { UI.status('还没连上平台', 'warn'); return false } const s = getStore() const op = lastOp for (const tid of op.newTrackIds) { removeNodesByTrack(tid) s.commit('marks/deleteMarks', [tid, op.frameId, '3d']) } const restored = Object.assign({}, op.originalMark) restored.id = null restored.is_computed = 0 restored.point_num = 0 s.commit('marks/addOrReplaceMark3D', [MARK_ACTION_ADD, restored, op.trackId, op.classId, op.frameId]) addNodes([[restored, op.trackId]]) lastOp = null UI.status('已回退上一次拆分', 'ok') return true } actions.undo = undoLastSplit /** 面板上那个 Ctrl+Q 按钮:等价于点一下平台的「拾取地面高度」 */ function triggerPickGround() { if (!appReady()) { UI.status('还没连上平台', 'warn'); return false } if (bundlePatched()) { UI.status('产物已自带 Ctrl+Q,直接按 Ctrl+Q 即可', 'ok'); return false } const btn = pickBtnRef.el || findPickGroundButton() if (!btn) { UI.status('没找到「拾取地面高度」按钮(面板结构可能变了)', 'warn'); return false } clampGroundHeight() if (triggerPickButton(btn)) { UI.status('已触发「拾取地面高度」,去 3D 视图里点一个点', 'ok') return true } UI.status('触发拾取失败', 'warn') return false } actions.pick = triggerPickGround // ── 面板样式 ─────────────────────────────────────────────────────────────── const CSS = [ '#dsh-ls-panel{position:fixed;z-index:2147483000;width:224px;background:rgba(28,32,38,.96);', 'color:#e8eaed;border:1px solid #3c4450;border-radius:8px;', 'font:13px/1.5 system-ui,-apple-system,"Microsoft YaHei",sans-serif;', 'box-shadow:0 8px 26px rgba(0,0,0,.45);user-select:none}', '#dsh-ls-panel.dsh-ls-min .dsh-ls-body{display:none}', '#dsh-ls-panel .dsh-ls-head{display:flex;align-items:center;gap:6px;padding:7px 9px;cursor:move;', 'background:linear-gradient(180deg,#39424f,#2b323b);border-radius:7px 7px 0 0;font-weight:600;font-size:12.5px}', '#dsh-ls-panel .dsh-ls-dot{width:8px;height:8px;border-radius:50%;background:#888;flex:0 0 auto}', '#dsh-ls-panel .dsh-ls-dot.ok{background:#38b06a;box-shadow:0 0 6px #38b06a}', '#dsh-ls-panel .dsh-ls-dot.warn{background:#d8973c}', '#dsh-ls-panel .dsh-ls-title{flex:1 1 auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '#dsh-ls-panel .dsh-ls-minbtn,#dsh-ls-panel .dsh-ls-x{cursor:pointer;opacity:.65;padding:0 3px;line-height:1}', '#dsh-ls-panel .dsh-ls-minbtn:hover,#dsh-ls-panel .dsh-ls-x:hover{opacity:1}', '#dsh-ls-panel .dsh-ls-body{padding:9px}', '#dsh-ls-panel .dsh-ls-info{font-size:11.5px;color:#9aa4b2;margin:0 0 8px;max-height:150px;overflow:auto;', 'background:#20252c;border-radius:5px;padding:6px 7px;word-break:break-all;white-space:pre-wrap}', '#dsh-ls-panel button{width:100%;box-sizing:border-box;border:1px solid #46505e;background:#333c48;color:#e8eaed;', 'border-radius:5px;padding:7px 8px;font-size:12.5px;cursor:pointer;margin-bottom:6px;font-family:inherit}', '#dsh-ls-panel button:hover{background:#3d4855;border-color:#5a6577}', '#dsh-ls-panel button:active{transform:translateY(1px)}', '#dsh-ls-panel button.dsh-ls-main{background:#2f6fb5;border-color:#3f86d1;font-weight:600;padding:9px}', '#dsh-ls-panel button.dsh-ls-main:hover{background:#3a7fc6}', '#dsh-ls-panel .dsh-ls-row{display:flex;gap:6px}', '#dsh-ls-panel .dsh-ls-row button{margin-bottom:6px}', '#dsh-ls-panel .dsh-ls-opt{display:flex;align-items:center;gap:6px;font-size:11.5px;color:#b9c2cf;margin-top:6px;cursor:pointer}', '#dsh-ls-panel .dsh-ls-opt input{cursor:pointer;margin:0}', '#dsh-ls-panel .dsh-ls-tip{font-size:10.5px;color:#7d8797;margin-top:6px;line-height:1.45}', '#dsh-ls-toggle{position:fixed;z-index:2147483000;left:10px;top:50%;transform:translateY(-50%);', 'background:rgba(47,111,181,.92);color:#fff;border:1px solid #3f86d1;border-radius:6px;', 'padding:8px 6px;font:12px system-ui,-apple-system,"Microsoft YaHei",sans-serif;cursor:pointer;', 'writing-mode:vertical-rl;letter-spacing:2px;box-shadow:0 4px 14px rgba(0,0,0,.35)}', '#dsh-ls-toggle:hover{background:rgba(58,127,198,1)}', ].join('') // ── 面板 ─────────────────────────────────────────────────────────────────── const UI = { el: null, toggleEl: null, infoEl: null, dotEl: null, _t: null, _diag: false, status(msg, kind) { if (this.infoEl) { this.infoEl.textContent = msg this.infoEl.style.color = kind === 'ok' ? '#7fd6a0' : kind === 'warn' ? '#e0b062' : '#9aa4b2' } if (this.dotEl) this.dotEl.className = 'dsh-ls-dot ' + (kind || '') if (kind === 'warn') warn(msg) clearTimeout(this._t) if (kind === 'ok') this._t = setTimeout(() => { this._diag = false; this.refreshInfo() }, 4000) }, showDiag() { this._diag = true if (this.infoEl) { this.infoEl.textContent = diagnose() this.infoEl.style.color = '#e0b062' } if (this.dotEl) this.dotEl.className = 'dsh-ls-dot warn' }, refreshInfo() { if (!this.infoEl || this._diag) return resolveCtx() if (!appReady()) { this.infoEl.textContent = '等待平台加载…(' + ctx.source + ')\n点「诊断」查看详情' this.infoEl.style.color = '#e0b062' if (this.dotEl) this.dotEl.className = 'dsh-ls-dot warn' return } const picked = getSelected3DLine() if (!picked) { this.infoEl.textContent = '已连接平台。未选中线段 —— 请在 3D 视图里点一下那条线' this.infoEl.style.color = '#9aa4b2' if (this.dotEl) this.dotEl.className = 'dsh-ls-dot' return } const m = picked.mark || {} const cnt = Array.isArray(m.points) ? m.points.length : 0 let cls = '?' try { cls = marksGetter('classId')(picked.trackId) } catch (e) { /* 忽略 */ } this.infoEl.textContent = '已选中 ' + (m.type || '?') + ':' + cnt + ' 个点' + ',trackId=' + picked.trackId + ',类别 ' + cls this.infoEl.style.color = '#7fd6a0' if (this.dotEl) this.dotEl.className = 'dsh-ls-dot ok' }, setVisible(v) { prefs.visible = v if (this.el) this.el.style.display = v ? '' : 'none' if (this.toggleEl) this.toggleEl.style.display = v ? 'none' : '' savePrefs(prefs) }, toggleMin() { prefs.collapsed = !prefs.collapsed if (this.el) this.el.classList.toggle('dsh-ls-min', prefs.collapsed) savePrefs(prefs) }, move(x, y) { const el = this.el if (!el) return const w = el.offsetWidth || 224, h = el.offsetHeight || 220 x = Math.max(2, Math.min(window.innerWidth - w - 2, x)) y = Math.max(2, Math.min(window.innerHeight - h - 2, y)) el.style.left = x + 'px' el.style.top = y + 'px' prefs.x = x; prefs.y = y }, } function buildUI() { if (typeof GM_addStyle === 'function') GM_addStyle(CSS) else { const st = document.createElement('style') st.textContent = CSS document.head.appendChild(st) } const toggle = document.createElement('div') toggle.id = 'dsh-ls-toggle' toggle.textContent = '线段拆分' toggle.title = '打开线段拆分控制面板' toggle.addEventListener('click', () => UI.setVisible(true)) document.body.appendChild(toggle) UI.toggleEl = toggle const el = document.createElement('div') el.id = 'dsh-ls-panel' el.innerHTML = [ '