Optimize DICOM UPP registration lazy loading and fusion views

This commit is contained in:
Codex
2026-05-29 16:53:01 +08:00
parent 1d6f04061a
commit 692c50e22c
4 changed files with 393 additions and 134 deletions

View File

@@ -39,6 +39,19 @@ const POSE_CONTROLS = [
];
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
const CASE_PAGE_SIZE = 20;
const POSE_SIGNATURE_KEYS = [
"rotateX",
"rotateY",
"rotateZ",
"translateX",
"translateY",
"translateZ",
"scale",
"flipX",
"flipY",
"flipZ",
];
const state = {
token: localStorage.getItem("dicom_upp_registration_token") || "",
@@ -51,7 +64,13 @@ const state = {
partFilter: "",
modelFilter: "",
search: "",
searchTimer: 0,
caseCollapsed: false,
caseLimit: CASE_PAGE_SIZE,
caseOffset: 0,
caseTotal: 0,
caseHasMore: false,
loadingCases: false,
activeToolTab: "series",
cases: [],
activeCase: null,
@@ -65,7 +84,7 @@ const state = {
pose: { ...DEFAULT_POSE },
windowMode: "default",
fusionMode: "fusion",
fusionDetail: "high",
fusionDetail: "low",
modelDetail: "standard",
mappingMode: "result",
sliceIndex: 0,
@@ -76,6 +95,7 @@ const state = {
lastAutoPose: null,
dirty: false,
dirtyReason: "",
savedPoseSignature: "",
saving: false,
fusionVersion: 0,
volumeMeta: null,
@@ -86,7 +106,7 @@ const state = {
scene: null,
controls: null,
fusionRoot: null,
viewPose: { rotateX: 0, rotateZ: 0, translateX: 0, translateY: 0, scale: 1 },
viewPose: { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 },
sceneDrag: null,
dicomGroup: null,
dicomPlane: null,
@@ -175,9 +195,23 @@ function markDirty(reason = "pose") {
setSaveState("未保存", "warn");
}
function poseSignature(pose = state.pose) {
const normalized = {};
POSE_SIGNATURE_KEYS.forEach((key) => {
const value = pose?.[key] ?? DEFAULT_POSE[key];
normalized[key] = typeof value === "boolean" ? value : Number(Number(value || 0).toFixed(4));
});
return JSON.stringify(normalized);
}
function poseChanged() {
return state.savedPoseSignature && poseSignature(state.pose) !== state.savedPoseSignature;
}
function resetDirty() {
state.dirty = false;
state.dirtyReason = "";
state.savedPoseSignature = poseSignature(state.pose);
setSaveState("已保存", "ok");
}
@@ -341,28 +375,47 @@ function pollCacheWhileRunning(force = false) {
}, running ? 1600 : 800);
}
async function loadCases(selectFirst = true) {
setTopLoading(true);
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);
const rows = await api(`/api/cases?${params.toString()}`);
state.cases = rows;
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 (state.activeCase && !rows.some((row) => sameCase(row, state.activeCase))) {
} else if (!append && state.activeCase && !state.cases.some((row) => sameCase(row, state.activeCase))) {
state.activeCase = null;
clearDetail();
}
} finally {
setTopLoading(false);
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 || "未指定模型");
}
@@ -439,11 +492,22 @@ function visibleModelRecords() {
return state.modelMeshes.filter((record) => state.selectedStlIds.has(Number(record.file?.id)) && record.mesh?.visible !== false);
}
function modelPoseMatrix() {
if (!state.modelGroup) return new THREE.Matrix4();
state.modelGroup.updateMatrix();
return state.modelGroup.matrix.clone();
}
function visibleModelBox() {
const records = visibleModelRecords();
if (!records.length) return null;
const box = new THREE.Box3();
records.forEach((record) => box.expandByObject(record.mesh));
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;
}
@@ -455,9 +519,9 @@ function bodyPartTags(row) {
}
function renderCases() {
$("caseCount").textContent = state.cases.length;
$("caseCount").textContent = state.caseTotal ? `${state.cases.length}/${state.caseTotal}` : state.cases.length;
const active = state.activeCase;
$("caseList").innerHTML = state.cases.length
const cards = state.cases.length
? state.cases
.map((row) => {
const tags = bodyPartTags(row).slice(0, 3);
@@ -483,9 +547,11 @@ function renderCases() {
})
.join("")
: `<div class="empty-state">没有符合条件的完整关联 CT</div>`;
$("caseList").innerHTML = `${cards}${state.caseHasMore ? `<button id="loadMoreCasesBtn" class="load-more-btn" type="button">${state.loadingCases ? "读取中..." : "继续加载"}</button>` : ""}`;
$("caseList").querySelectorAll(".case-card").forEach((button) => {
button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型"));
});
$("loadMoreCasesBtn")?.addEventListener("click", loadMoreCases);
}
function clearDetail() {
@@ -505,7 +571,7 @@ function clearDetail() {
}
async function confirmChange() {
if (!state.dirty) return true;
if (!state.dirty || !poseChanged()) return true;
return window.confirm("当前配准参数还未保存,确定切换吗?");
}
@@ -518,7 +584,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const previousPose = { ...state.pose };
if (!(await confirmChange())) return;
setTopLoading(true);
setFusionLoading(true, "正在读取 CT 关联数据", 6);
setFusionLoading(true, "正在读取当前 CT 数据", 6);
try {
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
@@ -532,10 +598,10 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
state.series = seriesPayload.series || [];
state.stlFiles = stlPayload.files || [];
state.registrationStatus = registration.registration_status || "unregistered";
const savedTransform = registration.transform || {};
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 || "low";
state.fusionDetail = state.fusionDetail || "low";
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "standard";
state.autoResult = null;
state.lastAutoPose = null;
@@ -617,18 +683,14 @@ function currentSeries() {
function renderActiveHeader() {
const row = state.activeCase;
if (!row) return;
const selected = currentSeries();
const registration = registrationSeries();
const bodyTags = bodyPartTags(row);
const selectedLabels = registration?.annotation_labels?.length ? registration.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : [];
$("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`;
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${row.patient_id || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${bodyTags.join("、") || row.study_description || "部位未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${row.patient_id || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
const tags = [
`<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
`<span class="tag">${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}</span>`,
...(registration ? [`<span class="tag">配准 ${escapeHtml(registration.description || "当前序列")}</span>`] : []),
...selectedLabels.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
...bodyTags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
];
$("activeTags").innerHTML = tags.join("");
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
@@ -766,10 +828,21 @@ async function invertStlSelection() {
async function applyStlSelectionChange() {
markDirty("stl");
renderStl();
if (!state.modelMeshes.length) {
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();
@@ -1207,10 +1280,10 @@ function updateFusionMeta() {
function fusionDetailSettings() {
return {
low: { planeOpacity: 0.82, sliceOpacity: 0.018, slicePlanes: 10, pointOpacity: 0.035, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 },
medium: { planeOpacity: 0.92, sliceOpacity: 0.024, slicePlanes: 14, pointOpacity: 0.055, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 512 },
high: { planeOpacity: 1, sliceOpacity: 0.028, slicePlanes: 18, pointOpacity: 0.075, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 768 },
}[state.fusionDetail] || { planeOpacity: 0.82, sliceOpacity: 0.018, slicePlanes: 10, pointOpacity: 0.035, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 };
low: { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 },
medium: { planeOpacity: 1, sliceOpacity: 0.07, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 },
high: { planeOpacity: 1, sliceOpacity: 0.11, slicePlanes: 72, pointOpacity: 0, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 1024 },
}[state.fusionDetail] || { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 };
}
function modelDetailSettings() {
@@ -1333,7 +1406,7 @@ async function loadTexture(url) {
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.magFilter = THREE.NearestFilter;
texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1);
texture.needsUpdate = true;
resolve(texture);
@@ -1677,7 +1750,6 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit
const maskData = maskContext.createImageData(width, height);
const radius = solidStrokeRadius(width, height);
const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15);
const fallbackGroups = [];
let filledPixels = 0;
groups.forEach((group) => {
@@ -1713,16 +1785,12 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit
});
groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius);
filledPixels += groupPixels;
if (groupPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) fallbackGroups.push(group);
});
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3);
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
maskContext.putImageData(maskData, 0, 0);
context.drawImage(maskCanvas, 0, 0);
fallbackGroups.forEach((group) => {
filledPixels += drawFallbackClosedRegion(context, width, height, group, color, opacity);
});
if (filledPixels === 0) {
context.save();
@@ -1739,6 +1807,19 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit
context.stroke();
context.restore();
}
context.save();
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.65;
context.strokeStyle = color;
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
segments.forEach((segment) => {
context.moveTo(segment.a.x, segment.a.y);
context.lineTo(segment.b.x, segment.b.y);
});
context.stroke();
context.restore();
return filledPixels;
}
@@ -1774,7 +1855,7 @@ async function drawMappingView(version = state.mappingVersion) {
x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width,
y: (0.5 - point.y / volumeScene.height) * height,
});
state.modelGroup?.updateMatrixWorld(true);
const matrix = modelPoseMatrix();
let activeModules = 0;
let segmentCount = 0;
@@ -1789,15 +1870,12 @@ async function drawMappingView(version = state.mappingVersion) {
const position = record.mesh.geometry.attributes.position;
if (!position) continue;
const triangleCount = Math.floor(position.count / 3);
const step = Math.max(1, Math.ceil(triangleCount / 70000));
const step = Math.max(1, Math.ceil(triangleCount / 260000));
const segments = [];
for (let tri = 0; tri < triangleCount; tri += step) {
tempA.fromBufferAttribute(position, tri * 3);
tempB.fromBufferAttribute(position, tri * 3 + 1);
tempC.fromBufferAttribute(position, tri * 3 + 2);
record.mesh.localToWorld(tempA);
record.mesh.localToWorld(tempB);
record.mesh.localToWorld(tempC);
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) };
@@ -1912,11 +1990,11 @@ async function buildDicomVolume(volume) {
opacity: fusionDetail.planeOpacity,
side: THREE.DoubleSide,
depthWrite: false,
depthTest: true,
depthTest: false,
}),
);
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
plane.renderOrder = 20;
plane.renderOrder = 80;
state.dicomGroup.add(plane);
state.dicomPlane = plane;
}
@@ -1944,46 +2022,51 @@ async function buildDicomVolume(volume) {
}
}
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 (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;
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().setFromObject(state.dicomGroup);
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() {
@@ -1998,6 +2081,7 @@ function updateBaseModelScaleFromBounds() {
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({
@@ -2015,9 +2099,34 @@ function addModelBoundsFrame(size) {
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?.children.length) {
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();
@@ -2027,22 +2136,15 @@ async function buildStlModels(volume, onProgress = null) {
onProgress?.(100);
return;
}
state.rawModelGroup.position.set(0, 0, 0);
state.rawModelGroup.rotation.set(0, 0, 0);
state.rawModelGroup.scale.set(1, 1, 1);
clearGroup(state.rawModelGroup);
state.modelMeshes = [];
state.modelBoundsFrame = null;
state.modelLocalBounds = null;
state.stlSignature = signature;
const files = state.stlFiles;
if (!files.length) {
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({
@@ -2069,31 +2171,50 @@ async function buildStlModels(volume, onProgress = null) {
mesh.userData.file = file;
mesh.userData.color = cssColor(index);
state.rawModelGroup.add(mesh);
state.modelMeshes.push({ mesh, file, color: cssColor(index), index });
const record = { mesh, file, color: cssColor(Number(file.id) || index), index: Number(file.id) || index };
state.modelMeshes.push(record);
freshRecords.push(record);
onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100);
}
if (!state.modelLocalBounds && state.modelMeshes.length) {
const rawBox = new THREE.Box3();
state.modelMeshes.forEach((record) => {
record.mesh.geometry.computeBoundingBox?.();
if (record.mesh.geometry.boundingBox) rawBox.union(record.mesh.geometry.boundingBox);
});
const center = new THREE.Vector3();
rawBox.getCenter(center);
state.modelMeshes.forEach((record) => {
const geometry = record.mesh.geometry;
geometry.translate(-center.x, -center.y, -center.z);
geometry.computeBoundingBox?.();
geometry.computeBoundingSphere?.();
geometry.computeVertexNormals?.();
});
state.modelLocalBounds = {
center: { x: center.x, y: center.y, z: center.z },
size: { x: 1, y: 1, z: 1 },
};
} else if (state.modelLocalBounds && freshRecords.length) {
const center = state.modelLocalBounds.center;
freshRecords.forEach((record) => {
const geometry = record.mesh.geometry;
geometry.translate(-Number(center.x || 0), -Number(center.y || 0), -Number(center.z || 0));
geometry.computeBoundingBox?.();
geometry.computeBoundingSphere?.();
geometry.computeVertexNormals?.();
});
}
const bbox = new THREE.Box3();
state.modelMeshes.forEach((record) => {
record.mesh.geometry.computeBoundingBox?.();
if (record.mesh.geometry.boundingBox) bbox.union(record.mesh.geometry.boundingBox);
});
const center = new THREE.Vector3();
const size = new THREE.Vector3();
bbox.getCenter(center);
bbox.getSize(size);
state.modelMeshes.forEach((record) => {
const geometry = record.mesh.geometry;
geometry.translate(-center.x, -center.y, -center.z);
geometry.computeBoundingBox?.();
geometry.computeBoundingSphere?.();
geometry.computeVertexNormals?.();
});
state.modelLocalBounds = {
center: { x: center.x, y: center.y, z: center.z },
size: { x: size.x, y: size.y, z: size.z },
};
if (!bbox.isEmpty()) bbox.getSize(size);
if (state.modelLocalBounds) state.modelLocalBounds.size = { x: size.x, y: size.y, z: size.z };
updateBaseModelScaleFromBounds();
addModelBoundsFrame(size);
if (size.x || size.y || size.z) addModelBoundsFrame(size);
applyStlVisibility();
applyPose();
scheduleMappingDraw();
@@ -2111,9 +2232,9 @@ function applyPose(poseInput = state.pose) {
state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0);
const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale;
state.modelGroup.scale.set(
scale * Math.max(0.001, Number(pose.scaleX) || 1) * (pose.flipX ? -1 : 1),
scale * Math.max(0.001, Number(pose.scaleY) || 1) * (pose.flipY ? -1 : 1),
scale * Math.max(0.001, Number(pose.scaleZ) || 1) * (pose.flipZ ? -1 : 1),
scale * (pose.flipX ? -1 : 1),
scale * (pose.flipY ? -1 : 1),
scale * (pose.flipZ ? -1 : 1),
);
state.modelGroup.updateMatrixWorld(true);
scheduleMappingDraw();
@@ -2154,7 +2275,7 @@ function fitCamera() {
}
function resetSceneView() {
state.viewPose = { rotateX: 0, rotateZ: 0, translateX: 0, translateY: 0, scale: 1 };
state.viewPose = { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 };
applySceneViewPose();
fitCamera();
}
@@ -2180,21 +2301,24 @@ function stretchAxis(axis) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
const axisKey = { x: "scaleX", y: "scaleY", z: "scaleZ" }[axis];
if (!axisKey) 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 currentAxisScale = Math.max(0.001, Number(state.pose[axisKey]) || 1);
const targetSize = Math.max(0.001, dicomSize[axis]);
const currentSize = Math.max(0.001, modelSize[axis]);
const next = Math.max(0.2, Math.min(4, currentAxisScale * (targetSize / currentSize)));
state.pose[axisKey] = Number(next.toFixed(3));
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[axisKey]}`;
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体等比例拉伸,缩放 ${state.pose.scale}`;
updateAutoPoseResult();
}
@@ -2609,6 +2733,12 @@ function wireEvents() {
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 || "";