899 lines
33 KiB
JavaScript
899 lines
33 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: "上腹部",
|
||
lower_abdomen: "下腹部",
|
||
pelvis: "盆腔",
|
||
};
|
||
|
||
const DEFAULT_POSE = {
|
||
rotateX: 0,
|
||
rotateY: 0,
|
||
rotateZ: 0,
|
||
translateX: 0,
|
||
translateY: 0,
|
||
translateZ: 0,
|
||
scale: 1,
|
||
flipX: false,
|
||
flipY: false,
|
||
flipZ: false,
|
||
};
|
||
|
||
const POSE_CONTROLS = [
|
||
{ key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 1, suffix: "°" },
|
||
{ key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 1, suffix: "°" },
|
||
{ key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 1, suffix: "°" },
|
||
{ key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.01, suffix: "" },
|
||
{ key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.01, suffix: "" },
|
||
{ key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.01, suffix: "" },
|
||
{ key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.01, suffix: "x" },
|
||
];
|
||
|
||
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
|
||
|
||
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" },
|
||
statusFilter: "",
|
||
partFilter: "",
|
||
search: "",
|
||
cases: [],
|
||
activeCase: null,
|
||
series: [],
|
||
selectedSeriesUid: "",
|
||
stlFiles: [],
|
||
selectedStlIds: new Set(),
|
||
algorithmModel: "",
|
||
registrationStatus: "unregistered",
|
||
pose: { ...DEFAULT_POSE },
|
||
windowMode: "default",
|
||
sliceIndex: 0,
|
||
dirty: false,
|
||
saving: false,
|
||
fusionVersion: 0,
|
||
volumeMeta: null,
|
||
sceneReady: false,
|
||
renderer: null,
|
||
camera: null,
|
||
scene: null,
|
||
controls: null,
|
||
dicomGroup: null,
|
||
modelGroup: null,
|
||
rawModelGroup: null,
|
||
baseModelScale: 1,
|
||
resizeObserver: null,
|
||
};
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
function cls(value) {
|
||
return value ? "active" : "";
|
||
}
|
||
|
||
function setTopLoading(visible) {
|
||
$("topLoading").classList.toggle("hidden", !visible);
|
||
}
|
||
|
||
function setFusionLoading(visible, text = "正在构建融合视图") {
|
||
$("fusionLoading").classList.toggle("hidden", !visible);
|
||
$("fusionLoading").querySelector("span").textContent = text;
|
||
}
|
||
|
||
function setSaveState(text, tone = "") {
|
||
const el = $("saveState");
|
||
el.textContent = text;
|
||
el.className = tone;
|
||
}
|
||
|
||
function markDirty() {
|
||
state.dirty = true;
|
||
setSaveState("未保存", "warn");
|
||
}
|
||
|
||
function resetDirty() {
|
||
state.dirty = false;
|
||
setSaveState("已保存", "ok");
|
||
}
|
||
|
||
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();
|
||
} catch (error) {
|
||
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
|
||
console.warn(error);
|
||
}
|
||
buildPoseControls();
|
||
await loadStatus();
|
||
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 loadCases(selectFirst = true) {
|
||
setTopLoading(true);
|
||
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);
|
||
const rows = await api(`/api/cases?${params.toString()}`);
|
||
state.cases = rows;
|
||
renderCases();
|
||
if (selectFirst && !state.activeCase && rows.length) {
|
||
await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型");
|
||
} else if (state.activeCase && !rows.some((row) => sameCase(row, state.activeCase))) {
|
||
state.activeCase = null;
|
||
clearDetail();
|
||
}
|
||
} finally {
|
||
setTopLoading(false);
|
||
}
|
||
}
|
||
|
||
function sameCase(a, b) {
|
||
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
|
||
}
|
||
|
||
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 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.cases.length;
|
||
const active = state.activeCase;
|
||
$("caseList").innerHTML = 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").querySelectorAll(".case-card").forEach((button) => {
|
||
button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型"));
|
||
});
|
||
}
|
||
|
||
function clearDetail() {
|
||
state.series = [];
|
||
state.stlFiles = [];
|
||
state.selectedStlIds = new Set();
|
||
state.selectedSeriesUid = "";
|
||
$("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 = "";
|
||
clearFusion();
|
||
}
|
||
|
||
async function confirmChange() {
|
||
if (!state.dirty) return true;
|
||
return window.confirm("当前配准参数还未保存,确定切换吗?");
|
||
}
|
||
|
||
async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
||
if (!ctNumber || !(await confirmChange())) return;
|
||
setTopLoading(true);
|
||
setFusionLoading(true, "正在读取 CT 关联数据");
|
||
try {
|
||
const params = new URLSearchParams({ algorithm_model: algorithmModel || "未指定模型" });
|
||
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";
|
||
state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) };
|
||
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.selectedSeriesUid = registration.series_instance_uid || pickDefaultSeries(state.series)?.series_uid || "";
|
||
const selected = currentSeries();
|
||
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
|
||
|
||
renderCases();
|
||
renderActiveHeader();
|
||
renderSeries();
|
||
renderStl();
|
||
renderPoseControls();
|
||
resetDirty();
|
||
await loadFusion();
|
||
} catch (error) {
|
||
$("fusionStatus").textContent = error.message;
|
||
clearFusion();
|
||
} finally {
|
||
setTopLoading(false);
|
||
setFusionLoading(false);
|
||
}
|
||
}
|
||
|
||
function pickDefaultSeries(series) {
|
||
return [...series]
|
||
.filter((item) => Number(item.count || 0) > 1)
|
||
.sort((a, b) => Number(b.count || 0) - Number(a.count || 0))[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 currentSeries() {
|
||
return state.series.find((item) => item.series_uid === state.selectedSeriesUid) || null;
|
||
}
|
||
|
||
function renderActiveHeader() {
|
||
const row = state.activeCase;
|
||
if (!row) return;
|
||
$("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`;
|
||
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${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 ${row.registration_status === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
|
||
...bodyPartTags(row).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
|
||
? state.series
|
||
.map((item) => {
|
||
const active = item.series_uid === state.selectedSeriesUid;
|
||
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);
|
||
return `
|
||
<button class="series-card ${active ? "active" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
|
||
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
|
||
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
|
||
<small>序列 ${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>
|
||
</button>
|
||
`;
|
||
})
|
||
.join("")
|
||
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
|
||
$("seriesList").querySelectorAll(".series-card").forEach((button) => {
|
||
button.addEventListener("click", () => selectSeries(button.dataset.series));
|
||
});
|
||
updateSliceControl();
|
||
}
|
||
|
||
async function selectSeries(uid) {
|
||
if (!uid || uid === state.selectedSeriesUid) return;
|
||
if (!(await confirmChange())) return;
|
||
state.selectedSeriesUid = uid;
|
||
const selected = currentSeries();
|
||
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
|
||
markDirty();
|
||
renderSeries();
|
||
await loadFusion();
|
||
}
|
||
|
||
function renderStl() {
|
||
$("stlCount").textContent = state.stlFiles.length;
|
||
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("");
|
||
$("stlList").innerHTML = state.stlFiles.length
|
||
? state.stlFiles
|
||
.map((file, index) => {
|
||
const checked = state.selectedStlIds.has(Number(file.id));
|
||
return `
|
||
<label class="stl-row ${checked ? "active" : ""}" title="${escapeHtml(file.file_name)}">
|
||
<input type="checkbox" data-stl="${Number(file.id)}" ${checked ? "checked" : ""} />
|
||
<span>
|
||
<strong>${escapeHtml(file.segment_name || file.file_name)}</strong>
|
||
<small>${escapeHtml(file.family || "未分类")} · ${escapeHtml(file.category || "")} · ${formatBytes(file.size_bytes)}</small>
|
||
<small style="color:${cssColor(index)}">${escapeHtml(file.file_name)}</small>
|
||
</span>
|
||
</label>
|
||
`;
|
||
})
|
||
.join("")
|
||
: `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
|
||
$("stlList").querySelectorAll("input[data-stl]").forEach((input) => {
|
||
input.addEventListener("change", async () => {
|
||
const id = Number(input.dataset.stl);
|
||
if (input.checked) state.selectedStlIds.add(id);
|
||
else state.selectedStlIds.delete(id);
|
||
markDirty();
|
||
renderStl();
|
||
await loadFusion();
|
||
});
|
||
});
|
||
}
|
||
|
||
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>
|
||
<input type="range" min="${item.min}" max="${item.max}" step="${item.step}" />
|
||
<input type="number" min="${item.min}" max="${item.max}" step="${item.step}" />
|
||
</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 sync = (value, source) => {
|
||
const numeric = Number(value);
|
||
state.pose[key] = Number.isFinite(numeric) ? numeric : 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-flip]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const key = button.dataset.flip;
|
||
state.pose[key] = !state.pose[key];
|
||
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(2);
|
||
});
|
||
document.querySelectorAll("[data-flip]").forEach((button) => {
|
||
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
|
||
});
|
||
renderActiveHeader();
|
||
}
|
||
|
||
function updateSliceControl() {
|
||
const selected = currentSeries();
|
||
const count = Number(selected?.count || 0);
|
||
$("sliceSlider").max = Math.max(0, count - 1);
|
||
$("sliceSlider").value = Math.min(state.sliceIndex, Math.max(0, count - 1));
|
||
$("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 1} / ${count}` : "切片 0 / 0";
|
||
}
|
||
|
||
function initScene() {
|
||
if (state.sceneReady) return;
|
||
const viewport = $("fusionViewport");
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||
renderer.setClearColor(0x000000, 1);
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||
viewport.appendChild(renderer.domElement);
|
||
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000);
|
||
camera.position.set(0, -8.5, 4.4);
|
||
camera.up.set(0, 0, 1);
|
||
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
controls.enableDamping = true;
|
||
controls.dampingFactor = 0.08;
|
||
controls.target.set(0, 0, 0);
|
||
|
||
const ambient = new THREE.AmbientLight(0xffffff, 0.82);
|
||
const directional = new THREE.DirectionalLight(0xffffff, 1.8);
|
||
directional.position.set(3.5, -4, 6);
|
||
scene.add(ambient, directional);
|
||
|
||
const dicomGroup = new THREE.Group();
|
||
const modelGroup = new THREE.Group();
|
||
const rawModelGroup = new THREE.Group();
|
||
modelGroup.add(rawModelGroup);
|
||
scene.add(dicomGroup, modelGroup);
|
||
|
||
Object.assign(state, { renderer, camera, scene, controls, dicomGroup, modelGroup, rawModelGroup, 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.resizeObserver = new ResizeObserver(resize);
|
||
state.resizeObserver.observe(viewport);
|
||
resize();
|
||
|
||
const animate = () => {
|
||
requestAnimationFrame(animate);
|
||
controls.update();
|
||
renderer.render(scene, camera);
|
||
};
|
||
animate();
|
||
}
|
||
|
||
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);
|
||
state.volumeMeta = null;
|
||
$("fusionMeta").textContent = "DICOM - · STL -";
|
||
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
|
||
updateSliceControl();
|
||
}
|
||
|
||
async function loadFusion() {
|
||
const version = ++state.fusionVersion;
|
||
const selected = currentSeries();
|
||
if (!state.sceneReady) {
|
||
$("fusionStatus").textContent = "当前浏览器未能启用 WebGL,无法显示三维融合视图";
|
||
return;
|
||
}
|
||
if (!state.activeCase || !selected) {
|
||
clearFusion();
|
||
return;
|
||
}
|
||
setFusionLoading(true, "正在按需加载 DICOM 切片与 STL");
|
||
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,
|
||
radius: "24",
|
||
});
|
||
const volume = await api(`/api/dicom/fusion-volume?${params.toString()}`);
|
||
if (version !== state.fusionVersion) return;
|
||
state.volumeMeta = volume;
|
||
await buildDicomVolume(volume);
|
||
if (version !== state.fusionVersion) return;
|
||
await buildStlModels(volume);
|
||
if (version !== state.fusionVersion) return;
|
||
fitCamera();
|
||
$("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`;
|
||
$("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size} 个`;
|
||
} 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, resolve, undefined, reject);
|
||
});
|
||
}
|
||
|
||
async function buildDicomVolume(volume) {
|
||
clearGroup(state.dicomGroup);
|
||
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 = 5.2 / maxPhysical;
|
||
const width = (physical.width || 1) * sceneScale;
|
||
const height = (physical.height || 1) * sceneScale;
|
||
const depth = (physical.depth || 1) * sceneScale;
|
||
const centerIndex = Number(volume.center || 0);
|
||
const indices = volume.indices || [];
|
||
const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0;
|
||
const textures = await Promise.all((volume.frames || []).map((frame) => loadTexture(frame)));
|
||
textures.forEach((texture, index) => {
|
||
texture.colorSpace = THREE.SRGBColorSpace;
|
||
const material = new THREE.MeshBasicMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
opacity: index === centerPosition ? 0.78 : 0.14,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false,
|
||
});
|
||
const plane = new THREE.Mesh(new THREE.PlaneGeometry(width, height), material);
|
||
const z = (Number(indices[index] ?? centerIndex) - centerIndex) * Number(volume.spacing?.slice || 1) * sceneScale;
|
||
plane.position.z = z;
|
||
state.dicomGroup.add(plane);
|
||
});
|
||
const box = new THREE.BoxGeometry(width, height, Math.max(depth, 0.02));
|
||
const edges = new THREE.EdgesGeometry(box);
|
||
const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 }));
|
||
state.dicomGroup.add(lines);
|
||
state.baseModelScale = sceneScale;
|
||
}
|
||
|
||
async function buildStlModels(volume) {
|
||
clearGroup(state.rawModelGroup);
|
||
const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
|
||
if (!files.length) {
|
||
applyPose();
|
||
return;
|
||
}
|
||
const loader = new STLLoader();
|
||
const token = encodeURIComponent(state.token);
|
||
for (const [index, file] of files.entries()) {
|
||
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 material = new THREE.MeshStandardMaterial({
|
||
color: STL_COLORS[index % STL_COLORS.length],
|
||
metalness: 0.08,
|
||
roughness: 0.5,
|
||
transparent: true,
|
||
opacity: 0.58,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false,
|
||
});
|
||
const mesh = new THREE.Mesh(geometry, material);
|
||
mesh.name = file.segment_name || file.file_name;
|
||
state.rawModelGroup.add(mesh);
|
||
}
|
||
const bbox = new THREE.Box3().setFromObject(state.rawModelGroup);
|
||
const center = new THREE.Vector3();
|
||
bbox.getCenter(center);
|
||
state.rawModelGroup.position.set(-center.x, -center.y, -center.z);
|
||
applyPose();
|
||
}
|
||
|
||
function applyPose() {
|
||
if (!state.modelGroup) return;
|
||
const pose = { ...DEFAULT_POSE, ...state.pose };
|
||
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));
|
||
}
|
||
|
||
function fitCamera() {
|
||
if (!state.camera || !state.controls) return;
|
||
const box = new THREE.Box3();
|
||
box.expandByObject(state.dicomGroup);
|
||
box.expandByObject(state.modelGroup);
|
||
const size = new THREE.Vector3();
|
||
const center = new THREE.Vector3();
|
||
box.getSize(size);
|
||
box.getCenter(center);
|
||
const distance = Math.max(size.x, size.y, size.z, 1) * 1.8;
|
||
state.camera.position.set(center.x, center.y - distance, center.z + distance * 0.45);
|
||
state.controls.target.copy(center);
|
||
state.controls.update();
|
||
}
|
||
|
||
async function saveRegistration(nextStatus = null) {
|
||
if (!state.activeCase || !currentSeries() || state.saving) return;
|
||
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) => [file.file_name, { color: cssColor(index), opacity: 0.58 }])),
|
||
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))],
|
||
},
|
||
notes: $("notes").value,
|
||
}),
|
||
});
|
||
state.registrationStatus = status;
|
||
if (state.activeCase) state.activeCase.registration_status = status;
|
||
const existing = state.cases.find((row) => sameCase(row, state.activeCase));
|
||
if (existing) existing.registration_status = status;
|
||
resetDirty();
|
||
renderCases();
|
||
renderActiveHeader();
|
||
} catch (error) {
|
||
setSaveState(error.message, "error");
|
||
} finally {
|
||
state.saving = false;
|
||
$("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 wireEvents() {
|
||
$("loginForm").addEventListener("submit", login);
|
||
$("logoutBtn").addEventListener("click", () => logout());
|
||
$("refreshBtn").addEventListener("click", async () => {
|
||
await loadStatus();
|
||
await loadCases(false);
|
||
});
|
||
$("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
|
||
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
|
||
$("fitBtn").addEventListener("click", fitCamera);
|
||
$("saveBtn").addEventListener("click", () => saveRegistration());
|
||
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
|
||
$("notes").addEventListener("input", markDirty);
|
||
$("caseSearch").addEventListener("input", () => {
|
||
state.search = $("caseSearch").value.trim();
|
||
window.clearTimeout(state.searchTimer);
|
||
state.searchTimer = window.setTimeout(() => loadCases(false), 240);
|
||
});
|
||
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(".segmented button").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
state.windowMode = button.dataset.window || "default";
|
||
document.querySelectorAll(".segmented button").forEach((item) => item.classList.toggle("active", item === button));
|
||
await loadFusion();
|
||
});
|
||
});
|
||
$("sliceSlider").addEventListener("input", () => {
|
||
state.sliceIndex = Number($("sliceSlider").value || 0);
|
||
updateSliceControl();
|
||
});
|
||
$("sliceSlider").addEventListener("change", () => loadFusion());
|
||
}
|
||
|
||
wireEvents();
|
||
|
||
if (state.token) {
|
||
bootstrap();
|
||
} else {
|
||
loginVisible(true);
|
||
}
|