// ==UserScript== // @name 点云标注 - 线段一分为二(控制面板版) // @namespace dsh.pcd.linesplit // @version 3.10.0 // @description 3D 线段:断开成两条(点哪个点就在其后断开,单键 M)、按你指定的点合并成一条(Shift+N)。页面内可拖拽控制面板;自动适配脚本猫沙箱,并提供一键诊断 // @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, // ── 二合一 ────────────────────────────────────────────────────────────── // 平台没有线段多选(点线只会更新 selectMarkInfo.trackId), // 所以这里记忆"先后点过的两条线",再用 Shift+N 合并。 MERGE_HOTKEY: true, MERGE_KEY: 'n', MERGE_NEED_SHIFT: true, // 两线最近端点相距超过这个值就提示一下(不阻止合并) MERGE_WARN_GAP: 0.5, } 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 MERGE_LABEL = (CONFIG.MERGE_NEED_SHIFT ? 'Shift+' : (isMac ? '⌘+' : 'Ctrl+')) + CONFIG.MERGE_KEY.toUpperCase() // 合并时接哪一对端点(面板上可切换,默认"两个末点相接"= 两个 5 号点) const MERGE_JOIN_LABEL = { 'tail-tail': '末点-末点(两个 5 号点)', 'tail-head': '末点-首点(接成一串)', 'head-head': '首点-首点(两个 1 号点)', 'head-tail': '首点-末点', 'auto': '自动取最近端点', } // ── 页面对象解析(本版核心修复) ─────────────────────────────────────────── 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, // 断开方式:gap = 两条不共享点(点 2 号 -> 1-2 / 3-4-5); // vertex = 两条共享断点(点 2 号 -> 1-2 / 2-3-4-5) splitMode: 'gap', // 合并时接哪一对端点:默认"两个末点相接"(两个 5 号点) mergeJoin: 'tail-tail', }, 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) } if (prefs.splitMode !== 'gap' && prefs.splitMode !== 'vertex') prefs.splitMode = 'gap' if (!MERGE_JOIN_LABEL[prefs.mergeJoin]) prefs.mergeJoin = 'tail-tail' 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) } // ── 「在哪个点上断开」的捕获 ──────────────────────────────────────────────── // 平台源码里点中线段上的球体会走 InputHandler 的 3D 事件系统: // dispatchEvent({ type:"mouseup", object: 球体 }) // 而球体就是线段节点的子对象,所以用屏幕投影找最近的点,就能算出它的序号。 const splitPoint = { trackId: null, frameId: null, index: -1, num: -1, via: '' } let pointDrag = null let lastPickDiag = { ok: false, reason: '尚未点过', best: -1, dist: null, threshold: 20, rect: null } function resetSplitPoint(trackId, frameId) { if (splitPoint.trackId !== trackId || splitPoint.frameId !== frameId) { splitPoint.trackId = (trackId == null ? null : trackId) splitPoint.frameId = (frameId == null ? null : frameId) splitPoint.index = -1 splitPoint.num = -1 splitPoint.via = '' } } /** * 屏幕坐标 -> 顶点序号;阈值内取最近的那个,找不到返回 -1。 * * 位置来源**优先用点标注(CSS2DObject)的世界坐标**: * - 平台 createPointLabel 里 `s.position.copy(球心)`,所以标注位置就是圆点位置, * 且平台自己也是用标注文本当编号("1".."n"); * - 这样拿到的序号与屏幕上看到的编号一致,不依赖 children 数组顺序 * (线段被增删点后,数组顺序未必等于编号)。 * 取不到标注时再退回球体位置。 */ function pickPointIndexAt(node, clientX, clientY) { lastPickDiag = { ok: false, reason: '', best: -1, bestNum: -1, dist: null, threshold: 20, rect: null, via: '' } try { const viewer = getViewer() const cam = viewer && (viewer.mainCamera || viewer.camera) if (!cam) { lastPickDiag.reason = '取不到相机'; return -1 } const el = viewer.canvasContainer || viewer.domElement if (!el || !el.getBoundingClientRect) { lastPickDiag.reason = '取不到画布'; return -1 } const rect = el.getBoundingClientRect() if (!rect.width || !rect.height) { lastPickDiag.reason = '画布尺寸为 0'; return -1 } lastPickDiag.rect = { l: Math.round(rect.left), t: Math.round(rect.top), w: Math.round(rect.width), h: Math.round(rect.height) } const spheres = node.getSpheresAndLines ? node.getSpheresAndLines()[0] : null const labels = node.pointLabels const zoom = cam.zoom || 1 const radius = (node.radius && node.radius.value) ? node.radius.value : 1 // 候选点:{ x, y, z, idx, num } const cands = [] if (Array.isArray(labels) && labels.length) { lastPickDiag.via = '点标注' // 标注位置比球心高一个固定的世界坐标偏移(平台 createPointLabel 里是 radius*3, // 但这里**不去硬编码**:直接用"标注位置 - 球体位置"算出真实偏移再减掉, // 这样无论平台怎么改偏移量、相机是正交还是透视,都拿到的是球心本身。 for (let i = 0; i < labels.length; i++) { const lb = labels[i] if (!lb || !lb.position) continue let num = i + 1 try { const parsed = parseInt(String(lb.element && lb.element.textContent).replace(/[^\d]/g, ''), 10) if (!isNaN(parsed) && parsed >= 1) num = parsed } catch (e) { /* 用 i+1 */ } // 用下标 i 对应的球体来量这个偏移(两者同序,都是平台的 children 顺序) const sp = (spheres && spheres[i] && spheres[i].position) ? spheres[i].position : null const dx0 = sp ? (lb.position.x - sp.x) : 0 const dy0 = sp ? (lb.position.y - sp.y) : 0 const dz0 = sp ? (lb.position.z - sp.z) : 0 const sameSpace = sp ? (Math.abs(dx0) + Math.abs(dy0) + Math.abs(dz0) < 50) : false cands.push({ x: lb.position.x - (sameSpace ? dx0 : 0), y: lb.position.y - (sameSpace ? dy0 : 0), z: lb.position.z - (sameSpace ? dz0 : 0), idx: i, num: num, }) } } if (!cands.length && spheres && spheres.length) { lastPickDiag.via = '球体' for (let i = 0; i < spheres.length; i++) { const p = spheres[i].position cands.push({ x: p.x, y: p.y, z: p.z, idx: i, num: i + 1 }) } } if (!cands.length) { lastPickDiag.reason = '线段上没有点'; return -1 } const local = new (spheres && spheres[0] ? spheres[0].position.constructor : Object)() let best = -1, bestNum = -1, bestD = Infinity const THRESHOLD = lastPickDiag.threshold lastPickDiag.pointCount = cands.length for (const c of cands) { if (local.set) local.set(c.x, c.y, c.z) else { local.x = c.x; local.y = c.y; local.z = c.z } if (local.project) local.project(cam) const sx = (local.x * 0.5 + 0.5) * rect.width const sy = (-local.y * 0.5 + 0.5) * rect.height const dx = sx - (clientX - rect.left) const dy = sy - (clientY - rect.top) const d = Math.sqrt(dx * dx + dy * dy) lastPickDiag['d' + c.num] = Math.round(d) // 严格取"离点击处最近"的那个;仅当明显更近(>0.5px)才换,避免同距时偏向数组顺序 if (best === -1 || d < bestD - 0.5) { bestD = d; best = c.idx; bestNum = c.num } } lastPickDiag.best = best lastPickDiag.bestNum = bestNum lastPickDiag.dist = Math.round(bestD * 10) / 10 if (bestD <= THRESHOLD) { lastPickDiag.ok = true; return best } lastPickDiag.reason = '最近的点距离 ' + lastPickDiag.dist + 'px,超过阈值 ' + THRESHOLD + 'px' return -1 } catch (e) { lastPickDiag.reason = '计算异常: ' + e.message return -1 } } function onPointDown(e) { pointDrag = { x: e.clientX, y: e.clientY } } function onPointUp(e) { if (!appReady()) return // 带 Ctrl/⌘ 的点击是"指定合并接头",交给 onCtlPointDown 处理,不当选断点 if (e.ctrlKey || e.metaKey) return const viewer = getViewer() const el = viewer.canvasContainer || viewer.domElement if (!el) return if (e.target !== el && el.contains && !el.contains(e.target)) return // 拖动过(在移动点)就不算"选点" if (pointDrag && (Math.abs(e.clientX - pointDrag.x) > 4 || Math.abs(e.clientY - pointDrag.y) > 4)) { pointDrag = null return } pointDrag = null const info = getStore().state.status.selectMarkInfo || {} const trackId = info.trackId if (trackId == null) return let node = null try { node = (viewer.sseEditor.lineScene.children || []).filter((n) => n && n.trackId === trackId)[0] } catch (err) { node = null } if (!node || node.isClosed) return // 多边形不参与 const idx = pickPointIndexAt(node, e.clientX, e.clientY) const num = (lastPickDiag.bestNum && lastPickDiag.bestNum >= 1) ? lastPickDiag.bestNum : (idx + 1) resetSplitPoint(trackId, curFrameId()) if (idx >= 0) { splitPoint.index = idx splitPoint.num = num splitPoint.via = lastPickDiag.via // 同一个点也当作"合并接头"的指定:点哪条线的哪个点,就把那条线的接头定在这里 rememberMergePick(trackId, curFrameId(), idx, num) log('已选中断点:屏幕上编号 ' + num + ' 的点(数组下标 ' + idx + ',来源=' + lastPickDiag.via + ',最近距离 ' + lastPickDiag.dist + 'px)', lastPickDiag) // 把命中情况直接写进面板,便于对照屏幕上看到的编号,一旦选错能立刻看出来 const dAll = [] for (let k = 1; k <= (lastPickDiag.pointCount || 0); k++) { if (lastPickDiag['d' + k] != null) dAll.push(k + ':' + lastPickDiag['d' + k] + 'px') } UI.status('已选中第 ' + num + ' 个点(就在这个点上断开)' + ' 按 ' + HOTKEY_LABEL + ' 或点「一分为二」' + ' · 命中距离 ' + lastPickDiag.dist + 'px' + (dAll.length ? ';各点距离 ' + dAll.join(' ') : ''), 'ok') } else if (lastPickDiag.dist != null) { // 点在附近但没命中点(比如点在两点之间的线上):给出可排查的信息 log('未命中顶点:' + lastPickDiag.reason, lastPickDiag) } } // 平台点中线段只会把 trackId 写进 selectMarkInfo,没有多选; // 所以这里轮询选中变化,按"先进先出"维护最近的两条不同线段。 // 记录项是 "trackId|dimension":同一条线在 2D 小窗和 3D 主视图里各算一次点击。 const recent = [] const RECENT_MAX = 2 let lastSelectSig = null let mergeArmed = false // 用户先点了「二合一」,等着点两条线 /** * 「指定接头」:你在某条线上点中哪个圆点,就记住那条线的接头是它。 * 合并时用这两个被指定的点相接(没指定则按 joinMode,默认末-末)。 */ const mergePick = {} function rememberMergePick(trackId, frameId, idx, num) { if (trackId == null || idx == null || idx < 0) return mergePick[String(trackId)] = { frameId: frameId, index: idx, num: (num >= 1 ? num : idx + 1) } } function clearMergePicks() { for (const k of Object.keys(mergePick)) delete mergePick[k] } function getMergePick(trackId, frameId) { const r = mergePick[String(trackId)] if (!r || r.frameId !== frameId) return null return r } /** * 按"指定接头"把两条线接成一条,**原有顶点一个都不动**。 * iNum = A 的接头序号,jNum = B 的接头序号(都是 1-based,就是屏幕上看到的编号) * 结果:A1..Ai, Bj, B(j+1)..Bm, B(j-1)..B1 —— A 的接头点与 B 的接头点直接相连 * j 在端点时就是干净拼接:j=1 -> A1..Ai, B1..Bm;j=m -> A1..Ai, Bm..B1 */ function planJoinAtPoints(a, b, iNum, jNum) { const i = Math.max(1, Math.min(a.length, iNum)) - 1 const j = Math.max(1, Math.min(b.length, jNum)) - 1 const pts = a.slice(0, i + 1).concat(b.slice(j)) // A1..Ai + Bj..Bm if (j > 0) pts.push.apply(pts, b.slice(0, j).reverse()) // 再折回 B(j-1)..B1 return { pts: pts, gap: dist(a[i], b[j]), atPoint: true, junctionA: i + 1, junctionB: j + 1, mode: 'points', } } // 取回原始 trackId 的数字类型:平台的 trackId 是数字, // 别让配对记录(字符串)把它变成 "7" 这种字符串传回 store const entryId = (e) => { const s = String(e).split('|')[0] return /^\d+$/.test(s) ? Number(s) : s } function pushRecent(trackId, dim) { if (trackId == null) return const key = trackId + '|' + (dim || '3d') const i = recent.indexOf(key) if (i >= 0) recent.splice(i, 1) // 再点一次同一根(同一视图)-> 提到最新 recent.push(key) while (recent.length > RECENT_MAX) recent.shift() } function pollSelection() { try { if (!appReady()) return const info = getStore().state.status.selectMarkInfo || {} const sig = String(info.trackId) + '@' + curFrameId() + '#' + String(info.dimension) if (sig === lastSelectSig) return lastSelectSig = sig if (info.trackId == null) return const frameId = curFrameId() const mark = marksGetter('getMarks')(info.trackId, '3d', frameId) if (!mark || mark.type !== TOOL_LINE_3D) return // 只记线段 // 注意:这里**不按 dimension 过滤**。3D 主视图点线是 '3d',2D 相机小窗里点线是 '2d', // 两者都是"点中了一条线",都该计入配对;否则会出现"我选了啊,它却不认"的困惑。 const dim = info.dimension || '3d' pushRecent(info.trackId, dim) resetSplitPoint(info.trackId, frameId) log('已选中线段 trackId=' + info.trackId + '(来源 ' + dim + ',合并配对:[' + recent.join(', ') + '],共 ' + recent.length + '/2)') UI.refreshInfo() // 立刻刷新面板,不用等下一次轮询 if (mergeArmed && recent.length >= 2) { // 用户是先点「二合一」再点线的:凑够两条就自动合并 mergeArmed = false setTimeout(() => { mergeTwoLines() }, 0) } } catch (e) { /* 忽略 */ } } function getMergePair() { if (recent.length < 2) return null const s = getStore() if (!s) return null const frameId = curFrameId() const idA = entryId(recent[0]) const idB = entryId(recent[1]) const a = s.getters['marks/getMarks'](idA, '3d', frameId) const b = s.getters['marks/getMarks'](idB, '3d', frameId) if (!a || !b) return null if (a.type !== TOOL_LINE_3D || b.type !== TOOL_LINE_3D) return null if (idA === idB) return null // 同一根线(在 2D/3D 各点了一次)不算一对 return { idA: idA, markA: a, idB: idB, markB: b } } /** * 选接口 + 拼接:把两条线接成一条折线,**原有顶点一个都不动**。 * * joinMode 决定接哪一对端点: * 'tail-tail'(默认):A 的最后一个点 <- B 的最后一个点(就是"两个 5 号点连在一起") * 'tail-head' :A 的最后一个点 <- B 的第一个点(接成一串) * 'head-head' :A 的第一个点 <- B 的第一个点 * 'head-tail' :A 的第一个点 <- B 的最后一个点 * 'auto' :四种里取端点距离最近的(旧行为,容易"乱连") * * 每种模式里 B 的朝向都被选成让接缝更短的那个;反向不改变任何顶点位置,只影响点序。 * 若被选中的那对端点本来就重合,会去掉重复点,不产生重复顶点。 * * 返回 { pts, gap, reversed, deduped, joinAt, mode, total, vertexCount } */ function decideMergeJoin(a, b, frameId, joinMode) { const total = a.length + b.length const A_FIRST = a[0], A_LAST = a[a.length - 1] const B_FIRST = b[0], B_LAST = b[b.length - 1] // joinAt = a.length 表示 B 接在 A 尾部;joinAt = 1 表示 B 接在 A 首部 const options = [ { key: 'tail-head', d: dist(A_LAST, B_FIRST), reversed: false, joinAt: a.length }, { key: 'tail-tail', d: dist(A_LAST, B_LAST), reversed: true, joinAt: a.length }, { key: 'head-head', d: dist(A_FIRST, B_FIRST), reversed: true, joinAt: 1 }, { key: 'head-tail', d: dist(A_FIRST, B_LAST), reversed: false, joinAt: 1 }, ] let pick if (joinMode && joinMode !== 'auto') { pick = options.filter((o) => o.key === joinMode)[0] || options[0] } else { pick = options.slice().sort((x, y) => x.d - y.d)[0] } const EPS = 1e-6 let pts, deduped = false if (pick.joinAt === 1) { // 接口在 A 的首端:要让"B 的接口点"与"A 的首点"相邻, // 所以把 A 反向(A 的首点落到末尾),再接在 B 后面 // 注意:必须复制数组再动,绝不能就地改动 store 里的 points(否则会破坏原标注) const tail = pick.reversed ? a.slice() : a.slice().reverse() const jA = tail[tail.length - 1] if (dist(jA, b[b.length - 1]) <= EPS) { tail.pop(); deduped = true } pts = b.slice().concat(tail) } else { // 接口在 A 的尾端:让"B 的接口点"落在开头,再接在 A 后面 const seq = pick.reversed ? b.slice().reverse() : b.slice() if (dist(a[a.length - 1], seq[0]) <= EPS) { seq.shift(); deduped = true } pts = a.slice().concat(seq) } return { pts: pts, gap: pick.d, reversed: pick.reversed, deduped: deduped, joinAt: pick.joinAt, mode: pick.key, total: total, vertexCount: deduped ? total - 1 : total, } } /** 两条线能否合并:类型、不同 trackId、同类别、点数、非闭合 */ function checkMergeable(pair) { if (!pair) return '请先在 3D 视图里依次点选两条线段' const a = pair.markA, b = pair.markB if (a.type !== TOOL_LINE_3D || b.type !== TOOL_LINE_3D) return '只能合并线段(line_3d)' const pa = Array.isArray(a.points) ? a.points : [] const pb = Array.isArray(b.points) ? b.points : [] if (pa.length < CONFIG.MIN_POINTS || pb.length < CONFIG.MIN_POINTS) return '线段点数不足' let ca = null, cb = null try { ca = marksGetter('classId')(pair.idA) } catch (e) { /* 忽略 */ } try { cb = marksGetter('classId')(pair.idB) } catch (e) { /* 忽略 */ } if (ca != null && cb != null && ca !== cb) return '两条线段的类别不同(' + ca + ' / ' + cb + '),不能合并' return null } function mergeTwoLines() { if (!appReady()) { UI.status('还没连上平台', 'warn'); return null } const pair = getMergePair() const bad = checkMergeable(pair) if (bad) { // 只点了一条(或还没点):进入"选择模式",让用户接着点线,点满自动合并 if (recent.length < 2) { mergeArmed = true UI.status('已进入合并选择模式:请在 3D 视图里点两条线段(还差 ' + (2 - recent.length) + ' 条),点满会自动合并', 'warn') } else { mergeArmed = false UI.status(bad, 'warn') } return null } mergeArmed = false const s = getStore() const frameId = curFrameId() // 类别直接用已经校验过的标注里的字段,不再回查 store(避免依赖二次取数) const classId = (pair.markA && pair.markA.classId != null) ? pair.markA.classId : marksGetter('classId')(pair.idA) const a = pair.markA.points const b = pair.markB.points // 优先用"你在两条线上分别点中的那个点"作为接头;没指定才按 joinMode(默认末-末) const pickA = getMergePick(pair.idA, frameId) const pickB = getMergePick(pair.idB, frameId) let join if (pickA && pickB) { join = planJoinAtPoints(a, b, pickA.num, pickB.num) log('按指定接头合并:A 的第 ' + pickA.num + ' 个点 <- B 的第 ' + pickB.num + ' 个点') } else { join = decideMergeJoin(a, b, frameId, prefs.mergeJoin) } // 供回退使用 lastOp = { kind: 'merge', originals: [ { mark: JSON.parse(JSON.stringify(pair.markA)), trackId: pair.idA, classId: marksGetter('classId')(pair.idA) }, { mark: JSON.parse(JSON.stringify(pair.markB)), trackId: pair.idB, classId: marksGetter('classId')(pair.idB) }, ], frameId: frameId, mergedTrackIds: [pair.idA], } const merged = Object.assign({}, pair.markA) merged.points = join.pts merged.id = null merged.is_computed = 0 merged.point_num = 0 merged.trackId = pair.idA removeNodesByTrack(pair.idA) removeNodesByTrack(pair.idB) s.commit('marks/deleteMarks', [pair.idA, frameId, '3d']) s.commit('marks/deleteMarks', [pair.idB, frameId, '3d']) s.commit('marks/addOrReplaceMark3D', [MARK_ACTION_ADD, merged, pair.idA, classId, frameId]) addNodes([[merged, pair.idA]]) recent.length = 0 lastSelectSig = null mergeArmed = false splitPoint.index = -1 clearMergePicks() UI.refreshInfo() // 立刻把面板刷成合并后的状态 let howLabel if (join.mode === 'points') { howLabel = '按你指定的接头:A 第 ' + join.junctionA + ' 点 <- B 第 ' + join.junctionB + ' 点' } else { howLabel = '按「' + (MERGE_JOIN_LABEL[join.mode] || join.mode) + '」接上' + (join.reversed ? '(第二条已反向)' : '') } const msg = '已合并为一条标注:' + merged.points.length + ' 个点(顶点位置未改动;' + howLabel + ')' log(msg, { 合并: [pair.idA, pair.idB], 接缝: join.gap, mode: join.mode }) // 平行线的接缝本来就有一段距离,这是预期行为,用中性提示而不是警告 UI.status(msg + '\n两接口点相距 ' + join.gap.toFixed(3) + ',合并后会多出这一段连接线', 'ok') return { trackId: pair.idA, points: merged.points, gap: join.gap, reversed: join.reversed } } /** * 按住 Ctrl/⌘ 点某个圆点 = 指定"合并接头"。 * 捕获阶段监听,不改动平台的事件流;平台自己的 Ctrl+左键行为不受影响。 * 记录的先后顺序就是"先点哪条线的哪个点、后点哪条线的哪个点"。 */ function onCtlPointDown(e) { if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return if (e.button !== 0) return if (!appReady()) return if (isTypingTarget(e.target)) return const viewer = getViewer() const el = viewer.canvasContainer || viewer.domElement if (!el) return if (e.target !== el && el.contains && !el.contains(e.target)) return const info = getStore().state.status.selectMarkInfo || {} const trackId = info.trackId if (trackId == null) return let node = null try { node = (viewer.sseEditor.lineScene.children || []).filter((n) => n && n.trackId === trackId)[0] } catch (err) { node = null } if (!node || node.isClosed) return const idx = pickPointIndexAt(node, e.clientX, e.clientY) const num = (lastPickDiag.bestNum && lastPickDiag.bestNum >= 1) ? lastPickDiag.bestNum : (idx + 1) if (idx < 0) { UI.status('Ctrl+点击没点中圆点(最近的点 ' + lastPickDiag.dist + 'px,阈值 20px)—— 请点在圆点上', 'warn') return } const frameId = curFrameId() rememberMergePick(trackId, frameId, idx, num) pushRecent(trackId, info.dimension || '3d') // 顺序:先点的成为合并时的第一条 log('Ctrl+点击:已指定 trackId=' + trackId + ' 的第 ' + num + ' 个点为合并接头(配对 [' + recent.join(', ') + '])') UI.refreshInfo() UI.status('已指定合并接头:' + trackId + ' 的第 ' + num + ' 个点' + '(还需在另一条线上再 Ctrl+点一个点,然后按 ' + MERGE_LABEL + ')', 'ok') } // ── 选中线段 ─────────────────────────────────────────────────────────────── 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] /** * 在指定顶点处断开,两种模式: * * mode='gap'(默认,用户要的那种):**两条不共享点** * 点第 k 个点 -> 左 = [0..k],右 = [k+1..n-1] * 例:5 个点、点 2 号 -> [1,2] 与 [3,4,5] * * mode='vertex':**两条共享断点**(像把两条线用同一个端点接在一起) * 点第 k 个点 -> 左 = [0..k],右 = [k..n-1] * 例:5 个点、点 2 号 -> [1,2] 与 [2,3,4,5] */ function planSplitAt(points, k, mode) { const n = points.length if (!(k >= 0 && k < n)) return null const gapMode = mode !== 'vertex' if (gapMode) { // 断在 k 与 k+1 之间:两侧都不能少于 2 个点 if (k < 1 || k > n - 3) return null const left = points.slice(0, k + 1) const right = points.slice(k + 1) if (left.length < CONFIG.MIN_POINTS || right.length < CONFIG.MIN_POINTS) return null // 断口中点(仅用于显示) const cut = midpoint(points[k], points[k + 1]) return { left: left, right: right, splitIndex: k, cutAt: cut, atPoint: true, mode: 'gap' } } // 共享顶点模式 if (k === 0 || k === n - 1) return null const left = points.slice(0, k + 1) const right = points.slice(k) if (left.length < CONFIG.MIN_POINTS || right.length < CONFIG.MIN_POINTS) return null return { left: left, right: right, splitIndex: k, cutAt: points[k], atPoint: true, mode: 'vertex' } } /** * 没选点时的几何规则: * - gap 模式:断在最中间的接缝上(左 = [0..k],右 = [k+1..]),两条不共享点; * - vertex 模式:断在中间那个顶点上,两条共享该点; * - 2 点线:没有中间接缝,两种模式都退化为两点中点切开(各 2 点)。 */ function planSplit(points, mode) { const n = points.length if (n < CONFIG.MIN_POINTS) return null const gapMode = mode !== 'vertex' if (n === 2) { const cut = midpoint(points[0], points[1]) return { left: [points[0], cut], right: [cut, points[1]], splitIndex: 0, cutAt: cut, atPoint: false, mode: 'gap' } } if (gapMode) { // 没选点时取"最均衡的接缝":让两侧点数差距最小 // 5 点 -> 断在 2|3 之间(2/3);6 点 -> 断在 3|4 之间(3/3) let bestK = Math.floor((n - 1) / 2) let bestDiff = Infinity for (let k = 1; k <= n - 3; k++) { const diff = Math.abs((k + 1) - (n - k - 1)) if (diff < bestDiff) { bestDiff = diff; bestK = k } } const r = planSplitAt(points, bestK, 'gap') if (r) return r } return planSplitAt(points, Math.floor((n - 1) / 2), 'vertex') } // ── 场景节点 ─────────────────────────────────────────────────────────────── 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('断点拾取:' + (lastPickDiag.ok ? ('正常,最近点距离 ' + lastPickDiag.dist + 'px') : ('未命中(' + lastPickDiag.reason + ')'))) if (lastPickDiag.rect) { lines.push('画布矩形: left=' + lastPickDiag.rect.l + ' top=' + lastPickDiag.rect.t + ' w=' + lastPickDiag.rect.w + ' h=' + lastPickDiag.rect.h) } 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 } // 优先用"刚点中的那个点"作为断点 resetSplitPoint(trackId, curFrameId()) let plan = null let where = '' const mode = prefs.splitMode === 'vertex' ? 'vertex' : 'gap' if (splitPoint.index >= 0) { plan = planSplitAt(points, splitPoint.index, mode) const numLabel = splitPoint.num >= 1 ? splitPoint.num : splitPoint.index + 1 if (!plan) { UI.status(mode === 'gap' ? ('不能在 ' + numLabel + ' 号这里断开:' + (splitPoint.index < 1 ? '这是第 1 个点,前面没有点了' : '后面不足 2 个点') + ';换个靠中间的点,或把下方「断开方式」改成「共享断点」') : ('第 ' + numLabel + ' 个点是端点,不能在端点断开;请选中间的点'), 'warn') return null } where = mode === 'gap' ? (numLabel + ' 号与后一点之间') : ('第 ' + numLabel + ' 个点') } else { plan = planSplit(points, mode) if (plan) { where = (points.length === 2 ? '中点' : (mode === 'gap' ? '中间的接缝' : '中间的顶点')) log('没有选中的点,改用几何规则在' + where + '断开') } } 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 = { kind: 'split', 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.mode === 'gap' ? '两条不共享点' : '两条共享断点') + '):' + plan.left.length + ' 点 + ' + plan.right.length + ' 点' + '(断开处 Z=' + plan.cutAt[2].toFixed(3) + ')' log(msg, { 原trackId: trackId, 新trackId: [idA, idB], atPoint: !!plan.atPoint, mode: plan.mode }) UI.status(msg, 'ok') splitPoint.index = -1 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 if (op.kind === 'merge') { // 合并的回退:删掉合并后的线,把原来两条还原 for (const tid of op.mergedTrackIds) { removeNodesByTrack(tid) s.commit('marks/deleteMarks', [tid, op.frameId, '3d']) } const pairs = [] for (const o of op.originals) { const m = Object.assign({}, o.mark) m.id = null m.is_computed = 0 m.point_num = 0 s.commit('marks/addOrReplaceMark3D', [MARK_ACTION_ADD, m, o.trackId, o.classId, op.frameId]) pairs.push([m, o.trackId]) } addNodes(pairs) lastOp = null recent.length = 0 lastSelectSig = null mergeArmed = false UI.status('已回退合并,两条线段已还原', 'ok') return true } const { originalMark, trackId, classId, frameId, newTrackIds } = op for (const tid of newTrackIds) { removeNodesByTrack(tid) s.commit('marks/deleteMarks', [tid, frameId, '3d']) } const restored = Object.assign({}, originalMark) restored.id = null restored.is_computed = 0 restored.point_num = 0 s.commit('marks/addOrReplaceMark3D', [MARK_ACTION_ADD, restored, trackId, classId, frameId]) addNodes([[restored, trackId]]) lastOp = null UI.status('已回退上一次拆分', 'ok') return true } actions.undo = undoLastSplit actions.merge = mergeTwoLines function onMergeKey(e) { if (!CONFIG.MERGE_HOTKEY) return if (e.repeat) return if (e.altKey) return if (CONFIG.MERGE_NEED_SHIFT !== !!e.shiftKey) return if (!CONFIG.MERGE_NEED_SHIFT && !(e.ctrlKey || e.metaKey)) return if (String(e.key).toLowerCase() !== CONFIG.MERGE_KEY) return if (isTypingTarget(e.target)) return if (!appReady()) return if (shortKeysDisabled()) return setTimeout(() => { if (e.defaultPrevented) return // 每次按键都要重新配对(用户可能刚点了第二条线) pollSelection() const n = mergeTwoLines() if (n) e.preventDefault() }, 0) } /** 面板上那个 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() // 「二合一」的配对状态:不管当前有没有选中线,都要显示出来 let pairTip if (mergeArmed) { pairTip = '合并:已进入选择模式 —— 请在 3D 视图里点两条线段(还差 ' + (2 - recent.length) + ' 条),点满自动合并' } else if (recent.length === 0) { pairTip = '合并:还没有选中线段 —— 在 3D 里依次点两条线,或先点「二合一」' } else if (recent.length === 1) { pairTip = '合并:已选中 ' + entryId(recent[0]) + ',还需再点一条 → 然后按 ' + MERGE_LABEL } else { const pair = getMergePair() if (pair) { const pkA = getMergePick(pair.idA, curFrameId()) const pkB = getMergePick(pair.idB, curFrameId()) let how if (pkA && pkB) { how = '接 A 第 ' + pkA.num + ' 点 ←→ B 第 ' + pkB.num + ' 点(你指定的接头)' } else if (pkA || pkB) { how = '已指定 A/B 其中一条的接头,请再在另一条线上点一下要接的点' } else { const join = decideMergeJoin(pair.markA.points, pair.markB.points, curFrameId(), prefs.mergeJoin) how = '按「' + (MERGE_JOIN_LABEL[join.mode] || join.mode) + '」接' + '(两个接口点相距 ' + join.gap.toFixed(3) + ')' } pairTip = '合并:已配对 ' + pair.idA + ' + ' + pair.idB + '\n ' + how + ' → 按 ' + MERGE_LABEL } else { pairTip = '合并:配对的线段已失效(或两次点的是同一根),请重新点选两条' } } if (!picked) { this.infoEl.textContent = '已连接平台。' + pairTip 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) { /* 忽略 */ } resetSplitPoint(picked.trackId, curFrameId()) const at = splitPoint.index >= 0 ? '\n断点:第 ' + (splitPoint.num >= 1 ? splitPoint.num : splitPoint.index + 1) + ' 个点(就在这里断开)' : '\n断点:未选点 —— 先在 3D 里点一下要断开的位置那个圆点' this.infoEl.textContent = '已选中 ' + (m.type || '?') + ':' + cnt + ' 个点' + ',trackId=' + picked.trackId + ',类别 ' + cls + at + '\n' + pairTip this.infoEl.style.color = '#7fd6a0' if (this.dotEl) this.dotEl.className = 'dsh-ls-dot ok' }, /** 高亮当前断开方式 / 合并接头 对应的按钮 */ syncModeButtons() { if (!this.el) return const modes = this.el.querySelectorAll ? this.el.querySelectorAll('.dsh-ls-mode') : [] for (const b of modes) { const on = b.getAttribute('data-mode') === prefs.splitMode b.style.background = on ? '#2f6fb5' : '#333c48' b.style.borderColor = on ? '#3f86d1' : '#46505e' b.style.fontWeight = on ? '600' : '400' } const joins = this.el.querySelectorAll ? this.el.querySelectorAll('.dsh-ls-join') : [] for (const b of joins) { const on = b.getAttribute('data-join') === prefs.mergeJoin b.style.background = on ? '#2f6fb5' : '#333c48' b.style.borderColor = on ? '#3f86d1' : '#46505e' b.style.fontWeight = on ? '600' : '400' } }, 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 = [ '
', ' ', ' 线段一分为二', ' –', ' ✕', '
', '
', '
初始化中…
', ' ', ' ', '
', ' ', ' ', '
', '
', ' 断开方式', ' ', ' ', '
', '
', ' 合并接头', ' ', ' ', ' ', ' ', '
', ' ', ' ', ' ', '
' + '【一分为二】① 在 3D 里点一下要断开的圆点(左侧带编号 1/2/3… 的点);' + '② 按 ' + HOTKEY_LABEL + ' 或点「一分为二」。
' + ' 不共享点:点 2 号 → 1-2 一条、3-4-5 一条(两条没有共用的点)。
' + ' 共享断点:点 2 号 → 1-2 一条、2-3-4-5 一条(两条共用 2 号点)。
' + '【二合一】平台没有"选中两条线"的功能,所以由脚本记:' + '在 3D 里点第一条线 → 再点第二条线,然后按 ' + MERGE_LABEL + ' 合并;' + '也可以先点「二合一」进入选择模式,再依次点两条线,点满自动合并。
' + '接哪两个点由你定:按住 Ctrl 点一个圆点 = 指定它为接头,' + '在另一条线上再 Ctrl+点 一个圆点,然后按 ' + MERGE_LABEL + ',这两个点就连起来。
' + '不想按 Ctrl 也行:在两条线上各普通点一下你要接的圆点,效果一样。
' + '没指定时才按上面「合并接头」的设置(默认末-末 = 两个 5 号点)。
' + '合并不改动原有顶点位置;两线之间会多出一段连接线(你指定的两点之间)。
' + '两者都能用「回退上一次」撤销。Ctrl+Q = 拾取地面高度。
', '
', ].join('') document.body.appendChild(el) UI.el = el UI.infoEl = el.querySelector('.dsh-ls-info') UI.dotEl = el.querySelector('.dsh-ls-dot') if (prefs.x != null && prefs.y != null) UI.move(prefs.x, prefs.y) else UI.move(Math.max(10, window.innerWidth - 250), 96) el.addEventListener('click', (e) => { const t = e.target if (t.classList && t.classList.contains('dsh-ls-x')) { UI.setVisible(false); return } if (t.classList && t.classList.contains('dsh-ls-minbtn')) { UI.toggleMin(); return } const act = t.getAttribute && t.getAttribute('data-act') if (act === 'split') actions.split() else if (act === 'mode-gap' || act === 'mode-vertex') { prefs.splitMode = (act === 'mode-vertex') ? 'vertex' : 'gap' savePrefs(prefs) UI.syncModeButtons() UI.status(prefs.splitMode === 'gap' ? '断开方式:两条不共享点(点 2 号 -> 1-2 与 3-4-5)' : '断开方式:两条共享断点(点 2 号 -> 1-2 与 2-3-4-5)', 'ok') } else if (act === 'merge') { pollSelection(); actions.merge() } else if (act && act.indexOf('join-') === 0) { const j = act.slice(5) if (MERGE_JOIN_LABEL[j]) { prefs.mergeJoin = j savePrefs(prefs) UI.syncModeButtons() UI.refreshInfo() UI.status('合并接头:' + MERGE_JOIN_LABEL[j], 'ok') } } else if (act === 'undo') actions.undo() else if (act === 'pick') actions.pick() else if (act === 'diagnose') UI.showDiag() }) el.addEventListener('change', (e) => { const t = e.target const act = t.getAttribute && t.getAttribute('data-act') if (act === 'hotkey') { prefs.hotkey = !!t.checked savePrefs(prefs) UI.status(prefs.hotkey ? (HOTKEY_LABEL + ' 快捷键已开启') : (HOTKEY_LABEL + ' 快捷键已关闭')) } else if (act === 'log') { prefs.log = !!t.checked savePrefs(prefs) } }) el.querySelector('[data-act="hotkey"]').checked = !!prefs.hotkey el.querySelector('[data-act="log"]').checked = !!prefs.log el.classList.toggle('dsh-ls-min', !!prefs.collapsed) UI.syncModeButtons() const head = el.querySelector('.dsh-ls-head') let dragging = false, ox = 0, oy = 0 head.addEventListener('mousedown', (e) => { if (e.target.classList.contains('dsh-ls-x') || e.target.classList.contains('dsh-ls-minbtn')) return dragging = true ox = e.clientX - el.offsetLeft oy = e.clientY - el.offsetTop e.preventDefault() }) document.addEventListener('mousemove', (e) => { if (dragging) UI.move(e.clientX - ox, e.clientY - oy) }) document.addEventListener('mouseup', () => { if (dragging) { dragging = false; savePrefs(prefs) } }) window.addEventListener('resize', () => { if (prefs.x != null) UI.move(prefs.x, prefs.y) }) UI.setVisible(prefs.visible !== false) UI.refreshInfo() // 每秒刷新一次状态(选点、切换线段都要能及时反映),面板隐藏时跳过以省开销 setInterval(() => { if (prefs.visible !== false) UI.refreshInfo() }, 1000) log('控制面板已注入(页面对象来源:' + ctx.source + ')') } // ── 快捷键(默认关闭) ───────────────────────────────────────────────────── function isTypingTarget(t) { if (!t) return false const tag = (t.tagName || '').toLowerCase() return tag === 'input' || tag === 'textarea' || tag === 'select' || t.isContentEditable === true } function onKeyDown(e) { if (!prefs.hotkey) return if (e.repeat) return if (String(e.key).toLowerCase() !== CONFIG.KEY) return const ctrlOrMeta = isMac ? e.metaKey : e.ctrlKey if (CONFIG.NEED_CTRL) { if (!ctrlOrMeta) return } else { // 单键模式:带了任何修饰键都不算,避免和平台的 Ctrl/Alt/Shift 组合撞车 if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return } if (isTypingTarget(e.target)) return if (shortKeysDisabled()) return setTimeout(() => { if (e.defaultPrevented) { log(HOTKEY_LABEL + ' 已被平台占用,本次让位'); return } actions.split() }, 0) } function bindPointPick() { const viewer = getViewer() if (!viewer) return false const el = viewer.canvasContainer || viewer.domElement if (!el || el.__dshLsPointBound) return !!el el.__dshLsPointBound = true // 捕获阶段:只用来记录坐标/算最近点,不改事件本身,平台行为完全不变 el.addEventListener('mousedown', onPointDown, true) el.addEventListener('mouseup', onPointUp, true) // Ctrl/⌘ + 点击圆点 = 指定合并接头 el.addEventListener('mousedown', onCtlPointDown, true) log('已挂上「点选断点 / Ctrl+点指定合并接头」的监听') return true } // ── 启动 ─────────────────────────────────────────────────────────────────── function boot() { if (!document.body) { setTimeout(boot, 200); return } if (document.getElementById('dsh-ls-panel')) return resolveCtx() buildUI() watchPickButton() if (!bindPointPick()) { // viewer 还没出来,等它一下(点云加载完才有 canvasContainer) let tries = 0 const t = setInterval(() => { tries++ if (bindPointPick() || tries > 240) clearInterval(t) }, 500) } document.addEventListener('keydown', onKeyDown, false) document.addEventListener('keydown', onPickKey, false) document.addEventListener('keydown', onMergeKey, false) // 轮询选中变化,用来配对"先后点过的两条线" setInterval(pollSelection, 200) if (typeof GM_registerMenuCommand === 'function') { try { GM_registerMenuCommand('线段一分为二:打开控制面板', () => UI.setVisible(true)) GM_registerMenuCommand('线段一分为二:立即拆分选中线段', () => actions.split()) GM_registerMenuCommand('线段一分为二:回退上一次拆分', () => actions.undo()) GM_registerMenuCommand('线段二合一:合并刚点选的两条线 (Shift+N)', () => { pollSelection(); actions.merge() }) GM_registerMenuCommand('线段一分为二:拾取地面高度 (Ctrl+Q)', () => actions.pick()) GM_registerMenuCommand('线段一分为二:诊断连接状态', () => UI.showDiag()) } catch (e) { /* 忽略 */ } } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot) else boot() // ── 调试入口 ─────────────────────────────────────────────────────────────── window.__lineSplit = { split: splitSelectedLine, merge: mergeTwoLines, undo: undoLastSplit, recent: recent, mergeArmed: function () { return mergeArmed }, mergePick: mergePick, mergePair: getMergePair, pickGround: triggerPickGround, diagnose: diagnose, panel: UI, ctx: ctx, prefs: prefs, splitPoint: splitPoint, planSplitAt: planSplitAt, planSplit: planSplit, store: getStore, viewer: getViewer, selected: getSelected3DLine, } // pickDiag 每次点选都会重新赋值,必须用 getter 取当前值,否则拿到的是旧快照 Object.defineProperty(window.__lineSplit, 'pickDiag', { get: function () { return lastPickDiag } }) })()