2113 lines
83 KiB
JavaScript
2113 lines
83 KiB
JavaScript
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" },
|
||
statusFilter: "",
|
||
partFilter: "",
|
||
modelFilter: "",
|
||
search: "",
|
||
caseCollapsed: false,
|
||
activeToolTab: "series",
|
||
cases: [],
|
||
activeCase: null,
|
||
series: [],
|
||
selectedSeriesUid: "",
|
||
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,
|
||
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 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 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.selectedStlIds].map(Number).sort((a, b) => a - b).join(","),
|
||
].join("|");
|
||
}
|
||
|
||
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 `
|
||
<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").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 = "";
|
||
$("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) 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.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 bodyTags = bodyPartTags(row);
|
||
const selectedLabels = selected?.annotation_labels?.length ? selected.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 = [
|
||
`<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
|
||
`<span class="tag">${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}</span>`,
|
||
...(selected ? [`<span class="tag">${escapeHtml(selected.description || "当前序列")}</span>`] : []),
|
||
...selectedLabels.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
|
||
...bodyTags.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))
|
||
.join("")
|
||
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
|
||
document.querySelectorAll(".series-card[data-series]").forEach((button) => {
|
||
button.addEventListener("click", () => selectSeries(button.dataset.series));
|
||
});
|
||
updateSliceControl();
|
||
renderDicomAnnotation();
|
||
}
|
||
|
||
function seriesCardHtml(item, active = 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 `
|
||
<button class="series-card ${active ? "active" : ""} ${skipped ? "skipped" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
|
||
<span class="series-check" aria-hidden="true">${active ? "✓" : ""}</span>
|
||
<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>
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
async function selectSeries(uid) {
|
||
if (!uid || uid === state.selectedSeriesUid) return;
|
||
if (!(await confirmChange())) return;
|
||
state.selectedSeriesUid = 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;
|
||
setSaveState("序列未保存", "warn");
|
||
renderSeries();
|
||
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);
|
||
setSaveState("模型未保存", "warn");
|
||
renderStl();
|
||
await loadFusion();
|
||
});
|
||
});
|
||
renderAutoBoneOptions();
|
||
}
|
||
|
||
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();
|
||
});
|
||
});
|
||
}
|
||
|
||
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);
|
||
state.volumeMeta = null;
|
||
state.volumeScene = null;
|
||
state.modelMeshes = [];
|
||
state.dicomPlane = null;
|
||
state.dicomPoints = null;
|
||
state.dicomSlicePlanes = [];
|
||
$("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;
|
||
$("fusionMeta").textContent = state.fusionMode === "model"
|
||
? `单独模型 · STL ${state.selectedStlIds.size} 个`
|
||
: $("fusionMeta").textContent;
|
||
}
|
||
|
||
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 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";
|
||
$("fusionMeta").textContent = `DICOM ${volume.start + 1}-${volume.end + 1}/${volume.total} · STL ${state.selectedStlIds.size} 个`;
|
||
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 = `<div class="empty-state">${escapeHtml(message)}</div>`;
|
||
}
|
||
|
||
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;
|
||
}
|
||
if (!image?.complete || !image.naturalWidth || !volumeScene || !state.modelMeshes.length) {
|
||
resetMappingCanvas(state.modelMeshes.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();
|
||
|
||
state.modelMeshes.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}/${state.modelMeshes.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>`;
|
||
}
|
||
|
||
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 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: 0xfacc15, transparent: true, opacity: 0.56 }),
|
||
);
|
||
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 = [];
|
||
const frames = volume.frames || [];
|
||
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);
|
||
}
|
||
|
||
async function buildStlModels(volume, onProgress = null) {
|
||
const signature = stlSelectionSignature();
|
||
if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) {
|
||
applyPose();
|
||
applyModelDetail();
|
||
scheduleMappingDraw();
|
||
onProgress?.(100);
|
||
return;
|
||
}
|
||
clearGroup(state.rawModelGroup);
|
||
state.modelMeshes = [];
|
||
state.stlSignature = signature;
|
||
const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
|
||
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().setFromObject(state.rawModelGroup);
|
||
const center = new THREE.Vector3();
|
||
bbox.getCenter(center);
|
||
state.rawModelGroup.position.set(-center.x, -center.y, -center.z);
|
||
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();
|
||
box.expandByObject(state.dicomGroup);
|
||
box.expandByObject(state.modelGroup);
|
||
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) {
|
||
if (!state.rawModelGroup?.children.length || !state.dicomSceneBox) {
|
||
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||
return;
|
||
}
|
||
const axisKey = { x: "scaleX", y: "scaleY", z: "scaleZ" }[axis];
|
||
if (!axisKey) return;
|
||
const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
|
||
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();
|
||
loadFusion();
|
||
}
|
||
|
||
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 = [
|
||
`<span>平移 X ${delta.translateX}</span>`,
|
||
`<span>平移 Y ${delta.translateY}</span>`,
|
||
`<span>平移 Z ${delta.translateZ}</span>`,
|
||
`<span>缩放 ${delta.scale}</span>`,
|
||
].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 || !state.rawModelGroup?.children.length) return -Infinity;
|
||
const config = settings || readAutoSettings();
|
||
const current = { ...state.pose };
|
||
applyPose(pose);
|
||
const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
|
||
applyPose(current);
|
||
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() || !state.rawModelGroup?.children.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() || !state.rawModelGroup?.children.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;
|
||
if (state.activeCase) state.activeCase.registration_status = status;
|
||
const existing = state.cases.find((row) => sameCase(row, state.activeCase));
|
||
if (existing) existing.registration_status = status;
|
||
resetDirty();
|
||
renderCases();
|
||
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 loadCases(false);
|
||
});
|
||
$("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"));
|
||
$("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);
|
||
}
|