// ==UserScript== // @name 点云标注增强(自动保存 + 重叠提醒) // @name:zh-CN 点云标注增强(自动保存 + 重叠提醒) // @namespace https://dawnchan.online // @version 3.3.0 // @description 点云标注页增强:重叠提醒(当前帧/全部帧、汇总横幅、点条目直接选中平台框并闪标签)+ 自动保存、类别统计、尺寸同步、切帧修复。只读检测,不改标注数据。 // @author cc(自动保存)/ 本机合并 // @homepageURL https://dawnchan.online // @match *://*/*pointcloud* // @match *://*/w/pointcloud/* // @match *://*/pointcloud/* // @match *://*/pointcloud/ // @match *://*/pointcloud // @grant none // @noframes // @run-at document-start // ==/UserScript== /* ===================================================================== * 合并说明 v3.3.0 * * 模块 A:自动保存套件(原「自动保存.js」v2.9.4,逻辑未改,缩放部分已摘除) * 自动保存 / 类别统计 / 固定底面尺寸+角度同步 / 切帧属性异常修复。 * 按需求去掉了全部缩放功能:2D 相机图缩放平移保持、2D 图片缩放记忆、 * 面板上的「2D 缩放」开关 —— 脚本也不再 hook CanvasRenderingContext2D。 * * 模块 B:3D 标注重叠提醒(原「点云标注重叠提醒.user.js」,判定逻辑未改,提醒交互升级到 v1.1.0) * 只读 window.viewer.volumeScene 与整包 marks,画面描红 + 面板 + 新冲突提醒, * 不改任何标注数据;Ctrl+Alt+O 开关提醒面板。 * * 本文件的扩展版与用户脚本版由 build-merged-v3.mjs 生成,模块代码完全一致, * 仅头部与「合并说明」不同:扩展版由 manifest 注入(world MAIN), * 用户脚本版由 ScriptCat / Tampermonkey 以 @grant none 注入页面上下文。 * ===================================================================== */ /* ===================================================================== * 模块 A · 自动保存套件(原 自动保存.js v2.9.4,已摘除缩放模块) * ===================================================================== */ (function () { 'use strict'; const LS_KEY = 'bch_widget_v2'; const MIN_SAVE_MS = 10000; const MAX_SAVE_MS = 24 * 60 * 60 * 1000; const DEFAULTS = { autoSave: true, interval: 5, unit: 'minutes', scope: 'all', collapsed: false, autoFix: false, sizeSyncAngle: true, pos: null }; let settings = loadSettings(); // ========== 设置 ========== function loadSettings() { try { const raw = localStorage.getItem(LS_KEY); const parsed = raw ? JSON.parse(raw) : {}; const s = Object.assign({}, DEFAULTS, parsed || {}); s.interval = Math.round(Number(s.interval)); if (!Number.isFinite(s.interval) || s.interval < 1) s.interval = DEFAULTS.interval; s.interval = Math.min(999, Math.max(1, s.interval)); if (!['seconds', 'minutes', 'hours'].includes(s.unit)) s.unit = DEFAULTS.unit; if (!['frame', 'all'].includes(s.scope)) s.scope = DEFAULTS.scope; s.autoSave = !!s.autoSave; s.autoFix = !!s.autoFix; s.sizeSyncAngle = s.sizeSyncAngle !== false; s.collapsed = !!s.collapsed; if (!s.pos || typeof s.pos.x !== 'number' || typeof s.pos.y !== 'number') s.pos = null; return s; } catch (e) { return Object.assign({}, DEFAULTS); } } function persist() { try { localStorage.setItem(LS_KEY, JSON.stringify(settings)); } catch (e) { // localStorage 写入失败不影响统计 } } function intervalMs() { const mult = settings.unit === 'seconds' ? 1000 : settings.unit === 'minutes' ? 60000 : 3600000; return Math.min(MAX_SAVE_MS, Math.max(MIN_SAVE_MS, Math.round(settings.interval * mult))); } // ========== 平台数据访问 ========== function getInstance() { try { return window.instance || null; } catch (e) { return null; } } function getSM() { const inst = getInstance(); try { return inst && inst.StoreManager ? inst.StoreManager : null; } catch (e) { return null; } } function canEdit() { const inst = getInstance(); try { return !!(inst && inst.canEdit); } catch (e) { return false; } } function getStatusState() { const sm = getSM(); if (!sm) return null; try { return sm.statusState(); } catch (e) { return null; } } function getSnapshot() { const sm = getSM(); if (!sm) return null; try { const cfgState = sm.configState(); const markState = sm.marksState(); const cfg = cfgState.config || {}; const frames = Array.isArray(cfgState.frames) ? cfgState.frames : []; const frameIds = cfgState.frameIds || {}; const curId = sm.getCurFrameId(); const idx = frameIds[curId]; return { sm, marks: markState.marks || {}, versions: `${markState.curVer}|${markState.curMetaVer}`, config: cfg, frames, frameIds, curId, curIndex: typeof idx === 'number' ? idx : -1, taskKey: cfg.task_key || cfg.id || '', frameSetKey: frames.length ? String(frames[0] && frames[0].task_id) + '|' + String(frames[frames.length - 1] && frames[frames.length - 1].task_id) : '' }; } catch (e) { return null; } } // ========== 统计 ========== const TOOL_2D_MAP = { rect: 'volume', cuboid_2d: 'volume', line: 'line_3d', polygon: 'polygon_3d' }; function frameHas2D(cams) { if (!cams) return false; if (Array.isArray(cams)) return cams.length > 0; if (typeof cams !== 'object') return false; for (const key in cams) { const arr = cams[key]; if (Array.isArray(arr) && arr.length) return true; } return false; } function trackHasMark(track, frameId) { if (!track || !frameId) return false; const d3 = track['3d']; if (d3 && d3[frameId]) return true; const d2 = track['2d']; return !!(d2 && frameHas2D(d2[frameId])); } function first2DType(arr) { if (!arr) return null; if (Array.isArray(arr)) { return arr.length && arr[0] && arr[0].type ? arr[0].type : null; } for (const key in arr) { const sub = arr[key]; if (Array.isArray(sub) && sub.length && sub[0] && sub[0].type) return sub[0].type; } return null; } function trackToolType(track, frameId) { if (!track) return null; const d3 = track['3d']; if (frameId && d3 && d3[frameId] && d3[frameId].type) return d3[frameId].type; if (d3) { for (const fid in d3) { const mark = d3[fid]; if (mark && mark.type) return mark.type; } } const d2 = track['2d']; if (d2) { if (frameId) { const type = first2DType(d2[frameId]); if (type) return TOOL_2D_MAP[type] || type; } for (const fid in d2) { const type = first2DType(d2[fid]); if (type) return TOOL_2D_MAP[type] || type; } } return null; } function countFrame(snapshot) { const map = new Map(); let total = 0; const frameId = snapshot.curId; if (!frameId) return { map, total }; for (const trackId in snapshot.marks) { const track = snapshot.marks[trackId]; if (!trackHasMark(track, frameId)) continue; const tool = trackToolType(track, frameId); if (!tool) continue; const key = tool + '|' + (track.class != null ? track.class : '?'); map.set(key, (map.get(key) || 0) + 1); total++; } return { map, total }; } function countAll(snapshot) { const map = new Map(); let total = 0; const validFrames = new Set( snapshot.frames .map(function (f) { return f && f.task_id != null ? String(f.task_id) : null; }) .filter(function (id) { return id != null; }) ); if (!validFrames.size) return { map, total }; for (const trackId in snapshot.marks) { const track = snapshot.marks[trackId]; if (!track) continue; const tool = trackToolType(track, null); if (!tool) continue; const counted = new Set(); const d3 = track['3d']; if (d3) { for (const fid in d3) { if (validFrames.has(String(fid)) && d3[fid]) counted.add(fid); } } const d2 = track['2d']; if (d2) { for (const fid in d2) { if (!validFrames.has(String(fid)) || counted.has(fid)) continue; if (frameHas2D(d2[fid])) counted.add(fid); } } if (!counted.size) continue; const key = tool + '|' + (track.class != null ? track.class : '?'); map.set(key, (map.get(key) || 0) + counted.size); total += counted.size; } return { map, total }; } function classInfo(snapshot, toolType, classId) { let cls = null; const classes = snapshot.config && snapshot.config.classes; const group = classes && classes[toolType]; if (group && group.listClass) cls = group.listClass[classId]; if (!cls) { const props = snapshot.config && snapshot.config.properties; if (Array.isArray(props)) { const g = props.find(function (p) { return p && p.toolType === toolType; }); if (g && g.listClass) cls = g.listClass[classId]; } } let name = ''; let color = ''; if (cls) { name = cls.ptitle || cls.pname || ''; color = cls.color || ''; } if (!name) name = '类别 ' + classId; if (!/^#[0-9a-f]{3,8}$/i.test(color) && !/^rgba?\(/i.test(color)) color = '#7a7a7a'; return { name: name, color: color, tool: toolType, id: classId }; } // ========== 尺寸同步 ========== function toDims(value) { if (Array.isArray(value) && value.length >= 3) { const out = value.slice(0, 3).map(Number); return out.every(function (v) { return Number.isFinite(v) && v > 0; }) ? out : null; } if (value && typeof value === 'object') { const candidates = [ [value.l, value.w, value.h], [value.length, value.width, value.height], [value.x, value.y, value.z] ]; for (const candidate of candidates) { if (candidate.every(function (v) { return typeof v === 'number' && Number.isFinite(v) && v > 0; })) return candidate.slice(); } } return null; } function markDims(mark) { if (!mark) return null; for (const key of ['scale', 'size', 'dimension', 'dimensions']) { const dims = toDims(mark[key]); if (dims) return dims; } return null; } function toVec3(value) { if (Array.isArray(value) && value.length >= 3) { const out = value.slice(0, 3).map(Number); return out.every(Number.isFinite) ? out : null; } if (value && typeof value === 'object') { const out = [Number(value.x), Number(value.y), Number(value.z)]; return out.every(Number.isFinite) ? out : null; } return null; } function toRot(value) { if (Array.isArray(value) && value.length >= 2) { return [Number(value[0]) || 0, Number(value[1]) || 0, Number(value[2]) || 0]; } if (value && typeof value === 'object') { return [ Number(value.x != null ? value.x : value._x) || 0, Number(value.y != null ? value.y : value._y) || 0, Number(value.z != null ? value.z : value._z) || 0 ]; } return [0, 0, 0]; } function zAxisOfEuler(rotation) { const rx = rotation[0] || 0; const ry = rotation[1] || 0; const cx = Math.cos(rx), sx = Math.sin(rx); const cy = Math.cos(ry), sy = Math.sin(ry); return [sy, -sx * cy, cx * cy]; } function anglesEqual(a, b) { const ra = toRot(a); const rb = toRot(b); for (let i = 0; i < 3; i++) { let diff = ra[i] - rb[i]; while (diff > Math.PI) diff -= Math.PI * 2; while (diff < -Math.PI) diff += Math.PI * 2; if (Math.abs(diff) > 1e-4) return false; } return true; } function fmtAngles(rotation) { return toRot(rotation).map(function (v) { return String(Number((v * 180 / Math.PI).toFixed(1))) + '°'; }).join(' / '); } function groundAnchorOf(mark, dims) { const position = toVec3(mark && mark.position); if (!position) return null; const rotation = toRot(mark && mark.rotation); const axis = zAxisOfEuler(rotation); const half = dims[2] / 2; const plusZ = position[2] + axis[2] * half; const minusZ = position[2] - axis[2] * half; const sign = plusZ < minusZ ? 1 : -1; return { position: position, rotation: rotation, axis: axis, sign: sign, anchor: [ position[0] + axis[0] * half * sign, position[1] + axis[1] * half * sign, position[2] + axis[2] * half * sign ] }; } function positionForRotation(mark, oldDims, targetDims, targetRotation) { const oldInfo = groundAnchorOf(mark, oldDims); if (!oldInfo) return null; const rotation = targetRotation ? toRot(targetRotation) : oldInfo.rotation; const axis = zAxisOfEuler(rotation); const newHalf = targetDims[2] / 2; // 目标角度下,局部 -z 面朝下则 sign=-1;+z 面朝下则 sign=1。 const sign = axis[2] < 0 ? 1 : -1; const next = [ oldInfo.anchor[0] - axis[0] * sign * newHalf, oldInfo.anchor[1] - axis[1] * sign * newHalf, oldInfo.anchor[2] - axis[2] * sign * newHalf ]; // 浮点校正:贴地面中心保持原来的世界 z。 const nextAnchorZ = next[2] + axis[2] * sign * newHalf; const dz = oldInfo.anchor[2] - nextAnchorZ; if (Math.abs(dz) > 1e-12) next[2] += dz; return next; } function fixedBottomPosition(mark, oldDims, targetDims) { return positionForRotation(mark, oldDims, targetDims, toRot(mark && mark.rotation)); } function fmtSize(dims) { return dims.map(function (v) { return String(Number(v.toFixed(3))); }).join('×'); } function getSizeTarget(snapshot) { if (!snapshot) return { error: '等待平台数据…' }; const sm = snapshot.sm; let selected = null; try { selected = sm.statusState().selectMarkInfo; } catch (e) { selected = null; } const trackId = selected && selected.trackId != null ? selected.trackId : null; if (trackId == null) return { error: '先在平台里选中一个 3D 立体框' }; const trackKey = String(trackId); const track = snapshot.marks[trackKey] || snapshot.marks[trackId]; if (!track || !track['3d']) return { error: '选中的目标物没有 3D 标注' }; const mark = track['3d'][snapshot.curId]; if (!mark) return { error: '当前帧没有该目标物的 3D 框' }; const dims = markDims(mark); if (!dims) return { error: '当前帧的框没有可用尺寸' }; const tool = mark.type || trackToolType(track, snapshot.curId); const info = classInfo(snapshot, tool, track.class); const classId = track.class != null ? track.class : (selected.classId != null ? selected.classId : 0); return { trackId: trackId, trackKey: trackKey, track: track, mark: mark, dims: dims, rotation: toRot(mark.rotation), classId: classId, className: info.name, curFrameNo: snapshot.curIndex >= 0 ? snapshot.curIndex + 1 : 0 }; } function buildSizePlan(snapshot, range) { const target = getSizeTarget(snapshot); if (target.error) return { error: target.error }; if (!canEdit()) return { error: '只读账号,无法修改尺寸' }; let status = null; try { status = snapshot.sm.statusState(); } catch (e) { status = null; } if (status && status.multiFrame) return { error: '多帧合并中,暂停尺寸同步' }; if (status && status.disabledShortKey) return { error: '平台弹窗打开中,请先关闭弹窗' }; const d3 = target.track['3d']; const angleSync = !!settings.sizeSyncAngle; const targetRotation = toRot(target.mark.rotation); const changes = [], same = [], locked = [], missing = [], noSize = []; for (let frameNo = range.start; frameNo <= range.end; frameNo++) { const frame = snapshot.frames[frameNo - 1]; const frameId = frame && frame.task_id; if (frameId == null) { missing.push(frameNo); continue; } const mark = d3[frameId]; if (!mark) { missing.push(frameNo); continue; } const dims = markDims(mark); if (!dims) { noSize.push(frameNo); continue; } let allowed = true; if (typeof snapshot.sm.isAllowOpera === 'function') { try { allowed = !!snapshot.sm.isAllowOpera(mark, 'size'); } catch (e) { allowed = true; } } if (!allowed) { locked.push(frameNo); continue; } const sizeSame = dims.every(function (v, i) { return Math.abs(v - target.dims[i]) <= 1e-9; }); const oldRotation = toRot(mark.rotation); const angleSame = anglesEqual(oldRotation, targetRotation); if (sizeSame && (!angleSync || angleSame)) { same.push(frameNo); continue; } changes.push({ frameNo: frameNo, frameId: frameId, oldDims: dims.slice(), oldRotation: oldRotation.slice(), oldPosition: toVec3(mark.position), sizeSame: sizeSame, angleSame: angleSame }); } return { target: target, range: range, changes: changes, same: same, locked: locked, missing: missing, noSize: noSize, angleSync: angleSync, taskKey: snapshot.taskKey + '|' + snapshot.frames.length + '|' + snapshot.frameSetKey, trackId: target.trackId, trackKey: target.trackKey, classId: target.classId }; } function applySizePlan(plan) { const sm = getSM(); if (!sm || typeof sm.markCommit !== 'function') return { error: '平台 markCommit 不可用' }; const current = getSnapshot(); if (!current) return { error: '平台数据已断开' }; const currentTaskKey = current.taskKey + '|' + current.frames.length + '|' + current.frameSetKey; if (currentTaskKey !== plan.taskKey) return { error: '任务已切换,已取消' }; const track = current.marks[plan.trackKey] || current.marks[plan.trackId]; if (!track || !track['3d']) return { error: '目标物已不存在' }; const d3 = track['3d']; const target = plan.target.dims; const targetRotation = plan.angleSync ? toRot(plan.target.rotation) : null; const undoEntries = []; let changed = 0, locked = 0, missing = 0; for (const change of plan.changes) { const mark = d3[change.frameId]; if (!mark) { missing++; continue; } if (typeof sm.isAllowOpera === 'function') { let allowed = true; try { allowed = !!sm.isAllowOpera(mark, 'size'); } catch (e) { allowed = true; } if (!allowed) { locked++; continue; } } const dims = markDims(mark) || change.oldDims; const oldPosition = toVec3(mark.position); const oldRotation = toRot(mark.rotation); const newRotation = plan.angleSync ? targetRotation : oldRotation; const newPosition = positionForRotation(mark, dims, target, newRotation); undoEntries.push({ frameId: change.frameId, scale: dims.slice(), rotation: oldRotation.slice(), position: oldPosition ? oldPosition.slice() : null }); if (Array.isArray(mark.scale)) { mark.scale[0] = target[0]; mark.scale[1] = target[1]; mark.scale[2] = target[2]; } else { mark.scale = target.slice(); } if (plan.angleSync && newRotation) mark.rotation = newRotation.slice(); if (newPosition) mark.position = newPosition; mark.is_computed = 0; mark.point_num = 0; sm.markCommit('addOrReplaceMark3D', [1, mark, plan.trackId, plan.classId, change.frameId, null, true]); changed++; } if (changed) { lastSizeUndo = { taskKey: plan.taskKey, trackId: plan.trackId, trackKey: plan.trackKey, classId: plan.classId, entries: undoEntries, at: Date.now() }; } return { changed: changed, locked: locked, missing: missing }; } // ========== 格式化 ========== const NUM_FMT = (function () { try { return new Intl.NumberFormat('zh-CN'); } catch (e) { return null; } })(); function formatNum(n) { n = Number(n) || 0; return NUM_FMT ? NUM_FMT.format(n) : String(n); } function compactNum(n) { n = Number(n) || 0; if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'm'; if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; return String(n); } function formatDuration(sec) { sec = Math.max(0, Math.ceil(sec)); const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = sec % 60; if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); return m + ':' + String(s).padStart(2, '0'); } function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } // ========== 自动保存 ========== let autoTimer = null; let autoGeneration = 0; let nextSaveAt = 0; let savingUntil = 0; let pendingSave = null; let messageObserverStarted = false; let flash = { text: '', kind: '', until: 0 }; function setStatus(text, kind, ms) { flash = { text: text, kind: kind || '', until: Date.now() + (ms || 2500) }; updateFooter(); } function isDirty() { const sm = getSM(); if (!sm) return false; try { const marks = sm.marksState(); if (marks.curVer !== marks.updatedVer || marks.curMetaVer !== marks.updatedMetaVer) return true; const seg = sm.segState(); if (seg && (seg.curSegVer !== seg.updateSegVer || seg.segProjectionVer !== seg.updateSegProjectionVer)) return true; } catch (e) { return true; } return false; } function triggerSave() { // 优先点击平台真实的保存按钮,让平台自己的“保存成功/失败”提示出现 const button = findSaveButton(); if (button) { try { button.click(); return true; } catch (e) { console.error('[框数助手] 点击保存按钮失败', e); } } // 找不到按钮时退回平台保存快捷键 Ctrl+S const isMac = /Mac/.test(navigator.platform || ''); try { document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', code: 'KeyS', ctrlKey: !isMac, metaKey: isMac, bubbles: true, cancelable: true })); return true; } catch (e) { console.error('[框数助手] 保存快捷键派发失败', e); return false; } } function findSaveButton() { try { const top = document.getElementById('top'); if (!top) return null; const groups = []; for (const child of top.children) { if (child.classList && child.classList.contains('top-item')) groups.push(child); } const group = groups[groups.length - 1]; // 平台把保存按钮固定放在最后一个 top-item 组的最后一位 if (!group || top.lastElementChild !== group) return null; const buttons = group.querySelectorAll('button'); // 编辑权限下通常是 [通过, 保存];超过 4 个说明结构变了,退回快捷键更安全 if (buttons.length < 1 || buttons.length > 4) return null; const button = buttons[buttons.length - 1]; if (!button || button.disabled) return null; return button; } catch (e) { return null; } } function tryAutoSave(manual) { const inst = getInstance(); if (!inst) { if (manual) setStatus('平台未就绪', 'warn'); return; } if (!inst.canEdit) { setStatus('只读账号,无法保存', 'warn'); return; } const sm = getSM(); if (!sm) { if (manual) setStatus('平台未就绪', 'warn'); return; } let status = null; try { status = sm.statusState(); } catch (e) { status = null; } if (status && status.isMarksObtained === false) { if (manual) setStatus('标注数据加载中…', 'warn'); return; } if (status && status.multiFrame) { setStatus('多帧合并中,已跳过', 'warn'); return; } if (status && status.disabledShortKey) { setStatus('弹窗打开中,已跳过', 'warn'); return; } if (Date.now() < savingUntil) { if (manual) setStatus('正在保存…', 'warn'); return; } const dirty = isDirty(); // 自动保存只在有改动时触发,避免每隔几分钟弹一次“无需保存” // 手动保存即使没改动也触发,让平台给出“已同步至最新”的明确反馈 if (!manual && !dirty) return; if (!triggerSave()) { setStatus('保存请求失败', 'error'); return; } pendingSave = { at: Date.now(), baselineDirty: dirty }; savingUntil = Date.now() + 4000; setStatus('正在保存…', 'info', 5000); } function saveAfterBulkEdit() { if (Date.now() < savingUntil) { const delay = Math.max(500, savingUntil - Date.now() + 200); setTimeout(function () { if (isDirty()) tryAutoSave(true); }, delay); return; } tryAutoSave(true); } function scheduleAutoSave() { autoGeneration++; if (autoTimer) { clearTimeout(autoTimer); autoTimer = null; } if (!settings.autoSave) { nextSaveAt = 0; updateFooter(); return; } const ms = intervalMs(); nextSaveAt = Date.now() + ms; const generation = autoGeneration; const tick = function () { if (generation !== autoGeneration) return; try { if (!document.hidden) tryAutoSave(false); } catch (e) { console.error('[框数助手] 自动保存失败', e); } nextSaveAt = Date.now() + ms; autoTimer = setTimeout(tick, ms); updateFooter(); }; autoTimer = setTimeout(tick, ms); updateFooter(); } function checkPendingSave() { if (!pendingSave) return; const elapsed = Date.now() - pendingSave.at; if (!pendingSave.baselineDirty) { // 手动保存但本来就没改动:平台通常会提示“已同步至最新”,没捕获到就在这里兜底 if (elapsed > 1200) { setStatus('已同步至最新', 'success', 5000); pendingSave = null; savingUntil = 0; } return; } if (!isDirty()) { setStatus('保存成功', 'success', 6000); pendingSave = null; savingUntil = 0; return; } if (elapsed > 12000) { setStatus('保存状态未确认,请检查网络或平台提示', 'warn', 7000); pendingSave = null; savingUntil = 0; } } function setupMessageObserver() { if (messageObserverStarted || !document.body) return; messageObserverStarted = true; const observer = new MutationObserver(function (mutations) { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== 1) continue; const messages = []; if (node.matches && node.matches('.el-message')) messages.push(node); if (node.querySelectorAll) { const nested = node.querySelectorAll('.el-message'); for (const el of nested) messages.push(el); } for (const el of messages) handlePlatformMessage(el); } } }); observer.observe(document.body, { childList: true, subtree: true }); } function handlePlatformMessage(el) { const content = el.querySelector('.el-message__content') || el; const text = (content.textContent || '').trim(); if (!text) return; const isError = el.classList.contains('el-message--error'); const isSaveText = /保存成功|保存失败|无需保存|已同步至最新|保存出错/.test(text); const withinSave = pendingSave && Date.now() - pendingSave.at < 15000; if (!isSaveText && !(isError && withinSave)) return; pendingSave = null; savingUntil = 0; if (isError || /保存失败|保存出错/.test(text)) { setStatus(text, 'error', 8000); } else { setStatus(text, 'success', 6000); } } // ========== 界面 ========== let els = null; let refreshKey = ''; let booted = false; let lastSnapshot = null; let sizeRangeKey = ''; let sizeFlash = null; let lastSizeUndo = null; let fixFlash = null; let autoFixLastFrame = ''; let autoFixTaskKey = ''; let autoFixLastSummary = ''; let autoFixBusy = false; function buildUI() { if (document.getElementById('bch-panel')) return false; if (!document.getElementById('bch-style')) { const style = document.createElement('style'); style.id = 'bch-style'; style.textContent = ` #bch-panel, #bch-ball, #bch-panel * { box-sizing: border-box; } #bch-panel { position: fixed; top: 12px; right: 12px; width: 256px; display: flex; flex-direction: column; background: #232323; color: #e8e8e8; border: 1px solid #3a3a3a; border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.38); font: 12px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; z-index: 999999; overflow: hidden; user-select: none; max-height: calc(100vh - 8px); } #bch-panel .bch-head { display:flex; align-items:center; gap:4px; padding:7px 8px 7px 10px; border-bottom:1px solid #343434; cursor:move; } #bch-panel .bch-title { flex:1; font-size:12.5px; font-weight:600; color:#f2f2f2; white-space:nowrap; letter-spacing:.2px; } #bch-panel .bch-icon { width:24px; height:24px; padding:0; border:0; border-radius:5px; background:transparent; color:#a9a9a9; font-size:14px; line-height:24px; text-align:center; cursor:pointer; } #bch-panel .bch-icon:hover { background:#343434; color:#fff; } #bch-panel .bch-switch { position:relative; display:inline-block; width:34px; height:18px; flex:none; cursor:pointer; } #bch-panel .bch-switch input { position:absolute; opacity:0; width:0; height:0; } #bch-panel .bch-slider { position:absolute; inset:0; background:#5a5a5a; border-radius:10px; transition:background .2s; } #bch-panel .bch-slider::before { content:""; position:absolute; width:14px; height:14px; left:2px; top:2px; border-radius:50%; background:#fff; transition:transform .2s; box-shadow:0 1px 3px rgba(0,0,0,.35); } #bch-panel .bch-switch input:checked + .bch-slider { background:#2ecc71; } #bch-panel .bch-switch input:checked + .bch-slider::before { transform:translateX(16px); } #bch-panel .bch-switch input:disabled + .bch-slider { opacity:.45; cursor:not-allowed; } #bch-panel .bch-settings { padding:8px 10px; background:#262626; border-bottom:1px solid #343434; } #bch-panel .bch-set-row { display:flex; align-items:center; gap:6px; color:#bdbdbd; font-size:11px; } #bch-panel .bch-set-row input, #bch-panel .bch-set-row select { background:#1a1a1a; color:#e8e8e8; border:1px solid #404040; border-radius:4px; padding:2px 4px; font-size:11px; outline:none; } #bch-panel .bch-set-row input { width:52px; } #bch-panel .bch-set-row select { flex:1; min-width:0; } #bch-panel .bch-hint { margin-top:6px; font-size:10px; color:#777; line-height:1.4; } #bch-panel .bch-tabs { display:flex; gap:2px; margin:8px 10px 0; padding:2px; background:#1b1b1b; border-radius:6px; } #bch-panel .bch-tab { flex:1; text-align:center; padding:3px 0; border-radius:4px; color:#9a9a9a; font-size:11px; cursor:pointer; } #bch-panel .bch-tab:hover { color:#ddd; } #bch-panel .bch-tab.active { background:#383838; color:#fff; box-shadow:0 1px 2px rgba(0,0,0,.3); } #bch-panel .bch-total { display:flex; align-items:flex-end; justify-content:space-between; gap:8px; padding:8px 12px 2px; } #bch-panel .bch-num { font-size:26px; font-weight:700; line-height:1; color:#fff; font-variant-numeric:tabular-nums; letter-spacing:-.5px; } #bch-panel .bch-unit { margin-left:3px; font-size:11px; color:#8d8d8d; font-weight:400; } #bch-panel .bch-sub { font-size:10px; color:#7d7d7d; white-space:nowrap; padding-bottom:2px; } #bch-panel .bch-list { flex:0 1 auto; min-height:0; margin:4px 10px 8px; max-height:188px; overflow-y:auto; } #bch-panel .bch-row { display:flex; align-items:center; gap:6px; padding:3px 2px; min-height:20px; } #bch-panel .bch-row:hover { background:#2b2b2b; border-radius:4px; } #bch-panel .bch-dot { width:7px; height:7px; border-radius:50%; flex:none; } #bch-panel .bch-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#cfcfcf; font-size:11.5px; } #bch-panel .bch-count { color:#fff; font-weight:600; font-size:11.5px; font-variant-numeric:tabular-nums; } #bch-panel .bch-empty { padding:12px 0; text-align:center; color:#777; font-size:11px; } #bch-panel .bch-size { border-top:1px solid #2f2f2f; } #bch-panel .bch-size-head { display:flex; align-items:center; gap:6px; padding:6px 10px; cursor:pointer; } #bch-panel .bch-size-head:hover { background:#2a2a2a; } #bch-panel .bch-size-title { flex:none; color:#d6d6d6; font-size:11px; font-weight:600; } #bch-panel .bch-size-sub { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#7f7f7f; font-size:10px; text-align:right; } #bch-panel .bch-chev { flex:none; color:#999; font-size:11px; transition:transform .15s; } #bch-panel .bch-size-head.open .bch-chev { transform:rotate(90deg); } #bch-panel .bch-size-body { padding:0 10px 8px; } #bch-panel .bch-size-row { display:flex; align-items:center; gap:4px; color:#9a9a9a; font-size:10px; } #bch-panel .bch-size-row input { width:48px; padding:2px 4px; border:1px solid #404040; border-radius:4px; background:#1a1a1a; color:#e8e8e8; font-size:11px; outline:none; } #bch-panel .bch-size-mini { flex:none; padding:2px 6px; border:1px solid #404040; border-radius:4px; background:#2a2a2a; color:#aaa; font-size:10px; cursor:pointer; } #bch-panel .bch-size-mini:hover { background:#333; color:#fff; } #bch-panel .bch-size-tip { min-height:14px; margin-top:5px; overflow:hidden; color:#8a8a8a; font-size:10px; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; } #bch-panel .bch-size-btn { width:100%; margin-top:6px; padding:4px 0; border:1px solid #3f6188; border-radius:4px; background:#2f4a6b; color:#bcd7f2; font-size:11px; cursor:pointer; } #bch-panel .bch-size-btn:hover { background:#35567d; } #bch-panel .bch-size-btn:disabled { opacity:.4; cursor:not-allowed; } #bch-panel .bch-size-undo { width:100%; margin-top:5px; padding:3px 0; border:1px solid #4a4a4a; border-radius:4px; background:transparent; color:#aaa; font-size:10px; cursor:pointer; } #bch-panel .bch-size-undo:hover { border-color:#666; color:#fff; } #bch-panel .bch-size-check { display:flex; align-items:center; gap:5px; margin-top:6px; color:#9a9a9a; font-size:10px; cursor:pointer; } #bch-panel .bch-size-check input { margin:0; accent-color:#2ecc71; } #bch-panel .bch-fix .bch-size-title { color:#e8c879; } #bch-panel .bch-fix-btn { border-color:#6b5a2f; background:#3b3320; color:#f0d18a; } #bch-panel .bch-fix-btn:hover { background:#4a4028; } #bch-panel .bch-fix-btn.stop { border-color:#7a3d3d; background:#4a2626; color:#f0a0a0; } #bch-panel .bch-foot { display:flex; align-items:center; gap:6px; padding:6px 10px; background:#202020; border-top:1px solid #343434; } #bch-panel .bch-status-dot { width:6px; height:6px; border-radius:50%; background:#2ecc71; flex:none; } #bch-panel .bch-foot-text { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#8f8f8f; font-size:10px; } #bch-panel .bch-save { border:1px solid #3c5a45; background:#28382d; color:#9fd6ae; border-radius:4px; padding:2px 7px; font-size:10px; cursor:pointer; } #bch-panel .bch-save:hover { background:#324737; } #bch-panel .bch-save:disabled { opacity:.4; cursor:not-allowed; } #bch-panel .bch-list::-webkit-scrollbar { width:4px; } #bch-panel .bch-list::-webkit-scrollbar-thumb { background:#4a4a4a; border-radius:2px; } #bch-ball { position: fixed; width:46px; height:46px; display:none; flex-direction:column; align-items:center; justify-content:center; background:rgba(35,35,35,.94); border:1px solid #444; border-radius:50%; box-shadow:0 4px 14px rgba(0,0,0,.35); color:#fff; cursor:grab; z-index:999998; user-select:none; backdrop-filter:blur(4px); } #bch-ball:active { cursor:grabbing; } #bch-ball .bch-ball-num { font-size:12px; font-weight:700; line-height:1.1; max-width:40px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } #bch-ball .bch-ball-label { font-size:8px; color:#8f8f8f; line-height:1.1; } #bch-ball .bch-ball-dot { position:absolute; top:3px; right:4px; width:6px; height:6px; border-radius:50%; background:#2ecc71; } #bch-panel [hidden] { display:none !important; } `; document.head.appendChild(style); } const panel = document.createElement('div'); panel.id = 'bch-panel'; panel.innerHTML = `
框数助手
当前帧
全部帧
—框
等待平台数据…
尺寸同步 选中 3D 框后可用 ▸
切帧修复 未开启 ▸
初始化…
`; document.body.appendChild(panel); const ball = document.createElement('div'); ball.id = 'bch-ball'; ball.title = '框数助手(点击展开)'; ball.innerHTML = ` — 框`; document.body.appendChild(ball); els = { panel: panel, ball: ball, head: panel.querySelector('#bch-head'), auto: panel.querySelector('#bch-auto'), gear: panel.querySelector('#bch-gear'), min: panel.querySelector('#bch-min'), settings: panel.querySelector('#bch-settings'), interval: panel.querySelector('#bch-interval'), unit: panel.querySelector('#bch-unit'), tabs: Array.prototype.slice.call(panel.querySelectorAll('.bch-tab')), total: panel.querySelector('#bch-total'), sub: panel.querySelector('#bch-sub'), list: panel.querySelector('#bch-list'), sizeHead: panel.querySelector('#bch-size-head'), sizeSub: panel.querySelector('#bch-size-sub'), sizeChev: panel.querySelector('#bch-size-chev'), sizeBody: panel.querySelector('#bch-size-body'), sizeFrom: panel.querySelector('#bch-size-from'), sizeTo: panel.querySelector('#bch-size-to'), sizeLast: panel.querySelector('#bch-size-last'), sizeTip: panel.querySelector('#bch-size-tip'), sizeAngle: panel.querySelector('#bch-size-angle'), sizeRun: panel.querySelector('#bch-size-run'), sizeUndo: panel.querySelector('#bch-size-undo'), fixHead: panel.querySelector('#bch-fix-head'), fixSub: panel.querySelector('#bch-fix-sub'), fixChev: panel.querySelector('#bch-fix-chev'), fixBody: panel.querySelector('#bch-fix-body'), fixTip: panel.querySelector('#bch-fix-tip'), fixRun: panel.querySelector('#bch-fix-run'), fixAuto: panel.querySelector('#bch-fix-auto'), footDot: panel.querySelector('#bch-foot-dot'), footText: panel.querySelector('#bch-foot-text'), save: panel.querySelector('#bch-save'), ballNum: ball.querySelector('#bch-ball-num'), ballDot: ball.querySelector('#bch-ball-dot') }; return true; } function bindUI() { els.tabs.forEach(function (tab) { tab.addEventListener('click', function () { const scope = tab.dataset.scope; if (settings.scope === scope) return; settings.scope = scope; persist(); updateTabs(); refresh(true); }); }); els.auto.checked = settings.autoSave; els.auto.addEventListener('change', function () { if (!canEdit()) { els.auto.checked = false; setStatus('当前账号无编辑权限', 'warn'); return; } settings.autoSave = els.auto.checked; persist(); scheduleAutoSave(); }); els.gear.addEventListener('click', function (e) { e.stopPropagation(); els.settings.hidden = !els.settings.hidden; }); els.interval.value = settings.interval; els.unit.value = settings.unit; const applyInterval = function () { let value = Math.round(Number(els.interval.value)); if (!Number.isFinite(value) || value < 1) value = DEFAULTS.interval; value = Math.min(999, Math.max(1, value)); if (els.unit.value === 'seconds') value = Math.max(10, value); els.interval.value = value; settings.interval = value; settings.unit = els.unit.value; persist(); scheduleAutoSave(); }; els.interval.addEventListener('change', applyInterval); els.unit.addEventListener('change', applyInterval); els.min.addEventListener('click', function (e) { e.stopPropagation(); setCollapsed(true); }); els.save.addEventListener('click', function (e) { e.stopPropagation(); tryAutoSave(true); scheduleAutoSave(); }); els.sizeHead.addEventListener('click', function () { const open = els.sizeBody.hidden; els.sizeBody.hidden = !open; els.sizeHead.classList.toggle('open', open); }); els.sizeLast.addEventListener('click', function (e) { e.stopPropagation(); if (lastSnapshot && lastSnapshot.frames.length) { els.sizeTo.value = lastSnapshot.frames.length; updateSizeUI(lastSnapshot); } }); els.sizeFrom.addEventListener('input', function () { updateSizeUI(lastSnapshot); }); els.sizeTo.addEventListener('input', function () { updateSizeUI(lastSnapshot); }); els.sizeAngle.checked = !!settings.sizeSyncAngle; els.sizeAngle.addEventListener('change', function () { settings.sizeSyncAngle = els.sizeAngle.checked; persist(); }); els.sizeRun.addEventListener('click', function () { runSizeSync(); }); els.sizeUndo.addEventListener('click', function () { undoSizeSync(); }); els.fixHead.addEventListener('click', function (e) { if (e.target.closest('label')) return; const open = els.fixBody.hidden; els.fixBody.hidden = !open; els.fixHead.classList.toggle('open', open); }); els.fixAuto.addEventListener('change', function () { settings.autoFix = els.fixAuto.checked; autoFixLastFrame = ''; autoFixLastSummary = ''; persist(); if (settings.autoFix) maybeAutoFix(); updateFixUI(lastSnapshot, true); }); els.fixRun.addEventListener('click', function () { fixCurrentFrame(true); }); document.addEventListener('pointerdown', function (e) { if (!els.settings.hidden && !els.settings.contains(e.target) && !els.gear.contains(e.target)) { els.settings.hidden = true; } }); makeDraggable(els.head, els.panel, false); makeDraggable(els.ball, els.ball, true); window.addEventListener('resize', function () { clampCurrent(); }); document.addEventListener('visibilitychange', function () { if (!document.hidden) { refresh(true); updateFooter(); } }); } function makeDraggable(handle, target, isBall) { let dragging = false; let moved = false; let startX = 0; let startY = 0; let originX = 0; let originY = 0; handle.addEventListener('pointerdown', function (e) { if (e.button !== 0) return; if (!isBall && e.target.closest('button, input, select, label')) return; dragging = true; moved = false; startX = e.clientX; startY = e.clientY; const rect = target.getBoundingClientRect(); originX = rect.left; originY = rect.top; try { handle.setPointerCapture(e.pointerId); } catch (err) {} e.preventDefault(); }); handle.addEventListener('pointermove', function (e) { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) + Math.abs(dy) > 3) moved = true; placeWidget(target, originX + dx, originY + dy); }); function finish(e) { if (!dragging) return; dragging = false; try { handle.releasePointerCapture(e.pointerId); } catch (err) {} if (moved) { settings.pos = { x: target.offsetLeft, y: target.offsetTop }; persist(); } else if (isBall) { setCollapsed(false); } } handle.addEventListener('pointerup', finish); handle.addEventListener('pointercancel', function (e) { if (!dragging) return; dragging = false; try { handle.releasePointerCapture(e.pointerId); } catch (err) {} }); } function placeWidget(el, x, y) { const w = el.offsetWidth || 256; const h = el.offsetHeight || 120; const nx = clamp(x, 4, Math.max(4, window.innerWidth - w - 4)); const ny = clamp(y, 4, Math.max(4, window.innerHeight - h - 4)); el.style.left = nx + 'px'; el.style.top = ny + 'px'; el.style.right = 'auto'; el.style.bottom = 'auto'; } function defaultPlace(el, isBall) { if (isBall) { placeWidget(el, window.innerWidth - 62, Math.round(window.innerHeight * 0.45)); } else { placeWidget(el, window.innerWidth - 268, 54); } } function clampCurrent() { if (!els) return; const el = settings.collapsed ? els.ball : els.panel; if (!el || el.style.display === 'none') return; const rect = el.getBoundingClientRect(); placeWidget(el, rect.left, rect.top); } function setCollapsed(collapsed) { if (settings.collapsed === collapsed && (collapsed ? els.panel.style.display === 'none' : els.panel.style.display !== 'none')) { return; } if (collapsed) { const rect = els.panel.getBoundingClientRect(); els.panel.style.display = 'none'; els.ball.style.display = 'flex'; placeWidget(els.ball, rect.left, rect.top); settings.pos = { x: els.ball.offsetLeft, y: els.ball.offsetTop }; } else { const rect = els.ball.getBoundingClientRect(); els.ball.style.display = 'none'; els.panel.style.display = 'flex'; placeWidget(els.panel, rect.left, rect.top); settings.pos = { x: els.panel.offsetLeft, y: els.panel.offsetTop }; refresh(true); } settings.collapsed = collapsed; persist(); updateFooter(); } function applyInitialState() { if (settings.collapsed) { els.panel.style.display = 'none'; els.ball.style.display = 'flex'; if (settings.pos) placeWidget(els.ball, settings.pos.x, settings.pos.y); else defaultPlace(els.ball, true); } else { els.ball.style.display = 'none'; els.panel.style.display = 'flex'; if (settings.pos) placeWidget(els.panel, settings.pos.x, settings.pos.y); else defaultPlace(els.panel, false); } updateTabs(); } function updateTabs() { if (!els) return; els.tabs.forEach(function (tab) { tab.classList.toggle('active', tab.dataset.scope === settings.scope); }); } function renderWaiting(text) { if (!els) return; els.total.textContent = '\u2014'; els.sub.textContent = ''; els.ballNum.textContent = '\u2014'; const sig = 'wait:' + text; if (els.list.dataset.sig === sig) return; els.list.dataset.sig = sig; els.list.textContent = ''; const empty = document.createElement('div'); empty.className = 'bch-empty'; empty.textContent = text; els.list.appendChild(empty); } function renderCounts(snapshot, result) { if (!els) return; const entries = []; const cache = new Map(); result.map.forEach(function (count, key) { const idx = key.indexOf('|'); const tool = key.slice(0, idx); const classId = key.slice(idx + 1); const infoKey = tool + '|' + classId; let info = cache.get(infoKey); if (!info) { info = classInfo(snapshot, tool, classId); cache.set(infoKey, info); } entries.push({ name: info.name, color: info.color, tool: info.tool, id: info.id, count: count }); }); entries.sort(function (a, b) { return b.count - a.count || a.name.localeCompare(b.name, 'zh-CN'); }); els.total.textContent = formatNum(result.total); els.sub.textContent = settings.scope === 'frame' ? (snapshot.curIndex >= 0 ? '第 ' + (snapshot.curIndex + 1) + '/' + snapshot.frames.length + ' 帧' : '共 ' + snapshot.frames.length + ' 帧') : snapshot.frames.length + ' 帧'; els.ballNum.textContent = compactNum(result.total); const sig = entries.map(function (e) { return e.tool + '|' + e.id + '|' + e.name + '|' + e.color + '|' + e.count; }).join(','); if (els.list.dataset.sig !== sig) { els.list.dataset.sig = sig; els.list.textContent = ''; if (!entries.length) { const empty = document.createElement('div'); empty.className = 'bch-empty'; empty.textContent = '暂无标注'; els.list.appendChild(empty); } else { const frag = document.createDocumentFragment(); entries.forEach(function (entry) { const row = document.createElement('div'); row.className = 'bch-row'; row.title = entry.tool + ' · 类别 ' + entry.id; const dot = document.createElement('span'); dot.className = 'bch-dot'; dot.style.background = entry.color; const name = document.createElement('span'); name.className = 'bch-name'; name.textContent = entry.name; const count = document.createElement('span'); count.className = 'bch-count'; count.textContent = formatNum(entry.count); row.appendChild(dot); row.appendChild(name); row.appendChild(count); frag.appendChild(row); }); els.list.appendChild(frag); } } updateTabs(); } function updateFooter() { if (!els) return; const now = Date.now(); const inst = getInstance(); const status = getStatusState(); let text = ''; let dot = '#2ecc71'; let saveDisabled = false; if (flash.until > now && flash.text) { text = flash.text; dot = flash.kind === 'warn' ? '#f0a04b' : flash.kind === 'error' ? '#e74c3c' : '#2ecc71'; } else if (!inst) { text = '等待平台数据…'; dot = '#666'; saveDisabled = true; } else if (!inst.canEdit) { text = '只读账号 · 不自动保存'; dot = '#f0a04b'; saveDisabled = true; } else if (status && status.multiFrame) { text = '多帧合并中 · 暂停保存'; dot = '#f0a04b'; saveDisabled = true; } else if (!settings.autoSave) { text = '自动保存已关闭'; dot = '#666'; } else { const left = Math.max(0, Math.ceil((nextSaveAt - now) / 1000)); text = '自动保存 · ' + formatDuration(left) + ' 后'; dot = '#2ecc71'; } els.footText.textContent = text; els.footText.title = text; els.footDot.style.background = dot; els.save.disabled = saveDisabled; els.auto.checked = settings.autoSave && !!inst && !!inst.canEdit; els.auto.disabled = !inst || !inst.canEdit; els.ballDot.style.background = settings.autoSave && !!inst && !!inst.canEdit ? '#2ecc71' : '#666'; } function parseSizeRange(total) { const start = Math.round(Number(els.sizeFrom.value)); const end = Math.round(Number(els.sizeTo.value)); if (!Number.isFinite(start) || !Number.isFinite(end)) return null; if (start < 1 || end < 1 || start > total || end > total || start > end) return null; return { start: start, end: end }; } function setSizeTip(text, kind, ms) { sizeFlash = { text: text, kind: kind || '', until: Date.now() + (ms || 6000) }; updateSizeUI(lastSnapshot); } function updateSizeUI(snapshot) { if (!els || !els.sizeRun) return; if (!snapshot || !snapshot.frames.length) { els.sizeSub.textContent = snapshot ? '正在加载任务…' : '等待平台数据…'; els.sizeSub.title = els.sizeSub.textContent; els.sizeTip.textContent = els.sizeSub.textContent; els.sizeTip.style.color = '#8a8a8a'; els.sizeRun.disabled = true; els.sizeUndo.hidden = true; return; } const taskKey = snapshot.taskKey + '|' + snapshot.frames.length + '|' + snapshot.frameSetKey; if (sizeRangeKey !== taskKey) { sizeRangeKey = taskKey; const current = snapshot.curIndex >= 0 ? snapshot.curIndex + 1 : 1; els.sizeFrom.value = current; els.sizeTo.value = current; sizeFlash = null; lastSizeUndo = null; els.sizeUndo.hidden = true; } const total = snapshot.frames.length; els.sizeFrom.max = total; els.sizeTo.max = total; const canUndo = !!(lastSizeUndo && lastSizeUndo.taskKey === taskKey); els.sizeUndo.hidden = !canUndo; const target = getSizeTarget(snapshot); if (target.error) { els.sizeSub.textContent = target.error; els.sizeSub.title = target.error; els.sizeTip.textContent = target.error; els.sizeTip.title = target.error; els.sizeTip.style.color = '#8a8a8a'; els.sizeRun.disabled = true; return; } els.sizeSub.textContent = target.className + ' · ' + fmtSize(target.dims) + 'm'; els.sizeSub.title = '目标物 #' + target.trackId + ' · ' + target.className + ' · ' + fmtSize(target.dims) + ' m'; const range = parseSizeRange(total); let tip = ''; let disabled = false; let status = null; try { status = snapshot.sm.statusState(); } catch (e) { status = null; } if (!canEdit()) { tip = '只读账号,无法修改尺寸'; disabled = true; } else if (status && status.multiFrame) { tip = '多帧合并中,暂停尺寸同步'; disabled = true; } else if (status && status.disabledShortKey) { tip = '平台弹窗打开中,请先关闭'; disabled = true; } else if (!range) { tip = '帧范围需在 1–' + total + ',且起始帧 ≤ 结束帧'; disabled = true; } else { const curText = target.curFrameNo ? '第 ' + target.curFrameNo + ' 帧' : '当前帧'; tip = '基准:' + curText + (settings.sizeSyncAngle ? ' 尺寸/角度' : ' 尺寸') + ' · 同步第 ' + range.start + '–' + range.end + ' 帧(含首尾)'; } if (sizeFlash && sizeFlash.until > Date.now()) { tip = sizeFlash.text; els.sizeTip.style.color = sizeFlash.kind === 'success' ? '#7fd99a' : sizeFlash.kind === 'warn' ? '#f0a04b' : sizeFlash.kind === 'error' ? '#e74c3c' : '#8a8a8a'; } else { els.sizeTip.style.color = '#8a8a8a'; } els.sizeTip.textContent = tip; els.sizeTip.title = tip; els.sizeRun.disabled = disabled || !range; } function runSizeSync() { const snapshot = getSnapshot(); if (!snapshot || !snapshot.frames.length) { setSizeTip('平台数据未就绪', 'warn', 4000); return; } const range = parseSizeRange(snapshot.frames.length); if (!range) { setSizeTip('帧范围无效(1–' + snapshot.frames.length + ',且起帧 ≤ 止帧)', 'warn', 5000); return; } const plan = buildSizePlan(snapshot, range); if (plan.error) { setSizeTip(plan.error, 'warn', 5000); return; } if (!plan.changes.length) { const extra = plan.locked.length + plan.missing.length + plan.noSize.length; setSizeTip('范围内没有需要修改的帧' + (extra ? '(跳过 ' + extra + ' 帧)' : ''), 'success', 6000); return; } const curText = plan.target.curFrameNo ? '第 ' + plan.target.curFrameNo + ' 帧' : '当前帧'; const lines = [ '目标物 #' + plan.trackId + ' [' + plan.target.className + ']', '基准:' + curText + '尺寸 ' + fmtSize(plan.target.dims) + ' m', '范围:第 ' + range.start + '–' + range.end + ' 帧(含首尾)', '角度:' + (plan.angleSync ? '同步当前帧 ' + fmtAngles(plan.target.rotation) : '保持各帧原角度'), '底面固定:贴近地面的一侧不动,其余 5 个面伸缩', '将修改 ' + plan.changes.length + ' 帧' + (plan.same.length ? ',' + plan.same.length + ' 帧已一致' : ''), plan.locked.length ? '锁定跳过 ' + plan.locked.length + ' 帧' : '', (plan.missing.length + plan.noSize.length) ? '无框/无尺寸跳过 ' + (plan.missing.length + plan.noSize.length) + ' 帧' : '', '', '确定执行吗?' ]; if (!confirm(lines.filter(Boolean).join('\n'))) return; const result = applySizePlan(plan); if (result.error) { setSizeTip(result.error, 'error', 6000); return; } const parts = ['已同步 ' + result.changed + ' 帧' + (plan.angleSync ? '(尺寸+角度)' : '')]; if (result.locked) parts.push('锁定跳过 ' + result.locked + ' 帧'); if (result.missing) parts.push('缺失跳过 ' + result.missing + ' 帧'); if (result.changed) { parts.push('未立即保存'); els.sizeUndo.hidden = false; } setSizeTip(parts.join(','), result.changed ? 'success' : 'warn', 8000); } function undoSizeSync() { const snapshot = getSnapshot(); if (!snapshot) { setSizeTip('平台数据未就绪', 'warn', 4000); return; } if (!lastSizeUndo) { setSizeTip('没有可撤销的同步', 'warn', 4000); return; } const taskKey = snapshot.taskKey + '|' + snapshot.frames.length + '|' + snapshot.frameSetKey; if (lastSizeUndo.taskKey !== taskKey) { lastSizeUndo = null; els.sizeUndo.hidden = true; setSizeTip('任务已切换,无法撤销', 'warn', 4000); return; } const track = snapshot.marks[lastSizeUndo.trackKey] || snapshot.marks[lastSizeUndo.trackId]; if (!track || !track['3d']) { lastSizeUndo = null; els.sizeUndo.hidden = true; setSizeTip('目标物已不存在,无法撤销', 'warn', 4000); return; } if (!confirm('撤销上次尺寸同步?将恢复 ' + lastSizeUndo.entries.length + ' 帧的尺寸、角度和位置。')) return; const sm = getSM(); if (!sm || typeof sm.markCommit !== 'function') { setSizeTip('平台 markCommit 不可用', 'error', 5000); return; } let changed = 0, skipped = 0; for (const entry of lastSizeUndo.entries) { const mark = track['3d'][entry.frameId]; if (!mark) { skipped++; continue; } if (typeof sm.isAllowOpera === 'function') { let allowed = true; try { allowed = !!sm.isAllowOpera(mark, 'size'); } catch (e) { allowed = true; } if (!allowed) { skipped++; continue; } } if (Array.isArray(mark.scale)) { mark.scale[0] = entry.scale[0]; mark.scale[1] = entry.scale[1]; mark.scale[2] = entry.scale[2]; } else { mark.scale = entry.scale.slice(); } if (entry.rotation) mark.rotation = entry.rotation.slice(); if (entry.position) mark.position = entry.position.slice(); mark.is_computed = 0; mark.point_num = 0; sm.markCommit('addOrReplaceMark3D', [1, mark, lastSizeUndo.trackId, lastSizeUndo.classId, entry.frameId, null, true]); changed++; } lastSizeUndo = null; els.sizeUndo.hidden = true; if (!changed) { setSizeTip('撤销失败:没有可恢复的帧', 'error', 5000); return; } setSizeTip('已撤销 ' + changed + ' 帧' + (skipped ? ',跳过 ' + skipped + ' 帧' : '') + '(未立即保存)', 'success', 8000); } // ========== 切帧修复(属性未设置) ========== function isEmptyAttrValue(value) { return value == null || value === '' || (Array.isArray(value) && value.length === 0); } function getAttrsConfig(sm, toolType, classId) { if (!sm || typeof sm.configGetter !== 'function') return null; try { const config = sm.configGetter('getAttrsByClassId')(toolType, classId); if (!config || typeof config !== 'object') return null; for (const key in config) return config; } catch (e) {} return null; } function attrsComplete(config, attrs) { for (const key in config) { if (!attrs || !attrs[key]) return false; } return true; } function buildCompleteAttrs(config, attrs) { const out = {}; for (const key in config) { let value = attrs && attrs[key]; if (isEmptyAttrValue(value)) { const item = config[key]; value = item ? item.default_value : null; } if (isEmptyAttrValue(value)) return null; out[key] = Array.isArray(value) ? value.slice() : [value]; } return out; } function fixTaskKey(snapshot) { return snapshot.taskKey + '|' + snapshot.frames.length + '|' + snapshot.frameSetKey; } function setFixTip(text, kind, ms) { fixFlash = { text: text, kind: kind || '', until: Date.now() + (ms || 5000) }; updateFixUI(lastSnapshot, true); } function updateFixUI(snapshot, force) { if (!els || !els.fixRun) return; if (!snapshot || !snapshot.frames.length) { els.fixSub.textContent = snapshot ? '正在加载任务…' : '等待平台数据…'; els.fixSub.title = els.fixSub.textContent; els.fixTip.textContent = els.fixSub.textContent; els.fixTip.style.color = '#8a8a8a'; els.fixAuto.checked = !!settings.autoFix; els.fixAuto.disabled = true; els.fixRun.disabled = true; return; } const taskKey = fixTaskKey(snapshot); if (taskKey !== autoFixTaskKey) { autoFixTaskKey = taskKey; autoFixLastFrame = ''; autoFixLastSummary = ''; } let status = null; try { status = snapshot.sm.statusState(); } catch (e) { status = null; } els.fixAuto.checked = !!settings.autoFix; els.fixAuto.disabled = !canEdit(); let sub = settings.autoFix ? '已开启' : '未开启'; if (settings.autoFix && autoFixLastSummary) sub += ' · ' + autoFixLastSummary; els.fixSub.textContent = sub; els.fixSub.title = sub; let tip = '自动补全当前帧“属性未设置”的框'; let disabled = !canEdit(); if (!canEdit()) { tip = '只读账号,无法修复'; } else if (status && status.multiFrame) { tip = '多帧合并中,暂停修复'; disabled = true; } else if (status && status.disabledShortKey) { tip = '平台弹窗打开中,修复会暂缓'; disabled = true; } if (fixFlash && fixFlash.until > Date.now()) { tip = fixFlash.text; els.fixTip.style.color = fixFlash.kind === 'success' ? '#7fd99a' : fixFlash.kind === 'warn' ? '#f0a04b' : fixFlash.kind === 'error' ? '#e74c3c' : '#8a8a8a'; } else { els.fixTip.style.color = '#8a8a8a'; } els.fixTip.textContent = tip; els.fixTip.title = tip; els.fixRun.disabled = disabled; els.fixRun.textContent = '立即修复当前帧'; } function fixCurrentFrame(manual) { const snapshot = getSnapshot(); if (!snapshot || !snapshot.frames.length) { if (manual) setFixTip('平台数据未就绪', 'warn', 4000); return { fixed: 0, needManual: 0 }; } if (!canEdit()) { if (manual) setFixTip('只读账号,无法修复', 'warn', 4000); return { fixed: 0, needManual: 0 }; } let status = null; try { status = snapshot.sm.statusState(); } catch (e) { status = null; } if (status && status.multiFrame) { if (manual) setFixTip('多帧合并中,暂停修复', 'warn', 4000); return { fixed: 0, needManual: 0 }; } if (status && status.disabledShortKey) { if (manual) setFixTip('平台弹窗打开中,请先关闭', 'warn', 4000); return { fixed: 0, needManual: 0 }; } autoFixTaskKey = fixTaskKey(snapshot); autoFixLastFrame = snapshot.curId; autoFixLastSummary = ''; fixFlash = null; const sm = snapshot.sm; let marks; try { marks = sm.marksState().marks; } catch (e) { if (manual) setFixTip('读取标注数据失败', 'error', 4000); return { fixed: 0, needManual: 0 }; } const frameId = snapshot.curId; let fixed = 0; let needManual = 0; const handled = new Set(); function targetKey(trackId, dimension, camera) { return String(trackId) + '|' + (dimension || '') + '|' + (camera || ''); } function findMark(trackId, dimension, camera) { const track = marks[trackId] || marks[String(trackId)]; if (!track) return null; if (dimension === '3d') return (track['3d'] && track['3d'][frameId]) || null; if (dimension === '2d') { const list = track['2d'] && track['2d'][frameId]; if (!Array.isArray(list)) return null; if (camera) { return list.find(function (mark) { return mark && mark.camera === camera; }) || null; } return list[0] || null; } return null; } function tryFix(trackId, mark, classId, dimension, camera, force) { if (!mark) return; const config = getAttrsConfig(sm, mark.type, classId); if (!config) return; const completeNow = attrsComplete(config, mark.attrs); if (completeNow && !force) return; const complete = buildCompleteAttrs(config, mark.attrs); if (!complete) { if (!completeNow) needManual++; return; } try { sm.markCommit('changeAttrs', [complete, mark.type, trackId, dimension, frameId, camera || null]); handled.add(targetKey(trackId, dimension, camera)); fixed++; } catch (e) { console.warn('[框数助手] 修复属性失败', e); needManual++; } } // 1) 当前帧异常列表里明确报“属性未设置”的框:即使属性看起来完整也强制写回一次, // 让平台的 Checker 重新检查并清掉这条异常。 let exceptions = null; try { exceptions = sm.postilState ? sm.postilState().exceptions : null; } catch (e) { exceptions = null; } if (exceptions) { for (const id in exceptions) { const list = exceptions[id]; if (!Array.isArray(list)) continue; for (const item of list) { if (!item || String(item.taskId) !== String(frameId)) continue; if (!item.err || String(item.err).indexOf('属性未设置') < 0) continue; const key = targetKey(item.trackId, item.dimension, item.camera); if (handled.has(key)) continue; const track = marks[item.trackId] || marks[String(item.trackId)]; if (!track) continue; const mark = findMark(item.trackId, item.dimension, item.camera); if (!mark) continue; tryFix(item.trackId, mark, track.class, item.dimension, item.camera, true); } } } // 2) 再主动扫一遍当前帧所有框,补上异常列表里还没出现的缺失属性。 for (const trackId in marks) { const track = marks[trackId]; if (!track) continue; const classId = track.class != null ? track.class : null; const d3 = track['3d']; const mark3 = d3 && d3[frameId]; if (mark3 && !handled.has(targetKey(trackId, '3d', null))) { tryFix(trackId, mark3, classId, '3d', null, false); } const d2 = track['2d']; const marks2 = d2 && d2[frameId]; if (Array.isArray(marks2)) { for (const mark2 of marks2) { if (!mark2) continue; const camera = mark2.camera || null; if (handled.has(targetKey(trackId, '2d', camera))) continue; tryFix(trackId, mark2, classId, '2d', camera, false); } } } const summary = fixed ? (needManual ? '修' + fixed + '/手' + needManual : '修' + fixed) : (needManual ? '手' + needManual : '本帧OK'); autoFixLastSummary = summary; if (manual) { if (fixed) { setFixTip('已修复当前帧 ' + fixed + ' 个框' + (needManual ? ',另有 ' + needManual + ' 个需手动补全' : ''), needManual ? 'warn' : 'success', 6000); saveAfterBulkEdit(); } else if (needManual) { setFixTip('有 ' + needManual + ' 个框无法自动补全,请手动选择属性', 'warn', 5500); } else { setFixTip('当前帧没有需要修复的属性异常', 'success', 4500); } } else if (fixed) { setFixTip('已自动修复 ' + fixed + ' 个框属性', 'success', 2500); } else if (needManual) { setFixTip('有 ' + needManual + ' 个框需手动补全属性', 'warn', 3000); } updateFixUI(getSnapshot() || snapshot, true); return { fixed: fixed, needManual: needManual }; } function maybeAutoFix() { if (!settings.autoFix || autoFixBusy) return; const snapshot = getSnapshot(); if (!snapshot || !snapshot.frames.length) return; if (!canEdit()) return; let status = null; try { status = snapshot.sm.statusState(); } catch (e) { status = null; } if (status && (status.multiFrame || status.disabledShortKey)) return; const taskKey = fixTaskKey(snapshot); if (taskKey !== autoFixTaskKey) { autoFixTaskKey = taskKey; autoFixLastFrame = ''; autoFixLastSummary = ''; } if (snapshot.curId === autoFixLastFrame) return; autoFixBusy = true; try { fixCurrentFrame(false); } finally { autoFixBusy = false; } } // ========== 2D 图片缩放记忆 ========== function isVisibleElement(el) { if (!el) return false; try { return el.getClientRects().length > 0 || el.offsetParent !== null; } catch (e) { return false; } } function refresh(force) { if (!els) return; let snapshot; try { snapshot = getSnapshot(); } catch (e) { snapshot = null; } lastSnapshot = snapshot; updateSizeUI(snapshot); updateFixUI(snapshot); if (!snapshot) { renderWaiting('等待平台数据…'); return; } if (!snapshot.frames.length) { renderWaiting('正在加载任务…'); return; } const key = settings.scope === 'frame' ? 'f|' + snapshot.curId + '|' + snapshot.versions + '|' + snapshot.frames.length + '|' + snapshot.taskKey : 'a|' + snapshot.versions + '|' + snapshot.frames.length + '|' + snapshot.taskKey + '|' + snapshot.frameSetKey; if (!force && key === refreshKey) return; refreshKey = key; try { const result = settings.scope === 'frame' ? countFrame(snapshot) : countAll(snapshot); renderCounts(snapshot, result); } catch (e) { console.error('[框数助手] 统计失败', e); renderWaiting('统计失败,请刷新页面'); } } // ========== 初始化 ========== function init() { if (booted) return; booted = true; if (!buildUI()) return; bindUI(); setupMessageObserver(); applyInitialState(); els.interval.value = settings.interval; els.unit.value = settings.unit; els.auto.checked = settings.autoSave; scheduleAutoSave(); refresh(true); updateFooter(); setInterval(function () { if (document.hidden) return; checkPendingSave(); refresh(false); updateFooter(); }, 1000); setInterval(function () { if (document.hidden) return; maybeAutoFix(); }, 500); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { setTimeout(init, 300); }); } else { setTimeout(init, 300); } })(); /* ===================================================================== * 模块 B · 3D 标注重叠提醒(原 点云标注重叠提醒.user.js,判定逻辑未改) * ===================================================================== */ /* * 工作原理 * --------- * 标注页用 three.js 渲染,所有 3D 框都是 window.viewer.volumeScene 的子对象 * (class BoxVolume,标记数据挂在 obj.mark 上,尺寸 = scale,位置 = position,姿态 = rotation)。 * 本脚本每隔 ~0.6s 读取一次这些框: * 1. 用 matrixWorld 把单位立方体 8 个角点变换到世界坐标; * 2. 投影到地面(XY 平面)求凸包,得到每个框的「脚印」多边形; * 3. 两两做凸多边形求交(Sutherland–Hodgman), * 重叠率 = 交面积 / 两框中较小的那个的面积; * 4. 同时要求两者在 Z 方向真的相交(否则是上下叠放,不算错)。 * 超过阈值的组合在画面里描红、在右上角面板列出,新出现的组合会弹提醒。 * * 只读:不改 scene、不改 marks、不发请求。 */ (function () { 'use strict'; if (window.__PCQA_LOADED__) return; window.__PCQA_LOADED__ = true; /* ===================================================================== * 0. 几何核心(纯函数、无 DOM)—— 单测会抽取 @core 段单独跑 * ===================================================================== */ /* @core:begin */ var CORE_EPS = 1e-7; // 单位立方体 8 个角点(BoxGeometry(1,1,1),真实尺寸由 scale 决定) var CUBE_CORNERS = [ [-0.5, -0.5, -0.5], [0.5, -0.5, -0.5], [0.5, 0.5, -0.5], [-0.5, 0.5, -0.5], [-0.5, -0.5, 0.5], [0.5, -0.5, 0.5], [0.5, 0.5, 0.5], [-0.5, 0.5, 0.5] ]; /** 把单位立方体的 8 个角点变换到世界坐标。m 可以是 THREE.Matrix4 或 {elements}。 */ function transformCorners(m) { var e = (m && m.elements) ? m.elements : m; var out = []; for (var i = 0; i < CUBE_CORNERS.length; i++) { var c = CUBE_CORNERS[i]; var x = c[0], y = c[1], z = c[2]; out.push([ e[0] * x + e[4] * y + e[8] * z + e[12], e[1] * x + e[5] * y + e[9] * z + e[13], e[2] * x + e[6] * y + e[10] * z + e[14] ]); } return out; } /** 二维凸包(Andrew monotone chain),输入 [{x,y}],返回逆时针多边形。 */ function convexHull2D(points) { var pts = points.slice().sort(function (a, b) { return a.x - b.x || a.y - b.y; }); if (pts.length <= 2) return pts; function cross(o, a, b) { return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); } var lower = [], i; for (i = 0; i < pts.length; i++) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pts[i]) <= CORE_EPS) { lower.pop(); } lower.push(pts[i]); } var upper = []; for (i = pts.length - 1; i >= 0; i--) { while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pts[i]) <= CORE_EPS) { upper.pop(); } upper.push(pts[i]); } lower.pop(); upper.pop(); return lower.concat(upper); } /** 多边形有向面积(逆时针为正)。 */ function polyArea(poly) { var s = 0; for (var i = 0; i < poly.length; i++) { var a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } return s / 2; } /** 线段 p1p2 与直线 ab 的交点(只用于凸多边形裁剪,参数在裁剪侧已保证有交点)。 */ function segIntersect(p1, p2, a, b) { var d1x = p2.x - p1.x, d1y = p2.y - p1.y; var d2x = b.x - a.x, d2y = b.y - a.y; var den = d1x * d2y - d1y * d2x; if (Math.abs(den) < 1e-12) return null; var t = ((a.x - p1.x) * d2y - (a.y - p1.y) * d2x) / den; return { x: p1.x + t * d1x, y: p1.y + t * d1y }; } /** 两个凸多边形求交(Sutherland–Hodgman)。返回逆时针多边形,可能为空。 */ function clipConvex(subject, clip) { var out = subject.slice(); if (!out.length || !clip || clip.length < 3) return []; var c = clip.slice(); if (polyArea(c) < 0) c.reverse(); // 统一成逆时针("内侧" = 左侧) for (var i = 0; i < c.length && out.length; i++) { var A = c[i], B = c[(i + 1) % c.length]; var input = out; out = []; for (var j = 0; j < input.length; j++) { var cur = input[j]; var prev = input[(j + input.length - 1) % input.length]; var curIn = (B.x - A.x) * (cur.y - A.y) - (B.y - A.y) * (cur.x - A.x) >= -1e-9; var prevIn = (B.x - A.x) * (prev.y - A.y) - (B.y - A.y) * (prev.x - A.x) >= -1e-9; if (curIn) { if (!prevIn) { var ip1 = segIntersect(prev, cur, A, B); if (ip1) out.push(ip1); } out.push(cur); } else if (prevIn) { var ip2 = segIntersect(prev, cur, A, B); if (ip2) out.push(ip2); } } } return dedupePoly(out); } function dedupePoly(poly, eps) { if (!poly || poly.length < 2) return poly || []; eps = eps == null ? 1e-7 : eps; var out = []; for (var i = 0; i < poly.length; i++) { var p = poly[i]; var q = out[out.length - 1]; if (q && Math.abs(p.x - q.x) <= eps && Math.abs(p.y - q.y) <= eps) continue; out.push(p); } if (out.length > 1) { var f = out[0], l = out[out.length - 1]; if (Math.abs(f.x - l.x) <= eps && Math.abs(f.y - l.y) <= eps) out.pop(); } return out; } /** * 由 8 个世界角点得到地面脚印信息。 * 返回 {hull, area, zmin, zmax, height, cx, cy, radius, minX, maxX, minY, maxY} */ function footprintFromCorners(corners) { if (!corners || corners.length < 8) return null; var pts = []; var zmin = Infinity, zmax = -Infinity; var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (var i = 0; i < corners.length; i++) { var c = corners[i]; pts.push({ x: c[0], y: c[1] }); if (c[2] < zmin) zmin = c[2]; if (c[2] > zmax) zmax = c[2]; if (c[0] < minX) minX = c[0]; if (c[0] > maxX) maxX = c[0]; if (c[1] < minY) minY = c[1]; if (c[1] > maxY) maxY = c[1]; } var hull = convexHull2D(pts); if (hull.length < 3) return null; var area = Math.abs(polyArea(hull)); if (!(area > 1e-4)) return null; var cx = 0, cy = 0; for (var k = 0; k < hull.length; k++) { cx += hull[k].x; cy += hull[k].y; } cx /= hull.length; cy /= hull.length; var radius = 0; for (var r = 0; r < hull.length; r++) { var d = Math.hypot(hull[r].x - cx, hull[r].y - cy); if (d > radius) radius = d; } return { hull: hull, area: area, zmin: zmin, zmax: zmax, height: Math.max(0, zmax - zmin), cx: cx, cy: cy, radius: radius, minX: minX, maxX: maxX, minY: minY, maxY: maxY }; } /** * 计算两个脚印的重叠指标。 * opts: {minZOverlap(默认 0.05), minArea(默认 1e-4)} * 返回 {poly, interArea, r2d, r3d, zOverlap} 或 null(不相交) * r2d = 交面积 / min(两框面积) —— 鸟瞰重叠率,主判据 * r3d = 交体积 / min(两框体积) —— 同时考虑高度,≈1 基本就是重复标注 */ function overlapMetrics(a, b, opts) { if (!a || !b) return null; opts = opts || {}; var minZ = opts.minZOverlap == null ? 0.05 : opts.minZOverlap; var minA = opts.minArea == null ? 1e-4 : opts.minArea; // 便宜预筛:中心距离 / 包围盒 var rad = a.radius + b.radius; if (Math.hypot(a.cx - b.cx, a.cy - b.cy) > rad) return null; if (a.maxX < b.minX || b.maxX < a.minX || a.maxY < b.minY || b.maxY < a.minY) return null; var zov = Math.min(a.zmax, b.zmax) - Math.max(a.zmin, b.zmin); if (zov <= minZ) return null; var poly = clipConvex(a.hull, b.hull); if (poly.length < 3) return null; var interArea = Math.abs(polyArea(poly)); if (interArea <= minA) return null; var smallArea = Math.min(a.area, b.area); var r2d = interArea / smallArea; var volA = a.area * Math.max(a.height, 1e-3); var volB = b.area * Math.max(b.height, 1e-3); var r3d = (interArea * zov) / Math.max(Math.min(volA, volB), 1e-9); return { poly: poly, interArea: interArea, r2d: Math.min(r2d, 1), r3d: Math.min(r3d, 1), zOverlap: zov }; } /** * 世界坐标点 → 画布像素坐标。 * cam: {matrixWorldInverse:{elements}, projectionMatrix:{elements}} */ function projectPoint(cam, p, width, height) { if (!cam || !cam.matrixWorldInverse || !cam.projectionMatrix) return null; var v = cam.matrixWorldInverse.elements; var pr = cam.projectionMatrix.elements; var x = p[0], y = p[1], z = p[2]; var vx = v[0] * x + v[4] * y + v[8] * z + v[12]; var vy = v[1] * x + v[5] * y + v[9] * z + v[13]; var vz = v[2] * x + v[6] * y + v[10] * z + v[14]; var cx = pr[0] * vx + pr[4] * vy + pr[8] * vz + pr[12]; var cy = pr[1] * vx + pr[5] * vy + pr[9] * vz + pr[13]; var cw = pr[3] * vx + pr[7] * vy + pr[11] * vz + pr[15]; if (Math.abs(cw) < 1e-9) return null; return { x: (cx / cw * 0.5 + 0.5) * width, y: (-cy / cw * 0.5 + 0.5) * height }; } /* @core:end */ /* ===================================================================== * 1. 配置 * ===================================================================== */ var STORE_KEY = 'pcqa.overlap.cfg.v1'; var state = { threshold: 0.25, // 鸟瞰重叠率阈值 crossClass: false, // 是否跨类别检查 ignoreVirtual: true, // 忽略预测框(visual_type = 2) drawOverlay: true, // 画布描红 scope: 'frame', // frame = 当前帧 / all = 整包全部帧 panelPos: null, // {left, top} collapsed: false }; try { var saved = JSON.parse(localStorage.getItem(STORE_KEY) || 'null'); if (saved && typeof saved === 'object') { ['threshold', 'crossClass', 'ignoreVirtual', 'drawOverlay', 'collapsed', 'scope'].forEach(function (k) { if (saved[k] !== undefined) state[k] = saved[k]; }); if (state.scope !== 'all') state.scope = 'frame'; if (saved.panelPos && typeof saved.panelPos.left === 'number') state.panelPos = saved.panelPos; } } catch (e) { /* localStorage 不可用就算了 */ } function saveState() { try { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } catch (e) {} } /* ===================================================================== * 2. 页面适配:找到 viewer / 读框 / 找画布 * ===================================================================== */ var app = { viewer: null, canvas: null }; function findViewer() { var v = window.viewer; if (v && v.volumeScene) return v; var inst = window.instance; if (inst) { try { if (inst._viewer && inst._viewer.volumeScene) return inst._viewer; } catch (e) {} } return null; } function findCanvas(viewer) { if (viewer && viewer.render && viewer.render.domElement) return viewer.render.domElement; return document.getElementById('canvas') || document.querySelector('#threejs canvas') || null; } /** * 平台的 StoreManager 门面。只用来「定位选中」和反查 classId, * 不会写任何标注数据。 */ function getSM() { try { var inst = window.instance; if (inst && inst.StoreManager) return inst.StoreManager; } catch (e) {} return null; } function classIdOf(trackId) { var sm = getSM(); if (!sm || !sm.marksGetter) return null; try { var f = sm.marksGetter('classId'); var v = f ? f(trackId) : null; return v === undefined ? null : v; } catch (e) { return null; } } function pairByKey(key) { for (var i = 0; i < pairs.length; i++) if (pairs[i].key === key) return pairs[i]; return null; } function colorOf(o) { try { if (o.color && typeof o.color.getStyle === 'function') { var s = o.color.getStyle(); if (s && s !== 'rgb(255, 255, 255)' && s !== 'rgb(255,255,255)') return s; } } catch (e) {} return '#ff5c8a'; } /** 从 volumeScene 读取当前帧所有 3D 框。 */ function readBoxes(viewer) { var boxes = []; var scene = viewer && viewer.volumeScene; if (!scene || !scene.children) return boxes; var list = scene.children; for (var i = 0; i < list.length; i++) { var o = list[i]; if (!o || !o.isObject3D) continue; var mark = o.mark || o._mark; if (!mark) continue; // 只处理 3D 立体框:line_3d / polygon_3d 没有有意义的 8 角点包络, // 硬算会得到一个虚假的小方块,造成误报。 if (typeof mark.type === 'string' && mark.type !== 'volume') continue; if (o.trackId === null || o.trackId === undefined) continue; if (o.visible === false) continue; if (!o.matrixWorld) continue; if (typeof o.updateWorldMatrix === 'function') { try { o.updateWorldMatrix(true, false); } catch (e) {} } var corners; try { corners = transformCorners(o.matrixWorld); } catch (e) { continue; } var fp = footprintFromCorners(corners); if (!fp) continue; boxes.push({ id: String(o.trackId), trackId: o.trackId, cid: classIdOf(o.trackId), cls: o.className || '未分类', color: colorOf(o), virtual: mark.visual_type === 2, diff: mark.visual_type === 1, object: o, corners: corners, fp: fp }); } return boxes; } /** 两两检查,返回按重叠率降序排列的冲突对。 */ function detect(boxes) { var pairs = []; var n = boxes.length; var maxN = 400; // 防御:异常大的场景不做 O(n²) if (n > maxN) n = maxN; for (var i = 0; i < n; i++) { var a = boxes[i]; if (state.ignoreVirtual && a.virtual) continue; for (var j = i + 1; j < n; j++) { var b = boxes[j]; if (state.ignoreVirtual && b.virtual) continue; if (!state.crossClass && a.cls !== b.cls) continue; var m = overlapMetrics(a.fp, b.fp); if (!m) continue; if (m.r2d < state.threshold) continue; pairs.push({ key: a.id + '|' + b.id, a: a, b: b, r2d: m.r2d, r3d: m.r3d, poly: m.poly, zOverlap: m.zOverlap }); } } pairs.sort(function (p, q) { return q.r2d - p.r2d || q.r3d - p.r3d; }); return pairs; } /* ===================================================================== * 2.5 整包(全部帧)扫描:直接读 marks,不切帧 * ===================================================================== */ var allConf = []; // [{fid, frameNo, key, a, b, r2d, r3d}] var allInfo = { tracks: 0, frames: 0, at: 0, ms: 0, err: '', order: [] }; function getStoreObj() { var sm = getSM(); if (!sm) return null; try { if (typeof sm.getStore === 'function') return sm.getStore(); } catch (e) {} try { if (sm.store) return sm.store; } catch (e) {} return null; } /** classId → 类别显示名(和平台标签一致,取 getClass 的 ptitle) */ function classTitle(classId) { var sm = getSM(); if (!sm || classId === null || classId === undefined) return ''; try { var c = sm.configGetter('getClass')(classId); if (c) return c.ptitle || c.name || c.title || ''; } catch (e) {} return ''; } /** 手写欧拉 XYZ(与 three.js Matrix4.makeRotationFromEuler 的 XYZ 分支同式),返回 {elements} */ function eulerMatrix(rx, ry, rz, sx, sy, sz, px, py, pz) { var a = Math.cos(rx), b = Math.sin(rx); var c = Math.cos(ry), d = Math.sin(ry); var e = Math.cos(rz), f = Math.sin(rz); var ae = a * e, af = a * f, be = b * e, bf = b * f; var te = new Array(16); te[0] = c * e; te[4] = -c * f; te[8] = d; te[1] = af + be * d; te[5] = ae - bf * d; te[9] = -b * c; te[2] = bf - ae * d; te[6] = be + af * d; te[10] = a * c; te[3] = 0; te[7] = 0; te[11] = 0; te[15] = 1; // 先缩放(BoxGeometry(1,1,1) 的 scale 即真实尺寸),再旋转,最后平移。 // 注意:scale 是「按轴」缩放,三列各乘自己的系数,别按分量乘。 te[0] *= sx; te[1] *= sx; te[2] *= sx; te[4] *= sy; te[5] *= sy; te[6] *= sy; te[8] *= sz; te[9] *= sz; te[10] *= sz; te[12] = px; te[13] = py; te[14] = pz; return { elements: te }; } function cornersFromMark(mark) { var p = mark.position || [0, 0, 0]; var r = mark.rotation || [0, 0, 0]; var s = mark.scale || [1, 1, 1]; var T = window.__THREE__; if (T && T.Matrix4 && T.Vector3 && T.Euler && T.Quaternion) { try { var m4 = new T.Matrix4().compose( new T.Vector3(+p[0] || 0, +p[1] || 0, +p[2] || 0), new T.Quaternion().setFromEuler(new T.Euler(+r[0] || 0, +r[1] || 0, +r[2] || 0)), new T.Vector3(+s[0] || 0.001, +s[1] || 0.001, +s[2] || 0.001) ); return transformCorners(m4); } catch (e) { /* 落回手写矩阵 */ } } return transformCorners(eulerMatrix(+r[0] || 0, +r[1] || 0, +r[2] || 0, +s[0] || 0.001, +s[1] || 0.001, +s[2] || 0.001, +p[0] || 0, +p[1] || 0, +p[2] || 0)); } function frameNo(fid, fmap, order) { var n = -1; if (fmap && fmap[fid] !== undefined && fmap[fid] !== null) n = Number(fmap[fid]); else n = order.indexOf(fid); return (isFinite(n) && n >= 0) ? n + 1 : null; } /** * 读整包 marks,逐帧两两算重叠。只读,不写任何标注数据。 * marks 结构:{ [trackId]: { '3d': { [taskId]: mark } } } */ function scanAllFrames(silent) { var started = Date.now(); allConf = []; allInfo = { tracks: 0, frames: 0, at: started, ms: 0, err: '', order: [] }; var sm = getSM(); var st = null; try { st = sm && typeof sm.marksState === 'function' ? sm.marksState() : null; } catch (e) {} var marks = st && st.marks; if (!marks) { allInfo.err = '读不到整包数据(可能未登录或平台结构变化)'; return allInfo; } var cfg = null; try { cfg = sm && typeof sm.configState === 'function' ? sm.configState() : null; } catch (e) {} var frames = (cfg && cfg.frames) || null; var fmap = (cfg && cfg.frameIds) || null; var byFrame = {}; var trackCount = 0; for (var tid in marks) { var track = marks[tid]; var d3 = track && track['3d']; if (!d3) continue; var cid = classIdOf(tid); var name = classTitle(cid) || (typeof track.class === 'string' && track.class) || '未分类'; var used = false; for (var fid in d3) { var mk = d3[fid]; if (!mk || typeof mk !== 'object') continue; if (typeof mk.type === 'string' && mk.type !== 'volume') continue; if (state.ignoreVirtual && mk.visual_type === 2) continue; var fp; try { fp = footprintFromCorners(cornersFromMark(mk)); } catch (e) { continue; } if (!fp) continue; if (!byFrame[fid]) byFrame[fid] = []; byFrame[fid].push({ id: String(tid), trackId: tid, cid: cid, cls: name, virtual: mk.visual_type === 2, fp: fp }); used = true; } if (used) trackCount++; } var order = []; if (frames && frames.length) { for (var i = 0; i < frames.length; i++) if (byFrame[frames[i]]) order.push(String(frames[i])); } for (var f2 in byFrame) if (order.indexOf(f2) < 0) order.push(f2); for (var fi = 0; fi < order.length; fi++) { var f = order[fi]; var arr = byFrame[f] || []; if (arr.length < 2) continue; var ps = detect(arr); for (var j = 0; j < ps.length; j++) { ps[j].fid = f; ps[j].frameNo = frameNo(f, fmap, order); ps[j].ekey = f + '|' + ps[j].key; allConf.push(ps[j]); } } allConf.sort(function (p, q) { return q.r2d - p.r2d || (p.frameNo || 0) - (q.frameNo || 0); }); allInfo.tracks = trackCount; allInfo.frames = order.length; allInfo.order = order; allInfo.ms = Date.now() - started; if (!silent && window.console) { console.info('[PCQA] 整包扫描:' + allInfo.frames + ' 帧 / ' + trackCount + ' 个对象 · ' + allConf.length + ' 处重叠 · ' + allInfo.ms + 'ms'); } return allInfo; } /** 按当前阈值过滤后的整包冲突(阈值是软的,不用重算) */ function allFiltered() { var out = []; for (var i = 0; i < allConf.length; i++) { if (allConf[i].r2d >= state.threshold) out.push(allConf[i]); } return out; } function allPairByKey(key) { for (var i = 0; i < allConf.length; i++) if (allConf[i].ekey === key) return allConf[i]; return null; } function syncScopeButtons() { var scbs = ui.panel.querySelectorAll('.scb'); var allN = state.scope === 'all' ? allFiltered().length : -1; for (var i = 0; i < scbs.length; i++) { var on = scbs[i].getAttribute('data-scope') === state.scope; scbs[i].setAttribute('data-on', on ? '1' : '0'); var num = scbs[i].querySelector('.n'); if (num) { if (scbs[i].getAttribute('data-scope') === 'frame') num.textContent = String(pairs.length); else num.textContent = allInfo.at ? String(allN) : '–'; } } var rf = ui.panel.querySelector('.refresh'); if (rf) rf.style.display = state.scope === 'all' ? '' : 'none'; } function setScope(v) { state.scope = v === 'all' ? 'all' : 'frame'; saveState(); if (state.scope === 'all') scanAllFrames(true); lastListSig = null; renderPanel(); } /** * 跳到某一帧并定位这条冲突:切帧 → 等平台渲染 → 选中平台框 + 标签闪 → 顺手弹一条横幅。 */ function jumpToFrame(fid, p) { if (!p) return; var store = getStoreObj(); var changed = false; try { if (store && typeof store.commit === 'function') { store.commit('status/setCurFrameId', /^\d+$/.test(fid) ? Number(fid) : fid); changed = true; } } catch (e) { if (window.console) console.warn('[PCQA] 切帧失败:', e); } if (!changed && window.console) console.warn('[PCQA] 找不到 store,无法自动切帧'); var tries = 0; var timer = setInterval(function () { tries++; var ready = false; try { var list = app.viewer && app.viewer.volumeScene ? app.viewer.volumeScene.children : null; if (list) { for (var i = 0; i < list.length; i++) { if (String(list[i].trackId) === String(p.a.trackId)) { ready = true; break; } } } } catch (e) {} if (!ready && tries <= 25) return; clearInterval(timer); scan(); var fresh = null; for (var j = 0; j < pairs.length; j++) { if ((pairs[j].a.id === p.a.id && pairs[j].b.id === p.b.id) || (pairs[j].a.id === p.b.id && pairs[j].b.id === p.a.id)) { fresh = pairs[j]; break; } } if (fresh) { locatePair(fresh); pulseUntil.set(fresh.key, Date.now() + 9000); } else { locatePair(p); } // 全部帧列表里的行高亮:用整包 key(fid|a|b),否则行不会亮 focusKey = p.ekey; focusUntil = performance.now() + 8000; renderRowsFocus(); // 用户要求:跳过去之后自动弹横幅提醒这一帧的冲突 notifyFresh([p]); }, 120); } /* ===================================================================== * 3. UI:面板(Shadow DOM)+ 画布高亮 + 提醒 * ===================================================================== */ var CSS = [ ':host{all:initial;}', '*{box-sizing:border-box;}', '.root{position:fixed;inset:0;pointer-events:none;z-index:2147483000;', ' font:12px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",system-ui,sans-serif;', ' color:#e7eaf0;-webkit-font-smoothing:antialiased;}', '.panel{position:absolute;width:312px;max-width:calc(100vw - 24px);', ' background:rgba(17,19,24,.95);border:1px solid rgba(255,255,255,.10);border-radius:12px;', ' box-shadow:0 12px 34px rgba(0,0,0,.45),0 2px 8px rgba(0,0,0,.35);overflow:hidden;', ' pointer-events:auto;backdrop-filter:blur(8px);}', '.hd{display:flex;align-items:center;gap:7px;padding:8px 10px;cursor:move;user-select:none;', ' background:linear-gradient(90deg,rgba(255,60,96,.20),rgba(255,60,96,.02) 55%,transparent);', ' border-bottom:1px solid rgba(255,255,255,.07);}', '.led{width:7px;height:7px;border-radius:50%;background:#38d18a;box-shadow:0 0 8px rgba(56,209,138,.8);flex:none;}', '.panel[data-st="warn"] .led{background:#ff3b5c;box-shadow:0 0 8px rgba(255,59,92,.9);animation:blink 1.4s infinite;}', '@keyframes blink{50%{opacity:.35;}}', '.title{font-weight:600;font-size:12.5px;letter-spacing:.2px;white-space:nowrap;}', '.count{min-width:20px;height:18px;padding:0 6px;border-radius:9px;background:rgba(56,209,138,.18);', ' color:#6ee7b7;font-weight:700;font-size:11px;display:inline-flex;align-items:center;justify-content:center;}', '.panel[data-st="warn"] .count{background:rgba(255,59,92,.22);color:#ff8ba0;}', '.grow{flex:1;}', '.btn{border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.05);color:#cfd6e2;', ' border-radius:6px;padding:2px 7px;font-size:11px;cursor:pointer;font-family:inherit;}', '.btn:hover{background:rgba(255,255,255,.12);color:#fff;}', '.body{padding:8px 10px 10px;}', '.stats{color:#8b95a7;font-size:11px;margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}', '.strow{display:flex;align-items:center;gap:6px;margin-bottom:6px;}', '.strow .stats{margin-bottom:0;flex:1;min-width:0;}', '.strow .chk{flex:none;font-size:10.5px;padding:1px 7px;line-height:16px;}', '.scope{display:flex;align-items:center;gap:4px;margin-bottom:7px;}', '.scb{flex:1;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);color:#aab3c2;', ' border-radius:6px;padding:3px 4px;font-size:11px;cursor:pointer;font-family:inherit;white-space:nowrap;}', '.scb:hover{background:rgba(255,255,255,.10);color:#fff;}', '.scb[data-on="1"]{border-color:rgba(255,59,92,.7);background:rgba(255,59,92,.16);color:#ffd7de;font-weight:600;}', '.scb .n{opacity:.75;font-weight:400;}', '.refresh{flex:none;font-size:10.5px;padding:2px 7px;line-height:15px;}', '.fhd{display:flex;align-items:center;gap:6px;margin:7px 2px 3px;color:#9aa4b4;font-size:10.5px;}', '.fhd .fl{color:#cfd6e2;font-weight:600;}', '.fhd .fc{margin-left:auto;color:#ff8ba0;}', '.fhd .bar{flex:1;height:1px;background:rgba(255,255,255,.08);}', '.list{max-height:224px;overflow:auto;margin:0 -4px 6px;padding:0 4px;scrollbar-width:thin;}', '.list::-webkit-scrollbar{width:6px;}', '.list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.16);border-radius:3px;}', '.row{display:flex;align-items:center;gap:6px;padding:5px 6px;border-radius:7px;cursor:pointer;', ' border:1px solid transparent;transition:background .12s,border-color .12s;}', '.row:hover{background:rgba(255,255,255,.07);}', '.row[data-sev="hi"]{border-left:3px solid #ff3b5c;}', '.row[data-sev="mid"]{border-left:3px solid #ff8c3c;}', '.row[data-sev="lo"]{border-left:3px solid #ffc43c;}', '.row .act{flex:none;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.06);', ' color:#c9d2e3;border-radius:5px;padding:0 6px;font-size:10px;line-height:16px;cursor:pointer;font-family:inherit;', ' opacity:0;transition:opacity .12s;}', '.row:hover .act{opacity:1;}', '.row .act:hover{background:rgba(255,255,255,.18);color:#fff;}', '.row[data-f="1"]{background:rgba(255,59,92,.14);border-color:rgba(255,59,92,.55);}', '.dot{width:6px;height:6px;border-radius:2px;flex:none;}', '.names{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#dfe4ee;}', '.names i{color:#6b7484;font-style:normal;margin:0 2px;}', '.pct{font-weight:700;color:#ff8ba0;font-variant-numeric:tabular-nums;flex:none;}', '.tag{font-size:10px;padding:0 4px;border-radius:4px;background:rgba(255,180,0,.16);color:#ffc44d;flex:none;}', '.empty{color:#6b7484;padding:10px 6px;text-align:center;}', '.cfg{border-top:1px solid rgba(255,255,255,.07);padding-top:8px;display:flex;flex-direction:column;gap:7px;}', '.cfg1{display:flex;align-items:center;gap:8px;}', '.cfg1 span{color:#8b95a7;font-size:11px;white-space:nowrap;}', 'input[type=range]{-webkit-appearance:none;appearance:none;flex:1;height:3px;border-radius:2px;', ' background:linear-gradient(90deg,#ff3b5c var(--p,30%),rgba(255,255,255,.14) var(--p,30%));outline:none;}', 'input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;', ' background:#fff;border:2px solid #ff3b5c;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,.5);}', '.thv{color:#ff8ba0;font-weight:700;font-variant-numeric:tabular-nums;min-width:34px;text-align:right;}', '.cfg2{display:flex;flex-wrap:wrap;gap:4px 12px;}', '.cfg2 label{display:inline-flex;align-items:center;gap:4px;color:#98a2b3;font-size:11px;cursor:pointer;user-select:none;}', '.cfg2 input{accent-color:#ff3b5c;width:12px;height:12px;margin:0;cursor:pointer;}', '.hint{margin-top:7px;color:#5f6879;font-size:10.5px;line-height:1.45;}', '.toasts{position:absolute;left:50%;top:16px;transform:translateX(-50%);display:flex;flex-direction:column;gap:8px;', ' align-items:center;pointer-events:none;z-index:6;}', '.toast{pointer-events:auto;display:flex;align-items:flex-start;gap:10px;max-width:min(680px,92vw);', ' padding:11px 13px;border-radius:12px;color:#fff2f4;font-size:12.5px;line-height:1.5;', ' background:linear-gradient(155deg,rgba(58,12,20,.98),rgba(26,8,12,.98));border:1px solid rgba(255,59,92,.8);', ' box-shadow:0 14px 40px rgba(0,0,0,.6),0 0 26px rgba(255,59,92,.2);animation:pop .2s ease-out;}', '.toast[data-sev="mid"]{border-color:rgba(255,140,60,.82);background:linear-gradient(155deg,rgba(58,28,8,.98),rgba(26,14,6,.98));', ' box-shadow:0 14px 40px rgba(0,0,0,.6),0 0 26px rgba(255,140,60,.18);}', '.toast[data-sev="lo"]{border-color:rgba(255,196,60,.72);background:linear-gradient(155deg,rgba(48,38,8,.98),rgba(24,18,6,.98));', ' box-shadow:0 14px 40px rgba(0,0,0,.6),0 0 26px rgba(255,196,60,.16);}', '.toast.out{opacity:0;transform:translateY(-8px);transition:opacity .45s,transform .45s;}', '.toast .ic{width:20px;height:20px;border-radius:6px;background:#ff3b5c;color:#fff;display:flex;', ' align-items:center;justify-content:center;font-weight:800;font-size:12px;flex:none;margin-top:1px;}', '.toast[data-sev="mid"] .ic{background:#ff8c3c;}', '.toast[data-sev="lo"] .ic{background:#ffc43c;color:#3a2a00;}', '.toast .body{flex:1;min-width:0;}', '.toast .tt{font-weight:700;color:#fff;font-size:12.5px;}', '.toast .mm{margin-top:2px;color:#ffdfe5;font-size:12px;}', '.toast .sm{margin-top:3px;color:rgba(255,226,232,.72);font-size:11px;}', '.toast b{color:#fff;}', '.toast .acts{display:flex;flex-direction:column;gap:5px;flex:none;margin-left:4px;}', '.toast .go{border:1px solid rgba(255,255,255,.24);background:rgba(255,255,255,.08);', ' color:#fff;border-radius:6px;padding:2px 9px;font-size:11px;cursor:pointer;font-family:inherit;white-space:nowrap;}', '.toast .go:hover{background:rgba(255,255,255,.2);}', '@keyframes pop{from{transform:translateY(-8px) scale(.98);}}' ].join('\n'); var ui = {}; var pairs = []; var boxes = []; var lastScanAt = 0; var lastError = null; function buildUI() { var host = document.createElement('div'); host.id = 'pcqa-host'; var sh = host.attachShadow({ mode: 'open' }); var style = document.createElement('style'); style.textContent = CSS; sh.appendChild(style); var root = document.createElement('div'); root.className = 'root'; sh.appendChild(root); var panel = document.createElement('div'); panel.className = 'panel'; panel.setAttribute('data-st', 'ok'); panel.innerHTML = [ '
', ' ', ' 重叠标注提醒', ' 0', ' ', ' ', '
', '
', '
', ' ', ' ', ' ', '
', '
初始化…
', '
', '
', '
', '
重叠阈值
', '
', ' ', ' ', ' ', '
', '
', '
点击条目:选中平台框 + 标签闪烁 · 仅在本地只读计算,不会改动标注
', '
' ].join(''); root.appendChild(panel); var toasts = document.createElement('div'); toasts.className = 'toasts'; root.appendChild(toasts); document.body.appendChild(host); ui.host = host; ui.shadow = sh; ui.root = root; ui.panel = panel; ui.hd = panel.querySelector('.hd'); ui.count = panel.querySelector('.count'); ui.collapseBtn = panel.querySelector('.collapse'); ui.stats = panel.querySelector('.stats'); ui.chk = panel.querySelector('.chk'); ui.list = panel.querySelector('.list'); ui.thr = panel.querySelector('input[type=range]'); ui.thv = panel.querySelector('.thv'); ui.toasts = toasts; // 初始位置 placePanel(); if (state.collapsed) setCollapsed(true); syncControls(); // ---- 交互 ---- ui.collapseBtn.addEventListener('click', function (ev) { ev.stopPropagation(); setCollapsed(!state.collapsed); }); ui.thr.addEventListener('input', function () { state.threshold = clamp(parseInt(ui.thr.value, 10), 5, 80) / 100; syncControls(); saveState(); markBaseline(); scan(); }); var boxes_ = panel.querySelectorAll('.cfg2 input'); for (var i = 0; i < boxes_.length; i++) { boxes_[i].addEventListener('change', function (ev) { var k = ev.target.getAttribute('data-k'); state[k] = !!ev.target.checked; saveState(); markBaseline(); scan(); }); } ui.chk.addEventListener('click', function (ev) { ev.stopPropagation(); checkNow(); }); ui.list.addEventListener('click', function (ev) { var row = ev.target.closest ? ev.target.closest('.row') : null; if (!row) return; var p = pairByKey(row.getAttribute('data-key')); if (state.scope === 'all') { var ap = allPairByKey(row.getAttribute('data-key')); if (ap) { jumpToFrame(ap.fid, ap); return; } } if (p) locatePair(p); }); // ---- 视角切换:当前帧 / 全部帧 ---- var scbs = panel.querySelectorAll('.scb'); for (var si = 0; si < scbs.length; si++) { scbs[si].addEventListener('click', function (ev) { var v = ev.currentTarget.getAttribute('data-scope'); setScope(v); }); } panel.querySelector('.refresh').addEventListener('click', function (ev) { ev.stopPropagation(); scanAllFrames(true); lastListSig = null; renderPanel(); }); syncScopeButtons(); makeDraggable(panel, ui.hd); // 面板内的鼠标 / 滚轮 / 键盘事件不再冒泡到页面, // 避免点个开关顺带触发标注工具的拖拽或快捷键。 // 注意必须用冒泡阶段(不能 stopPropagation 在 host 捕获阶段,那会把自己的按钮也挡掉)。 ['mousedown', 'mouseup', 'click', 'dblclick', 'keydown', 'wheel'].forEach(function (t) { panel.addEventListener(t, function (ev) { ev.stopPropagation(); }, t === 'wheel' ? { passive: true } : undefined); }); } function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); } function placePanel() { var p = ui.panel; var w = 312, h = 220; var left, top; if (state.panelPos) { left = state.panelPos.left; top = state.panelPos.top; } else { left = 88; top = Math.max(76, Math.round(window.innerHeight * 0.12)); } left = clamp(left, 8, Math.max(8, window.innerWidth - w - 8)); top = clamp(top, 8, Math.max(8, window.innerHeight - 80)); p.style.left = left + 'px'; p.style.top = top + 'px'; p.style.right = 'auto'; if (state.panelPos && (left !== state.panelPos.left || top !== state.panelPos.top)) { state.panelPos = { left: left, top: top }; saveState(); } } function makeDraggable(panel, handle) { var drag = null; handle.addEventListener('mousedown', function (ev) { if (ev.button !== 0) return; if (ev.target.closest && ev.target.closest('button')) return; var r = panel.getBoundingClientRect(); drag = { dx: ev.clientX - r.left, dy: ev.clientY - r.top }; ev.preventDefault(); window.addEventListener('mousemove', onMove, true); window.addEventListener('mouseup', onUp, true); }); function onMove(ev) { if (!drag) return; var left = clamp(ev.clientX - drag.dx, 4, window.innerWidth - 80); var top = clamp(ev.clientY - drag.dy, 4, window.innerHeight - 40); panel.style.left = left + 'px'; panel.style.top = top + 'px'; ev.preventDefault(); } function onUp() { if (!drag) return; drag = null; window.removeEventListener('mousemove', onMove, true); window.removeEventListener('mouseup', onUp, true); var r = panel.getBoundingClientRect(); state.panelPos = { left: Math.round(r.left), top: Math.round(r.top) }; saveState(); } } function setCollapsed(v) { state.collapsed = !!v; ui.panel.querySelector('.body').style.display = v ? 'none' : ''; ui.collapseBtn.textContent = v ? '展开' : '收起'; saveState(); } function syncControls() { ui.thr.value = String(Math.round(state.threshold * 100)); ui.thr.style.setProperty('--p', Math.round(state.threshold * 100) + '%'); ui.thv.textContent = Math.round(state.threshold * 100) + '%'; var els = ui.panel.querySelectorAll('.cfg2 input'); for (var i = 0; i < els.length; i++) { var k = els[i].getAttribute('data-k'); els[i].checked = !!state[k]; } } /* ---------------- 面板内容 ---------------- */ var lastListSig = null; function renderPanel() { var allList = state.scope === 'all' ? allFiltered() : []; var n = state.scope === 'all' ? allList.length : pairs.length; ui.panel.setAttribute('data-st', n ? 'warn' : 'ok'); ui.count.textContent = String(n); var t = new Date(lastScanAt); var ts = pad(t.getHours()) + ':' + pad(t.getMinutes()) + ':' + pad(t.getSeconds()); var who = app.viewer ? '' : ' · 未找到标注视图'; if (state.scope === 'all') { if (allInfo.err) { ui.stats.textContent = '全部帧不可用:' + allInfo.err; } else { var framesHit = {}; for (var zi = 0; zi < allList.length; zi++) framesHit[allList[zi].fid] = 1; ui.stats.textContent = '整包 ' + allInfo.frames + ' 帧 · ' + allInfo.tracks + ' 个对象 · ' + (n ? n + ' 处重叠(' + Object.keys(framesHit).length + ' 帧)' : '未发现重叠') + ' · ' + allInfo.ms + 'ms'; } } else { ui.stats.textContent = '已检查 ' + boxes.length + ' 个 3D 框 · ' + (n ? n + ' 处疑似重叠' : '未发现重叠') + ' · ' + ts + who; } // 签名里带上「有没有 viewer」和视角模式,否则从"等待加载"变成"没有重叠"时列表不会刷新 var sig = state.scope + '|' + (app.viewer ? 'v' : 'n') + '|' + (state.scope === 'all' ? allList.map(function (p) { return p.fid + ':' + p.key; }).join(',') : pairs.map(function (p) { return p.key; }).join(',')); if (sig === lastListSig) { syncScopeButtons(); return; } lastListSig = sig; ui.list.innerHTML = ''; if (state.scope === 'all') { renderAllList(allList); syncScopeButtons(); return; } if (!n) { var e = document.createElement('div'); e.className = 'empty'; e.textContent = app.viewer ? '✓ 当前画面没有明显重叠的标注框' : '等待标注视图加载…'; ui.list.appendChild(e); return; } var max = Math.min(pairs.length, 40); for (var i = 0; i < max; i++) { var p = pairs[i]; var row = document.createElement('div'); row.className = 'row'; row.setAttribute('data-key', p.key); row.setAttribute('data-sev', sevOf(p.r2d)); row.title = p.a.cls + '-' + p.a.id + ' 与 ' + p.b.cls + '-' + p.b.id + '\n鸟瞰重叠 ' + pct(p.r2d) + ' · 体积重叠 ' + pct(p.r3d) + '\n点击定位:选中平台框并闪烁标签 8 秒'; var dot = document.createElement('span'); dot.className = 'dot'; dot.style.background = p.a.color || '#ff5c8a'; var names = document.createElement('span'); names.className = 'names'; names.innerHTML = ''; names.appendChild(document.createTextNode(nameOf(p.a))); var arrow = document.createElement('i'); arrow.textContent = '↔'; names.appendChild(arrow); names.appendChild(document.createTextNode(nameOf(p.b))); var pc = document.createElement('span'); pc.className = 'pct'; pc.textContent = pct(p.r2d); row.appendChild(dot); row.appendChild(names); if (p.r3d >= 0.5) { var tag = document.createElement('span'); tag.className = 'tag'; tag.textContent = '疑似重复'; row.appendChild(tag); } row.appendChild(pc); var act = document.createElement('button'); act.className = 'act'; act.type = 'button'; act.textContent = '定位'; act.addEventListener('click', function (ev) { ev.stopPropagation(); var box = ev.target.closest ? ev.target.closest('.row') : null; var pair = box ? pairByKey(box.getAttribute('data-key')) : null; if (pair) locatePair(pair); }); row.appendChild(act); ui.list.appendChild(row); } } /* ---------------- 全部帧列表(按帧分组) ---------------- */ function renderAllList(list) { if (allInfo.err) { var e0 = document.createElement('div'); e0.className = 'empty'; e0.textContent = allInfo.err; ui.list.appendChild(e0); return; } if (!allInfo.at) { var e1 = document.createElement('div'); e1.className = 'empty'; e1.textContent = '点「刷新」读取整包数据'; ui.list.appendChild(e1); return; } if (!list.length) { var e2 = document.createElement('div'); e2.className = 'empty'; e2.textContent = '整包里没发现重叠(阈值 ' + pct(state.threshold) + ')'; ui.list.appendChild(e2); return; } var groups = {}, order = []; for (var i = 0; i < list.length; i++) { var p = list[i]; if (!groups[p.fid]) { groups[p.fid] = []; order.push(p.fid); } groups[p.fid].push(p); } // 按帧号排序(拿不到帧号就保持 frames 顺序) order.sort(function (x, y) { var a = groups[x][0].frameNo, b = groups[y][0].frameNo; if (a === null || a === undefined) return 1; if (b === null || b === undefined) return -1; return a - b; }); var shown = 0, MAX = 80; for (var g = 0; g < order.length && shown < MAX; g++) { var fid = order[g]; var rows = groups[fid]; var hd = document.createElement('div'); hd.className = 'fhd'; var fl = document.createElement('span'); fl.className = 'fl'; fl.textContent = '帧 ' + (rows[0].frameNo || '?') + (allInfo.frames ? '/' + allInfo.frames : ''); var bar = document.createElement('span'); bar.className = 'bar'; var fc = document.createElement('span'); fc.className = 'fc'; fc.textContent = rows.length + ' 处'; hd.appendChild(fl); hd.appendChild(bar); hd.appendChild(fc); hd.addEventListener('click', function () { /* 帧头不跳转 */ }); ui.list.appendChild(hd); for (var j = 0; j < rows.length && shown < MAX; j++, shown++) { ui.list.appendChild(buildAllRow(rows[j])); } } var total = list.length; if (total > shown) { var more = document.createElement('div'); more.className = 'empty'; more.textContent = '还有 ' + (total - shown) + ' 处未显示(共 ' + total + ' 处),可调高阈值后重新刷新'; ui.list.appendChild(more); } } function buildAllRow(p) { var row = document.createElement('div'); row.className = 'row'; row.setAttribute('role', 'button'); row.setAttribute('tabindex', '0'); row.setAttribute('data-key', p.ekey); row.setAttribute('data-fid', p.fid); row.setAttribute('data-sev', sevOf(p.r2d)); row.title = '点击:跳到该帧并选中这两个标注框'; var dot = document.createElement('span'); dot.className = 'dot'; dot.style.background = p.a.color || '#ff5c8a'; var names = document.createElement('span'); names.className = 'names'; names.appendChild(document.createTextNode(nameOf(p.a))); var arrow = document.createElement('i'); arrow.textContent = '↔'; names.appendChild(arrow); names.appendChild(document.createTextNode(nameOf(p.b))); var pc = document.createElement('span'); pc.className = 'pct'; pc.textContent = pct(p.r2d); row.appendChild(dot); row.appendChild(names); if (p.r3d >= 0.5) { var tag = document.createElement('span'); tag.className = 'tag'; tag.textContent = '疑似重复'; row.appendChild(tag); } row.appendChild(pc); var act = document.createElement('button'); act.className = 'act'; act.type = 'button'; act.textContent = '跳转'; act.addEventListener('click', function (ev) { ev.stopPropagation(); jumpToFrame(p.fid, p); }); row.appendChild(act); return row; } function nameOf(b) { return (b.cls || '未分类') + '-' + b.id; } function pct(v) { return Math.round(v * 100) + '%'; } function pad(n) { return n < 10 ? '0' + n : String(n); } /* ---------------- 提醒 ---------------- */ function focusPair(key, ms) { focusKey = key; focusUntil = performance.now() + (ms || 8000); renderRowsFocus(); } /** 点一条冲突:画布脉冲 + 真选中平台框 + 两个标签闪一下。 */ function locatePair(p) { if (!p) return; focusPair(p.key, 8000); var sm = getSM(); if (sm && typeof sm.selectMarkInfo === 'function') { try { sm.selectMarkInfo(String(p.a.trackId), p.a.cid, '3d', null, false); } catch (e) { if (window.console) console.warn('[PCQA] 选中平台框失败,已降级为只高亮:', e); } } flashLabel(p.a.trackId); flashLabel(p.b.trackId); } /** 让平台上对应的 CSS2D 标签闪 4 秒。只改滤镜/底色,绝不动 transform。 */ var labelTimers = {}; function flashLabel(trackId) { ensureDocStyle(); var id = String(trackId); var nodes = document.querySelectorAll('.threejs-label'); for (var i = 0; i < nodes.length; i++) { var first = (nodes[i].textContent || '').split('\n')[0].trim(); var m = /-(\d+)\s*(?:\(.*\))?$/.exec(first); if (!m || m[1] !== id) continue; (function (node) { node.classList.add('pcqa-lblhit'); if (labelTimers[id]) clearTimeout(labelTimers[id]); labelTimers[id] = setTimeout(function () { node.classList.remove('pcqa-lblhit'); delete labelTimers[id]; }, 4000); })(nodes[i]); } } function ensureDocStyle() { if (document.getElementById('pcqa-doc-style')) return; var st = document.createElement('style'); st.id = 'pcqa-doc-style'; st.textContent = // 静态高亮 + 呼吸动画:动画被节流/冻结时(无头、后台标签)静态样式照样可见 '.threejs-label.pcqa-lblhit{filter:drop-shadow(0 0 7px #ff3054) brightness(1.45);' + 'background:rgba(255,48,84,.26);border-radius:4px;animation:pcqa-lblflash .8s ease-in-out 5;}' + '@keyframes pcqa-lblflash{0%,100%{filter:drop-shadow(0 0 7px #ff3054) brightness(1.45);background:rgba(255,48,84,.26);}' + '50%{filter:drop-shadow(0 0 13px #ff3054) brightness(1.85);background:rgba(255,48,84,.46);}}'; (document.head || document.documentElement).appendChild(st); } /** 面板「立即检查」:清掉已知状态,让当前冲突重新走一次确认流程。 */ function checkNow() { announced.clear(); candidate.clear(); firstScan = false; scan(); if (ui.stats) ui.stats.textContent = '正在重新检查…'; } function renderRowsFocus() { var rows = ui.list.querySelectorAll('.row'); for (var i = 0; i < rows.length; i++) { rows[i].setAttribute('data-f', rows[i].getAttribute('data-key') === focusKey ? '1' : '0'); } } function sevOf(r) { // 先按界面显示的两位百分比取整,避免 49.996% 显示成 50% 却是中危 var v = Math.round(r * 100) / 100; return v >= 0.5 ? 'hi' : (v >= 0.35 ? 'mid' : 'lo'); } /** * 把这一次扫描新确认的冲突合并成一条横幅(借尺寸校验的提醒做法): * 深色卡片 + 严重度配色 + 4.2s 自动收起(高危/多条 8s)+ 悬停暂停。 */ function notifyFresh(fresh) { if (!fresh || !fresh.length) return; var top = fresh[0]; var sev = sevOf(top.r2d); var many = fresh.length > 1; var life = (many || sev === 'hi') ? 8000 : 4200; // 同一时刻只留一条,避免刷屏 while (ui.toasts.firstChild) ui.toasts.removeChild(ui.toasts.firstChild); var el = document.createElement('div'); el.className = 'toast'; el.setAttribute('data-sev', sev); var ic = document.createElement('span'); ic.className = 'ic'; ic.textContent = sev === 'hi' ? '!' : 'i'; var body = document.createElement('div'); body.className = 'body'; var tt = document.createElement('div'); tt.className = 'tt'; tt.textContent = many ? '检出 ' + fresh.length + ' 处标注重叠,最高 ' + pct(top.r2d) : '检出标注重叠:鸟瞰 ' + pct(top.r2d); var mm = document.createElement('div'); mm.className = 'mm'; var lines = []; for (var i = 0; i < fresh.length && i < 2; i++) { lines.push(esc(nameOf(fresh[i].a)) + ' ↔ ' + esc(nameOf(fresh[i].b)) + ' · ' + pct(fresh[i].r2d) + (fresh[i].r3d >= 0.5 ? ' · 疑似重复' : '')); } mm.innerHTML = lines.join('
') + (fresh.length > 2 ? '
还有 ' + (fresh.length - 2) + ' 处…' : ''); var sm = document.createElement('div'); sm.className = 'sm'; sm.textContent = sev === 'hi' ? '重叠很严重,建议核对是否重复标注' : '点「定位」可直接跳到对应标注框'; body.appendChild(tt); body.appendChild(mm); body.appendChild(sm); var acts = document.createElement('div'); acts.className = 'acts'; var go = document.createElement('button'); go.className = 'go'; go.type = 'button'; go.textContent = '定位'; go.addEventListener('click', function (ev) { ev.stopPropagation(); locatePair(top); }); var openBtn = document.createElement('button'); openBtn.className = 'go'; openBtn.type = 'button'; openBtn.textContent = '面板'; openBtn.addEventListener('click', function (ev) { ev.stopPropagation(); setCollapsed(false); focusPair(top.key, 8000); }); acts.appendChild(go); acts.appendChild(openBtn); el.appendChild(ic); el.appendChild(body); el.appendChild(acts); ui.toasts.appendChild(el); var timers = { hide: 0, kill: 0 }; function schedule(ms) { clearTimeout(timers.hide); clearTimeout(timers.kill); timers.hide = setTimeout(function () { el.classList.add('out'); }, ms); timers.kill = setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, ms + 500); } schedule(life); el.addEventListener('mouseenter', function () { clearTimeout(timers.hide); clearTimeout(timers.kill); el.classList.remove('out'); }); el.addEventListener('mouseleave', function () { schedule(2500); }); return el; } function esc(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } /* ===================================================================== * 4. 扫描循环 * ===================================================================== */ var known = new Set(); // 上一次扫描仍存在的组合 var candidate = new Map(); // 首次出现时间 var announced = new Set(); // 已提醒过且仍存在的组合 var pulseUntil = new Map(); // key -> 结束时间 var firstScan = true; var focusKey = null; var focusUntil = 0; /** * 把「当前检出的冲突」直接记为已知道,不弹提醒。 * 调阈值 / 切开关时会临时过滤掉一些冲突,重新放宽后它们会再出现; * 那是用户自己调设置的结果,不该连弹一堆提醒。 */ function markBaseline() { firstScan = true; } function scan() { lastScanAt = Date.now(); try { var v = findViewer(); app.viewer = v; app.canvas = v ? findCanvas(v) : null; if (!v) { boxes = []; pairs = []; renderPanel(); return; } boxes = readBoxes(v); pairs = detect(boxes); // ---- 新冲突确认(连续两次扫描都出现才算,避免拖拽途中误报) ---- var cur = new Set(); var now = lastScanAt; var fresh = []; for (var i = 0; i < pairs.length; i++) { var k = pairs[i].key; cur.add(k); if (announced.has(k)) continue; if (!candidate.has(k)) { candidate.set(k, now); } else if (now - candidate.get(k) >= 550) { announced.add(k); candidate.delete(k); fresh.push(pairs[i]); } } for (var k2 of announced) if (!cur.has(k2)) announced.delete(k2); for (var k3 of candidate.keys()) if (!cur.has(k3)) candidate.delete(k3); for (var k4 of known) if (!cur.has(k4)) { pulseUntil.delete(k4); if (focusKey === k4) focusKey = null; } if (firstScan) { // 首次加载不弹提醒,避免一进来就刷屏;把已有冲突视为已知 for (var k5 of cur) announced.add(k5); firstScan = false; } else { if (fresh.length && state.scope !== 'all') { notifyFresh(fresh); for (var fi = 0; fi < fresh.length && fi < 6; fi++) { pulseUntil.set(fresh[fi].key, now + 9000); } focusKey = null; } } known = cur; lastError = null; } catch (err) { lastError = err; if (window.console) console.warn('[PCQA] 扫描失败:', err); if (ui.stats) ui.stats.textContent = '扫描出错:' + (err && err.message ? err.message : err); } renderPanel(); syncScopeButtons(); renderRowsFocus(); } /* ===================================================================== * 5. 画布高亮 * ===================================================================== */ var overlay = null; var overlayCtx = null; var overlayRectKey = ''; function ensureOverlay() { if (overlay) return; overlay = document.createElement('canvas'); overlay.id = 'pcqa-overlay'; overlay.style.cssText = 'position:fixed;pointer-events:none;z-index:2147482000;display:none;' + 'left:0;top:0;width:0;height:0;'; document.body.appendChild(overlay); overlayCtx = overlay.getContext('2d'); } function removeOverlay() { if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); overlay = null; overlayCtx = null; overlayRectKey = ''; } function syncOverlayBox() { var canvasEl = app.canvas; if (!canvasEl || !canvasEl.getBoundingClientRect) return null; var r = canvasEl.getBoundingClientRect(); if (r.width < 20 || r.height < 20) return null; var key = [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)].join(','); if (key !== overlayRectKey) { overlayRectKey = key; var dpr = Math.min(window.devicePixelRatio || 1, 2); overlay.width = Math.round(r.width * dpr); overlay.height = Math.round(r.height * dpr); overlay.style.left = r.left + 'px'; overlay.style.top = r.top + 'px'; overlay.style.width = r.width + 'px'; overlay.style.height = r.height + 'px'; overlay._dpr = dpr; } return { rect: r, dpr: overlay._dpr || 1 }; } function clearOverlay() { if (!overlay) return; if (overlay.style.display !== 'none') overlay.style.display = 'none'; } function drawPoly(ctx, cam, pts3, W, H) { ctx.beginPath(); var ok = true; for (var i = 0; i < pts3.length; i++) { var s = projectPoint(cam, pts3[i], W, H); if (!s) { ok = false; break; } if (i === 0) ctx.moveTo(s.x, s.y); else ctx.lineTo(s.x, s.y); } if (ok) ctx.closePath(); return ok; } function frame(now) { requestAnimationFrame(frame); if (!state.drawOverlay) { clearOverlay(); return; } if (!pairs.length || !app.viewer || !app.canvas) { clearOverlay(); return; } ensureOverlay(); var box = syncOverlayBox(); if (!box) { clearOverlay(); return; } var ctx = overlayCtx; var W = box.rect.width, H = box.rect.height; if (overlay.style.display !== 'block') overlay.style.display = 'block'; ctx.setTransform(box.dpr, 0, 0, box.dpr, 0, 0); ctx.clearRect(0, 0, W, H); var cam = app.viewer.camera; if (!cam) return; var maxDraw = Math.min(pairs.length, 24); for (var i = 0; i < maxDraw; i++) { var p = pairs[i]; var focused = (p.key === focusKey && now < focusUntil) || now < (pulseUntil.get(p.key) || 0); var inter = p.poly.map(function (q) { return [q.x, q.y, 0]; }); // 相交区域挪到两个框重叠段的中部,斜视图下更贴合 var za = Math.max(p.a.fp.zmin, p.b.fp.zmin); var zb = Math.min(p.a.fp.zmax, p.b.fp.zmax); var z = (za + zb) / 2; for (var q = 0; q < inter.length; q++) inter[q][2] = z; // 两个框的地面轮廓 if (focused || pairs.length <= 8) { var hullA = p.a.fp.hull.map(function (h) { return [h.x, h.y, p.a.fp.zmin + 0.03]; }); var hullB = p.b.fp.hull.map(function (h) { return [h.x, h.y, p.b.fp.zmin + 0.03]; }); ctx.save(); ctx.lineWidth = focused ? 2 : 1; ctx.setLineDash([5, 4]); ctx.strokeStyle = hexA(p.a.color, focused ? 0.95 : 0.5); if (drawPoly(ctx, cam, hullA, W, H)) ctx.stroke(); ctx.strokeStyle = hexA(p.b.color, focused ? 0.95 : 0.5); if (drawPoly(ctx, cam, hullB, W, H)) ctx.stroke(); ctx.restore(); } // 相交区域填充 if (drawPoly(ctx, cam, inter, W, H)) { ctx.save(); var alpha = focused ? 0.34 : 0.18 + Math.min(p.r2d, 1) * 0.12; if (focused) { var pulse = 0.5 + 0.5 * Math.sin(now / 180); ctx.shadowColor = 'rgba(255,40,80,' + (0.45 + 0.35 * pulse) + ')'; ctx.shadowBlur = 12 + 8 * pulse; } ctx.fillStyle = 'rgba(255,48,84,' + alpha + ')'; ctx.fill(); ctx.shadowBlur = 0; ctx.lineWidth = focused ? 2.4 : 1.4; ctx.strokeStyle = focused ? 'rgba(255,60,96,1)' : 'rgba(255,72,104,.85)'; ctx.stroke(); ctx.restore(); // 百分比文字 var cx = 0, cy = 0; for (var ci = 0; ci < inter.length; ci++) { cx += inter[ci][0]; cy += inter[ci][1]; } cx /= inter.length; cy /= inter.length; var sp = projectPoint(cam, [cx, cy, z], W, H); if (sp) { var label = pct(p.r2d); ctx.save(); ctx.font = '600 11px -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif'; var w = ctx.measureText(label).width + 10; ctx.fillStyle = 'rgba(18,20,26,.86)'; ctx.beginPath(); if (ctx.roundRect) { ctx.roundRect(sp.x - w / 2, sp.y - 9, w, 18, 5); } else { ctx.rect(sp.x - w / 2, sp.y - 9, w, 18); } ctx.fill(); ctx.fillStyle = '#ffd2dc'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, sp.x, sp.y + 0.5); ctx.restore(); } } } } /** 'rgb(255,0,0)' → 'rgba(255,0,0,a)';十六进制同理。解析失败则回退粉红。 */ function hexA(color, a) { if (typeof color !== 'string') return 'rgba(255,92,138,' + a + ')'; var m = color.match(/rgba?\(([^)]+)\)/i); if (m) { var parts = m[1].split(',').map(function (x) { return parseFloat(x); }); if (parts.length >= 3) return 'rgba(' + parts[0] + ',' + parts[1] + ',' + parts[2] + ',' + a + ')'; } var h = color.replace('#', ''); if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; if (h.length === 6 && /^[0-9a-f]{6}$/i.test(h)) { var r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16); return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'; } return 'rgba(255,92,138,' + a + ')'; } /* ===================================================================== * 6. 启动 * ===================================================================== */ function boot() { buildUI(); scan(); if (state.scope === 'all') { scanAllFrames(true); lastListSig = null; renderPanel(); } setInterval(scan, 600); requestAnimationFrame(frame); window.addEventListener('resize', function () { placePanel(); overlayRectKey = ''; }); window.__PCQA__ = { loaded: true, version: '1.2.0', state: state, scan: scan, get pairs() { return pairs; }, get boxes() { return boxes; }, focusPair: focusPair, locatePair: locatePair, checkNow: checkNow, markBaseline: markBaseline, setScope: function (v) { setScope(v); return state.scope; }, scanAllFrames: function () { var r = scanAllFrames(true); lastListSig = null; renderPanel(); return r; }, get allPairs() { return allConf; }, get allCount() { return allFiltered().length; }, setThreshold: function (v) { state.threshold = clamp(v, 0.05, 0.8); syncControls(); saveState(); markBaseline(); scan(); lastListSig = null; renderPanel(); } }; if (window.console) console.info('[PCQA] 重叠标注提醒已加载 v1.2.0'); } if (document.body) boot(); else window.addEventListener('DOMContentLoaded', boot); })();