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) => `
`,
)
.join("")}
`;
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) => `${escapeHtml(part)}`).join("");
}
function renderCases() {
$("caseCount").textContent = String(state.cases.length);
if (!state.cases.length) {
$("caseList").innerHTML = `没有 DICOM + STL 匹配检查
`;
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 ? `${Number(row.locked_count)} 已锁` : "";
return `
`;
})
.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) => `${escapeHtml(label)}`).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 = `没有可用 DICOM 序列
`;
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 `
`;
})
.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) => ``)
.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 `
`;
})
.join("")
: `没有 STL 文件
`;
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();