Refine registration series defaults and mapping controls
This commit is contained in:
@@ -799,7 +799,9 @@ def dicom_fusion_volume(
|
|||||||
series_uid: str,
|
series_uid: str,
|
||||||
center_index: int = 0,
|
center_index: int = 0,
|
||||||
window: str = "soft",
|
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),
|
_: str = Depends(require_auth),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
stack_data = load_stack_data(ct_number, series_uid)
|
stack_data = load_stack_data(ct_number, series_uid)
|
||||||
@@ -807,10 +809,17 @@ def dicom_fusion_volume(
|
|||||||
datasets = stack_data["datasets"]
|
datasets = stack_data["datasets"]
|
||||||
total = int(stack.shape[0])
|
total = int(stack.shape[0])
|
||||||
center_index = min(max(0, center_index), total - 1)
|
center_index = min(max(0, center_index), total - 1)
|
||||||
|
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)
|
start = max(0, center_index - radius)
|
||||||
end = min(total - 1, center_index + radius)
|
end = min(total - 1, center_index + radius)
|
||||||
if end - start + 1 > 48:
|
max_frames = 72
|
||||||
step = int(np.ceil((end - start + 1) / 48))
|
if end - start + 1 > max_frames:
|
||||||
|
step = int(np.ceil((end - start + 1) / max_frames))
|
||||||
indices = list(range(start, end + 1, step))
|
indices = list(range(start, end + 1, step))
|
||||||
else:
|
else:
|
||||||
indices = list(range(start, end + 1))
|
indices = list(range(start, end + 1))
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const state = {
|
|||||||
windowMode: "default",
|
windowMode: "default",
|
||||||
fusionMode: "fusion",
|
fusionMode: "fusion",
|
||||||
sliceIndex: 0,
|
sliceIndex: 0,
|
||||||
|
sliceRangeStart: 0,
|
||||||
|
sliceRangeEnd: 0,
|
||||||
dicomPreviewTimer: 0,
|
dicomPreviewTimer: 0,
|
||||||
autoResult: null,
|
autoResult: null,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
@@ -78,9 +80,10 @@ const state = {
|
|||||||
modelGroup: null,
|
modelGroup: null,
|
||||||
rawModelGroup: null,
|
rawModelGroup: null,
|
||||||
modelMeshes: [],
|
modelMeshes: [],
|
||||||
|
stlSignature: "",
|
||||||
baseModelScale: 1,
|
baseModelScale: 1,
|
||||||
dicomSceneBox: null,
|
dicomSceneBox: null,
|
||||||
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0 },
|
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0, rotation: 0 },
|
||||||
mappingDrag: null,
|
mappingDrag: null,
|
||||||
mappingDrawFrame: 0,
|
mappingDrawFrame: 0,
|
||||||
resizeObserver: null,
|
resizeObserver: null,
|
||||||
@@ -103,9 +106,17 @@ function setTopLoading(visible) {
|
|||||||
$("topLoading").classList.toggle("hidden", !visible);
|
$("topLoading").classList.toggle("hidden", !visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFusionLoading(visible, text = "正在构建融合视图") {
|
function setFusionLoading(visible, text = "正在构建融合视图", progress = null) {
|
||||||
$("fusionLoading").classList.toggle("hidden", !visible);
|
$("fusionLoading").classList.toggle("hidden", !visible);
|
||||||
$("fusionLoading").querySelector("span").textContent = text;
|
$("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 = "") {
|
function setSaveState(text, tone = "") {
|
||||||
@@ -296,7 +307,30 @@ function isUpperAbdomenSeries(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isPortalPhaseSeries(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) {
|
function bodyPartTags(row) {
|
||||||
@@ -375,7 +409,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
|||||||
const previousPose = { ...state.pose };
|
const previousPose = { ...state.pose };
|
||||||
if (!switchingModelOnly && !(await confirmChange())) return;
|
if (!switchingModelOnly && !(await confirmChange())) return;
|
||||||
setTopLoading(true);
|
setTopLoading(true);
|
||||||
setFusionLoading(true, "正在读取 CT 关联数据");
|
setFusionLoading(true, "正在读取 CT 关联数据", 6);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
|
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
|
||||||
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
|
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));
|
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.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();
|
const selected = currentSeries();
|
||||||
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
|
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();
|
renderCases();
|
||||||
renderActiveHeader();
|
renderActiveHeader();
|
||||||
renderSeries();
|
renderSeries();
|
||||||
renderStl();
|
renderStl();
|
||||||
renderPoseControls();
|
renderPoseControls();
|
||||||
if (keepPreviousPose) markDirty("model");
|
resetDirty();
|
||||||
else resetDirty();
|
|
||||||
await loadFusion();
|
await loadFusion();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$("fusionStatus").textContent = error.message;
|
$("fusionStatus").textContent = error.message;
|
||||||
@@ -423,10 +462,9 @@ function pickDefaultSeries(series) {
|
|||||||
const candidates = [...series].filter((item) => Number(item.count || 0) > 1);
|
const candidates = [...series].filter((item) => Number(item.count || 0) > 1);
|
||||||
const usable = candidates.filter((item) => !isSkippedSeries(item));
|
const usable = candidates.filter((item) => !isSkippedSeries(item));
|
||||||
const pool = usable.length ? usable : candidates;
|
const pool = usable.length ? usable : candidates;
|
||||||
const algorithm = String(state.algorithmModel || "").toLowerCase();
|
|
||||||
const rank = (item) => {
|
const rank = (item) => {
|
||||||
let score = Number(item.count || 0) / 10000;
|
let score = Number(item.count || 0) / 10000;
|
||||||
if (algorithm.includes("肝胆") || algorithm.includes("liver") || algorithm.includes("bile")) {
|
if (isLiverBiliaryModel()) {
|
||||||
if (isPortalPhaseSeries(item)) score += 900;
|
if (isPortalPhaseSeries(item)) score += 900;
|
||||||
else if (isUpperAbdomenSeries(item)) score += 720;
|
else if (isUpperAbdomenSeries(item)) score += 720;
|
||||||
} else if (isUpperAbdomenSeries(item)) {
|
} else if (isUpperAbdomenSeries(item)) {
|
||||||
@@ -486,7 +524,7 @@ function renderSeries() {
|
|||||||
const activeSeries = currentSeries();
|
const activeSeries = currentSeries();
|
||||||
$("matchedSeries").innerHTML = activeSeries ? seriesCardHtml(activeSeries, true) : `<div class="empty-state">尚未选择配准序列</div>`;
|
$("matchedSeries").innerHTML = activeSeries ? seriesCardHtml(activeSeries, true) : `<div class="empty-state">尚未选择配准序列</div>`;
|
||||||
$("seriesList").innerHTML = state.series.length
|
$("seriesList").innerHTML = state.series.length
|
||||||
? state.series
|
? sortedSeriesForDisplay(state.series)
|
||||||
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
|
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
|
||||||
.join("")
|
.join("")
|
||||||
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
|
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
|
||||||
@@ -520,7 +558,9 @@ async function selectSeries(uid) {
|
|||||||
state.selectedSeriesUid = uid;
|
state.selectedSeriesUid = uid;
|
||||||
const selected = currentSeries();
|
const selected = currentSeries();
|
||||||
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
|
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();
|
renderSeries();
|
||||||
renderDicomAnnotation();
|
renderDicomAnnotation();
|
||||||
await loadFusion();
|
await loadFusion();
|
||||||
@@ -552,7 +592,7 @@ function renderStl() {
|
|||||||
const id = Number(input.dataset.stl);
|
const id = Number(input.dataset.stl);
|
||||||
if (input.checked) state.selectedStlIds.add(id);
|
if (input.checked) state.selectedStlIds.add(id);
|
||||||
else state.selectedStlIds.delete(id);
|
else state.selectedStlIds.delete(id);
|
||||||
markDirty();
|
setSaveState("模型未保存", "warn");
|
||||||
renderStl();
|
renderStl();
|
||||||
await loadFusion();
|
await loadFusion();
|
||||||
});
|
});
|
||||||
@@ -642,9 +682,19 @@ function updateSliceControl() {
|
|||||||
const count = Number(selected?.count || 0);
|
const count = Number(selected?.count || 0);
|
||||||
const max = Math.max(0, count - 1);
|
const max = Math.max(0, count - 1);
|
||||||
state.sliceIndex = Math.max(0, Math.min(state.sliceIndex, max));
|
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").max = max;
|
||||||
$("sliceSlider").value = state.sliceIndex;
|
$("sliceSlider").value = state.sliceIndex;
|
||||||
$("sliceLabel").textContent = count ? `切片 ${state.sliceIndex + 1} / ${count}` : "切片 0 / 0";
|
$("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").max = max;
|
||||||
$("mappingSliceSlider").value = max - state.sliceIndex;
|
$("mappingSliceSlider").value = max - state.sliceIndex;
|
||||||
$("mappingSliceLabel").textContent = count ? `${state.sliceIndex + 1} / ${count}` : "0 / 0";
|
$("mappingSliceLabel").textContent = count ? `${state.sliceIndex + 1} / ${count}` : "0 / 0";
|
||||||
@@ -795,7 +845,7 @@ async function loadFusion() {
|
|||||||
clearFusion();
|
clearFusion();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFusionLoading(true, "正在按需加载 DICOM 切片与 STL");
|
setFusionLoading(true, "正在读取 DICOM 切片范围", 8);
|
||||||
updateSliceControl();
|
updateSliceControl();
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -803,14 +853,17 @@ async function loadFusion() {
|
|||||||
series_uid: selected.series_uid,
|
series_uid: selected.series_uid,
|
||||||
center_index: String(state.sliceIndex),
|
center_index: String(state.sliceIndex),
|
||||||
window: state.windowMode,
|
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()}`);
|
const volume = await api(`/api/dicom/fusion-volume?${params.toString()}`);
|
||||||
if (version !== state.fusionVersion) return;
|
if (version !== state.fusionVersion) return;
|
||||||
state.volumeMeta = volume;
|
state.volumeMeta = volume;
|
||||||
|
setFusionLoading(true, "正在构建 DICOM 切片范围", 38);
|
||||||
await buildDicomVolume(volume);
|
await buildDicomVolume(volume);
|
||||||
if (version !== state.fusionVersion) return;
|
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 (version !== state.fusionVersion) return;
|
||||||
if (state.sceneReady) {
|
if (state.sceneReady) {
|
||||||
applyFusionMode();
|
applyFusionMode();
|
||||||
@@ -819,8 +872,9 @@ async function loadFusion() {
|
|||||||
$("fusionStatus").textContent = state.sceneReady
|
$("fusionStatus").textContent = state.sceneReady
|
||||||
? `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`
|
? `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`
|
||||||
: `${selected.description || "DICOM 序列"} · 2D 映射已就绪,当前浏览器未启用 WebGL`;
|
: `${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();
|
renderDicomAnnotation();
|
||||||
|
setFusionLoading(true, "正在渲染右侧分割映射", 96);
|
||||||
updateDicomPreview();
|
updateDicomPreview();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearFusion();
|
clearFusion();
|
||||||
@@ -888,8 +942,8 @@ function scheduleMappingDraw() {
|
|||||||
function applyMappingTransform() {
|
function applyMappingTransform() {
|
||||||
const layer = $("mappingLayer");
|
const layer = $("mappingLayer");
|
||||||
if (!layer) return;
|
if (!layer) return;
|
||||||
const { scale, offsetX, offsetY } = state.mappingViewport;
|
const { scale, offsetX, offsetY, rotation } = state.mappingViewport;
|
||||||
layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(${scale})`;
|
layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) rotate(${rotation || 0}deg) scale(${scale})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function intersectEdgeWithZ(a, b, targetZ) {
|
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);
|
maskContext.putImageData(imageData, 0, 0);
|
||||||
|
context.globalAlpha = 0.45;
|
||||||
context.drawImage(maskCanvas, 0, 0);
|
context.drawImage(maskCanvas, 0, 0);
|
||||||
context.save();
|
context.globalAlpha = 1;
|
||||||
context.globalAlpha = Math.max(0.18, Math.min(opacity, 1));
|
return filled + hullPixels;
|
||||||
context.strokeStyle = color;
|
}
|
||||||
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
|
|
||||||
context.lineCap = "round";
|
function fillConvexHullMask(context, width, height, segments, color, opacity = 0.72) {
|
||||||
context.lineJoin = "round";
|
const points = [];
|
||||||
context.beginPath();
|
|
||||||
segments.forEach((segment) => {
|
segments.forEach((segment) => {
|
||||||
context.moveTo(segment.a.x, segment.a.y);
|
[segment.a, segment.b].forEach((point) => {
|
||||||
context.lineTo(segment.b.x, segment.b.y);
|
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.stroke();
|
||||||
context.restore();
|
context.restore();
|
||||||
return filled;
|
return Math.max(1, Math.round(hull.length * 6));
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawMappingView() {
|
function drawMappingView() {
|
||||||
@@ -1190,9 +1279,17 @@ async function buildDicomVolume(volume) {
|
|||||||
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
|
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);
|
clearGroup(state.rawModelGroup);
|
||||||
state.modelMeshes = [];
|
state.modelMeshes = [];
|
||||||
|
state.stlSignature = signature;
|
||||||
const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
|
const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
|
||||||
if (!files.length) {
|
if (!files.length) {
|
||||||
applyPose();
|
applyPose();
|
||||||
@@ -1201,6 +1298,7 @@ async function buildStlModels(volume) {
|
|||||||
}
|
}
|
||||||
const loader = new STLLoader();
|
const loader = new STLLoader();
|
||||||
for (const [index, file] of files.entries()) {
|
for (const [index, file] of files.entries()) {
|
||||||
|
onProgress?.((index / Math.max(files.length, 1)) * 100);
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
ct_number: state.activeCase.ct_number,
|
ct_number: state.activeCase.ct_number,
|
||||||
file_id: String(file.id),
|
file_id: String(file.id),
|
||||||
@@ -1225,6 +1323,7 @@ async function buildStlModels(volume) {
|
|||||||
mesh.userData.color = cssColor(index);
|
mesh.userData.color = cssColor(index);
|
||||||
state.rawModelGroup.add(mesh);
|
state.rawModelGroup.add(mesh);
|
||||||
state.modelMeshes.push({ mesh, file, color: cssColor(index), index });
|
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 bbox = new THREE.Box3().setFromObject(state.rawModelGroup);
|
||||||
const center = new THREE.Vector3();
|
const center = new THREE.Vector3();
|
||||||
@@ -1232,6 +1331,7 @@ async function buildStlModels(volume) {
|
|||||||
state.rawModelGroup.position.set(-center.x, -center.y, -center.z);
|
state.rawModelGroup.position.set(-center.x, -center.y, -center.z);
|
||||||
applyPose();
|
applyPose();
|
||||||
scheduleMappingDraw();
|
scheduleMappingDraw();
|
||||||
|
onProgress?.(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPose(poseInput = state.pose) {
|
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() {
|
function wireEvents() {
|
||||||
$("loginForm").addEventListener("submit", login);
|
$("loginForm").addEventListener("submit", login);
|
||||||
$("logoutBtn").addEventListener("click", () => logout());
|
$("logoutBtn").addEventListener("click", () => logout());
|
||||||
@@ -1529,6 +1640,11 @@ function wireEvents() {
|
|||||||
$("stretchXBtn").addEventListener("click", () => stretchAxis("x"));
|
$("stretchXBtn").addEventListener("click", () => stretchAxis("x"));
|
||||||
$("stretchYBtn").addEventListener("click", () => stretchAxis("y"));
|
$("stretchYBtn").addEventListener("click", () => stretchAxis("y"));
|
||||||
$("stretchZBtn").addEventListener("click", () => stretchAxis("z"));
|
$("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);
|
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
|
||||||
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
|
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
|
||||||
$("autoFineBtn").addEventListener("click", runAutoFine);
|
$("autoFineBtn").addEventListener("click", runAutoFine);
|
||||||
@@ -1541,7 +1657,15 @@ function wireEvents() {
|
|||||||
resetMappingCanvas("DICOM Base Layer 加载失败");
|
resetMappingCanvas("DICOM Base Layer 加载失败");
|
||||||
});
|
});
|
||||||
$("mappingResetBtn").addEventListener("click", () => {
|
$("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();
|
applyMappingTransform();
|
||||||
});
|
});
|
||||||
$("mappingViewport").addEventListener("wheel", (event) => {
|
$("mappingViewport").addEventListener("wheel", (event) => {
|
||||||
@@ -1605,11 +1729,10 @@ function wireEvents() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.querySelectorAll("[data-window]").forEach((button) => {
|
document.querySelectorAll("[data-window]").forEach((button) => {
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", () => setWindowMode(button.dataset.window || "default"));
|
||||||
state.windowMode = button.dataset.window || "default";
|
|
||||||
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item === button));
|
|
||||||
await loadFusion();
|
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll("[data-map-window]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => setWindowMode(button.dataset.mapWindow || "default"));
|
||||||
});
|
});
|
||||||
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
|
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
@@ -1625,7 +1748,6 @@ function wireEvents() {
|
|||||||
renderDicomAnnotation();
|
renderDicomAnnotation();
|
||||||
updateDicomPreview();
|
updateDicomPreview();
|
||||||
});
|
});
|
||||||
$("sliceSlider").addEventListener("change", () => loadFusion());
|
|
||||||
$("mappingSliceSlider").addEventListener("input", () => {
|
$("mappingSliceSlider").addEventListener("input", () => {
|
||||||
const selected = currentSeries();
|
const selected = currentSeries();
|
||||||
const max = Math.max(0, Number(selected?.count || 0) - 1);
|
const max = Math.max(0, Number(selected?.count || 0) - 1);
|
||||||
@@ -1634,7 +1756,15 @@ function wireEvents() {
|
|||||||
renderDicomAnnotation();
|
renderDicomAnnotation();
|
||||||
updateDicomPreview();
|
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();
|
wireEvents();
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
<div class="tool-tabs">
|
<div class="tool-tabs">
|
||||||
<button class="active" data-tool-tab="series">序列</button>
|
<button class="active" data-tool-tab="series">序列</button>
|
||||||
<button data-tool-tab="models">组织分割</button>
|
<button data-tool-tab="models">组织分割</button>
|
||||||
<button data-tool-tab="pose">标记</button>
|
<button data-tool-tab="pose">位姿</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="tool-pane active" data-tool-pane="series">
|
<section class="tool-pane active" data-tool-pane="series">
|
||||||
@@ -146,18 +146,8 @@
|
|||||||
<span>位姿自动调整</span>
|
<span>位姿自动调整</span>
|
||||||
<em id="autoState">未运行</em>
|
<em id="autoState">未运行</em>
|
||||||
</div>
|
</div>
|
||||||
<div class="auto-box">
|
<div class="auto-box compact-auto">
|
||||||
<div class="auto-options">
|
<button id="openAutoModalBtn" class="wide-action" type="button">打开自动调整区域</button>
|
||||||
<label><input id="autoX" type="checkbox" checked /> X</label>
|
|
||||||
<label><input id="autoY" type="checkbox" checked /> Y</label>
|
|
||||||
<label><input id="autoZ" type="checkbox" checked /> Z</label>
|
|
||||||
<label><input id="autoScale" type="checkbox" checked /> 缩放</label>
|
|
||||||
</div>
|
|
||||||
<div class="pose-actions">
|
|
||||||
<button id="suggestBoneBtn" type="button">建议骨骼模型</button>
|
|
||||||
<button id="autoCoarseBtn" type="button">自动粗配准</button>
|
|
||||||
<button id="autoFineBtn" type="button">自动微调</button>
|
|
||||||
</div>
|
|
||||||
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</div>
|
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
|
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
|
||||||
@@ -191,13 +181,29 @@
|
|||||||
<div class="fusion-hud right" id="fusionMeta">DICOM - · STL -</div>
|
<div class="fusion-hud right" id="fusionMeta">DICOM - · STL -</div>
|
||||||
<div id="fusionLoading" class="fusion-loading hidden">
|
<div id="fusionLoading" class="fusion-loading hidden">
|
||||||
<span>正在构建融合视图</span>
|
<span>正在构建融合视图</span>
|
||||||
<div><i></i></div>
|
<div><i id="fusionProgressFill"></i></div>
|
||||||
|
<em id="fusionProgressText">0%</em>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="slice-row">
|
<div class="slice-range-panel">
|
||||||
|
<div class="slice-range-head">
|
||||||
|
<strong>DICOM 切片范围</strong>
|
||||||
|
<span id="sliceRangeLabel">0 - 0 / 0</span>
|
||||||
|
</div>
|
||||||
|
<div class="dual-range">
|
||||||
|
<input id="sliceRangeStart" type="range" min="0" max="0" value="0" />
|
||||||
|
<input id="sliceRangeEnd" type="range" min="0" max="0" value="0" />
|
||||||
|
</div>
|
||||||
|
<div class="slice-range-foot">
|
||||||
|
<span id="sliceStartLabel">起点 0</span>
|
||||||
|
<span>范围</span>
|
||||||
|
<span id="sliceEndLabel">终点 0</span>
|
||||||
|
</div>
|
||||||
|
<div class="slice-current-row">
|
||||||
<span id="sliceLabel">切片 0 / 0</span>
|
<span id="sliceLabel">切片 0 / 0</span>
|
||||||
<input id="sliceSlider" type="range" min="0" max="0" value="0" />
|
<input id="sliceSlider" type="range" min="0" max="0" value="0" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="viewer-panel dicom-panel panel">
|
<section class="viewer-panel dicom-panel panel">
|
||||||
@@ -207,6 +213,14 @@
|
|||||||
<p id="dicomPanelMeta">Base DICOM · Overlay Label Map</p>
|
<p id="dicomPanelMeta">Base DICOM · Overlay Label Map</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer-tools">
|
<div class="viewer-tools">
|
||||||
|
<div class="segmented mini-segmented">
|
||||||
|
<button data-map-window="default" class="active">默认</button>
|
||||||
|
<button data-map-window="bone">骨窗</button>
|
||||||
|
<button data-map-window="soft">软组织</button>
|
||||||
|
<button data-map-window="contrast">高对比</button>
|
||||||
|
</div>
|
||||||
|
<button id="mappingRotateLeftBtn" class="ghost-btn" type="button">左旋</button>
|
||||||
|
<button id="mappingRotateRightBtn" class="ghost-btn" type="button">右旋</button>
|
||||||
<button id="mappingResetBtn" class="ghost-btn" type="button">位置重置</button>
|
<button id="mappingResetBtn" class="ghost-btn" type="button">位置重置</button>
|
||||||
<button id="openViewerBtn" class="ghost-btn" type="button">阅片系统</button>
|
<button id="openViewerBtn" class="ghost-btn" type="button">阅片系统</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,6 +257,29 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div id="autoModal" class="modal-shell hidden">
|
||||||
|
<div class="modal-panel">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<h2>位姿自动调整</h2>
|
||||||
|
<p>选择参与自动配准的调整方向</p>
|
||||||
|
</div>
|
||||||
|
<button id="closeAutoModalBtn" class="ghost-btn" type="button">关闭</button>
|
||||||
|
</div>
|
||||||
|
<div class="auto-options modal-options">
|
||||||
|
<label><input id="autoX" type="checkbox" checked /> X 方向</label>
|
||||||
|
<label><input id="autoY" type="checkbox" checked /> Y 方向</label>
|
||||||
|
<label><input id="autoZ" type="checkbox" checked /> Z 方向</label>
|
||||||
|
<label><input id="autoScale" type="checkbox" checked /> 缩放</label>
|
||||||
|
</div>
|
||||||
|
<div class="pose-actions modal-actions">
|
||||||
|
<button id="suggestBoneBtn" type="button">建议骨骼模型</button>
|
||||||
|
<button id="autoCoarseBtn" type="button">自动粗配准</button>
|
||||||
|
<button id="autoFineBtn" type="button">自动微调</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/static/app.js"></script>
|
<script type="module" src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -662,6 +662,12 @@ button {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dicom-panel .viewer-head {
|
||||||
|
align-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-head h2 {
|
.viewer-head h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -696,6 +702,11 @@ button {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mini-segmented button {
|
||||||
|
min-width: 56px;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
.ghost-btn,
|
.ghost-btn,
|
||||||
.primary-btn,
|
.primary-btn,
|
||||||
.state-btn {
|
.state-btn {
|
||||||
@@ -855,12 +866,36 @@ button {
|
|||||||
.top-loading span {
|
.top-loading span {
|
||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 34%;
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: linear-gradient(90deg, transparent, var(--cyan), var(--blue), transparent);
|
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;
|
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 {
|
.top-loading {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 66px;
|
top: 66px;
|
||||||
@@ -881,16 +916,80 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.slice-row {
|
.slice-range-panel {
|
||||||
height: 48px;
|
min-height: 104px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 98px minmax(0, 1fr);
|
grid-template-rows: auto 26px auto 24px;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
align-items: center;
|
padding: 10px 16px;
|
||||||
padding: 0 14px;
|
|
||||||
border-top: 1px solid var(--line);
|
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 {
|
.dicom-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -1197,7 +1296,7 @@ input[type="range"] {
|
|||||||
|
|
||||||
.pose-control {
|
.pose-control {
|
||||||
display: grid;
|
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;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -1210,13 +1309,15 @@ input[type="range"] {
|
|||||||
|
|
||||||
.pose-control input[type="number"] {
|
.pose-control input[type="number"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 30px;
|
height: 36px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(6, 10, 15, 0.86);
|
background: rgba(6, 10, 15, 0.86);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-align: right;
|
font-size: 16px;
|
||||||
padding: 0 7px;
|
font-weight: 900;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pose-control button,
|
.pose-control button,
|
||||||
@@ -1281,6 +1382,16 @@ input[type="range"] {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-auto {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide-action {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
.auto-options {
|
.auto-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
@@ -1320,6 +1431,53 @@ input[type="range"] {
|
|||||||
text-align: center;
|
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 {
|
.login-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user