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, scaleX: 1, scaleY: 1, scaleZ: 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: "", caseCollapsed: false, activeToolTab: "series", cases: [], activeCase: null, series: [], selectedSeriesUid: "", stlFiles: [], selectedStlIds: new Set(), algorithmModel: "", registrationStatus: "unregistered", pose: { ...DEFAULT_POSE }, windowMode: "default", fusionMode: "fusion", sliceIndex: 0, dicomPreviewTimer: 0, autoResult: null, dirty: false, dirtyReason: "", saving: false, fusionVersion: 0, volumeMeta: null, volumeScene: null, sceneReady: false, renderer: null, camera: null, scene: null, controls: null, dicomGroup: null, modelGroup: null, rawModelGroup: null, modelMeshes: [], baseModelScale: 1, dicomSceneBox: null, mappingViewport: { scale: 1, offsetX: 0, offsetY: 0 }, mappingDrag: null, mappingDrawFrame: 0, resizeObserver: null, resizeScene: 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 setAutoState(text, tone = "") { const el = $("autoState"); if (!el) return; el.textContent = text; el.className = tone; } function markDirty(reason = "pose") { state.dirty = true; state.dirtyReason = reason; setSaveState("未保存", "warn"); } function resetDirty() { state.dirty = false; state.dirtyReason = ""; 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) { initGeometryFallback(); $("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 sameCtNumber(a, b) { return normalize(a) === normalize(b); } 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 seriesLabels(item) { return Array.isArray(item?.annotation_labels) ? item.annotation_labels.map((label) => String(label || "")) : []; } function isSkippedSeries(item) { return seriesLabels(item).some((label) => label.includes("略过") || label.includes("不采用")); } function hasLabel(item, matcher) { return seriesLabels(item).some((label) => matcher(String(label))); } function isUpperAbdomenSeries(item) { return hasLabel(item, (label) => label.includes("上腹部")); } function isPortalPhaseSeries(item) { return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期"))); } 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 = ""; $("patientSummary").textContent = "未选择 CT"; $("summaryCt").textContent = "-"; $("summaryPatientId").textContent = "-"; $("summaryStudyTime").textContent = "-"; $("summaryBodyPart").textContent = "-"; $("matchedSeries").innerHTML = ""; $("seriesList").innerHTML = `
未选择 CT
`; $("stlList").innerHTML = `
未选择 STL
`; $("modelRail").innerHTML = ""; renderDicomAnnotation(); clearFusion(); } async function confirmChange() { if (!state.dirty) return true; return window.confirm("当前配准参数还未保存,确定切换吗?"); } async function selectCase(ctNumber, algorithmModel = "未指定模型") { if (!ctNumber) return; const normalizedAlgorithm = algorithmModel || "未指定模型"; const switchingModelOnly = state.activeCase && sameCtNumber(state.activeCase.ct_number, ctNumber) && (state.algorithmModel || "未指定模型") !== normalizedAlgorithm; const previousPose = { ...state.pose }; if (!switchingModelOnly && !(await confirmChange())) return; setTopLoading(true); setFusionLoading(true, "正在读取 CT 关联数据"); try { const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm }); 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"; const savedTransform = registration.transform || {}; const keepPreviousPose = switchingModelOnly; state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform }; state.autoResult = null; 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(); if (keepPreviousPose) markDirty("model"); else resetDirty(); await loadFusion(); } catch (error) { $("fusionStatus").textContent = error.message; clearFusion(); } finally { setTopLoading(false); setFusionLoading(false); } } function pickDefaultSeries(series) { const candidates = [...series].filter((item) => Number(item.count || 0) > 1); const usable = candidates.filter((item) => !isSkippedSeries(item)); const pool = usable.length ? usable : candidates; const algorithm = String(state.algorithmModel || "").toLowerCase(); const rank = (item) => { let score = Number(item.count || 0) / 10000; if (algorithm.includes("肝胆") || algorithm.includes("liver") || algorithm.includes("bile")) { if (isPortalPhaseSeries(item)) score += 900; else if (isUpperAbdomenSeries(item)) score += 720; } else if (isUpperAbdomenSeries(item)) { score += 120; } if (isSkippedSeries(item)) score -= 1000; return score; }; return [...pool] .sort((a, b) => { const diff = rank(b) - rank(a); if (Math.abs(diff) > 1e-6) return diff; return Number(a.series_number || 999999) - Number(b.series_number || 999999); })[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"); $("patientSummary").textContent = `${row.patient_name || row.upp_patient_name || "-"} ${row.patient_sex || ""}`.trim(); $("summaryCt").textContent = row.ct_number || "-"; $("summaryPatientId").textContent = row.patient_id || "-"; $("summaryStudyTime").textContent = fmtDateTime(row.study_date, row.study_time) || "-"; $("summaryBodyPart").textContent = bodyPartTags(row).join("、") || row.study_description || "-"; } function renderSeries() { $("seriesCount").textContent = state.series.length; const activeSeries = currentSeries(); $("matchedSeries").innerHTML = activeSeries ? seriesCardHtml(activeSeries, true) : `
尚未选择配准序列
`; $("seriesList").innerHTML = state.series.length ? state.series .map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid)) .join("") : `
未读取到 DICOM 序列
`; document.querySelectorAll(".series-card[data-series]").forEach((button) => { button.addEventListener("click", () => selectSeries(button.dataset.series)); }); updateSliceControl(); renderDicomAnnotation(); } function seriesCardHtml(item, active = false) { 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); const skipped = isSkippedSeries(item); return ` `; } 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(); renderDicomAnnotation(); 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) => `
${item.key.startsWith("rotate") ? `
` : ""}
`, ).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-nudge]").forEach((button) => { button.addEventListener("click", () => nudgePose(button.dataset.nudge, Number(button.dataset.delta || 0))); }); document.querySelectorAll("[data-flip]").forEach((button) => { button.addEventListener("click", () => { const key = button.dataset.flip; state.pose[key] = !state.pose[key]; renderPoseControls(); applyPose(); markDirty(); }); }); } function nudgePose(key, delta) { if (!key || !Number.isFinite(delta)) return; const option = POSE_CONTROLS.find((item) => item.key === key); const current = Number(state.pose[key] ?? DEFAULT_POSE[key]); const next = Math.max(option?.min ?? -Infinity, Math.min(option?.max ?? Infinity, current + delta)); state.pose[key] = Number(next.toFixed(3)); 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); const max = Math.max(0, count - 1); state.sliceIndex = Math.max(0, Math.min(state.sliceIndex, max)); $("sliceSlider").max = max; $("sliceSlider").value = state.sliceIndex; $("sliceLabel").textContent = count ? `切片 ${state.sliceIndex + 1} / ${count}` : "切片 0 / 0"; $("mappingSliceSlider").max = max; $("mappingSliceSlider").value = max - state.sliceIndex; $("mappingSliceLabel").textContent = count ? `${state.sliceIndex + 1} / ${count}` : "0 / 0"; } function renderDicomAnnotation() { const row = state.activeCase; const selected = currentSeries(); const count = Number(selected?.count || 0); $("dicomPanelMeta").textContent = selected ? `${selected.description || "未命名序列"} · ${count} 张` : "当前序列切片"; $("dicomInfoLeft").textContent = row && selected ? `${row.patient_name || row.upp_patient_name || "-"}\n${row.patient_id || "-"}\n${fmtDateTime(row.study_date, row.study_time) || "-"}\n${selected.description || "-"}` : "未选择序列"; $("dicomInfoRight").textContent = selected ? `${selected.modality || "CT"}\n${selected.rows || "-"}×${selected.columns || "-"}\n${selected.slice_thickness || "厚度 -"}` : ""; $("dicomInfoBottom").textContent = count ? `Img:${state.sliceIndex + 1}/${count}` : "Img:-"; const tags = selected?.annotation_labels?.length ? selected.annotation_labels : bodyPartTags(row || {}); $("annotationTags").innerHTML = tags.length ? tags.map((tag) => `${escapeHtml(tag)}`).join("") : `未标注`; } function updateDicomPreview() { const selected = currentSeries(); if (!state.activeCase || !selected) { $("dicomPreview").removeAttribute("src"); renderDicomAnnotation(); resetMappingCanvas("未选择 DICOM 序列"); return; } window.clearTimeout(state.dicomPreviewTimer); state.dicomPreviewTimer = window.setTimeout(() => { const params = new URLSearchParams({ ct_number: state.activeCase.ct_number, series_uid: selected.series_uid, index: String(state.sliceIndex), window: state.windowMode, access_token: state.token, }); $("dicomLoading").classList.remove("hidden"); resetMappingCanvas("正在加载 DICOM Base Layer"); $("dicomPreview").src = `/api/dicom/image?${params.toString()}`; renderDicomAnnotation(); }, 80); } 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.resizeScene = resize; state.resizeObserver = new ResizeObserver(resize); state.resizeObserver.observe(viewport); resize(); const animate = () => { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }; animate(); } function initGeometryFallback() { if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return; const dicomGroup = new THREE.Group(); const modelGroup = new THREE.Group(); const rawModelGroup = new THREE.Group(); modelGroup.add(rawModelGroup); Object.assign(state, { dicomGroup, modelGroup, rawModelGroup }); } 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; state.volumeScene = null; state.modelMeshes = []; $("fusionMeta").textContent = "DICOM - · STL -"; $("fusionStatus").textContent = "等待选择 DICOM 与 STL"; updateSliceControl(); resetMappingCanvas(); } function applyFusionMode() { if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model"; if (state.modelGroup) state.modelGroup.visible = true; $("fusionMeta").textContent = state.fusionMode === "model" ? `单独模型 · STL ${state.selectedStlIds.size} 个` : $("fusionMeta").textContent; } async function loadFusion() { const version = ++state.fusionVersion; const selected = currentSeries(); if (!state.sceneReady) initGeometryFallback(); 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; if (state.sceneReady) { applyFusionMode(); fitCamera(); } $("fusionStatus").textContent = state.sceneReady ? `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张` : `${selected.description || "DICOM 序列"} · 2D 映射已就绪,当前浏览器未启用 WebGL`; $("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size} 个`; renderDicomAnnotation(); updateDicomPreview(); } 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 loadImageData(url, maxSize = 256) { const image = await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = url; }); const scale = Math.min(1, maxSize / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height, 1)); const width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale)); const height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale)); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d", { willReadFrequently: true }); context.drawImage(image, 0, 0, width, height); return { width, height, data: context.getImageData(0, 0, width, height).data }; } function sliceToSceneZ(sliceIndex, volumeScene = state.volumeScene) { if (!volumeScene) return 0; return volumeScene.total <= 1 ? 0 : -volumeScene.depth / 2 + (volumeScene.depth * Math.max(0, Math.min(sliceIndex, volumeScene.total - 1))) / (volumeScene.total - 1); } function parseCssColor(color) { const value = String(color || "#19d6c3").replace("#", ""); const hex = value.length === 3 ? value.split("").map((char) => char + char).join("") : value.padStart(6, "0").slice(0, 6); const intValue = Number.parseInt(hex, 16); return { r: (intValue >> 16) & 255, g: (intValue >> 8) & 255, b: intValue & 255, }; } function resetMappingCanvas(message = "当前切片暂无可见构件") { const canvas = $("mappingCanvas"); const context = canvas?.getContext("2d"); if (context) context.clearRect(0, 0, canvas.width || 1, canvas.height || 1); $("mappingStats").textContent = "0/0 构件 · 0 边 · 0 px"; $("mappingLegend").innerHTML = `
${escapeHtml(message)}
`; } function scheduleMappingDraw() { window.cancelAnimationFrame(state.mappingDrawFrame); state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView()); } function applyMappingTransform() { const layer = $("mappingLayer"); if (!layer) return; const { scale, offsetX, offsetY } = state.mappingViewport; layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(${scale})`; } function intersectEdgeWithZ(a, b, targetZ) { const da = a.z - targetZ; const db = b.z - targetZ; if (Math.abs(da) < 1e-5 && Math.abs(db) < 1e-5) return [a.clone(), b.clone()]; if (Math.abs(da) < 1e-5) return [a.clone()]; if (Math.abs(db) < 1e-5) return [b.clone()]; if ((da > 0 && db > 0) || (da < 0 && db < 0)) return []; const t = da / (da - db); return [new THREE.Vector3( a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, targetZ, )]; } function uniquePlanePoints(points) { const unique = []; points.forEach((point) => { if (!unique.some((item) => item.distanceToSquared(point) < 1e-8)) unique.push(point); }); return unique; } function intersectTriangleWithZ(a, b, c, targetZ) { const points = uniquePlanePoints([ ...intersectEdgeWithZ(a, b, targetZ), ...intersectEdgeWithZ(b, c, targetZ), ...intersectEdgeWithZ(c, a, targetZ), ]); if (points.length < 2) return null; return { a: points[0], b: points[1] }; } function addSegmentIntersectionsToRows(rows, width, height, segment) { const a = segment.a; const b = segment.b; if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return; const minY = Math.max(0, Math.floor(Math.min(a.y, b.y))); const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y))); const deltaY = b.y - a.y; if (Math.abs(deltaY) < 1e-6) { const row = Math.max(0, Math.min(height - 1, Math.round(a.y))); rows[row].push(Math.max(0, Math.min(width - 1, a.x))); rows[row].push(Math.max(0, Math.min(width - 1, b.x))); return; } for (let row = minY; row <= maxY; row += 1) { const sampleY = row + 0.5; const t = (sampleY - a.y) / deltaY; if (t < 0 || t > 1) continue; const x = a.x + (b.x - a.x) * t; if (Number.isFinite(x)) rows[row].push(x); } } function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) { if (!segments.length) return 0; const rgb = parseCssColor(color); const maskCanvas = document.createElement("canvas"); maskCanvas.width = width; maskCanvas.height = height; const maskContext = maskCanvas.getContext("2d"); const imageData = maskContext.createImageData(width, height); const rows = Array.from({ length: height }, () => []); const alpha = Math.round(Math.max(0.12, Math.min(opacity, 1)) * 190); let filled = 0; segments.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment)); rows.forEach((intersections, y) => { if (intersections.length < 2) return; intersections.sort((a, b) => a - b); const cleaned = []; intersections.forEach((x) => { const previous = cleaned[cleaned.length - 1]; if (previous === undefined || Math.abs(previous - x) > 0.35) cleaned.push(x); }); for (let index = 0; index + 1 < cleaned.length; index += 2) { const startX = Math.max(0, Math.min(width - 1, Math.ceil(cleaned[index]))); const endX = Math.max(0, Math.min(width - 1, Math.floor(cleaned[index + 1]))); if (endX < startX) continue; for (let x = startX; x <= endX; x += 1) { const offset = (y * width + x) * 4; imageData.data[offset] = rgb.r; imageData.data[offset + 1] = rgb.g; imageData.data[offset + 2] = rgb.b; imageData.data[offset + 3] = alpha; filled += 1; } } }); maskContext.putImageData(imageData, 0, 0); context.drawImage(maskCanvas, 0, 0); context.save(); context.globalAlpha = Math.max(0.18, Math.min(opacity, 1)); context.strokeStyle = color; context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012); context.lineCap = "round"; context.lineJoin = "round"; context.beginPath(); segments.forEach((segment) => { context.moveTo(segment.a.x, segment.a.y); context.lineTo(segment.b.x, segment.b.y); }); context.stroke(); context.restore(); return filled; } function drawMappingView() { const image = $("dicomPreview"); const canvas = $("mappingCanvas"); const volumeScene = state.volumeScene; if (!image?.complete || !image.naturalWidth || !volumeScene || !state.modelMeshes.length) { resetMappingCanvas(state.modelMeshes.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件"); return; } const width = image.naturalWidth; const height = image.naturalHeight; if (canvas.width !== width) canvas.width = width; if (canvas.height !== height) canvas.height = height; const context = canvas.getContext("2d"); context.clearRect(0, 0, width, height); const targetZ = sliceToSceneZ(state.sliceIndex, volumeScene); const mapPoint = (point) => ({ x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width, y: (0.5 - point.y / volumeScene.height) * height, }); state.modelGroup?.updateMatrixWorld(true); let activeModules = 0; let segmentCount = 0; let filledPixels = 0; const modules = []; const tempA = new THREE.Vector3(); const tempB = new THREE.Vector3(); const tempC = new THREE.Vector3(); state.modelMeshes.forEach((record, index) => { const position = record.mesh.geometry.attributes.position; if (!position) return; const triangleCount = Math.floor(position.count / 3); const step = Math.max(1, Math.ceil(triangleCount / 70000)); const segments = []; for (let tri = 0; tri < triangleCount; tri += step) { tempA.fromBufferAttribute(position, tri * 3); tempB.fromBufferAttribute(position, tri * 3 + 1); tempC.fromBufferAttribute(position, tri * 3 + 2); record.mesh.localToWorld(tempA); record.mesh.localToWorld(tempB); record.mesh.localToWorld(tempC); const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ); if (!segment) continue; const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) }; if ( mapped.a.x > -width && mapped.a.x < width * 2 && mapped.b.x > -width && mapped.b.x < width * 2 && mapped.a.y > -height && mapped.a.y < height * 2 && mapped.b.y > -height && mapped.b.y < height * 2 ) { segments.push(mapped); } } const pixels = fillSegmentsAsSolidMask(context, width, height, segments, record.color, 0.66); if (segments.length || pixels) { activeModules += 1; segmentCount += segments.length; filledPixels += pixels; modules.push({ id: index + 1, name: record.file.segment_name || record.file.file_name || `STL ${index + 1}`, color: record.color, segments: segments.length, pixels, }); } }); $("mappingStats").textContent = `${activeModules}/${state.modelMeshes.length} 构件 · ${segmentCount} 边 · ${filledPixels} px`; $("mappingLegend").innerHTML = modules.length ? modules.map((item) => `
${escapeHtml(item.name)} ID ${item.id} ${item.segments} 边 ${item.pixels} px
`).join("") : `
当前切片暂无可见构件
`; } 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 = 4.8 / maxPhysical; const width = (physical.width || 1) * sceneScale; const height = (physical.height || 1) * sceneScale; const depth = Math.max((physical.depth || 1) * sceneScale, 0.18); const centerIndex = Number(volume.center || 0); const total = Math.max(1, Number(volume.total || 1)); const indices = volume.indices || []; const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0; state.volumeScene = { width, height, depth, total, sceneScale, rowSpacing: Number(volume.spacing?.row || 1), columnSpacing: Number(volume.spacing?.column || 1), sliceSpacing: Number(volume.spacing?.slice || 1), }; const boxMesh = new THREE.Mesh( new THREE.BoxGeometry(width, height, depth), new THREE.MeshBasicMaterial({ color: 0x020617, transparent: true, opacity: 0.045, depthWrite: false }), ); state.dicomGroup.add(boxMesh); const edges = new THREE.LineSegments( new THREE.EdgesGeometry(boxMesh.geometry), new THREE.LineBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 0.46 }), ); state.dicomGroup.add(edges); const localStart = Number(volume.start ?? indices[0] ?? centerIndex); const localEnd = Number(volume.end ?? indices[indices.length - 1] ?? centerIndex); const rangeDepth = Math.max(0.015, Math.abs(sliceToSceneZ(localEnd) - sliceToSceneZ(localStart))); const rangeBox = new THREE.LineSegments( new THREE.EdgesGeometry(new THREE.BoxGeometry(width * 0.94, height * 0.94, rangeDepth)), new THREE.LineBasicMaterial({ color: 0xfacc15, transparent: true, opacity: 0.56 }), ); rangeBox.position.z = (sliceToSceneZ(localStart) + sliceToSceneZ(localEnd)) / 2; state.dicomGroup.add(rangeBox); const centerFrame = (volume.frames || [])[centerPosition]; if (centerFrame) { const texture = await loadTexture(centerFrame); texture.colorSpace = THREE.SRGBColorSpace; const plane = new THREE.Mesh( new THREE.PlaneGeometry(width, height), new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: 0.42, side: THREE.DoubleSide, depthWrite: false, }), ); plane.position.z = sliceToSceneZ(centerIndex) + 0.006; state.dicomGroup.add(plane); } const pointPositions = []; const pointColors = []; const frames = volume.frames || []; for (const [index, frame] of frames.entries()) { const image = await loadImageData(frame, 180); const stride = Math.max(3, Math.round(Math.max(image.width, image.height) / 72)); const z = sliceToSceneZ(Number(indices[index] ?? centerIndex)); for (let y = 0; y < image.height; y += stride) { for (let x = 0; x < image.width; x += stride) { const offset = (y * image.width + x) * 4; const lum = image.data[offset]; if (lum < 12) continue; pointPositions.push((x / Math.max(image.width - 1, 1) - 0.5) * width); pointPositions.push((0.5 - y / Math.max(image.height - 1, 1)) * height); pointPositions.push(z); const v = 0.16 + (lum / 255) * 0.72; pointColors.push(v * 0.92, v, Math.min(1, v * 1.08)); } } } if (pointPositions.length) { const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.Float32BufferAttribute(pointPositions, 3)); geometry.setAttribute("color", new THREE.Float32BufferAttribute(pointColors, 3)); const points = new THREE.Points( geometry, new THREE.PointsMaterial({ size: 0.018, vertexColors: true, transparent: true, opacity: 0.34, depthWrite: false, }), ); state.dicomGroup.add(points); } state.baseModelScale = sceneScale; state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup); } async function buildStlModels(volume) { clearGroup(state.rawModelGroup); state.modelMeshes = []; const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id))); if (!files.length) { applyPose(); resetMappingCanvas("当前没有可见 STL 构件"); return; } const loader = new STLLoader(); 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; mesh.userData.file = file; mesh.userData.color = cssColor(index); state.rawModelGroup.add(mesh); state.modelMeshes.push({ mesh, file, color: cssColor(index), index }); } 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(); scheduleMappingDraw(); } function applyPose(poseInput = state.pose) { if (!state.modelGroup) return; const pose = { ...DEFAULT_POSE, ...poseInput }; 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 * Math.max(0.001, Number(pose.scaleX) || 1) * (pose.flipX ? -1 : 1), scale * Math.max(0.001, Number(pose.scaleY) || 1) * (pose.flipY ? -1 : 1), scale * Math.max(0.001, Number(pose.scaleZ) || 1) * (pose.flipZ ? -1 : 1), ); state.modelGroup.updateMatrixWorld(true); scheduleMappingDraw(); } 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(); } function resetPose(kind) { if (kind === "rotation") { state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 }; } else if (kind === "transform") { state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1, scaleX: 1, scaleY: 1, scaleZ: 1 }; } else if (kind === "flip") { state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false }; } else { state.pose = { ...DEFAULT_POSE }; } renderPoseControls(); applyPose(); markDirty(); } function stretchAxis(axis) { if (!state.rawModelGroup?.children.length || !state.dicomSceneBox) { $("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。"; return; } const axisKey = { x: "scaleX", y: "scaleY", z: "scaleZ" }[axis]; if (!axisKey) return; const modelBox = new THREE.Box3().setFromObject(state.modelGroup); const dicomSize = new THREE.Vector3(); const modelSize = new THREE.Vector3(); state.dicomSceneBox.getSize(dicomSize); modelBox.getSize(modelSize); const currentAxisScale = Math.max(0.001, Number(state.pose[axisKey]) || 1); const targetSize = Math.max(0.001, dicomSize[axis]); const currentSize = Math.max(0.001, modelSize[axis]); const next = Math.max(0.2, Math.min(4, currentAxisScale * (targetSize / currentSize))); state.pose[axisKey] = Number(next.toFixed(3)); applyPose(); markDirty("pose"); setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok"); $("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`; } function suggestBoneSelection() { const boneKeys = ["rib", "vertebra", "sternum", "bone", "spine", "肋", "椎", "骨", "胸骨"]; const next = new Set(); state.stlFiles.forEach((file) => { const haystack = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase(); if (boneKeys.some((key) => haystack.includes(key))) next.add(Number(file.id)); }); if (!next.size) { state.stlFiles.slice(0, Math.min(6, state.stlFiles.length)).forEach((file) => next.add(Number(file.id))); } state.selectedStlIds = next; renderStl(); markDirty(); loadFusion(); } function candidateScore(pose) { if (!state.dicomSceneBox || !state.modelGroup || !state.rawModelGroup?.children.length) return -Infinity; const current = { ...state.pose }; applyPose(pose); const modelBox = new THREE.Box3().setFromObject(state.modelGroup); applyPose(current); const overlap = modelBox.clone().intersect(state.dicomSceneBox); const overlapSize = new THREE.Vector3(); overlap.getSize(overlapSize); const modelSize = new THREE.Vector3(); const dicomSize = new THREE.Vector3(); modelBox.getSize(modelSize); state.dicomSceneBox.getSize(dicomSize); const overlapVolume = Math.max(0, overlapSize.x) * Math.max(0, overlapSize.y) * Math.max(0, overlapSize.z); const modelVolume = Math.max(modelSize.x * modelSize.y * modelSize.z, 0.001); const modelCenter = new THREE.Vector3(); const dicomCenter = new THREE.Vector3(); modelBox.getCenter(modelCenter); state.dicomSceneBox.getCenter(dicomCenter); const centerPenalty = modelCenter.distanceTo(dicomCenter) / Math.max(dicomSize.length(), 0.001); const scalePenalty = Math.abs((pose.scale || 1) - 1) * 0.03; return overlapVolume / modelVolume - centerPenalty - scalePenalty; } function runAutoCoarse() { if (!state.activeCase || !currentSeries() || !state.rawModelGroup?.children.length) { $("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。"; return; } state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 }; renderPoseControls(); applyPose(); fitCamera(); state.autoResult = { score: candidateScore(state.pose), pose: { ...state.pose }, evaluated: 1 }; setAutoState("粗配准完成", "ok"); $("autoResult").textContent = `已按 DICOM 物理尺寸复位平移/缩放,score ${state.autoResult.score.toFixed(4)}`; markDirty(); } function runAutoFine() { if (!state.activeCase || !currentSeries() || !state.rawModelGroup?.children.length) { $("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。"; return; } setAutoState("运行中", "warn"); $("autoResult").textContent = "正在进行几何重叠微调..."; const adjustable = { translateX: $("autoX").checked, translateY: $("autoY").checked, translateZ: $("autoZ").checked, scale: $("autoScale").checked, }; let best = { pose: { ...state.pose }, score: candidateScore(state.pose), mode: "初始位姿" }; let evaluated = 1; const rounds = [ { move: 0.34, scale: 0.12 }, { move: 0.18, scale: 0.06 }, { move: 0.08, scale: 0.025 }, { move: 0.035, scale: 0.01 }, ]; for (const round of rounds) { const candidates = []; if (adjustable.translateX) candidates.push(["translateX", round.move], ["translateX", -round.move]); if (adjustable.translateY) candidates.push(["translateY", round.move], ["translateY", -round.move]); if (adjustable.translateZ) candidates.push(["translateZ", round.move], ["translateZ", -round.move]); if (adjustable.scale) candidates.push(["scale", round.scale], ["scale", -round.scale]); for (const [key, delta] of candidates) { const pose = { ...best.pose, [key]: key === "scale" ? Math.max(0.2, Math.min(3, best.pose.scale + delta)) : best.pose[key] + delta }; const score = candidateScore(pose); evaluated += 1; if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` }; } } state.pose = { ...best.pose }; state.autoResult = { ...best, evaluated }; renderPoseControls(); applyPose(); fitCamera(); setAutoState("微调完成", "ok"); $("autoResult").textContent = `最佳候选:${best.mode};score ${best.score.toFixed(4)};评估 ${evaluated} 个候选。`; markDirty(); } 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))], fusion_mode: state.fusionMode, auto_result: state.autoResult, }, 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()); $("caseToggleBtn").addEventListener("click", () => { state.caseCollapsed = !state.caseCollapsed; document.querySelector(".workspace").classList.toggle("case-collapsed", state.caseCollapsed); $("caseToggleBtn").classList.toggle("active", state.caseCollapsed); window.setTimeout(() => { state.resizeScene?.(); fitCamera(); }, 230); }); $("refreshBtn").addEventListener("click", async () => { await loadStatus(); await loadCases(false); }); $("relationBtn").addEventListener("click", () => openLinkedApp("relation")); $("viewerBtn").addEventListener("click", () => openLinkedApp("viewer")); $("openViewerBtn").addEventListener("click", () => openLinkedApp("viewer")); $("reloadFusionBtn").addEventListener("click", () => loadFusion()); $("fitBtn").addEventListener("click", fitCamera); $("saveBtn").addEventListener("click", () => saveRegistration()); $("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered")); $("notes").addEventListener("input", markDirty); $("resetRotationBtn").addEventListener("click", () => resetPose("rotation")); $("resetTransformBtn").addEventListener("click", () => resetPose("transform")); $("resetFlipBtn").addEventListener("click", () => resetPose("flip")); $("stretchXBtn").addEventListener("click", () => stretchAxis("x")); $("stretchYBtn").addEventListener("click", () => stretchAxis("y")); $("stretchZBtn").addEventListener("click", () => stretchAxis("z")); $("suggestBoneBtn").addEventListener("click", suggestBoneSelection); $("autoCoarseBtn").addEventListener("click", runAutoCoarse); $("autoFineBtn").addEventListener("click", runAutoFine); $("dicomPreview").addEventListener("load", () => { $("dicomLoading").classList.add("hidden"); scheduleMappingDraw(); }); $("dicomPreview").addEventListener("error", () => { $("dicomLoading").classList.add("hidden"); resetMappingCanvas("DICOM Base Layer 加载失败"); }); $("mappingResetBtn").addEventListener("click", () => { state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0 }; applyMappingTransform(); }); $("mappingViewport").addEventListener("wheel", (event) => { event.preventDefault(); const factor = event.deltaY > 0 ? 0.9 : 1.1; state.mappingViewport.scale = Math.max(0.45, Math.min(6, state.mappingViewport.scale * factor)); applyMappingTransform(); }, { passive: false }); $("mappingViewport").addEventListener("pointerdown", (event) => { if (event.button !== 0) return; state.mappingDrag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, offsetX: state.mappingViewport.offsetX, offsetY: state.mappingViewport.offsetY, }; $("mappingViewport").classList.add("dragging"); $("mappingViewport").setPointerCapture(event.pointerId); }); $("mappingViewport").addEventListener("pointermove", (event) => { const drag = state.mappingDrag; if (!drag || drag.pointerId !== event.pointerId) return; state.mappingViewport.offsetX = drag.offsetX + event.clientX - drag.startX; state.mappingViewport.offsetY = drag.offsetY + event.clientY - drag.startY; applyMappingTransform(); }); const stopMappingDrag = (event) => { const drag = state.mappingDrag; if (!drag || drag.pointerId !== event.pointerId) return; state.mappingDrag = null; $("mappingViewport").classList.remove("dragging"); if ($("mappingViewport").hasPointerCapture(event.pointerId)) $("mappingViewport").releasePointerCapture(event.pointerId); }; $("mappingViewport").addEventListener("pointerup", stopMappingDrag); $("mappingViewport").addEventListener("pointercancel", stopMappingDrag); document.querySelectorAll("[data-tool-tab]").forEach((button) => { button.addEventListener("click", () => { state.activeToolTab = button.dataset.toolTab || "series"; document.querySelectorAll("[data-tool-tab]").forEach((item) => item.classList.toggle("active", item === button)); document.querySelectorAll("[data-tool-pane]").forEach((pane) => pane.classList.toggle("active", pane.dataset.toolPane === state.activeToolTab)); }); }); $("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("[data-window]").forEach((button) => { button.addEventListener("click", async () => { state.windowMode = button.dataset.window || "default"; document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item === button)); await loadFusion(); }); }); document.querySelectorAll("[data-fusion-mode]").forEach((button) => { button.addEventListener("click", () => { state.fusionMode = button.dataset.fusionMode || "fusion"; document.querySelectorAll("[data-fusion-mode]").forEach((item) => item.classList.toggle("active", item === button)); applyFusionMode(); fitCamera(); }); }); $("sliceSlider").addEventListener("input", () => { state.sliceIndex = Number($("sliceSlider").value || 0); updateSliceControl(); renderDicomAnnotation(); updateDicomPreview(); }); $("sliceSlider").addEventListener("change", () => loadFusion()); $("mappingSliceSlider").addEventListener("input", () => { const selected = currentSeries(); const max = Math.max(0, Number(selected?.count || 0) - 1); state.sliceIndex = max - Number($("mappingSliceSlider").value || 0); updateSliceControl(); renderDicomAnnotation(); updateDicomPreview(); }); $("mappingSliceSlider").addEventListener("change", () => loadFusion()); } wireEvents(); if (state.token) { bootstrap(); } else { loginVisible(true); }