2026-05-20-02-15-10 优化融合视角方向标识
This commit is contained in:
@@ -30,6 +30,15 @@ type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||
type DicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||
type ModelPoseKey = keyof ModelPose;
|
||||
type PoseDraftValues = Record<ModelPoseKey, string>;
|
||||
type AxisKey = 'x' | 'y' | 'z';
|
||||
|
||||
interface AxisVector2D {
|
||||
dx: number;
|
||||
dy: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
type AxisProjection = Record<AxisKey, AxisVector2D>;
|
||||
|
||||
const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale'];
|
||||
|
||||
@@ -76,6 +85,12 @@ const exportOptions: Array<{ id: ProjectExportTarget; label: string; description
|
||||
];
|
||||
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||
const fusionBaseExtent = 4.6;
|
||||
const axisInsetLength = 17;
|
||||
const defaultAxisProjection: AxisProjection = {
|
||||
x: { dx: axisInsetLength, dy: 0, opacity: 0.95 },
|
||||
y: { dx: -10, dy: 10, opacity: 0.82 },
|
||||
z: { dx: 0, dy: -axisInsetLength, opacity: 0.95 },
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
@@ -198,6 +213,50 @@ function parseImportedPosePayload(payload: unknown) {
|
||||
return { activePose, importedModelPoses };
|
||||
}
|
||||
|
||||
function projectModelAxisDirections(camera: THREE.Camera, object: THREE.Object3D): AxisProjection {
|
||||
const origin = object.getWorldPosition(new THREE.Vector3());
|
||||
const originProjected = origin.clone().project(camera);
|
||||
const quaternion = object.getWorldQuaternion(new THREE.Quaternion());
|
||||
const axisDirections: Record<AxisKey, THREE.Vector3> = {
|
||||
x: new THREE.Vector3(1, 0, 0),
|
||||
y: new THREE.Vector3(0, 1, 0),
|
||||
z: new THREE.Vector3(0, 0, 1),
|
||||
};
|
||||
|
||||
const projectAxis = (direction: THREE.Vector3): AxisVector2D => {
|
||||
const end = origin.clone().add(direction.applyQuaternion(quaternion).normalize().multiplyScalar(0.72));
|
||||
const endProjected = end.project(camera);
|
||||
const dx = endProjected.x - originProjected.x;
|
||||
const dy = originProjected.y - endProjected.y;
|
||||
const magnitude = Math.hypot(dx, dy);
|
||||
|
||||
if (magnitude < 0.0001) {
|
||||
return { dx: 0, dy: -5, opacity: 0.5 };
|
||||
}
|
||||
|
||||
return {
|
||||
dx: (dx / magnitude) * axisInsetLength,
|
||||
dy: (dy / magnitude) * axisInsetLength,
|
||||
opacity: endProjected.z < originProjected.z ? 1 : 0.58,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
x: projectAxis(axisDirections.x),
|
||||
y: projectAxis(axisDirections.y),
|
||||
z: projectAxis(axisDirections.z),
|
||||
};
|
||||
}
|
||||
|
||||
function axisProjectionSignature(projection: AxisProjection) {
|
||||
return (['x', 'y', 'z'] as AxisKey[])
|
||||
.map((key) => {
|
||||
const item = projection[key];
|
||||
return `${Math.round(item.dx * 10)},${Math.round(item.dy * 10)},${Math.round(item.opacity * 100)}`;
|
||||
})
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function createDicomTexture(frame: string, width: number, height: number) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
@@ -227,28 +286,58 @@ function createDicomTexture(frame: string, width: number, height: number) {
|
||||
return texture;
|
||||
}
|
||||
|
||||
function CoordinateAxesInset() {
|
||||
function CoordinateAxesInset({ projection }: { projection: AxisProjection }) {
|
||||
const origin = { x: 25, y: 31 };
|
||||
const axisItems: Array<{ key: AxisKey; label: string; color: string; labelColor: string; markerId: string }> = [
|
||||
{ key: 'x', label: 'X', color: '#ef4444', labelColor: '#fecaca', markerId: 'fusion-axis-arrow-x' },
|
||||
{ key: 'y', label: 'Y', color: '#22c55e', labelColor: '#bbf7d0', markerId: 'fusion-axis-arrow-y' },
|
||||
{ key: 'z', label: 'Z', color: '#38bdf8', labelColor: '#bae6fd', markerId: 'fusion-axis-arrow-z' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute bottom-4 right-4 z-10 rounded-xl border border-white/10 bg-black/65 p-2 shadow-lg backdrop-blur-sm">
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" aria-hidden="true" className="block">
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-3 right-3 z-10 rounded-lg border border-white/10 bg-black/60 p-1.5 shadow-lg backdrop-blur-sm"
|
||||
title="当前视角下模型平移 XYZ 方向"
|
||||
>
|
||||
<svg width="54" height="54" viewBox="0 0 54 54" aria-hidden="true" className="block">
|
||||
<defs>
|
||||
<marker id="axis-arrow-red" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="#ef4444" />
|
||||
</marker>
|
||||
<marker id="axis-arrow-green" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="#22c55e" />
|
||||
</marker>
|
||||
<marker id="axis-arrow-blue" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L6,3 L0,6 Z" fill="#38bdf8" />
|
||||
</marker>
|
||||
{axisItems.map((item) => (
|
||||
<marker key={item.key} id={item.markerId} markerWidth="5" markerHeight="5" refX="4.3" refY="2.5" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L5,2.5 L0,5 Z" fill={item.color} />
|
||||
</marker>
|
||||
))}
|
||||
</defs>
|
||||
<circle cx="28" cy="44" r="3" fill="#e5e7eb" />
|
||||
<line x1="28" y1="44" x2="58" y2="44" stroke="#ef4444" strokeWidth="3" markerEnd="url(#axis-arrow-red)" />
|
||||
<line x1="28" y1="44" x2="14" y2="58" stroke="#22c55e" strokeWidth="3" markerEnd="url(#axis-arrow-green)" />
|
||||
<line x1="28" y1="44" x2="28" y2="12" stroke="#38bdf8" strokeWidth="3" markerEnd="url(#axis-arrow-blue)" />
|
||||
<text x="61" y="48" fill="#fecaca" fontSize="10" fontWeight="700">X</text>
|
||||
<text x="5" y="66" fill="#bbf7d0" fontSize="10" fontWeight="700">Y</text>
|
||||
<text x="24" y="10" fill="#bae6fd" fontSize="10" fontWeight="700">Z</text>
|
||||
<circle cx={origin.x} cy={origin.y} r="2.2" fill="#e5e7eb" />
|
||||
{axisItems.map((item) => {
|
||||
const vector = projection[item.key];
|
||||
const endX = origin.x + vector.dx;
|
||||
const endY = origin.y + vector.dy;
|
||||
const textAnchor = vector.dx >= 0 ? 'start' : 'end';
|
||||
return (
|
||||
<g key={item.key} opacity={vector.opacity}>
|
||||
<line
|
||||
x1={origin.x}
|
||||
y1={origin.y}
|
||||
x2={endX}
|
||||
y2={endY}
|
||||
stroke={item.color}
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
markerEnd={`url(#${item.markerId})`}
|
||||
/>
|
||||
<text
|
||||
x={endX + (vector.dx >= 0 ? 4 : -4)}
|
||||
y={endY + (vector.dy >= 0 ? 6 : -3)}
|
||||
fill={item.labelColor}
|
||||
fontSize="8"
|
||||
fontWeight="700"
|
||||
textAnchor={textAnchor}
|
||||
>
|
||||
{item.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
@@ -283,6 +372,8 @@ function FusionThreeView({
|
||||
const modelPoseRef = useRef(modelPose);
|
||||
const [status, setStatus] = useState('准备融合 DICOM 与 STL');
|
||||
const [loadProgress, setLoadProgress] = useState(0);
|
||||
const [axisProjection, setAxisProjection] = useState<AxisProjection>(defaultAxisProjection);
|
||||
const axisProjectionSignatureRef = useRef(axisProjectionSignature(defaultAxisProjection));
|
||||
|
||||
useEffect(() => {
|
||||
modelPoseRef.current = modelPose;
|
||||
@@ -295,6 +386,8 @@ function FusionThreeView({
|
||||
container.innerHTML = '';
|
||||
setStatus('正在构建三维融合场景...');
|
||||
setLoadProgress(8);
|
||||
setAxisProjection(defaultAxisProjection);
|
||||
axisProjectionSignatureRef.current = axisProjectionSignature(defaultAxisProjection);
|
||||
|
||||
let disposed = false;
|
||||
let animationId = 0;
|
||||
@@ -548,8 +641,8 @@ function FusionThreeView({
|
||||
fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ);
|
||||
fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0);
|
||||
fusionRoot.scale.setScalar(rootPose.scale);
|
||||
fusionRoot.updateMatrixWorld(true);
|
||||
if (cutEnabled) {
|
||||
fusionRoot.updateMatrixWorld(true);
|
||||
const rootQuaternion = fusionRoot.getWorldQuaternion(new THREE.Quaternion());
|
||||
const lowerNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(rootQuaternion).normalize();
|
||||
const upperNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(rootQuaternion).normalize();
|
||||
@@ -571,6 +664,13 @@ function FusionThreeView({
|
||||
pose.translateZ,
|
||||
);
|
||||
modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale);
|
||||
modelPoseGroup.updateMatrixWorld(true);
|
||||
const nextAxisProjection = projectModelAxisDirections(camera, modelPoseGroup);
|
||||
const nextAxisSignature = axisProjectionSignature(nextAxisProjection);
|
||||
if (axisProjectionSignatureRef.current !== nextAxisSignature) {
|
||||
axisProjectionSignatureRef.current = nextAxisSignature;
|
||||
setAxisProjection(nextAxisProjection);
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
animationId = window.requestAnimationFrame(animate);
|
||||
};
|
||||
@@ -626,7 +726,7 @@ function FusionThreeView({
|
||||
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
|
||||
DICOM {volume ? `${volume.start + 1}-${volume.end + 1}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
|
||||
</div>
|
||||
<CoordinateAxesInset />
|
||||
<CoordinateAxesInset projection={axisProjection} />
|
||||
{loadProgress < 100 && (
|
||||
<div className="absolute inset-x-10 bottom-8 rounded-xl border border-white/10 bg-black/70 p-3">
|
||||
<div className="mb-2 flex items-center justify-between text-[10px] font-bold text-white/70">
|
||||
|
||||
Reference in New Issue
Block a user