diff --git a/DICOM_and_UPP配准/app.py b/DICOM_and_UPP配准/app.py index c29565e..6a4150f 100644 --- a/DICOM_and_UPP配准/app.py +++ b/DICOM_and_UPP配准/app.py @@ -799,7 +799,9 @@ def dicom_fusion_volume( series_uid: str, center_index: int = 0, window: str = "soft", - radius: int = Query(default=24, ge=4, le=48), + radius: int = Query(default=96, ge=4, le=512), + range_start: int | None = None, + range_end: int | None = None, _: str = Depends(require_auth), ) -> dict[str, Any]: stack_data = load_stack_data(ct_number, series_uid) @@ -807,10 +809,17 @@ def dicom_fusion_volume( datasets = stack_data["datasets"] total = int(stack.shape[0]) center_index = min(max(0, center_index), total - 1) - start = max(0, center_index - radius) - end = min(total - 1, center_index + radius) - if end - start + 1 > 48: - step = int(np.ceil((end - start + 1) / 48)) + if range_start is not None and range_end is not None: + start = min(max(0, int(range_start)), total - 1) + end = min(max(0, int(range_end)), total - 1) + if start > end: + start, end = end, start + else: + start = max(0, center_index - radius) + end = min(total - 1, center_index + radius) + max_frames = 72 + if end - start + 1 > max_frames: + step = int(np.ceil((end - start + 1) / max_frames)) indices = list(range(start, end + 1, step)) else: indices = list(range(start, end + 1)) diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js index c3927fc..9bddb0b 100644 --- a/DICOM_and_UPP配准/static/app.js +++ b/DICOM_and_UPP配准/static/app.js @@ -61,6 +61,8 @@ const state = { windowMode: "default", fusionMode: "fusion", sliceIndex: 0, + sliceRangeStart: 0, + sliceRangeEnd: 0, dicomPreviewTimer: 0, autoResult: null, dirty: false, @@ -78,9 +80,10 @@ const state = { modelGroup: null, rawModelGroup: null, modelMeshes: [], + stlSignature: "", baseModelScale: 1, dicomSceneBox: null, - mappingViewport: { scale: 1, offsetX: 0, offsetY: 0 }, + mappingViewport: { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 }, mappingDrag: null, mappingDrawFrame: 0, resizeObserver: null, @@ -103,9 +106,17 @@ function setTopLoading(visible) { $("topLoading").classList.toggle("hidden", !visible); } -function setFusionLoading(visible, text = "正在构建融合视图") { +function setFusionLoading(visible, text = "正在构建融合视图", progress = null) { $("fusionLoading").classList.toggle("hidden", !visible); $("fusionLoading").querySelector("span").textContent = text; + const fill = $("fusionProgressFill"); + const label = $("fusionProgressText"); + if (fill && label) { + const value = progress == null ? 0 : Math.max(0, Math.min(100, Number(progress) || 0)); + fill.style.width = progress == null ? "34%" : `${value}%`; + fill.classList.toggle("indeterminate", progress == null); + label.textContent = progress == null ? "" : `${Math.round(value)}%`; + } } function setSaveState(text, tone = "") { @@ -296,7 +307,30 @@ function isUpperAbdomenSeries(item) { } function isPortalPhaseSeries(item) { - return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期"))); + return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期") || label.includes("门静脉"))); +} + +function isLiverBiliaryModel(model = state.algorithmModel) { + const value = String(model || "").toLowerCase(); + return value.includes("肝胆") || value.includes("liver") || value.includes("bile"); +} + +function sortedSeriesForDisplay(series) { + return [...series].sort((a, b) => { + const skippedDiff = Number(isSkippedSeries(a)) - Number(isSkippedSeries(b)); + if (skippedDiff) return skippedDiff; + const timeDiff = String(a.first_time || a.series_time || "").localeCompare(String(b.first_time || b.series_time || "")); + if (timeDiff) return timeDiff; + return Number(a.series_number || 999999) - Number(b.series_number || 999999); + }); +} + +function stlSelectionSignature() { + return [ + normalize(state.activeCase?.ct_number), + state.algorithmModel || "", + [...state.selectedStlIds].map(Number).sort((a, b) => a - b).join(","), + ].join("|"); } function bodyPartTags(row) { @@ -375,7 +409,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") { const previousPose = { ...state.pose }; if (!switchingModelOnly && !(await confirmChange())) return; setTopLoading(true); - setFusionLoading(true, "正在读取 CT 关联数据"); + setFusionLoading(true, "正在读取 CT 关联数据", 6); try { const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm }); const [detail, seriesPayload, stlPayload, registration] = await Promise.all([ @@ -398,17 +432,22 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") { 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.selectedSeriesUid = registration.series_instance_uid || pickDefaultSeries(state.series)?.series_uid || ""; + const defaultSeries = pickDefaultSeries(state.series); + const savedSeries = state.series.find((item) => item.series_uid === registration.series_instance_uid); + const shouldUseSavedSeries = registration.registration_status === "registered" && savedSeries; + state.selectedSeriesUid = shouldUseSavedSeries ? savedSeries.series_uid : defaultSeries?.series_uid || ""; const selected = currentSeries(); state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0; + state.sliceRangeStart = 0; + state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0; + state.stlSignature = ""; renderCases(); renderActiveHeader(); renderSeries(); renderStl(); renderPoseControls(); - if (keepPreviousPose) markDirty("model"); - else resetDirty(); + resetDirty(); await loadFusion(); } catch (error) { $("fusionStatus").textContent = error.message; @@ -423,10 +462,9 @@ 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 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 (isLiverBiliaryModel()) { if (isPortalPhaseSeries(item)) score += 900; else if (isUpperAbdomenSeries(item)) score += 720; } else if (isUpperAbdomenSeries(item)) { @@ -486,7 +524,7 @@ function renderSeries() { const activeSeries = currentSeries(); $("matchedSeries").innerHTML = activeSeries ? seriesCardHtml(activeSeries, true) : `
尚未选择配准序列
`; $("seriesList").innerHTML = state.series.length - ? state.series + ? sortedSeriesForDisplay(state.series) .map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid)) .join("") : `
未读取到 DICOM 序列
`; @@ -520,7 +558,9 @@ async function selectSeries(uid) { state.selectedSeriesUid = uid; const selected = currentSeries(); state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0; - markDirty(); + state.sliceRangeStart = 0; + state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0; + setSaveState("序列未保存", "warn"); renderSeries(); renderDicomAnnotation(); await loadFusion(); @@ -552,7 +592,7 @@ function renderStl() { const id = Number(input.dataset.stl); if (input.checked) state.selectedStlIds.add(id); else state.selectedStlIds.delete(id); - markDirty(); + setSaveState("模型未保存", "warn"); renderStl(); await loadFusion(); }); @@ -642,9 +682,19 @@ function updateSliceControl() { 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]; $("sliceSlider").max = max; $("sliceSlider").value = state.sliceIndex; $("sliceLabel").textContent = count ? `切片 ${state.sliceIndex + 1} / ${count}` : "切片 0 / 0"; + $("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"; @@ -795,7 +845,7 @@ async function loadFusion() { clearFusion(); return; } - setFusionLoading(true, "正在按需加载 DICOM 切片与 STL"); + setFusionLoading(true, "正在读取 DICOM 切片范围", 8); updateSliceControl(); try { const params = new URLSearchParams({ @@ -803,14 +853,17 @@ async function loadFusion() { series_uid: selected.series_uid, center_index: String(state.sliceIndex), window: state.windowMode, - radius: "24", + 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); if (version !== state.fusionVersion) return; - await buildStlModels(volume); + setFusionLoading(true, "正在加载 STL 模型", 62); + await buildStlModels(volume, (progress) => setFusionLoading(true, "正在加载 STL 模型", 62 + progress * 0.3)); if (version !== state.fusionVersion) return; if (state.sceneReady) { applyFusionMode(); @@ -819,8 +872,9 @@ async function loadFusion() { $("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} 个`; + $("fusionMeta").textContent = `DICOM ${volume.start + 1}-${volume.end + 1}/${volume.total} · STL ${state.selectedStlIds.size} 个`; renderDicomAnnotation(); + setFusionLoading(true, "正在渲染右侧分割映射", 96); updateDicomPreview(); } catch (error) { clearFusion(); @@ -888,8 +942,8 @@ function scheduleMappingDraw() { 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})`; + 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) { @@ -983,22 +1037,57 @@ function fillSegmentsAsSolidMask(context, width, height, segments, color, opacit } }); + const hullPixels = fillConvexHullMask(context, width, height, segments, color, opacity); maskContext.putImageData(imageData, 0, 0); + context.globalAlpha = 0.45; 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(); + context.globalAlpha = 1; + return filled + hullPixels; +} + +function fillConvexHullMask(context, width, height, segments, color, opacity = 0.72) { + const points = []; segments.forEach((segment) => { - context.moveTo(segment.a.x, segment.a.y); - context.lineTo(segment.b.x, segment.b.y); + [segment.a, segment.b].forEach((point) => { + if (Number.isFinite(point.x) && Number.isFinite(point.y) && point.x > -width && point.x < width * 2 && point.y > -height && point.y < height * 2) { + points.push({ x: point.x, y: point.y }); + } + }); }); + if (points.length < 3) return 0; + const sorted = points + .filter((point, index) => points.findIndex((item) => Math.abs(item.x - point.x) < 0.5 && Math.abs(item.y - point.y) < 0.5) === index) + .sort((a, b) => (Math.abs(a.x - b.x) > 0.001 ? a.x - b.x : a.y - b.y)); + if (sorted.length < 3) return 0; + const cross = (origin, a, b) => (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x); + const lower = []; + sorted.forEach((point) => { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) lower.pop(); + lower.push(point); + }); + const upper = []; + [...sorted].reverse().forEach((point) => { + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) upper.pop(); + upper.push(point); + }); + const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)]; + if (hull.length < 3) return 0; + context.save(); + context.globalAlpha = Math.max(0.18, Math.min(opacity, 1)) * 0.58; + context.fillStyle = color; + context.strokeStyle = color; + context.lineWidth = Math.max(1.2, Math.max(width, height) * 0.002); + context.beginPath(); + hull.forEach((point, index) => { + if (index === 0) context.moveTo(point.x, point.y); + else context.lineTo(point.x, point.y); + }); + context.closePath(); + context.fill(); + context.globalAlpha = Math.max(0.22, Math.min(opacity, 1)); context.stroke(); context.restore(); - return filled; + return Math.max(1, Math.round(hull.length * 6)); } function drawMappingView() { @@ -1190,9 +1279,17 @@ async function buildDicomVolume(volume) { state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup); } -async function buildStlModels(volume) { +async function buildStlModels(volume, onProgress = null) { + const signature = stlSelectionSignature(); + if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) { + applyPose(); + scheduleMappingDraw(); + onProgress?.(100); + return; + } clearGroup(state.rawModelGroup); state.modelMeshes = []; + state.stlSignature = signature; const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id))); if (!files.length) { applyPose(); @@ -1201,6 +1298,7 @@ async function buildStlModels(volume) { } const loader = new STLLoader(); 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), @@ -1225,6 +1323,7 @@ async function buildStlModels(volume) { mesh.userData.color = cssColor(index); state.rawModelGroup.add(mesh); state.modelMeshes.push({ mesh, file, color: cssColor(index), index }); + onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100); } const bbox = new THREE.Box3().setFromObject(state.rawModelGroup); const center = new THREE.Vector3(); @@ -1232,6 +1331,7 @@ async function buildStlModels(volume) { state.rawModelGroup.position.set(-center.x, -center.y, -center.z); applyPose(); scheduleMappingDraw(); + onProgress?.(100); } function applyPose(poseInput = state.pose) { @@ -1499,6 +1599,17 @@ function openLinkedApp(kind) { } } +function setWindowMode(mode, reload = true) { + state.windowMode = mode || "default"; + document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item.dataset.window === state.windowMode)); + document.querySelectorAll("[data-map-window]").forEach((item) => item.classList.toggle("active", item.dataset.mapWindow === state.windowMode)); + if (reload) loadFusion(); +} + +function setAutoModalVisible(visible) { + $("autoModal").classList.toggle("hidden", !visible); +} + function wireEvents() { $("loginForm").addEventListener("submit", login); $("logoutBtn").addEventListener("click", () => logout()); @@ -1529,6 +1640,11 @@ function wireEvents() { $("stretchXBtn").addEventListener("click", () => stretchAxis("x")); $("stretchYBtn").addEventListener("click", () => stretchAxis("y")); $("stretchZBtn").addEventListener("click", () => stretchAxis("z")); + $("openAutoModalBtn").addEventListener("click", () => setAutoModalVisible(true)); + $("closeAutoModalBtn").addEventListener("click", () => setAutoModalVisible(false)); + $("autoModal").addEventListener("click", (event) => { + if (event.target === $("autoModal")) setAutoModalVisible(false); + }); $("suggestBoneBtn").addEventListener("click", suggestBoneSelection); $("autoCoarseBtn").addEventListener("click", runAutoCoarse); $("autoFineBtn").addEventListener("click", runAutoFine); @@ -1541,7 +1657,15 @@ function wireEvents() { resetMappingCanvas("DICOM Base Layer 加载失败"); }); $("mappingResetBtn").addEventListener("click", () => { - state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0 }; + 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) => { @@ -1605,11 +1729,10 @@ function wireEvents() { }); }); document.querySelectorAll("[data-window]").forEach((button) => { - button.addEventListener("click", async () => { - state.windowMode = button.dataset.window || "default"; - document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item === button)); - await loadFusion(); - }); + 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-fusion-mode]").forEach((button) => { button.addEventListener("click", () => { @@ -1625,7 +1748,6 @@ function wireEvents() { renderDicomAnnotation(); updateDicomPreview(); }); - $("sliceSlider").addEventListener("change", () => loadFusion()); $("mappingSliceSlider").addEventListener("input", () => { const selected = currentSeries(); const max = Math.max(0, Number(selected?.count || 0) - 1); @@ -1634,7 +1756,15 @@ function wireEvents() { renderDicomAnnotation(); updateDicomPreview(); }); - $("mappingSliceSlider").addEventListener("change", () => loadFusion()); + 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(); diff --git a/DICOM_and_UPP配准/static/index.html b/DICOM_and_UPP配准/static/index.html index c76536e..b2695b9 100644 --- a/DICOM_and_UPP配准/static/index.html +++ b/DICOM_and_UPP配准/static/index.html @@ -91,7 +91,7 @@
- +
@@ -146,18 +146,8 @@ 位姿自动调整 未运行 -
-
- - - - -
-
- - - -
+
+
选择 DICOM 序列和 STL 后可运行。
@@ -191,12 +181,28 @@
DICOM - · STL -
-
- 切片 0 / 0 - +
+
+ DICOM 切片范围 + 0 - 0 / 0 +
+
+ + +
+
+ 起点 0 + 范围 + 终点 0 +
+
+ 切片 0 / 0 + +
@@ -207,6 +213,14 @@

Base DICOM · Overlay Label Map

+
+ + + + +
+ +
@@ -243,6 +257,29 @@ + + diff --git a/DICOM_and_UPP配准/static/styles.css b/DICOM_and_UPP配准/static/styles.css index 9096c2e..3c6cca0 100644 --- a/DICOM_and_UPP配准/static/styles.css +++ b/DICOM_and_UPP配准/static/styles.css @@ -662,6 +662,12 @@ button { overflow-x: auto; } +.dicom-panel .viewer-head { + align-content: center; + flex-wrap: wrap; + overflow-x: auto; +} + .viewer-head h2 { margin: 0; font-size: 16px; @@ -696,6 +702,11 @@ button { color: white; } +.mini-segmented button { + min-width: 56px; + min-height: 30px; +} + .ghost-btn, .primary-btn, .state-btn { @@ -855,12 +866,36 @@ button { .top-loading span { display: block; height: 100%; - width: 34%; border-radius: 999px; background: linear-gradient(90deg, transparent, var(--cyan), var(--blue), transparent); +} + +.fusion-loading i { + width: 0; + background: linear-gradient(90deg, var(--cyan), var(--blue)); + transition: width 0.18s ease; +} + +.fusion-loading i.indeterminate { + width: 34%; animation: sweep 1.1s ease-in-out infinite; } +.top-loading span { + width: 34%; + animation: sweep 1.1s ease-in-out infinite; +} + +.fusion-loading em { + display: block; + margin-top: 6px; + color: #c8f7ff; + font-size: 11px; + font-style: normal; + font-weight: 900; + text-align: right; +} + .top-loading { position: fixed; top: 66px; @@ -881,16 +916,80 @@ button { } } -.slice-row { - height: 48px; +.slice-range-panel { + min-height: 104px; display: grid; - grid-template-columns: 98px minmax(0, 1fr); - gap: 12px; - align-items: center; - padding: 0 14px; + grid-template-rows: auto 26px auto 24px; + gap: 8px; + padding: 10px 16px; border-top: 1px solid var(--line); } +.slice-range-head, +.slice-range-foot, +.slice-current-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.slice-range-head strong { + color: #dbeafe; + font-size: 13px; + font-weight: 950; +} + +.slice-range-head span, +.slice-range-foot span, +.slice-current-row span { + color: var(--muted); + font-size: 12px; + font-weight: 900; +} + +.slice-range-head span { + color: #8fb5ff; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.dual-range { + position: relative; + height: 26px; +} + +.dual-range::before { + content: ""; + position: absolute; + top: 11px; + left: 0; + right: 0; + height: 8px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.22); +} + +.dual-range input { + position: absolute; + inset: 0; + width: 100%; + pointer-events: none; + background: transparent; +} + +.dual-range input::-webkit-slider-thumb { + pointer-events: auto; +} + +.dual-range input::-moz-range-thumb { + pointer-events: auto; +} + +.slice-current-row { + display: grid; + grid-template-columns: 104px minmax(0, 1fr); +} + .dicom-stage { position: relative; min-height: 0; @@ -1197,7 +1296,7 @@ input[type="range"] { .pose-control { display: grid; - grid-template-columns: 56px 26px minmax(0, 1fr) 26px 64px; + grid-template-columns: 62px 28px minmax(96px, 1fr) 28px minmax(82px, 96px); gap: 6px; align-items: center; } @@ -1210,13 +1309,15 @@ input[type="range"] { .pose-control input[type="number"] { width: 100%; - height: 30px; + height: 36px; border: 1px solid var(--line); border-radius: 8px; background: rgba(6, 10, 15, 0.86); color: var(--text); - text-align: right; - padding: 0 7px; + font-size: 16px; + font-weight: 900; + text-align: center; + padding: 0 6px; } .pose-control button, @@ -1281,6 +1382,16 @@ input[type="range"] { padding: 10px; } +.compact-auto { + display: grid; + gap: 8px; +} + +.wide-action { + width: 100%; + min-height: 34px; +} + .auto-options { display: grid; grid-template-columns: repeat(4, 1fr); @@ -1320,6 +1431,53 @@ input[type="range"] { text-align: center; } +.modal-shell { + position: fixed; + inset: 0; + z-index: 90; + display: flex; + align-items: center; + justify-content: center; + background: rgba(2, 6, 12, 0.72); + backdrop-filter: blur(8px); +} + +.modal-panel { + width: min(560px, calc(100vw - 36px)); + border: 1px solid var(--line); + border-radius: 16px; + background: linear-gradient(180deg, rgba(18, 28, 42, 0.98), rgba(8, 13, 20, 0.98)); + box-shadow: var(--shadow); + padding: 16px; +} + +.modal-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.modal-head h2 { + margin: 0; + font-size: 18px; +} + +.modal-head p { + margin: 5px 0 0; + color: var(--muted); + font-size: 12px; +} + +.modal-options { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.modal-actions { + margin: 12px 0 0; +} + .login-overlay { position: fixed; inset: 0;