// ==UserScript== // @name 点云3D多边形-线段上加点 // @namespace lane-add-point // @version 1.0.1 // @description 点云 3D 多边形/折线:鼠标靠近边线即高亮并显示候选点,单击直接在边线上插入顶点(垂足位置),插入后可继续拖动。 // @author - // @match *://*/* // @run-at document-idle // @grant none // @noframes // ==/UserScript== /* * 使用说明 * ------------------------------------------------------------------ * 1. 安装后打开点云标注页面(编辑模式),鼠标移到任意多边形边线附近(默认 12px 内): * 该边高亮成琥珀色,边上最近点出现琥珀色候选点。 * 2. 左键单击即在该位置插入一个新顶点,多边形立即成形,新顶点可继续拖动。 * 3. 光标压在顶点附近(默认 6px 内)时不显示候选点、也不插点,把点击让给顶点的选中/拖拽, * 同时避免生成零长度的边。原来「线段、多边形增加点 Ctrl+1 / 删除点 Ctrl+2」行为保持一致。 * 4. 只在点云 3D 主视图生效;四个 2D 投影视图、其它帧的预测标注物、批注多边形都不参与。 * * * 命中宽容度可调(控制台执行,刷新后仍生效,无需重新安装脚本): * localStorage.setItem('lane_edge_hit_px', 20) // 离边线多少像素算“在线上”,默认 12 * localStorage.setItem('lane_vertex_hit_px', 4) // 顶点屏蔽半径,默认 6 * * 若没生效,打开控制台看是否有 “[lane-add-point] 已注入” 日志;也可手动执行 * window.__laneAddPoint.install(window.__laneAddPoint.findViewer()) */ (function () { "use strict"; var W = (typeof unsafeWindow !== "undefined" && unsafeWindow) || window; var DEFAULTS = { EDGE_PX: 12, VERTEX_PX: 6 }; 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 function readPx(key, def) { var v = parseFloat(localStorage.getItem(key)); return (isNaN(v) || v <= 0) ? def : v; } // #region lane-methods // 返回 LineHandler 实例需要的一组方法(调用时 this 必须是该实例)。 // deps: { S: StoreManager, Vector3, Color } 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) laneEdges: function (poly) { var r = poly.getSpheresAndLines(), sph = r[0], ln = r[1], out = []; for (var i = 0; i < ln.length; i++) { var a = sph[i], b = sph[(i + 1) % sph.length]; if (a && b) out.push({ index: i, line: ln[i], a: a.position, b: b.position }); } return out; }, // 鼠标射线:NDC(-1) 为近平面点、NDC(+1) 为远平面点,方向指向场景内部 laneMouseRay: function (ev) { var ndc = this.viewer && this.viewer.camera ? null : null; var rect = this.domElement.getBoundingClientRect(); var x = (ev.clientX - rect.left) / rect.width * 2 - 1; var y = -((ev.clientY - rect.top) / rect.height * 2 - 1); var cam = this.viewer.camera; var near = new Vector3(x, y, -1).unproject(cam); var far = new Vector3(x, y, 1).unproject(cam); return { origin: near, direction: far.sub(near).normalize() }; }, // 射线(origin,direction) 到线段(a,b) 的最近点,结果一定落在线段上 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 }; }, // 找到鼠标指向的那条边:屏幕空间选边 + 包围盒预筛 + 顶点屏蔽 + 优先选中对象 laneTarget: function (ev) { var renderer = this.viewer.render; var canvas = renderer && renderer.domElement; if (!canvas || !ev) return null; var crect = canvas.getBoundingClientRect(); if (!(crect.width > 0 && ev.clientX >= crect.left && ev.clientX <= crect.right && ev.clientY >= crect.top && ev.clientY <= crect.bottom)) return null; var rect = this.domElement.getBoundingClientRect(); var mx = ev.clientX - rect.left, my = ev.clientY - rect.top; var vpx = readPx("lane_vertex_hit_px", DEFAULTS.VERTEX_PX); var epx = readPx("lane_edge_hit_px", DEFAULTS.EDGE_PX); var polys = this.lanePolylines(), self = this; var minVertex = Infinity, metas = []; for (var i = 0; i < polys.length; i++) { var sph = polys[i].getSpheresAndLines()[0], pts = [], x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity, seen = false; for (var j = 0; j < sph.length; j++) { var p = self.laneProject(sph[j].position, rect); pts.push(p); if (!p) continue; seen = true; if (p.x < x0) x0 = p.x; if (p.x > x1) x1 = p.x; if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y; var d = Math.hypot(p.x - mx, p.y - my); if (d < minVertex) minVertex = d; } metas.push(seen ? { poly: polys[i], pts: pts, x0: x0, y0: y0, x1: x1, y1: y1 } : null); } var selectedTrack = null; try { var st = S.statusState(); selectedTrack = st && st.selectMarkInfo ? st.selectMarkInfo.trackId : null; } catch (e) { } var best = null, limit = epx; for (var k = 0; k < metas.length; k++) { var m = metas[k]; if (!m) continue; if (mx < m.x0 - limit || mx > m.x1 + limit || my < m.y0 - limit || my > m.y1 + limit) continue; var isSelected = m.poly.trackId === selectedTrack; var lines = m.poly.getSpheresAndLines()[1]; for (var e2 = 0; e2 < lines.length; e2++) { var A = m.pts[e2], B = m.pts[(e2 + 1) % m.pts.length]; if (!A || !B) continue; var ax = B.x - A.x, ay = B.y - A.y, l2 = ax * ax + ay * ay; var t = l2 > 0 ? ((mx - A.x) * ax + (my - A.y) * ay) / l2 : 0; t = t < 0 ? 0 : (t > 1 ? 1 : t); var dist = Math.hypot(mx - (A.x + t * ax), my - (A.y + t * ay)); if (dist > limit + 1.5) continue; if (best && !(dist < limit - .5 || (isSelected && best.poly.trackId !== selectedTrack))) continue; limit = dist; best = { poly: m.poly, lineIndex: e2 }; } } if (!best) return null; if (minVertex <= vpx) return null; // 顶点附近:交给顶点的选中/拖拽 var sph2 = best.poly.getSpheresAndLines()[0]; var pa = sph2[best.lineIndex], pb = sph2[(best.lineIndex + 1) % sph2.length]; if (!pa || !pb) return null; var ray = this.laneMouseRay(ev); best.point = this.laneClosestOnSegment(ray.origin, ray.direction, pa.position, pb.position); return best; }, // 精确命中细线时(旧逻辑),按该边自身几何求投影点 lanePointOnLine: function (line, ev) { try { var pos = line.geometry.attributes.position; if (!pos || pos.count < 2) return null; var ray = this.laneMouseRay(ev); var a = new Vector3(pos.getX(0), pos.getY(0), pos.getZ(0)); var b = new Vector3(pos.getX(1), pos.getY(1), pos.getZ(1)); return this.laneClosestOnSegment(ray.origin, ray.direction, a, b); } catch (e) { return null; } }, // 高亮命中的边 + 显示候选点 laneApplyHover: function (target) { var cur = this._laneHover; if (cur && target && cur.poly === target.poly && cur.lineIndex === target.lineIndex) { cur.point.copy(target.point); if (this._laneGhost) this._laneGhost.position.copy(target.point); this.viewer.rendererMain(); return; } this.laneClearHover(); if (!target) return; var line = target.poly.getSpheresAndLines()[1][target.lineIndex]; if (!line || !line.material) return; if (!this._laneColor) { this._laneColor = new Color("#ffcc00"); var LH = this.constructor; var r = this.radius; this._laneGhost = LH && LH.createSphere ? LH.createSphere(null, this._laneColor, r) : null; if (this._laneGhost) { this._laneGhost.name = "lane_hover_point"; if (this._laneGhost.material) this._laneGhost.material.opacity = 1; this._laneGhost.visible = false; this.viewer.scene.add(this._laneGhost); } } if (!this._laneGhost) return; var savedColor = line.material.color ? line.material.color.clone() : null; var savedOpacity = line.material.opacity; line.material.color = this._laneColor.clone(); line.material.opacity = 1; this._laneGhost.position.copy(target.point); this._laneGhost.visible = true; this._laneHover = { poly: target.poly, lineIndex: target.lineIndex, line: line, point: target.point.clone(), savedColor: savedColor, savedOpacity: savedOpacity }; this.viewer.rendererMain(); }, laneClearHover: function () { var cur = this._laneHover; if (!cur && (!this._laneGhost || !this._laneGhost.visible)) return; if (cur && cur.line && cur.line.material) { if (cur.savedColor) cur.line.material.color = cur.savedColor; if (typeof cur.savedOpacity === "number") cur.line.material.opacity = cur.savedOpacity; } this._laneHover = null; if (this._laneGhost) this._laneGhost.visible = false; this.viewer.rendererMain(); }, // 同一次点击是否已经插过点(平台的对象级 mouseup 与本脚本的 DOM mouseup 是同一次点击) laneIsSameClick: function (ev) { var stamp = ev && ev.timeStamp; return typeof stamp === "number" && stamp === this.__laneInsertStamp && (Date.now() - (this.__laneInsertAt || 0) < 800); }, // 在 poly 的第 lineIndex 条边上插入顶点(位置 point,严格在线上)并提交保存 laneInsertPoint: function (poly, lineIndex, point, stamp) { if (!poly || !(lineIndex >= 0) || !point) return false; var sph; try { sph = poly.getSpheresAndLines()[0]; } catch (e) { return false; } if (!sph || sph.length < 2) return false; lineIndex = Math.max(0, Math.min(lineIndex, sph.length - 1)); this.laneClearHover(); var sphere = this.createSphere(point.clone(), poly.selectColor); var line = this.constructor.createLine(null, null, poly.selectColor); this.addPickEvent(sphere); this.addPickEvent(line); try { poly.insertSphere(sphere, lineIndex + 1); poly.add(line); this.updateLinesPosition(poly); } catch (e) { console.warn("[lane-add-point] 插入失败", e); return false; } // 结构已改,立刻记下本次点击,防止同一次点击再插一次 if (typeof stamp === "number") { this.__laneInsertStamp = stamp; this.__laneInsertAt = Date.now(); } try { poly.initInner(); poly.updateMetriel(); poly.selectChanged(); } catch (e) { } // 提交保存:等价于平台内部 BUS_3D_CHANGED -> modifyMark3D(MARK_ACTION_MODIFY_MATRIX) try { S.selectMarkInfo(poly.trackId, poly.classId, "3d", null, false); } catch (e) { } try { S.modifyMark3D(MARK_ACTION_MODIFY_MATRIX, poly); } catch (e) { console.warn("[lane-add-point] 保存失败,请拖动任一顶点触发保存:", e); } try { this.viewer.rendererMain(); } catch (e) { } return true; }, laneHoverEnabled: function () { var type; try { type = S.getCurLabelType(); } catch (e) { return false; } if (type !== LABEL_TYPE_MARK) return false; if (this.isPainting || this.isDeleting || this.isPickPostil) return false; var ih = this.viewer.inputHandler; if (ih && ih.isRotating) return false; return !!(this.isAdding || this.canEdit()); }, laneHoverUpdate: function (ev) { try { if (this._laneDown || !this.laneHoverEnabled()) { this.laneClearHover(); return; } this.laneApplyHover(this.laneTarget(ev)); } catch (e) { console.warn("[lane-add-point] hover", e); } }, onLaneMousedown: function (ev) { this._laneDown = ev.button === 0 ? { x: ev.clientX, y: ev.clientY } : null; }, onLaneMouseup: function (ev) { try { var down = this._laneDown; this._laneDown = null; if (ev.button !== 0 || !down) return; if (Math.abs(ev.clientX - down.x) > 3 || Math.abs(ev.clientY - down.y) > 3) return; // 拖动不算点击 if (this.laneIsSameClick(ev)) return; // 同一次点击已插入 if (!this.laneHoverEnabled()) return; var target = this._laneHover; if (!target) { target = this.laneTarget(ev); if (!target) return; } this.laneInsertPoint(target.poly, target.lineIndex, target.point, ev.timeStamp); } catch (e) { console.warn("[lane-add-point] insert", e); } } }; } // #endregion lane-methods // 注意: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 ref = lh.defColor || lh.selectColor; if (!ref) return false; var Color = ref.constructor; var methods = makeLaneMethods({ S: S, Vector3: Vector3, Color: Color }); for (var k in methods) if (Object.prototype.hasOwnProperty.call(methods, k)) lh[k] = methods[k]; // 「线段、多边形增加点 Ctrl+1」也统一走投影插入,避免与旧逻辑重复插点 var proto = Object.getPrototypeOf(lh); var origOnLineMouseup = proto.onLineMouseup; lh.onLineMouseup = function (e) { try { if (this.laneIsSameClick(e && e.event)) return; if (this.isAdding && e && e.object && e.object.type === "Line" && e.object.parent && typeof e.object.parent.getLineIndex === "function") { var poly = e.object.parent; var point = this.lanePointOnLine(e.object, e.event); if (point && this.laneInsertPoint(poly, poly.getLineIndex(e.object), point, e.event && e.event.timeStamp)) { if (e.consume) e.consume(); return; } } } catch (err) { console.warn("[lane-add-point] addmode", err); } return origOnLineMouseup.call(this, e); }; // 已存在的多边形重新注册拾取事件,让上面的包装立即生效 var reregister = function (scene) { if (!scene || !scene.children) return; scene.children.slice().forEach(function (o) { if (o && typeof o.getSpheresAndLines === "function") { try { lh.addPickEvents(o); } catch (e) { } } }); }; reregister(viewer.lineScene); reregister(viewer.annotationGroup); lh.__laneInstalled = true; console.log("[lane-add-point] 已注入:靠近多边形边线单击即可加点(阈值 " + readPx("lane_edge_hit_px", DEFAULTS.EDGE_PX) + "px / 顶点屏蔽 " + readPx("lane_vertex_hit_px", DEFAULTS.VERTEX_PX) + "px)"); } // 画布容器被重建时重新挂监听(视图切换/重新挂载的情况) if (lh.__laneDom !== lh.domElement && lh.domElement && lh.domElement.addEventListener) { lh.__laneDom = lh.domElement; lh.domElement.addEventListener("mousemove", function (e) { lh.laneHoverUpdate(e); }, false); lh.domElement.addEventListener("mousedown", function (e) { lh.onLaneMousedown(e); }, false); lh.domElement.addEventListener("mouseup", function (e) { lh.onLaneMouseup(e); }, false); } return true; } W.__laneAddPoint = { install: install, findViewer: findViewer, makeLaneMethods: makeLaneMethods, version: "1.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("[lane-add-point]", 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()); }); })();