import * as THREE from "three";
import { STLLoader } from "./vendor/STLLoader.js";
import { OrbitControls } from "./vendor/OrbitControls.js";
const $ = (id) => document.getElementById(id);
const BODY_LABELS = {
head_neck: "头颈部",
chest: "胸部",
upper_abdomen: "上腹部",
abdomen_pelvis: "腹盆部",
};
const DEFAULT_POSE = {
rotateX: 0,
rotateY: 0,
rotateZ: 0,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
flipX: false,
flipY: false,
flipZ: false,
};
const POSE_CONTROLS = [
{ key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" },
{ key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" },
{ key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" },
{ key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" },
{ key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" },
{ key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" },
{ key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.001, decimals: 3, suffix: "x" },
];
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
const CASE_PAGE_SIZE = 20;
const FUSION_BASE_EXTENT = 4.6;
const AXIS_INSET_LENGTH = 17;
const DEFAULT_AXIS_PROJECTION = {
x: { dx: AXIS_INSET_LENGTH, dy: 0, opacity: 0.95 },
y: { dx: -10, dy: 10, opacity: 0.82 },
z: { dx: 0, dy: -AXIS_INSET_LENGTH, opacity: 0.95 },
};
const POSE_SIGNATURE_KEYS = [
"rotateX",
"rotateY",
"rotateZ",
"translateX",
"translateY",
"translateZ",
"scale",
"flipX",
"flipY",
"flipZ",
];
const AUTO_PROGRESS_STEPS = [
{ id: "save", label: "保存" },
{ id: "bone", label: "骨窗" },
{ id: "sample", label: "采样" },
{ id: "score", label: "评分" },
{ id: "apply", label: "应用" },
];
const BONE_BRIGHT_THRESHOLD = 185;
const BONE_EDGE_THRESHOLD = 165;
const BONE_FIXED_REWARD = 1;
const BONE_EDGE_REWARD = 0.22;
const STL_DISPLAY_MODES = ["hidden", "solid", "translucent"];
const STL_DISPLAY_LABELS = {
hidden: "取消显示",
solid: "实体显示",
translucent: "半透明",
};
const LIVER_STL_GROUPS = [
{ key: "liver_segments", label: "肝段", order: 10 },
{ key: "liver_body", label: "肝脏", order: 20 },
{ key: "liver_vessels", label: "肝血管", order: 30 },
{ key: "liver_lesions", label: "肿瘤和囊肿", order: 40 },
{ key: "body_surface", label: "骨骼皮肤", order: 50 },
{ key: "other", label: "其他", order: 90 },
];
const LIVER_STL_GROUP_LOOKUP = Object.fromEntries(LIVER_STL_GROUPS.map((group) => [group.key, group]));
const LIVER_STL_LABELS = {
bile_duct: "胆管",
cholecyst: "胆囊",
gallbladder: "胆囊",
hipbone: "髋骨",
liver: "肝脏整体",
liver_artery: "肝动脉",
liver_left: "左肝叶",
liver_right: "右肝叶",
liver_vein: "肝静脉",
pancreas: "胰腺",
portal_vein: "门静脉",
rib: "肋骨",
ribs: "肋骨",
sacrum: "骶骨",
skin: "皮肤",
spleen: "脾脏",
sternum: "胸骨",
vertebra: "椎骨",
vertebrae: "椎骨",
};
const ROMAN_NUMERALS = {
1: "I",
2: "II",
3: "III",
4: "IV",
5: "V",
6: "VI",
7: "VII",
8: "VIII",
};
const state = {
token: localStorage.getItem("dicom_upp_registration_token") || "",
user: null,
links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" },
settings: null,
cacheStatus: null,
cachePollTimer: 0,
statusFilter: "",
partFilter: "",
modelFilter: "",
search: "",
searchTimer: 0,
caseCollapsed: false,
caseLimit: CASE_PAGE_SIZE,
caseOffset: 0,
caseTotal: 0,
caseHasMore: false,
loadingCases: false,
activeToolTab: "series",
cases: [],
activeCase: null,
series: [],
selectedSeriesUid: "",
registrationSeriesUid: "",
stlFiles: [],
selectedStlIds: new Set(),
stlDisplayModes: {},
collapsedStlFamilies: new Set(),
algorithmModel: "",
registrationStatus: "unregistered",
pose: { ...DEFAULT_POSE },
windowMode: "default",
fusionMode: "fusion",
fusionDetail: "high",
modelDetail: "ultra",
mappingMode: "result",
sliceIndex: 0,
sliceRangeStart: 0,
sliceRangeEnd: 0,
dicomPreviewTimer: 0,
autoResult: null,
lastAutoPose: null,
autoDeltaBaseline: null,
autoFineRunning: false,
poseControlsLocked: false,
collapsedPoseSections: { manual: false, auto: false },
dirty: false,
dirtyReason: "",
savedPoseSignature: "",
saving: false,
fusionVersion: 0,
volumeMeta: null,
volumeScene: null,
sceneReady: false,
renderer: null,
camera: null,
scene: null,
controls: null,
fusionRoot: null,
viewPose: { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 },
sceneDrag: null,
dicomGroup: null,
dicomPlane: null,
dicomPoints: null,
dicomSlicePlanes: [],
modelGroup: null,
rawModelGroup: null,
axisGroup: null,
modelMeshes: [],
stlSignature: "",
baseModelScale: 1,
modelBoundsFrame: null,
modelLocalBounds: null,
dicomSceneBox: null,
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 },
mappingDrag: null,
mappingDrawFrame: 0,
mappingVersion: 0,
resizeObserver: null,
resizeScene: null,
axisProjectionSignature: "",
nudgeTimer: null,
nudgeDelayTimer: null,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function cls(value) {
return value ? "active" : "";
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function getDicomDisplaySliceNumber(sliceIndex, totalSlices) {
const total = Math.max(Math.round(Number(totalSlices) || 0), 0);
if (!total) return 0;
return total - clamp(Math.round(Number(sliceIndex) || 0), 0, total - 1);
}
function getDicomDisplayRange(startIndex, endIndex, totalSlices) {
const first = getDicomDisplaySliceNumber(startIndex, totalSlices);
const second = getDicomDisplaySliceNumber(endIndex, totalSlices);
return {
start: Math.min(first, second),
end: Math.max(first, second),
};
}
function setFusionWebglError(message = "") {
const overlay = $("fusionWebglError");
if (!overlay) return;
overlay.classList.toggle("hidden", !message);
const detail = overlay.querySelector("p");
if (detail && message) detail.textContent = message;
}
function setTopLoading(visible) {
$("topLoading").classList.toggle("hidden", !visible);
}
function setFusionLoading(visible, text = "正在构建融合视图", progress = null) {
$("fusionLoading").classList.toggle("hidden", !visible);
$("fusionLoading").querySelector("span").textContent = text;
const fill = $("fusionProgressFill");
const label = $("fusionProgressText");
if (fill && label) {
const value = progress == null ? 0 : Math.max(0, Math.min(100, Number(progress) || 0));
fill.style.width = progress == null ? "34%" : `${value}%`;
fill.classList.toggle("indeterminate", progress == null);
label.textContent = progress == null ? "" : `${Math.round(value)}%`;
}
}
function setDicomLoading(visible, text = "正在加载分割结果", progress = null) {
$("dicomLoading").classList.toggle("hidden", !visible);
$("dicomLoading").querySelector("span").textContent = text;
const fill = $("dicomProgressFill");
const label = $("dicomProgressText");
if (!fill || !label) return;
const value = progress == null ? 0 : clamp(Number(progress) || 0, 0, 100);
fill.style.width = progress == null ? "34%" : `${value}%`;
fill.classList.toggle("indeterminate", progress == null);
label.textContent = progress == null ? "" : `${Math.round(value)}%`;
}
function setSaveState(text, tone = "") {
const el = $("saveState");
el.textContent = text;
el.className = tone;
}
function setAutoState(text, tone = "") {
const el = $("autoState");
if (!el) return;
el.textContent = text;
el.className = tone;
}
function markDirty(reason = "pose") {
state.dirty = true;
state.dirtyReason = reason;
setSaveState("未保存", "warn");
if (reason === "pose") updateAutoPoseResult();
}
function poseSignature(pose = state.pose) {
const normalized = {};
POSE_SIGNATURE_KEYS.forEach((key) => {
const value = pose?.[key] ?? DEFAULT_POSE[key];
normalized[key] = typeof value === "boolean" ? value : Number(Number(value || 0).toFixed(4));
});
return JSON.stringify(normalized);
}
function poseChanged() {
return state.savedPoseSignature && poseSignature(state.pose) !== state.savedPoseSignature;
}
function resetDirty() {
state.dirty = false;
state.dirtyReason = "";
state.savedPoseSignature = poseSignature(state.pose);
setSaveState("已保存", "ok");
resetAutoDeltaBaseline();
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.token) headers.set("Authorization", `Bearer ${state.token}`);
if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const response = await fetch(path, { ...options, headers });
if (response.status === 401) {
logout(false);
throw new Error("登录已过期");
}
if (!response.ok) {
let message = response.statusText;
try {
const payload = await response.json();
message = payload.detail || message;
} catch {
message = await response.text();
}
throw new Error(message || "请求失败");
}
const type = response.headers.get("content-type") || "";
return type.includes("application/json") ? response.json() : response.text();
}
function loginVisible(visible) {
$("loginOverlay").classList.toggle("hidden", !visible);
}
async function login(event) {
event.preventDefault();
$("loginError").textContent = "";
try {
const payload = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }),
});
state.token = payload.token;
state.user = payload;
localStorage.setItem("dicom_upp_registration_token", state.token);
loginVisible(false);
await bootstrap();
} catch (error) {
$("loginError").textContent = error.message;
}
}
function logout(clearToken = true) {
if (clearToken) localStorage.removeItem("dicom_upp_registration_token");
state.token = "";
state.user = null;
loginVisible(true);
$("userBadge").textContent = "未登录";
}
async function bootstrap() {
setTopLoading(true);
try {
const me = await api("/api/auth/me");
state.user = me;
$("userBadge").textContent = `${me.username} · ${me.role || "管理员"}`;
loginVisible(false);
try {
initScene();
setFusionWebglError("");
} catch (error) {
initGeometryFallback();
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
setFusionWebglError("三维融合视图暂不可用,请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与映射功能仍可继续使用。");
console.warn(error);
}
buildPoseControls();
await loadStatus();
await loadSettings();
await loadCases();
} catch (error) {
console.warn(error);
if (!state.token) logout(false);
else {
$("dbStatus").textContent = error.message || "启动失败";
$("dbStatus").className = "status-pill offline";
loginVisible(false);
}
} finally {
setTopLoading(false);
}
}
async function loadStatus() {
try {
const payload = await api("/api/status");
state.links = payload.links || state.links;
const db = payload.database || {};
$("dbStatus").textContent = db.ok ? `数据库已连接 · ${payload.counts?.complete_cases || 0}` : "数据库异常";
$("dbStatus").className = `status-pill ${db.ok ? "online" : "offline"}`;
} catch (error) {
$("dbStatus").textContent = "数据库异常";
$("dbStatus").className = "status-pill offline";
}
}
async function loadSettings() {
try {
const payload = await api("/api/settings");
state.settings = payload.refresh || {};
state.cacheStatus = payload.cache || {};
renderSettings();
pollCacheWhileRunning();
} catch (error) {
console.warn(error);
}
}
function renderSettings() {
const settings = state.settings || {};
const cache = state.cacheStatus || {};
$("autoRefreshEnabled").checked = settings.auto_refresh_enabled !== false;
$("refreshTime").value = settings.refresh_time || "03:00";
$("cacheRootText").textContent = cache.cache_root || settings.cache_root || "";
$("cacheStage").textContent = cache.running ? "刷新中" : cache.stage === "finished" ? "已完成" : cache.stage === "error" ? "异常" : "待刷新";
$("cacheStage").className = `status-pill ${cache.stage === "error" ? "offline" : cache.running || cache.stage === "finished" ? "online" : ""}`;
const progress = Math.max(0, Math.min(100, Number(cache.progress || 0)));
$("cacheProgressFill").style.width = `${progress}%`;
$("cacheMessage").textContent = cache.message || "等待刷新";
$("cacheCases").textContent = `${Number(cache.processed_cases || 0)} / ${Number(cache.total_cases || 0)} CT`;
$("cacheFiles").textContent = `STL 复制 ${Number(cache.copied_files || 0)} · 复用 ${Number(cache.skipped_files || 0)} · 缺失 ${Number(cache.missing_files || 0)}`;
$("cacheNextRun").textContent = cache.next_scheduled_at ? `下次 ${cache.next_scheduled_at}` : "下次 -";
$("manualCacheRefreshBtn").disabled = Boolean(cache.running);
}
async function saveSettings() {
const payload = {
auto_refresh_enabled: $("autoRefreshEnabled").checked,
refresh_time: $("refreshTime").value || "03:00",
};
const result = await api("/api/settings", { method: "POST", body: JSON.stringify(payload) });
state.settings = result.refresh || payload;
state.cacheStatus = result.cache || state.cacheStatus;
renderSettings();
}
async function refreshCacheNow() {
$("manualCacheRefreshBtn").disabled = true;
const result = await api("/api/cache/refresh", { method: "POST", body: JSON.stringify({}) });
state.cacheStatus = result.cache || state.cacheStatus;
renderSettings();
pollCacheWhileRunning(true);
}
function pollCacheWhileRunning(force = false) {
window.clearTimeout(state.cachePollTimer);
const running = Boolean(state.cacheStatus?.running);
if (!running && !force) return;
state.cachePollTimer = window.setTimeout(async () => {
try {
state.cacheStatus = await api("/api/cache/status");
renderSettings();
} catch (error) {
console.warn(error);
}
pollCacheWhileRunning();
}, running ? 1600 : 800);
}
async function loadCases(selectFirst = true, append = false) {
if (state.loadingCases) return;
state.loadingCases = true;
if (!append) {
state.caseOffset = 0;
state.caseHasMore = false;
state.cases = [];
}
setTopLoading(!append);
try {
const params = new URLSearchParams();
if (state.search) params.set("q", state.search);
if (state.statusFilter) params.set("status", state.statusFilter);
if (state.partFilter) params.set("body_part", state.partFilter);
if (state.modelFilter) params.set("algorithm_model", state.modelFilter);
params.set("limit", String(state.caseLimit));
params.set("offset", String(append ? state.caseOffset : 0));
const payload = await api(`/api/cases?${params.toString()}`);
const rows = Array.isArray(payload) ? payload : payload.rows || [];
state.caseTotal = Array.isArray(payload) ? (append ? state.cases.length + rows.length : rows.length) : Number(payload.total || 0);
state.caseHasMore = Array.isArray(payload) ? rows.length >= state.caseLimit : Boolean(payload.has_more);
state.caseOffset = (append ? state.caseOffset : 0) + rows.length;
state.cases = append ? [...state.cases, ...rows] : rows;
renderCases();
if (selectFirst && !state.activeCase && rows.length) {
await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型");
} else if (!append && state.activeCase && !state.cases.some((row) => sameCase(row, state.activeCase))) {
state.activeCase = null;
clearDetail();
}
} finally {
state.loadingCases = false;
if (!append) setTopLoading(false);
}
}
async function loadMoreCases() {
if (state.loadingCases || !state.caseHasMore) return;
await loadCases(false, true);
}
function sameCase(a, b) {
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
}
function sameCtNumber(a, b) {
return normalize(a) === normalize(b);
}
function normalize(value) {
return String(value || "").trim().toUpperCase();
}
function fmtDateTime(date, time) {
const d = String(date || "").replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
const t = fmtTime(time);
return [d, t].filter(Boolean).join(" ");
}
function fmtTime(value) {
const raw = String(value || "").replace(/[^\d.]/g, "");
if (!raw) return "";
const main = raw.split(".")[0].padEnd(6, "0").slice(0, 6);
return `${main.slice(0, 2)}:${main.slice(2, 4)}:${main.slice(4, 6)}`;
}
function statusText(value) {
return value === "registered" ? "已配准" : "未配准";
}
function seriesLabels(item) {
return Array.isArray(item?.annotation_labels) ? item.annotation_labels.map((label) => String(label || "")) : [];
}
function isSkippedSeries(item) {
return seriesLabels(item).some((label) => label.includes("略过") || label.includes("不采用"));
}
function hasLabel(item, matcher) {
return seriesLabels(item).some((label) => matcher(String(label)));
}
function isUpperAbdomenSeries(item) {
return hasLabel(item, (label) => label.includes("上腹部"));
}
function isPortalPhaseSeries(item) {
return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期") || label.includes("门静脉")));
}
function isLiverBiliaryModel(model = state.algorithmModel) {
const value = String(model || "").toLowerCase();
return value.includes("肝胆") || value.includes("liver") || value.includes("bile");
}
function sortedSeriesForDisplay(series) {
return [...series].sort((a, b) => {
const skippedDiff = Number(isSkippedSeries(a)) - Number(isSkippedSeries(b));
if (skippedDiff) return skippedDiff;
const timeDiff = String(a.first_time || a.series_time || "").localeCompare(String(b.first_time || b.series_time || ""));
if (timeDiff) return timeDiff;
return Number(a.series_number || 999999) - Number(b.series_number || 999999);
});
}
function stlSelectionSignature() {
return [
normalize(state.activeCase?.ct_number),
state.algorithmModel || "",
state.stlFiles.map((file) => `${Number(file.id)}:${file.file_name || ""}:${file.size_bytes || 0}`).join(","),
].join("|");
}
function visibleModelRecords() {
return state.modelMeshes.filter((record) => stlMode(record.file) !== "hidden" && record.mesh?.visible !== false);
}
function modelPoseMatrix() {
if (!state.modelGroup) return new THREE.Matrix4();
state.modelGroup.updateMatrix();
return state.modelGroup.matrix.clone();
}
function visibleModelBox(records = visibleModelRecords()) {
if (!records.length) return null;
const box = new THREE.Box3();
const matrix = modelPoseMatrix();
records.forEach((record) => {
record.mesh.geometry.computeBoundingBox?.();
const localBox = record.mesh.geometry.boundingBox;
if (localBox) box.union(localBox.clone().applyMatrix4(matrix));
});
return box;
}
function autoReferenceRecords(settings = null) {
const visibleRecords = visibleModelRecords();
const referenceIds = new Set((settings?.referenceIds || []).map((id) => Number(id)));
if (!referenceIds.size) return visibleRecords;
const referenceRecords = visibleRecords.filter((record) => referenceIds.has(Number(record.file?.id)));
return referenceRecords.length ? referenceRecords : visibleRecords;
}
function bodyPartTags(row) {
const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : [];
const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : [];
if (labels.length) return labels;
return keys.map((key) => BODY_LABELS[key]).filter(Boolean);
}
function renderCases() {
$("caseCount").textContent = state.caseTotal ? `${state.cases.length}/${state.caseTotal}` : state.cases.length;
const active = state.activeCase;
const cards = state.cases.length
? state.cases
.map((row) => {
const tags = bodyPartTags(row).slice(0, 3);
const status = row.registration_status || "unregistered";
const selected = active && sameCase(row, active);
const title = escapeHtml(row.ct_number || row.ct_key);
const patient = escapeHtml(row.patient_name || row.upp_patient_name || "-");
const meta = `${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`;
return `
`;
})
.join("")
: `
没有符合条件的完整关联 CT
`;
$("caseList").innerHTML = `${cards}${state.caseHasMore ? `` : ""}`;
$("caseList").querySelectorAll(".case-card").forEach((button) => {
button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型"));
});
$("loadMoreCasesBtn")?.addEventListener("click", loadMoreCases);
}
function clearDetail() {
state.series = [];
state.stlFiles = [];
state.selectedStlIds = new Set();
state.stlDisplayModes = {};
state.collapsedStlFamilies = new Set();
state.selectedSeriesUid = "";
state.registrationSeriesUid = "";
$("activeTitle").textContent = "未选择 CT";
$("activeMeta").textContent = "从左侧选择一个完整关联检查";
$("activeTags").innerHTML = "";
$("seriesList").innerHTML = `未选择 CT
`;
$("stlList").innerHTML = `未选择 STL
`;
$("modelRail").innerHTML = "";
renderDicomAnnotation();
clearFusion();
}
async function confirmChange() {
if (!state.dirty || !poseChanged()) return true;
return window.confirm("当前配准参数还未保存,确定切换吗?");
}
async function selectCase(ctNumber, algorithmModel = "未指定模型") {
if (!ctNumber) return;
const normalizedAlgorithm = algorithmModel || "未指定模型";
const switchingModelOnly = state.activeCase
&& sameCtNumber(state.activeCase.ct_number, ctNumber)
&& (state.algorithmModel || "未指定模型") !== normalizedAlgorithm;
const previousPose = { ...state.pose };
if (!(await confirmChange())) return;
setTopLoading(true);
setFusionLoading(true, "正在读取当前 CT 数据", 6);
try {
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
api(`/api/cases/${encodeURIComponent(ctNumber)}?${params.toString()}`),
api(`/api/cases/${encodeURIComponent(ctNumber)}/series`),
api(`/api/cases/${encodeURIComponent(ctNumber)}/stl?${params.toString()}`),
api(`/api/registrations/${encodeURIComponent(ctNumber)}?${params.toString()}`),
]);
state.activeCase = detail;
state.algorithmModel = detail.algorithm_model || algorithmModel || "未指定模型";
state.series = seriesPayload.series || [];
state.stlFiles = stlPayload.files || [];
state.registrationStatus = registration.registration_status || "unregistered";
const savedTransform = { ...(registration.transform || {}), scaleX: 1, scaleY: 1, scaleZ: 1 };
const keepPreviousPose = switchingModelOnly;
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
state.fusionDetail = registration.model_reference?.fusion_detail || state.fusionDetail || "high";
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "ultra";
state.autoResult = null;
state.lastAutoPose = null;
state.autoDeltaBaseline = null;
state.sliceIndex = 0;
$("notes").value = registration.notes || "";
const savedIds = new Set((registration.selected_stl_files || []).map((item) => Number(item.id)).filter(Number.isFinite));
state.selectedStlIds = savedIds.size ? savedIds : pickDefaultStlIds(state.stlFiles);
state.stlDisplayModes = initStlDisplayModes(state.stlFiles, state.selectedStlIds, registration.module_styles || {});
state.collapsedStlFamilies = new Set();
const defaultSeries = pickDefaultSeries(state.series);
const savedSeries = state.series.find((item) => item.series_uid === registration.series_instance_uid);
const shouldUseSavedSeries = registration.registration_status === "registered" && savedSeries;
state.registrationSeriesUid = savedSeries?.series_uid || "";
state.selectedSeriesUid = shouldUseSavedSeries ? savedSeries.series_uid : defaultSeries?.series_uid || "";
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
state.sliceRangeStart = 0;
state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
state.stlSignature = "";
renderCases();
renderActiveHeader();
renderSeries();
renderStl();
renderPoseControls();
syncDisplayControls();
resetDirty();
await loadFusion();
} catch (error) {
$("fusionStatus").textContent = error.message;
clearFusion();
} finally {
setTopLoading(false);
setFusionLoading(false);
}
}
function pickDefaultSeries(series) {
const candidates = [...series].filter((item) => Number(item.count || 0) > 1);
const usable = candidates.filter((item) => !isSkippedSeries(item));
const pool = usable.length ? usable : candidates;
const rank = (item) => {
let score = Number(item.count || 0) / 10000;
if (isLiverBiliaryModel()) {
if (isPortalPhaseSeries(item)) score += 900;
else if (isUpperAbdomenSeries(item)) score += 720;
} else if (isUpperAbdomenSeries(item)) {
score += 120;
}
if (isSkippedSeries(item)) score -= 1000;
return score;
};
return [...pool]
.sort((a, b) => {
const diff = rank(b) - rank(a);
if (Math.abs(diff) > 1e-6) return diff;
return Number(a.series_number || 999999) - Number(b.series_number || 999999);
})[0] || series[0] || null;
}
function pickDefaultStlIds(files) {
const priority = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen", "vertebrae"];
const ranked = [...files].sort((a, b) => {
const af = String(a.family || a.segment_name || a.file_name || "").toLowerCase();
const bf = String(b.family || b.segment_name || b.file_name || "").toLowerCase();
const ai = priority.findIndex((key) => af === key || af.includes(key));
const bi = priority.findIndex((key) => bf === key || bf.includes(key));
const ar = ai < 0 ? 999 : ai;
const br = bi < 0 ? 999 : bi;
if (ar !== br) return ar - br;
return Number(a.id || 0) - Number(b.id || 0);
});
return new Set(ranked.slice(0, Math.min(6, ranked.length)).map((file) => Number(file.id)));
}
function normalizedStlId(fileOrId) {
const id = typeof fileOrId === "object" ? Number(fileOrId?.id) : Number(fileOrId);
return Number.isFinite(id) ? id : null;
}
function validStlDisplayMode(mode) {
return STL_DISPLAY_MODES.includes(mode) ? mode : "";
}
function stlMode(fileOrId) {
const id = normalizedStlId(fileOrId);
if (id == null || !state.selectedStlIds.has(id)) return "hidden";
return validStlDisplayMode(state.stlDisplayModes[id]) || "translucent";
}
function stlFamilyLabel(file) {
return String(file.family || "未分类");
}
function normalizedStlText(file) {
return [
file.family,
file.segment_name,
file.file_name,
file.category,
].map((value) => String(value || "").toLowerCase()).join(" ");
}
function normalizedStlName(file) {
return String(file.segment_name || file.family || file.file_name || "")
.replace(/\.stl$/i, "")
.toLowerCase();
}
function stlSegmentNumber(file) {
const text = normalizedStlText(file);
const match = text.match(/(?:segment[_\s-]*s?|肝段)\s*([0-9]+)/i) || text.match(/_s([0-9]+)/i);
return match ? Number(match[1]) : 999;
}
function lesionNumber(file) {
const text = normalizedStlText(file);
const match = text.match(/(?:^|[_\s-])(?:liver[_\s-]*)?(?:tumor|lesion|cyst)[_\s-]*([0-9]+)/i)
|| text.match(/(?:肿瘤|囊肿)[_\s-]*([0-9]+)/i);
return match ? Number(match[1]) : 99;
}
function isTumorStl(file, category = String(file.category || "")) {
const text = normalizedStlText(file);
return /(?:^|[_\s-])(?:liver[_\s-]*)?(?:tumor|lesion)(?:[_\s-]|\d|$)/i.test(text) || category.includes("肿瘤");
}
function isCystStl(file, category = String(file.category || "")) {
const text = normalizedStlText(file);
return /(?:^|[_\s-])(?:liver[_\s-]*)?cyst(?:[_\s-]|\d|$)/i.test(text) || category.includes("囊肿");
}
function numberedLesionLabel(file, baseLabel) {
const number = lesionNumber(file);
return number < 99 ? `${baseLabel} ${number}` : baseLabel;
}
function liverStlPresentation(file) {
const text = normalizedStlText(file);
const name = normalizedStlName(file);
const category = String(file.category || "");
let groupKey = "other";
let itemOrder = 900;
let label = LIVER_STL_LABELS[name] || file.segment_name || file.family || file.file_name;
if (text.includes("liver_segment") || category.includes("肝段")) {
const segment = stlSegmentNumber(file);
groupKey = "liver_segments";
itemOrder = segment;
label = `肝段 ${ROMAN_NUMERALS[segment] || segment}`;
} else if (["liver", "liver_left", "liver_right"].includes(name) || category.includes("肝脏主体")) {
groupKey = "liver_body";
itemOrder = { liver: 1, liver_left: 2, liver_right: 3 }[name] || 99;
} else if (["liver_artery", "liver_vein", "portal_vein"].includes(name)) {
groupKey = "liver_vessels";
itemOrder = { liver_artery: 1, liver_vein: 2, portal_vein: 3 }[name] || 99;
} else if (isTumorStl(file, category) || isCystStl(file, category)) {
const tumor = isTumorStl(file, category);
groupKey = "liver_lesions";
itemOrder = (tumor ? 1 : 20) + lesionNumber(file) / 100;
label = tumor
? numberedLesionLabel(file, "肝脏肿瘤")
: numberedLesionLabel(file, "肝囊肿");
} else if (category.includes("体表骨骼") || ["hipbone", "rib", "ribs", "sacrum", "skin", "sternum", "vertebra", "vertebrae"].includes(name)) {
groupKey = "body_surface";
itemOrder = { vertebrae: 1, vertebra: 1, rib: 2, ribs: 2, sternum: 3, sacrum: 4, hipbone: 5, skin: 6 }[name] || 99;
} else {
itemOrder = { bile_duct: 1, cholecyst: 2, gallbladder: 2, pancreas: 3, spleen: 4 }[name] || 99;
}
const group = LIVER_STL_GROUP_LOOKUP[groupKey] || LIVER_STL_GROUP_LOOKUP.other;
return {
key: group.key,
label: group.label,
order: group.order,
itemOrder,
itemLabel: label,
hierarchy: true,
};
}
function stlPresentation(file) {
if (isLiverBiliaryModel(file.algorithm_model || state.algorithmModel)) return liverStlPresentation(file);
const label = stlFamilyLabel(file);
return {
key: label,
label,
order: 100,
itemOrder: 100,
itemLabel: file.segment_name || file.file_name,
hierarchy: false,
};
}
function compactStlName(file) {
return String(file.segment_name || file.family || file.file_name || `STL_${file.id || ""}`)
.replace(/\.stl$/i, "");
}
function stlFileIndex(file) {
const id = normalizedStlId(file);
const index = state.stlFiles.findIndex((item) => normalizedStlId(item) === id);
return index >= 0 ? index : 0;
}
function stlColor(file, fallbackIndex = 0) {
const index = stlFileIndex(file);
return cssColor(index >= 0 ? index : fallbackIndex);
}
function syncStlDisplayModesForSelection(defaultMode = "translucent") {
const next = {};
state.stlFiles.forEach((file) => {
const id = normalizedStlId(file);
if (id == null || !state.selectedStlIds.has(id)) return;
next[id] = validStlDisplayMode(state.stlDisplayModes[id]) || defaultMode;
});
state.stlDisplayModes = next;
}
function initStlDisplayModes(files, selectedIds, moduleStyles = {}) {
const modes = {};
files.forEach((file) => {
const id = normalizedStlId(file);
if (id == null || !selectedIds.has(id)) return;
const style = moduleStyles?.[file.file_name] || moduleStyles?.[file.segment_name] || {};
const savedMode = validStlDisplayMode(style.mode);
if (savedMode && savedMode !== "hidden") {
modes[id] = savedMode;
return;
}
modes[id] = Number(style.opacity) >= 0.95 ? "solid" : "translucent";
});
return modes;
}
async function setStlMode(fileOrId, mode) {
const id = normalizedStlId(fileOrId);
const nextMode = validStlDisplayMode(mode);
if (id == null || !nextMode) return;
if (nextMode === "hidden") {
state.selectedStlIds.delete(id);
delete state.stlDisplayModes[id];
} else {
state.selectedStlIds.add(id);
state.stlDisplayModes[id] = nextMode;
}
await applyStlSelectionChange();
}
async function cycleStlMode(file) {
const next = {
hidden: "solid",
solid: "translucent",
translucent: "hidden",
}[stlMode(file)] || "translucent";
await setStlMode(file, next);
}
async function setAllStlMode(mode) {
const nextMode = validStlDisplayMode(mode);
if (!state.stlFiles.length || !nextMode) return;
if (nextMode === "hidden") {
state.selectedStlIds = new Set();
state.stlDisplayModes = {};
} else {
state.selectedStlIds = new Set(state.stlFiles.map((file) => normalizedStlId(file)).filter((id) => id != null));
state.stlDisplayModes = {};
state.selectedStlIds.forEach((id) => {
state.stlDisplayModes[id] = nextMode;
});
}
await applyStlSelectionChange();
}
function aggregateStlMode() {
if (!state.stlFiles.length || !state.selectedStlIds.size) return "hidden";
const modes = state.stlFiles.map((file) => stlMode(file));
if (modes.every((mode) => mode === "solid")) return "solid";
if (modes.every((mode) => mode === "translucent")) return "translucent";
if (modes.every((mode) => mode === "hidden")) return "hidden";
return "mixed";
}
function renderStlVisibilityAction() {
const button = $("cycleAllStlModeBtn");
if (!button) return;
const mode = aggregateStlMode();
button.classList.remove("hidden-eye", "solid-eye", "translucent-eye", "mixed-eye");
button.classList.add(`${mode === "mixed" ? "mixed" : mode}-eye`);
const label = {
hidden: "当前全部隐藏,点击切换为全部实体",
solid: "当前全部实体,点击切换为全部半透明",
translucent: "当前全部半透明,点击切换为全部隐藏",
mixed: "当前为混合显示,点击切换为全部实体",
}[mode];
button.title = label;
button.setAttribute("aria-label", label);
}
async function cycleAllStlMode() {
const mode = aggregateStlMode();
const next = mode === "solid" ? "translucent" : mode === "translucent" ? "hidden" : "solid";
await setAllStlMode(next);
}
function currentSeries() {
return state.series.find((item) => item.series_uid === state.selectedSeriesUid) || null;
}
function renderActiveHeader() {
const row = state.activeCase;
if (!row) return;
const registration = registrationSeries();
const selectedLabels = registration?.annotation_labels?.length ? registration.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : [];
$("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`;
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${row.patient_id || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`;
const tags = [
`${statusText(state.registrationStatus)}`,
`${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}`,
...selectedLabels.map((tag) => `${escapeHtml(tag)}`),
];
$("activeTags").innerHTML = tags.join("");
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
$("statusBtn").classList.toggle("unregistered", state.registrationStatus !== "registered");
}
function renderSeries() {
$("seriesCount").textContent = state.series.length;
$("seriesList").innerHTML = state.series.length
? sortedSeriesForDisplay(state.series)
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid, item.series_uid === state.registrationSeriesUid))
.join("")
: `未读取到 DICOM 序列
`;
document.querySelectorAll(".series-card[data-series]").forEach((card) => {
card.addEventListener("click", (event) => {
if (event.target.closest(".series-check")) return;
selectSeries(card.dataset.series);
});
card.addEventListener("keydown", (event) => {
if (!["Enter", " "].includes(event.key)) return;
event.preventDefault();
selectSeries(card.dataset.series);
});
});
document.querySelectorAll(".series-check[data-registration-series]").forEach((button) => {
button.addEventListener("click", async (event) => {
event.stopPropagation();
await setRegistrationSeries(button.dataset.registrationSeries);
});
});
updateSliceControl();
renderDicomAnnotation();
}
function seriesCardHtml(item, active = false, registrationSelected = false) {
const labels = (item.annotation_labels || []).map((label) => `${escapeHtml(label)}`).join("");
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
const skipped = isSkippedSeries(item);
return `
${escapeHtml(item.description || "未命名序列")}
拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}
${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张
${item.body_part_dicom ? `${escapeHtml(item.body_part_dicom)}` : ""}
${labels || `未标注`}
`;
}
function registrationSeries() {
return state.series.find((item) => item.series_uid === state.registrationSeriesUid) || null;
}
async function setRegistrationSeries(uid) {
if (!uid) return;
if (uid !== state.selectedSeriesUid) {
await selectSeries(uid, { markRegistration: true });
return;
}
state.registrationSeriesUid = uid;
markDirty("series");
renderSeries();
renderActiveHeader();
}
async function selectSeries(uid, options = {}) {
if (!uid || uid === state.selectedSeriesUid) return;
if (!(await confirmChange())) return;
state.selectedSeriesUid = uid;
if (options.markRegistration) state.registrationSeriesUid = uid;
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
state.sliceRangeStart = 0;
state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0;
if (options.markRegistration) markDirty("series");
else setSaveState("正在浏览序列", "");
renderSeries();
renderActiveHeader();
renderDicomAnnotation();
await loadFusion();
}
function renderStl() {
$("stlCount").textContent = state.stlFiles.length ? `${state.selectedStlIds.size}/${state.stlFiles.length}` : "0";
renderStlVisibilityAction();
const models = [...new Set(state.stlFiles.map((item) => item.algorithm_model || state.algorithmModel || "未指定模型"))];
$("modelRail").innerHTML = models.map((model) => `${escapeHtml(model)}`).join("");
if (!state.stlFiles.length) {
$("stlList").innerHTML = `当前算法模型没有 STL 文件
`;
renderAutoBoneOptions();
return;
}
const grouped = new Map();
state.stlFiles.forEach((file, index) => {
const presentation = stlPresentation(file);
if (!grouped.has(presentation.key)) {
grouped.set(presentation.key, {
key: presentation.key,
label: presentation.label,
order: presentation.order,
hierarchy: presentation.hierarchy,
rows: [],
});
}
grouped.get(presentation.key).rows.push({ file, index, presentation });
});
$("stlList").innerHTML = [...grouped.values()]
.sort((a, b) => a.order - b.order || a.label.localeCompare(b.label))
.map((group) => {
const rows = group.rows.sort((a, b) => (
a.presentation.itemOrder - b.presentation.itemOrder
|| String(a.presentation.itemLabel).localeCompare(String(b.presentation.itemLabel))
|| a.index - b.index
));
const visibleCount = rows.filter(({ file }) => stlMode(file) !== "hidden").length;
const collapsed = state.collapsedStlFamilies.has(group.key);
return `
${rows.map(({ file, index, presentation }) => {
const id = normalizedStlId(file);
const mode = stlMode(file);
const color = stlColor(file, index);
return `
`;
}).join("")}
`;
}).join("");
$("stlList").querySelectorAll("[data-family-toggle]").forEach((button) => {
button.addEventListener("click", () => {
const groupKey = button.dataset.familyToggle;
if (state.collapsedStlFamilies.has(groupKey)) state.collapsedStlFamilies.delete(groupKey);
else state.collapsedStlFamilies.add(groupKey);
renderStl();
});
});
$("stlList").querySelectorAll("[data-stl-mode]").forEach((button) => {
button.addEventListener("click", async () => {
const id = Number(button.dataset.stlMode);
const file = state.stlFiles.find((item) => normalizedStlId(item) === id);
if (file) await cycleStlMode(file);
});
});
renderAutoBoneOptions();
}
async function selectAllStl() {
await setAllStlMode("translucent");
}
async function invertStlSelection() {
if (!state.stlFiles.length) return;
const nextSelected = new Set();
const nextModes = {};
state.stlFiles.forEach((file) => {
const id = normalizedStlId(file);
if (id != null && !state.selectedStlIds.has(id)) {
nextSelected.add(id);
nextModes[id] = "translucent";
}
});
state.selectedStlIds = nextSelected;
state.stlDisplayModes = nextModes;
await applyStlSelectionChange();
}
async function applyStlSelectionChange() {
markDirty("stl");
syncStlDisplayModesForSelection();
renderStl();
const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite));
const needsLoad = [...state.selectedStlIds].some((id) => !loadedIds.has(Number(id)));
if (!state.volumeMeta || (!state.modelMeshes.length && state.selectedStlIds.size)) {
await loadFusion();
return;
}
if (needsLoad) {
setFusionLoading(true, "正在补加载 STL 模型", 62);
await buildStlModels(state.volumeMeta, (progress) => setFusionLoading(true, "正在补加载 STL 模型", 62 + progress * 0.3));
setFusionLoading(false);
applyModelDetail();
if (state.sceneReady) fitCamera();
renderStl();
return;
}
applyStlVisibility();
applyModelDetail();
scheduleMappingDraw();
}
function stlLikelyScore(file) {
const textValue = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
const priority = [
["rib", 95],
["vertebra", 94],
["spine", 92],
["sternum", 90],
["pelvis", 82],
["skull", 78],
["bone", 72],
];
return priority.reduce((score, [key, value]) => textValue.includes(key) ? Math.max(score, value) : score, 0);
}
function stlDisplayName(file) {
return String(file.segment_name || file.family || file.file_name || `stl_${file.id || ""}`).replace(/\\.stl$/i, "");
}
function renderAutoBoneOptions() {
const container = $("autoBoneOptions");
if (!container) return;
if (!state.stlFiles.length) {
container.innerHTML = `No STL
`;
return;
}
const rows = [...state.stlFiles].sort((a, b) => {
const diff = stlLikelyScore(b) - stlLikelyScore(a);
if (diff) return diff;
return stlDisplayName(a).localeCompare(stlDisplayName(b));
});
container.innerHTML = rows.map((file) => {
const id = Number(file.id);
const likely = stlLikelyScore(file) > 0;
const checked = likely ? "checked" : "";
const title = escapeHtml(`${file.file_name || ""} ${file.family || ""}`.trim());
return `
`;
}).join("");
}
function cssColor(index) {
return `#${STL_COLORS[index % STL_COLORS.length].toString(16).padStart(6, "0")}`;
}
function formatBytes(value) {
const n = Number(value || 0);
if (!n) return "大小未知";
if (n > 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024).toFixed(1)} KB`;
}
function buildPoseControls() {
$("poseGrid").innerHTML = POSE_CONTROLS.map(
(item) => `
`,
).join("");
$("poseGrid").querySelectorAll(".pose-control").forEach((row) => {
const key = row.dataset.pose;
const range = row.querySelector("input[type='range']");
const number = row.querySelector("input[type='number']");
const option = POSE_CONTROLS.find((item) => item.key === key);
const sync = (value, source) => {
if (state.poseControlsLocked) return;
const numeric = Number(value);
state.pose[key] = Number.isFinite(numeric) ? Number(numeric.toFixed(option?.decimals ?? 3)) : DEFAULT_POSE[key];
if (source !== range) range.value = state.pose[key];
if (source !== number) number.value = state.pose[key];
applyPose();
markDirty();
};
range.addEventListener("input", () => sync(range.value, range));
number.addEventListener("input", () => sync(number.value, number));
});
document.querySelectorAll("[data-nudge]").forEach((button) => wireHoldNudge(button));
document.querySelectorAll("[data-flip]").forEach((button) => {
button.addEventListener("click", () => {
if (state.poseControlsLocked) return;
const key = button.dataset.flip;
state.pose[key] = !state.pose[key];
renderPoseControls();
applyPose();
markDirty();
});
});
updateAutoPoseResult(state.pose, state.pose);
applyPoseLockState();
}
function wireHoldNudge(button) {
const fixedDelta = Number(button.dataset.delta || 0);
if (Math.abs(fixedDelta) >= 10) {
button.addEventListener("click", () => nudgePose(button.dataset.nudge, fixedDelta));
return;
}
const start = (event) => {
if (state.poseControlsLocked || button.disabled) return;
if (event.button !== undefined && event.button !== 0) return;
event.preventDefault();
const key = button.dataset.nudge;
const delta = Number(button.dataset.delta || 0);
nudgePose(key, delta);
clearNudgeTimers();
state.nudgeDelayTimer = window.setTimeout(() => {
state.nudgeTimer = window.setInterval(() => nudgePose(key, delta), 54);
}, 260);
button.setPointerCapture?.(event.pointerId);
};
const stop = (event) => {
clearNudgeTimers();
if (event?.pointerId !== undefined && button.hasPointerCapture?.(event.pointerId)) {
button.releasePointerCapture(event.pointerId);
}
};
button.addEventListener("pointerdown", start);
button.addEventListener("pointerup", stop);
button.addEventListener("pointercancel", stop);
button.addEventListener("pointerleave", stop);
}
function clearNudgeTimers() {
window.clearTimeout(state.nudgeDelayTimer);
window.clearInterval(state.nudgeTimer);
state.nudgeDelayTimer = null;
state.nudgeTimer = null;
}
function nudgePose(key, delta) {
if (state.poseControlsLocked) return;
if (!key || !Number.isFinite(delta)) return;
const option = POSE_CONTROLS.find((item) => item.key === key);
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
const next = Math.max(option?.min ?? -Infinity, Math.min(option?.max ?? Infinity, current + delta));
state.pose[key] = Number(next.toFixed(option?.decimals ?? 3));
renderPoseControls();
applyPose();
markDirty();
}
function renderPoseControls() {
POSE_CONTROLS.forEach((item) => {
const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`);
if (!row) return;
const value = Number(state.pose[item.key] ?? DEFAULT_POSE[item.key]);
row.querySelector("input[type='range']").value = value;
row.querySelector("input[type='number']").value = Number.isInteger(value) ? value : value.toFixed(item.decimals ?? 3);
});
document.querySelectorAll("[data-flip]").forEach((button) => {
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
});
renderActiveHeader();
updateStretchButtons();
applyPoseLockState();
}
function isRightAngleRotation(value) {
const normalized = ((Number(value) || 0) % 90 + 90) % 90;
return normalized < 0.001 || Math.abs(normalized - 90) < 0.001;
}
function canStretchByAxis() {
return ["rotateX", "rotateY", "rotateZ"].every((key) => isRightAngleRotation(state.pose[key]));
}
function updateStretchButtons() {
const enabled = canStretchByAxis() && !state.poseControlsLocked;
["x", "y", "z"].forEach((axis) => {
const button = $(`stretch${axis.toUpperCase()}Btn`);
if (!button) return;
button.disabled = !enabled;
button.title = enabled
? `按 ${axis.toUpperCase()} 方向自动等比例拉伸模型`
: state.poseControlsLocked
? "自动迭代运行中,暂不可调整位姿"
: "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可用";
});
}
function renderPoseSectionCollapse() {
const manualCollapsed = Boolean(state.collapsedPoseSections.manual);
const autoCollapsed = Boolean(state.collapsedPoseSections.auto);
$("poseManualBody")?.classList.toggle("collapsed", manualCollapsed);
$("poseAutoBody")?.classList.toggle("collapsed", autoCollapsed);
$("poseManualToggle")?.classList.toggle("collapsed", manualCollapsed);
$("poseAutoToggle")?.classList.toggle("collapsed", autoCollapsed);
}
function applyPoseLockState() {
const locked = Boolean(state.poseControlsLocked);
const selectors = [
"#poseGrid input",
"#poseGrid button",
"[data-flip]",
"#resetRotationBtn",
"#resetTransformBtn",
"#resetFlipBtn",
"#openAutoModalBtn",
"#autoSettingsPanel input",
"#autoSettingsPanel button",
"#savePoseBtn",
"#saveIterateBtn",
"#restorePoseBtn",
"#saveBtn",
"#statusBtn",
"#autoCoarseBtn",
"#autoFineBtn",
];
selectors.forEach((selector) => {
document.querySelectorAll(selector).forEach((element) => {
element.disabled = locked;
});
});
document.querySelectorAll(".pose-control, .flip-row, .pose-actions, .auto-settings-panel").forEach((element) => {
element.classList.toggle("locked", locked);
});
updateStretchButtons();
}
function setPoseEditingLocked(locked) {
state.poseControlsLocked = Boolean(locked);
if (locked) clearNudgeTimers();
applyPoseLockState();
}
function updateSliceControl() {
const selected = currentSeries();
const count = Number(selected?.count || 0);
const max = Math.max(0, count - 1);
state.sliceIndex = Math.max(0, Math.min(state.sliceIndex, max));
state.sliceRangeStart = Math.max(0, Math.min(Number(state.sliceRangeStart) || 0, max));
state.sliceRangeEnd = Math.max(0, Math.min(Number(state.sliceRangeEnd) || max, max));
if (state.sliceRangeStart > state.sliceRangeEnd) [state.sliceRangeStart, state.sliceRangeEnd] = [state.sliceRangeEnd, state.sliceRangeStart];
$("sliceRangeStart").max = max;
$("sliceRangeEnd").max = max;
$("sliceRangeStart").value = state.sliceRangeStart;
$("sliceRangeEnd").value = state.sliceRangeEnd;
$("sliceRangeLabel").textContent = count ? `${state.sliceRangeStart + 1} - ${state.sliceRangeEnd + 1} / ${count}` : "0 - 0 / 0";
$("sliceStartLabel").textContent = count ? `起点 ${state.sliceRangeStart + 1}` : "起点 0";
$("sliceEndLabel").textContent = count ? `终点 ${state.sliceRangeEnd + 1}` : "终点 0";
$("mappingSliceSlider").max = max;
$("mappingSliceSlider").value = max - state.sliceIndex;
$("mappingSliceLabel").textContent = count ? `${state.sliceIndex + 1} / ${count}` : "0 / 0";
}
function renderDicomAnnotation() {
const row = state.activeCase;
const selected = currentSeries();
const count = Number(selected?.count || 0);
$("mappingSummaryTitle").textContent = state.mappingMode === "image" ? "单独影像" : "可见 STL 构件";
$("dicomInfoLeft").textContent = row && selected
? `${row.patient_name || row.upp_patient_name || "-"}\n${row.patient_id || "-"}\n${fmtDateTime(row.study_date, row.study_time) || "-"}\n${selected.description || "-"}`
: "未选择序列";
$("dicomInfoRight").textContent = selected
? `${selected.modality || "CT"}\n${selected.rows || "-"}×${selected.columns || "-"}\n${selected.slice_thickness || "厚度 -"}`
: "";
$("dicomInfoBottom").textContent = count ? `Img:${state.sliceIndex + 1}/${count}` : "Img:-";
renderActiveHeader();
}
function updateDicomPreview() {
const selected = currentSeries();
if (!state.activeCase || !selected) {
$("dicomPreview").removeAttribute("src");
renderDicomAnnotation();
resetMappingCanvas("未选择 DICOM 序列");
return;
}
window.clearTimeout(state.dicomPreviewTimer);
state.dicomPreviewTimer = window.setTimeout(() => {
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
series_uid: selected.series_uid,
index: String(state.sliceIndex),
window: state.windowMode,
access_token: state.token,
});
setDicomLoading(true, "正在加载 DICOM Base Layer", 16);
resetMappingCanvas("正在加载 DICOM Base Layer");
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
renderDicomAnnotation();
}, 80);
}
function initScene() {
if (state.sceneReady) return;
const viewport = $("fusionViewport");
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setClearColor(0x030712, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.localClippingEnabled = true;
viewport.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x030712);
const camera = new THREE.PerspectiveCamera(45, 1, 0.05, 1000);
camera.position.set(0, -6.2, 4.6);
camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enabled = false;
controls.target.set(0, 0, 0);
const ambient = new THREE.AmbientLight(0xffffff, 0.72);
const keyLight = new THREE.DirectionalLight(0xffffff, 1.1);
keyLight.position.set(4, -5, 5);
const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.55);
fillLight.position.set(-4, 3, 2);
scene.add(ambient, keyLight, fillLight);
const dicomGroup = new THREE.Group();
const modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group();
const fusionRoot = new THREE.Group();
modelGroup.add(rawModelGroup);
fusionRoot.add(dicomGroup, modelGroup);
scene.add(fusionRoot);
Object.assign(state, { renderer, camera, scene, controls, fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup: null, sceneReady: true });
const resize = () => {
const rect = viewport.getBoundingClientRect();
if (!rect.width || !rect.height) return;
renderer.setSize(rect.width, rect.height, false);
camera.aspect = rect.width / rect.height;
camera.updateProjectionMatrix();
};
state.resizeScene = resize;
state.resizeObserver = new ResizeObserver(resize);
state.resizeObserver.observe(viewport);
resize();
const animate = () => {
requestAnimationFrame(animate);
applySceneViewPose();
updateFusionAxisInset();
renderer.render(scene, camera);
};
animate();
wireFusionSceneDrag(renderer.domElement);
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
}
function axisProjectionSignature(projection) {
return ["x", "y", "z"]
.map((key) => {
const item = projection[key] || DEFAULT_AXIS_PROJECTION[key];
return `${Math.round(item.dx * 10)},${Math.round(item.dy * 10)},${Math.round(item.opacity * 100)}`;
})
.join("|");
}
function projectModelAxisDirections(camera, object) {
const origin = object.getWorldPosition(new THREE.Vector3());
const originProjected = origin.clone().project(camera);
const quaternion = object.getWorldQuaternion(new THREE.Quaternion());
const axisDirections = {
x: new THREE.Vector3(1, 0, 0),
y: new THREE.Vector3(0, 1, 0),
z: new THREE.Vector3(0, 0, 1),
};
const projectAxis = (direction) => {
const end = origin.clone().add(direction.applyQuaternion(quaternion).normalize().multiplyScalar(0.72));
const endProjected = end.project(camera);
const dx = endProjected.x - originProjected.x;
const dy = originProjected.y - endProjected.y;
const magnitude = Math.hypot(dx, dy);
if (magnitude < 0.0001) return { dx: 0, dy: -5, opacity: 0.5 };
return {
dx: (dx / magnitude) * AXIS_INSET_LENGTH,
dy: (dy / magnitude) * AXIS_INSET_LENGTH,
opacity: endProjected.z < originProjected.z ? 1 : 0.58,
};
};
return {
x: projectAxis(axisDirections.x),
y: projectAxis(axisDirections.y),
z: projectAxis(axisDirections.z),
};
}
function updateFusionAxisInset(projection = null) {
if (!projection) {
if (!state.camera || !state.modelGroup) projection = DEFAULT_AXIS_PROJECTION;
else {
state.fusionRoot?.updateMatrixWorld(true);
state.modelGroup.updateMatrixWorld(true);
projection = projectModelAxisDirections(state.camera, state.modelGroup);
}
}
const signature = axisProjectionSignature(projection);
if (state.axisProjectionSignature === signature) return;
state.axisProjectionSignature = signature;
const origin = { x: 25, y: 31 };
const items = {
x: { line: $("fusionAxisXLine"), text: $("fusionAxisXText"), group: $("fusionAxisX") },
y: { line: $("fusionAxisYLine"), text: $("fusionAxisYText"), group: $("fusionAxisY") },
z: { line: $("fusionAxisZLine"), text: $("fusionAxisZText"), group: $("fusionAxisZ") },
};
Object.entries(items).forEach(([key, item]) => {
const vector = projection[key] || DEFAULT_AXIS_PROJECTION[key];
const endX = origin.x + vector.dx;
const endY = origin.y + vector.dy;
item.group?.setAttribute("opacity", String(vector.opacity));
item.line?.setAttribute("x2", String(endX));
item.line?.setAttribute("y2", String(endY));
item.text?.setAttribute("x", String(endX + (vector.dx >= 0 ? 4 : -4)));
item.text?.setAttribute("y", String(endY + (vector.dy >= 0 ? 6 : -3)));
item.text?.setAttribute("text-anchor", vector.dx >= 0 ? "start" : "end");
});
}
function createAxisLabel(text, color) {
const canvas = document.createElement("canvas");
canvas.width = 96;
canvas.height = 64;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = color;
context.font = "900 42px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(text, 48, 34);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }));
sprite.scale.set(0.28, 0.18, 1);
return sprite;
}
function createSceneAxes() {
const group = new THREE.Group();
const length = 0.78;
const headLength = 0.16;
const headWidth = 0.08;
const axes = [
{ label: "X", color: 0xf87171, css: "#f87171", dir: new THREE.Vector3(1, 0, 0) },
{ label: "Y", color: 0x4ade80, css: "#4ade80", dir: new THREE.Vector3(0, 1, 0) },
{ label: "Z", color: 0x60a5fa, css: "#60a5fa", dir: new THREE.Vector3(0, 0, 1) },
];
axes.forEach((axis) => {
const arrow = new THREE.ArrowHelper(axis.dir, new THREE.Vector3(0, 0, 0), length, axis.color, headLength, headWidth);
const label = createAxisLabel(axis.label, axis.css);
label.position.copy(axis.dir.clone().multiplyScalar(length + 0.16));
group.add(arrow, label);
});
group.visible = true;
return group;
}
function initGeometryFallback() {
if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return;
const fusionRoot = new THREE.Group();
const dicomGroup = new THREE.Group();
const modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group();
modelGroup.add(rawModelGroup);
fusionRoot.add(dicomGroup, modelGroup);
Object.assign(state, { fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup: null });
}
function applySceneViewPose() {
if (!state.fusionRoot) return;
const pose = state.viewPose || {};
state.fusionRoot.rotation.set(Number(pose.rotateX) || 0, 0, Number(pose.rotateZ) || 0);
state.fusionRoot.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, 0);
state.fusionRoot.scale.setScalar(Math.max(0.2, Number(pose.scale) || 1));
state.fusionRoot.updateMatrixWorld(true);
}
function wireFusionSceneDrag(canvas) {
if (!canvas) return;
canvas.style.touchAction = "none";
canvas.addEventListener("contextmenu", (event) => event.preventDefault());
canvas.addEventListener("pointerdown", (event) => {
event.preventDefault();
state.sceneDrag = {
pointerId: event.pointerId,
mode: event.button === 2 || event.shiftKey ? "pan" : "rotate",
startX: event.clientX,
startY: event.clientY,
pose: { ...state.viewPose },
};
canvas.setPointerCapture?.(event.pointerId);
});
canvas.addEventListener("pointermove", (event) => {
const drag = state.sceneDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
event.preventDefault();
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (drag.mode === "pan") {
state.viewPose.translateX = drag.pose.translateX + dx * 0.006;
state.viewPose.translateY = drag.pose.translateY - dy * 0.006;
} else {
state.viewPose.rotateZ = drag.pose.rotateZ + dx * 0.008;
state.viewPose.rotateX = drag.pose.rotateX + dy * 0.008;
}
applySceneViewPose();
});
const stop = (event) => {
if (state.sceneDrag?.pointerId !== event.pointerId) return;
state.sceneDrag = null;
canvas.releasePointerCapture?.(event.pointerId);
};
canvas.addEventListener("pointerup", stop);
canvas.addEventListener("pointercancel", stop);
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
state.viewPose.scale = Math.max(0.35, Math.min(3, state.viewPose.scale - event.deltaY * 0.001));
applySceneViewPose();
}, { passive: false });
}
function clearGroup(group) {
if (!group) return;
while (group.children.length) {
const child = group.children.pop();
child.traverse?.((node) => {
node.geometry?.dispose?.();
if (node.material) {
if (Array.isArray(node.material)) node.material.forEach((material) => material.dispose?.());
else node.material.dispose?.();
}
node.material?.map?.dispose?.();
});
}
}
function clearFusion() {
clearGroup(state.dicomGroup);
clearGroup(state.rawModelGroup);
if (state.rawModelGroup) {
state.rawModelGroup.position.set(0, 0, 0);
state.rawModelGroup.rotation.set(0, 0, 0);
state.rawModelGroup.scale.set(1, 1, 1);
}
state.volumeMeta = null;
state.volumeScene = null;
state.modelMeshes = [];
state.dicomPlane = null;
state.dicomPoints = null;
state.dicomSlicePlanes = [];
state.modelBoundsFrame = null;
state.modelLocalBounds = null;
$("fusionMeta").textContent = "DICOM - · STL -";
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
updateSliceControl();
resetMappingCanvas();
}
function applyFusionMode() {
if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model";
if (state.modelGroup) state.modelGroup.visible = true;
updateFusionMeta();
}
function updateFusionMeta() {
const selectedCount = visibleModelRecords().length || state.selectedStlIds.size;
if (state.fusionMode === "model") {
$("fusionMeta").textContent = `单独模型 · STL ${selectedCount} 个`;
return;
}
const volume = state.volumeMeta;
if (!volume) {
$("fusionMeta").textContent = `DICOM - · STL ${selectedCount} 个`;
return;
}
const displayRange = getDicomDisplayRange(volume.start, volume.end, volume.total);
$("fusionMeta").textContent = `DICOM ${displayRange.start}-${displayRange.end}/${volume.total} · STL ${selectedCount} 个`;
}
function fusionDetailSettings() {
return {
low: { planeOpacity: 0.42, sliceOpacity: 0.045, boxOpacity: 0.2, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 },
medium: { planeOpacity: 0.56, sliceOpacity: 0.075, boxOpacity: 0.26, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 },
high: { planeOpacity: 0.68, sliceOpacity: 0.11, boxOpacity: 0.32, slicePlanes: 72, pointOpacity: 0, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 1024 },
}[state.fusionDetail] || { planeOpacity: 0.42, sliceOpacity: 0.045, boxOpacity: 0.2, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 };
}
function modelDetailSettings() {
return {
standard: { opacity: 0.52, wireframe: false, depthWrite: false, metalness: 0.08, roughness: 0.55 },
fine: { opacity: 0.66, wireframe: false, depthWrite: false, metalness: 0.04, roughness: 0.48 },
ultra: { opacity: 0.82, wireframe: false, depthWrite: false, metalness: 0.02, roughness: 0.42 },
solid: { opacity: 1, wireframe: false, depthWrite: true, metalness: 0.02, roughness: 0.36 },
}[state.modelDetail] || { opacity: 0.52, wireframe: false, depthWrite: false, metalness: 0.08, roughness: 0.55 };
}
function applyFusionDetail() {
const detail = fusionDetailSettings();
if (state.dicomPlane?.material) {
state.dicomPlane.material.opacity = detail.planeOpacity;
state.dicomPlane.material.transparent = true;
state.dicomPlane.material.depthWrite = false;
state.dicomPlane.material.needsUpdate = true;
}
if (state.dicomPoints?.material) {
state.dicomPoints.material.opacity = detail.pointOpacity;
state.dicomPoints.material.size = detail.pointSize;
state.dicomPoints.material.needsUpdate = true;
}
state.dicomSlicePlanes.forEach((plane) => {
if (!plane.material) return;
plane.material.opacity = plane.userData?.rangeBoundary ? detail.planeOpacity : detail.sliceOpacity;
plane.material.needsUpdate = true;
});
}
function applyModelDetail() {
const detail = modelDetailSettings();
state.modelMeshes.forEach((record) => {
const material = record.mesh.material;
if (!material) return;
const mode = stlMode(record.file);
const opacity = mode === "solid" ? 1 : Math.min(0.52, detail.opacity);
material.opacity = opacity;
material.transparent = opacity < 1;
material.depthWrite = mode === "solid" ? true : false;
material.wireframe = detail.wireframe;
material.metalness = detail.metalness;
material.roughness = detail.roughness;
material.needsUpdate = true;
});
scheduleMappingDraw();
}
function applyStlVisibility() {
state.modelMeshes.forEach((record) => {
record.mesh.visible = stlMode(record.file) !== "hidden";
});
if (state.modelBoundsFrame) state.modelBoundsFrame.visible = state.modelMeshes.some((record) => record.mesh.visible);
if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model";
if (state.modelGroup) state.modelGroup.visible = true;
updateFusionMeta();
scheduleMappingDraw();
}
function syncDisplayControls() {
document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item.dataset.fusionDetail === state.fusionDetail));
document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item.dataset.modelDetail === state.modelDetail));
document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode));
}
async function loadFusion() {
const version = ++state.fusionVersion;
const selected = currentSeries();
if (!state.sceneReady) initGeometryFallback();
if (!state.activeCase || !selected) {
clearFusion();
return;
}
$("fusionStatus").textContent = "正在构建三维融合场景...";
setFusionLoading(true, "正在读取 DICOM 切片范围", 8);
updateSliceControl();
try {
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
series_uid: selected.series_uid,
center_index: String(state.sliceIndex),
window: state.windowMode,
detail: state.fusionDetail,
texture_size: String(fusionDetailSettings().textureSize),
range_start: String(state.sliceRangeStart),
range_end: String(state.sliceRangeEnd),
});
const volume = await api(`/api/dicom/fusion-volume?${params.toString()}`);
if (version !== state.fusionVersion) return;
state.volumeMeta = volume;
setFusionLoading(true, "正在构建 DICOM 切片范围", 38);
await buildDicomVolume(volume);
applyFusionDetail();
if (version !== state.fusionVersion) return;
setFusionLoading(true, "正在加载 STL 模型", 62);
await buildStlModels(volume, (progress) => setFusionLoading(true, "正在加载 STL 模型", 62 + progress * 0.3));
applyModelDetail();
if (version !== state.fusionVersion) return;
if (state.sceneReady) {
applyFusionMode();
fitCamera();
}
$("fusionStatus").textContent = state.sceneReady
? "三维融合场景已就绪"
: "2D 映射已就绪,当前浏览器未启用 WebGL";
updateFusionMeta();
renderDicomAnnotation();
setFusionLoading(true, "正在渲染右侧分割映射", 96);
updateDicomPreview();
} catch (error) {
clearFusion();
$("fusionStatus").textContent = error.message;
} finally {
if (version === state.fusionVersion) setFusionLoading(false);
}
}
async function loadTexture(url) {
return new Promise((resolve, reject) => {
new THREE.TextureLoader().load(url, (texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.NearestFilter;
texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1);
texture.needsUpdate = true;
resolve(texture);
}, undefined, reject);
});
}
async function loadDicomFusionTexture(url) {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
const width = image.naturalWidth || image.width || 1;
const height = image.naturalHeight || image.height || 1;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height);
for (let index = 0; index < imageData.data.length; index += 4) {
const value = imageData.data[index];
imageData.data[index + 1] = value;
imageData.data[index + 2] = value;
imageData.data[index + 3] = value > 4 ? Math.min(235, 42 + Math.round(value * 0.76)) : 0;
}
context.putImageData(imageData, 0, 0);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1);
texture.needsUpdate = true;
return texture;
}
async function loadImageData(url, maxSize = 256) {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
const scale = Math.min(1, maxSize / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height, 1));
const width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale));
const height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
return { width, height, data: context.getImageData(0, 0, width, height).data };
}
function sliceToSceneZ(sliceIndex, volumeScene = state.volumeScene) {
if (!volumeScene) return 0;
return volumeScene.total <= 1
? 0
: -volumeScene.depth / 2 + (volumeScene.depth * Math.max(0, Math.min(sliceIndex, volumeScene.total - 1))) / (volumeScene.total - 1);
}
function parseCssColor(color) {
const value = String(color || "#19d6c3").replace("#", "");
const hex = value.length === 3 ? value.split("").map((char) => char + char).join("") : value.padStart(6, "0").slice(0, 6);
const intValue = Number.parseInt(hex, 16);
return {
r: (intValue >> 16) & 255,
g: (intValue >> 8) & 255,
b: intValue & 255,
};
}
function resetMappingCanvas(message = "当前切片暂无可见构件") {
const canvas = $("mappingCanvas");
const context = canvas?.getContext("2d");
if (context) context.clearRect(0, 0, canvas.width || 1, canvas.height || 1);
$("mappingStats").textContent = "0/0 构件 · 0 边 · 0 px";
$("mappingLegend").innerHTML = `${escapeHtml(message)}
`;
}
function scheduleMappingDraw() {
window.cancelAnimationFrame(state.mappingDrawFrame);
const version = ++state.mappingVersion;
state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView(version));
}
function applyMappingTransform() {
const layer = $("mappingLayer");
if (!layer) return;
const { scale, offsetX, offsetY, rotation } = state.mappingViewport;
layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) rotate(${rotation || 0}deg) scale(${scale})`;
}
function intersectEdgeWithZ(a, b, targetZ) {
const da = a.z - targetZ;
const db = b.z - targetZ;
if (Math.abs(da) < 1e-5 && Math.abs(db) < 1e-5) return [a.clone(), b.clone()];
if (Math.abs(da) < 1e-5) return [a.clone()];
if (Math.abs(db) < 1e-5) return [b.clone()];
if ((da > 0 && db > 0) || (da < 0 && db < 0)) return [];
const t = da / (da - db);
return [new THREE.Vector3(
a.x + (b.x - a.x) * t,
a.y + (b.y - a.y) * t,
targetZ,
)];
}
function uniquePlanePoints(points) {
const unique = [];
points.forEach((point) => {
if (!unique.some((item) => item.distanceToSquared(point) < 1e-8)) unique.push(point);
});
return unique;
}
function intersectTriangleWithZ(a, b, c, targetZ) {
const points = uniquePlanePoints([
...intersectEdgeWithZ(a, b, targetZ),
...intersectEdgeWithZ(b, c, targetZ),
...intersectEdgeWithZ(c, a, targetZ),
]);
if (points.length < 2) return null;
return { a: points[0], b: points[1] };
}
function addSegmentIntersectionsToRows(rows, width, height, segment) {
const a = segment.a;
const b = segment.b;
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return;
const deltaY = b.y - a.y;
if (Math.abs(deltaY) < 0.01) {
return;
}
const minY = Math.max(0, Math.floor(Math.min(a.y, b.y)));
const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y)));
for (let row = minY; row <= maxY; row += 1) {
const sampleY = row + 0.5;
const crosses = (sampleY >= a.y && sampleY < b.y) || (sampleY >= b.y && sampleY < a.y);
if (!crosses) continue;
const t = (sampleY - a.y) / deltaY;
const x = a.x + (b.x - a.x) * t;
if (Number.isFinite(x)) rows[row].push(x);
}
}
function groupPlaneSegmentsByConnectivity(segments, tolerance = 1.35) {
if (segments.length <= 1) return segments.length ? [segments] : [];
const parents = segments.map((_, index) => index);
const find = (index) => {
if (parents[index] !== index) parents[index] = find(parents[index]);
return parents[index];
};
const union = (left, right) => {
const leftRoot = find(left);
const rightRoot = find(right);
if (leftRoot !== rightRoot) parents[rightRoot] = leftRoot;
};
const buckets = new Map();
const cellSize = Math.max(tolerance, 0.1);
const toleranceSquared = tolerance * tolerance;
const cellKey = (x, y) => `${x},${y}`;
segments.forEach((segment, index) => {
[segment.a, segment.b].forEach((point) => {
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return;
const cellX = Math.floor(point.x / cellSize);
const cellY = Math.floor(point.y / cellSize);
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
const candidates = buckets.get(cellKey(cellX + dx, cellY + dy));
candidates?.forEach((candidate) => {
const distanceSquared = (candidate.x - point.x) ** 2 + (candidate.y - point.y) ** 2;
if (distanceSquared <= toleranceSquared) union(index, candidate.index);
});
}
}
const key = cellKey(cellX, cellY);
const bucket = buckets.get(key) ?? [];
bucket.push({ x: point.x, y: point.y, index });
buckets.set(key, bucket);
});
});
const groups = new Map();
segments.forEach((segment, index) => {
const root = find(index);
const group = groups.get(root) ?? [];
group.push(segment);
groups.set(root, group);
});
return [...groups.values()].sort((left, right) => right.length - left.length);
}
function paintMaskPixel(maskData, width, height, x, y, rgb, alpha) {
if (x < 0 || x >= width || y < 0 || y >= height) return 0;
const offset = (y * width + x) * 4;
if (maskData.data[offset + 3] > 0) return 0;
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
return 1;
}
function solidStrokeRadius(width, height) {
return Math.max(1, Math.min(2.4, Math.max(width, height) * 0.002));
}
function fillSegmentCapsulesIntoMask(maskData, width, height, segments, rgb, alpha, radius) {
let paintedPixels = 0;
const radiusSquared = radius * radius;
segments.forEach(({ a, b }) => {
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return;
const dx = b.x - a.x;
const dy = b.y - a.y;
const lengthSquared = dx * dx + dy * dy;
const minX = clamp(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1);
const maxX = clamp(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1);
const minY = clamp(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1);
const maxY = clamp(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1);
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
const px = x + 0.5;
const py = y + 0.5;
const t = lengthSquared <= 1e-6
? 0
: clamp(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1);
const closestX = a.x + dx * t;
const closestY = a.y + dy * t;
if ((px - closestX) ** 2 + (py - closestY) ** 2 <= radiusSquared) {
paintedPixels += paintMaskPixel(maskData, width, height, x, y, rgb, alpha);
}
}
}
});
return paintedPixels;
}
function closeSmallMaskGaps(maskData, width, height, rgb, alpha, maxGap = 2) {
const toFill = new Set();
const hasPixel = (x, y) => maskData.data[(y * width + x) * 4 + 3] > 0;
const mark = (x, y) => {
if (x >= 0 && x < width && y >= 0 && y < height && !hasPixel(x, y)) toFill.add(y * width + x);
};
for (let y = 0; y < height; y += 1) {
let lastFilled = -1;
for (let x = 0; x < width; x += 1) {
if (!hasPixel(x, y)) continue;
const gap = x - lastFilled - 1;
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
for (let fillX = lastFilled + 1; fillX < x; fillX += 1) mark(fillX, y);
}
lastFilled = x;
}
}
for (let x = 0; x < width; x += 1) {
let lastFilled = -1;
for (let y = 0; y < height; y += 1) {
if (!hasPixel(x, y)) continue;
const gap = y - lastFilled - 1;
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
for (let fillY = lastFilled + 1; fillY < y; fillY += 1) mark(x, fillY);
}
lastFilled = y;
}
}
toFill.forEach((index) => {
const offset = index * 4;
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
});
return toFill.size;
}
function fillInternalMaskHoles(maskData, width, height, rgb, alpha) {
const outside = new Uint8Array(width * height);
const stack = [];
const pushIfEmpty = (x, y) => {
if (x < 0 || x >= width || y < 0 || y >= height) return;
const index = y * width + x;
if (outside[index] || maskData.data[index * 4 + 3] > 0) return;
outside[index] = 1;
stack.push(index);
};
for (let x = 0; x < width; x += 1) {
pushIfEmpty(x, 0);
pushIfEmpty(x, height - 1);
}
for (let y = 0; y < height; y += 1) {
pushIfEmpty(0, y);
pushIfEmpty(width - 1, y);
}
while (stack.length) {
const index = stack.pop();
const x = index % width;
const y = Math.floor(index / width);
pushIfEmpty(x + 1, y);
pushIfEmpty(x - 1, y);
pushIfEmpty(x, y + 1);
pushIfEmpty(x, y - 1);
}
let patchedPixels = 0;
for (let index = 0; index < outside.length; index += 1) {
const offset = index * 4;
if (!outside[index] && maskData.data[offset + 3] === 0) {
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
patchedPixels += 1;
}
}
return patchedPixels;
}
function drawFallbackClosedRegion(context, width, height, segments, color, opacity = 0.72) {
const points = segments.flatMap((segment) => [segment.a, segment.b])
.filter((point) => (
Number.isFinite(point.x)
&& Number.isFinite(point.y)
&& point.x >= -width
&& point.x <= width * 2
&& point.y >= -height
&& point.y <= height * 2
));
if (points.length < 3) return 0;
const uniquePoints = [];
points.forEach((point) => {
if (!uniquePoints.some((current) => (current.x - point.x) ** 2 + (current.y - point.y) ** 2 < 1e-6)) uniquePoints.push(point);
});
if (uniquePoints.length < 3) return 0;
const sorted = [...uniquePoints].sort((left, right) => (Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y));
const cross = (origin, a, b) => (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x);
const lower = [];
sorted.forEach((point) => {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) lower.pop();
lower.push(point);
});
const upper = [];
[...sorted].reverse().forEach((point) => {
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) upper.pop();
upper.push(point);
});
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
const ordered = hull.length >= 3 ? hull : uniquePoints;
context.save();
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62;
context.fillStyle = color;
context.beginPath();
ordered.forEach((point, index) => {
if (index === 0) context.moveTo(point.x, point.y);
else context.lineTo(point.x, point.y);
});
context.closePath();
context.fill();
context.restore();
return Math.max(1, Math.round(ordered.length / 2));
}
function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) {
if (!segments.length) return 0;
const rgb = parseCssColor(color);
const alpha = Math.round(clamp(opacity, 0.1, 1) * 220);
const maskCanvas = document.createElement("canvas");
maskCanvas.width = width;
maskCanvas.height = height;
const maskContext = maskCanvas.getContext("2d");
if (!maskContext) return 0;
maskContext.imageSmoothingEnabled = false;
const maskData = maskContext.createImageData(width, height);
const radius = solidStrokeRadius(width, height);
const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15);
let filledPixels = 0;
groups.forEach((group) => {
const rows = Array.from({ length: height }, () => []);
group.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
let groupPixels = 0;
rows.forEach((intersections, row) => {
if (intersections.length < 2) return;
intersections.sort((left, right) => left - right);
const cleaned = [];
intersections.forEach((x) => {
const previous = cleaned[cleaned.length - 1];
if (previous === undefined || Math.abs(previous - x) > 0.35) cleaned.push(x);
});
for (let index = 0; index + 1 < cleaned.length; index += 2) {
const rawStartX = cleaned[index];
const rawEndX = cleaned[index + 1];
if (rawEndX < 0 || rawStartX > width - 1) continue;
const startX = clamp(Math.ceil(rawStartX), 0, width - 1);
const endX = clamp(Math.floor(rawEndX), 0, width - 1);
if (endX < startX) continue;
for (let x = startX; x <= endX; x += 1) {
const offset = (row * width + x) * 4;
if (maskData.data[offset + 3] === 0) {
maskData.data[offset] = rgb.r;
maskData.data[offset + 1] = rgb.g;
maskData.data[offset + 2] = rgb.b;
maskData.data[offset + 3] = alpha;
groupPixels += 1;
}
}
}
});
groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius);
filledPixels += groupPixels;
});
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 1);
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
maskContext.putImageData(maskData, 0, 0);
context.imageSmoothingEnabled = false;
context.drawImage(maskCanvas, 0, 0);
return filledPixels;
}
async function drawMappingView(version = state.mappingVersion) {
const image = $("dicomPreview");
const canvas = $("mappingCanvas");
const volumeScene = state.volumeScene;
canvas.style.display = state.mappingMode === "image" ? "none" : "";
if (state.mappingMode === "image") {
setDicomLoading(false);
resetMappingCanvas("单独影像模式未叠加 STL 分割");
return;
}
const visibleRecords = visibleModelRecords();
if (!image?.complete || !image.naturalWidth || !volumeScene || !visibleRecords.length) {
if (!visibleRecords.length) setDicomLoading(false);
else setDicomLoading(true, "等待 DICOM Base Layer", 32);
resetMappingCanvas(visibleRecords.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
return;
}
setDicomLoading(true, "正在计算 STL 分割映射", 48);
const width = image.naturalWidth;
const height = image.naturalHeight;
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const context = canvas.getContext("2d");
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, width, height);
const targetZ = sliceToSceneZ(state.sliceIndex, volumeScene);
const mapPoint = (point) => ({
x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width,
y: (0.5 - point.y / volumeScene.height) * height,
});
const matrix = modelPoseMatrix();
let activeModules = 0;
let segmentCount = 0;
let filledPixels = 0;
const modules = [];
const tempA = new THREE.Vector3();
const tempB = new THREE.Vector3();
const tempC = new THREE.Vector3();
for (const [index, record] of visibleRecords.entries()) {
if (version !== state.mappingVersion) return;
const position = record.mesh.geometry.attributes.position;
if (!position) continue;
const triangleCount = Math.floor(position.count / 3);
const step = 1;
const segments = [];
for (let tri = 0; tri < triangleCount; tri += step) {
tempA.fromBufferAttribute(position, tri * 3).applyMatrix4(matrix);
tempB.fromBufferAttribute(position, tri * 3 + 1).applyMatrix4(matrix);
tempC.fromBufferAttribute(position, tri * 3 + 2).applyMatrix4(matrix);
const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ);
if (!segment) continue;
const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) };
if (
mapped.a.x > -width && mapped.a.x < width * 2
&& mapped.b.x > -width && mapped.b.x < width * 2
&& mapped.a.y > -height && mapped.a.y < height * 2
&& mapped.b.y > -height && mapped.b.y < height * 2
) {
segments.push(mapped);
}
}
const pixels = fillSegmentsAsSolidMask(context, width, height, segments, record.color, 0.66);
if (segments.length || pixels) {
activeModules += 1;
segmentCount += segments.length;
filledPixels += pixels;
modules.push({
id: index + 1,
name: record.file.segment_name || record.file.file_name || `STL ${index + 1}`,
color: record.color,
segments: segments.length,
pixels,
});
}
const progress = 48 + ((index + 1) / Math.max(visibleRecords.length, 1)) * 48;
const label = record.file.segment_name || record.file.file_name || `STL ${index + 1}`;
setDicomLoading(true, `正在计算 ${label}`, progress);
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
if (version !== state.mappingVersion) return;
$("mappingStats").textContent = `${activeModules}/${visibleRecords.length} 构件 · ${segmentCount} 边 · ${filledPixels} px`;
$("mappingLegend").innerHTML = modules.length
? modules.map((item) => `
${escapeHtml(item.name)}
ID ${item.id}
${item.segments} 边
${item.pixels} px
`).join("")
: `当前切片暂无可见构件
`;
setDicomLoading(false);
}
async function buildDicomVolume(volume) {
clearGroup(state.dicomGroup);
state.dicomPlane = null;
state.dicomPoints = null;
state.dicomSlicePlanes = [];
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 };
const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
const sceneScale = FUSION_BASE_EXTENT / maxPhysical;
const width = (physical.width || 1) * sceneScale;
const height = (physical.height || 1) * sceneScale;
const depth = Math.max((physical.depth || 1) * sceneScale, 0.18);
const centerIndex = Number(volume.center || 0);
const total = Math.max(1, Number(volume.total || 1));
const indices = volume.indices || [];
const frames = volume.frames || [];
const fusionDetail = fusionDetailSettings();
const rangeStart = clamp(Number(volume.start ?? indices[0] ?? centerIndex) || 0, 0, total - 1);
const rangeEnd = clamp(Number(volume.end ?? indices[indices.length - 1] ?? centerIndex) || 0, 0, total - 1);
const boundaryIndices = new Set([Math.min(rangeStart, rangeEnd), Math.max(rangeStart, rangeEnd)]);
state.volumeScene = {
width,
height,
depth,
total,
sceneScale,
rowSpacing: Number(volume.spacing?.row || 1),
columnSpacing: Number(volume.spacing?.column || 1),
sliceSpacing: Number(volume.spacing?.slice || 1),
};
const boxMesh = new THREE.Mesh(
new THREE.BoxGeometry(width, height, depth),
new THREE.MeshBasicMaterial({ color: 0x020617, transparent: true, opacity: fusionDetail.boxOpacity, depthWrite: false }),
);
state.dicomGroup.add(boxMesh);
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(boxMesh.geometry),
new THREE.LineBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 0.46 }),
);
state.dicomGroup.add(edges);
const planeGeometry = new THREE.PlaneGeometry(width, height);
for (let frameIndex = 0; frameIndex < frames.length; frameIndex += 1) {
const dicomIndex = clamp(Number(indices[frameIndex] ?? centerIndex) || 0, 0, total - 1);
const isBoundary = boundaryIndices.has(dicomIndex) || frames.length === 1;
const texture = await loadDicomFusionTexture(frames[frameIndex]);
const plane = new THREE.Mesh(
planeGeometry,
new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: isBoundary ? fusionDetail.planeOpacity : fusionDetail.sliceOpacity,
side: THREE.DoubleSide,
depthWrite: false,
depthTest: true,
}),
);
plane.position.z = sliceToSceneZ(dicomIndex) + (isBoundary ? 0.006 : 0);
plane.renderOrder = isBoundary ? 16 : 4;
plane.userData.rangeBoundary = isBoundary;
state.dicomGroup.add(plane);
state.dicomSlicePlanes.push(plane);
if (isBoundary && !state.dicomPlane) state.dicomPlane = plane;
}
if (fusionDetail.pointOpacity > 0) {
const pointPositions = [];
const pointColors = [];
for (const [index, frame] of frames.entries()) {
const image = await loadImageData(frame, 180);
const stride = Math.max(2, Math.round(Math.max(image.width, image.height) / fusionDetail.pointGrid));
const z = sliceToSceneZ(Number(indices[index] ?? centerIndex));
for (let y = 0; y < image.height; y += stride) {
for (let x = 0; x < image.width; x += stride) {
const offset = (y * image.width + x) * 4;
const lum = image.data[offset];
if (lum < fusionDetail.pointThreshold) continue;
pointPositions.push((x / Math.max(image.width - 1, 1) - 0.5) * width);
pointPositions.push((0.5 - y / Math.max(image.height - 1, 1)) * height);
pointPositions.push(z);
const v = 0.16 + (lum / 255) * 0.72;
pointColors.push(v * 0.92, v, Math.min(1, v * 1.08));
}
}
}
if (pointPositions.length) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(pointPositions, 3));
geometry.setAttribute("color", new THREE.Float32BufferAttribute(pointColors, 3));
const points = new THREE.Points(
geometry,
new THREE.PointsMaterial({
size: fusionDetail.pointSize,
vertexColors: true,
transparent: true,
opacity: fusionDetail.pointOpacity,
depthWrite: false,
}),
);
points.renderOrder = 2;
state.dicomGroup.add(points);
state.dicomPoints = points;
}
}
state.baseModelScale = sceneScale;
state.dicomSceneBox = new THREE.Box3(
new THREE.Vector3(-width / 2, -height / 2, -depth / 2),
new THREE.Vector3(width / 2, height / 2, depth / 2),
);
}
function updateBaseModelScaleFromBounds() {
const size = state.modelLocalBounds?.size;
if (!size) return;
const maxModelSize = Math.max(Number(size.x) || 0, Number(size.y) || 0, Number(size.z) || 0, 1);
const volumeSize = state.volumeScene
? Math.max(state.volumeScene.width, state.volumeScene.height, state.volumeScene.depth, 1)
: FUSION_BASE_EXTENT;
state.baseModelScale = (volumeSize / maxModelSize) * 0.92;
}
function addModelBoundsFrame(size) {
if (!state.rawModelGroup || !size) return;
removeModelBoundsFrame();
const frame = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
new THREE.LineBasicMaterial({
color: 0xfacc15,
transparent: true,
opacity: 0.72,
}),
);
frame.name = "model-bounds";
frame.renderOrder = 1000;
state.rawModelGroup.add(frame);
state.modelBoundsFrame = frame;
}
function removeModelBoundsFrame() {
const frame = state.modelBoundsFrame;
if (!frame || !state.rawModelGroup) return;
state.rawModelGroup.remove(frame);
frame.geometry?.dispose?.();
if (Array.isArray(frame.material)) frame.material.forEach((material) => material.dispose?.());
else frame.material?.dispose?.();
state.modelBoundsFrame = null;
}
async function buildStlModels(volume, onProgress = null) {
const signature = stlSelectionSignature();
if (signature && signature !== state.stlSignature) {
state.rawModelGroup.position.set(0, 0, 0);
state.rawModelGroup.rotation.set(0, 0, 0);
state.rawModelGroup.scale.set(1, 1, 1);
clearGroup(state.rawModelGroup);
state.modelMeshes = [];
state.modelBoundsFrame = null;
state.modelLocalBounds = null;
state.stlSignature = signature;
}
const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite));
const files = state.stlFiles.filter((file) => (
state.selectedStlIds.has(Number(file.id))
&& !loadedIds.has(Number(file.id))
));
if (signature && signature === state.stlSignature && !files.length && state.rawModelGroup?.children.length) {
updateBaseModelScaleFromBounds();
if (!state.modelBoundsFrame && state.modelLocalBounds?.size) addModelBoundsFrame(state.modelLocalBounds.size);
applyStlVisibility();
applyPose();
applyModelDetail();
scheduleMappingDraw();
onProgress?.(100);
return;
}
if (!files.length && !state.modelMeshes.length) {
applyPose();
resetMappingCanvas("当前没有可见 STL 构件");
onProgress?.(100);
return;
}
const loader = new STLLoader();
const detail = modelDetailSettings();
const freshRecords = [];
for (const [index, file] of files.entries()) {
onProgress?.((index / Math.max(files.length, 1)) * 100);
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
file_id: String(file.id),
algorithm_model: state.algorithmModel,
access_token: state.token,
});
const geometry = await loader.loadAsync(`/api/stl/file?${params.toString()}`);
geometry.computeVertexNormals();
geometry.computeBoundingBox();
const color = stlColor(file, index);
const material = new THREE.MeshStandardMaterial({
color,
metalness: detail.metalness,
roughness: detail.roughness,
transparent: true,
opacity: detail.opacity,
side: THREE.DoubleSide,
depthWrite: detail.depthWrite,
wireframe: detail.wireframe,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.name = file.segment_name || file.file_name;
mesh.userData.file = file;
mesh.userData.color = color;
state.rawModelGroup.add(mesh);
const record = { mesh, file, color, index };
state.modelMeshes.push(record);
freshRecords.push(record);
onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100);
}
if (!state.modelLocalBounds && state.modelMeshes.length) {
const rawBox = new THREE.Box3();
state.modelMeshes.forEach((record) => {
record.mesh.geometry.computeBoundingBox?.();
if (record.mesh.geometry.boundingBox) rawBox.union(record.mesh.geometry.boundingBox);
});
const center = new THREE.Vector3();
rawBox.getCenter(center);
state.modelMeshes.forEach((record) => {
const geometry = record.mesh.geometry;
geometry.translate(-center.x, -center.y, -center.z);
geometry.computeBoundingBox?.();
geometry.computeBoundingSphere?.();
geometry.computeVertexNormals?.();
});
state.modelLocalBounds = {
center: { x: center.x, y: center.y, z: center.z },
size: { x: 1, y: 1, z: 1 },
};
} else if (state.modelLocalBounds && freshRecords.length) {
const center = state.modelLocalBounds.center;
freshRecords.forEach((record) => {
const geometry = record.mesh.geometry;
geometry.translate(-Number(center.x || 0), -Number(center.y || 0), -Number(center.z || 0));
geometry.computeBoundingBox?.();
geometry.computeBoundingSphere?.();
geometry.computeVertexNormals?.();
});
}
const bbox = new THREE.Box3();
state.modelMeshes.forEach((record) => {
record.mesh.geometry.computeBoundingBox?.();
if (record.mesh.geometry.boundingBox) bbox.union(record.mesh.geometry.boundingBox);
});
const size = new THREE.Vector3();
if (!bbox.isEmpty()) bbox.getSize(size);
if (state.modelLocalBounds) state.modelLocalBounds.size = { x: size.x, y: size.y, z: size.z };
updateBaseModelScaleFromBounds();
if (size.x || size.y || size.z) addModelBoundsFrame(size);
applyStlVisibility();
applyModelDetail();
applyPose();
scheduleMappingDraw();
onProgress?.(100);
}
function applyPose(poseInput = state.pose, options = {}) {
if (!state.modelGroup) return;
const pose = { ...DEFAULT_POSE, ...poseInput };
state.modelGroup.rotation.set(
THREE.MathUtils.degToRad(Number(pose.rotateX) || 0),
THREE.MathUtils.degToRad(Number(pose.rotateY) || 0),
THREE.MathUtils.degToRad(Number(pose.rotateZ) || 0),
);
state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0);
const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale;
state.modelGroup.scale.set(
scale * (pose.flipX ? -1 : 1),
scale * (pose.flipY ? -1 : 1),
scale * (pose.flipZ ? -1 : 1),
);
state.modelGroup.updateMatrixWorld(true);
if (options.scheduleMapping !== false) scheduleMappingDraw();
}
function fitCamera() {
if (!state.camera) return;
applySceneViewPose();
state.camera.position.set(0, -6.2, 4.6);
state.camera.up.set(0, 0, 1);
state.camera.lookAt(0, 0, 0);
state.controls?.target.set(0, 0, 0);
state.controls?.update();
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
}
function resetSceneView() {
state.viewPose = { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 };
applySceneViewPose();
fitCamera();
$("fusionStatus").textContent = state.volumeMeta ? "三维融合视角已复位" : "等待选择 DICOM 与 STL";
}
function resetPose(kind) {
if (state.poseControlsLocked) return;
if (kind === "rotation") {
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
} else if (kind === "transform") {
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1, scaleX: 1, scaleY: 1, scaleZ: 1 };
} else if (kind === "flip") {
state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false };
} else {
state.pose = { ...DEFAULT_POSE };
}
renderPoseControls();
applyPose();
markDirty();
}
function stretchAxis(axis) {
if (state.poseControlsLocked) {
$("autoResult").textContent = "自动迭代运行中,暂不可调整位姿。";
return;
}
if (!canStretchByAxis()) {
const message = "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可使用 XYZ 拉伸。";
setAutoState("XYZ 拉伸不可用", "warn");
$("autoResult").textContent = message;
updateStretchButtons();
return;
}
const modelBox = visibleModelBox();
if (!modelBox || !state.dicomSceneBox) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
if (!["x", "y", "z"].includes(axis)) return;
const dicomSize = new THREE.Vector3();
const modelSize = new THREE.Vector3();
state.dicomSceneBox.getSize(dicomSize);
modelBox.getSize(modelSize);
const targetSize = Math.max(0.001, dicomSize[axis]);
const currentSize = Math.max(0.001, modelSize[axis]);
const currentScale = Math.max(0.001, Number(state.pose.scale) || 1);
const next = Math.max(0.2, Math.min(4, currentScale * (targetSize / currentSize)));
state.pose.scale = Number(next.toFixed(3));
state.pose.scaleX = 1;
state.pose.scaleY = 1;
state.pose.scaleZ = 1;
renderPoseControls();
applyPose();
markDirty("pose");
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体等比例拉伸,缩放 ${state.pose.scale}`;
updateAutoPoseResult();
}
function suggestBoneSelection() {
const checkedIds = [...document.querySelectorAll("#autoBoneOptions input:checked")]
.map((input) => Number(input.value))
.filter(Number.isFinite);
const next = new Set(checkedIds);
if (!next.size) {
[...state.stlFiles]
.sort((a, b) => stlLikelyScore(b) - stlLikelyScore(a))
.slice(0, Math.min(6, state.stlFiles.length))
.forEach((file) => next.add(Number(file.id)));
}
state.selectedStlIds = next;
renderStl();
markDirty();
applyStlSelectionChange();
}
function readAutoSettings() {
const numericValue = (id, fallback) => {
const value = Number($(id)?.value);
return Number.isFinite(value) ? value : fallback;
};
const referenceIds = [...document.querySelectorAll("#autoBoneOptions input:checked")]
.map((input) => Number(input.value))
.filter(Number.isFinite);
return {
adjustable: {
translateX: $("autoX")?.checked,
translateY: $("autoY")?.checked,
translateZ: $("autoZ")?.checked,
scale: $("autoScale")?.checked,
},
referenceIds,
sampleSlices: Math.max(3, Math.min(60, Math.round(numericValue("autoSampleSlicesNumber", 9)))),
boneReward: numericValue("autoBoneReward", 1),
outsidePenalty: numericValue("autoOutsidePenalty", 0.1),
movePenalty: numericValue("autoMovePenalty", 0),
scalePenalty: numericValue("autoScalePenalty", 0),
iterations: Math.max(1, Math.min(100, Math.round(numericValue("autoIterations", 30)))),
candidates: Math.max(6, Math.min(96, Math.round(numericValue("autoCandidates", 36)))),
};
}
function resetAutoSettings() {
if (state.poseControlsLocked) return;
const checkedDefaults = {
autoX: true,
autoY: true,
autoZ: true,
autoScale: true,
};
Object.entries(checkedDefaults).forEach(([id, value]) => {
const input = $(id);
if (input) input.checked = value;
});
const valueDefaults = {
autoSampleSlices: 9,
autoSampleSlicesNumber: 9,
autoBoneReward: 1,
autoOutsidePenalty: 0.1,
autoMovePenalty: 0,
autoScalePenalty: 0,
autoIterations: 30,
autoCandidates: 36,
};
Object.entries(valueDefaults).forEach(([id, value]) => {
const input = $(id);
if (input) input.value = value;
});
renderAutoBoneOptions();
state.autoResult = null;
setAutoProgress(false);
setAutoState("已恢复", "ok");
$("autoResult").textContent = "迭代参数已恢复初始设置。";
}
function poseDelta(from, to) {
return {
rotateX: Number(((to.rotateX || 0) - (from.rotateX || 0)).toFixed(3)),
rotateY: Number(((to.rotateY || 0) - (from.rotateY || 0)).toFixed(3)),
rotateZ: Number(((to.rotateZ || 0) - (from.rotateZ || 0)).toFixed(3)),
translateX: Number(((to.translateX || 0) - (from.translateX || 0)).toFixed(3)),
translateY: Number(((to.translateY || 0) - (from.translateY || 0)).toFixed(3)),
translateZ: Number(((to.translateZ || 0) - (from.translateZ || 0)).toFixed(3)),
scale: Number(((to.scale || 1) / Math.max(0.001, from.scale || 1)).toFixed(3)),
};
}
function signedValue(value, decimals = 3) {
const numeric = Number(value || 0);
if (Math.abs(numeric) < 10 ** -decimals) return "0";
return `${numeric > 0 ? "+" : ""}${numeric.toFixed(decimals)}`;
}
function autoPoseResultItem(label, deltaText, currentText, changed = true) {
return `
${escapeHtml(label)}
${escapeHtml(deltaText)}
${escapeHtml(currentText)}
`;
}
function updateAutoPoseResult(before = state.autoDeltaBaseline || state.lastAutoPose?.before || state.pose, after = state.pose) {
const delta = poseDelta(before, after);
const el = $("autoPoseResult");
if (!el) return;
el.innerHTML = [
autoPoseResultItem("旋转 X", `Δ ${signedValue(delta.rotateX, 1)}°`, `当前 ${Number(after.rotateX || 0).toFixed(1)}°`, Math.abs(delta.rotateX) > 0.0005),
autoPoseResultItem("旋转 Y", `Δ ${signedValue(delta.rotateY, 1)}°`, `当前 ${Number(after.rotateY || 0).toFixed(1)}°`, Math.abs(delta.rotateY) > 0.0005),
autoPoseResultItem("旋转 Z", `Δ ${signedValue(delta.rotateZ, 1)}°`, `当前 ${Number(after.rotateZ || 0).toFixed(1)}°`, Math.abs(delta.rotateZ) > 0.0005),
autoPoseResultItem("平移 X", `Δ ${signedValue(delta.translateX)}`, `当前 ${Number(after.translateX || 0).toFixed(3)}`, Math.abs(delta.translateX) > 0.0005),
autoPoseResultItem("平移 Y", `Δ ${signedValue(delta.translateY)}`, `当前 ${Number(after.translateY || 0).toFixed(3)}`, Math.abs(delta.translateY) > 0.0005),
autoPoseResultItem("平移 Z", `Δ ${signedValue(delta.translateZ)}`, `当前 ${Number(after.translateZ || 0).toFixed(3)}`, Math.abs(delta.translateZ) > 0.0005),
autoPoseResultItem("缩放", `×${Number(delta.scale || 1).toFixed(3)}`, `当前 ${Number(after.scale || 1).toFixed(3)}`, Math.abs((delta.scale || 1) - 1) > 0.0005),
].join("");
}
function resetAutoDeltaBaseline() {
state.autoDeltaBaseline = { ...state.pose };
updateAutoPoseResult(state.pose, state.pose);
}
function renderAutoProgressSteps(activeStep = "") {
const stepsEl = $("autoProgressSteps");
if (!stepsEl) return;
const activeIndex = AUTO_PROGRESS_STEPS.findIndex((step) => step.id === activeStep);
stepsEl.innerHTML = AUTO_PROGRESS_STEPS.map((step, index) => {
const cls = index < activeIndex ? "done" : step.id === activeStep ? "active" : "";
return `${escapeHtml(step.label)}`;
}).join("");
}
function setAutoProgress(visible, value = 0, title = "", detail = "", activeStep = "") {
const wrap = $("autoProgress");
if (!wrap) return;
wrap.classList.toggle("hidden", !visible);
const safeValue = Math.max(0, Math.min(100, Number(value) || 0));
$("autoProgressFill").style.width = `${safeValue}%`;
$("autoProgressText").textContent = `${Math.round(safeValue)}%`;
$("autoProgressTitle").textContent = title || "自动微调处理中";
$("autoProgressDetail").textContent = detail || "正在准备自动微调任务。";
renderAutoProgressSteps(activeStep);
}
function waitForUiFrame() {
return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
}
function autoSettingsForStorage(settings) {
const { imageContext, ...rest } = settings || {};
return rest;
}
function chooseAutoVolumeEntries(settings) {
const volume = state.volumeMeta;
const frames = volume?.frames || [];
const indices = volume?.indices || [];
if (!frames.length || !indices.length) return [];
const entries = frames.map((frame, index) => ({ frame, slice: Number(indices[index] ?? 0) }));
const limit = Math.max(3, Math.min(entries.length, Number(settings.sampleSlices || 9)));
if (entries.length <= limit) return entries;
const selected = new Map();
for (let index = 0; index < limit; index += 1) {
const entryIndex = Math.round((index * (entries.length - 1)) / Math.max(limit - 1, 1));
selected.set(entryIndex, entries[entryIndex]);
}
return [...selected.values()].sort((left, right) => left.slice - right.slice);
}
function collectAutoSamplePoints(settings, maxPoints = 5200) {
const records = autoReferenceRecords(settings);
const totalVertices = records.reduce((sum, record) => sum + (record.mesh.geometry.attributes.position?.count || 0), 0);
if (!totalVertices) return [];
const stride = Math.max(1, Math.ceil(totalVertices / maxPoints));
const samples = [];
records.forEach((record) => {
const attribute = record.mesh.geometry.attributes.position;
if (!attribute) return;
for (let index = 0; index < attribute.count; index += stride) {
samples.push({
x: attribute.getX(index),
y: attribute.getY(index),
z: attribute.getZ(index),
});
}
});
return samples;
}
async function prepareAutoFineImageContext(settings) {
if (!state.volumeMeta || !state.volumeScene) return null;
const entries = chooseAutoVolumeEntries(settings);
const samples = collectAutoSamplePoints(settings);
if (!entries.length || !samples.length) return null;
const frames = [];
for (const [index, entry] of entries.entries()) {
const progress = 20 + (index / Math.max(entries.length, 1)) * 16;
setAutoProgress(true, progress, "读取骨窗采样切片", `正在读取第 ${index + 1}/${entries.length} 张骨窗切片,切片 ${entry.slice + 1}。`, "bone");
await waitForUiFrame();
frames.push({ ...entry, image: await loadImageData(entry.frame, 320) });
}
const sortedSlices = frames.map((entry) => entry.slice).sort((left, right) => left - right);
const gaps = sortedSlices.slice(1).map((slice, index) => Math.abs(slice - sortedSlices[index]));
const sliceTolerance = Math.max(1, Math.ceil((gaps.length ? Math.max(...gaps) : 1) / 2));
return {
frames,
samples,
sliceTolerance,
volumeScene: { ...state.volumeScene },
};
}
function scorePoseAgainstBoneImages(pose, context, settings) {
if (!context?.frames?.length || !context.samples?.length || !context.volumeScene) return NaN;
const current = { ...state.pose };
applyPose(pose, { scheduleMapping: false });
const matrix = modelPoseMatrix();
applyPose(current, { scheduleMapping: false });
const volume = context.volumeScene;
const point = new THREE.Vector3();
let hitReward = 0;
let missPenalty = 0;
let contributors = 0;
for (const sample of context.samples) {
point.set(sample.x, sample.y, sample.z).applyMatrix4(matrix);
if (
point.x < -volume.width / 2 || point.x > volume.width / 2
|| point.y < -volume.height / 2 || point.y > volume.height / 2
|| point.z < -volume.depth / 2 || point.z > volume.depth / 2
) {
missPenalty += 0.85;
contributors += 1;
continue;
}
const slice = volume.total <= 1 ? 0 : ((point.z + volume.depth / 2) / Math.max(volume.depth, 0.001)) * (volume.total - 1);
let nearest = context.frames[0];
let nearestDistance = Infinity;
for (const frame of context.frames) {
const distance = Math.abs(frame.slice - slice);
if (distance < nearestDistance) {
nearest = frame;
nearestDistance = distance;
}
}
if (nearestDistance > context.sliceTolerance) {
missPenalty += 0.28;
contributors += 1;
continue;
}
const image = nearest.image;
const x = Math.round(((point.x + volume.width / 2) / Math.max(volume.width, 0.001)) * (image.width - 1));
const y = Math.round(((volume.height / 2 - point.y) / Math.max(volume.height, 0.001)) * (image.height - 1));
if (x < 0 || x >= image.width || y < 0 || y >= image.height) {
missPenalty += 0.75;
contributors += 1;
continue;
}
const lum = image.data[(y * image.width + x) * 4] || 0;
if (lum >= BONE_BRIGHT_THRESHOLD) hitReward += BONE_FIXED_REWARD;
else if (lum >= BONE_EDGE_THRESHOLD) hitReward += BONE_EDGE_REWARD;
else missPenalty += 1.05 - (lum / Math.max(BONE_EDGE_THRESHOLD, 1)) * 0.28;
contributors += 1;
}
const safeContributors = Math.max(contributors, 1);
const basePose = settings.basePose || state.autoDeltaBaseline || state.pose;
const movement = Math.sqrt(
((pose.translateX || 0) - (basePose.translateX || 0)) ** 2
+ ((pose.translateY || 0) - (basePose.translateY || 0)) ** 2
+ ((pose.translateZ || 0) - (basePose.translateZ || 0)) ** 2,
);
const movementPenalty = movement * Number(settings.movePenalty || 0);
const scalePenalty = Math.abs((pose.scale || 1) - (basePose.scale || 1)) * Number(settings.scalePenalty || 0);
return (hitReward / safeContributors) * Number(settings.boneReward || 1)
- (missPenalty / safeContributors) * (0.25 + Number(settings.outsidePenalty || 0))
- movementPenalty
- scalePenalty;
}
function restoreAutoPose() {
if (state.poseControlsLocked) return;
if (!state.lastAutoPose?.before) {
$("autoResult").textContent = "暂无可退回的自动调整位姿。";
return;
}
state.pose = { ...state.lastAutoPose.before };
renderPoseControls();
applyPose();
fitCamera();
resetAutoDeltaBaseline();
$("autoResult").textContent = "已退回到自动调整前的位姿。";
markDirty();
}
function candidateScore(pose, settings = null) {
if (!state.dicomSceneBox || !state.modelGroup || !visibleModelRecords().length) return -Infinity;
const config = settings || readAutoSettings();
const records = autoReferenceRecords(config);
if (!records.length) return -Infinity;
const current = { ...state.pose };
applyPose(pose, { scheduleMapping: false });
const modelBox = visibleModelBox(records);
applyPose(current, { scheduleMapping: false });
if (!modelBox) return -Infinity;
const overlap = modelBox.clone().intersect(state.dicomSceneBox);
const overlapSize = new THREE.Vector3();
overlap.getSize(overlapSize);
const modelSize = new THREE.Vector3();
const dicomSize = new THREE.Vector3();
modelBox.getSize(modelSize);
state.dicomSceneBox.getSize(dicomSize);
const overlapVolume = Math.max(0, overlapSize.x) * Math.max(0, overlapSize.y) * Math.max(0, overlapSize.z);
const modelVolume = Math.max(modelSize.x * modelSize.y * modelSize.z, 0.001);
const modelCenter = new THREE.Vector3();
const dicomCenter = new THREE.Vector3();
modelBox.getCenter(modelCenter);
state.dicomSceneBox.getCenter(dicomCenter);
const centerPenalty = modelCenter.distanceTo(dicomCenter) / Math.max(dicomSize.length(), 0.001);
const scalePenalty = Math.abs((pose.scale || 1) - 1) * (0.03 + Number(config.scalePenalty || 0));
const movePenalty = (Math.abs(pose.translateX || 0) + Math.abs(pose.translateY || 0) + Math.abs(pose.translateZ || 0)) * Number(config.movePenalty || 0);
const geometryScore = (overlapVolume / modelVolume) * Number(config.boneReward || 1) - centerPenalty - scalePenalty - movePenalty - Number(config.outsidePenalty || 0) * 0.01;
if (config.imageContext) {
const imageScore = scorePoseAgainstBoneImages(pose, config.imageContext, config);
if (Number.isFinite(imageScore)) return imageScore + geometryScore * 0.12;
}
return geometryScore;
}
function describeAutoReference(settings) {
const records = autoReferenceRecords(settings);
if (!records.length) return "当前没有可用参考 STL";
const names = records.slice(0, 4).map((record) => compactStlName(record.file));
const suffix = records.length > names.length ? ` 等 ${records.length} 个` : "";
return `参考 STL:${names.join("、")}${suffix}`;
}
function estimateCoarsePose(settings) {
const records = autoReferenceRecords(settings);
const modelBox = visibleModelBox(records);
if (!modelBox || !state.dicomSceneBox) return null;
const adjustable = settings.adjustable || {};
const dicomSize = new THREE.Vector3();
const modelSize = new THREE.Vector3();
const dicomCenter = new THREE.Vector3();
state.dicomSceneBox.getSize(dicomSize);
state.dicomSceneBox.getCenter(dicomCenter);
modelBox.getSize(modelSize);
const candidate = { ...state.pose };
if (adjustable.scale) {
const ratios = ["x", "y", "z"]
.map((axis) => dicomSize[axis] / Math.max(modelSize[axis], 0.001))
.filter((value) => Number.isFinite(value) && value > 0);
if (ratios.length) {
const fitRatio = Math.min(...ratios) * 0.96;
candidate.scale = Number(clamp((candidate.scale || 1) * fitRatio, 0.2, 3).toFixed(3));
}
}
const previous = { ...state.pose };
applyPose(candidate, { scheduleMapping: false });
const nextBox = visibleModelBox(records);
applyPose(previous, { scheduleMapping: false });
if (!nextBox) return candidate;
const modelCenter = new THREE.Vector3();
nextBox.getCenter(modelCenter);
const offset = dicomCenter.clone().sub(modelCenter);
if (adjustable.translateX) candidate.translateX = Number(((candidate.translateX || 0) + offset.x).toFixed(3));
if (adjustable.translateY) candidate.translateY = Number(((candidate.translateY || 0) + offset.y).toFixed(3));
if (adjustable.translateZ) candidate.translateZ = Number(((candidate.translateZ || 0) + offset.z).toFixed(3));
return candidate;
}
function runAutoCoarse() {
if (state.poseControlsLocked) return;
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
if (state.autoFineRunning) return;
setAutoProgress(false);
const before = { ...state.pose };
const settings = readAutoSettings();
const estimated = estimateCoarsePose(settings);
if (!estimated) {
$("autoResult").textContent = "当前缺少 DICOM 或 STL 盒体信息,无法计算粗配准。";
setAutoState("粗配准失败", "error");
return;
}
state.pose = estimated;
renderPoseControls();
applyPose();
fitCamera();
state.autoResult = { score: candidateScore(state.pose, settings), pose: { ...state.pose }, evaluated: 1, settings };
state.lastAutoPose = { before, after: { ...state.pose } };
state.autoDeltaBaseline = { ...before };
setAutoState("粗配准完成", "ok");
const delta = poseDelta(before, state.pose);
$("autoResult").textContent = `已按 DICOM/STL 盒体估算中心与缩放:ΔX ${signedValue(delta.translateX)},ΔY ${signedValue(delta.translateY)},ΔZ ${signedValue(delta.translateZ)},缩放 ×${delta.scale.toFixed(3)};score ${state.autoResult.score.toFixed(4)}。`;
updateAutoPoseResult(before, state.pose);
markDirty();
}
async function runAutoFine(options = {}) {
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
if (state.autoFineRunning && !options.lockAlreadyHeld) return;
const before = { ...state.pose };
const settings = { ...readAutoSettings(), basePose: before };
const adjustable = settings.adjustable;
const lockAlreadyHeld = Boolean(options.lockAlreadyHeld);
state.autoFineRunning = true;
if (!lockAlreadyHeld) setPoseEditingLocked(true);
setAutoState("运行中", "warn");
setAutoProgress(true, options.fromSaveIterate ? 12 : 4, "锁定位姿参数", "自动微调运行中,位姿输入、XYZ 拉伸和迭代按钮已临时锁定。", options.fromSaveIterate ? "save" : "bone");
$("autoResult").textContent = "正在准备自动微调:锁定位姿控件并切换骨窗采样。";
await waitForUiFrame();
try {
setAutoProgress(true, 14, "加载骨窗参考", "读取骨窗 DICOM 切片范围,并重建三维参考显示。", "bone");
if (state.windowMode !== "bone") await setWindowMode("bone", true);
await waitForUiFrame();
setAutoProgress(true, 22, "采样参考 STL", "正在从勾选的参考 STL 和骨窗切片中构建评分样本。", "sample");
settings.imageContext = await prepareAutoFineImageContext(settings);
const imageContextText = settings.imageContext
? `已采样 ${settings.imageContext.samples.length} 个 STL 点、${settings.imageContext.frames.length} 张骨窗切片;仅高亮骨性像素参与正向奖励。`
: "未能建立骨窗像素样本,将退回 DICOM/STL 三维盒体评分。";
setAutoProgress(true, 38, "建立候选队列", imageContextText, "score");
await waitForUiFrame();
let best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" };
let evaluated = 1;
const coarsePose = estimateCoarsePose(settings);
const coarseSeed = coarsePose ? { pose: coarsePose, mode: "粗配准种子" } : null;
if (coarsePose) {
const coarseScore = candidateScore(coarsePose, settings);
evaluated += 1;
if (coarseScore > best.score) best = { pose: coarsePose, score: coarseScore, mode: "粗配准种子" };
}
const rounds = Array.from({ length: settings.iterations }, (_, index) => {
const t = Math.max(0.18, 1 - index / Math.max(settings.iterations, 1));
return { move: 0.34 * t, scale: 0.12 * t };
});
for (const [index, round] of rounds.entries()) {
const candidates = [];
const enabledKeys = [];
if (adjustable.translateX) enabledKeys.push("translateX");
if (adjustable.translateY) enabledKeys.push("translateY");
if (adjustable.translateZ) enabledKeys.push("translateZ");
if (adjustable.scale) enabledKeys.push("scale");
const stepForKey = (key, ratio = 1) => (key === "scale" ? round.scale : round.move) * ratio;
const poseWithDelta = (sourcePose, key, delta) => {
const baseValue = Number(sourcePose[key] ?? DEFAULT_POSE[key]);
const nextValue = key === "scale" ? Math.max(0.2, Math.min(3, baseValue + delta)) : baseValue + delta;
return { ...sourcePose, [key]: Number(nextValue.toFixed(key === "scale" ? 3 : 4)) };
};
const searchBases = [{ pose: best.pose, mode: best.mode }];
if (coarseSeed && poseSignature(coarseSeed.pose) !== poseSignature(best.pose)) searchBases.push(coarseSeed);
const seenCandidates = new Set();
const addCandidate = (pose, mode) => {
const signature = poseSignature(pose);
if (seenCandidates.has(signature)) return;
seenCandidates.add(signature);
candidates.push({ pose, mode });
};
searchBases.forEach((base) => {
enabledKeys.forEach((key) => {
addCandidate(poseWithDelta(base.pose, key, stepForKey(key)), `${base.mode} · ${key} +`);
addCandidate(poseWithDelta(base.pose, key, -stepForKey(key)), `${base.mode} · ${key} -`);
});
for (let left = 0; left < enabledKeys.length; left += 1) {
for (let right = left + 1; right < enabledKeys.length; right += 1) {
[-1, 1].forEach((leftSign) => {
[-1, 1].forEach((rightSign) => {
const leftKey = enabledKeys[left];
const rightKey = enabledKeys[right];
const pose = poseWithDelta(
poseWithDelta(base.pose, leftKey, stepForKey(leftKey, 0.65) * leftSign),
rightKey,
stepForKey(rightKey, 0.65) * rightSign,
);
addCandidate(pose, `${base.mode} · ${leftKey}/${rightKey} 联合`);
});
});
}
}
});
const candidateLimit = Math.max(settings.candidates, settings.candidates * searchBases.length);
const candidatesToEvaluate = candidates.slice(0, candidateLimit);
for (const candidate of candidatesToEvaluate) {
const pose = candidate.pose;
const score = candidateScore(pose, settings);
evaluated += 1;
if (score > best.score) best = { pose, score, mode: candidate.mode };
}
const progress = 40 + ((index + 1) / Math.max(rounds.length, 1)) * 48;
const scoreText = Number.isFinite(best.score) ? best.score.toFixed(4) : "不可用";
setAutoProgress(true, progress, `候选评分 ${index + 1}/${rounds.length}`, `本轮评估 ${candidatesToEvaluate.length}/${candidates.length} 个候选,累计 ${evaluated} 个;当前最高分候选 ${best.mode},score ${scoreText}。`, "score");
await waitForUiFrame();
}
setAutoProgress(true, 94, "应用最佳位姿", "正在把本轮最高分候选写回位姿控件和融合视图。", "apply");
state.pose = { ...best.pose };
state.autoResult = { ...best, evaluated, settings: { ...autoSettingsForStorage(settings), scoring: settings.imageContext ? "fixed-bright-bone-window-stl-samples" : "reference-stl-box-overlap", window: "bone", boneThreshold: BONE_BRIGHT_THRESHOLD, boneReward: BONE_FIXED_REWARD } };
state.lastAutoPose = { before, after: { ...state.pose } };
state.autoDeltaBaseline = { ...before };
renderPoseControls();
applyPose();
fitCamera();
const changed = poseSignature(best.pose) !== poseSignature(before);
setAutoState(changed ? "微调完成" : "微调无变化", changed ? "ok" : "warn");
const scoreText = Number.isFinite(best.score) ? best.score.toFixed(4) : "不可用";
const referenceText = describeAutoReference(settings);
$("autoResult").textContent = changed
? `本轮最高分候选:${best.mode};score ${scoreText};评估 ${evaluated} 个候选;${settings.imageContext ? `骨窗固定命中奖励,亮度 ≥ ${BONE_BRIGHT_THRESHOLD} 统一计为骨骼命中` : "参考 STL 盒体评分"};${referenceText}。`
: `本轮未找到比当前位姿更高分的候选;score ${scoreText};评估 ${evaluated} 个候选。这不代表已配准正确,只说明当前搜索范围和参考 STL 下没有更高分;${referenceText};自动微调目前只调整平移/缩放,明显旋转错位需要先手动校正。`;
updateAutoPoseResult(before, state.pose);
setAutoProgress(true, 100, changed ? "微调完成" : "本轮未改动", changed ? `已应用 ${best.mode}。` : "没有强行移动模型;请检查参考 STL 和大角度旋转。", "apply");
markDirty();
} catch (error) {
setAutoState("微调失败", "error");
$("autoResult").textContent = error.message || "自动微调失败。";
setAutoProgress(false);
} finally {
state.autoFineRunning = false;
setPoseEditingLocked(false);
}
}
async function saveAndIteratePose() {
if (state.autoFineRunning || state.poseControlsLocked) return;
state.autoFineRunning = true;
setPoseEditingLocked(true);
setAutoState("保存中", "warn");
setAutoProgress(true, 4, "保存当前位姿", "先保存当前人工位姿,并以保存后的位姿作为本轮自动微调起点。", "save");
const saved = await saveRegistration();
if (!saved) {
state.autoFineRunning = false;
setPoseEditingLocked(false);
setAutoProgress(false);
return;
}
state.autoFineRunning = false;
await runAutoFine({ lockAlreadyHeld: true, fromSaveIterate: true });
}
async function saveRegistration(nextStatus = null) {
if (!state.activeCase || !currentSeries() || state.saving) return false;
state.saving = true;
$("saveBtn").disabled = true;
$("statusBtn").disabled = true;
setSaveState("保存中", "warn");
const selectedSeries = currentSeries();
const status = nextStatus || state.registrationStatus;
const selectedFiles = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
try {
await api("/api/registrations", {
method: "POST",
body: JSON.stringify({
ct_number: state.activeCase.ct_number,
algorithm_model: state.algorithmModel,
registration_status: status,
series_instance_uid: selectedSeries.series_uid,
series_description: selectedSeries.description || "",
selected_stl_files: selectedFiles.map((file) => ({
id: file.id,
file_name: file.file_name,
segment_name: file.segment_name,
family: file.family,
category: file.category,
algorithm_model: file.algorithm_model,
})),
transform: state.pose,
module_styles: Object.fromEntries(selectedFiles.map((file, index) => {
const mode = stlMode(file);
return [file.file_name, {
color: stlColor(file, index),
opacity: mode === "solid" ? 1 : 0.52,
detail: state.modelDetail,
mode,
}];
})),
dicom_reference: {
series_uid: selectedSeries.series_uid,
series_number: selectedSeries.series_number,
description: selectedSeries.description,
slice_index: state.sliceIndex,
slice_count: selectedSeries.count,
window: state.windowMode,
volume: state.volumeMeta?.physicalSize || null,
},
model_reference: {
algorithm_model: state.algorithmModel,
selected_count: selectedFiles.length,
families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))],
fusion_mode: state.fusionMode,
fusion_detail: state.fusionDetail,
model_detail: state.modelDetail,
auto_result: state.autoResult,
},
notes: $("notes").value,
}),
});
state.registrationStatus = status;
state.registrationSeriesUid = selectedSeries.series_uid;
if (state.activeCase) {
state.activeCase.registration_status = status;
state.activeCase.series_instance_uid = selectedSeries.series_uid;
state.activeCase.series_description = selectedSeries.description || "";
}
const existing = state.cases.find((row) => sameCase(row, state.activeCase));
if (existing) {
existing.registration_status = status;
existing.series_instance_uid = selectedSeries.series_uid;
existing.series_description = selectedSeries.description || "";
}
resetDirty();
renderCases();
renderSeries();
renderActiveHeader();
return true;
} catch (error) {
setSaveState(error.message, "error");
return false;
} finally {
state.saving = false;
if (state.poseControlsLocked) {
applyPoseLockState();
} else {
$("saveBtn").disabled = false;
$("statusBtn").disabled = false;
}
}
}
function sameHostUrl(raw, targetPort) {
try {
const url = new URL(raw);
const here = new URL(window.location.href);
if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname)) url.hostname = here.hostname;
if (targetPort) url.port = String(targetPort);
return url.toString();
} catch {
const here = new URL(window.location.href);
here.port = String(targetPort || here.port);
here.pathname = "/";
here.search = "";
return here.toString();
}
}
function openLinkedApp(kind) {
const ct = state.activeCase?.ct_number || "";
const model = state.algorithmModel || "";
if (kind === "viewer") {
const url = new URL(sameHostUrl(state.links.pacs_viewer_url, 8107));
if (ct) url.searchParams.set("ct_number", ct);
window.open(url.toString(), "_blank");
} else {
const url = new URL(sameHostUrl(state.links.relation_viewer_url, 8108));
if (ct) url.searchParams.set("ct_number", ct);
if (model) url.searchParams.set("algorithm_model", model);
window.open(url.toString(), "_blank");
}
}
function setWindowMode(mode, reload = true) {
state.windowMode = mode || "default";
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item.dataset.window === state.windowMode));
document.querySelectorAll("[data-map-window]").forEach((item) => item.classList.toggle("active", item.dataset.mapWindow === state.windowMode));
return reload ? loadFusion() : Promise.resolve();
}
function setMappingMode(mode) {
state.mappingMode = mode === "image" ? "image" : "result";
document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode));
renderDicomAnnotation();
scheduleMappingDraw();
}
function wireEvents() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", () => logout());
$("caseToggleBtn").addEventListener("click", () => {
state.caseCollapsed = !state.caseCollapsed;
document.querySelector(".workspace").classList.toggle("case-collapsed", state.caseCollapsed);
$("caseToggleBtn").classList.toggle("active", state.caseCollapsed);
window.setTimeout(() => {
state.resizeScene?.();
fitCamera();
}, 230);
});
$("refreshBtn").addEventListener("click", async () => {
await loadStatus();
await loadSettings();
await loadCases(false);
});
$("settingsBtn").addEventListener("click", async () => {
$("settingsPanel").classList.remove("hidden");
await loadSettings();
});
$("settingsCloseBtn").addEventListener("click", () => $("settingsPanel").classList.add("hidden"));
$("saveSettingsBtn").addEventListener("click", saveSettings);
$("manualCacheRefreshBtn").addEventListener("click", refreshCacheNow);
$("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("fitBtn").addEventListener("click", resetSceneView);
$("saveBtn").addEventListener("click", () => saveRegistration());
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
$("notes").addEventListener("input", () => markDirty("notes"));
$("cycleAllStlModeBtn").addEventListener("click", cycleAllStlMode);
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
$("poseManualToggle").addEventListener("click", () => {
state.collapsedPoseSections.manual = !state.collapsedPoseSections.manual;
renderPoseSectionCollapse();
});
$("poseAutoToggle").addEventListener("click", () => {
state.collapsedPoseSections.auto = !state.collapsedPoseSections.auto;
renderPoseSectionCollapse();
});
$("stretchXBtn").addEventListener("click", () => stretchAxis("x"));
$("stretchYBtn").addEventListener("click", () => stretchAxis("y"));
$("stretchZBtn").addEventListener("click", () => stretchAxis("z"));
$("openAutoModalBtn").addEventListener("click", resetAutoSettings);
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
$("autoCoarseBtn")?.addEventListener("click", runAutoCoarse);
$("autoFineBtn")?.addEventListener("click", runAutoFine);
$("savePoseBtn").addEventListener("click", () => saveRegistration());
$("saveIterateBtn").addEventListener("click", saveAndIteratePose);
$("restorePoseBtn").addEventListener("click", restoreAutoPose);
const syncSampleSlices = (source) => {
const range = $("autoSampleSlices");
const number = $("autoSampleSlicesNumber");
const value = Math.max(3, Math.min(60, Number(source.value || 9)));
range.value = value;
number.value = value;
};
$("autoSampleSlices").addEventListener("input", () => syncSampleSlices($("autoSampleSlices")));
$("autoSampleSlicesNumber").addEventListener("input", () => syncSampleSlices($("autoSampleSlicesNumber")));
$("autoDefaultsBtn").addEventListener("click", () => {
$("autoSampleSlices").value = 9;
$("autoSampleSlicesNumber").value = 9;
$("autoIterations").value = 30;
});
$("dicomPreview").addEventListener("load", () => {
setDicomLoading(true, "正在计算 STL 分割映射", 42);
scheduleMappingDraw();
});
$("dicomPreview").addEventListener("error", () => {
setDicomLoading(false);
resetMappingCanvas("DICOM Base Layer 加载失败");
});
$("mappingResetBtn").addEventListener("click", () => {
state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 };
applyMappingTransform();
});
$("mappingRotateLeftBtn").addEventListener("click", () => {
state.mappingViewport.rotation = (Number(state.mappingViewport.rotation) || 0) - 90;
applyMappingTransform();
});
$("mappingRotateRightBtn").addEventListener("click", () => {
state.mappingViewport.rotation = (Number(state.mappingViewport.rotation) || 0) + 90;
applyMappingTransform();
});
$("mappingViewport").addEventListener("wheel", (event) => {
event.preventDefault();
const factor = event.deltaY > 0 ? 0.9 : 1.1;
state.mappingViewport.scale = Math.max(0.45, Math.min(6, state.mappingViewport.scale * factor));
applyMappingTransform();
}, { passive: false });
$("mappingViewport").addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
state.mappingDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
offsetX: state.mappingViewport.offsetX,
offsetY: state.mappingViewport.offsetY,
};
$("mappingViewport").classList.add("dragging");
$("mappingViewport").setPointerCapture(event.pointerId);
});
$("mappingViewport").addEventListener("pointermove", (event) => {
const drag = state.mappingDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.mappingViewport.offsetX = drag.offsetX + event.clientX - drag.startX;
state.mappingViewport.offsetY = drag.offsetY + event.clientY - drag.startY;
applyMappingTransform();
});
const stopMappingDrag = (event) => {
const drag = state.mappingDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.mappingDrag = null;
$("mappingViewport").classList.remove("dragging");
if ($("mappingViewport").hasPointerCapture(event.pointerId)) $("mappingViewport").releasePointerCapture(event.pointerId);
};
$("mappingViewport").addEventListener("pointerup", stopMappingDrag);
$("mappingViewport").addEventListener("pointercancel", stopMappingDrag);
document.querySelectorAll("[data-tool-tab]").forEach((button) => {
button.addEventListener("click", () => {
state.activeToolTab = button.dataset.toolTab || "series";
document.querySelectorAll("[data-tool-tab]").forEach((item) => item.classList.toggle("active", item === button));
document.querySelectorAll("[data-tool-pane]").forEach((pane) => pane.classList.toggle("active", pane.dataset.toolPane === state.activeToolTab));
});
});
$("caseSearch").addEventListener("input", () => {
state.search = $("caseSearch").value.trim();
window.clearTimeout(state.searchTimer);
state.searchTimer = window.setTimeout(() => loadCases(false), 240);
});
$("caseList").addEventListener("scroll", () => {
const list = $("caseList");
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 180) {
loadMoreCases();
}
});
document.querySelectorAll(".filter[data-status]").forEach((button) => {
button.addEventListener("click", async () => {
state.statusFilter = button.dataset.status || "";
document.querySelectorAll(".filter[data-status]").forEach((item) => item.classList.toggle("active", item === button));
await loadCases(false);
});
});
document.querySelectorAll(".filter[data-part]").forEach((button) => {
button.addEventListener("click", async () => {
state.partFilter = button.dataset.part || "";
document.querySelectorAll(".filter[data-part]").forEach((item) => item.classList.toggle("active", item === button));
await loadCases(false);
});
});
document.querySelectorAll(".filter[data-model-filter]").forEach((button) => {
button.addEventListener("click", async () => {
state.modelFilter = button.dataset.modelFilter || "";
document.querySelectorAll(".filter[data-model-filter]").forEach((item) => item.classList.toggle("active", item === button));
await loadCases(false);
});
});
document.querySelectorAll("[data-window]").forEach((button) => {
button.addEventListener("click", () => setWindowMode(button.dataset.window || "default"));
});
document.querySelectorAll("[data-map-window]").forEach((button) => {
button.addEventListener("click", () => setWindowMode(button.dataset.mapWindow || "default"));
});
document.querySelectorAll("[data-map-mode]").forEach((button) => {
button.addEventListener("click", () => setMappingMode(button.dataset.mapMode || "result"));
});
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
button.addEventListener("click", () => {
state.fusionMode = button.dataset.fusionMode || "fusion";
document.querySelectorAll("[data-fusion-mode]").forEach((item) => item.classList.toggle("active", item === button));
applyFusionMode();
fitCamera();
});
});
document.querySelectorAll("[data-fusion-detail]").forEach((button) => {
button.addEventListener("click", async () => {
const nextDetail = button.dataset.fusionDetail || "low";
if (nextDetail === state.fusionDetail) return;
state.fusionDetail = nextDetail;
document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item === button));
applyFusionDetail();
markDirty("display");
await loadFusion();
});
});
document.querySelectorAll("[data-model-detail]").forEach((button) => {
button.addEventListener("click", () => {
state.modelDetail = button.dataset.modelDetail || "standard";
document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item === button));
applyModelDetail();
markDirty("display");
});
});
$("mappingSliceSlider").addEventListener("input", () => {
const selected = currentSeries();
const max = Math.max(0, Number(selected?.count || 0) - 1);
state.sliceIndex = max - Number($("mappingSliceSlider").value || 0);
updateSliceControl();
renderDicomAnnotation();
updateDicomPreview();
});
const updateRange = () => {
state.sliceRangeStart = Number($("sliceRangeStart").value || 0);
state.sliceRangeEnd = Number($("sliceRangeEnd").value || 0);
updateSliceControl();
};
$("sliceRangeStart").addEventListener("input", updateRange);
$("sliceRangeEnd").addEventListener("input", updateRange);
$("sliceRangeStart").addEventListener("change", () => loadFusion());
$("sliceRangeEnd").addEventListener("change", () => loadFusion());
}
wireEvents();
renderPoseSectionCollapse();
if (state.token) {
bootstrap();
} else {
loginVisible(true);
}