Migrate registration workspace fusion mapping views
This commit is contained in:
@@ -20,6 +20,9 @@ const DEFAULT_POSE = {
|
||||
translateY: 0,
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scaleZ: 1,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
flipZ: false,
|
||||
@@ -61,9 +64,11 @@ const state = {
|
||||
dicomPreviewTimer: 0,
|
||||
autoResult: null,
|
||||
dirty: false,
|
||||
dirtyReason: "",
|
||||
saving: false,
|
||||
fusionVersion: 0,
|
||||
volumeMeta: null,
|
||||
volumeScene: null,
|
||||
sceneReady: false,
|
||||
renderer: null,
|
||||
camera: null,
|
||||
@@ -72,8 +77,12 @@ const state = {
|
||||
dicomGroup: null,
|
||||
modelGroup: null,
|
||||
rawModelGroup: null,
|
||||
modelMeshes: [],
|
||||
baseModelScale: 1,
|
||||
dicomSceneBox: null,
|
||||
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0 },
|
||||
mappingDrag: null,
|
||||
mappingDrawFrame: 0,
|
||||
resizeObserver: null,
|
||||
resizeScene: null,
|
||||
};
|
||||
@@ -112,13 +121,15 @@ function setAutoState(text, tone = "") {
|
||||
el.className = tone;
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
function markDirty(reason = "pose") {
|
||||
state.dirty = true;
|
||||
state.dirtyReason = reason;
|
||||
setSaveState("未保存", "warn");
|
||||
}
|
||||
|
||||
function resetDirty() {
|
||||
state.dirty = false;
|
||||
state.dirtyReason = "";
|
||||
setSaveState("已保存", "ok");
|
||||
}
|
||||
|
||||
@@ -185,6 +196,7 @@ async function bootstrap() {
|
||||
try {
|
||||
initScene();
|
||||
} catch (error) {
|
||||
initGeometryFallback();
|
||||
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
|
||||
console.warn(error);
|
||||
}
|
||||
@@ -242,6 +254,10 @@ 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();
|
||||
}
|
||||
@@ -263,6 +279,26 @@ 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("门静脉期")));
|
||||
}
|
||||
|
||||
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 : [];
|
||||
@@ -331,11 +367,17 @@ async function confirmChange() {
|
||||
}
|
||||
|
||||
async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
||||
if (!ctNumber || !(await confirmChange())) return;
|
||||
if (!ctNumber) return;
|
||||
const normalizedAlgorithm = algorithmModel || "未指定模型";
|
||||
const switchingModelOnly = state.activeCase
|
||||
&& sameCtNumber(state.activeCase.ct_number, ctNumber)
|
||||
&& (state.algorithmModel || "未指定模型") !== normalizedAlgorithm;
|
||||
const previousPose = { ...state.pose };
|
||||
if (!switchingModelOnly && !(await confirmChange())) return;
|
||||
setTopLoading(true);
|
||||
setFusionLoading(true, "正在读取 CT 关联数据");
|
||||
try {
|
||||
const params = new URLSearchParams({ algorithm_model: algorithmModel || "未指定模型" });
|
||||
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`),
|
||||
@@ -347,7 +389,9 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
||||
state.series = seriesPayload.series || [];
|
||||
state.stlFiles = stlPayload.files || [];
|
||||
state.registrationStatus = registration.registration_status || "unregistered";
|
||||
state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) };
|
||||
const savedTransform = registration.transform || {};
|
||||
const keepPreviousPose = switchingModelOnly;
|
||||
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
|
||||
state.autoResult = null;
|
||||
state.sliceIndex = 0;
|
||||
$("notes").value = registration.notes || "";
|
||||
@@ -363,7 +407,8 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
||||
renderSeries();
|
||||
renderStl();
|
||||
renderPoseControls();
|
||||
resetDirty();
|
||||
if (keepPreviousPose) markDirty("model");
|
||||
else resetDirty();
|
||||
await loadFusion();
|
||||
} catch (error) {
|
||||
$("fusionStatus").textContent = error.message;
|
||||
@@ -375,9 +420,27 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
||||
}
|
||||
|
||||
function pickDefaultSeries(series) {
|
||||
return [...series]
|
||||
.filter((item) => Number(item.count || 0) > 1)
|
||||
.sort((a, b) => Number(b.count || 0) - Number(a.count || 0))[0] || series[0] || null;
|
||||
const candidates = [...series].filter((item) => Number(item.count || 0) > 1);
|
||||
const usable = candidates.filter((item) => !isSkippedSeries(item));
|
||||
const pool = usable.length ? usable : candidates;
|
||||
const algorithm = String(state.algorithmModel || "").toLowerCase();
|
||||
const rank = (item) => {
|
||||
let score = Number(item.count || 0) / 10000;
|
||||
if (algorithm.includes("肝胆") || algorithm.includes("liver") || algorithm.includes("bile")) {
|
||||
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) {
|
||||
@@ -437,8 +500,9 @@ function renderSeries() {
|
||||
function seriesCardHtml(item, active = false) {
|
||||
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
|
||||
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
|
||||
const skipped = isSkippedSeries(item);
|
||||
return `
|
||||
<button class="series-card ${active ? "active" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
|
||||
<button class="series-card ${active ? "active" : ""} ${skipped ? "skipped" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
|
||||
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
|
||||
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
|
||||
<small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
|
||||
@@ -576,9 +640,14 @@ function renderPoseControls() {
|
||||
function updateSliceControl() {
|
||||
const selected = currentSeries();
|
||||
const count = Number(selected?.count || 0);
|
||||
$("sliceSlider").max = Math.max(0, count - 1);
|
||||
$("sliceSlider").value = Math.min(state.sliceIndex, Math.max(0, count - 1));
|
||||
$("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 1} / ${count}` : "切片 0 / 0";
|
||||
const max = Math.max(0, count - 1);
|
||||
state.sliceIndex = Math.max(0, Math.min(state.sliceIndex, max));
|
||||
$("sliceSlider").max = max;
|
||||
$("sliceSlider").value = state.sliceIndex;
|
||||
$("sliceLabel").textContent = count ? `切片 ${state.sliceIndex + 1} / ${count}` : "切片 0 / 0";
|
||||
$("mappingSliceSlider").max = max;
|
||||
$("mappingSliceSlider").value = max - state.sliceIndex;
|
||||
$("mappingSliceLabel").textContent = count ? `${state.sliceIndex + 1} / ${count}` : "0 / 0";
|
||||
}
|
||||
|
||||
function renderDicomAnnotation() {
|
||||
@@ -604,6 +673,7 @@ function updateDicomPreview() {
|
||||
if (!state.activeCase || !selected) {
|
||||
$("dicomPreview").removeAttribute("src");
|
||||
renderDicomAnnotation();
|
||||
resetMappingCanvas("未选择 DICOM 序列");
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(state.dicomPreviewTimer);
|
||||
@@ -616,6 +686,7 @@ function updateDicomPreview() {
|
||||
access_token: state.token,
|
||||
});
|
||||
$("dicomLoading").classList.remove("hidden");
|
||||
resetMappingCanvas("正在加载 DICOM Base Layer");
|
||||
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
|
||||
renderDicomAnnotation();
|
||||
}, 80);
|
||||
@@ -672,6 +743,15 @@ function initScene() {
|
||||
animate();
|
||||
}
|
||||
|
||||
function initGeometryFallback() {
|
||||
if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return;
|
||||
const dicomGroup = new THREE.Group();
|
||||
const modelGroup = new THREE.Group();
|
||||
const rawModelGroup = new THREE.Group();
|
||||
modelGroup.add(rawModelGroup);
|
||||
Object.assign(state, { dicomGroup, modelGroup, rawModelGroup });
|
||||
}
|
||||
|
||||
function clearGroup(group) {
|
||||
if (!group) return;
|
||||
while (group.children.length) {
|
||||
@@ -691,9 +771,12 @@ function clearFusion() {
|
||||
clearGroup(state.dicomGroup);
|
||||
clearGroup(state.rawModelGroup);
|
||||
state.volumeMeta = null;
|
||||
state.volumeScene = null;
|
||||
state.modelMeshes = [];
|
||||
$("fusionMeta").textContent = "DICOM - · STL -";
|
||||
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
|
||||
updateSliceControl();
|
||||
resetMappingCanvas();
|
||||
}
|
||||
|
||||
function applyFusionMode() {
|
||||
@@ -707,10 +790,7 @@ function applyFusionMode() {
|
||||
async function loadFusion() {
|
||||
const version = ++state.fusionVersion;
|
||||
const selected = currentSeries();
|
||||
if (!state.sceneReady) {
|
||||
$("fusionStatus").textContent = "当前浏览器未能启用 WebGL,无法显示三维融合视图";
|
||||
return;
|
||||
}
|
||||
if (!state.sceneReady) initGeometryFallback();
|
||||
if (!state.activeCase || !selected) {
|
||||
clearFusion();
|
||||
return;
|
||||
@@ -732,9 +812,13 @@ async function loadFusion() {
|
||||
if (version !== state.fusionVersion) return;
|
||||
await buildStlModels(volume);
|
||||
if (version !== state.fusionVersion) return;
|
||||
applyFusionMode();
|
||||
fitCamera();
|
||||
$("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`;
|
||||
if (state.sceneReady) {
|
||||
applyFusionMode();
|
||||
fitCamera();
|
||||
}
|
||||
$("fusionStatus").textContent = state.sceneReady
|
||||
? `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`
|
||||
: `${selected.description || "DICOM 序列"} · 2D 映射已就绪,当前浏览器未启用 WebGL`;
|
||||
$("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size} 个`;
|
||||
renderDicomAnnotation();
|
||||
updateDicomPreview();
|
||||
@@ -752,49 +836,370 @@ async function loadTexture(url) {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadImageData(url, maxSize = 256) {
|
||||
const image = await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
const scale = Math.min(1, maxSize / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height, 1));
|
||||
const width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale));
|
||||
const height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
return { width, height, data: context.getImageData(0, 0, width, height).data };
|
||||
}
|
||||
|
||||
function sliceToSceneZ(sliceIndex, volumeScene = state.volumeScene) {
|
||||
if (!volumeScene) return 0;
|
||||
return volumeScene.total <= 1
|
||||
? 0
|
||||
: -volumeScene.depth / 2 + (volumeScene.depth * Math.max(0, Math.min(sliceIndex, volumeScene.total - 1))) / (volumeScene.total - 1);
|
||||
}
|
||||
|
||||
function parseCssColor(color) {
|
||||
const value = String(color || "#19d6c3").replace("#", "");
|
||||
const hex = value.length === 3 ? value.split("").map((char) => char + char).join("") : value.padStart(6, "0").slice(0, 6);
|
||||
const intValue = Number.parseInt(hex, 16);
|
||||
return {
|
||||
r: (intValue >> 16) & 255,
|
||||
g: (intValue >> 8) & 255,
|
||||
b: intValue & 255,
|
||||
};
|
||||
}
|
||||
|
||||
function resetMappingCanvas(message = "当前切片暂无可见构件") {
|
||||
const canvas = $("mappingCanvas");
|
||||
const context = canvas?.getContext("2d");
|
||||
if (context) context.clearRect(0, 0, canvas.width || 1, canvas.height || 1);
|
||||
$("mappingStats").textContent = "0/0 构件 · 0 边 · 0 px";
|
||||
$("mappingLegend").innerHTML = `<div class="empty-state">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
function scheduleMappingDraw() {
|
||||
window.cancelAnimationFrame(state.mappingDrawFrame);
|
||||
state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView());
|
||||
}
|
||||
|
||||
function applyMappingTransform() {
|
||||
const layer = $("mappingLayer");
|
||||
if (!layer) return;
|
||||
const { scale, offsetX, offsetY } = state.mappingViewport;
|
||||
layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(${scale})`;
|
||||
}
|
||||
|
||||
function intersectEdgeWithZ(a, b, targetZ) {
|
||||
const da = a.z - targetZ;
|
||||
const db = b.z - targetZ;
|
||||
if (Math.abs(da) < 1e-5 && Math.abs(db) < 1e-5) return [a.clone(), b.clone()];
|
||||
if (Math.abs(da) < 1e-5) return [a.clone()];
|
||||
if (Math.abs(db) < 1e-5) return [b.clone()];
|
||||
if ((da > 0 && db > 0) || (da < 0 && db < 0)) return [];
|
||||
const t = da / (da - db);
|
||||
return [new THREE.Vector3(
|
||||
a.x + (b.x - a.x) * t,
|
||||
a.y + (b.y - a.y) * t,
|
||||
targetZ,
|
||||
)];
|
||||
}
|
||||
|
||||
function uniquePlanePoints(points) {
|
||||
const unique = [];
|
||||
points.forEach((point) => {
|
||||
if (!unique.some((item) => item.distanceToSquared(point) < 1e-8)) unique.push(point);
|
||||
});
|
||||
return unique;
|
||||
}
|
||||
|
||||
function intersectTriangleWithZ(a, b, c, targetZ) {
|
||||
const points = uniquePlanePoints([
|
||||
...intersectEdgeWithZ(a, b, targetZ),
|
||||
...intersectEdgeWithZ(b, c, targetZ),
|
||||
...intersectEdgeWithZ(c, a, targetZ),
|
||||
]);
|
||||
if (points.length < 2) return null;
|
||||
return { a: points[0], b: points[1] };
|
||||
}
|
||||
|
||||
function addSegmentIntersectionsToRows(rows, width, height, segment) {
|
||||
const a = segment.a;
|
||||
const b = segment.b;
|
||||
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return;
|
||||
const minY = Math.max(0, Math.floor(Math.min(a.y, b.y)));
|
||||
const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y)));
|
||||
const deltaY = b.y - a.y;
|
||||
if (Math.abs(deltaY) < 1e-6) {
|
||||
const row = Math.max(0, Math.min(height - 1, Math.round(a.y)));
|
||||
rows[row].push(Math.max(0, Math.min(width - 1, a.x)));
|
||||
rows[row].push(Math.max(0, Math.min(width - 1, b.x)));
|
||||
return;
|
||||
}
|
||||
for (let row = minY; row <= maxY; row += 1) {
|
||||
const sampleY = row + 0.5;
|
||||
const t = (sampleY - a.y) / deltaY;
|
||||
if (t < 0 || t > 1) continue;
|
||||
const x = a.x + (b.x - a.x) * t;
|
||||
if (Number.isFinite(x)) rows[row].push(x);
|
||||
}
|
||||
}
|
||||
|
||||
function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) {
|
||||
if (!segments.length) return 0;
|
||||
const rgb = parseCssColor(color);
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = width;
|
||||
maskCanvas.height = height;
|
||||
const maskContext = maskCanvas.getContext("2d");
|
||||
const imageData = maskContext.createImageData(width, height);
|
||||
const rows = Array.from({ length: height }, () => []);
|
||||
const alpha = Math.round(Math.max(0.12, Math.min(opacity, 1)) * 190);
|
||||
let filled = 0;
|
||||
|
||||
segments.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
|
||||
rows.forEach((intersections, y) => {
|
||||
if (intersections.length < 2) return;
|
||||
intersections.sort((a, b) => a - b);
|
||||
const cleaned = [];
|
||||
intersections.forEach((x) => {
|
||||
const previous = cleaned[cleaned.length - 1];
|
||||
if (previous === undefined || Math.abs(previous - x) > 0.35) cleaned.push(x);
|
||||
});
|
||||
for (let index = 0; index + 1 < cleaned.length; index += 2) {
|
||||
const startX = Math.max(0, Math.min(width - 1, Math.ceil(cleaned[index])));
|
||||
const endX = Math.max(0, Math.min(width - 1, Math.floor(cleaned[index + 1])));
|
||||
if (endX < startX) continue;
|
||||
for (let x = startX; x <= endX; x += 1) {
|
||||
const offset = (y * width + x) * 4;
|
||||
imageData.data[offset] = rgb.r;
|
||||
imageData.data[offset + 1] = rgb.g;
|
||||
imageData.data[offset + 2] = rgb.b;
|
||||
imageData.data[offset + 3] = alpha;
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
maskContext.putImageData(imageData, 0, 0);
|
||||
context.drawImage(maskCanvas, 0, 0);
|
||||
context.save();
|
||||
context.globalAlpha = Math.max(0.18, Math.min(opacity, 1));
|
||||
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 filled;
|
||||
}
|
||||
|
||||
function drawMappingView() {
|
||||
const image = $("dicomPreview");
|
||||
const canvas = $("mappingCanvas");
|
||||
const volumeScene = state.volumeScene;
|
||||
if (!image?.complete || !image.naturalWidth || !volumeScene || !state.modelMeshes.length) {
|
||||
resetMappingCanvas(state.modelMeshes.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
|
||||
return;
|
||||
}
|
||||
|
||||
const width = image.naturalWidth;
|
||||
const height = image.naturalHeight;
|
||||
if (canvas.width !== width) canvas.width = width;
|
||||
if (canvas.height !== height) canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
const targetZ = sliceToSceneZ(state.sliceIndex, volumeScene);
|
||||
const mapPoint = (point) => ({
|
||||
x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width,
|
||||
y: (0.5 - point.y / volumeScene.height) * height,
|
||||
});
|
||||
state.modelGroup?.updateMatrixWorld(true);
|
||||
|
||||
let activeModules = 0;
|
||||
let segmentCount = 0;
|
||||
let filledPixels = 0;
|
||||
const modules = [];
|
||||
const tempA = new THREE.Vector3();
|
||||
const tempB = new THREE.Vector3();
|
||||
const tempC = new THREE.Vector3();
|
||||
|
||||
state.modelMeshes.forEach((record, index) => {
|
||||
const position = record.mesh.geometry.attributes.position;
|
||||
if (!position) return;
|
||||
const triangleCount = Math.floor(position.count / 3);
|
||||
const step = Math.max(1, Math.ceil(triangleCount / 70000));
|
||||
const segments = [];
|
||||
for (let tri = 0; tri < triangleCount; tri += step) {
|
||||
tempA.fromBufferAttribute(position, tri * 3);
|
||||
tempB.fromBufferAttribute(position, tri * 3 + 1);
|
||||
tempC.fromBufferAttribute(position, tri * 3 + 2);
|
||||
record.mesh.localToWorld(tempA);
|
||||
record.mesh.localToWorld(tempB);
|
||||
record.mesh.localToWorld(tempC);
|
||||
const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ);
|
||||
if (!segment) continue;
|
||||
const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) };
|
||||
if (
|
||||
mapped.a.x > -width && mapped.a.x < width * 2
|
||||
&& mapped.b.x > -width && mapped.b.x < width * 2
|
||||
&& mapped.a.y > -height && mapped.a.y < height * 2
|
||||
&& mapped.b.y > -height && mapped.b.y < height * 2
|
||||
) {
|
||||
segments.push(mapped);
|
||||
}
|
||||
}
|
||||
const pixels = fillSegmentsAsSolidMask(context, width, height, segments, record.color, 0.66);
|
||||
if (segments.length || pixels) {
|
||||
activeModules += 1;
|
||||
segmentCount += segments.length;
|
||||
filledPixels += pixels;
|
||||
modules.push({
|
||||
id: index + 1,
|
||||
name: record.file.segment_name || record.file.file_name || `STL ${index + 1}`,
|
||||
color: record.color,
|
||||
segments: segments.length,
|
||||
pixels,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$("mappingStats").textContent = `${activeModules}/${state.modelMeshes.length} 构件 · ${segmentCount} 边 · ${filledPixels} px`;
|
||||
$("mappingLegend").innerHTML = modules.length
|
||||
? modules.map((item) => `
|
||||
<div class="legend-item" title="${escapeHtml(item.name)}">
|
||||
<span class="legend-color" style="background:${escapeHtml(item.color)}"></span>
|
||||
<span class="legend-name">${escapeHtml(item.name)}</span>
|
||||
<span class="legend-meta">ID ${item.id}</span>
|
||||
<span></span>
|
||||
<span class="legend-meta">${item.segments} 边</span>
|
||||
<span class="legend-meta">${item.pixels} px</span>
|
||||
</div>
|
||||
`).join("")
|
||||
: `<div class="empty-state">当前切片暂无可见构件</div>`;
|
||||
}
|
||||
|
||||
async function buildDicomVolume(volume) {
|
||||
clearGroup(state.dicomGroup);
|
||||
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 = 5.2 / maxPhysical;
|
||||
const sceneScale = 4.8 / maxPhysical;
|
||||
const width = (physical.width || 1) * sceneScale;
|
||||
const height = (physical.height || 1) * sceneScale;
|
||||
const depth = (physical.depth || 1) * sceneScale;
|
||||
const depth = Math.max((physical.depth || 1) * sceneScale, 0.18);
|
||||
const centerIndex = Number(volume.center || 0);
|
||||
const total = Math.max(1, Number(volume.total || 1));
|
||||
const indices = volume.indices || [];
|
||||
const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0;
|
||||
const textures = await Promise.all((volume.frames || []).map((frame) => loadTexture(frame)));
|
||||
textures.forEach((texture, index) => {
|
||||
state.volumeScene = {
|
||||
width,
|
||||
height,
|
||||
depth,
|
||||
total,
|
||||
sceneScale,
|
||||
rowSpacing: Number(volume.spacing?.row || 1),
|
||||
columnSpacing: Number(volume.spacing?.column || 1),
|
||||
sliceSpacing: Number(volume.spacing?.slice || 1),
|
||||
};
|
||||
|
||||
const boxMesh = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(width, height, depth),
|
||||
new THREE.MeshBasicMaterial({ color: 0x020617, transparent: true, opacity: 0.045, depthWrite: false }),
|
||||
);
|
||||
state.dicomGroup.add(boxMesh);
|
||||
const edges = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(boxMesh.geometry),
|
||||
new THREE.LineBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 0.46 }),
|
||||
);
|
||||
state.dicomGroup.add(edges);
|
||||
|
||||
const localStart = Number(volume.start ?? indices[0] ?? centerIndex);
|
||||
const localEnd = Number(volume.end ?? indices[indices.length - 1] ?? centerIndex);
|
||||
const rangeDepth = Math.max(0.015, Math.abs(sliceToSceneZ(localEnd) - sliceToSceneZ(localStart)));
|
||||
const rangeBox = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(new THREE.BoxGeometry(width * 0.94, height * 0.94, rangeDepth)),
|
||||
new THREE.LineBasicMaterial({ color: 0xfacc15, transparent: true, opacity: 0.56 }),
|
||||
);
|
||||
rangeBox.position.z = (sliceToSceneZ(localStart) + sliceToSceneZ(localEnd)) / 2;
|
||||
state.dicomGroup.add(rangeBox);
|
||||
|
||||
const centerFrame = (volume.frames || [])[centerPosition];
|
||||
if (centerFrame) {
|
||||
const texture = await loadTexture(centerFrame);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: index === centerPosition ? 0.78 : 0.14,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const plane = new THREE.Mesh(new THREE.PlaneGeometry(width, height), material);
|
||||
const z = (Number(indices[index] ?? centerIndex) - centerIndex) * Number(volume.spacing?.slice || 1) * sceneScale;
|
||||
plane.position.z = z;
|
||||
const plane = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(width, height),
|
||||
new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0.42,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
|
||||
state.dicomGroup.add(plane);
|
||||
});
|
||||
const box = new THREE.BoxGeometry(width, height, Math.max(depth, 0.02));
|
||||
const edges = new THREE.EdgesGeometry(box);
|
||||
const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 }));
|
||||
state.dicomGroup.add(lines);
|
||||
}
|
||||
|
||||
const pointPositions = [];
|
||||
const pointColors = [];
|
||||
const frames = volume.frames || [];
|
||||
for (const [index, frame] of frames.entries()) {
|
||||
const image = await loadImageData(frame, 180);
|
||||
const stride = Math.max(3, Math.round(Math.max(image.width, image.height) / 72));
|
||||
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 < 12) 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: 0.018,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.34,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
state.dicomGroup.add(points);
|
||||
}
|
||||
|
||||
state.baseModelScale = sceneScale;
|
||||
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
|
||||
}
|
||||
|
||||
async function buildStlModels(volume) {
|
||||
clearGroup(state.rawModelGroup);
|
||||
state.modelMeshes = [];
|
||||
const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
|
||||
if (!files.length) {
|
||||
applyPose();
|
||||
resetMappingCanvas("当前没有可见 STL 构件");
|
||||
return;
|
||||
}
|
||||
const loader = new STLLoader();
|
||||
const token = encodeURIComponent(state.token);
|
||||
for (const [index, file] of files.entries()) {
|
||||
const params = new URLSearchParams({
|
||||
ct_number: state.activeCase.ct_number,
|
||||
@@ -816,13 +1221,17 @@ async function buildStlModels(volume) {
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.name = file.segment_name || file.file_name;
|
||||
mesh.userData.file = file;
|
||||
mesh.userData.color = cssColor(index);
|
||||
state.rawModelGroup.add(mesh);
|
||||
state.modelMeshes.push({ mesh, file, color: cssColor(index), index });
|
||||
}
|
||||
const bbox = new THREE.Box3().setFromObject(state.rawModelGroup);
|
||||
const center = new THREE.Vector3();
|
||||
bbox.getCenter(center);
|
||||
state.rawModelGroup.position.set(-center.x, -center.y, -center.z);
|
||||
applyPose();
|
||||
scheduleMappingDraw();
|
||||
}
|
||||
|
||||
function applyPose(poseInput = state.pose) {
|
||||
@@ -835,7 +1244,13 @@ 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 * (pose.flipX ? -1 : 1), scale * (pose.flipY ? -1 : 1), scale * (pose.flipZ ? -1 : 1));
|
||||
state.modelGroup.scale.set(
|
||||
scale * Math.max(0.001, Number(pose.scaleX) || 1) * (pose.flipX ? -1 : 1),
|
||||
scale * Math.max(0.001, Number(pose.scaleY) || 1) * (pose.flipY ? -1 : 1),
|
||||
scale * Math.max(0.001, Number(pose.scaleZ) || 1) * (pose.flipZ ? -1 : 1),
|
||||
);
|
||||
state.modelGroup.updateMatrixWorld(true);
|
||||
scheduleMappingDraw();
|
||||
}
|
||||
|
||||
function fitCamera() {
|
||||
@@ -857,7 +1272,7 @@ function resetPose(kind) {
|
||||
if (kind === "rotation") {
|
||||
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
|
||||
} else if (kind === "transform") {
|
||||
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 };
|
||||
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 {
|
||||
@@ -868,6 +1283,29 @@ function resetPose(kind) {
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function stretchAxis(axis) {
|
||||
if (!state.rawModelGroup?.children.length || !state.dicomSceneBox) {
|
||||
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||||
return;
|
||||
}
|
||||
const axisKey = { x: "scaleX", y: "scaleY", z: "scaleZ" }[axis];
|
||||
if (!axisKey) return;
|
||||
const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
|
||||
const dicomSize = new THREE.Vector3();
|
||||
const modelSize = new THREE.Vector3();
|
||||
state.dicomSceneBox.getSize(dicomSize);
|
||||
modelBox.getSize(modelSize);
|
||||
const currentAxisScale = Math.max(0.001, Number(state.pose[axisKey]) || 1);
|
||||
const targetSize = Math.max(0.001, dicomSize[axis]);
|
||||
const currentSize = Math.max(0.001, modelSize[axis]);
|
||||
const next = Math.max(0.2, Math.min(4, currentAxisScale * (targetSize / currentSize)));
|
||||
state.pose[axisKey] = Number(next.toFixed(3));
|
||||
applyPose();
|
||||
markDirty("pose");
|
||||
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
|
||||
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`;
|
||||
}
|
||||
|
||||
function suggestBoneSelection() {
|
||||
const boneKeys = ["rib", "vertebra", "sternum", "bone", "spine", "肋", "椎", "骨", "胸骨"];
|
||||
const next = new Set();
|
||||
@@ -1088,11 +1526,58 @@ function wireEvents() {
|
||||
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
|
||||
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
|
||||
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
|
||||
$("stretchXBtn").addEventListener("click", () => stretchAxis("x"));
|
||||
$("stretchYBtn").addEventListener("click", () => stretchAxis("y"));
|
||||
$("stretchZBtn").addEventListener("click", () => stretchAxis("z"));
|
||||
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
|
||||
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
|
||||
$("autoFineBtn").addEventListener("click", runAutoFine);
|
||||
$("dicomPreview").addEventListener("load", () => $("dicomLoading").classList.add("hidden"));
|
||||
$("dicomPreview").addEventListener("error", () => $("dicomLoading").classList.add("hidden"));
|
||||
$("dicomPreview").addEventListener("load", () => {
|
||||
$("dicomLoading").classList.add("hidden");
|
||||
scheduleMappingDraw();
|
||||
});
|
||||
$("dicomPreview").addEventListener("error", () => {
|
||||
$("dicomLoading").classList.add("hidden");
|
||||
resetMappingCanvas("DICOM Base Layer 加载失败");
|
||||
});
|
||||
$("mappingResetBtn").addEventListener("click", () => {
|
||||
state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||
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";
|
||||
@@ -1141,6 +1626,15 @@ function wireEvents() {
|
||||
updateDicomPreview();
|
||||
});
|
||||
$("sliceSlider").addEventListener("change", () => loadFusion());
|
||||
$("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();
|
||||
});
|
||||
$("mappingSliceSlider").addEventListener("change", () => loadFusion());
|
||||
}
|
||||
|
||||
wireEvents();
|
||||
|
||||
@@ -36,9 +36,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button id="caseToggleBtn" class="icon-btn" type="button" title="收起/展开完整关联 CT">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<button id="relationBtn" class="ghost-btn accent" type="button">数据库关联可视化</button>
|
||||
<button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button>
|
||||
<span id="dbStatus" class="status-pill">数据库</span>
|
||||
@@ -52,9 +49,14 @@
|
||||
<main class="workspace">
|
||||
<aside class="case-panel panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>完整关联 CT</h2>
|
||||
<p>DICOM + STL</p>
|
||||
<div class="panel-title-row">
|
||||
<div>
|
||||
<h2>完整关联 CT</h2>
|
||||
<p>DICOM + STL</p>
|
||||
</div>
|
||||
<button id="caseToggleBtn" class="icon-btn" type="button" title="收起/展开完整关联 CT">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
<span id="caseCount">0</span>
|
||||
</div>
|
||||
@@ -175,6 +177,9 @@
|
||||
<button data-window="contrast">高对比</button>
|
||||
</div>
|
||||
<div class="viewer-tools">
|
||||
<button id="stretchXBtn" class="ghost-btn">X拉伸</button>
|
||||
<button id="stretchYBtn" class="ghost-btn">Y拉伸</button>
|
||||
<button id="stretchZBtn" class="ghost-btn">Z拉伸</button>
|
||||
<button id="fitBtn" class="ghost-btn">视角复位</button>
|
||||
<button id="saveBtn" class="primary-btn">保存配准</button>
|
||||
<button id="statusBtn" class="state-btn">标为已配准</button>
|
||||
@@ -198,24 +203,41 @@
|
||||
<section class="viewer-panel dicom-panel panel">
|
||||
<div class="viewer-head">
|
||||
<div>
|
||||
<h2>DICOM 标注</h2>
|
||||
<p id="dicomPanelMeta">当前序列切片</p>
|
||||
<h2>逆向分割映射视图</h2>
|
||||
<p id="dicomPanelMeta">Base DICOM · Overlay Label Map</p>
|
||||
</div>
|
||||
<div class="viewer-tools">
|
||||
<button id="mappingResetBtn" class="ghost-btn" type="button">位置重置</button>
|
||||
<button id="openViewerBtn" class="ghost-btn" type="button">阅片系统</button>
|
||||
</div>
|
||||
<button id="openViewerBtn" class="ghost-btn" type="button">阅片系统</button>
|
||||
</div>
|
||||
<div class="dicom-stage">
|
||||
<img id="dicomPreview" alt="DICOM 切片" />
|
||||
<div class="crosshair horizontal"></div>
|
||||
<div class="crosshair vertical"></div>
|
||||
<div id="mappingViewport" class="mapping-viewport">
|
||||
<div id="mappingLayer" class="mapping-layer">
|
||||
<img id="dicomPreview" alt="DICOM 切片" />
|
||||
<canvas id="mappingCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mapping-slice-rail">
|
||||
<input id="mappingSliceSlider" type="range" min="0" max="0" value="0" />
|
||||
<span id="mappingSliceLabel">0 / 0</span>
|
||||
</div>
|
||||
<div class="dicom-overlay top-left" id="dicomInfoLeft">未选择序列</div>
|
||||
<div class="dicom-overlay top-right" id="dicomInfoRight"></div>
|
||||
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
|
||||
<div id="dicomLoading" class="fusion-loading hidden">
|
||||
<span>正在加载 DICOM 标注视图</span>
|
||||
<span>正在加载逆向分割映射视图</span>
|
||||
<div><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="annotationTags" class="annotation-tags"></div>
|
||||
<div class="mapping-summary">
|
||||
<div class="mapping-summary-head">
|
||||
<span>Overlay Label Map</span>
|
||||
<em id="mappingStats">0/0 构件 · 0 边 · 0 px</em>
|
||||
</div>
|
||||
<div id="annotationTags" class="annotation-tags"></div>
|
||||
<div id="mappingLegend" class="mapping-legend"></div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -115,13 +115,24 @@ button {
|
||||
}
|
||||
|
||||
.workspace.case-collapsed {
|
||||
grid-template-columns: 0 minmax(0, 1fr);
|
||||
grid-template-columns: 54px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workspace.case-collapsed .case-panel {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(-18px);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.workspace.case-collapsed .case-panel > :not(.panel-head),
|
||||
.workspace.case-collapsed .panel-head .panel-title-row > div,
|
||||
.workspace.case-collapsed .panel-head #caseCount {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace.case-collapsed .case-panel .panel-head {
|
||||
justify-content: center;
|
||||
padding: 9px 7px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
@@ -174,6 +185,19 @@ button {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.panel-title-row {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel-title-row .icon-btn {
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.panel-head.compact {
|
||||
min-height: 42px;
|
||||
padding: 10px 12px;
|
||||
@@ -338,6 +362,24 @@ button {
|
||||
background: linear-gradient(180deg, rgba(20, 45, 88, 0.96), rgba(10, 18, 31, 0.96));
|
||||
}
|
||||
|
||||
.series-card.skipped {
|
||||
opacity: 0.54;
|
||||
filter: grayscale(0.95);
|
||||
background: rgba(8, 12, 18, 0.7);
|
||||
}
|
||||
|
||||
.series-card.skipped.active {
|
||||
opacity: 0.78;
|
||||
border-color: rgba(148, 163, 184, 0.5);
|
||||
background: linear-gradient(180deg, rgba(34, 41, 53, 0.86), rgba(12, 17, 24, 0.9));
|
||||
}
|
||||
|
||||
.series-card.skipped .mini-tags i {
|
||||
border-color: rgba(148, 163, 184, 0.48);
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.case-card strong,
|
||||
.series-card strong {
|
||||
display: block;
|
||||
@@ -617,6 +659,7 @@ button {
|
||||
.fusion-panel .viewer-head {
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.viewer-head h2 {
|
||||
@@ -855,7 +898,29 @@ button {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.dicom-stage img {
|
||||
.mapping-viewport {
|
||||
position: absolute;
|
||||
inset: 0 54px 0 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.mapping-viewport.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mapping-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.dicom-stage img,
|
||||
.dicom-stage canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -864,6 +929,91 @@ button {
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.dicom-stage canvas {
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mapping-slice-rail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 6;
|
||||
width: 54px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
border-left: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.mapping-slice-rail::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 26px;
|
||||
bottom: 56px;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
#mappingSliceSlider {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(66vh, 520px);
|
||||
height: 34px;
|
||||
transform: rotate(-90deg);
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#mappingSliceSlider::-webkit-slider-runnable-track {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
#mappingSliceSlider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
margin-top: -8.5px;
|
||||
border: 3px solid #93c5fd;
|
||||
border-radius: 7px;
|
||||
background: #60a5fa;
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
#mappingSliceSlider::-moz-range-track {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
#mappingSliceSlider::-moz-range-thumb {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 3px solid #93c5fd;
|
||||
border-radius: 7px;
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
#mappingSliceLabel {
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
bottom: 14px;
|
||||
color: #c8f7ff;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.crosshair {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
@@ -916,13 +1066,12 @@ button {
|
||||
}
|
||||
|
||||
.annotation-tags {
|
||||
min-height: 48px;
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 12px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.annotation-tags span {
|
||||
@@ -936,6 +1085,78 @@ button {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.mapping-summary {
|
||||
min-height: 122px;
|
||||
max-height: 170px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--line);
|
||||
background: #070c14;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.mapping-summary-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: #dbeafe;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.mapping-summary-head em {
|
||||
flex: 0 0 auto;
|
||||
color: #c8f7ff;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.mapping-legend {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mapping-legend .legend-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: #b6c8dd;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.mapping-legend .legend-color {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mapping-legend .legend-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mapping-legend .legend-meta {
|
||||
color: #9debf4;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.slice-row span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
|
||||
Reference in New Issue
Block a user