Compare commits

..

2 Commits

Author SHA1 Message Date
2e04e2d5f9 2026-05-19-23-47-31 优化逆向分割映射视图 2026-05-19 23:56:48 +08:00
f730a1c48b 2026-05-19-22-59-07 建立代码编纂工作流 2026-05-19 23:06:18 +08:00
12 changed files with 1016 additions and 85 deletions

23
AGENTS.md Normal file
View File

@@ -0,0 +1,23 @@
# 项目协作约束
本项目所有后续项目修改相关需求,都必须先执行 `工程分析/代码编纂工作流.md`
最低要求:
- 每次执行前记录开始时间,格式为 `{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`
- 阅读或创建 `工程分析/`,并阅读 `工程分析/工程整体分析.md``工程分析/经验记录.md`
- 写入当次 `需求分析-{timestamp}.md``实现方案-{timestamp}.md``测试方案-{timestamp}.md`
- 最终执行方案前再次确认已读 `经验记录.md`
- 执行后按 A/B/C/D 四段式追加 `工程分析/经验记录.md`
- 使用 Git/Gitea 对本次文档做备份 commitcommit message 必须包含时间戳和简要描述。
- 重新部署项目并验证服务。
当前项目部署优先使用:
```bash
cd WebSite
npm run build
npm run serve -- --host 0.0.0.0 --port 4000
```
若需要长期运行服务,优先沿用 `tmux` 会话 `revoxelseg-dicom`

View File

@@ -5,12 +5,14 @@ import {
Download,
Rotate3d,
AlertCircle,
Play,
ChevronLeft,
ChevronRight,
Eye,
Layers,
Save,
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, ModuleStyle, Project } from '../types';
import { DicomFusionVolume, DicomPreview, ModuleStyle, Project } from '../types';
import { api, downloadMask } from '../lib/api';
interface ModelPose {
@@ -792,9 +794,398 @@ function CutSectionPreview({
);
}
interface ModelBounds {
min: { x: number; y: number; z: number };
max: { x: number; y: number; z: number };
}
function getPayloadBounds(payload: ModelPreviewPayload): ModelBounds | null {
if (payload.bounds) {
return payload.bounds;
}
if (payload.vertices.length < 3) {
return null;
}
const bounds: ModelBounds = {
min: { x: Infinity, y: Infinity, z: Infinity },
max: { x: -Infinity, y: -Infinity, z: -Infinity },
};
for (let index = 0; index < payload.vertices.length; index += 3) {
const x = payload.vertices[index];
const y = payload.vertices[index + 1];
const z = payload.vertices[index + 2];
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);
}
return Number.isFinite(bounds.min.x) ? bounds : null;
}
function getGlobalModelBounds(files: string[], previews: Record<string, ModelPreviewPayload>) {
const bounds: ModelBounds = {
min: { x: Infinity, y: Infinity, z: Infinity },
max: { x: -Infinity, y: -Infinity, z: -Infinity },
};
let hasBounds = false;
files.forEach((fileName) => {
const payloadBounds = previews[fileName] ? getPayloadBounds(previews[fileName]) : null;
if (!payloadBounds) {
return;
}
hasBounds = true;
bounds.min.x = Math.min(bounds.min.x, payloadBounds.min.x);
bounds.min.y = Math.min(bounds.min.y, payloadBounds.min.y);
bounds.min.z = Math.min(bounds.min.z, payloadBounds.min.z);
bounds.max.x = Math.max(bounds.max.x, payloadBounds.max.x);
bounds.max.y = Math.max(bounds.max.y, payloadBounds.max.y);
bounds.max.z = Math.max(bounds.max.z, payloadBounds.max.z);
});
return hasBounds ? bounds : null;
}
function drawDicomBaseLayer(canvas: HTMLCanvasElement, preview: DicomPreview) {
canvas.width = preview.width;
canvas.height = preview.height;
const context = canvas.getContext('2d');
if (!context) {
return;
}
const binary = atob(preview.pixels);
const imageData = context.createImageData(preview.width, preview.height);
for (let index = 0; index < binary.length; index += 1) {
const value = binary.charCodeAt(index);
const offset = index * 4;
imageData.data[offset] = value;
imageData.data[offset + 1] = value;
imageData.data[offset + 2] = value;
imageData.data[offset + 3] = 255;
}
context.putImageData(imageData, 0, 0);
}
function drawVoxelOverlayLayer(
canvas: HTMLCanvasElement,
preview: DicomPreview,
files: string[],
previews: Record<string, ModelPreviewPayload>,
moduleStyles: Record<string, ModuleStyle>,
slice: number,
totalSlices: number,
) {
canvas.width = preview.width;
canvas.height = preview.height;
const context = canvas.getContext('2d');
if (!context) {
return { activeModules: 0, paintedTriangles: 0 };
}
context.clearRect(0, 0, preview.width, preview.height);
const globalBounds = getGlobalModelBounds(files, previews);
if (!globalBounds) {
return { activeModules: 0, paintedTriangles: 0 };
}
const spanX = Math.max(globalBounds.max.x - globalBounds.min.x, 0.001);
const spanY = Math.max(globalBounds.max.y - globalBounds.min.y, 0.001);
const spanZ = Math.max(globalBounds.max.z - globalBounds.min.z, 0.001);
const normalizedSlice = totalSlices <= 1 ? 0.5 : clamp(slice, 0, totalSlices - 1) / (totalSlices - 1);
const targetZ = globalBounds.min.z + normalizedSlice * spanZ;
const sliceBand = Math.max(spanZ / Math.max(totalSlices, 1) * 1.85, spanZ * 0.014, 0.001);
const paddingX = preview.width * 0.08;
const paddingY = preview.height * 0.08;
const drawableWidth = Math.max(preview.width - paddingX * 2, 1);
const drawableHeight = Math.max(preview.height - paddingY * 2, 1);
const mapX = (x: number) => paddingX + ((x - globalBounds.min.x) / spanX) * drawableWidth;
const mapY = (y: number) => preview.height - paddingY - ((y - globalBounds.min.y) / spanY) * drawableHeight;
let activeModules = 0;
let paintedTriangles = 0;
context.save();
context.lineJoin = 'round';
context.lineCap = 'round';
context.globalCompositeOperation = 'source-over';
files.forEach((fileName, index) => {
const payload = previews[fileName];
const style = moduleStyles[fileName] ?? {
visible: true,
color: moduleColors[index % moduleColors.length],
opacity: 0.72,
partId: index + 1,
};
if (!payload || style.visible === false) {
return;
}
let modulePainted = false;
context.fillStyle = style.color;
context.strokeStyle = style.color;
context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.5;
context.lineWidth = Math.max(preview.width, preview.height) * 0.0015;
for (let vertexIndex = 0; vertexIndex < payload.vertices.length; vertexIndex += 9) {
const z1 = payload.vertices[vertexIndex + 2];
const z2 = payload.vertices[vertexIndex + 5];
const z3 = payload.vertices[vertexIndex + 8];
const minZ = Math.min(z1, z2, z3);
const maxZ = Math.max(z1, z2, z3);
if (minZ > targetZ + sliceBand || maxZ < targetZ - sliceBand) {
continue;
}
context.beginPath();
context.moveTo(mapX(payload.vertices[vertexIndex]), mapY(payload.vertices[vertexIndex + 1]));
context.lineTo(mapX(payload.vertices[vertexIndex + 3]), mapY(payload.vertices[vertexIndex + 4]));
context.lineTo(mapX(payload.vertices[vertexIndex + 6]), mapY(payload.vertices[vertexIndex + 7]));
context.closePath();
context.fill();
context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.72;
context.stroke();
context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.5;
modulePainted = true;
paintedTriangles += 1;
}
if (modulePainted) {
activeModules += 1;
}
});
context.restore();
return { activeModules, paintedTriangles };
}
function VoxelizationMappingView({
project,
moduleStyles,
detailLimit,
slice,
totalSlices,
onSliceChange,
}: {
project: Project | null;
moduleStyles: Record<string, ModuleStyle>;
detailLimit: number;
slice: number;
totalSlices: number;
onSliceChange: (slice: number) => void;
}) {
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
const [modelPreviews, setModelPreviews] = useState<Record<string, ModelPreviewPayload>>({});
const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
const [overlayStats, setOverlayStats] = useState({ activeModules: 0, paintedTriangles: 0 });
const maxSlice = Math.max(totalSlices - 1, 0);
const safeSlice = clamp(slice, 0, maxSlice);
const stlFiles = project?.stlFiles ?? [];
const visibleModuleCount = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false).length;
useEffect(() => {
if (!project?.dicomCount) {
setDicomPreview(null);
setDicomStatus('没有可显示的 DICOM 切片');
return;
}
let disposed = false;
setDicomStatus('正在载入 DICOM Base Layer...');
api.getDicomPreview(project.id, safeSlice, 'axial', 'soft')
.then((preview) => {
if (disposed) return;
setDicomPreview(preview);
setDicomStatus('DICOM Base Layer 已就绪');
})
.catch((error) => {
if (disposed) return;
setDicomPreview(null);
setDicomStatus(error instanceof Error ? error.message : 'DICOM 切片载入失败');
});
return () => {
disposed = true;
};
}, [project?.id, project?.dicomCount, safeSlice]);
useEffect(() => {
if (!project || !stlFiles.length) {
setModelPreviews({});
setOverlayStatus('当前项目没有 STL 构件');
return;
}
let disposed = false;
setOverlayStatus('正在载入 STL 构件层级...');
Promise.allSettled(stlFiles.map((fileName) => (
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 72000)}`)
.then((response) => {
if (!response.ok) {
throw new Error('STL 构件预览加载失败');
}
return response.json() as Promise<ModelPreviewPayload>;
})
.then((payload) => ({ fileName, payload }))
))).then((results) => {
if (disposed) return;
const nextPreviews: Record<string, ModelPreviewPayload> = {};
results.forEach((result) => {
if (result.status === 'fulfilled') {
nextPreviews[result.value.fileName] = result.value.payload;
}
});
setModelPreviews(nextPreviews);
setOverlayStatus(Object.keys(nextPreviews).length ? 'Overlay Label Map 已就绪' : 'Overlay Layer 无可用 STL 数据');
});
return () => {
disposed = true;
};
}, [project?.id, stlFiles.join('|'), detailLimit]);
useEffect(() => {
const canvas = baseCanvasRef.current;
if (!canvas || !dicomPreview) {
return;
}
drawDicomBaseLayer(canvas, dicomPreview);
}, [dicomPreview]);
useEffect(() => {
const canvas = overlayCanvasRef.current;
if (!canvas || !dicomPreview) {
return;
}
const stats = drawVoxelOverlayLayer(
canvas,
dicomPreview,
stlFiles,
modelPreviews,
moduleStyles,
safeSlice,
Math.max(totalSlices, 1),
);
setOverlayStats(stats);
}, [dicomPreview, stlFiles.join('|'), modelPreviews, JSON.stringify(moduleStyles), safeSlice, totalSlices]);
const stepSlice = (delta: number) => {
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
};
const slicePercent = maxSlice > 0 ? (safeSlice / maxSlice) * 100 : 0;
return (
<div className="relative flex h-full min-h-[420px] flex-col overflow-hidden rounded-3xl border border-slate-900 bg-slate-950 shadow-2xl">
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
<div className="absolute left-4 top-4 z-10 flex flex-wrap gap-2">
<span className="rounded-lg border border-white/10 bg-black/65 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-200">
Base DICOM
</span>
<span className="rounded-lg border border-cyan-300/20 bg-cyan-950/70 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-cyan-100">
Overlay Label Map
</span>
</div>
<div className="absolute right-4 top-4 z-10 rounded-lg border border-white/10 bg-black/65 px-2.5 py-1 text-[10px] font-mono text-white/70">
Z {safeSlice + 1}/{Math.max(totalSlices, 1)}
</div>
{dicomPreview ? (
<div className="absolute inset-0 flex items-center justify-center">
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain mix-blend-screen" />
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center px-8 text-center text-xs font-bold text-white/40">
{dicomStatus}
</div>
)}
<div className="pointer-events-none absolute inset-x-4 bottom-4 z-10 rounded-2xl border border-white/10 bg-black/70 p-3 backdrop-blur-sm">
<div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-white/70">
<span className="truncate">{overlayStatus}</span>
<span className="font-mono text-cyan-100">
{overlayStats.activeModules}/{visibleModuleCount} · {overlayStats.paintedTriangles}
</span>
</div>
<div className="grid max-h-20 grid-cols-1 gap-1 overflow-auto pr-1">
{stlFiles.map((fileName, index) => {
const style = moduleStyles[fileName] ?? {
visible: true,
color: moduleColors[index % moduleColors.length],
opacity: 0.72,
partId: index + 1,
};
return (
<div key={fileName} className={`flex items-center gap-2 text-[9px] font-bold ${style.visible ? 'text-white/70' : 'text-white/25'}`}>
<span className="h-2.5 w-2.5 rounded-sm border border-white/20" style={{ backgroundColor: style.color, opacity: style.visible ? style.opacity : 0.25 }} />
<span className="min-w-0 flex-1 truncate">{fileName.replace(/\.stl$/i, '')}</span>
<span className="font-mono">ID {style.partId}</span>
</div>
);
})}
</div>
</div>
</div>
<div className="border-t border-white/10 bg-slate-950 px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Slice Navigator</p>
<span className="font-mono text-[10px] font-bold text-cyan-100">
{safeSlice + 1} / {Math.max(totalSlices, 1)}
</span>
</div>
<div className="grid grid-cols-[28px_1fr_28px] items-center gap-3">
<button
onClick={() => stepSlice(-1)}
disabled={safeSlice <= 0}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
title="上一层"
>
<ChevronLeft size={15} />
</button>
<div className="relative h-8">
<div className="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-slate-800" />
<div
className="absolute top-1/2 h-2 -translate-y-1/2 rounded-full bg-cyan-400"
style={{ left: 0, width: `${slicePercent}%` }}
/>
<input
type="range"
min="0"
max={maxSlice}
value={safeSlice}
onChange={(event) => onSliceChange(Number(event.target.value))}
className="mapping-slice-input"
aria-label="逆向分割映射视图切片导航"
/>
</div>
<button
onClick={() => stepSlice(1)}
disabled={safeSlice >= maxSlice}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
title="下一层"
>
<ChevronRight size={15} />
</button>
</div>
</div>
</div>
);
}
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const [sliceStart, setSliceStart] = useState(0);
const [sliceEnd, setSliceEnd] = useState(49);
const [mappingSlice, setMappingSlice] = useState(0);
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low');
@@ -879,6 +1270,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
setSliceStart(0);
setSliceEnd(maxIndex);
setMappingSlice(maxIndex);
setModelPose(defaultModelPose);
const nextStyles: Record<string, ModuleStyle> = {};
(item.stlFiles ?? []).forEach((fileName, index) => {
@@ -1034,6 +1426,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
const safeSliceStart = clamp(sliceStart, 0, maxSlice);
const safeSliceEnd = clamp(sliceEnd, 0, maxSlice);
const safeMappingSlice = clamp(mappingSlice, 0, maxSlice);
const displayStart = Math.min(safeSliceStart, safeSliceEnd);
const displayEnd = Math.max(safeSliceStart, safeSliceEnd);
const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0;
@@ -1416,8 +1809,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
<div className="lg:col-span-3 flex flex-col gap-4 overflow-hidden">
<div className="px-2 flex items-center justify-between shrink-0">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<Play size={18} className="text-blue-500" />
Mask
<Layers size={18} className="text-cyan-500" />
</h3>
<div className="flex gap-2">
<button
@@ -1439,15 +1832,13 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
</div>
</div>
<CutSectionPreview
<VoxelizationMappingView
project={project}
volume={fusionVolume}
modelPose={modelPose}
moduleStyles={moduleStyles}
detailLimit={selectedDisplay.limit}
cutEnabled={cutEnabled}
cutStart={displayStart}
cutEnd={displayEnd}
slice={safeMappingSlice}
totalSlices={project?.dicomCount ?? 0}
onSliceChange={setMappingSlice}
/>
</div>
</div>

View File

@@ -59,3 +59,60 @@
.dicom-range-input:active::-moz-range-thumb {
cursor: grabbing;
}
.mapping-slice-input {
appearance: none;
-webkit-appearance: none;
background: transparent;
height: 100%;
inset: 0;
position: absolute;
width: 100%;
}
.mapping-slice-input:focus {
outline: none;
}
.mapping-slice-input::-webkit-slider-runnable-track {
background: transparent;
border: 0;
height: 8px;
}
.mapping-slice-input::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
background: #22d3ee;
border: 3px solid #0f172a;
border-radius: 9999px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
cursor: grab;
height: 22px;
margin-top: -7px;
width: 22px;
}
.mapping-slice-input::-moz-range-track {
background: transparent;
border: 0;
height: 8px;
}
.mapping-slice-input::-moz-range-thumb {
background: #22d3ee;
border: 3px solid #0f172a;
border-radius: 9999px;
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
cursor: grab;
height: 16px;
width: 16px;
}
.mapping-slice-input:active::-webkit-slider-thumb {
cursor: grabbing;
}
.mapping-slice-input:active::-moz-range-thumb {
cursor: grabbing;
}

View File

@@ -1,60 +1,68 @@
# 代码编纂工作流
本工作流适用于后续所有项目修改相关需求。除非用户明确说明跳过流程,否则每次修改都按以下步骤执行。
更新时间2026-05-19-22-59-07
本工作流适用于后续所有项目修改相关需求。只要用户提出的是项目修改、修复、重构、部署、文档治理或功能调整,都必须按本流程走;除非用户明确要求跳过其中某一步。
## 0. 记录开始时间
- 每次执行前先记录问题开始时间,格式为 `{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}`
- 时间戳用于贯穿需求分析、实现方案、测试方案、经验记录和 Git 提交信息
- 时间以当前执行环境为准
- 同一个时间戳贯穿当次需求分析、实现方案、测试方案、经验记录、commit message 和最终汇报。
## 1. 阅读工程分析
## 1. 阅读或创建工程分析目录
- 每次修改前阅读或创建 `工程分析/` 文件夹
- 每次修改前确认 `工程分析/` 存在,不存在则创建
- 优先阅读:
- `工程分析/代码编纂工作流.md`
- `工程分析/工程整体分析.md`
- `工程分析/经验记录.md`
- 与当前需求相关的历史 `需求分析-*``实现方案-*``测试方案-*` 文档
- 与当前需求相关的历史 `需求分析-*``实现方案-*``测试方案-*`
- 如果发现 `工程分析/` 文档在工作区被删除,应先说明风险,只恢复或重建当次必要的核心文档,不把无关删除混入提交。
## 2. 写入需求分析
- 将用户当次需求整理写入:
- `工程分析/需求分析-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md`
- 内容至少包
- 文档至少包
- 开始时间
- 原始需求摘要
- 业务目标
- 输入与输出
- 影响范围
- 关键约束
- 风险点
- 待确认问题
- 待确认问题或默认假设
## 3. 写入实现方案并等待人工审核
## 3. 写入实现方案
- 将实现方案写入:
- `工程分析/实现方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md`
- 文档必须包含:
- 文档至少包含:
- 实现方案文档路径
- 修改目标
- 涉及路径
- 技术路线
- 数据流或交互流程
- 执行步骤
- 兼容性与回滚方案
- 预计文件变更
- 人工审核状态
- 实现方案写完后必须等待用户二次人工审核确认
- 未收到明确确认前,不执行项目业务代码修改。
- 提交与部署策略
- 2026-05-07-16-35-52 起,用户已确认“都确认,后续直接搞”。因此默认不在实现方案阶段暂停等待二次确认;若用户明确要求人工审核,则必须等待确认后再执行
## 4. 写入测试方案并等待人工审核
## 4. 写入测试方案
- 将测试方案写入:
- `工程分析/测试方案-{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}.md`
- 文档必须包含:
- 文档至少包含:
- 测试方案文档路径
- 静态检查
- 单元或集成测试
- 构建检查
- 关键业务场景验证
- 医学影像数据相关边界验证
- 回归风险
- 人工审核状态
- 测试方案写完后必须等待用户二次人工审核确认。
- 未收到明确确认前,不执行最终修改方案
- 部署验证
- Git/Gitea 备份验证
- 风险与回归关注点
- 默认不在测试方案阶段暂停等待二次确认;若用户明确要求人工审核,则必须等待确认后再执行
## 5. 执行前后维护经验记录
@@ -73,30 +81,25 @@
- Commit message 必须同时包含:
- `{Year}-{Mon}-{Day}-{Hour}-{Min}-{Sec}` 时间戳
- 本次修改简要描述
- 备份完成后提醒用户:文档备份 commit 已完成
- 备份提交时默认只暂存本次相关文档和明确属于本次修改的文件,避免提交无关工作区变化
- 备份完成后必须提醒用户:文档备份 commit 已完成。
- 默认远程仓库:
- `http://192.168.31.5:5002/admin/REVOXELSEG_DICOM.git`
## 7. 重新部署
- 最终修改完成并通过测试后,重新部署本项目。
- 若项目只有前端开发环境,默认使用 `WebSite/` 下的脚本
- 优先使用当前项目约定
- `cd WebSite`
- `npm run build`
- `npm run dev`
- 若已有服务进程或部署脚本,优先沿用现有部署方式。
- `tmux` 会话 `revoxelseg-dicom`
- `npm run serve -- --host 0.0.0.0 --port 4000`
- 若端口或会话冲突,先检查并记录实际处理方式。
- 部署后至少验证:
- `http://127.0.0.1:4000/api/health`
- `http://127.0.0.1:4000/`
## 人工确认口令
## 8. 最终汇报
后续当我完成实现方案和测试方案文档后,需要用户明确回复类似以下内容才继续:
- `确认实现方案`
- `确认测试方案`
- `确认执行`
如果用户对方案提出修改意见,则先更新对应文档,再等待新的确认。
## 默认执行确认更新
- 2026-05-07-16-35-52 起,用户已确认“都确认,后续直接搞”。
- 后续项目修改需求仍必须记录时间、创建需求分析、实现方案、测试方案、读取经验记录、执行后更新经验记录、备份 commit、重新部署。
- 除非用户明确要求暂停或人工审核,后续不再在实现方案和测试方案阶段停等二次确认。
- 汇报本次开始时间、修改文件、测试结果、部署地址和 commit 状态。
- 如果某一步无法完成,必须说明原因、影响范围和建议下一步。

View File

@@ -0,0 +1,55 @@
# 实现方案-2026-05-19-22-59-07
## 实现方案文档路径
`工程分析/实现方案-2026-05-19-22-59-07.md`
## 修改目标
建立并固化项目级代码编纂工作流,使后续项目修改必须留下需求分析、实现方案、测试方案、经验记录、备份 commit 与部署验证。
## 涉及路径
- `工程分析/工程整体分析.md`
- `工程分析/代码编纂工作流.md`
- `工程分析/需求分析-2026-05-19-22-59-07.md`
- `工程分析/实现方案-2026-05-19-22-59-07.md`
- `工程分析/测试方案-2026-05-19-22-59-07.md`
- `工程分析/经验记录.md`
- `AGENTS.md`
## 技术路线
1. 记录本次开始时间 `2026-05-19-22-59-07`
2. 阅读项目 README、`WebSite/README.md``package.json``server.ts`、API 封装和主要 React 组件。
3. 从 Git 中读取旧 `工程分析/工程整体分析.md``代码编纂工作流.md``经验记录.md`,保留旧知识库。
4. 恢复并更新三个核心文档,只处理本次必要文件。
5. 新增本次需求分析、实现方案、测试方案。
6. 新增根目录 `AGENTS.md`,提醒后续进入仓库的助手优先执行该工作流。
7. 执行静态检查和构建。
8. 使用 `tmux` 重新部署 `WebSite` 服务到 `0.0.0.0:4000`
9. 验证 HTTP 健康检查和页面响应。
10. 仅暂存本次相关文档和 `AGENTS.md`,创建包含时间戳和简要描述的 commit并尝试推送到 Gitea。
## 兼容性与回滚方案
- 本次不修改业务代码,不改变 API、前端页面和运行态数据结构。
- 若文档内容需要回滚,可回退本次 commit。
- 若部署失败,可保留当前代码构建结果,检查 `tmux` 会话日志和端口占用后重新启动。
- 若 Gitea 推送失败,本地 commit 仍保留,可稍后网络恢复后执行 `git push origin main`
## 预计文件变更
- 更新:`工程分析/工程整体分析.md`
- 更新:`工程分析/代码编纂工作流.md`
- 更新:`工程分析/经验记录.md`
- 新增:`工程分析/需求分析-2026-05-19-22-59-07.md`
- 新增:`工程分析/实现方案-2026-05-19-22-59-07.md`
- 新增:`工程分析/测试方案-2026-05-19-22-59-07.md`
- 新增:`AGENTS.md`
## 提交与部署策略
- 暂存时显式列出本次文件,避免把工作区已有历史删除状态混入 commit。
- commit message 使用:`2026-05-19-22-59-07 建立代码编纂工作流`
- 部署方式沿用项目约定:`tmux` 会话 `revoxelseg-dicom` + `npm run serve -- --host 0.0.0.0 --port 4000`

View File

@@ -0,0 +1,57 @@
# 实现方案-2026-05-19-23-47-31
## 实现方案文档路径
`工程分析/实现方案-2026-05-19-23-47-31.md`
## 修改目标
将逆向工作区右侧 `Mask 展示` 改造成二维多图层“逆向分割映射视图”,实现 DICOM Base Layer、STL 逆向投影 Overlay Label Map、构件层级属性联动和独立 Slice Navigator。
## 涉及路径
- `WebSite/src/components/ReverseWorkspace.tsx`
- `WebSite/src/index.css`
- `工程分析/需求分析-2026-05-19-23-47-31.md`
- `工程分析/实现方案-2026-05-19-23-47-31.md`
- `工程分析/测试方案-2026-05-19-23-47-31.md`
- `工程分析/经验记录.md`
## 技术路线
1.`ReverseWorkspace.tsx` 中新增独立 `mappingSlice` 状态。
2. 将右侧 `CutSectionPreview` 替换为 `VoxelizationMappingView`
3. `VoxelizationMappingView` 使用两个叠放 Canvas
- Base Canvas 绘制 `api.getDicomPreview(project.id, mappingSlice, 'axial', 'soft')` 返回的 DICOM 灰度像素。
- Overlay Canvas 根据 STL preview 顶点、当前切片 Z 位置和 `moduleStyles` 生成 Label Map 投影。
4. Overlay 绘制规则:
- 对每个可见 STL 构件读取颜色、透明度、`partId`
- 将 STL 顶点 bounds 归一化映射到 DICOM canvas。
- 只绘制与当前 Z 切片带宽相交的三角面。
- 隐藏构件不绘制,透明度随构件层级滑条变化。
5. 在右侧视图底部添加专属 Slice Navigator
- 独立范围为 `0 ~ project.dicomCount - 1`
- 支持拖动和左右单步按钮。
- 不修改左侧 DICOM 范围,也不影响三维融合切分状态。
6. 保留 NII/NII.GZ 导出按钮。
7. 为新滑条补充稳定尺寸和样式,避免 UI 抖动。
## 兼容性与回滚方案
- 不修改后端接口和数据结构,回滚时只需恢复 `ReverseWorkspace.tsx``index.css`
- 如果 STL preview 加载失败,右侧仍显示 DICOM Base Layer并显示 Overlay 状态提示。
- 如果 DICOM preview 加载失败,显示加载或错误状态,不影响左侧三维融合视图。
## 预计文件变更
- 更新 `ReverseWorkspace.tsx`:新增二维映射视图、独立切片状态、右侧标题与图层联动。
- 更新 `index.css`:新增独立 Slice Navigator 滑条样式。
- 新增本次三份工程文档。
- 更新 `经验记录.md`
## 提交与部署策略
- 仅暂存本次代码和文档文件。
- 提交信息使用:`2026-05-19-23-47-31 优化逆向分割映射视图`
- 运行 `npm run lint``npm run build`
- 重新部署 `tmux` 会话 `revoxelseg-dicom`,验证 `http://127.0.0.1:4000/api/health` 和首页响应。

View File

@@ -1,57 +1,85 @@
# 工程整体分析
更新时间2026-05-04-02-38-48
更新时间2026-05-19-22-59-07
## 项目定位
本项目计划建设为“基于模型逆向体素化及 DICOM 分割标注系统”。
本项目“基于模型逆向体素化及 DICOM 分割标注系统”的前后端一体化演示工程。核心目标是读取 `Head_CT_DICOM/` 中的 DICOM 序列和 `Head_CT_ReConstruct/` 中的 STL 重建模型,在浏览器中完成 DICOM 切片预览、STL 模型预览、影像与模型三维融合观察、构件样式配置,并提供 NIfTI Mask 演示导出能力
目标能力:
- 输入头部 DICOM 序列。
- 对 DICOM 进行三维重建或读取已有重建模型。
- 从重建后的 STL 模型反向生成体素级分割 Mask。
- 面向医学影像分割标注流程提供可视化、管理和结果导出能力。
当前系统仍以演示闭环为主,真实医学级 STL 到 DICOM 空间体素化算法尚未接入。后续接入真实算法时,应保留现有 API 和前端工作流,并替换后端 Mask 生成逻辑或接入独立算法服务。
## 当前目录结构
- `WebSite/`:前端应用主体,基于 Vite、React、TypeScript
- `WebSite/src/`:主要页面和组件代码
- `Head_CT_DICOM/`:头部 CT DICOM 文件目录
- `Head_CT_ReConstruct/`:已重建 STL 文件目录,包括头部、头颅、气管、肿瘤等结构
- `工程分析/`:工程分析、需求分析、实现方案、测试方案和经验记录目录
- `README.md`:项目目标、构建运行和部署入口说明
- `WebSite/`:前后端一体服务目录,包含 Vite React 前端与 Express 后端
- `WebSite/server.ts`Express API、Vite 中间件、DICOM/STL 解析预览、NIfTI 演示导出、运行态状态存储
- `WebSite/src/`React 页面、组件、类型和 API 封装
- `WebSite/src/components/Login.tsx`:登录页,默认账号 `admin`、密码 `123456`
- `WebSite/src/components/Sidebar.tsx`:主导航,包含总体概况、项目库、逆向工作区、系统管理工作区。
- `WebSite/src/components/Overview.tsx`项目、DICOM、STL、Mask 导出统计概览。
- `WebSite/src/components/ProjectLibrary.tsx`项目库、DICOM 切片预览、STL 预览、构件层级样式、Mask 下载入口。
- `WebSite/src/components/ReverseWorkspace.tsx`:逆向工作区,提供 DICOM/STL 三维融合、模型位姿、切片范围和 STL 切面展示。
- `WebSite/src/components/UserManagement.tsx`:系统管理与演示环境重置。
- `Head_CT_DICOM/`:默认 DICOM 序列目录,不作为普通文档备份重点。
- `Head_CT_ReConstruct/`:默认 STL 重建模型目录,不作为普通文档备份重点。
- `工程分析/`:工程整体分析、代码编纂工作流、需求分析、实现方案、测试方案和经验记录。
## 前端技术栈
## 技术栈
- React 19
- TypeScript
- Vite 6
- Tailwind CSS 4
- Three.js、React Three Fiber、Drei
- Framer Motion / Motion
- Recharts
- Lucide React
- Node.js 18+ 与 npm。
- React 19、TypeScript、Vite 6。
- Express 4 与 Vite 中间件,开发和 API 统一由 `npm run serve` 启动。
- Tailwind CSS 4
- Three.js、React Three Fiber、Drei,用于 STL 和融合视图。
- Motion/Framer Motion、Recharts、Lucide React。
- Node 内置 `fs/path/zlib` 负责状态文件、DICOM/STL 文件读取、tar.gz 与 NIfTI gzip 演示导出。
## 后端能力
`WebSite/server.ts` 当前提供:
- `/api/health` 健康检查。
- `/api/session``/api/login``/api/logout`:共享登录状态。
- `/api/users`:用户列表。
- `/api/projects` 及项目增删改查。
- `/api/projects/:projectId/module-styles`构件显示、颜色、透明度、Mask ID 的项目级持久化。
- `/api/projects/:projectId/dicom-preview`:轴向、矢状面、冠状面 DICOM 预览。
- `/api/projects/:projectId/dicom-fusion-volume`:三维融合所需的 DICOM 体纹理抽样。
- `/api/projects/:projectId/dicom-archive`:默认 DICOM 序列 tar.gz 下载。
- `/api/projects/:projectId/dicom-info`DICOM 关键元数据、spacing 与物理尺寸。
- `/api/projects/:projectId/models/:fileName``/preview`STL 文件与抽样三角面预览。
- `/api/overview`:概况统计。
- `/api/demo/reset`:恢复默认演示环境。
- `/api/projects/:projectId/export-mask`:生成 `.nii``.nii.gz` 演示分割 Mask。
## 运行态与数据
- `WebSite/data/state.json`:共享登录、项目、用户和构件样式状态。
- `WebSite/exports/`Mask 导出文件。
- `WebSite/dist/`:生产构建产物。
- 默认项目 ID 为 `head-ct-demo`,由后端扫描 `Head_CT_DICOM/``Head_CT_ReConstruct/` 生成。
- DICOM/STL 数据和运行态输出通常不应混入文档备份提交。
## 已知脚本
`WebSite/package.json` 中已有脚本
`WebSite/` 下执行
- `npm run dev`:启动 Vite 开发服务器,端口 3000监听 `0.0.0.0`
- `npm run build`:生产构建。
- `npm run preview`:预览构建结果。
- `npm run clean`:删除 `dist`
- `npm ci`:安装依赖
- `npm run lint`:执行 `tsc --noEmit` 类型检查。
- `npm run build`Vite 生产构建。
- `npm run dev`:仅启动 Vite 开发服务器,默认端口 3000。
- `npm run serve -- --host 0.0.0.0 --port 4000`:启动 Express + Vite 一体服务,是当前优先部署方式。
## 数据资产
## 部署约定
- DICOM 数据位于 `Head_CT_DICOM/`
- STL 重建模型位于 `Head_CT_ReConstruct/`
- 医学数据和模型文件体积可能较大Git 备份时应谨慎,默认不将原始影像与重建模型纳入普通文档备份提交
- 当前 README 指向访问地址 `http://192.168.3.11:4000/`
- 历史经验记录显示本项目长期使用 `tmux` 会话 `revoxelseg-dicom` 托管服务,避免普通后台进程被回收
- 重新部署前先检查端口和已有 `tmux` 会话;部署后用 `/api/health` 和页面 HTTP 响应验证
## 后续重点
## 修改风险重点
- 明确 DICOM 读取、排序、空间信息解析方式
- 明确 STL 到体素 Mask 的坐标对齐策略
- 明确 Mask 输出格式,例如 NIfTI、NRRD、DICOM SEG、PNG 序列或项目自定义格式
- 明确前端是否只做可视化,还是需要增加后端处理服务
- 对医学影像处理流程建立可复现测试样例和数据校验
- DICOM 与 STL 的空间基准不能随显示范围变化而变化,显示范围和物理坐标基准要分开处理
- 没有真实体素化或真实语义分割结果时,不应伪造 Mask 图像
- 构件样式是跨页面、跨浏览器共享状态,新增字段必须检查初始化、服务端归一化、前端更新和 fallback
- UI 截图或页面文字相关需求应先用可见文案全局定位真实源码
- 文档备份提交必须避免把无关的运行态产物、大型医学数据或既有未确认删除混入 commit

View File

@@ -0,0 +1,54 @@
# 测试方案-2026-05-19-22-59-07
## 测试方案文档路径
`工程分析/测试方案-2026-05-19-22-59-07.md`
## 静态检查
-`WebSite/` 下执行 `npm run lint`,确认 TypeScript 类型检查通过。
## 构建检查
-`WebSite/` 下执行 `npm run build`,确认生产构建成功生成 `dist/`
## 文档验证
- 确认 `工程分析/` 存在。
- 确认本次需求分析、实现方案、测试方案均按时间戳命名。
- 确认 `工程分析/代码编纂工作流.md` 覆盖用户要求的 0 到 7 步。
- 确认 `工程分析/经验记录.md` 保留旧经验并追加本次四段式记录。
- 确认 `AGENTS.md` 包含后续项目修改必须执行该流程的约束。
## 部署验证
- 检查已有 `tmux` 会话和 `4000` 端口占用。
- 使用 `tmux` 重新启动 `revoxelseg-dicom` 会话。
- 验证:
- `curl http://127.0.0.1:4000/api/health`
- `curl -I http://127.0.0.1:4000/`
## Git/Gitea 备份验证
- 使用 `git status --short` 检查工作区。
- 仅暂存本次相关文件。
- 创建 commit`2026-05-19-22-59-07 建立代码编纂工作流`
- 尝试 `git push origin main`
- 完成后提醒用户文档备份 commit 已完成;若推送失败,说明本地 commit 和失败原因。
## 回归关注点
- 本次不改业务代码,主要回归风险来自重新部署。
- 注意不要把历史 `工程分析/需求分析-*``实现方案-*``测试方案-*` 的既有删除状态一起提交。
- 注意不要提交 `WebSite/data/``WebSite/exports/`、医学影像数据或 STL 模型数据。
## 实际执行结果
- `npm run lint`:通过。
- `npm run build`通过Vite 提示存在大 chunk 警告,但构建成功。
- `tmux` 部署:已创建会话 `revoxelseg-dicom`
- 监听端口:`0.0.0.0:4000`HMR 端口 `24679`
- 健康检查:`curl http://127.0.0.1:4000/api/health` 返回 `ok: true`
- 首页验证:`curl -I http://127.0.0.1:4000/` 返回 `HTTP/1.1 200 OK`
- Git 本地备份 commit已创建本次工作流文档备份 commit提交信息为 `2026-05-19-22-59-07 建立代码编纂工作流`
- Gitea 远端推送:执行 `git push origin main` 时失败,原因是 HTTP 远端 `http://192.168.31.5:5002` 无法读取用户名;未在命令行拼接或保存凭据。

View File

@@ -0,0 +1,63 @@
# 测试方案-2026-05-19-23-47-31
## 测试方案文档路径
`工程分析/测试方案-2026-05-19-23-47-31.md`
## 静态检查
-`WebSite/` 下执行 `npm run lint`
## 构建检查
-`WebSite/` 下执行 `npm run build`
## 关键业务场景验证
- 打开逆向工作区,确认右侧标题为“逆向分割映射视图”。
- 确认右侧视图显示 DICOM Base Layer而不是纯 STL 三维实体切面。
- 确认 Overlay Layer 会显示与 STL 构件对应的彩色 Label Map。
- 在中间“构件层级”面板修改构件颜色,右侧 Overlay 即时变色。
- 修改构件透明度,右侧 Overlay 透明度即时变化。
- 隐藏某个构件,右侧 Overlay 中对应构件消失。
- 调整构件 `ID` 后,右侧图例中的 Label ID 与构件层级保持一致。
- 拖动右侧 Slice NavigatorDICOM 切片和 Overlay 逐层切换。
- 确认右侧 Slice Navigator 不改变左侧 DICOM 切片范围。
## 医学影像数据相关边界验证
- DICOM 切片总数为 0 或项目加载中时,右侧应显示加载/空状态。
- STL preview 加载失败时DICOM Base Layer 仍可显示。
- 切片序号必须 clamp 到 `1 ~ dicomCount`
- Canvas Base Layer 与 Overlay Layer 必须尺寸一致。
## 部署验证
- 重新部署后验证:
- `curl http://127.0.0.1:4000/api/health`
- `curl -I http://127.0.0.1:4000/`
## Git/Gitea 备份验证
- 显式暂存本次相关代码和文档,避免提交历史删除状态。
- 创建包含时间戳和描述的 commit。
- 尝试推送到 `origin/main`;若仍因 Gitea HTTP 凭据失败,则记录本地 commit 已完成、远端推送未完成。
## 回归关注点
- 右侧视图改为 2D Canvas 后,不能影响左侧 `FusionThreeView`
- 构件层级状态仍通过 `api.updateProjectModuleStyles` 持久化。
- 不引入新的后端依赖。
## 实际执行结果
- `npm run lint`:通过。
- `npm run build`通过Vite 仍提示大 chunk 警告,但构建成功。
- DICOM preview 接口验证:`/api/projects/head-ct-demo/dicom-preview?slice=0&plane=axial&mode=soft` 返回 `200`
- STL preview 接口验证:`/api/projects/head-ct-demo/models/%E5%A4%B4%E9%83%A8.stl/preview?limit=72000` 返回 `200`
- 健康检查:`/api/health` 返回 `ok: true`
- 重新部署:已重启 `tmux` 会话 `revoxelseg-dicom`,服务日志显示 `ReVoxelSeg DICOM server ready at http://0.0.0.0:4000/`
- 部署后健康检查:`curl http://127.0.0.1:4000/api/health` 返回 `ok: true`
- 部署后首页验证:`curl -I http://127.0.0.1:4000/` 返回 `HTTP/1.1 200 OK`
- Git 本地备份 commit已创建本次修改备份 commit提交信息为 `2026-05-19-23-47-31 优化逆向分割映射视图`
- Gitea 远端推送:执行 `git push origin main` 仍失败,原因是 HTTP 远端 `http://192.168.31.5:5002` 无法读取用户名;未在命令行拼接或保存凭据。

View File

@@ -793,3 +793,93 @@ C. 解决问题方案
D. 后续如何避免问题
右侧结果展示区域应优先呈现真实数据或真实处理结果,避免使用占位式示意图长期冒充结果;如果尚未生成真实语义分割 Mask应明确展示当前可验证的 STL 切面实体,而不是伪造 Mask 形状。
## 2026-05-19-22-59-07 工程分析核心文档被删除状态下的恢复
A. 具体问题
本次开始时工作区中大量 `工程分析/` 历史文档处于删除状态,且本地目录不存在;如果直接创建新的短版 `经验记录.md` 或直接暂存整个目录,可能丢失旧知识库或把无关删除混入本次备份。
B. 产生问题原因
这些删除状态在本次操作前已经存在,且 `工程分析/经验记录.md` 在 Git 中已有 795 行历史经验。当前需求又要求读取或创建 `工程分析/`,容易把“恢复核心文档”和“提交所有删除”混为一谈。
C. 解决问题方案
先通过 Git 读取旧 `工程整体分析.md``代码编纂工作流.md``经验记录.md`,确认历史内容;随后只恢复并更新这三个核心文件,新增本次三份时间戳文档和 `AGENTS.md`,暂存时显式列出本次相关路径,避免提交其他历史删除。
D. 后续如何避免问题
每次发现工作区已有删除或修改时,先区分是否属于本次需求;文档备份 commit 要显式暂存文件路径,不能用 `git add .`。对 `经验记录.md` 这类知识库文件,更新前必须确认旧内容仍被保留。
## 2026-05-19-22-59-07 固化后续代码编纂工作流
A. 具体问题
用户要求后续所有项目修改相关需求都执行固定流程,但仅在对话中说明流程不够稳固,未来进入项目的助手或开发者可能看不到这条约束。
B. 产生问题原因
工作流要求跨越多次会话和多次修改,单靠当前对话记忆不可靠;项目内原有 `代码编纂工作流.md` 也处于工作区删除状态,需要恢复并更新为当前版本。
C. 解决问题方案
更新 `工程分析/代码编纂工作流.md`,明确 0 到 7 步、默认不等待二次确认、文档备份提交和重新部署要求;同时新增根目录 `AGENTS.md`,把后续修改必须读取并执行该工作流作为项目入口约束。
D. 后续如何避免问题
后续任何项目修改前,先读取根目录 `AGENTS.md``工程分析/代码编纂工作流.md`;若用户提出新流程要求,应同步更新这两个入口文档和经验记录,确保流程约束留在仓库中。
## 2026-05-19-22-59-07 Gitea HTTP 远端缺少可用凭据
A. 具体问题
本次本地文档备份 commit 已完成,但执行 `git push origin main` 时失败Git 提示无法读取 `http://192.168.31.5:5002` 的用户名。
B. 产生问题原因
当前 `origin` 使用 HTTP Gitea 地址,执行环境没有交互式用户名输入,也没有可用的凭据助手或已保存认证信息。
C. 解决问题方案
保留本地 commit不在命令行临时拼接账号密码也不把凭据信息写入仓库或远端 URL。将推送失败原因记录到测试方案和经验记录中等待用户提供安全的凭据方式后再推送。
D. 后续如何避免问题
需要 Gitea 远端备份前先确认远端认证方式。优先使用安全的凭据助手、SSH remote 或用户已配置好的 token不要把账号密码写进 commit、文档、remote URL 或 shell 历史。
## 2026-05-19-23-47-31 逆向分割映射视图的数据来源边界
A. 具体问题
用户要求右侧二维视图展示由三维 STL 模型逆向推导的分割掩码 Label Map并与构件层级严格对应但当前后端尚未提供医学级真实体素化 Label Map 文件。
B. 产生问题原因
现有系统的真实数据能力主要是 DICOM preview、DICOM fusion volume 和 STL preview 三角面抽样,历史版本的右侧区域也已从伪二维 Mask 改为 STL 实体切面。若直接绘制任意彩色区域,会再次变成无来源 Mask。
C. 解决问题方案
右侧新建 `VoxelizationMappingView`,底层用 DICOM preview 灰度像素作为 Base Layer上层只根据 STL preview 顶点、当前 Z 切片位置和构件 `moduleStyles` 投影绘制 Overlay Label Map。每个彩色覆盖区域都来自具体 STL 构件,并复用构件颜色、透明度、显示隐藏和 `partId`
D. 后续如何避免问题
在真实体素化服务接入前,所有 Label Map 类可视化必须明确使用 STL 几何或后端生成结果作为来源;不要重新引入无数据来源的演示 Mask。后续若增加医学级体素化应保留当前 Base/Overlay UI但将 Overlay 数据源替换为后端真实 Label Map。
## 2026-05-19-23-47-31 右侧独立切片导航与左侧范围状态分离
A. 具体问题
右侧二维映射视图需要逐层浏览单张 DICOM 切片,而左侧三维融合视图使用的是 DICOM 切片范围和模型切分区间;如果共用同一状态,拖动右侧滑条会破坏左侧融合范围。
B. 产生问题原因
旧工作区已经有 `sliceStart/sliceEnd` 双端点状态,用于三维 DICOM 范围和 STL clipping plane。右侧新增 Slice Navigator 是单切片校验动作,语义不同。
C. 解决问题方案
新增独立 `mappingSlice` 状态传入 `VoxelizationMappingView`,右侧 Slice Navigator 只修改该状态;左侧 `sliceStart/sliceEnd` 继续驱动三维融合范围和模型切分,两套状态互不覆盖。
D. 后续如何避免问题
新增影像浏览控件前先判断其控制对象是“单切片位置”还是“显示范围/切割范围”。单切片校验使用独立 slice 状态,范围切割使用起止端点状态,避免不同视图之间产生隐式联动。

View File

@@ -0,0 +1,53 @@
# 需求分析-2026-05-19-22-59-07
## 开始时间
2026-05-19-22-59-07
## 原始需求摘要
用户要求在仔细阅读项目后,新建一套完整的代码编纂工作流。后续凡是项目修改相关需求,都需要按该流程执行:记录开始时间、阅读或创建 `工程分析/`、写入需求分析/实现方案/测试方案、执行前阅读经验记录、执行后按四段式更新经验记录、使用 Gitea 备份 commit并重新部署项目。
## 业务目标
- 将项目修改流程固化为可追踪、可复用、可审计的工程文档制度。
- 让后续需求从“直接改代码”升级为“先分析、再计划、再测试、再沉淀、再备份、再部署”的闭环。
- 保留已有经验记录中的关键教训,避免重复犯错。
- 让未来进入仓库的助手或开发者能通过项目文档理解必须执行的流程。
## 输入与输出
- 输入:用户给出的 0 到 7 条流程要求、当前项目源码、已有 README、Git 中历史 `工程分析/` 文档。
- 输出:
- 更新后的 `工程分析/工程整体分析.md`
- 更新后的 `工程分析/代码编纂工作流.md`
- 本次 `需求分析``实现方案``测试方案`
- 更新后的 `工程分析/经验记录.md`
- 根目录 `AGENTS.md`,用于提示后续工作必须遵守该流程
- 文档备份 commit 与重新部署结果
## 影响范围
- 文档治理:`工程分析/` 下核心文档和本次时间戳文档。
- 协作约束:根目录新增 `AGENTS.md`
- 项目业务代码:本次不修改业务代码。
- 部署:按当前项目部署方式重新启动 `WebSite` 服务。
## 关键约束
- 当前工作区已有大量历史 `工程分析/` 时间戳文档处于删除状态,这些删除不是本次操作产生,不能混入本次提交。
- 必须先阅读旧 `经验记录.md`尤其是部署端口、tmux 托管、真实源码定位、DICOM/STL 空间基准、不要伪造 Mask 等历史经验。
- 文档备份 commit 必须包含时间戳和本次修改简要描述。
- 重新部署优先使用 `WebSite``npm run serve -- --host 0.0.0.0 --port 4000`
## 风险点
- 如果误暂存整个 `工程分析/`,可能把历史文档删除一起提交。
- 如果只创建新经验记录而不保留旧内容,会丢失已有知识库。
- 如果部署时直接占用端口,可能影响正在运行的服务。
- 如果未来助手没有读取项目文档,可能无法自动遵守流程,因此需要 `AGENTS.md` 增强可见性。
## 默认假设
- 用户本次需求本身也按新流程执行,但不需要在实现方案和测试方案阶段停等人工确认,因为历史工作流已有“后续直接搞”的确认。
- “Gitea 备份 commit”理解为在本地 Git 创建 commit并尝试推送到已配置的 Gitea `origin`

View File

@@ -0,0 +1,57 @@
# 需求分析-2026-05-19-23-47-31
## 开始时间
2026-05-19-23-47-31
## 原始需求摘要
用户要求对逆向工作区右侧视图做产品润色将“Mask 展示”重命名为“逆向分割映射视图”或“体素化映射视图”;明确该区域以二维 DICOM 切片为底图,叠加由三维 STL 模型逆向推导生成的二维分割掩码 Label Map覆盖层颜色、透明度、显示隐藏状态必须与中间“可视化工具栏”的“构件层级”实时联动右侧视图还需要独立 Slice Navigator支持逐层浏览当前 Z 轴 DICOM 切片及对应分割结果。
## 业务目标
- 将右侧结果区从“STL 实体切面”语义升级为“DICOM + STL 逆向体素化映射”的二维校验视图。
- 让医生或标注人员能在同一个二维视图中核对原始 CT 灰度影像和由 STL 构件层级推导出的 Label Map。
- 保证构件层级面板中的颜色、透明度和显隐状态立即反映在右侧叠加层中。
- 让右侧视图拥有独立于左侧 DICOM 范围控件的逐层切片浏览能力。
## 输入与输出
- 输入:
- 默认项目的 DICOM 切片预览接口 `/api/projects/:projectId/dicom-preview`
- STL preview 接口 `/api/projects/:projectId/models/:fileName/preview`
- 项目级 `moduleStyles`
- 用户选择的独立映射切片序号
- 输出:
- 右侧“逆向分割映射视图”
- DICOM Base Layer
- STL 逆向投影 Overlay Label Map
- 与构件层级联动的颜色、透明度、显示隐藏状态
- 独立 Slice Navigator
## 影响范围
- 主要影响 `WebSite/src/components/ReverseWorkspace.tsx`
- 可能需要补充 `WebSite/src/index.css` 的滑条样式。
- 不改变后端 API、不改变项目状态结构、不改变导出格式。
- 文档会新增本次需求、实现、测试方案,并更新经验记录。
## 关键约束
- 历史经验要求不要伪造无来源 Mask。本次覆盖层必须从 STL preview 几何数据推导,且每个覆盖区域对应具体 STL 构件与 `partId`
- DICOM 原始影像必须作为底层常驻显示。
- Overlay 可视属性必须直接读取 `moduleStyles`,避免右侧另建一套独立状态。
- Slice Navigator 必须独立于左侧 DICOM 切片范围,不应破坏左侧三维融合和模型切分流程。
- 当前工作区已有历史工程分析文档删除状态,本次提交不能混入这些删除。
## 风险点
- 当前后端尚未提供医学级真实体素化 Label Map本次前端只能基于 STL preview 三角面做浏览器侧逆向投影映射,精度取决于 STL 预览采样与配准状态。
- 高频拖动 Slice Navigator 会触发 DICOM 预览请求,需要防止旧请求覆盖新结果。
- Canvas 图层尺寸必须保持一致,否则 DICOM 和 Overlay 会错位。
- 构件数量和 STL 采样量较大时Overlay 绘制可能有性能压力。
## 默认假设
- 本次按已有确认执行,不在方案阶段等待二次确认。
- 右侧视图标题采用“逆向分割映射视图”,在界面中辅以 Label Map、Slice Navigator 等短标签。