2026-05-07-16-35-52 优化3D模型位姿控制

This commit is contained in:
2026-05-07 16:43:41 +08:00
parent aa0d51316e
commit e1c34f27bb
6 changed files with 386 additions and 41 deletions

View File

@@ -51,6 +51,8 @@ interface ModelPreviewPayload {
vertices: number[];
}
type ModelPoseKey = keyof ModelPose;
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [
{ id: 'preview', label: '预览', limit: 6000 },
@@ -67,6 +69,32 @@ const defaultModelPose: ModelPose = {
translateZ: 0,
scale: 1,
};
const modelPoseLimits: Record<ModelPoseKey, { min: number; max: number }> = {
rotateX: { min: -180, max: 180 },
rotateY: { min: -180, max: 180 },
rotateZ: { min: -180, max: 180 },
translateX: { min: -2, max: 2 },
translateY: { min: -2, max: 2 },
translateZ: { min: -2, max: 2 },
scale: { min: 0.5, max: 2.5 },
};
function clampModelPoseValue(key: ModelPoseKey, value: number) {
const limit = modelPoseLimits[key];
return Math.max(limit.min, Math.min(limit.max, value));
}
function clampModelPose(next: ModelPose): ModelPose {
return {
rotateX: clampModelPoseValue('rotateX', next.rotateX),
rotateY: clampModelPoseValue('rotateY', next.rotateY),
rotateZ: clampModelPoseValue('rotateZ', next.rotateZ),
translateX: clampModelPoseValue('translateX', next.translateX),
translateY: clampModelPoseValue('translateY', next.translateY),
translateZ: clampModelPoseValue('translateZ', next.translateZ),
scale: clampModelPoseValue('scale', next.scale),
};
}
function drawFallbackModelPreview(
canvas: HTMLCanvasElement,
@@ -240,15 +268,6 @@ function NativeStlViewer({
const container = containerRef.current;
if (!container) return;
const clampPose = (next: ModelPose): ModelPose => ({
rotateX: Math.max(-180, Math.min(180, next.rotateX)),
rotateY: Math.max(-180, Math.min(180, next.rotateY)),
rotateZ: Math.max(-180, Math.min(180, next.rotateZ)),
translateX: Math.max(-2, Math.min(2, next.translateX)),
translateY: Math.max(-2, Math.min(2, next.translateY)),
translateZ: Math.max(-2, Math.min(2, next.translateZ)),
scale: Math.max(0.5, Math.min(2.5, next.scale)),
});
const dragState = {
active: false,
mode: 'rotate' as 'rotate' | 'pan',
@@ -272,14 +291,14 @@ function NativeStlViewer({
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
if (dragState.mode === 'pan') {
onPoseChangeRef.current(clampPose({
onPoseChangeRef.current(clampModelPose({
...dragState.startPose,
translateX: dragState.startPose.translateX + deltaX * 0.006,
translateY: dragState.startPose.translateY - deltaY * 0.006,
}));
return;
}
onPoseChangeRef.current(clampPose({
onPoseChangeRef.current(clampModelPose({
...dragState.startPose,
rotateY: dragState.startPose.rotateY + deltaX * 0.35,
rotateX: dragState.startPose.rotateX + deltaY * 0.35,
@@ -294,7 +313,7 @@ function NativeStlViewer({
};
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
onPoseChangeRef.current(clampPose({
onPoseChangeRef.current(clampModelPose({
...poseRef.current,
scale: poseRef.current.scale - event.deltaY * 0.001,
}));
@@ -398,9 +417,11 @@ function NativeStlViewer({
fillLight.position.set(-4, 2, -3);
scene.add(fillLight);
const group = new THREE.Group();
const poseGroup = new THREE.Group();
const pivotGroup = new THREE.Group();
poseGroup.add(pivotGroup);
let baseScale = 1;
scene.add(group);
scene.add(poseGroup);
let loaded = 0;
let failed = 0;
@@ -429,26 +450,27 @@ function NativeStlViewer({
side: THREE.DoubleSide,
}),
);
group.add(mesh);
pivotGroup.add(mesh);
loaded += 1;
setProgress(Math.round(((loaded + failed) / visibleFiles.length) * 100));
setStatus(`已加载 ${loaded} / ${visibleFiles.length} 个 STL 预览`);
if (loaded + failed === visibleFiles.length) {
const box = new THREE.Box3().setFromObject(group);
const box = new THREE.Box3().setFromObject(pivotGroup);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxSize = Math.max(size.x, size.y, size.z) || 1;
group.traverse((object) => {
pivotGroup.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.translate(-center.x, -center.y, -center.z);
object.geometry.computeBoundingSphere();
object.geometry.computeVertexNormals();
}
});
group.position.set(0, 0, 0);
poseGroup.position.set(0, 0, 0);
pivotGroup.position.set(0, 0, 0);
baseScale = 4.2 / maxSize;
group.scale.setScalar(baseScale * poseRef.current.scale);
pivotGroup.scale.setScalar(baseScale * poseRef.current.scale);
camera.lookAt(0, 0, 0);
setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成');
}
@@ -472,13 +494,13 @@ function NativeStlViewer({
const animate = () => {
if (disposed) return;
const currentPose = poseRef.current;
group.rotation.set(
poseGroup.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ);
pivotGroup.rotation.set(
THREE.MathUtils.degToRad(currentPose.rotateX),
THREE.MathUtils.degToRad(currentPose.rotateY),
THREE.MathUtils.degToRad(currentPose.rotateZ),
);
group.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ);
group.scale.setScalar(baseScale * currentPose.scale);
pivotGroup.scale.setScalar(baseScale * currentPose.scale);
renderer.render(scene, camera);
animationId = window.requestAnimationFrame(animate);
};
@@ -489,7 +511,7 @@ function NativeStlViewer({
window.cancelAnimationFrame(animationId);
window.removeEventListener('resize', handleResize);
renderer.dispose();
group.traverse((object) => {
poseGroup.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.dispose();
const material = object.material;
@@ -523,6 +545,23 @@ function NativeStlViewer({
{status}
</div>
)}
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-slate-200 bg-white/85 px-3 py-2 shadow-sm">
<div className="mb-2 flex h-12 w-16 items-end justify-center">
<div className="relative h-10 w-10">
<span className="absolute left-5 top-5 h-px w-8 origin-left -rotate-[18deg] bg-red-500" />
<span className="absolute left-[19px] top-5 h-8 w-px origin-bottom bg-emerald-500" />
<span className="absolute left-5 top-5 h-px w-7 origin-left rotate-[42deg] bg-blue-500" />
<span className="absolute -right-3 top-3 text-[9px] font-black text-red-500">X</span>
<span className="absolute left-4 -top-2 text-[9px] font-black text-emerald-500">Y</span>
<span className="absolute right-0 bottom-0 text-[9px] font-black text-blue-500">Z</span>
</div>
</div>
<div className="space-y-0.5 font-mono text-[9px] text-slate-500">
<div>X {Math.round(pose.rotateX)}°</div>
<div>Y {Math.round(pose.rotateY)}°</div>
<div>Z {Math.round(pose.rotateZ)}°</div>
</div>
</div>
</div>
);
}
@@ -707,14 +746,36 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
};
const updateModelPose = (partial: Partial<ModelPose>) => {
setModelPose((current) => ({
setModelPose((current) => clampModelPose({
...current,
...partial,
}));
};
const resetModelPose = () => {
setModelPose(defaultModelPose);
const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
setModelPose((current) => clampModelPose({
...current,
[key]: clampModelPoseValue(key, current[key] + delta),
}));
};
const resetModelRotationPose = () => {
setModelPose((current) => ({
...current,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
}));
};
const resetModelTransformPose = () => {
setModelPose((current) => ({
...current,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
}));
};
const rotateDicom = (delta: number) => {
@@ -1145,26 +1206,41 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</div>
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-bold text-slate-700">姿</p>
<button
onClick={resetModelPose}
className="text-[10px] font-bold text-blue-600 hover:text-blue-700"
>
姿
</button>
<div className="flex items-center gap-1">
<button
onClick={resetModelRotationPose}
className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-blue-600 shadow-sm border border-slate-100 hover:bg-blue-50"
>
姿
</button>
<button
onClick={resetModelTransformPose}
className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-slate-600 shadow-sm border border-slate-100 hover:bg-slate-100"
>
姿
</button>
</div>
</div>
{[
{ key: 'rotateX', label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX },
{ key: 'rotateY', label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY },
{ key: 'rotateZ', label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ },
{ key: 'translateX', label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX },
{ key: 'translateY', label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY },
{ key: 'translateZ', label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ },
{ key: 'scale', label: '缩放', min: 0.5, max: 2.5, step: 0.05, value: modelPose.scale },
{ key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX, minus: '-90°', plus: '+90°', delta: 90 },
{ key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY, minus: '-90°', plus: '+90°', delta: 90 },
{ key: 'rotateZ' as const, label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ, minus: '-90°', plus: '+90°', delta: 90 },
{ key: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX, minus: '-X', plus: '+X', delta: 0.25 },
{ key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY, minus: '-Y', plus: '+Y', delta: 0.25 },
{ key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ, minus: '-Z', plus: '+Z', delta: 0.25 },
{ key: 'scale' as const, label: '缩放', min: 0.5, max: 2.5, step: 0.05, value: modelPose.scale, minus: '-0.1', plus: '+0.1', delta: 0.1 },
].map((item) => (
<div key={item.key} className="grid grid-cols-[48px_1fr_42px] items-center gap-2">
<div key={item.key} className="grid grid-cols-[48px_40px_1fr_40px_42px] items-center gap-2">
<span className="text-[10px] font-bold text-slate-500">{item.label}</span>
<button
onClick={() => nudgeModelPose(item.key, -item.delta)}
className="h-6 rounded-md bg-white text-[10px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
title={`${item.label} ${item.minus}`}
>
{item.minus}
</button>
<input
type="range"
min={item.min}
@@ -1174,6 +1250,13 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
onChange={(event) => updateModelPose({ [item.key]: Number(event.target.value) } as Partial<ModelPose>)}
className="w-full accent-blue-600"
/>
<button
onClick={() => nudgeModelPose(item.key, item.delta)}
className="h-6 rounded-md bg-white text-[10px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
title={`${item.label} ${item.plus}`}
>
{item.plus}
</button>
<span className="text-[10px] font-mono text-slate-400 text-right">{Number(item.value).toFixed(item.step < 1 ? 2 : 0)}</span>
</div>
))}