Files
PACS/DICOM_and_UPP配准/static/app.js
2026-05-30 10:37:44 +08:00

2998 lines
117 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 state = {
token: localStorage.getItem("dicom_upp_registration_token") || "",
user: null,
links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" },
settings: null,
cacheStatus: null,
cachePollTimer: 0,
statusFilter: "",
partFilter: "",
modelFilter: "",
search: "",
searchTimer: 0,
caseCollapsed: false,
caseLimit: CASE_PAGE_SIZE,
caseOffset: 0,
caseTotal: 0,
caseHasMore: false,
loadingCases: false,
activeToolTab: "series",
cases: [],
activeCase: null,
series: [],
selectedSeriesUid: "",
registrationSeriesUid: "",
stlFiles: [],
selectedStlIds: new Set(),
algorithmModel: "",
registrationStatus: "unregistered",
pose: { ...DEFAULT_POSE },
windowMode: "default",
fusionMode: "fusion",
fusionDetail: "high",
modelDetail: "ultra",
mappingMode: "result",
sliceIndex: 0,
sliceRangeStart: 0,
sliceRangeEnd: 0,
dicomPreviewTimer: 0,
autoResult: null,
lastAutoPose: null,
dirty: false,
dirtyReason: "",
savedPoseSignature: "",
saving: false,
fusionVersion: 0,
volumeMeta: null,
volumeScene: null,
sceneReady: false,
renderer: null,
camera: null,
scene: null,
controls: null,
fusionRoot: null,
viewPose: { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 },
sceneDrag: null,
dicomGroup: null,
dicomPlane: null,
dicomPoints: null,
dicomSlicePlanes: [],
modelGroup: null,
rawModelGroup: null,
axisGroup: null,
modelMeshes: [],
stlSignature: "",
baseModelScale: 1,
modelBoundsFrame: null,
modelLocalBounds: null,
dicomSceneBox: null,
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 },
mappingDrag: null,
mappingDrawFrame: 0,
mappingVersion: 0,
resizeObserver: null,
resizeScene: null,
axisProjectionSignature: "",
nudgeTimer: null,
nudgeDelayTimer: null,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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");
}
function poseSignature(pose = state.pose) {
const normalized = {};
POSE_SIGNATURE_KEYS.forEach((key) => {
const value = pose?.[key] ?? DEFAULT_POSE[key];
normalized[key] = typeof value === "boolean" ? value : Number(Number(value || 0).toFixed(4));
});
return JSON.stringify(normalized);
}
function poseChanged() {
return state.savedPoseSignature && poseSignature(state.pose) !== state.savedPoseSignature;
}
function resetDirty() {
state.dirty = false;
state.dirtyReason = "";
state.savedPoseSignature = poseSignature(state.pose);
setSaveState("已保存", "ok");
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.token) headers.set("Authorization", `Bearer ${state.token}`);
if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const response = await fetch(path, { ...options, headers });
if (response.status === 401) {
logout(false);
throw new Error("登录已过期");
}
if (!response.ok) {
let message = response.statusText;
try {
const payload = await response.json();
message = payload.detail || message;
} catch {
message = await response.text();
}
throw new Error(message || "请求失败");
}
const type = response.headers.get("content-type") || "";
return type.includes("application/json") ? response.json() : response.text();
}
function loginVisible(visible) {
$("loginOverlay").classList.toggle("hidden", !visible);
}
async function login(event) {
event.preventDefault();
$("loginError").textContent = "";
try {
const payload = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }),
});
state.token = payload.token;
state.user = payload;
localStorage.setItem("dicom_upp_registration_token", state.token);
loginVisible(false);
await bootstrap();
} catch (error) {
$("loginError").textContent = error.message;
}
}
function logout(clearToken = true) {
if (clearToken) localStorage.removeItem("dicom_upp_registration_token");
state.token = "";
state.user = null;
loginVisible(true);
$("userBadge").textContent = "未登录";
}
async function bootstrap() {
setTopLoading(true);
try {
const me = await api("/api/auth/me");
state.user = me;
$("userBadge").textContent = `${me.username} · ${me.role || "管理员"}`;
loginVisible(false);
try {
initScene();
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) => state.selectedStlIds.has(Number(record.file?.id)) && record.mesh?.visible !== false);
}
function modelPoseMatrix() {
if (!state.modelGroup) return new THREE.Matrix4();
state.modelGroup.updateMatrix();
return state.modelGroup.matrix.clone();
}
function visibleModelBox() {
const records = visibleModelRecords();
if (!records.length) return null;
const box = new THREE.Box3();
const matrix = modelPoseMatrix();
records.forEach((record) => {
record.mesh.geometry.computeBoundingBox?.();
const localBox = record.mesh.geometry.boundingBox;
if (localBox) box.union(localBox.clone().applyMatrix4(matrix));
});
return box;
}
function bodyPartTags(row) {
const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : [];
const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : [];
if (labels.length) return labels;
return keys.map((key) => BODY_LABELS[key]).filter(Boolean);
}
function renderCases() {
$("caseCount").textContent = state.caseTotal ? `${state.cases.length}/${state.caseTotal}` : state.cases.length;
const active = state.activeCase;
const cards = state.cases.length
? state.cases
.map((row) => {
const tags = bodyPartTags(row).slice(0, 3);
const status = row.registration_status || "unregistered";
const selected = active && sameCase(row, active);
const title = escapeHtml(row.ct_number || row.ct_key);
const patient = escapeHtml(row.patient_name || row.upp_patient_name || "-");
const meta = `${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
return `
<button class="case-card ${selected ? "active" : ""}" data-ct="${escapeHtml(row.ct_number)}" data-algorithm="${escapeHtml(row.algorithm_model || "未指定模型")}" title="${title} ${patient}">
<div class="card-line">
<strong>${title}</strong>
<em class="tag ${status === "registered" ? "green" : "amber"}">${statusText(status)}</em>
</div>
<span>${patient} · ${escapeHtml(row.patient_id || row.patient_id_masked || "")}</span>
<small>${escapeHtml(meta)}</small>
<div class="mini-tags">
<i>${escapeHtml(row.algorithm_model || "未指定模型")}</i>
${tags.map((tag) => `<i>${escapeHtml(tag)}</i>`).join("")}
</div>
</button>
`;
})
.join("")
: `<div class="empty-state">没有符合条件的完整关联 CT</div>`;
$("caseList").innerHTML = `${cards}${state.caseHasMore ? `<button id="loadMoreCasesBtn" class="load-more-btn" type="button">${state.loadingCases ? "读取中..." : "继续加载"}</button>` : ""}`;
$("caseList").querySelectorAll(".case-card").forEach((button) => {
button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型"));
});
$("loadMoreCasesBtn")?.addEventListener("click", loadMoreCases);
}
function clearDetail() {
state.series = [];
state.stlFiles = [];
state.selectedStlIds = new Set();
state.selectedSeriesUid = "";
state.registrationSeriesUid = "";
$("activeTitle").textContent = "未选择 CT";
$("activeMeta").textContent = "从左侧选择一个完整关联检查";
$("activeTags").innerHTML = "";
$("seriesList").innerHTML = `<div class="empty-state">未选择 CT</div>`;
$("stlList").innerHTML = `<div class="empty-state">未选择 STL</div>`;
$("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.sliceIndex = 0;
$("notes").value = registration.notes || "";
const savedIds = new Set((registration.selected_stl_files || []).map((item) => Number(item.id)).filter(Number.isFinite));
state.selectedStlIds = savedIds.size ? savedIds : pickDefaultStlIds(state.stlFiles);
const defaultSeries = pickDefaultSeries(state.series);
const savedSeries = state.series.find((item) => item.series_uid === registration.series_instance_uid);
const shouldUseSavedSeries = registration.registration_status === "registered" && savedSeries;
state.registrationSeriesUid = savedSeries?.series_uid || "";
state.selectedSeriesUid = shouldUseSavedSeries ? savedSeries.series_uid : defaultSeries?.series_uid || "";
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
state.sliceRangeStart = 0;
state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
state.stlSignature = "";
renderCases();
renderActiveHeader();
renderSeries();
renderStl();
renderPoseControls();
syncDisplayControls();
resetDirty();
await loadFusion();
} catch (error) {
$("fusionStatus").textContent = error.message;
clearFusion();
} finally {
setTopLoading(false);
setFusionLoading(false);
}
}
function pickDefaultSeries(series) {
const candidates = [...series].filter((item) => Number(item.count || 0) > 1);
const usable = candidates.filter((item) => !isSkippedSeries(item));
const pool = usable.length ? usable : candidates;
const rank = (item) => {
let score = Number(item.count || 0) / 10000;
if (isLiverBiliaryModel()) {
if (isPortalPhaseSeries(item)) score += 900;
else if (isUpperAbdomenSeries(item)) score += 720;
} else if (isUpperAbdomenSeries(item)) {
score += 120;
}
if (isSkippedSeries(item)) score -= 1000;
return score;
};
return [...pool]
.sort((a, b) => {
const diff = rank(b) - rank(a);
if (Math.abs(diff) > 1e-6) return diff;
return Number(a.series_number || 999999) - Number(b.series_number || 999999);
})[0] || series[0] || null;
}
function pickDefaultStlIds(files) {
const priority = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen", "vertebrae"];
const ranked = [...files].sort((a, b) => {
const af = String(a.family || a.segment_name || a.file_name || "").toLowerCase();
const bf = String(b.family || b.segment_name || b.file_name || "").toLowerCase();
const ai = priority.findIndex((key) => af === key || af.includes(key));
const bi = priority.findIndex((key) => bf === key || bf.includes(key));
const ar = ai < 0 ? 999 : ai;
const br = bi < 0 ? 999 : bi;
if (ar !== br) return ar - br;
return Number(a.id || 0) - Number(b.id || 0);
});
return new Set(ranked.slice(0, Math.min(6, ranked.length)).map((file) => Number(file.id)));
}
function currentSeries() {
return state.series.find((item) => item.series_uid === state.selectedSeriesUid) || null;
}
function renderActiveHeader() {
const row = state.activeCase;
if (!row) return;
const registration = registrationSeries();
const selectedLabels = registration?.annotation_labels?.length ? registration.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : [];
$("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`;
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${row.patient_id || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
const tags = [
`<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
`<span class="tag">${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}</span>`,
...selectedLabels.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
];
$("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("")
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
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) => `<i>${escapeHtml(label)}</i>`).join("");
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
const skipped = isSkippedSeries(item);
return `
<div class="series-card ${active ? "active" : ""} ${registrationSelected ? "registration-selected" : ""} ${skipped ? "skipped" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}" role="button" tabindex="0">
<button class="series-check" data-registration-series="${escapeHtml(item.series_uid)}" type="button" aria-label="设为配准 DICOM">${registrationSelected ? "✓" : ""}</button>
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
<small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
<div class="mini-tags">
${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""}
${labels || `<i>未标注</i>`}
</div>
</div>
`;
}
function registrationSeries() {
return state.series.find((item) => item.series_uid === state.registrationSeriesUid) || null;
}
async function setRegistrationSeries(uid) {
if (!uid) return;
if (uid !== state.selectedSeriesUid) {
await selectSeries(uid, { markRegistration: true });
return;
}
state.registrationSeriesUid = uid;
markDirty("series");
renderSeries();
renderActiveHeader();
}
async function selectSeries(uid, options = {}) {
if (!uid || uid === state.selectedSeriesUid) return;
if (!(await confirmChange())) return;
state.selectedSeriesUid = uid;
if (options.markRegistration) state.registrationSeriesUid = uid;
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
state.sliceRangeStart = 0;
state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
if (options.markRegistration) markDirty("series");
else setSaveState("正在浏览序列", "");
renderSeries();
renderActiveHeader();
renderDicomAnnotation();
await loadFusion();
}
function renderStl() {
$("stlCount").textContent = state.stlFiles.length;
const models = [...new Set(state.stlFiles.map((item) => item.algorithm_model || state.algorithmModel || "未指定模型"))];
$("modelRail").innerHTML = models.map((model) => `<span class="chip active">${escapeHtml(model)}</span>`).join("");
$("stlList").innerHTML = state.stlFiles.length
? state.stlFiles
.map((file, index) => {
const checked = state.selectedStlIds.has(Number(file.id));
return `
<label class="stl-row ${checked ? "active" : ""}" title="${escapeHtml(file.file_name)}">
<input type="checkbox" data-stl="${Number(file.id)}" ${checked ? "checked" : ""} />
<span>
<strong>${escapeHtml(file.segment_name || file.file_name)}</strong>
<small>${escapeHtml(file.family || "未分类")} · ${escapeHtml(file.category || "")} · ${formatBytes(file.size_bytes)}</small>
<small style="color:${cssColor(index)}">${escapeHtml(file.file_name)}</small>
</span>
</label>
`;
})
.join("")
: `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
$("stlList").querySelectorAll("input[data-stl]").forEach((input) => {
input.addEventListener("change", async () => {
const id = Number(input.dataset.stl);
if (input.checked) state.selectedStlIds.add(id);
else state.selectedStlIds.delete(id);
await applyStlSelectionChange();
});
});
renderAutoBoneOptions();
}
async function selectAllStl() {
if (!state.stlFiles.length) return;
state.selectedStlIds = new Set(state.stlFiles.map((file) => Number(file.id)).filter(Number.isFinite));
await applyStlSelectionChange();
}
async function invertStlSelection() {
if (!state.stlFiles.length) return;
const next = new Set();
state.stlFiles.forEach((file) => {
const id = Number(file.id);
if (Number.isFinite(id) && !state.selectedStlIds.has(id)) next.add(id);
});
state.selectedStlIds = next;
await applyStlSelectionChange();
}
async function applyStlSelectionChange() {
markDirty("stl");
renderStl();
const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite));
const needsLoad = [...state.selectedStlIds].some((id) => !loadedIds.has(Number(id)));
if (!state.volumeMeta || (!state.modelMeshes.length && state.selectedStlIds.size)) {
await loadFusion();
return;
}
if (needsLoad) {
setFusionLoading(true, "正在补加载 STL 模型", 62);
await buildStlModels(state.volumeMeta, (progress) => setFusionLoading(true, "正在补加载 STL 模型", 62 + progress * 0.3));
setFusionLoading(false);
applyModelDetail();
if (state.sceneReady) fitCamera();
renderStl();
return;
}
applyStlVisibility();
applyModelDetail();
scheduleMappingDraw();
}
function stlLikelyScore(file) {
const textValue = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
const priority = [
["rib", 95],
["vertebra", 94],
["spine", 92],
["sternum", 90],
["pelvis", 82],
["skull", 78],
["bone", 72],
];
return priority.reduce((score, [key, value]) => textValue.includes(key) ? Math.max(score, value) : score, 0);
}
function stlDisplayName(file) {
return String(file.segment_name || file.family || file.file_name || `stl_${file.id || ""}`).replace(/\\.stl$/i, "");
}
function renderAutoBoneOptions() {
const container = $("autoBoneOptions");
if (!container) return;
if (!state.stlFiles.length) {
container.innerHTML = `<div class="empty-state compact-empty">No STL</div>`;
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 `
<label title="${title}" class="${likely ? "likely" : ""}">
<input type="checkbox" value="${id}" ${checked} />
<span>${escapeHtml(stlDisplayName(file))}</span>
${likely ? `<em>priority</em>` : ""}
</label>
`;
}).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) => `
<div class="pose-control" data-pose="${item.key}">
<label>${item.label}</label>
<button type="button" data-nudge="${item.key}" data-delta="${-item.step}">-</button>
<input type="range" min="${item.min}" max="${item.max}" step="${item.step}" />
<button type="button" data-nudge="${item.key}" data-delta="${item.step}">+</button>
<input type="number" min="${item.min}" max="${item.max}" step="${item.step}" />
${item.key.startsWith("rotate") ? `<div class="quick-row"><button type="button" data-nudge="${item.key}" data-delta="-90">-90°</button><button type="button" data-nudge="${item.key}" data-delta="90">+90°</button></div>` : ""}
</div>
`,
).join("");
$("poseGrid").querySelectorAll(".pose-control").forEach((row) => {
const key = row.dataset.pose;
const range = row.querySelector("input[type='range']");
const number = row.querySelector("input[type='number']");
const option = POSE_CONTROLS.find((item) => item.key === key);
const sync = (value, source) => {
const numeric = Number(value);
state.pose[key] = Number.isFinite(numeric) ? Number(numeric.toFixed(option?.decimals ?? 3)) : DEFAULT_POSE[key];
if (source !== range) range.value = state.pose[key];
if (source !== number) number.value = state.pose[key];
applyPose();
markDirty();
};
range.addEventListener("input", () => sync(range.value, range));
number.addEventListener("input", () => sync(number.value, number));
});
document.querySelectorAll("[data-nudge]").forEach((button) => wireHoldNudge(button));
document.querySelectorAll("[data-flip]").forEach((button) => {
button.addEventListener("click", () => {
const key = button.dataset.flip;
state.pose[key] = !state.pose[key];
renderPoseControls();
applyPose();
markDirty();
});
});
updateAutoPoseResult(state.pose, state.pose);
}
function wireHoldNudge(button) {
const fixedDelta = Number(button.dataset.delta || 0);
if (Math.abs(fixedDelta) >= 10) {
button.addEventListener("click", () => nudgePose(button.dataset.nudge, fixedDelta));
return;
}
const start = (event) => {
if (event.button !== undefined && event.button !== 0) return;
event.preventDefault();
const key = button.dataset.nudge;
const delta = Number(button.dataset.delta || 0);
nudgePose(key, delta);
clearNudgeTimers();
state.nudgeDelayTimer = window.setTimeout(() => {
state.nudgeTimer = window.setInterval(() => nudgePose(key, delta), 54);
}, 260);
button.setPointerCapture?.(event.pointerId);
};
const stop = (event) => {
clearNudgeTimers();
if (event?.pointerId !== undefined && button.hasPointerCapture?.(event.pointerId)) {
button.releasePointerCapture(event.pointerId);
}
};
button.addEventListener("pointerdown", start);
button.addEventListener("pointerup", stop);
button.addEventListener("pointercancel", stop);
button.addEventListener("pointerleave", stop);
}
function clearNudgeTimers() {
window.clearTimeout(state.nudgeDelayTimer);
window.clearInterval(state.nudgeTimer);
state.nudgeDelayTimer = null;
state.nudgeTimer = null;
}
function nudgePose(key, delta) {
if (!key || !Number.isFinite(delta)) return;
const option = POSE_CONTROLS.find((item) => item.key === key);
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
const next = Math.max(option?.min ?? -Infinity, Math.min(option?.max ?? Infinity, current + delta));
state.pose[key] = Number(next.toFixed(option?.decimals ?? 3));
renderPoseControls();
applyPose();
markDirty();
}
function renderPoseControls() {
POSE_CONTROLS.forEach((item) => {
const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`);
if (!row) return;
const value = Number(state.pose[item.key] ?? DEFAULT_POSE[item.key]);
row.querySelector("input[type='range']").value = value;
row.querySelector("input[type='number']").value = Number.isInteger(value) ? value : value.toFixed(item.decimals ?? 3);
});
document.querySelectorAll("[data-flip]").forEach((button) => {
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
});
renderActiveHeader();
updateStretchButtons();
}
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();
["x", "y", "z"].forEach((axis) => {
const button = $(`stretch${axis.toUpperCase()}Btn`);
if (!button) return;
button.disabled = !enabled;
button.title = enabled
? `${axis.toUpperCase()} 方向自动等比例拉伸模型`
: "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可用";
});
}
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;
material.opacity = detail.opacity;
material.transparent = detail.opacity < 1;
material.depthWrite = detail.depthWrite;
material.wireframe = detail.wireframe;
material.metalness = detail.metalness;
material.roughness = detail.roughness;
material.needsUpdate = true;
});
scheduleMappingDraw();
}
function applyStlVisibility() {
const selected = state.selectedStlIds;
state.modelMeshes.forEach((record) => {
record.mesh.visible = selected.has(Number(record.file?.id));
});
if (state.modelBoundsFrame) state.modelBoundsFrame.visible = state.modelMeshes.some((record) => record.mesh.visible);
if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model";
if (state.modelGroup) state.modelGroup.visible = true;
updateFusionMeta();
scheduleMappingDraw();
}
function syncDisplayControls() {
document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item.dataset.fusionDetail === state.fusionDetail));
document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item.dataset.modelDetail === state.modelDetail));
document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode));
}
async function loadFusion() {
const version = ++state.fusionVersion;
const selected = currentSeries();
if (!state.sceneReady) initGeometryFallback();
if (!state.activeCase || !selected) {
clearFusion();
return;
}
$("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 = `<div class="empty-state">${escapeHtml(message)}</div>`;
}
function scheduleMappingDraw() {
window.cancelAnimationFrame(state.mappingDrawFrame);
const version = ++state.mappingVersion;
state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView(version));
}
function applyMappingTransform() {
const layer = $("mappingLayer");
if (!layer) return;
const { scale, offsetX, offsetY, rotation } = state.mappingViewport;
layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) rotate(${rotation || 0}deg) scale(${scale})`;
}
function intersectEdgeWithZ(a, b, targetZ) {
const da = a.z - targetZ;
const db = b.z - targetZ;
if (Math.abs(da) < 1e-5 && Math.abs(db) < 1e-5) return [a.clone(), b.clone()];
if (Math.abs(da) < 1e-5) return [a.clone()];
if (Math.abs(db) < 1e-5) return [b.clone()];
if ((da > 0 && db > 0) || (da < 0 && db < 0)) return [];
const t = da / (da - db);
return [new THREE.Vector3(
a.x + (b.x - a.x) * t,
a.y + (b.y - a.y) * t,
targetZ,
)];
}
function uniquePlanePoints(points) {
const unique = [];
points.forEach((point) => {
if (!unique.some((item) => item.distanceToSquared(point) < 1e-8)) unique.push(point);
});
return unique;
}
function intersectTriangleWithZ(a, b, c, targetZ) {
const points = uniquePlanePoints([
...intersectEdgeWithZ(a, b, targetZ),
...intersectEdgeWithZ(b, c, targetZ),
...intersectEdgeWithZ(c, a, targetZ),
]);
if (points.length < 2) return null;
return { a: points[0], b: points[1] };
}
function addSegmentIntersectionsToRows(rows, width, height, segment) {
const a = segment.a;
const b = segment.b;
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return;
const deltaY = b.y - a.y;
if (Math.abs(deltaY) < 0.01) {
return;
}
const minY = Math.max(0, Math.floor(Math.min(a.y, b.y)));
const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y)));
for (let row = minY; row <= maxY; row += 1) {
const sampleY = row + 0.5;
const crosses = (sampleY >= a.y && sampleY < b.y) || (sampleY >= b.y && sampleY < a.y);
if (!crosses) continue;
const t = (sampleY - a.y) / deltaY;
const x = a.x + (b.x - a.x) * t;
if (Number.isFinite(x)) rows[row].push(x);
}
}
function groupPlaneSegmentsByConnectivity(segments, tolerance = 1.35) {
if (segments.length <= 1) return segments.length ? [segments] : [];
const parents = segments.map((_, index) => index);
const find = (index) => {
if (parents[index] !== index) parents[index] = find(parents[index]);
return parents[index];
};
const union = (left, right) => {
const leftRoot = find(left);
const rightRoot = find(right);
if (leftRoot !== rightRoot) parents[rightRoot] = leftRoot;
};
const buckets = new Map();
const cellSize = Math.max(tolerance, 0.1);
const toleranceSquared = tolerance * tolerance;
const cellKey = (x, y) => `${x},${y}`;
segments.forEach((segment, index) => {
[segment.a, segment.b].forEach((point) => {
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
const cellX = Math.floor(point.x / cellSize);
const cellY = Math.floor(point.y / cellSize);
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
const candidates = buckets.get(cellKey(cellX + dx, cellY + dy));
candidates?.forEach((candidate) => {
const distanceSquared = (candidate.x - point.x) ** 2 + (candidate.y - point.y) ** 2;
if (distanceSquared <= toleranceSquared) union(index, candidate.index);
});
}
}
const key = cellKey(cellX, cellY);
const bucket = buckets.get(key) ?? [];
bucket.push({ x: point.x, y: point.y, index });
buckets.set(key, bucket);
});
});
const groups = new Map();
segments.forEach((segment, index) => {
const root = find(index);
const group = groups.get(root) ?? [];
group.push(segment);
groups.set(root, group);
});
return [...groups.values()].sort((left, right) => right.length - left.length);
}
function paintMaskPixel(maskData, width, height, x, y, rgb, alpha) {
if (x < 0 || x >= width || y < 0 || y >= height) return 0;
const offset = (y * width + x) * 4;
if (maskData.data[offset + 3] > 0) return 0;
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
return 1;
}
function solidStrokeRadius(width, height) {
return Math.max(2.2, Math.min(5.5, Math.max(width, height) * 0.006));
}
function fillSegmentCapsulesIntoMask(maskData, width, height, segments, rgb, alpha, radius) {
let paintedPixels = 0;
const radiusSquared = radius * radius;
segments.forEach(({ a, b }) => {
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return;
const dx = b.x - a.x;
const dy = b.y - a.y;
const lengthSquared = dx * dx + dy * dy;
const minX = clamp(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1);
const maxX = clamp(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1);
const minY = clamp(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1);
const maxY = clamp(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1);
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
const px = x + 0.5;
const py = y + 0.5;
const t = lengthSquared <= 1e-6
? 0
: clamp(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1);
const closestX = a.x + dx * t;
const closestY = a.y + dy * t;
if ((px - closestX) ** 2 + (py - closestY) ** 2 <= radiusSquared) {
paintedPixels += paintMaskPixel(maskData, width, height, x, y, rgb, alpha);
}
}
}
});
return paintedPixels;
}
function closeSmallMaskGaps(maskData, width, height, rgb, alpha, maxGap = 2) {
const toFill = new Set();
const hasPixel = (x, y) => maskData.data[(y * width + x) * 4 + 3] > 0;
const mark = (x, y) => {
if (x >= 0 && x < width && y >= 0 && y < height && !hasPixel(x, y)) toFill.add(y * width + x);
};
for (let y = 0; y < height; y += 1) {
let lastFilled = -1;
for (let x = 0; x < width; x += 1) {
if (!hasPixel(x, y)) continue;
const gap = x - lastFilled - 1;
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
for (let fillX = lastFilled + 1; fillX < x; fillX += 1) mark(fillX, y);
}
lastFilled = x;
}
}
for (let x = 0; x < width; x += 1) {
let lastFilled = -1;
for (let y = 0; y < height; y += 1) {
if (!hasPixel(x, y)) continue;
const gap = y - lastFilled - 1;
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
for (let fillY = lastFilled + 1; fillY < y; fillY += 1) mark(x, fillY);
}
lastFilled = y;
}
}
toFill.forEach((index) => {
const offset = index * 4;
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
});
return toFill.size;
}
function fillInternalMaskHoles(maskData, width, height, rgb, alpha) {
const outside = new Uint8Array(width * height);
const stack = [];
const pushIfEmpty = (x, y) => {
if (x < 0 || x >= width || y < 0 || y >= height) return;
const index = y * width + x;
if (outside[index] || maskData.data[index * 4 + 3] > 0) return;
outside[index] = 1;
stack.push(index);
};
for (let x = 0; x < width; x += 1) {
pushIfEmpty(x, 0);
pushIfEmpty(x, height - 1);
}
for (let y = 0; y < height; y += 1) {
pushIfEmpty(0, y);
pushIfEmpty(width - 1, y);
}
while (stack.length) {
const index = stack.pop();
const x = index % width;
const y = Math.floor(index / width);
pushIfEmpty(x + 1, y);
pushIfEmpty(x - 1, y);
pushIfEmpty(x, y + 1);
pushIfEmpty(x, y - 1);
}
let patchedPixels = 0;
for (let index = 0; index < outside.length; index += 1) {
const offset = index * 4;
if (!outside[index] && maskData.data[offset + 3] === 0) {
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
patchedPixels += 1;
}
}
return patchedPixels;
}
function drawFallbackClosedRegion(context, width, height, segments, color, opacity = 0.72) {
const points = segments.flatMap((segment) => [segment.a, segment.b])
.filter((point) => (
Number.isFinite(point.x)
&& Number.isFinite(point.y)
&& point.x >= -width
&& point.x <= width * 2
&& point.y >= -height
&& point.y <= height * 2
));
if (points.length < 3) return 0;
const uniquePoints = [];
points.forEach((point) => {
if (!uniquePoints.some((current) => (current.x - point.x) ** 2 + (current.y - point.y) ** 2 < 1e-6)) uniquePoints.push(point);
});
if (uniquePoints.length < 3) return 0;
const sorted = [...uniquePoints].sort((left, right) => (Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y));
const cross = (origin, a, b) => (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x);
const lower = [];
sorted.forEach((point) => {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) lower.pop();
lower.push(point);
});
const upper = [];
[...sorted].reverse().forEach((point) => {
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) upper.pop();
upper.push(point);
});
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
const ordered = hull.length >= 3 ? hull : uniquePoints;
context.save();
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62;
context.fillStyle = color;
context.beginPath();
ordered.forEach((point, index) => {
if (index === 0) context.moveTo(point.x, point.y);
else context.lineTo(point.x, point.y);
});
context.closePath();
context.fill();
context.restore();
return Math.max(1, Math.round(ordered.length / 2));
}
function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) {
if (!segments.length) return 0;
const rgb = parseCssColor(color);
const alpha = Math.round(clamp(opacity, 0.1, 1) * 190);
const maskCanvas = document.createElement("canvas");
maskCanvas.width = width;
maskCanvas.height = height;
const maskContext = maskCanvas.getContext("2d");
if (!maskContext) return 0;
const maskData = maskContext.createImageData(width, height);
const radius = solidStrokeRadius(width, height);
const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15);
let filledPixels = 0;
groups.forEach((group) => {
const rows = Array.from({ length: height }, () => []);
group.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
let groupPixels = 0;
rows.forEach((intersections, row) => {
if (intersections.length < 2) return;
intersections.sort((left, right) => left - right);
const cleaned = [];
intersections.forEach((x) => {
const previous = cleaned[cleaned.length - 1];
if (previous === undefined || Math.abs(previous - x) > 0.35) cleaned.push(x);
});
for (let index = 0; index + 1 < cleaned.length; index += 2) {
const rawStartX = cleaned[index];
const rawEndX = cleaned[index + 1];
if (rawEndX < 0 || rawStartX > width - 1) continue;
const startX = clamp(Math.ceil(rawStartX), 0, width - 1);
const endX = clamp(Math.floor(rawEndX), 0, width - 1);
if (endX < startX) continue;
for (let x = startX; x <= endX; x += 1) {
const offset = (row * width + x) * 4;
if (maskData.data[offset + 3] === 0) {
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
groupPixels += 1;
}
}
}
});
groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius);
filledPixels += groupPixels;
});
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3);
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
maskContext.putImageData(maskData, 0, 0);
context.drawImage(maskCanvas, 0, 0);
if (filledPixels === 0) {
context.save();
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.42;
context.strokeStyle = color;
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
segments.forEach((segment) => {
context.moveTo(segment.a.x, segment.a.y);
context.lineTo(segment.b.x, segment.b.y);
});
context.stroke();
context.restore();
}
context.save();
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.65;
context.strokeStyle = color;
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
segments.forEach((segment) => {
context.moveTo(segment.a.x, segment.a.y);
context.lineTo(segment.b.x, segment.b.y);
});
context.stroke();
context.restore();
return filledPixels;
}
async function drawMappingView(version = state.mappingVersion) {
const image = $("dicomPreview");
const canvas = $("mappingCanvas");
const volumeScene = state.volumeScene;
canvas.style.display = state.mappingMode === "image" ? "none" : "";
if (state.mappingMode === "image") {
setDicomLoading(false);
resetMappingCanvas("单独影像模式未叠加 STL 分割");
return;
}
const visibleRecords = visibleModelRecords();
if (!image?.complete || !image.naturalWidth || !volumeScene || !visibleRecords.length) {
if (!visibleRecords.length) setDicomLoading(false);
else setDicomLoading(true, "等待 DICOM Base Layer", 32);
resetMappingCanvas(visibleRecords.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
return;
}
setDicomLoading(true, "正在计算 STL 分割映射", 48);
const width = image.naturalWidth;
const height = image.naturalHeight;
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const context = canvas.getContext("2d");
context.clearRect(0, 0, width, height);
const targetZ = sliceToSceneZ(state.sliceIndex, volumeScene);
const mapPoint = (point) => ({
x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width,
y: (0.5 - point.y / volumeScene.height) * height,
});
const matrix = modelPoseMatrix();
let activeModules = 0;
let segmentCount = 0;
let filledPixels = 0;
const modules = [];
const tempA = new THREE.Vector3();
const tempB = new THREE.Vector3();
const tempC = new THREE.Vector3();
for (const [index, record] of visibleRecords.entries()) {
if (version !== state.mappingVersion) return;
const position = record.mesh.geometry.attributes.position;
if (!position) continue;
const triangleCount = Math.floor(position.count / 3);
const step = Math.max(1, Math.ceil(triangleCount / 260000));
const segments = [];
for (let tri = 0; tri < triangleCount; tri += step) {
tempA.fromBufferAttribute(position, tri * 3).applyMatrix4(matrix);
tempB.fromBufferAttribute(position, tri * 3 + 1).applyMatrix4(matrix);
tempC.fromBufferAttribute(position, tri * 3 + 2).applyMatrix4(matrix);
const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ);
if (!segment) continue;
const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) };
if (
mapped.a.x > -width && mapped.a.x < width * 2
&& mapped.b.x > -width && mapped.b.x < width * 2
&& mapped.a.y > -height && mapped.a.y < height * 2
&& mapped.b.y > -height && mapped.b.y < height * 2
) {
segments.push(mapped);
}
}
const pixels = fillSegmentsAsSolidMask(context, width, height, segments, record.color, 0.66);
if (segments.length || pixels) {
activeModules += 1;
segmentCount += segments.length;
filledPixels += pixels;
modules.push({
id: index + 1,
name: record.file.segment_name || record.file.file_name || `STL ${index + 1}`,
color: record.color,
segments: segments.length,
pixels,
});
}
const progress = 48 + ((index + 1) / Math.max(visibleRecords.length, 1)) * 48;
const label = record.file.segment_name || record.file.file_name || `STL ${index + 1}`;
setDicomLoading(true, `正在计算 ${label}`, progress);
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
if (version !== state.mappingVersion) return;
$("mappingStats").textContent = `${activeModules}/${visibleRecords.length} 构件 · ${segmentCount} 边 · ${filledPixels} px`;
$("mappingLegend").innerHTML = modules.length
? modules.map((item) => `
<div class="legend-item" title="${escapeHtml(item.name)}">
<span class="legend-color" style="background:${escapeHtml(item.color)}"></span>
<span class="legend-name">${escapeHtml(item.name)}</span>
<span class="legend-meta">ID ${item.id}</span>
<span></span>
<span class="legend-meta">${item.segments} 边</span>
<span class="legend-meta">${item.pixels} px</span>
</div>
`).join("")
: `<div class="empty-state">当前切片暂无可见构件</div>`;
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 material = new THREE.MeshStandardMaterial({
color: STL_COLORS[index % STL_COLORS.length],
metalness: detail.metalness,
roughness: detail.roughness,
transparent: true,
opacity: detail.opacity,
side: THREE.DoubleSide,
depthWrite: detail.depthWrite,
wireframe: detail.wireframe,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.name = file.segment_name || file.file_name;
mesh.userData.file = file;
mesh.userData.color = cssColor(index);
state.rawModelGroup.add(mesh);
const record = { mesh, file, color: cssColor(Number(file.id) || index), index: Number(file.id) || index };
state.modelMeshes.push(record);
freshRecords.push(record);
onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100);
}
if (!state.modelLocalBounds && state.modelMeshes.length) {
const rawBox = new THREE.Box3();
state.modelMeshes.forEach((record) => {
record.mesh.geometry.computeBoundingBox?.();
if (record.mesh.geometry.boundingBox) rawBox.union(record.mesh.geometry.boundingBox);
});
const center = new THREE.Vector3();
rawBox.getCenter(center);
state.modelMeshes.forEach((record) => {
const geometry = record.mesh.geometry;
geometry.translate(-center.x, -center.y, -center.z);
geometry.computeBoundingBox?.();
geometry.computeBoundingSphere?.();
geometry.computeVertexNormals?.();
});
state.modelLocalBounds = {
center: { x: center.x, y: center.y, z: center.z },
size: { x: 1, y: 1, z: 1 },
};
} else if (state.modelLocalBounds && freshRecords.length) {
const center = state.modelLocalBounds.center;
freshRecords.forEach((record) => {
const geometry = record.mesh.geometry;
geometry.translate(-Number(center.x || 0), -Number(center.y || 0), -Number(center.z || 0));
geometry.computeBoundingBox?.();
geometry.computeBoundingSphere?.();
geometry.computeVertexNormals?.();
});
}
const bbox = new THREE.Box3();
state.modelMeshes.forEach((record) => {
record.mesh.geometry.computeBoundingBox?.();
if (record.mesh.geometry.boundingBox) bbox.union(record.mesh.geometry.boundingBox);
});
const size = new THREE.Vector3();
if (!bbox.isEmpty()) bbox.getSize(size);
if (state.modelLocalBounds) state.modelLocalBounds.size = { x: size.x, y: size.y, z: size.z };
updateBaseModelScaleFromBounds();
if (size.x || size.y || size.z) addModelBoundsFrame(size);
applyStlVisibility();
applyPose();
scheduleMappingDraw();
onProgress?.(100);
}
function applyPose(poseInput = state.pose) {
if (!state.modelGroup) return;
const pose = { ...DEFAULT_POSE, ...poseInput };
state.modelGroup.rotation.set(
THREE.MathUtils.degToRad(Number(pose.rotateX) || 0),
THREE.MathUtils.degToRad(Number(pose.rotateY) || 0),
THREE.MathUtils.degToRad(Number(pose.rotateZ) || 0),
);
state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0);
const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale;
state.modelGroup.scale.set(
scale * (pose.flipX ? -1 : 1),
scale * (pose.flipY ? -1 : 1),
scale * (pose.flipZ ? -1 : 1),
);
state.modelGroup.updateMatrixWorld(true);
scheduleMappingDraw();
}
function fitCamera() {
if (!state.camera) return;
applySceneViewPose();
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 (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 (!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;
};
return {
adjustable: {
translateX: $("autoX")?.checked,
translateY: $("autoY")?.checked,
translateZ: $("autoZ")?.checked,
scale: $("autoScale")?.checked,
},
sampleSlices: Math.max(3, Math.min(60, Math.round(numericValue("autoSampleSlicesNumber", 9)))),
boneReward: numericValue("autoBoneReward", 1),
outsidePenalty: numericValue("autoOutsidePenalty", 0.1),
movePenalty: numericValue("autoMovePenalty", 0),
scalePenalty: numericValue("autoScalePenalty", 0),
iterations: Math.max(1, Math.min(100, Math.round(numericValue("autoIterations", 30)))),
candidates: Math.max(6, Math.min(96, Math.round(numericValue("autoCandidates", 36)))),
};
}
function poseDelta(from, to) {
return {
translateX: Number(((to.translateX || 0) - (from.translateX || 0)).toFixed(3)),
translateY: Number(((to.translateY || 0) - (from.translateY || 0)).toFixed(3)),
translateZ: Number(((to.translateZ || 0) - (from.translateZ || 0)).toFixed(3)),
scale: Number(((to.scale || 1) / Math.max(0.001, from.scale || 1)).toFixed(3)),
};
}
function 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 `
<span class="${changed ? "changed" : "muted"}">
<b>${escapeHtml(label)}</b>
<strong>${escapeHtml(deltaText)}</strong>
<small>${escapeHtml(currentText)}</small>
</span>
`;
}
function updateAutoPoseResult(before = 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.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 restoreAutoPose() {
if (!state.lastAutoPose?.before) {
$("autoResult").textContent = "暂无可退回的自动调整位姿。";
return;
}
state.pose = { ...state.lastAutoPose.before };
renderPoseControls();
applyPose();
fitCamera();
updateAutoPoseResult(state.lastAutoPose.after, state.pose);
$("autoResult").textContent = "已退回到自动调整前的位姿。";
markDirty();
}
function candidateScore(pose, settings = null) {
if (!state.dicomSceneBox || !state.modelGroup || !visibleModelRecords().length) return -Infinity;
const config = settings || readAutoSettings();
const current = { ...state.pose };
applyPose(pose);
const modelBox = visibleModelBox();
applyPose(current);
if (!modelBox) return -Infinity;
const overlap = modelBox.clone().intersect(state.dicomSceneBox);
const overlapSize = new THREE.Vector3();
overlap.getSize(overlapSize);
const modelSize = new THREE.Vector3();
const dicomSize = new THREE.Vector3();
modelBox.getSize(modelSize);
state.dicomSceneBox.getSize(dicomSize);
const overlapVolume = Math.max(0, overlapSize.x) * Math.max(0, overlapSize.y) * Math.max(0, overlapSize.z);
const modelVolume = Math.max(modelSize.x * modelSize.y * modelSize.z, 0.001);
const modelCenter = new THREE.Vector3();
const dicomCenter = new THREE.Vector3();
modelBox.getCenter(modelCenter);
state.dicomSceneBox.getCenter(dicomCenter);
const centerPenalty = modelCenter.distanceTo(dicomCenter) / Math.max(dicomSize.length(), 0.001);
const scalePenalty = Math.abs((pose.scale || 1) - 1) * (0.03 + Number(config.scalePenalty || 0));
const movePenalty = (Math.abs(pose.translateX || 0) + Math.abs(pose.translateY || 0) + Math.abs(pose.translateZ || 0)) * Number(config.movePenalty || 0);
return (overlapVolume / modelVolume) * Number(config.boneReward || 1) - centerPenalty - scalePenalty - movePenalty - Number(config.outsidePenalty || 0) * 0.01;
}
function estimateCoarsePose(settings) {
const modelBox = visibleModelBox();
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);
const nextBox = visibleModelBox();
applyPose(previous);
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.activeCase || !currentSeries() || !visibleModelRecords().length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
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 } };
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();
}
function runAutoFine() {
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
setAutoState("运行中", "warn");
$("autoResult").textContent = "正在进行几何重叠微调...";
const before = { ...state.pose };
const settings = readAutoSettings();
const adjustable = settings.adjustable;
let best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" };
let evaluated = 1;
const rounds = Array.from({ length: settings.iterations }, (_, index) => {
const t = Math.max(0.18, 1 - index / Math.max(settings.iterations, 1));
return { move: 0.34 * t, scale: 0.12 * t };
});
for (const round of rounds) {
const candidates = [];
if (adjustable.translateX) candidates.push(["translateX", round.move], ["translateX", -round.move]);
if (adjustable.translateY) candidates.push(["translateY", round.move], ["translateY", -round.move]);
if (adjustable.translateZ) candidates.push(["translateZ", round.move], ["translateZ", -round.move]);
if (adjustable.scale) candidates.push(["scale", round.scale], ["scale", -round.scale]);
for (const [key, delta] of candidates.slice(0, settings.candidates)) {
const pose = { ...best.pose, [key]: key === "scale" ? Math.max(0.2, Math.min(3, best.pose.scale + delta)) : best.pose[key] + delta };
const score = candidateScore(pose, settings);
evaluated += 1;
if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` };
}
}
state.pose = { ...best.pose };
state.autoResult = { ...best, evaluated, settings };
state.lastAutoPose = { before, after: { ...state.pose } };
renderPoseControls();
applyPose();
fitCamera();
setAutoState("微调完成", "ok");
$("autoResult").textContent = `最佳候选:${best.mode}score ${best.score.toFixed(4)};评估 ${evaluated} 个候选;采样 ${settings.sampleSlices} 张。`;
updateAutoPoseResult(before, state.pose);
markDirty();
}
async function saveRegistration(nextStatus = null) {
if (!state.activeCase || !currentSeries() || state.saving) return;
state.saving = true;
$("saveBtn").disabled = true;
$("statusBtn").disabled = true;
setSaveState("保存中", "warn");
const selectedSeries = currentSeries();
const status = nextStatus || state.registrationStatus;
const selectedFiles = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
try {
await api("/api/registrations", {
method: "POST",
body: JSON.stringify({
ct_number: state.activeCase.ct_number,
algorithm_model: state.algorithmModel,
registration_status: status,
series_instance_uid: selectedSeries.series_uid,
series_description: selectedSeries.description || "",
selected_stl_files: selectedFiles.map((file) => ({
id: file.id,
file_name: file.file_name,
segment_name: file.segment_name,
family: file.family,
category: file.category,
algorithm_model: file.algorithm_model,
})),
transform: state.pose,
module_styles: Object.fromEntries(selectedFiles.map((file, index) => [file.file_name, { color: cssColor(index), opacity: modelDetailSettings().opacity, detail: state.modelDetail }])),
dicom_reference: {
series_uid: selectedSeries.series_uid,
series_number: selectedSeries.series_number,
description: selectedSeries.description,
slice_index: state.sliceIndex,
slice_count: selectedSeries.count,
window: state.windowMode,
volume: state.volumeMeta?.physicalSize || null,
},
model_reference: {
algorithm_model: state.algorithmModel,
selected_count: selectedFiles.length,
families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))],
fusion_mode: state.fusionMode,
fusion_detail: state.fusionDetail,
model_detail: state.modelDetail,
auto_result: state.autoResult,
},
notes: $("notes").value,
}),
});
state.registrationStatus = status;
state.registrationSeriesUid = selectedSeries.series_uid;
if (state.activeCase) {
state.activeCase.registration_status = status;
state.activeCase.series_instance_uid = selectedSeries.series_uid;
state.activeCase.series_description = selectedSeries.description || "";
}
const existing = state.cases.find((row) => sameCase(row, state.activeCase));
if (existing) {
existing.registration_status = status;
existing.series_instance_uid = selectedSeries.series_uid;
existing.series_description = selectedSeries.description || "";
}
resetDirty();
renderCases();
renderSeries();
renderActiveHeader();
} catch (error) {
setSaveState(error.message, "error");
} finally {
state.saving = false;
$("saveBtn").disabled = false;
$("statusBtn").disabled = false;
}
}
function sameHostUrl(raw, targetPort) {
try {
const url = new URL(raw);
const here = new URL(window.location.href);
if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname)) url.hostname = here.hostname;
if (targetPort) url.port = String(targetPort);
return url.toString();
} catch {
const here = new URL(window.location.href);
here.port = String(targetPort || here.port);
here.pathname = "/";
here.search = "";
return here.toString();
}
}
function openLinkedApp(kind) {
const ct = state.activeCase?.ct_number || "";
const model = state.algorithmModel || "";
if (kind === "viewer") {
const url = new URL(sameHostUrl(state.links.pacs_viewer_url, 8107));
if (ct) url.searchParams.set("ct_number", ct);
window.open(url.toString(), "_blank");
} else {
const url = new URL(sameHostUrl(state.links.relation_viewer_url, 8108));
if (ct) url.searchParams.set("ct_number", ct);
if (model) url.searchParams.set("algorithm_model", model);
window.open(url.toString(), "_blank");
}
}
function setWindowMode(mode, reload = true) {
state.windowMode = mode || "default";
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item.dataset.window === state.windowMode));
document.querySelectorAll("[data-map-window]").forEach((item) => item.classList.toggle("active", item.dataset.mapWindow === state.windowMode));
if (reload) loadFusion();
}
function setMappingMode(mode) {
state.mappingMode = mode === "image" ? "image" : "result";
document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode));
renderDicomAnnotation();
scheduleMappingDraw();
}
function wireEvents() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", () => logout());
$("caseToggleBtn").addEventListener("click", () => {
state.caseCollapsed = !state.caseCollapsed;
document.querySelector(".workspace").classList.toggle("case-collapsed", state.caseCollapsed);
$("caseToggleBtn").classList.toggle("active", state.caseCollapsed);
window.setTimeout(() => {
state.resizeScene?.();
fitCamera();
}, 230);
});
$("refreshBtn").addEventListener("click", async () => {
await loadStatus();
await loadSettings();
await loadCases(false);
});
$("settingsBtn").addEventListener("click", async () => {
$("settingsPanel").classList.remove("hidden");
await loadSettings();
});
$("settingsCloseBtn").addEventListener("click", () => $("settingsPanel").classList.add("hidden"));
$("saveSettingsBtn").addEventListener("click", saveSettings);
$("manualCacheRefreshBtn").addEventListener("click", refreshCacheNow);
$("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("fitBtn").addEventListener("click", resetSceneView);
$("saveBtn").addEventListener("click", () => saveRegistration());
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
$("notes").addEventListener("input", () => markDirty("notes"));
$("selectAllStlBtn").addEventListener("click", selectAllStl);
$("invertStlBtn").addEventListener("click", invertStlSelection);
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
$("stretchXBtn").addEventListener("click", () => stretchAxis("x"));
$("stretchYBtn").addEventListener("click", () => stretchAxis("y"));
$("stretchZBtn").addEventListener("click", () => stretchAxis("z"));
$("openAutoModalBtn").addEventListener("click", () => {
$("autoSettingsPanel").scrollIntoView({ block: "nearest", behavior: "smooth" });
});
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
$("autoFineBtn").addEventListener("click", runAutoFine);
$("savePoseBtn").addEventListener("click", () => saveRegistration());
$("saveIterateBtn").addEventListener("click", async () => {
await saveRegistration();
runAutoFine();
});
$("restorePoseBtn").addEventListener("click", restoreAutoPose);
const syncSampleSlices = (source) => {
const range = $("autoSampleSlices");
const number = $("autoSampleSlicesNumber");
const value = Math.max(3, Math.min(60, Number(source.value || 9)));
range.value = value;
number.value = value;
};
$("autoSampleSlices").addEventListener("input", () => syncSampleSlices($("autoSampleSlices")));
$("autoSampleSlicesNumber").addEventListener("input", () => syncSampleSlices($("autoSampleSlicesNumber")));
$("autoDefaultsBtn").addEventListener("click", () => {
$("autoSampleSlices").value = 9;
$("autoSampleSlicesNumber").value = 9;
$("autoIterations").value = 30;
});
$("dicomPreview").addEventListener("load", () => {
setDicomLoading(true, "正在计算 STL 分割映射", 42);
scheduleMappingDraw();
});
$("dicomPreview").addEventListener("error", () => {
setDicomLoading(false);
resetMappingCanvas("DICOM Base Layer 加载失败");
});
$("mappingResetBtn").addEventListener("click", () => {
state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 };
applyMappingTransform();
});
$("mappingRotateLeftBtn").addEventListener("click", () => {
state.mappingViewport.rotation = (Number(state.mappingViewport.rotation) || 0) - 90;
applyMappingTransform();
});
$("mappingRotateRightBtn").addEventListener("click", () => {
state.mappingViewport.rotation = (Number(state.mappingViewport.rotation) || 0) + 90;
applyMappingTransform();
});
$("mappingViewport").addEventListener("wheel", (event) => {
event.preventDefault();
const factor = event.deltaY > 0 ? 0.9 : 1.1;
state.mappingViewport.scale = Math.max(0.45, Math.min(6, state.mappingViewport.scale * factor));
applyMappingTransform();
}, { passive: false });
$("mappingViewport").addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
state.mappingDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
offsetX: state.mappingViewport.offsetX,
offsetY: state.mappingViewport.offsetY,
};
$("mappingViewport").classList.add("dragging");
$("mappingViewport").setPointerCapture(event.pointerId);
});
$("mappingViewport").addEventListener("pointermove", (event) => {
const drag = state.mappingDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.mappingViewport.offsetX = drag.offsetX + event.clientX - drag.startX;
state.mappingViewport.offsetY = drag.offsetY + event.clientY - drag.startY;
applyMappingTransform();
});
const stopMappingDrag = (event) => {
const drag = state.mappingDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.mappingDrag = null;
$("mappingViewport").classList.remove("dragging");
if ($("mappingViewport").hasPointerCapture(event.pointerId)) $("mappingViewport").releasePointerCapture(event.pointerId);
};
$("mappingViewport").addEventListener("pointerup", stopMappingDrag);
$("mappingViewport").addEventListener("pointercancel", stopMappingDrag);
document.querySelectorAll("[data-tool-tab]").forEach((button) => {
button.addEventListener("click", () => {
state.activeToolTab = button.dataset.toolTab || "series";
document.querySelectorAll("[data-tool-tab]").forEach((item) => item.classList.toggle("active", item === button));
document.querySelectorAll("[data-tool-pane]").forEach((pane) => pane.classList.toggle("active", pane.dataset.toolPane === state.activeToolTab));
});
});
$("caseSearch").addEventListener("input", () => {
state.search = $("caseSearch").value.trim();
window.clearTimeout(state.searchTimer);
state.searchTimer = window.setTimeout(() => loadCases(false), 240);
});
$("caseList").addEventListener("scroll", () => {
const list = $("caseList");
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 180) {
loadMoreCases();
}
});
document.querySelectorAll(".filter[data-status]").forEach((button) => {
button.addEventListener("click", async () => {
state.statusFilter = button.dataset.status || "";
document.querySelectorAll(".filter[data-status]").forEach((item) => item.classList.toggle("active", item === button));
await loadCases(false);
});
});
document.querySelectorAll(".filter[data-part]").forEach((button) => {
button.addEventListener("click", async () => {
state.partFilter = button.dataset.part || "";
document.querySelectorAll(".filter[data-part]").forEach((item) => item.classList.toggle("active", item === button));
await loadCases(false);
});
});
document.querySelectorAll(".filter[data-model-filter]").forEach((button) => {
button.addEventListener("click", async () => {
state.modelFilter = button.dataset.modelFilter || "";
document.querySelectorAll(".filter[data-model-filter]").forEach((item) => item.classList.toggle("active", item === button));
await loadCases(false);
});
});
document.querySelectorAll("[data-window]").forEach((button) => {
button.addEventListener("click", () => setWindowMode(button.dataset.window || "default"));
});
document.querySelectorAll("[data-map-window]").forEach((button) => {
button.addEventListener("click", () => setWindowMode(button.dataset.mapWindow || "default"));
});
document.querySelectorAll("[data-map-mode]").forEach((button) => {
button.addEventListener("click", () => setMappingMode(button.dataset.mapMode || "result"));
});
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
button.addEventListener("click", () => {
state.fusionMode = button.dataset.fusionMode || "fusion";
document.querySelectorAll("[data-fusion-mode]").forEach((item) => item.classList.toggle("active", item === button));
applyFusionMode();
fitCamera();
});
});
document.querySelectorAll("[data-fusion-detail]").forEach((button) => {
button.addEventListener("click", async () => {
const nextDetail = button.dataset.fusionDetail || "low";
if (nextDetail === state.fusionDetail) return;
state.fusionDetail = nextDetail;
document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item === button));
applyFusionDetail();
markDirty("display");
await loadFusion();
});
});
document.querySelectorAll("[data-model-detail]").forEach((button) => {
button.addEventListener("click", () => {
state.modelDetail = button.dataset.modelDetail || "standard";
document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item === button));
applyModelDetail();
markDirty("display");
});
});
$("mappingSliceSlider").addEventListener("input", () => {
const selected = currentSeries();
const max = Math.max(0, Number(selected?.count || 0) - 1);
state.sliceIndex = max - Number($("mappingSliceSlider").value || 0);
updateSliceControl();
renderDicomAnnotation();
updateDicomPreview();
});
const updateRange = () => {
state.sliceRangeStart = Number($("sliceRangeStart").value || 0);
state.sliceRangeEnd = Number($("sliceRangeEnd").value || 0);
updateSliceControl();
};
$("sliceRangeStart").addEventListener("input", updateRange);
$("sliceRangeEnd").addEventListener("input", updateRange);
$("sliceRangeStart").addEventListener("change", () => loadFusion());
$("sliceRangeEnd").addEventListener("change", () => loadFusion());
}
wireEvents();
if (state.token) {
bootstrap();
} else {
loginVisible(true);
}