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: 0.1, decimals: 1, suffix: "°" }, { key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" }, { key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" }, { key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" }, { key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" }, { key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" }, { key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.001, decimals: 3, suffix: "x" }, ]; const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a]; const CASE_PAGE_SIZE = 20; const POSE_SIGNATURE_KEYS = [ "rotateX", "rotateY", "rotateZ", "translateX", "translateY", "translateZ", "scale", "flipX", "flipY", "flipZ", ]; 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" }, settings: null, cacheStatus: null, cachePollTimer: 0, statusFilter: "", partFilter: "", modelFilter: "", search: "", searchTimer: 0, caseCollapsed: false, caseLimit: CASE_PAGE_SIZE, caseOffset: 0, caseTotal: 0, caseHasMore: false, loadingCases: false, activeToolTab: "series", cases: [], activeCase: null, series: [], selectedSeriesUid: "", registrationSeriesUid: "", stlFiles: [], selectedStlIds: new Set(), algorithmModel: "", registrationStatus: "unregistered", pose: { ...DEFAULT_POSE }, windowMode: "default", fusionMode: "fusion", fusionDetail: "low", modelDetail: "standard", mappingMode: "result", sliceIndex: 0, sliceRangeStart: 0, sliceRangeEnd: 0, dicomPreviewTimer: 0, autoResult: null, lastAutoPose: null, dirty: false, dirtyReason: "", savedPoseSignature: "", saving: false, fusionVersion: 0, volumeMeta: null, volumeScene: null, sceneReady: false, renderer: null, camera: null, scene: null, controls: null, fusionRoot: null, viewPose: { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 }, sceneDrag: null, dicomGroup: null, dicomPlane: null, dicomPoints: null, dicomSlicePlanes: [], modelGroup: null, rawModelGroup: null, axisGroup: null, modelMeshes: [], stlSignature: "", baseModelScale: 1, modelBoundsFrame: null, modelLocalBounds: null, dicomSceneBox: null, mappingViewport: { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 }, mappingDrag: null, mappingDrawFrame: 0, mappingVersion: 0, resizeObserver: null, resizeScene: null, nudgeTimer: null, nudgeDelayTimer: null, }; function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function cls(value) { return value ? "active" : ""; } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function setTopLoading(visible) { $("topLoading").classList.toggle("hidden", !visible); } function setFusionLoading(visible, text = "正在构建融合视图", progress = null) { $("fusionLoading").classList.toggle("hidden", !visible); $("fusionLoading").querySelector("span").textContent = text; const fill = $("fusionProgressFill"); const label = $("fusionProgressText"); if (fill && label) { const value = progress == null ? 0 : Math.max(0, Math.min(100, Number(progress) || 0)); fill.style.width = progress == null ? "34%" : `${value}%`; fill.classList.toggle("indeterminate", progress == null); label.textContent = progress == null ? "" : `${Math.round(value)}%`; } } function setDicomLoading(visible, text = "正在加载分割结果", progress = null) { $("dicomLoading").classList.toggle("hidden", !visible); $("dicomLoading").querySelector("span").textContent = text; const fill = $("dicomProgressFill"); const label = $("dicomProgressText"); if (!fill || !label) return; const value = progress == null ? 0 : clamp(Number(progress) || 0, 0, 100); fill.style.width = progress == null ? "34%" : `${value}%`; fill.classList.toggle("indeterminate", progress == null); label.textContent = progress == null ? "" : `${Math.round(value)}%`; } 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 poseSignature(pose = state.pose) { const normalized = {}; POSE_SIGNATURE_KEYS.forEach((key) => { const value = pose?.[key] ?? DEFAULT_POSE[key]; normalized[key] = typeof value === "boolean" ? value : Number(Number(value || 0).toFixed(4)); }); return JSON.stringify(normalized); } function poseChanged() { return state.savedPoseSignature && poseSignature(state.pose) !== state.savedPoseSignature; } function resetDirty() { state.dirty = false; state.dirtyReason = ""; state.savedPoseSignature = poseSignature(state.pose); 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 loadSettings(); 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 loadSettings() { try { const payload = await api("/api/settings"); state.settings = payload.refresh || {}; state.cacheStatus = payload.cache || {}; renderSettings(); pollCacheWhileRunning(); } catch (error) { console.warn(error); } } function renderSettings() { const settings = state.settings || {}; const cache = state.cacheStatus || {}; $("autoRefreshEnabled").checked = settings.auto_refresh_enabled !== false; $("refreshTime").value = settings.refresh_time || "03:00"; $("cacheRootText").textContent = cache.cache_root || settings.cache_root || ""; $("cacheStage").textContent = cache.running ? "刷新中" : cache.stage === "finished" ? "已完成" : cache.stage === "error" ? "异常" : "待刷新"; $("cacheStage").className = `status-pill ${cache.stage === "error" ? "offline" : cache.running || cache.stage === "finished" ? "online" : ""}`; const progress = Math.max(0, Math.min(100, Number(cache.progress || 0))); $("cacheProgressFill").style.width = `${progress}%`; $("cacheMessage").textContent = cache.message || "等待刷新"; $("cacheCases").textContent = `${Number(cache.processed_cases || 0)} / ${Number(cache.total_cases || 0)} CT`; $("cacheFiles").textContent = `STL 复制 ${Number(cache.copied_files || 0)} · 复用 ${Number(cache.skipped_files || 0)} · 缺失 ${Number(cache.missing_files || 0)}`; $("cacheNextRun").textContent = cache.next_scheduled_at ? `下次 ${cache.next_scheduled_at}` : "下次 -"; $("manualCacheRefreshBtn").disabled = Boolean(cache.running); } async function saveSettings() { const payload = { auto_refresh_enabled: $("autoRefreshEnabled").checked, refresh_time: $("refreshTime").value || "03:00", }; const result = await api("/api/settings", { method: "POST", body: JSON.stringify(payload) }); state.settings = result.refresh || payload; state.cacheStatus = result.cache || state.cacheStatus; renderSettings(); } async function refreshCacheNow() { $("manualCacheRefreshBtn").disabled = true; const result = await api("/api/cache/refresh", { method: "POST", body: JSON.stringify({}) }); state.cacheStatus = result.cache || state.cacheStatus; renderSettings(); pollCacheWhileRunning(true); } function pollCacheWhileRunning(force = false) { window.clearTimeout(state.cachePollTimer); const running = Boolean(state.cacheStatus?.running); if (!running && !force) return; state.cachePollTimer = window.setTimeout(async () => { try { state.cacheStatus = await api("/api/cache/status"); renderSettings(); } catch (error) { console.warn(error); } pollCacheWhileRunning(); }, running ? 1600 : 800); } async function loadCases(selectFirst = true, append = false) { if (state.loadingCases) return; state.loadingCases = true; if (!append) { state.caseOffset = 0; state.caseHasMore = false; state.cases = []; } setTopLoading(!append); 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); if (state.modelFilter) params.set("algorithm_model", state.modelFilter); params.set("limit", String(state.caseLimit)); params.set("offset", String(append ? state.caseOffset : 0)); const payload = await api(`/api/cases?${params.toString()}`); const rows = Array.isArray(payload) ? payload : payload.rows || []; state.caseTotal = Array.isArray(payload) ? (append ? state.cases.length + rows.length : rows.length) : Number(payload.total || 0); state.caseHasMore = Array.isArray(payload) ? rows.length >= state.caseLimit : Boolean(payload.has_more); state.caseOffset = (append ? state.caseOffset : 0) + rows.length; state.cases = append ? [...state.cases, ...rows] : rows; renderCases(); if (selectFirst && !state.activeCase && rows.length) { await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型"); } else if (!append && state.activeCase && !state.cases.some((row) => sameCase(row, state.activeCase))) { state.activeCase = null; clearDetail(); } } finally { state.loadingCases = false; if (!append) setTopLoading(false); } } async function loadMoreCases() { if (state.loadingCases || !state.caseHasMore) return; await loadCases(false, true); } 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("门静脉期") || label.includes("门静脉"))); } function isLiverBiliaryModel(model = state.algorithmModel) { const value = String(model || "").toLowerCase(); return value.includes("肝胆") || value.includes("liver") || value.includes("bile"); } function sortedSeriesForDisplay(series) { return [...series].sort((a, b) => { const skippedDiff = Number(isSkippedSeries(a)) - Number(isSkippedSeries(b)); if (skippedDiff) return skippedDiff; const timeDiff = String(a.first_time || a.series_time || "").localeCompare(String(b.first_time || b.series_time || "")); if (timeDiff) return timeDiff; return Number(a.series_number || 999999) - Number(b.series_number || 999999); }); } function stlSelectionSignature() { return [ normalize(state.activeCase?.ct_number), state.algorithmModel || "", state.stlFiles.map((file) => `${Number(file.id)}:${file.file_name || ""}:${file.size_bytes || 0}`).join(","), ].join("|"); } function visibleModelRecords() { return state.modelMeshes.filter((record) => state.selectedStlIds.has(Number(record.file?.id)) && record.mesh?.visible !== false); } function modelPoseMatrix() { if (!state.modelGroup) return new THREE.Matrix4(); state.modelGroup.updateMatrix(); return state.modelGroup.matrix.clone(); } function visibleModelBox() { const records = visibleModelRecords(); if (!records.length) return null; const box = new THREE.Box3(); const matrix = modelPoseMatrix(); records.forEach((record) => { record.mesh.geometry.computeBoundingBox?.(); const localBox = record.mesh.geometry.boundingBox; if (localBox) box.union(localBox.clone().applyMatrix4(matrix)); }); return box; } 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.caseTotal ? `${state.cases.length}/${state.caseTotal}` : state.cases.length; const active = state.activeCase; const cards = 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").innerHTML = `${cards}${state.caseHasMore ? `` : ""}`; $("caseList").querySelectorAll(".case-card").forEach((button) => { button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型")); }); $("loadMoreCasesBtn")?.addEventListener("click", loadMoreCases); } function clearDetail() { state.series = []; state.stlFiles = []; state.selectedStlIds = new Set(); state.selectedSeriesUid = ""; state.registrationSeriesUid = ""; $("activeTitle").textContent = "未选择 CT"; $("activeMeta").textContent = "从左侧选择一个完整关联检查"; $("activeTags").innerHTML = ""; $("seriesList").innerHTML = `
未选择 CT
`; $("stlList").innerHTML = `
未选择 STL
`; $("modelRail").innerHTML = ""; renderDicomAnnotation(); clearFusion(); } async function confirmChange() { if (!state.dirty || !poseChanged()) 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 (!(await confirmChange())) return; setTopLoading(true); setFusionLoading(true, "正在读取当前 CT 数据", 6); 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 || {}), scaleX: 1, scaleY: 1, scaleZ: 1 }; const keepPreviousPose = switchingModelOnly; state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform }; state.fusionDetail = state.fusionDetail || "low"; state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "standard"; state.autoResult = null; state.lastAutoPose = 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); const defaultSeries = pickDefaultSeries(state.series); const savedSeries = state.series.find((item) => item.series_uid === registration.series_instance_uid); const shouldUseSavedSeries = registration.registration_status === "registered" && savedSeries; state.registrationSeriesUid = savedSeries?.series_uid || ""; state.selectedSeriesUid = shouldUseSavedSeries ? savedSeries.series_uid : defaultSeries?.series_uid || ""; const selected = currentSeries(); state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0; state.sliceRangeStart = 0; state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0; state.stlSignature = ""; renderCases(); renderActiveHeader(); renderSeries(); renderStl(); renderPoseControls(); syncDisplayControls(); 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 rank = (item) => { let score = Number(item.count || 0) / 10000; if (isLiverBiliaryModel()) { 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; const registration = registrationSeries(); const selectedLabels = registration?.annotation_labels?.length ? registration.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : []; $("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`; $("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${row.patient_id || "-"} · ${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)}`, `${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}`, ...selectedLabels.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 ? sortedSeriesForDisplay(state.series) .map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid, item.series_uid === state.registrationSeriesUid)) .join("") : `
未读取到 DICOM 序列
`; document.querySelectorAll(".series-card[data-series]").forEach((card) => { card.addEventListener("click", (event) => { if (event.target.closest(".series-check")) return; selectSeries(card.dataset.series); }); card.addEventListener("keydown", (event) => { if (!["Enter", " "].includes(event.key)) return; event.preventDefault(); selectSeries(card.dataset.series); }); }); document.querySelectorAll(".series-check[data-registration-series]").forEach((button) => { button.addEventListener("click", async (event) => { event.stopPropagation(); await setRegistrationSeries(button.dataset.registrationSeries); }); }); updateSliceControl(); renderDicomAnnotation(); } function seriesCardHtml(item, active = false, registrationSelected = 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 `
${escapeHtml(item.description || "未命名序列")} 拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")} ${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张
${item.body_part_dicom ? `${escapeHtml(item.body_part_dicom)}` : ""} ${labels || `未标注`}
`; } function registrationSeries() { return state.series.find((item) => item.series_uid === state.registrationSeriesUid) || null; } async function setRegistrationSeries(uid) { if (!uid) return; if (uid !== state.selectedSeriesUid) { await selectSeries(uid, { markRegistration: true }); return; } state.registrationSeriesUid = uid; markDirty("series"); renderSeries(); renderActiveHeader(); } async function selectSeries(uid, options = {}) { if (!uid || uid === state.selectedSeriesUid) return; if (!(await confirmChange())) return; state.selectedSeriesUid = uid; if (options.markRegistration) state.registrationSeriesUid = uid; const selected = currentSeries(); state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0; state.sliceRangeStart = 0; state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0; if (options.markRegistration) markDirty("series"); else setSaveState("正在浏览序列", ""); renderSeries(); renderActiveHeader(); 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); await applyStlSelectionChange(); }); }); renderAutoBoneOptions(); } async function selectAllStl() { if (!state.stlFiles.length) return; state.selectedStlIds = new Set(state.stlFiles.map((file) => Number(file.id)).filter(Number.isFinite)); await applyStlSelectionChange(); } async function invertStlSelection() { if (!state.stlFiles.length) return; const next = new Set(); state.stlFiles.forEach((file) => { const id = Number(file.id); if (Number.isFinite(id) && !state.selectedStlIds.has(id)) next.add(id); }); state.selectedStlIds = next; await applyStlSelectionChange(); } async function applyStlSelectionChange() { markDirty("stl"); renderStl(); const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite)); const needsLoad = [...state.selectedStlIds].some((id) => !loadedIds.has(Number(id))); if (!state.volumeMeta || (!state.modelMeshes.length && state.selectedStlIds.size)) { await loadFusion(); return; } if (needsLoad) { setFusionLoading(true, "正在补加载 STL 模型", 62); await buildStlModels(state.volumeMeta, (progress) => setFusionLoading(true, "正在补加载 STL 模型", 62 + progress * 0.3)); setFusionLoading(false); applyModelDetail(); if (state.sceneReady) fitCamera(); renderStl(); return; } applyStlVisibility(); applyModelDetail(); scheduleMappingDraw(); } function stlLikelyScore(file) { const textValue = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase(); const priority = [ ["rib", 95], ["vertebra", 94], ["spine", 92], ["sternum", 90], ["pelvis", 82], ["skull", 78], ["bone", 72], ]; return priority.reduce((score, [key, value]) => textValue.includes(key) ? Math.max(score, value) : score, 0); } function stlDisplayName(file) { return String(file.segment_name || file.family || file.file_name || `stl_${file.id || ""}`).replace(/\\.stl$/i, ""); } function renderAutoBoneOptions() { const container = $("autoBoneOptions"); if (!container) return; if (!state.stlFiles.length) { container.innerHTML = `
No STL
`; return; } const rows = [...state.stlFiles].sort((a, b) => { const diff = stlLikelyScore(b) - stlLikelyScore(a); if (diff) return diff; return stlDisplayName(a).localeCompare(stlDisplayName(b)); }); container.innerHTML = rows.map((file) => { const id = Number(file.id); const likely = stlLikelyScore(file) > 0; const checked = likely ? "checked" : ""; const title = escapeHtml(`${file.file_name || ""} ${file.family || ""}`.trim()); return ` `; }).join(""); } 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 option = POSE_CONTROLS.find((item) => item.key === key); const sync = (value, source) => { const numeric = Number(value); state.pose[key] = Number.isFinite(numeric) ? Number(numeric.toFixed(option?.decimals ?? 3)) : 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) => wireHoldNudge(button)); document.querySelectorAll("[data-flip]").forEach((button) => { button.addEventListener("click", () => { const key = button.dataset.flip; state.pose[key] = !state.pose[key]; renderPoseControls(); applyPose(); markDirty(); }); }); } function wireHoldNudge(button) { const fixedDelta = Number(button.dataset.delta || 0); if (Math.abs(fixedDelta) >= 10) { button.addEventListener("click", () => nudgePose(button.dataset.nudge, fixedDelta)); return; } const start = (event) => { if (event.button !== undefined && event.button !== 0) return; event.preventDefault(); const key = button.dataset.nudge; const delta = Number(button.dataset.delta || 0); nudgePose(key, delta); clearNudgeTimers(); state.nudgeDelayTimer = window.setTimeout(() => { state.nudgeTimer = window.setInterval(() => nudgePose(key, delta), 54); }, 260); button.setPointerCapture?.(event.pointerId); }; const stop = (event) => { clearNudgeTimers(); if (event?.pointerId !== undefined && button.hasPointerCapture?.(event.pointerId)) { button.releasePointerCapture(event.pointerId); } }; button.addEventListener("pointerdown", start); button.addEventListener("pointerup", stop); button.addEventListener("pointercancel", stop); button.addEventListener("pointerleave", stop); } function clearNudgeTimers() { window.clearTimeout(state.nudgeDelayTimer); window.clearInterval(state.nudgeTimer); state.nudgeDelayTimer = null; state.nudgeTimer = null; } 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(option?.decimals ?? 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(item.decimals ?? 3); }); 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)); state.sliceRangeStart = Math.max(0, Math.min(Number(state.sliceRangeStart) || 0, max)); state.sliceRangeEnd = Math.max(0, Math.min(Number(state.sliceRangeEnd) || max, max)); if (state.sliceRangeStart > state.sliceRangeEnd) [state.sliceRangeStart, state.sliceRangeEnd] = [state.sliceRangeEnd, state.sliceRangeStart]; $("sliceRangeStart").max = max; $("sliceRangeEnd").max = max; $("sliceRangeStart").value = state.sliceRangeStart; $("sliceRangeEnd").value = state.sliceRangeEnd; $("sliceRangeLabel").textContent = count ? `${state.sliceRangeStart + 1} - ${state.sliceRangeEnd + 1} / ${count}` : "0 - 0 / 0"; $("sliceStartLabel").textContent = count ? `起点 ${state.sliceRangeStart + 1}` : "起点 0"; $("sliceEndLabel").textContent = count ? `终点 ${state.sliceRangeEnd + 1}` : "终点 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); $("mappingSummaryTitle").textContent = state.mappingMode === "image" ? "单独影像" : "可见 STL 构件"; $("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:-"; renderActiveHeader(); } 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, }); setDicomLoading(true, "正在加载 DICOM Base Layer", 16); 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); camera.lookAt(0, 0, 0); const controls = new OrbitControls(camera, renderer.domElement); controls.enabled = false; 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(); const axisGroup = createSceneAxes(); const fusionRoot = new THREE.Group(); modelGroup.add(rawModelGroup); fusionRoot.add(dicomGroup, modelGroup, axisGroup); scene.add(fusionRoot); Object.assign(state, { renderer, camera, scene, controls, fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup, 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); applySceneViewPose(); renderer.render(scene, camera); }; animate(); wireFusionSceneDrag(renderer.domElement); } function createAxisLabel(text, color) { const canvas = document.createElement("canvas"); canvas.width = 96; canvas.height = 64; const context = canvas.getContext("2d"); context.clearRect(0, 0, canvas.width, canvas.height); context.fillStyle = color; context.font = "900 42px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; context.textAlign = "center"; context.textBaseline = "middle"; context.fillText(text, 48, 34); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false })); sprite.scale.set(0.28, 0.18, 1); return sprite; } function createSceneAxes() { const group = new THREE.Group(); const length = 0.78; const headLength = 0.16; const headWidth = 0.08; const axes = [ { label: "X", color: 0xf87171, css: "#f87171", dir: new THREE.Vector3(1, 0, 0) }, { label: "Y", color: 0x4ade80, css: "#4ade80", dir: new THREE.Vector3(0, 1, 0) }, { label: "Z", color: 0x60a5fa, css: "#60a5fa", dir: new THREE.Vector3(0, 0, 1) }, ]; axes.forEach((axis) => { const arrow = new THREE.ArrowHelper(axis.dir, new THREE.Vector3(0, 0, 0), length, axis.color, headLength, headWidth); const label = createAxisLabel(axis.label, axis.css); label.position.copy(axis.dir.clone().multiplyScalar(length + 0.16)); group.add(arrow, label); }); group.visible = true; return group; } function initGeometryFallback() { if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return; const fusionRoot = new THREE.Group(); const dicomGroup = new THREE.Group(); const modelGroup = new THREE.Group(); const rawModelGroup = new THREE.Group(); modelGroup.add(rawModelGroup); fusionRoot.add(dicomGroup, modelGroup); Object.assign(state, { fusionRoot, dicomGroup, modelGroup, rawModelGroup }); } function applySceneViewPose() { if (!state.fusionRoot) return; const pose = state.viewPose || {}; state.fusionRoot.rotation.set(Number(pose.rotateX) || 0, 0, Number(pose.rotateZ) || 0); state.fusionRoot.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, 0); state.fusionRoot.scale.setScalar(Math.max(0.2, Number(pose.scale) || 1)); state.fusionRoot.updateMatrixWorld(true); } function wireFusionSceneDrag(canvas) { if (!canvas) return; canvas.style.touchAction = "none"; canvas.addEventListener("contextmenu", (event) => event.preventDefault()); canvas.addEventListener("pointerdown", (event) => { event.preventDefault(); state.sceneDrag = { pointerId: event.pointerId, mode: event.button === 2 || event.shiftKey ? "pan" : "rotate", startX: event.clientX, startY: event.clientY, pose: { ...state.viewPose }, }; canvas.setPointerCapture?.(event.pointerId); }); canvas.addEventListener("pointermove", (event) => { const drag = state.sceneDrag; if (!drag || drag.pointerId !== event.pointerId) return; event.preventDefault(); const dx = event.clientX - drag.startX; const dy = event.clientY - drag.startY; if (drag.mode === "pan") { state.viewPose.translateX = drag.pose.translateX + dx * 0.006; state.viewPose.translateY = drag.pose.translateY - dy * 0.006; } else { state.viewPose.rotateZ = drag.pose.rotateZ + dx * 0.008; state.viewPose.rotateX = drag.pose.rotateX + dy * 0.008; } applySceneViewPose(); }); const stop = (event) => { if (state.sceneDrag?.pointerId !== event.pointerId) return; state.sceneDrag = null; canvas.releasePointerCapture?.(event.pointerId); }; canvas.addEventListener("pointerup", stop); canvas.addEventListener("pointercancel", stop); canvas.addEventListener("wheel", (event) => { event.preventDefault(); state.viewPose.scale = Math.max(0.35, Math.min(3, state.viewPose.scale - event.deltaY * 0.001)); applySceneViewPose(); }, { passive: false }); } 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); if (state.rawModelGroup) { state.rawModelGroup.position.set(0, 0, 0); state.rawModelGroup.rotation.set(0, 0, 0); state.rawModelGroup.scale.set(1, 1, 1); } state.volumeMeta = null; state.volumeScene = null; state.modelMeshes = []; state.dicomPlane = null; state.dicomPoints = null; state.dicomSlicePlanes = []; state.modelBoundsFrame = null; state.modelLocalBounds = null; $("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; updateFusionMeta(); } function updateFusionMeta() { const selectedCount = visibleModelRecords().length || state.selectedStlIds.size; if (state.fusionMode === "model") { $("fusionMeta").textContent = `单独模型 · STL ${selectedCount} 个`; return; } const volume = state.volumeMeta; $("fusionMeta").textContent = volume ? `DICOM ${volume.start + 1}-${volume.end + 1}/${volume.total} · STL ${selectedCount} 个` : `DICOM - · STL ${selectedCount} 个`; } function fusionDetailSettings() { return { low: { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 }, medium: { planeOpacity: 1, sliceOpacity: 0.07, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 }, high: { planeOpacity: 1, sliceOpacity: 0.11, slicePlanes: 72, pointOpacity: 0, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 1024 }, }[state.fusionDetail] || { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 }; } function modelDetailSettings() { return { standard: { opacity: 0.52, wireframe: false, depthWrite: false, metalness: 0.08, roughness: 0.55 }, fine: { opacity: 0.66, wireframe: false, depthWrite: false, metalness: 0.04, roughness: 0.48 }, ultra: { opacity: 0.82, wireframe: false, depthWrite: false, metalness: 0.02, roughness: 0.42 }, solid: { opacity: 1, wireframe: false, depthWrite: true, metalness: 0.02, roughness: 0.36 }, }[state.modelDetail] || { opacity: 0.52, wireframe: false, depthWrite: false, metalness: 0.08, roughness: 0.55 }; } function applyFusionDetail() { const detail = fusionDetailSettings(); if (state.dicomPlane?.material) { state.dicomPlane.material.opacity = detail.planeOpacity; state.dicomPlane.material.transparent = true; state.dicomPlane.material.depthWrite = false; state.dicomPlane.material.needsUpdate = true; } if (state.dicomPoints?.material) { state.dicomPoints.material.opacity = detail.pointOpacity; state.dicomPoints.material.size = detail.pointSize; state.dicomPoints.material.needsUpdate = true; } state.dicomSlicePlanes.forEach((plane) => { if (!plane.material) return; plane.material.opacity = detail.sliceOpacity; plane.material.needsUpdate = true; }); } function applyModelDetail() { const detail = modelDetailSettings(); state.modelMeshes.forEach((record) => { const material = record.mesh.material; if (!material) return; material.opacity = detail.opacity; material.transparent = detail.opacity < 1; material.depthWrite = detail.depthWrite; material.wireframe = detail.wireframe; material.metalness = detail.metalness; material.roughness = detail.roughness; material.needsUpdate = true; }); scheduleMappingDraw(); } function applyStlVisibility() { const selected = state.selectedStlIds; state.modelMeshes.forEach((record) => { record.mesh.visible = selected.has(Number(record.file?.id)); }); if (state.modelBoundsFrame) state.modelBoundsFrame.visible = state.modelMeshes.some((record) => record.mesh.visible); if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model"; if (state.modelGroup) state.modelGroup.visible = true; updateFusionMeta(); scheduleMappingDraw(); } function syncDisplayControls() { document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item.dataset.fusionDetail === state.fusionDetail)); document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item.dataset.modelDetail === state.modelDetail)); document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode)); } async function loadFusion() { const version = ++state.fusionVersion; const selected = currentSeries(); if (!state.sceneReady) initGeometryFallback(); if (!state.activeCase || !selected) { clearFusion(); return; } setFusionLoading(true, "正在读取 DICOM 切片范围", 8); 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, detail: state.fusionDetail, texture_size: String(fusionDetailSettings().textureSize), range_start: String(state.sliceRangeStart), range_end: String(state.sliceRangeEnd), }); const volume = await api(`/api/dicom/fusion-volume?${params.toString()}`); if (version !== state.fusionVersion) return; state.volumeMeta = volume; setFusionLoading(true, "正在构建 DICOM 切片范围", 38); await buildDicomVolume(volume); applyFusionDetail(); if (version !== state.fusionVersion) return; setFusionLoading(true, "正在加载 STL 模型", 62); await buildStlModels(volume, (progress) => setFusionLoading(true, "正在加载 STL 模型", 62 + progress * 0.3)); applyModelDetail(); if (version !== state.fusionVersion) return; if (state.sceneReady) { applyFusionMode(); fitCamera(); } $("fusionStatus").textContent = state.sceneReady ? "三维融合场景已就绪" : "2D 映射已就绪,当前浏览器未启用 WebGL"; updateFusionMeta(); renderDicomAnnotation(); setFusionLoading(true, "正在渲染右侧分割映射", 96); 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, (texture) => { texture.colorSpace = THREE.SRGBColorSpace; texture.generateMipmaps = false; texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.NearestFilter; texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1); texture.needsUpdate = true; resolve(texture); }, 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); const version = ++state.mappingVersion; state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView(version)); } function applyMappingTransform() { const layer = $("mappingLayer"); if (!layer) return; const { scale, offsetX, offsetY, rotation } = state.mappingViewport; layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) rotate(${rotation || 0}deg) 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 deltaY = b.y - a.y; if (Math.abs(deltaY) < 0.01) { 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))); for (let row = minY; row <= maxY; row += 1) { const sampleY = row + 0.5; const crosses = (sampleY >= a.y && sampleY < b.y) || (sampleY >= b.y && sampleY < a.y); if (!crosses) continue; const t = (sampleY - a.y) / deltaY; const x = a.x + (b.x - a.x) * t; if (Number.isFinite(x)) rows[row].push(x); } } function groupPlaneSegmentsByConnectivity(segments, tolerance = 1.35) { if (segments.length <= 1) return segments.length ? [segments] : []; const parents = segments.map((_, index) => index); const find = (index) => { if (parents[index] !== index) parents[index] = find(parents[index]); return parents[index]; }; const union = (left, right) => { const leftRoot = find(left); const rightRoot = find(right); if (leftRoot !== rightRoot) parents[rightRoot] = leftRoot; }; const buckets = new Map(); const cellSize = Math.max(tolerance, 0.1); const toleranceSquared = tolerance * tolerance; const cellKey = (x, y) => `${x},${y}`; segments.forEach((segment, index) => { [segment.a, segment.b].forEach((point) => { if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return; const cellX = Math.floor(point.x / cellSize); const cellY = Math.floor(point.y / cellSize); for (let dx = -1; dx <= 1; dx += 1) { for (let dy = -1; dy <= 1; dy += 1) { const candidates = buckets.get(cellKey(cellX + dx, cellY + dy)); candidates?.forEach((candidate) => { const distanceSquared = (candidate.x - point.x) ** 2 + (candidate.y - point.y) ** 2; if (distanceSquared <= toleranceSquared) union(index, candidate.index); }); } } const key = cellKey(cellX, cellY); const bucket = buckets.get(key) ?? []; bucket.push({ x: point.x, y: point.y, index }); buckets.set(key, bucket); }); }); const groups = new Map(); segments.forEach((segment, index) => { const root = find(index); const group = groups.get(root) ?? []; group.push(segment); groups.set(root, group); }); return [...groups.values()].sort((left, right) => right.length - left.length); } function paintMaskPixel(maskData, width, height, x, y, rgb, alpha) { if (x < 0 || x >= width || y < 0 || y >= height) return 0; const offset = (y * width + x) * 4; if (maskData.data[offset + 3] > 0) return 0; maskData.data[offset] = rgb.r; maskData.data[offset + 1] = rgb.g; maskData.data[offset + 2] = rgb.b; maskData.data[offset + 3] = alpha; return 1; } function solidStrokeRadius(width, height) { return Math.max(2.2, Math.min(5.5, Math.max(width, height) * 0.006)); } function fillSegmentCapsulesIntoMask(maskData, width, height, segments, rgb, alpha, radius) { let paintedPixels = 0; const radiusSquared = radius * radius; segments.forEach(({ a, b }) => { if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return; const dx = b.x - a.x; const dy = b.y - a.y; const lengthSquared = dx * dx + dy * dy; const minX = clamp(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1); const maxX = clamp(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1); const minY = clamp(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1); const maxY = clamp(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1); for (let y = minY; y <= maxY; y += 1) { for (let x = minX; x <= maxX; x += 1) { const px = x + 0.5; const py = y + 0.5; const t = lengthSquared <= 1e-6 ? 0 : clamp(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1); const closestX = a.x + dx * t; const closestY = a.y + dy * t; if ((px - closestX) ** 2 + (py - closestY) ** 2 <= radiusSquared) { paintedPixels += paintMaskPixel(maskData, width, height, x, y, rgb, alpha); } } } }); return paintedPixels; } function closeSmallMaskGaps(maskData, width, height, rgb, alpha, maxGap = 2) { const toFill = new Set(); const hasPixel = (x, y) => maskData.data[(y * width + x) * 4 + 3] > 0; const mark = (x, y) => { if (x >= 0 && x < width && y >= 0 && y < height && !hasPixel(x, y)) toFill.add(y * width + x); }; for (let y = 0; y < height; y += 1) { let lastFilled = -1; for (let x = 0; x < width; x += 1) { if (!hasPixel(x, y)) continue; const gap = x - lastFilled - 1; if (lastFilled >= 0 && gap > 0 && gap <= maxGap) { for (let fillX = lastFilled + 1; fillX < x; fillX += 1) mark(fillX, y); } lastFilled = x; } } for (let x = 0; x < width; x += 1) { let lastFilled = -1; for (let y = 0; y < height; y += 1) { if (!hasPixel(x, y)) continue; const gap = y - lastFilled - 1; if (lastFilled >= 0 && gap > 0 && gap <= maxGap) { for (let fillY = lastFilled + 1; fillY < y; fillY += 1) mark(x, fillY); } lastFilled = y; } } toFill.forEach((index) => { const offset = index * 4; maskData.data[offset] = rgb.r; maskData.data[offset + 1] = rgb.g; maskData.data[offset + 2] = rgb.b; maskData.data[offset + 3] = alpha; }); return toFill.size; } function fillInternalMaskHoles(maskData, width, height, rgb, alpha) { const outside = new Uint8Array(width * height); const stack = []; const pushIfEmpty = (x, y) => { if (x < 0 || x >= width || y < 0 || y >= height) return; const index = y * width + x; if (outside[index] || maskData.data[index * 4 + 3] > 0) return; outside[index] = 1; stack.push(index); }; for (let x = 0; x < width; x += 1) { pushIfEmpty(x, 0); pushIfEmpty(x, height - 1); } for (let y = 0; y < height; y += 1) { pushIfEmpty(0, y); pushIfEmpty(width - 1, y); } while (stack.length) { const index = stack.pop(); const x = index % width; const y = Math.floor(index / width); pushIfEmpty(x + 1, y); pushIfEmpty(x - 1, y); pushIfEmpty(x, y + 1); pushIfEmpty(x, y - 1); } let patchedPixels = 0; for (let index = 0; index < outside.length; index += 1) { const offset = index * 4; if (!outside[index] && maskData.data[offset + 3] === 0) { maskData.data[offset] = rgb.r; maskData.data[offset + 1] = rgb.g; maskData.data[offset + 2] = rgb.b; maskData.data[offset + 3] = alpha; patchedPixels += 1; } } return patchedPixels; } function drawFallbackClosedRegion(context, width, height, segments, color, opacity = 0.72) { const points = segments.flatMap((segment) => [segment.a, segment.b]) .filter((point) => ( Number.isFinite(point.x) && Number.isFinite(point.y) && point.x >= -width && point.x <= width * 2 && point.y >= -height && point.y <= height * 2 )); if (points.length < 3) return 0; const uniquePoints = []; points.forEach((point) => { if (!uniquePoints.some((current) => (current.x - point.x) ** 2 + (current.y - point.y) ** 2 < 1e-6)) uniquePoints.push(point); }); if (uniquePoints.length < 3) return 0; const sorted = [...uniquePoints].sort((left, right) => (Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y)); const cross = (origin, a, b) => (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x); const lower = []; sorted.forEach((point) => { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) lower.pop(); lower.push(point); }); const upper = []; [...sorted].reverse().forEach((point) => { while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) upper.pop(); upper.push(point); }); const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)]; const ordered = hull.length >= 3 ? hull : uniquePoints; context.save(); context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62; context.fillStyle = color; context.beginPath(); ordered.forEach((point, index) => { if (index === 0) context.moveTo(point.x, point.y); else context.lineTo(point.x, point.y); }); context.closePath(); context.fill(); context.restore(); return Math.max(1, Math.round(ordered.length / 2)); } function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) { if (!segments.length) return 0; const rgb = parseCssColor(color); const alpha = Math.round(clamp(opacity, 0.1, 1) * 190); const maskCanvas = document.createElement("canvas"); maskCanvas.width = width; maskCanvas.height = height; const maskContext = maskCanvas.getContext("2d"); if (!maskContext) return 0; const maskData = maskContext.createImageData(width, height); const radius = solidStrokeRadius(width, height); const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15); let filledPixels = 0; groups.forEach((group) => { const rows = Array.from({ length: height }, () => []); group.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment)); let groupPixels = 0; rows.forEach((intersections, row) => { if (intersections.length < 2) return; intersections.sort((left, right) => left - right); 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 rawStartX = cleaned[index]; const rawEndX = cleaned[index + 1]; if (rawEndX < 0 || rawStartX > width - 1) continue; const startX = clamp(Math.ceil(rawStartX), 0, width - 1); const endX = clamp(Math.floor(rawEndX), 0, width - 1); if (endX < startX) continue; for (let x = startX; x <= endX; x += 1) { const offset = (row * width + x) * 4; if (maskData.data[offset + 3] === 0) { maskData.data[offset] = rgb.r; maskData.data[offset + 1] = rgb.g; maskData.data[offset + 2] = rgb.b; maskData.data[offset + 3] = alpha; groupPixels += 1; } } } }); groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius); filledPixels += groupPixels; }); filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3); filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha); maskContext.putImageData(maskData, 0, 0); context.drawImage(maskCanvas, 0, 0); if (filledPixels === 0) { context.save(); context.globalAlpha = clamp(opacity, 0.1, 1) * 0.42; 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(); } context.save(); context.globalAlpha = clamp(opacity, 0.1, 1) * 0.65; 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 filledPixels; } async function drawMappingView(version = state.mappingVersion) { const image = $("dicomPreview"); const canvas = $("mappingCanvas"); const volumeScene = state.volumeScene; canvas.style.display = state.mappingMode === "image" ? "none" : ""; if (state.mappingMode === "image") { setDicomLoading(false); resetMappingCanvas("单独影像模式未叠加 STL 分割"); return; } const visibleRecords = visibleModelRecords(); if (!image?.complete || !image.naturalWidth || !volumeScene || !visibleRecords.length) { if (!visibleRecords.length) setDicomLoading(false); else setDicomLoading(true, "等待 DICOM Base Layer", 32); resetMappingCanvas(visibleRecords.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件"); return; } setDicomLoading(true, "正在计算 STL 分割映射", 48); 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, }); const matrix = modelPoseMatrix(); 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(); for (const [index, record] of visibleRecords.entries()) { if (version !== state.mappingVersion) return; const position = record.mesh.geometry.attributes.position; if (!position) continue; const triangleCount = Math.floor(position.count / 3); const step = Math.max(1, Math.ceil(triangleCount / 260000)); const segments = []; for (let tri = 0; tri < triangleCount; tri += step) { tempA.fromBufferAttribute(position, tri * 3).applyMatrix4(matrix); tempB.fromBufferAttribute(position, tri * 3 + 1).applyMatrix4(matrix); tempC.fromBufferAttribute(position, tri * 3 + 2).applyMatrix4(matrix); 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, }); } const progress = 48 + ((index + 1) / Math.max(visibleRecords.length, 1)) * 48; const label = record.file.segment_name || record.file.file_name || `STL ${index + 1}`; setDicomLoading(true, `正在计算 ${label}`, progress); await new Promise((resolve) => window.setTimeout(resolve, 0)); } if (version !== state.mappingVersion) return; $("mappingStats").textContent = `${activeModules}/${visibleRecords.length} 构件 · ${segmentCount} 边 · ${filledPixels} px`; $("mappingLegend").innerHTML = modules.length ? modules.map((item) => `
${escapeHtml(item.name)} ID ${item.id} ${item.segments} 边 ${item.pixels} px
`).join("") : `
当前切片暂无可见构件
`; setDicomLoading(false); } async function buildDicomVolume(volume) { clearGroup(state.dicomGroup); state.dicomPlane = null; state.dicomPoints = null; state.dicomSlicePlanes = []; 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 frames = volume.frames || []; const centerPosition = indices.length ? indices.reduce((bestIndex, item, index) => { const bestDistance = Math.abs(Number(indices[bestIndex] ?? centerIndex) - centerIndex); const distance = Math.abs(Number(item ?? centerIndex) - centerIndex); return distance < bestDistance ? index : bestIndex; }, 0) : 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: 0x38bdf8, transparent: true, opacity: 0.24 }), ); rangeBox.position.z = (sliceToSceneZ(localStart) + sliceToSceneZ(localEnd)) / 2; state.dicomGroup.add(rangeBox); const centerFrame = (volume.frames || [])[centerPosition]; const fusionDetail = fusionDetailSettings(); if (centerFrame) { const texture = await loadTexture(centerFrame); const plane = new THREE.Mesh( new THREE.PlaneGeometry(width, height), new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: fusionDetail.planeOpacity, side: THREE.DoubleSide, depthWrite: false, depthTest: false, }), ); plane.position.z = sliceToSceneZ(centerIndex) + 0.006; plane.renderOrder = 80; state.dicomGroup.add(plane); state.dicomPlane = plane; } if (frames.length > 1) { const planeStep = Math.max(1, Math.ceil(frames.length / Math.max(1, fusionDetail.slicePlanes))); for (let frameIndex = 0; frameIndex < frames.length; frameIndex += planeStep) { if (frameIndex === centerPosition) continue; const texture = await loadTexture(frames[frameIndex]); const plane = new THREE.Mesh( new THREE.PlaneGeometry(width, height), new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: fusionDetail.sliceOpacity, side: THREE.DoubleSide, depthWrite: false, depthTest: true, }), ); plane.position.z = sliceToSceneZ(Number(indices[frameIndex] ?? centerIndex)); plane.renderOrder = 4; state.dicomGroup.add(plane); state.dicomSlicePlanes.push(plane); } } if (fusionDetail.pointOpacity > 0) { const pointPositions = []; const pointColors = []; for (const [index, frame] of frames.entries()) { const image = await loadImageData(frame, 180); const stride = Math.max(2, Math.round(Math.max(image.width, image.height) / fusionDetail.pointGrid)); 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 < fusionDetail.pointThreshold) 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: fusionDetail.pointSize, vertexColors: true, transparent: true, opacity: fusionDetail.pointOpacity, depthWrite: false, }), ); points.renderOrder = 2; state.dicomGroup.add(points); state.dicomPoints = points; } } state.baseModelScale = sceneScale; state.dicomSceneBox = new THREE.Box3( new THREE.Vector3(-width / 2, -height / 2, -depth / 2), new THREE.Vector3(width / 2, height / 2, depth / 2), ); } function updateBaseModelScaleFromBounds() { const size = state.modelLocalBounds?.size; if (!size) return; const maxModelSize = Math.max(Number(size.x) || 0, Number(size.y) || 0, Number(size.z) || 0, 1); const volumeSize = state.volumeScene ? Math.max(state.volumeScene.width, state.volumeScene.height, state.volumeScene.depth, 1) : 4.8; state.baseModelScale = (volumeSize / maxModelSize) * 0.92; } function addModelBoundsFrame(size) { if (!state.rawModelGroup || !size) return; removeModelBoundsFrame(); const frame = new THREE.LineSegments( new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)), new THREE.LineBasicMaterial({ color: 0xfacc15, transparent: true, opacity: 0.96, depthTest: false, depthWrite: false, toneMapped: false, }), ); frame.name = "model-bounds"; frame.renderOrder = 1000; state.rawModelGroup.add(frame); state.modelBoundsFrame = frame; } function removeModelBoundsFrame() { const frame = state.modelBoundsFrame; if (!frame || !state.rawModelGroup) return; state.rawModelGroup.remove(frame); frame.geometry?.dispose?.(); if (Array.isArray(frame.material)) frame.material.forEach((material) => material.dispose?.()); else frame.material?.dispose?.(); state.modelBoundsFrame = null; } async function buildStlModels(volume, onProgress = null) { const signature = stlSelectionSignature(); if (signature && signature !== state.stlSignature) { state.rawModelGroup.position.set(0, 0, 0); state.rawModelGroup.rotation.set(0, 0, 0); state.rawModelGroup.scale.set(1, 1, 1); clearGroup(state.rawModelGroup); state.modelMeshes = []; state.modelBoundsFrame = null; state.modelLocalBounds = null; state.stlSignature = signature; } const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite)); const files = state.stlFiles.filter((file) => ( state.selectedStlIds.has(Number(file.id)) && !loadedIds.has(Number(file.id)) )); if (signature && signature === state.stlSignature && !files.length && state.rawModelGroup?.children.length) { updateBaseModelScaleFromBounds(); if (!state.modelBoundsFrame && state.modelLocalBounds?.size) addModelBoundsFrame(state.modelLocalBounds.size); applyStlVisibility(); applyPose(); applyModelDetail(); scheduleMappingDraw(); onProgress?.(100); return; } if (!files.length && !state.modelMeshes.length) { applyPose(); resetMappingCanvas("当前没有可见 STL 构件"); onProgress?.(100); return; } const loader = new STLLoader(); const detail = modelDetailSettings(); const freshRecords = []; for (const [index, file] of files.entries()) { onProgress?.((index / Math.max(files.length, 1)) * 100); 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: detail.metalness, roughness: detail.roughness, transparent: true, opacity: detail.opacity, side: THREE.DoubleSide, depthWrite: detail.depthWrite, wireframe: detail.wireframe, }); 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); const record = { mesh, file, color: cssColor(Number(file.id) || index), index: Number(file.id) || index }; state.modelMeshes.push(record); freshRecords.push(record); onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100); } if (!state.modelLocalBounds && state.modelMeshes.length) { const rawBox = new THREE.Box3(); state.modelMeshes.forEach((record) => { record.mesh.geometry.computeBoundingBox?.(); if (record.mesh.geometry.boundingBox) rawBox.union(record.mesh.geometry.boundingBox); }); const center = new THREE.Vector3(); rawBox.getCenter(center); state.modelMeshes.forEach((record) => { const geometry = record.mesh.geometry; geometry.translate(-center.x, -center.y, -center.z); geometry.computeBoundingBox?.(); geometry.computeBoundingSphere?.(); geometry.computeVertexNormals?.(); }); state.modelLocalBounds = { center: { x: center.x, y: center.y, z: center.z }, size: { x: 1, y: 1, z: 1 }, }; } else if (state.modelLocalBounds && freshRecords.length) { const center = state.modelLocalBounds.center; freshRecords.forEach((record) => { const geometry = record.mesh.geometry; geometry.translate(-Number(center.x || 0), -Number(center.y || 0), -Number(center.z || 0)); geometry.computeBoundingBox?.(); geometry.computeBoundingSphere?.(); geometry.computeVertexNormals?.(); }); } const bbox = new THREE.Box3(); state.modelMeshes.forEach((record) => { record.mesh.geometry.computeBoundingBox?.(); if (record.mesh.geometry.boundingBox) bbox.union(record.mesh.geometry.boundingBox); }); const size = new THREE.Vector3(); if (!bbox.isEmpty()) bbox.getSize(size); if (state.modelLocalBounds) state.modelLocalBounds.size = { x: size.x, y: size.y, z: size.z }; updateBaseModelScaleFromBounds(); if (size.x || size.y || size.z) addModelBoundsFrame(size); applyStlVisibility(); applyPose(); scheduleMappingDraw(); onProgress?.(100); } 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 * (pose.flipX ? -1 : 1), scale * (pose.flipY ? -1 : 1), scale * (pose.flipZ ? -1 : 1), ); state.modelGroup.updateMatrixWorld(true); scheduleMappingDraw(); } function fitCamera() { if (!state.camera) return; applySceneViewPose(); const box = new THREE.Box3(); if (state.dicomGroup?.visible !== false) box.expandByObject(state.dicomGroup); const modelBox = visibleModelBox(); if (modelBox) box.union(modelBox); if (box.isEmpty()) { state.camera.position.set(0, -8.5, 4.4); state.camera.lookAt(0, 0, 0); state.controls?.target.set(0, 0, 0); state.controls?.update(); return; } 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.camera.lookAt(center); state.controls?.target.copy(center); if (state.axisGroup) { const axisScale = Math.max(size.x, size.y, size.z, 1) / 5.5; state.axisGroup.scale.setScalar(axisScale); state.axisGroup.position.set( center.x - size.x * 0.46, center.y - size.y * 0.46, center.z - size.z * 0.42, ); } state.controls?.update(); } function resetSceneView() { state.viewPose = { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 }; applySceneViewPose(); fitCamera(); } 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) { const modelBox = visibleModelBox(); if (!modelBox || !state.dicomSceneBox) { $("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。"; return; } if (!["x", "y", "z"].includes(axis)) return; const dicomSize = new THREE.Vector3(); const modelSize = new THREE.Vector3(); state.dicomSceneBox.getSize(dicomSize); modelBox.getSize(modelSize); const targetSize = Math.max(0.001, dicomSize[axis]); const currentSize = Math.max(0.001, modelSize[axis]); const currentScale = Math.max(0.001, Number(state.pose.scale) || 1); const next = Math.max(0.2, Math.min(4, currentScale * (targetSize / currentSize))); state.pose.scale = Number(next.toFixed(3)); state.pose.scaleX = 1; state.pose.scaleY = 1; state.pose.scaleZ = 1; renderPoseControls(); applyPose(); markDirty("pose"); setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok"); $("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体等比例拉伸,缩放 ${state.pose.scale}`; updateAutoPoseResult(); } function suggestBoneSelection() { const checkedIds = [...document.querySelectorAll("#autoBoneOptions input:checked")] .map((input) => Number(input.value)) .filter(Number.isFinite); const next = new Set(checkedIds); if (!next.size) { [...state.stlFiles] .sort((a, b) => stlLikelyScore(b) - stlLikelyScore(a)) .slice(0, Math.min(6, state.stlFiles.length)) .forEach((file) => next.add(Number(file.id))); } state.selectedStlIds = next; renderStl(); markDirty(); applyStlSelectionChange(); } function readAutoSettings() { const numericValue = (id, fallback) => { const value = Number($(id)?.value); return Number.isFinite(value) ? value : fallback; }; return { adjustable: { translateX: $("autoX")?.checked, translateY: $("autoY")?.checked, translateZ: $("autoZ")?.checked, scale: $("autoScale")?.checked, }, sampleSlices: Math.max(3, Math.min(60, Math.round(numericValue("autoSampleSlicesNumber", 9)))), boneReward: numericValue("autoBoneReward", 1), outsidePenalty: numericValue("autoOutsidePenalty", 0.1), movePenalty: numericValue("autoMovePenalty", 0), scalePenalty: numericValue("autoScalePenalty", 0), iterations: Math.max(1, Math.min(100, Math.round(numericValue("autoIterations", 30)))), candidates: Math.max(6, Math.min(96, Math.round(numericValue("autoCandidates", 36)))), }; } function poseDelta(from, to) { return { translateX: Number(((to.translateX || 0) - (from.translateX || 0)).toFixed(3)), translateY: Number(((to.translateY || 0) - (from.translateY || 0)).toFixed(3)), translateZ: Number(((to.translateZ || 0) - (from.translateZ || 0)).toFixed(3)), scale: Number(((to.scale || 1) / Math.max(0.001, from.scale || 1)).toFixed(3)), }; } function updateAutoPoseResult(before = state.lastAutoPose?.before || state.pose, after = state.pose) { const delta = poseDelta(before, after); const el = $("autoPoseResult"); if (!el) return; el.innerHTML = [ `平移 X ${delta.translateX}`, `平移 Y ${delta.translateY}`, `平移 Z ${delta.translateZ}`, `缩放 ${delta.scale}`, ].join(""); } function restoreAutoPose() { if (!state.lastAutoPose?.before) { $("autoResult").textContent = "暂无可退回的自动调整位姿。"; return; } state.pose = { ...state.lastAutoPose.before }; renderPoseControls(); applyPose(); fitCamera(); updateAutoPoseResult(state.lastAutoPose.after, state.pose); $("autoResult").textContent = "已退回到自动调整前的位姿。"; markDirty(); } function candidateScore(pose, settings = null) { if (!state.dicomSceneBox || !state.modelGroup || !visibleModelRecords().length) return -Infinity; const config = settings || readAutoSettings(); const current = { ...state.pose }; applyPose(pose); const modelBox = visibleModelBox(); applyPose(current); if (!modelBox) return -Infinity; 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 + Number(config.scalePenalty || 0)); const movePenalty = (Math.abs(pose.translateX || 0) + Math.abs(pose.translateY || 0) + Math.abs(pose.translateZ || 0)) * Number(config.movePenalty || 0); return (overlapVolume / modelVolume) * Number(config.boneReward || 1) - centerPenalty - scalePenalty - movePenalty - Number(config.outsidePenalty || 0) * 0.01; } function runAutoCoarse() { if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) { $("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。"; return; } const before = { ...state.pose }; const settings = readAutoSettings(); state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 }; renderPoseControls(); applyPose(); fitCamera(); state.autoResult = { score: candidateScore(state.pose, settings), pose: { ...state.pose }, evaluated: 1, settings }; state.lastAutoPose = { before, after: { ...state.pose } }; setAutoState("粗配准完成", "ok"); $("autoResult").textContent = `已按 DICOM 物理尺寸复位平移/缩放,score ${state.autoResult.score.toFixed(4)};采样 ${settings.sampleSlices} 张。`; updateAutoPoseResult(before, state.pose); markDirty(); } function runAutoFine() { if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) { $("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。"; return; } setAutoState("运行中", "warn"); $("autoResult").textContent = "正在进行几何重叠微调..."; const before = { ...state.pose }; const settings = readAutoSettings(); const adjustable = settings.adjustable; let best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" }; let evaluated = 1; const rounds = Array.from({ length: settings.iterations }, (_, index) => { const t = Math.max(0.18, 1 - index / Math.max(settings.iterations, 1)); return { move: 0.34 * t, scale: 0.12 * t }; }); 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.slice(0, settings.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, settings); evaluated += 1; if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` }; } } state.pose = { ...best.pose }; state.autoResult = { ...best, evaluated, settings }; state.lastAutoPose = { before, after: { ...state.pose } }; renderPoseControls(); applyPose(); fitCamera(); setAutoState("微调完成", "ok"); $("autoResult").textContent = `最佳候选:${best.mode};score ${best.score.toFixed(4)};评估 ${evaluated} 个候选;采样 ${settings.sampleSlices} 张。`; updateAutoPoseResult(before, state.pose); 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: modelDetailSettings().opacity, detail: state.modelDetail }])), 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, fusion_detail: state.fusionDetail, model_detail: state.modelDetail, auto_result: state.autoResult, }, notes: $("notes").value, }), }); state.registrationStatus = status; state.registrationSeriesUid = selectedSeries.series_uid; if (state.activeCase) { state.activeCase.registration_status = status; state.activeCase.series_instance_uid = selectedSeries.series_uid; state.activeCase.series_description = selectedSeries.description || ""; } const existing = state.cases.find((row) => sameCase(row, state.activeCase)); if (existing) { existing.registration_status = status; existing.series_instance_uid = selectedSeries.series_uid; existing.series_description = selectedSeries.description || ""; } resetDirty(); renderCases(); renderSeries(); 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 setWindowMode(mode, reload = true) { state.windowMode = mode || "default"; document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item.dataset.window === state.windowMode)); document.querySelectorAll("[data-map-window]").forEach((item) => item.classList.toggle("active", item.dataset.mapWindow === state.windowMode)); if (reload) loadFusion(); } function setMappingMode(mode) { state.mappingMode = mode === "image" ? "image" : "result"; document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode)); renderDicomAnnotation(); scheduleMappingDraw(); } 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 loadSettings(); await loadCases(false); }); $("settingsBtn").addEventListener("click", async () => { $("settingsPanel").classList.remove("hidden"); await loadSettings(); }); $("settingsCloseBtn").addEventListener("click", () => $("settingsPanel").classList.add("hidden")); $("saveSettingsBtn").addEventListener("click", saveSettings); $("manualCacheRefreshBtn").addEventListener("click", refreshCacheNow); $("relationBtn").addEventListener("click", () => openLinkedApp("relation")); $("viewerBtn").addEventListener("click", () => openLinkedApp("viewer")); $("fitBtn").addEventListener("click", resetSceneView); $("saveBtn").addEventListener("click", () => saveRegistration()); $("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered")); $("notes").addEventListener("input", () => markDirty("notes")); $("selectAllStlBtn").addEventListener("click", selectAllStl); $("invertStlBtn").addEventListener("click", invertStlSelection); $("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")); $("openAutoModalBtn").addEventListener("click", () => { $("autoSettingsPanel").scrollIntoView({ block: "nearest", behavior: "smooth" }); }); $("suggestBoneBtn").addEventListener("click", suggestBoneSelection); $("autoCoarseBtn").addEventListener("click", runAutoCoarse); $("autoFineBtn").addEventListener("click", runAutoFine); $("savePoseBtn").addEventListener("click", () => saveRegistration()); $("saveIterateBtn").addEventListener("click", async () => { await saveRegistration(); runAutoFine(); }); $("restorePoseBtn").addEventListener("click", restoreAutoPose); const syncSampleSlices = (source) => { const range = $("autoSampleSlices"); const number = $("autoSampleSlicesNumber"); const value = Math.max(3, Math.min(60, Number(source.value || 9))); range.value = value; number.value = value; }; $("autoSampleSlices").addEventListener("input", () => syncSampleSlices($("autoSampleSlices"))); $("autoSampleSlicesNumber").addEventListener("input", () => syncSampleSlices($("autoSampleSlicesNumber"))); $("autoDefaultsBtn").addEventListener("click", () => { $("autoSampleSlices").value = 9; $("autoSampleSlicesNumber").value = 9; $("autoIterations").value = 30; }); $("dicomPreview").addEventListener("load", () => { setDicomLoading(true, "正在计算 STL 分割映射", 42); scheduleMappingDraw(); }); $("dicomPreview").addEventListener("error", () => { setDicomLoading(false); resetMappingCanvas("DICOM Base Layer 加载失败"); }); $("mappingResetBtn").addEventListener("click", () => { state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 }; applyMappingTransform(); }); $("mappingRotateLeftBtn").addEventListener("click", () => { state.mappingViewport.rotation = (Number(state.mappingViewport.rotation) || 0) - 90; applyMappingTransform(); }); $("mappingRotateRightBtn").addEventListener("click", () => { state.mappingViewport.rotation = (Number(state.mappingViewport.rotation) || 0) + 90; 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); }); $("caseList").addEventListener("scroll", () => { const list = $("caseList"); if (list.scrollTop + list.clientHeight >= list.scrollHeight - 180) { loadMoreCases(); } }); 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(".filter[data-model-filter]").forEach((button) => { button.addEventListener("click", async () => { state.modelFilter = button.dataset.modelFilter || ""; document.querySelectorAll(".filter[data-model-filter]").forEach((item) => item.classList.toggle("active", item === button)); await loadCases(false); }); }); document.querySelectorAll("[data-window]").forEach((button) => { button.addEventListener("click", () => setWindowMode(button.dataset.window || "default")); }); document.querySelectorAll("[data-map-window]").forEach((button) => { button.addEventListener("click", () => setWindowMode(button.dataset.mapWindow || "default")); }); document.querySelectorAll("[data-map-mode]").forEach((button) => { button.addEventListener("click", () => setMappingMode(button.dataset.mapMode || "result")); }); 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(); }); }); document.querySelectorAll("[data-fusion-detail]").forEach((button) => { button.addEventListener("click", async () => { const nextDetail = button.dataset.fusionDetail || "low"; if (nextDetail === state.fusionDetail) return; state.fusionDetail = nextDetail; document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item === button)); applyFusionDetail(); markDirty("display"); await loadFusion(); }); }); document.querySelectorAll("[data-model-detail]").forEach((button) => { button.addEventListener("click", () => { state.modelDetail = button.dataset.modelDetail || "standard"; document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item === button)); applyModelDetail(); markDirty("display"); }); }); $("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(); }); const updateRange = () => { state.sliceRangeStart = Number($("sliceRangeStart").value || 0); state.sliceRangeEnd = Number($("sliceRangeEnd").value || 0); updateSliceControl(); }; $("sliceRangeStart").addEventListener("input", updateRange); $("sliceRangeEnd").addEventListener("input", updateRange); $("sliceRangeStart").addEventListener("change", () => loadFusion()); $("sliceRangeEnd").addEventListener("change", () => loadFusion()); } wireEvents(); if (state.token) { bootstrap(); } else { loginVisible(true); }