Files
PACS/DICOM_and_UPP配准/static/app.js
2026-05-28 19:46:51 +08:00

899 lines
33 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 $ = (id) => document.getElementById(id);
const BODY_LABELS = {
head_neck: "头颈部",
chest: "胸部",
upper_abdomen: "上腹部",
lower_abdomen: "下腹部",
pelvis: "盆腔",
};
const DEFAULT_POSE = {
rotateX: 0,
rotateY: 0,
rotateZ: 0,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
flipX: false,
flipY: false,
flipZ: false,
};
const POSE_CONTROLS = [
{ key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 1, suffix: "°" },
{ key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 1, suffix: "°" },
{ key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 1, suffix: "°" },
{ key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.01, suffix: "" },
{ key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.01, suffix: "" },
{ key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.01, suffix: "" },
{ key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.01, suffix: "x" },
];
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
const state = {
token: localStorage.getItem("dicom_upp_registration_token") || "",
user: null,
links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" },
statusFilter: "",
partFilter: "",
search: "",
cases: [],
activeCase: null,
series: [],
selectedSeriesUid: "",
stlFiles: [],
selectedStlIds: new Set(),
algorithmModel: "",
registrationStatus: "unregistered",
pose: { ...DEFAULT_POSE },
windowMode: "default",
sliceIndex: 0,
dirty: false,
saving: false,
fusionVersion: 0,
volumeMeta: null,
sceneReady: false,
renderer: null,
camera: null,
scene: null,
controls: null,
dicomGroup: null,
modelGroup: null,
rawModelGroup: null,
baseModelScale: 1,
resizeObserver: null,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function cls(value) {
return value ? "active" : "";
}
function setTopLoading(visible) {
$("topLoading").classList.toggle("hidden", !visible);
}
function setFusionLoading(visible, text = "正在构建融合视图") {
$("fusionLoading").classList.toggle("hidden", !visible);
$("fusionLoading").querySelector("span").textContent = text;
}
function setSaveState(text, tone = "") {
const el = $("saveState");
el.textContent = text;
el.className = tone;
}
function markDirty() {
state.dirty = true;
setSaveState("未保存", "warn");
}
function resetDirty() {
state.dirty = false;
setSaveState("已保存", "ok");
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.token) headers.set("Authorization", `Bearer ${state.token}`);
if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const response = await fetch(path, { ...options, headers });
if (response.status === 401) {
logout(false);
throw new Error("登录已过期");
}
if (!response.ok) {
let message = response.statusText;
try {
const payload = await response.json();
message = payload.detail || message;
} catch {
message = await response.text();
}
throw new Error(message || "请求失败");
}
const type = response.headers.get("content-type") || "";
return type.includes("application/json") ? response.json() : response.text();
}
function loginVisible(visible) {
$("loginOverlay").classList.toggle("hidden", !visible);
}
async function login(event) {
event.preventDefault();
$("loginError").textContent = "";
try {
const payload = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }),
});
state.token = payload.token;
state.user = payload;
localStorage.setItem("dicom_upp_registration_token", state.token);
loginVisible(false);
await bootstrap();
} catch (error) {
$("loginError").textContent = error.message;
}
}
function logout(clearToken = true) {
if (clearToken) localStorage.removeItem("dicom_upp_registration_token");
state.token = "";
state.user = null;
loginVisible(true);
$("userBadge").textContent = "未登录";
}
async function bootstrap() {
setTopLoading(true);
try {
const me = await api("/api/auth/me");
state.user = me;
$("userBadge").textContent = `${me.username} · ${me.role || "管理员"}`;
loginVisible(false);
try {
initScene();
} catch (error) {
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
console.warn(error);
}
buildPoseControls();
await loadStatus();
await loadCases();
} catch (error) {
console.warn(error);
if (!state.token) logout(false);
else {
$("dbStatus").textContent = error.message || "启动失败";
$("dbStatus").className = "status-pill offline";
loginVisible(false);
}
} finally {
setTopLoading(false);
}
}
async function loadStatus() {
try {
const payload = await api("/api/status");
state.links = payload.links || state.links;
const db = payload.database || {};
$("dbStatus").textContent = db.ok ? `数据库已连接 · ${payload.counts?.complete_cases || 0}` : "数据库异常";
$("dbStatus").className = `status-pill ${db.ok ? "online" : "offline"}`;
} catch (error) {
$("dbStatus").textContent = "数据库异常";
$("dbStatus").className = "status-pill offline";
}
}
async function loadCases(selectFirst = true) {
setTopLoading(true);
try {
const params = new URLSearchParams();
if (state.search) params.set("q", state.search);
if (state.statusFilter) params.set("status", state.statusFilter);
if (state.partFilter) params.set("body_part", state.partFilter);
const rows = await api(`/api/cases?${params.toString()}`);
state.cases = rows;
renderCases();
if (selectFirst && !state.activeCase && rows.length) {
await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型");
} else if (state.activeCase && !rows.some((row) => sameCase(row, state.activeCase))) {
state.activeCase = null;
clearDetail();
}
} finally {
setTopLoading(false);
}
}
function sameCase(a, b) {
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
}
function normalize(value) {
return String(value || "").trim().toUpperCase();
}
function fmtDateTime(date, time) {
const d = String(date || "").replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
const t = fmtTime(time);
return [d, t].filter(Boolean).join(" ");
}
function fmtTime(value) {
const raw = String(value || "").replace(/[^\d.]/g, "");
if (!raw) return "";
const main = raw.split(".")[0].padEnd(6, "0").slice(0, 6);
return `${main.slice(0, 2)}:${main.slice(2, 4)}:${main.slice(4, 6)}`;
}
function statusText(value) {
return value === "registered" ? "已配准" : "未配准";
}
function bodyPartTags(row) {
const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : [];
const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : [];
if (labels.length) return labels;
return keys.map((key) => BODY_LABELS[key]).filter(Boolean);
}
function renderCases() {
$("caseCount").textContent = state.cases.length;
const active = state.activeCase;
$("caseList").innerHTML = state.cases.length
? state.cases
.map((row) => {
const tags = bodyPartTags(row).slice(0, 3);
const status = row.registration_status || "unregistered";
const selected = active && sameCase(row, active);
const title = escapeHtml(row.ct_number || row.ct_key);
const patient = escapeHtml(row.patient_name || row.upp_patient_name || "-");
const meta = `${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
return `
<button class="case-card ${selected ? "active" : ""}" data-ct="${escapeHtml(row.ct_number)}" data-algorithm="${escapeHtml(row.algorithm_model || "未指定模型")}" title="${title} ${patient}">
<div class="card-line">
<strong>${title}</strong>
<em class="tag ${status === "registered" ? "green" : "amber"}">${statusText(status)}</em>
</div>
<span>${patient} · ${escapeHtml(row.patient_id || row.patient_id_masked || "")}</span>
<small>${escapeHtml(meta)}</small>
<div class="mini-tags">
<i>${escapeHtml(row.algorithm_model || "未指定模型")}</i>
${tags.map((tag) => `<i>${escapeHtml(tag)}</i>`).join("")}
</div>
</button>
`;
})
.join("")
: `<div class="empty-state">没有符合条件的完整关联 CT</div>`;
$("caseList").querySelectorAll(".case-card").forEach((button) => {
button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型"));
});
}
function clearDetail() {
state.series = [];
state.stlFiles = [];
state.selectedStlIds = new Set();
state.selectedSeriesUid = "";
$("activeTitle").textContent = "未选择 CT";
$("activeMeta").textContent = "从左侧选择一个完整关联检查";
$("activeTags").innerHTML = "";
$("seriesList").innerHTML = `<div class="empty-state">未选择 CT</div>`;
$("stlList").innerHTML = `<div class="empty-state">未选择 STL</div>`;
$("modelRail").innerHTML = "";
clearFusion();
}
async function confirmChange() {
if (!state.dirty) return true;
return window.confirm("当前配准参数还未保存,确定切换吗?");
}
async function selectCase(ctNumber, algorithmModel = "未指定模型") {
if (!ctNumber || !(await confirmChange())) return;
setTopLoading(true);
setFusionLoading(true, "正在读取 CT 关联数据");
try {
const params = new URLSearchParams({ algorithm_model: algorithmModel || "未指定模型" });
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
api(`/api/cases/${encodeURIComponent(ctNumber)}?${params.toString()}`),
api(`/api/cases/${encodeURIComponent(ctNumber)}/series`),
api(`/api/cases/${encodeURIComponent(ctNumber)}/stl?${params.toString()}`),
api(`/api/registrations/${encodeURIComponent(ctNumber)}?${params.toString()}`),
]);
state.activeCase = detail;
state.algorithmModel = detail.algorithm_model || algorithmModel || "未指定模型";
state.series = seriesPayload.series || [];
state.stlFiles = stlPayload.files || [];
state.registrationStatus = registration.registration_status || "unregistered";
state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) };
state.sliceIndex = 0;
$("notes").value = registration.notes || "";
const savedIds = new Set((registration.selected_stl_files || []).map((item) => Number(item.id)).filter(Number.isFinite));
state.selectedStlIds = savedIds.size ? savedIds : pickDefaultStlIds(state.stlFiles);
state.selectedSeriesUid = registration.series_instance_uid || pickDefaultSeries(state.series)?.series_uid || "";
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
renderCases();
renderActiveHeader();
renderSeries();
renderStl();
renderPoseControls();
resetDirty();
await loadFusion();
} catch (error) {
$("fusionStatus").textContent = error.message;
clearFusion();
} finally {
setTopLoading(false);
setFusionLoading(false);
}
}
function pickDefaultSeries(series) {
return [...series]
.filter((item) => Number(item.count || 0) > 1)
.sort((a, b) => Number(b.count || 0) - Number(a.count || 0))[0] || series[0] || null;
}
function pickDefaultStlIds(files) {
const priority = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen", "vertebrae"];
const ranked = [...files].sort((a, b) => {
const af = String(a.family || a.segment_name || a.file_name || "").toLowerCase();
const bf = String(b.family || b.segment_name || b.file_name || "").toLowerCase();
const ai = priority.findIndex((key) => af === key || af.includes(key));
const bi = priority.findIndex((key) => bf === key || bf.includes(key));
const ar = ai < 0 ? 999 : ai;
const br = bi < 0 ? 999 : bi;
if (ar !== br) return ar - br;
return Number(a.id || 0) - Number(b.id || 0);
});
return new Set(ranked.slice(0, Math.min(6, ranked.length)).map((file) => Number(file.id)));
}
function currentSeries() {
return state.series.find((item) => item.series_uid === state.selectedSeriesUid) || null;
}
function renderActiveHeader() {
const row = state.activeCase;
if (!row) return;
$("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`;
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
const tags = [
`<span class="tag ${row.registration_status === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
...bodyPartTags(row).map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
];
$("activeTags").innerHTML = tags.join("");
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
$("statusBtn").classList.toggle("unregistered", state.registrationStatus !== "registered");
}
function renderSeries() {
$("seriesCount").textContent = state.series.length;
$("seriesList").innerHTML = state.series.length
? state.series
.map((item) => {
const active = item.series_uid === state.selectedSeriesUid;
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
return `
<button class="series-card ${active ? "active" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
<small>序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
<div class="mini-tags">
${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""}
${labels || `<i>未标注</i>`}
</div>
</button>
`;
})
.join("")
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
$("seriesList").querySelectorAll(".series-card").forEach((button) => {
button.addEventListener("click", () => selectSeries(button.dataset.series));
});
updateSliceControl();
}
async function selectSeries(uid) {
if (!uid || uid === state.selectedSeriesUid) return;
if (!(await confirmChange())) return;
state.selectedSeriesUid = uid;
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
markDirty();
renderSeries();
await loadFusion();
}
function renderStl() {
$("stlCount").textContent = state.stlFiles.length;
const models = [...new Set(state.stlFiles.map((item) => item.algorithm_model || state.algorithmModel || "未指定模型"))];
$("modelRail").innerHTML = models.map((model) => `<span class="chip active">${escapeHtml(model)}</span>`).join("");
$("stlList").innerHTML = state.stlFiles.length
? state.stlFiles
.map((file, index) => {
const checked = state.selectedStlIds.has(Number(file.id));
return `
<label class="stl-row ${checked ? "active" : ""}" title="${escapeHtml(file.file_name)}">
<input type="checkbox" data-stl="${Number(file.id)}" ${checked ? "checked" : ""} />
<span>
<strong>${escapeHtml(file.segment_name || file.file_name)}</strong>
<small>${escapeHtml(file.family || "未分类")} · ${escapeHtml(file.category || "")} · ${formatBytes(file.size_bytes)}</small>
<small style="color:${cssColor(index)}">${escapeHtml(file.file_name)}</small>
</span>
</label>
`;
})
.join("")
: `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
$("stlList").querySelectorAll("input[data-stl]").forEach((input) => {
input.addEventListener("change", async () => {
const id = Number(input.dataset.stl);
if (input.checked) state.selectedStlIds.add(id);
else state.selectedStlIds.delete(id);
markDirty();
renderStl();
await loadFusion();
});
});
}
function cssColor(index) {
return `#${STL_COLORS[index % STL_COLORS.length].toString(16).padStart(6, "0")}`;
}
function formatBytes(value) {
const n = Number(value || 0);
if (!n) return "大小未知";
if (n > 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024).toFixed(1)} KB`;
}
function buildPoseControls() {
$("poseGrid").innerHTML = POSE_CONTROLS.map(
(item) => `
<div class="pose-control" data-pose="${item.key}">
<label>${item.label}</label>
<input type="range" min="${item.min}" max="${item.max}" step="${item.step}" />
<input type="number" min="${item.min}" max="${item.max}" step="${item.step}" />
</div>
`,
).join("");
$("poseGrid").querySelectorAll(".pose-control").forEach((row) => {
const key = row.dataset.pose;
const range = row.querySelector("input[type='range']");
const number = row.querySelector("input[type='number']");
const sync = (value, source) => {
const numeric = Number(value);
state.pose[key] = Number.isFinite(numeric) ? numeric : DEFAULT_POSE[key];
if (source !== range) range.value = state.pose[key];
if (source !== number) number.value = state.pose[key];
applyPose();
markDirty();
};
range.addEventListener("input", () => sync(range.value, range));
number.addEventListener("input", () => sync(number.value, number));
});
document.querySelectorAll("[data-flip]").forEach((button) => {
button.addEventListener("click", () => {
const key = button.dataset.flip;
state.pose[key] = !state.pose[key];
renderPoseControls();
applyPose();
markDirty();
});
});
}
function renderPoseControls() {
POSE_CONTROLS.forEach((item) => {
const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`);
if (!row) return;
const value = Number(state.pose[item.key] ?? DEFAULT_POSE[item.key]);
row.querySelector("input[type='range']").value = value;
row.querySelector("input[type='number']").value = Number.isInteger(value) ? value : value.toFixed(2);
});
document.querySelectorAll("[data-flip]").forEach((button) => {
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
});
renderActiveHeader();
}
function updateSliceControl() {
const selected = currentSeries();
const count = Number(selected?.count || 0);
$("sliceSlider").max = Math.max(0, count - 1);
$("sliceSlider").value = Math.min(state.sliceIndex, Math.max(0, count - 1));
$("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 1} / ${count}` : "切片 0 / 0";
}
function initScene() {
if (state.sceneReady) return;
const viewport = $("fusionViewport");
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setClearColor(0x000000, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
viewport.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000);
camera.position.set(0, -8.5, 4.4);
camera.up.set(0, 0, 1);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 0, 0);
const ambient = new THREE.AmbientLight(0xffffff, 0.82);
const directional = new THREE.DirectionalLight(0xffffff, 1.8);
directional.position.set(3.5, -4, 6);
scene.add(ambient, directional);
const dicomGroup = new THREE.Group();
const modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group();
modelGroup.add(rawModelGroup);
scene.add(dicomGroup, modelGroup);
Object.assign(state, { renderer, camera, scene, controls, dicomGroup, modelGroup, rawModelGroup, sceneReady: true });
const resize = () => {
const rect = viewport.getBoundingClientRect();
if (!rect.width || !rect.height) return;
renderer.setSize(rect.width, rect.height, false);
camera.aspect = rect.width / rect.height;
camera.updateProjectionMatrix();
};
state.resizeObserver = new ResizeObserver(resize);
state.resizeObserver.observe(viewport);
resize();
const animate = () => {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
}
function clearGroup(group) {
if (!group) return;
while (group.children.length) {
const child = group.children.pop();
child.traverse?.((node) => {
node.geometry?.dispose?.();
if (node.material) {
if (Array.isArray(node.material)) node.material.forEach((material) => material.dispose?.());
else node.material.dispose?.();
}
node.material?.map?.dispose?.();
});
}
}
function clearFusion() {
clearGroup(state.dicomGroup);
clearGroup(state.rawModelGroup);
state.volumeMeta = null;
$("fusionMeta").textContent = "DICOM - · STL -";
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
updateSliceControl();
}
async function loadFusion() {
const version = ++state.fusionVersion;
const selected = currentSeries();
if (!state.sceneReady) {
$("fusionStatus").textContent = "当前浏览器未能启用 WebGL无法显示三维融合视图";
return;
}
if (!state.activeCase || !selected) {
clearFusion();
return;
}
setFusionLoading(true, "正在按需加载 DICOM 切片与 STL");
updateSliceControl();
try {
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
series_uid: selected.series_uid,
center_index: String(state.sliceIndex),
window: state.windowMode,
radius: "24",
});
const volume = await api(`/api/dicom/fusion-volume?${params.toString()}`);
if (version !== state.fusionVersion) return;
state.volumeMeta = volume;
await buildDicomVolume(volume);
if (version !== state.fusionVersion) return;
await buildStlModels(volume);
if (version !== state.fusionVersion) return;
fitCamera();
$("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)}`;
$("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size}`;
} catch (error) {
clearFusion();
$("fusionStatus").textContent = error.message;
} finally {
if (version === state.fusionVersion) setFusionLoading(false);
}
}
async function loadTexture(url) {
return new Promise((resolve, reject) => {
new THREE.TextureLoader().load(url, resolve, undefined, reject);
});
}
async function buildDicomVolume(volume) {
clearGroup(state.dicomGroup);
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 };
const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
const sceneScale = 5.2 / maxPhysical;
const width = (physical.width || 1) * sceneScale;
const height = (physical.height || 1) * sceneScale;
const depth = (physical.depth || 1) * sceneScale;
const centerIndex = Number(volume.center || 0);
const indices = volume.indices || [];
const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0;
const textures = await Promise.all((volume.frames || []).map((frame) => loadTexture(frame)));
textures.forEach((texture, index) => {
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: index === centerPosition ? 0.78 : 0.14,
side: THREE.DoubleSide,
depthWrite: false,
});
const plane = new THREE.Mesh(new THREE.PlaneGeometry(width, height), material);
const z = (Number(indices[index] ?? centerIndex) - centerIndex) * Number(volume.spacing?.slice || 1) * sceneScale;
plane.position.z = z;
state.dicomGroup.add(plane);
});
const box = new THREE.BoxGeometry(width, height, Math.max(depth, 0.02));
const edges = new THREE.EdgesGeometry(box);
const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 }));
state.dicomGroup.add(lines);
state.baseModelScale = sceneScale;
}
async function buildStlModels(volume) {
clearGroup(state.rawModelGroup);
const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
if (!files.length) {
applyPose();
return;
}
const loader = new STLLoader();
const token = encodeURIComponent(state.token);
for (const [index, file] of files.entries()) {
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
file_id: String(file.id),
algorithm_model: state.algorithmModel,
access_token: state.token,
});
const geometry = await loader.loadAsync(`/api/stl/file?${params.toString()}`);
geometry.computeVertexNormals();
geometry.computeBoundingBox();
const material = new THREE.MeshStandardMaterial({
color: STL_COLORS[index % STL_COLORS.length],
metalness: 0.08,
roughness: 0.5,
transparent: true,
opacity: 0.58,
side: THREE.DoubleSide,
depthWrite: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.name = file.segment_name || file.file_name;
state.rawModelGroup.add(mesh);
}
const bbox = new THREE.Box3().setFromObject(state.rawModelGroup);
const center = new THREE.Vector3();
bbox.getCenter(center);
state.rawModelGroup.position.set(-center.x, -center.y, -center.z);
applyPose();
}
function applyPose() {
if (!state.modelGroup) return;
const pose = { ...DEFAULT_POSE, ...state.pose };
state.modelGroup.rotation.set(
THREE.MathUtils.degToRad(Number(pose.rotateX) || 0),
THREE.MathUtils.degToRad(Number(pose.rotateY) || 0),
THREE.MathUtils.degToRad(Number(pose.rotateZ) || 0),
);
state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0);
const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale;
state.modelGroup.scale.set(scale * (pose.flipX ? -1 : 1), scale * (pose.flipY ? -1 : 1), scale * (pose.flipZ ? -1 : 1));
}
function fitCamera() {
if (!state.camera || !state.controls) return;
const box = new THREE.Box3();
box.expandByObject(state.dicomGroup);
box.expandByObject(state.modelGroup);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
const distance = Math.max(size.x, size.y, size.z, 1) * 1.8;
state.camera.position.set(center.x, center.y - distance, center.z + distance * 0.45);
state.controls.target.copy(center);
state.controls.update();
}
async function saveRegistration(nextStatus = null) {
if (!state.activeCase || !currentSeries() || state.saving) return;
state.saving = true;
$("saveBtn").disabled = true;
$("statusBtn").disabled = true;
setSaveState("保存中", "warn");
const selectedSeries = currentSeries();
const status = nextStatus || state.registrationStatus;
const selectedFiles = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
try {
await api("/api/registrations", {
method: "POST",
body: JSON.stringify({
ct_number: state.activeCase.ct_number,
algorithm_model: state.algorithmModel,
registration_status: status,
series_instance_uid: selectedSeries.series_uid,
series_description: selectedSeries.description || "",
selected_stl_files: selectedFiles.map((file) => ({
id: file.id,
file_name: file.file_name,
segment_name: file.segment_name,
family: file.family,
category: file.category,
algorithm_model: file.algorithm_model,
})),
transform: state.pose,
module_styles: Object.fromEntries(selectedFiles.map((file, index) => [file.file_name, { color: cssColor(index), opacity: 0.58 }])),
dicom_reference: {
series_uid: selectedSeries.series_uid,
series_number: selectedSeries.series_number,
description: selectedSeries.description,
slice_index: state.sliceIndex,
slice_count: selectedSeries.count,
window: state.windowMode,
volume: state.volumeMeta?.physicalSize || null,
},
model_reference: {
algorithm_model: state.algorithmModel,
selected_count: selectedFiles.length,
families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))],
},
notes: $("notes").value,
}),
});
state.registrationStatus = status;
if (state.activeCase) state.activeCase.registration_status = status;
const existing = state.cases.find((row) => sameCase(row, state.activeCase));
if (existing) existing.registration_status = status;
resetDirty();
renderCases();
renderActiveHeader();
} catch (error) {
setSaveState(error.message, "error");
} finally {
state.saving = false;
$("saveBtn").disabled = false;
$("statusBtn").disabled = false;
}
}
function sameHostUrl(raw, targetPort) {
try {
const url = new URL(raw);
const here = new URL(window.location.href);
if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname)) url.hostname = here.hostname;
if (targetPort) url.port = String(targetPort);
return url.toString();
} catch {
const here = new URL(window.location.href);
here.port = String(targetPort || here.port);
here.pathname = "/";
here.search = "";
return here.toString();
}
}
function openLinkedApp(kind) {
const ct = state.activeCase?.ct_number || "";
const model = state.algorithmModel || "";
if (kind === "viewer") {
const url = new URL(sameHostUrl(state.links.pacs_viewer_url, 8107));
if (ct) url.searchParams.set("ct_number", ct);
window.open(url.toString(), "_blank");
} else {
const url = new URL(sameHostUrl(state.links.relation_viewer_url, 8108));
if (ct) url.searchParams.set("ct_number", ct);
if (model) url.searchParams.set("algorithm_model", model);
window.open(url.toString(), "_blank");
}
}
function wireEvents() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", () => logout());
$("refreshBtn").addEventListener("click", async () => {
await loadStatus();
await loadCases(false);
});
$("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("fitBtn").addEventListener("click", fitCamera);
$("saveBtn").addEventListener("click", () => saveRegistration());
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
$("notes").addEventListener("input", markDirty);
$("caseSearch").addEventListener("input", () => {
state.search = $("caseSearch").value.trim();
window.clearTimeout(state.searchTimer);
state.searchTimer = window.setTimeout(() => loadCases(false), 240);
});
document.querySelectorAll(".filter[data-status]").forEach((button) => {
button.addEventListener("click", async () => {
state.statusFilter = button.dataset.status || "";
document.querySelectorAll(".filter[data-status]").forEach((item) => item.classList.toggle("active", item === button));
await loadCases(false);
});
});
document.querySelectorAll(".filter[data-part]").forEach((button) => {
button.addEventListener("click", async () => {
state.partFilter = button.dataset.part || "";
document.querySelectorAll(".filter[data-part]").forEach((item) => item.classList.toggle("active", item === button));
await loadCases(false);
});
});
document.querySelectorAll(".segmented button").forEach((button) => {
button.addEventListener("click", async () => {
state.windowMode = button.dataset.window || "default";
document.querySelectorAll(".segmented button").forEach((item) => item.classList.toggle("active", item === button));
await loadFusion();
});
});
$("sliceSlider").addEventListener("input", () => {
state.sliceIndex = Number($("sliceSlider").value || 0);
updateSliceControl();
});
$("sliceSlider").addEventListener("change", () => loadFusion());
}
wireEvents();
if (state.token) {
bootstrap();
} else {
loginVisible(true);
}