// ==UserScript==
// @name 点云3D标注增强
// @namespace cloud-annotate-plus
// @version 3.4.1
// @description 点云标注平台增强:①索套切边——按 M 沿要保留的边界打点、Q 闭合,边界即被替换成这条线;②顶点吸附合并——拖动顶点靠近另一顶点自动吸附,松手合并成一个点;③吸附到地面——把折线/多边形(含闭合区域)顶点吸到正下方点云最低点;④RGB 着色(静默版 v2.2.0)——数据自带 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)。
*
* 二、标注范围限制(黄框内)
* 平台会给一个标注范围(3D 视图里的黄色框)。脚本读取的是平台自己的那份数据:
* config.mark_region 的 box/rect(x/y 各给 [min,max])、circle/arc(半径 + 起止角),
* 以及按帧的 getFrameRange(帧号)。与平台画黄框用的是同一份配置,不需要另外填数值。
* 效果:拖动顶点时会自动吸附到范围边缘、**绝不超出**;画新点(含索套切出的新顶点)也夹在范围内。
* 只限制 X/Y,不动 Z(黄框在 z=0 平面上,只是范围示意)。
* 平台没配范围时不做任何限制;也可以在控制台手动指定兜底范围:
* localStorage.setItem("lane_region_box", "xmin,xmax,ymin,ymax")。
*
* 三、顶点吸附合并(编辑模式)
* 拖动一个顶点靠近同一个多边形的另一个顶点(默认 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着色」;不再显示右上角胶囊,控制入口在控制台:
* window.__lassoCut.rgb.setRgb() / diagnose() / restore() / stat()。
* 数据自带 rgba 的就地解出颜色,没有颜色字段的用相机+标定投影着色;不改数据、不重传。
* 统计信息在控制台 window.__xjRgbLastStat;诊断结果输出到 F12 Console。
*
* 五、吸附到地面(贴地)
* 按钮已植入平台顶部工具条(平台顶部工具条「显示距离」左边);万一平台改版找不到工具条,
* 会自动退回“视图左下角浮动按钮”。
* 作用:把当前帧折线/多边形的顶点吸到正下方点云的最低点(先按 0.4 米找,找不到逐级扩大到 2 米,
* 取半径内最低的 Z,所以不会吸到车顶)。支持闭合区域(polygon_3d 路面多边形)。
* 已选中某个标注物时只贴它,没选中则贴当前视图里全部;Alt+点击可撤销上一次贴地。
* **只改当前帧**:保存走平台的「修改3D点」(MARK_ACTION_MODIFY_POINTS)——平台对这种 action 不做多帧
* 同步,所以每帧的地面都要单独贴(这也是原「点云车道线工具」的做法;而拖动顶点走的是「矩阵修改」,
* 多帧模式下仍会按平台行为同步到其它帧)。
* 约束与兜底(避免“附近没有点云被乱吸”):半径内至少要有 3 个点才认;找到的高度若偏离整帧参照
* 高度超过 1 米,判为乱吸改用兜底;兜底顺序 = 同一条线上已解决的前/后邻居高度 → 整帧参照高度
* (已吸附点的平均值,剔除离群;全无时用点云 5% 分位 Z)。
* 可调:lane_ground_r(初半径 0.4)、lane_ground_maxr(最大半径 2.0)、lane_ground_minpts(最少点数 3)、
* lane_ground_maxdev(偏离上限 1.0 米,0=不限)。
*
* 版本记录:
* v3.4.1 贴地改为**只影响当前帧**:保存从「矩阵修改」改成平台「修改3D点」(MARK_ACTION_MODIFY_POINTS),
* 该 action 下平台不会把改动同步到其它帧,避免多帧模式下“点一次全包都贴了”。撤销同样只改当前帧。
* v3.4.0 新增「标注范围限制」:读取平台自己的标注范围配置(mark_region 的 box/rect/circle/arc 与按帧的
* getFrameRange),拖动顶点时自动吸附到范围边缘、绝不超出,画新点与索套切出的顶点同样受限。
* v3.3.1 「吸附到地面」按钮位置改为“显示距离”左边(之前插到了两个勾选项中间),并换成浅蓝底/蓝边的
* 清晰配色;索套浮动按钮与 RGB 胶囊保持移除状态。
* v3.3.0 贴地加了约束与兜底(避免“附近没点云被乱吸”):①半径内必须至少有 3 个点才认这个高度;
* ②找到的高度若偏离整帧参照高度超过 1 米,视为乱吸、改走兜底;③兜底顺序 = 同一条线上已解决
* 的前/后邻居高度 → 整帧参照高度(已吸附点的平均值,剔离群;全无时用点云 5% 分位 Z)。
* 界面调整:「吸附到地面」按钮改为直接植入平台顶部工具条(“显示距离/无需标注”那一行);
* 去掉索套浮动按钮(功能保留,按 M 进入)与 RGB 右上角胶囊(着色照常,控制入口改为
* window.__lassoCut.rgb.setRgb()/diagnose()/restore()/stat())。
* v3.2.0 新增「吸附到地面」:把当前帧折线/多边形的顶点吸到正下方点云的最低点(0.4 米起逐级扩大
* 到 2 米,取半径内最低 Z,避免吸到车顶);**支持闭合区域(polygon_3d 路面多边形)**,
* 原「点云车道线工具」只处理 line_3d 折线。选中某个标注物时只贴它,未选中则贴当前视图全部;
* 已提交保存,按钮上 Alt+点击可撤销上一次贴地。
* 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 MARK_ACTION_MODIFY_POINTS = 3; // Constants.MARK_ACTION_MODIFY_POINTS(平台对这种 action 不做多帧同步)
var DEFAULTS = { SNAP_PX: 12, SNAP_OTHER_PX: 0, SNAP_WORLD: 3, SNAP_VERTEX_PX: 12, CUT_VERTEX_PX: 6, GROUND_R: 0.4, GROUND_MAXR: 2.0, GROUND_MINPTS: 3, GROUND_MAXDEV: 1.0 };
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 };
},
// ---------- 平台标注范围(黄框):读取平台自己的配置,拖动/绘制时把顶点夹在里面 ----------
// 来源:config.mark_region 的 box/rect(x/y/z 各给 [min,max])与 circle/arc(半径 + 可选起止角),
// 以及按帧的 configGetter("getFrameRange")(frameId)。与平台画黄框用的是同一份数据,无需另外配置。
laneMarkRegions: function () {
var frameId = null;
try { frameId = S.getCurFrameId(); } catch (e) { }
if (this._laneRegionsCache && this._laneRegionsFrame === frameId) return this._laneRegionsCache;
var out = [], self = this;
var num = function (v) { var n = parseFloat(v); return isNaN(n) ? null : n; };
var addBox = function (it) {
if (!it) return;
var x = it.x, y = it.y;
if (!x || x.length < 2 || !y || y.length < 2) return;
var x0 = num(x[0]), x1 = num(x[1]), y0 = num(y[0]), y1 = num(y[1]);
if (x0 === null || x1 === null || y0 === null || y1 === null) return;
out.push({ kind: "box", x: [Math.min(x0, x1), Math.max(x0, x1)], y: [Math.min(y0, y1), Math.max(y0, y1)] });
};
var addArc = function (it) {
if (!it) return;
var r = num(it.radius);
if (!(r > 0)) return;
var c = it.center || [0, 0, 0];
var a0 = num(it.start), a1 = num(it.end);
if (a0 === null) a0 = 0;
if (a1 === null) a1 = 360;
out.push({ kind: "arc", cx: num(c[0]) || 0, cy: num(c[1]) || 0, r: r, a0: a0, a1: a1 });
};
var eat = function (src) {
if (!src) return;
var k;
for (k = 0; k < 2; k++) if (src[k ? "rect" : "box"]) src[k ? "rect" : "box"].forEach(addBox);
for (k = 0; k < 2; k++) if (src[k ? "arc" : "circle"]) src[k ? "arc" : "circle"].forEach(addArc);
};
try { eat(S.configState().config.mark_region); } catch (e) { }
try {
var g = S.configGetter("getFrameRange");
if (g) eat(g(frameId));
} catch (e) { }
// 手动覆盖(万一平台没配范围,可在控制台写 "xmin,xmax,ymin,ymax")
try {
var man = localStorage.getItem("lane_region_box");
if (man) {
var p = man.split(",").map(num);
if (p.length === 4 && p[0] !== null && p[1] !== null && p[2] !== null && p[3] !== null) {
out.push({ kind: "box", x: [Math.min(p[0], p[1]), Math.max(p[0], p[1])], y: [Math.min(p[2], p[3]), Math.max(p[2], p[3])] });
}
}
} catch (e) { }
this._laneRegionsCache = out;
this._laneRegionsFrame = frameId;
return out;
},
// 把 (x,y) 夹进标注范围(多个范围取并集,落在最近的那个边缘上)。没有配置范围时返回 null 表示不限制。
laneClampXY: function (x, y) {
var regions = this.laneMarkRegions();
if (!regions || !regions.length) return null;
var norm = function (v) { v = v % 360; return v < 0 ? v + 360 : v; };
var best = null, bestD = Infinity;
for (var i = 0; i < regions.length; i++) {
var r = regions[i], cx = x, cy = y;
if (r.kind === "box") {
cx = x < r.x[0] ? r.x[0] : (x > r.x[1] ? r.x[1] : x);
cy = y < r.y[0] ? r.y[0] : (y > r.y[1] ? r.y[1] : y);
} else {
var dx = x - r.cx, dy = y - r.cy, d = Math.sqrt(dx * dx + dy * dy);
var span = norm(r.a1 - r.a0), full = span >= 359.999;
var a = Math.atan2(dy, dx) * 180 / Math.PI, inSector = full;
if (!full) {
var rel = norm(a - r.a0);
inSector = rel <= span + 1e-6;
if (!inSector) { // 落在扇形角度之外 -> 投影到较近的那条边界射线
var useEnd = (span - rel) < rel;
var ba = (useEnd ? r.a1 : r.a0) * Math.PI / 180;
var proj = dx * Math.cos(ba) + dy * Math.sin(ba);
proj = proj < 0 ? 0 : (proj > r.r ? r.r : proj);
cx = r.cx + proj * Math.cos(ba); cy = r.cy + proj * Math.sin(ba);
}
}
if (inSector) {
if (d <= r.r || d < 1e-12) { cx = x; cy = y; } // 在扇形/圆内
else { cx = r.cx + dx / d * r.r; cy = r.cy + dy / d * r.r; } // 超出半径 -> 贴到弧上
}
}
var ex = x - cx, ey = y - cy, e = ex * ex + ey * ey;
if (e < bestD) { bestD = e; best = { x: cx, y: cy, moved: e > 1e-12 }; }
}
return best;
},
// 直接把一个顶点夹进标注范围(并同步显示/保存),返回是否发生了夹取
laneClampSphere: function (sphere) {
if (!sphere || !sphere.position) return false;
var c = this.laneClampXY(sphere.position.x, sphere.position.y);
if (!c || !c.moved) return false;
sphere.position.x = c.x;
sphere.position.y = c.y;
var poly = sphere.parent;
if (poly && typeof poly.getSpheresAndLines === "function") {
try { this.updateLinesPosition(poly); } catch (e) { }
try { poly.initInner(); } catch (e) { }
try { S.modifyMark3D(MARK_ACTION_MODIFY_MATRIX, poly); } catch (e) { }
}
try { this.viewer.rendererMain(); } catch (e) { }
return true;
},
// 拖动顶点时的吸附目标:屏幕上足够近、且世界距离也在阈值内的“另一个顶点”
// 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;
},
// 索套不再生成浮动按钮(功能保留:按 M 键进入/退出)
laneLassoButton: function () { return null; },
laneLassoButtonUpdate: function () { },
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; }
var cc = this.laneClampXY ? this.laneClampXY(v.x, v.y) : null; // 新顶点也限制在标注范围内
if (cc && cc.moved) { v.x = cc.x; v.y = cc.y; }
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
// #region lane-ground
// 吸附到地面(贴地):把当前帧折线/多边形顶点吸到正下方点云的最低点,支持闭合区域(polygon_3d)。
// 约束与兜底(避免"附近没有点云被乱吸"):
// 1) 半径内必须有足够多的点(默认 ≥3)才认这个高度,孤零零一两个点不算;
// 2) 找到的高度若明显偏离整帧参照高度(默认 >1 米),视为乱吸,改用兜底;
// 3) 兜底顺序:同一条线上已解决的前/后邻居高度 → 整帧参照高度(已吸附点的平均值,
// 剔除离群后取均值;一个都没有时用点云整体的 5% 分位 Z)→ 都没有才保持原值。
function makeGroundMethods(deps) {
var S = deps.S, Vector3 = deps.Vector3;
return {
// 把点云按 XY 网格分桶:避免每个顶点都扫一遍全量点云(百万点云会卡死)
laneBuildGroundGrid: function (pos, cell) {
var cells = new Map();
for (var i = 0; i + 2 < pos.length; i += 3) {
var ix = Math.floor(pos[i] / cell), iy = Math.floor(pos[i + 1] / cell);
var k = ix + "," + iy;
var arr = cells.get(k);
if (!arr) { arr = []; cells.set(k, arr); }
arr.push(i);
}
return { cells: cells, cell: cell };
},
// 半径内最低的 Z;startR 起逐级扩大,直到 maxR;要求该半径内至少有 minPts 个点,否则继续扩大
laneGroundZBelow: function (grid, pos, x, y, startR, maxR, minPts) {
var need = (minPts > 0) ? minPts : 1;
var cell = grid.cell;
var ix = Math.floor(x / cell), iy = Math.floor(y / cell);
var rings = Math.ceil(maxR / cell) + 1, cand = [], dx, dy, t, arr;
for (dx = -rings; dx <= rings; dx++) {
for (dy = -rings; dy <= rings; dy++) {
arr = grid.cells.get((ix + dx) + "," + (iy + dy));
if (arr) for (t = 0; t < arr.length; t++) cand.push(arr[t]);
}
}
if (!cand.length) return null;
for (var r = startR; r <= maxR + 1e-6; r += startR) {
var r2 = r * r, minZ = Infinity, hit = 0;
for (var q = 0; q < cand.length; q++) {
var j = cand[q];
var ddx = pos[j] - x, ddy = pos[j + 1] - y;
if (ddx * ddx + ddy * ddy <= r2) {
var pz = pos[j + 2];
if (pz < minZ) minZ = pz;
hit++;
}
}
if (hit >= need) return minZ;
}
return null;
},
laneGroundPosArray: function () {
var pos = null;
try { pos = this.viewer.cloudData && this.viewer.cloudData.positionArray; } catch (e) { }
if (!pos) { try { pos = this.viewer.points.geometry.attributes.position.array; } catch (e) { } }
return pos;
},
// 最后的兜底:点云整体高度的 5% 分位(近似地面高度)
laneGroundFallbackZ: function (pos) {
var zs = [];
for (var i = 2; i < pos.length; i += 3) zs.push(pos[i]);
if (!zs.length) return null;
zs.sort(function (a, b) { return a - b; });
return zs[Math.floor(zs.length * 0.05)];
},
// 吸附到地面。已选中某个标注物时只吸附它,否则吸附当前视图里所有折线/多边形。
laneSnapToGround: function () {
if (!this.canEdit || !this.canEdit()) { this.laneToast("当前不是编辑模式,无法贴地"); return false; }
var pos = this.laneGroundPosArray();
if (!pos) { this.laneToast("未取到点云数据,无法贴地"); return false; }
var startR = readPx("lane_ground_r", DEFAULTS.GROUND_R);
var maxR = readPx("lane_ground_maxr", DEFAULTS.GROUND_MAXR);
var minPts = readPx("lane_ground_minpts", DEFAULTS.GROUND_MINPTS);
var maxDev = readPx("lane_ground_maxdev", DEFAULTS.GROUND_MAXDEV); // 0 = 不做偏离限制
if (!(startR > 0)) startR = DEFAULTS.GROUND_R;
if (!(maxR >= startR)) maxR = Math.max(startR, DEFAULTS.GROUND_MAXR);
var polys = this.lanePolylines();
var sel = null;
try { sel = S.statusState().selectMarkInfo.trackId; } catch (e) { }
var targets = [];
for (var i = 0; i < polys.length; i++) {
if (!sel || polys[i].trackId === sel) targets.push(polys[i]);
}
if (!targets.length) {
this.laneToast(sel ? "当前选中/视图里没有可贴地的折线或多边形" : "当前视图没有可贴地的折线或多边形");
return false;
}
var grid = this.laneBuildGroundGrid(pos, Math.max(maxR, 0.2));
// ---- 第一遍:用点云找高度,收集所有找到的值 ----
var plans = [];
var zs = [];
for (var a = 0; a < targets.length; a++) {
var poly0 = targets[a], mk0 = poly0.mark;
if (!mk0 || !mk0.points || !mk0.points.length) continue;
var list = [];
for (var b = 0; b < mk0.points.length; b++) {
var p0 = mk0.points[b];
if (!p0 || p0.length < 3) { list.push(null); continue; }
var gz = this.laneGroundZBelow(grid, pos, p0[0], p0[1], startR, maxR, minPts);
list.push(gz);
if (gz !== null) zs.push(gz);
}
plans.push({ poly: poly0, cloud: list });
}
if (!plans.length) { this.laneToast("当前视图没有可贴地的折线或多边形"); return false; }
// ---- 整帧参照高度:先取中位数,再对 ±maxDev 内的值取平均(比纯平均抗离群) ----
var ref = null;
if (zs.length) {
var sorted = zs.slice().sort(function (x, y) { return x - y; });
var med = sorted[Math.floor(sorted.length / 2)];
var sum = 0, n = 0;
for (var s = 0; s < sorted.length; s++) {
if (maxDev > 0 && Math.abs(sorted[s] - med) > maxDev) continue;
sum += sorted[s]; n++;
}
ref = n ? (sum / n) : med;
} else {
ref = this.laneGroundFallbackZ(pos); // 点云一个都没找到(点云极稀疏)时的兜底
}
// ---- 第二遍:决定每个顶点的最终高度 ----
var undo = [], byCloud = 0, byNeighbor = 0, byRef = 0, skipped = 0, rejected = 0, missed = [];
for (var t = 0; t < plans.length; t++) {
var poly = plans[t].poly, mk = poly.mark, cloud = plans[t].cloud;
var before = mk.points.map(function (q) { return [q[0], q[1], q[2]]; });
var finalZ = [], changed = false;
// 2.1 先用点云结果(过偏离限制)
for (var j = 0; j < mk.points.length; j++) {
var gz2 = cloud[j];
if (gz2 !== null && maxDev > 0 && ref !== null && Math.abs(gz2 - ref) > maxDev) {
rejected++; gz2 = null; // 明显偏离整帧高度 -> 认为乱吸,走兜底
}
finalZ.push(gz2);
}
// 2.2 没找到的:用同一条线上已解决的前/后邻居(各取最近 2 个)估算
for (var j2 = 0; j2 < finalZ.length; j2++) {
if (finalZ[j2] !== null) continue;
var nb = [], k2;
for (k2 = j2 - 1; k2 >= 0 && nb.length < 2; k2--) if (finalZ[k2] !== null) nb.push(finalZ[k2]);
for (k2 = j2 + 1; k2 < finalZ.length && nb.length < 4; k2++) if (finalZ[k2] !== null) nb.push(finalZ[k2]);
if (nb.length) {
var m = 0;
for (k2 = 0; k2 < nb.length; k2++) m += nb[k2];
finalZ[j2] = m / nb.length;
byNeighbor++;
} else if (ref !== null) {
finalZ[j2] = ref; // 整帧参照高度
byRef++;
} else {
missed.push({ trackId: poly.trackId, index: j2, x: mk.points[j2][0], y: mk.points[j2][1], z: mk.points[j2][2] });
continue;
}
}
// 2.3 写回
for (var j3 = 0; j3 < mk.points.length; j3++) {
var p3 = mk.points[j3], fz = finalZ[j3];
if (fz === null || fz === undefined || !p3 || p3.length < 3) continue;
if (cloud[j3] !== null) byCloud++;
if (Math.abs(fz - p3[2]) < 0.01) { skipped++; continue; }
p3[2] = fz; changed = true;
}
if (!changed) continue;
undo.push({ poly: poly, points: before });
try { poly.initChildren(); } catch (e) { }
// 用「修改3D点」提交:平台对这种 action 不做多帧同步,所以贴地只改当前帧
try { S.modifyMark3D(MARK_ACTION_MODIFY_POINTS, poly); } catch (e) { }
}
if (undo.length) this._laneSnapUndo = undo;
this._laneSnapMissed = missed;
try { this.viewer.rendererMain(); } catch (e) { }
var moved = byCloud + byNeighbor + byRef - skipped;
if (!undo.length) {
this.laneToast("无需贴地:" + skipped + " 个点已在地面" + (missed.length ? "," + missed.length + " 个点没有任何参照" : ""));
return false;
}
this.laneToast("已贴地:" + undo.length + " 个标注物 / " + moved + " 个点(云点 " + byCloud +
" · 邻近估算 " + byNeighbor + " · 参照高度 " + byRef +
(rejected ? " · 偏离被拒 " + rejected : "") +
(skipped ? " · 已在地面 " + skipped : "") +
(missed.length ? " · 无参照 " + missed.length : "") + ") · Alt+点击可撤销");
return true;
},
// 撤销上一次贴地(按钮上 Alt+点击)
laneUndoSnap: function () {
var u = this._laneSnapUndo;
if (!u || !u.length) { this.laneToast("没有可撤销的贴地操作"); return false; }
var n = 0;
for (var i = 0; i < u.length; i++) {
var it = u[i];
if (!it.poly || !it.poly.mark) continue;
it.poly.mark.points = it.points.map(function (q) { return [q[0], q[1], q[2]]; });
try { it.poly.initChildren(); } catch (e) { }
try { S.modifyMark3D(MARK_ACTION_MODIFY_POINTS, it.poly); } catch (e) { } // 撤销同样只改当前帧
n++;
}
this._laneSnapUndo = null;
try { this.viewer.rendererMain(); } catch (e) { }
this.laneToast("已撤销贴地(还原 " + n + " 个标注物)");
return true;
},
/* ---------- 按钮:直接植入平台顶部工具条(“显示距离 / 无需标注”那一行) ---------- */
laneEnsureGroundStyle: function () {
if (this._groundStyle || document.getElementById('lane-ground-style')) return;
var st = document.createElement('style');
st.id = 'lane-ground-style';
st.textContent =
'#lane-ground-btn{font:inherit;color:inherit;line-height:1.4;}' +
'#lane-ground-btn.lane-gb-toolbar{position:static;display:inline-block;vertical-align:middle;' +
'margin:0 12px 0 0;padding:3px 12px;border:1px solid #2f6fb5;border-radius:3px;' +
'background:#e8f2fd;color:#1b5fa8;font-weight:600;cursor:pointer;white-space:nowrap;user-select:none;' +
'box-shadow:0 1px 2px rgba(0,0,0,.12);}' +
'#lane-ground-btn.lane-gb-toolbar:hover{background:#d3e6fb;border-color:#1b5fa8;}' +
'#lane-ground-btn.lane-gb-float{position:fixed;z-index:6;padding:4px 9px;border-radius:5px;color:#fff;' +
'background:rgba(20,20,20,.72);border:1px solid rgba(255,255,255,.28);cursor:pointer;white-space:nowrap;user-select:none;}';
(document.head || document.body).appendChild(st);
this._groundStyle = st;
},
// 找平台工具条里“显示距离 / 无需标注”所在的那一行
laneToolbarHost: function () {
var anchors = ['显示距离', '无需标注'], found = [], all, i, el, txt;
try { all = document.querySelectorAll('label,span,em,dd,dt,div,p,li'); } catch (e) { all = []; }
for (i = 0; i < all.length && found.length < 2; i++) {
el = all[i];
if (!el || (el.children && el.children.length)) continue; // 只要最内层文本
txt = (el.textContent || '').replace(/\s+/g, '').trim();
if (anchors.indexOf(txt) >= 0) found.push(el);
}
if (!found.length) return null;
// 插在“显示距离”这一项的**前面**:wrap 是这一项(可能就是 label 本身),row 是它所在的那一行
var anchor = found[0], wrap = anchor.parentElement || anchor, row = wrap.parentElement || wrap;
return { row: row, before: wrap, anchor: anchor };
},
laneGroundButton: function () {
if (this._groundBtn && this._groundBtn.isConnected) return this._groundBtn;
var self = this;
var b = document.createElement("div");
b.id = "lane-ground-btn";
b.textContent = "吸附到地面";
b.title = "把折线/多边形的顶点吸附到正下方点云的最低点(选中某个标注物则只贴它;Alt+点击=撤销上次贴地)";
b.addEventListener("click", function (ev) {
ev.stopPropagation();
ev.preventDefault();
if (ev.altKey) self.laneUndoSnap(); else self.laneSnapToGround();
}, true);
document.body.appendChild(b);
this._groundBtn = b;
return b;
},
// 把按钮挂到平台工具条上;挂不上就退回“视图左下角浮动按钮”
laneMountGroundButton: function () {
var b = this.laneGroundButton();
if (!b) return false;
this.laneEnsureGroundStyle();
var host = null;
try { host = this.laneToolbarHost(); } catch (e) { }
if (host && host.row && host.before && host.before.parentElement === host.row) {
if (b.parentNode !== host.row || b.nextSibling !== host.before) {
try { host.row.insertBefore(b, host.before); } catch (e) { }
}
if (b.className !== 'lane-gb-toolbar') { b.className = 'lane-gb-toolbar'; b.style.cssText = ''; }
this._groundBtnFloating = false;
return true;
}
// 兜底:浮动按钮
if (!this._groundBtnFloating) {
b.className = 'lane-gb-float';
b.style.cssText = '';
this._groundBtnFloating = true;
}
if (this.domElement && this.domElement.getBoundingClientRect) {
var rect = this.domElement.getBoundingClientRect();
b.style.left = (rect.left + 12) + "px";
b.style.top = (rect.bottom - 32) + "px";
b.style.display = this.laneLassoEnabled() ? "block" : "none";
}
return false;
}
};
}
// #endregion lane-ground
// #endregion lane-ground
// 注意: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 groundMethods = makeGroundMethods({ S: S, Vector3: Vector3 });
for (var k3 in groundMethods) if (Object.prototype.hasOwnProperty.call(groundMethods, k3)) lh[k3] = groundMethods[k3];
// 索套模式内于捕获阶段拦截鼠标/指针事件,避免平台同时处理或转动视角;未进入模式时直接放行
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);
}
// ---- 标注范围夹取:拖动、画点时取到的坐标统一夹进平台范围(只夹 X/Y,绝不超出黄框) ----
var prevGetMousePosition = (typeof lh.getMousePosition === "function") ? lh.getMousePosition : null;
if (prevGetMousePosition) {
lh.getMousePosition = function (ndc, forceGround) {
var p = prevGetMousePosition.call(lh, ndc, forceGround);
try {
if (p && lh.laneClampXY) {
var c = lh.laneClampXY(p.x, p.y);
if (c && c.moved) { p.x = c.x; p.y = c.y; }
}
} catch (err) { }
return p;
};
}
// ---- 顶点吸附合并:拖动顶点靠近另一个顶点时吸附对齐,松手后合并成一个点 ----
// 注意: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) { } }
if (lh.laneMountGroundButton) { try { lh.laneMountGroundButton(); } catch (e) { } }
return true;
}
W.__lassoCut = {
install: install,
findViewer: findViewer,
makeLaneMethods: makeLaneMethods,
makeLassoMethods: makeLassoMethods,
makeGroundMethods: makeGroundMethods,
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 = `