// ==UserScript==
// @name 点云3D标注增强(索套切边 + 吸附合并 + RGB着色)
// @namespace cloud-annotate-plus
// @version 1.1.0
// @description 点云标注平台三合一增强:①索套切边——按 M 沿要保留的边界打点、Q 闭合,边界即被替换成这条线;②顶点吸附合并——拖动顶点靠近另一顶点自动吸附,松手合并成一个点;③RGB 着色——数据自带 rgba 的就地解出,没有颜色字段的用相机+标定投影着色。
// @author -
// @match *://*/*
// @grant none
// @run-at document-idle
// ==/UserScript==
/*
* 使用说明(三个独立功能,互不干扰)
* ==================================================================
* 一、索套切边(3D 主视图,2D 屏幕空间)
* M 键(或视图左下角「索套切边」按钮)进入索套模式 -> 沿“想保留的那条边界”依次左键打点
* -> 线变绿表示可切(正好穿过多边形边界两次),画面上会用半透明红色标出“将被删除的区域”
* 按住 Alt+Q 保留另一侧;模式内 Backspace 退点、Z 撤销上次切边、Esc/M 退出;
* 只拦左键,右键拖动平移点云、中键/滚轮缩放照常可用。
* 打点时靠近多边形顶点(默认 12px 内)会自动吸附到该顶点(画成白圈):这样“从一个顶点切到另一个顶点”
* 也算两次相交,可以直接沿已有顶点改边界。可调:lane_vertex_snap_px(吸附半径,默认 12)、
* lane_cut_vertex_px(判定“落在顶点上”的容差,默认 6)。
*
* 二、顶点吸附合并(编辑模式)
* 拖动一个顶点靠近同一个多边形的另一个顶点(默认 12px、世界距离 3 米内)会吸附对齐,
* 提示点琥珀色,松手合并成一个点(顶点数 -1,形状不变);顶点数到下限(多边形 3 点)时只吸附不合并。
* 只有“朝目标方向靠近”(当前距离小于抓取时距离的一半)才会吸附,所以正常拖动顶点不受影响。
* localStorage.setItem("lane_snap_other_px", 8) // 允许吸附别的多边形顶点(只吸附不删点,提示点青色)
* localStorage.setItem("lane_snap_px", 16) // 吸附半径,默认 12
* localStorage.setItem("lane_snap_world", 5) // 世界距离上限,默认 3,0=不限
*
* 三、RGB 点云着色(静默版 v2.2.0,原脚本逻辑原样并入)
* 自动跟随帧变化着色并切到官方「rgb着色」;右上角有状态胶囊,点开可「用rgb着色 / 诊断 / 还原」。
* 数据自带 rgba 的就地解出颜色,没有颜色字段的用相机+标定投影着色;不改数据、不重传。
* 统计信息在控制台 window.__xjRgbLastStat;诊断结果输出到 F12 Console。
*
* 版本记录:
* v3.1.0 索套可以选中多边形顶点了:打点时靠近顶点自动吸附(画成白圈),并且“从一个顶点切到另一个顶点”
* 也算两个交点(交点可以是边上的穿越点,也可以直接是已有顶点)。仍要求正好两个交点。
* 顺带修掉弧线收集的顺序问题(跨过 0 号顶点时顶点顺序会乱、环自相交,导致选错侧)。
* v3.0.2 修复索套切边「删错一侧 / 顶点丢失」:索套从多边形的同一条边进出时,原来会把该侧的顶点全部丢掉,
* 结果只剩“老边 + 新曲线”围成的小条,也就是该删的那部分反而被留下;
* 同时把默认保留侧改成“多边形重心所在的一侧”(原来只按面积比大小,路面在屏幕上很扁时会选错),
* 并新增“将被删除区域”的红色半透明预览(按住 Alt 会实时切到另一侧)。
* v3.0.2 修复索套切边「删错一侧 / 顶点丢失」:索套从多边形的同一条边进出时(沿着一条边切一刀,很常见),
* 原来会把该侧的顶点全部丢掉,结果只剩“老边 + 新曲线”围成的小条,也就是该删的那部分反而被留下;
* 同时把默认保留侧改成“多边形重心所在的一侧”(原来只按面积比大小,路面在屏幕上很扁时会选错),
* 并新增“将被删除区域”的红色半透明预览(按住 Alt 会实时切到另一侧)。
* v3.0.1 修复「拖动顶点完全拖不动」:吸附合并的拖动包装函数没做 bind,而 three.js 派发事件时
* 是用球体作为 this 调用监听器的,于是每次拖动都抛异常并打断平台的鼠标事件链;
* 同时修掉「顶点本来就贴近另一个顶点时一拖就被吸住」,改为必须朝目标靠近(距离减半)才吸附。
* v3.0.0 索套切边 + 顶点吸附合并 + RGB 着色三合一。
*
* 注意事项:
* - 本脚本已包含原「点云3D多边形-索套切边」与「平台端RGB着色(静默版)」两个脚本的全部功能,
* 请在 ScriptCat 里停用/删除它们,避免 RGB 着色重复执行(会出现两个状态胶囊)。
* - @match 目前是全部站点,脚本会自行判断是否平台页面(依据 window.instance / #app / pointcloud 路径),
* 非平台页面不做任何事。建议按需收窄成你们平台地址,例如 http://121.37.95.217:8080/ 加一个星号。
* - 索套切边与吸附合并只作用于点云 3D 主视图的标注物;四个 2D 投影视图、其它帧的预测标注物(虚拟)、
* 批注多边形都不参与。RGB 着色只影响显示,不改动任何标注与数据。
*/
// ================================================================
// 一、索套切边 + 顶点吸附合并(3D 标注交互)
// 依赖平台暴露的 window.instance / window.viewer;非平台页面自动退出
// ================================================================
(function () {
"use strict";
var W = (typeof unsafeWindow !== "undefined" && unsafeWindow) || window;
var LABEL_TYPE_MARK = "mark"; // Constants.LABEL_TYPE_MARK
var VISUAL_TYPE_VIRTUAL = 2; // Constants.VISUAL_TYPE_VIRTUAL(其它帧的预测标注物)
var MARK_ACTION_MODIFY_MATRIX = 1; // Constants.MARK_ACTION_MODIFY_MATRIX
var DEFAULTS = { SNAP_PX: 12, SNAP_OTHER_PX: 0, SNAP_WORLD: 3, SNAP_VERTEX_PX: 12, CUT_VERTEX_PX: 6 };
function readPx(key, def) {
var v = parseFloat(localStorage.getItem(key));
return (isNaN(v) || v < 0) ? def : v;
}
// #region lane-methods
// 索套切边依赖的基础工具(调用时 this 必须是 LineHandler 实例)。
// deps: { S: StoreManager, Vector3 }
function makeLaneMethods(deps) {
var S = deps.S, Vector3 = deps.Vector3, Color = deps.Color;
return {
// 只考虑:3D 主视图里的多边形/折线、非批注、有 mark、非虚拟(其它帧预测)
lanePolylines: function () {
var out = [];
var scene = this.viewer.lineScene;
if (!scene) return out;
scene.traverseVisible(function (o) {
if (o && typeof o.getSpheresAndLines === "function" && !o.isPostil && o.trackId && o.mark &&
o.markType !== VISUAL_TYPE_VIRTUAL) out.push(o);
});
return out;
},
// 边数组按索引与顶点配对:边 i = 顶点 i -> 顶点 i+1(闭合时最后一条回到 0)
laneClosestOnSegment: function (ro, rd, a, b) {
var ab = b.clone().sub(a), len2 = ab.lengthSq();
if (len2 < 1e-12) return a.clone();
var w = ro.clone().sub(a), wr = w.dot(rd), wa = w.dot(ab), bar = ab.dot(rd);
var den = len2 - bar * bar;
// 候选:两端点、射线原点在线段上的垂足、区域内部驻点、两区域交界处
var cands = [0, 1, wa / len2];
if (Math.abs(den) > 1e-12) cands.push((wa - bar * wr) / den);
if (Math.abs(bar) > 1e-12) cands.push(wr / bar);
var best = null, bestD = Infinity;
for (var i = 0; i < cands.length; i++) {
var s = cands[i];
s = s < 0 ? 0 : (s > 1 ? 1 : s);
var ux = a.x + ab.x * s - ro.x, uy = a.y + ab.y * s - ro.y, uz = a.z + ab.z * s - ro.z;
var t = ux * rd.x + uy * rd.y + uz * rd.z;
if (t < 0) t = 0;
var d2 = ux * ux + uy * uy + uz * uz - t * t;
if (d2 < bestD - 1e-12) {
bestD = d2;
best = a.clone().add(ab.clone().multiplyScalar(s));
}
}
return best || a.clone();
},
// 世界坐标 -> 画布像素;相机后方返回 null
laneProject: function (p, rect) {
if (!p) return null;
var cam = this.viewer.camera;
if (cam.matrixWorldInverse) {
var v = p.clone().applyMatrix4(cam.matrixWorldInverse);
if (v.z >= -1e-6) return null;
}
var n = p.clone().project(cam);
return { x: (n.x * .5 + .5) * rect.width, y: (-n.y * .5 + .5) * rect.height };
},
// 拖动顶点时的吸附目标:屏幕上足够近、且世界距离也在阈值内的“另一个顶点”
// ndc 为拖拽事件里的鼠标位置(InputHandler.drag.end);不传则用被拖动顶点当前的投影位置
laneSnapTarget: function (sphere, ndc, grabPx) {
var poly = sphere && sphere.parent;
if (!poly || typeof poly.getSpheresAndLines !== "function") return null;
var rect = this.domElement.getBoundingClientRect();
var from = null;
if (ndc && typeof ndc.x === "number") {
from = { x: (ndc.x * .5 + .5) * rect.width, y: (-ndc.y * .5 + .5) * rect.height };
} else {
from = this.laneProject(sphere.position, rect);
}
if (!from) return null;
var startPx = (grabPx && typeof grabPx.x === "number") ? grabPx : from;
var pxSame = readPx("lane_snap_px", DEFAULTS.SNAP_PX);
var pxOther = readPx("lane_snap_other_px", DEFAULTS.SNAP_OTHER_PX); // 默认 0:不吸附别的多边形
var worldMax = readPx("lane_snap_world", DEFAULTS.SNAP_WORLD); // 0 表示不限世界距离
var polys = this.lanePolylines(), best = null, bestD = Infinity;
for (var i = 0; i < polys.length; i++) {
var p = polys[i], same = (p === poly), limit = same ? pxSame : pxOther;
if (!(limit > 0)) continue;
var sph = p.getSpheresAndLines()[0];
for (var j = 0; j < sph.length; j++) {
var sp = sph[j];
if (sp === sphere) continue;
if (worldMax > 0 && sp.position.distanceTo(sphere.position) > worldMax) continue;
var pt = this.laneProject(sp.position, rect);
if (!pt) continue;
var d = Math.hypot(pt.x - from.x, pt.y - from.y);
var d0 = Math.hypot(pt.x - startPx.x, pt.y - startPx.y);
// 必须“正在靠近”:当前距离要小于抓取时距离的一半,才算要吸附到这个顶点。
// 否则顶点本来就贴着另一个顶点(例如缩小后都挤在一起)时,一拖就会被吸住、拖不动。
if (!(d <= limit && d < d0 * .5)) continue;
if (d < bestD) { bestD = d; best = { poly: p, sphere: sp, same: same, px: d }; }
}
}
return best;
},
// 松手后合并:删掉被拖动的那个顶点(同多边形才合并;顶点数已达下限时只保持吸附位置)
laneMergeVertex: function (info) {
if (!info || !info.poly || !info.sphere) return false;
var r = info.poly.getSpheresAndLines(), spheres = r[0], lines = r[1];
var minNum = info.poly.isClosed ? 3 : (this.lineMinPointsNum || 2);
if (spheres.length <= minNum) return false;
var line = lines[0];
// 结构性改动:删一个顶点与一条边,随后边按索引重排(与平台删点逻辑一致)
try {
if (info.sphere.removeFromParent) info.sphere.removeFromParent();
if (line && line.removeFromParent) line.removeFromParent();
this.updateLinesPosition(info.poly);
} catch (e) {
console.warn("[snap-merge] merge", e);
return false;
}
// 以下失败不影响数据
try { this.removePickEvent(info.sphere); } catch (e) { }
try { if (line) this.removePickEvent(line); } catch (e) { }
try { info.poly.initInner(); } catch (e) { }
try {
S.modifyMark3D(MARK_ACTION_MODIFY_MATRIX, info.poly);
} catch (e) {
console.warn("[snap-merge] 合并后保存失败,请拖动任一顶点触发保存:", e);
}
try { this.viewer.rendererMain(); } catch (e) { }
return true;
},
// 吸附提示小球(懒创建):琥珀色 = 会合并,青色 = 只吸附对齐
laneEnsureGhost: function () {
if (this._laneGhost) return this._laneGhost;
var ref = this.defColor || this.selectColor;
var ColorCls = ref && ref.constructor;
if (!ColorCls || typeof ColorCls !== "function") return null;
if (!this._laneColor) this._laneColor = new ColorCls("#ffcc00");
if (!this._laneSnapColor) this._laneSnapColor = new ColorCls("#00e0ff");
var LH = this.constructor;
if (!LH || !LH.createSphere) return null;
var g = LH.createSphere(null, this._laneColor, this.radius);
if (!g) return null;
g.name = "lane_snap_point";
if (g.material) g.material.opacity = 1;
g.visible = false;
this.viewer.scene.add(g);
this._laneGhost = g;
return g;
},
laneClearGhost: function () {
if (this._laneGhost && this._laneGhost.visible) {
this._laneGhost.visible = false;
try { this.viewer.rendererMain(); } catch (e) { }
}
},
// 拖动结束(冒泡阶段 mouseup):把吸附到一起的顶点合并成一个
onLaneMergeMouseup: function (ev) {
try {
var snap = this.__laneSnap;
this.__laneSnap = null;
if (!snap || !ev || ev.button !== 0) return;
if (this._laneLassoActive) return;
if (snap.same) this.laneMergeVertex(snap);
this.laneClearGhost();
} catch (e) { console.warn("[snap-merge] mouseup", e); }
},
};
}
// #endregion lane-methods
// #region lane-lasso
// 2D 索套切边:在屏幕上沿想保留的边界打点画一条开放折线,按 Q 闭合,
// 多边形边界被这条线替换(画到哪、边界就走到哪),另一侧的顶点被裁掉。
function makeLassoMethods(deps) {
var S = deps.S, Vector3 = deps.Vector3;
var COL_OPEN = "#ff3b30"; // 还没形成有效切割
var COL_OK = "#22c55e"; // 可以切
var FONT = "12px/1.5 system-ui,-apple-system,'Microsoft YaHei',sans-serif";
return {
// ---------- 屏幕 / 射线基础 ----------
laneRayFromNdc: function (ndc) {
var cam = this.viewer.camera;
var near = new Vector3(ndc.x, ndc.y, -1).unproject(cam);
var far = new Vector3(ndc.x, ndc.y, 1).unproject(cam);
return { origin: near, direction: far.sub(near).normalize() };
},
laneNdcOfEvent: function (ev) {
var r = this.domElement.getBoundingClientRect();
return { x: (ev.clientX - r.left) / r.width * 2 - 1, y: -((ev.clientY - r.top) / r.height * 2 - 1) };
},
lanePxToNdc: function (px, rect) {
return { x: px.x / rect.width * 2 - 1, y: -(px.y / rect.height * 2 - 1) };
},
laneToClient: function (px, rect) {
return { x: rect.left + px.x, y: rect.top + px.y };
},
laneLerpPx: function (a, b, t) {
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
},
// 3 个不共线顶点确定的平面
lanePolyPlane: function (pts) {
if (!pts || pts.length < 3) return null;
var a = pts[0];
for (var i = 1; i < pts.length; i++) {
var v1 = pts[i].clone().sub(a);
if (v1.lengthSq() < 1e-12) continue;
for (var j = i + 1; j < pts.length; j++) {
var v2 = pts[j].clone().sub(a);
if (v2.lengthSq() < 1e-12) continue;
var n = v1.clone().cross(v2);
if (n.length() > 1e-9) return { point: a.clone(), normal: n.normalize() };
}
}
return null;
},
laneRayPlane: function (ray, plane) {
var n = plane.normal, o = ray.origin, d = ray.direction;
var den = d.x * n.x + d.y * n.y + d.z * n.z;
if (Math.abs(den) < 1e-9) return null;
var t = ((plane.point.x - o.x) * n.x + (plane.point.y - o.y) * n.y + (plane.point.z - o.z) * n.z) / den;
if (t < 0) return null;
return new Vector3(o.x + d.x * t, o.y + d.y * t, o.z + d.z * t);
},
// 屏幕点 -> 世界点(优先落在该多边形所在平面,退化时落到地面)
laneWorldOfNdc: function (ndc, poly) {
var ray = this.laneRayFromNdc(ndc), plane = null;
if (poly) {
try {
plane = this.lanePolyPlane(poly.getSpheresAndLines()[0].map(function (s) { return s.position; }));
} catch (e) { }
}
if (!plane) {
var gh = 0;
try { gh = S.statusState().groundHeight || 0; } catch (e) { }
plane = { point: new Vector3(0, 0, gh), normal: { x: 0, y: 0, z: 1 } };
}
return this.laneRayPlane(ray, plane);
},
// ---------- 2D 纯几何:索套切分 ----------
laneSegIntersect: function (a, b, c, d) {
var rx = b.x - a.x, ry = b.y - a.y, sx = d.x - c.x, sy = d.y - c.y;
var den = rx * sy - ry * sx;
if (Math.abs(den) < 1e-12) return null;
var qx = c.x - a.x, qy = c.y - a.y;
var t = (qx * sy - qy * sx) / den;
var u = (qx * ry - qy * rx) / den;
if (t < -1e-9 || t > 1 + 1e-9 || u < -1e-9 || u > 1 + 1e-9) return null;
return { t: t < 0 ? 0 : (t > 1 ? 1 : t), u: u < 0 ? 0 : (u > 1 ? 1 : u) };
},
// 沿多边形边界:从 eFrom 边之后的顶点走到 eTo 边的起点(不含两端交点)
// 沿多边形边界从参数 sFrom 正向走到参数 sTo,收集严格介于两者之间的顶点。
// 参数 = 边索引 + 该边上的比例:顶点 v 的参数就是 v,顶点 v→v+1 这条边上的参数在 (v, v+1)。
// 这样“边上的穿越点”和“正好落在顶点上”可以用同一套参数描述。
laneArcRefsBetween: function (sFrom, sTo, polyPx) {
var out = [], n = polyPx.length, span = sTo - sFrom;
while (span < 0) span += n; // 出口在入口“后方”时要绕整整一圈
if (span <= 1e-9) return out;
// 必须按“沿边界行走”的顺序收集(不能按顶点序号遍历,否则跨过 0 号顶点时顺序会乱、环会自相交)
var k = Math.floor(sFrom) + 1;
for (var guard = 0; guard < n; guard++) {
var d = k - sFrom;
if (d >= span - 1e-9) break; // 已经到(或越过)出口
out.push({ k: "v", i: k % n, px: polyPx[k % n] });
k++;
}
return out;
},
// 屏幕点是否落在某个多边形顶点附近(返回顶点序号,没有则 -1)
laneNearVertex: function (x, y, polyPx, tol) {
for (var i = 0; i < polyPx.length; i++) {
var dx = x - polyPx[i].x, dy = y - polyPx[i].y;
if (Math.sqrt(dx * dx + dy * dy) <= tol) return i;
}
return -1;
},
// 点是否在(屏幕空间)多边形环内
lanePointInRing: function (pt, refs) {
var inside = false;
for (var i = 0, j = refs.length - 1; i < refs.length; j = i++) {
var a = refs[i].px, b = refs[j].px;
if (((a.y > pt.y) !== (b.y > pt.y)) &&
(pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) inside = !inside;
}
return inside;
},
laneRingArea: function (refs) {
var s = 0;
for (var i = 0; i < refs.length; i++) {
var a = refs[i].px, b = refs[(i + 1) % refs.length].px;
s += a.x * b.y - b.x * a.y;
}
return s / 2;
},
// 用开放折线 lasso 切闭合多边形 poly(屏幕坐标),返回 { refs, area, otherArea }
// 用开放折线 lasso 切闭合多边形 poly(都是屏幕坐标)。
// 仍然要求正好两个“交点”,但交点既可以是边上的穿越点,也可以直接是多边形的已有顶点
// (画索套时靠近顶点会自动吸附,所以“从一个顶点切到另一个顶点”同样成立)。
laneCut2D: function (polyPx, lassoPx, flip, preferPoint, vertexTol) {
var n = polyPx.length, m = lassoPx.length;
if (n < 3 || m < 2) return null;
var tol = (typeof vertexTol === "number" && vertexTol > 0) ? vertexTol : 6;
var hits = [];
// ① 索套的点正好落在多边形顶点上(吸附后就是这种情形):直接算一个交点,参数取该顶点
for (var i = 0; i < m; i++) {
var vi = this.laneNearVertex(lassoPx[i].x, lassoPx[i].y, polyPx, tol);
if (vi >= 0) hits.push({ order: i, v: vi, s: vi, px: polyPx[vi] });
}
// ② 边上的穿越点:落在顶点附近的不再重复计入(交给 ①)
for (var j = 0; j < m - 1; j++) {
for (var k = 0; k < n; k++) {
var h = this.laneSegIntersect(lassoPx[j], lassoPx[j + 1], polyPx[k], polyPx[(k + 1) % n]);
if (!h) continue;
var px = this.laneLerpPx(lassoPx[j], lassoPx[j + 1], h.t);
if (this.laneNearVertex(px.x, px.y, polyPx, tol) >= 0) continue;
hits.push({ order: j + h.t, e: k, u: h.u, s: k + h.u, px: px });
}
}
if (hits.length < 2) return null;
hits.sort(function (x, y) { return x.order - y.order; });
// 合并几乎重合的命中,并要求恰好两个
var uniq = [];
for (var q = 0; q < hits.length; q++) {
var dup = false;
for (var w = 0; w < uniq.length; w++) {
var ddx = hits[q].px.x - uniq[w].px.x, ddy = hits[q].px.y - uniq[w].px.y;
if (Math.sqrt(ddx * ddx + ddy * ddy) < 1.5) { dup = true; break; }
}
if (!dup) uniq.push(hits[q]);
}
if (uniq.length !== 2) return null;
var entry = uniq[0], exit = uniq[1];
var refOf = function (hit) {
return (hit.v === undefined || hit.v === null)
? { k: "c", e: hit.e, u: hit.u, px: hit.px }
: { k: "v", i: hit.v, px: hit.px };
};
var entryRef = refOf(entry), exitRef = refOf(exit);
var mid = [];
for (var q2 = 0; q2 < m; q2++) {
if (q2 > entry.order && q2 < exit.order) mid.push({ k: "l", i: q2, px: lassoPx[q2] });
}
var arcA = this.laneArcRefsBetween(entry.s, exit.s, polyPx).concat([exitRef]);
var arcB = this.laneArcRefsBetween(exit.s, entry.s, polyPx).concat([entryRef]);
var ringA = [entryRef].concat(arcA, mid.slice().reverse());
var ringB = [exitRef].concat(arcB, mid);
var aA = Math.abs(this.laneRingArea(ringA)), aB = Math.abs(this.laneRingArea(ringB));
// 两侧都要有实际面积:贴着自己边界画的索套只会在原地“折返”,这种退化的切分拒绝执行
if (!(Math.max(aA, aB) > 1) || Math.min(aA, aB) < 2) return null;
// 默认保留“原多边形重心所在的那一侧”(也就是多边形主体)。
// 只按面积比大小在细长的路面上不可靠:屏幕上很扁时,要删掉的那一小片面积反而可能更大。
var takeA;
if (preferPoint) {
var inA = this.lanePointInRing(preferPoint, ringA);
var inB = this.lanePointInRing(preferPoint, ringB);
takeA = (inA !== inB) ? inA : (aA >= aB);
} else {
takeA = (aA >= aB);
}
if (flip) takeA = !takeA; // Alt:换成另一侧
return {
refs: takeA ? ringA : ringB,
removed: takeA ? ringB : ringA, // 将被删除的那一侧(用于预览)
area: takeA ? aA : aB,
otherArea: takeA ? aB : aA,
crossings: [entryRef, exitRef]
};
},
// ---------- 索套状态 / 绘制 ----------
laneLassoEnabled: function () {
var t;
try { t = S.getCurLabelType(); } catch (e) { return false; }
if (t !== LABEL_TYPE_MARK) return false;
if (this.isPainting || this.isDeleting) return false;
return !!this.canEdit();
},
laneLassoCanvas: function () {
if (this._laneCanvas && this._laneCanvas.isConnected) return this._laneCanvas;
var cv = document.createElement("canvas");
cv.id = "lane-lasso-canvas";
cv.style.cssText = "position:fixed;left:0;top:0;pointer-events:none;z-index:4;display:none";
document.body.appendChild(cv);
this._laneCanvas = cv;
return cv;
},
laneLassoButton: function () {
if (this._laneBtn && this._laneBtn.isConnected) return this._laneBtn;
var self = this;
var b = document.createElement("div");
b.id = "lane-lasso-btn";
b.style.cssText = "position:fixed;z-index:6;padding:4px 9px;border-radius:5px;font:" + FONT +
";color:#fff;background:rgba(20,20,20,.72);border:1px solid rgba(255,255,255,.28);cursor:pointer;user-select:none;white-space:nowrap;display:none";
b.textContent = "索套切边 (M)";
b.addEventListener("click", function (ev) {
ev.stopPropagation();
ev.preventDefault();
self.laneLassoMode(!self._laneLassoActive);
if (self._laneLassoActive) self.laneToast("索套切边:沿边界打点,Q 闭合切边");
}, true);
document.body.appendChild(b);
this._laneBtn = b;
return b;
},
laneLassoButtonUpdate: function () {
var b = this.laneLassoButton();
if (!b || !this.domElement) return;
var rect = this.domElement.getBoundingClientRect();
b.style.left = (rect.left + 12) + "px";
b.style.top = (rect.bottom - 32) + "px";
if (!this._laneLassoActive && !this.laneLassoEnabled()) { b.style.display = "none"; return; }
b.style.display = "block";
b.textContent = this._laneLassoActive
? "索套切边中 · 左键打点,Q 闭合,Esc 退出"
: "索套切边 (M)";
b.style.background = this._laneLassoActive ? "rgba(190,55,45,.88)" : "rgba(20,20,20,.72)";
},
laneLassoOwnUi: function (t) {
return !!(t && ((this._laneBtn && this._laneBtn.contains(t)) || (this._laneToastEl && this._laneToastEl.contains(t))));
},
laneToast: function (text) {
var el = this._laneToastEl;
if (!el) {
el = document.createElement("div");
el.id = "lane-toast";
el.style.cssText = "position:fixed;z-index:8;left:50%;transform:translateX(-50%);padding:6px 12px;border-radius:6px;font:" + FONT +
";color:#fff;background:rgba(0,0,0,.8);pointer-events:none;display:none;white-space:nowrap";
document.body.appendChild(el);
this._laneToastEl = el;
}
var rect = this.domElement.getBoundingClientRect();
el.style.top = (rect.bottom - 64) + "px";
el.textContent = text;
el.style.display = "block";
var self = this;
if (this._laneToastTimer) clearTimeout(this._laneToastTimer);
this._laneToastTimer = setTimeout(function () { el.style.display = "none"; }, 2600);
},
laneLassoMode: function (on) {
this._laneLassoActive = !!on;
this._laneLassoPts = [];
this._laneCursor = null;
if (on) {
this._laneUndo = null;
this._laneSelTrack = null;
this.laneClearGhost();
try { this._laneSelTrack = S.statusState().selectMarkInfo.trackId; } catch (e) { }
}
if (this.domElement) this.domElement.style.cursor = on ? "crosshair" : "";
this._laneLassoShow = on;
this.laneLassoDraw();
},
// 当前索套点(世界点按当前视图重新投影)-> 画布相对像素
laneLassoScreen: function (rect) {
var out = [];
for (var i = 0; i < this._laneLassoPts.length; i++) {
var p = this._laneLassoPts[i], px = null;
if (p.world) px = this.laneProject(p.world, rect);
if (!px) px = p.pxFallback;
if (px) out.push(px);
}
return out;
},
// 找到会被切分的多边形:优先已选中的那个
laneLassoPlan: function (flip) {
var rect = this.domElement.getBoundingClientRect();
var lassoPx = this.laneLassoScreen(rect);
if (lassoPx.length < 2) return null;
var polys = this.lanePolylines();
var selected = null;
try { selected = S.statusState().selectMarkInfo.trackId; } catch (e) { }
if (!selected && this._laneSelTrack) selected = this._laneSelTrack;
var best = null;
for (var i = 0; i < polys.length; i++) {
var p = polys[i], sph = null;
try { sph = p.getSpheresAndLines()[0]; } catch (e) { continue; }
if (!sph || sph.length < 3) continue;
var polyPx = [], ok = true;
for (var j = 0; j < sph.length; j++) {
var px = this.laneProject(sph[j].position, rect);
if (!px) { ok = false; break; }
polyPx.push(px);
}
if (!ok) continue;
// 以多边形重心为“要保留的那一侧”的参照点(重心落在原多边形外时不用它)
var cx = 0, cy = 0, q2;
for (q2 = 0; q2 < polyPx.length; q2++) { cx += polyPx[q2].x; cy += polyPx[q2].y; }
cx /= polyPx.length; cy /= polyPx.length;
var polyRefs = [];
for (q2 = 0; q2 < polyPx.length; q2++) polyRefs.push({ px: polyPx[q2] });
var center = this.lanePointInRing({ x: cx, y: cy }, polyRefs) ? { x: cx, y: cy } : null;
var cut = this.laneCut2D(polyPx, lassoPx, flip, center, readPx("lane_cut_vertex_px", DEFAULTS.CUT_VERTEX_PX));
if (!cut) continue;
var isSel = p.trackId === selected;
if (!best || (isSel && !best.isSel) || (isSel === best.isSel && cut.area > best.cut.area)) {
best = { poly: p, cut: cut, isSel: isSel };
}
}
return best;
},
laneLassoDraw: function () {
var cv = this.laneLassoCanvas();
if (!cv) return;
var dpr = window.devicePixelRatio || 1;
var w = Math.max(1, window.innerWidth), h = Math.max(1, window.innerHeight);
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";
}
var ctx = cv.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
if (!this._laneLassoActive) {
cv.style.display = "none";
this.laneLassoButtonUpdate();
return;
}
cv.style.display = "block";
var rect = this.domElement.getBoundingClientRect();
var pts = [], i;
for (i = 0; i < this._laneLassoPts.length; i++) {
var p = this._laneLassoPts[i], px = p.world ? this.laneProject(p.world, rect) : null;
pts.push(px ? this.laneToClient(px, rect) : p.client);
}
var plan = this.laneLassoPlan(!!this._laneAlt); // 按住 Alt 时预览另一侧
var col = plan ? COL_OK : COL_OPEN;
// 先把“将被删除的区域”铺出来(半透明红),这样按 Q 之前就能看清删哪一侧
if (plan && plan.cut.removed && plan.cut.removed.length > 2) {
var rem = plan.cut.removed;
var rp = this.laneToClient(rem[0].px, rect);
ctx.beginPath();
ctx.moveTo(rp.x, rp.y);
for (var ri = 1; ri < rem.length; ri++) {
var rq = this.laneToClient(rem[ri].px, rect);
ctx.lineTo(rq.x, rq.y);
}
ctx.closePath();
ctx.fillStyle = "rgba(255,72,60,.28)";
ctx.fill();
ctx.strokeStyle = "rgba(255,72,60,.85)";
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.lineJoin = "round";
ctx.lineCap = "round";
if (pts.length > 1) {
ctx.strokeStyle = col;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
}
if (pts.length && this._laneCursor) {
ctx.save();
ctx.setLineDash([6, 4]);
ctx.strokeStyle = col;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(pts[pts.length - 1].x, pts[pts.length - 1].y);
ctx.lineTo(this._laneCursor.x, this._laneCursor.y);
ctx.stroke();
ctx.restore();
}
if (plan) {
var cr = [plan.cut.crossings[0], plan.cut.crossings[1]];
for (i = 0; i < cr.length; i++) {
var cp = this.laneToClient(cr[i].px, rect);
ctx.beginPath();
ctx.arc(cp.x, cp.y, 6, 0, Math.PI * 2);
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.stroke();
}
}
for (i = 0; i < pts.length; i++) {
var isSnap = !!(this._laneLassoPts[i] && this._laneLassoPts[i].vertex);
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, isSnap ? 7 : 4, 0, Math.PI * 2);
if (isSnap) { // 吸附到多边形顶点的点画成白圈,便于确认
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.strokeStyle = col;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, 4, 0, Math.PI * 2);
ctx.fillStyle = col;
ctx.fill();
} else {
ctx.fillStyle = col;
ctx.fill();
}
}
// 提示条
var snapN = 0;
for (i = 0; i < this._laneLassoPts.length; i++) if (this._laneLassoPts[i].vertex) snapN++;
var hint = plan
? "红色区域将被删除:" + (plan.poly.className || plan.poly.classId || plan.poly.trackId || "多边形") +
" · Q 确认(Alt 换成另一侧)· Z 撤销 · Esc/M 退出"
: (snapN ? "已吸附 " + snapN + " 个多边形顶点" : "索套切边:左键打点(靠近多边形顶点会自动吸附)") +
" · Q 闭合切边 · Backspace 退点 · Esc/M 退出";
ctx.font = FONT.split("/")[0].replace("12px", "13px") + " system-ui,-apple-system,'Microsoft YaHei',sans-serif";
var tw = ctx.measureText(hint).width;
var bx = Math.max(8, (w - tw) / 2 - 10), by = rect.top + 10;
ctx.fillStyle = "rgba(0,0,0,.72)";
ctx.fillRect(bx, by, tw + 20, 26);
ctx.fillStyle = "#fff";
ctx.fillText(hint, bx + 10, by + 18);
this.laneLassoButtonUpdate();
},
// ---------- 交互 ----------
// 找光标附近的多边形顶点(用于吸附;优先当前会被切的那个多边形)
laneLassoVertexAt: function (px, rect, preferPoly) {
var limit = readPx("lane_vertex_snap_px", DEFAULTS.SNAP_VERTEX_PX);
if (!(limit > 0)) return null;
var polys = this.lanePolylines(), best = null, bestD = limit;
for (var i = 0; i < polys.length; i++) {
var p = polys[i], sph = p.getSpheresAndLines()[0];
for (var j = 0; j < sph.length; j++) {
var sp = this.laneProject(sph[j].position, rect);
if (!sp) continue;
var d = Math.hypot(sp.x - px.x, sp.y - px.y) + ((preferPoly && p === preferPoly) ? 0 : 3);
if (d < bestD) { bestD = d; best = { poly: p, index: j, px: sp, position: sph[j].position }; }
}
}
return best;
},
laneLassoAddPoint: function (ev) {
var rect = this.domElement.getBoundingClientRect();
var ndc = this.laneNdcOfEvent(ev);
var plan = this.laneLassoPlan(false);
var world = this.laneWorldOfNdc(ndc, plan && plan.poly);
var px = { x: ev.clientX - rect.left, y: ev.clientY - rect.top };
// 靠近多边形顶点就吸附到该顶点:可以精确地“从这个顶点切到那个顶点”
var snapV = this.laneLassoVertexAt(px, rect, plan && plan.poly);
if (snapV && snapV.position) { world = snapV.position.clone(); px = snapV.px; }
this._laneLassoPts.push({
world: world,
pxFallback: px,
client: { x: rect.left + px.x, y: rect.top + px.y },
vertex: snapV ? { poly: snapV.poly, index: snapV.index } : null
});
this._laneCursor = { x: ev.clientX, y: ev.clientY };
this.laneLassoDraw();
},
laneLassoHandleDom: function (ev) {
if (!this._laneLassoActive) return;
if (ev.type === "mousedown" || ev.type === "pointerdown") {
this._laneAlt = !!ev.altKey;
if (ev.button === 0) this.laneLassoAddPoint(ev); // 只有左键打点
return;
}
if (ev.type === "mousemove" || ev.type === "pointermove") {
this._laneCursor = { x: ev.clientX, y: ev.clientY };
this._laneAlt = !!ev.altKey;
this.laneLassoDraw();
}
},
laneLassoApply: function (flip) {
var plan = this.laneLassoPlan(flip);
if (!plan) { this.laneToast("索套需要正好穿过多边形边界两次(线变绿才能切)"); return false; }
var rect = this.domElement.getBoundingClientRect();
var poly = plan.poly;
var sph = poly.getSpheresAndLines()[0];
var p3 = sph.map(function (s) { return s.position; });
var plane = this.lanePolyPlane(p3);
var out = [];
for (var i = 0; i < plan.cut.refs.length; i++) {
var ref = plan.cut.refs[i], v = null;
if (ref.k === "v") {
v = p3[ref.i] ? p3[ref.i].clone() : null;
} else {
var ray = this.laneRayFromNdc(this.lanePxToNdc(ref.px, rect));
if (ref.k === "c") {
var a = p3[ref.e], b = p3[(ref.e + 1) % p3.length];
v = (a && b) ? this.laneClosestOnSegment(ray.origin, ray.direction, a, b) : null;
} else {
v = plane ? this.laneRayPlane(ray, plane) : null;
if (!v) {
var gh = 0;
try { gh = S.statusState().groundHeight || 0; } catch (e) { }
v = this.laneRayPlane(ray, { point: new Vector3(0, 0, gh), normal: { x: 0, y: 0, z: 1 } });
}
}
}
if (!v) { this.laneToast("切分失败:新顶点坐标无法求解,请调整索套"); return false; }
out.push([v.x, v.y, v.z]);
}
if (out.length < 3) { this.laneToast("切分后顶点不足 3 个,已取消"); return false; }
var oldPts = (poly.mark && poly.mark.points)
? poly.mark.points.map(function (q) { return [q[0], q[1], q[2]]; })
: null;
try {
if (poly.mark) poly.mark.points = out;
poly.initChildren();
} catch (e) {
console.warn("[lasso-cut] cut", e);
this.laneToast("切分失败");
return false;
}
try { S.modifyMark3D(MARK_ACTION_MODIFY_MATRIX, poly); } catch (e) { console.warn("[lasso-cut] 保存失败", e); }
this._laneUndo = oldPts ? { poly: poly, points: oldPts } : null;
this._laneLassoPts = [];
this.viewer.rendererMain();
this.laneLassoDraw();
this.laneToast("已切边:" + (poly.className || poly.classId || poly.trackId || "") +
",顶点 " + poly.getSpheresAndLines()[0].length + " 个(Z 可撤销)");
return true;
},
laneLassoUndo: function () {
var u = this._laneUndo;
if (!u || !u.poly) { this.laneToast("没有可撤销的切边"); return false; }
try {
if (u.poly.mark) u.poly.mark.points = u.points.map(function (q) { return [q[0], q[1], q[2]]; });
u.poly.initChildren();
S.modifyMark3D(MARK_ACTION_MODIFY_MATRIX, u.poly);
} catch (e) {
console.warn("[lasso-cut] undo", e);
this.laneToast("撤销失败");
return false;
}
this._laneUndo = null;
this._laneLassoPts = [];
this.viewer.rendererMain();
this.laneLassoDraw();
this.laneToast("已撤销上一次切边");
return true;
},
laneLassoKey: function (ev) {
var t = ev.target || {}, tag = (t.tagName || "").toUpperCase();
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || t.isContentEditable) return;
var k = (ev.key || "").toLowerCase();
if (!this._laneLassoActive) {
if (k === "m" && !ev.ctrlKey && !ev.metaKey && !ev.altKey && !ev.shiftKey && this.laneLassoEnabled()) {
var dis = false;
try { dis = S.isDisabledShortKey ? S.isDisabledShortKey() : false; } catch (e) { }
if (dis) return;
this.laneLassoMode(true);
this.laneToast("索套切边:沿要保留的边界打点,Q 闭合切边");
ev.preventDefault();
ev.stopPropagation();
}
return;
}
var used = true;
if (k === "q" || k === "enter") this.laneLassoApply(!!ev.altKey);
else if (k === "escape") { this.laneLassoMode(false); this.laneToast("已退出索套切边"); }
else if (k === "backspace") { this._laneLassoPts.pop(); this.laneLassoDraw(); }
else if (k === "z" && !ev.ctrlKey && !ev.metaKey) this.laneLassoUndo();
else if (k === "m") { this.laneLassoMode(false); this.laneToast("已退出索套切边"); }
else used = false;
if (used) { ev.preventDefault(); ev.stopPropagation(); }
}
};
}
// #endregion lane-lasso
// 注意:instance.viewer 是懒加载 getter,读取它会创建 Viewer;
// 这里只读内部字段 _viewer,避免在平台还没准备好时提前实例化。
function findViewer() {
var inst = W.instance;
if (inst && inst._viewer) return inst._viewer;
return W.viewer || null; // Viewer 构造时会把自己挂到 window.viewer
}
function findStore() {
var inst = W.instance;
try { return inst && inst.StoreManager; } catch (e) { return null; }
}
function install(viewer) {
if (!viewer || !viewer.lineHandler || !viewer.render) return false;
var lh = viewer.lineHandler;
if (!lh.__laneInstalled) {
var S = findStore();
if (!S) return false;
if (!viewer.camera || !viewer.camera.position) return false;
var Vector3 = viewer.camera.position.constructor;
// 索套切边需要的基础工具(多边形枚举 / 投影 / 点到线段最近点)
var helpers = makeLaneMethods({ S: S, Vector3: Vector3 });
for (var k in helpers) if (Object.prototype.hasOwnProperty.call(helpers, k)) lh[k] = helpers[k];
var lassoMethods = makeLassoMethods({ S: S, Vector3: Vector3 });
for (var k2 in lassoMethods) if (Object.prototype.hasOwnProperty.call(lassoMethods, k2)) lh[k2] = lassoMethods[k2];
// 索套模式内于捕获阶段拦截鼠标/指针事件,避免平台同时处理或转动视角;未进入模式时直接放行
var lassoTarget = null;
var bindLassoDom = function () {
if (lassoTarget === lh.domElement || !lh.domElement || !lh.domElement.addEventListener) return;
lassoTarget = lh.domElement;
var swallow = function (ev) {
if (!lh._laneLassoActive) return;
if (lh.laneLassoOwnUi(ev.target)) return;
// 右键/中键一律放行:平台的相机操作用的是 OrbitControls 默认映射
// (右键拖动平移点云、中键缩放、左键旋转),不能被索套抢走
var isMove = ev.type === "mousemove" || ev.type === "pointermove";
var nonLeft = (typeof ev.button === "number" && ev.button > 0) ||
(isMove && typeof ev.buttons === "number" && (ev.buttons & 6) !== 0);
if (nonLeft) { lh.laneLassoDraw(); return; }
ev.stopPropagation();
if (ev.cancelable) ev.preventDefault();
lh.laneLassoHandleDom(ev);
};
lh.__laneSwallow = swallow;
["mousedown", "mouseup", "mousemove", "click", "dblclick", "contextmenu",
"pointerdown", "pointerup", "pointermove"].forEach(function (t) {
lassoTarget.addEventListener(t, swallow, true);
});
};
lh.__laneBindLassoDom = bindLassoDom;
bindLassoDom();
if (!window.__lassoCutKeyBound) {
window.__lassoCutKeyBound = true;
window.addEventListener("keydown", function (ev) { lh.laneLassoKey(ev); }, true);
}
// ---- 顶点吸附合并:拖动顶点靠近另一个顶点时吸附对齐,松手后合并成一个点 ----
// 注意:three.js 派发事件用的是 listener.call(目标对象, event),监听器里的 this 会是那个球体
// 而不是 LineHandler(平台自己的实现就是因为这个才在构造函数里 bind 过)。
// 所以下面统一用 lh 取对象、并把包装函数 bind 到 lh,任何情况下都不会因为 this 不对而报错。
var prevOnLineDrag = (typeof lh.onLineDrag === "function")
? lh.onLineDrag // 平台构造时 bind 好的原实现
: Object.getPrototypeOf(lh).onLineDrag; // 兜底:原型方法
lh.onLineDrag = function (e) {
try {
if (!lh.canEdit() || lh.isDeleting) return;
var sphere = e && e.drag && e.drag.object;
if (sphere && sphere.parent && sphere.parent.markType !== VISUAL_TYPE_VIRTUAL &&
typeof sphere.parent.getSpheresAndLines === "function") {
var rect = lh.domElement.getBoundingClientRect();
var st = e.drag && e.drag.start;
var grabPx = (st && typeof st.x === "number")
? { x: (st.x * .5 + .5) * rect.width, y: (-st.y * .5 + .5) * rect.height }
: null;
var snap = lh.laneSnapTarget(sphere, e.drag && e.drag.end, grabPx);
if (snap) {
sphere.position.copy(snap.sphere.position);
var poly = sphere.parent;
lh.updateLinesPosition(poly);
try { poly.initInner(); } catch (err) { }
try { S.modifyMark3D(MARK_ACTION_MODIFY_MATRIX, poly); } catch (err) { }
lh.__laneSnap = { poly: poly, sphere: sphere, target: snap.sphere, same: snap.same };
var g = lh.laneEnsureGhost();
if (g) {
if (g.material && g.material.color) {
g.material.color = (snap.same ? lh._laneColor : lh._laneSnapColor).clone();
}
g.position.copy(snap.sphere.position);
g.visible = true;
}
lh.viewer.rendererMain();
return;
}
}
} catch (err) { console.warn("[snap-merge] drag", err); }
try {
lh.__laneSnap = null;
lh.laneClearGhost();
} catch (err) { }
return prevOnLineDrag.call(lh, e);
}.bind(lh);
// 已有多边形重新注册拾取事件(平台注册时读取 this.onLineDrag,包装后才生效);
// 先把旧的拖动监听摘掉,避免同一次拖动被处理两次
var reregister = function (scene) {
if (!scene || !scene.children) return;
scene.children.slice().forEach(function (o) {
if (!o || typeof o.getSpheresAndLines !== "function") return;
try {
o.children.forEach(function (ch) {
if (ch && ch.removeEventListener) {
ch.removeEventListener("drag", prevOnLineDrag);
ch.removeEventListener("drag", lh.onLineDrag);
}
});
} catch (e) { }
try { lh.addPickEvents(o); } catch (e) { }
});
};
reregister(viewer.lineScene);
reregister(viewer.annotationGroup);
lh.domElement.addEventListener("mouseup", function (e) { lh.onLaneMergeMouseup(e); }, false);
lh.__laneInstalled = true;
console.log("[lasso-cut] 已注入:按 M 进入索套切边(左键打点,Q 闭合切边)");
}
// 画布容器被重建时重新挂监听(视图切换/重新挂载的情况)
if (lh.__laneDom !== lh.domElement && lh.domElement && lh.domElement.addEventListener) {
lh.__laneDom = lh.domElement;
if (lh.__laneBindLassoDom) lh.__laneBindLassoDom();
}
if (lh.laneLassoButtonUpdate) { try { lh.laneLassoButtonUpdate(); } catch (e) { } }
return true;
}
W.__lassoCut = {
install: install,
findViewer: findViewer,
makeLaneMethods: makeLaneMethods,
makeLassoMethods: makeLassoMethods,
version: "2.0.0"
};
// 点云页面是懒加载的:轮询等待 viewer / StoreManager 就绪;注入成功后转低频巡检,
// 以便视图重新挂载、容器被替换时能自动补挂监听。
if (!document.querySelector("#app")) return; // 非平台页面,直接退出
var tries = 0, slow = false, timer = null;
var tick = function () {
tries++;
var done = false;
try { done = install(findViewer()); } catch (e) { console.warn("[lasso-cut]", e); }
if (done && !slow) { // 注入成功:降频巡检
slow = true;
clearInterval(timer);
timer = setInterval(tick, 3000);
} else if (!done && tries > 600) { // 5 分钟仍未就绪,放弃
clearInterval(timer);
}
};
timer = setInterval(tick, 500);
W.addEventListener("load", function () { install(findViewer()); });
})();
// ================================================================
// 二、RGB 点云着色(静默版 v2.2.0,原样并入)
// ================================================================
(function () {
'use strict';
/* ================================================================
* 0. 参数(无 UI 开关;颜色一律按原始值写入,不做任何增强)
* ================================================================ */
const CFG = {
autoFollow: true, // 自动跟随帧变化
keepRgbMode: true, // 自动切到官方「rgb着色」并保持(用户手动改后不再抢)
prefetchNext: true, // 空闲时预热下一帧的相机图,切帧近乎瞬时(不用可设 false)
pollMs: 80,
imgCacheMax: 24,
pcdCacheMax: 2,
imgProxy: '', // 图片跨域读不了时可填代理前缀,例如 'http://127.0.0.1:8000/?url='
};
const LOG = '[平台RGB着色]';
const log = (...a) => console.log(LOG, ...a);
/* ================================================================
* 1. 平台对象(均按平台自身实现核对过)
* ================================================================ */
const getInstance = () => window.instance || null;
const getSM = () => {
const i = getInstance();
return (i && i.StoreManager) || null;
};
const getViewer = () => {
const i = getInstance();
return window.viewer || (i && i.viewer) || null;
};
function sensorParams() {
const SM = getSM();
if (!SM) return null;
try {
const sp = SM.configState().params.sensor_params;
return sp && typeof sp === 'object' ? sp : null;
} catch (e) { return null; }
}
function frameById(id) {
const SM = getSM();
if (!SM) return null;
try {
const g = SM.configGetter('frame');
if (!g) return null;
return (id !== null && id !== undefined ? g(id) : g()) || null;
} catch (e) { return null; }
}
function poseById(id) {
const SM = getSM();
if (!SM) return null;
try {
const g = SM.configGetter('getPose');
if (!g) return null;
const p = g(id);
return (p && p.length >= 16) ? p : null;
} catch (e) { return null; }
}
function imageUrlOf(frame, key) {
try {
const arr = frame && frame.slave_info && frame.slave_info[key];
const first = Array.isArray(arr) ? arr[0] : arr;
return (first && (first.url || first.src)) || null;
} catch (e) { return null; }
}
/* ================================================================
* 2. 4x4 工具(行/列主序都支持,用于坐标系判定)
* ================================================================ */
const colMajorTo4x4 = (el) => [
[el[0], el[4], el[8], el[12]],
[el[1], el[5], el[9], el[13]],
[el[2], el[6], el[10], el[14]],
[el[3], el[7], el[11], el[15]],
];
const rowMajorTo4x4 = (el) => [
[el[0], el[1], el[2], el[3]],
[el[4], el[5], el[6], el[7]],
[el[8], el[9], el[10], el[11]],
[el[12], el[13], el[14], el[15]],
];
function matMul(A, B) {
const C = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]];
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
let s = 0;
for (let k = 0; k < 4; k++) s += A[r][k] * B[k][c];
C[r][c] = s;
}
}
return C;
}
function matInv(M) {
const a = [];
for (let r = 0; r < 4; r++) a.push(M[r].slice().concat([r === 0 ? 1 : 0, r === 1 ? 1 : 0, r === 2 ? 1 : 0, r === 3 ? 1 : 0]));
for (let c = 0; c < 4; c++) {
let piv = c;
for (let r = c + 1; r < 4; r++) if (Math.abs(a[r][c]) > Math.abs(a[piv][c])) piv = r;
if (Math.abs(a[piv][c]) < 1e-12) return null;
const t = a[c]; a[c] = a[piv]; a[piv] = t;
const d = a[c][c];
for (let k = 0; k < 8; k++) a[c][k] /= d;
for (let r = 0; r < 4; r++) {
if (r === c) continue;
const f = a[r][c];
if (!f) continue;
for (let k = 0; k < 8; k++) a[r][k] -= f * a[c][k];
}
}
return [
[a[0][4], a[0][5], a[0][6], a[0][7]],
[a[1][4], a[1][5], a[1][6], a[1][7]],
[a[2][4], a[2][5], a[2][6], a[2][7]],
[a[3][4], a[3][5], a[3][6], a[3][7]],
];
}
/* ================================================================
* 3. 相机图像(缓存 / 跨域降级 / 并发去重)
* ================================================================ */
const imgCache = new Map();
const imgPending = new Map();
const imgFailed = new Map();
function decodeImage(url, useCors) {
return new Promise((resolve, reject) => {
const img = new Image();
if (useCors) img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
const cv = document.createElement('canvas');
cv.width = w; cv.height = h;
const ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
resolve({ data: ctx.getImageData(0, 0, w, h).data, width: w, height: h });
} catch (e) { reject(new Error('画布跨域污染,无法读取像素')); }
};
img.onerror = () => reject(new Error('图片加载失败'));
img.src = url;
});
}
async function fetchDecode(url) {
const res = await fetch(url, { mode: 'cors', credentials: 'same-origin' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const bmp = await createImageBitmap(await res.blob());
const cv = document.createElement('canvas');
cv.width = bmp.width; cv.height = bmp.height;
const ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.drawImage(bmp, 0, 0);
const d = ctx.getImageData(0, 0, bmp.width, bmp.height).data;
if (bmp.close) bmp.close();
return { data: d, width: bmp.width, height: bmp.height };
}
/**
* 取图策略按“来源”记忆:同源走 (能和平台共用同一份浏览器缓存与已解码位图);
* 跨域必须先走 crossOrigin,否则会先下载一遍拿不到像素的图,再为 CORS 重下一遍。
* 第一次成功的方式会被记下来,后续同来源直接用,避免任何重复下载。
*/
const originStrategy = new Map();
function originOf(url) {
try { return new URL(url, location.href).origin; } catch (e) { return 'unknown'; }
}
function strategyOrder(url) {
const known = originStrategy.get(originOf(url));
const base = originOf(url) === location.origin ? ['plain', 'fetch', 'cors'] : ['cors', 'fetch', 'plain'];
if (!known) return base;
return [known].concat(base.filter((x) => x !== known));
}
const STRAT = {
plain: (u) => decodeImage(u, false),
cors: (u) => decodeImage(u, true),
fetch: fetchDecode,
};
async function loadImage(rawUrl) {
if (imgCache.has(rawUrl)) return imgCache.get(rawUrl);
if (imgPending.has(rawUrl)) return imgPending.get(rawUrl);
if (imgFailed.has(rawUrl)) return null;
const p = (async () => {
const urls = CFG.imgProxy ? [CFG.imgProxy + encodeURIComponent(rawUrl), rawUrl] : [rawUrl];
const errs = [];
for (const u of urls) {
for (const name of strategyOrder(u)) {
try {
const r = await STRAT[name](u);
originStrategy.set(originOf(u), name); // 记住这条来源可行的方式
return r;
} catch (e) { errs.push(name + ': ' + e.message); }
}
}
imgFailed.set(rawUrl, errs.join(' | '));
return null;
})();
imgPending.set(rawUrl, p);
const r = await p;
imgPending.delete(rawUrl);
if (r) {
imgCache.set(rawUrl, r);
if (imgCache.size > CFG.imgCacheMax) imgCache.delete(imgCache.keys().next().value);
}
return r;
}
/* ================================================================
* 4. PCD 直读
* 平台 loader 只认字段名 rgb;static_map.pcd 是 rgba,所以平台点不亮 rgb着色
* 这里把文件里的 rgb / rgba 直接解出来(支持 ascii / binary / binary_compressed)
* ================================================================ */
const pcdCache = new Map();
function lzfDecompress(input, outLen) {
const out = new Uint8Array(outLen);
let i = 0, o = 0;
while (i < input.length) {
let ctrl = input[i++];
if (ctrl < 32) {
ctrl++;
if (o + ctrl > outLen) break;
out.set(input.subarray(i, i + ctrl), o);
i += ctrl; o += ctrl;
} else {
let len = ctrl >> 5;
let ref = o - ((ctrl & 0x1f) << 8) - 1;
if (len === 7) len += input[i++];
ref -= input[i++];
if (ref < 0) break;
for (let k = 0; k <= len + 1; k++) {
if (o >= outLen) break;
out[o++] = out[ref++];
}
}
}
return out;
}
function parsePcdHeader(bytes) {
const headLen = Math.min(bytes.length, 8192);
let text = '';
for (let i = 0; i < headLen; i++) text += String.fromCharCode(bytes[i]);
// 逐行扫描:必须跳过 # 注释行,否则
// "# .PCD v0.7 - Point Cloud Data file format" 会被误当成 DATA 行
const map = {};
let dataType = null, dataOffset = -1, i = 0;
while (i < text.length) {
let end = text.indexOf('\n', i);
if (end < 0) end = text.length;
let line = text.slice(i, end);
const next = end + 1;
if (line.endsWith('\r')) line = line.slice(0, -1);
const t = line.trim();
if (t && t.charAt(0) !== '#') {
const parts = t.split(/\s+/);
const key = parts[0].toUpperCase();
if (key === 'DATA') {
dataType = (parts[1] || '').toLowerCase();
dataOffset = next; // 头部为纯 ASCII,字符偏移 == 字节偏移
break;
}
map[key] = parts.slice(1);
}
i = next;
}
if (!dataType || dataOffset < 0) return null;
const fields = map.FIELDS || [];
const sizes = (map.SIZE || []).map(Number);
const types = map.TYPE || [];
const counts = (map.COUNT || []).map(Number);
const points = parseInt((map.POINTS && map.POINTS[0]) || '0', 10);
if (!fields.length || !points) return null;
const cnt = counts.length ? counts : fields.map(() => 1);
let rowSize = 0;
const offset = {};
for (let k = 0; k < fields.length; k++) {
offset[fields[k]] = rowSize;
rowSize += (sizes[k] || 1) * (cnt[k] || 1);
}
return { fields, sizes, types, counts: cnt, points, dataType, dataOffset, rowSize, offset };
}
/** 取出数据段(自动处理 binary_compressed 的 LZF + SOA 布局) */
function dataOf(bytes, hdr) {
if (hdr.dataType === 'binary_compressed') {
const dv = new DataView(bytes.buffer, bytes.byteOffset + hdr.dataOffset);
const compSize = dv.getUint32(0, true);
const uncompSize = dv.getUint32(4, true);
const comp = new Uint8Array(bytes.buffer, bytes.byteOffset + hdr.dataOffset + 8, compSize);
return { data: lzfDecompress(comp, uncompSize), soa: true };
}
if (hdr.dataType === 'binary') {
return { data: new Uint8Array(bytes.buffer, bytes.byteOffset + hdr.dataOffset), soa: false };
}
return null; // ascii 单独处理
}
/** 解出 rgb / rgba 颜色(R 在 +2 字节、G +1、B +0,与平台 loader 的读法一致) */
function extractColors(bytes, hdr) {
let ci = hdr.fields.indexOf('rgb');
if (ci < 0) ci = hdr.fields.indexOf('rgba');
if (ci < 0) return null;
const fieldName = hdr.fields[ci];
if ((hdr.sizes[ci] || 0) !== 4) return null;
const n = hdr.points;
const out = new Float32Array(n * 3);
const pack = dataOf(bytes, hdr);
if (pack) {
const { data, soa } = pack;
const co = hdr.offset[fieldName];
const stride = hdr.sizes[ci] || 4;
for (let i = 0; i < n; i++) {
const base = soa ? (co * n + i * stride) : (i * hdr.rowSize + co);
const j = i * 3;
out[j] = data[base + 2] / 255;
out[j + 1] = data[base + 1] / 255;
out[j + 2] = data[base] / 255;
}
} else if (hdr.dataType === 'ascii') {
const txt = new TextDecoder().decode(bytes.subarray(hdr.dataOffset));
const lines = txt.split(/\r?\n/);
const isF = hdr.types[ci] === 'F';
for (let i = 0; i < n; i++) {
const parts = (lines[i] || '').trim().split(/\s+/);
if (parts.length <= ci) break;
const raw = parseFloat(parts[ci]);
let v;
if (isF) { const f = new Float32Array(1); f[0] = raw; v = new Uint32Array(f.buffer)[0]; }
else v = raw >>> 0;
const j = i * 3;
out[j] = ((v >> 16) & 255) / 255;
out[j + 1] = ((v >> 8) & 255) / 255;
out[j + 2] = (v & 255) / 255;
}
} else {
return null;
}
return { colors: out, fieldName, points: n };
}
/** 校验 pcd 与当前 geometry 是否同一份(点数 + 若干点 xyz 对齐) */
function verifySameCloud(bytes, hdr, posArr, count) {
if (hdr.points !== count) return { same: false, why: '点数不一致 pcd=' + hdr.points + ' 几何=' + count };
const pack = dataOf(bytes, hdr);
if (!pack) return { same: true, why: 'ascii 未校验' };
const { data, soa } = pack;
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
const samples = [0, 1, 2, 100, 1000].filter((i) => i < count);
for (const i of samples) {
for (const pair of [['x', 0], ['y', 1], ['z', 2]]) {
const f = pair[0], k = pair[1];
const off = hdr.offset[f];
if (off === undefined) continue;
const at = soa ? (off * count + i * 4) : (i * hdr.rowSize + off);
if (at + 4 > dv.byteLength) continue;
const val = dv.getFloat32(at, true);
const geo = posArr[i * 3 + k];
if (!isFinite(val) || !isFinite(geo)) continue;
if (Math.abs(val - geo) > 1e-3) {
return { same: false, why: '坐标不对齐(第' + i + '点 ' + f + ': pcd=' + val + ' 几何=' + geo + ')' };
}
}
}
return { same: true, why: 'ok' };
}
/** 只取 pcd 头部(8KB)判断有没有 rgb/rgba 字段 —— 避免为探明字段名而下整个文件 */
const probeCache = new Map();
const dirNoColor = new Set(); // 同一批数据的其他帧同样没有颜色字段,可直接跳过探测
const dirOf = (u) => { const i = u.lastIndexOf('/'); return i > 0 ? u.slice(0, i) : u; };
async function probePcdHeader(url) {
if (probeCache.has(url)) return probeCache.get(url);
let res = null;
try {
res = await fetch(url, {
headers: { Range: 'bytes=0-8191' },
credentials: 'same-origin',
});
if (!res.ok && res.status !== 206) throw new Error('HTTP ' + res.status);
let bytes = null;
if (res.status === 206) {
bytes = new Uint8Array(await res.arrayBuffer());
} else if (res.body && typeof res.body.getReader === 'function') {
// 服务器忽略了 Range:读第一块就中止,不把整个文件拖下来
const reader = res.body.getReader();
const chunks = [];
let total = 0;
while (total < 8192) {
const r = await reader.read();
if (r.done) break;
chunks.push(r.value);
total += r.value.length;
}
try { await reader.cancel(); } catch (e) {}
bytes = new Uint8Array(total);
let o = 0;
for (const c of chunks) { bytes.set(c, o); o += c.length; }
} else {
bytes = new Uint8Array(await res.arrayBuffer());
}
const hdr = parsePcdHeader(bytes);
const rec = hdr ? { hdr } : null;
probeCache.set(url, rec);
if (probeCache.size > 200) probeCache.delete(probeCache.keys().next().value);
return rec;
} catch (e) {
probeCache.set(url, null);
return null;
}
}
async function readPcdFile(url) {
if (!url) return null;
if (pcdCache.has(url)) return pcdCache.get(url);
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) throw new Error('pcd HTTP ' + res.status);
const bytes = new Uint8Array(await res.arrayBuffer());
const hdr = parsePcdHeader(bytes);
if (!hdr) throw new Error('pcd 头解析失败(可能不是 PCD 或头部超长)');
const rec = { bytes, hdr };
pcdCache.set(url, rec);
if (pcdCache.size > CFG.pcdCacheMax) pcdCache.delete(pcdCache.keys().next().value);
return rec;
}
/* ================================================================
* 5. 投影(与平台 computePointWithoutBehindFilter 同一套公式)
* ================================================================ */
function project(x, y, z, cam) {
const e = cam.extrinsic;
if (!e || e.length < 3) return null;
const xc = e[0][0] * x + e[0][1] * y + e[0][2] * z + e[0][3];
const yc = e[1][0] * x + e[1][1] * y + e[1][2] * z + e[1][3];
const zc = e[2][0] * x + e[2][1] * y + e[2][2] * z + e[2][3];
if (!(zc > 0.01)) return null;
let u = xc / zc, v = yc / zc;
const k1 = cam.k1 || 0, k2 = cam.k2 || 0, k3 = cam.k3 || 0;
const p1 = cam.p1 || 0, p2 = cam.p2 || 0;
if (k1 || k2 || k3 || p1 || p2) {
const r2 = u * u + v * v;
const radial = 1 + k1 * r2 + k2 * r2 * r2 + k3 * r2 * r2 * r2;
const tx = 2 * p1 * u * v + p2 * (r2 + 2 * u * u);
const ty = p1 * (r2 + 2 * v * v) + 2 * p2 * u * v;
u = u * radial + tx;
v = v * radial + ty;
}
return [u * cam.fx + cam.cx, v * cam.fy + cam.cy, zc];
}
/** 本帧可用的相机任务(只列参数与图 URL,不触发加载) */
function cameraJobs(sp, frame) {
const jobs = [];
for (const key of Object.keys(sp)) {
const c = sp[key];
if (!c || c.fx === null || c.fx === undefined || !c.extrinsic) continue;
const model = c.camera_model || 'pinhole';
if (model !== 'pinhole' && model !== 'pinhole1') continue;
const url = imageUrlOf(frame, key);
if (!url) continue;
jobs.push({ key, cam: c, url });
}
return jobs;
}
async function loadCameraImages(sp, frame) {
const jobs = cameraJobs(sp, frame);
const imgs = await Promise.all(jobs.map((jb) => loadImage(jb.url)));
const out = [];
for (let i = 0; i < jobs.length; i++) {
if (!imgs[i]) continue;
out.push({ key: jobs[i].key, cam: jobs[i].cam, data: imgs[i].data, width: imgs[i].width, height: imgs[i].height });
}
return out;
}
/** 单台相机:只写"比已知更近"的点 —— 颜色随图像到达逐块出现,不必等 4 张全齐 */
function projectCamera(cam, img, ci, st) {
const { rg, pos, bestZ, bestU, bestCi, buf } = st;
const w = img.width, h = img.height, data = img.data;
for (let i = rg.start; i < rg.end; i++) {
const j = i * 3;
const pr = project(pos[j], pos[j + 1], pos[j + 2], cam);
if (!pr) continue;
const px = pr[0], py = pr[1], zc = pr[2];
if (px < 0 || px >= w || py < 0 || py >= h) continue;
if (zc >= bestZ[i]) continue;
bestZ[i] = zc; bestU[i] = px; bestCi[i] = ci;
const idx = ((py | 0) * w + (px | 0)) * 4;
buf[j] = data[idx] / 255;
buf[j + 1] = data[idx + 1] / 255;
buf[j + 2] = data[idx + 2] / 255;
}
}
/** 收尾统计:按"最终获胜相机"精确统计命中/亮度/像素离散 */
function finishStats(st) {
const { rg, buf, bestZ, bestU, bestCi, camStat } = st;
let hit = 0, lumSum = 0, darkN = 0, su = 0, su2 = 0, nPix = 0;
for (let i = rg.start; i < rg.end; i++) {
if (!(bestZ[i] < Infinity)) continue;
const j = i * 3;
const r = buf[j], g = buf[j + 1], b = buf[j + 2];
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
hit++; lumSum += lum; if (lum < 0.02) darkN++;
su += bestU[i]; su2 += bestU[i] * bestU[i]; nPix++;
const ci = bestCi[i];
if (ci >= 0 && camStat[ci]) { camStat[ci].hit++; camStat[ci].lum += lum; }
}
const meanLum = hit ? lumSum / hit : 0;
const stdU = nPix ? Math.sqrt(Math.max(0, su2 / nPix - (su / nPix) ** 2)) : 0;
return { hit, meanLum, darkN, spread: stdU / (st.imgW || 768) };
}
const nowMs = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
/** 合并多次渲染请求到一帧动画里,避免重复 rendererMain */
let renderPending = false;
function scheduleRender() {
if (renderPending) return;
renderPending = true;
const doIt = () => {
renderPending = false;
const v = getViewer();
if (!v) return;
try { v.rendererMain(); } catch (e) {}
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(doIt);
else setTimeout(doIt, 16);
}
/** 采样一批点,统计"至少落在一台相机画面内"的比例 */
function sampleCoverage(pos, count, cams) {
if (!cams.length) return 0;
const step = Math.max(1, Math.floor(count / 4000));
let tried = 0, hit = 0;
for (let i = 0; i < count; i += step) {
const j = i * 3;
const x = pos[j], y = pos[j + 1], z = pos[j + 2];
tried++;
for (let ci = 0; ci < cams.length; ci++) {
const c = cams[ci];
const pr = project(x, y, z, c.cam);
if (!pr) continue;
if (pr[0] >= 0 && pr[0] < c.width && pr[1] >= 0 && pr[1] < c.height) { hit++; break; }
}
}
return tried ? hit / tried : 0;
}
/* ================================================================
* 6. 着色核心
* ================================================================ */
const ownColor = new WeakSet();
const busy = new WeakSet();
const sigOf = new WeakMap();
const _ids = new WeakMap();
let _idSeq = 0;
function arrayId(arr) {
if (!_ids.has(arr)) _ids.set(arr, ++_idSeq);
return _ids.get(arr);
}
function signatureOf(p) {
const g = p.geometry;
if (!g || !g.attributes || !g.attributes.position) return null;
const a = g.attributes.position;
return (p.taskId === undefined ? '-' : String(p.taskId)) + '|' + a.count + '|' + (a.array ? arrayId(a.array) : '');
}
function buildRanges(p, count) {
const v = getViewer(), SM = getSM();
let cur = null;
try { cur = SM.getCurFrameId(); } catch (e) {}
const table = v && v.cloudData && v.cloudData.comparisonTable;
const isMerged = (p.multiFrameIds && p.multiFrameIds.length) ||
(v && v.splicePCD && p === v.splicePCD) || p.name === 'split_points';
if (isMerged && Array.isArray(table) && table.length) {
return table.map((t) => ({ start: t.Indexes.start, end: t.Indexes.end + 1, frameId: t.pcdInfo }));
}
return [{ start: 0, end: count, frameId: (p.taskId !== null && p.taskId !== undefined) ? p.taskId : cur }];
}
/**
* 坐标系统判定:同一批点分别按三种假设投影,谁落进画面的比例高谁对。
* H1 点云在本帧雷达系:p_cam = T_v2c * p
* H2 点云在世界系,位姿按平台读法(列主序):p_cam = T_v2c * inv(pose) * p
* H3 点云在世界系,位姿按 JSON 行主序: p_cam = T_v2c * inv(pose) * p
* 返回按命中率降序的假设列表(带合成后的 extrinsic)。
*/
function detectFrames(baseCam, pose, pos, count, cams) {
const covOf = (ext) => {
const cs = cams.map((c) => ({
cam: Object.assign({}, c.cam, { extrinsic: ext }),
width: c.width, height: c.height,
}));
return sampleCoverage(pos, count, cs);
};
const list = [{ name: '本帧雷达系', ext: baseCam.extrinsic, cov: covOf(baseCam.extrinsic) }];
if (pose) {
const invA = matInv(colMajorTo4x4(pose));
if (invA) {
const extA = matMul(baseCam.extrinsic, invA);
list.push({ name: '世界系(位姿列主序)', ext: extA, cov: covOf(extA) });
}
const invB = matInv(rowMajorTo4x4(pose));
if (invB) {
const extB = matMul(baseCam.extrinsic, invB);
list.push({ name: '世界系(位姿行主序)', ext: extB, cov: covOf(extB) });
}
}
list.sort((a, b) => b.cov - a.cov);
return list;
}
async function colorizePoints(p) {
const v = getViewer(), SM = getSM();
if (!v || !SM) return { ok: false, msg: '平台对象未就绪' };
const geom = p.geometry;
if (!geom || !geom.attributes || !geom.attributes.position) return { ok: false, msg: '点云未加载' };
const count = geom.attributes.position.count;
// 地图任务里除首帧外都是 0 点占位 pcd(上传脚本 create_empty_pcd 生成),不是错误
if (!count) return { ok: true, empty: true, msg: '本帧为空点云(占位帧),无需着色' };
// ① 平台原生已支持(文件字段名就是 rgb)→ 什么都不用做
if (geom.attributes.color && !ownColor.has(geom)) {
v.supportRgbColor = true;
return { ok: true, native: true, msg: '数据自带 rgb,平台原生支持' };
}
const ranges = buildRanges(p, count);
const buf = new Float32Array(count * 3);
const pos = geom.attributes.position.array;
const t0 = nowMs();
let filled = 0;
let src = null, note = null;
// 每个点的最近相机深度/像素/归属,供"逐相机到达即上色"用
const bestZ = new Float32Array(count).fill(Infinity);
const bestU = new Float32Array(count);
const bestCi = new Int8Array(count).fill(-1);
// 颜色属性先挂上去,这样每台相机图像一到就能立刻看到效果
let attr = geom.attributes.color;
if (!attr || attr.count !== count || !attr.array || attr.array.length !== count * 3) {
const Ctor = geom.attributes.position.constructor;
attr = new Ctor(buf, 3);
geom.setAttribute('color', attr);
} else {
attr.array.set(buf);
attr.needsUpdate = true;
}
const flush = () => { attr.needsUpdate = true; scheduleRender(); };
for (const rg of ranges) {
const frame = frameById(rg.frameId);
const sp = sensorParams();
const pcdUrl = p.url || (frame && frame.url) || null;
// ②+③ 并行:一边探 pcd 头(8KB)判断有没有 rgb/rgba,一边同时开始下 4 张相机图
// 同一批数据的后续帧字段布局相同,已知没有颜色字段就直接跳过这次探测(省一个来回)
// 但若几何里带 intensity,说明文件字段不止 xyz,必须真探测(地图就是 x y z intensity rgba label)
const canSkipProbe = pcdUrl && !geom.attributes.intensity && dirNoColor.has(dirOf(pcdUrl));
const [probe] = await Promise.all([
pcdUrl ? (canSkipProbe ? Promise.resolve({ hdr: { fields: [] } }) : probePcdHeader(pcdUrl)) : Promise.resolve(null),
sp ? loadCameraImages(sp, frame) : Promise.resolve([]),
]);
// ② 文件自带 rgb/rgba → 就地解出(地图那种 rgba 就走这里)
const hasColorField = !!(probe && probe.hdr &&
(probe.hdr.fields.indexOf('rgb') >= 0 || probe.hdr.fields.indexOf('rgba') >= 0));
if (probe && probe.hdr && !hasColorField && pcdUrl && probe.hdr.fields.length) {
dirNoColor.add(dirOf(pcdUrl));
}
if (hasColorField) {
try {
const rec = await readPcdFile(pcdUrl); // 确认有颜色字段才下整个文件
const chk = rec ? verifySameCloud(rec.bytes, rec.hdr, pos, count) : null;
if (rec && chk && !chk.same) {
note = 'pcd 与几何校验未通过:' + chk.why;
} else if (rec) {
const got = extractColors(rec.bytes, rec.hdr);
if (got) {
for (let i = rg.start; i < rg.end; i++) {
const j = i * 3;
buf[j] = got.colors[j]; buf[j + 1] = got.colors[j + 1]; buf[j + 2] = got.colors[j + 2];
}
filled += (rg.end - rg.start);
src = '文件字段 ' + got.fieldName + '(平台不认,已就地解出)';
attr.array.set(buf); flush();
continue;
}
}
} catch (e) {
note = 'pcd 读取失败:' + e.message;
}
}
// ③ 相机投影着色:平台约定点云在本帧传感器系(poseMatrix 只用于显示/叠帧换算),直接用 T_v2c
if (!sp) { note = note || '缺少标定参数 sensor_params'; continue; }
const jobs = cameraJobs(sp, frame);
if (!jobs.length) { note = note || ('本帧(' + rg.frameId + ')无可用相机图像(跨域或字段缺失)'); continue; }
const camStat = jobs.map((c) => ({ key: c.key, size: '待加载', hit: 0, lum: 0 }));
const st = { bestZ, bestU, bestCi, buf, rg, pos, camStat, imgW: 0, t0, firstMs: undefined };
// 每台相机图像一到就先给"比已知更近"的点上色,不再等 4 张全齐
let arrived = 0;
await Promise.all(jobs.map(async (jb, ci) => {
const img = await loadImage(jb.url);
if (!img) return;
camStat[ci].size = img.width + 'x' + img.height;
if (img.width > st.imgW) st.imgW = img.width;
projectCamera(jb.cam, img, ci, st);
flush();
arrived++;
if (st.firstMs === undefined) st.firstMs = Math.round(nowMs() - t0);
if (arrived < jobs.length) setStatus('着色中… ' + arrived + '/' + jobs.length + ' 相机');
}));
const fin = finishStats(st);
const hit = fin.hit, meanLum = fin.meanLum, spread = fin.spread;
window.__xjRgbLastStat = {
帧: rg.frameId, 点数: count, 命中: hit, 覆盖率: (hit / count * 100).toFixed(1) + '%',
平均亮度: meanLum.toFixed(3), 很暗点占比: hit ? (fin.darkN / hit * 100).toFixed(1) + '%' : '-',
像素横向离散: spread.toFixed(3),
首图上色耗时: (st.firstMs === undefined ? '-' : st.firstMs + 'ms'),
本帧总耗时: Math.round(nowMs() - t0) + 'ms',
各相机: camStat.map((c) => c.key + ':命中' + c.hit + ',亮度' + (c.hit ? (c.lum / c.hit).toFixed(3) : '-') + ',' + c.size),
};
log('本帧统计', window.__xjRgbLastStat);
// 退化/异常保护:像素挤在一条窄带里或采样明显发暗,说明投影关系不对,
// 这时候切到 rgb 着色只会显示一片异常颜色,宁可不切
if (hit && spread < 0.05) {
buf.fill(0); attr.array.set(buf); flush();
return { ok: false, msg: '投影退化(像素离散' + spread.toFixed(3) + '):点云坐标系可能与标定不一致,已放弃切到rgb着色' };
}
if (hit > 100 && meanLum < 0.03) {
buf.fill(0); attr.array.set(buf); flush();
return { ok: false, msg: '采样颜色异常发暗(平均亮度' + meanLum.toFixed(3) + '):相机图或标定可疑,已放弃切到rgb着色' };
}
filled += hit;
src = src || ('相机投影[命中 ' + (hit / count * 100).toFixed(0) + '% 亮度' + meanLum.toFixed(2) + ' 离散' + spread.toFixed(2) + ']');
if (!hit && !note) note = '投影未命中任何相机画面';
}
if (!filled) return { ok: false, msg: note || '未能着色' };
// 颜色属性在前面已经建好并挂上去了,这里只做最终提交
ownColor.add(geom);
attr.array.set(buf);
attr.needsUpdate = true;
// ★ 让平台自己认可"这份点云支持 rgb 着色",于是官方「rgb着色」就能勾选
v.supportRgbColor = true;
if (CFG.keepRgbMode && !userOverride) setColorType(3);
try { v.rendererMain(); } catch (e) {}
const pct = ((filled / count) * 100).toFixed(1);
return { ok: true, msg: filled + '/' + count + ' 点(' + pct + '%) · ' + src };
}
/* ================================================================
* 7. 与平台着色模式联动(不抢用户手动选择)
* ================================================================ */
let userOverride = false;
let lastSeenColorType = null;
let originalColorType = null;
function setColorType(n) {
const v = getViewer(), SM = getSM();
if (!v || !SM) return;
try {
SM.updateStatusProperty('colorType', n);
v.setPCDColorModel(n, SM.statusState().colorValue);
v.rendererMain();
} catch (e) {}
}
function watchColorType(sawNew) {
const SM = getSM();
if (!SM) return;
let cur = null;
try { cur = SM.statusState().colorType; } catch (e) { return; }
if (lastSeenColorType !== null && cur !== lastSeenColorType && cur !== 3 && !sawNew) userOverride = true;
lastSeenColorType = cur;
}
/* ================================================================
* 8. 叠帧保险 + 主循环
* ================================================================ */
function patchSplice(v) {
if (!v || v.__xjSplicePatched || typeof v.splicePointCloud !== 'function') return;
const orig = v.splicePointCloud;
v.splicePointCloud = function (points) {
try {
const g = points && points.geometry;
if (g && g.attributes && g.attributes.position && !g.attributes.color) {
const Ctor = g.attributes.position.constructor;
g.setAttribute('color', new Ctor(new Float32Array(g.attributes.position.count * 3), 3));
ownColor.add(g);
}
} catch (e) {}
return orig.apply(this, arguments);
};
v.__xjSplicePatched = true;
}
function collectTargets() {
const v = getViewer();
if (!v) return [];
const out = [], seen = new Set();
const push = (p) => {
if (!p || !p.geometry || !p.geometry.attributes || !p.geometry.attributes.position) return;
if (seen.has(p)) return;
seen.add(p); out.push(p);
};
push(v.points);
for (const g of [v.pointCloudGroup, v.compareGroup, v.sseGroup]) {
if (g && Array.isArray(g.children)) g.children.forEach(push);
}
return out;
}
let ticking = false;
async function tick() {
if (ticking) return;
ticking = true;
try {
// 帧号一变就立刻把"图和 pcd 头"先拉起来,与平台自己加载点云的过程重叠,
// 等点云一出现基本只剩投影的十几毫秒
const SM0 = getSM();
if (SM0) {
let fid = null;
try { fid = SM0.getCurFrameId(); } catch (e) {}
warmFrame(fid);
}
patchSplice(getViewer());
const targets = collectTargets();
let sawNew = false;
for (const p of targets) {
const sig = signatureOf(p);
if (!sig || sigOf.get(p) === sig || busy.has(p)) continue;
sawNew = true;
busy.add(p);
try {
const r = await colorizePoints(p);
if (r && r.ok) { sigOf.set(p, sig); setStatus(r.msg, false, r.native); schedulePrefetch(); }
else if (r && r.msg) setStatus(r.msg, true);
} catch (e) {
setStatus('异常: ' + e.message, true);
log('colorize 异常', e);
} finally { busy.delete(p); }
}
watchColorType(sawNew);
refreshModeText();
} finally {
ticking = false;
}
}
function restore() {
const v = getViewer();
if (!v) return;
let n = 0;
for (const p of collectTargets()) {
const g = p.geometry;
if (!g || !ownColor.has(g)) continue;
try { if (g.attributes.color) { g.deleteAttribute('color'); n++; } } catch (e) {}
sigOf.delete(p);
}
v.supportRgbColor = false;
setColorType(originalColorType !== null && originalColorType !== 3 ? originalColorType : 1);
try { v.rendererMain(); } catch (e) {}
setStatus('已还原(' + n + ' 个点云)', false);
}
async function diagnose() {
const v = getViewer(), SM = getSM();
const out = {};
try {
const g = v && v.points && v.points.geometry;
out.点云已加载 = !!(g && g.attributes && g.attributes.position);
if (out.点云已加载) {
out.点数 = g.attributes.position.count;
out.几何属性 = Object.keys(g.attributes);
out.平台认为支持rgb = !!v.supportRgbColor;
out.平台认为支持强度 = !!v.supportIntensityColor;
}
} catch (e) { out._err = String(e); }
try { out.当前着色模式 = SM.statusState().colorType; } catch (e) {}
try {
const cfg = SM.configState();
out.data_type = cfg.params && cfg.params.data_type;
out.相机 = Object.keys((cfg.params && cfg.params.sensor_params) || {});
out.有poses = !!(cfg.params && cfg.params.poses && Object.keys(cfg.params.poses).length);
} catch (e) {}
try {
const p = v.points;
const url = (p && p.url) || ((frameById(null) || {}).url);
out.本帧pcd = url;
if (url) {
const rec = await readPcdFile(url);
if (rec) {
out.pcd字段 = rec.hdr.fields;
out.pcd类型 = rec.hdr.types;
out.pcd编码 = rec.hdr.dataType;
out.pcd点数 = rec.hdr.points;
out.有rgb字段 = rec.hdr.fields.indexOf('rgb') >= 0;
out.有rgba字段 = rec.hdr.fields.indexOf('rgba') >= 0;
if (p && p.geometry) {
out.pcd与几何一致 = verifySameCloud(rec.bytes, rec.hdr, p.geometry.attributes.position.array, p.geometry.attributes.position.count);
}
}
}
} catch (e) { out._err_pcd = String(e); }
try {
out.本帧位姿 = poseById(SM.getCurFrameId()) ? '有' : '无';
out.本帧着色统计 = window.__xjRgbLastStat || null;
} catch (e) {}
const camArr = [];
const cams = {};
const sp = sensorParams(), frame = frameById(null);
if (sp) {
for (const k of Object.keys(sp)) {
const u = imageUrlOf(frame, k);
const rec = { 标定: !!(sp[k] && sp[k].fx !== undefined), 图像: u || null };
if (u) {
const im = await loadImage(u);
rec.可读像素 = !!im;
if (im) {
rec.尺寸 = im.width + 'x' + im.height;
camArr.push({ key: k, cam: sp[k], data: im.data, width: im.width, height: im.height });
} else rec.失败原因 = imgFailed.get(u) || '未知';
}
cams[k] = rec;
}
}
out.相机明细 = cams;
// 参考:把点云按"本帧雷达系 / 世界系(位姿列主序) / 世界系(位姿行主序)"三种假设各投一遍
try {
const p = v.points;
if (p && p.geometry && p.geometry.attributes.position && camArr.length) {
const pos = p.geometry.attributes.position.array;
const cnt = p.geometry.attributes.position.count;
const hyps = detectFrames(camArr[0].cam, poseById(SM.getCurFrameId()), pos, cnt, camArr);
out.坐标系假设 = hyps.map((h) => ({ 假设: h.name, 命中率: (h.cov * 100).toFixed(1) + '%' }));
}
} catch (e) { out._err_hyp = String(e); }
log('诊断', out);
return out;
}
/* ================================================================
* 9. 极简 UI:一个状态胶囊(没有任何调色项)
* ================================================================ */
const CSS = `
#xjrgb-root{position:fixed;right:20px;top:74px;z-index:2147483000;font:12px/1.5 -apple-system,"Microsoft YaHei","PingFang SC",sans-serif;user-select:none}
#xjrgb-pill{display:flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:13px;cursor:pointer;
background:rgba(9,14,26,.82);border:1px solid rgba(56,189,248,.32);color:#cfe6ff;font-size:11px}
#xjrgb-dot{width:7px;height:7px;border-radius:50%;background:#22c55e;box-shadow:0 0 7px #22c55e}
#xjrgb-dot.err{background:#f87171;box-shadow:0 0 7px #f87171}
#xjrgb-card{display:none;margin-top:6px;width:256px;padding:9px 11px;border-radius:10px;
background:rgba(9,14,26,.94);border:1px solid rgba(56,189,248,.28);box-shadow:0 10px 30px rgba(0,0,0,.5)}
#xjrgb-card.open{display:block}
#xjrgb-status{font-size:11px;color:#9fb6d0;line-height:1.5;word-break:break-all;margin-bottom:7px}
.xjrgb-btns{display:flex;gap:6px}
.xjrgb-btn{flex:1;height:26px;border:1px solid rgba(56,189,248,.3);border-radius:6px;background:rgba(30,41,59,.6);
color:#9fb6d0;font-size:11px;cursor:pointer}
.xjrgb-btn:hover{color:#e6f7ff}
`;
let ui = null;
function setStatus(msg, isErr, native) {
if (!ui) return;
ui.status.textContent = msg || '';
ui.dot.className = isErr ? 'err' : '';
ui.pillTxt.textContent = native ? 'RGB 原生' : 'RGB';
}
function buildPill() {
if (document.getElementById('xjrgb-root')) return;
const style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
const root = document.createElement('div');
root.id = 'xjrgb-root';
root.innerHTML = `