// ==UserScript== // @name 标注增强 · 选点改属性 + 黄线±N米高亮 // @namespace https://github.com/local/od-annotation-enhance // @version 4.3.1 // @description 1) 改属性:平台工具选点(可多框累加)+S 只改选中点+撤销,已选点灰色显示;单击某点会显示它**距黄线多少米**;2) 黄线±N米:距黄线 N 米内的点全部高亮(点本体=原点云颜色,外圈白色描边标识,可用 config.hlOutline 关);3) 面板三栏统一配色;__adge.diag()/diagStraddle()。 // @author ZCode // @match *://*/pointcloud/* // @run-at document-start // @grant none // @noframes // ==/UserScript== /* * 数据模型(逆向 + 实测自平台 bundle / 任务 382044): * - 点级语义分割任务,无立体框。每点类别:viewer.sseEditor.categoryNameArray[i],形如 "truck-9"(类别-实例号), * 下标与 viewer.cloudData.positionArray(世界坐标 xyz 扁平数组)一一对应。 * - 类别清单:StoreManager.configState().classes.segmentation.listClass[classId].ptitle。 * 平台中文用 "⻋"(U+2ECB) 而非 "车"(U+8F66),故一律按英文 classId 处理。 * - 「标注范围」黄线:configState().config.mark_region.rect[0] = {x:[min,max], y:[min,max]}(地面XY矩形,无高度)。 * - 改类原语:SseEditor.coverIndexFromSelection(i) 把点 i 改写为 categoryInfo.pname-实例; * 默认“新增模式”只写未标注点,改已标注点必须用“覆盖模式”。 * - 聚光灯裁剪后,裁剪区域内 = hiddenIndices[i]===0 的点。 */ (function () { 'use strict'; // rAF 兼容包装(浏览器用 window.requestAnimationFrame,node/异常环境回退 setTimeout) const RAF = (function () { try { if (typeof window !== 'undefined' && window.requestAnimationFrame) return window.requestAnimationFrame.bind(window); } catch (e) {} try { if (typeof requestAnimationFrame === 'function') return requestAnimationFrame; } catch (e) {} return function (fn) { return setTimeout(function () { fn(Date.now()); }, 16); }; })(); const CAF = (function () { try { if (typeof window !== 'undefined' && window.cancelAnimationFrame) return window.cancelAnimationFrame.bind(window); } catch (e) {} try { if (typeof cancelAnimationFrame === 'function') return cancelAnimationFrame; } catch (e) {} return function (id) { try { clearTimeout(id); } catch (e) {} }; })(); /* ================================================================== * * 配置 / 状态 * ================================================================== */ const CONFIG = { keepCrop: true, band: 3, // 黄线内外各扩 band 米(高亮用) safetyMax: 100, // 单次改类超过该点数需二次确认(0=不限制) hlRadius: 3.5, // 高亮点半径(px) hlOutline: true, // 高亮点是否加白色描边(点本体颜色始终=原点云颜色) }; const STATE = { cropActive: false, cropPolygon: null, // 本次裁剪的多边形(屏幕坐标) cropIndices: null, // 裁剪瞬间精确锁定的框内点下标 cropMask: null, cropSelCount: 0, // 锁定的点数 cropTotal: 0, // 总点数 cropFrameId: null, // 裁剪发生时的帧号;切帧后作废 // ★ 选区:以“点下标”表示的集合 —— 与相机无关,缩放/旋转都不影响。 // 来源:聚光灯裁剪(替换)或 单击累积(增删)。S 只改这个集合。 selSet: null, selFrameId: null, accumMode: false, // 单击累积模式:单击点=加入/移出选区 multiBox: true, // 多框累加:平台工具每次框选的结果累加(而非替换),最后按 S 一次改 baselineVisible: 0, // 选区操作前的可见点数(安全阀分母) // ★ 平台工具选区:拦截平台的 add/cover/removeIndexFromSelection, // 记录“平台画笔/矩形/多边形/套索”本次操作触碰了哪些点。 // 这是最可靠的来源 —— 与相机无关、与工具无关,平台选了什么就是什么。 platSelPatched: false, __adgeInternalCall: false, // 插件自身操作时置位,避免被平台选区捕获误记 lastClickT: 0, lastClickX: 0, lastClickY: 0, undoSnapshot: null, // 上次改类前的 { cat, inst } 快照,用于撤销 confirmedLarge: false, confirmedCount: false, selSource: null, // 选区来源:'crop' | 'poly' | 'click' —— 决定多边形框选是累加还是重新开始 hlBand: null, // 黄线 ±N 米内的点 [{i,r,g,b}](按点云原色绘制,持久) hlObjects: null, // 最近一次高亮的统计信息 hlMask: null, // 强高亮:被写入 colorArray 的点 -> 原色(取消时还原) hlLoopOn: false, // 持久重绘循环开关 hlRaf: null, hlCanvas: null, // 当前叠加画布引用 __adgeInternalSelect: false, spotlightPatched: false, classList: null, // [{classId, ptitle}] panelBuilt: false, minimized: false, clickPick: true, // 单击选点(原生需 Alt+单击)——默认开启 clickRelabel: false, // 单击即把该点改为下拉所选类别 clickPatched: false, pickedIndex: -1, // 最近一次选中的点 _colorCtor: null, // THREE.Color 构造器(懒获取) hotkeyInstalled: false, downX: null, downY: null, }; /* ================================================================== * * 基础工具 * ================================================================== */ function waitFor(cond, timeout, interval) { timeout = timeout == null ? 30000 : timeout; interval = interval == null ? 200 : interval; return new Promise(function (resolve) { if (cond()) return resolve(true); const start = Date.now(); const t = setInterval(function () { if (cond()) { clearInterval(t); resolve(true); } else if (Date.now() - start > timeout) { clearInterval(t); resolve(false); } }, interval); }); } function toast(text) { try { let host = document.getElementById('_adge_toast'); if (!host) { host = document.createElement('div'); host.id = '_adge_toast'; host.style.cssText = 'position:fixed;top:72px;left:50%;transform:translateX(-50%);z-index:2147483600;' + 'background:rgba(23,28,40,.96);color:#e8eaed;padding:9px 18px;border-radius:6px;' + 'border:1px solid #2a3145;font-size:13px;line-height:1.5;max-width:72vw;' + 'font-family:system-ui,"Microsoft YaHei",sans-serif;pointer-events:none;opacity:0;' + 'transition:opacity .18s;box-shadow:0 8px 24px rgba(0,0,0,.45);'; document.body.appendChild(host); } host.textContent = text; host.style.opacity = '1'; clearTimeout(host._t); host._t = setTimeout(function () { host.style.opacity = '0'; }, 3200); } catch (e) {} } const log = function () { console.log.apply(console, ['[标注增强]'].concat(Array.prototype.slice.call(arguments))); }; function getMS() { try { return window.instance && window.instance.StoreManager; } catch (e) { return null; } } function getViewer() { try { return window.instance && window.instance.viewer; } catch (e) { return null; } } function getEditor() { const v = getViewer(); return v ? v.sseEditor : null; } function getFrameId() { const ms = getMS(); try { return ms ? ms.getCurFrameId() : null; } catch (e) { return null; } } function baseClassOf(name) { return String(name || '').split('-')[0]; } function instanceOf(name) { const p = String(name || '').split('-'); return p.length > 1 ? p[1] : '0'; } // 读取类别清单(classId + 中文名) function loadClassList() { if (STATE.classList) return STATE.classList; try { const ms = getMS(); const cat = ms.configState().classes && ms.configState().classes.segmentation; const list = cat && cat.listClass; if (!list) return null; const arr = []; for (const cid in list) arr.push({ classId: cid, ptitle: (list[cid] && list[cid].ptitle) || cid }); STATE.classList = arr; return arr; } catch (e) { return null; } } /* ================================================================== * * 功能一:聚光灯裁剪后可继续编辑 * ================================================================== */ // 偶奇规则:点 (x,y) 是否在多边形 poly=[[x,y],...] 内(屏幕坐标) function pointInPoly(x, y, poly) { let inside = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const xi = poly[i][0], yi = poly[i][1]; const xj = poly[j][0], yj = poly[j][1]; const inter = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi); if (inter) inside = !inside; } return inside; } // 把“多边形圈选”翻译成点下标集合:**对每个点做相机投影**,再判是否落在屏幕多边形内。 // // 为什么不复用平台的 updatePixelProjection:它先用一个“中景深度 OBB 盒子”把所有点 // 预筛一遍(halfSize 只按中景深度的横向范围),凡是离相机较远、但屏幕上确实落在框内的点 // 会被这一步静默丢掉 —— 这就是“框内的点有些没被改”的根因。这里改为逐点投影,不做任何预裁剪, // 结果与屏幕上画出的框完全一致。 function polygonSelectedIndices(ed, polygon, opts) { opts = opts || {}; const viewer = getViewer(); const cam = viewer && viewer.camera; const pos = (viewer && viewer.cloudData && viewer.cloudData.positionArray) || ed.cloudData; if (!cam || !pos || !polygon || polygon.length < 3) return null; // 借一个 Vector3 构造器(相机 position 就是 Vector3) let V3 = null; try { V3 = cam.position && cam.position.constructor; } catch (e) {} if (!V3) { try { V3 = viewer.control && viewer.control.target && viewer.control.target.constructor; } catch (e) {} } if (!V3 || typeof V3.prototype.project !== 'function') return null; // 画布尺寸(平台内部就是用 screen.width/height 来把 NDC 映射到像素) let W = (ed.screen && ed.screen.width) || 0, H = (ed.screen && ed.screen.height) || 0; if (!W || !H) { const c = ed.canvasContainer || (viewer && viewer.canvasContainer); const r = c && c.getBoundingClientRect ? c.getBoundingClientRect() : null; if (r) { W = r.width; H = r.height; } } if (!W || !H) return null; try { if (cam.updateMatrixWorld) cam.updateMatrixWorld(); } catch (e) {} try { if (viewer.updateMatrixWorld) viewer.updateMatrixWorld(); } catch (e) {} // 相机前方判定(排除相机后方点——平台自带的投影会把后方点也算进来,导致“选一片却改了全部”) let fwd = null, camPos = cam.position || null; try { if (typeof cam.getWorldDirection === 'function') { fwd = new V3(); cam.getWorldDirection(fwd); } } catch (e) {} // 可见性过滤:隐藏的点不应被选中(配合「只看某属性」) const hid = opts.visibleOnly && ed.hiddenIndices ? ed.hiddenIndices : null; const n = Math.min(Math.floor(pos.length / 3), ed.categoryNameArray ? ed.categoryNameArray.length : Infinity); const v = new V3(); const out = []; for (let i = 0; i < n; i++) { if (hid && hid[i]) continue; // 隐藏的点跳过 const px = pos[i * 3], py = pos[i * 3 + 1], pz = pos[i * 3 + 2]; if (fwd && camPos && (px - camPos.x) * fwd.x + (py - camPos.y) * fwd.y + (pz - camPos.z) * fwd.z <= 0) continue; // 相机后方 v.set(px, py, pz); v.project(cam); if (!(v.z >= -1.0001 && v.z <= 1.0001)) continue; // 视锥前后之外 const sx = v.x * W / 2 + W / 2; const sy = -v.y * H / 2 + H / 2; if (pointInPoly(sx, sy, polygon)) out.push(i); } return out; } // 聚光灯裁剪后应保留的点 = **平台自己判定为“框内”的点**。 // ⚠ 教训:不要用自己投影去算框内 —— 屏幕坐标换算(DPI/容器尺寸/相机类型)稍有出入就会 // 把一大片点算成框内(表现为“只框几个点却全选了”)。而平台 `spotlightPolygon` 结束后 // 已经把“框外点”全部 hideIndex 了,因此 hiddenIndices[i]===0 就是平台认定的框内点, // 与它渲染出来的画面 100% 一致。这里直接采用它。 // 若「只看某属性」生效,再把框内但不属于“只看集合”的点重新隐藏(去掉其它类别)。 // 返回:应保留(可见)的点下标,即真正的选区。 function cropApplyVisibility(ed, polygon) { const post = ed.hiddenIndices; if (!post) return null; const ov = ed.onlyVisibleIndices; let onlyActive = false; if (ov) { for (let i = 0; i < ov.length; i++) if (ov[i]) { onlyActive = true; break; } } const out = []; let hid = 0; for (let i = 0; i < post.length; i++) { if (post[i]) continue; // 平台判定在框外 if (onlyActive && !(ov && ov[i])) { // 只看生效且该点不属于只看集合 → 其它类别,隐藏 try { ed.hideIndex(i); } catch (e) {} hid++; } else { out.push(i); } } if (hid > 0) { try { if (typeof ed.invalidatePosition === 'function') ed.invalidatePosition(); } catch (e) {} } log('裁剪选区:平台框内 ' + (out.length + hid) + ' 点,保留 ' + out.length + ' 点' + (onlyActive ? '(已按「只看」过滤)' : '')); return out; } // 保存多边形,并写入**选区**(点下标集合,与相机无关)。 // idxs 可由调用方传入(已做「只看某属性」过滤);不传则取全部框内点。 function setCropPolygon(polygon, srcName, idxs) { if (!polygon || polygon.length < 3) return false; STATE.cropPolygon = polygon.map(function (p) { return [p[0], p[1]]; }); STATE.cropFrameId = getFrameId(); STATE.cropActive = true; STATE.confirmedLarge = false; // idxs==null 表示无法从平台得到可靠的框内点 → 宁可不选,也不用可能有误的投影去“猜” if (idxs == null) idxs = []; STATE.accumMode = false; // 标记来源为“裁剪”:多边形框选遇到非 poly 来源会重新开始,避免与裁剪残留叠加 STATE.selSource = 'crop'; // 保险:若裁剪锁定的点占了“裁剪前”可见点的大半(可能是平台选区判定偏大),不自动选中, // 只保留裁剪用于查看/继续编辑,避免“按 Q 就锁定了全部、随后被 S 全改”。 const vis = STATE.baselineVisible || visibleCount(); if (vis > 0 && (idxs ? idxs.length : 0) > vis * 0.5) { // 只清“选中”,保留裁剪本身(可继续查看/编辑),避免被 S 全改 STATE.selSet = null; STATE.cropIndices = null; STATE.cropSelCount = 0; STATE.selFrameId = null; STATE.selSource = null; updateCropUI(); scheduleHighlight(); toast('⚠ 裁剪锁定 ' + (idxs ? idxs.length : 0) + '/' + vis + ' 点(占比过大),已不自动选中;请用多边形工具圈选要改的点。'); return true; } setSelection(idxs || [], 'replace'); log('裁剪已锁定 ' + (idxs ? idxs.length : 0) + ' 点(' + srcName + ',帧 ' + STATE.cropFrameId + ')'); return true; } // ---- 选区集合(点下标)管理:与相机无关 ---- function totalPoints() { try { const v = getViewer(); const pa = (v && v.cloudData && v.cloudData.positionArray) || (getEditor() || {}).cloudData || []; const n = Math.floor(pa.length / 3); const m = (getEditor() || { categoryNameArray: [] }).categoryNameArray.length; return m ? Math.min(n, m) : n; } catch (e) { return 0; } } // 当前可见点数(hiddenIndices[i]===0);只算可见点是“全改了”判定的正确分母 function visibleCount() { const ed = getEditor(); if (!ed || !ed.hiddenIndices) return totalPoints(); let c = 0; const h = ed.hiddenIndices; for (let i = 0; i < h.length; i++) if (!h[i]) c++; return c || totalPoints(); } function setSelection(indices, mode) { if (!STATE.selSet) STATE.selSet = new Set(); if (mode === 'replace') STATE.selSet = new Set(indices || []); else if (mode === 'add') { for (let i = 0; i < (indices || []).length; i++) STATE.selSet.add(indices[i]); } else if (mode === 'remove') { for (let i = 0; i < (indices || []).length; i++) STATE.selSet.delete(indices[i]); } STATE.selFrameId = getFrameId(); STATE.cropIndices = Array.from(STATE.selSet); STATE.cropSelCount = STATE.selSet.size; STATE.cropTotal = totalPoints() || STATE.cropSelCount; updateCropUI(); scheduleHighlight(); } function toggleSelection(idx) { if (!STATE.selSet) STATE.selSet = new Set(); if (STATE.selSet.has(idx)) STATE.selSet.delete(idx); else STATE.selSet.add(idx); STATE.selFrameId = getFrameId(); STATE.selSource = 'click'; STATE.cropIndices = Array.from(STATE.selSet); STATE.cropSelCount = STATE.selSet.size; STATE.cropTotal = totalPoints() || STATE.cropSelCount; updateCropUI(); scheduleHighlight(); } function clearSelection() { STATE.selSet = null; STATE.cropIndices = null; STATE.cropSelCount = 0; STATE.cropPolygon = null; STATE.selFrameId = null; STATE.cropFrameId = null; STATE.selSource = null; updateCropUI(); scheduleHighlight(); } function selectionValid() { if (!STATE.selSet || STATE.selSet.size === 0) return false; if (STATE.selFrameId != null && getFrameId() !== STATE.selFrameId) return false; return true; } // 把“已选中的点”画成**灰色实心点**盖在原点上(复用平台拾取画布), // 让用户一眼看出哪些点已框选。为性能考虑:rAF 合并重绘 + 数量上限。 let _hlRaf = null; function scheduleHighlight() { if (_hlRaf) return; try { _hlRaf = RAF(function () { _hlRaf = null; paintCropHighlight(); }); } catch (e) { _hlRaf = null; paintCropHighlight(); } } function paintCropHighlight() { const v = getViewer(), ed = getEditor(), ph = v && v.pickHelper; try { if (ph && typeof ph.clearCanvasMouse === 'function') ph.clearCanvasMouse(); } catch (e) {} if (!v || !ed || !ph || !ph.ctx) return; let V3 = null; try { V3 = v.camera && v.camera.position && v.camera.position.constructor; } catch (e) {} if (!V3) return; const cam = v.camera, pos = ed.cloudData || (v.cloudData && v.cloudData.positionArray); if (!pos) return; const W = (ed.screen && ed.screen.width) || (ph.screen && ph.screen.width) || 0; const H = (ed.screen && ed.screen.height) || (ph.screen && ph.screen.height) || 0; if (!W || !H) return; try { if (cam.updateMatrixWorld) cam.updateMatrixWorld(); } catch (e) {} const ctx = ph.ctx; const vv = new V3(); const MAX_DRAW = 3000; // 黄线附近高亮已改由独立叠加层(#_adge_hl)持久绘制,见 drawLineHighlight() // 已框选的点:灰色实心点(临时画在平台拾取画布上) const indices = STATE.cropIndices; if (!indices || !indices.length) return; const step = indices.length > MAX_DRAW ? Math.ceil(indices.length / MAX_DRAW) : 1; ctx.save(); ctx.fillStyle = 'rgba(150,150,150,0.95)'; ctx.strokeStyle = 'rgba(90,90,90,1)'; ctx.lineWidth = 1; for (let k = 0; k < indices.length; k += step) { const i = indices[k]; vv.set(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]); vv.project(cam); if (!(vv.z >= -1 && vv.z <= 1)) continue; const sx = vv.x * W / 2 + W / 2, sy = -vv.y * H / 2 + H / 2; ctx.beginPath(); ctx.arc(sx, sy, 2.5, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } /* ------------------------------------------------------------------ * * 黄线 ±N 米 · 持久高亮层 * 平台自己的叠加画布会被鼠标移动/重绘清掉,做不到“一直高亮”。 * 这里在点云容器上叠一张**独立的 canvas**,用 rAF 持续按相机重绘被选点, * 颜色取点云本身颜色、**与原色完全一致**(取消即消失、恢复原样)。 * 完全不改任何标注数据,纯显示层。 * ------------------------------------------------------------------ */ function ensureHlCanvas() { const v = getViewer(), ed = getEditor(); const host = (ed && ed.canvasContainer) || (v && v.canvasContainer) || v.domElement; if (!host) return null; // 清理可能存在的重复/游离叠加画布(避免“清了还在”) try { const all = document.querySelectorAll ? document.querySelectorAll('#_adge_hl') : []; for (let i = 0; i < all.length; i++) { const c = all[i]; if (c.parentNode && c.parentNode !== host) c.parentNode.removeChild(c); } } catch (e) {} let cv = document.getElementById('_adge_hl'); if (cv && cv.parentNode === host) { STATE.hlCanvas = cv; return cv; } if (cv && cv.parentNode) { try { cv.parentNode.removeChild(cv); } catch (e) {} } if (!cv) { cv = document.createElement('canvas'); cv.id = '_adge_hl'; } const st = window.getComputedStyle ? window.getComputedStyle(host) : null; if (st && st.position === 'static') { try { host.style.position = 'relative'; } catch (e) {} } cv.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;z-index:5;'; try { host.appendChild(cv); } catch (e) {} STATE.hlCanvas = cv; return cv; } function sizeHlCanvas(cv, host) { const r = host.getBoundingClientRect ? host.getBoundingClientRect() : { width: 0, height: 0 }; const dpr = window.devicePixelRatio || 1; const W = Math.max(1, Math.round(r.width)), H = Math.max(1, Math.round(r.height)); if (cv.width !== Math.round(W * dpr) || cv.height !== Math.round(H * dpr)) { cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr); cv.style.width = W + 'px'; cv.style.height = H + 'px'; } return { W: W, H: H, dpr: dpr }; } function drawHlFrame() { const v = getViewer(), ed = getEditor(); const list = STATE.hlBand; const cv = ensureHlCanvas(); if (!cv) return false; const ctx = cv.getContext('2d'); const host = cv.parentNode; const dim = sizeHlCanvas(cv, host); ctx.setTransform(dim.dpr, 0, 0, dim.dpr, 0, 0); ctx.clearRect(0, 0, dim.W, dim.H); if (!list || !list.length || !v || !ed) return true; const cam = v.camera, pos = ed.cloudData || (v.cloudData && v.cloudData.positionArray); if (!cam || !pos) return true; let V3 = null; try { V3 = cam.position && cam.position.constructor; } catch (e) {} if (!V3) return true; try { if (cam.updateMatrixWorld) cam.updateMatrixWorld(); } catch (e) {} const vv = new V3(); // 点本身用**原点云颜色**(不变色);外面加一圈白色描边做“高亮”标识,便于分辨。 const MAX_DRAW = 40000; const step = list.length > MAX_DRAW ? Math.ceil(list.length / MAX_DRAW) : 1; const R = Number(CONFIG.hlRadius) > 0 ? Number(CONFIG.hlRadius) : 3.5; for (let k = 0; k < list.length; k += step) { const it = list[k], i = it.i; vv.set(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]); vv.project(cam); if (!(vv.z >= -1 && vv.z <= 1)) continue; const sx = vv.x * dim.W / 2 + dim.W / 2, sy = -vv.y * dim.H / 2 + dim.H / 2; const rr = Math.round(Math.max(0, Math.min(1, it.r || 0)) * 255); const gg = Math.round(Math.max(0, Math.min(1, it.g || 0)) * 255); const bb = Math.round(Math.max(0, Math.min(1, it.b || 0)) * 255); // 描边(白)— 只是标记,不改变点自身的颜色;可用 config.hlOutline=false 关闭 if (CONFIG.hlOutline !== false) { ctx.beginPath(); ctx.arc(sx, sy, R + 1.2, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.fill(); } // 点本体:与原点云颜色完全一致 ctx.beginPath(); ctx.arc(sx, sy, R, 0, Math.PI * 2); ctx.fillStyle = 'rgb(' + rr + ',' + gg + ',' + bb + ')'; ctx.fill(); } return true; } function hlLoop() { if (!STATE.hlLoopOn) return; try { drawHlFrame(); } catch (e) {} STATE.hlRaf = RAF(hlLoop); } function startHlLoop() { if (STATE.hlLoopOn) return; STATE.hlLoopOn = true; try { STATE.hlRaf = RAF(hlLoop); } catch (e) { hlLoop(); } } function stopHlLoop() { STATE.hlLoopOn = false; if (STATE.hlRaf) { try { CAF(STATE.hlRaf); } catch (e) {} STATE.hlRaf = null; } } // 待改点集合 = 选区(点下标)。来源:平台工具操作后自动捕获 / 单击累积。 function mergedSelection() { const cur = getFrameId(); if (STATE.selSet && STATE.selFrameId === cur) return new Set(STATE.selSet); return new Set(); } function mergedCount() { return mergedSelection().size; } function mergedFrameOk() { return !!(STATE.selSet && STATE.selSet.size && STATE.selFrameId === getFrameId()); } // 面板显示当前选区点数(点下标集合,与相机无关) function updateCropUI() { const el = document.getElementById('_adge_crop_txt'); // “还原裁剪”按钮仅在存在裁剪时出现 try { const rb = document.getElementById('_adge_panel') && document.getElementById('_adge_panel').querySelector('[data-ag="restore"]'); if (rb) rb.style.display = STATE.cropPolygon ? '' : 'none'; } catch (e) {} if (!el) return; const n = mergedCount(); if (!n) { el.textContent = '选区:空'; el.style.color = '#8b93a3'; return; } // 用“当前可见点数”作分母,占比才反映真实大小(用整帧会严重偏小) const vis = visibleCount(); const pct = vis ? Math.round(n / vis * 100) : 0; el.textContent = '选区:' + n + ' 点' + (STATE.multiBox ? '(多框累加)' : '') + (vis ? ' · 占可见 ' + pct + '%' : ''); el.style.color = (pct > 50) ? '#ff7b8a' : (pct > 20 ? '#ffc107' : '#3ecf8e'); } function installSpotlightEditable(ed) { if (!ed || STATE.spotlightPatched) return; STATE.spotlightPatched = true; const origExit = typeof ed.exitSpotlight === 'function' ? ed.exitSpotlight.bind(ed) : null; ed.__adgeOrigExitSpotlight = origExit; // 不拦截 backSseEditor:它由 setSseShader(false)(退出语义分割)触发,是平台正常清理路径。 if (typeof ed.spotlightPolygon === 'function') { const origSpot = ed.spotlightPolygon.bind(ed); ed.spotlightPolygon = function (polygon) { // 裁剪前记录“基线可见点数”,供后续安全阀计算占比(裁剪后可见数会变) let baseVis = 0; try { baseVis = visibleCount(); } catch (e) {} const res = origSpot(polygon); try { if (CONFIG.keepCrop) { ed.__adgeKeepCrop = true; const selIdx = cropApplyVisibility(ed, polygon); STATE.baselineVisible = baseVis; // 安全阀用它作分母 setCropPolygon(polygon, 'spotlightPolygon', selIdx); toast('裁剪已保留:锁定 ' + STATE.cropSelCount + ' 点(已黄色高亮),按 S 批量改类'); setTimeout(switchToChooseTool, 200); } } catch (e) {} return res; }; } // 平台工具(矩形/圆形/多边形/套索)选完都会调用 selectByPolygon。 // 为与平台显示 100% 一致,这里**让平台自己做选区判定**(它用 updatePixelProjection + // 绕数,和它渲染黄框完全一致),只是在调用期间**临时接管逐点回调**: // · 记录平台判定为“框内”的点下标; // · 不让平台真的改类别(改类统一交给 S)。 // 这样避免了“自己投影算框内”在坐标系换算上出错导致的“全选”。 if (typeof ed.selectByPolygon === 'function') { const origSel = ed.selectByPolygon.bind(ed); ed.__adgeOrigSelectByPolygon = origSel; ed.selectByPolygon = function (polygon) { const idxs = []; const names = ['addIndexFromSelection', 'coverIndexFromSelection', 'removeIndexFromSelection']; const saved = {}; let capture = true; for (let n = 0; n < names.length; n++) { const nm = names[n]; if (typeof ed[nm] === 'function') { saved[nm] = ed[nm]; ed[nm] = function (i) { if (capture && i != null && i >= 0) idxs.push(i); }; } } // 平台 selectByPolygon 需要 categoryInfo.pname(否则抛错);临时补一个 const needCat = !ed.categoryInfo || !ed.categoryInfo.pname; let backup = null; if (needCat) { let pname = currentNewClass(); if (!pname) { try { pname = getMS().statusState().selectClassId; } catch (x) {} } if (!pname) { pname = firstClassId(); } if (!pname) { for (const nm in saved) ed[nm] = saved[nm]; toast('请先在面板「改为」选一个类别,或左侧选类别'); return; } let color = null; try { color = toColor(ed, getMS().segGetter('getColor')(pname, null)); } catch (x) {} backup = ed.categoryInfo; ed.categoryInfo = { pname: pname, color: color, categoryColor: color, instanceId: null }; } try { origSel(polygon); } catch (e) { log('平台 selectByPolygon 抛错:', e && e.message); } finally { capture = false; for (const nm in saved) ed[nm] = saved[nm]; if (needCat) { try { ed.categoryInfo = backup; } catch (x) {} } try { if (ed.selection) ed.selection.length = 0; } catch (x) {} try { if (ed.selectionPixels) ed.selectionPixels.length = 0; } catch (x) {} } // 去重 const uniq = []; const seen = new Set(); for (let k = 0; k < idxs.length; k++) { const i = idxs[k]; if (!seen.has(i)) { seen.add(i); uniq.push(i); } } if (!uniq.length) { toast('多边形内没有点(请再圈住一些点)'); return; } const cur = getFrameId(); // ★ 关键修复:只有当上一次选区**也是多边形框选**累加而来时才累加; // 若上一次是聚光灯裁剪(或别的来源)留下的,多边形框选**重新开始**, // 避免“Q 锁定的那一大坨残留 + 这次框选”叠加成一大片。 const sameSource = STATE.selSource === 'poly'; const mode = (STATE.multiBox && sameSource && STATE.selSet && STATE.selFrameId === cur) ? 'add' : 'replace'; STATE.selSource = 'poly'; if (mode === 'replace') { try { STATE.baselineVisible = visibleCount(); } catch (x) {} } setSelection(uniq, mode); const visN = STATE.baselineVisible || visibleCount(); if (visN > 0 && STATE.cropSelCount > visN * 0.5) { toast('⚠ 选区 ' + STATE.cropSelCount + ' 点,约占可见 ' + visN + ' 点的 ' + Math.round(STATE.cropSelCount / visN * 100) + '%。若只想改少量点,请缩小多边形。'); } else { toast('本次选中 ' + uniq.length + ' 点,选区共 ' + STATE.cropSelCount + ' 点(可继续框选,按 S 改类)'); } }; } ed.exitSpotlight = function () { try { if (CONFIG.keepCrop && ed.__adgeKeepCrop) { ed.isSpotlightActive = false; log('exitSpotlight 已拦截:保留裁剪区域'); return; } } catch (e) {} return origExit ? origExit() : undefined; }; log('聚光灯可编辑已启用'); } /* ------------------------------------------------------------------ * * 说明:**不再**通过包装 `sseColoring` 去读平台的 `ed.selection`。 * 平台在“新增/覆盖”模式下会把大量点塞进 `ed.selection`,一旦读取就会把选区 * 变成一大片(表现为“只框几个点却全选中/改了全部”),且该行为与类别数量相关。 * 现在所有选区**只**来自插件自身对多边形的精确投影(`polygonSelectedIndices`), * 平台工具(矩形/圆形/多边形/套索)已被 `selectByPolygon` 包装接管,无需再监听。 * ------------------------------------------------------------------ */ function installPlatformSelTracker(ed) { if (!ed || STATE.platSelPatched) return; STATE.platSelPatched = true; log('选区来源:平台自身选区结果(多边形工具经临时接管记录,聚光灯用平台裁剪后的可见点)'); } // 从“聚光灯”工具切回“选择/编辑(choose)”,便于裁剪后直接单击选点 function switchToChooseTool() { const ms = getMS(); if (!ms || !ms.getCurSegmentationTool) return; let tries = 0; const t = setInterval(function () { tries++; let cur = null; try { cur = ms.getCurSegmentationTool(); } catch (e) {} if (cur === 'choose') { clearInterval(t); return; } if (cur === 'spotlight') { try { ms.statusCommit('setSegToolStatus', 'spotlight'); } catch (e) {} } if (tries > 15) clearInterval(t); }, 150); } function restoreCrop() { const ed = getEditor(); if (!ed) return false; try { ed.__adgeKeepCrop = false; clearCropState(); // 平台 exitSpotlight 会用裁剪前的 _preSpotlightHidden 整份还原可见性。 // 但裁剪期间若改过类别(点已不属于“只看”的那类),那份旧快照会让这些点错误地保持可见。 // 因此还原后按“当前类别”重新计算一次可见性(initSettings 会依据「只看」重设 hiddenIndices)。 ed._preSpotlightHidden = null; if (typeof ed.__adgeOrigExitSpotlight === 'function') ed.__adgeOrigExitSpotlight(); if (typeof ed.initSettings === 'function') { try { ed.initSettings(); } catch (e) { log('还原后重算可见性失败:', e && e.message); } } else { // 兜底:移除仅由本插件造成的额外隐藏 try { if (ed.onlyVisibleIndices && ed.hiddenIndices) { for (let i = 0; i < ed.hiddenIndices.length; i++) if (ed.hiddenIndices[i] && !ed.onlyVisibleIndices[i] && ed.categoryIDArray && ed.categoryIDArray[i]) { /* 保持平台状态 */ } } } catch (e) {} } if (typeof ed.invalidatePosition === 'function') ed.invalidatePosition(); if (typeof ed.updatePointCloudColor === 'function') ed.updatePointCloudColor(); toast('已还原裁剪:按当前「只看」重新显示(此前改过的类别保持不变)'); return true; } catch (e) { log('还原裁剪失败', e); return false; } } // 清理裁剪状态(切帧/失效时调用) function clearCropState() { STATE.cropActive = false; STATE.cropPolygon = null; STATE.cropIndices = null; STATE.cropMask = null; STATE.cropSelCount = 0; STATE.cropTotal = 0; STATE.cropFrameId = null; STATE.selSet = null; STATE.selFrameId = null; try { const ph = getViewer() && getViewer().pickHelper; if (ph && typeof ph.clearCanvasMouse === 'function') ph.clearCanvasMouse(); } catch (e) {} updateCropUI(); } /* ------------------------------------------------------------------ * * 改类核心:把指定下标的点改为某类别 * 复用平台原语 coverIndexFromSelection(覆盖模式),并补全平台在 * selectByPolygon 里做的统计 / 重着色 / 提交。 * * 注意:平台着色器靠 categoryID==1 时取 sseColor(每点 RGB)上色; * categoryInfo.categoryColor 必须是 THREE.Color 对象(平台在 * setSseSelectInfo 里用 new Color(字符串) 包了一层)。若直接放颜色 * 字符串,getClassColor().r 会是 undefined,colorArray 变 NaN,点会“消失”。 * ------------------------------------------------------------------ */ // 取 THREE.Color 构造器(借 getClassColor 的默认分支 new Color(1,1,1)) function getColorCtor(ed) { if (STATE._colorCtor) return STATE._colorCtor; try { const sc = ed.categoryInfo, sm = ed.selectionMode, ss = ed.supportInstance; ed.categoryInfo = null; ed.selectionMode = 'add'; ed.supportInstance = false; const probe = (typeof ed.getClassColor === 'function') ? ed.getClassColor() : null; ed.categoryInfo = sc; ed.selectionMode = sm; ed.supportInstance = ss; if (probe && probe.constructor && probe.constructor !== String && probe.constructor !== Number) { STATE._colorCtor = probe.constructor; return STATE._colorCtor; } } catch (e) {} return null; } function toColor(ed, raw) { if (raw && typeof raw === 'object' && raw.r != null) return raw; // 已是 Color const Ctor = getColorCtor(ed); if (!Ctor) return null; try { const c = new Ctor(); if (typeof c.set === 'function' && raw != null) c.set(raw); return c; } catch (e) { return null; } } function beginRelabel(ed, ms, newClass) { const colorObj = toColor(ed, (function () { try { return ms.segGetter('getColor')(newClass, null); } catch (e) { return null; } })()); const saved = { mode: ed.selectionMode, cat: ed.categoryInfo, sup: ed.supportInstance, inst: ed.instanceID, maxNum: ed.maxNum, }; ed.selectionMode = 'cover'; // 覆盖模式:已标注的点也能改 ed.supportInstance = false; ed.instanceID = 0; // categoryColor 必须是 Color 对象,sseColoring / 着色器才能取到 r/g/b ed.categoryInfo = { pname: newClass, color: colorObj, instanceId: null, categoryColor: colorObj }; // 与平台 selectByPolygon 一致:maxNum = 该新类别现有最大实例号(无则该类别为空 → 0) // 这样 coverIndexFromSelection 分配的实例号 = maxNum+1,且一次操作内所有点同组。 try { let maxInst = -1; const names = ed.categoryNameArray || []; for (let i = 0; i < names.length; i++) { const nm = names[i]; if (!nm) continue; const p = nm.split('-'); if (p[0] !== newClass) continue; const v = p.length > 1 ? parseInt(p[1], 10) : 0; if (!isNaN(v) && v > maxInst) maxInst = v; } ed.maxNum = maxInst < 0 ? 0 : maxInst; } catch (e) {} return { saved: saved, colorObj: colorObj }; } function endRelabel(ed, saved) { ed.selectionMode = saved.mode; ed.categoryInfo = saved.cat; ed.supportInstance = saved.sup; ed.instanceID = saved.inst; ed.maxNum = saved.maxNum; } // 清除画布上的黄色轮廓: // #canvasMouse (ed.context) —— 聚光灯/多边形工具的 currentTool.polygon 轮廓 // #canvasSelector (ed.ctx) —— 选择区域的黄色凸包轮廓 function clearDrawOverlays(ed) { try { if (ed.selection) ed.selection.length = 0; } catch (e) {} try { if (ed.selectionPixels) ed.selectionPixels.length = 0; } catch (e) {} try { if (ed.currentTool && ed.currentTool.polygon) ed.currentTool.polygon.length = 0; } catch (e) {} try { if (typeof ed.clearCanvasMouse === 'function') ed.clearCanvasMouse(); } catch (e) {} if (typeof ed.clearCanvasSelection === 'function') { try { ed.clearCanvasSelection(); } catch (e) {} } else if (typeof ed.drawCanvasSelection === 'function') { try { ed.drawCanvasSelection(); } catch (e) {} } } function commitRelabel(ed, ms, colorObj, changedIdx) { // 直接把新颜色/类别标记写进每点数组,不依赖 getClassColor try { const ca = ed.colorArray, cid = ed.categoryIDArray, sec = ed.selectEffectArray; if (Array.isArray(changedIdx)) { for (let k = 0; k < changedIdx.length; k++) { const i = changedIdx[k]; if (colorObj && ca) { ca[i * 3] = colorObj.r; ca[i * 3 + 1] = colorObj.g; ca[i * 3 + 2] = colorObj.b; } if (cid) cid[i] = 1; if (sec) sec[i] = 0; // 清掉选中高亮(否则一直半透明放大) } } } catch (e) {} // 改完立即抹掉黄色选择/裁剪轮廓线 clearDrawOverlays(ed); // 兜底:平台的手势/按键处理可能把轮廓重画回来,稍后再清一次(若用户已开始画新轮廓则不动) setTimeout(function () { try { if (!ed.currentTool || !ed.currentTool.polygon || ed.currentTool.polygon.length === 0) { if (typeof ed.clearCanvasMouse === 'function') ed.clearCanvasMouse(); } } catch (e) {} try { if (ed.selection) ed.selection.length = 0; if (ed.selectionPixels) ed.selectionPixels.length = 0; if (typeof ed.clearCanvasSelection === 'function') ed.clearCanvasSelection(); } catch (e) {} }, 80); if (typeof ed.updatePointCloudColor === 'function') ed.updatePointCloudColor(); else if (typeof ed.sseColoring === 'function') ed.sseColoring(); if (typeof ed.statisticalData === 'function') { ed.initStatistic(); for (let i = 0; i < ed.categoryNameArray.length; i++) ed.statisticalData(i); } const frameId = getFrameId(); ms.segCommit('updateOriginal', [frameId, ed.categoryNameArray, ed.instanceIDArray]); if (ed.updateData) ms.segCommit('change', [ed.updateData]); if (typeof ed.incrementalUpdateLabels === 'function') ed.incrementalUpdateLabels(); if (typeof ed.invalidatePosition === 'function') ed.invalidatePosition(); if (typeof ed.invalidateSection === 'function') ed.invalidateSection(); } // 把“当前选区集合”内的点批量改为指定类别。 // 选区是点下标集合(与相机无关),因此缩放/旋转不影响;按 S 只改这批点,不多不少。 function relabelCrop(newClass, fromClass) { const ed = getEditor(), ms = getMS(); if (!ed || !ms) { toast('插件尚未就绪'); return; } if (!newClass) { toast('请先选择要改成的类别'); return; } try { if (ms.getCurLabelType && ms.getCurLabelType() !== 'segmentation') { toast('请先进入语义分割模式'); return; } } catch (e) {} try { if (ms.isMultiFrame && ms.isMultiFrame()) return; } catch (e) {} if (typeof ed.coverIndexFromSelection !== 'function') { toast('平台版本不支持该操作'); return; } if (!ed.categoryNameArray || !ed.categoryNameArray.length) { toast('分割点云未加载'); return; } // 待改点 = 选区(点下标,与相机无关) let selIdx = Array.from(mergedSelection()); if (!selIdx.length) { toast('选区为空:用平台矩形/画笔/多边形选点,或勾「单击累积」单击加点'); return; } // 安全阀 1:绝对数量上限——默认超过 100 点就必须二次确认(防“全改了”)。 const cap = (CONFIG.safetyMax && CONFIG.safetyMax > 0) ? CONFIG.safetyMax : 100; if (selIdx.length > cap && !STATE.confirmedCount) { STATE.confirmedCount = true; toast('⚠ 本次要改 ' + selIdx.length + ' 个点(超过 ' + cap + ')。确认无误请再按一次 S;否则点「清空选区」重选。可调整面板上限。'); return; } STATE.confirmedCount = false; // 安全阀 2:用“基线可见点数”作分母;没有基线才退回当前可见数。 const visN = STATE.baselineVisible || visibleCount(); if (visN > 0 && selIdx.length > visN * 0.5 && !STATE.confirmedLarge) { STATE.confirmedLarge = true; toast('⚠ 选区 ' + selIdx.length + ' 点,约占原本可见 ' + visN + ' 点的 ' + Math.round(selIdx.length / visN * 100) + '%。确认无误请再按一次 S;否则点「清空选区」重选。'); return; } STATE.confirmedLarge = false; // fromClass 过滤(按原类别)——只改这批点里原类别匹配的 if (fromClass) { selIdx = selIdx.filter(function (i) { return baseClassOf(ed.categoryNameArray[i]) === fromClass; }); if (!selIdx.length) { toast('选区内没有「' + classTitleOf(fromClass) + '」的点'); return; } } // 记录快照用于撤销 try { STATE.undoSnapshot = { frameId: getFrameId(), cat: ed.categoryNameArray.slice(), inst: ed.instanceIDArray ? ed.instanceIDArray.slice() : null }; } catch (e) { STATE.undoSnapshot = null; } // 用平台原语逐点覆盖改类(保证类别名/实例号/统计口径与平台一致) // 临时置 __adgeInternalCall,避免自己的这一步被“平台选区追踪”记录进去 const begun = beginRelabel(ed, ms, newClass); const changedIdx = []; STATE.__adgeInternalCall = true; try { for (let k = 0; k < selIdx.length; k++) { const i = selIdx[k]; if (ed.pointInVolumeIndices && ed.pointInVolumeIndices.has(i)) continue; const before = ed.categoryNameArray[i]; ed.coverIndexFromSelection(i, null); if (ed.categoryNameArray[i] !== before) changedIdx.push(i); } } catch (e) { log('改类失败', e); toast('改类失败:' + (e && e.message ? e : e)); return; } finally { STATE.__adgeInternalCall = false; endRelabel(ed, begun.saved); } // 重建颜色/统计并提交 refreshAfterRelabel(ed, ms, changedIdx.length ? changedIdx : selIdx); // 改完清掉选区,避免下次误用;同时清平台 selection,开始新一轮框选 STATE.selSet = null; STATE.cropIndices = null; STATE.cropSelCount = 0; try { if (ed.selection) ed.selection.length = 0; } catch (e) {} updateCropUI(); scheduleHighlight(); log('改类完成:选区 ' + selIdx.length + ' 点,实际改动 ' + changedIdx.length + ' 点,改为 ' + newClass); toast('已将选区内 ' + changedIdx.length + ' 个点改为「' + classTitleOf(newClass) + '」' + (fromClass ? '(仅原类别 ' + classTitleOf(fromClass) + ')' : '') + '(可点「撤销上次改类」还原)'); } // 撤销上一次批量改类 function undoCropRelabel() { const ed = getEditor(), ms = getMS(); const snap = STATE.undoSnapshot; if (!ed || !ms || !snap) { toast('没有可撤销的改类操作'); return; } if (snap.frameId != null && getFrameId() !== snap.frameId) { toast('已切帧,无法撤销'); STATE.undoSnapshot = null; return; } try { for (let i = 0; i < snap.cat.length; i++) { if (ed.categoryNameArray[i] !== snap.cat[i]) ed.categoryNameArray[i] = snap.cat[i]; if (snap.inst && ed.instanceIDArray) ed.instanceIDArray[i] = snap.inst[i]; } } catch (e) { toast('撤销失败'); return; } const all = []; for (let i = 0; i < snap.cat.length; i++) all.push(i); refreshAfterRelabel(ed, ms, all); STATE.undoSnapshot = null; toast('已撤销上次改类'); log('已撤销上次改类'); } // 平台 selectByPolygon 已改好类别,这里只负责:重着色/统计/提交/清线 function refreshAfterRelabel(ed, ms, changedIdx) { try { const ca = ed.colorArray, cid = ed.categoryIDArray, sec = ed.selectEffectArray; const colorObj = toColor(ed, (function () { try { return ms.segGetter('getColor')(ed.categoryInfo && ed.categoryInfo.pname, null); } catch (e) { return null; } })()); if (Array.isArray(changedIdx) && ca) { for (let k = 0; k < changedIdx.length; k++) { const i = changedIdx[k]; const nm = ed.categoryNameArray[i] || ''; if (!nm) continue; const cls = baseClassOf(nm); let c = null; try { c = toColor(ed, ms.segGetter('getColor')(cls, null)); } catch (e) {} if (c) { ca[i * 3] = c.r; ca[i * 3 + 1] = c.g; ca[i * 3 + 2] = c.b; } if (cid) cid[i] = 1; if (sec) sec[i] = 0; } } } catch (e) {} if (ed.selection) ed.selection.length = 0; if (ed.selectionPixels) ed.selectionPixels.length = 0; clearDrawOverlays(ed); if (typeof ed.updatePointCloudColor === 'function') ed.updatePointCloudColor(); else if (typeof ed.sseColoring === 'function') ed.sseColoring(); if (typeof ed.statisticalData === 'function') { ed.initStatistic(); for (let i = 0; i < ed.categoryNameArray.length; i++) ed.statisticalData(i); } const frameId = getFrameId(); try { ms.segCommit('updateOriginal', [frameId, ed.categoryNameArray, ed.instanceIDArray]); } catch (e) {} if (ed.updateData) { try { ms.segCommit('change', [ed.updateData]); } catch (e) {} } if (typeof ed.incrementalUpdateLabels === 'function') { try { ed.incrementalUpdateLabels(); } catch (e) {} } if (typeof ed.invalidatePosition === 'function') { try { ed.invalidatePosition(); } catch (e) {} } if (typeof ed.invalidateSection === 'function') { try { ed.invalidateSection(); } catch (e) {} } } // 只改单个点(单击选点用) function relabelOnePoint(index, newClass) { const ed = getEditor(), ms = getMS(); if (!ed || !ms || index == null || index < 0) return false; if (!newClass) return false; if (!ed.categoryNameArray || index >= ed.categoryNameArray.length) return false; if (baseClassOf(ed.categoryNameArray[index]) === newClass) return false; // 已是该类别 const begun = beginRelabel(ed, ms, newClass); let ok = false; STATE.__adgeInternalCall = true; try { ed.coverIndexFromSelection(index, null); // 校验是否真的改了(类别被锁定时平台会静默跳过) ok = baseClassOf(ed.categoryNameArray[index]) === newClass; if (ok) commitRelabel(ed, ms, begun.colorObj, [index]); else log('单点改类被拒绝(该类别可能被锁定): ' + baseClassOf(ed.categoryNameArray[index])); } catch (e) { log('单点改类失败', e); ok = false; } finally { STATE.__adgeInternalCall = false; endRelabel(ed, begun.saved); } return ok; } function classTitleOf(classId) { const list = loadClassList(); if (list) for (let i = 0; i < list.length; i++) if (list[i].classId === classId) return list[i].ptitle; return classId; } // 面板:显示/刷新“已选中点”的编号与属性 function pointInfoText(idx) { if (idx == null || idx < 0) return { text: '无', cls: '', inst: '', name: '' }; const ed = getEditor(); const name = (ed && ed.categoryNameArray) ? (ed.categoryNameArray[idx] || '') : ''; const cls = baseClassOf(name), inst = instanceOf(name); const clsName = cls ? (classTitleOf(cls) + '(' + cls + ')') : '未标注'; // 附上“距黄线多少米”,便于确认是否在高亮范围内 let distStr = ''; try { const r = getRect(); const pa = ed && (ed.cloudData || (getViewer() && getViewer().cloudData && getViewer().cloudData.positionArray)); if (r && pa) { const bi = boundaryInfo(pa[idx * 3], pa[idx * 3 + 1], r); if (bi.side !== 0) { const band = Math.max(0.1, Number(CONFIG.band) || 3); distStr = ' · 距黄线' + bi.dist.toFixed(1) + '米' + (bi.dist <= band ? '(在' + band + '米内)' : '(超出' + band + '米)'); } } } catch (e) {} return { text: '#' + idx + ' ' + clsName + (inst && inst !== '0' ? ' · 实例' + inst : '') + distStr, cls: cls, inst: inst, name: name, }; } function updatePickedUI(idx) { STATE.pickedIndex = (idx == null ? -1 : idx); const el = document.getElementById('_adge_picked_txt'); if (el) { const info = pointInfoText(idx); el.textContent = '选中点:' + info.text; el.style.color = (idx == null || idx < 0) ? '#8b93a3' : '#dfe3ea'; } } // 在点云上给选中的点画一圈黄色标记(复用平台拾取画布) function showPickedMarker(idx) { const v = getViewer(), ph = v && v.pickHelper, ed = getEditor(); if (!v || !ph || !ed || !ed.cloudData || idx == null || idx < 0) return; try { const V3 = (ph.raycaster && ph.raycaster.ray && ph.raycaster.ray.origin) ? ph.raycaster.ray.origin.constructor : null; if (!V3) return; const p = ed.cloudData, i = idx * 3; const w = new V3(p[i], p[i + 1], p[i + 2]); w.project(v.camera); const sx = Math.round(w.x * ph.screen.width / 2 + ph.screen.width / 2); const sy = Math.round(-w.y * ph.screen.height / 2 + ph.screen.height / 2); if (typeof ph.hoverPoint === 'function') ph.hoverPoint(sx, sy); } catch (e) {} } // 面板按钮:把当前选中的点改为下拉所选类别 function relabelPicked() { const nc = currentNewClass(); if (!nc) { toast('请先在上方「改为」选择类别'); return; } const i = STATE.pickedIndex; if (i == null || i < 0) { toast('请先在点云上单击选中一个点'); return; } const ed = getEditor(); const before = (ed && ed.categoryNameArray) ? baseClassOf(ed.categoryNameArray[i]) : ''; const ok = relabelOnePoint(i, nc); updatePickedUI(i); showPickedMarker(i); if (ok) toast('点 #' + i + ' 已改为「' + classTitleOf(nc) + '」'); else if (before === nc) toast('点 #' + i + ' 已经是「' + classTitleOf(nc) + '」'); else toast('点 #' + i + ' 改类未生效(该类别可能被锁定,见控制台日志)'); } /* ================================================================== * * 单击选点:原生需按住 Alt + 单击;这里复用 viewer.pickHelper 的 * raycasting 拿到点索引,实现普通左键单击即可选中/改类。 * 仅在语义分割 + “选择/编辑(choose)”工具下生效,避免抢占画框操作。 * ================================================================== */ function installClickPick(v) { if (!v || STATE.clickPatched) return; const el = v.canvasContainer || v.domElement || (v.rendererMain && v.rendererMain.domElement) || (v.renderer && v.renderer.domElement); if (!el || !el.addEventListener) return; STATE.clickPatched = true; el.addEventListener('pointerdown', function (e) { STATE.downX = e.clientX; STATE.downY = e.clientY; }, true); el.addEventListener('click', function (e) { if (!STATE.clickPick) return; if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; // 拖动过(旋转视角)就不当作点选 if (STATE.downX != null && (Math.abs(e.clientX - STATE.downX) > 4 || Math.abs(e.clientY - STATE.downY) > 4)) return; const ms = getMS(), ed = getEditor(), ph = v.pickHelper; if (!ms || !ed || !ph || !ph.raycasting) return; try { if (ms.getCurLabelType() !== 'segmentation') return; } catch (x) { return; } try { if (ms.getCurSegmentationTool() !== 'choose') { toast('请先切到“选择/编辑”工具(choose)后再单击选点'); return; } } catch (x) {} // 借用平台的拾取(临时置 isAlting,只为借用 raycasting 取索引) const prevAlt = ph.isAlting; let idx = -1; try { ph.isAlting = true; if (typeof ph.updateMouse === 'function') ph.updateMouse(e); ph.raycasting(); idx = ph.curIntersectIndex; } catch (x) { log('拾取失败', x); } finally { ph.isAlting = prevAlt; } if (idx == null || idx < 0) { toast('此处未拾取到点'); return; } // 面板显示该点的编号与属性 updatePickedUI(idx); const info = pointInfoText(idx); // 原生选中该点(左侧高亮其类别/实例),与 Alt+单击 行为一致 try { if (typeof ed.pickPoint === 'function') ed.pickPoint(idx); } catch (x) {} const modKey = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey; // 累积模式:单击 = 加入/移出选区(与相机无关,可缩放后继续点) if (STATE.accumMode && !modKey) { toggleSelection(idx); const inSel = STATE.selSet && STATE.selSet.has(idx); toast((inSel ? '已加入' : '已移出') + '选区 · 共 ' + (STATE.selSet ? STATE.selSet.size : 0) + ' 点' + '(# ' + idx + ' ' + (info.cls ? classTitleOf(info.cls) : '未标注') + ')'); return; } showPickedMarker(idx); if (STATE.clickRelabel) { const newClass = currentNewClass(); if (!newClass) { toast('请先在上方「改为」选择类别'); return; } const ok = relabelOnePoint(idx, newClass); updatePickedUI(idx); showPickedMarker(idx); toast('点 #' + idx + ':' + (info.cls ? classTitleOf(info.cls) : '未标注') + (ok ? ' → 已改为「' + classTitleOf(newClass) + '」' : '(已是该类别)')); } // 未开启“单击即改类”时不做提示(面板已显示),避免遮挡 }, false); } function currentNewClass() { const panel = document.getElementById('_adge_panel'); const sel = panel && panel.querySelector('[data-ag="newClass"]'); return sel ? sel.value : ''; } function currentFromClass() { const panel = document.getElementById('_adge_panel'); const sel = panel && panel.querySelector('[data-ag="fromClass"]'); return sel ? sel.value : ''; } function firstClassId() { const list = loadClassList(); return (list && list.length) ? list[0].classId : 'ignore'; } /* ------------------------------------------------------------------ * * 快捷键 S:把当前裁剪区域内的点批量改为面板「改为」所选类别。 * 流程:聚光灯圈选 → 按 Q 闭合裁剪 → 按 S 直接批量改。 * 平台未占用 S(仅 Ctrl+S 为保存),按住 Ctrl/Alt/Cmd 时不触发。 * ------------------------------------------------------------------ */ function installRelabelHotkey() { if (STATE.hotkeyInstalled) return; STATE.hotkeyInstalled = true; document.addEventListener('keydown', function (e) { if (e.key !== 's' && e.key !== 'S') return; if (e.ctrlKey || e.altKey || e.metaKey) return; // Ctrl+S 归平台保存 const t = e.target; const tag = (t && t.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select' || (t && t.isContentEditable)) return; const ms = getMS(), ed = getEditor(); if (!ms || !ed) return; try { if (ms.getCurLabelType && ms.getCurLabelType() !== 'segmentation') return; } catch (x) { return; } try { if (ms.isDisabledShortKey && ms.isDisabledShortKey()) return; } catch (x) {} try { if (ms.isMultiFrame && ms.isMultiFrame()) return; } catch (x) {} if (!mergedFrameOk()) { toast(mergedCount() ? '选区已失效(切过帧),请重新用平台工具选点或单击加点后再按 S' : '按 S 批量改类:请先用平台工具(画笔/矩形/多边形/套索)选点,或勾「单击累积」单击加点'); return; } const nc = currentNewClass(); if (!nc) { toast('请先在面板「改为」里选好目标类别,再按 S'); return; } e.preventDefault(); e.stopImmediatePropagation(); relabelCrop(nc, currentFromClass() || null); }, true); } /* ================================================================== * * 功能二:以「标注范围」黄线为中心±3米的属性一致性校验 * ================================================================== */ // 读取“黄线”矩形。平台可能画两种黄色框: // ① 任务级「标注范围」config.mark_region // ② 每帧范围 getFrameRange(frameId)(同样用黄色画) // 优先用 ①;没有时回退 ②,避免“看不到黄线/读错框”。 function rectFromBlock(block) { if (!block) return null; const arr = block.rect || block.box; if (arr && arr.length) { const b = arr[0]; if (b && b.x && b.y) { return { x0: Math.min(b.x[0], b.x[1]), x1: Math.max(b.x[0], b.x[1]), y0: Math.min(b.y[0], b.y[1]), y1: Math.max(b.y[0], b.y[1]), seed: null, }; } } // 圆形范围:退化为“以圆心为中心、半径 r 的方框”(近似),至少能定位 if (block.circle && block.circle.length) { const c = block.circle[0]; if (c && c.radius > 0) { const cx = (c.center && c.center[0]) || 0, cy = (c.center && c.center[1]) || 0; return { x0: cx - c.radius, x1: cx + c.radius, y0: cy - c.radius, y1: cy + c.radius, seed: 'circle' }; } } return null; } function getAllRects() { const out = []; try { const cfg = getMS().configState().config || {}; const m = rectFromBlock(cfg.mark_region); out.push({ src: 'mark_region', rect: m }); } catch (e) { out.push({ src: 'mark_region', rect: null, err: String(e) }); } try { const ms = getMS(); const fr = ms.configGetter('getFrameRange')(ms.getCurFrameId()); out.push({ src: 'frameRange', rect: rectFromBlock(fr), raw: fr ? Object.keys(fr) : null }); } catch (e) { out.push({ src: 'frameRange', rect: null, err: String(e) }); } return out; } function getRect() { const all = getAllRects(); for (let i = 0; i < all.length; i++) if (all[i].rect) return all[i].rect; return null; } // 黄线判定:矩形(含退化成的线段)内外。 // 返回 {side, dist}: side=1 内、-1 外、0 不可判定;dist 为到边界的绝对距离(m) function boundaryInfo(x, y, r) { const w = r.x1 - r.x0, h = r.y1 - r.y0; const eps = 1e-6; // 退化:某一维宽度≈0 → 实质是一条线段,用“侧”判定(有向距离) if (w <= eps && h <= eps) return { side: 0, dist: 0 }; // 退化成一个点,无法判内外 if (h <= eps) { // 水平线段 y=r.y0 const d = y - r.y0; return { side: d >= 0 ? 1 : -1, dist: Math.abs(d) }; } if (w <= eps) { // 垂直线段 x=r.x0 const d = x - r.x0; return { side: d >= 0 ? 1 : -1, dist: Math.abs(d) }; } // 正常矩形:内部取到最近边的距离(正),外部取负 const inside = x >= r.x0 && x <= r.x1 && y >= r.y0 && y <= r.y1; if (inside) { return { side: 1, dist: Math.min(x - r.x0, r.x1 - x, y - r.y0, r.y1 - y) }; } const ox = x < r.x0 ? r.x0 - x : (x > r.x1 ? x - r.x1 : 0); const oy = y < r.y0 ? r.y0 - y : (y > r.y1 ? y - r.y1 : 0); return { side: -1, dist: Math.sqrt(ox * ox + oy * oy) }; } function getSegData() { const viewer = getViewer(); const ed = getEditor(); let pos = null, labels = null; try { pos = (viewer && viewer.cloudData && viewer.cloudData.positionArray) || (ed && ed.cloudData) || null; labels = (ed && ed.categoryNameArray) || null; } catch (e) {} if (!pos || !pos.length) return { error: '点云未加载' }; if (!labels || !labels.length) return { error: '未取到语义分割点云(需处于「语义分割」模式且已加载分割数据)' }; const n = Math.floor(pos.length / 3); return { pos: pos, labels: labels, count: Math.min(n, labels.length) }; } // 高亮:**黄线内外 N 米内的全部可见点**(不判断是否跨线),用各自原有颜色持久显示。 // 结果存到 STATE.hlBand = [{i,r,g,b}],由独立叠加层持续重绘;取消即消失、恢复原亮度。 function buildStraddleHighlight() { const data = getSegData(); if (data.error) { toast(data.error); return { error: data.error }; } const rect = getRect(); if (!rect) { toast('未取到黄线矩形(mark_region / 帧范围均为空)'); return { error: 'no-rect' }; } const band = Math.max(0.1, Number(CONFIG.band) || 3); const ed = getEditor(); const ca = ed.colorArray; const hid = ed.hiddenIndices; const pos = data.pos, count = data.count; const list = []; for (let i = 0; i < count; i++) { if (hid && hid[i]) continue; // 隐藏的点不高亮 const bi = boundaryInfo(pos[i * 3], pos[i * 3 + 1], rect); if (bi.side === 0 || bi.dist > band) continue; // 只看距黄线 ≤ band 米 let r = 1, g = 1, b = 1; if (ca) { r = ca[i * 3]; g = ca[i * 3 + 1]; b = ca[i * 3 + 2]; } list.push({ i: i, r: r, g: g, b: b }); } STATE.hlBand = list; STATE.hlObjects = [{ count: list.length }]; if (!list.length) { stopHlLoop(); clearOverlayCanvas(); return { count: 0, band: band }; } try { drawHlFrame(); } catch (e) {} // 立即画一帧(同时创建叠加画布) startHlLoop(); log('黄线 ±' + band + ' 米高亮:' + list.length + ' 点(原色,持久显示);黄线矩形=' + 'x[' + rect.x0 + ',' + rect.x1 + '] y[' + rect.y0 + ',' + rect.y1 + ']' + (rect.seed === 'circle' ? '(圆形范围近似为方框)' : '')); return { count: list.length, band: band, rect: rect }; } function clearOverlayCanvas() { // 彻底清除:清空 + 从 DOM 移除所有叠加画布,避免任何残留 try { const all = document.querySelectorAll ? document.querySelectorAll('#_adge_hl') : []; for (let i = 0; i < all.length; i++) { const c = all[i]; try { const ctx2 = c.getContext('2d'); if (ctx2) ctx2.clearRect(0, 0, c.width, c.height); } catch (e) {} if (c.parentNode) { try { c.parentNode.removeChild(c); } catch (e) {} } } } catch (e) {} const cv = STATE.hlCanvas || document.getElementById('_adge_hl'); if (cv) { try { const c = cv.getContext('2d'); if (c) c.clearRect(0, 0, cv.width, cv.height); } catch (e) {} if (cv.parentNode) { try { cv.parentNode.removeChild(cv); } catch (e) {} } } STATE.hlCanvas = null; } function clearStraddleHighlight() { STATE.hlBand = null; STATE.hlObjects = null; stopHlLoop(); clearOverlayCanvas(); } // 无结果时给一句“为什么”的提示:最近的点距黄线多远、当前用的黄线矩形是什么 function whyNoStraddle() { try { const data = getSegData(); if (data.error) return data.error; const rect = getRect(); if (!rect) return '未取到黄线矩形(mark_region / 帧范围均为空)'; const pos = data.pos; let near = Infinity; for (let i = 0; i < data.count; i++) { const bi = boundaryInfo(pos[i * 3], pos[i * 3 + 1], rect); if (bi.side === 0) continue; if (bi.dist < near) near = bi.dist; } const rstr = 'x[' + rect.x0 + ',' + rect.x1 + '] y[' + rect.y0 + ',' + rect.y1 + ']'; if (!isFinite(near)) return '黄线 ' + rstr + ';本帧没有可判定的点'; return '黄线 ' + rstr + ';最近的点距黄线 ' + near.toFixed(1) + ' 米(当前 ' + CONFIG.band + ' 米)'; } catch (e) { return '诊断出错: ' + e.message; } } // 诊断:为什么“高亮黄线±N米”没结果。打印黄线矩形来源、坐标范围、band 内点数。 function diagStraddle() { const out = {}; const data = getSegData(); if (data.error) { out.error = data.error; console.log('[标注增强] 高亮诊断:', out); return out; } const ed2 = getEditor(); const rect = getRect(); out.rect = rect; out.allRects = getAllRects(); // 黄线两个来源都列出,便于判断读的是哪个 out.band = Math.max(0.1, Number(CONFIG.band) || 3); const pos = data.pos, count = data.count; const hid = ed2 && ed2.hiddenIndices; // 到黄线的距离分布(全部点) let nearest = Infinity, visibleInBand = 0, hiddenInBand = 0, hidden = 0; const sample = []; for (let i = 0; i < count; i++) { const isHidden = !!(hid && hid[i]); const bi = boundaryInfo(pos[i * 3], pos[i * 3 + 1], rect || { x0: -70, x1: 70, y0: -70, y1: 70 }); if (isHidden) hidden++; if (bi.side === 0) continue; if (bi.dist < nearest) nearest = bi.dist; if (bi.dist <= out.band) { if (isHidden) hiddenInBand++; else visibleInBand++; } if (sample.length < 8) sample.push(Number(bi.dist.toFixed(2))); } out.totalPts = count; out.hiddenPts = hidden; out.nearestDist = isFinite(nearest) ? Number(nearest.toFixed(2)) : null; out.visiblePtsInBand = visibleInBand; out.hiddenPtsInBand = hiddenInBand; out.sampleDists = sample; out.currentHlCount = STATE.hlBand ? STATE.hlBand.length : 0; out.hlLoopOn = STATE.hlLoopOn; console.log('[标注增强] 高亮诊断:', out); return out; } /* ================================================================== * * 面板 UI * ================================================================== */ function injectStyles() { if (document.getElementById('_adge_style')) return; const css = document.createElement('style'); css.id = '_adge_style'; // 统一配色:深灰蓝底 + 单一强调色(#5b8cff),主操作绿、次要中性、危险红。 css.textContent = '#_adge_panel{position:fixed;top:52px;right:12px;width:288px;z-index:2147483400;' + 'background:#15171d;border:1px solid #262a34;border-radius:12px;' + 'box-shadow:0 10px 30px rgba(0,0,0,.55);font-family:system-ui,"Microsoft YaHei",sans-serif;' + 'font-size:12.5px;color:#dfe3ea;user-select:none;overflow:hidden;}' + '#_adge_panel .ag-head{display:flex;align-items:center;justify-content:space-between;height:38px;padding:0 12px;' + 'background:#1b1e26;border-bottom:1px solid #262a34;cursor:move;}' + '#_adge_panel .ag-title{font-weight:600;font-size:12.5px;display:flex;align-items:center;gap:8px;letter-spacing:.3px;}' + '#_adge_panel .ag-dot{width:8px;height:8px;border-radius:50%;background:#5b8cff;box-shadow:0 0 8px rgba(91,140,255,.6);}' + '#_adge_panel .ag-hbtn{width:24px;height:24px;border:none;background:transparent;color:#6b7280;font-size:15px;cursor:pointer;border-radius:6px;}' + '#_adge_panel .ag-hbtn:hover{background:#242833;color:#dfe3ea;}' + '#_adge_panel .ag-body{padding:2px 12px 12px;max-height:78vh;overflow-y:auto;}' + '#_adge_panel .ag-body::-webkit-scrollbar{width:6px;}' + '#_adge_panel .ag-body::-webkit-scrollbar-thumb{background:#2c313d;border-radius:3px;}' + '#_adge_panel .ag-sec{padding-top:12px;}' + '#_adge_panel .ag-sec+.ag-sec{margin-top:8px;padding-top:12px;border-top:1px solid #23262f;}' + '#_adge_panel .ag-sec-title{display:flex;align-items:center;gap:7px;margin-bottom:9px;font-weight:600;font-size:12px;color:#aab3c5;}' + '#_adge_panel .ag-bar{width:3px;height:12px;border-radius:2px;background:#5b8cff;}' + '#_adge_panel .ag-check{display:flex;align-items:center;gap:8px;cursor:pointer;color:#c2c8d4;font-size:12.5px;line-height:1.6;padding:1px 0;}' + '#_adge_panel .ag-check:hover{color:#eef1f6;}' + '#_adge_panel .ag-check input{width:14px;height:14px;margin:0;accent-color:#5b8cff;cursor:pointer;flex:0 0 auto;}' + '#_adge_panel .ag-row{display:flex;align-items:center;gap:8px;margin-bottom:8px;color:#c2c8d4;font-size:12.5px;}' + '#_adge_panel .ag-row label{flex:0 0 auto;color:#8b93a3;}' + '#_adge_panel input.ag-num{width:58px;background:#0e1016;border:1px solid #2a2f3a;border-radius:6px;color:#dfe3ea;padding:5px 7px;font-size:12.5px;outline:none;}' + '#_adge_panel input.ag-num:focus{border-color:#5b8cff;}' + '#_adge_panel select.ag-sel{flex:1;min-width:0;background:#0e1016;border:1px solid #2a2f3a;border-radius:6px;color:#dfe3ea;padding:5px 8px;font-size:12.5px;outline:none;cursor:pointer;}' + '#_adge_panel select.ag-sel:focus{border-color:#5b8cff;}' + '#_adge_panel textarea.ag-ta{width:100%;box-sizing:border-box;background:#0e1016;border:1px solid #2a2f3a;border-radius:6px;' + 'color:#aeb6c6;padding:6px 8px;font-size:11.5px;line-height:1.5;resize:vertical;min-height:34px;outline:none;font-family:inherit;margin-bottom:8px;}' + '#_adge_panel textarea.ag-ta:focus{border-color:#5b8cff;}' + '#_adge_panel .ag-btn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;box-sizing:border-box;' + 'border:1px solid #2a2f3a;background:#1b1e26;border-radius:7px;padding:8px 0;cursor:pointer;font-size:12.5px;' + 'color:#c2c8d4;font-weight:600;transition:background .15s,border-color .15s;font-family:inherit;}' + '#_adge_panel .ag-btn:hover{background:#222631;border-color:#3a4150;color:#eef1f6;}' + '#_adge_panel .ag-btn:active{transform:translateY(1px);}' + '#_adge_panel .ag-btn.go{background:#1f8a5b;border-color:#27a06b;color:#fff;}' + '#_adge_panel .ag-btn.go:hover{background:#24a06a;}' + '#_adge_panel .ag-btn.sm{padding:6px 0;font-size:12px;}' + '#_adge_panel .ag-btnrow{display:flex;gap:8px;margin-top:8px;}' + '#_adge_panel .ag-btnrow .ag-btn{flex:1;}' + '#_adge_panel .ag-kbd{display:inline-block;padding:0 5px;border:1px solid rgba(255,255,255,.25);border-radius:4px;font-size:11px;line-height:16px;opacity:.9;}' + '#_adge_panel .ag-status{margin:8px 0 0;padding:7px 9px;border-radius:7px;background:#0e1016;border:1px solid #23262f;' + 'font-size:12px;line-height:1.55;color:#8b93a3;font-weight:600;word-break:break-all;min-height:16px;}' + '#_adge_panel .ag-stat{margin:8px 0 4px;padding:7px 9px;border-radius:7px;background:#0e1016;border:1px solid #23262f;font-size:11.5px;color:#93a0b8;line-height:1.7;}' + '#_adge_panel .ag-badge{display:inline-block;padding:1px 7px;border-radius:9px;font-size:11px;font-weight:700;}' + '#_adge_panel .ag-ok{background:rgba(39,160,107,.16);color:#3ecf8e;border:1px solid rgba(39,160,107,.35);}' + '#_adge_panel .ag-err{background:rgba(220,70,90,.14);color:#ff7b8a;border:1px solid rgba(220,70,90,.35);}' + '#_adge_panel .ag-item{background:#171a21;border-left:3px solid #dc465a;padding:7px 9px;margin-bottom:5px;' + 'border-radius:0 6px 6px 0;font-size:12px;line-height:1.6;color:#c8cdd8;cursor:pointer;transition:background .15s;}' + '#_adge_panel .ag-item:hover{background:#1e222b;}' + '#_adge_panel .ag-item.ok{border-left-color:#27a06b;}' + '#_adge_panel .ag-item .ag-k{color:#7f8899;font-size:11px;}' + '#_adge_panel .ag-item.active{background:rgba(91,140,255,.12);border-left-color:#5b8cff;}' + '#_adge_panel .ag-empty{color:#6f7787;text-align:center;padding:12px 0;font-size:12px;}' + '#_adge_panel .ag-hint{margin-top:7px;font-size:11px;line-height:1.6;color:#6f7787;}'; document.head.appendChild(css); } function buildPanel() { if (STATE.panelBuilt || !document.body) return; STATE.panelBuilt = true; injectStyles(); const panel = document.createElement('div'); panel.id = '_adge_panel'; panel.innerHTML = '
' + ' 标注增强' + ' ' + '
' + '
' + // —— 改属性 —— '
' + '
改属性
' + '
' + '
' + ' ' + '
选区:空
' + '
' + ' ' + ' ' + '
' + ' ' + '
' + // —— 选点 —— '
' + '
选点
' + ' ' + ' ' + '
选中点:无
' + ' ' + '
用平台的矩形/圆形/多边形/套索工具圈选要改的点(可多框几次累加);选定后按 S 或上面绿按钮改类。
' + '
' + // —— 黄线 —— '
' + '
黄线 ±N 米
' + '
' + '
' + ' ' + ' ' + '
' + '
把距黄线 N 米内的点**全部**用点云原本的颜色高亮(持久,鼠标移动/旋转不消失);点「清除高亮」即恢复原样。
' + '
' + '
'; document.body.appendChild(panel); const $$ = function (s) { return panel.querySelector(s); }; populateClassSelects(); updateCropUI(); $$('[data-ag="multibox"]').checked = STATE.multiBox; $$('[data-ag="multibox"]').addEventListener('change', function (e) { STATE.multiBox = e.target.checked; toast(STATE.multiBox ? '已开启多框累加' : '已关闭多框累加(每次框选替换选区)'); }); $$('[data-ag="undo"]').addEventListener('click', undoCropRelabel); $$('[data-ag="restore"]').addEventListener('click', restoreCrop); $$('[data-ag="clrsel"]').addEventListener('click', function () { clearSelection(); STATE.undoSnapshot = null; toast('已清空选区'); }); $$('[data-ag="clickPick"]').addEventListener('change', function (e) { STATE.clickPick = e.target.checked; }); $$('[data-ag="relabel"]').addEventListener('click', function () { const nc = $$('[data-ag="newClass"]').value; const fc = $$('[data-ag="fromClass"]').value; relabelCrop(nc, fc || null); }); $$('[data-ag="relabelPicked"]').addEventListener('click', relabelPicked); $$('[data-ag="clickPick"]').checked = STATE.clickPick; $$('[data-ag="hlstraddle"]').addEventListener('click', function () { const info = buildStraddleHighlight(); if (info && info.error) return; const n = STATE.hlBand ? STATE.hlBand.length : 0; if (!n) toast('无高亮点:' + whyNoStraddle()); else toast('已高亮距黄线 ' + CONFIG.band + ' 米内的 ' + n + ' 点(原色,持久显示)'); }); $$('[data-ag="hlclear"]').addEventListener('click', function () { clearStraddleHighlight(); toast('已清除高亮'); }); $$('[data-ag="band"]').addEventListener('change', function (e) { CONFIG.band = Math.max(0, Number(e.target.value) || 0); if (STATE.hlBand && STATE.hlBand.length) buildStraddleHighlight(); // 已高亮时改距离即时刷新 }); $$('[data-ag="min"]').addEventListener('click', function () { STATE.minimized = !STATE.minimized; panel.querySelector('.ag-body').style.display = STATE.minimized ? 'none' : 'block'; }); const head = panel.querySelector('.ag-head'); let drag = null; head.addEventListener('mousedown', function (e) { if (e.target.closest('button')) return; const r = panel.getBoundingClientRect(); drag = { dx: e.clientX - r.left, dy: e.clientY - r.top }; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { if (!drag) return; panel.style.left = (e.clientX - drag.dx) + 'px'; panel.style.top = (e.clientY - drag.dy) + 'px'; panel.style.right = 'auto'; }); document.addEventListener('mouseup', function () { drag = null; }); } // 填充类别下拉(店可能未就绪,重试) function populateClassSelects() { const panel = document.getElementById('_adge_panel'); if (!panel) return; const list = loadClassList(); if (!list || !list.length) { setTimeout(populateClassSelects, 1000); return; } const newSel = panel.querySelector('[data-ag="newClass"]'); const fromSel = panel.querySelector('[data-ag="fromClass"]'); if (!newSel || !fromSel) return; const opts = list.map(function (c) { return ''; }).join(''); newSel.innerHTML = opts; fromSel.innerHTML = '' + opts; } /* ================================================================== * * 启动 * ================================================================== */ // 监听切帧:帧一变,裁剪快照立即作废,避免把整帧误当成裁剪区域 function watchFrameChange() { let last = getFrameId(); setInterval(function () { const cur = getFrameId(); if (cur !== last) { last = cur; if (STATE.selSet && STATE.selSet.size) { clearCropState(); log('检测到切帧,已清除裁剪状态(如需批量改类请重新聚光灯裁剪)'); } // 切帧时自动清除黄线高亮(点云已换,旧高亮无意义) if (STATE.hlBand && STATE.hlBand.length) { clearStraddleHighlight(); try { if (typeof updateCropUI === 'function') updateCropUI(); } catch (e) {} log('检测到切帧,已自动清除黄线高亮'); } } }, 400); } function boot() { waitFor(function () { const v = getViewer(); return !!(window.instance && window.instance.StoreManager && v && v.sseEditor); }, 40000, 200).then(function (ok) { if (!ok) { log('等待平台 SseEditor 超时,聚光灯可编辑未启用'); return; } const v = getViewer(); installSpotlightEditable(getEditor()); installPlatformSelTracker(getEditor()); installClickPick(v); installRelabelHotkey(); watchFrameChange(); log('已就绪:用平台工具选点后按 S 批量改类;单击选点需切到“选择/编辑”工具'); }); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', buildPanel); else buildPanel(); } // 诊断:把“选区/多边形/屏幕尺寸/投影”等关键数字打印出来,便于定位为什么框选过宽。 // 用法:框选后(按 S 之前)在控制台执行 __adge.diag() function diag() { const ed = getEditor(), v = getViewer(); const out = {}; try { out.frameId = getFrameId(); out.polygon = STATE.cropPolygon; out.selCount = STATE.selSet ? STATE.selSet.size : 0; out.baselineVisible = STATE.baselineVisible; out.visibleNow = visibleCount(); out.screen = ed && ed.screen ? { w: ed.screen.width, h: ed.screen.height, left: ed.screen.left, top: ed.screen.top } : null; const c = (ed && ed.canvasContainer) || (v && v.canvasContainer); out.canvasRect = c && c.getBoundingClientRect ? (function (r) { return { w: Math.round(r.width), h: Math.round(r.height), left: Math.round(r.left), top: Math.round(r.top) }; })(c.getBoundingClientRect()) : null; out.devicePixelRatio = window.devicePixelRatio; out.camera = v && v.camera ? { type: v.camera.type, isPerp: !!v.camera.isPerspectiveCamera, far: v.camera.far, w: v.camera.right != null ? (v.camera.right - v.camera.left) : null } : null; out.hiddenStats = (function () { const h = ed && ed.hiddenIndices; if (!h) return null; let hid = 0; for (let i = 0; i < h.length; i++) if (h[i]) hid++; return { total: h.length, hidden: hid, visible: h.length - hid }; })(); if (out.polygon && out.polygon.length) { let xs = out.polygon.map(p => p[0]), ys = out.polygon.map(p => p[1]); out.polyBBox = { x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) }; } // 用插件投影再算一遍,供对比 try { out.myProjection = STATE.cropPolygon ? (polygonSelectedIndices(ed, STATE.cropPolygon, { visibleOnly: false }) || []).length : null; } catch (e) { out.myProjection = 'err:' + e.message; } // 平台自带投影:跑一次 updatePixelProjection,看它认为框内有多少点、像素坐标长什么样 try { if (STATE.cropPolygon && ed && typeof ed.updatePixelProjection === 'function') { ed.updatePixelProjection(STATE.cropPolygon); out.platformFrustumCount = (ed.frustrumIndices || []).length; const pp = ed.pixelProjections || []; out.platformPixelSample = pp.slice(0, 5); // 像素坐标范围,用来判断是否与多边形同一坐标系 let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity; for (let k = 0; k < pp.length; k++) { const p = pp[k]; if (!p) continue; if (p[0] < x0) x0 = p[0]; if (p[0] > x1) x1 = p[0]; if (p[1] < y0) y0 = p[1]; if (p[1] > y1) y1 = p[1]; } out.platformPixelBBox = pp.length ? { x0: x0, x1: x1, y0: y0, y1: y1 } : null; try { ed.frustrumIndices.length = 0; ed.pixelProjections.length = 0; } catch (e) {} } } catch (e) { out.platformProjErr = e.message; } } catch (e) { out.error = e.message; } console.log('[标注增强] 诊断:', out); return out; } window.__adge = { highlightStraddle: buildStraddleHighlight, diagStraddle: diagStraddle, clearStraddleHighlight: clearStraddleHighlight, relabelCrop: relabelCrop, relabelOnePoint: relabelOnePoint, relabelPicked: relabelPicked, undoCropRelabel: undoCropRelabel, diag: diag, paintCropHighlight: paintCropHighlight, toggleSelection: toggleSelection, setSelection: setSelection, clearSelection: clearSelection, selectionValid: selectionValid, mergedSelection: mergedSelection, mergedCount: mergedCount, installPlatformSelTracker: installPlatformSelTracker, installRelabelHotkey: installRelabelHotkey, updatePickedUI: updatePickedUI, pointInfoText: pointInfoText, installClickPick: installClickPick, restoreCrop: restoreCrop, clearCropState: clearCropState, resolveCropPredicate: selectionValid, getEditor: getEditor, getRect: getRect, getClassList: loadClassList, config: CONFIG, state: STATE, }; boot(); })();