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: "上腹部", 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 FUSION_BASE_EXTENT = 4.6; const AXIS_INSET_LENGTH = 17; const DEFAULT_AXIS_PROJECTION = { x: { dx: AXIS_INSET_LENGTH, dy: 0, opacity: 0.95 }, y: { dx: -10, dy: 10, opacity: 0.82 }, z: { dx: 0, dy: -AXIS_INSET_LENGTH, opacity: 0.95 }, }; const POSE_SIGNATURE_KEYS = [ "rotateX", "rotateY", "rotateZ", "translateX", "translateY", "translateZ", "scale", "flipX", "flipY", "flipZ", ]; const AUTO_PROGRESS_STEPS = [ { id: "save", label: "保存" }, { id: "bone", label: "骨窗" }, { id: "sample", label: "采样" }, { id: "score", label: "评分" }, { id: "apply", label: "应用" }, ]; const STL_DISPLAY_MODES = ["hidden", "solid", "translucent"]; const STL_DISPLAY_LABELS = { hidden: "取消显示", solid: "实体显示", translucent: "半透明", }; const LIVER_STL_GROUPS = [ { key: "liver_segments", label: "肝段", order: 10 }, { key: "liver_body", label: "肝脏", order: 20 }, { key: "liver_vessels", label: "肝血管", order: 30 }, { key: "liver_lesions", label: "肿瘤和囊肿", order: 40 }, { key: "body_surface", label: "骨骼皮肤", order: 50 }, { key: "other", label: "其他", order: 90 }, ]; const LIVER_STL_GROUP_LOOKUP = Object.fromEntries(LIVER_STL_GROUPS.map((group) => [group.key, group])); const LIVER_STL_LABELS = { bile_duct: "胆管", cholecyst: "胆囊", gallbladder: "胆囊", hipbone: "髋骨", liver: "肝脏整体", liver_artery: "肝动脉", liver_left: "左肝叶", liver_right: "右肝叶", liver_vein: "肝静脉", pancreas: "胰腺", portal_vein: "门静脉", rib: "肋骨", ribs: "肋骨", sacrum: "骶骨", skin: "皮肤", spleen: "脾脏", sternum: "胸骨", vertebra: "椎骨", vertebrae: "椎骨", }; const ROMAN_NUMERALS = { 1: "I", 2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI", 7: "VII", 8: "VIII", }; 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(), stlDisplayModes: {}, collapsedStlFamilies: new Set(), algorithmModel: "", registrationStatus: "unregistered", pose: { ...DEFAULT_POSE }, windowMode: "default", fusionMode: "fusion", fusionDetail: "high", modelDetail: "ultra", mappingMode: "result", sliceIndex: 0, sliceRangeStart: 0, sliceRangeEnd: 0, dicomPreviewTimer: 0, autoResult: null, lastAutoPose: null, autoDeltaBaseline: null, autoFineRunning: false, poseControlsLocked: false, collapsedPoseSections: { manual: false, auto: false }, 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, axisProjectionSignature: "", 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 getDicomDisplaySliceNumber(sliceIndex, totalSlices) { const total = Math.max(Math.round(Number(totalSlices) || 0), 0); if (!total) return 0; return total - clamp(Math.round(Number(sliceIndex) || 0), 0, total - 1); } function getDicomDisplayRange(startIndex, endIndex, totalSlices) { const first = getDicomDisplaySliceNumber(startIndex, totalSlices); const second = getDicomDisplaySliceNumber(endIndex, totalSlices); return { start: Math.min(first, second), end: Math.max(first, second), }; } function setFusionWebglError(message = "") { const overlay = $("fusionWebglError"); if (!overlay) return; overlay.classList.toggle("hidden", !message); const detail = overlay.querySelector("p"); if (detail && message) detail.textContent = message; } 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"); if (reason === "pose") updateAutoPoseResult(); } 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"); resetAutoDeltaBaseline(); } 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(); setFusionWebglError(""); } catch (error) { initGeometryFallback(); $("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`; setFusionWebglError("三维融合视图暂不可用,请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与映射功能仍可继续使用。"); 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) => stlMode(record.file) !== "hidden" && record.mesh?.visible !== false); } function modelPoseMatrix() { if (!state.modelGroup) return new THREE.Matrix4(); state.modelGroup.updateMatrix(); return state.modelGroup.matrix.clone(); } function visibleModelBox(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 autoReferenceRecords(settings = null) { const visibleRecords = visibleModelRecords(); const referenceIds = new Set((settings?.referenceIds || []).map((id) => Number(id))); if (!referenceIds.size) return visibleRecords; const referenceRecords = visibleRecords.filter((record) => referenceIds.has(Number(record.file?.id))); return referenceRecords.length ? referenceRecords : visibleRecords; } 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.stlDisplayModes = {}; state.collapsedStlFamilies = 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 = registration.model_reference?.fusion_detail || state.fusionDetail || "high"; state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "ultra"; state.autoResult = null; state.lastAutoPose = null; state.autoDeltaBaseline = null; state.sliceIndex = 0; $("notes").value = registration.notes || ""; const savedIds = new Set((registration.selected_stl_files || []).map((item) => Number(item.id)).filter(Number.isFinite)); state.selectedStlIds = savedIds.size ? savedIds : pickDefaultStlIds(state.stlFiles); state.stlDisplayModes = initStlDisplayModes(state.stlFiles, state.selectedStlIds, registration.module_styles || {}); state.collapsedStlFamilies = new Set(); 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 normalizedStlId(fileOrId) { const id = typeof fileOrId === "object" ? Number(fileOrId?.id) : Number(fileOrId); return Number.isFinite(id) ? id : null; } function validStlDisplayMode(mode) { return STL_DISPLAY_MODES.includes(mode) ? mode : ""; } function stlMode(fileOrId) { const id = normalizedStlId(fileOrId); if (id == null || !state.selectedStlIds.has(id)) return "hidden"; return validStlDisplayMode(state.stlDisplayModes[id]) || "translucent"; } function stlFamilyLabel(file) { return String(file.family || "未分类"); } function normalizedStlText(file) { return [ file.family, file.segment_name, file.file_name, file.category, ].map((value) => String(value || "").toLowerCase()).join(" "); } function normalizedStlName(file) { return String(file.segment_name || file.family || file.file_name || "") .replace(/\.stl$/i, "") .toLowerCase(); } function stlSegmentNumber(file) { const text = normalizedStlText(file); const match = text.match(/(?:segment[_\s-]*s?|肝段)\s*([0-9]+)/i) || text.match(/_s([0-9]+)/i); return match ? Number(match[1]) : 999; } function lesionNumber(file) { const text = normalizedStlText(file); const match = text.match(/(?:^|[_\s-])(?:liver[_\s-]*)?(?:tumor|lesion|cyst)[_\s-]*([0-9]+)/i) || text.match(/(?:肿瘤|囊肿)[_\s-]*([0-9]+)/i); return match ? Number(match[1]) : 99; } function isTumorStl(file, category = String(file.category || "")) { const text = normalizedStlText(file); return /(?:^|[_\s-])(?:liver[_\s-]*)?(?:tumor|lesion)(?:[_\s-]|\d|$)/i.test(text) || category.includes("肿瘤"); } function isCystStl(file, category = String(file.category || "")) { const text = normalizedStlText(file); return /(?:^|[_\s-])(?:liver[_\s-]*)?cyst(?:[_\s-]|\d|$)/i.test(text) || category.includes("囊肿"); } function numberedLesionLabel(file, baseLabel) { const number = lesionNumber(file); return number < 99 ? `${baseLabel} ${number}` : baseLabel; } function liverStlPresentation(file) { const text = normalizedStlText(file); const name = normalizedStlName(file); const category = String(file.category || ""); let groupKey = "other"; let itemOrder = 900; let label = LIVER_STL_LABELS[name] || file.segment_name || file.family || file.file_name; if (text.includes("liver_segment") || category.includes("肝段")) { const segment = stlSegmentNumber(file); groupKey = "liver_segments"; itemOrder = segment; label = `肝段 ${ROMAN_NUMERALS[segment] || segment}`; } else if (["liver", "liver_left", "liver_right"].includes(name) || category.includes("肝脏主体")) { groupKey = "liver_body"; itemOrder = { liver: 1, liver_left: 2, liver_right: 3 }[name] || 99; } else if (["liver_artery", "liver_vein", "portal_vein"].includes(name)) { groupKey = "liver_vessels"; itemOrder = { liver_artery: 1, liver_vein: 2, portal_vein: 3 }[name] || 99; } else if (isTumorStl(file, category) || isCystStl(file, category)) { const tumor = isTumorStl(file, category); groupKey = "liver_lesions"; itemOrder = (tumor ? 1 : 20) + lesionNumber(file) / 100; label = tumor ? numberedLesionLabel(file, "肝脏肿瘤") : numberedLesionLabel(file, "肝囊肿"); } else if (category.includes("体表骨骼") || ["hipbone", "rib", "ribs", "sacrum", "skin", "sternum", "vertebra", "vertebrae"].includes(name)) { groupKey = "body_surface"; itemOrder = { vertebrae: 1, vertebra: 1, rib: 2, ribs: 2, sternum: 3, sacrum: 4, hipbone: 5, skin: 6 }[name] || 99; } else { itemOrder = { bile_duct: 1, cholecyst: 2, gallbladder: 2, pancreas: 3, spleen: 4 }[name] || 99; } const group = LIVER_STL_GROUP_LOOKUP[groupKey] || LIVER_STL_GROUP_LOOKUP.other; return { key: group.key, label: group.label, order: group.order, itemOrder, itemLabel: label, hierarchy: true, }; } function stlPresentation(file) { if (isLiverBiliaryModel(file.algorithm_model || state.algorithmModel)) return liverStlPresentation(file); const label = stlFamilyLabel(file); return { key: label, label, order: 100, itemOrder: 100, itemLabel: file.segment_name || file.file_name, hierarchy: false, }; } function compactStlName(file) { return String(file.segment_name || file.family || file.file_name || `STL_${file.id || ""}`) .replace(/\.stl$/i, ""); } function stlFileIndex(file) { const id = normalizedStlId(file); const index = state.stlFiles.findIndex((item) => normalizedStlId(item) === id); return index >= 0 ? index : 0; } function stlColor(file, fallbackIndex = 0) { const index = stlFileIndex(file); return cssColor(index >= 0 ? index : fallbackIndex); } function syncStlDisplayModesForSelection(defaultMode = "translucent") { const next = {}; state.stlFiles.forEach((file) => { const id = normalizedStlId(file); if (id == null || !state.selectedStlIds.has(id)) return; next[id] = validStlDisplayMode(state.stlDisplayModes[id]) || defaultMode; }); state.stlDisplayModes = next; } function initStlDisplayModes(files, selectedIds, moduleStyles = {}) { const modes = {}; files.forEach((file) => { const id = normalizedStlId(file); if (id == null || !selectedIds.has(id)) return; const style = moduleStyles?.[file.file_name] || moduleStyles?.[file.segment_name] || {}; const savedMode = validStlDisplayMode(style.mode); if (savedMode && savedMode !== "hidden") { modes[id] = savedMode; return; } modes[id] = Number(style.opacity) >= 0.95 ? "solid" : "translucent"; }); return modes; } async function setStlMode(fileOrId, mode) { const id = normalizedStlId(fileOrId); const nextMode = validStlDisplayMode(mode); if (id == null || !nextMode) return; if (nextMode === "hidden") { state.selectedStlIds.delete(id); delete state.stlDisplayModes[id]; } else { state.selectedStlIds.add(id); state.stlDisplayModes[id] = nextMode; } await applyStlSelectionChange(); } async function cycleStlMode(file) { const next = { hidden: "solid", solid: "translucent", translucent: "hidden", }[stlMode(file)] || "translucent"; await setStlMode(file, next); } async function setAllStlMode(mode) { const nextMode = validStlDisplayMode(mode); if (!state.stlFiles.length || !nextMode) return; if (nextMode === "hidden") { state.selectedStlIds = new Set(); state.stlDisplayModes = {}; } else { state.selectedStlIds = new Set(state.stlFiles.map((file) => normalizedStlId(file)).filter((id) => id != null)); state.stlDisplayModes = {}; state.selectedStlIds.forEach((id) => { state.stlDisplayModes[id] = nextMode; }); } await applyStlSelectionChange(); } function aggregateStlMode() { if (!state.stlFiles.length || !state.selectedStlIds.size) return "hidden"; const modes = state.stlFiles.map((file) => stlMode(file)); if (modes.every((mode) => mode === "solid")) return "solid"; if (modes.every((mode) => mode === "translucent")) return "translucent"; if (modes.every((mode) => mode === "hidden")) return "hidden"; return "mixed"; } function renderStlVisibilityAction() { const button = $("cycleAllStlModeBtn"); if (!button) return; const mode = aggregateStlMode(); button.classList.remove("hidden-eye", "solid-eye", "translucent-eye", "mixed-eye"); button.classList.add(`${mode === "mixed" ? "mixed" : mode}-eye`); const label = { hidden: "当前全部隐藏,点击切换为全部实体", solid: "当前全部实体,点击切换为全部半透明", translucent: "当前全部半透明,点击切换为全部隐藏", mixed: "当前为混合显示,点击切换为全部实体", }[mode]; button.title = label; button.setAttribute("aria-label", label); } async function cycleAllStlMode() { const mode = aggregateStlMode(); const next = mode === "solid" ? "translucent" : mode === "translucent" ? "hidden" : "solid"; await setAllStlMode(next); } 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 ? `${state.selectedStlIds.size}/${state.stlFiles.length}` : "0"; renderStlVisibilityAction(); const models = [...new Set(state.stlFiles.map((item) => item.algorithm_model || state.algorithmModel || "未指定模型"))]; $("modelRail").innerHTML = models.map((model) => `${escapeHtml(model)}`).join(""); if (!state.stlFiles.length) { $("stlList").innerHTML = `
当前算法模型没有 STL 文件
`; renderAutoBoneOptions(); return; } const grouped = new Map(); state.stlFiles.forEach((file, index) => { const presentation = stlPresentation(file); if (!grouped.has(presentation.key)) { grouped.set(presentation.key, { key: presentation.key, label: presentation.label, order: presentation.order, hierarchy: presentation.hierarchy, rows: [], }); } grouped.get(presentation.key).rows.push({ file, index, presentation }); }); $("stlList").innerHTML = [...grouped.values()] .sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)) .map((group) => { const rows = group.rows.sort((a, b) => ( a.presentation.itemOrder - b.presentation.itemOrder || String(a.presentation.itemLabel).localeCompare(String(b.presentation.itemLabel)) || a.index - b.index )); const visibleCount = rows.filter(({ file }) => stlMode(file) !== "hidden").length; const collapsed = state.collapsedStlFamilies.has(group.key); return `
${rows.map(({ file, index, presentation }) => { const id = normalizedStlId(file); const mode = stlMode(file); const color = stlColor(file, index); return ` `; }).join("")}
`; }).join(""); $("stlList").querySelectorAll("[data-family-toggle]").forEach((button) => { button.addEventListener("click", () => { const groupKey = button.dataset.familyToggle; if (state.collapsedStlFamilies.has(groupKey)) state.collapsedStlFamilies.delete(groupKey); else state.collapsedStlFamilies.add(groupKey); renderStl(); }); }); $("stlList").querySelectorAll("[data-stl-mode]").forEach((button) => { button.addEventListener("click", async () => { const id = Number(button.dataset.stlMode); const file = state.stlFiles.find((item) => normalizedStlId(item) === id); if (file) await cycleStlMode(file); }); }); renderAutoBoneOptions(); } async function selectAllStl() { await setAllStlMode("translucent"); } async function invertStlSelection() { if (!state.stlFiles.length) return; const nextSelected = new Set(); const nextModes = {}; state.stlFiles.forEach((file) => { const id = normalizedStlId(file); if (id != null && !state.selectedStlIds.has(id)) { nextSelected.add(id); nextModes[id] = "translucent"; } }); state.selectedStlIds = nextSelected; state.stlDisplayModes = nextModes; await applyStlSelectionChange(); } async function applyStlSelectionChange() { markDirty("stl"); syncStlDisplayModesForSelection(); 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) => { if (state.poseControlsLocked) return; 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", () => { if (state.poseControlsLocked) return; const key = button.dataset.flip; state.pose[key] = !state.pose[key]; renderPoseControls(); applyPose(); markDirty(); }); }); updateAutoPoseResult(state.pose, state.pose); applyPoseLockState(); } 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 (state.poseControlsLocked || button.disabled) return; 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 (state.poseControlsLocked) return; 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(); updateStretchButtons(); applyPoseLockState(); } function isRightAngleRotation(value) { const normalized = ((Number(value) || 0) % 90 + 90) % 90; return normalized < 0.001 || Math.abs(normalized - 90) < 0.001; } function canStretchByAxis() { return ["rotateX", "rotateY", "rotateZ"].every((key) => isRightAngleRotation(state.pose[key])); } function updateStretchButtons() { const enabled = canStretchByAxis() && !state.poseControlsLocked; ["x", "y", "z"].forEach((axis) => { const button = $(`stretch${axis.toUpperCase()}Btn`); if (!button) return; button.disabled = !enabled; button.title = enabled ? `按 ${axis.toUpperCase()} 方向自动等比例拉伸模型` : state.poseControlsLocked ? "自动迭代运行中,暂不可调整位姿" : "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可用"; }); } function renderPoseSectionCollapse() { const manualCollapsed = Boolean(state.collapsedPoseSections.manual); const autoCollapsed = Boolean(state.collapsedPoseSections.auto); $("poseManualBody")?.classList.toggle("collapsed", manualCollapsed); $("poseAutoBody")?.classList.toggle("collapsed", autoCollapsed); $("poseManualToggle")?.classList.toggle("collapsed", manualCollapsed); $("poseAutoToggle")?.classList.toggle("collapsed", autoCollapsed); } function applyPoseLockState() { const locked = Boolean(state.poseControlsLocked); const selectors = [ "#poseGrid input", "#poseGrid button", "[data-flip]", "#resetRotationBtn", "#resetTransformBtn", "#resetFlipBtn", "#openAutoModalBtn", "#autoSettingsPanel input", "#autoSettingsPanel button", "#savePoseBtn", "#saveIterateBtn", "#restorePoseBtn", "#saveBtn", "#statusBtn", "#autoCoarseBtn", "#autoFineBtn", ]; selectors.forEach((selector) => { document.querySelectorAll(selector).forEach((element) => { element.disabled = locked; }); }); document.querySelectorAll(".pose-control, .flip-row, .pose-actions, .auto-settings-panel").forEach((element) => { element.classList.toggle("locked", locked); }); updateStretchButtons(); } function setPoseEditingLocked(locked) { state.poseControlsLocked = Boolean(locked); if (locked) clearNudgeTimers(); applyPoseLockState(); } 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: true }); renderer.setClearColor(0x030712, 1); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.localClippingEnabled = true; viewport.appendChild(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x030712); const camera = new THREE.PerspectiveCamera(45, 1, 0.05, 1000); camera.position.set(0, -6.2, 4.6); 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.72); const keyLight = new THREE.DirectionalLight(0xffffff, 1.1); keyLight.position.set(4, -5, 5); const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.55); fillLight.position.set(-4, 3, 2); scene.add(ambient, keyLight, fillLight); const dicomGroup = new THREE.Group(); const modelGroup = new THREE.Group(); const rawModelGroup = new THREE.Group(); const fusionRoot = new THREE.Group(); modelGroup.add(rawModelGroup); fusionRoot.add(dicomGroup, modelGroup); scene.add(fusionRoot); Object.assign(state, { renderer, camera, scene, controls, fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup: null, 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(); updateFusionAxisInset(); renderer.render(scene, camera); }; animate(); wireFusionSceneDrag(renderer.domElement); updateFusionAxisInset(DEFAULT_AXIS_PROJECTION); } function axisProjectionSignature(projection) { return ["x", "y", "z"] .map((key) => { const item = projection[key] || DEFAULT_AXIS_PROJECTION[key]; return `${Math.round(item.dx * 10)},${Math.round(item.dy * 10)},${Math.round(item.opacity * 100)}`; }) .join("|"); } function projectModelAxisDirections(camera, object) { const origin = object.getWorldPosition(new THREE.Vector3()); const originProjected = origin.clone().project(camera); const quaternion = object.getWorldQuaternion(new THREE.Quaternion()); const axisDirections = { x: new THREE.Vector3(1, 0, 0), y: new THREE.Vector3(0, 1, 0), z: new THREE.Vector3(0, 0, 1), }; const projectAxis = (direction) => { const end = origin.clone().add(direction.applyQuaternion(quaternion).normalize().multiplyScalar(0.72)); const endProjected = end.project(camera); const dx = endProjected.x - originProjected.x; const dy = originProjected.y - endProjected.y; const magnitude = Math.hypot(dx, dy); if (magnitude < 0.0001) return { dx: 0, dy: -5, opacity: 0.5 }; return { dx: (dx / magnitude) * AXIS_INSET_LENGTH, dy: (dy / magnitude) * AXIS_INSET_LENGTH, opacity: endProjected.z < originProjected.z ? 1 : 0.58, }; }; return { x: projectAxis(axisDirections.x), y: projectAxis(axisDirections.y), z: projectAxis(axisDirections.z), }; } function updateFusionAxisInset(projection = null) { if (!projection) { if (!state.camera || !state.modelGroup) projection = DEFAULT_AXIS_PROJECTION; else { state.fusionRoot?.updateMatrixWorld(true); state.modelGroup.updateMatrixWorld(true); projection = projectModelAxisDirections(state.camera, state.modelGroup); } } const signature = axisProjectionSignature(projection); if (state.axisProjectionSignature === signature) return; state.axisProjectionSignature = signature; const origin = { x: 25, y: 31 }; const items = { x: { line: $("fusionAxisXLine"), text: $("fusionAxisXText"), group: $("fusionAxisX") }, y: { line: $("fusionAxisYLine"), text: $("fusionAxisYText"), group: $("fusionAxisY") }, z: { line: $("fusionAxisZLine"), text: $("fusionAxisZText"), group: $("fusionAxisZ") }, }; Object.entries(items).forEach(([key, item]) => { const vector = projection[key] || DEFAULT_AXIS_PROJECTION[key]; const endX = origin.x + vector.dx; const endY = origin.y + vector.dy; item.group?.setAttribute("opacity", String(vector.opacity)); item.line?.setAttribute("x2", String(endX)); item.line?.setAttribute("y2", String(endY)); item.text?.setAttribute("x", String(endX + (vector.dx >= 0 ? 4 : -4))); item.text?.setAttribute("y", String(endY + (vector.dy >= 0 ? 6 : -3))); item.text?.setAttribute("text-anchor", vector.dx >= 0 ? "start" : "end"); }); } 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, axisGroup: null }); } 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"; updateFusionAxisInset(DEFAULT_AXIS_PROJECTION); 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; if (!volume) { $("fusionMeta").textContent = `DICOM - · STL ${selectedCount} 个`; return; } const displayRange = getDicomDisplayRange(volume.start, volume.end, volume.total); $("fusionMeta").textContent = `DICOM ${displayRange.start}-${displayRange.end}/${volume.total} · STL ${selectedCount} 个`; } function fusionDetailSettings() { return { low: { planeOpacity: 0.42, sliceOpacity: 0.045, boxOpacity: 0.2, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 }, medium: { planeOpacity: 0.56, sliceOpacity: 0.075, boxOpacity: 0.26, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 }, high: { planeOpacity: 0.68, sliceOpacity: 0.11, boxOpacity: 0.32, slicePlanes: 72, pointOpacity: 0, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 1024 }, }[state.fusionDetail] || { planeOpacity: 0.42, sliceOpacity: 0.045, boxOpacity: 0.2, 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 = plane.userData?.rangeBoundary ? detail.planeOpacity : detail.sliceOpacity; plane.material.needsUpdate = true; }); } function applyModelDetail() { const detail = modelDetailSettings(); state.modelMeshes.forEach((record) => { const material = record.mesh.material; if (!material) return; const mode = stlMode(record.file); const opacity = mode === "solid" ? 1 : Math.min(0.52, detail.opacity); material.opacity = opacity; material.transparent = opacity < 1; material.depthWrite = mode === "solid" ? true : false; material.wireframe = detail.wireframe; material.metalness = detail.metalness; material.roughness = detail.roughness; material.needsUpdate = true; }); scheduleMappingDraw(); } function applyStlVisibility() { state.modelMeshes.forEach((record) => { record.mesh.visible = stlMode(record.file) !== "hidden"; }); 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; } $("fusionStatus").textContent = "正在构建三维融合场景..."; 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 loadDicomFusionTexture(url) { const image = await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = url; }); const width = image.naturalWidth || image.width || 1; const height = image.naturalHeight || image.height || 1; 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); const imageData = context.getImageData(0, 0, width, height); for (let index = 0; index < imageData.data.length; index += 4) { const value = imageData.data[index]; imageData.data[index + 1] = value; imageData.data[index + 2] = value; imageData.data[index + 3] = value > 4 ? Math.min(235, 42 + Math.round(value * 0.76)) : 0; } context.putImageData(imageData, 0, 0); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.generateMipmaps = false; texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1); texture.needsUpdate = true; return texture; } 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(1, Math.min(2.4, Math.max(width, height) * 0.002)); } 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) * 220); const maskCanvas = document.createElement("canvas"); maskCanvas.width = width; maskCanvas.height = height; const maskContext = maskCanvas.getContext("2d"); if (!maskContext) return 0; maskContext.imageSmoothingEnabled = false; 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, 1); filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha); maskContext.putImageData(maskData, 0, 0); context.imageSmoothingEnabled = false; context.drawImage(maskCanvas, 0, 0); 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.imageSmoothingEnabled = false; 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 = 1; 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 = FUSION_BASE_EXTENT / 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 fusionDetail = fusionDetailSettings(); const rangeStart = clamp(Number(volume.start ?? indices[0] ?? centerIndex) || 0, 0, total - 1); const rangeEnd = clamp(Number(volume.end ?? indices[indices.length - 1] ?? centerIndex) || 0, 0, total - 1); const boundaryIndices = new Set([Math.min(rangeStart, rangeEnd), Math.max(rangeStart, rangeEnd)]); 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: fusionDetail.boxOpacity, 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 planeGeometry = new THREE.PlaneGeometry(width, height); for (let frameIndex = 0; frameIndex < frames.length; frameIndex += 1) { const dicomIndex = clamp(Number(indices[frameIndex] ?? centerIndex) || 0, 0, total - 1); const isBoundary = boundaryIndices.has(dicomIndex) || frames.length === 1; const texture = await loadDicomFusionTexture(frames[frameIndex]); const plane = new THREE.Mesh( planeGeometry, new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: isBoundary ? fusionDetail.planeOpacity : fusionDetail.sliceOpacity, side: THREE.DoubleSide, depthWrite: false, depthTest: true, }), ); plane.position.z = sliceToSceneZ(dicomIndex) + (isBoundary ? 0.006 : 0); plane.renderOrder = isBoundary ? 16 : 4; plane.userData.rangeBoundary = isBoundary; state.dicomGroup.add(plane); state.dicomSlicePlanes.push(plane); if (isBoundary && !state.dicomPlane) state.dicomPlane = 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) : FUSION_BASE_EXTENT; 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.72, }), ); 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 color = stlColor(file, index); const material = new THREE.MeshStandardMaterial({ color, 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 = color; state.rawModelGroup.add(mesh); const record = { mesh, file, color, 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(); applyModelDetail(); applyPose(); scheduleMappingDraw(); onProgress?.(100); } function applyPose(poseInput = state.pose, options = {}) { 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); if (options.scheduleMapping !== false) scheduleMappingDraw(); } function fitCamera() { if (!state.camera) return; applySceneViewPose(); state.camera.position.set(0, -6.2, 4.6); state.camera.up.set(0, 0, 1); state.camera.lookAt(0, 0, 0); state.controls?.target.set(0, 0, 0); state.controls?.update(); updateFusionAxisInset(DEFAULT_AXIS_PROJECTION); } function resetSceneView() { state.viewPose = { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 }; applySceneViewPose(); fitCamera(); $("fusionStatus").textContent = state.volumeMeta ? "三维融合视角已复位" : "等待选择 DICOM 与 STL"; } function resetPose(kind) { if (state.poseControlsLocked) return; if (kind === "rotation") { state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 }; } else if (kind === "transform") { state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1, scaleX: 1, scaleY: 1, scaleZ: 1 }; } else if (kind === "flip") { state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false }; } else { state.pose = { ...DEFAULT_POSE }; } renderPoseControls(); applyPose(); markDirty(); } function stretchAxis(axis) { if (state.poseControlsLocked) { $("autoResult").textContent = "自动迭代运行中,暂不可调整位姿。"; return; } if (!canStretchByAxis()) { const message = "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可使用 XYZ 拉伸。"; setAutoState("XYZ 拉伸不可用", "warn"); $("autoResult").textContent = message; updateStretchButtons(); return; } 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; }; const referenceIds = [...document.querySelectorAll("#autoBoneOptions input:checked")] .map((input) => Number(input.value)) .filter(Number.isFinite); return { adjustable: { translateX: $("autoX")?.checked, translateY: $("autoY")?.checked, translateZ: $("autoZ")?.checked, scale: $("autoScale")?.checked, }, referenceIds, 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 resetAutoSettings() { if (state.poseControlsLocked) return; const checkedDefaults = { autoX: true, autoY: true, autoZ: true, autoScale: true, }; Object.entries(checkedDefaults).forEach(([id, value]) => { const input = $(id); if (input) input.checked = value; }); const valueDefaults = { autoSampleSlices: 9, autoSampleSlicesNumber: 9, autoBoneReward: 1, autoOutsidePenalty: 0.1, autoMovePenalty: 0, autoScalePenalty: 0, autoIterations: 30, autoCandidates: 36, }; Object.entries(valueDefaults).forEach(([id, value]) => { const input = $(id); if (input) input.value = value; }); renderAutoBoneOptions(); state.autoResult = null; setAutoProgress(false); setAutoState("已恢复", "ok"); $("autoResult").textContent = "迭代参数已恢复初始设置。"; } function poseDelta(from, to) { return { rotateX: Number(((to.rotateX || 0) - (from.rotateX || 0)).toFixed(3)), rotateY: Number(((to.rotateY || 0) - (from.rotateY || 0)).toFixed(3)), rotateZ: Number(((to.rotateZ || 0) - (from.rotateZ || 0)).toFixed(3)), 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 signedValue(value, decimals = 3) { const numeric = Number(value || 0); if (Math.abs(numeric) < 10 ** -decimals) return "0"; return `${numeric > 0 ? "+" : ""}${numeric.toFixed(decimals)}`; } function autoPoseResultItem(label, deltaText, currentText, changed = true) { return ` ${escapeHtml(label)} ${escapeHtml(deltaText)} ${escapeHtml(currentText)} `; } function updateAutoPoseResult(before = state.autoDeltaBaseline || state.lastAutoPose?.before || state.pose, after = state.pose) { const delta = poseDelta(before, after); const el = $("autoPoseResult"); if (!el) return; el.innerHTML = [ autoPoseResultItem("旋转 X", `Δ ${signedValue(delta.rotateX, 1)}°`, `当前 ${Number(after.rotateX || 0).toFixed(1)}°`, Math.abs(delta.rotateX) > 0.0005), autoPoseResultItem("旋转 Y", `Δ ${signedValue(delta.rotateY, 1)}°`, `当前 ${Number(after.rotateY || 0).toFixed(1)}°`, Math.abs(delta.rotateY) > 0.0005), autoPoseResultItem("旋转 Z", `Δ ${signedValue(delta.rotateZ, 1)}°`, `当前 ${Number(after.rotateZ || 0).toFixed(1)}°`, Math.abs(delta.rotateZ) > 0.0005), autoPoseResultItem("平移 X", `Δ ${signedValue(delta.translateX)}`, `当前 ${Number(after.translateX || 0).toFixed(3)}`, Math.abs(delta.translateX) > 0.0005), autoPoseResultItem("平移 Y", `Δ ${signedValue(delta.translateY)}`, `当前 ${Number(after.translateY || 0).toFixed(3)}`, Math.abs(delta.translateY) > 0.0005), autoPoseResultItem("平移 Z", `Δ ${signedValue(delta.translateZ)}`, `当前 ${Number(after.translateZ || 0).toFixed(3)}`, Math.abs(delta.translateZ) > 0.0005), autoPoseResultItem("缩放", `×${Number(delta.scale || 1).toFixed(3)}`, `当前 ${Number(after.scale || 1).toFixed(3)}`, Math.abs((delta.scale || 1) - 1) > 0.0005), ].join(""); } function resetAutoDeltaBaseline() { state.autoDeltaBaseline = { ...state.pose }; updateAutoPoseResult(state.pose, state.pose); } function renderAutoProgressSteps(activeStep = "") { const stepsEl = $("autoProgressSteps"); if (!stepsEl) return; const activeIndex = AUTO_PROGRESS_STEPS.findIndex((step) => step.id === activeStep); stepsEl.innerHTML = AUTO_PROGRESS_STEPS.map((step, index) => { const cls = index < activeIndex ? "done" : step.id === activeStep ? "active" : ""; return `${escapeHtml(step.label)}`; }).join(""); } function setAutoProgress(visible, value = 0, title = "", detail = "", activeStep = "") { const wrap = $("autoProgress"); if (!wrap) return; wrap.classList.toggle("hidden", !visible); const safeValue = Math.max(0, Math.min(100, Number(value) || 0)); $("autoProgressFill").style.width = `${safeValue}%`; $("autoProgressText").textContent = `${Math.round(safeValue)}%`; $("autoProgressTitle").textContent = title || "自动微调处理中"; $("autoProgressDetail").textContent = detail || "正在准备自动微调任务。"; renderAutoProgressSteps(activeStep); } function waitForUiFrame() { return new Promise((resolve) => window.requestAnimationFrame(() => resolve())); } function autoSettingsForStorage(settings) { const { imageContext, ...rest } = settings || {}; return rest; } function chooseAutoVolumeEntries(settings) { const volume = state.volumeMeta; const frames = volume?.frames || []; const indices = volume?.indices || []; if (!frames.length || !indices.length) return []; const entries = frames.map((frame, index) => ({ frame, slice: Number(indices[index] ?? 0) })); const limit = Math.max(3, Math.min(entries.length, Number(settings.sampleSlices || 9))); if (entries.length <= limit) return entries; const selected = new Map(); for (let index = 0; index < limit; index += 1) { const entryIndex = Math.round((index * (entries.length - 1)) / Math.max(limit - 1, 1)); selected.set(entryIndex, entries[entryIndex]); } return [...selected.values()].sort((left, right) => left.slice - right.slice); } function collectAutoSamplePoints(settings, maxPoints = 5200) { const records = autoReferenceRecords(settings); const totalVertices = records.reduce((sum, record) => sum + (record.mesh.geometry.attributes.position?.count || 0), 0); if (!totalVertices) return []; const stride = Math.max(1, Math.ceil(totalVertices / maxPoints)); const samples = []; records.forEach((record) => { const attribute = record.mesh.geometry.attributes.position; if (!attribute) return; for (let index = 0; index < attribute.count; index += stride) { samples.push({ x: attribute.getX(index), y: attribute.getY(index), z: attribute.getZ(index), }); } }); return samples; } async function prepareAutoFineImageContext(settings) { if (!state.volumeMeta || !state.volumeScene) return null; const entries = chooseAutoVolumeEntries(settings); const samples = collectAutoSamplePoints(settings); if (!entries.length || !samples.length) return null; const frames = []; for (const [index, entry] of entries.entries()) { const progress = 20 + (index / Math.max(entries.length, 1)) * 16; setAutoProgress(true, progress, "读取骨窗采样切片", `正在读取第 ${index + 1}/${entries.length} 张骨窗切片,切片 ${entry.slice + 1}。`, "bone"); await waitForUiFrame(); frames.push({ ...entry, image: await loadImageData(entry.frame, 320) }); } const sortedSlices = frames.map((entry) => entry.slice).sort((left, right) => left - right); const gaps = sortedSlices.slice(1).map((slice, index) => Math.abs(slice - sortedSlices[index])); const sliceTolerance = Math.max(1, Math.ceil((gaps.length ? Math.max(...gaps) : 1) / 2)); return { frames, samples, sliceTolerance, volumeScene: { ...state.volumeScene }, }; } function scorePoseAgainstBoneImages(pose, context, settings) { if (!context?.frames?.length || !context.samples?.length || !context.volumeScene) return NaN; const current = { ...state.pose }; applyPose(pose, { scheduleMapping: false }); const matrix = modelPoseMatrix(); applyPose(current, { scheduleMapping: false }); const volume = context.volumeScene; const point = new THREE.Vector3(); let hitReward = 0; let missPenalty = 0; let contributors = 0; for (const sample of context.samples) { point.set(sample.x, sample.y, sample.z).applyMatrix4(matrix); if ( point.x < -volume.width / 2 || point.x > volume.width / 2 || point.y < -volume.height / 2 || point.y > volume.height / 2 || point.z < -volume.depth / 2 || point.z > volume.depth / 2 ) { missPenalty += 0.85; contributors += 1; continue; } const slice = volume.total <= 1 ? 0 : ((point.z + volume.depth / 2) / Math.max(volume.depth, 0.001)) * (volume.total - 1); let nearest = context.frames[0]; let nearestDistance = Infinity; for (const frame of context.frames) { const distance = Math.abs(frame.slice - slice); if (distance < nearestDistance) { nearest = frame; nearestDistance = distance; } } if (nearestDistance > context.sliceTolerance) { missPenalty += 0.28; contributors += 1; continue; } const image = nearest.image; const x = Math.round(((point.x + volume.width / 2) / Math.max(volume.width, 0.001)) * (image.width - 1)); const y = Math.round(((volume.height / 2 - point.y) / Math.max(volume.height, 0.001)) * (image.height - 1)); if (x < 0 || x >= image.width || y < 0 || y >= image.height) { missPenalty += 0.75; contributors += 1; continue; } const lum = image.data[(y * image.width + x) * 4] || 0; if (lum >= 170) hitReward += 1; else if (lum >= 115) hitReward += 0.45; else if (lum >= 80) hitReward += 0.16; else missPenalty += 0.9 - lum / 255; contributors += 1; } const safeContributors = Math.max(contributors, 1); const basePose = settings.basePose || state.autoDeltaBaseline || state.pose; const movement = Math.sqrt( ((pose.translateX || 0) - (basePose.translateX || 0)) ** 2 + ((pose.translateY || 0) - (basePose.translateY || 0)) ** 2 + ((pose.translateZ || 0) - (basePose.translateZ || 0)) ** 2, ); const movementPenalty = movement * Number(settings.movePenalty || 0); const scalePenalty = Math.abs((pose.scale || 1) - (basePose.scale || 1)) * Number(settings.scalePenalty || 0); return (hitReward / safeContributors) * Number(settings.boneReward || 1) - (missPenalty / safeContributors) * (0.25 + Number(settings.outsidePenalty || 0)) - movementPenalty - scalePenalty; } function restoreAutoPose() { if (state.poseControlsLocked) return; if (!state.lastAutoPose?.before) { $("autoResult").textContent = "暂无可退回的自动调整位姿。"; return; } state.pose = { ...state.lastAutoPose.before }; renderPoseControls(); applyPose(); fitCamera(); resetAutoDeltaBaseline(); $("autoResult").textContent = "已退回到自动调整前的位姿。"; markDirty(); } function candidateScore(pose, settings = null) { if (!state.dicomSceneBox || !state.modelGroup || !visibleModelRecords().length) return -Infinity; const config = settings || readAutoSettings(); const records = autoReferenceRecords(config); if (!records.length) return -Infinity; const current = { ...state.pose }; applyPose(pose, { scheduleMapping: false }); const modelBox = visibleModelBox(records); applyPose(current, { scheduleMapping: false }); 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); const geometryScore = (overlapVolume / modelVolume) * Number(config.boneReward || 1) - centerPenalty - scalePenalty - movePenalty - Number(config.outsidePenalty || 0) * 0.01; if (config.imageContext) { const imageScore = scorePoseAgainstBoneImages(pose, config.imageContext, config); if (Number.isFinite(imageScore)) return imageScore + geometryScore * 0.12; } return geometryScore; } function describeAutoReference(settings) { const records = autoReferenceRecords(settings); if (!records.length) return "当前没有可用参考 STL"; const names = records.slice(0, 4).map((record) => compactStlName(record.file)); const suffix = records.length > names.length ? ` 等 ${records.length} 个` : ""; return `参考 STL:${names.join("、")}${suffix}`; } function estimateCoarsePose(settings) { const records = autoReferenceRecords(settings); const modelBox = visibleModelBox(records); if (!modelBox || !state.dicomSceneBox) return null; const adjustable = settings.adjustable || {}; const dicomSize = new THREE.Vector3(); const modelSize = new THREE.Vector3(); const dicomCenter = new THREE.Vector3(); state.dicomSceneBox.getSize(dicomSize); state.dicomSceneBox.getCenter(dicomCenter); modelBox.getSize(modelSize); const candidate = { ...state.pose }; if (adjustable.scale) { const ratios = ["x", "y", "z"] .map((axis) => dicomSize[axis] / Math.max(modelSize[axis], 0.001)) .filter((value) => Number.isFinite(value) && value > 0); if (ratios.length) { const fitRatio = Math.min(...ratios) * 0.96; candidate.scale = Number(clamp((candidate.scale || 1) * fitRatio, 0.2, 3).toFixed(3)); } } const previous = { ...state.pose }; applyPose(candidate, { scheduleMapping: false }); const nextBox = visibleModelBox(records); applyPose(previous, { scheduleMapping: false }); if (!nextBox) return candidate; const modelCenter = new THREE.Vector3(); nextBox.getCenter(modelCenter); const offset = dicomCenter.clone().sub(modelCenter); if (adjustable.translateX) candidate.translateX = Number(((candidate.translateX || 0) + offset.x).toFixed(3)); if (adjustable.translateY) candidate.translateY = Number(((candidate.translateY || 0) + offset.y).toFixed(3)); if (adjustable.translateZ) candidate.translateZ = Number(((candidate.translateZ || 0) + offset.z).toFixed(3)); return candidate; } function runAutoCoarse() { if (state.poseControlsLocked) return; if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) { $("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。"; return; } if (state.autoFineRunning) return; setAutoProgress(false); const before = { ...state.pose }; const settings = readAutoSettings(); const estimated = estimateCoarsePose(settings); if (!estimated) { $("autoResult").textContent = "当前缺少 DICOM 或 STL 盒体信息,无法计算粗配准。"; setAutoState("粗配准失败", "error"); return; } state.pose = estimated; renderPoseControls(); applyPose(); fitCamera(); state.autoResult = { score: candidateScore(state.pose, settings), pose: { ...state.pose }, evaluated: 1, settings }; state.lastAutoPose = { before, after: { ...state.pose } }; state.autoDeltaBaseline = { ...before }; setAutoState("粗配准完成", "ok"); const delta = poseDelta(before, state.pose); $("autoResult").textContent = `已按 DICOM/STL 盒体估算中心与缩放:ΔX ${signedValue(delta.translateX)},ΔY ${signedValue(delta.translateY)},ΔZ ${signedValue(delta.translateZ)},缩放 ×${delta.scale.toFixed(3)};score ${state.autoResult.score.toFixed(4)}。`; updateAutoPoseResult(before, state.pose); markDirty(); } async function runAutoFine(options = {}) { if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) { $("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。"; return; } if (state.autoFineRunning && !options.lockAlreadyHeld) return; const before = { ...state.pose }; const settings = { ...readAutoSettings(), basePose: before }; const adjustable = settings.adjustable; const lockAlreadyHeld = Boolean(options.lockAlreadyHeld); state.autoFineRunning = true; if (!lockAlreadyHeld) setPoseEditingLocked(true); setAutoState("运行中", "warn"); setAutoProgress(true, options.fromSaveIterate ? 12 : 4, "锁定位姿参数", "自动微调运行中,位姿输入、XYZ 拉伸和迭代按钮已临时锁定。", options.fromSaveIterate ? "save" : "bone"); $("autoResult").textContent = "正在准备自动微调:锁定位姿控件并切换骨窗采样。"; await waitForUiFrame(); try { setAutoProgress(true, 14, "加载骨窗参考", "读取骨窗 DICOM 切片范围,并重建三维参考显示。", "bone"); if (state.windowMode !== "bone") await setWindowMode("bone", true); await waitForUiFrame(); setAutoProgress(true, 22, "采样参考 STL", "正在从勾选的参考 STL 和骨窗切片中构建评分样本。", "sample"); settings.imageContext = await prepareAutoFineImageContext(settings); const imageContextText = settings.imageContext ? `已采样 ${settings.imageContext.samples.length} 个 STL 点、${settings.imageContext.frames.length} 张骨窗切片。` : "未能建立骨窗像素样本,将退回 DICOM/STL 三维盒体评分。"; setAutoProgress(true, 38, "建立候选队列", imageContextText, "score"); await waitForUiFrame(); let best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" }; let evaluated = 1; const coarsePose = estimateCoarsePose(settings); const coarseSeed = coarsePose ? { pose: coarsePose, mode: "粗配准种子" } : null; if (coarsePose) { const coarseScore = candidateScore(coarsePose, settings); evaluated += 1; if (coarseScore > best.score) best = { pose: coarsePose, score: coarseScore, mode: "粗配准种子" }; } 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 [index, round] of rounds.entries()) { const candidates = []; const enabledKeys = []; if (adjustable.translateX) enabledKeys.push("translateX"); if (adjustable.translateY) enabledKeys.push("translateY"); if (adjustable.translateZ) enabledKeys.push("translateZ"); if (adjustable.scale) enabledKeys.push("scale"); const stepForKey = (key, ratio = 1) => (key === "scale" ? round.scale : round.move) * ratio; const poseWithDelta = (sourcePose, key, delta) => { const baseValue = Number(sourcePose[key] ?? DEFAULT_POSE[key]); const nextValue = key === "scale" ? Math.max(0.2, Math.min(3, baseValue + delta)) : baseValue + delta; return { ...sourcePose, [key]: Number(nextValue.toFixed(key === "scale" ? 3 : 4)) }; }; const searchBases = [{ pose: best.pose, mode: best.mode }]; if (coarseSeed && poseSignature(coarseSeed.pose) !== poseSignature(best.pose)) searchBases.push(coarseSeed); const seenCandidates = new Set(); const addCandidate = (pose, mode) => { const signature = poseSignature(pose); if (seenCandidates.has(signature)) return; seenCandidates.add(signature); candidates.push({ pose, mode }); }; searchBases.forEach((base) => { enabledKeys.forEach((key) => { addCandidate(poseWithDelta(base.pose, key, stepForKey(key)), `${base.mode} · ${key} +`); addCandidate(poseWithDelta(base.pose, key, -stepForKey(key)), `${base.mode} · ${key} -`); }); for (let left = 0; left < enabledKeys.length; left += 1) { for (let right = left + 1; right < enabledKeys.length; right += 1) { [-1, 1].forEach((leftSign) => { [-1, 1].forEach((rightSign) => { const leftKey = enabledKeys[left]; const rightKey = enabledKeys[right]; const pose = poseWithDelta( poseWithDelta(base.pose, leftKey, stepForKey(leftKey, 0.65) * leftSign), rightKey, stepForKey(rightKey, 0.65) * rightSign, ); addCandidate(pose, `${base.mode} · ${leftKey}/${rightKey} 联合`); }); }); } } }); const candidateLimit = Math.max(settings.candidates, settings.candidates * searchBases.length); const candidatesToEvaluate = candidates.slice(0, candidateLimit); for (const candidate of candidatesToEvaluate) { const pose = candidate.pose; const score = candidateScore(pose, settings); evaluated += 1; if (score > best.score) best = { pose, score, mode: candidate.mode }; } const progress = 40 + ((index + 1) / Math.max(rounds.length, 1)) * 48; const scoreText = Number.isFinite(best.score) ? best.score.toFixed(4) : "不可用"; setAutoProgress(true, progress, `候选评分 ${index + 1}/${rounds.length}`, `本轮评估 ${candidatesToEvaluate.length}/${candidates.length} 个候选,累计 ${evaluated} 个;当前最高分候选 ${best.mode},score ${scoreText}。`, "score"); await waitForUiFrame(); } setAutoProgress(true, 94, "应用最佳位姿", "正在把本轮最高分候选写回位姿控件和融合视图。", "apply"); state.pose = { ...best.pose }; state.autoResult = { ...best, evaluated, settings: { ...autoSettingsForStorage(settings), scoring: settings.imageContext ? "bone-window-stl-samples" : "reference-stl-box-overlap", window: "bone" } }; state.lastAutoPose = { before, after: { ...state.pose } }; state.autoDeltaBaseline = { ...before }; renderPoseControls(); applyPose(); fitCamera(); const changed = poseSignature(best.pose) !== poseSignature(before); setAutoState(changed ? "微调完成" : "微调无变化", changed ? "ok" : "warn"); const scoreText = Number.isFinite(best.score) ? best.score.toFixed(4) : "不可用"; const referenceText = describeAutoReference(settings); $("autoResult").textContent = changed ? `本轮最高分候选:${best.mode};score ${scoreText};评估 ${evaluated} 个候选;${settings.imageContext ? "骨窗像素采样 + STL 参考评分" : "参考 STL 盒体评分"};${referenceText}。` : `本轮未找到比当前位姿更高分的候选;score ${scoreText};评估 ${evaluated} 个候选。这不代表已配准正确,只说明当前搜索范围和参考 STL 下没有更高分;${referenceText};自动微调目前只调整平移/缩放,明显旋转错位需要先手动校正。`; updateAutoPoseResult(before, state.pose); setAutoProgress(true, 100, changed ? "微调完成" : "本轮未改动", changed ? `已应用 ${best.mode}。` : "没有强行移动模型;请检查参考 STL 和大角度旋转。", "apply"); markDirty(); } catch (error) { setAutoState("微调失败", "error"); $("autoResult").textContent = error.message || "自动微调失败。"; setAutoProgress(false); } finally { state.autoFineRunning = false; setPoseEditingLocked(false); } } async function saveAndIteratePose() { if (state.autoFineRunning || state.poseControlsLocked) return; state.autoFineRunning = true; setPoseEditingLocked(true); setAutoState("保存中", "warn"); setAutoProgress(true, 4, "保存当前位姿", "先保存当前人工位姿,并以保存后的位姿作为本轮自动微调起点。", "save"); const saved = await saveRegistration(); if (!saved) { state.autoFineRunning = false; setPoseEditingLocked(false); setAutoProgress(false); return; } state.autoFineRunning = false; await runAutoFine({ lockAlreadyHeld: true, fromSaveIterate: true }); } async function saveRegistration(nextStatus = null) { if (!state.activeCase || !currentSeries() || state.saving) return false; 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) => { const mode = stlMode(file); return [file.file_name, { color: stlColor(file, index), opacity: mode === "solid" ? 1 : 0.52, detail: state.modelDetail, mode, }]; })), 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(); return true; } catch (error) { setSaveState(error.message, "error"); return false; } finally { state.saving = false; if (state.poseControlsLocked) { applyPoseLockState(); } else { $("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)); return reload ? loadFusion() : Promise.resolve(); } 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")); $("cycleAllStlModeBtn").addEventListener("click", cycleAllStlMode); $("resetRotationBtn").addEventListener("click", () => resetPose("rotation")); $("resetTransformBtn").addEventListener("click", () => resetPose("transform")); $("resetFlipBtn").addEventListener("click", () => resetPose("flip")); $("poseManualToggle").addEventListener("click", () => { state.collapsedPoseSections.manual = !state.collapsedPoseSections.manual; renderPoseSectionCollapse(); }); $("poseAutoToggle").addEventListener("click", () => { state.collapsedPoseSections.auto = !state.collapsedPoseSections.auto; renderPoseSectionCollapse(); }); $("stretchXBtn").addEventListener("click", () => stretchAxis("x")); $("stretchYBtn").addEventListener("click", () => stretchAxis("y")); $("stretchZBtn").addEventListener("click", () => stretchAxis("z")); $("openAutoModalBtn").addEventListener("click", resetAutoSettings); $("suggestBoneBtn").addEventListener("click", suggestBoneSelection); $("autoCoarseBtn")?.addEventListener("click", runAutoCoarse); $("autoFineBtn")?.addEventListener("click", runAutoFine); $("savePoseBtn").addEventListener("click", () => saveRegistration()); $("saveIterateBtn").addEventListener("click", saveAndIteratePose); $("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(); renderPoseSectionCollapse(); if (state.token) { bootstrap(); } else { loginVisible(true); }