2026-05-07-16-35-52 优化3D模型位姿控制
This commit is contained in:
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user