1057 lines
38 KiB
JavaScript
1057 lines
38 KiB
JavaScript
import * as THREE from "three";
|
||
import { STLLoader } from "./vendor/STLLoader.js";
|
||
import { OrbitControls } from "./vendor/OrbitControls.js";
|
||
|
||
const state = {
|
||
token: localStorage.getItem("dicom_upp_registration_token") || "",
|
||
user: null,
|
||
cases: [],
|
||
activeCase: null,
|
||
series: [],
|
||
selectedSeries: null,
|
||
stlFiles: [],
|
||
selectedStlIds: new Set(),
|
||
selectedFamilies: new Set(),
|
||
mode: "file",
|
||
registrations: [],
|
||
pose: defaultPose(),
|
||
maskVisible: true,
|
||
sliceIndex: 0,
|
||
plane: "axial",
|
||
window: "default",
|
||
loading: false,
|
||
searchTimer: null,
|
||
links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" },
|
||
scenes: {},
|
||
};
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
const poseFields = [
|
||
{ key: "translateX", label: "平移 X", min: -2, max: 2, step: 0.001 },
|
||
{ key: "translateY", label: "平移 Y", min: -2, max: 2, step: 0.001 },
|
||
{ key: "translateZ", label: "平移 Z", min: -2, max: 2, step: 0.001 },
|
||
{ key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 1 },
|
||
{ key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 1 },
|
||
{ key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 1 },
|
||
{ key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.001 },
|
||
];
|
||
|
||
const bodyPartLabels = {
|
||
head_neck: "头颈部",
|
||
chest: "胸部",
|
||
upper_abdomen: "上腹部",
|
||
lower_abdomen: "下腹部",
|
||
pelvis: "盆腔",
|
||
};
|
||
|
||
const familyColors = [
|
||
"#38d7c7",
|
||
"#f4a261",
|
||
"#d88cf6",
|
||
"#54df8c",
|
||
"#60a5fa",
|
||
"#f87171",
|
||
"#facc15",
|
||
"#a3e635",
|
||
"#22d3ee",
|
||
"#f472b6",
|
||
];
|
||
|
||
function defaultPose() {
|
||
return {
|
||
translateX: 0,
|
||
translateY: 0,
|
||
translateZ: 0,
|
||
rotateX: 0,
|
||
rotateY: 0,
|
||
rotateZ: 0,
|
||
scale: 1,
|
||
flipX: false,
|
||
flipY: false,
|
||
flipZ: false,
|
||
};
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function asList(value) {
|
||
return Array.isArray(value) ? value : [];
|
||
}
|
||
|
||
function fmtDate(value) {
|
||
const text = String(value || "");
|
||
if (!text) return "";
|
||
if (/^\d{8}$/.test(text)) return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
|
||
return text.replace("T", " ").slice(0, 19);
|
||
}
|
||
|
||
function fmtTime(value) {
|
||
const digits = String(value || "").replace(/\D/g, "").slice(0, 6);
|
||
if (digits.length < 6) return value || "";
|
||
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}:${digits.slice(4, 6)}`;
|
||
}
|
||
|
||
function fmtBytes(value) {
|
||
const size = Number(value || 0);
|
||
if (!size) return "0 B";
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)));
|
||
return `${(size / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||
}
|
||
|
||
function authParams(params = {}) {
|
||
const output = new URLSearchParams(params);
|
||
if (state.token) output.set("access_token", state.token);
|
||
return output;
|
||
}
|
||
|
||
async function request(path, options = {}) {
|
||
const headers = { ...(options.headers || {}) };
|
||
if (state.token) headers.Authorization = `Bearer ${state.token}`;
|
||
if (options.body && !(options.body instanceof FormData)) headers["Content-Type"] = "application/json";
|
||
const res = await fetch(path, { ...options, headers });
|
||
if (res.status === 401) {
|
||
showLogin(true);
|
||
throw new Error("unauthorized");
|
||
}
|
||
if (!res.ok) {
|
||
let detail = res.statusText;
|
||
try {
|
||
const data = await res.json();
|
||
detail = data.detail || detail;
|
||
} catch (_) {
|
||
detail = await res.text();
|
||
}
|
||
throw new Error(detail);
|
||
}
|
||
return res;
|
||
}
|
||
|
||
async function json(path, options = {}) {
|
||
const res = await request(path, options);
|
||
return res.json();
|
||
}
|
||
|
||
function setLoading(show) {
|
||
state.loading = show;
|
||
$("topLoading").classList.toggle("hidden", !show);
|
||
}
|
||
|
||
function showLogin(show) {
|
||
$("loginOverlay").classList.toggle("hidden", !show);
|
||
}
|
||
|
||
function sameHostUrl(rawUrl, fallbackPort) {
|
||
try {
|
||
const url = new URL(rawUrl || `${location.protocol}//${location.hostname}:${fallbackPort}`, location.href);
|
||
if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname) && !["127.0.0.1", "localhost"].includes(location.hostname)) {
|
||
url.hostname = location.hostname;
|
||
}
|
||
url.protocol = location.protocol;
|
||
return url.toString().replace(/\/$/, "");
|
||
} catch (_) {
|
||
return `${location.protocol}//${location.hostname}:${fallbackPort}`;
|
||
}
|
||
}
|
||
|
||
function relationBaseUrl() {
|
||
return sameHostUrl(state.links.relation_viewer_url, 8108);
|
||
}
|
||
|
||
function viewerBaseUrl() {
|
||
return sameHostUrl(state.links.pacs_viewer_url, 8107);
|
||
}
|
||
|
||
function renderPoseControls() {
|
||
$("poseGrid").innerHTML = `
|
||
${poseFields
|
||
.map(
|
||
(field) => `
|
||
<div class="pose-control">
|
||
<label>${field.label}</label>
|
||
<input data-pose-range="${field.key}" type="range" min="${field.min}" max="${field.max}" step="${field.step}" value="${state.pose[field.key]}" />
|
||
<input data-pose-number="${field.key}" type="number" min="${field.min}" max="${field.max}" step="${field.step}" value="${state.pose[field.key]}" />
|
||
</div>
|
||
`,
|
||
)
|
||
.join("")}
|
||
<div class="flip-row">
|
||
<button data-flip="flipX" type="button">翻转 X</button>
|
||
<button data-flip="flipY" type="button">翻转 Y</button>
|
||
<button data-flip="flipZ" type="button">翻转 Z</button>
|
||
</div>
|
||
`;
|
||
document.querySelectorAll("[data-pose-range], [data-pose-number]").forEach((input) => {
|
||
input.addEventListener("input", () => {
|
||
const key = input.dataset.poseRange || input.dataset.poseNumber;
|
||
state.pose[key] = Number(input.value);
|
||
syncPoseInputs();
|
||
markUnsaved();
|
||
updateScenesPose();
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-flip]").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
const key = button.dataset.flip;
|
||
state.pose[key] = !state.pose[key];
|
||
syncPoseInputs();
|
||
markUnsaved();
|
||
updateScenesPose();
|
||
});
|
||
});
|
||
syncPoseInputs();
|
||
}
|
||
|
||
function syncPoseInputs() {
|
||
poseFields.forEach((field) => {
|
||
const value = Number(state.pose[field.key] ?? defaultPose()[field.key]);
|
||
document.querySelectorAll(`[data-pose-range="${field.key}"], [data-pose-number="${field.key}"]`).forEach((input) => {
|
||
input.value = field.step < 1 ? value.toFixed(3) : String(Math.round(value));
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-flip]").forEach((button) => {
|
||
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
|
||
});
|
||
}
|
||
|
||
function markUnsaved() {
|
||
$("saveState").textContent = "未保存";
|
||
$("fusionMeta").textContent = "位姿未保存";
|
||
}
|
||
|
||
function setDbStatus(data) {
|
||
state.links = data.links || state.links;
|
||
const pill = $("dbStatus");
|
||
if (data.database?.ok) {
|
||
pill.textContent = `${data.database.database} 已连接`;
|
||
pill.className = "status-pill online";
|
||
} else {
|
||
pill.textContent = "数据库异常";
|
||
pill.className = "status-pill offline";
|
||
}
|
||
}
|
||
|
||
async function loadStatus() {
|
||
const data = await json("/api/status");
|
||
setDbStatus(data);
|
||
}
|
||
|
||
async function loadCases() {
|
||
setLoading(true);
|
||
try {
|
||
const q = $("caseSearch").value.trim();
|
||
const params = new URLSearchParams();
|
||
if (q) params.set("q", q);
|
||
params.set("limit", "120");
|
||
state.cases = await json(`/api/cases?${params.toString()}`);
|
||
renderCases();
|
||
const targetCt = new URLSearchParams(location.search).get("ct_number");
|
||
const next = targetCt
|
||
? state.cases.find((row) => String(row.ct_key).toUpperCase() === targetCt.toUpperCase() || String(row.pacs_ct_number).toUpperCase() === targetCt.toUpperCase())
|
||
: state.cases[0];
|
||
if (next) await selectCase(next.ct_key);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
function renderPartTags(row) {
|
||
const parts = asList(row.body_parts).map((part) => bodyPartLabels[part] || part);
|
||
return parts.map((part) => `<em class="tag">${escapeHtml(part)}</em>`).join("");
|
||
}
|
||
|
||
function renderCases() {
|
||
$("caseCount").textContent = String(state.cases.length);
|
||
if (!state.cases.length) {
|
||
$("caseList").innerHTML = `<div class="empty-state">没有 DICOM + STL 匹配检查</div>`;
|
||
return;
|
||
}
|
||
$("caseList").innerHTML = state.cases
|
||
.map((row) => {
|
||
const active = state.activeCase?.ct_key === row.ct_key ? "active" : "";
|
||
const regLabel = row.registration_count ? `已配准 ${row.registered_series || 0}` : "未配准";
|
||
const lockLabel = row.locked_count ? `<em class="tag locked">${Number(row.locked_count)} 已锁</em>` : "";
|
||
return `
|
||
<button class="case-card ${active}" data-case="${escapeHtml(row.ct_key)}" title="${escapeHtml(row.ct_key)}\n${escapeHtml(row.patient_name || "")}">
|
||
<div class="card-line">
|
||
<strong>${escapeHtml(row.ct_key)}</strong>
|
||
<em class="tag ${row.registration_count ? "" : "warn"}">${escapeHtml(regLabel)}</em>
|
||
</div>
|
||
<span>${escapeHtml(row.patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || "无ID")}</span>
|
||
<small>DICOM ${Number(row.series_count || 0)} 序列 · STL ${Number(row.stl_file_count || 0)} 个</small>
|
||
<div class="tag-line">
|
||
${row.algorithm_model ? `<em class="tag">${escapeHtml(row.algorithm_model)}</em>` : ""}
|
||
${renderPartTags(row)}
|
||
${lockLabel}
|
||
</div>
|
||
</button>
|
||
`;
|
||
})
|
||
.join("");
|
||
document.querySelectorAll("[data-case]").forEach((button) => {
|
||
button.addEventListener("click", () => selectCase(button.dataset.case));
|
||
});
|
||
}
|
||
|
||
async function selectCase(ctKey) {
|
||
if (!ctKey) return;
|
||
setLoading(true);
|
||
try {
|
||
const [detail, seriesPayload, stlPayload, regPayload] = await Promise.all([
|
||
json(`/api/cases/${encodeURIComponent(ctKey)}`),
|
||
json(`/api/cases/${encodeURIComponent(ctKey)}/series`),
|
||
json(`/api/cases/${encodeURIComponent(ctKey)}/stl`),
|
||
json(`/api/registrations/${encodeURIComponent(ctKey)}`),
|
||
]);
|
||
state.activeCase = detail;
|
||
state.series = seriesPayload.series || [];
|
||
state.stlFiles = stlPayload.files || [];
|
||
state.registrations = regPayload.registrations || [];
|
||
chooseDefaultStl();
|
||
const diagnosticSeries = [...state.series]
|
||
.filter((item) => Number(item.count || 0) >= 80 && !asList(item.annotation?.labels).includes("略过/不采用"))
|
||
.sort((left, right) => Number(right.count || 0) - Number(left.count || 0));
|
||
const largestSeries = [...state.series].sort((left, right) => Number(right.count || 0) - Number(left.count || 0));
|
||
state.selectedSeries = state.series.find((item) => item.registration?.registered) || diagnosticSeries[0] || largestSeries[0] || null;
|
||
state.sliceIndex = Math.max(0, Math.floor((state.selectedSeries?.count || 1) / 2));
|
||
applyLatestRegistration();
|
||
renderAll();
|
||
await reloadVisuals();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
function chooseDefaultStl() {
|
||
state.selectedStlIds.clear();
|
||
state.selectedFamilies.clear();
|
||
if (!state.stlFiles.length) return;
|
||
const families = Array.from(new Set(state.stlFiles.map((item) => item.family).filter(Boolean)));
|
||
const preferred = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen"];
|
||
const selectedFamily = preferred.find((family) => families.includes(family)) || families[0];
|
||
if (selectedFamily) {
|
||
state.selectedFamilies.add(selectedFamily);
|
||
state.stlFiles.filter((item) => item.family === selectedFamily).forEach((item) => state.selectedStlIds.add(Number(item.id)));
|
||
} else {
|
||
state.selectedStlIds.add(Number(state.stlFiles[0].id));
|
||
}
|
||
}
|
||
|
||
function renderAll() {
|
||
renderCases();
|
||
renderHero();
|
||
renderSeries();
|
||
renderStl();
|
||
renderPoseControls();
|
||
updateDICOMControls();
|
||
renderMaskPreview();
|
||
}
|
||
|
||
function renderHero() {
|
||
const row = state.activeCase;
|
||
$("activeTitle").textContent = row?.ct_key || "未选择 CT";
|
||
$("activeMeta").textContent = row
|
||
? `${row.patient_name || row.upp_patient_name || "无姓名"} · ${fmtDate(row.study_date)} ${fmtTime(row.study_time)} · ${Number(row.series_count || 0)} 序列`
|
||
: "从左侧选择一个 DICOM + STL 匹配检查";
|
||
const labels = [
|
||
row?.algorithm_model,
|
||
...asList(row?.body_parts).map((part) => bodyPartLabels[part] || part),
|
||
row?.registration_count ? `配准 ${row.registered_series || 0}` : "未配准",
|
||
row?.locked_count ? `锁定 ${row.locked_count}` : "",
|
||
].filter(Boolean);
|
||
$("activeTags").innerHTML = labels.map((label) => `<em class="tag">${escapeHtml(label)}</em>`).join("");
|
||
$("brandSubtitle").textContent = row?.ct_key ? `${row.ct_key} · STL ${Number(row.stl_file_count || 0)} 个` : "DICOM 序列、STL family 与人工位姿锁定";
|
||
}
|
||
|
||
function seriesTime(row) {
|
||
const start = fmtTime(row.first_time || row.series_time);
|
||
const end = fmtTime(row.last_time || "");
|
||
return start && end && start !== end ? `${start}-${end}` : start || end || "-";
|
||
}
|
||
|
||
function renderSeries() {
|
||
$("seriesCount").textContent = String(state.series.length);
|
||
if (!state.series.length) {
|
||
$("seriesList").innerHTML = `<div class="empty-state">没有可用 DICOM 序列</div>`;
|
||
return;
|
||
}
|
||
$("seriesList").innerHTML = state.series
|
||
.map((row) => {
|
||
const active = state.selectedSeries?.series_uid === row.series_uid ? "active" : "";
|
||
const reg = row.registration || {};
|
||
const labels = asList(row.annotation?.labels);
|
||
return `
|
||
<button class="series-card ${active}" data-series="${escapeHtml(row.series_uid)}" title="${escapeHtml(row.description)}\n${escapeHtml(row.series_uid)}">
|
||
<div class="card-line">
|
||
<strong>${escapeHtml(row.description || "未命名序列")}</strong>
|
||
${reg.locked ? `<em class="tag locked">已锁</em>` : reg.registered ? `<em class="tag">已配准</em>` : `<em class="tag warn">未配准</em>`}
|
||
</div>
|
||
<span>拍摄 ${escapeHtml(seriesTime(row))}</span>
|
||
<small>序列 ${escapeHtml(row.series_number || "-")} · ${Number(row.count || 0)} 张 · ${escapeHtml(row.rows || "-")}×${escapeHtml(row.columns || "-")}</small>
|
||
<div class="tag-line">
|
||
${labels.map((label) => `<em class="tag">${escapeHtml(label)}</em>`).join("")}
|
||
${asList(reg.labels).map((label) => `<em class="tag locked">${escapeHtml(label)}</em>`).join("")}
|
||
</div>
|
||
</button>
|
||
`;
|
||
})
|
||
.join("");
|
||
document.querySelectorAll("[data-series]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
const next = state.series.find((item) => item.series_uid === button.dataset.series);
|
||
if (!next) return;
|
||
state.selectedSeries = next;
|
||
state.sliceIndex = Math.max(0, Math.floor((next.count || 1) / 2));
|
||
applyLatestRegistration();
|
||
renderAll();
|
||
await reloadVisuals();
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderStl() {
|
||
$("stlCount").textContent = String(state.stlFiles.length);
|
||
$("modeFile").classList.toggle("active", state.mode === "file");
|
||
$("modeFamily").classList.toggle("active", state.mode === "family");
|
||
const families = Array.from(new Set(state.stlFiles.map((item) => item.family).filter(Boolean)));
|
||
$("familyRail").innerHTML = families
|
||
.map((family) => `<button class="chip ${state.selectedFamilies.has(family) ? "active" : ""}" data-family="${escapeHtml(family)}" type="button">${escapeHtml(family)}</button>`)
|
||
.join("");
|
||
document.querySelectorAll("[data-family]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
const family = button.dataset.family;
|
||
if (state.selectedFamilies.has(family)) state.selectedFamilies.delete(family);
|
||
else state.selectedFamilies.add(family);
|
||
state.selectedStlIds.clear();
|
||
state.stlFiles.filter((item) => state.selectedFamilies.has(item.family)).forEach((item) => state.selectedStlIds.add(Number(item.id)));
|
||
markUnsaved();
|
||
renderStl();
|
||
await reloadStlVisuals();
|
||
});
|
||
});
|
||
$("stlList").innerHTML = state.stlFiles.length
|
||
? state.stlFiles
|
||
.map((row) => {
|
||
const checked = state.selectedStlIds.has(Number(row.id));
|
||
return `
|
||
<label class="stl-row ${checked ? "active" : ""}" title="${escapeHtml(row.path || "")}">
|
||
<input data-stl="${Number(row.id)}" type="checkbox" ${checked ? "checked" : ""} />
|
||
<span>
|
||
<strong>${escapeHtml(row.segment_name || row.file_name)}</strong>
|
||
<small>${escapeHtml(row.family || "-")} · ${escapeHtml(row.category || "-")} · ${fmtBytes(row.size_bytes)}</small>
|
||
</span>
|
||
<em class="tag">${escapeHtml(row.algorithm_model || "STL")}</em>
|
||
</label>
|
||
`;
|
||
})
|
||
.join("")
|
||
: `<div class="empty-state">没有 STL 文件</div>`;
|
||
document.querySelectorAll("[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);
|
||
state.selectedFamilies = new Set(
|
||
Array.from(new Set(selectedStlFiles().map((item) => item.family).filter(Boolean))),
|
||
);
|
||
markUnsaved();
|
||
renderStl();
|
||
await reloadStlVisuals();
|
||
});
|
||
});
|
||
}
|
||
|
||
function selectedStlFiles() {
|
||
return state.stlFiles.filter((item) => state.selectedStlIds.has(Number(item.id)));
|
||
}
|
||
|
||
function currentStlLabel() {
|
||
const selected = selectedStlFiles();
|
||
if (!selected.length) return "未选择STL";
|
||
const families = Array.from(new Set(selected.map((item) => item.family).filter(Boolean)));
|
||
if (state.mode === "family" && families.length) return families.join("+");
|
||
return selected.map((item) => item.segment_name || item.file_name).slice(0, 4).join("+") + (selected.length > 4 ? `等${selected.length}个` : "");
|
||
}
|
||
|
||
function updateDICOMControls() {
|
||
const series = state.selectedSeries;
|
||
$("dicomMeta").textContent = series ? `${series.description} · ${series.count} 张` : "未选择序列";
|
||
$("sliceSlider").max = String(Math.max(0, Number(series?.count || 1) - 1));
|
||
$("sliceSlider").value = String(Math.min(state.sliceIndex, Number($("sliceSlider").max || 0)));
|
||
$("sliceLabel").textContent = series ? `${state.sliceIndex + 1} / ${Number(series.count || 0)}` : "0 / 0";
|
||
}
|
||
|
||
function imageUrl() {
|
||
if (!state.activeCase || !state.selectedSeries) return "";
|
||
return `/api/image?${authParams({
|
||
ct_number: state.activeCase.pacs_ct_number || state.activeCase.ct_key,
|
||
series_uid: state.selectedSeries.series_uid,
|
||
index: String(state.sliceIndex),
|
||
plane: state.plane,
|
||
window: state.window,
|
||
}).toString()}`;
|
||
}
|
||
|
||
async function reloadVisuals() {
|
||
updateDICOMControls();
|
||
await Promise.all([reloadDicomImage(), reloadStlVisuals()]);
|
||
}
|
||
|
||
async function reloadDicomImage() {
|
||
const url = imageUrl();
|
||
$("dicomEmpty").classList.toggle("hidden", Boolean(url));
|
||
$("segmentationEmpty").classList.toggle("hidden", Boolean(url && selectedStlFiles().length));
|
||
if (!url) {
|
||
$("dicomImage").removeAttribute("src");
|
||
$("segmentationImage").removeAttribute("src");
|
||
return;
|
||
}
|
||
$("dicomImage").src = url;
|
||
$("segmentationImage").src = url;
|
||
if (state.scenes.fusion) state.scenes.fusion.setDicomTexture(url, state.selectedSeries);
|
||
renderMaskPreview();
|
||
}
|
||
|
||
async function reloadStlVisuals() {
|
||
const selected = selectedStlFiles();
|
||
$("stlMeta").textContent = selected.length ? `${selected.length} 个构件 · ${currentStlLabel()}` : "未选择模型";
|
||
$("fusionMeta").textContent = $("saveState").textContent === "已保存" ? `已保存 · ${currentStlLabel()}` : `当前选择 · ${currentStlLabel()}`;
|
||
if (state.scenes.stl) state.scenes.stl.loadMeshes(selected);
|
||
if (state.scenes.fusion) state.scenes.fusion.loadMeshes(selected);
|
||
renderMaskPreview();
|
||
}
|
||
|
||
function applyLatestRegistration() {
|
||
const seriesUid = state.selectedSeries?.series_uid || "";
|
||
const selectedLabel = currentStlLabel();
|
||
const exact = state.registrations.find((item) => item.series_instance_uid === seriesUid && item.stl_family === selectedLabel);
|
||
const latest = exact || state.registrations.find((item) => item.series_instance_uid === seriesUid) || null;
|
||
if (latest?.transform) {
|
||
state.pose = { ...defaultPose(), ...latest.transform };
|
||
$("registrationNotes").value = latest.notes || "";
|
||
$("saveState").textContent = latest.locked ? "已锁定" : "已保存";
|
||
$("fusionMeta").textContent = latest.locked ? "配准已锁定" : "已载入已保存位姿";
|
||
} else {
|
||
state.pose = defaultPose();
|
||
$("registrationNotes").value = "";
|
||
$("saveState").textContent = "未保存";
|
||
$("fusionMeta").textContent = "位姿未保存";
|
||
}
|
||
updateScenesPose();
|
||
}
|
||
|
||
async function saveRegistration(locked = false, forceUnlock = false) {
|
||
if (!state.activeCase || !state.selectedSeries) return;
|
||
const selected = selectedStlFiles();
|
||
if (!selected.length) {
|
||
alert("请至少选择一个 STL 构件");
|
||
return;
|
||
}
|
||
const payload = {
|
||
ct_number: state.activeCase.pacs_ct_number || state.activeCase.ct_key,
|
||
series_instance_uid: state.selectedSeries.series_uid,
|
||
series_description: state.selectedSeries.description || "",
|
||
algorithm_model: state.activeCase.algorithm_model || selected[0]?.algorithm_model || "未指定模型",
|
||
stl_family: currentStlLabel(),
|
||
view_mode: state.mode,
|
||
selected_stl_files: selected.map((item) => ({
|
||
id: item.id,
|
||
file_name: item.file_name,
|
||
segment_name: item.segment_name,
|
||
family: item.family,
|
||
category: item.category,
|
||
algorithm_model: item.algorithm_model,
|
||
})),
|
||
transform: state.pose,
|
||
dicom_reference: {
|
||
series_number: state.selectedSeries.series_number,
|
||
slice_index: state.sliceIndex,
|
||
plane: state.plane,
|
||
window: state.window,
|
||
rows: state.selectedSeries.rows,
|
||
columns: state.selectedSeries.columns,
|
||
pixel_spacing: state.selectedSeries.pixel_spacing,
|
||
slice_thickness: state.selectedSeries.slice_thickness,
|
||
spacing_between_slices: state.selectedSeries.spacing_between_slices,
|
||
},
|
||
model_reference: { selected_count: selected.length, families: Array.from(state.selectedFamilies) },
|
||
segmentation: { preview: state.maskVisible, source: "visible_stl_preview" },
|
||
notes: $("registrationNotes").value.trim(),
|
||
locked,
|
||
force_unlock: forceUnlock,
|
||
};
|
||
try {
|
||
await json("/api/registrations", { method: "POST", body: JSON.stringify(payload) });
|
||
$("saveState").textContent = locked ? "已锁定" : "已保存";
|
||
$("fusionMeta").textContent = locked ? "配准已保存并锁定" : "配准已保存";
|
||
const regPayload = await json(`/api/registrations/${encodeURIComponent(payload.ct_number)}`);
|
||
state.registrations = regPayload.registrations || [];
|
||
renderSeries();
|
||
} catch (err) {
|
||
alert(err.message);
|
||
}
|
||
}
|
||
|
||
async function exportStl() {
|
||
if (!state.activeCase) return;
|
||
const selected = selectedStlFiles();
|
||
const payload = {
|
||
ct_number: state.activeCase.pacs_ct_number || state.activeCase.ct_key,
|
||
mode: state.mode,
|
||
file_ids: selected.map((item) => Number(item.id)),
|
||
families: Array.from(state.selectedFamilies),
|
||
include_pose: true,
|
||
transform: state.pose,
|
||
label: currentStlLabel(),
|
||
};
|
||
const res = await request("/api/export/stl", { method: "POST", body: JSON.stringify(payload) });
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `DICOM_UPP_STL_${payload.ct_number}.zip`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function colorFor(item, index) {
|
||
const key = item.family || item.segment_name || String(index);
|
||
let hash = 0;
|
||
for (let i = 0; i < key.length; i += 1) hash = (hash * 31 + key.charCodeAt(i)) % 997;
|
||
return familyColors[hash % familyColors.length];
|
||
}
|
||
|
||
function createFallbackScene(container, { fusion = false } = {}) {
|
||
const canvas = document.createElement("canvas");
|
||
const ctx = canvas.getContext("2d");
|
||
container.appendChild(canvas);
|
||
let files = [];
|
||
let dicomImage = null;
|
||
|
||
function resize() {
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
const width = Math.max(1, container.clientWidth);
|
||
const height = Math.max(1, container.clientHeight);
|
||
canvas.width = Math.floor(width * dpr);
|
||
canvas.height = Math.floor(height * dpr);
|
||
canvas.style.width = `${width}px`;
|
||
canvas.style.height = `${height}px`;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
draw();
|
||
}
|
||
|
||
function draw() {
|
||
const width = container.clientWidth || 1;
|
||
const height = container.clientHeight || 1;
|
||
ctx.clearRect(0, 0, width, height);
|
||
ctx.fillStyle = "#000";
|
||
ctx.fillRect(0, 0, width, height);
|
||
if (fusion && dicomImage?.complete && dicomImage.naturalWidth) {
|
||
const scale = Math.min(width / dicomImage.naturalWidth, height / dicomImage.naturalHeight) * 0.92;
|
||
const w = dicomImage.naturalWidth * scale;
|
||
const h = dicomImage.naturalHeight * scale;
|
||
ctx.globalAlpha = 0.72;
|
||
ctx.drawImage(dicomImage, (width - w) / 2, (height - h) / 2, w, h);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
const pose = state.pose;
|
||
const cx = width / 2 + Number(pose.translateX || 0) * width * 0.14;
|
||
const cy = height / 2 - Number(pose.translateY || 0) * height * 0.14;
|
||
const base = Math.max(20, Math.min(width, height) * 0.11 * Number(pose.scale || 1));
|
||
ctx.save();
|
||
ctx.translate(cx, cy);
|
||
ctx.rotate(THREE.MathUtils.degToRad(Number(pose.rotateZ || 0)));
|
||
files.slice(0, 12).forEach((file, index) => {
|
||
const angle = (index / Math.max(files.length, 1)) * Math.PI * 2;
|
||
const radius = base * (1.1 + (index % 4) * 0.16);
|
||
const x = Math.cos(angle) * radius;
|
||
const y = Math.sin(angle) * radius * 0.72;
|
||
ctx.fillStyle = `${colorFor(file, index)}99`;
|
||
ctx.strokeStyle = colorFor(file, index);
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.ellipse(x, y, base * (0.42 + (index % 3) * 0.07), base * (0.26 + (index % 2) * 0.05), angle, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
});
|
||
ctx.restore();
|
||
ctx.fillStyle = "rgba(234,242,251,0.72)";
|
||
ctx.font = "12px ui-sans-serif, system-ui";
|
||
ctx.fillText("WebGL不可用,已启用2D预览", 14, 22);
|
||
ctx.fillText(`${files.length} 个 STL 构件`, 14, height - 18);
|
||
}
|
||
|
||
resize();
|
||
window.addEventListener("resize", resize);
|
||
return {
|
||
loadMeshes(nextFiles) {
|
||
files = nextFiles || [];
|
||
draw();
|
||
},
|
||
setDicomTexture(url) {
|
||
if (!fusion || !url) return;
|
||
dicomImage = new Image();
|
||
dicomImage.onload = draw;
|
||
dicomImage.src = url;
|
||
},
|
||
updatePose: draw,
|
||
fit: draw,
|
||
dispose() {
|
||
window.removeEventListener("resize", resize);
|
||
container.innerHTML = "";
|
||
},
|
||
};
|
||
}
|
||
|
||
function createScene(container, { fusion = false } = {}) {
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color("#000000");
|
||
const camera = new THREE.PerspectiveCamera(42, 1, 0.01, 2000);
|
||
camera.position.set(0, -5.5, 3.2);
|
||
camera.up.set(0, 0, 1);
|
||
let renderer;
|
||
try {
|
||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
|
||
} catch (error) {
|
||
console.warn("WebGL不可用,切换到2D预览", error);
|
||
return createFallbackScene(container, { fusion });
|
||
}
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
container.appendChild(renderer.domElement);
|
||
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
controls.enableDamping = true;
|
||
controls.dampingFactor = 0.08;
|
||
controls.target.set(0, 0, 0);
|
||
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.72));
|
||
const keyLight = new THREE.DirectionalLight(0xffffff, 1.15);
|
||
keyLight.position.set(4, -5, 5);
|
||
scene.add(keyLight);
|
||
const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.5);
|
||
fillLight.position.set(-4, 3, 2);
|
||
scene.add(fillLight);
|
||
|
||
const modelGroup = new THREE.Group();
|
||
const pivot = new THREE.Group();
|
||
modelGroup.add(pivot);
|
||
scene.add(modelGroup);
|
||
|
||
let dicomPlane = null;
|
||
let dicomTexture = null;
|
||
if (fusion) {
|
||
const geometry = new THREE.PlaneGeometry(4.2, 4.2);
|
||
const material = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.76, side: THREE.DoubleSide });
|
||
dicomPlane = new THREE.Mesh(geometry, material);
|
||
dicomPlane.position.z = -0.02;
|
||
scene.add(dicomPlane);
|
||
}
|
||
|
||
let baseScale = 1;
|
||
let disposed = false;
|
||
const loader = new STLLoader();
|
||
|
||
function resize() {
|
||
const width = Math.max(1, container.clientWidth);
|
||
const height = Math.max(1, container.clientHeight);
|
||
camera.aspect = width / height;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(width, height, false);
|
||
}
|
||
|
||
function fit() {
|
||
camera.position.set(0, -5.5, 3.2);
|
||
controls.target.set(0, 0, 0);
|
||
controls.update();
|
||
}
|
||
|
||
function clearMeshes() {
|
||
while (pivot.children.length) {
|
||
const child = pivot.children.pop();
|
||
if (child.geometry) child.geometry.dispose();
|
||
if (child.material) child.material.dispose();
|
||
}
|
||
}
|
||
|
||
function loadMeshes(files) {
|
||
clearMeshes();
|
||
if (!files.length || !state.activeCase) return;
|
||
const ct = state.activeCase.pacs_ct_number || state.activeCase.ct_key;
|
||
const promises = files.map(
|
||
(file, index) =>
|
||
new Promise((resolve) => {
|
||
const url = `/api/stl?${authParams({ ct_number: ct, file_id: String(file.id) }).toString()}`;
|
||
loader.load(
|
||
url,
|
||
(geometry) => {
|
||
geometry.computeBoundingBox();
|
||
geometry.computeVertexNormals();
|
||
const material = new THREE.MeshStandardMaterial({
|
||
color: new THREE.Color(colorFor(file, index)),
|
||
roughness: 0.52,
|
||
metalness: 0.02,
|
||
transparent: fusion,
|
||
opacity: fusion ? 0.72 : 1,
|
||
side: THREE.DoubleSide,
|
||
});
|
||
const mesh = new THREE.Mesh(geometry, material);
|
||
mesh.userData = file;
|
||
pivot.add(mesh);
|
||
resolve(mesh);
|
||
},
|
||
undefined,
|
||
() => resolve(null),
|
||
);
|
||
}),
|
||
);
|
||
Promise.all(promises).then(() => {
|
||
if (disposed || !pivot.children.length) return;
|
||
const box = new THREE.Box3().setFromObject(pivot);
|
||
const center = box.getCenter(new THREE.Vector3());
|
||
const size = box.getSize(new THREE.Vector3());
|
||
const maxSize = Math.max(size.x, size.y, size.z, 1);
|
||
pivot.children.forEach((child) => {
|
||
child.geometry.translate(-center.x, -center.y, -center.z);
|
||
child.geometry.computeBoundingBox();
|
||
child.geometry.computeBoundingSphere();
|
||
});
|
||
baseScale = 4.2 / maxSize;
|
||
fit();
|
||
updatePose();
|
||
});
|
||
}
|
||
|
||
function updatePose() {
|
||
const pose = state.pose;
|
||
modelGroup.rotation.set(
|
||
THREE.MathUtils.degToRad(Number(pose.rotateX || 0)),
|
||
THREE.MathUtils.degToRad(Number(pose.rotateY || 0)),
|
||
THREE.MathUtils.degToRad(Number(pose.rotateZ || 0)),
|
||
);
|
||
modelGroup.position.set(Number(pose.translateX || 0), Number(pose.translateY || 0), Number(pose.translateZ || 0));
|
||
const scale = baseScale * Number(pose.scale || 1);
|
||
modelGroup.scale.set(pose.flipX ? -scale : scale, pose.flipY ? -scale : scale, pose.flipZ ? -scale : scale);
|
||
}
|
||
|
||
function setDicomTexture(url, series) {
|
||
if (!fusion || !dicomPlane || !url) return;
|
||
new THREE.TextureLoader().load(url, (texture) => {
|
||
if (dicomTexture) dicomTexture.dispose();
|
||
dicomTexture = texture;
|
||
dicomPlane.material.map = texture;
|
||
dicomPlane.material.needsUpdate = true;
|
||
const rows = Number(series?.rows || 512);
|
||
const cols = Number(series?.columns || 512);
|
||
const width = 4.2;
|
||
const height = rows && cols ? width * (rows / cols) : width;
|
||
dicomPlane.geometry.dispose();
|
||
dicomPlane.geometry = new THREE.PlaneGeometry(width, height);
|
||
});
|
||
}
|
||
|
||
function animate() {
|
||
if (disposed) return;
|
||
resize();
|
||
updatePose();
|
||
controls.update();
|
||
renderer.render(scene, camera);
|
||
requestAnimationFrame(animate);
|
||
}
|
||
animate();
|
||
|
||
window.addEventListener("resize", resize);
|
||
return {
|
||
loadMeshes,
|
||
setDicomTexture,
|
||
updatePose,
|
||
fit,
|
||
dispose() {
|
||
disposed = true;
|
||
window.removeEventListener("resize", resize);
|
||
clearMeshes();
|
||
if (dicomTexture) dicomTexture.dispose();
|
||
renderer.dispose();
|
||
container.innerHTML = "";
|
||
},
|
||
};
|
||
}
|
||
|
||
function updateScenesPose() {
|
||
Object.values(state.scenes).forEach((scene) => scene?.updatePose?.());
|
||
renderMaskPreview();
|
||
}
|
||
|
||
function renderMaskPreview() {
|
||
const canvas = $("maskCanvas");
|
||
const ctx = canvas.getContext("2d");
|
||
const rect = canvas.getBoundingClientRect();
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
|
||
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||
if (!state.maskVisible || !state.selectedSeries || !selectedStlFiles().length) return;
|
||
const files = selectedStlFiles().slice(0, 9);
|
||
files.forEach((file, index) => {
|
||
const color = colorFor(file, index);
|
||
const x = rect.width * (0.36 + (index % 3) * 0.12 + Number(state.pose.translateX || 0) * 0.08);
|
||
const y = rect.height * (0.38 + Math.floor(index / 3) * 0.12 - Number(state.pose.translateY || 0) * 0.08);
|
||
const radius = Math.max(18, Math.min(rect.width, rect.height) * (0.07 + (Number(state.pose.scale || 1) - 1) * 0.015));
|
||
ctx.fillStyle = `${color}66`;
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.ellipse(x, y, radius * (1.15 - (index % 2) * 0.24), radius * (0.76 + (index % 3) * 0.09), THREE.MathUtils.degToRad(Number(state.pose.rotateZ || 0) + index * 18), 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
});
|
||
ctx.fillStyle = "rgba(234,242,251,0.78)";
|
||
ctx.font = "12px ui-sans-serif, system-ui";
|
||
ctx.fillText(`${selectedStlFiles().length} 个可见 STL · 预览叠加`, 14, rect.height - 16);
|
||
}
|
||
|
||
async function login(event) {
|
||
event.preventDefault();
|
||
$("loginError").textContent = "";
|
||
try {
|
||
const data = await json("/api/auth/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }),
|
||
});
|
||
state.token = data.token;
|
||
state.user = { username: data.username, role: data.role };
|
||
localStorage.setItem("dicom_upp_registration_token", state.token);
|
||
applyAuthUi();
|
||
showLogin(false);
|
||
await refreshAll();
|
||
} catch (_) {
|
||
$("loginError").textContent = "登录失败";
|
||
}
|
||
}
|
||
|
||
function logout() {
|
||
state.token = "";
|
||
state.user = null;
|
||
localStorage.removeItem("dicom_upp_registration_token");
|
||
applyAuthUi();
|
||
showLogin(true);
|
||
}
|
||
|
||
function applyAuthUi() {
|
||
$("userBadge").textContent = state.user ? `${state.user.username} · ${state.user.role}` : "未登录";
|
||
}
|
||
|
||
async function loadCurrentUser() {
|
||
if (!state.token) {
|
||
showLogin(true);
|
||
return false;
|
||
}
|
||
try {
|
||
state.user = await json("/api/auth/me");
|
||
applyAuthUi();
|
||
showLogin(false);
|
||
return true;
|
||
} catch (_) {
|
||
state.token = "";
|
||
localStorage.removeItem("dicom_upp_registration_token");
|
||
applyAuthUi();
|
||
showLogin(true);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function refreshAll() {
|
||
setLoading(true);
|
||
try {
|
||
await loadStatus();
|
||
await loadCases();
|
||
} catch (err) {
|
||
$("dbStatus").textContent = err.message;
|
||
$("dbStatus").className = "status-pill offline";
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
function wire() {
|
||
$("loginForm").addEventListener("submit", login);
|
||
$("logoutBtn").addEventListener("click", logout);
|
||
$("refreshBtn").addEventListener("click", refreshAll);
|
||
$("relationBtn").addEventListener("click", () => window.open(relationBaseUrl(), "_blank", "noopener"));
|
||
$("viewerBtn").addEventListener("click", () => {
|
||
const ct = state.activeCase?.pacs_ct_number || state.activeCase?.ct_key || "";
|
||
const suffix = ct ? `/?ct_number=${encodeURIComponent(ct)}` : "";
|
||
window.open(`${viewerBaseUrl()}${suffix}`, "_blank", "noopener");
|
||
});
|
||
$("caseSearch").addEventListener("input", () => {
|
||
clearTimeout(state.searchTimer);
|
||
state.searchTimer = setTimeout(loadCases, 280);
|
||
});
|
||
$("modeFile").addEventListener("click", async () => {
|
||
state.mode = "file";
|
||
markUnsaved();
|
||
renderStl();
|
||
});
|
||
$("modeFamily").addEventListener("click", async () => {
|
||
state.mode = "family";
|
||
markUnsaved();
|
||
renderStl();
|
||
});
|
||
$("sliceSlider").addEventListener("input", async () => {
|
||
state.sliceIndex = Number($("sliceSlider").value || 0);
|
||
updateDICOMControls();
|
||
await reloadDicomImage();
|
||
markUnsaved();
|
||
});
|
||
$("planeSelect").addEventListener("change", async () => {
|
||
state.plane = $("planeSelect").value;
|
||
await reloadDicomImage();
|
||
});
|
||
$("windowSelect").addEventListener("change", async () => {
|
||
state.window = $("windowSelect").value;
|
||
await reloadDicomImage();
|
||
});
|
||
$("registrationNotes").addEventListener("input", markUnsaved);
|
||
$("resetPoseBtn").addEventListener("click", () => {
|
||
state.pose = defaultPose();
|
||
syncPoseInputs();
|
||
markUnsaved();
|
||
updateScenesPose();
|
||
});
|
||
$("saveRegistrationBtn").addEventListener("click", () => saveRegistration(false, false));
|
||
$("lockRegistrationBtn").addEventListener("click", () => saveRegistration(true, false));
|
||
$("unlockRegistrationBtn").addEventListener("click", () => saveRegistration(false, true));
|
||
$("exportStlBtn").addEventListener("click", exportStl);
|
||
$("fitStlBtn").addEventListener("click", () => state.scenes.stl?.fit());
|
||
$("fitFusionBtn").addEventListener("click", () => state.scenes.fusion?.fit());
|
||
$("toggleMaskBtn").addEventListener("click", () => {
|
||
state.maskVisible = !state.maskVisible;
|
||
$("toggleMaskBtn").textContent = state.maskVisible ? "隐藏" : "显示";
|
||
renderMaskPreview();
|
||
});
|
||
}
|
||
|
||
async function boot() {
|
||
state.scenes.stl = createScene($("stlViewport"), { fusion: false });
|
||
state.scenes.fusion = createScene($("fusionViewport"), { fusion: true });
|
||
renderPoseControls();
|
||
wire();
|
||
applyAuthUi();
|
||
const ok = await loadCurrentUser();
|
||
if (ok) await refreshAll();
|
||
}
|
||
|
||
boot();
|