3736 lines
147 KiB
JavaScript
3736 lines
147 KiB
JavaScript
import * as THREE from "three";
|
||
import { STLLoader } from "./vendor/STLLoader.js";
|
||
import { OrbitControls } from "./vendor/OrbitControls.js";
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const BODY_LABELS = {
|
||
head_neck: "头颈部",
|
||
chest: "胸部",
|
||
upper_abdomen: "上腹部",
|
||
abdomen_pelvis: "腹盆部",
|
||
};
|
||
|
||
const DEFAULT_POSE = {
|
||
rotateX: 0,
|
||
rotateY: 0,
|
||
rotateZ: 0,
|
||
translateX: 0,
|
||
translateY: 0,
|
||
translateZ: 0,
|
||
scale: 1,
|
||
scaleX: 1,
|
||
scaleY: 1,
|
||
scaleZ: 1,
|
||
flipX: false,
|
||
flipY: false,
|
||
flipZ: false,
|
||
};
|
||
|
||
const POSE_CONTROLS = [
|
||
{ key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" },
|
||
{ key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" },
|
||
{ key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" },
|
||
{ key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" },
|
||
{ key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" },
|
||
{ key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" },
|
||
{ key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.001, decimals: 3, suffix: "x" },
|
||
];
|
||
|
||
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
|
||
const CASE_PAGE_SIZE = 20;
|
||
const FUSION_BASE_EXTENT = 4.6;
|
||
const AXIS_INSET_LENGTH = 17;
|
||
const DEFAULT_AXIS_PROJECTION = {
|
||
x: { dx: AXIS_INSET_LENGTH, dy: 0, opacity: 0.95 },
|
||
y: { dx: -10, dy: 10, opacity: 0.82 },
|
||
z: { dx: 0, dy: -AXIS_INSET_LENGTH, opacity: 0.95 },
|
||
};
|
||
const POSE_SIGNATURE_KEYS = [
|
||
"rotateX",
|
||
"rotateY",
|
||
"rotateZ",
|
||
"translateX",
|
||
"translateY",
|
||
"translateZ",
|
||
"scale",
|
||
"flipX",
|
||
"flipY",
|
||
"flipZ",
|
||
];
|
||
const AUTO_PROGRESS_STEPS = [
|
||
{ id: "save", label: "保存" },
|
||
{ id: "bone", label: "骨窗" },
|
||
{ id: "sample", label: "采样" },
|
||
{ id: "score", label: "评分" },
|
||
{ id: "apply", label: "应用" },
|
||
];
|
||
const BONE_BRIGHT_THRESHOLD = 185;
|
||
const BONE_EDGE_THRESHOLD = 165;
|
||
const BONE_FIXED_REWARD = 1;
|
||
const BONE_EDGE_REWARD = 0.22;
|
||
const STL_DISPLAY_MODES = ["hidden", "solid", "translucent"];
|
||
const STL_DISPLAY_LABELS = {
|
||
hidden: "取消显示",
|
||
solid: "实体显示",
|
||
translucent: "半透明",
|
||
};
|
||
const LIVER_STL_GROUPS = [
|
||
{ key: "liver_segments", label: "肝段", order: 10 },
|
||
{ key: "liver_body", label: "肝脏", order: 20 },
|
||
{ key: "liver_vessels", label: "肝血管", order: 30 },
|
||
{ key: "liver_lesions", label: "肿瘤和囊肿", order: 40 },
|
||
{ key: "body_surface", label: "骨骼皮肤", order: 50 },
|
||
{ key: "other", label: "其他", order: 90 },
|
||
];
|
||
const LIVER_STL_GROUP_LOOKUP = Object.fromEntries(LIVER_STL_GROUPS.map((group) => [group.key, group]));
|
||
const LIVER_STL_LABELS = {
|
||
bile_duct: "胆管",
|
||
cholecyst: "胆囊",
|
||
gallbladder: "胆囊",
|
||
hipbone: "髋骨",
|
||
liver: "肝脏整体",
|
||
liver_artery: "肝动脉",
|
||
liver_left: "左肝叶",
|
||
liver_right: "右肝叶",
|
||
liver_vein: "肝静脉",
|
||
pancreas: "胰腺",
|
||
portal_vein: "门静脉",
|
||
rib: "肋骨",
|
||
ribs: "肋骨",
|
||
sacrum: "骶骨",
|
||
skin: "皮肤",
|
||
spleen: "脾脏",
|
||
sternum: "胸骨",
|
||
vertebra: "椎骨",
|
||
vertebrae: "椎骨",
|
||
};
|
||
const ROMAN_NUMERALS = {
|
||
1: "I",
|
||
2: "II",
|
||
3: "III",
|
||
4: "IV",
|
||
5: "V",
|
||
6: "VI",
|
||
7: "VII",
|
||
8: "VIII",
|
||
};
|
||
|
||
const state = {
|
||
token: localStorage.getItem("dicom_upp_registration_token") || "",
|
||
user: null,
|
||
links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" },
|
||
settings: null,
|
||
cacheStatus: null,
|
||
cachePollTimer: 0,
|
||
statusFilter: "",
|
||
partFilter: "",
|
||
modelFilter: "",
|
||
search: "",
|
||
searchTimer: 0,
|
||
caseCollapsed: false,
|
||
caseLimit: CASE_PAGE_SIZE,
|
||
caseOffset: 0,
|
||
caseTotal: 0,
|
||
caseHasMore: false,
|
||
loadingCases: false,
|
||
activeToolTab: "series",
|
||
cases: [],
|
||
activeCase: null,
|
||
series: [],
|
||
selectedSeriesUid: "",
|
||
registrationSeriesUid: "",
|
||
stlFiles: [],
|
||
selectedStlIds: new Set(),
|
||
stlDisplayModes: {},
|
||
collapsedStlFamilies: new Set(),
|
||
algorithmModel: "",
|
||
registrationStatus: "unregistered",
|
||
pose: { ...DEFAULT_POSE },
|
||
windowMode: "default",
|
||
fusionMode: "fusion",
|
||
fusionDetail: "high",
|
||
modelDetail: "ultra",
|
||
mappingMode: "result",
|
||
sliceIndex: 0,
|
||
sliceRangeStart: 0,
|
||
sliceRangeEnd: 0,
|
||
dicomPreviewTimer: 0,
|
||
autoResult: null,
|
||
lastAutoPose: null,
|
||
autoDeltaBaseline: null,
|
||
autoFineRunning: false,
|
||
poseControlsLocked: false,
|
||
collapsedPoseSections: { manual: false, auto: false },
|
||
dirty: false,
|
||
dirtyReason: "",
|
||
savedPoseSignature: "",
|
||
saving: false,
|
||
fusionVersion: 0,
|
||
volumeMeta: null,
|
||
volumeScene: null,
|
||
sceneReady: false,
|
||
renderer: null,
|
||
camera: null,
|
||
scene: null,
|
||
controls: null,
|
||
fusionRoot: null,
|
||
viewPose: { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 },
|
||
sceneDrag: null,
|
||
dicomGroup: null,
|
||
dicomPlane: null,
|
||
dicomPoints: null,
|
||
dicomSlicePlanes: [],
|
||
modelGroup: null,
|
||
rawModelGroup: null,
|
||
axisGroup: null,
|
||
modelMeshes: [],
|
||
stlSignature: "",
|
||
baseModelScale: 1,
|
||
modelBoundsFrame: null,
|
||
modelLocalBounds: null,
|
||
dicomSceneBox: null,
|
||
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 },
|
||
mappingDrag: null,
|
||
mappingDrawFrame: 0,
|
||
mappingVersion: 0,
|
||
resizeObserver: null,
|
||
resizeScene: null,
|
||
axisProjectionSignature: "",
|
||
nudgeTimer: null,
|
||
nudgeDelayTimer: null,
|
||
};
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
function cls(value) {
|
||
return value ? "active" : "";
|
||
}
|
||
|
||
function clamp(value, min, max) {
|
||
return Math.max(min, Math.min(max, value));
|
||
}
|
||
|
||
function getDicomDisplaySliceNumber(sliceIndex, totalSlices) {
|
||
const total = Math.max(Math.round(Number(totalSlices) || 0), 0);
|
||
if (!total) return 0;
|
||
return total - clamp(Math.round(Number(sliceIndex) || 0), 0, total - 1);
|
||
}
|
||
|
||
function getDicomDisplayRange(startIndex, endIndex, totalSlices) {
|
||
const first = getDicomDisplaySliceNumber(startIndex, totalSlices);
|
||
const second = getDicomDisplaySliceNumber(endIndex, totalSlices);
|
||
return {
|
||
start: Math.min(first, second),
|
||
end: Math.max(first, second),
|
||
};
|
||
}
|
||
|
||
function setFusionWebglError(message = "") {
|
||
const overlay = $("fusionWebglError");
|
||
if (!overlay) return;
|
||
overlay.classList.toggle("hidden", !message);
|
||
const detail = overlay.querySelector("p");
|
||
if (detail && message) detail.textContent = message;
|
||
}
|
||
|
||
function setTopLoading(visible) {
|
||
$("topLoading").classList.toggle("hidden", !visible);
|
||
}
|
||
|
||
function setFusionLoading(visible, text = "正在构建融合视图", progress = null) {
|
||
$("fusionLoading").classList.toggle("hidden", !visible);
|
||
$("fusionLoading").querySelector("span").textContent = text;
|
||
const fill = $("fusionProgressFill");
|
||
const label = $("fusionProgressText");
|
||
if (fill && label) {
|
||
const value = progress == null ? 0 : Math.max(0, Math.min(100, Number(progress) || 0));
|
||
fill.style.width = progress == null ? "34%" : `${value}%`;
|
||
fill.classList.toggle("indeterminate", progress == null);
|
||
label.textContent = progress == null ? "" : `${Math.round(value)}%`;
|
||
}
|
||
}
|
||
|
||
function setDicomLoading(visible, text = "正在加载分割结果", progress = null) {
|
||
$("dicomLoading").classList.toggle("hidden", !visible);
|
||
$("dicomLoading").querySelector("span").textContent = text;
|
||
const fill = $("dicomProgressFill");
|
||
const label = $("dicomProgressText");
|
||
if (!fill || !label) return;
|
||
const value = progress == null ? 0 : clamp(Number(progress) || 0, 0, 100);
|
||
fill.style.width = progress == null ? "34%" : `${value}%`;
|
||
fill.classList.toggle("indeterminate", progress == null);
|
||
label.textContent = progress == null ? "" : `${Math.round(value)}%`;
|
||
}
|
||
|
||
function setSaveState(text, tone = "") {
|
||
const el = $("saveState");
|
||
el.textContent = text;
|
||
el.className = tone;
|
||
}
|
||
|
||
function setAutoState(text, tone = "") {
|
||
const el = $("autoState");
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
el.className = tone;
|
||
}
|
||
|
||
function markDirty(reason = "pose") {
|
||
state.dirty = true;
|
||
state.dirtyReason = reason;
|
||
setSaveState("未保存", "warn");
|
||
if (reason === "pose") updateAutoPoseResult();
|
||
}
|
||
|
||
function poseSignature(pose = state.pose) {
|
||
const normalized = {};
|
||
POSE_SIGNATURE_KEYS.forEach((key) => {
|
||
const value = pose?.[key] ?? DEFAULT_POSE[key];
|
||
normalized[key] = typeof value === "boolean" ? value : Number(Number(value || 0).toFixed(4));
|
||
});
|
||
return JSON.stringify(normalized);
|
||
}
|
||
|
||
function poseChanged() {
|
||
return state.savedPoseSignature && poseSignature(state.pose) !== state.savedPoseSignature;
|
||
}
|
||
|
||
function resetDirty() {
|
||
state.dirty = false;
|
||
state.dirtyReason = "";
|
||
state.savedPoseSignature = poseSignature(state.pose);
|
||
setSaveState("已保存", "ok");
|
||
resetAutoDeltaBaseline();
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const headers = new Headers(options.headers || {});
|
||
if (state.token) headers.set("Authorization", `Bearer ${state.token}`);
|
||
if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||
const response = await fetch(path, { ...options, headers });
|
||
if (response.status === 401) {
|
||
logout(false);
|
||
throw new Error("登录已过期");
|
||
}
|
||
if (!response.ok) {
|
||
let message = response.statusText;
|
||
try {
|
||
const payload = await response.json();
|
||
message = payload.detail || message;
|
||
} catch {
|
||
message = await response.text();
|
||
}
|
||
throw new Error(message || "请求失败");
|
||
}
|
||
const type = response.headers.get("content-type") || "";
|
||
return type.includes("application/json") ? response.json() : response.text();
|
||
}
|
||
|
||
function loginVisible(visible) {
|
||
$("loginOverlay").classList.toggle("hidden", !visible);
|
||
}
|
||
|
||
async function login(event) {
|
||
event.preventDefault();
|
||
$("loginError").textContent = "";
|
||
try {
|
||
const payload = await api("/api/auth/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }),
|
||
});
|
||
state.token = payload.token;
|
||
state.user = payload;
|
||
localStorage.setItem("dicom_upp_registration_token", state.token);
|
||
loginVisible(false);
|
||
await bootstrap();
|
||
} catch (error) {
|
||
$("loginError").textContent = error.message;
|
||
}
|
||
}
|
||
|
||
function logout(clearToken = true) {
|
||
if (clearToken) localStorage.removeItem("dicom_upp_registration_token");
|
||
state.token = "";
|
||
state.user = null;
|
||
loginVisible(true);
|
||
$("userBadge").textContent = "未登录";
|
||
}
|
||
|
||
async function bootstrap() {
|
||
setTopLoading(true);
|
||
try {
|
||
const me = await api("/api/auth/me");
|
||
state.user = me;
|
||
$("userBadge").textContent = `${me.username} · ${me.role || "管理员"}`;
|
||
loginVisible(false);
|
||
try {
|
||
initScene();
|
||
setFusionWebglError("");
|
||
} catch (error) {
|
||
initGeometryFallback();
|
||
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
|
||
setFusionWebglError("三维融合视图暂不可用,请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与映射功能仍可继续使用。");
|
||
console.warn(error);
|
||
}
|
||
buildPoseControls();
|
||
await loadStatus();
|
||
await loadSettings();
|
||
await loadCases();
|
||
} catch (error) {
|
||
console.warn(error);
|
||
if (!state.token) logout(false);
|
||
else {
|
||
$("dbStatus").textContent = error.message || "启动失败";
|
||
$("dbStatus").className = "status-pill offline";
|
||
loginVisible(false);
|
||
}
|
||
} finally {
|
||
setTopLoading(false);
|
||
}
|
||
}
|
||
|
||
async function loadStatus() {
|
||
try {
|
||
const payload = await api("/api/status");
|
||
state.links = payload.links || state.links;
|
||
const db = payload.database || {};
|
||
$("dbStatus").textContent = db.ok ? `数据库已连接 · ${payload.counts?.complete_cases || 0}` : "数据库异常";
|
||
$("dbStatus").className = `status-pill ${db.ok ? "online" : "offline"}`;
|
||
} catch (error) {
|
||
$("dbStatus").textContent = "数据库异常";
|
||
$("dbStatus").className = "status-pill offline";
|
||
}
|
||
}
|
||
|
||
async function loadSettings() {
|
||
try {
|
||
const payload = await api("/api/settings");
|
||
state.settings = payload.refresh || {};
|
||
state.cacheStatus = payload.cache || {};
|
||
renderSettings();
|
||
pollCacheWhileRunning();
|
||
} catch (error) {
|
||
console.warn(error);
|
||
}
|
||
}
|
||
|
||
function renderSettings() {
|
||
const settings = state.settings || {};
|
||
const cache = state.cacheStatus || {};
|
||
$("autoRefreshEnabled").checked = settings.auto_refresh_enabled !== false;
|
||
$("refreshTime").value = settings.refresh_time || "03:00";
|
||
$("cacheRootText").textContent = cache.cache_root || settings.cache_root || "";
|
||
$("cacheStage").textContent = cache.running ? "刷新中" : cache.stage === "finished" ? "已完成" : cache.stage === "error" ? "异常" : "待刷新";
|
||
$("cacheStage").className = `status-pill ${cache.stage === "error" ? "offline" : cache.running || cache.stage === "finished" ? "online" : ""}`;
|
||
const progress = Math.max(0, Math.min(100, Number(cache.progress || 0)));
|
||
$("cacheProgressFill").style.width = `${progress}%`;
|
||
$("cacheMessage").textContent = cache.message || "等待刷新";
|
||
$("cacheCases").textContent = `${Number(cache.processed_cases || 0)} / ${Number(cache.total_cases || 0)} CT`;
|
||
$("cacheFiles").textContent = `STL 复制 ${Number(cache.copied_files || 0)} · 复用 ${Number(cache.skipped_files || 0)} · 缺失 ${Number(cache.missing_files || 0)}`;
|
||
$("cacheNextRun").textContent = cache.next_scheduled_at ? `下次 ${cache.next_scheduled_at}` : "下次 -";
|
||
$("manualCacheRefreshBtn").disabled = Boolean(cache.running);
|
||
}
|
||
|
||
async function saveSettings() {
|
||
const payload = {
|
||
auto_refresh_enabled: $("autoRefreshEnabled").checked,
|
||
refresh_time: $("refreshTime").value || "03:00",
|
||
};
|
||
const result = await api("/api/settings", { method: "POST", body: JSON.stringify(payload) });
|
||
state.settings = result.refresh || payload;
|
||
state.cacheStatus = result.cache || state.cacheStatus;
|
||
renderSettings();
|
||
}
|
||
|
||
async function refreshCacheNow() {
|
||
$("manualCacheRefreshBtn").disabled = true;
|
||
const result = await api("/api/cache/refresh", { method: "POST", body: JSON.stringify({}) });
|
||
state.cacheStatus = result.cache || state.cacheStatus;
|
||
renderSettings();
|
||
pollCacheWhileRunning(true);
|
||
}
|
||
|
||
function pollCacheWhileRunning(force = false) {
|
||
window.clearTimeout(state.cachePollTimer);
|
||
const running = Boolean(state.cacheStatus?.running);
|
||
if (!running && !force) return;
|
||
state.cachePollTimer = window.setTimeout(async () => {
|
||
try {
|
||
state.cacheStatus = await api("/api/cache/status");
|
||
renderSettings();
|
||
} catch (error) {
|
||
console.warn(error);
|
||
}
|
||
pollCacheWhileRunning();
|
||
}, running ? 1600 : 800);
|
||
}
|
||
|
||
async function loadCases(selectFirst = true, append = false) {
|
||
if (state.loadingCases) return;
|
||
state.loadingCases = true;
|
||
if (!append) {
|
||
state.caseOffset = 0;
|
||
state.caseHasMore = false;
|
||
state.cases = [];
|
||
}
|
||
setTopLoading(!append);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (state.search) params.set("q", state.search);
|
||
if (state.statusFilter) params.set("status", state.statusFilter);
|
||
if (state.partFilter) params.set("body_part", state.partFilter);
|
||
if (state.modelFilter) params.set("algorithm_model", state.modelFilter);
|
||
params.set("limit", String(state.caseLimit));
|
||
params.set("offset", String(append ? state.caseOffset : 0));
|
||
const payload = await api(`/api/cases?${params.toString()}`);
|
||
const rows = Array.isArray(payload) ? payload : payload.rows || [];
|
||
state.caseTotal = Array.isArray(payload) ? (append ? state.cases.length + rows.length : rows.length) : Number(payload.total || 0);
|
||
state.caseHasMore = Array.isArray(payload) ? rows.length >= state.caseLimit : Boolean(payload.has_more);
|
||
state.caseOffset = (append ? state.caseOffset : 0) + rows.length;
|
||
state.cases = append ? [...state.cases, ...rows] : rows;
|
||
renderCases();
|
||
if (selectFirst && !state.activeCase && rows.length) {
|
||
await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型");
|
||
} else if (!append && state.activeCase && !state.cases.some((row) => sameCase(row, state.activeCase))) {
|
||
state.activeCase = null;
|
||
clearDetail();
|
||
}
|
||
} finally {
|
||
state.loadingCases = false;
|
||
if (!append) setTopLoading(false);
|
||
}
|
||
}
|
||
|
||
async function loadMoreCases() {
|
||
if (state.loadingCases || !state.caseHasMore) return;
|
||
await loadCases(false, true);
|
||
}
|
||
|
||
function sameCase(a, b) {
|
||
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
|
||
}
|
||
|
||
function sameCtNumber(a, b) {
|
||
return normalize(a) === normalize(b);
|
||
}
|
||
|
||
function normalize(value) {
|
||
return String(value || "").trim().toUpperCase();
|
||
}
|
||
|
||
function fmtDateTime(date, time) {
|
||
const d = String(date || "").replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
|
||
const t = fmtTime(time);
|
||
return [d, t].filter(Boolean).join(" ");
|
||
}
|
||
|
||
function fmtTime(value) {
|
||
const raw = String(value || "").replace(/[^\d.]/g, "");
|
||
if (!raw) return "";
|
||
const main = raw.split(".")[0].padEnd(6, "0").slice(0, 6);
|
||
return `${main.slice(0, 2)}:${main.slice(2, 4)}:${main.slice(4, 6)}`;
|
||
}
|
||
|
||
function statusText(value) {
|
||
return value === "registered" ? "已配准" : "未配准";
|
||
}
|
||
|
||
function seriesLabels(item) {
|
||
return Array.isArray(item?.annotation_labels) ? item.annotation_labels.map((label) => String(label || "")) : [];
|
||
}
|
||
|
||
function isSkippedSeries(item) {
|
||
return seriesLabels(item).some((label) => label.includes("略过") || label.includes("不采用"));
|
||
}
|
||
|
||
function hasLabel(item, matcher) {
|
||
return seriesLabels(item).some((label) => matcher(String(label)));
|
||
}
|
||
|
||
function isUpperAbdomenSeries(item) {
|
||
return hasLabel(item, (label) => label.includes("上腹部"));
|
||
}
|
||
|
||
function isPortalPhaseSeries(item) {
|
||
return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期") || label.includes("门静脉")));
|
||
}
|
||
|
||
function isLiverBiliaryModel(model = state.algorithmModel) {
|
||
const value = String(model || "").toLowerCase();
|
||
return value.includes("肝胆") || value.includes("liver") || value.includes("bile");
|
||
}
|
||
|
||
function sortedSeriesForDisplay(series) {
|
||
return [...series].sort((a, b) => {
|
||
const skippedDiff = Number(isSkippedSeries(a)) - Number(isSkippedSeries(b));
|
||
if (skippedDiff) return skippedDiff;
|
||
const timeDiff = String(a.first_time || a.series_time || "").localeCompare(String(b.first_time || b.series_time || ""));
|
||
if (timeDiff) return timeDiff;
|
||
return Number(a.series_number || 999999) - Number(b.series_number || 999999);
|
||
});
|
||
}
|
||
|
||
function stlSelectionSignature() {
|
||
return [
|
||
normalize(state.activeCase?.ct_number),
|
||
state.algorithmModel || "",
|
||
state.stlFiles.map((file) => `${Number(file.id)}:${file.file_name || ""}:${file.size_bytes || 0}`).join(","),
|
||
].join("|");
|
||
}
|
||
|
||
function visibleModelRecords() {
|
||
return state.modelMeshes.filter((record) => stlMode(record.file) !== "hidden" && record.mesh?.visible !== false);
|
||
}
|
||
|
||
function modelPoseMatrix() {
|
||
if (!state.modelGroup) return new THREE.Matrix4();
|
||
state.modelGroup.updateMatrix();
|
||
return state.modelGroup.matrix.clone();
|
||
}
|
||
|
||
function visibleModelBox(records = visibleModelRecords()) {
|
||
if (!records.length) return null;
|
||
const box = new THREE.Box3();
|
||
const matrix = modelPoseMatrix();
|
||
records.forEach((record) => {
|
||
record.mesh.geometry.computeBoundingBox?.();
|
||
const localBox = record.mesh.geometry.boundingBox;
|
||
if (localBox) box.union(localBox.clone().applyMatrix4(matrix));
|
||
});
|
||
return box;
|
||
}
|
||
|
||
function autoReferenceRecords(settings = null) {
|
||
const visibleRecords = visibleModelRecords();
|
||
const referenceIds = new Set((settings?.referenceIds || []).map((id) => Number(id)));
|
||
if (!referenceIds.size) return visibleRecords;
|
||
const referenceRecords = visibleRecords.filter((record) => referenceIds.has(Number(record.file?.id)));
|
||
return referenceRecords.length ? referenceRecords : visibleRecords;
|
||
}
|
||
|
||
function bodyPartTags(row) {
|
||
const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : [];
|
||
const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : [];
|
||
if (labels.length) return labels;
|
||
return keys.map((key) => BODY_LABELS[key]).filter(Boolean);
|
||
}
|
||
|
||
function renderCases() {
|
||
$("caseCount").textContent = state.caseTotal ? `${state.cases.length}/${state.caseTotal}` : state.cases.length;
|
||
const active = state.activeCase;
|
||
const cards = state.cases.length
|
||
? state.cases
|
||
.map((row) => {
|
||
const tags = bodyPartTags(row).slice(0, 3);
|
||
const status = row.registration_status || "unregistered";
|
||
const selected = active && sameCase(row, active);
|
||
const title = escapeHtml(row.ct_number || row.ct_key);
|
||
const patient = escapeHtml(row.patient_name || row.upp_patient_name || "-");
|
||
const meta = `${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`;
|
||
return `
|
||
<button class="case-card ${selected ? "active" : ""}" data-ct="${escapeHtml(row.ct_number)}" data-algorithm="${escapeHtml(row.algorithm_model || "未指定模型")}" title="${title} ${patient}">
|
||
<div class="card-line">
|
||
<strong>${title}</strong>
|
||
<em class="tag ${status === "registered" ? "green" : "amber"}">${statusText(status)}</em>
|
||
</div>
|
||
<span>${patient} · ${escapeHtml(row.patient_id || row.patient_id_masked || "")}</span>
|
||
<small>${escapeHtml(meta)}</small>
|
||
<div class="mini-tags">
|
||
<i>${escapeHtml(row.algorithm_model || "未指定模型")}</i>
|
||
${tags.map((tag) => `<i>${escapeHtml(tag)}</i>`).join("")}
|
||
</div>
|
||
</button>
|
||
`;
|
||
})
|
||
.join("")
|
||
: `<div class="empty-state">没有符合条件的完整关联 CT</div>`;
|
||
$("caseList").innerHTML = `${cards}${state.caseHasMore ? `<button id="loadMoreCasesBtn" class="load-more-btn" type="button">${state.loadingCases ? "读取中..." : "继续加载"}</button>` : ""}`;
|
||
$("caseList").querySelectorAll(".case-card").forEach((button) => {
|
||
button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型"));
|
||
});
|
||
$("loadMoreCasesBtn")?.addEventListener("click", loadMoreCases);
|
||
}
|
||
|
||
function clearDetail() {
|
||
state.series = [];
|
||
state.stlFiles = [];
|
||
state.selectedStlIds = new Set();
|
||
state.stlDisplayModes = {};
|
||
state.collapsedStlFamilies = new Set();
|
||
state.selectedSeriesUid = "";
|
||
state.registrationSeriesUid = "";
|
||
$("activeTitle").textContent = "未选择 CT";
|
||
$("activeMeta").textContent = "从左侧选择一个完整关联检查";
|
||
$("activeTags").innerHTML = "";
|
||
$("seriesList").innerHTML = `<div class="empty-state">未选择 CT</div>`;
|
||
$("stlList").innerHTML = `<div class="empty-state">未选择 STL</div>`;
|
||
$("modelRail").innerHTML = "";
|
||
renderDicomAnnotation();
|
||
clearFusion();
|
||
}
|
||
|
||
async function confirmChange() {
|
||
if (!state.dirty || !poseChanged()) return true;
|
||
return window.confirm("当前配准参数还未保存,确定切换吗?");
|
||
}
|
||
|
||
async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
||
if (!ctNumber) return;
|
||
const normalizedAlgorithm = algorithmModel || "未指定模型";
|
||
const switchingModelOnly = state.activeCase
|
||
&& sameCtNumber(state.activeCase.ct_number, ctNumber)
|
||
&& (state.algorithmModel || "未指定模型") !== normalizedAlgorithm;
|
||
const previousPose = { ...state.pose };
|
||
if (!(await confirmChange())) return;
|
||
setTopLoading(true);
|
||
setFusionLoading(true, "正在读取当前 CT 数据", 6);
|
||
try {
|
||
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
|
||
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
|
||
api(`/api/cases/${encodeURIComponent(ctNumber)}?${params.toString()}`),
|
||
api(`/api/cases/${encodeURIComponent(ctNumber)}/series`),
|
||
api(`/api/cases/${encodeURIComponent(ctNumber)}/stl?${params.toString()}`),
|
||
api(`/api/registrations/${encodeURIComponent(ctNumber)}?${params.toString()}`),
|
||
]);
|
||
state.activeCase = detail;
|
||
state.algorithmModel = detail.algorithm_model || algorithmModel || "未指定模型";
|
||
state.series = seriesPayload.series || [];
|
||
state.stlFiles = stlPayload.files || [];
|
||
state.registrationStatus = registration.registration_status || "unregistered";
|
||
const savedTransform = { ...(registration.transform || {}), scaleX: 1, scaleY: 1, scaleZ: 1 };
|
||
const keepPreviousPose = switchingModelOnly;
|
||
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
|
||
state.fusionDetail = registration.model_reference?.fusion_detail || state.fusionDetail || "high";
|
||
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "ultra";
|
||
state.autoResult = null;
|
||
state.lastAutoPose = null;
|
||
state.autoDeltaBaseline = null;
|
||
state.sliceIndex = 0;
|
||
$("notes").value = registration.notes || "";
|
||
|
||
const savedIds = new Set((registration.selected_stl_files || []).map((item) => Number(item.id)).filter(Number.isFinite));
|
||
state.selectedStlIds = savedIds.size ? savedIds : pickDefaultStlIds(state.stlFiles);
|
||
state.stlDisplayModes = initStlDisplayModes(state.stlFiles, state.selectedStlIds, registration.module_styles || {});
|
||
state.collapsedStlFamilies = new Set();
|
||
const defaultSeries = pickDefaultSeries(state.series);
|
||
const savedSeries = state.series.find((item) => item.series_uid === registration.series_instance_uid);
|
||
const shouldUseSavedSeries = registration.registration_status === "registered" && savedSeries;
|
||
state.registrationSeriesUid = savedSeries?.series_uid || "";
|
||
state.selectedSeriesUid = shouldUseSavedSeries ? savedSeries.series_uid : defaultSeries?.series_uid || "";
|
||
const selected = currentSeries();
|
||
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
|
||
state.sliceRangeStart = 0;
|
||
state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
|
||
state.stlSignature = "";
|
||
|
||
renderCases();
|
||
renderActiveHeader();
|
||
renderSeries();
|
||
renderStl();
|
||
renderPoseControls();
|
||
syncDisplayControls();
|
||
resetDirty();
|
||
await loadFusion();
|
||
} catch (error) {
|
||
$("fusionStatus").textContent = error.message;
|
||
clearFusion();
|
||
} finally {
|
||
setTopLoading(false);
|
||
setFusionLoading(false);
|
||
}
|
||
}
|
||
|
||
function pickDefaultSeries(series) {
|
||
const candidates = [...series].filter((item) => Number(item.count || 0) > 1);
|
||
const usable = candidates.filter((item) => !isSkippedSeries(item));
|
||
const pool = usable.length ? usable : candidates;
|
||
const rank = (item) => {
|
||
let score = Number(item.count || 0) / 10000;
|
||
if (isLiverBiliaryModel()) {
|
||
if (isPortalPhaseSeries(item)) score += 900;
|
||
else if (isUpperAbdomenSeries(item)) score += 720;
|
||
} else if (isUpperAbdomenSeries(item)) {
|
||
score += 120;
|
||
}
|
||
if (isSkippedSeries(item)) score -= 1000;
|
||
return score;
|
||
};
|
||
return [...pool]
|
||
.sort((a, b) => {
|
||
const diff = rank(b) - rank(a);
|
||
if (Math.abs(diff) > 1e-6) return diff;
|
||
return Number(a.series_number || 999999) - Number(b.series_number || 999999);
|
||
})[0] || series[0] || null;
|
||
}
|
||
|
||
function pickDefaultStlIds(files) {
|
||
const priority = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen", "vertebrae"];
|
||
const ranked = [...files].sort((a, b) => {
|
||
const af = String(a.family || a.segment_name || a.file_name || "").toLowerCase();
|
||
const bf = String(b.family || b.segment_name || b.file_name || "").toLowerCase();
|
||
const ai = priority.findIndex((key) => af === key || af.includes(key));
|
||
const bi = priority.findIndex((key) => bf === key || bf.includes(key));
|
||
const ar = ai < 0 ? 999 : ai;
|
||
const br = bi < 0 ? 999 : bi;
|
||
if (ar !== br) return ar - br;
|
||
return Number(a.id || 0) - Number(b.id || 0);
|
||
});
|
||
return new Set(ranked.slice(0, Math.min(6, ranked.length)).map((file) => Number(file.id)));
|
||
}
|
||
|
||
function normalizedStlId(fileOrId) {
|
||
const id = typeof fileOrId === "object" ? Number(fileOrId?.id) : Number(fileOrId);
|
||
return Number.isFinite(id) ? id : null;
|
||
}
|
||
|
||
function validStlDisplayMode(mode) {
|
||
return STL_DISPLAY_MODES.includes(mode) ? mode : "";
|
||
}
|
||
|
||
function stlMode(fileOrId) {
|
||
const id = normalizedStlId(fileOrId);
|
||
if (id == null || !state.selectedStlIds.has(id)) return "hidden";
|
||
return validStlDisplayMode(state.stlDisplayModes[id]) || "translucent";
|
||
}
|
||
|
||
function stlFamilyLabel(file) {
|
||
return String(file.family || "未分类");
|
||
}
|
||
|
||
function normalizedStlText(file) {
|
||
return [
|
||
file.family,
|
||
file.segment_name,
|
||
file.file_name,
|
||
file.category,
|
||
].map((value) => String(value || "").toLowerCase()).join(" ");
|
||
}
|
||
|
||
function normalizedStlName(file) {
|
||
return String(file.segment_name || file.family || file.file_name || "")
|
||
.replace(/\.stl$/i, "")
|
||
.toLowerCase();
|
||
}
|
||
|
||
function stlSegmentNumber(file) {
|
||
const text = normalizedStlText(file);
|
||
const match = text.match(/(?:segment[_\s-]*s?|肝段)\s*([0-9]+)/i) || text.match(/_s([0-9]+)/i);
|
||
return match ? Number(match[1]) : 999;
|
||
}
|
||
|
||
function lesionNumber(file) {
|
||
const text = normalizedStlText(file);
|
||
const match = text.match(/(?:^|[_\s-])(?:liver[_\s-]*)?(?:tumor|lesion|cyst)[_\s-]*([0-9]+)/i)
|
||
|| text.match(/(?:肿瘤|囊肿)[_\s-]*([0-9]+)/i);
|
||
return match ? Number(match[1]) : 99;
|
||
}
|
||
|
||
function isTumorStl(file, category = String(file.category || "")) {
|
||
const text = normalizedStlText(file);
|
||
return /(?:^|[_\s-])(?:liver[_\s-]*)?(?:tumor|lesion)(?:[_\s-]|\d|$)/i.test(text) || category.includes("肿瘤");
|
||
}
|
||
|
||
function isCystStl(file, category = String(file.category || "")) {
|
||
const text = normalizedStlText(file);
|
||
return /(?:^|[_\s-])(?:liver[_\s-]*)?cyst(?:[_\s-]|\d|$)/i.test(text) || category.includes("囊肿");
|
||
}
|
||
|
||
function numberedLesionLabel(file, baseLabel) {
|
||
const number = lesionNumber(file);
|
||
return number < 99 ? `${baseLabel} ${number}` : baseLabel;
|
||
}
|
||
|
||
function liverStlPresentation(file) {
|
||
const text = normalizedStlText(file);
|
||
const name = normalizedStlName(file);
|
||
const category = String(file.category || "");
|
||
let groupKey = "other";
|
||
let itemOrder = 900;
|
||
let label = LIVER_STL_LABELS[name] || file.segment_name || file.family || file.file_name;
|
||
|
||
if (text.includes("liver_segment") || category.includes("肝段")) {
|
||
const segment = stlSegmentNumber(file);
|
||
groupKey = "liver_segments";
|
||
itemOrder = segment;
|
||
label = `肝段 ${ROMAN_NUMERALS[segment] || segment}`;
|
||
} else if (["liver", "liver_left", "liver_right"].includes(name) || category.includes("肝脏主体")) {
|
||
groupKey = "liver_body";
|
||
itemOrder = { liver: 1, liver_left: 2, liver_right: 3 }[name] || 99;
|
||
} else if (["liver_artery", "liver_vein", "portal_vein"].includes(name)) {
|
||
groupKey = "liver_vessels";
|
||
itemOrder = { liver_artery: 1, liver_vein: 2, portal_vein: 3 }[name] || 99;
|
||
} else if (isTumorStl(file, category) || isCystStl(file, category)) {
|
||
const tumor = isTumorStl(file, category);
|
||
groupKey = "liver_lesions";
|
||
itemOrder = (tumor ? 1 : 20) + lesionNumber(file) / 100;
|
||
label = tumor
|
||
? numberedLesionLabel(file, "肝脏肿瘤")
|
||
: numberedLesionLabel(file, "肝囊肿");
|
||
} else if (category.includes("体表骨骼") || ["hipbone", "rib", "ribs", "sacrum", "skin", "sternum", "vertebra", "vertebrae"].includes(name)) {
|
||
groupKey = "body_surface";
|
||
itemOrder = { vertebrae: 1, vertebra: 1, rib: 2, ribs: 2, sternum: 3, sacrum: 4, hipbone: 5, skin: 6 }[name] || 99;
|
||
} else {
|
||
itemOrder = { bile_duct: 1, cholecyst: 2, gallbladder: 2, pancreas: 3, spleen: 4 }[name] || 99;
|
||
}
|
||
|
||
const group = LIVER_STL_GROUP_LOOKUP[groupKey] || LIVER_STL_GROUP_LOOKUP.other;
|
||
return {
|
||
key: group.key,
|
||
label: group.label,
|
||
order: group.order,
|
||
itemOrder,
|
||
itemLabel: label,
|
||
hierarchy: true,
|
||
};
|
||
}
|
||
|
||
function stlPresentation(file) {
|
||
if (isLiverBiliaryModel(file.algorithm_model || state.algorithmModel)) return liverStlPresentation(file);
|
||
const label = stlFamilyLabel(file);
|
||
return {
|
||
key: label,
|
||
label,
|
||
order: 100,
|
||
itemOrder: 100,
|
||
itemLabel: file.segment_name || file.file_name,
|
||
hierarchy: false,
|
||
};
|
||
}
|
||
|
||
function compactStlName(file) {
|
||
return String(file.segment_name || file.family || file.file_name || `STL_${file.id || ""}`)
|
||
.replace(/\.stl$/i, "");
|
||
}
|
||
|
||
function stlFileIndex(file) {
|
||
const id = normalizedStlId(file);
|
||
const index = state.stlFiles.findIndex((item) => normalizedStlId(item) === id);
|
||
return index >= 0 ? index : 0;
|
||
}
|
||
|
||
function stlColor(file, fallbackIndex = 0) {
|
||
const index = stlFileIndex(file);
|
||
return cssColor(index >= 0 ? index : fallbackIndex);
|
||
}
|
||
|
||
function syncStlDisplayModesForSelection(defaultMode = "translucent") {
|
||
const next = {};
|
||
state.stlFiles.forEach((file) => {
|
||
const id = normalizedStlId(file);
|
||
if (id == null || !state.selectedStlIds.has(id)) return;
|
||
next[id] = validStlDisplayMode(state.stlDisplayModes[id]) || defaultMode;
|
||
});
|
||
state.stlDisplayModes = next;
|
||
}
|
||
|
||
function initStlDisplayModes(files, selectedIds, moduleStyles = {}) {
|
||
const modes = {};
|
||
files.forEach((file) => {
|
||
const id = normalizedStlId(file);
|
||
if (id == null || !selectedIds.has(id)) return;
|
||
const style = moduleStyles?.[file.file_name] || moduleStyles?.[file.segment_name] || {};
|
||
const savedMode = validStlDisplayMode(style.mode);
|
||
if (savedMode && savedMode !== "hidden") {
|
||
modes[id] = savedMode;
|
||
return;
|
||
}
|
||
modes[id] = Number(style.opacity) >= 0.95 ? "solid" : "translucent";
|
||
});
|
||
return modes;
|
||
}
|
||
|
||
async function setStlMode(fileOrId, mode) {
|
||
const id = normalizedStlId(fileOrId);
|
||
const nextMode = validStlDisplayMode(mode);
|
||
if (id == null || !nextMode) return;
|
||
if (nextMode === "hidden") {
|
||
state.selectedStlIds.delete(id);
|
||
delete state.stlDisplayModes[id];
|
||
} else {
|
||
state.selectedStlIds.add(id);
|
||
state.stlDisplayModes[id] = nextMode;
|
||
}
|
||
await applyStlSelectionChange();
|
||
}
|
||
|
||
async function cycleStlMode(file) {
|
||
const next = {
|
||
hidden: "solid",
|
||
solid: "translucent",
|
||
translucent: "hidden",
|
||
}[stlMode(file)] || "translucent";
|
||
await setStlMode(file, next);
|
||
}
|
||
|
||
async function setAllStlMode(mode) {
|
||
const nextMode = validStlDisplayMode(mode);
|
||
if (!state.stlFiles.length || !nextMode) return;
|
||
if (nextMode === "hidden") {
|
||
state.selectedStlIds = new Set();
|
||
state.stlDisplayModes = {};
|
||
} else {
|
||
state.selectedStlIds = new Set(state.stlFiles.map((file) => normalizedStlId(file)).filter((id) => id != null));
|
||
state.stlDisplayModes = {};
|
||
state.selectedStlIds.forEach((id) => {
|
||
state.stlDisplayModes[id] = nextMode;
|
||
});
|
||
}
|
||
await applyStlSelectionChange();
|
||
}
|
||
|
||
function aggregateStlMode() {
|
||
if (!state.stlFiles.length || !state.selectedStlIds.size) return "hidden";
|
||
const modes = state.stlFiles.map((file) => stlMode(file));
|
||
if (modes.every((mode) => mode === "solid")) return "solid";
|
||
if (modes.every((mode) => mode === "translucent")) return "translucent";
|
||
if (modes.every((mode) => mode === "hidden")) return "hidden";
|
||
return "mixed";
|
||
}
|
||
|
||
function renderStlVisibilityAction() {
|
||
const button = $("cycleAllStlModeBtn");
|
||
if (!button) return;
|
||
const mode = aggregateStlMode();
|
||
button.classList.remove("hidden-eye", "solid-eye", "translucent-eye", "mixed-eye");
|
||
button.classList.add(`${mode === "mixed" ? "mixed" : mode}-eye`);
|
||
const label = {
|
||
hidden: "当前全部隐藏,点击切换为全部实体",
|
||
solid: "当前全部实体,点击切换为全部半透明",
|
||
translucent: "当前全部半透明,点击切换为全部隐藏",
|
||
mixed: "当前为混合显示,点击切换为全部实体",
|
||
}[mode];
|
||
button.title = label;
|
||
button.setAttribute("aria-label", label);
|
||
}
|
||
|
||
async function cycleAllStlMode() {
|
||
const mode = aggregateStlMode();
|
||
const next = mode === "solid" ? "translucent" : mode === "translucent" ? "hidden" : "solid";
|
||
await setAllStlMode(next);
|
||
}
|
||
|
||
function currentSeries() {
|
||
return state.series.find((item) => item.series_uid === state.selectedSeriesUid) || null;
|
||
}
|
||
|
||
function renderActiveHeader() {
|
||
const row = state.activeCase;
|
||
if (!row) return;
|
||
const registration = registrationSeries();
|
||
const selectedLabels = registration?.annotation_labels?.length ? registration.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : [];
|
||
$("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`;
|
||
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${row.patient_id || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`;
|
||
const tags = [
|
||
`<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
|
||
`<span class="tag">${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}</span>`,
|
||
...selectedLabels.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
|
||
];
|
||
$("activeTags").innerHTML = tags.join("");
|
||
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
|
||
$("statusBtn").classList.toggle("unregistered", state.registrationStatus !== "registered");
|
||
}
|
||
|
||
function renderSeries() {
|
||
$("seriesCount").textContent = state.series.length;
|
||
$("seriesList").innerHTML = state.series.length
|
||
? sortedSeriesForDisplay(state.series)
|
||
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid, item.series_uid === state.registrationSeriesUid))
|
||
.join("")
|
||
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
|
||
document.querySelectorAll(".series-card[data-series]").forEach((card) => {
|
||
card.addEventListener("click", (event) => {
|
||
if (event.target.closest(".series-check")) return;
|
||
selectSeries(card.dataset.series);
|
||
});
|
||
card.addEventListener("keydown", (event) => {
|
||
if (!["Enter", " "].includes(event.key)) return;
|
||
event.preventDefault();
|
||
selectSeries(card.dataset.series);
|
||
});
|
||
});
|
||
document.querySelectorAll(".series-check[data-registration-series]").forEach((button) => {
|
||
button.addEventListener("click", async (event) => {
|
||
event.stopPropagation();
|
||
await setRegistrationSeries(button.dataset.registrationSeries);
|
||
});
|
||
});
|
||
updateSliceControl();
|
||
renderDicomAnnotation();
|
||
}
|
||
|
||
function seriesCardHtml(item, active = false, registrationSelected = false) {
|
||
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
|
||
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
|
||
const skipped = isSkippedSeries(item);
|
||
return `
|
||
<div class="series-card ${active ? "active" : ""} ${registrationSelected ? "registration-selected" : ""} ${skipped ? "skipped" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}" role="button" tabindex="0">
|
||
<button class="series-check" data-registration-series="${escapeHtml(item.series_uid)}" type="button" aria-label="设为配准 DICOM">${registrationSelected ? "✓" : ""}</button>
|
||
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
|
||
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
|
||
<small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
|
||
<div class="mini-tags">
|
||
${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""}
|
||
${labels || `<i>未标注</i>`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function registrationSeries() {
|
||
return state.series.find((item) => item.series_uid === state.registrationSeriesUid) || null;
|
||
}
|
||
|
||
async function setRegistrationSeries(uid) {
|
||
if (!uid) return;
|
||
if (uid !== state.selectedSeriesUid) {
|
||
await selectSeries(uid, { markRegistration: true });
|
||
return;
|
||
}
|
||
state.registrationSeriesUid = uid;
|
||
markDirty("series");
|
||
renderSeries();
|
||
renderActiveHeader();
|
||
}
|
||
|
||
async function selectSeries(uid, options = {}) {
|
||
if (!uid || uid === state.selectedSeriesUid) return;
|
||
if (!(await confirmChange())) return;
|
||
state.selectedSeriesUid = uid;
|
||
if (options.markRegistration) state.registrationSeriesUid = uid;
|
||
const selected = currentSeries();
|
||
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
|
||
state.sliceRangeStart = 0;
|
||
state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
|
||
if (options.markRegistration) markDirty("series");
|
||
else setSaveState("正在浏览序列", "");
|
||
renderSeries();
|
||
renderActiveHeader();
|
||
renderDicomAnnotation();
|
||
await loadFusion();
|
||
}
|
||
|
||
function renderStl() {
|
||
$("stlCount").textContent = state.stlFiles.length ? `${state.selectedStlIds.size}/${state.stlFiles.length}` : "0";
|
||
renderStlVisibilityAction();
|
||
const models = [...new Set(state.stlFiles.map((item) => item.algorithm_model || state.algorithmModel || "未指定模型"))];
|
||
$("modelRail").innerHTML = models.map((model) => `<span class="chip active">${escapeHtml(model)}</span>`).join("");
|
||
if (!state.stlFiles.length) {
|
||
$("stlList").innerHTML = `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
|
||
renderAutoBoneOptions();
|
||
return;
|
||
}
|
||
const grouped = new Map();
|
||
state.stlFiles.forEach((file, index) => {
|
||
const presentation = stlPresentation(file);
|
||
if (!grouped.has(presentation.key)) {
|
||
grouped.set(presentation.key, {
|
||
key: presentation.key,
|
||
label: presentation.label,
|
||
order: presentation.order,
|
||
hierarchy: presentation.hierarchy,
|
||
rows: [],
|
||
});
|
||
}
|
||
grouped.get(presentation.key).rows.push({ file, index, presentation });
|
||
});
|
||
$("stlList").innerHTML = [...grouped.values()]
|
||
.sort((a, b) => a.order - b.order || a.label.localeCompare(b.label))
|
||
.map((group) => {
|
||
const rows = group.rows.sort((a, b) => (
|
||
a.presentation.itemOrder - b.presentation.itemOrder
|
||
|| String(a.presentation.itemLabel).localeCompare(String(b.presentation.itemLabel))
|
||
|| a.index - b.index
|
||
));
|
||
const visibleCount = rows.filter(({ file }) => stlMode(file) !== "hidden").length;
|
||
const collapsed = state.collapsedStlFamilies.has(group.key);
|
||
return `
|
||
<section class="stl-family ${group.hierarchy ? "stl-hierarchy-group" : ""} ${collapsed ? "collapsed" : ""}">
|
||
<button class="stl-family-head" type="button" data-family-toggle="${escapeHtml(group.key)}" title="收起/展开 ${escapeHtml(group.label)}">
|
||
<span>${escapeHtml(group.label)}</span>
|
||
<em>${visibleCount}/${rows.length}</em>
|
||
<i></i>
|
||
</button>
|
||
<div class="stl-family-list">
|
||
${rows.map(({ file, index, presentation }) => {
|
||
const id = normalizedStlId(file);
|
||
const mode = stlMode(file);
|
||
const color = stlColor(file, index);
|
||
return `
|
||
<button class="stl-row stl-mode-${mode} ${mode !== "hidden" ? "active" : ""}" type="button" data-stl-mode="${id}" style="--stl-color:${escapeHtml(color)}" title="${escapeHtml(file.file_name)}">
|
||
<span class="stl-row-main">
|
||
<strong>${escapeHtml(compactStlName(file))}</strong>
|
||
</span>
|
||
<span class="stl-visibility-swatch ${mode}" aria-label="${escapeHtml(STL_DISPLAY_LABELS[mode])}"></span>
|
||
</button>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
</section>
|
||
`;
|
||
}).join("");
|
||
$("stlList").querySelectorAll("[data-family-toggle]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const groupKey = button.dataset.familyToggle;
|
||
if (state.collapsedStlFamilies.has(groupKey)) state.collapsedStlFamilies.delete(groupKey);
|
||
else state.collapsedStlFamilies.add(groupKey);
|
||
renderStl();
|
||
});
|
||
});
|
||
$("stlList").querySelectorAll("[data-stl-mode]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
const id = Number(button.dataset.stlMode);
|
||
const file = state.stlFiles.find((item) => normalizedStlId(item) === id);
|
||
if (file) await cycleStlMode(file);
|
||
});
|
||
});
|
||
renderAutoBoneOptions();
|
||
}
|
||
|
||
async function selectAllStl() {
|
||
await setAllStlMode("translucent");
|
||
}
|
||
|
||
async function invertStlSelection() {
|
||
if (!state.stlFiles.length) return;
|
||
const nextSelected = new Set();
|
||
const nextModes = {};
|
||
state.stlFiles.forEach((file) => {
|
||
const id = normalizedStlId(file);
|
||
if (id != null && !state.selectedStlIds.has(id)) {
|
||
nextSelected.add(id);
|
||
nextModes[id] = "translucent";
|
||
}
|
||
});
|
||
state.selectedStlIds = nextSelected;
|
||
state.stlDisplayModes = nextModes;
|
||
await applyStlSelectionChange();
|
||
}
|
||
|
||
async function applyStlSelectionChange() {
|
||
markDirty("stl");
|
||
syncStlDisplayModesForSelection();
|
||
renderStl();
|
||
const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite));
|
||
const needsLoad = [...state.selectedStlIds].some((id) => !loadedIds.has(Number(id)));
|
||
if (!state.volumeMeta || (!state.modelMeshes.length && state.selectedStlIds.size)) {
|
||
await loadFusion();
|
||
return;
|
||
}
|
||
if (needsLoad) {
|
||
setFusionLoading(true, "正在补加载 STL 模型", 62);
|
||
await buildStlModels(state.volumeMeta, (progress) => setFusionLoading(true, "正在补加载 STL 模型", 62 + progress * 0.3));
|
||
setFusionLoading(false);
|
||
applyModelDetail();
|
||
if (state.sceneReady) fitCamera();
|
||
renderStl();
|
||
return;
|
||
}
|
||
applyStlVisibility();
|
||
applyModelDetail();
|
||
scheduleMappingDraw();
|
||
}
|
||
|
||
function stlLikelyScore(file) {
|
||
const textValue = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
|
||
const priority = [
|
||
["rib", 95],
|
||
["vertebra", 94],
|
||
["spine", 92],
|
||
["sternum", 90],
|
||
["pelvis", 82],
|
||
["skull", 78],
|
||
["bone", 72],
|
||
];
|
||
return priority.reduce((score, [key, value]) => textValue.includes(key) ? Math.max(score, value) : score, 0);
|
||
}
|
||
|
||
function stlDisplayName(file) {
|
||
return String(file.segment_name || file.family || file.file_name || `stl_${file.id || ""}`).replace(/\\.stl$/i, "");
|
||
}
|
||
|
||
function renderAutoBoneOptions() {
|
||
const container = $("autoBoneOptions");
|
||
if (!container) return;
|
||
if (!state.stlFiles.length) {
|
||
container.innerHTML = `<div class="empty-state compact-empty">No STL</div>`;
|
||
return;
|
||
}
|
||
const rows = [...state.stlFiles].sort((a, b) => {
|
||
const diff = stlLikelyScore(b) - stlLikelyScore(a);
|
||
if (diff) return diff;
|
||
return stlDisplayName(a).localeCompare(stlDisplayName(b));
|
||
});
|
||
container.innerHTML = rows.map((file) => {
|
||
const id = Number(file.id);
|
||
const likely = stlLikelyScore(file) > 0;
|
||
const checked = likely ? "checked" : "";
|
||
const title = escapeHtml(`${file.file_name || ""} ${file.family || ""}`.trim());
|
||
return `
|
||
<label title="${title}" class="${likely ? "likely" : ""}">
|
||
<input type="checkbox" value="${id}" ${checked} />
|
||
<span>${escapeHtml(stlDisplayName(file))}</span>
|
||
${likely ? `<em>priority</em>` : ""}
|
||
</label>
|
||
`;
|
||
}).join("");
|
||
}
|
||
|
||
function cssColor(index) {
|
||
return `#${STL_COLORS[index % STL_COLORS.length].toString(16).padStart(6, "0")}`;
|
||
}
|
||
|
||
function formatBytes(value) {
|
||
const n = Number(value || 0);
|
||
if (!n) return "大小未知";
|
||
if (n > 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||
return `${(n / 1024).toFixed(1)} KB`;
|
||
}
|
||
|
||
function buildPoseControls() {
|
||
$("poseGrid").innerHTML = POSE_CONTROLS.map(
|
||
(item) => `
|
||
<div class="pose-control" data-pose="${item.key}">
|
||
<label>${item.label}</label>
|
||
<button type="button" data-nudge="${item.key}" data-delta="${-item.step}">-</button>
|
||
<input type="range" min="${item.min}" max="${item.max}" step="${item.step}" />
|
||
<button type="button" data-nudge="${item.key}" data-delta="${item.step}">+</button>
|
||
<input type="number" min="${item.min}" max="${item.max}" step="${item.step}" />
|
||
${item.key.startsWith("rotate") ? `<div class="quick-row"><button type="button" data-nudge="${item.key}" data-delta="-90">-90°</button><button type="button" data-nudge="${item.key}" data-delta="90">+90°</button></div>` : ""}
|
||
</div>
|
||
`,
|
||
).join("");
|
||
$("poseGrid").querySelectorAll(".pose-control").forEach((row) => {
|
||
const key = row.dataset.pose;
|
||
const range = row.querySelector("input[type='range']");
|
||
const number = row.querySelector("input[type='number']");
|
||
const option = POSE_CONTROLS.find((item) => item.key === key);
|
||
const sync = (value, source) => {
|
||
if (state.poseControlsLocked) return;
|
||
const numeric = Number(value);
|
||
state.pose[key] = Number.isFinite(numeric) ? Number(numeric.toFixed(option?.decimals ?? 3)) : DEFAULT_POSE[key];
|
||
if (source !== range) range.value = state.pose[key];
|
||
if (source !== number) number.value = state.pose[key];
|
||
applyPose();
|
||
markDirty();
|
||
};
|
||
range.addEventListener("input", () => sync(range.value, range));
|
||
number.addEventListener("input", () => sync(number.value, number));
|
||
});
|
||
document.querySelectorAll("[data-nudge]").forEach((button) => wireHoldNudge(button));
|
||
document.querySelectorAll("[data-flip]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
if (state.poseControlsLocked) return;
|
||
const key = button.dataset.flip;
|
||
state.pose[key] = !state.pose[key];
|
||
renderPoseControls();
|
||
applyPose();
|
||
markDirty();
|
||
});
|
||
});
|
||
updateAutoPoseResult(state.pose, state.pose);
|
||
applyPoseLockState();
|
||
}
|
||
|
||
function wireHoldNudge(button) {
|
||
const fixedDelta = Number(button.dataset.delta || 0);
|
||
if (Math.abs(fixedDelta) >= 10) {
|
||
button.addEventListener("click", () => nudgePose(button.dataset.nudge, fixedDelta));
|
||
return;
|
||
}
|
||
const start = (event) => {
|
||
if (state.poseControlsLocked || button.disabled) return;
|
||
if (event.button !== undefined && event.button !== 0) return;
|
||
event.preventDefault();
|
||
const key = button.dataset.nudge;
|
||
const delta = Number(button.dataset.delta || 0);
|
||
nudgePose(key, delta);
|
||
clearNudgeTimers();
|
||
state.nudgeDelayTimer = window.setTimeout(() => {
|
||
state.nudgeTimer = window.setInterval(() => nudgePose(key, delta), 54);
|
||
}, 260);
|
||
button.setPointerCapture?.(event.pointerId);
|
||
};
|
||
const stop = (event) => {
|
||
clearNudgeTimers();
|
||
if (event?.pointerId !== undefined && button.hasPointerCapture?.(event.pointerId)) {
|
||
button.releasePointerCapture(event.pointerId);
|
||
}
|
||
};
|
||
button.addEventListener("pointerdown", start);
|
||
button.addEventListener("pointerup", stop);
|
||
button.addEventListener("pointercancel", stop);
|
||
button.addEventListener("pointerleave", stop);
|
||
}
|
||
|
||
function clearNudgeTimers() {
|
||
window.clearTimeout(state.nudgeDelayTimer);
|
||
window.clearInterval(state.nudgeTimer);
|
||
state.nudgeDelayTimer = null;
|
||
state.nudgeTimer = null;
|
||
}
|
||
|
||
function nudgePose(key, delta) {
|
||
if (state.poseControlsLocked) return;
|
||
if (!key || !Number.isFinite(delta)) return;
|
||
const option = POSE_CONTROLS.find((item) => item.key === key);
|
||
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
|
||
const next = Math.max(option?.min ?? -Infinity, Math.min(option?.max ?? Infinity, current + delta));
|
||
state.pose[key] = Number(next.toFixed(option?.decimals ?? 3));
|
||
renderPoseControls();
|
||
applyPose();
|
||
markDirty();
|
||
}
|
||
|
||
function renderPoseControls() {
|
||
POSE_CONTROLS.forEach((item) => {
|
||
const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`);
|
||
if (!row) return;
|
||
const value = Number(state.pose[item.key] ?? DEFAULT_POSE[item.key]);
|
||
row.querySelector("input[type='range']").value = value;
|
||
row.querySelector("input[type='number']").value = Number.isInteger(value) ? value : value.toFixed(item.decimals ?? 3);
|
||
});
|
||
document.querySelectorAll("[data-flip]").forEach((button) => {
|
||
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
|
||
});
|
||
renderActiveHeader();
|
||
updateStretchButtons();
|
||
applyPoseLockState();
|
||
}
|
||
|
||
function isRightAngleRotation(value) {
|
||
const normalized = ((Number(value) || 0) % 90 + 90) % 90;
|
||
return normalized < 0.001 || Math.abs(normalized - 90) < 0.001;
|
||
}
|
||
|
||
function canStretchByAxis() {
|
||
return ["rotateX", "rotateY", "rotateZ"].every((key) => isRightAngleRotation(state.pose[key]));
|
||
}
|
||
|
||
function updateStretchButtons() {
|
||
const enabled = canStretchByAxis() && !state.poseControlsLocked;
|
||
["x", "y", "z"].forEach((axis) => {
|
||
const button = $(`stretch${axis.toUpperCase()}Btn`);
|
||
if (!button) return;
|
||
button.disabled = !enabled;
|
||
button.title = enabled
|
||
? `按 ${axis.toUpperCase()} 方向自动等比例拉伸模型`
|
||
: state.poseControlsLocked
|
||
? "自动迭代运行中,暂不可调整位姿"
|
||
: "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可用";
|
||
});
|
||
}
|
||
|
||
function renderPoseSectionCollapse() {
|
||
const manualCollapsed = Boolean(state.collapsedPoseSections.manual);
|
||
const autoCollapsed = Boolean(state.collapsedPoseSections.auto);
|
||
$("poseManualBody")?.classList.toggle("collapsed", manualCollapsed);
|
||
$("poseAutoBody")?.classList.toggle("collapsed", autoCollapsed);
|
||
$("poseManualToggle")?.classList.toggle("collapsed", manualCollapsed);
|
||
$("poseAutoToggle")?.classList.toggle("collapsed", autoCollapsed);
|
||
}
|
||
|
||
function applyPoseLockState() {
|
||
const locked = Boolean(state.poseControlsLocked);
|
||
const selectors = [
|
||
"#poseGrid input",
|
||
"#poseGrid button",
|
||
"[data-flip]",
|
||
"#resetRotationBtn",
|
||
"#resetTransformBtn",
|
||
"#resetFlipBtn",
|
||
"#openAutoModalBtn",
|
||
"#autoSettingsPanel input",
|
||
"#autoSettingsPanel button",
|
||
"#savePoseBtn",
|
||
"#saveIterateBtn",
|
||
"#restorePoseBtn",
|
||
"#saveBtn",
|
||
"#statusBtn",
|
||
"#autoCoarseBtn",
|
||
"#autoFineBtn",
|
||
];
|
||
selectors.forEach((selector) => {
|
||
document.querySelectorAll(selector).forEach((element) => {
|
||
element.disabled = locked;
|
||
});
|
||
});
|
||
document.querySelectorAll(".pose-control, .flip-row, .pose-actions, .auto-settings-panel").forEach((element) => {
|
||
element.classList.toggle("locked", locked);
|
||
});
|
||
updateStretchButtons();
|
||
}
|
||
|
||
function setPoseEditingLocked(locked) {
|
||
state.poseControlsLocked = Boolean(locked);
|
||
if (locked) clearNudgeTimers();
|
||
applyPoseLockState();
|
||
}
|
||
|
||
function updateSliceControl() {
|
||
const selected = currentSeries();
|
||
const count = Number(selected?.count || 0);
|
||
const max = Math.max(0, count - 1);
|
||
state.sliceIndex = Math.max(0, Math.min(state.sliceIndex, max));
|
||
state.sliceRangeStart = Math.max(0, Math.min(Number(state.sliceRangeStart) || 0, max));
|
||
state.sliceRangeEnd = Math.max(0, Math.min(Number(state.sliceRangeEnd) || max, max));
|
||
if (state.sliceRangeStart > state.sliceRangeEnd) [state.sliceRangeStart, state.sliceRangeEnd] = [state.sliceRangeEnd, state.sliceRangeStart];
|
||
$("sliceRangeStart").max = max;
|
||
$("sliceRangeEnd").max = max;
|
||
$("sliceRangeStart").value = state.sliceRangeStart;
|
||
$("sliceRangeEnd").value = state.sliceRangeEnd;
|
||
$("sliceRangeLabel").textContent = count ? `${state.sliceRangeStart + 1} - ${state.sliceRangeEnd + 1} / ${count}` : "0 - 0 / 0";
|
||
$("sliceStartLabel").textContent = count ? `起点 ${state.sliceRangeStart + 1}` : "起点 0";
|
||
$("sliceEndLabel").textContent = count ? `终点 ${state.sliceRangeEnd + 1}` : "终点 0";
|
||
$("mappingSliceSlider").max = max;
|
||
$("mappingSliceSlider").value = max - state.sliceIndex;
|
||
$("mappingSliceLabel").textContent = count ? `${state.sliceIndex + 1} / ${count}` : "0 / 0";
|
||
}
|
||
|
||
function renderDicomAnnotation() {
|
||
const row = state.activeCase;
|
||
const selected = currentSeries();
|
||
const count = Number(selected?.count || 0);
|
||
$("mappingSummaryTitle").textContent = state.mappingMode === "image" ? "单独影像" : "可见 STL 构件";
|
||
$("dicomInfoLeft").textContent = row && selected
|
||
? `${row.patient_name || row.upp_patient_name || "-"}\n${row.patient_id || "-"}\n${fmtDateTime(row.study_date, row.study_time) || "-"}\n${selected.description || "-"}`
|
||
: "未选择序列";
|
||
$("dicomInfoRight").textContent = selected
|
||
? `${selected.modality || "CT"}\n${selected.rows || "-"}×${selected.columns || "-"}\n${selected.slice_thickness || "厚度 -"}`
|
||
: "";
|
||
$("dicomInfoBottom").textContent = count ? `Img:${state.sliceIndex + 1}/${count}` : "Img:-";
|
||
renderActiveHeader();
|
||
}
|
||
|
||
function updateDicomPreview() {
|
||
const selected = currentSeries();
|
||
if (!state.activeCase || !selected) {
|
||
$("dicomPreview").removeAttribute("src");
|
||
renderDicomAnnotation();
|
||
resetMappingCanvas("未选择 DICOM 序列");
|
||
return;
|
||
}
|
||
window.clearTimeout(state.dicomPreviewTimer);
|
||
state.dicomPreviewTimer = window.setTimeout(() => {
|
||
const params = new URLSearchParams({
|
||
ct_number: state.activeCase.ct_number,
|
||
series_uid: selected.series_uid,
|
||
index: String(state.sliceIndex),
|
||
window: state.windowMode,
|
||
access_token: state.token,
|
||
});
|
||
setDicomLoading(true, "正在加载 DICOM Base Layer", 16);
|
||
resetMappingCanvas("正在加载 DICOM Base Layer");
|
||
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
|
||
renderDicomAnnotation();
|
||
}, 80);
|
||
}
|
||
|
||
function initScene() {
|
||
if (state.sceneReady) return;
|
||
const viewport = $("fusionViewport");
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||
renderer.setClearColor(0x030712, 1);
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||
renderer.localClippingEnabled = true;
|
||
viewport.appendChild(renderer.domElement);
|
||
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x030712);
|
||
|
||
const camera = new THREE.PerspectiveCamera(45, 1, 0.05, 1000);
|
||
camera.position.set(0, -6.2, 4.6);
|
||
camera.up.set(0, 0, 1);
|
||
camera.lookAt(0, 0, 0);
|
||
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
controls.enabled = false;
|
||
controls.target.set(0, 0, 0);
|
||
|
||
const ambient = new THREE.AmbientLight(0xffffff, 0.72);
|
||
const keyLight = new THREE.DirectionalLight(0xffffff, 1.1);
|
||
keyLight.position.set(4, -5, 5);
|
||
const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.55);
|
||
fillLight.position.set(-4, 3, 2);
|
||
scene.add(ambient, keyLight, fillLight);
|
||
|
||
const dicomGroup = new THREE.Group();
|
||
const modelGroup = new THREE.Group();
|
||
const rawModelGroup = new THREE.Group();
|
||
const fusionRoot = new THREE.Group();
|
||
modelGroup.add(rawModelGroup);
|
||
fusionRoot.add(dicomGroup, modelGroup);
|
||
scene.add(fusionRoot);
|
||
|
||
Object.assign(state, { renderer, camera, scene, controls, fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup: null, sceneReady: true });
|
||
|
||
const resize = () => {
|
||
const rect = viewport.getBoundingClientRect();
|
||
if (!rect.width || !rect.height) return;
|
||
renderer.setSize(rect.width, rect.height, false);
|
||
camera.aspect = rect.width / rect.height;
|
||
camera.updateProjectionMatrix();
|
||
};
|
||
state.resizeScene = resize;
|
||
state.resizeObserver = new ResizeObserver(resize);
|
||
state.resizeObserver.observe(viewport);
|
||
resize();
|
||
|
||
const animate = () => {
|
||
requestAnimationFrame(animate);
|
||
applySceneViewPose();
|
||
updateFusionAxisInset();
|
||
renderer.render(scene, camera);
|
||
};
|
||
animate();
|
||
wireFusionSceneDrag(renderer.domElement);
|
||
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
|
||
}
|
||
|
||
function axisProjectionSignature(projection) {
|
||
return ["x", "y", "z"]
|
||
.map((key) => {
|
||
const item = projection[key] || DEFAULT_AXIS_PROJECTION[key];
|
||
return `${Math.round(item.dx * 10)},${Math.round(item.dy * 10)},${Math.round(item.opacity * 100)}`;
|
||
})
|
||
.join("|");
|
||
}
|
||
|
||
function projectModelAxisDirections(camera, object) {
|
||
const origin = object.getWorldPosition(new THREE.Vector3());
|
||
const originProjected = origin.clone().project(camera);
|
||
const quaternion = object.getWorldQuaternion(new THREE.Quaternion());
|
||
const axisDirections = {
|
||
x: new THREE.Vector3(1, 0, 0),
|
||
y: new THREE.Vector3(0, 1, 0),
|
||
z: new THREE.Vector3(0, 0, 1),
|
||
};
|
||
const projectAxis = (direction) => {
|
||
const end = origin.clone().add(direction.applyQuaternion(quaternion).normalize().multiplyScalar(0.72));
|
||
const endProjected = end.project(camera);
|
||
const dx = endProjected.x - originProjected.x;
|
||
const dy = originProjected.y - endProjected.y;
|
||
const magnitude = Math.hypot(dx, dy);
|
||
if (magnitude < 0.0001) return { dx: 0, dy: -5, opacity: 0.5 };
|
||
return {
|
||
dx: (dx / magnitude) * AXIS_INSET_LENGTH,
|
||
dy: (dy / magnitude) * AXIS_INSET_LENGTH,
|
||
opacity: endProjected.z < originProjected.z ? 1 : 0.58,
|
||
};
|
||
};
|
||
return {
|
||
x: projectAxis(axisDirections.x),
|
||
y: projectAxis(axisDirections.y),
|
||
z: projectAxis(axisDirections.z),
|
||
};
|
||
}
|
||
|
||
function updateFusionAxisInset(projection = null) {
|
||
if (!projection) {
|
||
if (!state.camera || !state.modelGroup) projection = DEFAULT_AXIS_PROJECTION;
|
||
else {
|
||
state.fusionRoot?.updateMatrixWorld(true);
|
||
state.modelGroup.updateMatrixWorld(true);
|
||
projection = projectModelAxisDirections(state.camera, state.modelGroup);
|
||
}
|
||
}
|
||
const signature = axisProjectionSignature(projection);
|
||
if (state.axisProjectionSignature === signature) return;
|
||
state.axisProjectionSignature = signature;
|
||
const origin = { x: 25, y: 31 };
|
||
const items = {
|
||
x: { line: $("fusionAxisXLine"), text: $("fusionAxisXText"), group: $("fusionAxisX") },
|
||
y: { line: $("fusionAxisYLine"), text: $("fusionAxisYText"), group: $("fusionAxisY") },
|
||
z: { line: $("fusionAxisZLine"), text: $("fusionAxisZText"), group: $("fusionAxisZ") },
|
||
};
|
||
Object.entries(items).forEach(([key, item]) => {
|
||
const vector = projection[key] || DEFAULT_AXIS_PROJECTION[key];
|
||
const endX = origin.x + vector.dx;
|
||
const endY = origin.y + vector.dy;
|
||
item.group?.setAttribute("opacity", String(vector.opacity));
|
||
item.line?.setAttribute("x2", String(endX));
|
||
item.line?.setAttribute("y2", String(endY));
|
||
item.text?.setAttribute("x", String(endX + (vector.dx >= 0 ? 4 : -4)));
|
||
item.text?.setAttribute("y", String(endY + (vector.dy >= 0 ? 6 : -3)));
|
||
item.text?.setAttribute("text-anchor", vector.dx >= 0 ? "start" : "end");
|
||
});
|
||
}
|
||
|
||
function createAxisLabel(text, color) {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = 96;
|
||
canvas.height = 64;
|
||
const context = canvas.getContext("2d");
|
||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||
context.fillStyle = color;
|
||
context.font = "900 42px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
||
context.textAlign = "center";
|
||
context.textBaseline = "middle";
|
||
context.fillText(text, 48, 34);
|
||
const texture = new THREE.CanvasTexture(canvas);
|
||
texture.colorSpace = THREE.SRGBColorSpace;
|
||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }));
|
||
sprite.scale.set(0.28, 0.18, 1);
|
||
return sprite;
|
||
}
|
||
|
||
function createSceneAxes() {
|
||
const group = new THREE.Group();
|
||
const length = 0.78;
|
||
const headLength = 0.16;
|
||
const headWidth = 0.08;
|
||
const axes = [
|
||
{ label: "X", color: 0xf87171, css: "#f87171", dir: new THREE.Vector3(1, 0, 0) },
|
||
{ label: "Y", color: 0x4ade80, css: "#4ade80", dir: new THREE.Vector3(0, 1, 0) },
|
||
{ label: "Z", color: 0x60a5fa, css: "#60a5fa", dir: new THREE.Vector3(0, 0, 1) },
|
||
];
|
||
axes.forEach((axis) => {
|
||
const arrow = new THREE.ArrowHelper(axis.dir, new THREE.Vector3(0, 0, 0), length, axis.color, headLength, headWidth);
|
||
const label = createAxisLabel(axis.label, axis.css);
|
||
label.position.copy(axis.dir.clone().multiplyScalar(length + 0.16));
|
||
group.add(arrow, label);
|
||
});
|
||
group.visible = true;
|
||
return group;
|
||
}
|
||
|
||
function initGeometryFallback() {
|
||
if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return;
|
||
const fusionRoot = new THREE.Group();
|
||
const dicomGroup = new THREE.Group();
|
||
const modelGroup = new THREE.Group();
|
||
const rawModelGroup = new THREE.Group();
|
||
modelGroup.add(rawModelGroup);
|
||
fusionRoot.add(dicomGroup, modelGroup);
|
||
Object.assign(state, { fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup: null });
|
||
}
|
||
|
||
function applySceneViewPose() {
|
||
if (!state.fusionRoot) return;
|
||
const pose = state.viewPose || {};
|
||
state.fusionRoot.rotation.set(Number(pose.rotateX) || 0, 0, Number(pose.rotateZ) || 0);
|
||
state.fusionRoot.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, 0);
|
||
state.fusionRoot.scale.setScalar(Math.max(0.2, Number(pose.scale) || 1));
|
||
state.fusionRoot.updateMatrixWorld(true);
|
||
}
|
||
|
||
function wireFusionSceneDrag(canvas) {
|
||
if (!canvas) return;
|
||
canvas.style.touchAction = "none";
|
||
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
|
||
canvas.addEventListener("pointerdown", (event) => {
|
||
event.preventDefault();
|
||
state.sceneDrag = {
|
||
pointerId: event.pointerId,
|
||
mode: event.button === 2 || event.shiftKey ? "pan" : "rotate",
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
pose: { ...state.viewPose },
|
||
};
|
||
canvas.setPointerCapture?.(event.pointerId);
|
||
});
|
||
canvas.addEventListener("pointermove", (event) => {
|
||
const drag = state.sceneDrag;
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
event.preventDefault();
|
||
const dx = event.clientX - drag.startX;
|
||
const dy = event.clientY - drag.startY;
|
||
if (drag.mode === "pan") {
|
||
state.viewPose.translateX = drag.pose.translateX + dx * 0.006;
|
||
state.viewPose.translateY = drag.pose.translateY - dy * 0.006;
|
||
} else {
|
||
state.viewPose.rotateZ = drag.pose.rotateZ + dx * 0.008;
|
||
state.viewPose.rotateX = drag.pose.rotateX + dy * 0.008;
|
||
}
|
||
applySceneViewPose();
|
||
});
|
||
const stop = (event) => {
|
||
if (state.sceneDrag?.pointerId !== event.pointerId) return;
|
||
state.sceneDrag = null;
|
||
canvas.releasePointerCapture?.(event.pointerId);
|
||
};
|
||
canvas.addEventListener("pointerup", stop);
|
||
canvas.addEventListener("pointercancel", stop);
|
||
canvas.addEventListener("wheel", (event) => {
|
||
event.preventDefault();
|
||
state.viewPose.scale = Math.max(0.35, Math.min(3, state.viewPose.scale - event.deltaY * 0.001));
|
||
applySceneViewPose();
|
||
}, { passive: false });
|
||
}
|
||
|
||
function clearGroup(group) {
|
||
if (!group) return;
|
||
while (group.children.length) {
|
||
const child = group.children.pop();
|
||
child.traverse?.((node) => {
|
||
node.geometry?.dispose?.();
|
||
if (node.material) {
|
||
if (Array.isArray(node.material)) node.material.forEach((material) => material.dispose?.());
|
||
else node.material.dispose?.();
|
||
}
|
||
node.material?.map?.dispose?.();
|
||
});
|
||
}
|
||
}
|
||
|
||
function clearFusion() {
|
||
clearGroup(state.dicomGroup);
|
||
clearGroup(state.rawModelGroup);
|
||
if (state.rawModelGroup) {
|
||
state.rawModelGroup.position.set(0, 0, 0);
|
||
state.rawModelGroup.rotation.set(0, 0, 0);
|
||
state.rawModelGroup.scale.set(1, 1, 1);
|
||
}
|
||
state.volumeMeta = null;
|
||
state.volumeScene = null;
|
||
state.modelMeshes = [];
|
||
state.dicomPlane = null;
|
||
state.dicomPoints = null;
|
||
state.dicomSlicePlanes = [];
|
||
state.modelBoundsFrame = null;
|
||
state.modelLocalBounds = null;
|
||
$("fusionMeta").textContent = "DICOM - · STL -";
|
||
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
|
||
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
|
||
updateSliceControl();
|
||
resetMappingCanvas();
|
||
}
|
||
|
||
function applyFusionMode() {
|
||
if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model";
|
||
if (state.modelGroup) state.modelGroup.visible = true;
|
||
updateFusionMeta();
|
||
}
|
||
|
||
function updateFusionMeta() {
|
||
const selectedCount = visibleModelRecords().length || state.selectedStlIds.size;
|
||
if (state.fusionMode === "model") {
|
||
$("fusionMeta").textContent = `单独模型 · STL ${selectedCount} 个`;
|
||
return;
|
||
}
|
||
const volume = state.volumeMeta;
|
||
if (!volume) {
|
||
$("fusionMeta").textContent = `DICOM - · STL ${selectedCount} 个`;
|
||
return;
|
||
}
|
||
const displayRange = getDicomDisplayRange(volume.start, volume.end, volume.total);
|
||
$("fusionMeta").textContent = `DICOM ${displayRange.start}-${displayRange.end}/${volume.total} · STL ${selectedCount} 个`;
|
||
}
|
||
|
||
function fusionDetailSettings() {
|
||
return {
|
||
low: { planeOpacity: 0.42, sliceOpacity: 0.045, boxOpacity: 0.2, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 },
|
||
medium: { planeOpacity: 0.56, sliceOpacity: 0.075, boxOpacity: 0.26, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 },
|
||
high: { planeOpacity: 0.68, sliceOpacity: 0.11, boxOpacity: 0.32, slicePlanes: 72, pointOpacity: 0, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 1024 },
|
||
}[state.fusionDetail] || { planeOpacity: 0.42, sliceOpacity: 0.045, boxOpacity: 0.2, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 };
|
||
}
|
||
|
||
function modelDetailSettings() {
|
||
return {
|
||
standard: { opacity: 0.52, wireframe: false, depthWrite: false, metalness: 0.08, roughness: 0.55 },
|
||
fine: { opacity: 0.66, wireframe: false, depthWrite: false, metalness: 0.04, roughness: 0.48 },
|
||
ultra: { opacity: 0.82, wireframe: false, depthWrite: false, metalness: 0.02, roughness: 0.42 },
|
||
solid: { opacity: 1, wireframe: false, depthWrite: true, metalness: 0.02, roughness: 0.36 },
|
||
}[state.modelDetail] || { opacity: 0.52, wireframe: false, depthWrite: false, metalness: 0.08, roughness: 0.55 };
|
||
}
|
||
|
||
function applyFusionDetail() {
|
||
const detail = fusionDetailSettings();
|
||
if (state.dicomPlane?.material) {
|
||
state.dicomPlane.material.opacity = detail.planeOpacity;
|
||
state.dicomPlane.material.transparent = true;
|
||
state.dicomPlane.material.depthWrite = false;
|
||
state.dicomPlane.material.needsUpdate = true;
|
||
}
|
||
if (state.dicomPoints?.material) {
|
||
state.dicomPoints.material.opacity = detail.pointOpacity;
|
||
state.dicomPoints.material.size = detail.pointSize;
|
||
state.dicomPoints.material.needsUpdate = true;
|
||
}
|
||
state.dicomSlicePlanes.forEach((plane) => {
|
||
if (!plane.material) return;
|
||
plane.material.opacity = plane.userData?.rangeBoundary ? detail.planeOpacity : detail.sliceOpacity;
|
||
plane.material.needsUpdate = true;
|
||
});
|
||
}
|
||
|
||
function applyModelDetail() {
|
||
const detail = modelDetailSettings();
|
||
state.modelMeshes.forEach((record) => {
|
||
const material = record.mesh.material;
|
||
if (!material) return;
|
||
const mode = stlMode(record.file);
|
||
const opacity = mode === "solid" ? 1 : Math.min(0.52, detail.opacity);
|
||
material.opacity = opacity;
|
||
material.transparent = opacity < 1;
|
||
material.depthWrite = mode === "solid" ? true : false;
|
||
material.wireframe = detail.wireframe;
|
||
material.metalness = detail.metalness;
|
||
material.roughness = detail.roughness;
|
||
material.needsUpdate = true;
|
||
});
|
||
scheduleMappingDraw();
|
||
}
|
||
|
||
function applyStlVisibility() {
|
||
state.modelMeshes.forEach((record) => {
|
||
record.mesh.visible = stlMode(record.file) !== "hidden";
|
||
});
|
||
if (state.modelBoundsFrame) state.modelBoundsFrame.visible = state.modelMeshes.some((record) => record.mesh.visible);
|
||
if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model";
|
||
if (state.modelGroup) state.modelGroup.visible = true;
|
||
updateFusionMeta();
|
||
scheduleMappingDraw();
|
||
}
|
||
|
||
function syncDisplayControls() {
|
||
document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item.dataset.fusionDetail === state.fusionDetail));
|
||
document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item.dataset.modelDetail === state.modelDetail));
|
||
document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode));
|
||
}
|
||
|
||
async function loadFusion() {
|
||
const version = ++state.fusionVersion;
|
||
const selected = currentSeries();
|
||
if (!state.sceneReady) initGeometryFallback();
|
||
if (!state.activeCase || !selected) {
|
||
clearFusion();
|
||
return;
|
||
}
|
||
$("fusionStatus").textContent = "正在构建三维融合场景...";
|
||
setFusionLoading(true, "正在读取 DICOM 切片范围", 8);
|
||
updateSliceControl();
|
||
try {
|
||
const params = new URLSearchParams({
|
||
ct_number: state.activeCase.ct_number,
|
||
series_uid: selected.series_uid,
|
||
center_index: String(state.sliceIndex),
|
||
window: state.windowMode,
|
||
detail: state.fusionDetail,
|
||
texture_size: String(fusionDetailSettings().textureSize),
|
||
range_start: String(state.sliceRangeStart),
|
||
range_end: String(state.sliceRangeEnd),
|
||
});
|
||
const volume = await api(`/api/dicom/fusion-volume?${params.toString()}`);
|
||
if (version !== state.fusionVersion) return;
|
||
state.volumeMeta = volume;
|
||
setFusionLoading(true, "正在构建 DICOM 切片范围", 38);
|
||
await buildDicomVolume(volume);
|
||
applyFusionDetail();
|
||
if (version !== state.fusionVersion) return;
|
||
setFusionLoading(true, "正在加载 STL 模型", 62);
|
||
await buildStlModels(volume, (progress) => setFusionLoading(true, "正在加载 STL 模型", 62 + progress * 0.3));
|
||
applyModelDetail();
|
||
if (version !== state.fusionVersion) return;
|
||
if (state.sceneReady) {
|
||
applyFusionMode();
|
||
fitCamera();
|
||
}
|
||
$("fusionStatus").textContent = state.sceneReady
|
||
? "三维融合场景已就绪"
|
||
: "2D 映射已就绪,当前浏览器未启用 WebGL";
|
||
updateFusionMeta();
|
||
renderDicomAnnotation();
|
||
setFusionLoading(true, "正在渲染右侧分割映射", 96);
|
||
updateDicomPreview();
|
||
} catch (error) {
|
||
clearFusion();
|
||
$("fusionStatus").textContent = error.message;
|
||
} finally {
|
||
if (version === state.fusionVersion) setFusionLoading(false);
|
||
}
|
||
}
|
||
|
||
async function loadTexture(url) {
|
||
return new Promise((resolve, reject) => {
|
||
new THREE.TextureLoader().load(url, (texture) => {
|
||
texture.colorSpace = THREE.SRGBColorSpace;
|
||
texture.generateMipmaps = false;
|
||
texture.minFilter = THREE.LinearFilter;
|
||
texture.magFilter = THREE.NearestFilter;
|
||
texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1);
|
||
texture.needsUpdate = true;
|
||
resolve(texture);
|
||
}, undefined, reject);
|
||
});
|
||
}
|
||
|
||
async function loadDicomFusionTexture(url) {
|
||
const image = await new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.onload = () => resolve(img);
|
||
img.onerror = reject;
|
||
img.src = url;
|
||
});
|
||
const width = image.naturalWidth || image.width || 1;
|
||
const height = image.naturalHeight || image.height || 1;
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||
context.drawImage(image, 0, 0, width, height);
|
||
const imageData = context.getImageData(0, 0, width, height);
|
||
for (let index = 0; index < imageData.data.length; index += 4) {
|
||
const value = imageData.data[index];
|
||
imageData.data[index + 1] = value;
|
||
imageData.data[index + 2] = value;
|
||
imageData.data[index + 3] = value > 4 ? Math.min(235, 42 + Math.round(value * 0.76)) : 0;
|
||
}
|
||
context.putImageData(imageData, 0, 0);
|
||
const texture = new THREE.CanvasTexture(canvas);
|
||
texture.colorSpace = THREE.SRGBColorSpace;
|
||
texture.generateMipmaps = false;
|
||
texture.minFilter = THREE.LinearFilter;
|
||
texture.magFilter = THREE.LinearFilter;
|
||
texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1);
|
||
texture.needsUpdate = true;
|
||
return texture;
|
||
}
|
||
|
||
async function loadImageData(url, maxSize = 256) {
|
||
const image = await new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.onload = () => resolve(img);
|
||
img.onerror = reject;
|
||
img.src = url;
|
||
});
|
||
const scale = Math.min(1, maxSize / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height, 1));
|
||
const width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale));
|
||
const height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale));
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||
context.drawImage(image, 0, 0, width, height);
|
||
return { width, height, data: context.getImageData(0, 0, width, height).data };
|
||
}
|
||
|
||
function sliceToSceneZ(sliceIndex, volumeScene = state.volumeScene) {
|
||
if (!volumeScene) return 0;
|
||
return volumeScene.total <= 1
|
||
? 0
|
||
: -volumeScene.depth / 2 + (volumeScene.depth * Math.max(0, Math.min(sliceIndex, volumeScene.total - 1))) / (volumeScene.total - 1);
|
||
}
|
||
|
||
function parseCssColor(color) {
|
||
const value = String(color || "#19d6c3").replace("#", "");
|
||
const hex = value.length === 3 ? value.split("").map((char) => char + char).join("") : value.padStart(6, "0").slice(0, 6);
|
||
const intValue = Number.parseInt(hex, 16);
|
||
return {
|
||
r: (intValue >> 16) & 255,
|
||
g: (intValue >> 8) & 255,
|
||
b: intValue & 255,
|
||
};
|
||
}
|
||
|
||
function resetMappingCanvas(message = "当前切片暂无可见构件") {
|
||
const canvas = $("mappingCanvas");
|
||
const context = canvas?.getContext("2d");
|
||
if (context) context.clearRect(0, 0, canvas.width || 1, canvas.height || 1);
|
||
$("mappingStats").textContent = "0/0 构件 · 0 边 · 0 px";
|
||
$("mappingLegend").innerHTML = `<div class="empty-state">${escapeHtml(message)}</div>`;
|
||
}
|
||
|
||
function scheduleMappingDraw() {
|
||
window.cancelAnimationFrame(state.mappingDrawFrame);
|
||
const version = ++state.mappingVersion;
|
||
state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView(version));
|
||
}
|
||
|
||
function applyMappingTransform() {
|
||
const layer = $("mappingLayer");
|
||
if (!layer) return;
|
||
const { scale, offsetX, offsetY, rotation } = state.mappingViewport;
|
||
layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) rotate(${rotation || 0}deg) scale(${scale})`;
|
||
}
|
||
|
||
function intersectEdgeWithZ(a, b, targetZ) {
|
||
const da = a.z - targetZ;
|
||
const db = b.z - targetZ;
|
||
if (Math.abs(da) < 1e-5 && Math.abs(db) < 1e-5) return [a.clone(), b.clone()];
|
||
if (Math.abs(da) < 1e-5) return [a.clone()];
|
||
if (Math.abs(db) < 1e-5) return [b.clone()];
|
||
if ((da > 0 && db > 0) || (da < 0 && db < 0)) return [];
|
||
const t = da / (da - db);
|
||
return [new THREE.Vector3(
|
||
a.x + (b.x - a.x) * t,
|
||
a.y + (b.y - a.y) * t,
|
||
targetZ,
|
||
)];
|
||
}
|
||
|
||
function uniquePlanePoints(points) {
|
||
const unique = [];
|
||
points.forEach((point) => {
|
||
if (!unique.some((item) => item.distanceToSquared(point) < 1e-8)) unique.push(point);
|
||
});
|
||
return unique;
|
||
}
|
||
|
||
function intersectTriangleWithZ(a, b, c, targetZ) {
|
||
const points = uniquePlanePoints([
|
||
...intersectEdgeWithZ(a, b, targetZ),
|
||
...intersectEdgeWithZ(b, c, targetZ),
|
||
...intersectEdgeWithZ(c, a, targetZ),
|
||
]);
|
||
if (points.length < 2) return null;
|
||
return { a: points[0], b: points[1] };
|
||
}
|
||
|
||
function addSegmentIntersectionsToRows(rows, width, height, segment) {
|
||
const a = segment.a;
|
||
const b = segment.b;
|
||
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return;
|
||
const deltaY = b.y - a.y;
|
||
if (Math.abs(deltaY) < 0.01) {
|
||
return;
|
||
}
|
||
const minY = Math.max(0, Math.floor(Math.min(a.y, b.y)));
|
||
const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y)));
|
||
for (let row = minY; row <= maxY; row += 1) {
|
||
const sampleY = row + 0.5;
|
||
const crosses = (sampleY >= a.y && sampleY < b.y) || (sampleY >= b.y && sampleY < a.y);
|
||
if (!crosses) continue;
|
||
const t = (sampleY - a.y) / deltaY;
|
||
const x = a.x + (b.x - a.x) * t;
|
||
if (Number.isFinite(x)) rows[row].push(x);
|
||
}
|
||
}
|
||
|
||
function groupPlaneSegmentsByConnectivity(segments, tolerance = 1.35) {
|
||
if (segments.length <= 1) return segments.length ? [segments] : [];
|
||
const parents = segments.map((_, index) => index);
|
||
const find = (index) => {
|
||
if (parents[index] !== index) parents[index] = find(parents[index]);
|
||
return parents[index];
|
||
};
|
||
const union = (left, right) => {
|
||
const leftRoot = find(left);
|
||
const rightRoot = find(right);
|
||
if (leftRoot !== rightRoot) parents[rightRoot] = leftRoot;
|
||
};
|
||
const buckets = new Map();
|
||
const cellSize = Math.max(tolerance, 0.1);
|
||
const toleranceSquared = tolerance * tolerance;
|
||
const cellKey = (x, y) => `${x},${y}`;
|
||
segments.forEach((segment, index) => {
|
||
[segment.a, segment.b].forEach((point) => {
|
||
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
|
||
const cellX = Math.floor(point.x / cellSize);
|
||
const cellY = Math.floor(point.y / cellSize);
|
||
for (let dx = -1; dx <= 1; dx += 1) {
|
||
for (let dy = -1; dy <= 1; dy += 1) {
|
||
const candidates = buckets.get(cellKey(cellX + dx, cellY + dy));
|
||
candidates?.forEach((candidate) => {
|
||
const distanceSquared = (candidate.x - point.x) ** 2 + (candidate.y - point.y) ** 2;
|
||
if (distanceSquared <= toleranceSquared) union(index, candidate.index);
|
||
});
|
||
}
|
||
}
|
||
const key = cellKey(cellX, cellY);
|
||
const bucket = buckets.get(key) ?? [];
|
||
bucket.push({ x: point.x, y: point.y, index });
|
||
buckets.set(key, bucket);
|
||
});
|
||
});
|
||
const groups = new Map();
|
||
segments.forEach((segment, index) => {
|
||
const root = find(index);
|
||
const group = groups.get(root) ?? [];
|
||
group.push(segment);
|
||
groups.set(root, group);
|
||
});
|
||
return [...groups.values()].sort((left, right) => right.length - left.length);
|
||
}
|
||
|
||
function paintMaskPixel(maskData, width, height, x, y, rgb, alpha) {
|
||
if (x < 0 || x >= width || y < 0 || y >= height) return 0;
|
||
const offset = (y * width + x) * 4;
|
||
if (maskData.data[offset + 3] > 0) return 0;
|
||
maskData.data[offset] = rgb.r;
|
||
maskData.data[offset + 1] = rgb.g;
|
||
maskData.data[offset + 2] = rgb.b;
|
||
maskData.data[offset + 3] = alpha;
|
||
return 1;
|
||
}
|
||
|
||
function solidStrokeRadius(width, height) {
|
||
return Math.max(1, Math.min(2.4, Math.max(width, height) * 0.002));
|
||
}
|
||
|
||
function fillSegmentCapsulesIntoMask(maskData, width, height, segments, rgb, alpha, radius) {
|
||
let paintedPixels = 0;
|
||
const radiusSquared = radius * radius;
|
||
segments.forEach(({ a, b }) => {
|
||
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return;
|
||
const dx = b.x - a.x;
|
||
const dy = b.y - a.y;
|
||
const lengthSquared = dx * dx + dy * dy;
|
||
const minX = clamp(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1);
|
||
const maxX = clamp(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1);
|
||
const minY = clamp(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1);
|
||
const maxY = clamp(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1);
|
||
for (let y = minY; y <= maxY; y += 1) {
|
||
for (let x = minX; x <= maxX; x += 1) {
|
||
const px = x + 0.5;
|
||
const py = y + 0.5;
|
||
const t = lengthSquared <= 1e-6
|
||
? 0
|
||
: clamp(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1);
|
||
const closestX = a.x + dx * t;
|
||
const closestY = a.y + dy * t;
|
||
if ((px - closestX) ** 2 + (py - closestY) ** 2 <= radiusSquared) {
|
||
paintedPixels += paintMaskPixel(maskData, width, height, x, y, rgb, alpha);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
return paintedPixels;
|
||
}
|
||
|
||
function closeSmallMaskGaps(maskData, width, height, rgb, alpha, maxGap = 2) {
|
||
const toFill = new Set();
|
||
const hasPixel = (x, y) => maskData.data[(y * width + x) * 4 + 3] > 0;
|
||
const mark = (x, y) => {
|
||
if (x >= 0 && x < width && y >= 0 && y < height && !hasPixel(x, y)) toFill.add(y * width + x);
|
||
};
|
||
for (let y = 0; y < height; y += 1) {
|
||
let lastFilled = -1;
|
||
for (let x = 0; x < width; x += 1) {
|
||
if (!hasPixel(x, y)) continue;
|
||
const gap = x - lastFilled - 1;
|
||
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
|
||
for (let fillX = lastFilled + 1; fillX < x; fillX += 1) mark(fillX, y);
|
||
}
|
||
lastFilled = x;
|
||
}
|
||
}
|
||
for (let x = 0; x < width; x += 1) {
|
||
let lastFilled = -1;
|
||
for (let y = 0; y < height; y += 1) {
|
||
if (!hasPixel(x, y)) continue;
|
||
const gap = y - lastFilled - 1;
|
||
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
|
||
for (let fillY = lastFilled + 1; fillY < y; fillY += 1) mark(x, fillY);
|
||
}
|
||
lastFilled = y;
|
||
}
|
||
}
|
||
toFill.forEach((index) => {
|
||
const offset = index * 4;
|
||
maskData.data[offset] = rgb.r;
|
||
maskData.data[offset + 1] = rgb.g;
|
||
maskData.data[offset + 2] = rgb.b;
|
||
maskData.data[offset + 3] = alpha;
|
||
});
|
||
return toFill.size;
|
||
}
|
||
|
||
function fillInternalMaskHoles(maskData, width, height, rgb, alpha) {
|
||
const outside = new Uint8Array(width * height);
|
||
const stack = [];
|
||
const pushIfEmpty = (x, y) => {
|
||
if (x < 0 || x >= width || y < 0 || y >= height) return;
|
||
const index = y * width + x;
|
||
if (outside[index] || maskData.data[index * 4 + 3] > 0) return;
|
||
outside[index] = 1;
|
||
stack.push(index);
|
||
};
|
||
for (let x = 0; x < width; x += 1) {
|
||
pushIfEmpty(x, 0);
|
||
pushIfEmpty(x, height - 1);
|
||
}
|
||
for (let y = 0; y < height; y += 1) {
|
||
pushIfEmpty(0, y);
|
||
pushIfEmpty(width - 1, y);
|
||
}
|
||
while (stack.length) {
|
||
const index = stack.pop();
|
||
const x = index % width;
|
||
const y = Math.floor(index / width);
|
||
pushIfEmpty(x + 1, y);
|
||
pushIfEmpty(x - 1, y);
|
||
pushIfEmpty(x, y + 1);
|
||
pushIfEmpty(x, y - 1);
|
||
}
|
||
let patchedPixels = 0;
|
||
for (let index = 0; index < outside.length; index += 1) {
|
||
const offset = index * 4;
|
||
if (!outside[index] && maskData.data[offset + 3] === 0) {
|
||
maskData.data[offset] = rgb.r;
|
||
maskData.data[offset + 1] = rgb.g;
|
||
maskData.data[offset + 2] = rgb.b;
|
||
maskData.data[offset + 3] = alpha;
|
||
patchedPixels += 1;
|
||
}
|
||
}
|
||
return patchedPixels;
|
||
}
|
||
|
||
function drawFallbackClosedRegion(context, width, height, segments, color, opacity = 0.72) {
|
||
const points = segments.flatMap((segment) => [segment.a, segment.b])
|
||
.filter((point) => (
|
||
Number.isFinite(point.x)
|
||
&& Number.isFinite(point.y)
|
||
&& point.x >= -width
|
||
&& point.x <= width * 2
|
||
&& point.y >= -height
|
||
&& point.y <= height * 2
|
||
));
|
||
if (points.length < 3) return 0;
|
||
const uniquePoints = [];
|
||
points.forEach((point) => {
|
||
if (!uniquePoints.some((current) => (current.x - point.x) ** 2 + (current.y - point.y) ** 2 < 1e-6)) uniquePoints.push(point);
|
||
});
|
||
if (uniquePoints.length < 3) return 0;
|
||
const sorted = [...uniquePoints].sort((left, right) => (Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y));
|
||
const cross = (origin, a, b) => (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x);
|
||
const lower = [];
|
||
sorted.forEach((point) => {
|
||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) lower.pop();
|
||
lower.push(point);
|
||
});
|
||
const upper = [];
|
||
[...sorted].reverse().forEach((point) => {
|
||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) upper.pop();
|
||
upper.push(point);
|
||
});
|
||
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
|
||
const ordered = hull.length >= 3 ? hull : uniquePoints;
|
||
context.save();
|
||
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62;
|
||
context.fillStyle = color;
|
||
context.beginPath();
|
||
ordered.forEach((point, index) => {
|
||
if (index === 0) context.moveTo(point.x, point.y);
|
||
else context.lineTo(point.x, point.y);
|
||
});
|
||
context.closePath();
|
||
context.fill();
|
||
context.restore();
|
||
return Math.max(1, Math.round(ordered.length / 2));
|
||
}
|
||
|
||
function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) {
|
||
if (!segments.length) return 0;
|
||
const rgb = parseCssColor(color);
|
||
const alpha = Math.round(clamp(opacity, 0.1, 1) * 220);
|
||
const maskCanvas = document.createElement("canvas");
|
||
maskCanvas.width = width;
|
||
maskCanvas.height = height;
|
||
const maskContext = maskCanvas.getContext("2d");
|
||
if (!maskContext) return 0;
|
||
maskContext.imageSmoothingEnabled = false;
|
||
const maskData = maskContext.createImageData(width, height);
|
||
const radius = solidStrokeRadius(width, height);
|
||
const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15);
|
||
let filledPixels = 0;
|
||
|
||
groups.forEach((group) => {
|
||
const rows = Array.from({ length: height }, () => []);
|
||
group.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
|
||
let groupPixels = 0;
|
||
rows.forEach((intersections, row) => {
|
||
if (intersections.length < 2) return;
|
||
intersections.sort((left, right) => left - right);
|
||
const cleaned = [];
|
||
intersections.forEach((x) => {
|
||
const previous = cleaned[cleaned.length - 1];
|
||
if (previous === undefined || Math.abs(previous - x) > 0.35) cleaned.push(x);
|
||
});
|
||
for (let index = 0; index + 1 < cleaned.length; index += 2) {
|
||
const rawStartX = cleaned[index];
|
||
const rawEndX = cleaned[index + 1];
|
||
if (rawEndX < 0 || rawStartX > width - 1) continue;
|
||
const startX = clamp(Math.ceil(rawStartX), 0, width - 1);
|
||
const endX = clamp(Math.floor(rawEndX), 0, width - 1);
|
||
if (endX < startX) continue;
|
||
for (let x = startX; x <= endX; x += 1) {
|
||
const offset = (row * width + x) * 4;
|
||
if (maskData.data[offset + 3] === 0) {
|
||
maskData.data[offset] = rgb.r;
|
||
maskData.data[offset + 1] = rgb.g;
|
||
maskData.data[offset + 2] = rgb.b;
|
||
maskData.data[offset + 3] = alpha;
|
||
groupPixels += 1;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius);
|
||
filledPixels += groupPixels;
|
||
});
|
||
|
||
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 1);
|
||
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
|
||
maskContext.putImageData(maskData, 0, 0);
|
||
context.imageSmoothingEnabled = false;
|
||
context.drawImage(maskCanvas, 0, 0);
|
||
|
||
return filledPixels;
|
||
}
|
||
|
||
async function drawMappingView(version = state.mappingVersion) {
|
||
const image = $("dicomPreview");
|
||
const canvas = $("mappingCanvas");
|
||
const volumeScene = state.volumeScene;
|
||
canvas.style.display = state.mappingMode === "image" ? "none" : "";
|
||
if (state.mappingMode === "image") {
|
||
setDicomLoading(false);
|
||
resetMappingCanvas("单独影像模式未叠加 STL 分割");
|
||
return;
|
||
}
|
||
const visibleRecords = visibleModelRecords();
|
||
if (!image?.complete || !image.naturalWidth || !volumeScene || !visibleRecords.length) {
|
||
if (!visibleRecords.length) setDicomLoading(false);
|
||
else setDicomLoading(true, "等待 DICOM Base Layer", 32);
|
||
resetMappingCanvas(visibleRecords.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
|
||
return;
|
||
}
|
||
setDicomLoading(true, "正在计算 STL 分割映射", 48);
|
||
|
||
const width = image.naturalWidth;
|
||
const height = image.naturalHeight;
|
||
if (canvas.width !== width) canvas.width = width;
|
||
if (canvas.height !== height) canvas.height = height;
|
||
const context = canvas.getContext("2d");
|
||
context.imageSmoothingEnabled = false;
|
||
context.clearRect(0, 0, width, height);
|
||
|
||
const targetZ = sliceToSceneZ(state.sliceIndex, volumeScene);
|
||
const mapPoint = (point) => ({
|
||
x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width,
|
||
y: (0.5 - point.y / volumeScene.height) * height,
|
||
});
|
||
const matrix = modelPoseMatrix();
|
||
|
||
let activeModules = 0;
|
||
let segmentCount = 0;
|
||
let filledPixels = 0;
|
||
const modules = [];
|
||
const tempA = new THREE.Vector3();
|
||
const tempB = new THREE.Vector3();
|
||
const tempC = new THREE.Vector3();
|
||
|
||
for (const [index, record] of visibleRecords.entries()) {
|
||
if (version !== state.mappingVersion) return;
|
||
const position = record.mesh.geometry.attributes.position;
|
||
if (!position) continue;
|
||
const triangleCount = Math.floor(position.count / 3);
|
||
const step = 1;
|
||
const segments = [];
|
||
for (let tri = 0; tri < triangleCount; tri += step) {
|
||
tempA.fromBufferAttribute(position, tri * 3).applyMatrix4(matrix);
|
||
tempB.fromBufferAttribute(position, tri * 3 + 1).applyMatrix4(matrix);
|
||
tempC.fromBufferAttribute(position, tri * 3 + 2).applyMatrix4(matrix);
|
||
const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ);
|
||
if (!segment) continue;
|
||
const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) };
|
||
if (
|
||
mapped.a.x > -width && mapped.a.x < width * 2
|
||
&& mapped.b.x > -width && mapped.b.x < width * 2
|
||
&& mapped.a.y > -height && mapped.a.y < height * 2
|
||
&& mapped.b.y > -height && mapped.b.y < height * 2
|
||
) {
|
||
segments.push(mapped);
|
||
}
|
||
}
|
||
const pixels = fillSegmentsAsSolidMask(context, width, height, segments, record.color, 0.66);
|
||
if (segments.length || pixels) {
|
||
activeModules += 1;
|
||
segmentCount += segments.length;
|
||
filledPixels += pixels;
|
||
modules.push({
|
||
id: index + 1,
|
||
name: record.file.segment_name || record.file.file_name || `STL ${index + 1}`,
|
||
color: record.color,
|
||
segments: segments.length,
|
||
pixels,
|
||
});
|
||
}
|
||
const progress = 48 + ((index + 1) / Math.max(visibleRecords.length, 1)) * 48;
|
||
const label = record.file.segment_name || record.file.file_name || `STL ${index + 1}`;
|
||
setDicomLoading(true, `正在计算 ${label}`, progress);
|
||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||
}
|
||
if (version !== state.mappingVersion) return;
|
||
|
||
$("mappingStats").textContent = `${activeModules}/${visibleRecords.length} 构件 · ${segmentCount} 边 · ${filledPixels} px`;
|
||
$("mappingLegend").innerHTML = modules.length
|
||
? modules.map((item) => `
|
||
<div class="legend-item" title="${escapeHtml(item.name)}">
|
||
<span class="legend-color" style="background:${escapeHtml(item.color)}"></span>
|
||
<span class="legend-name">${escapeHtml(item.name)}</span>
|
||
<span class="legend-meta">ID ${item.id}</span>
|
||
<span></span>
|
||
<span class="legend-meta">${item.segments} 边</span>
|
||
<span class="legend-meta">${item.pixels} px</span>
|
||
</div>
|
||
`).join("")
|
||
: `<div class="empty-state">当前切片暂无可见构件</div>`;
|
||
setDicomLoading(false);
|
||
}
|
||
|
||
async function buildDicomVolume(volume) {
|
||
clearGroup(state.dicomGroup);
|
||
state.dicomPlane = null;
|
||
state.dicomPoints = null;
|
||
state.dicomSlicePlanes = [];
|
||
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 };
|
||
const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
|
||
const sceneScale = FUSION_BASE_EXTENT / maxPhysical;
|
||
const width = (physical.width || 1) * sceneScale;
|
||
const height = (physical.height || 1) * sceneScale;
|
||
const depth = Math.max((physical.depth || 1) * sceneScale, 0.18);
|
||
const centerIndex = Number(volume.center || 0);
|
||
const total = Math.max(1, Number(volume.total || 1));
|
||
const indices = volume.indices || [];
|
||
const frames = volume.frames || [];
|
||
const fusionDetail = fusionDetailSettings();
|
||
const rangeStart = clamp(Number(volume.start ?? indices[0] ?? centerIndex) || 0, 0, total - 1);
|
||
const rangeEnd = clamp(Number(volume.end ?? indices[indices.length - 1] ?? centerIndex) || 0, 0, total - 1);
|
||
const boundaryIndices = new Set([Math.min(rangeStart, rangeEnd), Math.max(rangeStart, rangeEnd)]);
|
||
state.volumeScene = {
|
||
width,
|
||
height,
|
||
depth,
|
||
total,
|
||
sceneScale,
|
||
rowSpacing: Number(volume.spacing?.row || 1),
|
||
columnSpacing: Number(volume.spacing?.column || 1),
|
||
sliceSpacing: Number(volume.spacing?.slice || 1),
|
||
};
|
||
|
||
const boxMesh = new THREE.Mesh(
|
||
new THREE.BoxGeometry(width, height, depth),
|
||
new THREE.MeshBasicMaterial({ color: 0x020617, transparent: true, opacity: fusionDetail.boxOpacity, depthWrite: false }),
|
||
);
|
||
state.dicomGroup.add(boxMesh);
|
||
const edges = new THREE.LineSegments(
|
||
new THREE.EdgesGeometry(boxMesh.geometry),
|
||
new THREE.LineBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 0.46 }),
|
||
);
|
||
state.dicomGroup.add(edges);
|
||
|
||
const planeGeometry = new THREE.PlaneGeometry(width, height);
|
||
for (let frameIndex = 0; frameIndex < frames.length; frameIndex += 1) {
|
||
const dicomIndex = clamp(Number(indices[frameIndex] ?? centerIndex) || 0, 0, total - 1);
|
||
const isBoundary = boundaryIndices.has(dicomIndex) || frames.length === 1;
|
||
const texture = await loadDicomFusionTexture(frames[frameIndex]);
|
||
const plane = new THREE.Mesh(
|
||
planeGeometry,
|
||
new THREE.MeshBasicMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
opacity: isBoundary ? fusionDetail.planeOpacity : fusionDetail.sliceOpacity,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false,
|
||
depthTest: true,
|
||
}),
|
||
);
|
||
plane.position.z = sliceToSceneZ(dicomIndex) + (isBoundary ? 0.006 : 0);
|
||
plane.renderOrder = isBoundary ? 16 : 4;
|
||
plane.userData.rangeBoundary = isBoundary;
|
||
state.dicomGroup.add(plane);
|
||
state.dicomSlicePlanes.push(plane);
|
||
if (isBoundary && !state.dicomPlane) state.dicomPlane = plane;
|
||
}
|
||
|
||
if (fusionDetail.pointOpacity > 0) {
|
||
const pointPositions = [];
|
||
const pointColors = [];
|
||
for (const [index, frame] of frames.entries()) {
|
||
const image = await loadImageData(frame, 180);
|
||
const stride = Math.max(2, Math.round(Math.max(image.width, image.height) / fusionDetail.pointGrid));
|
||
const z = sliceToSceneZ(Number(indices[index] ?? centerIndex));
|
||
for (let y = 0; y < image.height; y += stride) {
|
||
for (let x = 0; x < image.width; x += stride) {
|
||
const offset = (y * image.width + x) * 4;
|
||
const lum = image.data[offset];
|
||
if (lum < fusionDetail.pointThreshold) continue;
|
||
pointPositions.push((x / Math.max(image.width - 1, 1) - 0.5) * width);
|
||
pointPositions.push((0.5 - y / Math.max(image.height - 1, 1)) * height);
|
||
pointPositions.push(z);
|
||
const v = 0.16 + (lum / 255) * 0.72;
|
||
pointColors.push(v * 0.92, v, Math.min(1, v * 1.08));
|
||
}
|
||
}
|
||
}
|
||
if (pointPositions.length) {
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(pointPositions, 3));
|
||
geometry.setAttribute("color", new THREE.Float32BufferAttribute(pointColors, 3));
|
||
const points = new THREE.Points(
|
||
geometry,
|
||
new THREE.PointsMaterial({
|
||
size: fusionDetail.pointSize,
|
||
vertexColors: true,
|
||
transparent: true,
|
||
opacity: fusionDetail.pointOpacity,
|
||
depthWrite: false,
|
||
}),
|
||
);
|
||
points.renderOrder = 2;
|
||
state.dicomGroup.add(points);
|
||
state.dicomPoints = points;
|
||
}
|
||
}
|
||
|
||
state.baseModelScale = sceneScale;
|
||
state.dicomSceneBox = new THREE.Box3(
|
||
new THREE.Vector3(-width / 2, -height / 2, -depth / 2),
|
||
new THREE.Vector3(width / 2, height / 2, depth / 2),
|
||
);
|
||
}
|
||
|
||
function updateBaseModelScaleFromBounds() {
|
||
const size = state.modelLocalBounds?.size;
|
||
if (!size) return;
|
||
const maxModelSize = Math.max(Number(size.x) || 0, Number(size.y) || 0, Number(size.z) || 0, 1);
|
||
const volumeSize = state.volumeScene
|
||
? Math.max(state.volumeScene.width, state.volumeScene.height, state.volumeScene.depth, 1)
|
||
: FUSION_BASE_EXTENT;
|
||
state.baseModelScale = (volumeSize / maxModelSize) * 0.92;
|
||
}
|
||
|
||
function addModelBoundsFrame(size) {
|
||
if (!state.rawModelGroup || !size) return;
|
||
removeModelBoundsFrame();
|
||
const frame = new THREE.LineSegments(
|
||
new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
|
||
new THREE.LineBasicMaterial({
|
||
color: 0xfacc15,
|
||
transparent: true,
|
||
opacity: 0.72,
|
||
}),
|
||
);
|
||
frame.name = "model-bounds";
|
||
frame.renderOrder = 1000;
|
||
state.rawModelGroup.add(frame);
|
||
state.modelBoundsFrame = frame;
|
||
}
|
||
|
||
function removeModelBoundsFrame() {
|
||
const frame = state.modelBoundsFrame;
|
||
if (!frame || !state.rawModelGroup) return;
|
||
state.rawModelGroup.remove(frame);
|
||
frame.geometry?.dispose?.();
|
||
if (Array.isArray(frame.material)) frame.material.forEach((material) => material.dispose?.());
|
||
else frame.material?.dispose?.();
|
||
state.modelBoundsFrame = null;
|
||
}
|
||
|
||
async function buildStlModels(volume, onProgress = null) {
|
||
const signature = stlSelectionSignature();
|
||
if (signature && signature !== state.stlSignature) {
|
||
state.rawModelGroup.position.set(0, 0, 0);
|
||
state.rawModelGroup.rotation.set(0, 0, 0);
|
||
state.rawModelGroup.scale.set(1, 1, 1);
|
||
clearGroup(state.rawModelGroup);
|
||
state.modelMeshes = [];
|
||
state.modelBoundsFrame = null;
|
||
state.modelLocalBounds = null;
|
||
state.stlSignature = signature;
|
||
}
|
||
const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite));
|
||
const files = state.stlFiles.filter((file) => (
|
||
state.selectedStlIds.has(Number(file.id))
|
||
&& !loadedIds.has(Number(file.id))
|
||
));
|
||
if (signature && signature === state.stlSignature && !files.length && state.rawModelGroup?.children.length) {
|
||
updateBaseModelScaleFromBounds();
|
||
if (!state.modelBoundsFrame && state.modelLocalBounds?.size) addModelBoundsFrame(state.modelLocalBounds.size);
|
||
applyStlVisibility();
|
||
applyPose();
|
||
applyModelDetail();
|
||
scheduleMappingDraw();
|
||
onProgress?.(100);
|
||
return;
|
||
}
|
||
if (!files.length && !state.modelMeshes.length) {
|
||
applyPose();
|
||
resetMappingCanvas("当前没有可见 STL 构件");
|
||
onProgress?.(100);
|
||
return;
|
||
}
|
||
const loader = new STLLoader();
|
||
const detail = modelDetailSettings();
|
||
const freshRecords = [];
|
||
for (const [index, file] of files.entries()) {
|
||
onProgress?.((index / Math.max(files.length, 1)) * 100);
|
||
const params = new URLSearchParams({
|
||
ct_number: state.activeCase.ct_number,
|
||
file_id: String(file.id),
|
||
algorithm_model: state.algorithmModel,
|
||
access_token: state.token,
|
||
});
|
||
const geometry = await loader.loadAsync(`/api/stl/file?${params.toString()}`);
|
||
geometry.computeVertexNormals();
|
||
geometry.computeBoundingBox();
|
||
const color = stlColor(file, index);
|
||
const material = new THREE.MeshStandardMaterial({
|
||
color,
|
||
metalness: detail.metalness,
|
||
roughness: detail.roughness,
|
||
transparent: true,
|
||
opacity: detail.opacity,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: detail.depthWrite,
|
||
wireframe: detail.wireframe,
|
||
});
|
||
const mesh = new THREE.Mesh(geometry, material);
|
||
mesh.name = file.segment_name || file.file_name;
|
||
mesh.userData.file = file;
|
||
mesh.userData.color = color;
|
||
state.rawModelGroup.add(mesh);
|
||
const record = { mesh, file, color, index };
|
||
state.modelMeshes.push(record);
|
||
freshRecords.push(record);
|
||
onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100);
|
||
}
|
||
if (!state.modelLocalBounds && state.modelMeshes.length) {
|
||
const rawBox = new THREE.Box3();
|
||
state.modelMeshes.forEach((record) => {
|
||
record.mesh.geometry.computeBoundingBox?.();
|
||
if (record.mesh.geometry.boundingBox) rawBox.union(record.mesh.geometry.boundingBox);
|
||
});
|
||
const center = new THREE.Vector3();
|
||
rawBox.getCenter(center);
|
||
state.modelMeshes.forEach((record) => {
|
||
const geometry = record.mesh.geometry;
|
||
geometry.translate(-center.x, -center.y, -center.z);
|
||
geometry.computeBoundingBox?.();
|
||
geometry.computeBoundingSphere?.();
|
||
geometry.computeVertexNormals?.();
|
||
});
|
||
state.modelLocalBounds = {
|
||
center: { x: center.x, y: center.y, z: center.z },
|
||
size: { x: 1, y: 1, z: 1 },
|
||
};
|
||
} else if (state.modelLocalBounds && freshRecords.length) {
|
||
const center = state.modelLocalBounds.center;
|
||
freshRecords.forEach((record) => {
|
||
const geometry = record.mesh.geometry;
|
||
geometry.translate(-Number(center.x || 0), -Number(center.y || 0), -Number(center.z || 0));
|
||
geometry.computeBoundingBox?.();
|
||
geometry.computeBoundingSphere?.();
|
||
geometry.computeVertexNormals?.();
|
||
});
|
||
}
|
||
const bbox = new THREE.Box3();
|
||
state.modelMeshes.forEach((record) => {
|
||
record.mesh.geometry.computeBoundingBox?.();
|
||
if (record.mesh.geometry.boundingBox) bbox.union(record.mesh.geometry.boundingBox);
|
||
});
|
||
const size = new THREE.Vector3();
|
||
if (!bbox.isEmpty()) bbox.getSize(size);
|
||
if (state.modelLocalBounds) state.modelLocalBounds.size = { x: size.x, y: size.y, z: size.z };
|
||
updateBaseModelScaleFromBounds();
|
||
if (size.x || size.y || size.z) addModelBoundsFrame(size);
|
||
applyStlVisibility();
|
||
applyModelDetail();
|
||
applyPose();
|
||
scheduleMappingDraw();
|
||
onProgress?.(100);
|
||
}
|
||
|
||
function applyPose(poseInput = state.pose, options = {}) {
|
||
if (!state.modelGroup) return;
|
||
const pose = { ...DEFAULT_POSE, ...poseInput };
|
||
state.modelGroup.rotation.set(
|
||
THREE.MathUtils.degToRad(Number(pose.rotateX) || 0),
|
||
THREE.MathUtils.degToRad(Number(pose.rotateY) || 0),
|
||
THREE.MathUtils.degToRad(Number(pose.rotateZ) || 0),
|
||
);
|
||
state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0);
|
||
const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale;
|
||
state.modelGroup.scale.set(
|
||
scale * (pose.flipX ? -1 : 1),
|
||
scale * (pose.flipY ? -1 : 1),
|
||
scale * (pose.flipZ ? -1 : 1),
|
||
);
|
||
state.modelGroup.updateMatrixWorld(true);
|
||
if (options.scheduleMapping !== false) scheduleMappingDraw();
|
||
}
|
||
|
||
function fitCamera() {
|
||
if (!state.camera) return;
|
||
applySceneViewPose();
|
||
state.camera.position.set(0, -6.2, 4.6);
|
||
state.camera.up.set(0, 0, 1);
|
||
state.camera.lookAt(0, 0, 0);
|
||
state.controls?.target.set(0, 0, 0);
|
||
state.controls?.update();
|
||
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
|
||
}
|
||
|
||
function resetSceneView() {
|
||
state.viewPose = { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 };
|
||
applySceneViewPose();
|
||
fitCamera();
|
||
$("fusionStatus").textContent = state.volumeMeta ? "三维融合视角已复位" : "等待选择 DICOM 与 STL";
|
||
}
|
||
|
||
function resetPose(kind) {
|
||
if (state.poseControlsLocked) return;
|
||
if (kind === "rotation") {
|
||
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
|
||
} else if (kind === "transform") {
|
||
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1, scaleX: 1, scaleY: 1, scaleZ: 1 };
|
||
} else if (kind === "flip") {
|
||
state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false };
|
||
} else {
|
||
state.pose = { ...DEFAULT_POSE };
|
||
}
|
||
renderPoseControls();
|
||
applyPose();
|
||
markDirty();
|
||
}
|
||
|
||
function stretchAxis(axis) {
|
||
if (state.poseControlsLocked) {
|
||
$("autoResult").textContent = "自动迭代运行中,暂不可调整位姿。";
|
||
return;
|
||
}
|
||
if (!canStretchByAxis()) {
|
||
const message = "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可使用 XYZ 拉伸。";
|
||
setAutoState("XYZ 拉伸不可用", "warn");
|
||
$("autoResult").textContent = message;
|
||
updateStretchButtons();
|
||
return;
|
||
}
|
||
const modelBox = visibleModelBox();
|
||
if (!modelBox || !state.dicomSceneBox) {
|
||
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||
return;
|
||
}
|
||
if (!["x", "y", "z"].includes(axis)) return;
|
||
const dicomSize = new THREE.Vector3();
|
||
const modelSize = new THREE.Vector3();
|
||
state.dicomSceneBox.getSize(dicomSize);
|
||
modelBox.getSize(modelSize);
|
||
const targetSize = Math.max(0.001, dicomSize[axis]);
|
||
const currentSize = Math.max(0.001, modelSize[axis]);
|
||
const currentScale = Math.max(0.001, Number(state.pose.scale) || 1);
|
||
const next = Math.max(0.2, Math.min(4, currentScale * (targetSize / currentSize)));
|
||
state.pose.scale = Number(next.toFixed(3));
|
||
state.pose.scaleX = 1;
|
||
state.pose.scaleY = 1;
|
||
state.pose.scaleZ = 1;
|
||
renderPoseControls();
|
||
applyPose();
|
||
markDirty("pose");
|
||
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
|
||
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体等比例拉伸,缩放 ${state.pose.scale}`;
|
||
updateAutoPoseResult();
|
||
}
|
||
|
||
function suggestBoneSelection() {
|
||
const checkedIds = [...document.querySelectorAll("#autoBoneOptions input:checked")]
|
||
.map((input) => Number(input.value))
|
||
.filter(Number.isFinite);
|
||
const next = new Set(checkedIds);
|
||
if (!next.size) {
|
||
[...state.stlFiles]
|
||
.sort((a, b) => stlLikelyScore(b) - stlLikelyScore(a))
|
||
.slice(0, Math.min(6, state.stlFiles.length))
|
||
.forEach((file) => next.add(Number(file.id)));
|
||
}
|
||
state.selectedStlIds = next;
|
||
renderStl();
|
||
markDirty();
|
||
applyStlSelectionChange();
|
||
}
|
||
|
||
function readAutoSettings() {
|
||
const numericValue = (id, fallback) => {
|
||
const value = Number($(id)?.value);
|
||
return Number.isFinite(value) ? value : fallback;
|
||
};
|
||
const referenceIds = [...document.querySelectorAll("#autoBoneOptions input:checked")]
|
||
.map((input) => Number(input.value))
|
||
.filter(Number.isFinite);
|
||
return {
|
||
adjustable: {
|
||
translateX: $("autoX")?.checked,
|
||
translateY: $("autoY")?.checked,
|
||
translateZ: $("autoZ")?.checked,
|
||
scale: $("autoScale")?.checked,
|
||
},
|
||
referenceIds,
|
||
sampleSlices: Math.max(3, Math.min(60, Math.round(numericValue("autoSampleSlicesNumber", 9)))),
|
||
boneReward: numericValue("autoBoneReward", 1),
|
||
outsidePenalty: numericValue("autoOutsidePenalty", 0.1),
|
||
movePenalty: numericValue("autoMovePenalty", 0),
|
||
scalePenalty: numericValue("autoScalePenalty", 0),
|
||
iterations: Math.max(1, Math.min(100, Math.round(numericValue("autoIterations", 30)))),
|
||
candidates: Math.max(6, Math.min(96, Math.round(numericValue("autoCandidates", 36)))),
|
||
};
|
||
}
|
||
|
||
function resetAutoSettings() {
|
||
if (state.poseControlsLocked) return;
|
||
const checkedDefaults = {
|
||
autoX: true,
|
||
autoY: true,
|
||
autoZ: true,
|
||
autoScale: true,
|
||
};
|
||
Object.entries(checkedDefaults).forEach(([id, value]) => {
|
||
const input = $(id);
|
||
if (input) input.checked = value;
|
||
});
|
||
const valueDefaults = {
|
||
autoSampleSlices: 9,
|
||
autoSampleSlicesNumber: 9,
|
||
autoBoneReward: 1,
|
||
autoOutsidePenalty: 0.1,
|
||
autoMovePenalty: 0,
|
||
autoScalePenalty: 0,
|
||
autoIterations: 30,
|
||
autoCandidates: 36,
|
||
};
|
||
Object.entries(valueDefaults).forEach(([id, value]) => {
|
||
const input = $(id);
|
||
if (input) input.value = value;
|
||
});
|
||
renderAutoBoneOptions();
|
||
state.autoResult = null;
|
||
setAutoProgress(false);
|
||
setAutoState("已恢复", "ok");
|
||
$("autoResult").textContent = "迭代参数已恢复初始设置。";
|
||
}
|
||
|
||
function poseDelta(from, to) {
|
||
return {
|
||
rotateX: Number(((to.rotateX || 0) - (from.rotateX || 0)).toFixed(3)),
|
||
rotateY: Number(((to.rotateY || 0) - (from.rotateY || 0)).toFixed(3)),
|
||
rotateZ: Number(((to.rotateZ || 0) - (from.rotateZ || 0)).toFixed(3)),
|
||
translateX: Number(((to.translateX || 0) - (from.translateX || 0)).toFixed(3)),
|
||
translateY: Number(((to.translateY || 0) - (from.translateY || 0)).toFixed(3)),
|
||
translateZ: Number(((to.translateZ || 0) - (from.translateZ || 0)).toFixed(3)),
|
||
scale: Number(((to.scale || 1) / Math.max(0.001, from.scale || 1)).toFixed(3)),
|
||
};
|
||
}
|
||
|
||
function signedValue(value, decimals = 3) {
|
||
const numeric = Number(value || 0);
|
||
if (Math.abs(numeric) < 10 ** -decimals) return "0";
|
||
return `${numeric > 0 ? "+" : ""}${numeric.toFixed(decimals)}`;
|
||
}
|
||
|
||
function autoPoseResultItem(label, deltaText, currentText, changed = true) {
|
||
return `
|
||
<span class="${changed ? "changed" : "muted"}">
|
||
<b>${escapeHtml(label)}</b>
|
||
<strong>${escapeHtml(deltaText)}</strong>
|
||
<small>${escapeHtml(currentText)}</small>
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
function updateAutoPoseResult(before = state.autoDeltaBaseline || state.lastAutoPose?.before || state.pose, after = state.pose) {
|
||
const delta = poseDelta(before, after);
|
||
const el = $("autoPoseResult");
|
||
if (!el) return;
|
||
el.innerHTML = [
|
||
autoPoseResultItem("旋转 X", `Δ ${signedValue(delta.rotateX, 1)}°`, `当前 ${Number(after.rotateX || 0).toFixed(1)}°`, Math.abs(delta.rotateX) > 0.0005),
|
||
autoPoseResultItem("旋转 Y", `Δ ${signedValue(delta.rotateY, 1)}°`, `当前 ${Number(after.rotateY || 0).toFixed(1)}°`, Math.abs(delta.rotateY) > 0.0005),
|
||
autoPoseResultItem("旋转 Z", `Δ ${signedValue(delta.rotateZ, 1)}°`, `当前 ${Number(after.rotateZ || 0).toFixed(1)}°`, Math.abs(delta.rotateZ) > 0.0005),
|
||
autoPoseResultItem("平移 X", `Δ ${signedValue(delta.translateX)}`, `当前 ${Number(after.translateX || 0).toFixed(3)}`, Math.abs(delta.translateX) > 0.0005),
|
||
autoPoseResultItem("平移 Y", `Δ ${signedValue(delta.translateY)}`, `当前 ${Number(after.translateY || 0).toFixed(3)}`, Math.abs(delta.translateY) > 0.0005),
|
||
autoPoseResultItem("平移 Z", `Δ ${signedValue(delta.translateZ)}`, `当前 ${Number(after.translateZ || 0).toFixed(3)}`, Math.abs(delta.translateZ) > 0.0005),
|
||
autoPoseResultItem("缩放", `×${Number(delta.scale || 1).toFixed(3)}`, `当前 ${Number(after.scale || 1).toFixed(3)}`, Math.abs((delta.scale || 1) - 1) > 0.0005),
|
||
].join("");
|
||
}
|
||
|
||
function resetAutoDeltaBaseline() {
|
||
state.autoDeltaBaseline = { ...state.pose };
|
||
updateAutoPoseResult(state.pose, state.pose);
|
||
}
|
||
|
||
function renderAutoProgressSteps(activeStep = "") {
|
||
const stepsEl = $("autoProgressSteps");
|
||
if (!stepsEl) return;
|
||
const activeIndex = AUTO_PROGRESS_STEPS.findIndex((step) => step.id === activeStep);
|
||
stepsEl.innerHTML = AUTO_PROGRESS_STEPS.map((step, index) => {
|
||
const cls = index < activeIndex ? "done" : step.id === activeStep ? "active" : "";
|
||
return `<span class="${cls}">${escapeHtml(step.label)}</span>`;
|
||
}).join("");
|
||
}
|
||
|
||
function setAutoProgress(visible, value = 0, title = "", detail = "", activeStep = "") {
|
||
const wrap = $("autoProgress");
|
||
if (!wrap) return;
|
||
wrap.classList.toggle("hidden", !visible);
|
||
const safeValue = Math.max(0, Math.min(100, Number(value) || 0));
|
||
$("autoProgressFill").style.width = `${safeValue}%`;
|
||
$("autoProgressText").textContent = `${Math.round(safeValue)}%`;
|
||
$("autoProgressTitle").textContent = title || "自动微调处理中";
|
||
$("autoProgressDetail").textContent = detail || "正在准备自动微调任务。";
|
||
renderAutoProgressSteps(activeStep);
|
||
}
|
||
|
||
function waitForUiFrame() {
|
||
return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
|
||
}
|
||
|
||
function autoSettingsForStorage(settings) {
|
||
const { imageContext, ...rest } = settings || {};
|
||
return rest;
|
||
}
|
||
|
||
function chooseAutoVolumeEntries(settings) {
|
||
const volume = state.volumeMeta;
|
||
const frames = volume?.frames || [];
|
||
const indices = volume?.indices || [];
|
||
if (!frames.length || !indices.length) return [];
|
||
const entries = frames.map((frame, index) => ({ frame, slice: Number(indices[index] ?? 0) }));
|
||
const limit = Math.max(3, Math.min(entries.length, Number(settings.sampleSlices || 9)));
|
||
if (entries.length <= limit) return entries;
|
||
const selected = new Map();
|
||
for (let index = 0; index < limit; index += 1) {
|
||
const entryIndex = Math.round((index * (entries.length - 1)) / Math.max(limit - 1, 1));
|
||
selected.set(entryIndex, entries[entryIndex]);
|
||
}
|
||
return [...selected.values()].sort((left, right) => left.slice - right.slice);
|
||
}
|
||
|
||
function collectAutoSamplePoints(settings, maxPoints = 5200) {
|
||
const records = autoReferenceRecords(settings);
|
||
const totalVertices = records.reduce((sum, record) => sum + (record.mesh.geometry.attributes.position?.count || 0), 0);
|
||
if (!totalVertices) return [];
|
||
const stride = Math.max(1, Math.ceil(totalVertices / maxPoints));
|
||
const samples = [];
|
||
records.forEach((record) => {
|
||
const attribute = record.mesh.geometry.attributes.position;
|
||
if (!attribute) return;
|
||
for (let index = 0; index < attribute.count; index += stride) {
|
||
samples.push({
|
||
x: attribute.getX(index),
|
||
y: attribute.getY(index),
|
||
z: attribute.getZ(index),
|
||
});
|
||
}
|
||
});
|
||
return samples;
|
||
}
|
||
|
||
async function prepareAutoFineImageContext(settings) {
|
||
if (!state.volumeMeta || !state.volumeScene) return null;
|
||
const entries = chooseAutoVolumeEntries(settings);
|
||
const samples = collectAutoSamplePoints(settings);
|
||
if (!entries.length || !samples.length) return null;
|
||
const frames = [];
|
||
for (const [index, entry] of entries.entries()) {
|
||
const progress = 20 + (index / Math.max(entries.length, 1)) * 16;
|
||
setAutoProgress(true, progress, "读取骨窗采样切片", `正在读取第 ${index + 1}/${entries.length} 张骨窗切片,切片 ${entry.slice + 1}。`, "bone");
|
||
await waitForUiFrame();
|
||
frames.push({ ...entry, image: await loadImageData(entry.frame, 320) });
|
||
}
|
||
const sortedSlices = frames.map((entry) => entry.slice).sort((left, right) => left - right);
|
||
const gaps = sortedSlices.slice(1).map((slice, index) => Math.abs(slice - sortedSlices[index]));
|
||
const sliceTolerance = Math.max(1, Math.ceil((gaps.length ? Math.max(...gaps) : 1) / 2));
|
||
return {
|
||
frames,
|
||
samples,
|
||
sliceTolerance,
|
||
volumeScene: { ...state.volumeScene },
|
||
};
|
||
}
|
||
|
||
function scorePoseAgainstBoneImages(pose, context, settings) {
|
||
if (!context?.frames?.length || !context.samples?.length || !context.volumeScene) return NaN;
|
||
const current = { ...state.pose };
|
||
applyPose(pose, { scheduleMapping: false });
|
||
const matrix = modelPoseMatrix();
|
||
applyPose(current, { scheduleMapping: false });
|
||
const volume = context.volumeScene;
|
||
const point = new THREE.Vector3();
|
||
let hitReward = 0;
|
||
let missPenalty = 0;
|
||
let contributors = 0;
|
||
for (const sample of context.samples) {
|
||
point.set(sample.x, sample.y, sample.z).applyMatrix4(matrix);
|
||
if (
|
||
point.x < -volume.width / 2 || point.x > volume.width / 2
|
||
|| point.y < -volume.height / 2 || point.y > volume.height / 2
|
||
|| point.z < -volume.depth / 2 || point.z > volume.depth / 2
|
||
) {
|
||
missPenalty += 0.85;
|
||
contributors += 1;
|
||
continue;
|
||
}
|
||
const slice = volume.total <= 1 ? 0 : ((point.z + volume.depth / 2) / Math.max(volume.depth, 0.001)) * (volume.total - 1);
|
||
let nearest = context.frames[0];
|
||
let nearestDistance = Infinity;
|
||
for (const frame of context.frames) {
|
||
const distance = Math.abs(frame.slice - slice);
|
||
if (distance < nearestDistance) {
|
||
nearest = frame;
|
||
nearestDistance = distance;
|
||
}
|
||
}
|
||
if (nearestDistance > context.sliceTolerance) {
|
||
missPenalty += 0.28;
|
||
contributors += 1;
|
||
continue;
|
||
}
|
||
const image = nearest.image;
|
||
const x = Math.round(((point.x + volume.width / 2) / Math.max(volume.width, 0.001)) * (image.width - 1));
|
||
const y = Math.round(((volume.height / 2 - point.y) / Math.max(volume.height, 0.001)) * (image.height - 1));
|
||
if (x < 0 || x >= image.width || y < 0 || y >= image.height) {
|
||
missPenalty += 0.75;
|
||
contributors += 1;
|
||
continue;
|
||
}
|
||
const lum = image.data[(y * image.width + x) * 4] || 0;
|
||
if (lum >= BONE_BRIGHT_THRESHOLD) hitReward += BONE_FIXED_REWARD;
|
||
else if (lum >= BONE_EDGE_THRESHOLD) hitReward += BONE_EDGE_REWARD;
|
||
else missPenalty += 1.05 - (lum / Math.max(BONE_EDGE_THRESHOLD, 1)) * 0.28;
|
||
contributors += 1;
|
||
}
|
||
const safeContributors = Math.max(contributors, 1);
|
||
const basePose = settings.basePose || state.autoDeltaBaseline || state.pose;
|
||
const movement = Math.sqrt(
|
||
((pose.translateX || 0) - (basePose.translateX || 0)) ** 2
|
||
+ ((pose.translateY || 0) - (basePose.translateY || 0)) ** 2
|
||
+ ((pose.translateZ || 0) - (basePose.translateZ || 0)) ** 2,
|
||
);
|
||
const movementPenalty = movement * Number(settings.movePenalty || 0);
|
||
const scalePenalty = Math.abs((pose.scale || 1) - (basePose.scale || 1)) * Number(settings.scalePenalty || 0);
|
||
return (hitReward / safeContributors) * Number(settings.boneReward || 1)
|
||
- (missPenalty / safeContributors) * (0.25 + Number(settings.outsidePenalty || 0))
|
||
- movementPenalty
|
||
- scalePenalty;
|
||
}
|
||
|
||
function restoreAutoPose() {
|
||
if (state.poseControlsLocked) return;
|
||
if (!state.lastAutoPose?.before) {
|
||
$("autoResult").textContent = "暂无可退回的自动调整位姿。";
|
||
return;
|
||
}
|
||
state.pose = { ...state.lastAutoPose.before };
|
||
renderPoseControls();
|
||
applyPose();
|
||
fitCamera();
|
||
resetAutoDeltaBaseline();
|
||
$("autoResult").textContent = "已退回到自动调整前的位姿。";
|
||
markDirty();
|
||
}
|
||
|
||
function candidateScore(pose, settings = null) {
|
||
if (!state.dicomSceneBox || !state.modelGroup || !visibleModelRecords().length) return -Infinity;
|
||
const config = settings || readAutoSettings();
|
||
const records = autoReferenceRecords(config);
|
||
if (!records.length) return -Infinity;
|
||
const current = { ...state.pose };
|
||
applyPose(pose, { scheduleMapping: false });
|
||
const modelBox = visibleModelBox(records);
|
||
applyPose(current, { scheduleMapping: false });
|
||
if (!modelBox) return -Infinity;
|
||
const overlap = modelBox.clone().intersect(state.dicomSceneBox);
|
||
const overlapSize = new THREE.Vector3();
|
||
overlap.getSize(overlapSize);
|
||
const modelSize = new THREE.Vector3();
|
||
const dicomSize = new THREE.Vector3();
|
||
modelBox.getSize(modelSize);
|
||
state.dicomSceneBox.getSize(dicomSize);
|
||
const overlapVolume = Math.max(0, overlapSize.x) * Math.max(0, overlapSize.y) * Math.max(0, overlapSize.z);
|
||
const modelVolume = Math.max(modelSize.x * modelSize.y * modelSize.z, 0.001);
|
||
const modelCenter = new THREE.Vector3();
|
||
const dicomCenter = new THREE.Vector3();
|
||
modelBox.getCenter(modelCenter);
|
||
state.dicomSceneBox.getCenter(dicomCenter);
|
||
const centerPenalty = modelCenter.distanceTo(dicomCenter) / Math.max(dicomSize.length(), 0.001);
|
||
const scalePenalty = Math.abs((pose.scale || 1) - 1) * (0.03 + Number(config.scalePenalty || 0));
|
||
const movePenalty = (Math.abs(pose.translateX || 0) + Math.abs(pose.translateY || 0) + Math.abs(pose.translateZ || 0)) * Number(config.movePenalty || 0);
|
||
const geometryScore = (overlapVolume / modelVolume) * Number(config.boneReward || 1) - centerPenalty - scalePenalty - movePenalty - Number(config.outsidePenalty || 0) * 0.01;
|
||
if (config.imageContext) {
|
||
const imageScore = scorePoseAgainstBoneImages(pose, config.imageContext, config);
|
||
if (Number.isFinite(imageScore)) return imageScore + geometryScore * 0.12;
|
||
}
|
||
return geometryScore;
|
||
}
|
||
|
||
function describeAutoReference(settings) {
|
||
const records = autoReferenceRecords(settings);
|
||
if (!records.length) return "当前没有可用参考 STL";
|
||
const names = records.slice(0, 4).map((record) => compactStlName(record.file));
|
||
const suffix = records.length > names.length ? ` 等 ${records.length} 个` : "";
|
||
return `参考 STL:${names.join("、")}${suffix}`;
|
||
}
|
||
|
||
function estimateCoarsePose(settings) {
|
||
const records = autoReferenceRecords(settings);
|
||
const modelBox = visibleModelBox(records);
|
||
if (!modelBox || !state.dicomSceneBox) return null;
|
||
const adjustable = settings.adjustable || {};
|
||
const dicomSize = new THREE.Vector3();
|
||
const modelSize = new THREE.Vector3();
|
||
const dicomCenter = new THREE.Vector3();
|
||
state.dicomSceneBox.getSize(dicomSize);
|
||
state.dicomSceneBox.getCenter(dicomCenter);
|
||
modelBox.getSize(modelSize);
|
||
const candidate = { ...state.pose };
|
||
if (adjustable.scale) {
|
||
const ratios = ["x", "y", "z"]
|
||
.map((axis) => dicomSize[axis] / Math.max(modelSize[axis], 0.001))
|
||
.filter((value) => Number.isFinite(value) && value > 0);
|
||
if (ratios.length) {
|
||
const fitRatio = Math.min(...ratios) * 0.96;
|
||
candidate.scale = Number(clamp((candidate.scale || 1) * fitRatio, 0.2, 3).toFixed(3));
|
||
}
|
||
}
|
||
const previous = { ...state.pose };
|
||
applyPose(candidate, { scheduleMapping: false });
|
||
const nextBox = visibleModelBox(records);
|
||
applyPose(previous, { scheduleMapping: false });
|
||
if (!nextBox) return candidate;
|
||
const modelCenter = new THREE.Vector3();
|
||
nextBox.getCenter(modelCenter);
|
||
const offset = dicomCenter.clone().sub(modelCenter);
|
||
if (adjustable.translateX) candidate.translateX = Number(((candidate.translateX || 0) + offset.x).toFixed(3));
|
||
if (adjustable.translateY) candidate.translateY = Number(((candidate.translateY || 0) + offset.y).toFixed(3));
|
||
if (adjustable.translateZ) candidate.translateZ = Number(((candidate.translateZ || 0) + offset.z).toFixed(3));
|
||
return candidate;
|
||
}
|
||
|
||
function runAutoCoarse() {
|
||
if (state.poseControlsLocked) return;
|
||
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
|
||
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||
return;
|
||
}
|
||
if (state.autoFineRunning) return;
|
||
setAutoProgress(false);
|
||
const before = { ...state.pose };
|
||
const settings = readAutoSettings();
|
||
const estimated = estimateCoarsePose(settings);
|
||
if (!estimated) {
|
||
$("autoResult").textContent = "当前缺少 DICOM 或 STL 盒体信息,无法计算粗配准。";
|
||
setAutoState("粗配准失败", "error");
|
||
return;
|
||
}
|
||
state.pose = estimated;
|
||
renderPoseControls();
|
||
applyPose();
|
||
fitCamera();
|
||
state.autoResult = { score: candidateScore(state.pose, settings), pose: { ...state.pose }, evaluated: 1, settings };
|
||
state.lastAutoPose = { before, after: { ...state.pose } };
|
||
state.autoDeltaBaseline = { ...before };
|
||
setAutoState("粗配准完成", "ok");
|
||
const delta = poseDelta(before, state.pose);
|
||
$("autoResult").textContent = `已按 DICOM/STL 盒体估算中心与缩放:ΔX ${signedValue(delta.translateX)},ΔY ${signedValue(delta.translateY)},ΔZ ${signedValue(delta.translateZ)},缩放 ×${delta.scale.toFixed(3)};score ${state.autoResult.score.toFixed(4)}。`;
|
||
updateAutoPoseResult(before, state.pose);
|
||
markDirty();
|
||
}
|
||
|
||
async function runAutoFine(options = {}) {
|
||
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
|
||
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||
return;
|
||
}
|
||
if (state.autoFineRunning && !options.lockAlreadyHeld) return;
|
||
const before = { ...state.pose };
|
||
const settings = { ...readAutoSettings(), basePose: before };
|
||
const adjustable = settings.adjustable;
|
||
const lockAlreadyHeld = Boolean(options.lockAlreadyHeld);
|
||
state.autoFineRunning = true;
|
||
if (!lockAlreadyHeld) setPoseEditingLocked(true);
|
||
setAutoState("运行中", "warn");
|
||
setAutoProgress(true, options.fromSaveIterate ? 12 : 4, "锁定位姿参数", "自动微调运行中,位姿输入、XYZ 拉伸和迭代按钮已临时锁定。", options.fromSaveIterate ? "save" : "bone");
|
||
$("autoResult").textContent = "正在准备自动微调:锁定位姿控件并切换骨窗采样。";
|
||
await waitForUiFrame();
|
||
try {
|
||
setAutoProgress(true, 14, "加载骨窗参考", "读取骨窗 DICOM 切片范围,并重建三维参考显示。", "bone");
|
||
if (state.windowMode !== "bone") await setWindowMode("bone", true);
|
||
await waitForUiFrame();
|
||
|
||
setAutoProgress(true, 22, "采样参考 STL", "正在从勾选的参考 STL 和骨窗切片中构建评分样本。", "sample");
|
||
settings.imageContext = await prepareAutoFineImageContext(settings);
|
||
const imageContextText = settings.imageContext
|
||
? `已采样 ${settings.imageContext.samples.length} 个 STL 点、${settings.imageContext.frames.length} 张骨窗切片;仅高亮骨性像素参与正向奖励。`
|
||
: "未能建立骨窗像素样本,将退回 DICOM/STL 三维盒体评分。";
|
||
setAutoProgress(true, 38, "建立候选队列", imageContextText, "score");
|
||
await waitForUiFrame();
|
||
|
||
let best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" };
|
||
let evaluated = 1;
|
||
const coarsePose = estimateCoarsePose(settings);
|
||
const coarseSeed = coarsePose ? { pose: coarsePose, mode: "粗配准种子" } : null;
|
||
if (coarsePose) {
|
||
const coarseScore = candidateScore(coarsePose, settings);
|
||
evaluated += 1;
|
||
if (coarseScore > best.score) best = { pose: coarsePose, score: coarseScore, mode: "粗配准种子" };
|
||
}
|
||
const rounds = Array.from({ length: settings.iterations }, (_, index) => {
|
||
const t = Math.max(0.18, 1 - index / Math.max(settings.iterations, 1));
|
||
return { move: 0.34 * t, scale: 0.12 * t };
|
||
});
|
||
for (const [index, round] of rounds.entries()) {
|
||
const candidates = [];
|
||
const enabledKeys = [];
|
||
if (adjustable.translateX) enabledKeys.push("translateX");
|
||
if (adjustable.translateY) enabledKeys.push("translateY");
|
||
if (adjustable.translateZ) enabledKeys.push("translateZ");
|
||
if (adjustable.scale) enabledKeys.push("scale");
|
||
const stepForKey = (key, ratio = 1) => (key === "scale" ? round.scale : round.move) * ratio;
|
||
const poseWithDelta = (sourcePose, key, delta) => {
|
||
const baseValue = Number(sourcePose[key] ?? DEFAULT_POSE[key]);
|
||
const nextValue = key === "scale" ? Math.max(0.2, Math.min(3, baseValue + delta)) : baseValue + delta;
|
||
return { ...sourcePose, [key]: Number(nextValue.toFixed(key === "scale" ? 3 : 4)) };
|
||
};
|
||
const searchBases = [{ pose: best.pose, mode: best.mode }];
|
||
if (coarseSeed && poseSignature(coarseSeed.pose) !== poseSignature(best.pose)) searchBases.push(coarseSeed);
|
||
const seenCandidates = new Set();
|
||
const addCandidate = (pose, mode) => {
|
||
const signature = poseSignature(pose);
|
||
if (seenCandidates.has(signature)) return;
|
||
seenCandidates.add(signature);
|
||
candidates.push({ pose, mode });
|
||
};
|
||
searchBases.forEach((base) => {
|
||
enabledKeys.forEach((key) => {
|
||
addCandidate(poseWithDelta(base.pose, key, stepForKey(key)), `${base.mode} · ${key} +`);
|
||
addCandidate(poseWithDelta(base.pose, key, -stepForKey(key)), `${base.mode} · ${key} -`);
|
||
});
|
||
for (let left = 0; left < enabledKeys.length; left += 1) {
|
||
for (let right = left + 1; right < enabledKeys.length; right += 1) {
|
||
[-1, 1].forEach((leftSign) => {
|
||
[-1, 1].forEach((rightSign) => {
|
||
const leftKey = enabledKeys[left];
|
||
const rightKey = enabledKeys[right];
|
||
const pose = poseWithDelta(
|
||
poseWithDelta(base.pose, leftKey, stepForKey(leftKey, 0.65) * leftSign),
|
||
rightKey,
|
||
stepForKey(rightKey, 0.65) * rightSign,
|
||
);
|
||
addCandidate(pose, `${base.mode} · ${leftKey}/${rightKey} 联合`);
|
||
});
|
||
});
|
||
}
|
||
}
|
||
});
|
||
const candidateLimit = Math.max(settings.candidates, settings.candidates * searchBases.length);
|
||
const candidatesToEvaluate = candidates.slice(0, candidateLimit);
|
||
for (const candidate of candidatesToEvaluate) {
|
||
const pose = candidate.pose;
|
||
const score = candidateScore(pose, settings);
|
||
evaluated += 1;
|
||
if (score > best.score) best = { pose, score, mode: candidate.mode };
|
||
}
|
||
const progress = 40 + ((index + 1) / Math.max(rounds.length, 1)) * 48;
|
||
const scoreText = Number.isFinite(best.score) ? best.score.toFixed(4) : "不可用";
|
||
setAutoProgress(true, progress, `候选评分 ${index + 1}/${rounds.length}`, `本轮评估 ${candidatesToEvaluate.length}/${candidates.length} 个候选,累计 ${evaluated} 个;当前最高分候选 ${best.mode},score ${scoreText}。`, "score");
|
||
await waitForUiFrame();
|
||
}
|
||
setAutoProgress(true, 94, "应用最佳位姿", "正在把本轮最高分候选写回位姿控件和融合视图。", "apply");
|
||
state.pose = { ...best.pose };
|
||
state.autoResult = { ...best, evaluated, settings: { ...autoSettingsForStorage(settings), scoring: settings.imageContext ? "fixed-bright-bone-window-stl-samples" : "reference-stl-box-overlap", window: "bone", boneThreshold: BONE_BRIGHT_THRESHOLD, boneReward: BONE_FIXED_REWARD } };
|
||
state.lastAutoPose = { before, after: { ...state.pose } };
|
||
state.autoDeltaBaseline = { ...before };
|
||
renderPoseControls();
|
||
applyPose();
|
||
fitCamera();
|
||
const changed = poseSignature(best.pose) !== poseSignature(before);
|
||
setAutoState(changed ? "微调完成" : "微调无变化", changed ? "ok" : "warn");
|
||
const scoreText = Number.isFinite(best.score) ? best.score.toFixed(4) : "不可用";
|
||
const referenceText = describeAutoReference(settings);
|
||
$("autoResult").textContent = changed
|
||
? `本轮最高分候选:${best.mode};score ${scoreText};评估 ${evaluated} 个候选;${settings.imageContext ? `骨窗固定命中奖励,亮度 ≥ ${BONE_BRIGHT_THRESHOLD} 统一计为骨骼命中` : "参考 STL 盒体评分"};${referenceText}。`
|
||
: `本轮未找到比当前位姿更高分的候选;score ${scoreText};评估 ${evaluated} 个候选。这不代表已配准正确,只说明当前搜索范围和参考 STL 下没有更高分;${referenceText};自动微调目前只调整平移/缩放,明显旋转错位需要先手动校正。`;
|
||
updateAutoPoseResult(before, state.pose);
|
||
setAutoProgress(true, 100, changed ? "微调完成" : "本轮未改动", changed ? `已应用 ${best.mode}。` : "没有强行移动模型;请检查参考 STL 和大角度旋转。", "apply");
|
||
markDirty();
|
||
} catch (error) {
|
||
setAutoState("微调失败", "error");
|
||
$("autoResult").textContent = error.message || "自动微调失败。";
|
||
setAutoProgress(false);
|
||
} finally {
|
||
state.autoFineRunning = false;
|
||
setPoseEditingLocked(false);
|
||
}
|
||
}
|
||
|
||
async function saveAndIteratePose() {
|
||
if (state.autoFineRunning || state.poseControlsLocked) return;
|
||
state.autoFineRunning = true;
|
||
setPoseEditingLocked(true);
|
||
setAutoState("保存中", "warn");
|
||
setAutoProgress(true, 4, "保存当前位姿", "先保存当前人工位姿,并以保存后的位姿作为本轮自动微调起点。", "save");
|
||
const saved = await saveRegistration();
|
||
if (!saved) {
|
||
state.autoFineRunning = false;
|
||
setPoseEditingLocked(false);
|
||
setAutoProgress(false);
|
||
return;
|
||
}
|
||
state.autoFineRunning = false;
|
||
await runAutoFine({ lockAlreadyHeld: true, fromSaveIterate: true });
|
||
}
|
||
|
||
async function saveRegistration(nextStatus = null) {
|
||
if (!state.activeCase || !currentSeries() || state.saving) return false;
|
||
state.saving = true;
|
||
$("saveBtn").disabled = true;
|
||
$("statusBtn").disabled = true;
|
||
setSaveState("保存中", "warn");
|
||
const selectedSeries = currentSeries();
|
||
const status = nextStatus || state.registrationStatus;
|
||
const selectedFiles = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
|
||
try {
|
||
await api("/api/registrations", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
ct_number: state.activeCase.ct_number,
|
||
algorithm_model: state.algorithmModel,
|
||
registration_status: status,
|
||
series_instance_uid: selectedSeries.series_uid,
|
||
series_description: selectedSeries.description || "",
|
||
selected_stl_files: selectedFiles.map((file) => ({
|
||
id: file.id,
|
||
file_name: file.file_name,
|
||
segment_name: file.segment_name,
|
||
family: file.family,
|
||
category: file.category,
|
||
algorithm_model: file.algorithm_model,
|
||
})),
|
||
transform: state.pose,
|
||
module_styles: Object.fromEntries(selectedFiles.map((file, index) => {
|
||
const mode = stlMode(file);
|
||
return [file.file_name, {
|
||
color: stlColor(file, index),
|
||
opacity: mode === "solid" ? 1 : 0.52,
|
||
detail: state.modelDetail,
|
||
mode,
|
||
}];
|
||
})),
|
||
dicom_reference: {
|
||
series_uid: selectedSeries.series_uid,
|
||
series_number: selectedSeries.series_number,
|
||
description: selectedSeries.description,
|
||
slice_index: state.sliceIndex,
|
||
slice_count: selectedSeries.count,
|
||
window: state.windowMode,
|
||
volume: state.volumeMeta?.physicalSize || null,
|
||
},
|
||
model_reference: {
|
||
algorithm_model: state.algorithmModel,
|
||
selected_count: selectedFiles.length,
|
||
families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))],
|
||
fusion_mode: state.fusionMode,
|
||
fusion_detail: state.fusionDetail,
|
||
model_detail: state.modelDetail,
|
||
auto_result: state.autoResult,
|
||
},
|
||
notes: $("notes").value,
|
||
}),
|
||
});
|
||
state.registrationStatus = status;
|
||
state.registrationSeriesUid = selectedSeries.series_uid;
|
||
if (state.activeCase) {
|
||
state.activeCase.registration_status = status;
|
||
state.activeCase.series_instance_uid = selectedSeries.series_uid;
|
||
state.activeCase.series_description = selectedSeries.description || "";
|
||
}
|
||
const existing = state.cases.find((row) => sameCase(row, state.activeCase));
|
||
if (existing) {
|
||
existing.registration_status = status;
|
||
existing.series_instance_uid = selectedSeries.series_uid;
|
||
existing.series_description = selectedSeries.description || "";
|
||
}
|
||
resetDirty();
|
||
renderCases();
|
||
renderSeries();
|
||
renderActiveHeader();
|
||
return true;
|
||
} catch (error) {
|
||
setSaveState(error.message, "error");
|
||
return false;
|
||
} finally {
|
||
state.saving = false;
|
||
if (state.poseControlsLocked) {
|
||
applyPoseLockState();
|
||
} else {
|
||
$("saveBtn").disabled = false;
|
||
$("statusBtn").disabled = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
function sameHostUrl(raw, targetPort) {
|
||
try {
|
||
const url = new URL(raw);
|
||
const here = new URL(window.location.href);
|
||
if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname)) url.hostname = here.hostname;
|
||
if (targetPort) url.port = String(targetPort);
|
||
return url.toString();
|
||
} catch {
|
||
const here = new URL(window.location.href);
|
||
here.port = String(targetPort || here.port);
|
||
here.pathname = "/";
|
||
here.search = "";
|
||
return here.toString();
|
||
}
|
||
}
|
||
|
||
function openLinkedApp(kind) {
|
||
const ct = state.activeCase?.ct_number || "";
|
||
const model = state.algorithmModel || "";
|
||
if (kind === "viewer") {
|
||
const url = new URL(sameHostUrl(state.links.pacs_viewer_url, 8107));
|
||
if (ct) url.searchParams.set("ct_number", ct);
|
||
window.open(url.toString(), "_blank");
|
||
} else {
|
||
const url = new URL(sameHostUrl(state.links.relation_viewer_url, 8108));
|
||
if (ct) url.searchParams.set("ct_number", ct);
|
||
if (model) url.searchParams.set("algorithm_model", model);
|
||
window.open(url.toString(), "_blank");
|
||
}
|
||
}
|
||
|
||
function setWindowMode(mode, reload = true) {
|
||
state.windowMode = mode || "default";
|
||
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item.dataset.window === state.windowMode));
|
||
document.querySelectorAll("[data-map-window]").forEach((item) => item.classList.toggle("active", item.dataset.mapWindow === state.windowMode));
|
||
return reload ? loadFusion() : Promise.resolve();
|
||
}
|
||
|
||
function setMappingMode(mode) {
|
||
state.mappingMode = mode === "image" ? "image" : "result";
|
||
document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode));
|
||
renderDicomAnnotation();
|
||
scheduleMappingDraw();
|
||
}
|
||
|
||
function wireEvents() {
|
||
$("loginForm").addEventListener("submit", login);
|
||
$("logoutBtn").addEventListener("click", () => logout());
|
||
$("caseToggleBtn").addEventListener("click", () => {
|
||
state.caseCollapsed = !state.caseCollapsed;
|
||
document.querySelector(".workspace").classList.toggle("case-collapsed", state.caseCollapsed);
|
||
$("caseToggleBtn").classList.toggle("active", state.caseCollapsed);
|
||
window.setTimeout(() => {
|
||
state.resizeScene?.();
|
||
fitCamera();
|
||
}, 230);
|
||
});
|
||
$("refreshBtn").addEventListener("click", async () => {
|
||
await loadStatus();
|
||
await loadSettings();
|
||
await loadCases(false);
|
||
});
|
||
$("settingsBtn").addEventListener("click", async () => {
|
||
$("settingsPanel").classList.remove("hidden");
|
||
await loadSettings();
|
||
});
|
||
$("settingsCloseBtn").addEventListener("click", () => $("settingsPanel").classList.add("hidden"));
|
||
$("saveSettingsBtn").addEventListener("click", saveSettings);
|
||
$("manualCacheRefreshBtn").addEventListener("click", refreshCacheNow);
|
||
$("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
|
||
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
|
||
$("fitBtn").addEventListener("click", resetSceneView);
|
||
$("saveBtn").addEventListener("click", () => saveRegistration());
|
||
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
|
||
$("notes").addEventListener("input", () => markDirty("notes"));
|
||
$("cycleAllStlModeBtn").addEventListener("click", cycleAllStlMode);
|
||
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
|
||
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
|
||
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
|
||
$("poseManualToggle").addEventListener("click", () => {
|
||
state.collapsedPoseSections.manual = !state.collapsedPoseSections.manual;
|
||
renderPoseSectionCollapse();
|
||
});
|
||
$("poseAutoToggle").addEventListener("click", () => {
|
||
state.collapsedPoseSections.auto = !state.collapsedPoseSections.auto;
|
||
renderPoseSectionCollapse();
|
||
});
|
||
$("stretchXBtn").addEventListener("click", () => stretchAxis("x"));
|
||
$("stretchYBtn").addEventListener("click", () => stretchAxis("y"));
|
||
$("stretchZBtn").addEventListener("click", () => stretchAxis("z"));
|
||
$("openAutoModalBtn").addEventListener("click", resetAutoSettings);
|
||
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
|
||
$("autoCoarseBtn")?.addEventListener("click", runAutoCoarse);
|
||
$("autoFineBtn")?.addEventListener("click", runAutoFine);
|
||
$("savePoseBtn").addEventListener("click", () => saveRegistration());
|
||
$("saveIterateBtn").addEventListener("click", saveAndIteratePose);
|
||
$("restorePoseBtn").addEventListener("click", restoreAutoPose);
|
||
const syncSampleSlices = (source) => {
|
||
const range = $("autoSampleSlices");
|
||
const number = $("autoSampleSlicesNumber");
|
||
const value = Math.max(3, Math.min(60, Number(source.value || 9)));
|
||
range.value = value;
|
||
number.value = value;
|
||
};
|
||
$("autoSampleSlices").addEventListener("input", () => syncSampleSlices($("autoSampleSlices")));
|
||
$("autoSampleSlicesNumber").addEventListener("input", () => syncSampleSlices($("autoSampleSlicesNumber")));
|
||
$("autoDefaultsBtn").addEventListener("click", () => {
|
||
$("autoSampleSlices").value = 9;
|
||
$("autoSampleSlicesNumber").value = 9;
|
||
$("autoIterations").value = 30;
|
||
});
|
||
$("dicomPreview").addEventListener("load", () => {
|
||
setDicomLoading(true, "正在计算 STL 分割映射", 42);
|
||
scheduleMappingDraw();
|
||
});
|
||
$("dicomPreview").addEventListener("error", () => {
|
||
setDicomLoading(false);
|
||
resetMappingCanvas("DICOM Base Layer 加载失败");
|
||
});
|
||
$("mappingResetBtn").addEventListener("click", () => {
|
||
state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 };
|
||
applyMappingTransform();
|
||
});
|
||
$("mappingRotateLeftBtn").addEventListener("click", () => {
|
||
state.mappingViewport.rotation = (Number(state.mappingViewport.rotation) || 0) - 90;
|
||
applyMappingTransform();
|
||
});
|
||
$("mappingRotateRightBtn").addEventListener("click", () => {
|
||
state.mappingViewport.rotation = (Number(state.mappingViewport.rotation) || 0) + 90;
|
||
applyMappingTransform();
|
||
});
|
||
$("mappingViewport").addEventListener("wheel", (event) => {
|
||
event.preventDefault();
|
||
const factor = event.deltaY > 0 ? 0.9 : 1.1;
|
||
state.mappingViewport.scale = Math.max(0.45, Math.min(6, state.mappingViewport.scale * factor));
|
||
applyMappingTransform();
|
||
}, { passive: false });
|
||
$("mappingViewport").addEventListener("pointerdown", (event) => {
|
||
if (event.button !== 0) return;
|
||
state.mappingDrag = {
|
||
pointerId: event.pointerId,
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
offsetX: state.mappingViewport.offsetX,
|
||
offsetY: state.mappingViewport.offsetY,
|
||
};
|
||
$("mappingViewport").classList.add("dragging");
|
||
$("mappingViewport").setPointerCapture(event.pointerId);
|
||
});
|
||
$("mappingViewport").addEventListener("pointermove", (event) => {
|
||
const drag = state.mappingDrag;
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
state.mappingViewport.offsetX = drag.offsetX + event.clientX - drag.startX;
|
||
state.mappingViewport.offsetY = drag.offsetY + event.clientY - drag.startY;
|
||
applyMappingTransform();
|
||
});
|
||
const stopMappingDrag = (event) => {
|
||
const drag = state.mappingDrag;
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
state.mappingDrag = null;
|
||
$("mappingViewport").classList.remove("dragging");
|
||
if ($("mappingViewport").hasPointerCapture(event.pointerId)) $("mappingViewport").releasePointerCapture(event.pointerId);
|
||
};
|
||
$("mappingViewport").addEventListener("pointerup", stopMappingDrag);
|
||
$("mappingViewport").addEventListener("pointercancel", stopMappingDrag);
|
||
document.querySelectorAll("[data-tool-tab]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.activeToolTab = button.dataset.toolTab || "series";
|
||
document.querySelectorAll("[data-tool-tab]").forEach((item) => item.classList.toggle("active", item === button));
|
||
document.querySelectorAll("[data-tool-pane]").forEach((pane) => pane.classList.toggle("active", pane.dataset.toolPane === state.activeToolTab));
|
||
});
|
||
});
|
||
$("caseSearch").addEventListener("input", () => {
|
||
state.search = $("caseSearch").value.trim();
|
||
window.clearTimeout(state.searchTimer);
|
||
state.searchTimer = window.setTimeout(() => loadCases(false), 240);
|
||
});
|
||
$("caseList").addEventListener("scroll", () => {
|
||
const list = $("caseList");
|
||
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 180) {
|
||
loadMoreCases();
|
||
}
|
||
});
|
||
document.querySelectorAll(".filter[data-status]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
state.statusFilter = button.dataset.status || "";
|
||
document.querySelectorAll(".filter[data-status]").forEach((item) => item.classList.toggle("active", item === button));
|
||
await loadCases(false);
|
||
});
|
||
});
|
||
document.querySelectorAll(".filter[data-part]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
state.partFilter = button.dataset.part || "";
|
||
document.querySelectorAll(".filter[data-part]").forEach((item) => item.classList.toggle("active", item === button));
|
||
await loadCases(false);
|
||
});
|
||
});
|
||
document.querySelectorAll(".filter[data-model-filter]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
state.modelFilter = button.dataset.modelFilter || "";
|
||
document.querySelectorAll(".filter[data-model-filter]").forEach((item) => item.classList.toggle("active", item === button));
|
||
await loadCases(false);
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-window]").forEach((button) => {
|
||
button.addEventListener("click", () => setWindowMode(button.dataset.window || "default"));
|
||
});
|
||
document.querySelectorAll("[data-map-window]").forEach((button) => {
|
||
button.addEventListener("click", () => setWindowMode(button.dataset.mapWindow || "default"));
|
||
});
|
||
document.querySelectorAll("[data-map-mode]").forEach((button) => {
|
||
button.addEventListener("click", () => setMappingMode(button.dataset.mapMode || "result"));
|
||
});
|
||
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.fusionMode = button.dataset.fusionMode || "fusion";
|
||
document.querySelectorAll("[data-fusion-mode]").forEach((item) => item.classList.toggle("active", item === button));
|
||
applyFusionMode();
|
||
fitCamera();
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-fusion-detail]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
const nextDetail = button.dataset.fusionDetail || "low";
|
||
if (nextDetail === state.fusionDetail) return;
|
||
state.fusionDetail = nextDetail;
|
||
document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item === button));
|
||
applyFusionDetail();
|
||
markDirty("display");
|
||
await loadFusion();
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-model-detail]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
state.modelDetail = button.dataset.modelDetail || "standard";
|
||
document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item === button));
|
||
applyModelDetail();
|
||
markDirty("display");
|
||
});
|
||
});
|
||
$("mappingSliceSlider").addEventListener("input", () => {
|
||
const selected = currentSeries();
|
||
const max = Math.max(0, Number(selected?.count || 0) - 1);
|
||
state.sliceIndex = max - Number($("mappingSliceSlider").value || 0);
|
||
updateSliceControl();
|
||
renderDicomAnnotation();
|
||
updateDicomPreview();
|
||
});
|
||
const updateRange = () => {
|
||
state.sliceRangeStart = Number($("sliceRangeStart").value || 0);
|
||
state.sliceRangeEnd = Number($("sliceRangeEnd").value || 0);
|
||
updateSliceControl();
|
||
};
|
||
$("sliceRangeStart").addEventListener("input", updateRange);
|
||
$("sliceRangeEnd").addEventListener("input", updateRange);
|
||
$("sliceRangeStart").addEventListener("change", () => loadFusion());
|
||
$("sliceRangeEnd").addEventListener("change", () => loadFusion());
|
||
}
|
||
|
||
wireEvents();
|
||
renderPoseSectionCollapse();
|
||
|
||
if (state.token) {
|
||
bootstrap();
|
||
} else {
|
||
loginVisible(true);
|
||
}
|