2026-05-07-16-53-23 修正3D模型中心和坐标轴
This commit is contained in:
@@ -705,22 +705,40 @@ function createStlPreview(filePath: string, fileName: string, limit: number) {
|
|||||||
const step = Math.max(1, Math.ceil(triangleCount / sampleLimit));
|
const step = Math.max(1, Math.ceil(triangleCount / sampleLimit));
|
||||||
const vertices: number[] = [];
|
const vertices: number[] = [];
|
||||||
let sampledTriangles = 0;
|
let sampledTriangles = 0;
|
||||||
|
const bounds = {
|
||||||
|
min: { x: Infinity, y: Infinity, z: Infinity },
|
||||||
|
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
||||||
|
};
|
||||||
|
|
||||||
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += step) {
|
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
|
||||||
const offset = 84 + triangleIndex * 50;
|
const offset = 84 + triangleIndex * 50;
|
||||||
if (offset + 50 > buffer.length) {
|
if (offset + 50 > buffer.length) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldSample = triangleIndex % step === 0;
|
||||||
for (let vertex = 0; vertex < 3; vertex += 1) {
|
for (let vertex = 0; vertex < 3; vertex += 1) {
|
||||||
const vertexOffset = offset + 12 + vertex * 12;
|
const vertexOffset = offset + 12 + vertex * 12;
|
||||||
vertices.push(
|
const x = buffer.readFloatLE(vertexOffset);
|
||||||
Number(buffer.readFloatLE(vertexOffset).toFixed(3)),
|
const y = buffer.readFloatLE(vertexOffset + 4);
|
||||||
Number(buffer.readFloatLE(vertexOffset + 4).toFixed(3)),
|
const z = buffer.readFloatLE(vertexOffset + 8);
|
||||||
Number(buffer.readFloatLE(vertexOffset + 8).toFixed(3)),
|
bounds.min.x = Math.min(bounds.min.x, x);
|
||||||
);
|
bounds.min.y = Math.min(bounds.min.y, y);
|
||||||
|
bounds.min.z = Math.min(bounds.min.z, z);
|
||||||
|
bounds.max.x = Math.max(bounds.max.x, x);
|
||||||
|
bounds.max.y = Math.max(bounds.max.y, y);
|
||||||
|
bounds.max.z = Math.max(bounds.max.z, z);
|
||||||
|
if (shouldSample) {
|
||||||
|
vertices.push(
|
||||||
|
Number(x.toFixed(3)),
|
||||||
|
Number(y.toFixed(3)),
|
||||||
|
Number(z.toFixed(3)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldSample) {
|
||||||
|
sampledTriangles += 1;
|
||||||
}
|
}
|
||||||
sampledTriangles += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -728,6 +746,18 @@ function createStlPreview(filePath: string, fileName: string, limit: number) {
|
|||||||
triangleCount,
|
triangleCount,
|
||||||
sampledTriangles,
|
sampledTriangles,
|
||||||
vertices,
|
vertices,
|
||||||
|
bounds: {
|
||||||
|
min: {
|
||||||
|
x: Number(bounds.min.x.toFixed(3)),
|
||||||
|
y: Number(bounds.min.y.toFixed(3)),
|
||||||
|
z: Number(bounds.min.z.toFixed(3)),
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
x: Number(bounds.max.x.toFixed(3)),
|
||||||
|
y: Number(bounds.max.y.toFixed(3)),
|
||||||
|
z: Number(bounds.max.z.toFixed(3)),
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
modelPreviewCache.set(cacheKey, payload);
|
modelPreviewCache.set(cacheKey, payload);
|
||||||
return payload;
|
return payload;
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ interface ModelPreviewPayload {
|
|||||||
triangleCount: number;
|
triangleCount: number;
|
||||||
sampledTriangles: number;
|
sampledTriangles: number;
|
||||||
vertices: number[];
|
vertices: number[];
|
||||||
|
bounds?: {
|
||||||
|
min: { x: number; y: number; z: number };
|
||||||
|
max: { x: number; y: number; z: number };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelPoseKey = keyof ModelPose;
|
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({
|
function NativeStlViewer({
|
||||||
projectId,
|
projectId,
|
||||||
files,
|
files,
|
||||||
@@ -424,6 +475,7 @@ function NativeStlViewer({
|
|||||||
scene.add(poseGroup);
|
scene.add(poseGroup);
|
||||||
let loaded = 0;
|
let loaded = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||||||
|
|
||||||
visibleFiles.forEach((fileName) => {
|
visibleFiles.forEach((fileName) => {
|
||||||
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`)
|
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`)
|
||||||
@@ -451,18 +503,42 @@ function NativeStlViewer({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
pivotGroup.add(mesh);
|
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;
|
loaded += 1;
|
||||||
setProgress(Math.round(((loaded + failed) / visibleFiles.length) * 100));
|
setProgress(Math.round(((loaded + failed) / visibleFiles.length) * 100));
|
||||||
setStatus(`已加载 ${loaded} / ${visibleFiles.length} 个 STL 预览`);
|
setStatus(`已加载 ${loaded} / ${visibleFiles.length} 个 STL 预览`);
|
||||||
|
|
||||||
if (loaded + failed === visibleFiles.length) {
|
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 center = box.getCenter(new THREE.Vector3());
|
||||||
const size = box.getSize(new THREE.Vector3());
|
const size = box.getSize(new THREE.Vector3());
|
||||||
const maxSize = Math.max(size.x, size.y, size.z) || 1;
|
const maxSize = Math.max(size.x, size.y, size.z) || 1;
|
||||||
pivotGroup.traverse((object) => {
|
pivotGroup.traverse((object) => {
|
||||||
if (object instanceof THREE.Mesh) {
|
if (object instanceof THREE.Mesh) {
|
||||||
object.geometry.translate(-center.x, -center.y, -center.z);
|
object.geometry.translate(-center.x, -center.y, -center.z);
|
||||||
|
object.geometry.computeBoundingBox();
|
||||||
object.geometry.computeBoundingSphere();
|
object.geometry.computeBoundingSphere();
|
||||||
object.geometry.computeVertexNormals();
|
object.geometry.computeVertexNormals();
|
||||||
}
|
}
|
||||||
@@ -545,23 +621,7 @@ function NativeStlViewer({
|
|||||||
{status}
|
{status}
|
||||||
</div>
|
</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">
|
<OrientationGizmo pose={pose} />
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1217,7 +1277,7 @@ export default function ProjectLibrary({ onReverse }: { onReverse: (projId: stri
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={resetModelTransformPose}
|
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>
|
</button>
|
||||||
|
|||||||
82
工程分析/实现方案-2026-05-07-16-53-23.md
Normal file
82
工程分析/实现方案-2026-05-07-16-53-23.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 实现方案 - 2026-05-07-16-53-23
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
修复 3D 模型在细节档位切换和鼠标旋转时的中心漂移问题,并完善右下角坐标系联动和按钮样式。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
### 1. 分离 Three.js 渲染容器和 React overlay
|
||||||
|
|
||||||
|
当前 `NativeStlViewer` 会对 `containerRef.current.innerHTML = ''`,容易把 React overlay 或其他子节点一起清掉。改为:
|
||||||
|
|
||||||
|
- `viewportRef`:只用于挂载 Three.js canvas。
|
||||||
|
- 外层 React 容器保留进度条、状态条、右下角坐标系等 overlay。
|
||||||
|
- 清理时只清理 `viewportRef` 内的 canvas。
|
||||||
|
|
||||||
|
### 2. 修正模型重载后的中心与缩放
|
||||||
|
|
||||||
|
模型加载完成后:
|
||||||
|
|
||||||
|
- 先把所有 mesh 加入 `pivotGroup`。
|
||||||
|
- 基于 `pivotGroup` 的整体包围盒计算 `center` 和 `size`。
|
||||||
|
- 将所有 mesh geometry 统一平移 `-center`,保证拼装后的整体中心落在局部原点。
|
||||||
|
- 使用 `pivotGroup.rotation` 和 `pivotGroup.scale`,使用 `poseGroup.position` 做平移。
|
||||||
|
- 每次切换细节档位时重建模型,但继承同一份 `modelPose`,并确保新模型仍以整体中心为 pivot。
|
||||||
|
|
||||||
|
### 3. 鼠标拖拽围绕模型中心旋转
|
||||||
|
|
||||||
|
- 鼠标左键拖拽只更新 `rotateX/rotateY`,不改变 `translateX/Y/Z`。
|
||||||
|
- 旋转继续作用在 `pivotGroup`,而不是同时承担平移的 group。
|
||||||
|
- 对 `poseRef.current` 做统一 clamp,保持鼠标、滑块、快捷按钮状态一致。
|
||||||
|
|
||||||
|
### 4. 坐标系随位姿同步旋转
|
||||||
|
|
||||||
|
新增一个轻量 SVG 坐标轴组件:
|
||||||
|
|
||||||
|
- 根据 `rotateX/Y/Z` 构造 Three.js `Euler` 和 `Matrix4`。
|
||||||
|
- 将 X/Y/Z 三个单位轴向量通过旋转矩阵变换后投影到 2D。
|
||||||
|
- 在右下角 SVG 中绘制红色 X、绿色 Y、蓝色 Z 三条轴。
|
||||||
|
- 继续显示 X/Y/Z 当前角度数值。
|
||||||
|
|
||||||
|
### 5. 调整按钮颜色
|
||||||
|
|
||||||
|
- 将 `重置平移缩放位姿` 的文字颜色改为 `text-blue-600 hover:text-blue-700`,与 `重置旋转位姿` 一致。
|
||||||
|
|
||||||
|
## 数据流或交互流程
|
||||||
|
|
||||||
|
1. 用户拖拽鼠标或点击位姿按钮。
|
||||||
|
2. `modelPose` 更新并 clamp。
|
||||||
|
3. `NativeStlViewer` 的动画循环读取 `poseRef.current`。
|
||||||
|
4. `poseGroup` 只应用平移,`pivotGroup` 只应用旋转和缩放。
|
||||||
|
5. 右下角坐标轴组件读取同一份 `pose`,同步更新方向。
|
||||||
|
6. 切换模型显示细节档时重新请求 STL preview,但模型仍以整体包围盒中心作为 pivot。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- 修改仅影响项目库 3D 模型页,不修改 DICOM 后端和 mask 导出。
|
||||||
|
- 若 SVG 坐标轴表现不佳,可回滚为静态坐标轴加角度文本。
|
||||||
|
- 若新的 viewportRef 清理方式导致 canvas 不显示,可回滚为原始 containerRef,但必须避免清掉 overlay。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- 新增 `OrientationGizmo`。
|
||||||
|
- `NativeStlViewer` 改用 `viewportRef`。
|
||||||
|
- 调整模型中心化和清理逻辑。
|
||||||
|
- 调整重置按钮样式。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
- 本次免二次确认,方案写入后直接执行。
|
||||||
|
|
||||||
|
## 执行结果
|
||||||
|
|
||||||
|
- 已在 `WebSite/server.ts` 为 STL preview 增加全量包围盒 `bounds`。
|
||||||
|
- 已在 `WebSite/src/components/ProjectLibrary.tsx` 使用所有可见 STL 的稳定全量包围盒计算整体中心和缩放基准。
|
||||||
|
- 已将右下角静态坐标轴替换为基于 `rotateX/Y/Z` 投影的动态 SVG 坐标轴。
|
||||||
|
- 已统一 `重置旋转位姿` 与 `重置平移缩放位姿` 的字体颜色。
|
||||||
51
工程分析/测试方案-2026-05-07-16-53-23.md
Normal file
51
工程分析/测试方案-2026-05-07-16-53-23.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 测试方案 - 2026-05-07-16-53-23
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
1. `git status --short --branch`
|
||||||
|
2. `cd WebSite && npm run build`
|
||||||
|
3. `cd WebSite && npm run lint`
|
||||||
|
|
||||||
|
## 单元或集成测试
|
||||||
|
|
||||||
|
当前项目没有独立单元测试体系,本次采用构建、类型检查、API 冒烟和浏览器人工验证。
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
1. 打开 `http://192.168.3.11:4000/`。
|
||||||
|
2. 进入 `项目库 - 3D 模型`。
|
||||||
|
3. 验证模型正常加载,且右下角坐标系可见。
|
||||||
|
4. 用滑块或 `±90°` 按钮旋转 X/Y/Z:
|
||||||
|
- 模型围绕自身中心旋转。
|
||||||
|
- 右下角坐标系同步改变方向。
|
||||||
|
5. 用鼠标左键拖拽旋转:
|
||||||
|
- 控件数值同步变化。
|
||||||
|
- 旋转中心保持在模型内部几何中心。
|
||||||
|
6. 在旋转后切换 `预览/标准/精细/超精细`:
|
||||||
|
- 模型不发生离谱偏移。
|
||||||
|
- 当前旋转位姿继续保留。
|
||||||
|
7. 检查两个重置按钮:
|
||||||
|
- `重置旋转位姿` 和 `重置平移缩放位姿` 字体颜色一致。
|
||||||
|
- 重置逻辑仍分别只影响对应位姿维度。
|
||||||
|
|
||||||
|
## 医学影像数据相关边界验证
|
||||||
|
|
||||||
|
- 本次不修改 DICOM 解析、切片显示、分割结果导出。
|
||||||
|
- 回归确认 `/api/projects` 能正常返回默认项目。
|
||||||
|
|
||||||
|
## 回归风险
|
||||||
|
|
||||||
|
- Three.js canvas 挂载容器变更可能导致尺寸计算或 resize 失效。
|
||||||
|
- SVG 坐标轴投影使用当前欧拉角,需保证与模型旋转顺序一致。
|
||||||
|
- 切换细节档时仍需重新请求 STL preview,加载中的短暂进度变化是预期行为。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
- 本次免二次确认。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- `npm run build`:通过。
|
||||||
|
- `npm run lint`:通过,实际执行 `tsc --noEmit`。
|
||||||
|
- 重新部署后 `curl -I http://127.0.0.1:4000/`:返回 `HTTP/1.1 200 OK`。
|
||||||
|
- 重新部署后请求 `会厌.stl` preview:返回 `bounds.min/max`,确认后端已提供稳定全量包围盒。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -595,3 +595,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
三维位姿控制应按旋转、平移、缩放分开管理;模型加载后必须先基于所有 STL 的整体包围盒做中心化,再把旋转和缩放应用到中心 pivot 上;涉及交互状态时,滑块、按钮和鼠标操作必须共享同一份 clamp 逻辑。
|
三维位姿控制应按旋转、平移、缩放分开管理;模型加载后必须先基于所有 STL 的整体包围盒做中心化,再把旋转和缩放应用到中心 pivot 上;涉及交互状态时,滑块、按钮和鼠标操作必须共享同一份 clamp 逻辑。
|
||||||
|
|
||||||
|
## 2026-05-07-16-53-23 STL 抽样中心漂移与动态坐标轴
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
用户旋转模型后切换 `预览/标准/精细/超精细`,模型位置会明显偏移;鼠标拖拽旋转时也感觉旋转中心没有落在模型内部;右下角坐标轴不随旋转位姿变化。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
前端用当前抽样出来的 STL 顶点计算整体包围盒,不同细节档位的抽样集合不同,导致中心点和缩放基准随档位变化;右下角坐标轴是固定 HTML/CSS 图形,没有基于当前旋转矩阵更新。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
后端 STL preview 在遍历完整二进制 STL 时同步计算全量 `bounds`,前端加载时使用所有可见 STL 的全量包围盒合成稳定中心和尺寸,再把抽样 geometry 平移到该中心;右下角坐标轴改为 SVG,根据 `rotateX/Y/Z` 生成 Three.js 旋转矩阵并投影 X/Y/Z 三轴。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
任何用于配准、视角稳定、旋转中心的几何参数都必须来自全量模型或稳定元数据,不能来自随性能档位变化的抽样数据;坐标系、状态文字和模型实际位姿应共用同一份 pose 状态。
|
||||||
|
|||||||
55
工程分析/需求分析-2026-05-07-16-53-23.md
Normal file
55
工程分析/需求分析-2026-05-07-16-53-23.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# 需求分析 - 2026-05-07-16-53-23
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
用户要求继续严格使用代码编纂工作流处理 3D 模型位姿问题,本次无需人工二次确认:
|
||||||
|
|
||||||
|
1. 单击旋转 X/Y/Z 效果正常,但旋转后切换 `模型显示` 的预览/标准/精细/超精细时,模型位置会明显偏移。
|
||||||
|
2. 使用鼠标拖拽旋转时,旋转中心感觉没有落在模型内部中心。
|
||||||
|
3. 将 `重置平移缩放位姿` 的字体颜色改为与 `重置旋转位姿` 一致。
|
||||||
|
4. 右下角坐标系需要随着旋转位姿变化而同步旋转。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
- 让 3D 模型在切换显示细节档位时保持当前视觉中心稳定,不出现跳走或离谱偏移。
|
||||||
|
- 让鼠标拖拽旋转围绕模型自身中心进行,而不是绕画布或偏移点旋转。
|
||||||
|
- 保持两个重置按钮视觉层级一致。
|
||||||
|
- 右下角坐标系成为真实的当前位姿方向参考。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
输入:
|
||||||
|
|
||||||
|
- 用户在 `项目库 - 3D 模型` 中切换模型显示细节档位。
|
||||||
|
- 用户通过滑块、快捷按钮、鼠标拖拽调整旋转位姿。
|
||||||
|
- 用户点击重置按钮。
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
- 切换 `预览/标准/精细/超精细` 后模型仍围绕同一中心展示。
|
||||||
|
- 鼠标拖拽旋转时模型围绕内部几何中心旋转。
|
||||||
|
- `重置平移缩放位姿` 与 `重置旋转位姿` 文字颜色一致。
|
||||||
|
- 右下角 X/Y/Z 坐标系随 `rotateX/Y/Z` 变化同步旋转。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `NativeStlViewer` 中 Three.js 模型中心化、相机、渲染层级、重载逻辑。
|
||||||
|
- 鼠标拖拽旋转的状态更新方式。
|
||||||
|
- 右下角坐标系 overlay。
|
||||||
|
- 整体位姿按钮样式。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- 若渲染容器重建时没有清理旧 DOM,可能出现 overlay 被清空或事件丢失。
|
||||||
|
- 若每个 STL 文件分别中心化,会破坏拼装关系;必须使用所有可见 STL 的整体包围盒中心。
|
||||||
|
- 若切换细节档位后继承了旧平移/缩放,但新模型 baseScale 不一致,视觉上仍可能变化,需要保持统一相机距离和 pivot 中心。
|
||||||
|
- HTML 坐标轴用 CSS 旋转只近似表达 3D 方位;更可靠方案是用 SVG 根据旋转矩阵投影三轴。
|
||||||
|
|
||||||
|
## 待确认问题
|
||||||
|
|
||||||
|
- 本次用户已明确“需求分析、实现方案、测试方案、执行修改都不用人工二次确认”,因此直接执行。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
- 本次免二次确认。
|
||||||
Reference in New Issue
Block a user