2026-05-08-01-19-42 优化融合视角和模型位姿控制

This commit is contained in:
2026-05-08 01:29:58 +08:00
parent 97edf35bd0
commit 4ba85eba6e
7 changed files with 609 additions and 35 deletions

View File

@@ -1111,7 +1111,7 @@ export default function ProjectLibrary({
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden p-8">
{viewMode === 'dicom' && (
<div className="h-full flex gap-8">
<div className="h-full min-h-0 flex gap-8">
{/* Left: DICOM Viewer */}
<div className="flex-1 bg-slate-950 rounded-2xl relative border border-slate-800 flex items-center justify-center p-12">
<div className="absolute top-4 right-4 z-10 flex rounded-lg bg-white/5 p-1 backdrop-blur-sm border border-white/10">
@@ -1270,8 +1270,8 @@ export default function ProjectLibrary({
</div>
</div>
{/* Right: Sub-module List */}
<div className="w-80 h-full flex flex-col overflow-hidden">
<div className="shrink-0 space-y-4 pb-4">
<div className="w-80 h-full min-h-0 overflow-y-auto pr-1 scrollbar-hide">
<div className="space-y-4 pb-4">
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-bold text-slate-700"></p>
@@ -1360,7 +1360,7 @@ export default function ProjectLibrary({
<Eye size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
<div className="space-y-2 pb-4">
{stlFiles.map((fileName, i) => {
const name = fileName.replace(/\.stl$/i, '');
const style = moduleStyles[fileName] ?? { visible: true, color: defaultModuleColors[i % defaultModuleColors.length], opacity: 0.72, partId: i + 1 };

View File

@@ -37,6 +37,8 @@ interface ModelPreviewPayload {
}
type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
type DicomOpacityLevel = 'low' | 'medium' | 'high';
type ModelPoseKey = keyof ModelPose;
const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
{ id: 'standard', label: '标准', limit: 16000 },
@@ -44,6 +46,20 @@ const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }>
{ id: 'ultra', label: '超精细', limit: 72000 },
{ id: 'solid', label: '实体', limit: 200000 },
];
const dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOpacity: number; volumeOpacity: number; boxOpacity: number }> = [
{ id: 'low', label: '低', sliceOpacity: 0.82, volumeOpacity: 0.12, boxOpacity: 0.32 },
{ id: 'medium', label: '中', sliceOpacity: 0.92, volumeOpacity: 0.2, boxOpacity: 0.42 },
{ id: 'high', label: '高', sliceOpacity: 1, volumeOpacity: 0.32, boxOpacity: 0.54 },
];
const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: number; minus: string; plus: string; quick?: number }> = {
rotateX: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
rotateY: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
rotateZ: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
translateX: { min: -2, max: 2, step: 0.05, minus: '-X', plus: '+X' },
translateY: { min: -2, max: 2, step: 0.05, minus: '-Y', plus: '+Y' },
translateZ: { min: -2, max: 2, step: 0.05, minus: '-Z', plus: '+Z' },
scale: { min: 0.5, max: 2, step: 0.05, minus: '-S', plus: '+S' },
};
const defaultModelPose: ModelPose = {
rotateX: 0,
@@ -97,6 +113,10 @@ function FusionThreeView({
moduleStyles,
detailLimit,
solidMode,
dicomOpacity,
showBounds,
cutEnabled,
cutSlice,
}: {
project: Project;
volume: DicomFusionVolume | null;
@@ -104,6 +124,10 @@ function FusionThreeView({
moduleStyles: Record<string, ModuleStyle>;
detailLimit: number;
solidMode: boolean;
dicomOpacity: { sliceOpacity: number; volumeOpacity: number; boxOpacity: number };
showBounds: boolean;
cutEnabled: boolean;
cutSlice: number;
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const modelPoseRef = useRef(modelPose);
@@ -137,6 +161,7 @@ function FusionThreeView({
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
renderer.localClippingEnabled = true;
container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.72));
@@ -165,15 +190,34 @@ function FusionThreeView({
const box = new THREE.Mesh(
new THREE.BoxGeometry(dicomWidth, dicomHeight, dicomDepth),
new THREE.MeshBasicMaterial({ color: '#020617', transparent: true, opacity: 0.32, depthWrite: false }),
new THREE.MeshBasicMaterial({ color: '#020617', transparent: true, opacity: dicomOpacity.boxOpacity, depthWrite: false }),
);
dicomGroup.add(box);
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(box.geometry),
new THREE.LineBasicMaterial({ color: '#38bdf8', transparent: true, opacity: 0.46 }),
);
edges.visible = showBounds;
dicomGroup.add(edges);
const cutZ = volume.total <= 1
? 0
: -dicomDepth / 2 + (dicomDepth * clamp(cutSlice, 0, volume.total - 1)) / (volume.total - 1);
const clippingPlane = new THREE.Plane(new THREE.Vector3(0, 0, -1), cutZ);
const cutPlane = new THREE.Mesh(
new THREE.PlaneGeometry(dicomWidth, dicomHeight),
new THREE.MeshBasicMaterial({
color: '#f97316',
transparent: true,
opacity: cutEnabled ? 0.24 : 0,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
cutPlane.position.set(0, 0, cutZ);
cutPlane.visible = cutEnabled;
dicomGroup.add(cutPlane);
const textures: THREE.Texture[] = [];
volume.frames.forEach((frame, index) => {
const texture = createDicomTexture(frame, volume.width, volume.height);
@@ -183,7 +227,7 @@ function FusionThreeView({
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: isLast ? 0.82 : 0.12,
opacity: isLast ? dicomOpacity.sliceOpacity : dicomOpacity.volumeOpacity,
side: THREE.DoubleSide,
depthWrite: false,
});
@@ -227,6 +271,8 @@ function FusionThreeView({
roughness: solidMode ? 0.56 : 0.48,
metalness: 0.03,
side: THREE.DoubleSide,
clippingPlanes: cutEnabled ? [clippingPlane] : [],
clipShadows: true,
});
const mesh = new THREE.Mesh(geometry, material);
modelPivot.add(mesh);
@@ -262,8 +308,15 @@ function FusionThreeView({
object.geometry.computeVertexNormals();
}
});
const modelBounds = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
new THREE.LineBasicMaterial({ color: '#facc15', transparent: true, opacity: 0.72 }),
);
modelBounds.visible = showBounds;
modelPivot.add(modelBounds);
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
modelPoseGroup.position.set(0, 0, dicomDepth * 0.08);
modelPoseGroup.position.set(0, 0, 0);
modelPivot.position.set(0, 0, dicomDepth * 0.08);
setLoadProgress(100);
setStatus(stlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前项目没有 STL');
});
@@ -339,6 +392,12 @@ function FusionThreeView({
fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ);
fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0);
fusionRoot.scale.setScalar(rootPose.scale);
if (cutEnabled) {
fusionRoot.updateMatrixWorld(true);
const cutNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(fusionRoot.getWorldQuaternion(new THREE.Quaternion())).normalize();
const cutPoint = new THREE.Vector3(0, 0, cutZ).applyMatrix4(fusionRoot.matrixWorld);
clippingPlane.setFromNormalAndCoplanarPoint(cutNormal, cutPoint);
}
const pose = modelPoseRef.current;
modelPoseGroup.rotation.set(
@@ -349,7 +408,7 @@ function FusionThreeView({
modelPoseGroup.position.set(
pose.translateX,
pose.translateY,
dicomDepth * 0.08 + pose.translateZ,
pose.translateZ,
);
modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale);
renderer.render(scene, camera);
@@ -382,7 +441,20 @@ function FusionThreeView({
renderer.dispose();
container.innerHTML = '';
};
}, [project.id, project.stlFiles?.join('|'), volume, JSON.stringify(moduleStyles), detailLimit, solidMode]);
}, [
project.id,
project.stlFiles?.join('|'),
volume,
JSON.stringify(moduleStyles),
detailLimit,
solidMode,
dicomOpacity.sliceOpacity,
dicomOpacity.volumeOpacity,
dicomOpacity.boxOpacity,
showBounds,
cutEnabled,
cutSlice,
]);
return (
<div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
@@ -417,6 +489,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [sliceEnd, setSliceEnd] = useState(49);
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low');
const [showBounds, setShowBounds] = useState(true);
const [cutEnabled, setCutEnabled] = useState(false);
const [cutSlice, setCutSlice] = useState(0);
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
const [savedPoses, setSavedPoses] = useState<Array<{ id: string; name: string; pose: ModelPose }>>([
{ id: 'default', name: '默认', pose: defaultModelPose },
@@ -431,6 +507,9 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [fusionError, setFusionError] = useState('');
const [exporting, setExporting] = useState(false);
const [exportMessage, setExportMessage] = useState('准备就绪');
const [preloadMessage, setPreloadMessage] = useState('缓存空闲');
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
const [mappings] = useState<MaskMapping[]>([
{ className: '骨样组织', color: '#ff4d4f', maskId: 1 },
@@ -477,11 +556,30 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
});
};
const getFusionCacheKey = (projectIdValue: string, end: number, mode = 'soft') => `${projectIdValue}:${mode}:0:${end}`;
const loadFusionVolume = async (end: number, useCache = true) => {
if (!project?.dicomCount) return null;
const maxSliceValue = Math.max(project.dicomCount - 1, 0);
const safeEnd = clamp(end, 0, maxSliceValue);
const cacheKey = getFusionCacheKey(project.id, safeEnd);
const cached = fusionVolumeCacheRef.current.get(cacheKey);
if (useCache && cached) {
setFusionVolume(cached);
setPreloadMessage(`已使用缓存点位 ${safeEnd + 1}`);
return cached;
}
const volumePayload = await api.getDicomFusionVolume(project.id, 0, safeEnd, 'soft');
fusionVolumeCacheRef.current.set(cacheKey, volumePayload);
return volumePayload;
};
useEffect(() => {
api.getProject(projectId).then((item) => {
setProject(item);
const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0));
setSliceEnd(end);
setCutSlice(end);
setModelPose(defaultModelPose);
const nextStyles: Record<string, ModuleStyle> = {};
(item.stlFiles ?? []).forEach((fileName, index) => {
@@ -497,11 +595,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
useEffect(() => {
if (!project?.dicomCount) return;
const maxSlice = Math.max(project.dicomCount - 1, 0);
const safeStart = 0;
const safeEnd = clamp(sliceEnd, 0, maxSlice);
const timer = window.setTimeout(() => {
setFusionError('');
api.getDicomFusionVolume(project.id, safeStart, safeEnd, 'soft')
loadFusionVolume(safeEnd)
.then(setFusionVolume)
.catch((error) => {
setFusionVolume(null);
@@ -511,6 +608,15 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
return () => window.clearTimeout(timer);
}, [project?.id, project?.dicomCount, sliceEnd]);
useEffect(() => () => {
if (poseRepeatRef.current.timeout !== null) {
window.clearTimeout(poseRepeatRef.current.timeout);
}
if (poseRepeatRef.current.interval !== null) {
window.clearInterval(poseRepeatRef.current.interval);
}
}, []);
useEffect(() => {
if (isRegistering && progress < 100) {
const timer = setTimeout(() => setProgress((value) => value + 2), 50);
@@ -530,6 +636,59 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
setSelectedPoseId('custom');
};
const clampPoseValue = (key: ModelPoseKey, value: number) => {
const limit = poseStepConfig[key];
return clamp(value, limit.min, limit.max);
};
const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
setModelPose((current) => ({
...current,
[key]: clampPoseValue(key, current[key] + delta),
}));
setSelectedPoseId('custom');
};
const stopPoseRepeat = () => {
if (poseRepeatRef.current.timeout !== null) {
window.clearTimeout(poseRepeatRef.current.timeout);
poseRepeatRef.current.timeout = null;
}
if (poseRepeatRef.current.interval !== null) {
window.clearInterval(poseRepeatRef.current.interval);
poseRepeatRef.current.interval = null;
}
};
const startPoseRepeat = (key: ModelPoseKey, delta: number) => {
stopPoseRepeat();
poseRepeatRef.current.timeout = window.setTimeout(() => {
nudgeModelPose(key, delta);
poseRepeatRef.current.interval = window.setInterval(() => nudgeModelPose(key, delta), 90);
}, 360);
};
const resetRotationPose = () => {
setModelPose((current) => ({
...current,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
}));
setSelectedPoseId('custom');
};
const resetTransformPose = () => {
setModelPose((current) => ({
...current,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
}));
setSelectedPoseId('custom');
};
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
const stlFiles = project?.stlFiles ?? [];
const index = Math.max(0, stlFiles.indexOf(fileName));
@@ -557,6 +716,14 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
setSelectedPoseId(nextPose.id);
};
const renamePose = (poseId: string, name: string) => {
if (poseId === 'default') return;
const nextName = name.trim();
setSavedPoses((current) => current.map((item) => (
item.id === poseId ? { ...item, name: nextName || item.name } : item
)));
};
const selectPose = (poseId: string) => {
const selected = savedPoses.find((item) => item.id === poseId);
if (!selected) return;
@@ -568,6 +735,30 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const displayStart = 0;
const displayEnd = clamp(sliceEnd, 0, maxSlice);
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0];
const preloadPoints = [0.2, 0.4, 0.6, 0.8, 1].map((ratio) => clamp(Math.max(0, Math.round((project?.dicomCount ?? 1) * ratio) - 1), 0, maxSlice));
const preloadFusionPoint = async (end: number) => {
if (!project) return;
const safeEnd = clamp(end, 0, maxSlice);
setPreloadMessage(`正在预存第 ${safeEnd + 1} 张...`);
try {
await loadFusionVolume(safeEnd, false);
setPreloadMessage(`已预存第 ${safeEnd + 1}`);
} catch (error) {
setPreloadMessage(error instanceof Error ? error.message : '预存失败');
}
};
const preloadAllFusionPoints = async () => {
setPreloadMessage('正在预存五个点位...');
try {
await Promise.all(preloadPoints.map((point) => loadFusionVolume(point, true)));
setPreloadMessage('五个点位已预存');
} catch (error) {
setPreloadMessage(error instanceof Error ? error.message : '五点预存失败');
}
};
return (
<div className="h-full flex flex-col gap-6">
@@ -624,6 +815,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
moduleStyles={moduleStyles}
detailLimit={selectedDisplay.limit}
solidMode={displayLevel === 'solid'}
dicomOpacity={selectedDicomOpacity}
showBounds={showBounds}
cutEnabled={cutEnabled}
cutSlice={cutSlice}
/>
) : (
<div className="flex-1 rounded-3xl border border-slate-100 bg-white flex items-center justify-center text-sm text-slate-400">
@@ -660,6 +855,32 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
<p className="mt-3 text-[10px] leading-5 text-slate-400">
1 使
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
{preloadPoints.map((point, index) => {
const cached = project ? fusionVolumeCacheRef.current.has(getFusionCacheKey(project.id, point)) : false;
return (
<button
key={`${point}-${index}`}
onClick={() => {
setSliceEnd(point);
preloadFusionPoint(point);
}}
className={`rounded-lg border px-2 py-1 text-[10px] font-bold transition-all ${
cached ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : 'border-slate-200 bg-white text-slate-500 hover:text-blue-600'
}`}
>
{index + 1} · {point + 1}
</button>
);
})}
<button
onClick={preloadAllFusionPoints}
className="rounded-lg bg-blue-600 px-2 py-1 text-[10px] font-bold text-white hover:bg-blue-700"
>
</button>
<span className="text-[10px] font-bold text-slate-400">{preloadMessage}</span>
</div>
</div>
</div>
@@ -690,6 +911,59 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</div>
</div>
<div>
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400"></p>
<div className="grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1">
{dicomOpacityOptions.map((option) => (
<button
key={option.id}
onClick={() => setDicomOpacityLevel(option.id)}
className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
dicomOpacityLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
DICOM {option.label}
</button>
))}
</div>
<label className="mt-2 flex items-center justify-between rounded-lg bg-slate-50 px-2 py-1.5 text-[10px] font-bold text-slate-500">
DICOM/
<input
type="checkbox"
checked={showBounds}
onChange={(event) => setShowBounds(event.target.checked)}
className="accent-blue-600"
/>
</label>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400"></p>
<label className="flex items-center gap-1 text-[10px] font-bold text-slate-500">
<input
type="checkbox"
checked={cutEnabled}
onChange={(event) => setCutEnabled(event.target.checked)}
className="accent-orange-500"
/>
</label>
</div>
<label className="grid grid-cols-[42px_1fr_34px] items-center gap-2 text-[10px] font-bold text-slate-500">
<input
type="range"
min="0"
max={maxSlice}
value={clamp(cutSlice, 0, maxSlice)}
onChange={(event) => setCutSlice(Number(event.target.value))}
className="accent-orange-500"
/>
<span className="text-right font-mono">{clamp(cutSlice, 0, maxSlice) + 1}</span>
</label>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">姿</p>
@@ -708,38 +982,97 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
<button
onClick={() => {
setModelPose(defaultModelPose);
setSelectedPoseId('default');
}}
className="h-8 w-full rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
>
姿
</button>
{selectedPoseId !== 'default' && selectedPoseId !== 'custom' && (
<input
value={savedPoses.find((item) => item.id === selectedPoseId)?.name ?? ''}
onChange={(event) => renamePose(selectedPoseId, event.target.value)}
className="mb-2 h-8 w-full rounded-lg border border-slate-200 bg-white px-2 text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400"
placeholder="位姿名称"
/>
)}
<div className="grid grid-cols-2 gap-2">
<button
onClick={resetRotationPose}
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
>
姿
</button>
<button
onClick={resetTransformPose}
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
>
姿
</button>
</div>
<div className="mt-3 space-y-2">
{[
{ key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX },
{ key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY },
{ key: 'rotateZ' as const, label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ },
{ key: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX },
{ key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY },
{ key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ },
{ key: 'scale' as const, label: '缩放', min: 0.5, max: 2, step: 0.05, value: modelPose.scale },
{ key: 'rotateX' as const, label: '旋转 X', value: modelPose.rotateX },
{ key: 'rotateY' as const, label: '旋转 Y', value: modelPose.rotateY },
{ key: 'rotateZ' as const, label: '旋转 Z', value: modelPose.rotateZ },
{ key: 'translateX' as const, label: '平移 X', value: modelPose.translateX },
{ key: 'translateY' as const, label: '平移 Y', value: modelPose.translateY },
{ key: 'translateZ' as const, label: '平移 Z', value: modelPose.translateZ },
{ key: 'scale' as const, label: '缩放', value: modelPose.scale },
].map((item) => (
<label key={item.key} className="grid grid-cols-[44px_1fr_34px] items-center gap-2 text-[10px] font-bold text-slate-500">
{item.label}
<div key={item.key} className="grid grid-cols-[44px_32px_1fr_32px_34px] items-center gap-2 text-[10px] font-bold text-slate-500">
<span>{item.label}</span>
<button
onMouseDown={() => startPoseRepeat(item.key, -poseStepConfig[item.key].step)}
onMouseUp={stopPoseRepeat}
onMouseLeave={stopPoseRepeat}
onTouchStart={(event) => {
event.preventDefault();
startPoseRepeat(item.key, -poseStepConfig[item.key].step);
}}
onTouchEnd={stopPoseRepeat}
onClick={() => nudgeModelPose(item.key, -poseStepConfig[item.key].step)}
className="h-6 rounded-md bg-white text-[9px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].minus}`}
>
-
</button>
<input
type="range"
min={item.min}
max={item.max}
step={item.step}
min={poseStepConfig[item.key].min}
max={poseStepConfig[item.key].max}
step={poseStepConfig[item.key].step}
value={item.value}
onChange={(event) => updateModelPose({ [item.key]: Number(event.target.value) })}
className="accent-blue-600"
/>
<span className="text-right font-mono">{Number(item.value).toFixed(item.step < 1 ? 2 : 0)}</span>
</label>
<button
onMouseDown={() => startPoseRepeat(item.key, poseStepConfig[item.key].step)}
onMouseUp={stopPoseRepeat}
onMouseLeave={stopPoseRepeat}
onTouchStart={(event) => {
event.preventDefault();
startPoseRepeat(item.key, poseStepConfig[item.key].step);
}}
onTouchEnd={stopPoseRepeat}
onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].step)}
className="h-6 rounded-md bg-white text-[9px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].plus}`}
>
+
</button>
<span className="text-right font-mono">{Number(item.value).toFixed(poseStepConfig[item.key].step < 1 ? 2 : 0)}</span>
{poseStepConfig[item.key].quick && (
<div className="col-start-2 col-span-3 grid grid-cols-2 gap-1">
<button
onClick={() => nudgeModelPose(item.key, -(poseStepConfig[item.key].quick ?? 0))}
className="h-6 rounded-md bg-slate-50 text-[9px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
>
-90°
</button>
<button
onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].quick ?? 0)}
className="h-6 rounded-md bg-slate-50 text-[9px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
>
+90°
</button>
</div>
)}
</div>
))}
</div>
</div>

View File

@@ -4,6 +4,7 @@ import {
BarChart3,
FolderRoot,
Box,
Workflow,
Settings,
LogOut,
ChevronLeft,
@@ -32,7 +33,7 @@ export default function Sidebar({
{ id: ViewType.OVERVIEW, icon: BarChart3, label: '总体概况' },
{ id: ViewType.PROJECTS, icon: FolderRoot, label: '项目库' },
{ id: ViewType.MODELS, icon: Box, label: '模型库' },
{ id: ViewType.WORKSPACE, icon: Box, label: '逆向工作区' },
{ id: ViewType.WORKSPACE, icon: Workflow, label: '逆向工作区' },
{ id: ViewType.SYSTEM, icon: Settings, label: '系统管理工作区' },
];