import * as THREE from "three"; import { STLLoader } from "./vendor/STLLoader.js"; import { OrbitControls } from "./vendor/OrbitControls.js"; const state = { token: localStorage.getItem("dicom_upp_registration_token") || "", user: null, cases: [], activeCase: null, series: [], selectedSeries: null, stlFiles: [], selectedStlIds: new Set(), selectedFamilies: new Set(), mode: "file", registrations: [], pose: defaultPose(), maskVisible: true, sliceIndex: 0, plane: "axial", window: "default", loading: false, searchTimer: null, links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" }, scenes: {}, }; const $ = (id) => document.getElementById(id); const poseFields = [ { key: "translateX", label: "平移 X", min: -2, max: 2, step: 0.001 }, { key: "translateY", label: "平移 Y", min: -2, max: 2, step: 0.001 }, { key: "translateZ", label: "平移 Z", min: -2, max: 2, step: 0.001 }, { key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 1 }, { key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 1 }, { key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 1 }, { key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.001 }, ]; const bodyPartLabels = { head_neck: "头颈部", chest: "胸部", upper_abdomen: "上腹部", lower_abdomen: "下腹部", pelvis: "盆腔", }; const familyColors = [ "#38d7c7", "#f4a261", "#d88cf6", "#54df8c", "#60a5fa", "#f87171", "#facc15", "#a3e635", "#22d3ee", "#f472b6", ]; function defaultPose() { return { translateX: 0, translateY: 0, translateZ: 0, rotateX: 0, rotateY: 0, rotateZ: 0, scale: 1, flipX: false, flipY: false, flipZ: false, }; } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function asList(value) { return Array.isArray(value) ? value : []; } function fmtDate(value) { const text = String(value || ""); if (!text) return ""; if (/^\d{8}$/.test(text)) return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`; return text.replace("T", " ").slice(0, 19); } function fmtTime(value) { const digits = String(value || "").replace(/\D/g, "").slice(0, 6); if (digits.length < 6) return value || ""; return `${digits.slice(0, 2)}:${digits.slice(2, 4)}:${digits.slice(4, 6)}`; } function fmtBytes(value) { const size = Number(value || 0); if (!size) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024))); return `${(size / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`; } function authParams(params = {}) { const output = new URLSearchParams(params); if (state.token) output.set("access_token", state.token); return output; } async function request(path, options = {}) { const headers = { ...(options.headers || {}) }; if (state.token) headers.Authorization = `Bearer ${state.token}`; if (options.body && !(options.body instanceof FormData)) headers["Content-Type"] = "application/json"; const res = await fetch(path, { ...options, headers }); if (res.status === 401) { showLogin(true); throw new Error("unauthorized"); } if (!res.ok) { let detail = res.statusText; try { const data = await res.json(); detail = data.detail || detail; } catch (_) { detail = await res.text(); } throw new Error(detail); } return res; } async function json(path, options = {}) { const res = await request(path, options); return res.json(); } function setLoading(show) { state.loading = show; $("topLoading").classList.toggle("hidden", !show); } function showLogin(show) { $("loginOverlay").classList.toggle("hidden", !show); } function sameHostUrl(rawUrl, fallbackPort) { try { const url = new URL(rawUrl || `${location.protocol}//${location.hostname}:${fallbackPort}`, location.href); if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname) && !["127.0.0.1", "localhost"].includes(location.hostname)) { url.hostname = location.hostname; } url.protocol = location.protocol; return url.toString().replace(/\/$/, ""); } catch (_) { return `${location.protocol}//${location.hostname}:${fallbackPort}`; } } function relationBaseUrl() { return sameHostUrl(state.links.relation_viewer_url, 8108); } function viewerBaseUrl() { return sameHostUrl(state.links.pacs_viewer_url, 8107); } function renderPoseControls() { $("poseGrid").innerHTML = ` ${poseFields .map( (field) => `
`, ) .join("")}
`; document.querySelectorAll("[data-pose-range], [data-pose-number]").forEach((input) => { input.addEventListener("input", () => { const key = input.dataset.poseRange || input.dataset.poseNumber; state.pose[key] = Number(input.value); syncPoseInputs(); markUnsaved(); updateScenesPose(); }); }); document.querySelectorAll("[data-flip]").forEach((button) => { button.addEventListener("click", () => { const key = button.dataset.flip; state.pose[key] = !state.pose[key]; syncPoseInputs(); markUnsaved(); updateScenesPose(); }); }); syncPoseInputs(); } function syncPoseInputs() { poseFields.forEach((field) => { const value = Number(state.pose[field.key] ?? defaultPose()[field.key]); document.querySelectorAll(`[data-pose-range="${field.key}"], [data-pose-number="${field.key}"]`).forEach((input) => { input.value = field.step < 1 ? value.toFixed(3) : String(Math.round(value)); }); }); document.querySelectorAll("[data-flip]").forEach((button) => { button.classList.toggle("active", Boolean(state.pose[button.dataset.flip])); }); } function markUnsaved() { $("saveState").textContent = "未保存"; $("fusionMeta").textContent = "位姿未保存"; } function setDbStatus(data) { state.links = data.links || state.links; const pill = $("dbStatus"); if (data.database?.ok) { pill.textContent = `${data.database.database} 已连接`; pill.className = "status-pill online"; } else { pill.textContent = "数据库异常"; pill.className = "status-pill offline"; } } async function loadStatus() { const data = await json("/api/status"); setDbStatus(data); } async function loadCases() { setLoading(true); try { const q = $("caseSearch").value.trim(); const params = new URLSearchParams(); if (q) params.set("q", q); params.set("limit", "120"); state.cases = await json(`/api/cases?${params.toString()}`); renderCases(); const targetCt = new URLSearchParams(location.search).get("ct_number"); const next = targetCt ? state.cases.find((row) => String(row.ct_key).toUpperCase() === targetCt.toUpperCase() || String(row.pacs_ct_number).toUpperCase() === targetCt.toUpperCase()) : state.cases[0]; if (next) await selectCase(next.ct_key); } finally { setLoading(false); } } function renderPartTags(row) { const parts = asList(row.body_parts).map((part) => bodyPartLabels[part] || part); return parts.map((part) => `${escapeHtml(part)}`).join(""); } function renderCases() { $("caseCount").textContent = String(state.cases.length); if (!state.cases.length) { $("caseList").innerHTML = `
没有 DICOM + STL 匹配检查
`; return; } $("caseList").innerHTML = state.cases .map((row) => { const active = state.activeCase?.ct_key === row.ct_key ? "active" : ""; const regLabel = row.registration_count ? `已配准 ${row.registered_series || 0}` : "未配准"; const lockLabel = row.locked_count ? `${Number(row.locked_count)} 已锁` : ""; return ` `; }) .join(""); document.querySelectorAll("[data-case]").forEach((button) => { button.addEventListener("click", () => selectCase(button.dataset.case)); }); } async function selectCase(ctKey) { if (!ctKey) return; setLoading(true); try { const [detail, seriesPayload, stlPayload, regPayload] = await Promise.all([ json(`/api/cases/${encodeURIComponent(ctKey)}`), json(`/api/cases/${encodeURIComponent(ctKey)}/series`), json(`/api/cases/${encodeURIComponent(ctKey)}/stl`), json(`/api/registrations/${encodeURIComponent(ctKey)}`), ]); state.activeCase = detail; state.series = seriesPayload.series || []; state.stlFiles = stlPayload.files || []; state.registrations = regPayload.registrations || []; chooseDefaultStl(); const diagnosticSeries = [...state.series] .filter((item) => Number(item.count || 0) >= 80 && !asList(item.annotation?.labels).includes("略过/不采用")) .sort((left, right) => Number(right.count || 0) - Number(left.count || 0)); const largestSeries = [...state.series].sort((left, right) => Number(right.count || 0) - Number(left.count || 0)); state.selectedSeries = state.series.find((item) => item.registration?.registered) || diagnosticSeries[0] || largestSeries[0] || null; state.sliceIndex = Math.max(0, Math.floor((state.selectedSeries?.count || 1) / 2)); applyLatestRegistration(); renderAll(); await reloadVisuals(); } catch (err) { alert(err.message); } finally { setLoading(false); } } function chooseDefaultStl() { state.selectedStlIds.clear(); state.selectedFamilies.clear(); if (!state.stlFiles.length) return; const families = Array.from(new Set(state.stlFiles.map((item) => item.family).filter(Boolean))); const preferred = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen"]; const selectedFamily = preferred.find((family) => families.includes(family)) || families[0]; if (selectedFamily) { state.selectedFamilies.add(selectedFamily); state.stlFiles.filter((item) => item.family === selectedFamily).forEach((item) => state.selectedStlIds.add(Number(item.id))); } else { state.selectedStlIds.add(Number(state.stlFiles[0].id)); } } function renderAll() { renderCases(); renderHero(); renderSeries(); renderStl(); renderPoseControls(); updateDICOMControls(); renderMaskPreview(); } function renderHero() { const row = state.activeCase; $("activeTitle").textContent = row?.ct_key || "未选择 CT"; $("activeMeta").textContent = row ? `${row.patient_name || row.upp_patient_name || "无姓名"} · ${fmtDate(row.study_date)} ${fmtTime(row.study_time)} · ${Number(row.series_count || 0)} 序列` : "从左侧选择一个 DICOM + STL 匹配检查"; const labels = [ row?.algorithm_model, ...asList(row?.body_parts).map((part) => bodyPartLabels[part] || part), row?.registration_count ? `配准 ${row.registered_series || 0}` : "未配准", row?.locked_count ? `锁定 ${row.locked_count}` : "", ].filter(Boolean); $("activeTags").innerHTML = labels.map((label) => `${escapeHtml(label)}`).join(""); $("brandSubtitle").textContent = row?.ct_key ? `${row.ct_key} · STL ${Number(row.stl_file_count || 0)} 个` : "DICOM 序列、STL family 与人工位姿锁定"; } function seriesTime(row) { const start = fmtTime(row.first_time || row.series_time); const end = fmtTime(row.last_time || ""); return start && end && start !== end ? `${start}-${end}` : start || end || "-"; } function renderSeries() { $("seriesCount").textContent = String(state.series.length); if (!state.series.length) { $("seriesList").innerHTML = `
没有可用 DICOM 序列
`; return; } $("seriesList").innerHTML = state.series .map((row) => { const active = state.selectedSeries?.series_uid === row.series_uid ? "active" : ""; const reg = row.registration || {}; const labels = asList(row.annotation?.labels); return ` `; }) .join(""); document.querySelectorAll("[data-series]").forEach((button) => { button.addEventListener("click", async () => { const next = state.series.find((item) => item.series_uid === button.dataset.series); if (!next) return; state.selectedSeries = next; state.sliceIndex = Math.max(0, Math.floor((next.count || 1) / 2)); applyLatestRegistration(); renderAll(); await reloadVisuals(); }); }); } function renderStl() { $("stlCount").textContent = String(state.stlFiles.length); $("modeFile").classList.toggle("active", state.mode === "file"); $("modeFamily").classList.toggle("active", state.mode === "family"); const families = Array.from(new Set(state.stlFiles.map((item) => item.family).filter(Boolean))); $("familyRail").innerHTML = families .map((family) => ``) .join(""); document.querySelectorAll("[data-family]").forEach((button) => { button.addEventListener("click", async () => { const family = button.dataset.family; if (state.selectedFamilies.has(family)) state.selectedFamilies.delete(family); else state.selectedFamilies.add(family); state.selectedStlIds.clear(); state.stlFiles.filter((item) => state.selectedFamilies.has(item.family)).forEach((item) => state.selectedStlIds.add(Number(item.id))); markUnsaved(); renderStl(); await reloadStlVisuals(); }); }); $("stlList").innerHTML = state.stlFiles.length ? state.stlFiles .map((row) => { const checked = state.selectedStlIds.has(Number(row.id)); return ` `; }) .join("") : `
没有 STL 文件
`; document.querySelectorAll("[data-stl]").forEach((input) => { input.addEventListener("change", async () => { const id = Number(input.dataset.stl); if (input.checked) state.selectedStlIds.add(id); else state.selectedStlIds.delete(id); state.selectedFamilies = new Set( Array.from(new Set(selectedStlFiles().map((item) => item.family).filter(Boolean))), ); markUnsaved(); renderStl(); await reloadStlVisuals(); }); }); } function selectedStlFiles() { return state.stlFiles.filter((item) => state.selectedStlIds.has(Number(item.id))); } function currentStlLabel() { const selected = selectedStlFiles(); if (!selected.length) return "未选择STL"; const families = Array.from(new Set(selected.map((item) => item.family).filter(Boolean))); if (state.mode === "family" && families.length) return families.join("+"); return selected.map((item) => item.segment_name || item.file_name).slice(0, 4).join("+") + (selected.length > 4 ? `等${selected.length}个` : ""); } function updateDICOMControls() { const series = state.selectedSeries; $("dicomMeta").textContent = series ? `${series.description} · ${series.count} 张` : "未选择序列"; $("sliceSlider").max = String(Math.max(0, Number(series?.count || 1) - 1)); $("sliceSlider").value = String(Math.min(state.sliceIndex, Number($("sliceSlider").max || 0))); $("sliceLabel").textContent = series ? `${state.sliceIndex + 1} / ${Number(series.count || 0)}` : "0 / 0"; } function imageUrl() { if (!state.activeCase || !state.selectedSeries) return ""; return `/api/image?${authParams({ ct_number: state.activeCase.pacs_ct_number || state.activeCase.ct_key, series_uid: state.selectedSeries.series_uid, index: String(state.sliceIndex), plane: state.plane, window: state.window, }).toString()}`; } async function reloadVisuals() { updateDICOMControls(); await Promise.all([reloadDicomImage(), reloadStlVisuals()]); } async function reloadDicomImage() { const url = imageUrl(); $("dicomEmpty").classList.toggle("hidden", Boolean(url)); $("segmentationEmpty").classList.toggle("hidden", Boolean(url && selectedStlFiles().length)); if (!url) { $("dicomImage").removeAttribute("src"); $("segmentationImage").removeAttribute("src"); return; } $("dicomImage").src = url; $("segmentationImage").src = url; if (state.scenes.fusion) state.scenes.fusion.setDicomTexture(url, state.selectedSeries); renderMaskPreview(); } async function reloadStlVisuals() { const selected = selectedStlFiles(); $("stlMeta").textContent = selected.length ? `${selected.length} 个构件 · ${currentStlLabel()}` : "未选择模型"; $("fusionMeta").textContent = $("saveState").textContent === "已保存" ? `已保存 · ${currentStlLabel()}` : `当前选择 · ${currentStlLabel()}`; if (state.scenes.stl) state.scenes.stl.loadMeshes(selected); if (state.scenes.fusion) state.scenes.fusion.loadMeshes(selected); renderMaskPreview(); } function applyLatestRegistration() { const seriesUid = state.selectedSeries?.series_uid || ""; const selectedLabel = currentStlLabel(); const exact = state.registrations.find((item) => item.series_instance_uid === seriesUid && item.stl_family === selectedLabel); const latest = exact || state.registrations.find((item) => item.series_instance_uid === seriesUid) || null; if (latest?.transform) { state.pose = { ...defaultPose(), ...latest.transform }; $("registrationNotes").value = latest.notes || ""; $("saveState").textContent = latest.locked ? "已锁定" : "已保存"; $("fusionMeta").textContent = latest.locked ? "配准已锁定" : "已载入已保存位姿"; } else { state.pose = defaultPose(); $("registrationNotes").value = ""; $("saveState").textContent = "未保存"; $("fusionMeta").textContent = "位姿未保存"; } updateScenesPose(); } async function saveRegistration(locked = false, forceUnlock = false) { if (!state.activeCase || !state.selectedSeries) return; const selected = selectedStlFiles(); if (!selected.length) { alert("请至少选择一个 STL 构件"); return; } const payload = { ct_number: state.activeCase.pacs_ct_number || state.activeCase.ct_key, series_instance_uid: state.selectedSeries.series_uid, series_description: state.selectedSeries.description || "", algorithm_model: state.activeCase.algorithm_model || selected[0]?.algorithm_model || "未指定模型", stl_family: currentStlLabel(), view_mode: state.mode, selected_stl_files: selected.map((item) => ({ id: item.id, file_name: item.file_name, segment_name: item.segment_name, family: item.family, category: item.category, algorithm_model: item.algorithm_model, })), transform: state.pose, dicom_reference: { series_number: state.selectedSeries.series_number, slice_index: state.sliceIndex, plane: state.plane, window: state.window, rows: state.selectedSeries.rows, columns: state.selectedSeries.columns, pixel_spacing: state.selectedSeries.pixel_spacing, slice_thickness: state.selectedSeries.slice_thickness, spacing_between_slices: state.selectedSeries.spacing_between_slices, }, model_reference: { selected_count: selected.length, families: Array.from(state.selectedFamilies) }, segmentation: { preview: state.maskVisible, source: "visible_stl_preview" }, notes: $("registrationNotes").value.trim(), locked, force_unlock: forceUnlock, }; try { await json("/api/registrations", { method: "POST", body: JSON.stringify(payload) }); $("saveState").textContent = locked ? "已锁定" : "已保存"; $("fusionMeta").textContent = locked ? "配准已保存并锁定" : "配准已保存"; const regPayload = await json(`/api/registrations/${encodeURIComponent(payload.ct_number)}`); state.registrations = regPayload.registrations || []; renderSeries(); } catch (err) { alert(err.message); } } async function exportStl() { if (!state.activeCase) return; const selected = selectedStlFiles(); const payload = { ct_number: state.activeCase.pacs_ct_number || state.activeCase.ct_key, mode: state.mode, file_ids: selected.map((item) => Number(item.id)), families: Array.from(state.selectedFamilies), include_pose: true, transform: state.pose, label: currentStlLabel(), }; const res = await request("/api/export/stl", { method: "POST", body: JSON.stringify(payload) }); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `DICOM_UPP_STL_${payload.ct_number}.zip`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } function colorFor(item, index) { const key = item.family || item.segment_name || String(index); let hash = 0; for (let i = 0; i < key.length; i += 1) hash = (hash * 31 + key.charCodeAt(i)) % 997; return familyColors[hash % familyColors.length]; } function createFallbackScene(container, { fusion = false } = {}) { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); container.appendChild(canvas); let files = []; let dicomImage = null; function resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); const width = Math.max(1, container.clientWidth); const height = Math.max(1, container.clientHeight); canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); draw(); } function draw() { const width = container.clientWidth || 1; const height = container.clientHeight || 1; ctx.clearRect(0, 0, width, height); ctx.fillStyle = "#000"; ctx.fillRect(0, 0, width, height); if (fusion && dicomImage?.complete && dicomImage.naturalWidth) { const scale = Math.min(width / dicomImage.naturalWidth, height / dicomImage.naturalHeight) * 0.92; const w = dicomImage.naturalWidth * scale; const h = dicomImage.naturalHeight * scale; ctx.globalAlpha = 0.72; ctx.drawImage(dicomImage, (width - w) / 2, (height - h) / 2, w, h); ctx.globalAlpha = 1; } const pose = state.pose; const cx = width / 2 + Number(pose.translateX || 0) * width * 0.14; const cy = height / 2 - Number(pose.translateY || 0) * height * 0.14; const base = Math.max(20, Math.min(width, height) * 0.11 * Number(pose.scale || 1)); ctx.save(); ctx.translate(cx, cy); ctx.rotate(THREE.MathUtils.degToRad(Number(pose.rotateZ || 0))); files.slice(0, 12).forEach((file, index) => { const angle = (index / Math.max(files.length, 1)) * Math.PI * 2; const radius = base * (1.1 + (index % 4) * 0.16); const x = Math.cos(angle) * radius; const y = Math.sin(angle) * radius * 0.72; ctx.fillStyle = `${colorFor(file, index)}99`; ctx.strokeStyle = colorFor(file, index); ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(x, y, base * (0.42 + (index % 3) * 0.07), base * (0.26 + (index % 2) * 0.05), angle, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); }); ctx.restore(); ctx.fillStyle = "rgba(234,242,251,0.72)"; ctx.font = "12px ui-sans-serif, system-ui"; ctx.fillText("WebGL不可用,已启用2D预览", 14, 22); ctx.fillText(`${files.length} 个 STL 构件`, 14, height - 18); } resize(); window.addEventListener("resize", resize); return { loadMeshes(nextFiles) { files = nextFiles || []; draw(); }, setDicomTexture(url) { if (!fusion || !url) return; dicomImage = new Image(); dicomImage.onload = draw; dicomImage.src = url; }, updatePose: draw, fit: draw, dispose() { window.removeEventListener("resize", resize); container.innerHTML = ""; }, }; } function createScene(container, { fusion = false } = {}) { const scene = new THREE.Scene(); scene.background = new THREE.Color("#000000"); const camera = new THREE.PerspectiveCamera(42, 1, 0.01, 2000); camera.position.set(0, -5.5, 3.2); camera.up.set(0, 0, 1); let renderer; try { renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true }); } catch (error) { console.warn("WebGL不可用,切换到2D预览", error); return createFallbackScene(container, { fusion }); } renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); container.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.target.set(0, 0, 0); scene.add(new THREE.AmbientLight(0xffffff, 0.72)); const keyLight = new THREE.DirectionalLight(0xffffff, 1.15); keyLight.position.set(4, -5, 5); scene.add(keyLight); const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.5); fillLight.position.set(-4, 3, 2); scene.add(fillLight); const modelGroup = new THREE.Group(); const pivot = new THREE.Group(); modelGroup.add(pivot); scene.add(modelGroup); let dicomPlane = null; let dicomTexture = null; if (fusion) { const geometry = new THREE.PlaneGeometry(4.2, 4.2); const material = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.76, side: THREE.DoubleSide }); dicomPlane = new THREE.Mesh(geometry, material); dicomPlane.position.z = -0.02; scene.add(dicomPlane); } let baseScale = 1; let disposed = false; const loader = new STLLoader(); function resize() { const width = Math.max(1, container.clientWidth); const height = Math.max(1, container.clientHeight); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); } function fit() { camera.position.set(0, -5.5, 3.2); controls.target.set(0, 0, 0); controls.update(); } function clearMeshes() { while (pivot.children.length) { const child = pivot.children.pop(); if (child.geometry) child.geometry.dispose(); if (child.material) child.material.dispose(); } } function loadMeshes(files) { clearMeshes(); if (!files.length || !state.activeCase) return; const ct = state.activeCase.pacs_ct_number || state.activeCase.ct_key; const promises = files.map( (file, index) => new Promise((resolve) => { const url = `/api/stl?${authParams({ ct_number: ct, file_id: String(file.id) }).toString()}`; loader.load( url, (geometry) => { geometry.computeBoundingBox(); geometry.computeVertexNormals(); const material = new THREE.MeshStandardMaterial({ color: new THREE.Color(colorFor(file, index)), roughness: 0.52, metalness: 0.02, transparent: fusion, opacity: fusion ? 0.72 : 1, side: THREE.DoubleSide, }); const mesh = new THREE.Mesh(geometry, material); mesh.userData = file; pivot.add(mesh); resolve(mesh); }, undefined, () => resolve(null), ); }), ); Promise.all(promises).then(() => { if (disposed || !pivot.children.length) return; const box = new THREE.Box3().setFromObject(pivot); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); const maxSize = Math.max(size.x, size.y, size.z, 1); pivot.children.forEach((child) => { child.geometry.translate(-center.x, -center.y, -center.z); child.geometry.computeBoundingBox(); child.geometry.computeBoundingSphere(); }); baseScale = 4.2 / maxSize; fit(); updatePose(); }); } function updatePose() { const pose = state.pose; modelGroup.rotation.set( THREE.MathUtils.degToRad(Number(pose.rotateX || 0)), THREE.MathUtils.degToRad(Number(pose.rotateY || 0)), THREE.MathUtils.degToRad(Number(pose.rotateZ || 0)), ); modelGroup.position.set(Number(pose.translateX || 0), Number(pose.translateY || 0), Number(pose.translateZ || 0)); const scale = baseScale * Number(pose.scale || 1); modelGroup.scale.set(pose.flipX ? -scale : scale, pose.flipY ? -scale : scale, pose.flipZ ? -scale : scale); } function setDicomTexture(url, series) { if (!fusion || !dicomPlane || !url) return; new THREE.TextureLoader().load(url, (texture) => { if (dicomTexture) dicomTexture.dispose(); dicomTexture = texture; dicomPlane.material.map = texture; dicomPlane.material.needsUpdate = true; const rows = Number(series?.rows || 512); const cols = Number(series?.columns || 512); const width = 4.2; const height = rows && cols ? width * (rows / cols) : width; dicomPlane.geometry.dispose(); dicomPlane.geometry = new THREE.PlaneGeometry(width, height); }); } function animate() { if (disposed) return; resize(); updatePose(); controls.update(); renderer.render(scene, camera); requestAnimationFrame(animate); } animate(); window.addEventListener("resize", resize); return { loadMeshes, setDicomTexture, updatePose, fit, dispose() { disposed = true; window.removeEventListener("resize", resize); clearMeshes(); if (dicomTexture) dicomTexture.dispose(); renderer.dispose(); container.innerHTML = ""; }, }; } function updateScenesPose() { Object.values(state.scenes).forEach((scene) => scene?.updatePose?.()); renderMaskPreview(); } function renderMaskPreview() { const canvas = $("maskCanvas"); const ctx = canvas.getContext("2d"); const rect = canvas.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, rect.width, rect.height); if (!state.maskVisible || !state.selectedSeries || !selectedStlFiles().length) return; const files = selectedStlFiles().slice(0, 9); files.forEach((file, index) => { const color = colorFor(file, index); const x = rect.width * (0.36 + (index % 3) * 0.12 + Number(state.pose.translateX || 0) * 0.08); const y = rect.height * (0.38 + Math.floor(index / 3) * 0.12 - Number(state.pose.translateY || 0) * 0.08); const radius = Math.max(18, Math.min(rect.width, rect.height) * (0.07 + (Number(state.pose.scale || 1) - 1) * 0.015)); ctx.fillStyle = `${color}66`; ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(x, y, radius * (1.15 - (index % 2) * 0.24), radius * (0.76 + (index % 3) * 0.09), THREE.MathUtils.degToRad(Number(state.pose.rotateZ || 0) + index * 18), 0, Math.PI * 2); ctx.fill(); ctx.stroke(); }); ctx.fillStyle = "rgba(234,242,251,0.78)"; ctx.font = "12px ui-sans-serif, system-ui"; ctx.fillText(`${selectedStlFiles().length} 个可见 STL · 预览叠加`, 14, rect.height - 16); } async function login(event) { event.preventDefault(); $("loginError").textContent = ""; try { const data = await json("/api/auth/login", { method: "POST", body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }), }); state.token = data.token; state.user = { username: data.username, role: data.role }; localStorage.setItem("dicom_upp_registration_token", state.token); applyAuthUi(); showLogin(false); await refreshAll(); } catch (_) { $("loginError").textContent = "登录失败"; } } function logout() { state.token = ""; state.user = null; localStorage.removeItem("dicom_upp_registration_token"); applyAuthUi(); showLogin(true); } function applyAuthUi() { $("userBadge").textContent = state.user ? `${state.user.username} · ${state.user.role}` : "未登录"; } async function loadCurrentUser() { if (!state.token) { showLogin(true); return false; } try { state.user = await json("/api/auth/me"); applyAuthUi(); showLogin(false); return true; } catch (_) { state.token = ""; localStorage.removeItem("dicom_upp_registration_token"); applyAuthUi(); showLogin(true); return false; } } async function refreshAll() { setLoading(true); try { await loadStatus(); await loadCases(); } catch (err) { $("dbStatus").textContent = err.message; $("dbStatus").className = "status-pill offline"; } finally { setLoading(false); } } function wire() { $("loginForm").addEventListener("submit", login); $("logoutBtn").addEventListener("click", logout); $("refreshBtn").addEventListener("click", refreshAll); $("relationBtn").addEventListener("click", () => window.open(relationBaseUrl(), "_blank", "noopener")); $("viewerBtn").addEventListener("click", () => { const ct = state.activeCase?.pacs_ct_number || state.activeCase?.ct_key || ""; const suffix = ct ? `/?ct_number=${encodeURIComponent(ct)}` : ""; window.open(`${viewerBaseUrl()}${suffix}`, "_blank", "noopener"); }); $("caseSearch").addEventListener("input", () => { clearTimeout(state.searchTimer); state.searchTimer = setTimeout(loadCases, 280); }); $("modeFile").addEventListener("click", async () => { state.mode = "file"; markUnsaved(); renderStl(); }); $("modeFamily").addEventListener("click", async () => { state.mode = "family"; markUnsaved(); renderStl(); }); $("sliceSlider").addEventListener("input", async () => { state.sliceIndex = Number($("sliceSlider").value || 0); updateDICOMControls(); await reloadDicomImage(); markUnsaved(); }); $("planeSelect").addEventListener("change", async () => { state.plane = $("planeSelect").value; await reloadDicomImage(); }); $("windowSelect").addEventListener("change", async () => { state.window = $("windowSelect").value; await reloadDicomImage(); }); $("registrationNotes").addEventListener("input", markUnsaved); $("resetPoseBtn").addEventListener("click", () => { state.pose = defaultPose(); syncPoseInputs(); markUnsaved(); updateScenesPose(); }); $("saveRegistrationBtn").addEventListener("click", () => saveRegistration(false, false)); $("lockRegistrationBtn").addEventListener("click", () => saveRegistration(true, false)); $("unlockRegistrationBtn").addEventListener("click", () => saveRegistration(false, true)); $("exportStlBtn").addEventListener("click", exportStl); $("fitStlBtn").addEventListener("click", () => state.scenes.stl?.fit()); $("fitFusionBtn").addEventListener("click", () => state.scenes.fusion?.fit()); $("toggleMaskBtn").addEventListener("click", () => { state.maskVisible = !state.maskVisible; $("toggleMaskBtn").textContent = state.maskVisible ? "隐藏" : "显示"; renderMaskPreview(); }); } async function boot() { state.scenes.stl = createScene($("stlViewport"), { fusion: false }); state.scenes.fusion = createScene($("fusionViewport"), { fusion: true }); renderPoseControls(); wire(); applyAuthUi(); const ok = await loadCurrentUser(); if (ok) await refreshAll(); } boot();