2026-05-20-02-15-10 优化融合视角方向标识

This commit is contained in:
2026-05-20 02:20:52 +08:00
parent 1ddca18116
commit 66ad99f996
5 changed files with 293 additions and 21 deletions

View File

@@ -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" />
{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);
if (cutEnabled) {
fusionRoot.updateMatrixWorld(true);
if (cutEnabled) {
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">

View File

@@ -0,0 +1,56 @@
# 实现方案-2026-05-20-02-15-10
## 实现方案文档路径
`工程分析/实现方案-2026-05-20-02-15-10.md`
## 修改目标
缩小“影像与模型融合视角”右下角 XYZ 标识,并将其从静态 SVG 改为随模型位姿和三维场景旋转联动的方向指示器。
## 涉及路径
- `WebSite/src/components/ReverseWorkspace.tsx`
- `工程分析/需求分析-2026-05-20-02-15-10.md`
- `工程分析/实现方案-2026-05-20-02-15-10.md`
- `工程分析/测试方案-2026-05-20-02-15-10.md`
- `工程分析/经验记录.md`
## 技术路线
1. 新增轴向投影数据结构:
- 定义 X/Y/Z 三个方向在屏幕 inset SVG 中的投影向量。
- 提供默认轴向,避免 DICOM/模型未加载时空白。
2. 改造 `CoordinateAxesInset`
- 视觉尺寸由 72px 缩小到约 54px。
- 线段终点、标签位置和透明度由投影数据驱动。
3.`FusionThreeView` 中计算动态方向:
- 在 Three.js animation loop 中让 `fusionRoot``modelPoseGroup` 更新矩阵。
- 读取 `modelPoseGroup.getWorldPosition()``getWorldQuaternion()`
- 将模型局部 X/Y/Z 方向投影到当前 camera 屏幕空间。
- 仅在投影签名变化时更新 React 状态,减少无意义重渲染。
## 执行步骤
- 增加 AxisProjection 类型、默认值和投影辅助函数。
- 改造坐标轴 inset 的 SVG 尺寸和绘制逻辑。
- 在融合视图动画中调用投影计算并传给 inset。
- 执行 `npm run lint``npm run build`
- 追加经验记录、提交、推送并重新部署。
## 兼容性与回滚方案
- 如果动态投影出现异常,默认轴向仍可显示。
- 不修改 DICOM/STL 真实数据、模型位姿状态和实际渲染变换,仅增加可视化辅助。
- 回滚只需恢复 `CoordinateAxesInset` 为静态 SVG。
## 预计文件变更
- `ReverseWorkspace.tsx`:动态坐标轴投影与缩小样式。
- 工程分析文档:新增本次三份文档,更新经验记录。
## 提交与部署策略
- Commit message`2026-05-20-02-15-10 优化融合视角方向标识`
- 显式暂存本次相关文件,避免提交历史删除状态。
- 推送到 Gitea `origin/main`,并使用 `tmux` 会话 `revoxelseg-dicom` 重新部署。

View File

@@ -0,0 +1,52 @@
# 测试方案-2026-05-20-02-15-10
## 测试方案文档路径
`工程分析/测试方案-2026-05-20-02-15-10.md`
## 静态检查
-`WebSite/` 下执行 `npm run lint`
## 构建检查
-`WebSite/` 下执行 `npm run build`
## 关键业务场景验证
- “影像与模型融合视角”右下角 XYZ 标识尺寸比旧版更小。
- 调整模型旋转 X/Y/Z 后,右下角 XYZ 方向随模型世界方向变化。
- 拖拽旋转三维融合视角后,右下角 XYZ 方向随场景旋转变化。
- 右下角标识不阻挡画布拖拽、滚轮缩放、右键/Shift 平移。
## 医学影像数据相关边界验证
- 该改动不改变 DICOM 体数据、STL 模型加载、FOV、右侧映射视图和 NIfTI 导出逻辑。
- 模型位姿状态仍是右侧映射和分割导出的权威输入。
## 部署验证
- 重启 `tmux` 会话 `revoxelseg-dicom`
- 验证:
- `curl http://127.0.0.1:4000/api/health`
- `curl -I http://127.0.0.1:4000/`
## Git/Gitea 备份验证
- 显式暂存本次相关代码和文档。
- 创建包含时间戳和描述的 commit。
- 推送到 Gitea `origin/main`
## 实测结果
- `npm run lint`:通过。
- `npm run build`:通过;仅保留 Vite chunk size 提醒。
- 代码检查确认右下角方向标识尺寸从 72px 缩小为 54px。
- 代码检查确认方向标识由 `modelPoseGroup.getWorldPosition()``getWorldQuaternion()` 投影生成,会包含模型位姿和 `fusionRoot` 场景旋转。
- 代码检查确认方向投影使用签名去重,避免每帧无条件触发 React 状态更新。
## 风险与回归关注点
- 避免每帧无条件触发 React 状态更新。
- 避免提交历史工程文档删除状态。
- 保持右下角标识在 DICOM 未加载时也有默认可见状态。

View File

@@ -1081,3 +1081,21 @@ C. 解决问题方案
D. 后续如何避免问题
成对设计导入/导出功能时,应把导出样例作为导入测试用例,同时兼容最小可用对象。导入端必须对字段缺失、字符串数字、越界数值和重复 id 做归一化处理。
## 2026-05-20-02-15-10 三维方向标识必须来自真实世界矩阵
A. 具体问题
右下角 XYZ 标识原先是静态 SVG无法随模型旋转位姿和三维融合场景拖拽旋转变化用户调整模型后标识仍显示固定方向容易误导“平移 X/Y/Z 后模型会往哪里移动”的判断。
B. 产生问题原因
旧标识只是界面装饰,没有读取 Three.js 中 `fusionRoot``modelPoseGroup` 的实时变换。模型实际显示方向由场景根节点旋转、模型位姿旋转和相机投影共同决定,静态二维箭头无法表达这个组合结果。
C. 解决问题方案
新增轴向投影辅助函数,在动画循环中读取 `modelPoseGroup` 的 world position 与 world quaternion将模型局部 X/Y/Z 方向投影到当前 camera 屏幕空间,再驱动右下角小 SVG 的线段终点。使用投影签名去重,只有方向变化时才更新 React 状态。
D. 后续如何避免问题
凡是三维视图中的方向、法向、切面或平移提示,都应从 Three.js 真实对象矩阵或统一坐标变换链路推导,不能手写静态示意。若该提示会随拖拽视角变化,还必须包含场景根节点和相机投影。

View File

@@ -0,0 +1,46 @@
# 需求分析-2026-05-20-02-15-10
## 开始时间
2026-05-20-02-15-10
## 原始需求摘要
用户要求将“影像与模型融合视角”右下角的 XYZ 标识缩小,并让该标识根据模型旋转位姿和三维场景视角变化实时旋转,用来表达模型执行平移 X/Y/Z 后的实际移动方向。
## 业务目标
- 降低右下角方向标识对三维融合视图的遮挡。
- 将静态方向标识升级为动态方向指示器。
- 让用户在调整模型位姿和拖拽三维场景后,仍能理解平移 X/Y/Z 在当前视角下的方向含义。
## 输入与输出
- 输入:
- 可视化工具栏中的模型旋转位姿。
- 三维融合视图内鼠标拖拽产生的场景旋转。
- 输出:
- 右下角更小的 XYZ 指示器。
- 指示器中的 X/Y/Z 方向随当前模型世界方向投影实时更新。
## 影响范围
- `WebSite/src/components/ReverseWorkspace.tsx`
- 工程分析文档与经验记录。
## 关键约束
- 坐标轴标识不能影响三维场景交互,仍然保持 `pointer-events-none`
- 标识需要使用当前 `modelPoseGroup` 世界矩阵/四元数,不做另一套独立的视觉猜测。
- 不修改实际模型平移逻辑,只让方向标识准确反映当前视角下的方向。
## 风险点
- 如果每帧强制 React 大量重渲染,可能影响三维场景流畅度。
- 如果只读取模型位姿而不读取场景根节点旋转,用户拖动三维视角后标识会再次失真。
- 如果标识过小,标签可读性可能下降。
## 默认假设
- “三维场景中的移动”按当前融合视图的场景旋转/拖拽视角理解;场景平移不会改变方向,因此方向标识主要跟随旋转。
- 方向标识表达的是当前屏幕视角下 X/Y/Z 平移方向,不作为绝对医学坐标系标尺。