2026-05-07-16-53-23 修正3D模型中心和坐标轴

This commit is contained in:
2026-05-07 16:59:33 +08:00
parent e1c34f27bb
commit bbc7d215e9
6 changed files with 322 additions and 26 deletions

View File

@@ -49,6 +49,10 @@ interface ModelPreviewPayload {
triangleCount: number;
sampledTriangles: number;
vertices: number[];
bounds?: {
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
};
}
type ModelPoseKey = keyof ModelPose;
@@ -235,6 +239,53 @@ function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: n
);
}
function OrientationGizmo({ pose }: { pose: ModelPose }) {
const axes = useMemo(() => {
const rotation = new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(
THREE.MathUtils.degToRad(pose.rotateX),
THREE.MathUtils.degToRad(pose.rotateY),
THREE.MathUtils.degToRad(pose.rotateZ),
'XYZ',
));
return [
{ id: 'X', color: '#ef4444', vector: new THREE.Vector3(1, 0, 0).applyMatrix4(rotation) },
{ id: 'Y', color: '#10b981', vector: new THREE.Vector3(0, 1, 0).applyMatrix4(rotation) },
{ id: 'Z', color: '#3b82f6', vector: new THREE.Vector3(0, 0, 1).applyMatrix4(rotation) },
]
.map((axis) => ({
...axis,
endX: 38 + axis.vector.x * 24 + axis.vector.z * 10,
endY: 38 - axis.vector.y * 24 + axis.vector.z * 8,
labelX: 38 + axis.vector.x * 30 + axis.vector.z * 12,
labelY: 38 - axis.vector.y * 30 + axis.vector.z * 10,
opacity: 0.55 + Math.max(-axis.vector.z, 0) * 0.45,
}))
.sort((a, b) => b.vector.z - a.vector.z);
}, [pose.rotateX, pose.rotateY, pose.rotateZ]);
return (
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-slate-200 bg-white/90 px-3 py-2 shadow-sm">
<svg width="76" height="70" viewBox="0 0 76 70" className="mb-1 block">
<circle cx="38" cy="38" r="2.5" fill="#0f172a" opacity="0.28" />
{axes.map((axis) => (
<g key={axis.id} opacity={axis.opacity}>
<line x1="38" y1="38" x2={axis.endX} y2={axis.endY} stroke={axis.color} strokeWidth="2.2" strokeLinecap="round" />
<circle cx={axis.endX} cy={axis.endY} r="2.4" fill={axis.color} />
<text x={axis.labelX} y={axis.labelY} fill={axis.color} fontSize="10" fontWeight="900" textAnchor="middle" dominantBaseline="middle">
{axis.id}
</text>
</g>
))}
</svg>
<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>
);
}
function NativeStlViewer({
projectId,
files,
@@ -424,6 +475,7 @@ function NativeStlViewer({
scene.add(poseGroup);
let loaded = 0;
let failed = 0;
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
visibleFiles.forEach((fileName) => {
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`)
@@ -451,18 +503,42 @@ function NativeStlViewer({
}),
);
pivotGroup.add(mesh);
if (payload.bounds) {
loadedBounds.push({
min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z),
max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z),
});
} else {
geometry.computeBoundingBox();
const geometryBox = geometry.boundingBox;
if (geometryBox) {
loadedBounds.push({
min: geometryBox.min.clone(),
max: geometryBox.max.clone(),
});
}
}
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(pivotGroup);
const box = new THREE.Box3();
if (loadedBounds.length) {
loadedBounds.forEach((bounds) => {
box.expandByPoint(bounds.min);
box.expandByPoint(bounds.max);
});
} else {
box.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;
pivotGroup.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.translate(-center.x, -center.y, -center.z);
object.geometry.computeBoundingBox();
object.geometry.computeBoundingSphere();
object.geometry.computeVertexNormals();
}
@@ -545,23 +621,7 @@ 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>
<OrientationGizmo pose={pose} />
</div>
);
}
@@ -1217,7 +1277,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
</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"
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>