// ==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 = `
框数助手
`;
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);
})();