Tighten registration controls and fusion display
This commit is contained in:
@@ -29,13 +29,13 @@ const DEFAULT_POSE = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const POSE_CONTROLS = [
|
const POSE_CONTROLS = [
|
||||||
{ key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 1, suffix: "°" },
|
{ key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" },
|
||||||
{ key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 1, suffix: "°" },
|
{ key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" },
|
||||||
{ key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 1, suffix: "°" },
|
{ key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" },
|
||||||
{ key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.01, suffix: "" },
|
{ key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" },
|
||||||
{ key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.01, suffix: "" },
|
{ key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" },
|
||||||
{ key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.01, suffix: "" },
|
{ key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" },
|
||||||
{ key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.01, suffix: "x" },
|
{ key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.001, decimals: 3, suffix: "x" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
|
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
|
||||||
@@ -61,7 +61,7 @@ const state = {
|
|||||||
pose: { ...DEFAULT_POSE },
|
pose: { ...DEFAULT_POSE },
|
||||||
windowMode: "default",
|
windowMode: "default",
|
||||||
fusionMode: "fusion",
|
fusionMode: "fusion",
|
||||||
fusionDetail: "low",
|
fusionDetail: "high",
|
||||||
modelDetail: "standard",
|
modelDetail: "standard",
|
||||||
mappingMode: "result",
|
mappingMode: "result",
|
||||||
sliceIndex: 0,
|
sliceIndex: 0,
|
||||||
@@ -84,8 +84,10 @@ const state = {
|
|||||||
dicomGroup: null,
|
dicomGroup: null,
|
||||||
dicomPlane: null,
|
dicomPlane: null,
|
||||||
dicomPoints: null,
|
dicomPoints: null,
|
||||||
|
dicomSlicePlanes: [],
|
||||||
modelGroup: null,
|
modelGroup: null,
|
||||||
rawModelGroup: null,
|
rawModelGroup: null,
|
||||||
|
axisGroup: null,
|
||||||
modelMeshes: [],
|
modelMeshes: [],
|
||||||
stlSignature: "",
|
stlSignature: "",
|
||||||
baseModelScale: 1,
|
baseModelScale: 1,
|
||||||
@@ -95,6 +97,8 @@ const state = {
|
|||||||
mappingDrawFrame: 0,
|
mappingDrawFrame: 0,
|
||||||
resizeObserver: null,
|
resizeObserver: null,
|
||||||
resizeScene: null,
|
resizeScene: null,
|
||||||
|
nudgeTimer: null,
|
||||||
|
nudgeDelayTimer: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
@@ -595,10 +599,6 @@ function renderStl() {
|
|||||||
: `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
|
: `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
|
||||||
$("stlList").querySelectorAll("input[data-stl]").forEach((input) => {
|
$("stlList").querySelectorAll("input[data-stl]").forEach((input) => {
|
||||||
input.addEventListener("change", async () => {
|
input.addEventListener("change", async () => {
|
||||||
if (state.dirty && !(await confirmChange())) {
|
|
||||||
input.checked = !input.checked;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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);
|
||||||
@@ -607,6 +607,52 @@ function renderStl() {
|
|||||||
await loadFusion();
|
await loadFusion();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
renderAutoBoneOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stlLikelyScore(file) {
|
||||||
|
const textValue = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
|
||||||
|
const priority = [
|
||||||
|
["rib", 95],
|
||||||
|
["vertebra", 94],
|
||||||
|
["spine", 92],
|
||||||
|
["sternum", 90],
|
||||||
|
["pelvis", 82],
|
||||||
|
["skull", 78],
|
||||||
|
["bone", 72],
|
||||||
|
];
|
||||||
|
return priority.reduce((score, [key, value]) => textValue.includes(key) ? Math.max(score, value) : score, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stlDisplayName(file) {
|
||||||
|
return String(file.segment_name || file.family || file.file_name || `stl_${file.id || ""}`).replace(/\\.stl$/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAutoBoneOptions() {
|
||||||
|
const container = $("autoBoneOptions");
|
||||||
|
if (!container) return;
|
||||||
|
if (!state.stlFiles.length) {
|
||||||
|
container.innerHTML = `<div class="empty-state compact-empty">No STL</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = [...state.stlFiles].sort((a, b) => {
|
||||||
|
const diff = stlLikelyScore(b) - stlLikelyScore(a);
|
||||||
|
if (diff) return diff;
|
||||||
|
return stlDisplayName(a).localeCompare(stlDisplayName(b));
|
||||||
|
});
|
||||||
|
container.innerHTML = rows.map((file) => {
|
||||||
|
const id = Number(file.id);
|
||||||
|
const likely = stlLikelyScore(file) > 0;
|
||||||
|
const checked = likely ? "checked" : "";
|
||||||
|
const title = escapeHtml(`${file.file_name || ""} ${file.family || ""}`.trim());
|
||||||
|
return `
|
||||||
|
<label title="${title}" class="${likely ? "likely" : ""}">
|
||||||
|
<input type="checkbox" value="${id}" ${checked} />
|
||||||
|
<span>${escapeHtml(stlDisplayName(file))}</span>
|
||||||
|
${likely ? `<em>priority</em>` : ""}
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function cssColor(index) {
|
function cssColor(index) {
|
||||||
@@ -637,9 +683,10 @@ function buildPoseControls() {
|
|||||||
const key = row.dataset.pose;
|
const key = row.dataset.pose;
|
||||||
const range = row.querySelector("input[type='range']");
|
const range = row.querySelector("input[type='range']");
|
||||||
const number = row.querySelector("input[type='number']");
|
const number = row.querySelector("input[type='number']");
|
||||||
|
const option = POSE_CONTROLS.find((item) => item.key === key);
|
||||||
const sync = (value, source) => {
|
const sync = (value, source) => {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
state.pose[key] = Number.isFinite(numeric) ? numeric : DEFAULT_POSE[key];
|
state.pose[key] = Number.isFinite(numeric) ? Number(numeric.toFixed(option?.decimals ?? 3)) : DEFAULT_POSE[key];
|
||||||
if (source !== range) range.value = state.pose[key];
|
if (source !== range) range.value = state.pose[key];
|
||||||
if (source !== number) number.value = state.pose[key];
|
if (source !== number) number.value = state.pose[key];
|
||||||
applyPose();
|
applyPose();
|
||||||
@@ -648,9 +695,7 @@ function buildPoseControls() {
|
|||||||
range.addEventListener("input", () => sync(range.value, range));
|
range.addEventListener("input", () => sync(range.value, range));
|
||||||
number.addEventListener("input", () => sync(number.value, number));
|
number.addEventListener("input", () => sync(number.value, number));
|
||||||
});
|
});
|
||||||
document.querySelectorAll("[data-nudge]").forEach((button) => {
|
document.querySelectorAll("[data-nudge]").forEach((button) => wireHoldNudge(button));
|
||||||
button.addEventListener("click", () => nudgePose(button.dataset.nudge, Number(button.dataset.delta || 0)));
|
|
||||||
});
|
|
||||||
document.querySelectorAll("[data-flip]").forEach((button) => {
|
document.querySelectorAll("[data-flip]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const key = button.dataset.flip;
|
const key = button.dataset.flip;
|
||||||
@@ -662,12 +707,49 @@ function buildPoseControls() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wireHoldNudge(button) {
|
||||||
|
const fixedDelta = Number(button.dataset.delta || 0);
|
||||||
|
if (Math.abs(fixedDelta) >= 10) {
|
||||||
|
button.addEventListener("click", () => nudgePose(button.dataset.nudge, fixedDelta));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = (event) => {
|
||||||
|
if (event.button !== undefined && event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const key = button.dataset.nudge;
|
||||||
|
const delta = Number(button.dataset.delta || 0);
|
||||||
|
nudgePose(key, delta);
|
||||||
|
clearNudgeTimers();
|
||||||
|
state.nudgeDelayTimer = window.setTimeout(() => {
|
||||||
|
state.nudgeTimer = window.setInterval(() => nudgePose(key, delta), 54);
|
||||||
|
}, 260);
|
||||||
|
button.setPointerCapture?.(event.pointerId);
|
||||||
|
};
|
||||||
|
const stop = (event) => {
|
||||||
|
clearNudgeTimers();
|
||||||
|
if (event?.pointerId !== undefined && button.hasPointerCapture?.(event.pointerId)) {
|
||||||
|
button.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
button.addEventListener("pointerdown", start);
|
||||||
|
button.addEventListener("pointerup", stop);
|
||||||
|
button.addEventListener("pointercancel", stop);
|
||||||
|
button.addEventListener("pointerleave", stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearNudgeTimers() {
|
||||||
|
window.clearTimeout(state.nudgeDelayTimer);
|
||||||
|
window.clearInterval(state.nudgeTimer);
|
||||||
|
state.nudgeDelayTimer = null;
|
||||||
|
state.nudgeTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
function nudgePose(key, delta) {
|
function nudgePose(key, delta) {
|
||||||
if (!key || !Number.isFinite(delta)) return;
|
if (!key || !Number.isFinite(delta)) return;
|
||||||
const option = POSE_CONTROLS.find((item) => item.key === key);
|
const option = POSE_CONTROLS.find((item) => item.key === key);
|
||||||
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
|
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
|
||||||
const next = Math.max(option?.min ?? -Infinity, Math.min(option?.max ?? Infinity, current + delta));
|
const next = Math.max(option?.min ?? -Infinity, Math.min(option?.max ?? Infinity, current + delta));
|
||||||
state.pose[key] = Number(next.toFixed(3));
|
state.pose[key] = Number(next.toFixed(option?.decimals ?? 3));
|
||||||
renderPoseControls();
|
renderPoseControls();
|
||||||
applyPose();
|
applyPose();
|
||||||
markDirty();
|
markDirty();
|
||||||
@@ -679,7 +761,7 @@ function renderPoseControls() {
|
|||||||
if (!row) return;
|
if (!row) return;
|
||||||
const value = Number(state.pose[item.key] ?? DEFAULT_POSE[item.key]);
|
const value = Number(state.pose[item.key] ?? DEFAULT_POSE[item.key]);
|
||||||
row.querySelector("input[type='range']").value = value;
|
row.querySelector("input[type='range']").value = value;
|
||||||
row.querySelector("input[type='number']").value = Number.isInteger(value) ? value : value.toFixed(2);
|
row.querySelector("input[type='number']").value = Number.isInteger(value) ? value : value.toFixed(item.decimals ?? 3);
|
||||||
});
|
});
|
||||||
document.querySelectorAll("[data-flip]").forEach((button) => {
|
document.querySelectorAll("[data-flip]").forEach((button) => {
|
||||||
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
|
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
|
||||||
@@ -772,10 +854,11 @@ function initScene() {
|
|||||||
const dicomGroup = new THREE.Group();
|
const dicomGroup = new THREE.Group();
|
||||||
const modelGroup = new THREE.Group();
|
const modelGroup = new THREE.Group();
|
||||||
const rawModelGroup = new THREE.Group();
|
const rawModelGroup = new THREE.Group();
|
||||||
|
const axisGroup = createSceneAxes();
|
||||||
modelGroup.add(rawModelGroup);
|
modelGroup.add(rawModelGroup);
|
||||||
scene.add(dicomGroup, modelGroup);
|
scene.add(dicomGroup, modelGroup, axisGroup);
|
||||||
|
|
||||||
Object.assign(state, { renderer, camera, scene, controls, dicomGroup, modelGroup, rawModelGroup, sceneReady: true });
|
Object.assign(state, { renderer, camera, scene, controls, dicomGroup, modelGroup, rawModelGroup, axisGroup, sceneReady: true });
|
||||||
|
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
const rect = viewport.getBoundingClientRect();
|
const rect = viewport.getBoundingClientRect();
|
||||||
@@ -797,6 +880,44 @@ function initScene() {
|
|||||||
animate();
|
animate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAxisLabel(text, color) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 96;
|
||||||
|
canvas.height = 64;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.font = "900 42px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.textBaseline = "middle";
|
||||||
|
context.fillText(text, 48, 34);
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }));
|
||||||
|
sprite.scale.set(0.28, 0.18, 1);
|
||||||
|
return sprite;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSceneAxes() {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const length = 0.78;
|
||||||
|
const headLength = 0.16;
|
||||||
|
const headWidth = 0.08;
|
||||||
|
const axes = [
|
||||||
|
{ label: "X", color: 0xf87171, css: "#f87171", dir: new THREE.Vector3(1, 0, 0) },
|
||||||
|
{ label: "Y", color: 0x4ade80, css: "#4ade80", dir: new THREE.Vector3(0, 1, 0) },
|
||||||
|
{ label: "Z", color: 0x60a5fa, css: "#60a5fa", dir: new THREE.Vector3(0, 0, 1) },
|
||||||
|
];
|
||||||
|
axes.forEach((axis) => {
|
||||||
|
const arrow = new THREE.ArrowHelper(axis.dir, new THREE.Vector3(0, 0, 0), length, axis.color, headLength, headWidth);
|
||||||
|
const label = createAxisLabel(axis.label, axis.css);
|
||||||
|
label.position.copy(axis.dir.clone().multiplyScalar(length + 0.16));
|
||||||
|
group.add(arrow, label);
|
||||||
|
});
|
||||||
|
group.visible = true;
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
function initGeometryFallback() {
|
function initGeometryFallback() {
|
||||||
if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return;
|
if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return;
|
||||||
const dicomGroup = new THREE.Group();
|
const dicomGroup = new THREE.Group();
|
||||||
@@ -829,6 +950,7 @@ function clearFusion() {
|
|||||||
state.modelMeshes = [];
|
state.modelMeshes = [];
|
||||||
state.dicomPlane = null;
|
state.dicomPlane = null;
|
||||||
state.dicomPoints = null;
|
state.dicomPoints = null;
|
||||||
|
state.dicomSlicePlanes = [];
|
||||||
$("fusionMeta").textContent = "DICOM - · STL -";
|
$("fusionMeta").textContent = "DICOM - · STL -";
|
||||||
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
|
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
|
||||||
updateSliceControl();
|
updateSliceControl();
|
||||||
@@ -845,10 +967,10 @@ function applyFusionMode() {
|
|||||||
|
|
||||||
function fusionDetailSettings() {
|
function fusionDetailSettings() {
|
||||||
return {
|
return {
|
||||||
low: { planeOpacity: 0.24, pointOpacity: 0.18, pointSize: 0.014 },
|
low: { planeOpacity: 0.32, sliceOpacity: 0.05, slicePlanes: 12, pointOpacity: 0.18, pointSize: 0.014, pointGrid: 72, pointThreshold: 24 },
|
||||||
medium: { planeOpacity: 0.38, pointOpacity: 0.3, pointSize: 0.018 },
|
medium: { planeOpacity: 0.46, sliceOpacity: 0.075, slicePlanes: 18, pointOpacity: 0.3, pointSize: 0.018, pointGrid: 88, pointThreshold: 22 },
|
||||||
high: { planeOpacity: 0.52, pointOpacity: 0.44, pointSize: 0.022 },
|
high: { planeOpacity: 0.6, sliceOpacity: 0.1, slicePlanes: 26, pointOpacity: 0.42, pointSize: 0.021, pointGrid: 104, pointThreshold: 20 },
|
||||||
}[state.fusionDetail] || { planeOpacity: 0.24, pointOpacity: 0.18, pointSize: 0.014 };
|
}[state.fusionDetail] || { planeOpacity: 0.32, sliceOpacity: 0.05, slicePlanes: 12, pointOpacity: 0.18, pointSize: 0.014, pointGrid: 72, pointThreshold: 24 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelDetailSettings() {
|
function modelDetailSettings() {
|
||||||
@@ -871,6 +993,11 @@ function applyFusionDetail() {
|
|||||||
state.dicomPoints.material.size = detail.pointSize;
|
state.dicomPoints.material.size = detail.pointSize;
|
||||||
state.dicomPoints.material.needsUpdate = true;
|
state.dicomPoints.material.needsUpdate = true;
|
||||||
}
|
}
|
||||||
|
state.dicomSlicePlanes.forEach((plane) => {
|
||||||
|
if (!plane.material) return;
|
||||||
|
plane.material.opacity = detail.sliceOpacity;
|
||||||
|
plane.material.needsUpdate = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyModelDetail() {
|
function applyModelDetail() {
|
||||||
@@ -1245,6 +1372,7 @@ async function buildDicomVolume(volume) {
|
|||||||
clearGroup(state.dicomGroup);
|
clearGroup(state.dicomGroup);
|
||||||
state.dicomPlane = null;
|
state.dicomPlane = null;
|
||||||
state.dicomPoints = null;
|
state.dicomPoints = null;
|
||||||
|
state.dicomSlicePlanes = [];
|
||||||
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 };
|
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 maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
|
||||||
const sceneScale = 4.8 / maxPhysical;
|
const sceneScale = 4.8 / maxPhysical;
|
||||||
@@ -1307,18 +1435,41 @@ async function buildDicomVolume(volume) {
|
|||||||
state.dicomPlane = plane;
|
state.dicomPlane = plane;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (frames.length > 1) {
|
||||||
|
const planeStep = Math.max(1, Math.ceil(frames.length / Math.max(1, fusionDetail.slicePlanes)));
|
||||||
|
for (let frameIndex = 0; frameIndex < frames.length; frameIndex += planeStep) {
|
||||||
|
if (frameIndex === centerPosition) continue;
|
||||||
|
const texture = await loadTexture(frames[frameIndex]);
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
const plane = new THREE.Mesh(
|
||||||
|
new THREE.PlaneGeometry(width, height),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
map: texture,
|
||||||
|
transparent: true,
|
||||||
|
opacity: fusionDetail.sliceOpacity,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
depthTest: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
plane.position.z = sliceToSceneZ(Number(indices[frameIndex] ?? centerIndex));
|
||||||
|
state.dicomGroup.add(plane);
|
||||||
|
state.dicomSlicePlanes.push(plane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pointPositions = [];
|
const pointPositions = [];
|
||||||
const pointColors = [];
|
const pointColors = [];
|
||||||
const frames = volume.frames || [];
|
const frames = volume.frames || [];
|
||||||
for (const [index, frame] of frames.entries()) {
|
for (const [index, frame] of frames.entries()) {
|
||||||
const image = await loadImageData(frame, 180);
|
const image = await loadImageData(frame, 180);
|
||||||
const stride = Math.max(3, Math.round(Math.max(image.width, image.height) / 72));
|
const stride = Math.max(2, Math.round(Math.max(image.width, image.height) / fusionDetail.pointGrid));
|
||||||
const z = sliceToSceneZ(Number(indices[index] ?? centerIndex));
|
const z = sliceToSceneZ(Number(indices[index] ?? centerIndex));
|
||||||
for (let y = 0; y < image.height; y += stride) {
|
for (let y = 0; y < image.height; y += stride) {
|
||||||
for (let x = 0; x < image.width; x += stride) {
|
for (let x = 0; x < image.width; x += stride) {
|
||||||
const offset = (y * image.width + x) * 4;
|
const offset = (y * image.width + x) * 4;
|
||||||
const lum = image.data[offset];
|
const lum = image.data[offset];
|
||||||
if (lum < 12) continue;
|
if (lum < fusionDetail.pointThreshold) continue;
|
||||||
pointPositions.push((x / Math.max(image.width - 1, 1) - 0.5) * width);
|
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((0.5 - y / Math.max(image.height - 1, 1)) * height);
|
||||||
pointPositions.push(z);
|
pointPositions.push(z);
|
||||||
@@ -1438,6 +1589,15 @@ function fitCamera() {
|
|||||||
const distance = Math.max(size.x, size.y, size.z, 1) * 1.8;
|
const distance = Math.max(size.x, size.y, size.z, 1) * 1.8;
|
||||||
state.camera.position.set(center.x, center.y - distance, center.z + distance * 0.45);
|
state.camera.position.set(center.x, center.y - distance, center.z + distance * 0.45);
|
||||||
state.controls.target.copy(center);
|
state.controls.target.copy(center);
|
||||||
|
if (state.axisGroup) {
|
||||||
|
const axisScale = Math.max(size.x, size.y, size.z, 1) / 5.5;
|
||||||
|
state.axisGroup.scale.setScalar(axisScale);
|
||||||
|
state.axisGroup.position.set(
|
||||||
|
center.x - size.x * 0.46,
|
||||||
|
center.y - size.y * 0.46,
|
||||||
|
center.z - size.z * 0.42,
|
||||||
|
);
|
||||||
|
}
|
||||||
state.controls.update();
|
state.controls.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1481,23 +1641,15 @@ function stretchAxis(axis) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function suggestBoneSelection() {
|
function suggestBoneSelection() {
|
||||||
const selectedBoneKeys = [...document.querySelectorAll("#autoBoneOptions input:checked")].map((input) => input.value);
|
const checkedIds = [...document.querySelectorAll("#autoBoneOptions input:checked")]
|
||||||
const keyMap = {
|
.map((input) => Number(input.value))
|
||||||
vertebra: ["vertebra", "spine", "椎", "脊柱"],
|
.filter(Number.isFinite);
|
||||||
rib: ["rib", "肋"],
|
const next = new Set(checkedIds);
|
||||||
sternum: ["sternum", "胸骨"],
|
|
||||||
pelvis: ["pelvis", "髂", "骨盆"],
|
|
||||||
};
|
|
||||||
const boneKeys = (selectedBoneKeys.length ? selectedBoneKeys : ["vertebra", "rib"])
|
|
||||||
.flatMap((key) => keyMap[key] || [key])
|
|
||||||
.concat(["bone", "骨"]);
|
|
||||||
const next = new Set();
|
|
||||||
state.stlFiles.forEach((file) => {
|
|
||||||
const haystack = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
|
|
||||||
if (boneKeys.some((key) => haystack.includes(key))) next.add(Number(file.id));
|
|
||||||
});
|
|
||||||
if (!next.size) {
|
if (!next.size) {
|
||||||
state.stlFiles.slice(0, Math.min(6, state.stlFiles.length)).forEach((file) => next.add(Number(file.id)));
|
[...state.stlFiles]
|
||||||
|
.sort((a, b) => stlLikelyScore(b) - stlLikelyScore(a))
|
||||||
|
.slice(0, Math.min(6, state.stlFiles.length))
|
||||||
|
.forEach((file) => next.add(Number(file.id)));
|
||||||
}
|
}
|
||||||
state.selectedStlIds = next;
|
state.selectedStlIds = next;
|
||||||
renderStl();
|
renderStl();
|
||||||
@@ -1522,7 +1674,7 @@ function readAutoSettings() {
|
|||||||
outsidePenalty: numericValue("autoOutsidePenalty", 0.1),
|
outsidePenalty: numericValue("autoOutsidePenalty", 0.1),
|
||||||
movePenalty: numericValue("autoMovePenalty", 0),
|
movePenalty: numericValue("autoMovePenalty", 0),
|
||||||
scalePenalty: numericValue("autoScalePenalty", 0),
|
scalePenalty: numericValue("autoScalePenalty", 0),
|
||||||
iterations: Math.max(1, Math.min(20, Math.round(numericValue("autoIterations", 6)))),
|
iterations: Math.max(1, Math.min(100, Math.round(numericValue("autoIterations", 30)))),
|
||||||
candidates: Math.max(6, Math.min(96, Math.round(numericValue("autoCandidates", 36)))),
|
candidates: Math.max(6, Math.min(96, Math.round(numericValue("autoCandidates", 36)))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1758,10 +1910,6 @@ function setMappingMode(mode) {
|
|||||||
scheduleMappingDraw();
|
scheduleMappingDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
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());
|
||||||
@@ -1790,10 +1938,8 @@ 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));
|
$("openAutoModalBtn").addEventListener("click", () => {
|
||||||
$("closeAutoModalBtn").addEventListener("click", () => setAutoModalVisible(false));
|
$("autoSettingsPanel").scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
$("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);
|
||||||
@@ -1816,6 +1962,7 @@ function wireEvents() {
|
|||||||
$("autoDefaultsBtn").addEventListener("click", () => {
|
$("autoDefaultsBtn").addEventListener("click", () => {
|
||||||
$("autoSampleSlices").value = 9;
|
$("autoSampleSlices").value = 9;
|
||||||
$("autoSampleSlicesNumber").value = 9;
|
$("autoSampleSlicesNumber").value = 9;
|
||||||
|
$("autoIterations").value = 30;
|
||||||
});
|
});
|
||||||
$("dicomPreview").addEventListener("load", () => {
|
$("dicomPreview").addEventListener("load", () => {
|
||||||
$("dicomLoading").classList.add("hidden");
|
$("dicomLoading").classList.add("hidden");
|
||||||
|
|||||||
@@ -101,13 +101,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="tool-pane active" data-tool-pane="series">
|
<section class="tool-pane active" data-tool-pane="series">
|
||||||
<div class="tool-section-title">
|
|
||||||
<span>融合显示</span>
|
|
||||||
</div>
|
|
||||||
<div class="display-segmented" id="fusionDetailControls">
|
<div class="display-segmented" id="fusionDetailControls">
|
||||||
<button class="active" data-fusion-detail="low" type="button">DICOM 低</button>
|
<button data-fusion-detail="low" type="button">DICOM 低</button>
|
||||||
<button data-fusion-detail="medium" type="button">DICOM 中</button>
|
<button data-fusion-detail="medium" type="button">DICOM 中</button>
|
||||||
<button data-fusion-detail="high" type="button">DICOM 高</button>
|
<button class="active" data-fusion-detail="high" type="button">DICOM 高</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-section-title">
|
<div class="tool-section-title">
|
||||||
<span>检查序列</span>
|
<span>检查序列</span>
|
||||||
@@ -117,16 +114,16 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="tool-pane" data-tool-pane="models">
|
<section class="tool-pane" data-tool-pane="models">
|
||||||
<div class="tool-section-title">
|
|
||||||
<span>模型可视</span>
|
|
||||||
<em id="stlCount">0</em>
|
|
||||||
</div>
|
|
||||||
<div class="display-segmented" id="modelDetailControls">
|
<div class="display-segmented" id="modelDetailControls">
|
||||||
<button class="active" data-model-detail="standard" type="button">标准</button>
|
<button class="active" data-model-detail="standard" type="button">标准</button>
|
||||||
<button data-model-detail="fine" type="button">精细</button>
|
<button data-model-detail="fine" type="button">精细</button>
|
||||||
<button data-model-detail="ultra" type="button">超精细</button>
|
<button data-model-detail="ultra" type="button">超精细</button>
|
||||||
<button data-model-detail="solid" type="button">实体</button>
|
<button data-model-detail="solid" type="button">实体</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tool-section-title compact-title">
|
||||||
|
<span>STL</span>
|
||||||
|
<em id="stlCount">0</em>
|
||||||
|
</div>
|
||||||
<div id="modelRail" class="chip-rail"></div>
|
<div id="modelRail" class="chip-rail"></div>
|
||||||
<div id="stlList" class="stl-list compact-list"></div>
|
<div id="stlList" class="stl-list compact-list"></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -153,7 +150,57 @@
|
|||||||
<em id="autoState">未运行</em>
|
<em id="autoState">未运行</em>
|
||||||
</div>
|
</div>
|
||||||
<div class="auto-box compact-auto">
|
<div class="auto-box compact-auto">
|
||||||
<button id="openAutoModalBtn" class="wide-action" type="button">打开自动调整区域</button>
|
<button id="openAutoModalBtn" class="wide-action" type="button">自动调整设置</button>
|
||||||
|
<div id="autoSettingsPanel" class="auto-settings-panel">
|
||||||
|
<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="auto-config-grid">
|
||||||
|
<section>
|
||||||
|
<div class="modal-section-head">
|
||||||
|
<strong>STL 参考区域</strong>
|
||||||
|
<button id="suggestBoneBtn" type="button">建议选择</button>
|
||||||
|
</div>
|
||||||
|
<div id="autoBoneOptions" class="bone-option-list"></div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div class="modal-section-head">
|
||||||
|
<strong>采样切片</strong>
|
||||||
|
<button id="autoDefaultsBtn" type="button">默认数量</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-slider">
|
||||||
|
<input id="autoSampleSlices" type="range" min="3" max="60" value="9" />
|
||||||
|
<input id="autoSampleSlicesNumber" type="number" min="3" max="60" value="9" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="auto-weight-grid">
|
||||||
|
<label>命中奖励<input id="autoBoneReward" type="number" step="0.1" value="1" /></label>
|
||||||
|
<label>区域惩罚<input id="autoOutsidePenalty" type="number" step="0.05" value="0.1" /></label>
|
||||||
|
<label>移动惩罚<input id="autoMovePenalty" type="number" step="0.01" value="0" /></label>
|
||||||
|
<label>缩放惩罚<input id="autoScalePenalty" type="number" step="0.01" value="0" /></label>
|
||||||
|
<label>迭代轮次<input id="autoIterations" type="number" min="1" max="100" value="30" /></label>
|
||||||
|
<label>每轮候选<input id="autoCandidates" type="number" min="6" max="96" value="36" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="pose-actions modal-actions">
|
||||||
|
<button id="autoCoarseBtn" type="button">自动粗配准</button>
|
||||||
|
<button id="autoFineBtn" type="button">自动微调</button>
|
||||||
|
</div>
|
||||||
|
<div id="autoPoseResult" class="auto-pose-result">
|
||||||
|
<span>平移 X 0</span>
|
||||||
|
<span>平移 Y 0</span>
|
||||||
|
<span>平移 Z 0</span>
|
||||||
|
<span>缩放 1</span>
|
||||||
|
</div>
|
||||||
|
<div class="pose-actions modal-actions">
|
||||||
|
<button id="savePoseBtn" type="button">保存位姿</button>
|
||||||
|
<button id="saveIterateBtn" type="button">保存并迭代</button>
|
||||||
|
<button id="restorePoseBtn" type="button">退回位姿</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
@@ -168,9 +215,11 @@
|
|||||||
<button data-fusion-mode="fusion" class="active">融合结果</button>
|
<button data-fusion-mode="fusion" class="active">融合结果</button>
|
||||||
<button data-fusion-mode="model">单独模型</button>
|
<button data-fusion-mode="model">单独模型</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fusion-save-actions">
|
||||||
<button id="saveBtn" class="primary-btn">保存配准</button>
|
<button id="saveBtn" class="primary-btn">保存配准</button>
|
||||||
<button id="statusBtn" class="state-btn">标为已配准</button>
|
<button id="statusBtn" class="state-btn">标为已配准</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="fusion-control-row">
|
<div class="fusion-control-row">
|
||||||
<div class="segmented">
|
<div class="segmented">
|
||||||
<button data-window="default" class="active">默认</button>
|
<button data-window="default" class="active">默认</button>
|
||||||
@@ -267,71 +316,6 @@
|
|||||||
</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="auto-config-grid">
|
|
||||||
<section>
|
|
||||||
<div class="modal-section-head">
|
|
||||||
<strong>骨骼区域</strong>
|
|
||||||
<button id="suggestBoneBtn" type="button">建议选择</button>
|
|
||||||
</div>
|
|
||||||
<div id="autoBoneOptions" class="bone-option-list">
|
|
||||||
<label><input type="checkbox" value="vertebra" checked /> 椎骨</label>
|
|
||||||
<label><input type="checkbox" value="rib" checked /> 肋骨</label>
|
|
||||||
<label><input type="checkbox" value="sternum" /> 胸骨</label>
|
|
||||||
<label><input type="checkbox" value="pelvis" /> 骨盆</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<div class="modal-section-head">
|
|
||||||
<strong>采样切片</strong>
|
|
||||||
<button id="autoDefaultsBtn" type="button">默认数量</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-slider">
|
|
||||||
<input id="autoSampleSlices" type="range" min="3" max="60" value="9" />
|
|
||||||
<input id="autoSampleSlicesNumber" type="number" min="3" max="60" value="9" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<div class="auto-weight-grid">
|
|
||||||
<label>骨窗命中奖励<input id="autoBoneReward" type="number" step="0.1" value="1" /></label>
|
|
||||||
<label>非骨区域惩罚<input id="autoOutsidePenalty" type="number" step="0.05" value="0.1" /></label>
|
|
||||||
<label>移动惩罚<input id="autoMovePenalty" type="number" step="0.01" value="0" /></label>
|
|
||||||
<label>缩放惩罚<input id="autoScalePenalty" type="number" step="0.01" value="0" /></label>
|
|
||||||
<label>迭代轮次<input id="autoIterations" type="number" min="1" max="20" value="6" /></label>
|
|
||||||
<label>每轮候选<input id="autoCandidates" type="number" min="6" max="96" value="36" /></label>
|
|
||||||
</div>
|
|
||||||
<div class="pose-actions modal-actions">
|
|
||||||
<button id="autoCoarseBtn" type="button">自动粗配准</button>
|
|
||||||
<button id="autoFineBtn" type="button">自动微调</button>
|
|
||||||
</div>
|
|
||||||
<div id="autoPoseResult" class="auto-pose-result">
|
|
||||||
<span>平移 X 0</span>
|
|
||||||
<span>平移 Y 0</span>
|
|
||||||
<span>平移 Z 0</span>
|
|
||||||
<span>缩放 1</span>
|
|
||||||
</div>
|
|
||||||
<div class="pose-actions modal-actions">
|
|
||||||
<button id="savePoseBtn" type="button">保存位姿</button>
|
|
||||||
<button id="saveIterateBtn" type="button">保存并迭代</button>
|
|
||||||
<button id="restorePoseBtn" 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>
|
||||||
|
|||||||
@@ -730,6 +730,13 @@ button {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fusion-save-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.fusion-control-row,
|
.fusion-control-row,
|
||||||
.mapping-toolbar {
|
.mapping-toolbar {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -771,12 +778,16 @@ button {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: 0 0 10px;
|
margin: 0 0 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(148, 163, 184, 0.08);
|
background: rgba(148, 163, 184, 0.08);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-pane > .display-segmented:first-child {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
#modelDetailControls {
|
#modelDetailControls {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -1471,6 +1482,11 @@ input[type="range"] {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auto-settings-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.wide-action {
|
.wide-action {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
@@ -1508,6 +1524,12 @@ input[type="range"] {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-title {
|
||||||
|
min-height: 28px;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: rgba(15, 22, 32, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -1571,6 +1593,19 @@ input[type="range"] {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-pane .auto-config-grid,
|
||||||
|
.tool-pane .auto-weight-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-pane .auto-config-grid {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-pane .auto-weight-grid {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.auto-config-grid section,
|
.auto-config-grid section,
|
||||||
.auto-weight-grid {
|
.auto-weight-grid {
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
@@ -1602,9 +1637,9 @@ input[type="range"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bone-option-list {
|
.bone-option-list {
|
||||||
max-height: 156px;
|
max-height: 220px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -1624,6 +1659,31 @@ input[type="range"] {
|
|||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bone-option-list label {
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bone-option-list label span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bone-option-list label em {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #9debf4;
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: normal;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bone-option-list label.likely {
|
||||||
|
border-color: rgba(25, 214, 195, 0.36);
|
||||||
|
background: rgba(20, 184, 166, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-slider {
|
.modal-slider {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 86px;
|
grid-template-columns: minmax(0, 1fr) 86px;
|
||||||
@@ -1667,6 +1727,14 @@ input[type="range"] {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-pane .auto-pose-result {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-empty {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.auto-pose-result span {
|
.auto-pose-result span {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user