Files
PACS/DICOM_and_UPP配准/static/app.js
2026-05-28 18:41:49 +08:00

1112 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
selectedAlgorithmModel: "",
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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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) {
state.selectedAlgorithmModel = "";
return;
}
const models = algorithmModels();
const nextModel = models.includes(state.selectedAlgorithmModel) ? state.selectedAlgorithmModel : models[0] || "";
selectDefaultStlForModel(nextModel);
}
function algorithmModels() {
return Array.from(new Set(state.stlFiles.map((item) => item.algorithm_model || "未指定模型").filter(Boolean))).sort();
}
function currentAlgorithmModel() {
const selected = selectedStlFiles();
const selectedModel = selected.find((item) => item.algorithm_model)?.algorithm_model;
if (selectedModel) return selectedModel;
if (state.selectedAlgorithmModel) return state.selectedAlgorithmModel;
return algorithmModels()[0] || state.activeCase?.algorithm_model || "未指定模型";
}
function stlFilesForSelectedModel() {
const model = currentAlgorithmModel();
return model ? state.stlFiles.filter((item) => (item.algorithm_model || "未指定模型") === model) : state.stlFiles;
}
function selectDefaultStlForModel(model) {
state.selectedStlIds.clear();
state.selectedFamilies.clear();
state.selectedAlgorithmModel = model || algorithmModels()[0] || "";
const candidates = stlFilesForSelectedModel();
if (!candidates.length) return;
const families = Array.from(new Set(candidates.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);
candidates.filter((item) => item.family === selectedFamily).forEach((item) => state.selectedStlIds.add(Number(item.id)));
} else {
state.selectedStlIds.add(Number(candidates[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 models = algorithmModels();
if (models.length && !models.includes(state.selectedAlgorithmModel)) {
state.selectedAlgorithmModel = models[0];
}
$("modelRail").innerHTML = models
.map((model) => `<button class="chip ${model === currentAlgorithmModel() ? "active" : ""}" data-model="${escapeHtml(model)}" type="button">${escapeHtml(model)}</button>`)
.join("");
const visibleFiles = stlFilesForSelectedModel();
const families = Array.from(new Set(visibleFiles.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-model]").forEach((button) => {
button.addEventListener("click", async () => {
selectDefaultStlForModel(button.dataset.model);
markUnsaved();
renderStl();
await reloadStlVisuals();
});
});
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();
visibleFiles.filter((item) => state.selectedFamilies.has(item.family)).forEach((item) => state.selectedStlIds.add(Number(item.id)));
markUnsaved();
renderStl();
await reloadStlVisuals();
});
});
$("stlList").innerHTML = visibleFiles.length
? visibleFiles
.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 model = currentAlgorithmModel();
const latest =
state.registrations.find((item) => item.algorithm_model === model) ||
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 selectedModels = Array.from(new Set(selected.map((item) => item.algorithm_model || "未指定模型")));
if (selectedModels.length > 1) {
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: selectedModels[0] || currentAlgorithmModel(),
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: { algorithm_model: selectedModels[0] || currentAlgorithmModel(), 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();