2 Commits

9 changed files with 443 additions and 15 deletions

View File

@@ -107,10 +107,28 @@ const modelPoseLimits: Record<ModelPoseKey, { min: number; max: number }> = {
translateZ: { min: -2, max: 2 }, translateZ: { min: -2, max: 2 },
scale: { min: 0.5, max: 2.5 }, scale: { min: 0.5, max: 2.5 },
}; };
const modelPoseStepPrecision: Partial<Record<ModelPoseKey, number>> = {
scale: 3,
};
function clampModelPoseValue(key: ModelPoseKey, value: number) { function clampModelPoseValue(key: ModelPoseKey, value: number) {
const limit = modelPoseLimits[key]; const limit = modelPoseLimits[key];
return Math.max(limit.min, Math.min(limit.max, value)); const clampedValue = Math.max(limit.min, Math.min(limit.max, value));
const precision = modelPoseStepPrecision[key];
return typeof precision === 'number' ? Number(clampedValue.toFixed(precision)) : clampedValue;
}
function getControlStepPrecision(step: number) {
if (step >= 1) {
return 0;
}
const text = step.toString();
if (text.includes('e-')) {
return Number(text.split('e-')[1] ?? 2);
}
return text.split('.')[1]?.length ?? 0;
} }
function clampModelPose(next: ModelPose): ModelPose { function clampModelPose(next: ModelPose): ModelPose {
@@ -1806,7 +1824,7 @@ export default function ProjectLibrary({
{ key: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX, minus: '-X', plus: '+X', delta: 0.25 }, { 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: '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: '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 }, { key: 'scale' as const, label: '缩放', min: 0.5, max: 2.5, step: 0.005, value: modelPose.scale, minus: '-0.005', plus: '+0.005', delta: 0.005 },
].map((item) => ( ].map((item) => (
<div key={item.key} className="grid grid-cols-[48px_40px_1fr_40px_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> <span className="text-[10px] font-bold text-slate-500">{item.label}</span>
@@ -1833,7 +1851,7 @@ export default function ProjectLibrary({
> >
{item.plus} {item.plus}
</button> </button>
<span className="text-[10px] font-mono text-slate-400 text-right">{Number(item.value).toFixed(item.step < 1 ? 2 : 0)}</span> <span className="text-[10px] font-mono text-slate-400 text-right">{Number(item.value).toFixed(getControlStepPrecision(item.step))}</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -80,7 +80,7 @@ const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: num
translateX: { min: -2, max: 2, step: 0.005, minus: '-X', plus: '+X' }, translateX: { min: -2, max: 2, step: 0.005, minus: '-X', plus: '+X' },
translateY: { min: -2, max: 2, step: 0.005, minus: '-Y', plus: '+Y' }, translateY: { min: -2, max: 2, step: 0.005, minus: '-Y', plus: '+Y' },
translateZ: { min: -2, max: 2, step: 0.005, minus: '-Z', plus: '+Z' }, translateZ: { min: -2, max: 2, step: 0.005, minus: '-Z', plus: '+Z' },
scale: { min: 0.5, max: 3, step: 0.05, minus: '-S', plus: '+S' }, scale: { min: 0.5, max: 3, step: 0.005, minus: '-S', plus: '+S' },
}; };
const defaultModelPose: ModelPose = { const defaultModelPose: ModelPose = {
@@ -538,6 +538,7 @@ export function FusionThreeView({
const modelPoseRef = useRef(modelPose); const modelPoseRef = useRef(modelPose);
const [status, setStatus] = useState('准备融合 DICOM 与 STL'); const [status, setStatus] = useState('准备融合 DICOM 与 STL');
const [loadProgress, setLoadProgress] = useState(0); const [loadProgress, setLoadProgress] = useState(0);
const [webglError, setWebglError] = useState<string | null>(null);
const [axisProjection, setAxisProjection] = useState<AxisProjection>(defaultAxisProjection); const [axisProjection, setAxisProjection] = useState<AxisProjection>(defaultAxisProjection);
const axisProjectionSignatureRef = useRef(axisProjectionSignature(defaultAxisProjection)); const axisProjectionSignatureRef = useRef(axisProjectionSignature(defaultAxisProjection));
const resetFusionViewRef = useRef<() => void>(() => undefined); const resetFusionViewRef = useRef<() => void>(() => undefined);
@@ -551,6 +552,7 @@ export function FusionThreeView({
if (!container || !volume) return; if (!container || !volume) return;
container.innerHTML = ''; container.innerHTML = '';
setWebglError(null);
setStatus('正在构建三维融合场景...'); setStatus('正在构建三维融合场景...');
setLoadProgress(8); setLoadProgress(8);
setAxisProjection(defaultAxisProjection); setAxisProjection(defaultAxisProjection);
@@ -568,7 +570,19 @@ export function FusionThreeView({
camera.up.set(0, 0, 1); camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0); camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
} catch {
const message = '当前浏览器无法创建 WebGL 三维上下文';
setStatus(message);
setLoadProgress(100);
setWebglError('三维融合视图暂不可用,请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与逆向分割映射功能仍可继续使用。');
resetFusionViewRef.current = () => undefined;
return () => {
container.innerHTML = '';
};
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height); renderer.setSize(width, height);
renderer.localClippingEnabled = true; renderer.localClippingEnabled = true;
@@ -897,6 +911,15 @@ export function FusionThreeView({
return ( return (
<div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl"> <div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" /> <div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
{webglError && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-slate-950/92 px-8 text-center">
<div className="max-w-md rounded-2xl border border-amber-300/20 bg-slate-900/90 p-6 text-amber-50 shadow-2xl">
<AlertCircle className="mx-auto mb-3 text-amber-300" size={30} />
<p className="text-sm font-bold"></p>
<p className="mt-3 text-xs leading-6 text-amber-100/75">{webglError}</p>
</div>
</div>
)}
<div className="pointer-events-none absolute left-4 top-4 rounded-xl border border-white/10 bg-black/60 px-3 py-2 text-[10px] font-mono text-white/60"> <div className="pointer-events-none absolute left-4 top-4 rounded-xl border border-white/10 bg-black/60 px-3 py-2 text-[10px] font-mono text-white/60">
{status} {status}
</div> </div>
@@ -953,6 +976,7 @@ function CutSectionPreview({
}) { }) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const modelPoseRef = useRef(modelPose); const modelPoseRef = useRef(modelPose);
const [webglError, setWebglError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
modelPoseRef.current = modelPose; modelPoseRef.current = modelPose;
@@ -963,6 +987,7 @@ function CutSectionPreview({
if (!container || !project || !volume) return; if (!container || !project || !volume) return;
container.innerHTML = ''; container.innerHTML = '';
setWebglError(null);
let disposed = false; let disposed = false;
let animationId = 0; let animationId = 0;
const scene = new THREE.Scene(); const scene = new THREE.Scene();
@@ -975,7 +1000,15 @@ function CutSectionPreview({
camera.up.set(0, 0, 1); camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0); camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
} catch {
setWebglError('当前浏览器无法创建 WebGL 三维上下文STL 切面三维预览暂不可用。');
return () => {
container.innerHTML = '';
};
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height); renderer.setSize(width, height);
renderer.localClippingEnabled = true; renderer.localClippingEnabled = true;
@@ -1218,6 +1251,13 @@ function CutSectionPreview({
return ( return (
<div className="relative h-full min-h-[420px] overflow-hidden rounded-3xl border border-slate-800 bg-slate-950 shadow-2xl"> <div className="relative h-full min-h-[420px] overflow-hidden rounded-3xl border border-slate-800 bg-slate-950 shadow-2xl">
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" /> <div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
{webglError && (
<div className="absolute inset-0 flex items-center justify-center px-6 text-center">
<div className="rounded-2xl border border-amber-300/20 bg-slate-900/90 p-5 text-xs font-bold leading-6 text-amber-100">
{webglError}
</div>
</div>
)}
{(!project || !volume) && ( {(!project || !volume) && (
<div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-white/40"> <div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-white/40">
STL ... STL ...
@@ -2545,19 +2585,19 @@ export default function ReverseWorkspace({
return volumePayload; return volumePayload;
}; };
const loadVisibleModelBounds = async () => { const loadGlobalModelBounds = async () => {
if (!project) { if (!project) {
return null; return null;
} }
const visibleFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false); const modelFiles = project.stlFiles ?? [];
const cacheKey = `${project.id}:${visibleFiles.join('|')}`; const cacheKey = `${project.id}:global:${modelFiles.join('|')}`;
const cached = modelBoundsCacheRef.current.get(cacheKey); const cached = modelBoundsCacheRef.current.get(cacheKey);
if (cached) { if (cached) {
return cached; return cached;
} }
const modelBox = new THREE.Box3(); const modelBox = new THREE.Box3();
const results = await Promise.allSettled(visibleFiles.map((fileName) => ( const results = await Promise.allSettled(modelFiles.map((fileName) => (
getCachedModelPreview(project.id, fileName, 1000) getCachedModelPreview(project.id, fileName, 1000)
))); )));
results.forEach((result) => { results.forEach((result) => {
@@ -2590,9 +2630,9 @@ export default function ReverseWorkspace({
setStretchingAxis(axis); setStretchingAxis(axis);
setFusionError(''); setFusionError('');
try { try {
const bounds = await loadVisibleModelBounds(); const bounds = await loadGlobalModelBounds();
if (!bounds) { if (!bounds) {
throw new Error('未获取到可见 STL 构件边界'); throw new Error('未获取到 STL 构件边界');
} }
const rawSize = new THREE.Vector3().subVectors(bounds.max, bounds.min); const rawSize = new THREE.Vector3().subVectors(bounds.max, bounds.min);
const rotatedSize = getRotatedModelSize(bounds, modelPose); const rotatedSize = getRotatedModelSize(bounds, modelPose);
@@ -2611,10 +2651,21 @@ export default function ReverseWorkspace({
}; };
const baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92; const baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92;
const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6); const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6);
const nextScale = clampPoseValue('scale', dicomSize[axis] / (rotatedAxisSize * baseScale)); const axisFitScale = dicomSize[axis] / (rotatedAxisSize * baseScale);
const containmentScale = Math.min(
dicomSize.x / (Math.max(rotatedSize.x, 1e-6) * baseScale),
dicomSize.y / (Math.max(rotatedSize.y, 1e-6) * baseScale),
dicomSize.z / (Math.max(rotatedSize.z, 1e-6) * baseScale),
);
const limitedByVolume = axisFitScale > containmentScale + 1e-6;
const nextScale = clampPoseValue('scale', Math.min(axisFitScale, containmentScale));
const nextPose = { ...modelPose, scale: nextScale }; const nextPose = { ...modelPose, scale: nextScale };
updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true }); updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true });
setPoseImportStatus(`已按 ${axis.toUpperCase()} 方向进行三维等比例拉伸`); setPoseImportStatus(
limitedByVolume
? `已按 ${axis.toUpperCase()} 方向拉伸,并限制在 DICOM 体范围内`
: `已按 ${axis.toUpperCase()} 方向进行三维等比例拉伸`,
);
if (options.silentInitial) { if (options.silentInitial) {
savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({ savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
modelPose: nextPose, modelPose: nextPose,
@@ -2752,7 +2803,8 @@ export default function ReverseWorkspace({
const clampPoseValue = (key: ModelPoseKey, value: number) => { const clampPoseValue = (key: ModelPoseKey, value: number) => {
const limit = poseStepConfig[key]; const limit = poseStepConfig[key];
return clamp(value, limit.min, limit.max); const precision = getStepPrecision(limit.step);
return Number(clamp(value, limit.min, limit.max).toFixed(precision));
}; };
const updateModelPose = (partial: Partial<ModelPose>, options: { markCustom?: boolean; keepStatus?: boolean } = {}) => { const updateModelPose = (partial: Partial<ModelPose>, options: { markCustom?: boolean; keepStatus?: boolean } = {}) => {

View File

@@ -0,0 +1,53 @@
# 实现方案-2026-05-21-00-58-25
实现方案文档路径:`工程分析/实现方案-2026-05-21-00-58-25.md`
## 修改目标
- 对系统进行静态、构建、API 与浏览器交互测试。
- 根据测试结果修复真实影响使用的问题。
- 更新软著说明书、登记表、代码汇总、素材清单,并重新生成说明书截图和 Word 文档。
- 按项目工作流提交代码与工程分析文档,软著材料不提交。
## 涉及路径
- 可能涉及:`WebSite/server.ts``WebSite/src/components/*``WebSite/src/lib/*``WebSite/src/types.ts`
- 工程分析:`工程分析/需求分析-2026-05-21-00-58-25.md``工程分析/实现方案-2026-05-21-00-58-25.md``工程分析/测试方案-2026-05-21-00-58-25.md``工程分析/经验记录.md`
- 软著材料:`新撰写软著文档/`,仅本地更新,不纳入 Gitea。
## 技术路线
1. 运行 `npm run lint``npm run build`、API 健康检查与关键接口抽查。
2. 使用浏览器自动化访问登录、总体概况、项目库、逆向工作区、系统管理等核心路径,观察控制台错误与页面状态。
3. 对发现的问题做小范围修复,避免引入新的数据结构风险。
4. 重新运行测试并部署。
5. 使用最新界面重新截取说明书图片,更新 Markdown 与 docx。
## 执行步骤
- 确认服务、依赖、脚本和软著材料现状。
- 执行静态与构建测试。
- 执行浏览器回归测试和页面截图。
- 修复测试中发现的缺陷。
- 重新生成软著文档和图片。
- 追加经验记录,精确暂存并提交代码/工程文档。
- 重新部署并验证服务。
## 兼容性与回滚方案
- 只修改明确发现的问题和文档内容,不改变默认医学数据。
- 如某项修复造成构建失败,回退该小范围 patch 并记录原因。
- 软著材料不提交,必要时可从本地历史文件恢复。
## 预计文件变更
- 新增本次工程分析三件套。
- 追加 `工程分析/经验记录.md`
- 视测试结果修改少量前后端文件。
- 本地更新 `新撰写软著文档/` 下 Markdown、docx 和图片。
## 提交与部署策略
- Gitea commit 只包含本次代码修复和工程分析文档。
- Commit message 包含 `2026-05-21-00-58-25` 与简要描述。
- 使用 `tmux` 会话 `revoxelseg-dicom` 重启 `npm run serve -- --host 0.0.0.0 --port 4000`

View File

@@ -0,0 +1,54 @@
# 实现方案-2026-05-21-09-29-47
实现方案文档路径:`工程分析/实现方案-2026-05-21-09-29-47.md`
## 修改目标
- 定位并修复自动拉伸按钮的尺度计算逻辑。
- 将缩放控件的展示精度和按钮调整步长统一为三位小数、`0.005`
- 完成静态检查、构建和部署验证。
## 涉及路径
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/components/ProjectLibrary.tsx`
- `工程分析/需求分析-2026-05-21-09-29-47.md`
- `工程分析/实现方案-2026-05-21-09-29-47.md`
- `工程分析/测试方案-2026-05-21-09-29-47.md`
- `工程分析/经验记录.md`
## 技术路线
1. 查找 `Z拉伸``Y拉伸``stretch``poseStepConfig``scale` 相关实现。
2. 对齐自动拉伸与三维融合视图的模型包围盒基准,避免可见构件和全局构件混用导致重复点击结果不同。
3. 将自动拉伸改为基于原始模型全局包围盒和 DICOM 物理尺寸计算目标缩放,使操作幂等。
4. 对单轴贴合增加 DICOM 体范围保护,避免 Y 等短轴贴合时把整体模型放大到超出视场。
5.`scale` 的步长改为 `0.005`,数值输入/展示统一三位小数。
6. 运行 `npm run lint``npm run build`,并通过接口/页面验证部署。
## 执行步骤
- 阅读相关源码和当前状态。
- 修改自动拉伸计算函数与缩放格式化函数。
- 确认保存快照和导出仍使用数值型 `scale`
- 构建并部署。
- 追加经验记录,提交并尝试推送 Gitea。
## 兼容性与回滚方案
- 修改仅限前端位姿计算与展示,不改变后端数据结构。
- 若自动拉伸回归失败,可回退本次 `ReverseWorkspace.tsx` 改动。
- 旧保存位姿仍可按数值读取,界面展示会统一为三位小数。
## 预计文件变更
- 修改 `WebSite/src/components/ReverseWorkspace.tsx`
- 修改 `WebSite/src/components/ProjectLibrary.tsx`
- 新增本次工程分析三件套。
- 追加 `工程分析/经验记录.md`
## 提交与部署策略
- 暂存本次代码与工程分析文档,避免历史删除、软著目录和压缩包进入提交。
- Commit message 包含 `2026-05-21-09-29-47` 与简要说明。
- 使用 `tmux` 会话 `revoxelseg-dicom` 重新部署端口 `4000`

View File

@@ -0,0 +1,63 @@
# 测试方案-2026-05-21-00-58-25
测试方案文档路径:`工程分析/测试方案-2026-05-21-00-58-25.md`
## 静态检查
- `cd WebSite && npm run lint`
- 检查 TypeScript 类型、组件属性和 API 调用签名。
## 构建检查
- `cd WebSite && npm run build`
- 确认 Vite 生产构建无错误。
## 关键业务场景验证
- 登录、总体概况、项目库三类数据页、逆向工作区、系统管理工作区可访问。
- 项目库导入 DICOM/STL 的入口、覆盖提醒、进度/失败反馈可见。
- 逆向分割结果视图、二维映射视图、Overlay Label Map、位置重置和切片导航布局符合当前功能。
- 用户管理新增、编辑、改密、冲突提示和删除限制逻辑无明显异常。
## 医学影像数据相关边界验证
- 默认项目有 DICOM/STL 时可预览,不存在资产时给出导入提示。
- 大体积/压缩包导入失败时主操作区保留错误,不造成浏览器无反馈。
- 导出项目及结果响应头文件名符合“项目名_时间”语义。
## 软著材料验证
- 说明书每个 `## X.` 章节均有图片引用。
- 图片文件存在且为最新重新拍摄。
- 代码汇总 Markdown 行数不少于 1600 行。
- 能重新生成 `.docx` 文件。
## 部署验证
- `http://127.0.0.1:4000/api/health`
- `http://127.0.0.1:4000/`
## Git/Gitea 备份验证
- 只暂存本次代码和工程分析文档。
- 不暂存 `新撰写软著文档/``3279-STL.zip`、默认医学数据或历史无关删除。
- `git push` 成功后记录 commit。
## 风险与回归关注点
- 三维/WebGL 页面加载可能较慢,截图前需等待稳定。
- 软著截图必须避免局部裁剪和敏感信息暴露。
- 修复导入或缓存逻辑时要避免跨项目串用旧资产。
## 实际执行结果
- `npm run lint`:通过。
- `npm run build`:通过,仅保留 Vite 大包体积提示。
- `/api/health`:通过。
- 项目列表接口:确认默认项目与用户项目可读取。
- 临时 ZIP/STL 导入回归:创建临时项目、上传含 `tiny.stl` 的 ZIP、返回 `modelCount=1` 后删除临时项目,通过。
- 重名用户接口回归:新增 `admin` 同名账号返回 409 与 `账号已存在`,通过。
- 导出响应头抽查:`export-bundle?targets=stl` 返回 `filename*` 中文文件名,通过。
- 浏览器自动化登录、项目库、DICOM 信息、3D 模型、逆向分割结果、逆向工作区、保存、导出面板、系统管理、退出均可访问SwiftShader WebGL 模式无 React fatal 错误。
- WebGL 异常模式:验证三维融合视图显示降级提示,页面未崩溃。
- 软著材料23 个章节、23 个图片引用、23 张 `chapter-*.png` 均为 1680x1050说明书 docx 中 23 个图片位置;代码汇总 10810 行;系统使用视频已刷新为约 48 秒 MP4。

View File

@@ -0,0 +1,52 @@
# 测试方案-2026-05-21-09-29-47
测试方案文档路径:`工程分析/测试方案-2026-05-21-09-29-47.md`
## 静态检查
- `cd WebSite && npm run lint`
## 构建检查
- `cd WebSite && npm run build`
## 关键业务场景验证
- 缩放按钮每次 `+` / `-` 调整 `0.005`
- 缩放输入/展示为三位小数。
- `Z拉伸` 对同一项目重复点击应保持幂等,不继续缩小或放大。
- `Y拉伸` 不应明显异常放大到超出 DICOM 体范围。
- 位姿保存和导出仍能读取 `scale` 数值。
## 医学影像数据相关边界验证
- 默认项目与项目 `123` 都不应修改原始 DICOM/STL 数据。
- 自动拉伸只基于 DICOM 物理尺寸与原始模型包围盒计算,不依赖当前查看视角缩放。
## 部署验证
- `http://127.0.0.1:4000/api/health`
- `http://127.0.0.1:4000/`
## Git/Gitea 备份验证
- 只提交本次相关代码和工程分析文档。
- 不提交软著材料、`3279-STL.zip`、历史删除或运行态产物。
## 风险与回归关注点
- 自动拉伸和用户缩放均写入 `modelPose.scale`,需避免把“当前缩放结果”当成下一次计算基准。
- 三位小数展示不能让内部数据变成字符串。
## 实际验证记录
- `npm run lint`:通过。
- `npm run build`通过Vite 生成生产构建;仅保留既有大 chunk 警告。
- 项目 `123` 尺寸复算:
- X 轴单轴贴合缩放约为 `1.087`
- Y 轴单轴贴合缩放约为 `1.591`,会造成用户反馈的明显放大。
- Z 轴贴合和 DICOM 三轴容纳上限约为 `0.937`
- 新逻辑会把 X/Y/Z 手动拉伸结果限制在 DICOM 体容纳上限内,避免 Y 拉伸越界放大,并让 Z 拉伸与初次自动 Z 拉伸保持一致。
- 缩放控件:
- 逆向工作区 `scale` 步长改为 `0.005`,输入框按三位小数展示。
- 项目库 3D 模型视图 `scale` 步长改为 `0.005`,显示按三位小数格式化,并对内部数值做三位小数取整。

View File

@@ -1441,3 +1441,39 @@ C. 解决问题方案
D. 后续如何避免问题 D. 后续如何避免问题
所有文件导入、导出、保存这类长操作失败时,都要在主操作路径旁保留显眼错误卡,不能只写入边栏或短暂 toast。后端对 4xx/5xx 的可恢复业务错误应记录足够上下文,尤其是项目 ID、导入类型、文件数量和原始异常消息方便从用户截图快速定位。 所有文件导入、导出、保存这类长操作失败时,都要在主操作路径旁保留显眼错误卡,不能只写入边栏或短暂 toast。后端对 4xx/5xx 的可恢复业务错误应记录足够上下文,尤其是项目 ID、导入类型、文件数量和原始异常消息方便从用户截图快速定位。
## 2026-05-21-00-58-25 三维视图需要 WebGL 降级保护且软著截图要强制视口
A. 具体问题
自动化浏览器回归进入逆向工作区时Headless Chrome 在默认图形参数下无法创建 WebGL 上下文,`FusionThreeView` 直接抛出 Three.js 渲染错误,导致工作区组件崩溃。软著截图第一次生成时浏览器实际截图尺寸为 1680x963与素材清单中约定的 1680x1050 不一致。
B. 产生问题原因
三维融合视图和 STL 切面预览此前默认 `new THREE.WebGLRenderer` 一定成功,没有捕获无 GPU、远程桌面、浏览器禁用硬件加速或 Headless 图形实现异常。截图脚本只设置了 Chrome 窗口大小,没有通过 DevTools `Emulation.setDeviceMetricsOverride` 强制页面视口,因此截图高度受 Headless 浏览器可用视区影响。
C. 解决问题方案
`FusionThreeView``CutSectionPreview` 初始化 WebGLRenderer 时增加 try/catch创建失败时显示三维视图不可用提示并保留二维 DICOM、逆向分割映射、保存和导出等功能继续可用。软著截图改用 Chrome Headless + SwiftShader WebGL 参数,并通过 DevTools 强制 1680x1050 视口截图后用文件尺寸、章节图片数量、Word drawing 数量和代码汇总行数做校验。
D. 后续如何避免问题
所有 WebGL/Three.js 视图都应把渲染器创建失败视为可恢复状态,不能让单个三维画布拖垮整个工作区。自动生成软著或验收截图时,除了设置浏览器窗口,还必须设置设备视口,并在生成后校验图片尺寸、非空像素、章节数量、图片引用和 docx 内嵌图片位置数量。
## 2026-05-21-09-29-47 自动拉伸要统一全局包围盒并限制短轴放大
A. 具体问题
项目 `123` 中,用户反馈初次进入逆向工作区时自动 Z 拉伸正常,但手动点击 `Z拉伸` 后仍像差一块;点击 `Y拉伸` 时模型整体会明显放大。同时用户要求 `缩放` 参数按三位小数展示,并且 `-` / `+` 每次只调整 `0.005`
B. 产生问题原因
自动拉伸此前容易在不同路径中混用可见构件包围盒和三维融合视图使用的全局 STL 包围盒,重复点击时计算基准和实际渲染基准不完全一致。另一方面,单轴贴合本质是三维等比例缩放;当 Y 轴是模型短轴时,直接按 Y 轴填满 DICOM 会把 X/Z 同步放大,表现为一键明显放大并可能超出视场。缩放控件也沿用了 `0.05` 步长和两位小数显示,不适合精细配准。
C. 解决问题方案
逆向工作区自动拉伸统一读取项目全局 STL 包围盒,并用与三维融合视图一致的 DICOM 物理尺寸和基础比例计算目标缩放。单轴贴合时先计算所选轴目标缩放,再计算 X/Y/Z 三轴都能容纳在 DICOM 体内的上限,最终取两者较小值,保证 `Y拉伸` 不会越界放大。`scale` 控件步长改为 `0.005`,输入和显示按三位小数格式化;项目库 3D 模型位姿也同步改为 `0.005` 步长并对内部缩放值做三位小数取整。
D. 后续如何避免问题
凡是涉及 STL 与 DICOM 尺度自动匹配的逻辑,都必须明确使用“全局包围盒”还是“可见构件包围盒”,并与实际渲染组件保持一致。单轴等比例贴合要考虑其他轴的视场约束,短轴贴合不能无限放大长轴;位姿数值控件的步长、显示精度、内部取整和导出数据要同步设计,避免 UI 看起来精细但实际状态漂移。

View File

@@ -0,0 +1,51 @@
# 需求分析-2026-05-21-00-58-25
开始时间2026-05-21-00-58-25
## 原始需求摘要
用户要求对当前程序进行细致测试,自主发现并修复问题;同时更新 `新撰写软著文档/` 中的所有材料,按最新功能重新细化说明,并重新拍摄软著说明书中的界面图片。
## 业务目标
- 提高当前系统在项目库、逆向工作区、导入导出、用户管理和医学影像/模型预览场景下的稳定性。
- 对自动测试、构建和浏览器交互进行回归检查,发现影响真实使用的问题后直接修复。
- 让软著说明书、登记表、代码汇总、素材清单和说明书截图与最新功能保持一致。
## 输入与输出
- 输入:
- 现有前后端代码与运行服务。
- 默认演示数据 `Head_CT_DICOM/``Head_CT_ReConstruct/`
- 现有软著材料目录 `新撰写软著文档/`
- 输出:
- 必要的程序修复。
- 本次工程分析三件套与经验记录追加。
- 更新后的软著 Markdown/Word/图片材料,但软著材料不纳入 Gitea 提交。
- 重新部署并验证后的服务。
## 影响范围
- `WebSite/` 前后端程序、测试和构建流程。
- 项目库、逆向工作区、导入导出、软著截图所依赖的页面展示。
- `新撰写软著文档/` 下的说明书、登记表、代码汇总、素材清单和截图。
## 关键约束
- 必须遵循 `工程分析/代码编纂工作流.md`
- 不提交软著撰写相关内容到 Gitea。
- 不混入历史无关删除、大型运行态产物、用户压缩包或默认医学数据的非必要变化。
- 修复时优先沿用现有 React/Express/TypeScript 结构。
## 风险点
- 大体积 DICOM/STL/ZIP 导入和三维预览容易造成浏览器内存压力。
- 软著截图可能包含敏感患者字段,截图前需尽量使用系统页面或脱敏内容。
- 当前工作树已有历史删除与未跟踪文件,提交时必须精确暂存。
- 若浏览器自动化无法加载 WebGL 或服务未启动,截图和交互验证需要先部署。
## 默认假设
- 用户授权本轮自主测试并修复发现的问题。
- `新撰写软著文档/` 可在本地更新,但不做 Gitea 备份提交。
- 默认部署端口仍为 `4000`tmux 会话优先使用 `revoxelseg-dicom`

View File

@@ -0,0 +1,49 @@
# 需求分析-2026-05-21-09-29-47
开始时间2026-05-21-09-29-47
## 原始需求摘要
用户反馈项目 `123` 中点击“Z拉伸”后模型在 DICOM 体高度方向仍差一块;初次进入项目时默认 Z 拉伸正常,但手动点击 Z 拉伸后异常。点击“Y拉伸”时图像明显放大。用户同时要求模型位姿中的“缩放”数值改为三位小数且通过 `-` / `+` 按钮每次只调整 `0.005`
## 业务目标
- 修复三维等比例自动拉伸按钮在重复点击或切换轴向时的尺度计算异常。
- 保持自动拉伸以未应用用户缩放前的模型/DICOM 基准为依据,不因当前 `modelPose.scale` 叠加而越拉越大或拉伸不足。
- 当单轴贴合会造成其他方向明显越界时,优先保持模型仍落在 DICOM 体视场范围内。
- 让缩放参数展示和微调粒度统一为 `0.005` 与三位小数,便于精细配准。
## 输入与输出
- 输入:
- 项目 `123` 的导入 DICOM/STL 数据。
- 当前逆向工作区模型位姿控件与自动拉伸逻辑。
- 输出:
- 修复后的自动拉伸算法。
- 缩放输入、按钮和显示精度调整。
- 测试、构建、部署与工程分析记录。
## 影响范围
- `WebSite/src/components/ReverseWorkspace.tsx`
- 逆向工作区与项目库复用的三维融合视图。
- 模型位姿保存、导出和重新进入工作区时的缩放值。
## 关键约束
- 不改变 DICOM/STL 原始数据。
- 不破坏当前旋转、平移、构件样式和逆向分割映射联动。
- 保持自动拉伸仅在旋转角度满足 90° 整数倍时可用。
## 风险点
- 自动拉伸若混用“当前位姿缩放后的包围盒”和“原始模型基准包围盒”,会导致重复点击后尺度漂移。
- 若只按短轴做等比例贴合,长轴会同步放大并超出 DICOM 体范围,表现为图像突然变大。
- 不同项目 STL 数量、模型大小和 DICOM 物理尺寸差异较大,算法需基于物理目标尺寸与原始模型尺寸计算。
- 缩放精度改动需同步输入框、按钮、滑条、保存快照和导出位姿。
## 默认假设
- “Z拉伸/Y拉伸”应是幂等操作同一轴重复点击不应继续改变缩放。
- 自动拉伸是三维等比例缩放,只改变 `modelPose.scale`,不改变旋转和平移。
- 三位小数展示可接受对已有保存位姿做格式化显示,不改变内部数值精度。