import * as THREE from "three";
import { STLLoader } from "./vendor/STLLoader.js";
import { OrbitControls } from "./vendor/OrbitControls.js";
const $ = (id) => document.getElementById(id);
const BODY_LABELS = {
head_neck: "头颈部",
chest: "胸部",
upper_abdomen: "上腹部",
lower_abdomen: "下腹部",
pelvis: "盆腔",
};
const DEFAULT_POSE = {
rotateX: 0,
rotateY: 0,
rotateZ: 0,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
flipX: false,
flipY: false,
flipZ: false,
};
const POSE_CONTROLS = [
{ key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 1, suffix: "°" },
{ key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 1, suffix: "°" },
{ key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 1, suffix: "°" },
{ key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.01, suffix: "" },
{ key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.01, suffix: "" },
{ key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.01, suffix: "" },
{ key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.01, suffix: "x" },
];
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
const state = {
token: localStorage.getItem("dicom_upp_registration_token") || "",
user: null,
links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" },
statusFilter: "",
partFilter: "",
search: "",
cases: [],
activeCase: null,
series: [],
selectedSeriesUid: "",
stlFiles: [],
selectedStlIds: new Set(),
algorithmModel: "",
registrationStatus: "unregistered",
pose: { ...DEFAULT_POSE },
windowMode: "default",
sliceIndex: 0,
dirty: false,
saving: false,
fusionVersion: 0,
volumeMeta: null,
sceneReady: false,
renderer: null,
camera: null,
scene: null,
controls: null,
dicomGroup: null,
modelGroup: null,
rawModelGroup: null,
baseModelScale: 1,
resizeObserver: null,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function cls(value) {
return value ? "active" : "";
}
function setTopLoading(visible) {
$("topLoading").classList.toggle("hidden", !visible);
}
function setFusionLoading(visible, text = "正在构建融合视图") {
$("fusionLoading").classList.toggle("hidden", !visible);
$("fusionLoading").querySelector("span").textContent = text;
}
function setSaveState(text, tone = "") {
const el = $("saveState");
el.textContent = text;
el.className = tone;
}
function markDirty() {
state.dirty = true;
setSaveState("未保存", "warn");
}
function resetDirty() {
state.dirty = false;
setSaveState("已保存", "ok");
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.token) headers.set("Authorization", `Bearer ${state.token}`);
if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const response = await fetch(path, { ...options, headers });
if (response.status === 401) {
logout(false);
throw new Error("登录已过期");
}
if (!response.ok) {
let message = response.statusText;
try {
const payload = await response.json();
message = payload.detail || message;
} catch {
message = await response.text();
}
throw new Error(message || "请求失败");
}
const type = response.headers.get("content-type") || "";
return type.includes("application/json") ? response.json() : response.text();
}
function loginVisible(visible) {
$("loginOverlay").classList.toggle("hidden", !visible);
}
async function login(event) {
event.preventDefault();
$("loginError").textContent = "";
try {
const payload = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }),
});
state.token = payload.token;
state.user = payload;
localStorage.setItem("dicom_upp_registration_token", state.token);
loginVisible(false);
await bootstrap();
} catch (error) {
$("loginError").textContent = error.message;
}
}
function logout(clearToken = true) {
if (clearToken) localStorage.removeItem("dicom_upp_registration_token");
state.token = "";
state.user = null;
loginVisible(true);
$("userBadge").textContent = "未登录";
}
async function bootstrap() {
setTopLoading(true);
try {
const me = await api("/api/auth/me");
state.user = me;
$("userBadge").textContent = `${me.username} · ${me.role || "管理员"}`;
loginVisible(false);
try {
initScene();
} catch (error) {
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
console.warn(error);
}
buildPoseControls();
await loadStatus();
await loadCases();
} catch (error) {
console.warn(error);
if (!state.token) logout(false);
else {
$("dbStatus").textContent = error.message || "启动失败";
$("dbStatus").className = "status-pill offline";
loginVisible(false);
}
} finally {
setTopLoading(false);
}
}
async function loadStatus() {
try {
const payload = await api("/api/status");
state.links = payload.links || state.links;
const db = payload.database || {};
$("dbStatus").textContent = db.ok ? `数据库已连接 · ${payload.counts?.complete_cases || 0}` : "数据库异常";
$("dbStatus").className = `status-pill ${db.ok ? "online" : "offline"}`;
} catch (error) {
$("dbStatus").textContent = "数据库异常";
$("dbStatus").className = "status-pill offline";
}
}
async function loadCases(selectFirst = true) {
setTopLoading(true);
try {
const params = new URLSearchParams();
if (state.search) params.set("q", state.search);
if (state.statusFilter) params.set("status", state.statusFilter);
if (state.partFilter) params.set("body_part", state.partFilter);
const rows = await api(`/api/cases?${params.toString()}`);
state.cases = rows;
renderCases();
if (selectFirst && !state.activeCase && rows.length) {
await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型");
} else if (state.activeCase && !rows.some((row) => sameCase(row, state.activeCase))) {
state.activeCase = null;
clearDetail();
}
} finally {
setTopLoading(false);
}
}
function sameCase(a, b) {
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
}
function normalize(value) {
return String(value || "").trim().toUpperCase();
}
function fmtDateTime(date, time) {
const d = String(date || "").replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
const t = fmtTime(time);
return [d, t].filter(Boolean).join(" ");
}
function fmtTime(value) {
const raw = String(value || "").replace(/[^\d.]/g, "");
if (!raw) return "";
const main = raw.split(".")[0].padEnd(6, "0").slice(0, 6);
return `${main.slice(0, 2)}:${main.slice(2, 4)}:${main.slice(4, 6)}`;
}
function statusText(value) {
return value === "registered" ? "已配准" : "未配准";
}
function bodyPartTags(row) {
const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : [];
const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : [];
if (labels.length) return labels;
return keys.map((key) => BODY_LABELS[key]).filter(Boolean);
}
function renderCases() {
$("caseCount").textContent = state.cases.length;
const active = state.activeCase;
$("caseList").innerHTML = state.cases.length
? state.cases
.map((row) => {
const tags = bodyPartTags(row).slice(0, 3);
const status = row.registration_status || "unregistered";
const selected = active && sameCase(row, active);
const title = escapeHtml(row.ct_number || row.ct_key);
const patient = escapeHtml(row.patient_name || row.upp_patient_name || "-");
const meta = `${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`;
return `
`;
})
.join("")
: `
没有符合条件的完整关联 CT
`;
$("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 = `未选择 CT
`;
$("stlList").innerHTML = `未选择 STL
`;
$("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 = [
`${statusText(state.registrationStatus)}`,
...bodyPartTags(row).map((tag) => `${escapeHtml(tag)}`),
];
$("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) => `${escapeHtml(label)}`).join("");
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
return `
`;
})
.join("")
: `未读取到 DICOM 序列
`;
$("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) => `${escapeHtml(model)}`).join("");
$("stlList").innerHTML = state.stlFiles.length
? state.stlFiles
.map((file, index) => {
const checked = state.selectedStlIds.has(Number(file.id));
return `
`;
})
.join("")
: `当前算法模型没有 STL 文件
`;
$("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) => `
`,
).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);
}