// ==UserScript== // @name 点云车道线工具 // @namespace http://tampermonkey.net/ // @version 1.2.0 // @description 吸附 + 矩形条带染色(真实分割) + 反转线序(持久化) + 拓扑判定/自动生成拓扑线 // @author You // @match *://*/* // @grant none // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const CFG = { snapDownXY: 0.4, // 正下方搜索的初始XY半径(米) snapDownMaxR: 2.0, // 正下方搜索的最大XY半径(米) colorWidth: 0.05, // 每条线左右各染色宽度(米),默认 0.05 laneColor: '#ff0000', // 平台取不到类颜色时的回退色 segClassName: '', // 真实写入的分割类别名(留空则用 lane 类名) segInstance: '1', // 分割实例号(类名-instance,如 line-1) topoMainClass: 'line', // 拓扑主线类别名(对应 Python 的 class=="line") topoClass: 'topo', // 拓扑辅助线类别名(对应 Python 的 class=="topo") topoEpsilon: 0.01, // 端点重合容差 topoMaxDist: 0, // 最大连接距离(米,0=不限,防止跨区乱连) pollInterval: 1000, }; // 分割染色(sseColor + categoryID)状态:仅记录被染色的点,清除时按原值还原 let coloredBand = new Map(); // pointIdx -> {cat, c0, c1, c2} let prevSseEnable = null; let segUndo = null; // 真实分割标注的快照,用于"清除染色"还原 {frameId, result, statistic, attributes} function waitForReady(cb) { const t = setInterval(() => { if (window.viewer && window.viewer.points && window.instance) { clearInterval(t); setTimeout(cb, 2500); } }, CFG.pollInterval); } function getSM() { try { if (window.instance && window.instance.StoreManager) return window.instance.StoreManager; } catch (e) { } try { const app = document.querySelector('#app')?.__vue_app__; if (app?.config?.globalProperties?.$store) { const s = app.config.globalProperties.$store; return { marksState: () => s.state.marks, statusState: () => s.state.status, configState: () => s.state.config, segState: () => s.state.segmentation, marksGetter: (g) => s.getters['marks/' + g], configGetter: (g) => s.getters['config/' + g], segGetter: (g) => s.getters['segmentation/' + g], getCurFrameId: () => s.state.status.curFrameId, }; } } catch (e) { } return null; } function getPosArr() { return window.viewer?.cloudData?.positionArray || window.viewer?.points?.geometry?.attributes?.position?.array || null; } function getColorAttr() { return window.viewer?.points?.geometry?.attributes?.color || null; } // 只取当前帧的 3D 标注数据;绝不回退到其他帧(避免把其他帧的线染到当前帧) function getCurFrame3D(mk, frameId) { const d3 = mk && mk['3d']; if (!d3 || typeof d3 !== 'object') return null; return d3[frameId] || d3[String(frameId)] || null; } function getPointsFromMark(mk, frameId) { const frameData = getCurFrame3D(mk, frameId); if (!frameData) return null; const raw = frameData.points; if (!raw || !Array.isArray(raw) || raw.length < 2) return null; const pts = []; for (const p of raw) { if (Array.isArray(p) && p.length >= 3) pts.push(p); } return pts.length >= 2 ? pts : null; } function getAllLanePoints(sm) { const marks = sm.marksState().marks; const frameId = sm.getCurFrameId ? sm.getCurFrameId() : sm.statusState().curFrameId; const result = []; if (!marks) return result; for (const tid in marks) { const mk = marks[tid]; const frameData = getCurFrame3D(mk, frameId); if (!frameData || frameData.type !== 'line_3d') continue; const pts = getPointsFromMark(mk, frameId); if (pts) { result.push({ trackId: tid, classId: mk.class ?? mk.classId ?? -1, className: mk.class, points: pts, }); } } return result; } function unwrap(v) { if (v === null || v === undefined) return null; if (typeof v === 'number') return v; if (typeof v === 'string') { const n = parseFloat(v); return isNaN(n) ? null : n; } if (typeof v === 'object') { if ('value' in v) v = v.value; else if ('z' in v) v = v.z; else if ('height' in v) v = v.height; else if (typeof v.toString === 'function') v = v.toString(); } if (typeof v === 'number') return v; if (typeof v === 'string') { const n = parseFloat(v); return isNaN(n) ? null : n; } return null; } function dist2D(ax, ay, bx, by) { return Math.sqrt((ax - bx) ** 2 + (ay - by) ** 2); } function dist3D(ax, ay, az, bx, by, bz) { return Math.sqrt((ax - bx) ** 2 + (ay - by) ** 2 + (az - bz) ** 2); } function pointToSegDist3D(px, py, pz, ax, ay, az, bx, by, bz) { const dx = bx - ax, dy = by - ay, dz = bz - az; const len2 = dx * dx + dy * dy + dz * dz; if (len2 < 1e-12) return dist3D(px, py, pz, ax, ay, az); let t = ((px - ax) * dx + (py - ay) * dy + (pz - az) * dz) / len2; t = Math.max(0, Math.min(1, t)); return dist3D(px, py, pz, ax + t * dx, ay + t * dy, az + t * dz); } // 矩形条带(平头端):投影落在线段范围内,且垂直距离 <= halfW,排除圆头 function pointInRectBand(px, py, pz, ax, ay, az, bx, by, bz, halfW) { const dx = bx - ax, dy = by - ay, dz = bz - az; const len2 = dx * dx + dy * dy + dz * dz; if (len2 < 1e-12) { const ex = px - ax, ey = py - ay, ez = pz - az; return ex * ex + ey * ey + ez * ez <= halfW * halfW; } let t = ((px - ax) * dx + (py - ay) * dy + (pz - az) * dz) / len2; if (t < 0 || t > 1) return false; // 超出线段两端 -> 正方头,不圆 const cx = ax + t * dx, cy = ay + t * dy, cz = az + t * dz; const ex = px - cx, ey = py - cy, ez = pz - cz; return ex * ex + ey * ey + ez * ez <= halfW * halfW; } function getGroundHeight(sm, pos) { try { const raw = sm.statusState().groundHeight; const gh = unwrap(raw); if (gh !== null && !isNaN(gh) && gh !== 0) { console.log('[车道线工具] 使用 store groundHeight:', gh); return gh; } } catch (e) { } const zArr = []; for (let i = 2; i < pos.length; i += 3) zArr.push(pos[i]); zArr.sort((a, b) => a - b); const z5 = zArr[Math.floor(zArr.length * 0.05)]; console.log('[车道线工具] 自动计算 groundHeight (Z 5%):', z5); return z5; } /* ==================== 分割染色辅助(sseColor + categoryID) ==================== */ // 读取该车道线类别配置的颜色(平台自带,无需手动选色) function getLaneClassColor(sm, className) { try { const getter = sm?.segGetter ? sm.segGetter('getColor') : null; if (getter && className) { const c = getter(className, ''); if (c && typeof c === 'object') { const r = c.r, g = c.g, b = c.b; if (typeof r === 'number' && typeof g === 'number' && typeof b === 'number') { return { r, g, b, usedClassColor: true }; } } } } catch (e) { } const h = hexToRGB(CFG.laneColor); return { r: h.r, g: h.g, b: h.b, usedClassColor: false }; } // 确保点云有 sseColor/categoryID 属性(缺失时用真正的 THREE.BufferAttribute 创建) function ensureSegAttrs(pos) { const geo = window.viewer?.points?.geometry; if (!geo) return null; const N = Math.floor(pos.length / 3); let categoryID = geo.attributes?.categoryID; let sseColor = geo.attributes?.sseColor; const BA = (window.__THREE__ && window.__THREE__.BufferAttribute) || (window.THREE && window.THREE.BufferAttribute); if (!categoryID) { const a = new Float32Array(N); a.fill(0); categoryID = BA ? new BA(a, 1) : { array: a, itemSize: 1, count: N, normalized: false }; geo.setAttribute('categoryID', categoryID); } if (!sseColor) { const a = new Float32Array(N * 3); a.fill(1); sseColor = BA ? new BA(a, 3) : { array: a, itemSize: 3, count: N, normalized: false }; geo.setAttribute('sseColor', sseColor); } return { sseColor, categoryID }; } // 切换分割着色开关(只改着色器 uniform,不动平台的 seg 状态) function setSseEnable(v) { try { if (window.viewer?.mainUniforms?.sseEnable) window.viewer.mainUniforms.sseEnable.value = !!v; } catch (e) { } try { if (window.viewer?.mutlUniforms?.sseEnable) window.viewer.mutlUniforms.sseEnable.value = !!v; } catch (e) { } } // 获取原始 Vuex store(用于 commit segmentation/updateOriginal + change 真实写入) function getRawStore() { try { const app = document.querySelector('#app')?.__vue_app__; if (app?.config?.globalProperties?.$store) return app.config.globalProperties.$store; } catch (e) { } return null; } // 把索引点写为真实分割标注(分配到 className 类别),并写入平台 seg 数据(segs/original) // 返回 {ok, count, reason};失败时由调用方回退到仅预览显示。 function paintBandAsSeg(sm, className, idxList, col) { const sseEditor = window.viewer?.sseEditor; if (!sseEditor || !sm) return { ok: false, reason: 'noEditor' }; if (!(sseEditor.dataEnable && sseEditor.isInit)) return { ok: false, reason: 'segNotLoaded' }; const frameId = sm.getCurFrameId ? sm.getCurFrameId() : sm.statusState().curFrameId; let original = null, segState = null; try { original = (sm.segGetter ? sm.segGetter('getOriginal') : null) || null; segState = sm.segState ? sm.segState() : null; } catch (e) { try { original = null; segState = sm.segState ? sm.segState() : null; } catch (e2) { return { ok: false, reason: 'noData' }; } } if (!original || !original[frameId] || !Array.isArray(original[frameId][0])) return { ok: false, reason: 'noOriginal' }; const catNames = original[frameId][0]; const instIds = Array.isArray(original[frameId][1]) ? original[frameId][1] : (original[frameId][1] = catNames.map(() => '')); const instance = CFG.segInstance || '1'; const fqName = className + '-' + instance; const preSnapshot = { frameId: frameId, result: [catNames.slice(0), (instIds || []).slice(0)], attributes: (segState && segState.attributes && segState.attributes[frameId]) || {}, count: idxList.length, }; for (const idx of idxList) { catNames[idx] = fqName; if (instIds) instIds[idx] = instance; } const stat = {}; for (let i = 0; i < catNames.length; i++) { const v = String(catNames[i] || ''); if (!v) continue; const cls = v.split('-')[0]; if (!stat[cls]) stat[cls] = { num: 0, instances: {} }; stat[cls].num++; } const store = getRawStore(); try { if (!(store && typeof store.commit === 'function')) return { ok: false, reason: 'noStore' }; store.commit('segmentation/updateOriginal', [frameId, catNames, instIds]); store.commit('segmentation/change', [stat, frameId]); try { store.commit('segmentation/updateAttributes', [fqName, { id: ['0'] }]); } catch (e) { } if (!segUndo) segUndo = preSnapshot; } catch (e) { return { ok: false, reason: 'commitFail' }; } const pos = getPosArr(); const attrs = pos ? ensureSegAttrs(pos) : null; if (attrs) { const cat = attrs.categoryID.array, sse = attrs.sseColor.array; for (const idx of idxList) { if (!coloredBand.has(idx)) { const o = idx * 3; coloredBand.set(idx, { cat: cat[idx], c0: sse[o], c1: sse[o + 1], c2: sse[o + 2] }); } cat[idx] = 1; const o = idx * 3; sse[o] = col.r; sse[o + 1] = col.g; sse[o + 2] = col.b; } attrs.sseColor.needsUpdate = true; attrs.categoryID.needsUpdate = true; } if (prevSseEnable === null) { try { prevSseEnable = !!window.viewer?.mainUniforms?.sseEnable?.value; } catch (e) { prevSseEnable = false; } } setSseEnable(true); return { ok: true, count: idxList.length }; } /* ==================== 功能1:一键吸附到地面(正下方) ==================== */ function findGroundZBelow(pos, x, y, startR, maxR) { let r = startR; while (r <= maxR + 1e-6) { const r2 = r * r; let minZ = Infinity, found = false; for (let i = 0; i < pos.length; i += 3) { const dx = pos[i] - x, dy = pos[i + 1] - y; if (dx * dx + dy * dy <= r2) { const pz = pos[i + 2]; if (pz < minZ) { minZ = pz; found = true; } } } if (found) return minZ; r += startR; } return null; } function findMarkByTrackId(rootObj, trackId) { if (!rootObj || !rootObj.children) return null; const queue = [...rootObj.children]; for (let i = 0; i < queue.length; i++) { const o = queue[i]; if (o && o.trackId == trackId) return o; if (o && o.children) queue.push(...o.children); } return null; } function rebuildMarkLine(trackId, mark) { try { const viewer = window.viewer; const roots = [viewer?.lineScene, viewer?.volumeScene].filter(Boolean); for (const root of roots) { const o = findMarkByTrackId(root, trackId); if (o) { o.mark = mark; if (typeof o.initChildren === 'function') o.initChildren(); break; } } } catch (e) { } } function snapLaneToGround() { const sm = getSM(); const pos = getPosArr(); if (!sm) return showError('无法访问 StoreManager'); if (!pos) return showError('无法访问点云数据'); const marks = sm.marksState().marks; const frameId = sm.getCurFrameId ? sm.getCurFrameId() : sm.statusState().curFrameId; if (!marks) return showInfo('未获取到标注数据'); const lanes = []; for (const tid in marks) { const mk = marks[tid]; const fd = getCurFrame3D(mk, frameId); if (!fd || fd.type !== 'line_3d') continue; const pts = fd.points; if (!Array.isArray(pts) || pts.length < 2) continue; lanes.push({ trackId: tid, classId: mk.class ?? mk.classId ?? -1, frameData: fd, points: pts }); } if (lanes.length === 0) return showInfo('未找到 line_3d 类型的车道线标注'); const xyTol = CFG.snapDownXY; const maxR = CFG.snapDownMaxR; let snapped = 0, skipped = 0, notFound = 0; const unSnapped = []; for (const lane of lanes) { for (let pi = 0; pi < lane.points.length; pi++) { const pt = lane.points[pi]; if (!Array.isArray(pt) || pt.length < 3) continue; const x = pt[0], y = pt[1], oldZ = pt[2]; const gz = findGroundZBelow(pos, x, y, xyTol, maxR); if (gz === null) { notFound++; unSnapped.push({ trackId: lane.trackId, classId: lane.classId, pi, x, y, z: oldZ }); continue; } if (Math.abs(gz - oldZ) < 0.01) { skipped++; continue; } pt[2] = gz; snapped++; } rebuildMarkLine(lane.trackId, lane.frameData); persistMarkChange(lane.trackId, frameId); } forceRefresh(); renderUnsnapped(unSnapped); showInfo('吸附完成:已吸附 ' + snapped + ' 点,跳过(已在地面) ' + skipped + ' 点,未找到 ' + notFound + ' 点'); } /* ==================== 功能2:车道线矩形条带染色 ==================== */ function colorLaneArea() { const sm = getSM(); const pos = getPosArr(); if (!sm) return showError('无法访问 StoreManager'); if (!pos) return showError('无法访问点云数据'); const lanes = getAllLanePoints(sm); if (lanes.length === 0) return showInfo('未找到 line_3d 类型的车道线标注'); const segments = []; let totalPoints = 0; for (const lane of lanes) { const pts = lane.points; totalPoints += pts.length; for (let i = 0; i < pts.length - 1; i++) { segments.push({ ax: pts[i][0], ay: pts[i][1], az: pts[i][2], bx: pts[i + 1][0], by: pts[i + 1][1], bz: pts[i + 1][2], }); } } if (segments.length === 0) return showInfo('车道线点数不足'); const col = getLaneClassColor(sm, lanes[0].className); const threshold = CFG.colorWidth; const band = []; for (let i = 0; i < pos.length; i += 3) { const px = pos[i], py = pos[i + 1], pz = pos[i + 2]; for (const seg of segments) { if (pointInRectBand(px, py, pz, seg.ax, seg.ay, seg.az, seg.bx, seg.by, seg.bz, threshold)) { band.push(i / 3); break; } } } if (band.length === 0) return showInfo('条带范围内没有点'); const writeClass = (CFG.segClassName || '').trim() || lanes[0].className || ''; let realOk = false, realReason = ''; if (writeClass) { const res = paintBandAsSeg(sm, writeClass, band, col); realOk = res.ok; realReason = res.reason || ''; } if (!realOk) { const attrs = ensureSegAttrs(pos); if (!attrs) return showError('无法访问点云几何'); const cat = attrs.categoryID.array, sse = attrs.sseColor.array; for (const idx of band) { if (!coloredBand.has(idx)) { const o = idx * 3; coloredBand.set(idx, { cat: cat[idx], c0: sse[o], c1: sse[o + 1], c2: sse[o + 2] }); } cat[idx] = 1; const o = idx * 3; sse[o] = col.r; sse[o + 1] = col.g; sse[o + 2] = col.b; } attrs.sseColor.needsUpdate = true; attrs.categoryID.needsUpdate = true; if (prevSseEnable === null) { try { prevSseEnable = !!window.viewer?.mainUniforms?.sseEnable?.value; } catch (e) { prevSseEnable = false; } } setSseEnable(true); } forceRefresh(); if (realOk) { showInfo('染色完成:' + band.length + ' 个点,每侧 ' + threshold + ' 米,已写入真实分割(' + writeClass + ')'); } else { showInfo('染色完成:' + band.length + ' 个点,每侧 ' + threshold + ' 米(仅预览,分割未加载:' + realReason + ')'); } } /* ==================== 功能:车道线拓扑生成(仅判定吸附,固定主线/辅线类) ==================== */ function checkTopoSnap() { const sm = getSM(); if (!sm) return showError('无法访问 StoreManager'); const marks = sm.marksState().marks; const frameId = sm.getCurFrameId ? sm.getCurFrameId() : sm.statusState().curFrameId; if (!marks) return showInfo('未获取到标注数据'); const mainCls = (CFG.topoMainClass || 'line').trim(); const topoCls = (CFG.topoClass || 'topo').trim(); const EPS = CFG.topoEpsilon || 0.01; const mainPts = []; const topoLines = []; for (const tid in marks) { const mk = marks[tid]; const fd = getCurFrame3D(mk, frameId); if (!fd || fd.type !== 'line_3d' || !fd.points || fd.points.length < 2) continue; const cls = mk.class; if (cls === mainCls) mainPts.push({ trackId: tid, pts: fd.points }); else if (cls === topoCls) topoLines.push({ trackId: tid, pts: fd.points }); } if (topoLines.length === 0) return showInfo('未找到辅助线类别(' + topoCls + ')的 topo 线'); if (mainPts.length === 0) return showInfo('未找到主线类别(' + mainCls + ')的车道线'); const ptDist = (a, b) => Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2); const matchIn = (target) => { const hits = []; for (const m of mainPts) { for (let i = 0; i < m.pts.length; i++) { if (ptDist(target, m.pts[i]) < EPS) hits.push(m.trackId + ' 点#' + i); } } return hits; }; let okCount = 0, badCount = 0; for (const t of topoLines) { const sH = matchIn(t.pts[0]); const eH = matchIn(t.pts[t.pts.length - 1]); if (sH.length > 0 && eH.length > 0) okCount++; else badCount++; } showInfo('拓扑判定:主线 ' + mainPts.length + ' 条 | topo线 ' + topoLines.length + ' 条 | 吸附OK ' + okCount + ' | 未吸附 ' + badCount); } /* ==================== 自动生成拓扑线 ==================== */ // 依据采集到的平台行为:addOrReplaceMark3D [0, mark, trackId, "topo", frameId, null] // mark = {type:"line_3d", visual_type:0, attrs:{}, points:[[...],[...]], id:""},id 留空自动生成, // 平台会据此自动计算 2D 投影(BUS_PROJECTION_COMPUTE)。 function generateTopoLanes() { const sm = getSM(); if (!sm) return showError('无法访问 StoreManager'); const markState = sm.marksState(); const frameId = sm.getCurFrameId ? sm.getCurFrameId() : sm.statusState().curFrameId; const marks = markState && markState.marks; if (!marks) return showError('未获取到标注数据'); const mainCls = (CFG.topoMainClass || 'line').trim(); const topoCls = (CFG.topoClass || 'topo').trim(); const EPS = CFG.topoEpsilon || 0.01; const maxDist = CFG.topoMaxDist || 0; const near = (a, b) => Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) < EPS; const ptDist = (a, b) => Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2); const lanes = []; const existingTopo = []; for (const tid in marks) { const mk = marks[tid]; const fd = getCurFrame3D(mk, frameId); if (!fd || fd.type !== 'line_3d' || !fd.points || fd.points.length < 2) continue; const cls = mk.class; if (cls === mainCls) { lanes.push({ trackId: tid, points: fd.points, start: fd.points[0], end: fd.points[fd.points.length - 1] }); } else if (cls === topoCls) { existingTopo.push({ s: fd.points[0], e: fd.points[fd.points.length - 1] }); } } if (lanes.length < 2) return showError('主线(' + mainCls + ')不足2条,无法生成 top 线'); // 贪心最近邻匹配:每条线"终点"连到其余线中最近的"起点", // 每条线至多一个前驱/后继,从而避免按 id 顺序连接导致的交叉乱连。 const edges = []; for (let i = 0; i < lanes.length; i++) { for (let j = 0; j < lanes.length; j++) { if (i === j) continue; const d = ptDist(lanes[i].end, lanes[j].start); if (maxDist > 0 && d > maxDist) continue; edges.push({ from: i, to: j, d: d }); } } edges.sort((a, b) => a.d - b.d); const usedFrom = new Set(), usedTo = new Set(); const succ = {}, pred = {}; const wouldCycle = (from, to) => { let cur = to, seen = new Set(); while (cur !== undefined) { if (cur === from) return true; if (seen.has(cur)) return true; seen.add(cur); cur = succ[cur]; } return false; }; const conns = []; for (const e of edges) { if (usedFrom.has(e.from) || usedTo.has(e.to) || wouldCycle(e.from, e.to)) continue; usedFrom.add(e.from); usedTo.add(e.to); succ[e.from] = e.to; pred[e.to] = e.from; conns.push(e); } // 生成下一个可用 trackId(数字自增) let nextTrack = 1; for (const k in marks) { const n = parseInt(k, 10); if (!isNaN(n) && n >= nextTrack) nextTrack = n + 1; } let created = 0, skipped = 0; for (const c of conns) { const a = lanes[c.from], b = lanes[c.to]; const startPt = a.end; // 前一条终点 const endPt = b.start; // 后一条起点 const dup = existingTopo.some(t => (near(t.s, startPt) && near(t.e, endPt)) || (near(t.s, endPt) && near(t.e, startPt))); if (dup) { skipped++; continue; } const mark = { type: 'line_3d', visual_type: 0, attrs: {}, points: [startPt, endPt], id: '' }; try { sm.markCommit('addOrReplaceMark3D', [0, mark, String(nextTrack), topoCls, frameId, null]); nextTrack++; created++; } catch (e) { } } showInfo('已生成 ' + created + ' 条 top 线(候选 ' + conns.length + ',跳过已有 ' + skipped + ' 条)'); } /* ==================== 功能:反转所选车道线的点顺序(持久化) ==================== */ function reverseSelectedLane() { const sm = getSM(); if (!sm) return showError('无法访问 StoreManager'); const frameId = sm.getCurFrameId ? sm.getCurFrameId() : sm.statusState().curFrameId; const selectInfo = sm.statusState().selectMarkInfo; const tid = selectInfo && selectInfo.trackId; if (!tid) return showInfo('请先用平台选中一条车道线,再点"反转线序"'); const marks = sm.marksState().marks; const mk = marks[tid]; if (!mk) return showInfo('未找到所选标注'); const frameData = getCurFrame3D(mk, frameId); if (!frameData || frameData.type !== 'line_3d' || !Array.isArray(frameData.points) || frameData.points.length < 2) { return showInfo('所选标注不是当前帧的 line_3d 车道线,无法反转'); } frameData.points.reverse(); // 同步可能用于显示/保存的其他 points 存放位置 if (Array.isArray(mk.points) && mk.points !== frameData.points) mk.points = frameData.points; rebuildMarkLine(tid, frameData); persistMarkChange(tid, frameId); forceRefresh(); showInfo('已反转所选车道线(' + tid + ')的点顺序,共 ' + frameData.points.length + ' 个点,保存后持久化'); } // 把标注变更提交给平台,使其可被保存持久化 // 用平台正规的"修改3D点"mutation(MARK_ACTION_MODIFY_POINTS=3)持久化: // 会递增 curVer 使保存重新上传,并触发 BUS_ADD_MODIFY_3D 重建显示。 function persistMarkChange(tid, frameId) { const sm = getSM(); const marks = sm && sm.marksState().marks; const mk = marks && marks[tid]; if (!mk) return; const fd = getCurFrame3D(mk, frameId); if (!fd || !Array.isArray(fd.points)) return; const newMark = Object.assign({}, fd, { points: fd.points.slice() }); const className = mk.class; const store = getRawStore(); if (sm && typeof sm.markCommit === 'function') { try { sm.markCommit('addOrReplaceMark3D', [3, newMark, tid, className, frameId, null]); return; } catch (e) { } } try { if (store && typeof store.commit === 'function') store.commit('marks/addOrReplaceMark3D', [3, newMark, tid, className, frameId, null]); } catch (e) { } } /* ==================== 在标注界面定位到点(移动 3D 相机焦点) ==================== */ function focusPoint(x, y, z) { const V = window.__THREE__ || window.THREE; const cam = window.viewer?.camera; const controls = window.viewer?.controls || window.viewer?.orbitControls || window.viewer?.trackballControls; if (cam && V && controls && controls.target) { const t = controls.target; const dir = new V.Vector3().subVectors(cam.position, t); let d = dir.length(); if (d < 1 || !isFinite(d)) d = 30; dir.normalize(); t.set(x, y, z); cam.position.set(x + dir.x * d, y + dir.y * d, z + dir.z * d); if (typeof controls.update === 'function') controls.update(); } else if (cam && V && typeof cam.lookAt === 'function') { cam.lookAt(new V.Vector3(x, y, z)); } else { showInfo('无法定位:未找到相机控制器'); return; } forceRefresh(); } /* ==================== 提示 / 错误显示 ==================== */ function toast(msg, type) { let t = document.getElementById('lt-toast'); if (!t) { t = document.createElement('div'); t.id = 'lt-toast'; t.style.cssText = 'position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:1000000;padding:10px 18px;border-radius:8px;font-size:13px;font-family:-apple-system,"PingFang SC",sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.4);max-width:80%;transition:opacity .3s;pointer-events:none;white-space:pre-wrap;text-align:center;'; document.body.appendChild(t); } t.style.background = type === 'err' ? '#f38ba8' : '#a6e3a1'; t.style.color = '#1e1e2e'; t.textContent = msg; t.style.opacity = '1'; clearTimeout(t._timer); t._timer = setTimeout(() => { t.style.opacity = '0'; }, 2800); } function setStatus(msg, type) { const el = document.getElementById('lt-status'); if (!el) return; el.style.display = 'block'; el.textContent = (type === 'err' ? '错误:' : '') + msg; el.style.color = type === 'err' ? '#f38ba8' : '#a6adc8'; el.style.borderColor = type === 'err' ? '#f38ba8' : '#45475a'; } function showError(msg) { setStatus(msg, 'err'); toast(msg, 'err'); } function showInfo(msg) { toast(msg, 'info'); } function renderUnsnapped(list) { const title = document.getElementById('lt-unlist-title'); const box = document.getElementById('lt-unlist'); if (!box) return; box.innerHTML = ''; if (!list || list.length === 0) { if (title) title.style.display = 'none'; box.style.display = 'none'; return; } if (title) title.style.display = 'block'; box.style.display = 'block'; list.forEach((it) => { const d = document.createElement('div'); d.className = 'lt-list-item'; d.textContent = '车道线 ' + it.trackId + ' 点#' + it.pi + ' (' + it.x.toFixed(2) + ', ' + it.y.toFixed(2) + ')'; d.addEventListener('click', () => { focusPoint(it.x, it.y, it.z); showInfo('已定位到 车道线 ' + it.trackId + ' 点#' + it.pi); }); box.appendChild(d); }); } function forceRefresh() { try { const pts = window.viewer?.points; if (pts?.geometry) { for (const key of Object.keys(pts.geometry.attributes)) { pts.geometry.attributes[key].needsUpdate = true; } pts.geometry.computeBoundingSphere(); pts.geometry.computeBoundingBox(); } } catch (e) { } try { window.viewer?.scene?.traverse?.(o => { if (o.material) o.material.needsUpdate = true; }); } catch (e) { } try { if (typeof window.viewer?.rendererMain === 'function') window.viewer.rendererMain(); else if (typeof window.viewer?.renderer === 'function') window.viewer.renderer(); } catch (e) { } try { window.dispatchEvent(new Event('resize')); } catch (e) { } } function hexToRGB(hex) { hex = hex.replace('#', ''); return { r: parseInt(hex.substring(0, 2), 16) / 255, g: parseInt(hex.substring(2, 4), 16) / 255, b: parseInt(hex.substring(4, 6), 16) / 255, }; } /* ==================== UI(正方形紧凑卡片) ==================== */ function createPanel() { const panel = document.createElement('div'); panel.id = 'lane-tools-panel'; panel.innerHTML = `