import * as THREE from "three";
import { STLLoader } from "./vendor/STLLoader.js";
import { OrbitControls } from "./vendor/OrbitControls.js";
const $ = (id) => document.getElementById(id);
const BODY_LABELS = {
head_neck: "头颈部",
chest: "胸部",
upper_abdomen: "上腹部",
lower_abdomen: "下腹部",
pelvis: "盆腔",
};
const DEFAULT_POSE = {
rotateX: 0,
rotateY: 0,
rotateZ: 0,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
flipX: false,
flipY: false,
flipZ: false,
};
const POSE_CONTROLS = [
{ key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" },
{ key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" },
{ key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" },
{ key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" },
{ key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" },
{ key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" },
{ key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.001, decimals: 3, suffix: "x" },
];
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
const 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: "",
caseCollapsed: 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: "standard",
mappingMode: "result",
sliceIndex: 0,
sliceRangeStart: 0,
sliceRangeEnd: 0,
dicomPreviewTimer: 0,
autoResult: null,
lastAutoPose: null,
dirty: false,
dirtyReason: "",
saving: false,
fusionVersion: 0,
volumeMeta: null,
volumeScene: null,
sceneReady: false,
renderer: null,
camera: null,
scene: null,
controls: 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,
resizeObserver: null,
resizeScene: null,
nudgeTimer: null,
nudgeDelayTimer: null,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function cls(value) {
return value ? "active" : "";
}
function 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 setSaveState(text, tone = "") {
const el = $("saveState");
el.textContent = text;
el.className = tone;
}
function setAutoState(text, tone = "") {
const el = $("autoState");
if (!el) return;
el.textContent = text;
el.className = tone;
}
function markDirty(reason = "pose") {
state.dirty = true;
state.dirtyReason = reason;
setSaveState("未保存", "warn");
}
function resetDirty() {
state.dirty = false;
state.dirtyReason = "";
setSaveState("已保存", "ok");
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.token) headers.set("Authorization", `Bearer ${state.token}`);
if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const response = await fetch(path, { ...options, headers });
if (response.status === 401) {
logout(false);
throw new Error("登录已过期");
}
if (!response.ok) {
let message = response.statusText;
try {
const payload = await response.json();
message = payload.detail || message;
} catch {
message = await response.text();
}
throw new Error(message || "请求失败");
}
const type = response.headers.get("content-type") || "";
return type.includes("application/json") ? response.json() : response.text();
}
function loginVisible(visible) {
$("loginOverlay").classList.toggle("hidden", !visible);
}
async function login(event) {
event.preventDefault();
$("loginError").textContent = "";
try {
const payload = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }),
});
state.token = payload.token;
state.user = payload;
localStorage.setItem("dicom_upp_registration_token", state.token);
loginVisible(false);
await bootstrap();
} catch (error) {
$("loginError").textContent = error.message;
}
}
function logout(clearToken = true) {
if (clearToken) localStorage.removeItem("dicom_upp_registration_token");
state.token = "";
state.user = null;
loginVisible(true);
$("userBadge").textContent = "未登录";
}
async function bootstrap() {
setTopLoading(true);
try {
const me = await api("/api/auth/me");
state.user = me;
$("userBadge").textContent = `${me.username} · ${me.role || "管理员"}`;
loginVisible(false);
try {
initScene();
} catch (error) {
initGeometryFallback();
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
console.warn(error);
}
buildPoseControls();
await loadStatus();
await 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) {
setTopLoading(true);
try {
const params = new URLSearchParams();
if (state.search) params.set("q", state.search);
if (state.statusFilter) params.set("status", state.statusFilter);
if (state.partFilter) params.set("body_part", state.partFilter);
if (state.modelFilter) params.set("algorithm_model", state.modelFilter);
const rows = await api(`/api/cases?${params.toString()}`);
state.cases = rows;
renderCases();
if (selectFirst && !state.activeCase && rows.length) {
await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型");
} else if (state.activeCase && !rows.some((row) => sameCase(row, state.activeCase))) {
state.activeCase = null;
clearDetail();
}
} finally {
setTopLoading(false);
}
}
function sameCase(a, b) {
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
}
function sameCtNumber(a, b) {
return normalize(a) === normalize(b);
}
function normalize(value) {
return String(value || "").trim().toUpperCase();
}
function fmtDateTime(date, time) {
const d = String(date || "").replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
const t = fmtTime(time);
return [d, t].filter(Boolean).join(" ");
}
function fmtTime(value) {
const raw = String(value || "").replace(/[^\d.]/g, "");
if (!raw) return "";
const main = raw.split(".")[0].padEnd(6, "0").slice(0, 6);
return `${main.slice(0, 2)}:${main.slice(2, 4)}:${main.slice(4, 6)}`;
}
function statusText(value) {
return value === "registered" ? "已配准" : "未配准";
}
function seriesLabels(item) {
return Array.isArray(item?.annotation_labels) ? item.annotation_labels.map((label) => String(label || "")) : [];
}
function isSkippedSeries(item) {
return seriesLabels(item).some((label) => label.includes("略过") || label.includes("不采用"));
}
function hasLabel(item, matcher) {
return seriesLabels(item).some((label) => matcher(String(label)));
}
function isUpperAbdomenSeries(item) {
return hasLabel(item, (label) => label.includes("上腹部"));
}
function isPortalPhaseSeries(item) {
return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期") || 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 visibleModelBox() {
const records = visibleModelRecords();
if (!records.length) return null;
const box = new THREE.Box3();
records.forEach((record) => box.expandByObject(record.mesh));
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.cases.length;
const active = state.activeCase;
$("caseList").innerHTML = state.cases.length
? state.cases
.map((row) => {
const tags = bodyPartTags(row).slice(0, 3);
const status = row.registration_status || "unregistered";
const selected = active && sameCase(row, active);
const title = escapeHtml(row.ct_number || row.ct_key);
const patient = escapeHtml(row.patient_name || row.upp_patient_name || "-");
const meta = `${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`;
return `
`;
})
.join("")
: `
没有符合条件的完整关联 CT
`;
$("caseList").querySelectorAll(".case-card").forEach((button) => {
button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型"));
});
}
function clearDetail() {
state.series = [];
state.stlFiles = [];
state.selectedStlIds = new Set();
state.selectedSeriesUid = "";
state.registrationSeriesUid = "";
$("activeTitle").textContent = "未选择 CT";
$("activeMeta").textContent = "从左侧选择一个完整关联检查";
$("activeTags").innerHTML = "";
$("seriesList").innerHTML = `未选择 CT
`;
$("stlList").innerHTML = `未选择 STL
`;
$("modelRail").innerHTML = "";
renderDicomAnnotation();
clearFusion();
}
async function confirmChange() {
if (!state.dirty) 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 || {};
const keepPreviousPose = switchingModelOnly;
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
state.fusionDetail = registration.model_reference?.fusion_detail || state.fusionDetail || "low";
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "standard";
state.autoResult = null;
state.lastAutoPose = null;
state.sliceIndex = 0;
$("notes").value = registration.notes || "";
const savedIds = new Set((registration.selected_stl_files || []).map((item) => Number(item.id)).filter(Number.isFinite));
state.selectedStlIds = savedIds.size ? savedIds : pickDefaultStlIds(state.stlFiles);
const defaultSeries = pickDefaultSeries(state.series);
const savedSeries = state.series.find((item) => item.series_uid === registration.series_instance_uid);
const shouldUseSavedSeries = registration.registration_status === "registered" && savedSeries;
state.registrationSeriesUid = savedSeries?.series_uid || "";
state.selectedSeriesUid = shouldUseSavedSeries ? savedSeries.series_uid : defaultSeries?.series_uid || "";
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
state.sliceRangeStart = 0;
state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
state.stlSignature = "";
renderCases();
renderActiveHeader();
renderSeries();
renderStl();
renderPoseControls();
syncDisplayControls();
resetDirty();
await loadFusion();
} catch (error) {
$("fusionStatus").textContent = error.message;
clearFusion();
} finally {
setTopLoading(false);
setFusionLoading(false);
}
}
function pickDefaultSeries(series) {
const candidates = [...series].filter((item) => Number(item.count || 0) > 1);
const usable = candidates.filter((item) => !isSkippedSeries(item));
const pool = usable.length ? usable : candidates;
const rank = (item) => {
let score = Number(item.count || 0) / 10000;
if (isLiverBiliaryModel()) {
if (isPortalPhaseSeries(item)) score += 900;
else if (isUpperAbdomenSeries(item)) score += 720;
} else if (isUpperAbdomenSeries(item)) {
score += 120;
}
if (isSkippedSeries(item)) score -= 1000;
return score;
};
return [...pool]
.sort((a, b) => {
const diff = rank(b) - rank(a);
if (Math.abs(diff) > 1e-6) return diff;
return Number(a.series_number || 999999) - Number(b.series_number || 999999);
})[0] || series[0] || null;
}
function pickDefaultStlIds(files) {
const priority = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen", "vertebrae"];
const ranked = [...files].sort((a, b) => {
const af = String(a.family || a.segment_name || a.file_name || "").toLowerCase();
const bf = String(b.family || b.segment_name || b.file_name || "").toLowerCase();
const ai = priority.findIndex((key) => af === key || af.includes(key));
const bi = priority.findIndex((key) => bf === key || bf.includes(key));
const ar = ai < 0 ? 999 : ai;
const br = bi < 0 ? 999 : bi;
if (ar !== br) return ar - br;
return Number(a.id || 0) - Number(b.id || 0);
});
return new Set(ranked.slice(0, Math.min(6, ranked.length)).map((file) => Number(file.id)));
}
function currentSeries() {
return state.series.find((item) => item.series_uid === state.selectedSeriesUid) || null;
}
function renderActiveHeader() {
const row = state.activeCase;
if (!row) return;
const selected = currentSeries();
const registration = registrationSeries();
const bodyTags = bodyPartTags(row);
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) || "检查时间未知"} · ${bodyTags.join("、") || row.study_description || "部位未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`;
const tags = [
`${statusText(state.registrationStatus)}`,
`${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}`,
...(registration ? [`配准 ${escapeHtml(registration.description || "当前序列")}`] : []),
...selectedLabels.map((tag) => `${escapeHtml(tag)}`),
...bodyTags.map((tag) => `${escapeHtml(tag)}`),
];
$("activeTags").innerHTML = tags.join("");
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
$("statusBtn").classList.toggle("unregistered", state.registrationStatus !== "registered");
}
function renderSeries() {
$("seriesCount").textContent = state.series.length;
$("seriesList").innerHTML = state.series.length
? sortedSeriesForDisplay(state.series)
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid, item.series_uid === state.registrationSeriesUid))
.join("")
: `未读取到 DICOM 序列
`;
document.querySelectorAll(".series-card[data-series]").forEach((card) => {
card.addEventListener("click", (event) => {
if (event.target.closest(".series-check")) return;
selectSeries(card.dataset.series);
});
card.addEventListener("keydown", (event) => {
if (!["Enter", " "].includes(event.key)) return;
event.preventDefault();
selectSeries(card.dataset.series);
});
});
document.querySelectorAll(".series-check[data-registration-series]").forEach((button) => {
button.addEventListener("click", async (event) => {
event.stopPropagation();
await setRegistrationSeries(button.dataset.registrationSeries);
});
});
updateSliceControl();
renderDicomAnnotation();
}
function seriesCardHtml(item, active = false, registrationSelected = false) {
const labels = (item.annotation_labels || []).map((label) => `${escapeHtml(label)}`).join("");
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
const skipped = isSkippedSeries(item);
return `
${escapeHtml(item.description || "未命名序列")}
拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}
${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张
${item.body_part_dicom ? `${escapeHtml(item.body_part_dicom)}` : ""}
${labels || `未标注`}
`;
}
function registrationSeries() {
return state.series.find((item) => item.series_uid === state.registrationSeriesUid) || null;
}
async function setRegistrationSeries(uid) {
if (!uid) return;
if (uid !== state.selectedSeriesUid) {
await selectSeries(uid, { markRegistration: true });
return;
}
state.registrationSeriesUid = uid;
markDirty("series");
renderSeries();
renderActiveHeader();
}
async function selectSeries(uid, options = {}) {
if (!uid || uid === state.selectedSeriesUid) return;
if (!(await confirmChange())) return;
state.selectedSeriesUid = uid;
if (options.markRegistration) state.registrationSeriesUid = uid;
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
state.sliceRangeStart = 0;
state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
if (options.markRegistration) markDirty("series");
else setSaveState("正在浏览序列", "");
renderSeries();
renderActiveHeader();
renderDicomAnnotation();
await loadFusion();
}
function renderStl() {
$("stlCount").textContent = state.stlFiles.length;
const models = [...new Set(state.stlFiles.map((item) => item.algorithm_model || state.algorithmModel || "未指定模型"))];
$("modelRail").innerHTML = models.map((model) => `${escapeHtml(model)}`).join("");
$("stlList").innerHTML = state.stlFiles.length
? state.stlFiles
.map((file, index) => {
const checked = state.selectedStlIds.has(Number(file.id));
return `
`;
})
.join("")
: `当前算法模型没有 STL 文件
`;
$("stlList").querySelectorAll("input[data-stl]").forEach((input) => {
input.addEventListener("change", async () => {
const id = Number(input.dataset.stl);
if (input.checked) state.selectedStlIds.add(id);
else state.selectedStlIds.delete(id);
await applyStlSelectionChange();
});
});
renderAutoBoneOptions();
}
async function selectAllStl() {
if (!state.stlFiles.length) return;
state.selectedStlIds = new Set(state.stlFiles.map((file) => Number(file.id)).filter(Number.isFinite));
await applyStlSelectionChange();
}
async function invertStlSelection() {
if (!state.stlFiles.length) return;
const next = new Set();
state.stlFiles.forEach((file) => {
const id = Number(file.id);
if (Number.isFinite(id) && !state.selectedStlIds.has(id)) next.add(id);
});
state.selectedStlIds = next;
await applyStlSelectionChange();
}
async function applyStlSelectionChange() {
markDirty("stl");
renderStl();
if (!state.modelMeshes.length) {
await loadFusion();
return;
}
applyStlVisibility();
applyModelDetail();
scheduleMappingDraw();
}
function stlLikelyScore(file) {
const textValue = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
const priority = [
["rib", 95],
["vertebra", 94],
["spine", 92],
["sternum", 90],
["pelvis", 82],
["skull", 78],
["bone", 72],
];
return priority.reduce((score, [key, value]) => textValue.includes(key) ? Math.max(score, value) : score, 0);
}
function stlDisplayName(file) {
return String(file.segment_name || file.family || file.file_name || `stl_${file.id || ""}`).replace(/\\.stl$/i, "");
}
function renderAutoBoneOptions() {
const container = $("autoBoneOptions");
if (!container) return;
if (!state.stlFiles.length) {
container.innerHTML = `No STL
`;
return;
}
const rows = [...state.stlFiles].sort((a, b) => {
const diff = stlLikelyScore(b) - stlLikelyScore(a);
if (diff) return diff;
return stlDisplayName(a).localeCompare(stlDisplayName(b));
});
container.innerHTML = rows.map((file) => {
const id = Number(file.id);
const likely = stlLikelyScore(file) > 0;
const checked = likely ? "checked" : "";
const title = escapeHtml(`${file.file_name || ""} ${file.family || ""}`.trim());
return `
`;
}).join("");
}
function cssColor(index) {
return `#${STL_COLORS[index % STL_COLORS.length].toString(16).padStart(6, "0")}`;
}
function formatBytes(value) {
const n = Number(value || 0);
if (!n) return "大小未知";
if (n > 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024).toFixed(1)} KB`;
}
function buildPoseControls() {
$("poseGrid").innerHTML = POSE_CONTROLS.map(
(item) => `
`,
).join("");
$("poseGrid").querySelectorAll(".pose-control").forEach((row) => {
const key = row.dataset.pose;
const range = row.querySelector("input[type='range']");
const number = row.querySelector("input[type='number']");
const option = POSE_CONTROLS.find((item) => item.key === key);
const sync = (value, source) => {
const numeric = Number(value);
state.pose[key] = Number.isFinite(numeric) ? Number(numeric.toFixed(option?.decimals ?? 3)) : DEFAULT_POSE[key];
if (source !== range) range.value = state.pose[key];
if (source !== number) number.value = state.pose[key];
applyPose();
markDirty();
};
range.addEventListener("input", () => sync(range.value, range));
number.addEventListener("input", () => sync(number.value, number));
});
document.querySelectorAll("[data-nudge]").forEach((button) => wireHoldNudge(button));
document.querySelectorAll("[data-flip]").forEach((button) => {
button.addEventListener("click", () => {
const key = button.dataset.flip;
state.pose[key] = !state.pose[key];
renderPoseControls();
applyPose();
markDirty();
});
});
}
function wireHoldNudge(button) {
const fixedDelta = Number(button.dataset.delta || 0);
if (Math.abs(fixedDelta) >= 10) {
button.addEventListener("click", () => nudgePose(button.dataset.nudge, fixedDelta));
return;
}
const start = (event) => {
if (event.button !== undefined && event.button !== 0) return;
event.preventDefault();
const key = button.dataset.nudge;
const delta = Number(button.dataset.delta || 0);
nudgePose(key, delta);
clearNudgeTimers();
state.nudgeDelayTimer = window.setTimeout(() => {
state.nudgeTimer = window.setInterval(() => nudgePose(key, delta), 54);
}, 260);
button.setPointerCapture?.(event.pointerId);
};
const stop = (event) => {
clearNudgeTimers();
if (event?.pointerId !== undefined && button.hasPointerCapture?.(event.pointerId)) {
button.releasePointerCapture(event.pointerId);
}
};
button.addEventListener("pointerdown", start);
button.addEventListener("pointerup", stop);
button.addEventListener("pointercancel", stop);
button.addEventListener("pointerleave", stop);
}
function clearNudgeTimers() {
window.clearTimeout(state.nudgeDelayTimer);
window.clearInterval(state.nudgeTimer);
state.nudgeDelayTimer = null;
state.nudgeTimer = null;
}
function nudgePose(key, delta) {
if (!key || !Number.isFinite(delta)) return;
const option = POSE_CONTROLS.find((item) => item.key === key);
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
const next = Math.max(option?.min ?? -Infinity, Math.min(option?.max ?? Infinity, current + delta));
state.pose[key] = Number(next.toFixed(option?.decimals ?? 3));
renderPoseControls();
applyPose();
markDirty();
}
function renderPoseControls() {
POSE_CONTROLS.forEach((item) => {
const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`);
if (!row) return;
const value = Number(state.pose[item.key] ?? DEFAULT_POSE[item.key]);
row.querySelector("input[type='range']").value = value;
row.querySelector("input[type='number']").value = Number.isInteger(value) ? value : value.toFixed(item.decimals ?? 3);
});
document.querySelectorAll("[data-flip]").forEach((button) => {
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
});
renderActiveHeader();
}
function updateSliceControl() {
const selected = currentSeries();
const count = Number(selected?.count || 0);
const max = Math.max(0, count - 1);
state.sliceIndex = Math.max(0, Math.min(state.sliceIndex, max));
state.sliceRangeStart = Math.max(0, Math.min(Number(state.sliceRangeStart) || 0, max));
state.sliceRangeEnd = Math.max(0, Math.min(Number(state.sliceRangeEnd) || max, max));
if (state.sliceRangeStart > state.sliceRangeEnd) [state.sliceRangeStart, state.sliceRangeEnd] = [state.sliceRangeEnd, state.sliceRangeStart];
$("sliceRangeStart").max = max;
$("sliceRangeEnd").max = max;
$("sliceRangeStart").value = state.sliceRangeStart;
$("sliceRangeEnd").value = state.sliceRangeEnd;
$("sliceRangeLabel").textContent = count ? `${state.sliceRangeStart + 1} - ${state.sliceRangeEnd + 1} / ${count}` : "0 - 0 / 0";
$("sliceStartLabel").textContent = count ? `起点 ${state.sliceRangeStart + 1}` : "起点 0";
$("sliceEndLabel").textContent = count ? `终点 ${state.sliceRangeEnd + 1}` : "终点 0";
$("mappingSliceSlider").max = max;
$("mappingSliceSlider").value = max - state.sliceIndex;
$("mappingSliceLabel").textContent = count ? `${state.sliceIndex + 1} / ${count}` : "0 / 0";
}
function renderDicomAnnotation() {
const row = state.activeCase;
const selected = currentSeries();
const count = Number(selected?.count || 0);
$("mappingSummaryTitle").textContent = state.mappingMode === "image" ? "单独影像" : "可见 STL 构件";
$("dicomInfoLeft").textContent = row && selected
? `${row.patient_name || row.upp_patient_name || "-"}\n${row.patient_id || "-"}\n${fmtDateTime(row.study_date, row.study_time) || "-"}\n${selected.description || "-"}`
: "未选择序列";
$("dicomInfoRight").textContent = selected
? `${selected.modality || "CT"}\n${selected.rows || "-"}×${selected.columns || "-"}\n${selected.slice_thickness || "厚度 -"}`
: "";
$("dicomInfoBottom").textContent = count ? `Img:${state.sliceIndex + 1}/${count}` : "Img:-";
renderActiveHeader();
}
function updateDicomPreview() {
const selected = currentSeries();
if (!state.activeCase || !selected) {
$("dicomPreview").removeAttribute("src");
renderDicomAnnotation();
resetMappingCanvas("未选择 DICOM 序列");
return;
}
window.clearTimeout(state.dicomPreviewTimer);
state.dicomPreviewTimer = window.setTimeout(() => {
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
series_uid: selected.series_uid,
index: String(state.sliceIndex),
window: state.windowMode,
access_token: state.token,
});
$("dicomLoading").classList.remove("hidden");
resetMappingCanvas("正在加载 DICOM Base Layer");
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
renderDicomAnnotation();
}, 80);
}
function initScene() {
if (state.sceneReady) return;
const viewport = $("fusionViewport");
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setClearColor(0x000000, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
viewport.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000);
camera.position.set(0, -8.5, 4.4);
camera.up.set(0, 0, 1);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 0, 0);
const ambient = new THREE.AmbientLight(0xffffff, 0.82);
const directional = new THREE.DirectionalLight(0xffffff, 1.8);
directional.position.set(3.5, -4, 6);
scene.add(ambient, directional);
const dicomGroup = new THREE.Group();
const modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group();
const axisGroup = createSceneAxes();
modelGroup.add(rawModelGroup);
scene.add(dicomGroup, modelGroup, axisGroup);
Object.assign(state, { renderer, camera, scene, controls, dicomGroup, modelGroup, rawModelGroup, axisGroup, sceneReady: true });
const resize = () => {
const rect = viewport.getBoundingClientRect();
if (!rect.width || !rect.height) return;
renderer.setSize(rect.width, rect.height, false);
camera.aspect = rect.width / rect.height;
camera.updateProjectionMatrix();
};
state.resizeScene = resize;
state.resizeObserver = new ResizeObserver(resize);
state.resizeObserver.observe(viewport);
resize();
const animate = () => {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
}
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 dicomGroup = new THREE.Group();
const modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group();
modelGroup.add(rawModelGroup);
Object.assign(state, { dicomGroup, modelGroup, rawModelGroup });
}
function clearGroup(group) {
if (!group) return;
while (group.children.length) {
const child = group.children.pop();
child.traverse?.((node) => {
node.geometry?.dispose?.();
if (node.material) {
if (Array.isArray(node.material)) node.material.forEach((material) => material.dispose?.());
else node.material.dispose?.();
}
node.material?.map?.dispose?.();
});
}
}
function clearFusion() {
clearGroup(state.dicomGroup);
clearGroup(state.rawModelGroup);
if (state.rawModelGroup) {
state.rawModelGroup.position.set(0, 0, 0);
state.rawModelGroup.rotation.set(0, 0, 0);
state.rawModelGroup.scale.set(1, 1, 1);
}
state.volumeMeta = null;
state.volumeScene = null;
state.modelMeshes = [];
state.dicomPlane = null;
state.dicomPoints = null;
state.dicomSlicePlanes = [];
state.modelBoundsFrame = null;
state.modelLocalBounds = null;
$("fusionMeta").textContent = "DICOM - · STL -";
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
updateSliceControl();
resetMappingCanvas();
}
function applyFusionMode() {
if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model";
if (state.modelGroup) state.modelGroup.visible = true;
updateFusionMeta();
}
function updateFusionMeta() {
const selectedCount = visibleModelRecords().length || state.selectedStlIds.size;
if (state.fusionMode === "model") {
$("fusionMeta").textContent = `单独模型 · STL ${selectedCount} 个`;
return;
}
const volume = state.volumeMeta;
$("fusionMeta").textContent = volume
? `DICOM ${volume.start + 1}-${volume.end + 1}/${volume.total} · STL ${selectedCount} 个`
: `DICOM - · STL ${selectedCount} 个`;
}
function fusionDetailSettings() {
return {
low: { planeOpacity: 0.32, sliceOpacity: 0.05, slicePlanes: 12, pointOpacity: 0.18, pointSize: 0.014, pointGrid: 72, pointThreshold: 24 },
medium: { planeOpacity: 0.46, sliceOpacity: 0.075, slicePlanes: 18, pointOpacity: 0.3, pointSize: 0.018, pointGrid: 88, pointThreshold: 22 },
high: { planeOpacity: 0.6, sliceOpacity: 0.1, slicePlanes: 26, pointOpacity: 0.42, pointSize: 0.021, pointGrid: 104, pointThreshold: 20 },
}[state.fusionDetail] || { planeOpacity: 0.32, sliceOpacity: 0.05, slicePlanes: 12, pointOpacity: 0.18, pointSize: 0.014, pointGrid: 72, pointThreshold: 24 };
}
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.needsUpdate = true;
}
if (state.dicomPoints?.material) {
state.dicomPoints.material.opacity = detail.pointOpacity;
state.dicomPoints.material.size = detail.pointSize;
state.dicomPoints.material.needsUpdate = true;
}
state.dicomSlicePlanes.forEach((plane) => {
if (!plane.material) return;
plane.material.opacity = detail.sliceOpacity;
plane.material.needsUpdate = true;
});
}
function applyModelDetail() {
const detail = modelDetailSettings();
state.modelMeshes.forEach((record) => {
const material = record.mesh.material;
if (!material) return;
material.opacity = detail.opacity;
material.transparent = detail.opacity < 1;
material.depthWrite = detail.depthWrite;
material.wireframe = detail.wireframe;
material.metalness = detail.metalness;
material.roughness = detail.roughness;
material.needsUpdate = true;
});
scheduleMappingDraw();
}
function applyStlVisibility() {
const selected = state.selectedStlIds;
state.modelMeshes.forEach((record) => {
record.mesh.visible = selected.has(Number(record.file?.id));
});
if (state.modelBoundsFrame) state.modelBoundsFrame.visible = state.modelMeshes.some((record) => record.mesh.visible);
if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model";
if (state.modelGroup) state.modelGroup.visible = true;
updateFusionMeta();
scheduleMappingDraw();
}
function syncDisplayControls() {
document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item.dataset.fusionDetail === state.fusionDetail));
document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item.dataset.modelDetail === state.modelDetail));
document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode));
}
async function loadFusion() {
const version = ++state.fusionVersion;
const selected = currentSeries();
if (!state.sceneReady) initGeometryFallback();
if (!state.activeCase || !selected) {
clearFusion();
return;
}
setFusionLoading(true, "正在读取 DICOM 切片范围", 8);
updateSliceControl();
try {
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
series_uid: selected.series_uid,
center_index: String(state.sliceIndex),
window: state.windowMode,
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, resolve, undefined, reject);
});
}
async function loadImageData(url, maxSize = 256) {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
const scale = Math.min(1, maxSize / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height, 1));
const width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale));
const height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
return { width, height, data: context.getImageData(0, 0, width, height).data };
}
function sliceToSceneZ(sliceIndex, volumeScene = state.volumeScene) {
if (!volumeScene) return 0;
return volumeScene.total <= 1
? 0
: -volumeScene.depth / 2 + (volumeScene.depth * Math.max(0, Math.min(sliceIndex, volumeScene.total - 1))) / (volumeScene.total - 1);
}
function parseCssColor(color) {
const value = String(color || "#19d6c3").replace("#", "");
const hex = value.length === 3 ? value.split("").map((char) => char + char).join("") : value.padStart(6, "0").slice(0, 6);
const intValue = Number.parseInt(hex, 16);
return {
r: (intValue >> 16) & 255,
g: (intValue >> 8) & 255,
b: intValue & 255,
};
}
function resetMappingCanvas(message = "当前切片暂无可见构件") {
const canvas = $("mappingCanvas");
const context = canvas?.getContext("2d");
if (context) context.clearRect(0, 0, canvas.width || 1, canvas.height || 1);
$("mappingStats").textContent = "0/0 构件 · 0 边 · 0 px";
$("mappingLegend").innerHTML = `${escapeHtml(message)}
`;
}
function scheduleMappingDraw() {
window.cancelAnimationFrame(state.mappingDrawFrame);
state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView());
}
function applyMappingTransform() {
const layer = $("mappingLayer");
if (!layer) return;
const { scale, offsetX, offsetY, 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 minY = Math.max(0, Math.floor(Math.min(a.y, b.y)));
const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y)));
const deltaY = b.y - a.y;
if (Math.abs(deltaY) < 1e-6) {
const row = Math.max(0, Math.min(height - 1, Math.round(a.y)));
rows[row].push(Math.max(0, Math.min(width - 1, a.x)));
rows[row].push(Math.max(0, Math.min(width - 1, b.x)));
return;
}
for (let row = minY; row <= maxY; row += 1) {
const sampleY = row + 0.5;
const t = (sampleY - a.y) / deltaY;
if (t < 0 || t > 1) continue;
const x = a.x + (b.x - a.x) * t;
if (Number.isFinite(x)) rows[row].push(x);
}
}
function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) {
if (!segments.length) return 0;
const rgb = parseCssColor(color);
const maskCanvas = document.createElement("canvas");
maskCanvas.width = width;
maskCanvas.height = height;
const maskContext = maskCanvas.getContext("2d");
const imageData = maskContext.createImageData(width, height);
const rows = Array.from({ length: height }, () => []);
const alpha = Math.round(Math.max(0.12, Math.min(opacity, 1)) * 190);
let filled = 0;
segments.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
rows.forEach((intersections, y) => {
if (intersections.length < 2) return;
intersections.sort((a, b) => a - b);
const cleaned = [];
intersections.forEach((x) => {
const previous = cleaned[cleaned.length - 1];
if (previous === undefined || Math.abs(previous - x) > 0.35) cleaned.push(x);
});
for (let index = 0; index + 1 < cleaned.length; index += 2) {
const startX = Math.max(0, Math.min(width - 1, Math.ceil(cleaned[index])));
const endX = Math.max(0, Math.min(width - 1, Math.floor(cleaned[index + 1])));
if (endX < startX) continue;
for (let x = startX; x <= endX; x += 1) {
const offset = (y * width + x) * 4;
imageData.data[offset] = rgb.r;
imageData.data[offset + 1] = rgb.g;
imageData.data[offset + 2] = rgb.b;
imageData.data[offset + 3] = alpha;
filled += 1;
}
}
});
const hullPixels = fillConvexHullMask(context, width, height, segments, color, opacity);
maskContext.putImageData(imageData, 0, 0);
context.globalAlpha = 0.45;
context.drawImage(maskCanvas, 0, 0);
context.globalAlpha = 1;
return filled + hullPixels;
}
function fillConvexHullMask(context, width, height, segments, color, opacity = 0.72) {
const points = [];
segments.forEach((segment) => {
[segment.a, segment.b].forEach((point) => {
if (Number.isFinite(point.x) && Number.isFinite(point.y) && point.x > -width && point.x < width * 2 && point.y > -height && point.y < height * 2) {
points.push({ x: point.x, y: point.y });
}
});
});
if (points.length < 3) return 0;
const sorted = points
.filter((point, index) => points.findIndex((item) => Math.abs(item.x - point.x) < 0.5 && Math.abs(item.y - point.y) < 0.5) === index)
.sort((a, b) => (Math.abs(a.x - b.x) > 0.001 ? a.x - b.x : a.y - b.y));
if (sorted.length < 3) return 0;
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)];
if (hull.length < 3) return 0;
context.save();
context.globalAlpha = Math.max(0.18, Math.min(opacity, 1)) * 0.58;
context.fillStyle = color;
context.strokeStyle = color;
context.lineWidth = Math.max(1.2, Math.max(width, height) * 0.002);
context.beginPath();
hull.forEach((point, index) => {
if (index === 0) context.moveTo(point.x, point.y);
else context.lineTo(point.x, point.y);
});
context.closePath();
context.fill();
context.globalAlpha = Math.max(0.22, Math.min(opacity, 1));
context.stroke();
context.restore();
return Math.max(1, Math.round(hull.length * 6));
}
function drawMappingView() {
const image = $("dicomPreview");
const canvas = $("mappingCanvas");
const volumeScene = state.volumeScene;
canvas.style.display = state.mappingMode === "image" ? "none" : "";
if (state.mappingMode === "image") {
resetMappingCanvas("单独影像模式未叠加 STL 分割");
return;
}
const visibleRecords = visibleModelRecords();
if (!image?.complete || !image.naturalWidth || !volumeScene || !visibleRecords.length) {
resetMappingCanvas(visibleRecords.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
return;
}
const width = image.naturalWidth;
const height = image.naturalHeight;
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const context = canvas.getContext("2d");
context.clearRect(0, 0, width, height);
const targetZ = sliceToSceneZ(state.sliceIndex, volumeScene);
const mapPoint = (point) => ({
x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width,
y: (0.5 - point.y / volumeScene.height) * height,
});
state.modelGroup?.updateMatrixWorld(true);
let activeModules = 0;
let segmentCount = 0;
let filledPixels = 0;
const modules = [];
const tempA = new THREE.Vector3();
const tempB = new THREE.Vector3();
const tempC = new THREE.Vector3();
visibleRecords.forEach((record, index) => {
const position = record.mesh.geometry.attributes.position;
if (!position) return;
const triangleCount = Math.floor(position.count / 3);
const step = Math.max(1, Math.ceil(triangleCount / 70000));
const segments = [];
for (let tri = 0; tri < triangleCount; tri += step) {
tempA.fromBufferAttribute(position, tri * 3);
tempB.fromBufferAttribute(position, tri * 3 + 1);
tempC.fromBufferAttribute(position, tri * 3 + 2);
record.mesh.localToWorld(tempA);
record.mesh.localToWorld(tempB);
record.mesh.localToWorld(tempC);
const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ);
if (!segment) continue;
const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) };
if (
mapped.a.x > -width && mapped.a.x < width * 2
&& mapped.b.x > -width && mapped.b.x < width * 2
&& mapped.a.y > -height && mapped.a.y < height * 2
&& mapped.b.y > -height && mapped.b.y < height * 2
) {
segments.push(mapped);
}
}
const pixels = fillSegmentsAsSolidMask(context, width, height, segments, record.color, 0.66);
if (segments.length || pixels) {
activeModules += 1;
segmentCount += segments.length;
filledPixels += pixels;
modules.push({
id: index + 1,
name: record.file.segment_name || record.file.file_name || `STL ${index + 1}`,
color: record.color,
segments: segments.length,
pixels,
});
}
});
$("mappingStats").textContent = `${activeModules}/${visibleRecords.length} 构件 · ${segmentCount} 边 · ${filledPixels} px`;
$("mappingLegend").innerHTML = modules.length
? modules.map((item) => `
${escapeHtml(item.name)}
ID ${item.id}
${item.segments} 边
${item.pixels} px
`).join("")
: `当前切片暂无可见构件
`;
}
async function buildDicomVolume(volume) {
clearGroup(state.dicomGroup);
state.dicomPlane = null;
state.dicomPoints = null;
state.dicomSlicePlanes = [];
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 };
const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
const sceneScale = 4.8 / maxPhysical;
const width = (physical.width || 1) * sceneScale;
const height = (physical.height || 1) * sceneScale;
const depth = Math.max((physical.depth || 1) * sceneScale, 0.18);
const centerIndex = Number(volume.center || 0);
const total = Math.max(1, Number(volume.total || 1));
const indices = volume.indices || [];
const frames = volume.frames || [];
const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0;
state.volumeScene = {
width,
height,
depth,
total,
sceneScale,
rowSpacing: Number(volume.spacing?.row || 1),
columnSpacing: Number(volume.spacing?.column || 1),
sliceSpacing: Number(volume.spacing?.slice || 1),
};
const boxMesh = new THREE.Mesh(
new THREE.BoxGeometry(width, height, depth),
new THREE.MeshBasicMaterial({ color: 0x020617, transparent: true, opacity: 0.045, depthWrite: false }),
);
state.dicomGroup.add(boxMesh);
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(boxMesh.geometry),
new THREE.LineBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 0.46 }),
);
state.dicomGroup.add(edges);
const localStart = Number(volume.start ?? indices[0] ?? centerIndex);
const localEnd = Number(volume.end ?? indices[indices.length - 1] ?? centerIndex);
const rangeDepth = Math.max(0.015, Math.abs(sliceToSceneZ(localEnd) - sliceToSceneZ(localStart)));
const rangeBox = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(width * 0.94, height * 0.94, rangeDepth)),
new THREE.LineBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 0.24 }),
);
rangeBox.position.z = (sliceToSceneZ(localStart) + sliceToSceneZ(localEnd)) / 2;
state.dicomGroup.add(rangeBox);
const centerFrame = (volume.frames || [])[centerPosition];
const fusionDetail = fusionDetailSettings();
if (centerFrame) {
const texture = await loadTexture(centerFrame);
texture.colorSpace = THREE.SRGBColorSpace;
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(width, height),
new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: fusionDetail.planeOpacity,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
state.dicomGroup.add(plane);
state.dicomPlane = plane;
}
if (frames.length > 1) {
const planeStep = Math.max(1, Math.ceil(frames.length / Math.max(1, fusionDetail.slicePlanes)));
for (let frameIndex = 0; frameIndex < frames.length; frameIndex += planeStep) {
if (frameIndex === centerPosition) continue;
const texture = await loadTexture(frames[frameIndex]);
texture.colorSpace = THREE.SRGBColorSpace;
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(width, height),
new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: fusionDetail.sliceOpacity,
side: THREE.DoubleSide,
depthWrite: false,
depthTest: true,
}),
);
plane.position.z = sliceToSceneZ(Number(indices[frameIndex] ?? centerIndex));
state.dicomGroup.add(plane);
state.dicomSlicePlanes.push(plane);
}
}
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,
}),
);
state.dicomGroup.add(points);
state.dicomPoints = points;
}
state.baseModelScale = sceneScale;
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
}
function updateBaseModelScaleFromBounds() {
const size = state.modelLocalBounds?.size;
if (!size) return;
const maxModelSize = Math.max(Number(size.x) || 0, Number(size.y) || 0, Number(size.z) || 0, 1);
const volumeSize = state.volumeScene
? Math.max(state.volumeScene.width, state.volumeScene.height, state.volumeScene.depth, 1)
: 4.8;
state.baseModelScale = (volumeSize / maxModelSize) * 0.92;
}
function addModelBoundsFrame(size) {
if (!state.rawModelGroup || !size) return;
const frame = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
new THREE.LineBasicMaterial({
color: 0xfacc15,
transparent: true,
opacity: 0.96,
depthTest: false,
depthWrite: false,
toneMapped: false,
}),
);
frame.name = "model-bounds";
frame.renderOrder = 1000;
state.rawModelGroup.add(frame);
state.modelBoundsFrame = frame;
}
async function buildStlModels(volume, onProgress = null) {
const signature = stlSelectionSignature();
if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) {
updateBaseModelScaleFromBounds();
if (!state.modelBoundsFrame && state.modelLocalBounds?.size) addModelBoundsFrame(state.modelLocalBounds.size);
applyStlVisibility();
applyPose();
applyModelDetail();
scheduleMappingDraw();
onProgress?.(100);
return;
}
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 files = state.stlFiles;
if (!files.length) {
applyPose();
resetMappingCanvas("当前没有可见 STL 构件");
return;
}
const loader = new STLLoader();
const detail = modelDetailSettings();
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);
state.modelMeshes.push({ mesh, file, color: cssColor(index), index });
onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100);
}
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 center = new THREE.Vector3();
const size = new THREE.Vector3();
bbox.getCenter(center);
bbox.getSize(size);
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: size.x, y: size.y, z: size.z },
};
updateBaseModelScaleFromBounds();
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 * Math.max(0.001, Number(pose.scaleX) || 1) * (pose.flipX ? -1 : 1),
scale * Math.max(0.001, Number(pose.scaleY) || 1) * (pose.flipY ? -1 : 1),
scale * Math.max(0.001, Number(pose.scaleZ) || 1) * (pose.flipZ ? -1 : 1),
);
state.modelGroup.updateMatrixWorld(true);
scheduleMappingDraw();
}
function fitCamera() {
if (!state.camera || !state.controls) return;
const box = new THREE.Box3();
if (state.dicomGroup?.visible !== false) box.expandByObject(state.dicomGroup);
const modelBox = visibleModelBox();
if (modelBox) box.union(modelBox);
if (box.isEmpty()) {
state.camera.position.set(0, -8.5, 4.4);
state.controls.target.set(0, 0, 0);
state.controls.update();
return;
}
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
const distance = Math.max(size.x, size.y, size.z, 1) * 1.8;
state.camera.position.set(center.x, center.y - distance, center.z + distance * 0.45);
state.controls.target.copy(center);
if (state.axisGroup) {
const axisScale = Math.max(size.x, size.y, size.z, 1) / 5.5;
state.axisGroup.scale.setScalar(axisScale);
state.axisGroup.position.set(
center.x - size.x * 0.46,
center.y - size.y * 0.46,
center.z - size.z * 0.42,
);
}
state.controls.update();
}
function resetPose(kind) {
if (kind === "rotation") {
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
} else if (kind === "transform") {
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1, scaleX: 1, scaleY: 1, scaleZ: 1 };
} else if (kind === "flip") {
state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false };
} else {
state.pose = { ...DEFAULT_POSE };
}
renderPoseControls();
applyPose();
markDirty();
}
function stretchAxis(axis) {
const modelBox = visibleModelBox();
if (!modelBox || !state.dicomSceneBox) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
const axisKey = { x: "scaleX", y: "scaleY", z: "scaleZ" }[axis];
if (!axisKey) return;
const dicomSize = new THREE.Vector3();
const modelSize = new THREE.Vector3();
state.dicomSceneBox.getSize(dicomSize);
modelBox.getSize(modelSize);
const currentAxisScale = Math.max(0.001, Number(state.pose[axisKey]) || 1);
const targetSize = Math.max(0.001, dicomSize[axis]);
const currentSize = Math.max(0.001, modelSize[axis]);
const next = Math.max(0.2, Math.min(4, currentAxisScale * (targetSize / currentSize)));
state.pose[axisKey] = Number(next.toFixed(3));
applyPose();
markDirty("pose");
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`;
updateAutoPoseResult();
}
function suggestBoneSelection() {
const checkedIds = [...document.querySelectorAll("#autoBoneOptions input:checked")]
.map((input) => Number(input.value))
.filter(Number.isFinite);
const next = new Set(checkedIds);
if (!next.size) {
[...state.stlFiles]
.sort((a, b) => stlLikelyScore(b) - stlLikelyScore(a))
.slice(0, Math.min(6, state.stlFiles.length))
.forEach((file) => next.add(Number(file.id)));
}
state.selectedStlIds = next;
renderStl();
markDirty();
applyStlSelectionChange();
}
function readAutoSettings() {
const numericValue = (id, fallback) => {
const value = Number($(id)?.value);
return Number.isFinite(value) ? value : fallback;
};
return {
adjustable: {
translateX: $("autoX")?.checked,
translateY: $("autoY")?.checked,
translateZ: $("autoZ")?.checked,
scale: $("autoScale")?.checked,
},
sampleSlices: Math.max(3, Math.min(60, Math.round(numericValue("autoSampleSlicesNumber", 9)))),
boneReward: numericValue("autoBoneReward", 1),
outsidePenalty: numericValue("autoOutsidePenalty", 0.1),
movePenalty: numericValue("autoMovePenalty", 0),
scalePenalty: numericValue("autoScalePenalty", 0),
iterations: Math.max(1, Math.min(100, Math.round(numericValue("autoIterations", 30)))),
candidates: Math.max(6, Math.min(96, Math.round(numericValue("autoCandidates", 36)))),
};
}
function poseDelta(from, to) {
return {
translateX: Number(((to.translateX || 0) - (from.translateX || 0)).toFixed(3)),
translateY: Number(((to.translateY || 0) - (from.translateY || 0)).toFixed(3)),
translateZ: Number(((to.translateZ || 0) - (from.translateZ || 0)).toFixed(3)),
scale: Number(((to.scale || 1) / Math.max(0.001, from.scale || 1)).toFixed(3)),
};
}
function updateAutoPoseResult(before = state.lastAutoPose?.before || state.pose, after = state.pose) {
const delta = poseDelta(before, after);
const el = $("autoPoseResult");
if (!el) return;
el.innerHTML = [
`平移 X ${delta.translateX}`,
`平移 Y ${delta.translateY}`,
`平移 Z ${delta.translateZ}`,
`缩放 ${delta.scale}`,
].join("");
}
function restoreAutoPose() {
if (!state.lastAutoPose?.before) {
$("autoResult").textContent = "暂无可退回的自动调整位姿。";
return;
}
state.pose = { ...state.lastAutoPose.before };
renderPoseControls();
applyPose();
fitCamera();
updateAutoPoseResult(state.lastAutoPose.after, state.pose);
$("autoResult").textContent = "已退回到自动调整前的位姿。";
markDirty();
}
function candidateScore(pose, settings = null) {
if (!state.dicomSceneBox || !state.modelGroup || !visibleModelRecords().length) return -Infinity;
const config = settings || readAutoSettings();
const current = { ...state.pose };
applyPose(pose);
const modelBox = visibleModelBox();
applyPose(current);
if (!modelBox) return -Infinity;
const overlap = modelBox.clone().intersect(state.dicomSceneBox);
const overlapSize = new THREE.Vector3();
overlap.getSize(overlapSize);
const modelSize = new THREE.Vector3();
const dicomSize = new THREE.Vector3();
modelBox.getSize(modelSize);
state.dicomSceneBox.getSize(dicomSize);
const overlapVolume = Math.max(0, overlapSize.x) * Math.max(0, overlapSize.y) * Math.max(0, overlapSize.z);
const modelVolume = Math.max(modelSize.x * modelSize.y * modelSize.z, 0.001);
const modelCenter = new THREE.Vector3();
const dicomCenter = new THREE.Vector3();
modelBox.getCenter(modelCenter);
state.dicomSceneBox.getCenter(dicomCenter);
const centerPenalty = modelCenter.distanceTo(dicomCenter) / Math.max(dicomSize.length(), 0.001);
const scalePenalty = Math.abs((pose.scale || 1) - 1) * (0.03 + Number(config.scalePenalty || 0));
const movePenalty = (Math.abs(pose.translateX || 0) + Math.abs(pose.translateY || 0) + Math.abs(pose.translateZ || 0)) * Number(config.movePenalty || 0);
return (overlapVolume / modelVolume) * Number(config.boneReward || 1) - centerPenalty - scalePenalty - movePenalty - Number(config.outsidePenalty || 0) * 0.01;
}
function runAutoCoarse() {
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
const before = { ...state.pose };
const settings = readAutoSettings();
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 };
renderPoseControls();
applyPose();
fitCamera();
state.autoResult = { score: candidateScore(state.pose, settings), pose: { ...state.pose }, evaluated: 1, settings };
state.lastAutoPose = { before, after: { ...state.pose } };
setAutoState("粗配准完成", "ok");
$("autoResult").textContent = `已按 DICOM 物理尺寸复位平移/缩放,score ${state.autoResult.score.toFixed(4)};采样 ${settings.sampleSlices} 张。`;
updateAutoPoseResult(before, state.pose);
markDirty();
}
function runAutoFine() {
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
setAutoState("运行中", "warn");
$("autoResult").textContent = "正在进行几何重叠微调...";
const before = { ...state.pose };
const settings = readAutoSettings();
const adjustable = settings.adjustable;
let best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" };
let evaluated = 1;
const rounds = Array.from({ length: settings.iterations }, (_, index) => {
const t = Math.max(0.18, 1 - index / Math.max(settings.iterations, 1));
return { move: 0.34 * t, scale: 0.12 * t };
});
for (const round of rounds) {
const candidates = [];
if (adjustable.translateX) candidates.push(["translateX", round.move], ["translateX", -round.move]);
if (adjustable.translateY) candidates.push(["translateY", round.move], ["translateY", -round.move]);
if (adjustable.translateZ) candidates.push(["translateZ", round.move], ["translateZ", -round.move]);
if (adjustable.scale) candidates.push(["scale", round.scale], ["scale", -round.scale]);
for (const [key, delta] of candidates.slice(0, settings.candidates)) {
const pose = { ...best.pose, [key]: key === "scale" ? Math.max(0.2, Math.min(3, best.pose.scale + delta)) : best.pose[key] + delta };
const score = candidateScore(pose, settings);
evaluated += 1;
if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` };
}
}
state.pose = { ...best.pose };
state.autoResult = { ...best, evaluated, settings };
state.lastAutoPose = { before, after: { ...state.pose } };
renderPoseControls();
applyPose();
fitCamera();
setAutoState("微调完成", "ok");
$("autoResult").textContent = `最佳候选:${best.mode};score ${best.score.toFixed(4)};评估 ${evaluated} 个候选;采样 ${settings.sampleSlices} 张。`;
updateAutoPoseResult(before, state.pose);
markDirty();
}
async function saveRegistration(nextStatus = null) {
if (!state.activeCase || !currentSeries() || state.saving) return;
state.saving = true;
$("saveBtn").disabled = true;
$("statusBtn").disabled = true;
setSaveState("保存中", "warn");
const selectedSeries = currentSeries();
const status = nextStatus || state.registrationStatus;
const selectedFiles = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
try {
await api("/api/registrations", {
method: "POST",
body: JSON.stringify({
ct_number: state.activeCase.ct_number,
algorithm_model: state.algorithmModel,
registration_status: status,
series_instance_uid: selectedSeries.series_uid,
series_description: selectedSeries.description || "",
selected_stl_files: selectedFiles.map((file) => ({
id: file.id,
file_name: file.file_name,
segment_name: file.segment_name,
family: file.family,
category: file.category,
algorithm_model: file.algorithm_model,
})),
transform: state.pose,
module_styles: Object.fromEntries(selectedFiles.map((file, index) => [file.file_name, { color: cssColor(index), opacity: modelDetailSettings().opacity, detail: state.modelDetail }])),
dicom_reference: {
series_uid: selectedSeries.series_uid,
series_number: selectedSeries.series_number,
description: selectedSeries.description,
slice_index: state.sliceIndex,
slice_count: selectedSeries.count,
window: state.windowMode,
volume: state.volumeMeta?.physicalSize || null,
},
model_reference: {
algorithm_model: state.algorithmModel,
selected_count: selectedFiles.length,
families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))],
fusion_mode: state.fusionMode,
fusion_detail: state.fusionDetail,
model_detail: state.modelDetail,
auto_result: state.autoResult,
},
notes: $("notes").value,
}),
});
state.registrationStatus = status;
state.registrationSeriesUid = selectedSeries.series_uid;
if (state.activeCase) {
state.activeCase.registration_status = status;
state.activeCase.series_instance_uid = selectedSeries.series_uid;
state.activeCase.series_description = selectedSeries.description || "";
}
const existing = state.cases.find((row) => sameCase(row, state.activeCase));
if (existing) {
existing.registration_status = status;
existing.series_instance_uid = selectedSeries.series_uid;
existing.series_description = selectedSeries.description || "";
}
resetDirty();
renderCases();
renderSeries();
renderActiveHeader();
} catch (error) {
setSaveState(error.message, "error");
} finally {
state.saving = false;
$("saveBtn").disabled = false;
$("statusBtn").disabled = false;
}
}
function sameHostUrl(raw, targetPort) {
try {
const url = new URL(raw);
const here = new URL(window.location.href);
if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname)) url.hostname = here.hostname;
if (targetPort) url.port = String(targetPort);
return url.toString();
} catch {
const here = new URL(window.location.href);
here.port = String(targetPort || here.port);
here.pathname = "/";
here.search = "";
return here.toString();
}
}
function openLinkedApp(kind) {
const ct = state.activeCase?.ct_number || "";
const model = state.algorithmModel || "";
if (kind === "viewer") {
const url = new URL(sameHostUrl(state.links.pacs_viewer_url, 8107));
if (ct) url.searchParams.set("ct_number", ct);
window.open(url.toString(), "_blank");
} else {
const url = new URL(sameHostUrl(state.links.relation_viewer_url, 8108));
if (ct) url.searchParams.set("ct_number", ct);
if (model) url.searchParams.set("algorithm_model", model);
window.open(url.toString(), "_blank");
}
}
function setWindowMode(mode, reload = true) {
state.windowMode = mode || "default";
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item.dataset.window === state.windowMode));
document.querySelectorAll("[data-map-window]").forEach((item) => item.classList.toggle("active", item.dataset.mapWindow === state.windowMode));
if (reload) loadFusion();
}
function setMappingMode(mode) {
state.mappingMode = mode === "image" ? "image" : "result";
document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode));
renderDicomAnnotation();
scheduleMappingDraw();
}
function wireEvents() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", () => logout());
$("caseToggleBtn").addEventListener("click", () => {
state.caseCollapsed = !state.caseCollapsed;
document.querySelector(".workspace").classList.toggle("case-collapsed", state.caseCollapsed);
$("caseToggleBtn").classList.toggle("active", state.caseCollapsed);
window.setTimeout(() => {
state.resizeScene?.();
fitCamera();
}, 230);
});
$("refreshBtn").addEventListener("click", async () => {
await loadStatus();
await loadSettings();
await loadCases(false);
});
$("settingsBtn").addEventListener("click", async () => {
$("settingsPanel").classList.remove("hidden");
await loadSettings();
});
$("settingsCloseBtn").addEventListener("click", () => $("settingsPanel").classList.add("hidden"));
$("saveSettingsBtn").addEventListener("click", saveSettings);
$("manualCacheRefreshBtn").addEventListener("click", refreshCacheNow);
$("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("fitBtn").addEventListener("click", fitCamera);
$("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", () => {
$("dicomLoading").classList.add("hidden");
scheduleMappingDraw();
});
$("dicomPreview").addEventListener("error", () => {
$("dicomLoading").classList.add("hidden");
resetMappingCanvas("DICOM Base Layer 加载失败");
});
$("mappingResetBtn").addEventListener("click", () => {
state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0, 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);
});
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", () => {
state.fusionDetail = button.dataset.fusionDetail || "low";
document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item === button));
applyFusionDetail();
markDirty("display");
});
});
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);
}