Compare commits
33 Commits
b07b04adeb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d167e1c10c | |||
| 5a9d09a5bf | |||
| 1e044fb349 | |||
| 60ebbf4196 | |||
| acdff763b5 | |||
| 21b372f705 | |||
| 27e0ec626e | |||
| a08bf908c9 | |||
| d1fa79aef9 | |||
| 2fac0200fc | |||
| bca3619b9b | |||
| 3d732ec898 | |||
| 2510937128 | |||
| e380a19acb | |||
| b6e6ace233 | |||
| 21b04ecffd | |||
| 1dcfc2a4c1 | |||
| d9572e6966 | |||
| 3bedf204c8 | |||
| e9f0823281 | |||
| f279770a0e | |||
| f54dafb83d | |||
| 4a57fbe537 | |||
| d75606568c | |||
| 90727ab1b9 | |||
| 8b4cff8197 | |||
| 99f2bc0750 | |||
| a5ff4d5338 | |||
| 536370ab9a | |||
| b027f50118 | |||
| e4a403f015 | |||
| d7eeedd9b3 | |||
| 464b1aab59 |
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
.git
|
||||
.gitignore
|
||||
.codex
|
||||
.agents
|
||||
node_modules
|
||||
WebSite/node_modules
|
||||
WebSite/dist
|
||||
WebSite/data
|
||||
WebSite/exports
|
||||
WebSite/.env
|
||||
Docker部署/**/data
|
||||
Docker部署/**/exports
|
||||
工程分析
|
||||
新撰写软著文档
|
||||
参考软著构建模板
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
*.7z
|
||||
*.rar
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -9,6 +9,13 @@ WebSite/dist/
|
||||
# Runtime backend state and generated exports
|
||||
WebSite/data/
|
||||
WebSite/exports/
|
||||
Docker部署/**/data/
|
||||
Docker部署/**/exports/
|
||||
Docker部署/**/locked-results/
|
||||
项目数据/锁定结果/
|
||||
|
||||
# Local deliverables not pushed to Gitea
|
||||
3. 代码汇总.docx
|
||||
|
||||
# Local env
|
||||
.env
|
||||
|
||||
34
Docker部署/Dockerfile
Normal file
34
Docker部署/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PORT=4000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
ARG HTTP_PROXY
|
||||
ARG HTTPS_PROXY
|
||||
ARG NO_PROXY
|
||||
|
||||
COPY WebSite/package*.json ./WebSite/
|
||||
|
||||
WORKDIR /app/WebSite
|
||||
RUN npm ci
|
||||
|
||||
WORKDIR /app
|
||||
COPY WebSite ./WebSite
|
||||
COPY Head_CT_DICOM ./Head_CT_DICOM
|
||||
COPY Head_CT_ReConstruct ./Head_CT_ReConstruct
|
||||
|
||||
WORKDIR /app/WebSite
|
||||
RUN npm run build && mkdir -p data exports /app/项目数据/锁定结果
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=12 --start-period=20s \
|
||||
CMD node -e "fetch('http://127.0.0.1:4000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["npm", "run", "serve", "--", "--host", "0.0.0.0", "--port", "4000"]
|
||||
150
Docker部署/README.md
Normal file
150
Docker部署/README.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# ReVoxelSeg DICOM Docker 部署说明
|
||||
|
||||
本目录提供两套 Docker Compose 部署方式:
|
||||
|
||||
- `本机/docker_compose.yaml`:适合当前开发机或普通 Linux 主机。
|
||||
- `威联通NAS/docker_compose.yaml`:适合 QNAP QTS Container Station,使用 `/share/Container/revoxelseg_dicom` 绝对路径。
|
||||
|
||||
两套 Compose 都包含:
|
||||
|
||||
- `revoxelseg_web`:ReVoxelSeg DICOM 前后端一体服务,容器端口 `4000`。
|
||||
- `revoxelseg_frpc`:FRPC 客户端,将 `revoxelseg_web:4000` 映射到 `82.157.255.195` 的远程端口 `10008`。
|
||||
|
||||
当前 Docker 构建会同步包含以下能力:
|
||||
|
||||
- 二维逆向分割映射按实体填充显示,rib、skin 等薄壳或细长构件会做局部实体化兜底,导出的分割 Label Map 也按填充区域写入。
|
||||
- 模型位姿支持以模型中心沿 X/Y/Z 轴镜像翻转,平移和缩放单击支持 `0.001` 级微调,长按加减会加速连续调整;保存、项目库预览和导出均沿用该位姿;自动拉伸完成后不再在位姿区保留成功提示。
|
||||
- “构件分别导出”会把所有构件 NIfTI 文件集中到导出包内的 `segmentation-parts/` 目录。
|
||||
- 项目库 DICOM 首页支持滚轮缩放、拖拽平移和位置重置。
|
||||
- 项目库与工作区的 DICOM 切片编号按医学影像顺序显示,滑条使用非进度条样式。
|
||||
- 项目库支持锁定/解锁项目、筛选未上锁项目,并在锁定时保存位姿快照到 `项目数据/锁定结果/`。
|
||||
- 逆向工作区“构件层级”支持一键显示或隐藏全部构件;切片滑条顶部为第 1 张,向下查看到第 N 张。
|
||||
- 逆向分割映射视图按当前可见构件加载高精度 STL 预览,并始终用全部 STL 边界保持统一模型坐标系;实体模式最高使用 80 万三角面预览,“可见类别 + 构件分别导出”严格只导出当前眼睛打开的构件。
|
||||
- 新增“自动微调匹配工作区”,可从逆向工作区当前位姿进入;默认仅微调平移 X/Y/Z 与缩放,旋转锁定,使用所选骨骼 STL 与 DICOM HU 骨窗进行候选迭代评分,采样切片按数量均匀筛选并真实参与评分,可将最佳位姿写回项目库;结果区支持当前位姿/自动匹配结果双视图对比。
|
||||
- 大型 STL preview JSON 响应会按浏览器能力 gzip 返回,降低公网 HTTP/2 代理下高精度预览的传输失败概率。
|
||||
|
||||
## 一、本机部署
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```bash
|
||||
docker compose -f Docker部署/本机/docker_compose.yaml up -d --build
|
||||
```
|
||||
|
||||
查看状态:
|
||||
|
||||
```bash
|
||||
docker compose -f Docker部署/本机/docker_compose.yaml ps
|
||||
docker compose -f Docker部署/本机/docker_compose.yaml logs -f revoxelseg_web
|
||||
docker compose -f Docker部署/本机/docker_compose.yaml logs -f revoxelseg_frpc
|
||||
```
|
||||
|
||||
访问地址:
|
||||
|
||||
```text
|
||||
http://192.168.3.11:4000/
|
||||
http://127.0.0.1:4000/
|
||||
```
|
||||
|
||||
公网访问需等待 FRPC 在线,并确认 NPM 反向代理已将 `revoxel.huijutec.cn` 转发到 FRP 服务端 `10008`:
|
||||
|
||||
```text
|
||||
https://revoxel.huijutec.cn/
|
||||
```
|
||||
|
||||
停止:
|
||||
|
||||
```bash
|
||||
docker compose -f Docker部署/本机/docker_compose.yaml down
|
||||
```
|
||||
|
||||
## 二、威联通 NAS / QTS Container Station 部署
|
||||
|
||||
建议把完整项目放到:
|
||||
|
||||
```text
|
||||
/share/Container/revoxelseg_dicom
|
||||
```
|
||||
|
||||
然后在 QTS Container Station 中创建应用,选择或粘贴:
|
||||
|
||||
```text
|
||||
/share/Container/revoxelseg_dicom/Docker部署/威联通NAS/docker_compose.yaml
|
||||
```
|
||||
|
||||
也可通过 SSH 执行:
|
||||
|
||||
```bash
|
||||
cd /share/Container/revoxelseg_dicom
|
||||
docker compose -f Docker部署/威联通NAS/docker_compose.yaml up -d --build
|
||||
```
|
||||
|
||||
如果项目目录不是 `/share/Container/revoxelseg_dicom`,需要同步修改 NAS Compose 中:
|
||||
|
||||
- `build.context`
|
||||
- `/share/Container/revoxelseg_dicom/data`
|
||||
- `/share/Container/revoxelseg_dicom/exports`
|
||||
- `/share/Container/revoxelseg_dicom/locked-results`
|
||||
|
||||
## 三、数据持久化
|
||||
|
||||
容器内路径:
|
||||
|
||||
- `/app/WebSite/data`:项目状态、上传数据和导入缓存。
|
||||
- `/app/WebSite/exports`:导出的 NII/NII.GZ/TAR.GZ 文件。
|
||||
- `/app/项目数据/锁定结果`:项目锁定时保存的位姿与构件样式快照。
|
||||
|
||||
本机部署会挂载到:
|
||||
|
||||
- `Docker部署/本机/data`
|
||||
- `Docker部署/本机/exports`
|
||||
- `Docker部署/本机/locked-results`
|
||||
|
||||
NAS 部署会挂载到:
|
||||
|
||||
- `/share/Container/revoxelseg_dicom/data`
|
||||
- `/share/Container/revoxelseg_dicom/exports`
|
||||
- `/share/Container/revoxelseg_dicom/locked-results`
|
||||
|
||||
默认演示数据 `Head_CT_DICOM/` 与 `Head_CT_ReConstruct/` 会复制进镜像,用于新环境首次启动后的默认项目。
|
||||
|
||||
## 四、FRPC 映射
|
||||
|
||||
Compose 中内置的 FRPC 配置为:
|
||||
|
||||
```toml
|
||||
serverAddr = "82.157.255.195"
|
||||
serverPort = 7000
|
||||
|
||||
auth.method = "token"
|
||||
auth.token = "en.xjtu.edu.cn"
|
||||
|
||||
transport.poolCount = 5
|
||||
transport.heartbeatTimeout = -1
|
||||
|
||||
[[proxies]]
|
||||
name = "ReVoxelSeg_DICOM"
|
||||
type = "tcp"
|
||||
localIP = "revoxelseg_web"
|
||||
localPort = 4000
|
||||
remotePort = 10008
|
||||
```
|
||||
|
||||
如果远端端口或代理名称调整,只需修改两份 `docker_compose.yaml` 中 `revoxelseg_frpc` 的内嵌配置。
|
||||
|
||||
## 五、旧 tmux 部署回退
|
||||
|
||||
若需要临时回退到旧部署方式:
|
||||
|
||||
```bash
|
||||
docker compose -f Docker部署/本机/docker_compose.yaml down
|
||||
cd WebSite
|
||||
npm run build
|
||||
npm run serve -- --host 0.0.0.0 --port 4000
|
||||
```
|
||||
|
||||
## 六、2026-05-24 交互修正
|
||||
|
||||
- 逆向工作区切换构件层级时,可见 STL 构件会显示加载进度,避免高精度预览加载期间误判为构件不显示。
|
||||
- DICOM 与逆向分割映射画布的滚轮缩放使用非被动 wheel 监听,修正浏览器控制台 `Unable to preventDefault inside passive event listener invocation` 警告。
|
||||
- rib、skin 等薄壳或细长构件在实体模式和逆向分割映射视图中使用更高精度 STL 预览,并增加局部厚度填充,避免只显示散点或单像素线段。
|
||||
70
Docker部署/威联通NAS/docker_compose.yaml
Normal file
70
Docker部署/威联通NAS/docker_compose.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
# ReVoxelSeg DICOM / QNAP QTS Container Station 直接部署版。
|
||||
# 建议把完整项目放到:/share/Container/revoxelseg_dicom
|
||||
# Container Station 不一定从项目根目录执行 Compose,因此 build.context 与 volumes 使用绝对路径。
|
||||
# 局域网访问:http://NAS_IP:4000/ 或 http://192.168.3.11:4000/
|
||||
# 公网访问:NPM 反向代理将 revoxel.huijutec.cn 转发到 FRP 服务端 10008 后生效。
|
||||
|
||||
name: revoxelseg-dicom-qnap
|
||||
|
||||
services:
|
||||
revoxelseg_web:
|
||||
image: revoxelseg-dicom:web-qnap-20260524-lock
|
||||
build:
|
||||
context: /share/Container/revoxelseg_dicom
|
||||
dockerfile: Docker部署/Dockerfile
|
||||
args:
|
||||
HTTP_PROXY: http://192.168.31.7:7893
|
||||
HTTPS_PROXY: http://192.168.31.7:7893
|
||||
NO_PROXY: localhost,127.0.0.1,192.168.31.0/24,192.168.3.0/24,revoxelseg_web,revoxelseg_frpc
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4000:4000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 4000
|
||||
APP_URL: https://revoxel.huijutec.cn
|
||||
TRUST_PROXY: "true"
|
||||
HTTP_PROXY: http://192.168.31.7:7893
|
||||
HTTPS_PROXY: http://192.168.31.7:7893
|
||||
http_proxy: http://192.168.31.7:7893
|
||||
https_proxy: http://192.168.31.7:7893
|
||||
NO_PROXY: localhost,127.0.0.1,192.168.31.0/24,192.168.3.0/24,revoxelseg_web,revoxelseg_frpc
|
||||
volumes:
|
||||
- /share/Container/revoxelseg_dicom/data:/app/WebSite/data
|
||||
- /share/Container/revoxelseg_dicom/exports:/app/WebSite/exports
|
||||
- /share/Container/revoxelseg_dicom/locked-results:/app/项目数据/锁定结果
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:4000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
revoxelseg_frpc:
|
||||
image: snowdreamtech/frpc:latest
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/bin/sh"]
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
cat > /tmp/frpc.toml <<'EOF'
|
||||
serverAddr = "82.157.255.195"
|
||||
serverPort = 7000
|
||||
|
||||
auth.method = "token"
|
||||
auth.token = "en.xjtu.edu.cn"
|
||||
|
||||
transport.poolCount = 5
|
||||
transport.heartbeatTimeout = -1
|
||||
|
||||
[[proxies]]
|
||||
name = "ReVoxelSeg_DICOM"
|
||||
type = "tcp"
|
||||
localIP = "revoxelseg_web"
|
||||
localPort = 4000
|
||||
remotePort = 10008
|
||||
EOF
|
||||
exec frpc -c /tmp/frpc.toml
|
||||
depends_on:
|
||||
revoxelseg_web:
|
||||
condition: service_healthy
|
||||
60
Docker部署/本机/docker_compose.yaml
Normal file
60
Docker部署/本机/docker_compose.yaml
Normal file
@@ -0,0 +1,60 @@
|
||||
# ReVoxelSeg DICOM 本机 Docker 部署版。
|
||||
# 运行位置:项目根目录下执行 `docker compose -f Docker部署/本机/docker_compose.yaml up -d --build`
|
||||
# 局域网访问:http://192.168.3.11:4000/
|
||||
# 公网访问:NPM 反向代理将 revoxel.huijutec.cn 转发到 FRP 服务端 10008 后生效。
|
||||
|
||||
name: revoxelseg-dicom-local
|
||||
|
||||
services:
|
||||
revoxelseg_web:
|
||||
image: revoxelseg-dicom:web-local-20260524-lock
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: Docker部署/Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4000:4000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 4000
|
||||
APP_URL: http://192.168.3.11:4000
|
||||
TRUST_PROXY: "true"
|
||||
volumes:
|
||||
- ./data:/app/WebSite/data
|
||||
- ./exports:/app/WebSite/exports
|
||||
- ./locked-results:/app/项目数据/锁定结果
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:4000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
revoxelseg_frpc:
|
||||
image: snowdreamtech/frpc:latest
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/bin/sh"]
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
cat > /tmp/frpc.toml <<'EOF'
|
||||
serverAddr = "82.157.255.195"
|
||||
serverPort = 7000
|
||||
|
||||
auth.method = "token"
|
||||
auth.token = "en.xjtu.edu.cn"
|
||||
|
||||
transport.poolCount = 5
|
||||
transport.heartbeatTimeout = -1
|
||||
|
||||
[[proxies]]
|
||||
name = "ReVoxelSeg_DICOM"
|
||||
type = "tcp"
|
||||
localIP = "revoxelseg_web"
|
||||
localPort = 4000
|
||||
remotePort = 10008
|
||||
EOF
|
||||
exec frpc -c /tmp/frpc.toml
|
||||
depends_on:
|
||||
revoxelseg_web:
|
||||
condition: service_healthy
|
||||
22
README.md
22
README.md
@@ -35,4 +35,26 @@ npm run serve -- --host 0.0.0.0 --port 4000
|
||||
http://192.168.3.11:4000/
|
||||
```
|
||||
|
||||
## Docker 部署
|
||||
|
||||
项目已提供本机与威联通 NAS 两套 Docker Compose 部署文件,详见:
|
||||
|
||||
```text
|
||||
Docker部署/README.md
|
||||
```
|
||||
|
||||
本机启动:
|
||||
|
||||
```bash
|
||||
docker compose -f Docker部署/本机/docker_compose.yaml up -d --build
|
||||
```
|
||||
|
||||
威联通 NAS / QTS Container Station 建议将完整项目放到 `/share/Container/revoxelseg_dicom`,再使用:
|
||||
|
||||
```text
|
||||
Docker部署/威联通NAS/docker_compose.yaml
|
||||
```
|
||||
|
||||
Compose 内置 frpc 映射,会将容器服务 `revoxelseg_web:4000` 映射到 FRP 服务端远程端口 `10008`,配合 NPM 反向代理后可通过 `https://revoxel.huijutec.cn/` 访问。
|
||||
|
||||
当前版本不需要 Python/conda。后续接入真实医学级 STL 体素化算法时,可新建 `revoxelseg` conda 环境并安装 SimpleITK、nibabel、numpy、trimesh、vtk 等算法依赖。
|
||||
|
||||
1700
WebSite/server.ts
1700
WebSite/server.ts
File diff suppressed because it is too large
Load Diff
@@ -15,8 +15,9 @@ import Sidebar from './components/Sidebar';
|
||||
import Overview from './components/Overview';
|
||||
import ProjectLibrary from './components/ProjectLibrary';
|
||||
import ReverseWorkspace from './components/ReverseWorkspace';
|
||||
import AutoMatchWorkspace from './components/AutoMatchWorkspace';
|
||||
import UserManagement from './components/UserManagement';
|
||||
import { ViewType } from './types';
|
||||
import { ModelPose, ViewType } from './types';
|
||||
import { api } from './lib/api';
|
||||
|
||||
export default function App() {
|
||||
@@ -25,13 +26,14 @@ export default function App() {
|
||||
const [activeView, setActiveView] = useState<ViewType>(ViewType.OVERVIEW);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [activeProjectId, setActiveProjectId] = useState('head-ct-demo');
|
||||
const [autoMatchInitialPose, setAutoMatchInitialPose] = useState<ModelPose | null>(null);
|
||||
const [projectLibraryInitialView, setProjectLibraryInitialView] = useState<'dicom' | 'model' | 'mask'>('dicom');
|
||||
const workspaceLeaveGuardRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const bootSessionResetRef = useRef(false);
|
||||
|
||||
// Automatically collapse main sidebar when entering Project Library or Workspace
|
||||
useEffect(() => {
|
||||
if (activeView === ViewType.PROJECTS || activeView === ViewType.WORKSPACE) {
|
||||
if (activeView === ViewType.PROJECTS || activeView === ViewType.WORKSPACE || activeView === ViewType.AUTO_MATCH) {
|
||||
setSidebarCollapsed(true);
|
||||
} else {
|
||||
setSidebarCollapsed(false);
|
||||
@@ -94,6 +96,9 @@ export default function App() {
|
||||
if (leaveWorkspace && nextView === ViewType.PROJECTS) {
|
||||
setProjectLibraryInitialView('mask');
|
||||
}
|
||||
if (nextView === ViewType.AUTO_MATCH) {
|
||||
setAutoMatchInitialPose(null);
|
||||
}
|
||||
setActiveView(nextView);
|
||||
};
|
||||
|
||||
@@ -123,6 +128,12 @@ export default function App() {
|
||||
setActiveView(ViewType.OVERVIEW);
|
||||
};
|
||||
|
||||
const openAutoMatchWorkspace = (projectId: string, pose?: ModelPose) => {
|
||||
setActiveProjectId(projectId);
|
||||
setAutoMatchInitialPose(pose ?? null);
|
||||
setActiveView(ViewType.AUTO_MATCH);
|
||||
};
|
||||
|
||||
if (sessionLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center text-slate-500 font-medium">
|
||||
@@ -153,6 +164,7 @@ export default function App() {
|
||||
{activeView === ViewType.OVERVIEW && '总体概况'}
|
||||
{activeView === ViewType.PROJECTS && '项目库'}
|
||||
{activeView === ViewType.WORKSPACE && '逆向工作区'}
|
||||
{activeView === ViewType.AUTO_MATCH && '自动微调匹配工作区'}
|
||||
{activeView === ViewType.SYSTEM && '系统管理工作区'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -185,11 +197,22 @@ export default function App() {
|
||||
{activeView === ViewType.WORKSPACE && (
|
||||
<ReverseWorkspace
|
||||
projectId={activeProjectId}
|
||||
onAutoMatch={openAutoMatchWorkspace}
|
||||
onLeaveGuardChange={(handler) => {
|
||||
workspaceLeaveGuardRef.current = handler;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeView === ViewType.AUTO_MATCH && (
|
||||
<AutoMatchWorkspace
|
||||
projectId={activeProjectId}
|
||||
initialPose={autoMatchInitialPose}
|
||||
onOpenReverse={(projectId) => {
|
||||
setActiveProjectId(projectId);
|
||||
setActiveView(ViewType.WORKSPACE);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeView === ViewType.SYSTEM && <UserManagement />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
714
WebSite/src/components/AutoMatchWorkspace.tsx
Normal file
714
WebSite/src/components/AutoMatchWorkspace.tsx
Normal file
@@ -0,0 +1,714 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bone,
|
||||
CheckCircle2,
|
||||
Crosshair,
|
||||
Loader2,
|
||||
Lock,
|
||||
Play,
|
||||
RefreshCcw,
|
||||
Save,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AutoMatchParameterKey,
|
||||
AutoMatchParameterSelection,
|
||||
AutoMatchWeights,
|
||||
ModuleStyle,
|
||||
ModelPose,
|
||||
Project,
|
||||
} from '../types';
|
||||
import { api } from '../lib/api';
|
||||
import { cn } from '../lib/utils';
|
||||
import { VoxelizationMappingView, type MappingDisplayMode } from './ReverseWorkspace';
|
||||
|
||||
const defaultModelPose: ModelPose = {
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
rotateZ: 0,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scaleZ: 1,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
flipZ: false,
|
||||
};
|
||||
|
||||
const defaultAdjustable: AutoMatchParameterSelection = {
|
||||
translateX: true,
|
||||
translateY: true,
|
||||
translateZ: true,
|
||||
scale: true,
|
||||
};
|
||||
|
||||
const defaultWeights: AutoMatchWeights = {
|
||||
boneReward: 1,
|
||||
missPenalty: 0.1,
|
||||
movementPenalty: 0,
|
||||
scalePenalty: 0,
|
||||
};
|
||||
|
||||
const parameterOptions: Array<{ key: AutoMatchParameterKey; label: string }> = [
|
||||
{ key: 'translateX', label: '平移 X' },
|
||||
{ key: 'translateY', label: '平移 Y' },
|
||||
{ key: 'translateZ', label: '平移 Z' },
|
||||
{ key: 'scale', label: '缩放' },
|
||||
];
|
||||
|
||||
const weightOptions: Array<{ key: keyof AutoMatchWeights; label: string; min: number; max: number; step: number }> = [
|
||||
{ key: 'boneReward', label: '骨窗命中奖励', min: 0.2, max: 2, step: 0.05 },
|
||||
{ key: 'missPenalty', label: '非骨区域惩罚', min: 0, max: 1.5, step: 0.05 },
|
||||
{ key: 'movementPenalty', label: '移动惩罚', min: 0, max: 0.4, step: 0.01 },
|
||||
{ key: 'scalePenalty', label: '缩放惩罚', min: 0, max: 0.6, step: 0.01 },
|
||||
];
|
||||
|
||||
const boneNamePattern = /(rib|bone|hipbone|hip|vertebra|spine|sternum|pelvis|sacrum|costal|skull|肋|骨)/i;
|
||||
const maxSampleSliceCount = 96;
|
||||
const defaultSampleSliceCount = 9;
|
||||
type AutoMatchMappingMode = 'selected' | 'score';
|
||||
const mappingModeOptions: Array<{ id: AutoMatchMappingMode; label: string }> = [
|
||||
{ id: 'selected', label: '所选骨骼区域' },
|
||||
{ id: 'score', label: '得分可视化' },
|
||||
];
|
||||
|
||||
function latestPoseFromProject(project: Project | null, incomingPose?: ModelPose | null) {
|
||||
if (incomingPose) {
|
||||
return incomingPose;
|
||||
}
|
||||
const latestResult = project?.segmentationResults?.[project.segmentationResults.length - 1];
|
||||
return latestResult?.pose
|
||||
?? project?.modelPoses?.find((pose) => pose.id === 'auto-match')?.pose
|
||||
?? project?.modelPoses?.find((pose) => pose.id === 'default')?.pose
|
||||
?? project?.modelPoses?.[0]?.pose
|
||||
?? defaultModelPose;
|
||||
}
|
||||
|
||||
function suggestedBoneFiles(project: Project | null) {
|
||||
const files = project?.stlFiles ?? [];
|
||||
const matched = files.filter((fileName) => boneNamePattern.test(fileName));
|
||||
return matched.length ? matched : files;
|
||||
}
|
||||
|
||||
function formatPoseNumber(value: number) {
|
||||
return Number.isFinite(value) ? value.toFixed(3) : '0.000';
|
||||
}
|
||||
|
||||
function poseDelta(base: ModelPose, next: ModelPose, key: AutoMatchParameterKey) {
|
||||
return next[key] - base[key];
|
||||
}
|
||||
|
||||
function defaultSampleSliceNumbers(total: number) {
|
||||
const maxSlice = Math.max(total - 1, 0);
|
||||
const fractions = [0.12, 0.22, 0.32, 0.42, 0.5, 0.58, 0.68, 0.78, 0.88];
|
||||
return [...new Set(fractions.map((fraction) => Math.max(0, Math.min(maxSlice, Math.round(maxSlice * fraction))) + 1))]
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function uniformSampleSliceNumbers(total: number, count: number) {
|
||||
const maxSlice = Math.max(total, 1);
|
||||
const safeCount = Math.max(1, Math.min(maxSampleSliceCount, Math.round(count)));
|
||||
if (safeCount <= 1) {
|
||||
return [Math.max(1, Math.round(maxSlice / 2))];
|
||||
}
|
||||
return [...new Set(Array.from({ length: safeCount }, (_, index) => (
|
||||
Math.max(1, Math.min(maxSlice, Math.round(1 + ((maxSlice - 1) * index) / (safeCount - 1))))
|
||||
)))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function poseHasVisibleChange(base: ModelPose, next: ModelPose) {
|
||||
return parameterOptions.some((option) => Math.abs(poseDelta(base, next, option.key)) >= 0.0005);
|
||||
}
|
||||
|
||||
function buildSelectedModuleStyles(
|
||||
project: Project | null,
|
||||
selectedFiles: string[],
|
||||
mode: AutoMatchMappingMode,
|
||||
accentColor: string,
|
||||
) {
|
||||
const selected = new Set(selectedFiles);
|
||||
return (project?.stlFiles ?? []).reduce<Record<string, ModuleStyle>>((accumulator, fileName, index) => {
|
||||
const source = project?.moduleStyles?.[fileName];
|
||||
const visible = selected.has(fileName);
|
||||
accumulator[fileName] = {
|
||||
visible,
|
||||
color: visible && mode === 'score' ? accentColor : source?.color ?? ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6'][index % 5],
|
||||
opacity: visible ? (mode === 'score' ? 0.9 : source?.opacity ?? 0.72) : source?.opacity ?? 0.72,
|
||||
partId: source?.partId ?? index + 1,
|
||||
};
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
interface AutoMatchWorkspaceProps {
|
||||
projectId: string;
|
||||
initialPose?: ModelPose | null;
|
||||
onOpenReverse?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export default function AutoMatchWorkspace({ projectId, initialPose, onOpenReverse }: AutoMatchWorkspaceProps) {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState(projectId);
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [basePose, setBasePose] = useState<ModelPose>(initialPose ?? defaultModelPose);
|
||||
const [selectedBoneFiles, setSelectedBoneFiles] = useState<string[]>([]);
|
||||
const [adjustable, setAdjustable] = useState<AutoMatchParameterSelection>(defaultAdjustable);
|
||||
const [weights, setWeights] = useState<AutoMatchWeights>(defaultWeights);
|
||||
const [sampleSliceCount, setSampleSliceCount] = useState(defaultSampleSliceCount);
|
||||
const [previewSlice, setPreviewSlice] = useState(0);
|
||||
const [mappingMode, setMappingMode] = useState<AutoMatchMappingMode>('selected');
|
||||
const [iterations, setIterations] = useState(6);
|
||||
const [candidatesPerRound, setCandidatesPerRound] = useState(36);
|
||||
const [result, setResult] = useState<{
|
||||
bestPose: ModelPose;
|
||||
bestScore: number;
|
||||
bestMode: string;
|
||||
bestChanged: AutoMatchParameterKey[];
|
||||
evaluated: number;
|
||||
sampleSlices: number[];
|
||||
} | null>(null);
|
||||
const [loadingProject, setLoadingProject] = useState(true);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [loadingPercent, setLoadingPercent] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedProjectId(projectId);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
api.getProjects()
|
||||
.then((items) => {
|
||||
if (mounted) {
|
||||
setProjects(items);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoadingProject(true);
|
||||
setError('');
|
||||
api.getProject(selectedProjectId)
|
||||
.then((nextProject) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
const incomingPose = selectedProjectId === projectId ? initialPose : null;
|
||||
setProject(nextProject);
|
||||
setBasePose(latestPoseFromProject(nextProject, incomingPose));
|
||||
setSelectedBoneFiles([]);
|
||||
setSampleSliceCount(defaultSampleSliceCount);
|
||||
setPreviewSlice(Math.max(0, Math.floor((nextProject.dicomCount - 1) / 2)));
|
||||
setResult(null);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (mounted) {
|
||||
setError(loadError instanceof Error ? loadError.message : '项目加载失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) {
|
||||
setLoadingProject(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [selectedProjectId, projectId, initialPose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) {
|
||||
return undefined;
|
||||
}
|
||||
setLoadingPercent(8);
|
||||
const timer = window.setInterval(() => {
|
||||
setLoadingPercent((current) => Math.min(92, current + Math.max(1, (94 - current) * 0.08)));
|
||||
}, 320);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running]);
|
||||
|
||||
const enabledParameterCount = useMemo(
|
||||
() => parameterOptions.filter((option) => adjustable[option.key]).length,
|
||||
[adjustable],
|
||||
);
|
||||
const parsedSampleSlices = useMemo(
|
||||
() => uniformSampleSliceNumbers(project?.dicomCount ?? 1, sampleSliceCount),
|
||||
[project?.dicomCount, sampleSliceCount],
|
||||
);
|
||||
const resultHasPoseChange = result ? poseHasVisibleChange(basePose, result.bestPose) : false;
|
||||
const maxPreviewSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
|
||||
const comparePose = result?.bestPose ?? basePose;
|
||||
const mappingDisplayMode: MappingDisplayMode = mappingMode === 'score' ? 'bone' : 'soft';
|
||||
const currentModuleStyles = useMemo(
|
||||
() => buildSelectedModuleStyles(project, selectedBoneFiles, mappingMode, '#f59e0b'),
|
||||
[project, selectedBoneFiles, mappingMode],
|
||||
);
|
||||
const bestModuleStyles = useMemo(
|
||||
() => buildSelectedModuleStyles(project, selectedBoneFiles, mappingMode, '#22c55e'),
|
||||
[project, selectedBoneFiles, mappingMode],
|
||||
);
|
||||
|
||||
const toggleBoneFile = (fileName: string) => {
|
||||
setSelectedBoneFiles((current) => (
|
||||
current.includes(fileName)
|
||||
? current.filter((item) => item !== fileName)
|
||||
: [...current, fileName]
|
||||
));
|
||||
};
|
||||
|
||||
const runAutoMatch = async () => {
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
if (!selectedBoneFiles.length) {
|
||||
setError('请选择至少一个骨骼区域 STL');
|
||||
return;
|
||||
}
|
||||
if (!enabledParameterCount) {
|
||||
setError('请选择至少一个可微调参数');
|
||||
return;
|
||||
}
|
||||
|
||||
setRunning(true);
|
||||
setError('');
|
||||
setStatus('');
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await api.runAutoMatch(project.id, {
|
||||
pose: basePose,
|
||||
adjustable,
|
||||
boneFiles: selectedBoneFiles,
|
||||
sampleSlices: parsedSampleSlices.map((slice) => slice - 1),
|
||||
iterations,
|
||||
candidatesPerRound,
|
||||
weights,
|
||||
});
|
||||
setResult({
|
||||
bestPose: data.bestPose,
|
||||
bestScore: data.bestScore,
|
||||
bestMode: data.bestMode,
|
||||
bestChanged: data.bestChanged,
|
||||
evaluated: data.evaluated,
|
||||
sampleSlices: data.sampleSlices,
|
||||
});
|
||||
setLoadingPercent(100);
|
||||
setStatus('已完成自动微调匹配');
|
||||
} catch (runError) {
|
||||
setError(runError instanceof Error ? runError.message : '自动微调匹配失败');
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyBestPose = async (returnToWorkspace = false) => {
|
||||
if (!project || !result) {
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setStatus('');
|
||||
try {
|
||||
const updated = await api.applyProjectModelPose(project.id, result.bestPose);
|
||||
setProject(updated);
|
||||
setBasePose(result.bestPose);
|
||||
setStatus('最佳位姿已写入项目库');
|
||||
if (returnToWorkspace) {
|
||||
onOpenReverse?.(project.id);
|
||||
}
|
||||
} catch (applyError) {
|
||||
setError(applyError instanceof Error ? applyError.message : '保存最佳位姿失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto pr-2">
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={selectedProjectId}
|
||||
onChange={(event) => setSelectedProjectId(event.target.value)}
|
||||
className="h-11 min-w-64 rounded-lg border border-slate-200 bg-white px-3 text-sm font-bold text-slate-700 shadow-sm outline-none focus:border-blue-400"
|
||||
>
|
||||
{projects.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="rounded-lg bg-blue-50 px-3 py-2 text-sm font-bold text-blue-700">DICOM {project?.dicomCount ?? '-'}</span>
|
||||
<span className="rounded-lg bg-slate-100 px-3 py-2 text-sm font-bold text-slate-700">STL {project?.modelCount ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onOpenReverse?.(selectedProjectId)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-bold text-slate-700 shadow-sm transition hover:border-blue-200 hover:text-blue-700"
|
||||
>
|
||||
<ArrowLeft size={17} />
|
||||
返回逆向工作区
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(running || loadingProject) && (
|
||||
<div className="mb-4 overflow-hidden rounded-lg border border-blue-100 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between px-4 py-3 text-sm font-bold text-slate-700">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 size={16} className="animate-spin text-blue-600" />
|
||||
{loadingProject ? '正在读取项目数据' : '正在迭代匹配'}
|
||||
</span>
|
||||
<span className="font-mono text-blue-700">{Math.round(loadingProject ? 28 : loadingPercent)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-100">
|
||||
<div
|
||||
className="h-full bg-blue-600 transition-all"
|
||||
style={{ width: `${loadingProject ? 28 : loadingPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-100 bg-red-50 px-4 py-3 text-sm font-bold text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{status && (
|
||||
<div className="mb-4 rounded-lg border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-700">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-h-0 grid-cols-[minmax(360px,0.85fr)_minmax(520px,1.15fr)] gap-5 max-xl:grid-cols-1">
|
||||
<section className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal size={18} className="text-blue-600" />
|
||||
<h2 className="text-base font-black text-slate-800">匹配参数</h2>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 rounded-lg bg-slate-100 px-2.5 py-1 text-[11px] font-black text-slate-600">
|
||||
<Lock size={13} />
|
||||
旋转锁定
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-5">
|
||||
<div>
|
||||
<div className="mb-3 text-xs font-black uppercase text-slate-400">可调整参数</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{parameterOptions.map((option) => (
|
||||
<label
|
||||
key={option.key}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg border px-3 py-2.5 text-sm font-bold transition',
|
||||
adjustable[option.key] ? 'border-blue-200 bg-blue-50 text-blue-700' : 'border-slate-200 bg-slate-50 text-slate-500',
|
||||
)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={adjustable[option.key]}
|
||||
onChange={(event) => setAdjustable((current) => ({ ...current, [option.key]: event.target.checked }))}
|
||||
className="h-4 w-4 accent-blue-600"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-black uppercase text-slate-400">骨骼区域</span>
|
||||
<button
|
||||
onClick={() => setSelectedBoneFiles(suggestedBoneFiles(project))}
|
||||
className="text-xs font-black text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
建议选择
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-60 space-y-2 overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-2">
|
||||
{(project?.stlFiles ?? []).map((fileName) => (
|
||||
<label
|
||||
key={fileName}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-bold transition',
|
||||
selectedBoneFiles.includes(fileName) ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-400',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedBoneFiles.includes(fileName)}
|
||||
onChange={() => toggleBoneFile(fileName)}
|
||||
className="h-4 w-4 accent-blue-600"
|
||||
/>
|
||||
<Bone size={16} className={selectedBoneFiles.includes(fileName) ? 'text-amber-500' : 'text-slate-300'} />
|
||||
<span className="min-w-0 truncate">{fileName.replace(/\.stl$/i, '')}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-black uppercase text-slate-400">采样切片</span>
|
||||
<button
|
||||
onClick={() => setSampleSliceCount(defaultSampleSliceCount)}
|
||||
className="text-xs font-black text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
默认数量
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_72px] items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={maxSampleSliceCount}
|
||||
value={sampleSliceCount}
|
||||
onChange={(event) => setSampleSliceCount(Number(event.target.value))}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxSampleSliceCount}
|
||||
value={sampleSliceCount}
|
||||
onChange={(event) => setSampleSliceCount(Math.max(1, Math.min(maxSampleSliceCount, Number(event.target.value) || 1)))}
|
||||
className="h-9 rounded-md border border-slate-200 px-2 text-right font-mono text-sm font-black text-slate-700 outline-none focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs font-bold text-slate-400">
|
||||
<span>均匀筛选 1-{Math.max(project?.dicomCount ?? 1, 1)}</span>
|
||||
<span>{parsedSampleSlices.length} 张</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-3 text-xs font-black uppercase text-slate-400">评分权重</div>
|
||||
<div className="space-y-3">
|
||||
{weightOptions.map((option) => (
|
||||
<label key={option.key} className="grid grid-cols-[112px_1fr_64px] items-center gap-3 text-xs font-bold text-slate-500">
|
||||
<span>{option.label}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={option.min}
|
||||
max={option.max}
|
||||
step={option.step}
|
||||
value={weights[option.key]}
|
||||
onChange={(event) => setWeights((current) => ({ ...current, [option.key]: Number(event.target.value) }))}
|
||||
className="accent-blue-600"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={option.min}
|
||||
max={option.max}
|
||||
step={option.step}
|
||||
value={weights[option.key]}
|
||||
onChange={(event) => setWeights((current) => ({ ...current, [option.key]: Number(event.target.value) }))}
|
||||
className="h-8 rounded-md border border-slate-200 bg-white px-2 text-right font-mono text-xs text-slate-700 outline-none focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="text-xs font-bold text-slate-500">
|
||||
迭代轮次
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={50}
|
||||
value={iterations}
|
||||
onChange={(event) => setIterations(Math.max(2, Math.min(50, Number(event.target.value) || 2)))}
|
||||
className="mt-1 h-10 w-full rounded-lg border border-slate-200 px-3 font-mono text-sm text-slate-700 outline-none focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs font-bold text-slate-500">
|
||||
每轮候选
|
||||
<input
|
||||
type="number"
|
||||
min={12}
|
||||
max={80}
|
||||
value={candidatesPerRound}
|
||||
onChange={(event) => setCandidatesPerRound(Math.max(12, Math.min(80, Number(event.target.value) || 12)))}
|
||||
className="mt-1 h-10 w-full rounded-lg border border-slate-200 px-3 font-mono text-sm text-slate-700 outline-none focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => void runAutoMatch()}
|
||||
disabled={running || loadingProject || !project}
|
||||
className="flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-blue-600 text-sm font-black text-white shadow-lg shadow-blue-950/10 transition hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{running ? <Loader2 size={18} className="animate-spin" /> : <Play size={18} />}
|
||||
开始自动微调匹配
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Crosshair size={18} className="text-blue-600" />
|
||||
<h2 className="text-base font-black text-slate-800">匹配结果</h2>
|
||||
</div>
|
||||
{result && (
|
||||
<span className="inline-flex items-center gap-1 rounded-lg bg-emerald-50 px-2.5 py-1 text-[11px] font-black text-emerald-700">
|
||||
<CheckCircle2 size={13} />
|
||||
score {result.bestScore.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-4 gap-3 max-lg:grid-cols-2">
|
||||
{parameterOptions.map((option) => (
|
||||
<div key={option.key} className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-3">
|
||||
<div className="text-[11px] font-black uppercase text-slate-400">{option.label}</div>
|
||||
<div className="mt-2 font-mono text-lg font-black text-slate-800">
|
||||
{formatPoseNumber((result?.bestPose ?? basePose)[option.key])}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'mt-1 font-mono text-xs font-bold',
|
||||
result && poseDelta(basePose, result.bestPose, option.key) >= 0 ? 'text-emerald-600' : 'text-red-500',
|
||||
)}
|
||||
>
|
||||
{result ? `${poseDelta(basePose, result.bestPose, option.key) >= 0 ? '+' : ''}${formatPoseNumber(poseDelta(basePose, result.bestPose, option.key))}` : '+0.000'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px] font-black text-slate-500">
|
||||
{[
|
||||
{ label: '轴拉伸 X', value: comparePose.scaleX },
|
||||
{ label: '轴拉伸 Y', value: comparePose.scaleY },
|
||||
{ label: '轴拉伸 Z', value: comparePose.scaleZ },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-lg border border-slate-100 bg-white px-3 py-2">
|
||||
<span className="mr-2 text-slate-400">{item.label}</span>
|
||||
<span className="font-mono text-slate-800">{formatPoseNumber(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{result && !resultHasPoseChange && (
|
||||
<div className="mt-4 rounded-lg border border-amber-100 bg-amber-50 px-4 py-3 text-sm font-bold text-amber-800">
|
||||
本轮最佳候选为“{result.bestMode}”,当前位姿得分最高,所以平移和缩放变化显示为 +0.000。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => void applyBestPose(false)}
|
||||
disabled={!result}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2.5 text-sm font-black text-white shadow-lg shadow-emerald-950/10 transition hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<Save size={17} />
|
||||
应用最佳位姿
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void applyBestPose(true)}
|
||||
disabled={!result}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-black text-white shadow-lg shadow-slate-950/10 transition hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
<Save size={17} />
|
||||
应用并返回逆向工作区
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setResult(null);
|
||||
setBasePose(latestPoseFromProject(project, selectedProjectId === projectId ? initialPose : null));
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-black text-slate-600 transition hover:border-blue-200 hover:text-blue-700"
|
||||
>
|
||||
<RefreshCcw size={17} />
|
||||
重置结果
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{mappingModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => setMappingMode(option.id)}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2 text-xs font-black transition',
|
||||
mappingMode === option.id ? 'bg-slate-900 text-white' : 'bg-white text-slate-500 hover:text-blue-700',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="font-mono text-xs font-black text-slate-500">
|
||||
切片 {previewSlice + 1} / {Math.max(project?.dicomCount ?? 1, 1)}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={maxPreviewSlice}
|
||||
value={Math.min(previewSlice, maxPreviewSlice)}
|
||||
onChange={(event) => setPreviewSlice(Number(event.target.value))}
|
||||
className="mb-4 w-full accent-blue-600"
|
||||
/>
|
||||
|
||||
{selectedBoneFiles.length ? (
|
||||
<div className="grid grid-cols-2 gap-4 max-2xl:grid-cols-1">
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-black text-slate-500">当前位姿</div>
|
||||
<VoxelizationMappingView
|
||||
project={project}
|
||||
moduleStyles={currentModuleStyles}
|
||||
modelPose={basePose}
|
||||
detailLimit={360000}
|
||||
slice={Math.min(previewSlice, maxPreviewSlice)}
|
||||
totalSlices={Math.max(project?.dicomCount ?? 1, 1)}
|
||||
onSliceChange={setPreviewSlice}
|
||||
displayMode={mappingDisplayMode}
|
||||
rotation={0}
|
||||
variant="workspace"
|
||||
overlayPlacement="bottom"
|
||||
showSliceControl={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-black text-slate-500">自动匹配结果</div>
|
||||
<VoxelizationMappingView
|
||||
project={project}
|
||||
moduleStyles={bestModuleStyles}
|
||||
modelPose={comparePose}
|
||||
detailLimit={360000}
|
||||
slice={Math.min(previewSlice, maxPreviewSlice)}
|
||||
totalSlices={Math.max(project?.dicomCount ?? 1, 1)}
|
||||
onSliceChange={setPreviewSlice}
|
||||
displayMode={mappingDisplayMode}
|
||||
rotation={0}
|
||||
variant="workspace"
|
||||
overlayPlacement="bottom"
|
||||
showSliceControl={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-white px-4 py-12 text-center text-sm font-bold text-slate-400">
|
||||
请选择骨骼区域后查看映射对比
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,13 @@ import {
|
||||
Layers,
|
||||
X,
|
||||
Trash2,
|
||||
Upload
|
||||
Upload,
|
||||
RefreshCcw,
|
||||
FlipHorizontal2,
|
||||
FlipVertical2,
|
||||
Move3d,
|
||||
Lock,
|
||||
Unlock
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
import { DicomFusionVolume, DicomInfo, DicomPreview, ModuleStyle, Project, SegmentationExportScope } from '../types';
|
||||
@@ -47,6 +53,12 @@ interface ModelPose {
|
||||
translateY: number;
|
||||
translateZ: number;
|
||||
scale: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
scaleZ: number;
|
||||
flipX: boolean;
|
||||
flipY: boolean;
|
||||
flipZ: boolean;
|
||||
}
|
||||
|
||||
interface ModelPreviewPayload {
|
||||
@@ -60,7 +72,8 @@ interface ModelPreviewPayload {
|
||||
};
|
||||
}
|
||||
|
||||
type ModelPoseKey = keyof ModelPose;
|
||||
type ModelPoseKey = Exclude<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
|
||||
type ModelPoseFlipKey = Extract<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
|
||||
|
||||
const defaultModuleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||||
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
|
||||
@@ -75,13 +88,13 @@ const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: stri
|
||||
];
|
||||
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
|
||||
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
|
||||
{ id: 'separate', label: '构件分别导出', description: '每个构件单独生成 NII.GZ' },
|
||||
{ id: 'separate', label: '构件分别导出', description: '全部构件集中到同一目录' },
|
||||
];
|
||||
const solidityOptions: Array<{ id: SolidityLevel; label: string; limit: number }> = [
|
||||
{ id: 'standard', label: '标准', limit: 16000 },
|
||||
{ id: 'fine', label: '精细', limit: 36000 },
|
||||
{ id: 'ultra', label: '超精细', limit: 72000 },
|
||||
{ id: 'solid', label: '实体', limit: 200000 },
|
||||
{ id: 'solid', label: '实体', limit: 800000 },
|
||||
];
|
||||
const defaultModelPose: ModelPose = {
|
||||
rotateX: 0,
|
||||
@@ -91,7 +104,18 @@ const defaultModelPose: ModelPose = {
|
||||
translateY: 0,
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scaleZ: 1,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
flipZ: false,
|
||||
};
|
||||
const modelPoseFlipOptions: Array<{ key: ModelPoseFlipKey; label: string; axis: string; icon: typeof FlipHorizontal2 }> = [
|
||||
{ key: 'flipX', label: '镜像 X', axis: 'X', icon: FlipHorizontal2 },
|
||||
{ key: 'flipY', label: '镜像 Y', axis: 'Y', icon: FlipVertical2 },
|
||||
{ key: 'flipZ', label: '镜像 Z', axis: 'Z', icon: Move3d },
|
||||
];
|
||||
const emptyOverlayStats: OverlayStats = {
|
||||
activeModules: 0,
|
||||
filledPixels: 0,
|
||||
@@ -106,11 +130,38 @@ const modelPoseLimits: Record<ModelPoseKey, { min: number; max: number }> = {
|
||||
translateY: { min: -2, max: 2 },
|
||||
translateZ: { min: -2, max: 2 },
|
||||
scale: { min: 0.5, max: 2.5 },
|
||||
scaleX: { min: 0.2, max: 8 },
|
||||
scaleY: { min: 0.2, max: 8 },
|
||||
scaleZ: { min: 0.2, max: 8 },
|
||||
};
|
||||
const modelPoseStepPrecision: Partial<Record<ModelPoseKey, number>> = {
|
||||
translateX: 3,
|
||||
translateY: 3,
|
||||
translateZ: 3,
|
||||
scale: 3,
|
||||
scaleX: 3,
|
||||
scaleY: 3,
|
||||
scaleZ: 3,
|
||||
};
|
||||
|
||||
function clampModelPoseValue(key: ModelPoseKey, value: number) {
|
||||
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 {
|
||||
@@ -122,13 +173,58 @@ function clampModelPose(next: ModelPose): ModelPose {
|
||||
translateY: clampModelPoseValue('translateY', next.translateY),
|
||||
translateZ: clampModelPoseValue('translateZ', next.translateZ),
|
||||
scale: clampModelPoseValue('scale', next.scale),
|
||||
scaleX: clampModelPoseValue('scaleX', next.scaleX),
|
||||
scaleY: clampModelPoseValue('scaleY', next.scaleY),
|
||||
scaleZ: clampModelPoseValue('scaleZ', next.scaleZ),
|
||||
flipX: Boolean(next.flipX),
|
||||
flipY: Boolean(next.flipY),
|
||||
flipZ: Boolean(next.flipZ),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModelPose(pose: Partial<ModelPose> | undefined): ModelPose {
|
||||
return clampModelPose({
|
||||
...defaultModelPose,
|
||||
...(pose ?? {}),
|
||||
flipX: typeof pose?.flipX === 'boolean' ? pose.flipX : defaultModelPose.flipX,
|
||||
flipY: typeof pose?.flipY === 'boolean' ? pose.flipY : defaultModelPose.flipY,
|
||||
flipZ: typeof pose?.flipZ === 'boolean' ? pose.flipZ : defaultModelPose.flipZ,
|
||||
});
|
||||
}
|
||||
|
||||
function formatPoseCompactValue(value: number, digits = 2) {
|
||||
return Number.isFinite(value) ? Number(value).toFixed(digits).replace(/\.?0+$/, '') : '0';
|
||||
}
|
||||
|
||||
function getProjectActivityTime(project: Project) {
|
||||
const latestResult = project.segmentationResults?.[project.segmentationResults.length - 1];
|
||||
const candidates = [
|
||||
project.lastProcessedAt,
|
||||
project.lockedAt,
|
||||
project.unlockedAt,
|
||||
latestResult?.createdAt,
|
||||
project.createTime,
|
||||
];
|
||||
return Math.max(...candidates.map((value) => (value ? Date.parse(value) : 0)).filter((value) => Number.isFinite(value)), 0);
|
||||
}
|
||||
|
||||
function sortProjectsByActivity(projects: Project[]) {
|
||||
return [...projects].sort((a, b) => getProjectActivityTime(b) - getProjectActivityTime(a));
|
||||
}
|
||||
|
||||
function formatProjectActivity(project: Project) {
|
||||
const time = getProjectActivityTime(project);
|
||||
if (!time) {
|
||||
return project.createTime;
|
||||
}
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(time));
|
||||
}
|
||||
|
||||
interface AssetImportProgressState {
|
||||
kind: ProjectAssetImportKind;
|
||||
fileCount: number;
|
||||
@@ -272,8 +368,27 @@ function displayDicomValue(value: string | number | null | undefined) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: number }) {
|
||||
function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) {
|
||||
const total = Math.max(Math.round(totalSlices), 0);
|
||||
if (!total) {
|
||||
return 0;
|
||||
}
|
||||
return total - Math.max(0, Math.min(total - 1, Math.round(sliceIndex)));
|
||||
}
|
||||
|
||||
function DicomCanvas({ preview, rotation, resetSignal }: { preview: DicomPreview; rotation: number; resetSignal: number }) {
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [viewport, setViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const panRef = useRef({
|
||||
active: false,
|
||||
pointerId: 0,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -283,11 +398,91 @@ function DicomCanvas({ preview, rotation }: { preview: DicomPreview; rotation: n
|
||||
drawDicomPreviewToCanvas(canvas, preview, rotation);
|
||||
}, [preview, rotation]);
|
||||
|
||||
useEffect(() => {
|
||||
setViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
||||
}, [resetSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||
setViewport((current) => ({
|
||||
...current,
|
||||
scale: Math.max(0.35, Math.min(6, current.scale * scaleFactor)),
|
||||
}));
|
||||
};
|
||||
|
||||
element.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => {
|
||||
element.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
panRef.current = {
|
||||
active: true,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
offsetX: viewport.offsetX,
|
||||
offsetY: viewport.offsetY,
|
||||
};
|
||||
setIsPanning(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const dragState = panRef.current;
|
||||
if (!dragState.active || dragState.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
setViewport((current) => ({
|
||||
...current,
|
||||
offsetX: dragState.offsetX + event.clientX - dragState.startX,
|
||||
offsetY: dragState.offsetY + event.clientY - dragState.startY,
|
||||
}));
|
||||
};
|
||||
const stopPointerDrag = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const dragState = panRef.current;
|
||||
if (!dragState.active || dragState.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
panRef.current = { ...dragState, active: false };
|
||||
setIsPanning(false);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="max-h-full max-w-full object-contain rounded-xl bg-black shadow-2xl ring-1 ring-white/25"
|
||||
/>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className={`relative flex h-full w-full touch-none items-center justify-center overflow-hidden rounded-xl bg-black ${isPanning ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={stopPointerDrag}
|
||||
onPointerCancel={stopPointerDrag}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-full max-w-full items-center justify-center"
|
||||
style={{
|
||||
transform: `translate3d(${viewport.offsetX}px, ${viewport.offsetY}px, 0) scale(${viewport.scale})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="max-h-full max-w-full select-none object-contain rounded-xl bg-black shadow-2xl ring-1 ring-white/25"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -300,9 +495,9 @@ function OrientationGizmo({ pose }: { pose: ModelPose }) {
|
||||
'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) },
|
||||
{ id: 'X', color: '#ef4444', vector: new THREE.Vector3(pose.flipX ? -1 : 1, 0, 0).applyMatrix4(rotation) },
|
||||
{ id: 'Y', color: '#10b981', vector: new THREE.Vector3(0, pose.flipY ? -1 : 1, 0).applyMatrix4(rotation) },
|
||||
{ id: 'Z', color: '#3b82f6', vector: new THREE.Vector3(0, 0, pose.flipZ ? -1 : 1).applyMatrix4(rotation) },
|
||||
]
|
||||
.map((axis) => ({
|
||||
...axis,
|
||||
@@ -313,7 +508,7 @@ function OrientationGizmo({ pose }: { pose: ModelPose }) {
|
||||
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]);
|
||||
}, [pose.rotateX, pose.rotateY, pose.rotateZ, pose.flipX, pose.flipY, pose.flipZ]);
|
||||
|
||||
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">
|
||||
@@ -519,29 +714,35 @@ function NativeStlViewer({
|
||||
scene.add(fillLight);
|
||||
|
||||
const poseGroup = new THREE.Group();
|
||||
const axisScaleGroup = new THREE.Group();
|
||||
const rotationScaleGroup = new THREE.Group();
|
||||
const pivotGroup = new THREE.Group();
|
||||
poseGroup.add(pivotGroup);
|
||||
rotationScaleGroup.add(pivotGroup);
|
||||
axisScaleGroup.add(rotationScaleGroup);
|
||||
poseGroup.add(axisScaleGroup);
|
||||
let baseScale = 1;
|
||||
scene.add(poseGroup);
|
||||
let loaded = 0;
|
||||
let failed = 0;
|
||||
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||||
const modelPreviewLimit = solidMode ? Math.max(detailLimit, 800000) : detailLimit;
|
||||
|
||||
visibleFiles.forEach((fileName) => {
|
||||
getCachedModelPreview(projectId, fileName, detailLimit)
|
||||
getCachedModelPreview(projectId, fileName, modelPreviewLimit)
|
||||
.then((payload) => {
|
||||
if (disposed) return;
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
|
||||
geometry.computeVertexNormals();
|
||||
const style = styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 };
|
||||
const materialOpacity = solidMode ? Math.max(style.opacity, 0.94) : style.opacity;
|
||||
const materialOpacity = solidMode ? 1 : style.opacity;
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: style.color,
|
||||
opacity: materialOpacity,
|
||||
transparent: materialOpacity < 1,
|
||||
depthWrite: materialOpacity >= 1,
|
||||
roughness: solidMode ? 0.56 : 0.42,
|
||||
metalness: 0.04,
|
||||
side: THREE.DoubleSide,
|
||||
@@ -591,7 +792,12 @@ function NativeStlViewer({
|
||||
poseGroup.position.set(0, 0, 0);
|
||||
pivotGroup.position.set(0, 0, 0);
|
||||
baseScale = 4.2 / maxSize;
|
||||
pivotGroup.scale.setScalar(baseScale * poseRef.current.scale);
|
||||
const initialPoseScale = baseScale * poseRef.current.scale;
|
||||
rotationScaleGroup.scale.set(
|
||||
poseRef.current.flipX ? -initialPoseScale : initialPoseScale,
|
||||
poseRef.current.flipY ? -initialPoseScale : initialPoseScale,
|
||||
poseRef.current.flipZ ? -initialPoseScale : initialPoseScale,
|
||||
);
|
||||
camera.lookAt(0, 0, 0);
|
||||
setStatus(failed ? `完成,${failed} 个模型加载失败` : '模型加载完成');
|
||||
}
|
||||
@@ -616,12 +822,18 @@ function NativeStlViewer({
|
||||
if (disposed) return;
|
||||
const currentPose = poseRef.current;
|
||||
poseGroup.position.set(currentPose.translateX, currentPose.translateY, currentPose.translateZ);
|
||||
pivotGroup.rotation.set(
|
||||
axisScaleGroup.scale.set(currentPose.scaleX, currentPose.scaleY, currentPose.scaleZ);
|
||||
rotationScaleGroup.rotation.set(
|
||||
THREE.MathUtils.degToRad(currentPose.rotateX),
|
||||
THREE.MathUtils.degToRad(currentPose.rotateY),
|
||||
THREE.MathUtils.degToRad(currentPose.rotateZ),
|
||||
);
|
||||
pivotGroup.scale.setScalar(baseScale * currentPose.scale);
|
||||
const poseScale = baseScale * currentPose.scale;
|
||||
rotationScaleGroup.scale.set(
|
||||
currentPose.flipX ? -poseScale : poseScale,
|
||||
currentPose.flipY ? -poseScale : poseScale,
|
||||
currentPose.flipZ ? -poseScale : poseScale,
|
||||
);
|
||||
renderer.render(scene, camera);
|
||||
animationId = window.requestAnimationFrame(animate);
|
||||
};
|
||||
@@ -682,12 +894,14 @@ export default function ProjectLibrary({
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [showUnlockedOnly, setShowUnlockedOnly] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'dicom' | 'model' | 'mask'>(initialViewMode);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [sliceIndex, setSliceIndex] = useState(0);
|
||||
const [plane, setPlane] = useState<Plane>('axial');
|
||||
const [displayMode, setDisplayMode] = useState<DisplayMode>('default');
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [dicomViewportResetSignal, setDicomViewportResetSignal] = useState(0);
|
||||
const [isSliceChanging, setIsSliceChanging] = useState(false);
|
||||
const [solidityLevel, setSolidityLevel] = useState<SolidityLevel>('standard');
|
||||
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
||||
@@ -722,6 +936,7 @@ export default function ProjectLibrary({
|
||||
const [maskSegmentationExportMode, setMaskSegmentationExportMode] = useState<SegmentationExportMode>('combined');
|
||||
const [maskExporting, setMaskExporting] = useState(false);
|
||||
const [assetImporting, setAssetImporting] = useState(false);
|
||||
const [lockChangingProjectId, setLockChangingProjectId] = useState('');
|
||||
const [assetImportProgress, setAssetImportProgress] = useState<AssetImportProgressState | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const importKindRef = useRef<ProjectAssetImportKind>('dicom');
|
||||
@@ -748,13 +963,14 @@ export default function ProjectLibrary({
|
||||
setLoading(true);
|
||||
return api.getProjects()
|
||||
.then((items) => {
|
||||
setProjects(items);
|
||||
items.slice(0, 2).forEach(preloadProjectAssets);
|
||||
const orderedItems = sortProjectsByActivity(items);
|
||||
setProjects(orderedItems);
|
||||
orderedItems.slice(0, 2).forEach(preloadProjectAssets);
|
||||
setSelectedProject((current) => {
|
||||
if (!current) {
|
||||
return items[0] ?? null;
|
||||
return orderedItems[0] ?? null;
|
||||
}
|
||||
return items.find((item) => item.id === current.id) ?? items[0] ?? null;
|
||||
return orderedItems.find((item) => item.id === current.id) ?? orderedItems[0] ?? null;
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
@@ -774,11 +990,12 @@ export default function ProjectLibrary({
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return projects;
|
||||
}
|
||||
return projects.filter((project) => project.name.toLowerCase().includes(keyword));
|
||||
}, [projects, search]);
|
||||
return sortProjectsByActivity(projects).filter((project) => {
|
||||
const matchesKeyword = !keyword || project.name.toLowerCase().includes(keyword);
|
||||
const matchesLockFilter = !showUnlockedOnly || project.locked !== true;
|
||||
return matchesKeyword && matchesLockFilter;
|
||||
});
|
||||
}, [projects, search, showUnlockedOnly]);
|
||||
|
||||
const stlFiles = selectedProject?.stlFiles ?? [];
|
||||
const planeOptions: Array<{ id: Plane; label: string }> = [
|
||||
@@ -794,11 +1011,18 @@ export default function ProjectLibrary({
|
||||
];
|
||||
const allModulesVisible = stlFiles.length > 0 && stlFiles.every((file) => moduleStyles[file]?.visible !== false);
|
||||
const sliceTotal = dicomPreview?.total ?? selectedProject?.dicomCount ?? 0;
|
||||
const dicomSliceTotal = sliceTotal || selectedProject?.dicomCount || 0;
|
||||
const dicomMaxSlice = Math.max(dicomSliceTotal - 1, 0);
|
||||
const safeDicomSlice = Math.max(0, Math.min(dicomMaxSlice, sliceIndex));
|
||||
const dicomDisplaySlice = getDicomDisplaySliceNumber(safeDicomSlice, dicomSliceTotal);
|
||||
const dicomSliderValue = safeDicomSlice;
|
||||
const selectedSolidity = solidityOptions.find((option) => option.id === solidityLevel) ?? solidityOptions[0];
|
||||
const savedSegmentationResults = selectedProject?.segmentationResults ?? [];
|
||||
const latestSegmentationResult = savedSegmentationResults[savedSegmentationResults.length - 1];
|
||||
const latestResultPose = latestSegmentationResult ? resultPose : modelPose;
|
||||
const latestResultStyles = latestSegmentationResult?.moduleStyles ?? moduleStyles;
|
||||
const latestResultStyles = latestSegmentationResult
|
||||
? { ...latestSegmentationResult.moduleStyles, ...moduleStyles }
|
||||
: moduleStyles;
|
||||
const resultMaxSlice = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0);
|
||||
const resultMappingSlice = Math.max(0, Math.min(resultMaxSlice, resultPreviewSlice));
|
||||
const resultDisplayOption = reverseDisplayOptions.find((option) => option.id === 'fine') ?? reverseDisplayOptions[0];
|
||||
@@ -820,7 +1044,7 @@ export default function ProjectLibrary({
|
||||
api.updateProjectModuleStyles(selectedProject.id, next)
|
||||
.then((updated) => {
|
||||
setSelectedProject(updated);
|
||||
setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item)));
|
||||
setProjects((items) => sortProjectsByActivity(items.map((item) => (item.id === updated.id ? updated : item))));
|
||||
})
|
||||
.catch((error) => {
|
||||
setActionMessage(error instanceof Error ? error.message : '构件样式保存失败');
|
||||
@@ -847,6 +1071,7 @@ export default function ProjectLibrary({
|
||||
pose: latestSegmentationResult?.pose ?? modelPose,
|
||||
segmentationScope: maskSegmentationScope,
|
||||
segmentationExportMode: maskSegmentationExportMode,
|
||||
moduleStyles,
|
||||
});
|
||||
window.setTimeout(() => setMaskExporting(false), 900);
|
||||
setShowMaskExportMenu(false);
|
||||
@@ -930,15 +1155,16 @@ export default function ProjectLibrary({
|
||||
clearCachedProjectAssets(updated.id);
|
||||
preloadedProjectIdsRef.current.delete(updated.id);
|
||||
setSelectedProject(updated);
|
||||
setProjects((items) => items.map((item) => (item.id === updated.id ? updated : item)));
|
||||
setProjects((items) => sortProjectsByActivity(items.map((item) => (item.id === updated.id ? updated : item))));
|
||||
const latestResult = updated.segmentationResults?.[updated.segmentationResults.length - 1];
|
||||
const nextStyles: Record<string, ModuleStyle> = {};
|
||||
(updated.stlFiles ?? []).forEach((fileName, index) => {
|
||||
nextStyles[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? updated.moduleStyles?.[fileName]);
|
||||
});
|
||||
setModuleStyles(nextStyles);
|
||||
setModelPose(latestResult?.pose ?? defaultModelPose);
|
||||
setResultPose(latestResult?.pose ?? defaultModelPose);
|
||||
const nextPose = normalizeModelPose(latestResult?.pose);
|
||||
setModelPose(nextPose);
|
||||
setResultPose(nextPose);
|
||||
setSliceIndex(0);
|
||||
setDicomPreview(null);
|
||||
setDicomError('');
|
||||
@@ -976,15 +1202,20 @@ export default function ProjectLibrary({
|
||||
|
||||
useEffect(() => {
|
||||
const latestResult = selectedProject?.segmentationResults?.[selectedProject.segmentationResults.length - 1];
|
||||
const maxIndex = Math.max((selectedProject?.dicomCount ?? 1) - 1, 0);
|
||||
const next: Record<string, ModuleStyle> = {};
|
||||
stlFiles.forEach((fileName, index) => {
|
||||
next[fileName] = makeDefaultModuleStyle(index, latestResult?.moduleStyles?.[fileName] ?? selectedProject?.moduleStyles?.[fileName] ?? moduleStyles[fileName]);
|
||||
next[fileName] = makeDefaultModuleStyle(index, {
|
||||
...(latestResult?.moduleStyles?.[fileName] ?? {}),
|
||||
...(selectedProject?.moduleStyles?.[fileName] ?? {}),
|
||||
});
|
||||
});
|
||||
setModuleStyles(next);
|
||||
setSliceIndex(0);
|
||||
setModelPose(latestResult?.pose ?? defaultModelPose);
|
||||
setResultPose(latestResult?.pose ?? defaultModelPose);
|
||||
setResultPreviewSlice(Math.max(0, Math.min(Math.max((selectedProject?.dicomCount ?? 1) - 1, 0), latestResult?.mappingSlice ?? 0)));
|
||||
const nextPose = normalizeModelPose(latestResult?.pose);
|
||||
setModelPose(nextPose);
|
||||
setResultPose(nextPose);
|
||||
setResultPreviewSlice(Math.max(0, Math.min(maxIndex, latestResult?.mappingSlice ?? maxIndex)));
|
||||
setResultDisplayMode('soft');
|
||||
setResultRotation(0);
|
||||
}, [selectedProject?.id]);
|
||||
@@ -1142,6 +1373,25 @@ export default function ProjectLibrary({
|
||||
translateY: 0,
|
||||
translateZ: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scaleZ: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleModelFlip = (key: ModelPoseFlipKey) => {
|
||||
setModelPose((current) => ({
|
||||
...current,
|
||||
[key]: !current[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const resetModelFlipPose = () => {
|
||||
setModelPose((current) => ({
|
||||
...current,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
flipZ: false,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -1160,8 +1410,9 @@ export default function ProjectLibrary({
|
||||
const link = document.createElement('a');
|
||||
const planeLabel = planeOptions.find((option) => option.id === plane)?.label ?? plane;
|
||||
const modeLabel = displayModes.find((mode) => mode.id === displayMode)?.label ?? displayMode;
|
||||
const displaySlice = getDicomDisplaySliceNumber(dicomPreview.slice, dicomPreview.total);
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.download = `${safeFilePart(selectedProject.name)}_${planeLabel}_slice-${dicomPreview.slice + 1}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`;
|
||||
link.download = `${safeFilePart(selectedProject.name)}_${planeLabel}_slice-${displaySlice}-of-${dicomPreview.total}_${modeLabel}_rot-${rotation}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
@@ -1230,6 +1481,31 @@ export default function ProjectLibrary({
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const handleToggleProjectLock = async (project: Project) => {
|
||||
setLockChangingProjectId(project.id);
|
||||
setActionMessage('');
|
||||
try {
|
||||
const updated = await api.updateProjectLock(project.id, project.locked !== true);
|
||||
setProjects((items) => sortProjectsByActivity(items.map((item) => (item.id === updated.id ? updated : item))));
|
||||
setSelectedProject((current) => (current?.id === updated.id ? updated : current));
|
||||
setActionMessage(updated.locked
|
||||
? `已锁定,位姿数据已保存到 ${updated.lockedPoseSnapshotPath ?? '项目数据/锁定结果'}`
|
||||
: '项目已解锁,可以进入逆向工作区');
|
||||
} catch (error) {
|
||||
setActionMessage(error instanceof Error ? error.message : '项目锁定状态更新失败');
|
||||
} finally {
|
||||
setLockChangingProjectId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterReverseWorkspace = (project: Project) => {
|
||||
if (project.locked) {
|
||||
setActionMessage('项目已锁定,请先点击「解锁项目」后再进入逆向工作区');
|
||||
return;
|
||||
}
|
||||
onReverse(project.id);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dicom' as const, label: 'DICOM 影像', icon: ImageIcon },
|
||||
{ id: 'model' as const, label: '3D 模型', icon: Box },
|
||||
@@ -1386,6 +1662,26 @@ export default function ProjectLibrary({
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setShowUnlockedOnly(false)}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 rounded-lg text-[10px] font-bold transition ${
|
||||
!showUnlockedOnly ? 'bg-slate-900 text-white shadow-sm' : 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<FolderRoot size={12} />
|
||||
全部项目
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowUnlockedOnly(true)}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 rounded-lg text-[10px] font-bold transition ${
|
||||
showUnlockedOnly ? 'bg-emerald-600 text-white shadow-sm' : 'bg-slate-50 text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<Unlock size={12} />
|
||||
未上锁
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 scrollbar-hide">
|
||||
{loading && <p className="text-xs text-slate-400 px-2">正在从后端载入项目...</p>}
|
||||
{filteredProjects.map((proj) => (
|
||||
@@ -1412,9 +1708,19 @@ export default function ProjectLibrary({
|
||||
className="w-full rounded-md px-2 py-1 text-xs text-slate-900 outline-none ring-1 ring-blue-200"
|
||||
/>
|
||||
) : (
|
||||
<p className={`font-bold text-xs truncate ${selectedProject?.id === proj.id ? 'text-white' : 'text-slate-700'}`}>
|
||||
{proj.name}
|
||||
</p>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<p className={`truncate text-xs font-bold ${selectedProject?.id === proj.id ? 'text-white' : 'text-slate-700'}`}>
|
||||
{proj.name}
|
||||
</p>
|
||||
{proj.locked && (
|
||||
<span className={`flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[8px] font-black ${
|
||||
selectedProject?.id === proj.id ? 'bg-white/15 text-blue-50' : 'bg-amber-50 text-amber-600'
|
||||
}`}>
|
||||
<Lock size={9} />
|
||||
锁
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editingProjectId !== proj.id && (
|
||||
@@ -1444,7 +1750,7 @@ export default function ProjectLibrary({
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-[10px] mt-1 ${selectedProject?.id === proj.id ? 'text-blue-100' : 'text-slate-400'}`}>
|
||||
{proj.createTime} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}
|
||||
处理 {formatProjectActivity(proj)} · DICOM {proj.dicomCount} · STL {proj.modelCount ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -1459,11 +1765,12 @@ export default function ProjectLibrary({
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => setSelectedProject(p)}
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center cursor-pointer transition-all ${
|
||||
className={`relative w-8 h-8 rounded-lg flex items-center justify-center cursor-pointer transition-all ${
|
||||
selectedProject?.id === p.id ? 'bg-blue-600 text-white shadow-md' : 'bg-slate-50 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<FolderRoot size={16} />
|
||||
{p.locked && <Lock size={9} className="absolute -right-1 -top-1 rounded-full bg-amber-100 p-0.5 text-amber-600" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1495,9 +1802,28 @@ export default function ProjectLibrary({
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => void handleToggleProjectLock(selectedProject)}
|
||||
disabled={lockChangingProjectId === selectedProject.id}
|
||||
className={`px-5 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 transition-all shadow-lg disabled:opacity-60 ${
|
||||
selectedProject.locked
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600'
|
||||
: 'bg-white text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50'
|
||||
}`}
|
||||
title={selectedProject.locked ? '解除项目锁定' : '锁定项目并保存位姿数据'}
|
||||
>
|
||||
{selectedProject.locked ? <Unlock size={18} /> : <Lock size={18} />}
|
||||
{lockChangingProjectId === selectedProject.id
|
||||
? '处理中'
|
||||
: selectedProject.locked ? '解锁项目' : '锁定项目'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReverse(selectedProject.id)}
|
||||
className="bg-blue-600 text-white px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 hover:bg-blue-700 transition-all shadow-lg"
|
||||
onClick={() => handleEnterReverseWorkspace(selectedProject)}
|
||||
className={`px-6 py-2.5 rounded-xl text-sm font-bold flex items-center gap-2 transition-all shadow-lg ${
|
||||
selectedProject.locked
|
||||
? 'bg-slate-300 text-slate-500 hover:bg-slate-300'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
<RotateCw size={18} /> 进入逆向工作区
|
||||
</button>
|
||||
@@ -1600,7 +1926,7 @@ export default function ProjectLibrary({
|
||||
key={option.id}
|
||||
onClick={() => {
|
||||
setPlane(option.id);
|
||||
setSliceIndex(option.id === 'axial' ? Math.floor((selectedProject.dicomCount || 1) / 2) : 256);
|
||||
setSliceIndex(0);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-md text-[10px] font-bold transition-all ${
|
||||
plane === option.id ? 'bg-blue-600 text-white' : 'text-white/50 hover:text-white'
|
||||
@@ -1638,6 +1964,13 @@ export default function ProjectLibrary({
|
||||
>
|
||||
<RotateCw size={12} /> 右转
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDicomViewportResetSignal((value) => value + 1)}
|
||||
className="px-3 py-1.5 rounded-md text-[10px] font-bold text-white/60 hover:bg-white/10 hover:text-white transition-all flex items-center gap-1"
|
||||
title="重置 DICOM 图片位置"
|
||||
>
|
||||
<RefreshCcw size={12} /> 位置重置
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute top-4 left-4 text-white/40 font-mono text-[10px] space-y-1">
|
||||
<p>PATIENT ID: {selectedProject.id}_XYZ</p>
|
||||
@@ -1646,7 +1979,7 @@ export default function ProjectLibrary({
|
||||
</div>
|
||||
<div className={`relative w-full h-full flex items-center justify-center transition-all duration-150 ${isSliceChanging ? 'scale-[1.01] opacity-85 brightness-110' : 'scale-100 opacity-100 brightness-100'}`}>
|
||||
{dicomPreview ? (
|
||||
<DicomCanvas preview={dicomPreview} rotation={rotation} />
|
||||
<DicomCanvas preview={dicomPreview} rotation={rotation} resetSignal={dicomViewportResetSignal} />
|
||||
) : (
|
||||
<p className="text-white/30 text-xs font-mono uppercase tracking-widest">
|
||||
{selectedProject.dicomCount ? dicomError || '正在解析 DICOM 像素...' : '请导入DICOM影像'}
|
||||
@@ -1660,53 +1993,52 @@ export default function ProjectLibrary({
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4 flex justify-between text-white/30 font-mono text-[10px]">
|
||||
<span>WW/WL: {dicomPreview?.windowWidth ?? 400}/{dicomPreview?.windowCenter ?? 40} · {displayModes.find((mode) => mode.id === displayMode)?.label}</span>
|
||||
<span>第 {selectedProject.dicomCount ? sliceIndex + 1 : 0} / {dicomPreview?.total ?? selectedProject.dicomCount} 张</span>
|
||||
<span>第 {selectedProject.dicomCount ? dicomDisplaySlice : 0} / {dicomSliceTotal || selectedProject.dicomCount} 张</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: Vertical Progress Bar */}
|
||||
<div className="w-24 h-full flex flex-col items-center py-4 bg-slate-50 rounded-2xl">
|
||||
<span className="text-[10px] text-slate-400 font-bold mb-3">切片</span>
|
||||
<span className="text-[10px] text-slate-500 font-bold mb-4 whitespace-nowrap">
|
||||
{sliceIndex + 1} / {sliceTotal || selectedProject.dicomCount}
|
||||
{dicomDisplaySlice} / {dicomSliceTotal || selectedProject.dicomCount}
|
||||
</span>
|
||||
<button
|
||||
onMouseDown={() => startSliceStep(1)}
|
||||
onMouseUp={stopSliceStep}
|
||||
onMouseLeave={stopSliceStep}
|
||||
onTouchStart={(event) => {
|
||||
event.preventDefault();
|
||||
onPointerDown={() => {
|
||||
startSliceStep(1);
|
||||
}}
|
||||
onTouchEnd={stopSliceStep}
|
||||
className="mb-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
|
||||
onPointerUp={stopSliceStep}
|
||||
onPointerLeave={stopSliceStep}
|
||||
onPointerCancel={stopSliceStep}
|
||||
className="mb-3 flex h-8 w-8 touch-none items-center justify-center rounded-full border border-slate-100 bg-white text-slate-500 shadow-sm hover:border-blue-100 hover:text-blue-600"
|
||||
title="长按向上移动切片"
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={Math.max((sliceTotal || selectedProject.dicomCount) - 1, 0)}
|
||||
value={sliceIndex}
|
||||
onChange={(e) => setSliceIndex(Number(e.target.value))}
|
||||
className="flex-1 w-6 accent-blue-600 cursor-pointer"
|
||||
style={{ writingMode: 'vertical-lr', direction: 'rtl' }}
|
||||
/>
|
||||
<div className="relative min-h-[260px] w-10 flex-1">
|
||||
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-800/70" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={dicomMaxSlice}
|
||||
value={dicomSliderValue}
|
||||
onChange={(event) => setSliceIndex(Number(event.target.value))}
|
||||
className="mapping-slice-dark-vertical-input"
|
||||
aria-label="项目库 DICOM 切片导航"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onMouseDown={() => startSliceStep(-1)}
|
||||
onMouseUp={stopSliceStep}
|
||||
onMouseLeave={stopSliceStep}
|
||||
onTouchStart={(event) => {
|
||||
event.preventDefault();
|
||||
onPointerDown={() => {
|
||||
startSliceStep(-1);
|
||||
}}
|
||||
onTouchEnd={stopSliceStep}
|
||||
className="mt-3 h-8 w-8 rounded-full bg-white text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:border-blue-100 flex items-center justify-center"
|
||||
onPointerUp={stopSliceStep}
|
||||
onPointerLeave={stopSliceStep}
|
||||
onPointerCancel={stopSliceStep}
|
||||
className="mt-3 flex h-8 w-8 touch-none items-center justify-center rounded-full border border-slate-100 bg-white text-slate-500 shadow-sm hover:border-blue-100 hover:text-blue-600"
|
||||
title="长按向下移动切片"
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<span className="text-[10px] text-blue-600 font-bold mt-4">#{sliceIndex + 1}</span>
|
||||
<span className="text-[10px] text-blue-600 font-bold mt-4">#{dicomDisplaySlice}</span>
|
||||
<div className="mt-5 flex w-full flex-col gap-2 px-2">
|
||||
<button
|
||||
onClick={downloadCurrentDicomPng}
|
||||
@@ -1789,30 +2121,60 @@ export default function ProjectLibrary({
|
||||
onClick={resetModelRotationPose}
|
||||
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
|
||||
onClick={resetModelTransformPose}
|
||||
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
|
||||
onClick={resetModelFlipPose}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{modelPoseFlipOptions.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const enabled = modelPose[item.key];
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => toggleModelFlip(item.key)}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 rounded-lg border text-[10px] font-bold transition ${
|
||||
enabled
|
||||
? 'border-emerald-200 bg-emerald-600 text-white shadow-sm'
|
||||
: 'border-slate-100 bg-white text-slate-500 shadow-sm hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700'
|
||||
}`}
|
||||
title={`以模型中心沿 ${item.axis} 轴镜像翻转`}
|
||||
>
|
||||
<Icon size={13} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{[
|
||||
{ key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX, minus: '-90°', plus: '+90°', delta: 90 },
|
||||
{ key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY, minus: '-90°', plus: '+90°', delta: 90 },
|
||||
{ key: 'rotateZ' as const, label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ, minus: '-90°', plus: '+90°', delta: 90 },
|
||||
{ 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: '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: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.001, value: modelPose.translateX, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
{ key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.001, value: modelPose.translateY, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
{ key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.001, value: modelPose.translateZ, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
{ key: 'scale' as const, label: '缩放', min: 0.5, max: 2.5, step: 0.001, value: modelPose.scale, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
{ key: 'scaleX' as const, label: '轴拉伸 X', min: 0.2, max: 8, step: 0.001, value: modelPose.scaleX, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
{ key: 'scaleY' as const, label: '轴拉伸 Y', min: 0.2, max: 8, step: 0.001, value: modelPose.scaleY, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
{ key: 'scaleZ' as const, label: '轴拉伸 Z', min: 0.2, max: 8, step: 0.001, value: modelPose.scaleZ, minus: '-0.001', plus: '+0.001', delta: 0.001 },
|
||||
].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-[62px_48px_1fr_48px_42px] items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-slate-500">{item.label}</span>
|
||||
<button
|
||||
onClick={() => nudgeModelPose(item.key, -item.delta)}
|
||||
className="h-6 rounded-md bg-white text-[10px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
|
||||
className="h-6 rounded-md bg-white text-[9px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
|
||||
title={`${item.label} ${item.minus}`}
|
||||
>
|
||||
{item.minus}
|
||||
@@ -1828,12 +2190,12 @@ export default function ProjectLibrary({
|
||||
/>
|
||||
<button
|
||||
onClick={() => nudgeModelPose(item.key, item.delta)}
|
||||
className="h-6 rounded-md bg-white text-[10px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
|
||||
className="h-6 rounded-md bg-white text-[9px] font-bold text-slate-500 shadow-sm border border-slate-100 hover:text-blue-600 hover:bg-blue-50"
|
||||
title={`${item.label} ${item.plus}`}
|
||||
>
|
||||
{item.plus}
|
||||
</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>
|
||||
@@ -2030,6 +2392,12 @@ export default function ProjectLibrary({
|
||||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TY {formatPoseCompactValue(latestResultPose.translateY, 3)}</span>
|
||||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">TZ {formatPoseCompactValue(latestResultPose.translateZ, 3)}</span>
|
||||
<span className="col-span-3 rounded-lg bg-slate-50 px-2 py-1.5">Scale {formatPoseCompactValue(latestResultPose.scale, 3)}</span>
|
||||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">SX {formatPoseCompactValue(latestResultPose.scaleX, 3)}</span>
|
||||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">SY {formatPoseCompactValue(latestResultPose.scaleY, 3)}</span>
|
||||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">SZ {formatPoseCompactValue(latestResultPose.scaleZ, 3)}</span>
|
||||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FX {latestResultPose.flipX ? '开' : '关'}</span>
|
||||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FY {latestResultPose.flipY ? '开' : '关'}</span>
|
||||
<span className="rounded-lg bg-slate-50 px-2 py-1.5">FZ {latestResultPose.flipZ ? '开' : '关'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import {
|
||||
BarChart3,
|
||||
FolderRoot,
|
||||
Workflow,
|
||||
Crosshair,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
@@ -32,6 +33,7 @@ export default function Sidebar({
|
||||
{ id: ViewType.OVERVIEW, icon: BarChart3, label: '总体概况' },
|
||||
{ id: ViewType.PROJECTS, icon: FolderRoot, label: '项目库' },
|
||||
{ id: ViewType.WORKSPACE, icon: Workflow, label: '逆向工作区' },
|
||||
{ id: ViewType.AUTO_MATCH, icon: Crosshair, label: '自动微调匹配工作区' },
|
||||
{ id: ViewType.SYSTEM, icon: Settings, label: '系统管理工作区' },
|
||||
];
|
||||
|
||||
|
||||
@@ -145,14 +145,15 @@
|
||||
.mapping-slice-vertical-input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: #2563eb;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
|
||||
background: #60a5fa;
|
||||
border: 2px solid #dbeafe;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18), 0 4px 10px rgba(30, 64, 175, 0.28);
|
||||
cursor: grab;
|
||||
height: 22px;
|
||||
margin-left: -7px;
|
||||
width: 22px;
|
||||
height: 18px;
|
||||
margin-left: -5px;
|
||||
transform: rotate(45deg);
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.mapping-slice-vertical-input::-moz-range-track {
|
||||
@@ -162,13 +163,14 @@
|
||||
}
|
||||
|
||||
.mapping-slice-vertical-input::-moz-range-thumb {
|
||||
background: #2563eb;
|
||||
border: 3px solid #ffffff;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.28);
|
||||
background: #60a5fa;
|
||||
border: 2px solid #dbeafe;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18), 0 4px 10px rgba(30, 64, 175, 0.28);
|
||||
cursor: grab;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
height: 14px;
|
||||
transform: rotate(45deg);
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.mapping-slice-vertical-input:active::-webkit-slider-thumb {
|
||||
@@ -207,14 +209,15 @@
|
||||
.mapping-slice-dark-vertical-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);
|
||||
background: #60a5fa;
|
||||
border: 2px solid #dbeafe;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.18), 0 7px 16px rgba(8, 47, 73, 0.45);
|
||||
cursor: grab;
|
||||
height: 20px;
|
||||
margin-left: -7px;
|
||||
width: 20px;
|
||||
height: 18px;
|
||||
margin-left: -6px;
|
||||
transform: rotate(45deg);
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.mapping-slice-dark-vertical-input::-moz-range-track {
|
||||
@@ -224,12 +227,13 @@
|
||||
}
|
||||
|
||||
.mapping-slice-dark-vertical-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);
|
||||
background: #60a5fa;
|
||||
border: 2px solid #dbeafe;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.18), 0 7px 16px rgba(8, 47, 73, 0.45);
|
||||
cursor: grab;
|
||||
height: 14px;
|
||||
transform: rotate(45deg);
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationDicomOpacityLevel, SegmentationDisplayLevel, SegmentationExportScope, SessionState, UserRecord } from '../types';
|
||||
import { AutoMatchRequest, AutoMatchResult, DicomFusionVolume, DicomInfo, DicomPreview, ModelPose, ModuleStyle, OverviewSummary, Project, SavedModelPose, SegmentationDicomOpacityLevel, SegmentationDisplayLevel, SegmentationExportScope, SessionState, UserRecord } from '../types';
|
||||
|
||||
export type ProjectExportTarget = 'dicom' | 'segmentation' | 'pose' | 'stl';
|
||||
export type SegmentationExportMode = 'combined' | 'separate';
|
||||
@@ -11,6 +11,8 @@ export interface ProjectAssetImportProgress {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export type ProjectExportDownloadProgress = ProjectAssetImportProgress;
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
headers: {
|
||||
@@ -114,6 +116,11 @@ export const api = {
|
||||
request<{ ok: boolean; deletedId: string }>(`/api/projects/${projectId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
updateProjectLock: (projectId: string, locked: boolean) =>
|
||||
request<Project>(`/api/projects/${projectId}/lock`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ locked }),
|
||||
}),
|
||||
updateProjectModuleStyles: (projectId: string, moduleStyles: Record<string, ModuleStyle>) =>
|
||||
request<Project>(`/api/projects/${projectId}/module-styles`, {
|
||||
method: 'PATCH',
|
||||
@@ -124,6 +131,16 @@ export const api = {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ modelPoses }),
|
||||
}),
|
||||
runAutoMatch: (projectId: string, payload: AutoMatchRequest) =>
|
||||
request<AutoMatchResult>(`/api/projects/${projectId}/auto-match`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
applyProjectModelPose: (projectId: string, pose: ModelPose) =>
|
||||
request<Project>(`/api/projects/${projectId}/model-pose`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ pose }),
|
||||
}),
|
||||
importProjectAssets: (
|
||||
projectId: string,
|
||||
kind: ProjectAssetImportKind,
|
||||
@@ -185,6 +202,83 @@ function triggerFileDownload(url: string) {
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, filename: string) {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
link.rel = 'noopener';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500);
|
||||
}
|
||||
|
||||
function filenameFromContentDisposition(header: string | null, fallback: string) {
|
||||
if (!header) {
|
||||
return fallback;
|
||||
}
|
||||
const encodedMatch = header.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (encodedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(encodedMatch[1].trim().replace(/^"|"$/g, ''));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
const plainMatch = header.match(/filename="([^"]+)"/i) ?? header.match(/filename=([^;]+)/i);
|
||||
return plainMatch?.[1]?.trim() || fallback;
|
||||
}
|
||||
|
||||
function downloadFileWithProgress(
|
||||
url: string,
|
||||
fallbackFilename: string,
|
||||
onProgress?: (progress: ProjectExportDownloadProgress) => void,
|
||||
) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.onprogress = (event) => {
|
||||
const total = event.lengthComputable ? event.total : 0;
|
||||
const loaded = event.loaded;
|
||||
const percent = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
|
||||
onProgress?.({ loaded, total, percent });
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
const filename = filenameFromContentDisposition(xhr.getResponseHeader('Content-Disposition'), fallbackFilename);
|
||||
triggerBlobDownload(xhr.response, filename);
|
||||
onProgress?.({ loaded: xhr.response?.size ?? 0, total: xhr.response?.size ?? 0, percent: 100 });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
let message = `请求失败:${xhr.status}`;
|
||||
try {
|
||||
const data = JSON.parse(String(reader.result ?? ''));
|
||||
if (typeof data?.message === 'string') {
|
||||
message = data.message;
|
||||
}
|
||||
} catch {
|
||||
const text = String(reader.result ?? '').trim();
|
||||
if (text) {
|
||||
message = text.slice(0, 240);
|
||||
}
|
||||
}
|
||||
reject(new Error(message));
|
||||
};
|
||||
reader.onerror = () => reject(new Error(`请求失败:${xhr.status}`));
|
||||
reader.readAsText(xhr.response);
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('网络连接中断,导出失败'));
|
||||
xhr.onabort = () => reject(new Error('导出已取消'));
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
function appendPose(params: URLSearchParams, pose?: ModelPose) {
|
||||
if (pose) {
|
||||
params.set('pose', JSON.stringify(pose));
|
||||
@@ -198,7 +292,13 @@ export async function downloadMask(projectId: string, format: 'nii' | 'nii.gz' =
|
||||
triggerFileDownload(`/api/projects/${projectId}/export-mask?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) {
|
||||
function appendModuleStyles(params: URLSearchParams, moduleStyles?: Record<string, ModuleStyle>) {
|
||||
if (moduleStyles) {
|
||||
params.set('moduleStyles', JSON.stringify(moduleStyles));
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadProjectExport(projectId: string, target: ProjectExportTarget, format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode; moduleStyles?: Record<string, ModuleStyle> } = {}) {
|
||||
const params = new URLSearchParams({ target, format });
|
||||
if (target === 'segmentation' || target === 'pose') {
|
||||
appendPose(params, options.pose);
|
||||
@@ -206,11 +306,18 @@ export async function downloadProjectExport(projectId: string, target: ProjectEx
|
||||
if (target === 'segmentation') {
|
||||
params.set('segmentationScope', options.segmentationScope ?? 'visible');
|
||||
params.set('segmentationExportMode', options.segmentationExportMode ?? 'combined');
|
||||
appendModuleStyles(params, options.moduleStyles);
|
||||
}
|
||||
triggerFileDownload(`/api/projects/${projectId}/export-nifti?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function downloadProjectExportBundle(projectId: string, targets: ProjectExportTarget[], format: 'nii' | 'nii.gz' = 'nii.gz', options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode } = {}) {
|
||||
export async function downloadProjectExportBundle(
|
||||
projectId: string,
|
||||
targets: ProjectExportTarget[],
|
||||
format: 'nii' | 'nii.gz' = 'nii.gz',
|
||||
options: { pose?: ModelPose; segmentationScope?: SegmentationExportScope; segmentationExportMode?: SegmentationExportMode; moduleStyles?: Record<string, ModuleStyle> } = {},
|
||||
onProgress?: (progress: ProjectExportDownloadProgress) => void,
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
targets: targets.join(','),
|
||||
format,
|
||||
@@ -218,7 +325,12 @@ export async function downloadProjectExportBundle(projectId: string, targets: Pr
|
||||
segmentationExportMode: options.segmentationExportMode ?? 'combined',
|
||||
});
|
||||
appendPose(params, options.pose);
|
||||
triggerFileDownload(`/api/projects/${projectId}/export-bundle?${params.toString()}`);
|
||||
appendModuleStyles(params, options.moduleStyles);
|
||||
await downloadFileWithProgress(
|
||||
`/api/projects/${projectId}/export-bundle?${params.toString()}`,
|
||||
`${projectId}-export.zip`,
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadDicomArchive(projectId: string) {
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum ViewType {
|
||||
OVERVIEW = 'overview',
|
||||
PROJECTS = 'projects',
|
||||
WORKSPACE = 'workspace',
|
||||
AUTO_MATCH = 'auto-match',
|
||||
SYSTEM = 'system',
|
||||
}
|
||||
|
||||
@@ -20,6 +21,11 @@ export interface Project {
|
||||
maskFormats?: Array<'nii' | 'nii.gz'>;
|
||||
exportedMaskCount?: number;
|
||||
isDefault?: boolean;
|
||||
locked?: boolean;
|
||||
lockedAt?: string | null;
|
||||
unlockedAt?: string | null;
|
||||
lastProcessedAt?: string;
|
||||
lockedPoseSnapshotPath?: string | null;
|
||||
moduleStyles?: Record<string, ModuleStyle>;
|
||||
modelPoses?: SavedModelPose[];
|
||||
segmentationResults?: SegmentationResult[];
|
||||
@@ -40,6 +46,12 @@ export interface ModelPose {
|
||||
translateY: number;
|
||||
translateZ: number;
|
||||
scale: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
scaleZ: number;
|
||||
flipX: boolean;
|
||||
flipY: boolean;
|
||||
flipZ: boolean;
|
||||
}
|
||||
|
||||
export interface SavedModelPose {
|
||||
@@ -48,6 +60,60 @@ export interface SavedModelPose {
|
||||
pose: ModelPose;
|
||||
}
|
||||
|
||||
export type AutoMatchParameterKey = 'translateX' | 'translateY' | 'translateZ' | 'scale';
|
||||
|
||||
export interface AutoMatchParameterSelection {
|
||||
translateX: boolean;
|
||||
translateY: boolean;
|
||||
translateZ: boolean;
|
||||
scale: boolean;
|
||||
}
|
||||
|
||||
export interface AutoMatchWeights {
|
||||
boneReward: number;
|
||||
missPenalty: number;
|
||||
movementPenalty: number;
|
||||
scalePenalty: number;
|
||||
}
|
||||
|
||||
export interface AutoMatchRequest {
|
||||
pose: ModelPose;
|
||||
adjustable: AutoMatchParameterSelection;
|
||||
boneFiles: string[];
|
||||
sampleSlices?: number[];
|
||||
iterations?: number;
|
||||
candidatesPerRound?: number;
|
||||
weights?: Partial<AutoMatchWeights>;
|
||||
}
|
||||
|
||||
export interface AutoMatchCandidate {
|
||||
iteration: number;
|
||||
mode: string;
|
||||
pose: ModelPose;
|
||||
score: number;
|
||||
boneReward: number;
|
||||
missPenalty: number;
|
||||
movementPenalty: number;
|
||||
scalePenalty: number;
|
||||
contributors: number;
|
||||
changed: AutoMatchParameterKey[];
|
||||
}
|
||||
|
||||
export interface AutoMatchResult {
|
||||
projectId: string;
|
||||
basePose: ModelPose;
|
||||
bestPose: ModelPose;
|
||||
bestScore: number;
|
||||
bestMode: string;
|
||||
bestChanged: AutoMatchParameterKey[];
|
||||
iterations: number;
|
||||
evaluated: number;
|
||||
boneFiles: string[];
|
||||
sampleSlices: number[];
|
||||
weights: AutoMatchWeights;
|
||||
trace: AutoMatchCandidate[];
|
||||
}
|
||||
|
||||
export type SegmentationExportScope = 'all' | 'visible';
|
||||
export type SegmentationDisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||||
export type SegmentationDicomOpacityLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
@@ -19,6 +19,7 @@ export default defineConfig(({mode}) => {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
allowedHosts: ['revoxel.huijutec.cn'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 代码编纂工作流
|
||||
|
||||
更新时间:2026-05-19-22-59-07
|
||||
更新时间:2026-05-24-10-09-37
|
||||
|
||||
本工作流适用于后续所有项目修改相关需求。只要用户提出的是项目修改、修复、重构、部署、文档治理或功能调整,都必须按本流程走;除非用户明确要求跳过其中某一步。
|
||||
|
||||
@@ -86,6 +86,17 @@
|
||||
- 默认远程仓库:
|
||||
- `http://192.168.31.5:5002/admin/REVOXELSEG_DICOM.git`
|
||||
|
||||
## 6.1 工程分析目录精简策略
|
||||
|
||||
- `工程分析/` 长期保留:
|
||||
- `代码编纂工作流.md`
|
||||
- `工程整体分析.md`
|
||||
- `经验记录.md`
|
||||
- 当次正在执行的 `需求分析-*`、`实现方案-*`、`测试方案-*`
|
||||
- 历史三件套不再默认长期堆积在目录中;完成沉淀后可通过 Git 历史追溯。
|
||||
- 可复用的坑点、约束和解决办法必须沉淀到 `经验记录.md`,不要只留在一次性三件套中。
|
||||
- 删除历史三件套属于文档治理变更,提交前必须确认没有混入源码、医学数据、运行态文件或用户未确认的非文档改动。
|
||||
|
||||
## 7. 重新部署
|
||||
|
||||
- 最终修改完成并通过测试后,重新部署本项目。
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# 实现方案
|
||||
|
||||
时间戳:2026-05-04-02-38-48
|
||||
|
||||
## 修改目标
|
||||
|
||||
建立项目级代码编纂工作流,并生成初始工程分析文档,使后续项目修改需求有固定执行路径。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `工程分析/代码编纂工作流.md`
|
||||
- `工程分析/工程整体分析.md`
|
||||
- `工程分析/需求分析-2026-05-04-02-38-48.md`
|
||||
- `工程分析/实现方案-2026-05-04-02-38-48.md`
|
||||
- `工程分析/测试方案-2026-05-04-02-38-48.md`
|
||||
- `工程分析/经验记录.md`
|
||||
- `.gitignore`
|
||||
- `README.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 创建 `工程分析/` 目录及工作流文档。
|
||||
2. 根据当前项目结构写入工程整体分析。
|
||||
3. 按时间戳写入本次需求、实现、测试文档。
|
||||
4. 创建经验记录知识库。
|
||||
5. 初始化 Git 仓库。
|
||||
6. 配置 Gitea 远程仓库。
|
||||
7. 提交文档备份 commit。
|
||||
8. 对 `WebSite/` 执行构建验证。
|
||||
9. 启动或重启开发部署服务。
|
||||
|
||||
## 数据与提交策略
|
||||
|
||||
- 默认提交工程文档、项目说明和必要 Git 配置。
|
||||
- 默认不提交 `Head_CT_DICOM/` 和 `Head_CT_ReConstruct/`,避免医学影像数据和 STL 模型进入普通文档备份。
|
||||
- 默认不提交 `WebSite/node_modules/`、`WebSite/dist/` 等构建产物。
|
||||
|
||||
## 回滚方案
|
||||
|
||||
- 若文档内容需要调整,直接修改对应 Markdown 文档并追加新的 commit。
|
||||
- 若 Gitea 推送失败,本地 commit 保留,可在网络或认证修复后重新 push。
|
||||
- 若部署失败,保留错误信息并写入经验记录。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
- 本文档用于建立工作流基线。
|
||||
- 后续业务代码修改时,必须在实现方案完成后等待用户二次确认。
|
||||
@@ -1,66 +0,0 @@
|
||||
# 实现方案
|
||||
|
||||
时间戳:2026-05-04-03-08-20
|
||||
|
||||
## 修改目标
|
||||
|
||||
将系统品牌标识统一为用户提供的 logo,并把浏览器 title 改为“模型逆向系统”;完成后部署到 `http://192.168.3.11:4000/`。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/index.html`
|
||||
- `WebSite/src/components/Login.tsx`
|
||||
- `WebSite/src/components/Sidebar.tsx`
|
||||
- `WebSite/public/logo.png` 或等效 logo 资源路径
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 确认 logo 资源可被项目读取。
|
||||
- 优先将用户提供的 logo 存为 `WebSite/public/logo.png`。
|
||||
- 前端通过 `/logo.png` 引用该资源。
|
||||
2. 修改浏览器标题。
|
||||
- 将 `WebSite/index.html` 中 `<title>` 改为 `模型逆向系统`。
|
||||
3. 修改 favicon。
|
||||
- 在 `WebSite/index.html` 中增加或更新 `<link rel="icon" href="/logo.png" />`。
|
||||
4. 修改登录页。
|
||||
- 将 `Login.tsx` 顶部的 lucide `Layout` 图标替换为 `<img src="/logo.png" alt="模型逆向系统" />`。
|
||||
- 标题显示为 `模型逆向系统`,保留或简化副标题以避免视觉重复。
|
||||
5. 修改左侧栏。
|
||||
- 将 `Sidebar.tsx` 左上角的蓝色方块 `Box` 图标替换为 `<img src="/logo.png" alt="模型逆向系统" />`。
|
||||
- 保持左侧栏文字标题为 `模型逆向系统`。
|
||||
6. 构建验证。
|
||||
- 执行 `npm run build`。
|
||||
7. 部署到 4000。
|
||||
- 检查 `4000` 端口占用。
|
||||
- 使用 `tmux` 会话托管 Vite 服务:
|
||||
- `node ./node_modules/vite/bin/vite.js --host 0.0.0.0 --port 4000 --strictPort`
|
||||
- 若原 `revoxelseg-dicom` 会话仍在 `3001`,优先关闭旧会话后使用同名会话部署到 `4000`。
|
||||
8. 验证访问。
|
||||
- `curl -I http://127.0.0.1:4000/`
|
||||
- `curl -I http://192.168.3.11:4000/`
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增 `WebSite/public/logo.png`。
|
||||
- 修改 `WebSite/index.html`。
|
||||
- 修改 `WebSite/src/components/Login.tsx`。
|
||||
- 修改 `WebSite/src/components/Sidebar.tsx`。
|
||||
- 更新 `工程分析/测试方案-2026-05-04-03-08-20.md` 执行结果。
|
||||
- 更新 `工程分析/经验记录.md`。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- favicon 和 logo 统一引用 `/logo.png`,便于后续替换资源。
|
||||
- 若 logo 文件不可用,可先不提交代码修改,等待用户提供本地图片文件。
|
||||
- 若 `4000` 端口不可用,记录占用进程并等待用户决定是否停止占用服务或改端口。
|
||||
- 回滚时恢复 `index.html`、`Login.tsx`、`Sidebar.tsx`,并停止 `4000` 对应 tmux 会话。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
已确认并执行。
|
||||
|
||||
确认记录:
|
||||
|
||||
- 用户已回复 `确认执行`。
|
||||
- 附件 logo 已在环境中确认,对应文件为 `/tmp/logo_check.png`,已复制为 `WebSite/public/logo.png`。
|
||||
@@ -1,105 +0,0 @@
|
||||
# 实现方案
|
||||
|
||||
时间戳:2026-05-04-03-21-40
|
||||
|
||||
## 修改目标
|
||||
|
||||
将当前前端静态演示升级为前后端协调系统:
|
||||
|
||||
- 后端统一管理登录状态、用户列表、项目列表和演示环境。
|
||||
- 项目列表默认载入 `Head_CT_DICOM` 与 `Head_CT_ReConstruct`。
|
||||
- 恢复演示环境出厂设置后恢复默认项目和默认用户。
|
||||
- 逆向工作区通过后端生成并下载 `.nii.gz` 分割 mask。
|
||||
- 继续部署到 `http://192.168.3.11:4000/`。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/package.json`
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/components/Login.tsx`
|
||||
- `WebSite/src/components/Overview.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/UserManagement.tsx`
|
||||
- `WebSite/data/state.json`
|
||||
- `WebSite/exports/`
|
||||
- `.gitignore`
|
||||
- `工程分析/测试方案-2026-05-04-03-21-40.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 新增 Express 后端 `server.ts`。
|
||||
- 开发环境通过 Vite 中间件服务前端。
|
||||
- API 和页面共用 `4000` 端口,避免跨域和前后端端口不一致。
|
||||
2. 后端状态持久化。
|
||||
- 使用 `WebSite/data/state.json` 保存演示状态。
|
||||
- 默认用户包括 `admin / 123456`。
|
||||
- 当前登录状态由后端统一维护,所有浏览器轮询 `/api/session` 同步。
|
||||
3. 默认项目载入。
|
||||
- 后端扫描 `../Head_CT_DICOM` 的 `.dcm` 数量。
|
||||
- 后端扫描 `../Head_CT_ReConstruct` 的 `.stl` 文件列表。
|
||||
- 构造默认项目 `head-ct-demo`,包含 DICOM/STL 路径、数量、模块名称。
|
||||
4. 恢复演示环境出厂设置。
|
||||
- `POST /api/demo/reset` 重建默认用户、默认项目、登录状态。
|
||||
- 前端系统管理页点击按钮后调用该接口,并刷新用户和项目相关数据。
|
||||
5. 前端 API 化。
|
||||
- `Login` 调用 `/api/login`。
|
||||
- `App` 调用 `/api/session` 并轮询同步登录状态。
|
||||
- `Overview` 调用 `/api/overview`。
|
||||
- `ProjectLibrary` 调用 `/api/projects`。
|
||||
- `UserManagement` 调用 `/api/users` 与 `/api/demo/reset`。
|
||||
- `ReverseWorkspace` 调用 `/api/projects/:id` 与 `/api/projects/:id/export-mask`。
|
||||
6. NIfTI 导出。
|
||||
- 后端生成 NIfTI-1 单文件 `.nii.gz`。
|
||||
- 使用 Node `zlib.gzipSync` 压缩。
|
||||
- 文件保存到 `WebSite/exports/`,同时以 attachment 下载。
|
||||
- NIfTI 内容先采用演示 mask 体素数据,尺寸、标签信息和项目元数据由后端生成。
|
||||
7. 部署。
|
||||
- `npm run build`
|
||||
- `npm run lint`
|
||||
- 使用 `tmux` 运行 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
|
||||
## 数据流
|
||||
|
||||
登录:
|
||||
|
||||
前端登录页 -> `POST /api/login` -> 后端验证账号密码 -> 更新共享 session -> 所有浏览器轮询 `/api/session` 后同步状态。
|
||||
|
||||
项目:
|
||||
|
||||
前端项目库 -> `GET /api/projects` -> 后端扫描或读取默认项目状态 -> 展示 DICOM 数量、STL 模块、路径和状态。
|
||||
|
||||
重置:
|
||||
|
||||
系统管理页 -> `POST /api/demo/reset` -> 后端重建默认状态 -> 前端刷新用户和项目。
|
||||
|
||||
导出:
|
||||
|
||||
逆向工作区 -> `POST /api/projects/:id/export-mask` -> 后端生成 `.nii.gz` -> 前端下载。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 保留 Vite/React 前端结构,后端通过同端口中间件接入。
|
||||
- DICOM/STL 仍不进入 Git。
|
||||
- `WebSite/data/state.json` 和 `WebSite/exports/` 作为运行态文件默认不纳入提交。
|
||||
- 若后端服务异常,可回滚 `server.ts` 和前端 API 调用改动,恢复纯前端 Vite 部署。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增 `WebSite/server.ts`
|
||||
- 新增 `WebSite/src/lib/api.ts`
|
||||
- 修改 `WebSite/package.json`
|
||||
- 修改 `WebSite/src/types.ts`
|
||||
- 修改主要页面组件以接入 API。
|
||||
- 修改 `.gitignore` 排除运行态数据和导出文件。
|
||||
- 更新工程分析文档和经验记录。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次用户明确要求需求分析、实现方案、测试方案、执行修改均不用人工二次确认。
|
||||
|
||||
状态:自动确认,继续执行。
|
||||
@@ -1,91 +0,0 @@
|
||||
# 实现方案
|
||||
|
||||
时间戳:2026-05-04-03-50-07
|
||||
|
||||
## 修改目标
|
||||
|
||||
修复概况统计、图表警告和模块高亮问题;完善项目库真实 DICOM/STL/分割结果展示;增加项目创建和重命名能力;补齐 README 构建方案并重新部署。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/Overview.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/README.md`
|
||||
- `README.md`
|
||||
- `工程分析/测试方案-2026-05-04-03-50-07.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 后端项目状态增强。
|
||||
- 保留默认项目,同时允许新增用户创建项目。
|
||||
- 新增 `POST /api/projects` 创建项目。
|
||||
- 新增 `PATCH /api/projects/:projectId` 修改项目名称。
|
||||
2. 后端 DICOM 预览。
|
||||
- 新增 `GET /api/projects/:projectId/dicom-preview?slice=0`。
|
||||
- 读取当前 DICOM 文件,解析 Rows、Columns、Pixel Data、Window Center/Width、Rescale Slope/Intercept。
|
||||
- 返回 `width`、`height`、`pixels` base64 灰度数据和 slice 元数据。
|
||||
3. 后端 STL 文件服务。
|
||||
- 新增 `GET /api/projects/:projectId/models/:fileName`。
|
||||
- 只允许读取项目对应 STL 列表中的文件,避免任意路径读取。
|
||||
4. 前端项目库。
|
||||
- DICOM 视图使用 canvas 绘制后端返回灰度像素。
|
||||
- 3D 模型视图使用 Three.js `STLLoader` 加载真实 STL。
|
||||
- 新增 `分割结果` tab,展示 mask 预览、标签图例和 NII/NII.GZ 下载。
|
||||
- 项目列表顶部增加创建按钮和名称输入。
|
||||
- 项目卡右侧增加编辑图标,可重命名。
|
||||
- 移除首个 STL 模块默认蓝色高亮,所有模块同等样式。
|
||||
5. 概况页。
|
||||
- 已处理项目改为“已导出 Mask 项目”,避免项目总数 1 时已处理 1 的误导。
|
||||
- 趋势数据改为平稳小范围变化。
|
||||
- Recharts 容器增加 `min-w-0`、固定高度和加载态,避免宽高 -1 警告。
|
||||
6. README。
|
||||
- 补充 Node 构建运行方案。
|
||||
- 说明当前无需 Python/conda;真实体素化阶段再引入 Python 环境建议。
|
||||
7. 验证与部署。
|
||||
- `npm run lint`
|
||||
- `npm run build`
|
||||
- API smoke test
|
||||
- 重新部署 `tmux` 会话到 `4000`。
|
||||
|
||||
## 数据流
|
||||
|
||||
DICOM 预览:
|
||||
|
||||
前端切片滑块 -> `/api/projects/:id/dicom-preview?slice=n` -> 后端解析 DICOM 像素 -> 前端 canvas 绘制。
|
||||
|
||||
STL 预览:
|
||||
|
||||
前端选择 STL 模块 -> `/api/projects/:id/models/:fileName` -> `STLLoader` 加载几何体 -> Three.js 渲染。
|
||||
|
||||
分割结果:
|
||||
|
||||
前端 `分割结果` tab -> 展示 mask 预览 -> 调用已有 `/api/projects/:id/export-mask` 导出 `nii` 或 `nii.gz`。
|
||||
|
||||
项目管理:
|
||||
|
||||
创建/重命名 -> 后端写入 `state.json` -> 项目列表刷新。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- DICOM/STL 原始文件仍不提交 Git。
|
||||
- 若 DICOM 解析失败,前端显示错误态,不影响其他页面。
|
||||
- 若 STL 加载失败,前端显示模型加载失败提示,不影响 DICOM 和分割结果。
|
||||
- 回滚时恢复 `server.ts` 和项目库组件即可回到上一版前后端演示状态。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改后端 API。
|
||||
- 修改项目库和概况页。
|
||||
- 修改 API 类型和封装。
|
||||
- 修改 README。
|
||||
- 更新工程分析文档和经验记录。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次用户明确要求无需人工二次确认。
|
||||
|
||||
状态:自动确认,继续执行。
|
||||
@@ -1,77 +0,0 @@
|
||||
# 实现方案
|
||||
|
||||
时间戳:2026-05-04-04-12-34
|
||||
|
||||
## 修改目标
|
||||
|
||||
完善项目库导入/下载按钮语义、DICOM 三方向预览、STL 多模型颜色透明度控制、项目创建弹窗、删除确认和编辑自动保存。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `工程分析/测试方案-2026-05-04-04-12-34.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 后端 DICOM 预览扩展。
|
||||
- `GET /api/projects/:projectId/dicom-preview?slice=&plane=`
|
||||
- 支持 `plane=axial|sagittal|coronal`。
|
||||
- 横断面读取单张 DICOM。
|
||||
- 矢状面/冠状面从 DICOM 序列采样生成重建平面。
|
||||
2. 后端项目删除。
|
||||
- 新增 `DELETE /api/projects/:projectId`。
|
||||
- 删除后写入共享状态。
|
||||
3. 前端 API 扩展。
|
||||
- `getDicomPreview(projectId, slice, plane)`。
|
||||
- `deleteProject(projectId)`。
|
||||
4. 项目库交互。
|
||||
- 顶部右侧按钮:DICOM/3D 视图显示“导入”;分割结果不显示顶部第二按钮。
|
||||
- 创建项目改为点击 `+` 弹窗输入名称。
|
||||
- 编辑项目名称改为输入框失焦或回车自动保存。
|
||||
- 删除项目点击垃圾桶后弹窗二次确认。
|
||||
5. DICOM UI。
|
||||
- 增加横断面、矢状面、冠状面切换。
|
||||
- 右侧滑块改为稳定轨道,显示 `第 n / 总数`。
|
||||
- 圆点与轨道对齐。
|
||||
6. 3D 模型 UI。
|
||||
- 右侧眼睛为全体显示/隐藏。
|
||||
- 每个 STL 使用颜色输入框和透明度滑块。
|
||||
- Three.js 同时渲染所有可见 STL,并应用对应颜色和透明度。
|
||||
- 删除无意义状态点。
|
||||
|
||||
## 数据流
|
||||
|
||||
DICOM:
|
||||
|
||||
前端选择方向和切片 -> 后端按方向返回灰度像素 -> 前端 canvas 绘制。
|
||||
|
||||
3D:
|
||||
|
||||
后端提供 STL 文件 -> 前端为每个 STL 建立颜色/透明度/可见性状态 -> Three.js 渲染多模型。
|
||||
|
||||
项目:
|
||||
|
||||
创建弹窗 -> `POST /api/projects`;编辑失焦 -> `PATCH /api/projects/:id`;删除确认 -> `DELETE /api/projects/:id`。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 保留原 `axial` 行为,新增方向参数不影响旧调用。
|
||||
- 若矢状面/冠状面解析失败,前端显示错误态。
|
||||
- 若 STL 多模型性能不足,可通过全体眼睛或单项眼睛隐藏模型。
|
||||
- 回滚时恢复 `ProjectLibrary.tsx` 和相关 API 即可。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改 `server.ts`、`api.ts`、`types.ts`、`ProjectLibrary.tsx`。
|
||||
- 更新测试方案执行结果。
|
||||
- 更新经验记录。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次用户明确要求无需人工二次确认。
|
||||
|
||||
状态:自动确认,继续执行。
|
||||
@@ -1,76 +0,0 @@
|
||||
# 实现方案
|
||||
|
||||
时间戳:2026-05-04-04-58-36
|
||||
|
||||
## 修改目标
|
||||
|
||||
修复项目列表按钮重叠;增强逆向工作区当前项目与融合视图;增加 DICOM 缓存和显示模式;重做 3D 模型渲染加载状态,避免 React Three Fiber 引入的 Three.js `Clock` 警告。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/测试方案-2026-05-04-04-58-36.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 项目列表布局。
|
||||
- 将收缩按钮从标题区右侧移到侧栏外侧中线位置。
|
||||
- 保留 `+` 在项目列表标题行内,避免重叠。
|
||||
2. DICOM 预览缓存与显示模式。
|
||||
- 后端增加内存缓存:按 project、plane、slice、mode 缓存灰度预览。
|
||||
- API 增加 `mode=default|bone|soft|contrast`。
|
||||
- 前端增加显示模式切换,并将 mode 传给 API。
|
||||
3. 3D 模型渲染。
|
||||
- 项目库中不再使用 React Three Fiber Canvas。
|
||||
- 后端增加 STL 二进制采样预览 API,避免浏览器一次解析 240MB 原始 STL。
|
||||
- 前端改用原生 Three.js 手动创建 renderer、camera、scene、geometry。
|
||||
- 显示加载进度条;WebGL 不可用时使用二维 canvas 模型预览兜底。
|
||||
- 使用自动包围盒归一化、居中、缩放,确保模型可见。
|
||||
4. 逆向工作区。
|
||||
- 拉取当前项目详情。
|
||||
- 顶部显示当前项目名、DICOM/STL 数量和路径。
|
||||
- 融合视图显示 DICOM canvas 背景,并叠加简化 STL/模型轮廓或模型投影效果,表达等比例缩放、中心对齐状态。
|
||||
5. 验证与部署。
|
||||
- `npm run lint`
|
||||
- `npm run build`
|
||||
- API smoke test
|
||||
- headless Chrome 冒烟检查
|
||||
- 重启 `tmux` 会话部署到 `4000`。
|
||||
|
||||
## 数据流
|
||||
|
||||
DICOM:
|
||||
|
||||
前端选择 plane/slice/mode -> 后端命中或生成缓存预览 -> 前端 canvas 显示。
|
||||
|
||||
3D:
|
||||
|
||||
前端读取 STL 采样预览 API -> 后端返回抽样三角面顶点 -> 原生 Three.js 生成材质、居中缩放 -> 渲染;WebGL 不可用时绘制二维投影预览。
|
||||
|
||||
逆向融合:
|
||||
|
||||
前端按当前项目获取 DICOM 预览和项目信息 -> canvas 绘制影像 -> HTML/SVG/Three 投影层叠加中心对齐模型轮廓。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- DICOM preview API 兼容旧参数,不传 mode 时默认为 default。
|
||||
- 如果原生 Three.js 渲染异常,页面会使用二维 canvas 兜底预览,不影响项目库浏览。
|
||||
- 运行态缓存仅在进程内,不写入 Git。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改后端 DICOM preview API。
|
||||
- 修改项目库 3D 组件和 DICOM 控件。
|
||||
- 修改逆向工作区融合视图。
|
||||
- 更新工程分析文档和经验记录。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次用户明确要求无需人工二次确认。
|
||||
|
||||
状态:自动确认,继续执行。
|
||||
@@ -1,61 +0,0 @@
|
||||
# 实现方案 - 2026-05-04-05-20-16
|
||||
|
||||
## 修改目标
|
||||
|
||||
围绕项目库 DICOM/3D 浏览体验和逆向工作区标题信息进行收敛修复,保证 DICOM 切片控制可连续移动、三平面可旋转且显示完整清晰,补充下载能力,并修复 3D 模型加载完成后仍不可见的问题。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. DICOM 文件排序
|
||||
- 将 DICOM 文件列表改为基于文件名的自然排序,确保 `1.dcm`、`2.dcm`、`10.dcm` 顺序正确。
|
||||
|
||||
2. DICOM 重建与清晰度
|
||||
- 后端对矢状面/冠状面重建结果进行非黑边界裁切并保留安全边距,减少“只显示一半”的视觉问题。
|
||||
- 对预览灰度图做轻量边缘增强,提高 DCM 影像边界辨识度。
|
||||
|
||||
3. 切片控制与旋转
|
||||
- 前端新增上下箭头按钮,长按时用定时器连续推进或回退切片。
|
||||
- DICOM 三平面统一支持左/右 90 度旋转,旋转状态参与画布绘制和 PNG 导出。
|
||||
|
||||
4. 下载能力
|
||||
- 后端新增 DICOM 序列压缩包接口,使用 `tar.gz` 生成压缩包,不引入额外依赖。
|
||||
- 前端增加下载按钮,支持当前图片 PNG 和 DICOM 压缩包。
|
||||
|
||||
5. 3D 模型显示
|
||||
- 继续使用后端 STL 抽样预览,前端原生 Three.js 渲染。
|
||||
- 修正模型居中和相机适配逻辑,增加坐标轴容错、投影兜底和加载进度。
|
||||
|
||||
6. 逆向工作区
|
||||
- 去掉 `Head_CT_DICOM ↔ Head_CT_ReConstruct` 路径说明,仅保留当前项目、DICOM 数量、STL 数量等上下文。
|
||||
|
||||
## 数据流与交互流程
|
||||
|
||||
- 项目库加载项目 -> 选择 DICOM 面/模式/切片 -> 后端返回灰度预览 -> 前端 canvas 绘制并按旋转角度显示。
|
||||
- 用户点击 PNG 下载 -> 使用当前 canvas 像素、平面、切片、模式、旋转角度生成命名完整的 PNG。
|
||||
- 用户点击 DICOM 压缩包下载 -> 后端按文件名自然排序打包 `Head_CT_DICOM`。
|
||||
- 用户进入 3D 模型页 -> 前端请求 STL 抽样预览 -> Three.js 居中缩放显示全部可见构件。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若 DICOM 压缩包下载异常,不影响 DICOM 预览和 PNG 下载。
|
||||
- 若 WebGL 不可用,仍保留二维 canvas 投影兜底预览。
|
||||
- 回滚时可恢复本次提交前的 `server.ts`、项目库和逆向工作区组件。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 前后端代码 4-5 个文件。
|
||||
- 本次流程文档 3 个文件。
|
||||
- 经验记录追加 2-3 条。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已在需求中明确:“本次的 需求分析、实现方案、测试方案、执行修改 都不用我人工二次确认了”。因此本方案记录后直接执行。
|
||||
@@ -1,82 +0,0 @@
|
||||
# 实现方案 - 2026-05-04-05-41-22
|
||||
|
||||
## 修改目标
|
||||
|
||||
在不直接加载全部原始 STL 的前提下,提高 3D 模型预览的实体感,并加入整体位姿控制;改进 DICOM 长按切片时的连续图像反馈;精简逆向工作区重复标题与项目描述。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
### 1. 3D 模型实体化程度
|
||||
|
||||
- 在项目库 3D 模型页增加“实体化程度”控制,建议分为:
|
||||
- `预览`:维持较低三角面抽样,优先速度。
|
||||
- `标准`:提高抽样数量,呈现连续表面。
|
||||
- `精细`:进一步提高抽样数量,但仍保留上限防止卡顿。
|
||||
- 将当前固定 `/preview?limit=6000` 改为根据实体化程度动态请求,例如 `6000 / 16000 / 36000`。
|
||||
- Three.js 渲染继续使用 `MeshStandardMaterial`,但新增:
|
||||
- `flatShading: false`
|
||||
- 更强的环境光、主光和边缘光。
|
||||
- 可选整体白色实体显示模式,让模型更接近用户参考图。
|
||||
- 保留每个 STL 构件的颜色和透明度控制,新增“整体白色实体”开关时临时覆盖颜色为白/灰,不破坏原配置。
|
||||
|
||||
### 2. 3D 模型位姿控制
|
||||
|
||||
- 在 3D 模型左侧画布上方或右侧面板增加整体位姿控制:
|
||||
- 旋转 X/Y/Z 滑块,范围 `-180° ~ 180°`。
|
||||
- 平移 X/Y/Z 微调,范围建议 `-2 ~ 2`。
|
||||
- 缩放滑块,范围建议 `0.5 ~ 2`。
|
||||
- 重置按钮。
|
||||
- 将自动旋转改为可开关;当用户调整位姿时,自动旋转默认关闭,避免控制冲突。
|
||||
- 将位姿状态传入 `NativeStlViewer`,渲染循环中应用到 group。
|
||||
|
||||
### 3. DICOM 长按连续图像变化
|
||||
|
||||
- 当前 `startSliceStep` 只是连续改变 `sliceIndex`,图像依赖 API 响应更新。为增强“动起来”的感觉:
|
||||
- 将步进间隔保持在 `90~120ms`,同时在请求期间显示轻量切片过渡效果。
|
||||
- 增加 `latestPreviewRequestRef` 或请求序列号,确保旧请求返回时不会覆盖新切片。
|
||||
- 在 `sliceIndex` 变化时,立即更新右下角切片文字和 canvas 容器的轻微淡入/亮度变化。
|
||||
- 若接口响应较慢,保留上一帧图像并叠加“正在切换”状态,避免画面停滞误解。
|
||||
|
||||
### 4. 逆向工作区信息精简
|
||||
|
||||
- 删除 `ReverseWorkspace` 页面内 `h2` 标题“逆向工作区”,保留全局 header 的标题。
|
||||
- 删除页面内副标题中的上方“当前项目:xxx”文本。
|
||||
- 保留下方标签行:
|
||||
- `当前项目:头部 CT 模型逆向体素化演示`
|
||||
- `DICOM 300`
|
||||
- `STL 9`
|
||||
- 将标签字号从 `text-[11px]` 提升为 `text-sm` 或 `text-[13px]`,内边距加大,提高可读性。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
1. 用户进入项目库 3D 模型页。
|
||||
2. 前端根据实体化程度选择 STL preview limit 请求后端抽样数据。
|
||||
3. Three.js 将各 STL 抽样三角面生成实体 mesh,按当前实体化、颜色、透明度、白色实体模式、整体位姿状态渲染。
|
||||
4. 用户拖动位姿控制滑块,前端立即更新 group 的 rotation / position / scale。
|
||||
5. 用户在 DICOM 页长按切片按钮,前端连续改变 `sliceIndex`,后端返回切片预览,前端按最新请求序号绘制最新帧。
|
||||
6. 用户进入逆向工作区,只看到全局 header 的“逆向工作区”和加大后的当前项目标签行。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 实体化程度默认使用“标准”,若性能不佳可切回“预览”。
|
||||
- WebGL 不可用时继续使用二维投影兜底,实体化和位姿控制主要作用于 WebGL 路径。
|
||||
- 若提高抽样上限导致性能问题,可只回滚实体化程度 limit 参数,不影响项目列表、DICOM 和导出功能。
|
||||
- 逆向工作区文案调整为纯 UI 变更,可直接回滚组件 JSX。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `ProjectLibrary.tsx`:新增 3D 渲染控制状态、实体化程度、位姿控制 UI、DICOM 请求序号和切片过渡反馈。
|
||||
- `server.ts`:可能提高 STL preview 的安全上限。
|
||||
- `ReverseWorkspace.tsx`:删除重复标题/副标题,调整标签字号。
|
||||
- `经验记录.md`:执行后追加本次经验。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已回复“确认方案”,按已确认方案执行项目业务代码修改。
|
||||
@@ -1,85 +0,0 @@
|
||||
# 实现方案 - 2026-05-04-05-56-34
|
||||
|
||||
## 修改目标
|
||||
|
||||
修正 DICOM 矢状面/冠状面的物理比例,新增 DICOM 详细信息查询;简化 3D 模型显示控制,加入更高实体化档位,并实现画布内鼠标旋转、平移、滚轮缩放且同步整体位姿控件。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
### 1. DICOM 空间信息解析
|
||||
|
||||
- 扩展后端 DICOM tag 解析:
|
||||
- Pixel Spacing `(0028,0030)`:单张切片内行/列像素实际距离。
|
||||
- Slice Thickness `(0018,0050)`。
|
||||
- Spacing Between Slices `(0018,0088)`。
|
||||
- Image Position Patient `(0020,0032)`:优先用相邻切片空间位置差计算真实切片间距。
|
||||
- Patient、Study、Series、Modality、Manufacturer、Rows、Columns、Window、Rescale 等基础信息。
|
||||
- 在 DICOM volume cache 中保存 `rowSpacing`、`columnSpacing`、`sliceSpacing`。
|
||||
|
||||
### 2. 多平面物理比例重采样
|
||||
|
||||
- 当前矢状面/冠状面生成后先得到原始矩阵。
|
||||
- 根据物理尺寸计算目标比例:
|
||||
- 横向:`切片数 * sliceSpacing`
|
||||
- 矢状面纵向:`rows * rowSpacing`
|
||||
- 冠状面纵向:`columns * columnSpacing`
|
||||
- 以较小物理间距作为输出采样单位,将重建图像最近邻重采样到接近真实物理比例的像素宽高。
|
||||
- 返回 `spacing` 和 `physicalSize`,供前端信息展示。
|
||||
|
||||
### 3. DICOM 详细信息查询
|
||||
|
||||
- 新增后端接口:`GET /api/projects/:projectId/dicom-info`。
|
||||
- 返回默认项目第一张 DICOM 与序列聚合信息:
|
||||
- patient、study、series、image、window、spacing、sequence、source 等分组。
|
||||
- 前端 DICOM 影像页新增“信息”按钮,打开弹窗/面板展示基本信息、像素间距、切片间距、图像矩阵、物理尺寸、文件数量、首尾文件等。
|
||||
|
||||
### 4. 3D 模型控制简化与增强
|
||||
|
||||
- 去掉“白色实体”开关和“自动旋转”开关。
|
||||
- 默认模型不自动旋转,正向放置。
|
||||
- 实体化档位改为:`预览 / 标准 / 精细 / 超精细`。
|
||||
- 后端 STL preview 抽样上限提升到 `72000`,前端超精细档使用 `72000`。
|
||||
- 重置位姿按钮移动到“整体位姿”标题右侧。
|
||||
|
||||
### 5. 鼠标/滚轮位姿交互
|
||||
|
||||
- 在 `NativeStlViewer` 容器上监听 pointer 和 wheel:
|
||||
- 左键拖拽:旋转 X/Y。
|
||||
- 右键或 Shift+拖拽:平移 X/Y。
|
||||
- 滚轮:缩放。
|
||||
- 交互时通过 `onPoseChange` 回写 React state,使滑块数值同步变化。
|
||||
- 禁用浏览器右键菜单,避免右键平移时弹出菜单。
|
||||
- 位姿仍作用于整体 group,不改变 STL 构件相对位置。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
1. 前端请求 DICOM preview,后端解析/缓存体数据和空间信息,按真实物理比例输出矢状面/冠状面。
|
||||
2. 前端点击 DICOM 信息按钮,请求 dicom-info,弹窗展示元数据和空间参数。
|
||||
3. 前端进入 3D 模型页,按当前实体化档位请求 STL preview。
|
||||
4. 用户拖拽/滚轮操作画布,`NativeStlViewer` 更新位姿并回写父组件,右侧滑块同步变化。
|
||||
5. 用户点击重置位姿,模型回到默认正向摆放。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若某些 DICOM tag 缺失,后端使用默认 spacing `1mm`,并在详情中展示“未知/默认”。
|
||||
- 多平面重采样使用最近邻,避免引入新依赖;如比例异常可回滚到原始矩阵输出。
|
||||
- 超精细档可能更慢,但保留低档位可回退。
|
||||
- 鼠标交互只作用于项目库 3D 视图,不影响 DICOM、导出和逆向工作区。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 后端:DICOM metadata/spacing 解析、多平面重采样、dicom-info API、STL 上限。
|
||||
- 前端:DICOM 信息弹窗、3D 控件重构、鼠标交互回写位姿。
|
||||
- 文档:测试结果和经验记录追加。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已明确本次无需人工二次确认,文档落地后直接执行。
|
||||
@@ -1,46 +0,0 @@
|
||||
# 实现方案 - 2026-05-07-16-20-46
|
||||
|
||||
## 修改目标
|
||||
|
||||
修正项目库 3D 模型页默认位姿,使初次打开和点击“重置位姿”都恢复到类似参考图的正常俯视/正向姿态。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 默认位姿
|
||||
- 保持 `defaultModelPose` 的旋转、平移和缩放为中性值,避免默认滑块显示已经偏转。
|
||||
- 重置位姿继续设置为 `defaultModelPose`。
|
||||
|
||||
2. 默认相机
|
||||
- 将 `NativeStlViewer` 默认 camera 从斜向等距视角调整为更接近参考图的俯视视角。
|
||||
- 使用 `camera.position.set(0, 0, 6)`、`camera.up.set(0, 1, 0)`、`camera.lookAt(0, 0, 0)`,让模型以 XY 平面正向进入视野。
|
||||
- resize 后保留相机方向。
|
||||
|
||||
3. 视觉验证
|
||||
- 进入 3D 模型页后,模型不再以明显斜向等距视角显示。
|
||||
- 通过鼠标/滚轮改变位姿后,点击重置回到标准默认视角。
|
||||
|
||||
4. 与上一轮未提交改动合并
|
||||
- 保留并验证 DICOM 空间比例、DICOM 信息面板、3D 超精细档、鼠标交互同步等改动。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
用户进入项目库 -> 点击 3D 模型 -> 前端创建 Three.js camera 并使用默认俯视相机 -> STL group 居中缩放 -> 默认位姿滑块为 0/0/0 与缩放 1 -> 用户交互后可点击重置恢复。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 如果参考视角需要再微调,可只调整 camera position/up,不影响 STL 数据和后端接口。
|
||||
- 回滚可恢复相机到原先 `(4.5, 3.5, 5)` 等距视角。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `ProjectLibrary.tsx` 中相机默认位置和说明文字。
|
||||
- `经验记录.md` 追加默认位姿经验。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已明确本次无需人工二次确认,文档落地后直接执行。
|
||||
@@ -1,111 +0,0 @@
|
||||
# 实现方案 - 2026-05-07-16-35-52
|
||||
|
||||
## 修改目标
|
||||
|
||||
围绕项目库 3D 模型预览页,完善整体位姿控制、旋转中心和方位提示:
|
||||
|
||||
- 将单个 `重置位姿` 拆为 `重置旋转位姿`、`重置平移缩放位姿`。
|
||||
- 旋转 X/Y/Z 增加 `-90°` 和 `+90°` 快捷按钮。
|
||||
- 平移 X/Y/Z 增加负向/正向快捷步进按钮,缩放增加缩小/放大快捷按钮。
|
||||
- 将模型旋转中心稳定在所有 STL 拼装后整体包围盒的中心。
|
||||
- 在 3D 模型画布右下角增加 X/Y/Z 方位坐标提示和当前旋转角度。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
|
||||
## 技术路线
|
||||
|
||||
### 1. 拆分重置按钮
|
||||
|
||||
新增两个函数:
|
||||
|
||||
- `resetModelRotationPose()`
|
||||
- 只重置 `rotateX`、`rotateY`、`rotateZ` 为 `0`。
|
||||
- 保留 `translateX/Y/Z` 和 `scale`。
|
||||
- `resetModelTransformPose()`
|
||||
- 重置 `translateX`、`translateY`、`translateZ` 为 `0`,`scale` 为 `1`。
|
||||
- 保留 `rotateX/Y/Z`。
|
||||
|
||||
将 `整体位姿` 标题右侧按钮改为两个小按钮:
|
||||
|
||||
- `重置旋转位姿`
|
||||
- `重置平移缩放位姿`
|
||||
|
||||
### 2. 增加快捷调整按钮
|
||||
|
||||
为位姿控制行增加快捷按钮:
|
||||
|
||||
- 旋转 X/Y/Z:
|
||||
- `-90°`:当前角度减 90,并限制在 `[-180, 180]`。
|
||||
- `+90°`:当前角度加 90,并限制在 `[-180, 180]`。
|
||||
- 平移 X/Y/Z:
|
||||
- 负向按钮:每次减 `0.25`。
|
||||
- 正向按钮:每次加 `0.25`。
|
||||
- 仍限制在 `[-2, 2]`。
|
||||
- 缩放:
|
||||
- 缩小按钮:每次减 `0.1`。
|
||||
- 放大按钮:每次加 `0.1`。
|
||||
- 仍限制在 `[0.5, 2.5]`。
|
||||
|
||||
为避免数值越界和重复逻辑,新增统一的 `clampModelPoseValue()` 和 `nudgeModelPose()`。
|
||||
|
||||
### 3. 修正模型旋转中心
|
||||
|
||||
当前 STL 预览已经会对 mesh geometry 进行中心化处理。本次进一步显式拆分 Three.js 层级:
|
||||
|
||||
- `poseGroup`:负责整体平移。
|
||||
- `pivotGroup`:位于模型整体几何中心,负责旋转和缩放。
|
||||
- 所有 STL mesh 挂载在 `pivotGroup` 下,并在写入顶点时先减去整体包围盒中心。
|
||||
|
||||
动画循环中:
|
||||
|
||||
- `poseGroup.position = translateX/Y/Z`
|
||||
- `pivotGroup.rotation = rotateX/Y/Z`
|
||||
- `pivotGroup.scale = baseScale * scale`
|
||||
|
||||
这样模型始终围绕整体包围盒中心旋转,平移不会改变旋转轴心。
|
||||
|
||||
### 4. 增加右下角 X/Y/Z 方位提示
|
||||
|
||||
在 `NativeStlViewer` 容器中增加绝对定位 overlay:
|
||||
|
||||
- 显示三个彩色轴向:X 红色、Y 绿色、Z 蓝色。
|
||||
- 显示当前 `rotateX/Y/Z` 数值,随滑块、快捷按钮、鼠标拖拽同步刷新。
|
||||
- 放置于画布右下角,避免遮挡左下角 `MODEL PATH` 状态信息。
|
||||
|
||||
## 数据流与交互流程
|
||||
|
||||
1. 用户点击快捷按钮或拖动滑块。
|
||||
2. 前端调用 `updateModelPose()` 或 `nudgeModelPose()`。
|
||||
3. `modelPose` React 状态更新。
|
||||
4. `NativeStlViewer` 通过 `poseRef` 在 Three.js 动画循环中读取最新状态。
|
||||
5. Three.js 更新 `poseGroup` 与 `pivotGroup`。
|
||||
6. 右下角方位提示读取同一份 `modelPose` 并更新显示。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 修改只影响 3D 模型页面,不影响 DICOM 影像、分割结果、后端 API。
|
||||
- 若旋转中心方案出现异常,可回滚到单 group 结构,同时保留 UI 快捷按钮。
|
||||
- 若 overlay 影响视觉,可只保留文本角度提示,不影响核心模型显示。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- 新增位姿 clamp/nudge/reset 方法。
|
||||
- 更新整体位姿控制区 UI。
|
||||
- 调整 `NativeStlViewer` 的 group 层级。
|
||||
- 增加右下角方位提示 overlay。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
- 实现方案:用户已确认。
|
||||
- 确认信息:用户回复“都确认,后续直接搞”。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 已在 `WebSite/src/components/ProjectLibrary.tsx` 完成实现。
|
||||
- 已拆分 `重置旋转位姿`、`重置平移缩放位姿`。
|
||||
- 已为旋转、平移、缩放增加快捷调整按钮。
|
||||
- 已将 3D 模型渲染层级拆为平移容器和中心旋转容器。
|
||||
- 已新增右下角 X/Y/Z 方位与旋转角度提示。
|
||||
@@ -1,82 +0,0 @@
|
||||
# 实现方案 - 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 坐标轴。
|
||||
- 已统一 `重置旋转位姿` 与 `重置平移缩放位姿` 的字体颜色。
|
||||
@@ -1,64 +0,0 @@
|
||||
# 实现方案 - 2026-05-07-17-05-43
|
||||
|
||||
## 修改目标
|
||||
|
||||
将 3D 模型显示档位由 `预览 / 标准 / 精细 / 超精细` 改为 `标准 / 精细 / 超精细 / 实体`,并让 `实体` 更接近完整 STL 面片渲染。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/server.ts`
|
||||
|
||||
## 技术路线
|
||||
|
||||
### 1. 更新档位类型和配置
|
||||
|
||||
- 将 `SolidityLevel` 从 `preview | standard | fine | ultra` 改为 `standard | fine | ultra | solid`。
|
||||
- 删除 `预览` 选项。
|
||||
- 新增 `实体` 选项。
|
||||
- 设置 `实体` 的 `limit` 为更高值,尽量覆盖默认 9 个 STL 的完整三角面。
|
||||
|
||||
### 2. 提升后端 STL preview 上限
|
||||
|
||||
- 后端 `createStlPreview()` 当前最大抽样上限为 `72000`。
|
||||
- 将上限提升到 `200000`,支持实体档位请求更多三角面。
|
||||
- 继续保留 `sampleLimit` clamp,避免异常超大请求拖垮服务。
|
||||
|
||||
### 3. 优化实体档位材质
|
||||
|
||||
- 在 `NativeStlViewer` 中判断当前档位是否为 `solid`。
|
||||
- `solid` 档位下使用更高的不透明度下限,例如 `0.92`,增强实体感。
|
||||
- 其他档位继续使用构件层级中的用户透明度设置。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
1. 用户点击 `实体`。
|
||||
2. 前端 `solidityLevel` 切换为 `solid`。
|
||||
3. `selectedSolidity.limit` 变大,`NativeStlViewer` 重新请求 STL preview。
|
||||
4. 后端按更高上限返回更多三角面和稳定 `bounds`。
|
||||
5. 前端以实体材质重新渲染模型。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若实体档位渲染压力过大,可降低 `solid.limit` 或后端最大上限。
|
||||
- 若用户仍需要低质量快速预览,可恢复 `preview` 配置。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- 更新 `SolidityLevel`、`solidityOptions`。
|
||||
- `NativeStlViewer` 增加 `solidMode` 参数。
|
||||
- 实体档位材质增强不透明度。
|
||||
- `WebSite/server.ts`
|
||||
- 提升 STL preview 最大抽样上限。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
- 本次免二次确认,方案写入后直接执行。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 已删除 `模型显示` 中的 `预览` 档位。
|
||||
- 已新增 `实体` 档位,前端请求上限为 `200000`。
|
||||
- 已将后端 STL preview 最大抽样上限从 `72000` 提升到 `200000`。
|
||||
- 已在实体档位提高材质不透明度下限,增强实体面片视觉。
|
||||
@@ -1,119 +0,0 @@
|
||||
# 实现方案 - 2026-05-07-17-28-34
|
||||
|
||||
## 修改目标
|
||||
|
||||
在逆向工作区实现三维 DICOM 体与 STL 模型融合视角:
|
||||
|
||||
- DICOM 切片范围可控。
|
||||
- DICOM 以黑色长方体和多张切片纹理展示。
|
||||
- 最后一张切片显示在体表面。
|
||||
- STL 模型叠加在 DICOM 体中。
|
||||
- 用户可以调整模型位姿。
|
||||
- DICOM 和 STL 可以一起旋转、平移、缩放观察。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
|
||||
## 技术路线
|
||||
|
||||
### 1. 后端新增 DICOM 融合体接口
|
||||
|
||||
新增接口:
|
||||
|
||||
```text
|
||||
GET /api/projects/:projectId/dicom-fusion-volume?start=0&end=49&mode=soft
|
||||
```
|
||||
|
||||
返回内容:
|
||||
|
||||
- `width`、`height`
|
||||
- `start`、`end`、`total`
|
||||
- `indices`
|
||||
- `frames`:每张切片灰度图 base64
|
||||
- `spacing`:row/column/slice
|
||||
- `physicalSize`:width/height/depth
|
||||
|
||||
为控制传输和纹理压力:
|
||||
|
||||
- 单次最多返回 64 张切片。
|
||||
- 纹理最大边长限制为 256。
|
||||
- 使用已有 `getDicomVolume()` 和 `resampleNearest()`,避免重复实现 DICOM 解析。
|
||||
|
||||
### 2. 前端新增类型与 API
|
||||
|
||||
- 在 `types.ts` 新增 `DicomFusionVolume`。
|
||||
- 在 `api.ts` 新增 `getDicomFusionVolume(projectId, start, end, mode)`。
|
||||
|
||||
### 3. 重构融合视角为 Three.js 场景
|
||||
|
||||
在 `ReverseWorkspace.tsx` 中新增 `FusionThreeView`:
|
||||
|
||||
- 创建 Three.js scene/camera/renderer。
|
||||
- DICOM:
|
||||
- 用半透明黑色 box 表示 CT 体。
|
||||
- 将范围内切片转为 `CanvasTexture`。
|
||||
- 按切片索引在 Z 方向分层放置。
|
||||
- 最后一帧贴到体表面并提高不透明度。
|
||||
- STL:
|
||||
- 调用已有 STL preview 接口加载模型构件。
|
||||
- 使用后端返回 `bounds` 合成稳定模型中心。
|
||||
- 将模型挂到 `modelGroup`。
|
||||
- 场景:
|
||||
- DICOM 体和模型都放入 `fusionRoot`,整体旋转和平移缩放作用在 `fusionRoot`。
|
||||
- 模型位姿单独作用在 `modelPoseGroup`。
|
||||
|
||||
### 4. 交互与控制
|
||||
|
||||
- 鼠标左键拖拽:旋转整体融合场景。
|
||||
- Shift/右键拖拽:平移整体融合场景。
|
||||
- 滚轮:缩放整体融合场景。
|
||||
- UI 控件:
|
||||
- 切片起点、切片终点。
|
||||
- 模型旋转 X/Y/Z。
|
||||
- 模型平移 X/Y/Z。
|
||||
- 模型缩放。
|
||||
- 重置模型位姿。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
1. 进入逆向工作区后加载项目详情。
|
||||
2. 根据默认切片范围请求 DICOM 融合体数据。
|
||||
3. 请求 STL preview 并加载模型。
|
||||
4. Three.js 创建 DICOM 体和 STL 模型。
|
||||
5. 用户拖拽场景时 DICOM 与 STL 一起旋转。
|
||||
6. 用户调整模型位姿时只改变 STL 模型相对 DICOM 的位置。
|
||||
7. 用户调整切片范围时重新请求 DICOM 融合体数据并刷新 CT 体。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若 WebGL 不可用,显示加载失败/不支持提示,不影响其他页面。
|
||||
- 若融合体接口失败,保留逆向工作区其他 mask 导出功能。
|
||||
- 若性能不足,可降低最大返回切片数和纹理尺寸。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- 新增 `createDicomFusionVolume()` 和 API route。
|
||||
- `WebSite/src/types.ts`
|
||||
- 新增 `DicomFusionVolume` 类型。
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- 新增 `getDicomFusionVolume()`。
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- 新增 Three.js 融合视角组件和位姿控制。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
- 本次免二次确认,方案写入后直接执行。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 已新增 `GET /api/projects/:projectId/dicom-fusion-volume`,支持按 `start/end/mode` 返回一段轴向 DICOM 体数据纹理。
|
||||
- 已新增 `DicomFusionVolume` 类型和 `api.getDicomFusionVolume()`。
|
||||
- 已将逆向工作区 `影像与模型融合视角` 重构为 Three.js 三维场景。
|
||||
- 已实现黑色 DICOM 长方体、范围内切片纹理层叠、最后一帧体表面显示。
|
||||
- 已将 STL 模型加载进同一融合场景,可通过模型位姿控件相对 DICOM 体调整。
|
||||
- 已支持鼠标拖拽/滚轮对 DICOM 体和 STL 模型整体旋转、平移、缩放。
|
||||
@@ -1,83 +0,0 @@
|
||||
# 实现方案 - 2026-05-07-18-11-12
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 修复项目库构件层级眼睛按钮。
|
||||
- 为构件层级增加可编辑 ID。
|
||||
- 左侧导航新增 `模型库`。
|
||||
- 逆向工作区将 `分割 Mask` 替换为 `可视化工具栏`,并加入模型显示、整体位姿保存/选择、构件层级。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/components/Sidebar.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
|
||||
## 技术路线
|
||||
|
||||
### 1. 左侧新增模型库
|
||||
|
||||
- 在 `ViewType` 中新增 `MODELS`。
|
||||
- Sidebar 增加 `模型库` 导航项。
|
||||
- App 中点击 `模型库` 时复用 `ProjectLibrary`,但通过 `initialViewMode="model"` 默认进入 3D 模型页。
|
||||
|
||||
### 2. 项目库构件层级眼睛修复与 ID
|
||||
|
||||
- `ModuleStyle` 增加 `partId`。
|
||||
- 初始化构件层级时按文件顺序写入 `partId=index+1`。
|
||||
- `toggleModuleVisibility()` 明确保留 `partId/color/opacity`,只切换 `visible`。
|
||||
- 构件眼睛按钮阻止冒泡,避免父级点击干扰。
|
||||
- 每个构件显示 `ID` 输入框,输入值限制为 1 到 255 的整数。
|
||||
|
||||
### 3. 逆向工作区可视化工具栏
|
||||
|
||||
- 将标题 `分割 Mask` 改为 `可视化工具栏`。
|
||||
- 增加模型显示档位:标准、精细、超精细、实体。
|
||||
- 增加整体位姿保存/选择:
|
||||
- 默认预设:默认、俯视、侧视。
|
||||
- 用户可保存当前位姿为 `位姿N`。
|
||||
- 选择后更新 `modelPose`。
|
||||
- 增加构件层级:
|
||||
- 眼睛显示/隐藏。
|
||||
- 颜色。
|
||||
- 透明度。
|
||||
- ID 1 到 255。
|
||||
- 将逆向工作区 Three.js 模型加载逻辑接入构件样式和显示档位。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
1. 用户进入 `模型库` 或项目库 3D 模型页。
|
||||
2. 构件样式状态按 STL 文件初始化。
|
||||
3. 用户点击眼睛按钮,前端更新对应构件 `visible`,Three.js 重新加载/渲染可见构件。
|
||||
4. 用户修改 ID,前端 clamp 到 1 到 255 并显示。
|
||||
5. 用户进入逆向工作区,工具栏显示模型显示、位姿预设、构件层级。
|
||||
6. 用户保存或选择整体位姿,融合视角模型位姿更新。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- `模型库` 复用项目库,不新增后端路由。
|
||||
- 构件 ID 仅在前端状态中使用,若后续需要持久化可再扩展后端项目状态。
|
||||
- 若逆向工作区工具栏过长,可后续拆成 tabs 或折叠区。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `types.ts`:新增 ViewType。
|
||||
- `Sidebar.tsx`:新增导航项。
|
||||
- `App.tsx`:支持模型库视图和 ProjectLibrary 初始 tab。
|
||||
- `ProjectLibrary.tsx`:构件 ID、眼睛修复、初始 viewMode 参数。
|
||||
- `ReverseWorkspace.tsx`:可视化工具栏与模型样式/位姿预设。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
- 本次免二次确认,方案写入后直接执行。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- 已新增左侧导航 `模型库`,默认进入项目库的 3D 模型页。
|
||||
- 已为项目库构件层级增加 `ID` 输入,默认 1 到 N,输入限制为 1 到 255。
|
||||
- 已修正项目库构件眼睛按钮切换时保留颜色、透明度、ID 状态。
|
||||
- 已将逆向工作区 `分割 Mask` 改为 `可视化工具栏`。
|
||||
- 已在可视化工具栏加入模型显示档位、整体位姿保存/选择、构件层级。
|
||||
- 已将逆向工作区融合视角的 STL 加载接入构件显示、颜色、透明度和模型显示档位。
|
||||
@@ -1,79 +0,0 @@
|
||||
# 实现方案 - 2026-05-07-18-42-53
|
||||
|
||||
## 修改目标
|
||||
|
||||
1. 移除逆向工作区可视化工具栏底部 Metadata 信息块。
|
||||
2. 将 DICOM 融合切片范围控制改为融合视角下方单滑条,滑条值表示展示到第几张切片。
|
||||
3. 将整体位姿控制区统一命名为“模型位姿”,并保留现有位姿保存、选择、重置和调节能力。
|
||||
4. 将构件 ID 从纯组件状态提升为后端项目状态字段,使项目库 3D 模型与逆向工作区可视化工具栏联动。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/server.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
### 构件 ID 联动
|
||||
|
||||
- 在后端项目数据中增加 `moduleStyles` 字段,存储每个 STL 文件的:
|
||||
- `visible`
|
||||
- `color`
|
||||
- `opacity`
|
||||
- `partId`
|
||||
- 项目库与逆向工作区读取项目时都使用同一个 `project.moduleStyles` 初始化。
|
||||
- 任一页面修改 ID、颜色、透明度、显示状态时,通过 `PATCH /api/projects/:projectId/module-styles` 写回后端。
|
||||
- 前端本地状态更新后同步调用 API,保证跨页面和跨浏览器一致。
|
||||
|
||||
### 融合切片范围
|
||||
|
||||
- 保留后端接口需要的 `start/end` 参数。
|
||||
- 前端只展示一个滑条 `sliceEnd`,内部固定 `start = 0`,`end = sliceEnd`。
|
||||
- 滑条放在影像与模型融合视角下方,便于操作与结果对应。
|
||||
|
||||
### 位姿模块整合
|
||||
|
||||
- 将工具栏内的“整体位姿”标题改为“模型位姿”。
|
||||
- 保留模型显示、位姿保存/选择、旋转、平移、缩放与重置功能,但减少重复命名。
|
||||
|
||||
### UI 清理
|
||||
|
||||
- 删除可视化工具栏底部 Metadata 代码块。
|
||||
|
||||
## 数据流
|
||||
|
||||
1. 后端读取或重置项目时生成默认 `moduleStyles`,`partId` 默认从 1 到 N。
|
||||
2. 项目库修改构件 ID 后调用 API 保存。
|
||||
3. 逆向工作区加载项目时读取最新 `moduleStyles`。
|
||||
4. 逆向工作区修改构件 ID 后同样调用 API 保存。
|
||||
5. 融合视角滑条变化后触发 `dicom-fusion-volume?start=0&end=<sliceEnd>` 重新加载体数据。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 对旧 `state.json` 做兼容:项目没有 `moduleStyles` 时由后端按 STL 列表自动补齐。
|
||||
- 若新增 API 出现问题,前端仍可回退为本地状态,但 ID 联动会退化。
|
||||
- 所有修改集中在项目状态、项目库与逆向工作区,回滚本次 commit 即可恢复。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `server.ts`:补齐项目 moduleStyles、增加更新 API。
|
||||
- `types.ts`:补充项目 moduleStyles 类型。
|
||||
- `ProjectLibrary.tsx`:读取/保存共享 moduleStyles。
|
||||
- `ReverseWorkspace.tsx`:读取/保存共享 moduleStyles,调整切片范围 UI,删除 Metadata,重命名位姿模块。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 已新增共享构件样式数据 `moduleStyles`,包含 `visible/color/opacity/partId`。
|
||||
- 已新增 `PATCH /api/projects/:projectId/module-styles`,用于项目库与逆向工作区同步构件 ID、颜色、透明度、显示隐藏状态。
|
||||
- 已将项目库和逆向工作区的构件 ID 读写改为同一后端项目状态。
|
||||
- 已删除逆向工作区可视化工具栏下方 `Metadata` 信息块。
|
||||
- 已将融合视角下方 DICOM 切片范围改为单一进度条,内部固定从第 1 张开始显示。
|
||||
- 已将可视化工具栏中的“整体位姿”改为“模型位姿”,并合并位姿保存、选择和各项旋转/平移/缩放控制。
|
||||
- 已同步将项目库 3D 模型中的“整体位姿”标题改为“模型位姿”。
|
||||
@@ -1,108 +0,0 @@
|
||||
# 实现方案 - 2026-05-08-01-19-42
|
||||
|
||||
## 修改目标
|
||||
|
||||
1. 修复模型库大缩放时构件层级不可见或不可滚动的问题。
|
||||
2. 更换逆向工作区侧栏图标,避免与模型库重复。
|
||||
3. 优化逆向工作区模型位姿控制:拆分重置按钮、增加 ±90° 旋转、位姿重命名、单击最低刻度、长按连续移动。
|
||||
4. 为融合视角增加 DICOM 体数据缓存与五个预存点位。
|
||||
5. 在融合视角显示 DICOM 边界框、模型边界框,并以 DICOM 中心作为模型旋转中心。
|
||||
6. 增加 DICOM 透明度三档控制。
|
||||
7. 增加基于 DICOM 帧的模型切分预览,显示切割面。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/Sidebar.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
### 模型库滚动
|
||||
|
||||
- 调整项目库 3D 模型页右侧栏为 `min-h-0` + 内部滚动结构。
|
||||
- 构件层级列表占用剩余高度,标题和控制区固定,防止浏览器放大后构件列表被挤出。
|
||||
|
||||
### 侧栏图标
|
||||
|
||||
- 侧栏中模型库继续使用 `Box`,逆向工作区改用 `Workflow` 或 `ScanLine` 类图标,保证含义和视觉不同。
|
||||
|
||||
### 位姿控制
|
||||
|
||||
- 在逆向工作区可视化工具栏中替换 `重置默认位姿` 为:
|
||||
- `重置旋转位姿`
|
||||
- `重置平移缩放位姿`
|
||||
- 旋转 X/Y/Z 增加 `-90°` 和 `+90°` 按钮。
|
||||
- 平移和缩放增加正负最小步进按钮。
|
||||
- 单击按钮只移动一个最低刻度;鼠标或触摸长按时启动定时器连续移动。
|
||||
- 保存位姿列表中允许重命名非默认位姿,默认位姿保持不可改名。
|
||||
|
||||
### DICOM 缓存与预存点
|
||||
|
||||
- 前端维护 `fusionVolumeCacheRef`,以 `projectId/mode/end` 作为 key 缓存融合体数据。
|
||||
- DICOM 切片范围显示五个点位按钮,分别映射 20%、40%、60%、80%、100%。
|
||||
- 点击预存点位时优先从缓存读取;缓存未命中则请求接口并写入缓存。
|
||||
- 增加“预存五点”按钮,后台并发预取五个点位。
|
||||
|
||||
### 边界框与旋转中心
|
||||
|
||||
- FusionThreeView 增加 DICOM BoxHelper/LineSegments 边界和模型边界。
|
||||
- 模型 STL 顶点以自身中心归一化后,整体 group 的 pivot 固定在 DICOM 体中心。
|
||||
- 旋转、缩放围绕 DICOM 中心进行,平移作为模型相对 DICOM 中心的偏移。
|
||||
|
||||
### DICOM 透明度
|
||||
|
||||
- 新增 `dicomOpacityLevel`,提供低/中/高三档。
|
||||
- 传入 FusionThreeView,控制 DICOM 体素切片和切片纹理材质透明度。
|
||||
|
||||
### 模型切分
|
||||
|
||||
- 新增切割开关、切割帧滑条和切割面显示。
|
||||
- Three.js 中使用 DICOM 切片帧在 Z 方向的相对位置作为切割平面。
|
||||
- 通过 local clipping plane 对模型材质裁剪,并显示半透明切割面。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
1. 进入逆向工作区后读取项目与 `moduleStyles`。
|
||||
2. Fusion 视角按当前切片范围加载体数据,先查前端缓存,未命中再请求后端。
|
||||
3. 用户点击五个点位或预存按钮,前端将对应 `end` 的 DICOM volume 写入缓存。
|
||||
4. 用户调整模型位姿,模型围绕 DICOM 中心旋转缩放,并实时更新边界框。
|
||||
5. 用户启用模型切分并选择 DICOM 帧,Three.js 使用对应切割平面裁剪模型并显示切割面。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 缓存仅存在浏览器内存中,不改变后端数据结构。
|
||||
- 新增控制项都保留默认值,旧项目数据可直接使用。
|
||||
- 若切割平面对部分浏览器性能影响较大,可关闭切割功能回到原始渲染。
|
||||
- 回滚本次 commit 即可恢复。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `Sidebar.tsx`:调整逆向工作区图标。
|
||||
- `ProjectLibrary.tsx`:修复右侧构件层级滚动布局。
|
||||
- `ReverseWorkspace.tsx`:增加缓存、预存点、位姿控制、边界框、透明度和切割功能。
|
||||
- `api.ts/types.ts/server.ts`:如需支持缓存辅助或类型扩展则同步调整。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 已将左侧逆向工作区图标从 `Box` 改为 `Workflow`,与模型库图标区分。
|
||||
- 已将模型库 3D 模型右侧栏改为整栏滚动结构,解决浏览器放大后构件层级下方内容不可见的问题。
|
||||
- 已在逆向工作区模型位姿中增加:
|
||||
- `重置旋转位姿`
|
||||
- `重置平移缩放位姿`
|
||||
- X/Y/Z 的 ±90° 快捷旋转
|
||||
- 旋转/平移/缩放的最低刻度单击步进与长按连续步进
|
||||
- 非默认位姿重命名输入
|
||||
- 已在融合视角中增加 DICOM 透明度低/中/高三档。
|
||||
- 已在融合视角中增加 DICOM 边界框和模型边界框显示开关。
|
||||
- 已将模型旋转中心改为以 DICOM 体中心为 pivot,模型自身偏移放入子节点,避免旋转时围绕模型偏移点运动。
|
||||
- 已增加 DICOM 切片范围五个点位和 `预存五点` 功能,前端使用内存 Map 缓存融合体数据。
|
||||
- 已增加模型切分开关、切割帧滑条、橙色切割面显示,并通过 Three.js clipping plane 裁剪模型。
|
||||
@@ -1,85 +0,0 @@
|
||||
# 实现方案 - 2026-05-08-01-53-07
|
||||
|
||||
## 修改目标
|
||||
|
||||
1. 固定融合视角的 DICOM 物理尺寸基准,使切片范围变化不改变模型原始位置。
|
||||
2. DICOM 切片范围默认显示到最高切片,并支持 `M-N` 双向范围选择。
|
||||
3. 优化逆向工作区布局,浏览器放大后可滚动访问 DICOM 切片范围。
|
||||
4. 项目库项目加载/导入后后台预加载 DICOM 与模型预览。
|
||||
5. 模型切分开启后,在 DICOM 切割帧上叠加 Mask 轮廓/填充,直观看到切割位置。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
### 固定 DICOM 体基准
|
||||
|
||||
- 后端 `dicom-fusion-volume` 的 `physicalSize.depth` 改为完整 DICOM 序列深度,而不是当前 `start/end` 范围深度。
|
||||
- 前端 FusionThreeView 使用完整物理尺寸计算 DICOM box 和模型缩放,范围变化只改变参与显示的帧,不改变空间基准。
|
||||
- 模型 pivot 保持 DICOM 中心,不根据切片范围重新定位。
|
||||
|
||||
### 双向范围滑条
|
||||
|
||||
- 恢复 `sliceStart` 与 `sliceEnd` 两个状态,但 UI 使用同一范围卡片表达。
|
||||
- 初始值设为 `sliceStart=maxSlice`、`sliceEnd=maxSlice`,即默认最高切片。
|
||||
- 请求接口前将 `min(sliceStart,sliceEnd)` 和 `max(sliceStart,sliceEnd)` 作为真实 `M-N`。
|
||||
- UI 显示 `M-N / total`,并提供两个滑条在同一区域控制左右端点。
|
||||
|
||||
### 放大布局
|
||||
|
||||
- 逆向工作区根容器允许纵向滚动。
|
||||
- 主三列区域保留内部滚动,左侧融合区域不因高度压缩导致 DICOM 范围卡片不可达。
|
||||
|
||||
### 后台预加载
|
||||
|
||||
- 项目库加载项目列表后,对默认/选中项目发起低成本预加载:
|
||||
- DICOM 预览中间帧。
|
||||
- 前几个 STL preview。
|
||||
- 融合体最高切片。
|
||||
- 使用 `void` 异步调用,不阻塞 UI。
|
||||
|
||||
### 切割 Mask 叠加
|
||||
|
||||
- FusionThreeView 中当 `cutEnabled` 时,根据当前切割帧创建 DICOM texture overlay。
|
||||
- 在切割帧平面上叠加半透明橙色 Mask 区域和边界线,表示模型切割位置。
|
||||
- 该 Mask 是可视化辅助层,不改变模型、不写出 NIfTI。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
1. 进入逆向工作区读取项目,计算 `maxSlice=dicomCount-1`。
|
||||
2. 初始范围设为 `maxSlice-maxSlice`。
|
||||
3. 用户拖动双向范围控制,前端排序后请求 `dicom-fusion-volume?start=M&end=N`。
|
||||
4. 后端返回当前范围帧,但 physical depth 始终以完整 DICOM 序列计算。
|
||||
5. FusionThreeView 使用固定 DICOM box 渲染 DICOM 与模型,模型位姿不受范围变化影响。
|
||||
6. 启用模型切分后,在切割帧平面叠加 Mask 预览。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 对任意 DICOM 总数使用 `dicomCount` 和接口返回 `total`,不硬编码 300。
|
||||
- 若项目没有 DICOM 或 STL,预加载自动跳过。
|
||||
- 回滚本次 commit 可恢复旧的单端点切片范围与切割显示。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `server.ts`:修正融合体 physical depth 基准。
|
||||
- `ProjectLibrary.tsx`:新增项目后台预加载。
|
||||
- `ReverseWorkspace.tsx`:双向范围、布局滚动、固定模型空间、切割 Mask 叠加。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 已将后端 `dicom-fusion-volume` 的 `physicalSize.depth` 改为完整 DICOM 序列深度,避免切片范围变化导致 DICOM 体和模型空间重新缩放。
|
||||
- 已将融合视图中每张 DICOM 帧的 Z 位置改为按真实 `volume.indices` 映射到完整 DICOM 深度,范围变化只增减显示帧,不重新铺满空间。
|
||||
- 已将逆向工作区 DICOM 切片范围改为双端点控制,支持 `M-N` 显示范围。
|
||||
- 已将默认切片范围初始化为最高切片 `maxSlice-maxSlice`,不硬编码 300。
|
||||
- 已让逆向工作区根容器支持纵向滚动,浏览器放大时仍可滚动访问 DICOM 切片范围。
|
||||
- 已在项目库中加入项目后台预加载:最高 DICOM 预览、最高融合体、前三个 STL 预览。
|
||||
- 已在模型切分开启时,在切割 DICOM 帧上叠加橙色 Mask 预览和边界标记,使用户能直接在 DICOM 上看到切割位置。
|
||||
@@ -1,53 +0,0 @@
|
||||
# 实现方案:单条 DICOM 范围滑块
|
||||
|
||||
时间戳:2026-05-08-03-03-52
|
||||
|
||||
## 修改目标
|
||||
|
||||
将逆向体素化工作区“DICOM 切片范围”从两个上下排列的原生进度条,改为一条自绘轨道叠加两个可拖动端点的范围条。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/index.css`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 在 `ReverseWorkspace.tsx` 中基于 `safeSliceStart`、`safeSliceEnd`、`displayStart`、`displayEnd` 计算范围条起止百分比。
|
||||
2. 替换原有两个 `<label><input type="range"></label>`。
|
||||
3. 使用一个 `relative` 容器绘制:
|
||||
- 灰色底轨;
|
||||
- 蓝色选中范围;
|
||||
- 两个透明轨道的 `range input`,只显示滑块端点。
|
||||
4. 在 `index.css` 中新增 `.dicom-range-input` 样式,隐藏原生轨道,保留 thumb,并让 thumb 可点击拖动。
|
||||
5. 端点重合时提高“起点”滑块层级,便于从默认 `300-300` 状态向左拖出范围。
|
||||
|
||||
## 数据流与交互流程
|
||||
|
||||
- 用户拖动起点端点:调用 `setSliceStart(Number(event.target.value))`。
|
||||
- 用户拖动终点端点:调用 `setSliceEnd(Number(event.target.value))`。
|
||||
- 当前显示范围继续由 `displayStart = min(start, end)` 与 `displayEnd = max(start, end)` 决定。
|
||||
- 后续 `loadFusionVolume(displayStart/displayEnd)` 相关逻辑保持现状。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若自定义样式在目标浏览器表现异常,可回滚本次对 `ReverseWorkspace.tsx` 和 `index.css` 的修改,恢复两个原生 range。
|
||||
- 本次不修改后端 API 和数据结构,回滚风险较低。
|
||||
|
||||
## 风险控制
|
||||
|
||||
- 保留无障碍 `aria-label`,明确起点和终点端点。
|
||||
- 通过 `npm run lint` 和 `npm run build` 检查 TypeScript 与构建。
|
||||
- 部署后通过页面源码或构建产物搜索确认不存在“起点/终点两个 label + 两个进度条”的旧结构。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增本次需求、实现、测试方案文档。
|
||||
- 修改 `ReverseWorkspace.tsx` 中 DICOM 范围控件 JSX。
|
||||
- 修改 `index.css` 增加范围条浏览器兼容样式。
|
||||
- 追加 `经验记录.md`。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已在项目工作流历史中确认后续直接执行,本次不等待二次人工审核。
|
||||
@@ -1,54 +0,0 @@
|
||||
# 实现方案:DICOM 范围驱动模型切分
|
||||
|
||||
时间戳:2026-05-08-03-13-20
|
||||
|
||||
## 修改目标
|
||||
|
||||
将逆向工作区模型切分从“独立帧滑块 + 单切面 + CUT MASK 贴图”改为“DICOM 切片范围双端点 + 双 clipping plane + 保留中间模型”。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 删除 `createCutMaskTexture` 及其调用,彻底去掉 `CUT MASK` 贴图。
|
||||
2. 删除 `cutSlice` 状态、初始化和模型切分的帧进度条。
|
||||
3. 项目加载时将 `sliceStart` 初始化为 `0`,`sliceEnd` 初始化为 `maxIndex`,让初始范围显示 `1~300`。
|
||||
4. `FusionThreeView` 接收 `cutStart`、`cutEnd`,来自 `displayStart`、`displayEnd`。
|
||||
5. 根据 DICOM 全序列总数和物理深度,将 `cutStart/cutEnd` 映射为 DICOM Z 坐标。
|
||||
6. 创建两个 clipping plane:
|
||||
- 起点切面:剔除起点外侧。
|
||||
- 终点切面:剔除终点外侧。
|
||||
7. 模型材质在 `cutEnabled` 时使用两个 clipping plane,`clipIntersection=false`,从而保留两平面之间的中间区域。
|
||||
8. 用两张半透明橙色切面辅助显示切割位置,但不再显示任何 mask 图片。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
- 用户调整 `DICOM 切片范围`。
|
||||
- `displayStart/displayEnd` 归一化端点顺序。
|
||||
- 前端按该范围加载 DICOM 纹理,并将同一范围传给 `FusionThreeView` 的模型切割逻辑。
|
||||
- 用户启用 `模型切分` 后,STL 模型只显示两帧之间的区域。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若双 clipping plane 行为异常,可回滚本次 `ReverseWorkspace.tsx` 修改,恢复 `cutSlice` 单切面逻辑。
|
||||
- 本次不改 API 和数据文件,回滚只影响前端可视化。
|
||||
|
||||
## 风险控制
|
||||
|
||||
- 使用 `npm run lint` 检查 TypeScript。
|
||||
- 使用 `npm run build` 检查生产构建。
|
||||
- 用 `rg` 确认源码和构建产物不再包含 `CUT MASK` 与 `createCutMaskTexture`。
|
||||
- 重新部署后从 dev server 拉取源码确认不再存在模型切分帧滑块。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改 `ReverseWorkspace.tsx`。
|
||||
- 新增本次需求、实现、测试方案文档。
|
||||
- 追加 `经验记录.md`。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已在项目工作流历史中确认后续直接执行,本次不等待二次人工审核。
|
||||
@@ -1,51 +0,0 @@
|
||||
# 实现方案:精简导航与切分显示
|
||||
|
||||
时间戳:2026-05-08-03-23-51
|
||||
|
||||
## 修改目标
|
||||
|
||||
删除重复“模型库”入口,简化逆向工作区 DICOM 范围卡片,并去掉模型切分时的红色辅助平面。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/Sidebar.tsx`
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 从 `ViewType` 中删除 `MODELS`。
|
||||
2. 从侧边栏 `menuItems` 中删除“模型库”,并移除不再使用的图标导入。
|
||||
3. 从 `App.tsx` 中删除 `ViewType.MODELS` 的标题和渲染分支,保留“项目库”中的模型查看能力。
|
||||
4. 删除 `ReverseWorkspace.tsx` 中 DICOM 范围说明文案、五点预存按钮、预存状态和相关函数。
|
||||
5. 保留 `fusionVolumeCacheRef`,让初始 `1~最终` 和用户实际访问过的范围自动进入缓存。
|
||||
6. 删除 `lowerCutPlane/upperCutPlane` Mesh 创建与加入场景逻辑,只保留 `lowerClippingPlane/upperClippingPlane` 用于真实裁切。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
- 项目入口统一从“项目库”进入。
|
||||
- 逆向工作区加载项目后默认请求完整 DICOM 范围,返回后写入缓存。
|
||||
- 启用模型切分后,STL 材质继续使用两张 clipping plane 裁切,不再渲染红色平面。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若用户仍需要独立模型入口,可恢复 `ViewType.MODELS`、侧边栏项和 `App.tsx` 对应分支。
|
||||
- 若需要切割面可视化,可重新添加半透明辅助平面;本次不影响 clipping plane 逻辑。
|
||||
|
||||
## 风险控制
|
||||
|
||||
- 使用 `rg` 确认不再存在 `ViewType.MODELS`、`模型库`、`预存五点`、红色切面 Mesh。
|
||||
- 执行 `npm run lint` 和 `npm run build`。
|
||||
- 重新部署并从 dev server 拉取源码确认变更生效。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改 4 个前端源码文件。
|
||||
- 新增 3 个工程分析文档。
|
||||
- 追加经验记录。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已在项目工作流历史中确认后续直接执行,本次不等待二次人工审核。
|
||||
@@ -1,50 +0,0 @@
|
||||
# 实现方案:右侧实体切面预览
|
||||
|
||||
时间戳:2026-05-08-03-35-22
|
||||
|
||||
## 修改目标
|
||||
|
||||
在 `Mask 展示` 区域新增真实 STL 切割实体预览组件,替代旧的二维示意 Mask,并删除底部导出进度栏。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 新增 `CutSectionPreview` 组件。
|
||||
2. 组件使用 Three.js 渲染 STL preview 顶点数据。
|
||||
3. 使用 DICOM `physicalSize.depth` 和 `total` 将 `displayStart/displayEnd` 映射到 Z 方向两张 clipping plane。
|
||||
4. 当“模型切分”启用时,对 STL 材质应用两张 clipping plane,保留中间实体区域;未启用时展示完整实体模型。
|
||||
5. 复用构件颜色、显示隐藏、模型位姿和高质量实体请求上限。
|
||||
6. 删除旧 `mappings` 假 Mask 形状、`MaskMapping` 类型引用、`exportMessage` 和底部导出进度栏。
|
||||
|
||||
## 数据流或交互流程
|
||||
|
||||
- DICOM 范围条更新 `displayStart/displayEnd`。
|
||||
- `FusionThreeView` 和 `CutSectionPreview` 都接收相同范围。
|
||||
- `CutSectionPreview` 请求 STL preview,居中、缩放并应用当前模型位姿。
|
||||
- 启用模型切分后,右侧实体预览显示裁切后的 STL 中间段。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若实体预览性能不满足要求,可降低 `detailLimit` 或回滚到旧 Mask 展示区域。
|
||||
- 本次不改 API 和数据文件,回滚只涉及前端组件。
|
||||
|
||||
## 风险控制
|
||||
|
||||
- 使用 `npm run lint` 验证类型。
|
||||
- 使用 `npm run build` 验证构建。
|
||||
- 使用 `rg` 确认旧 `导出进度`、`mappings`、假 Mask 结构已移除。
|
||||
- 重启服务并验证 4000 端口。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改 `ReverseWorkspace.tsx`。
|
||||
- 新增本次需求、实现、测试文档。
|
||||
- 追加经验记录。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已在项目工作流历史中确认后续直接执行,本次不等待二次人工审核。
|
||||
@@ -1,55 +0,0 @@
|
||||
# 实现方案-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`。
|
||||
@@ -1,57 +0,0 @@
|
||||
# 实现方案-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` 和首页响应。
|
||||
@@ -1,54 +0,0 @@
|
||||
# 实现方案-2026-05-20-00-19-47
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-00-19-47.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
让右侧“逆向分割映射视图”的 Overlay Label Map 实时响应中部模型位姿,并将渲染形态从表面三角面投影升级为闭合实体 Mask 区域填充。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-20-00-19-47.md`
|
||||
- `工程分析/实现方案-2026-05-20-00-19-47.md`
|
||||
- `工程分析/测试方案-2026-05-20-00-19-47.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 在 `VoxelizationMappingView` 新增 `modelPose` 入参,父组件直接传入中部工具栏当前位姿。
|
||||
2. 新增浏览器端刚性变换函数:
|
||||
- 以全局 STL bounds 中心归一化顶点。
|
||||
- 按 `scale -> rotateX -> rotateY -> rotateZ -> translate` 顺序变换。
|
||||
- 将变换后坐标映射到固定 DICOM Overlay 画布坐标系。
|
||||
3. 将原先“靠近切片的三角面填充”改为“切片平面求交”:
|
||||
- 对每个三角面与当前 Z 平面求交。
|
||||
- 收集切面边界线段。
|
||||
4. 将边界线段按扫描线射线法光栅化为实心 Mask:
|
||||
- 逐行收集边界线段与水平扫描线的交点。
|
||||
- 采用奇偶配对填充闭合区域内部像素。
|
||||
- 按构件颜色与透明度写入 Overlay ImageData,并通过离屏画布合成,避免多构件互相覆盖。
|
||||
5. 对边界不完全闭合的极少数抽样场景,使用端点多边形保底填充,避免完全空白。
|
||||
6. 使用 `requestAnimationFrame` 合并高频位姿变化,做到位姿滑条拖动时实时刷新。
|
||||
7. 更新右侧状态文案,显示实心 Mask 像素数量和参与构件数量。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 不修改后端 API,不影响 DICOM preview、STL preview 和导出接口。
|
||||
- 如果新填充算法异常,可回退到上一版 `drawVoxelOverlayLayer`。
|
||||
- 如果某个构件没有形成闭合区域,只影响该构件 Overlay,不影响 Base Layer 和其他构件。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `ReverseWorkspace.tsx`:新增位姿变换、切面求交、Mask flood fill、Overlay rAF 重绘。
|
||||
- 新增本次三份工程文档。
|
||||
- 更新 `经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 执行 `npm run lint` 与 `npm run build`。
|
||||
- 重新部署 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 提交信息:`2026-05-20-00-19-47 同步位姿并填充实体映射`
|
||||
- 使用已验证的 Gitea 临时凭据方式推送。
|
||||
@@ -1,67 +0,0 @@
|
||||
# 实现方案-2026-05-20-00-38-39
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-00-38-39.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
将右侧“逆向分割映射视图”的坐标映射改为与左侧“三维融合视图”同源的 DICOM 物理 FOV,并强化 STL Mesh-Plane Intersection 与实体填充,减少尺度错位和截面漏隙。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-20-00-38-39.md`
|
||||
- `工程分析/实现方案-2026-05-20-00-38-39.md`
|
||||
- `工程分析/测试方案-2026-05-20-00-38-39.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 新增统一空间指标计算:
|
||||
- 从 DICOM `physicalSize.width/height` 和 `spacing.slice` 推导物理深度。
|
||||
- 复用左侧三维融合视图 `baseExtent=4.6` 的归一化策略。
|
||||
- 计算 `dicomWidth/dicomHeight/dicomDepth`、当前 slice 对应 Z 坐标、STL bounds 中心和 `modelBaseScale`。
|
||||
- 左侧三维视图也改为使用全部 STL bounds 作为全局模型尺度,隐藏构件只影响渲染,不再改变全局比例。
|
||||
2. 修改右侧 STL 顶点变换:
|
||||
- 按左侧三维层级顺序复现 `modelPivot` 和 `modelPoseGroup`。
|
||||
- 使用 `modelBaseScale * pose.scale`、旋转、平移和 pivot Z 偏移。
|
||||
3. 修改右侧画布坐标映射:
|
||||
- `x` 映射到 `[-dicomWidth/2, dicomWidth/2]`。
|
||||
- `y` 映射到 `[-dicomHeight/2, dicomHeight/2]`。
|
||||
- 画布宽高仍为 DICOM preview 像素,物理 FOV 与 Base Layer 完全一致。
|
||||
4. 提升 Overlay 数据精度:
|
||||
- 右侧 STL preview 请求改为 `200000` 上限,尽量使用完整网格。
|
||||
- 继续逐三角面执行 Mesh-Plane Intersection,得到截面线段。
|
||||
5. 强化实体填充:
|
||||
- 扫描线收集截面线段交点并进行内部填充。
|
||||
- 对填充结果做边界外 flood fill,自动补齐内部未填孔洞。
|
||||
- 使用离屏 canvas 合成每个构件,避免多构件透明区域互相擦除。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 更新右侧空间指标、位姿变换和点到画布映射函数。
|
||||
- 更新左侧三维视图的模型 bounds 计算,保证构件显隐不改变全局 FOV。
|
||||
- 更新 STL preview 请求 limit。
|
||||
- 更新实体填充函数,加入内部孔洞填充。
|
||||
- 执行 `npm run lint` 和 `npm run build`。
|
||||
- 重新部署并验证服务。
|
||||
- 追加经验记录并提交推送。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 不改变外部 API 和状态结构。
|
||||
- 若新 FOV 映射异常,可回退到上一版 `drawVoxelOverlayLayer` 的归一化映射。
|
||||
- 若最大 STL preview 请求导致性能压力,可降低右侧独立请求 limit,但保留同源 FOV 计算。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `ReverseWorkspace.tsx`:更新 FOV 坐标、模型变换、填充算法和请求精度。
|
||||
- 新增本次三份工程文档。
|
||||
- 更新 `经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 提交信息:`2026-05-20-00-38-39 对齐FOV并强化网格截面填充`
|
||||
- 显式暂存本次相关文件,避免提交历史删除状态。
|
||||
- 使用 Gitea origin/main 备份并重新部署 `revoxelseg-dicom`。
|
||||
@@ -1,56 +0,0 @@
|
||||
# 实现方案-2026-05-20-01-08-38
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-01-08-38.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
优化逆向工作区三栏空间分配,降低左侧三维融合视图宽度并扩大中部可视化工具栏,同时将模型平移步长精细化为 `0.005`。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-20-01-08-38.md`
|
||||
- `工程分析/实现方案-2026-05-20-01-08-38.md`
|
||||
- `工程分析/测试方案-2026-05-20-01-08-38.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 调整逆向工作区桌面端 grid 栅格:
|
||||
- 左侧 `lg:col-span-7` 改为 `lg:col-span-6`。
|
||||
- 中部 `lg:col-span-2` 改为 `lg:col-span-3`。
|
||||
- 右侧保持 `lg:col-span-3`。
|
||||
2. 修改 `poseStepConfig`:
|
||||
- `translateX/Y/Z.step` 从 `0.05` 改为 `0.005`。
|
||||
3. 新增或调整数值精度显示函数:
|
||||
- 根据 step 自动判断显示位数。
|
||||
- `0.005` 显示三位小数,旋转仍显示整数,缩放仍显示两位小数。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 修改 `ReverseWorkspace.tsx`。
|
||||
- 执行 `npm run lint`。
|
||||
- 执行 `npm run build`。
|
||||
- 重新部署 `tmux` 服务并验证。
|
||||
- 更新测试结果与经验记录。
|
||||
- 显式暂存本次文件并提交推送。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 不修改数据结构与 API。
|
||||
- 如布局不合适,可回退为原 `7 / 2 / 3` 栅格。
|
||||
- 如 `0.005` 过细,可只回调 `poseStepConfig` 的平移 step。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `ReverseWorkspace.tsx`:布局栅格、平移步长、数值显示精度。
|
||||
- 新增本次三份工程文档。
|
||||
- 更新 `经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 提交信息:`2026-05-20-01-08-38 调整工具栏布局与平移步长`
|
||||
- 显式暂存本次相关文件,避免提交历史删除状态。
|
||||
- 推送到 Gitea `origin/main` 并重新部署。
|
||||
@@ -1,71 +0,0 @@
|
||||
# 实现方案-2026-05-20-01-38-33
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-01-38-33.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
完善逆向工作区导出与位姿持久化:移除自动配准演示入口,新增可选导出 DICOM NIfTI、分割 NIfTI 和位姿 JSON,修复 blob 下载警告,增加三维坐标轴示意,并让保存位姿跨项目进入持久保留。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-20-01-38-33.md`
|
||||
- `工程分析/实现方案-2026-05-20-01-38-33.md`
|
||||
- `工程分析/测试方案-2026-05-20-01-38-33.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 后端扩展项目状态:
|
||||
- 增加 `modelPoses` 字段,保存默认、俯视、侧视与用户自定义位姿。
|
||||
- 新增 `PATCH /api/projects/:projectId/model-poses`。
|
||||
2. 后端重写导出:
|
||||
- 新增通用 NIfTI-1 写入函数。
|
||||
- DICOM 原始影像导出为 int16 HU `.nii.gz`,维度与 spacing 来自 DICOM。
|
||||
- 分割影像导出为 STL 构件采样切面填充后的 uint8 label map `.nii.gz`,维度与 spacing 与 DICOM 完全一致。
|
||||
- 位姿数据导出为 JSON,并记录导出时的当前 activePose。
|
||||
- 保留 `/export-mask` 兼容旧分割导出。
|
||||
3. 前端下载方式:
|
||||
- 用直链 `<a href>` 下载替代 `fetch -> Blob -> URL.createObjectURL`。
|
||||
- 新增导出选项复选框和“导出所选”动作,分割和位姿导出会携带当前模型位姿。
|
||||
4. UI 调整:
|
||||
- 去掉“开始自动配准”按钮和相关演示进度状态。
|
||||
- 顶部按钮改为“导出全部 NII.GZ”。
|
||||
- 三维融合视角右下角增加固定 XYZ 坐标轴示意。
|
||||
5. 位姿持久化:
|
||||
- 项目加载时读取 `project.modelPoses`。
|
||||
- 保存/重命名位姿时写回后端。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 更新类型定义和后端状态归一化。
|
||||
- 实现 NIfTI/位姿导出接口。
|
||||
- 更新前端 API 下载方法。
|
||||
- 更新 ReverseWorkspace UI、导出选项、位姿保存逻辑和坐标轴。
|
||||
- 执行 `npm run lint` 与 `npm run build`。
|
||||
- 验证新导出接口返回文件,重新部署并推送。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 旧 `downloadMask` 与 `/export-mask` 保持可用,只把底层分割 NIfTI 改为同维度数据。
|
||||
- 如果完整 DICOM NIfTI 导出耗时过高,可后续增加范围导出或后台任务。
|
||||
- 如果浏览器仍对 HTTP 下载提示安全警告,需要部署 HTTPS;本次先消除 blob URL 警告来源。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `server.ts`:项目位姿字段、DICOM NIfTI 导出、STL Label Map 导出、位姿导出、兼容旧导出。
|
||||
- `api.ts`:直链下载和导出选项 API。
|
||||
- `types.ts`:项目位姿类型。
|
||||
- `ReverseWorkspace.tsx`:移除自动配准、导出选项、坐标轴、位姿持久化。
|
||||
- 新增本次三份工程文档,更新经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 提交信息:`2026-05-20-01-38-33 完善NII导出与位姿持久化`
|
||||
- 显式暂存本次相关文件,避免提交历史删除状态。
|
||||
- 推送到 Gitea `origin/main` 并重新部署。
|
||||
@@ -1,59 +0,0 @@
|
||||
# 实现方案-2026-05-20-02-02-37
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-02-02-37.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
让“模型位姿”每一项都支持直接数字输入,并新增导入位姿数据功能,兼容当前导出的位姿 JSON,导入后同步当前位姿和保存位姿列表。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-20-02-02-37.md`
|
||||
- `工程分析/实现方案-2026-05-20-02-02-37.md`
|
||||
- `工程分析/测试方案-2026-05-20-02-02-37.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 增加位姿归一化辅助函数:
|
||||
- 对每个位姿字段按 `poseStepConfig` 执行数字校验、clamp 和默认值补齐。
|
||||
- 对导入的保存位姿列表补齐默认/俯视/侧视,并去重。
|
||||
2. 模型位姿数值输入:
|
||||
- 将原只读数值改成 `<input type="number">`。
|
||||
- 使用现有 step/min/max/precision。
|
||||
- 输入合法时立即更新 `modelPose`,继续触发三维与二维映射联动。
|
||||
3. 导入位姿数据:
|
||||
- 新增隐藏 file input 与“导入”按钮。
|
||||
- 读取 JSON,兼容 `{ activePose, modelPoses }`、`{ pose }`、直接 pose 对象三种形式。
|
||||
- 导入 `modelPoses` 时调用现有 `commitSavedPoses` 持久化。
|
||||
4. 反馈与容错:
|
||||
- 导入成功设置提示;导入失败设置 `fusionError`。
|
||||
- 清空 file input value,允许重复导入同一文件。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 更新 `ReverseWorkspace.tsx` 的 import、ref、辅助函数和 handlers。
|
||||
- 调整模型位姿 UI,将保存按钮组改为保存/导入两个操作。
|
||||
- 将数值展示替换为可编辑数字输入。
|
||||
- 运行 `npm run lint` 与 `npm run build`。
|
||||
- 重新部署并验证服务。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 旧滑条、加减按钮、保存位姿、重命名位姿逻辑继续保留。
|
||||
- 导入功能只读本地 JSON,不改后端接口。
|
||||
- 如导入格式异常,只提示错误,不覆盖当前状态。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `ReverseWorkspace.tsx`:位姿数字输入、导入按钮、JSON 解析与归一化。
|
||||
- 工程分析文档:新增本次三份文档并追加经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message:`2026-05-20-02-02-37 支持位姿输入与导入`
|
||||
- 显式暂存本次相关文件,避免提交历史删除状态。
|
||||
- 推送到 Gitea `origin/main` 后,沿用 `tmux` 会话 `revoxelseg-dicom` 重新部署。
|
||||
@@ -1,56 +0,0 @@
|
||||
# 实现方案-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` 重新部署。
|
||||
@@ -1,68 +0,0 @@
|
||||
# 实现方案-2026-05-20-02-32-47
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-02-32-47.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
将顶部“导出全部 NII.GZ”改为一次性下载 `.tar.gz` 导出包;分割影像导出时自动附带 label/category 映射 JSON,并支持“所有类别/可见类别”范围选择。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-20-02-32-47.md`
|
||||
- `工程分析/实现方案-2026-05-20-02-32-47.md`
|
||||
- `工程分析/测试方案-2026-05-20-02-32-47.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 后端扩展分割生成参数:
|
||||
- 增加 `SegmentationExportScope = all | visible`。
|
||||
- `createSegmentationData` 按 scope 决定是否跳过隐藏构件。
|
||||
2. 后端新增类别映射 JSON:
|
||||
- 根据 `project.stlFiles` 和 `project.moduleStyles` 生成 label/name/fileName/color/opacity/visible。
|
||||
- scope 为 visible 时只包含可见构件。
|
||||
3. 后端新增导出包接口:
|
||||
- `GET /api/projects/:projectId/export-bundle`
|
||||
- 参数:`targets=dicom,segmentation,pose`、`format=nii.gz`、`pose=...`、`segmentationScope=visible|all`
|
||||
- 使用现有 tar header 工具封装为 `.tar.gz`。
|
||||
4. 前端 API:
|
||||
- 新增 `downloadProjectExportBundle`。
|
||||
- 保留单个分割 NII/NII.GZ 下载兼容。
|
||||
5. 前端菜单:
|
||||
- “导出所选”改为下载单个压缩包。
|
||||
- 增加分割类别范围的 segmented control。
|
||||
- 仅选中分割影像时显示类别范围选项。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 新增后端 tar entry 通用函数和 bundle 生成函数。
|
||||
- 修改 NIfTI 分割生成函数支持 scope。
|
||||
- 新增 label map JSON 生成逻辑。
|
||||
- 调整前端 API 和导出菜单。
|
||||
- 执行 `npm run lint`、`npm run build`。
|
||||
- 用临时/正式服务验证 bundle 接口返回 `.tar.gz`,并检查包内文件名。
|
||||
- 追加经验记录、commit、push、部署。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- `/export-nifti` 与 `/export-mask` 保持可用。
|
||||
- 单独分割导出默认沿用 visible scope。
|
||||
- 若 bundle 导出异常,可回滚到多文件直链下载。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `server.ts`:新增 bundle、分割 scope、类别映射 JSON。
|
||||
- `api.ts`:新增 bundle 下载 API。
|
||||
- `ReverseWorkspace.tsx`:导出菜单增加 scope 选择并调用 bundle。
|
||||
- 工程分析文档:新增本次文档,更新经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message:`2026-05-20-02-32-47 支持NII导出包与分割类别范围`
|
||||
- 显式暂存本次相关文件,避免提交历史删除状态。
|
||||
- 推送到 Gitea `origin/main`,并使用 `tmux` 会话 `revoxelseg-dicom` 重新部署。
|
||||
@@ -1,51 +0,0 @@
|
||||
# 实现方案:完整 STL 网格导出与实体填充增强
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-02-55-11.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
修复大面数 STL 构件导出后在 ITK-SNAP 中呈散点的问题,使分割 NIfTI 的 Label Map 基于完整二进制 STL 三角面求交,并在每层生成连续实体区域。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/需求分析-2026-05-20-02-55-11.md`
|
||||
- `工程分析/实现方案-2026-05-20-02-55-11.md`
|
||||
- `工程分析/测试方案-2026-05-20-02-55-11.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 保留 `createStlPreview` 作为网页预览和 bounds 统计来源。
|
||||
2. 新增导出专用 STL 迭代逻辑,直接读取完整二进制 STL buffer 中的全部三角面,不再使用 `sampleLimit=200000` 的预览顶点数组作为 NIfTI 生成输入。
|
||||
3. 对每个三角面执行当前位姿变换、DICOM 物理坐标映射、Mesh-Plane Intersection 和扫描线光栅化。
|
||||
4. 将每个构件、每个 slice 的 rows 光栅化到临时 mask,再做四边 flood fill,填补闭合区域内部孔洞后写入最终 Label Map。
|
||||
5. 保持 `visible/all` 分割范围、labels JSON、导出包结构不变。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 阅读当前导出生成链路,确认抽样来源和填充缺口。
|
||||
2. 编写完整 STL 三角面读取辅助函数,避免长期缓存完整顶点数组。
|
||||
3. 替换 `createSegmentationData` 中遍历 `payload.vertices` 的逻辑。
|
||||
4. 增强后端 slice mask 的内部孔洞填补。
|
||||
5. 运行类型检查、构建、导出接口验证。
|
||||
6. 用脚本检查导出的 Label Map 体素分布,重点确认 `头部` label 不再只集中于少量散点。
|
||||
7. 追加经验记录、提交、推送并重新部署。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- API 参数和前端调用不变,外部使用者无需调整。
|
||||
- 若完整 STL 导出耗时不可接受,可在后续引入后台任务或空间索引;本次先保证导出结果正确。
|
||||
- 回滚时可恢复到上一个提交,但会重新暴露大面数构件抽样导致的散点问题。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `WebSite/server.ts`:新增完整 STL 迭代与 slice mask 填充逻辑。
|
||||
- `工程分析/*-2026-05-20-02-55-11.md`:记录本次工作流。
|
||||
- `工程分析/经验记录.md`:追加 A/B/C/D 经验。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次相关代码和工程分析文档。
|
||||
- commit message 包含 `2026-05-20-02-55-11` 和简要描述。
|
||||
- 推送到 Gitea 后使用 `tmux` 会话 `revoxelseg-dicom` 重新部署。
|
||||
@@ -1,62 +0,0 @@
|
||||
# 实现方案:导出与项目库分割结果闭环
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-03-19-25.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
补齐原始 STL 导出、分割结果保存到项目库、项目库复用导出包功能、工作区布局与右侧构件提示优化,并新增系统使用说明 `Agent.md`。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `Agent.md`
|
||||
- `工程分析/需求分析-2026-05-20-03-19-25.md`
|
||||
- `工程分析/实现方案-2026-05-20-03-19-25.md`
|
||||
- `工程分析/测试方案-2026-05-20-03-19-25.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 后端导出目标新增 `stl`,在 `/export-bundle` 中将 `Head_CT_ReConstruct` 下的原始 STL 文件写入同一个 tar.gz。
|
||||
2. 项目状态新增 `segmentationResults`,保存当前分割结果的名称、时间、位姿、分割范围与构件样式快照。
|
||||
3. 新增 `POST /api/projects/:projectId/segmentation-results`,供逆向工作区 `保存至项目库` 调用。
|
||||
4. 项目库 `分割结果` 视图读取保存记录,并提供与逆向工作区一致的导出内容选择、分割范围选择和压缩包下载。
|
||||
5. 将默认/推荐位姿加入前后端默认位姿列表,逆向工作区加载项目时优先选择 `最佳位姿`。
|
||||
6. 工作区三栏布局从 `6/3/3` 调整为更偏向工具栏与映射视图的比例,压缩左侧融合视角。
|
||||
7. `OverlayStats` 增加当前 slice 的构件明细,图例仅显示当前切片中实际有边或像素的构件,并用紧凑表格呈现。
|
||||
8. 编写 `Agent.md`,包含系统使用方式和 500~1300 字主要功能说明。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 增加类型定义和后端状态归一化。
|
||||
2. 扩展导出包生成逻辑与 API 参数解析。
|
||||
3. 增加项目库保存接口与前端 API。
|
||||
4. 修改逆向工作区按钮、布局、右侧图例。
|
||||
5. 修改项目库分割结果区域和导出菜单。
|
||||
6. 编写 `Agent.md`。
|
||||
7. 运行 lint、build、接口导出验证、保存接口验证、部署验证。
|
||||
8. 追加经验记录、提交、推送、重新部署。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 原有导出目标 `dicom/segmentation/pose` 保持兼容。
|
||||
- `stl` 为新增可选目标,默认可不选以避免包过大。
|
||||
- 旧项目没有 `segmentationResults` 时归一化为空数组。
|
||||
- 回滚到上一提交会移除项目库保存结果和 STL 导出选项,但不影响原始医学数据。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 后端状态、导出和保存接口。
|
||||
- 前端 API、类型、逆向工作区与项目库 UI。
|
||||
- 新增 `Agent.md`。
|
||||
- 工程分析与经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次相关文件。
|
||||
- commit message 包含 `2026-05-20-03-19-25` 和简要说明。
|
||||
- 推送 Gitea 后使用 `tmux` 会话 `revoxelseg-dicom` 重启服务并验证。
|
||||
@@ -1,52 +0,0 @@
|
||||
# 实现方案:取消最佳位姿与软著材料生成
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-11-07-27.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
修正产品逻辑,取消“最佳位姿”的默认位姿和优先加载行为;按 `※撰写Agent.md` 流程生成软著材料到 `新撰写软著文档/`,且软著材料不提交到 Gitea。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `新撰写软著文档/`
|
||||
- `工程分析/需求分析-2026-05-20-11-07-27.md`
|
||||
- `工程分析/实现方案-2026-05-20-11-07-27.md`
|
||||
- `工程分析/测试方案-2026-05-20-11-07-27.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 后端 `defaultModelPoses()` 移除 `best/最佳位姿` 默认项。
|
||||
2. 前端 `ReverseWorkspace` 移除 `headCtBestPose` 与 `defaultSavedPoses` 中的最佳位姿,并取消加载项目时优先选择最佳位姿/位姿2的逻辑,恢复默认位姿优先。
|
||||
3. 读取 `参考软著构建模板/` 三份样例,学习章节结构、登记表字段和代码汇总格式。
|
||||
4. 分析 README、前后端入口、核心页面、导出与体素化逻辑,整理软著说明书、登记表和代码汇总。
|
||||
5. 创建 `新撰写软著文档/`,写入三份 Markdown、系统使用视频脚本/分镜、素材清单。
|
||||
6. 优先使用 Pandoc 生成 docx;若 Pandoc 不存在,则尝试使用 `python-docx` 或 LibreOffice 可行方案。
|
||||
7. 运行 lint、build、部署验证。
|
||||
8. 工程分析和代码修正提交到 Git/Gitea;软著输出目录不暂存、不提交。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 取消最佳位姿不影响用户已保存的自定义位姿。
|
||||
- 如用户后续需要导入 `head-ct-demo-pose-data.json`,仍可通过位姿导入入口使用。
|
||||
- 回滚代码提交可恢复最佳位姿逻辑,但与用户当前要求相反。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改:
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/经验记录.md`
|
||||
- 新增:
|
||||
- 本次工程分析三份文档。
|
||||
- `新撰写软著文档/` 下软著交付材料(不提交)。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Git 仅暂存代码修正和工程分析文档。
|
||||
- 不暂存 `新撰写软著文档/`、`head-ct-demo-pose-data.json`、`※撰写Agent.md` 等软著素材。
|
||||
- commit message 包含 `2026-05-20-11-07-27` 和简要描述。
|
||||
- 推送 Gitea 若网络不可达,记录失败原因并保留本地 commit。
|
||||
- 重新部署项目并验证服务。
|
||||
@@ -1,68 +0,0 @@
|
||||
# 实现方案:软著说明书图文化与代码汇总扩容
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-11-34-57.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
将软著说明书从初稿扩展为更完整的图文说明书,插入当前系统真实截图;将代码汇总 Markdown 扩展至至少 1600 行,同时保留软著材料不进入 Gitea 的约束。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `新撰写软著文档/1. 软著说明书.md`
|
||||
- `新撰写软著文档/1. 软著说明书.docx`
|
||||
- `新撰写软著文档/3. 代码汇总.md`
|
||||
- `新撰写软著文档/3. 代码汇总.docx`
|
||||
- `新撰写软著文档/images/`
|
||||
- `新撰写软著文档/功能验证与素材清单.md`
|
||||
- `工程分析/需求分析-2026-05-20-11-34-57.md`
|
||||
- `工程分析/实现方案-2026-05-20-11-34-57.md`
|
||||
- `工程分析/测试方案-2026-05-20-11-34-57.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 阅读 `※撰写Agent.md` 和现有软著初稿,确认说明书、截图、Word、代码汇总要求。
|
||||
2. 使用当前部署服务打开系统页面,通过自动化截图生成登录页、概况页、项目库、DICOM 查看、逆向工作区、系统管理等图片。
|
||||
3. 重写并扩展软著说明书 Markdown,将图片以 `` 形式插入对应章节。
|
||||
4. 从 `WebSite/src` 和 `WebSite/server.ts` 中抽取核心代码,生成至少 1600 行的 `3. 代码汇总.md`。
|
||||
5. 使用 `python-docx` 生成或覆盖说明书和代码汇总 Word,保证图片嵌入、代码文本可编辑。
|
||||
6. 使用 `unzip -t`、`wc -l`、图片引用检查验证交付物。
|
||||
7. 本次仅将工程分析文档与经验记录提交 Gitea,不提交 `新撰写软著文档/`。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 检查本地服务与截图工具可用性。
|
||||
- 生成截图到 `新撰写软著文档/images/`。
|
||||
- 扩写 `1. 软著说明书.md`。
|
||||
- 扩充 `3. 代码汇总.md` 至不少于 1600 行。
|
||||
- 重新生成 `1. 软著说明书.docx` 和 `3. 代码汇总.docx`。
|
||||
- 更新素材清单。
|
||||
- 运行文档检查和必要的项目构建/健康检查。
|
||||
- 精确暂存本次工程分析文档和经验记录,提交并推送。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 软著材料位于本地输出目录,不影响应用运行。
|
||||
- 若自动化截图失败,可保留已有文档并记录失败原因;但目标仍是完成真实截图插入。
|
||||
- 若 Word 生成失败,保留 Markdown 和图片作为可恢复源文件。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
本地软著材料:
|
||||
|
||||
- 更新说明书 Markdown/Word。
|
||||
- 更新代码汇总 Markdown/Word。
|
||||
- 新增截图图片。
|
||||
- 更新素材清单。
|
||||
|
||||
版本库备份:
|
||||
|
||||
- 新增本次工程分析三份文档。
|
||||
- 追加 `工程分析/经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 不暂存 `新撰写软著文档/`。
|
||||
- 不暂存 `参考软著构建模板/`、`※撰写Agent.md`、`head-ct-demo-pose-data.json`。
|
||||
- commit message 包含 `2026-05-20-11-34-57` 和简要描述。
|
||||
- 推送 Gitea 后执行或确认当前服务部署健康。
|
||||
@@ -1,60 +0,0 @@
|
||||
# 实现方案:默认医学数据资产入库
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-11-51-05.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
将默认项目依赖的 `Head_CT_DICOM/` 和 `Head_CT_ReConstruct/` 纳入 Git/Gitea,使新环境克隆仓库后能够直接加载默认演示项目。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `.gitignore`
|
||||
- `.gitattributes`
|
||||
- `Head_CT_DICOM/`
|
||||
- `Head_CT_ReConstruct/`
|
||||
- `工程分析/需求分析-2026-05-20-11-51-05.md`
|
||||
- `工程分析/实现方案-2026-05-20-11-51-05.md`
|
||||
- `工程分析/测试方案-2026-05-20-11-51-05.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 统计两个默认数据目录的文件数量与体积,记录入测试方案。
|
||||
2. 调整 `.gitignore`,移除 `Head_CT_DICOM/` 和 `Head_CT_ReConstruct/` 的忽略规则。
|
||||
3. 新增 `.gitattributes`,将 `*.dcm` 和 `*.stl` 作为二进制文件处理。
|
||||
4. 使用 `git add` 精确暂存默认数据目录、配置文件和本次工程分析文档。
|
||||
5. 检查暂存内容,确认不包含软著材料、历史删除状态或无关文件。
|
||||
6. 提交并推送到 Gitea。
|
||||
7. 构建并重新部署项目,验证默认项目接口返回 DICOM/STL 数量正常。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 读取 `工程分析/经验记录.md` 后执行最终修改。
|
||||
- 使用 `du`、`find`、`git status` 检查数据目录状态。
|
||||
- 修改 `.gitignore` 与 `.gitattributes`。
|
||||
- 运行 `npm run build`。
|
||||
- 验证 `/api/health`、首页和 `/api/projects/head-ct-demo`。
|
||||
- 精确提交并推送。
|
||||
- 重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 目录名保持不变,后端默认项目扫描逻辑无需修改。
|
||||
- 回滚时可恢复 `.gitignore` 忽略规则,并从 Git 历史中移除或停止跟踪默认数据;但历史大文件仍会存在于旧提交中。
|
||||
- 若 Gitea push 因大小限制失败,应保留本地 commit 并记录失败原因,再考虑 Git LFS、压缩包发布或对象存储。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改 `.gitignore`。
|
||||
- 新增 `.gitattributes`。
|
||||
- 新增 300 个 DICOM 文件。
|
||||
- 新增 9 个 STL 文件。
|
||||
- 新增本次工程分析三份文档。
|
||||
- 追加 `工程分析/经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- commit message 包含 `2026-05-20-11-51-05` 和“默认医学数据入库”描述。
|
||||
- 推送默认远端 `origin/main`。
|
||||
- 软著相关目录 `新撰写软著文档/` 不暂存、不提交。
|
||||
- 部署优先使用 `tmux` 会话 `revoxelseg-dicom` 与 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
@@ -1,70 +0,0 @@
|
||||
# 实现方案:说明书逐章配图与系统视频生成
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-12-09-47.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
为软著说明书每个二级章节配置真实系统截图,并录制一段包含位姿导入流程的系统使用视频。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `新撰写软著文档/1. 软著说明书.md`
|
||||
- `新撰写软著文档/1. 软著说明书.docx`
|
||||
- `新撰写软著文档/images/`
|
||||
- `新撰写软著文档/系统使用视频/`
|
||||
- `新撰写软著文档/功能验证与素材清单.md`
|
||||
- `head-ct-demo-pose-data.json`
|
||||
- `工程分析/需求分析-2026-05-20-12-09-47.md`
|
||||
- `工程分析/实现方案-2026-05-20-12-09-47.md`
|
||||
- `工程分析/测试方案-2026-05-20-12-09-47.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 统计说明书 `## X.` 章节数量,建立章节与截图文件映射。
|
||||
2. 使用 Chrome DevTools Protocol 自动打开系统、登录、切换页面、点击控件、打开弹窗和导入位姿。
|
||||
3. 使用软件 WebGL 参数录制或截取逆向工作区,避免三维视图空白。
|
||||
4. 生成 23 张章节截图到 `新撰写软著文档/images/`。
|
||||
5. 将每个 `## X.` 章节下插入对应图片引用。
|
||||
6. 使用截图序列和 `ffmpeg` 生成系统使用视频 MP4,视频中包含 `head-ct-demo-pose-data.json` 位姿导入状态。
|
||||
7. 重新生成说明书 Word,确保所有图片嵌入。
|
||||
8. 更新素材清单,记录截图与视频。
|
||||
9. 运行检查、构建和部署验证。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 检查 `ffmpeg`、Chrome 和系统服务状态。
|
||||
- 生成或刷新章节截图。
|
||||
- 自动化导入 `head-ct-demo-pose-data.json` 并截取/录制导入后的位姿状态。
|
||||
- 更新说明书 Markdown 和 Word。
|
||||
- 验证图片引用、Word 媒体数量、视频文件信息。
|
||||
- 工程分析文档与经验记录提交并推送。
|
||||
- 重新部署并验证服务。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 所有软著输出在本地目录,可通过历史 Markdown 或重新运行截图脚本恢复。
|
||||
- 若视频编码失败,保留截图序列并记录失败原因。
|
||||
- 若某个截图页面加载不完整,重新等待或切换到软件 WebGL 模式。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
软著本地材料:
|
||||
|
||||
- `新撰写软著文档/1. 软著说明书.md`
|
||||
- `新撰写软著文档/1. 软著说明书.docx`
|
||||
- `新撰写软著文档/images/*.png`
|
||||
- `新撰写软著文档/系统使用视频/系统使用视频.mp4`
|
||||
- `新撰写软著文档/功能验证与素材清单.md`
|
||||
|
||||
Gitea 备份:
|
||||
|
||||
- 本轮工程分析三份文档。
|
||||
- `工程分析/经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 不暂存 `新撰写软著文档/` 和 `head-ct-demo-pose-data.json`。
|
||||
- 仅暂存本轮工程分析文档与经验记录。
|
||||
- commit message 包含 `2026-05-20-12-09-47`。
|
||||
- 推送后重启 `tmux` 会话 `revoxelseg-dicom` 并验证服务。
|
||||
@@ -1,64 +0,0 @@
|
||||
# 实现方案:完整页面截图替换与下载内容核查
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-12-29-06.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
把软著说明书每个章节配图替换为未裁剪的完整页面截图,并核查说明书中涉及下载、导出和文件保存的内容。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `新撰写软著文档/1. 软著说明书.md`
|
||||
- `新撰写软著文档/1. 软著说明书.docx`
|
||||
- `新撰写软著文档/images/chapter-*.png`
|
||||
- `新撰写软著文档/功能验证与素材清单.md`
|
||||
- `工程分析/需求分析-2026-05-20-12-29-06.md`
|
||||
- `工程分析/实现方案-2026-05-20-12-29-06.md`
|
||||
- `工程分析/测试方案-2026-05-20-12-29-06.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 使用全文搜索定位说明书中的“下载”“导出”“保存”“NII.GZ”等相关表述。
|
||||
2. 使用 Chrome DevTools Protocol 自动化打开系统并截取完整视口截图。
|
||||
3. 对登录、总体概况、项目库、DICOM、STL、分割结果、逆向工作区、导出选项、系统管理、退出等章节生成对应完整图。
|
||||
4. 保持 Markdown 图片引用路径不变,仅覆盖图片文件内容。
|
||||
5. 重新生成 `1. 软著说明书.docx`,保证 23 张图片嵌入 Word。
|
||||
6. 更新素材清单,标注图片为完整页面截图。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 检查当前服务是否可访问。
|
||||
- 运行自动化截图脚本生成 23 张完整页面截图。
|
||||
- 对 DICOM 信息截图做必要脱敏。
|
||||
- 重新生成 Word。
|
||||
- 验证章节数、图片数、图片尺寸、Word 媒体数量和下载相关文本命中结果。
|
||||
- 构建与重新部署项目。
|
||||
- 提交工程分析文档与经验记录并推送到 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若某个截图失败,可重新运行自动化截图脚本覆盖对应图片。
|
||||
- 若 Word 生成失败,保留 Markdown 和图片,可再次用 `python-docx` 生成。
|
||||
- 若下载相关内容需要后续删除,可基于本轮搜索结果进一步调整说明书正文。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
本地软著材料:
|
||||
|
||||
- `新撰写软著文档/images/chapter-*.png`
|
||||
- `新撰写软著文档/1. 软著说明书.docx`
|
||||
- `新撰写软著文档/功能验证与素材清单.md`
|
||||
|
||||
Gitea 备份:
|
||||
|
||||
- 本轮工程分析三份文档。
|
||||
- `工程分析/经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 不提交 `新撰写软著文档/`。
|
||||
- 不提交 `head-ct-demo-pose-data.json`。
|
||||
- 仅暂存本轮工程分析文档与经验记录。
|
||||
- commit message 包含 `2026-05-20-12-29-06`。
|
||||
- 重新部署后验证 `/api/health` 和首页。
|
||||
@@ -1,79 +0,0 @@
|
||||
# 实现方案:逆向分割结果单结果化与入口统一
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-14-19-23.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
调整项目库与逆向工作区的逆向分割结果保存、展示、读取和导出交互,使结果只保留一个、命名统一、入口集中、退出有保存提醒。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/需求分析-2026-05-20-14-19-23.md`
|
||||
- `工程分析/实现方案-2026-05-20-14-19-23.md`
|
||||
- `工程分析/测试方案-2026-05-20-14-19-23.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 后端保存分割结果接口改为覆盖保存,只保留最新一条记录。
|
||||
2. 项目归一化时保留最新一条 `segmentationResults`,兼容旧状态中已有多条记录。
|
||||
3. 扩展 `SegmentationResult` 存储必要的切片范围、当前映射切片、显示/融合配置等上下文,用于重新进入工作区恢复。
|
||||
4. 逆向工作区加载项目时读取最新保存结果,恢复位姿、构件样式、分割范围和映射切片。
|
||||
5. `App.tsx` 接入工作区离开保护:离开逆向工作区时调用工作区注册的保存确认逻辑。
|
||||
6. 项目库页签改名为“逆向分割结果”,内容改为两张视图预览卡片和右侧导出面板。
|
||||
7. 顶部导出文案统一改为“导出项目及结果”,并将保存按钮移动到旁边。
|
||||
8. 删除映射视图标题旁边的 `NII`、`NII.GZ` 小下载按钮。
|
||||
9. 保存成功后显示顶部悬浮提示并渐隐消失。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 阅读现有类型、API、后端保存接口、项目库和逆向工作区组件结构。
|
||||
- 更新类型定义与后端归一化/保存逻辑。
|
||||
- 更新 API payload 和前端保存调用。
|
||||
- 更新项目库 UI 与导出按钮文案。
|
||||
- 更新逆向工作区顶部操作区和映射视图标题区。
|
||||
- 接入退出保存确认和进入工作区结果恢复。
|
||||
- 运行类型/构建检查、服务部署验证。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 后端归一化兼容旧的多条 `segmentationResults`,只取最后一条作为当前结果。
|
||||
- 若保存上下文字段缺失,前端继续使用默认位姿、默认切片范围和默认显示配置。
|
||||
- 若导出按钮菜单异常,可继续调用现有 `/api/projects/:projectId/export-bundle` 后端接口。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
程序文件:
|
||||
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/server.ts`
|
||||
|
||||
工程分析:
|
||||
|
||||
- 本轮三份分析文档。
|
||||
- `工程分析/经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 暂存本轮程序改动与工程分析文档。
|
||||
- 避免提交软著材料、运行态导出文件、旧历史文档删除状态。
|
||||
- commit message 包含 `2026-05-20-14-19-23`。
|
||||
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom` 并验证服务。
|
||||
|
||||
## 实际实现记录
|
||||
|
||||
- 后端 `segmentationResults` 归一化改为只保留最新一条,并扩展保存切片、显示、边界与切割状态字段。
|
||||
- 导出接口默认使用最新保存结果的位姿、构件样式和类别范围。
|
||||
- 逆向工作区进入时恢复最新保存结果,退出时注册保存确认守卫。
|
||||
- 项目库“逆向分割结果”改为两张预览图加右侧统一导出面板。
|
||||
- 顶部保存按钮迁移到导出按钮旁,并加入保存成功悬浮渐隐提示。
|
||||
@@ -1,83 +0,0 @@
|
||||
# 实现方案:逆向结果复核增强与管理功能修复
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-14-53-31.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
增强项目库逆向分割结果的复核交互,补齐逆向工作区映射视图 DICOM 控制,优化离开保存提示逻辑,修复起始页标题和系统管理用户操作。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/components/Login.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/UserManagement.tsx`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/需求分析-2026-05-20-14-53-31.md`
|
||||
- `工程分析/实现方案-2026-05-20-14-53-31.md`
|
||||
- `工程分析/测试方案-2026-05-20-14-53-31.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 检查登录页、主入口、项目库、逆向工作区和用户管理现有实现。
|
||||
2. 后端项目状态归一化时默认项目不再继承旧保存结果,确保初始逆向分割结果为空。
|
||||
3. 项目库逆向分割结果页复用 `NativeStlViewer` 提供可拖拽融合视图,固定精细模型显示和 DICOM 高融合语义;DICOM+分割卡片加入横向 Slice Navigator、DICOM 显示模式和旋转控制。
|
||||
4. 逆向工作区给映射视图加入 DICOM 显示模式与旋转状态,并用该状态请求/渲染预览。
|
||||
5. 逆向工作区保存后建立状态快照,只有当前可视化工具栏状态与快照不同才注册离开确认;确认文本改成单行语义。
|
||||
6. 起始页标题顺序调整;根地址访问时按最初起始页处理共享会话。
|
||||
7. 用户管理前后端补齐新增/更新/删除接口能力,前端禁止删除当前用户。
|
||||
8. 运行类型检查、构建、服务部署与接口验证。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 阅读相关源码与 API 封装。
|
||||
- 编写项目状态、API 与用户管理后端补丁。
|
||||
- 编写项目库和逆向工作区 UI/交互补丁。
|
||||
- 修复登录页标题与主入口初始会话策略。
|
||||
- 更新测试方案实际执行记录和经验记录。
|
||||
- 精确暂存本轮文件,提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 旧项目若已有保存结果,保留用户保存结果;默认演示项目初始状态按空结果处理。
|
||||
- 用户管理新增接口只操作 `WebSite/data/state.json` 中用户列表,不影响 DICOM/STL 原始数据。
|
||||
- 若项目库预览异常,仍可进入逆向工作区重新保存结果并导出。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
程序文件:
|
||||
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/components/Login.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/UserManagement.tsx`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/server.ts`
|
||||
|
||||
工程分析:
|
||||
|
||||
- 本轮三份分析文档。
|
||||
- `工程分析/经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 暂存本轮程序改动与工程分析文档。
|
||||
- 避免提交软著材料、历史删除状态和运行态导出文件。
|
||||
- commit message 包含 `2026-05-20-14-53-31`。
|
||||
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom`,验证 4000 端口服务。
|
||||
|
||||
## 实际实现记录
|
||||
|
||||
- 项目库逆向分割结果页新增结果 DICOM 显示模式、旋转控制和横向 Slice Navigator。
|
||||
- 项目库融合视角改为可拖拽结果位姿预览,并固定使用精细模型语义。
|
||||
- 后端分割结果记录增加 `schemaVersion`,过滤旧演示结果,默认项目初始结果为空;新保存结果继续持久化。
|
||||
- 逆向工作区映射视图新增默认/骨窗/软组织/高对比和左转/右转控制。
|
||||
- 逆向工作区新增保存快照判断,仅在保存相关状态变化后离开才弹窗。
|
||||
- 根页面启动时清理共享会话,显示起始登录页;登录页标题改为全称在上、简称在下。
|
||||
- 系统管理补齐用户新增、编辑、改密、删除接口和前端表单,并禁止删除当前登录用户。
|
||||
@@ -1,55 +0,0 @@
|
||||
# 实现方案:复用逆向工作区真实视图与拆分密码编辑
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-15-20-15.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
让项目库逆向分割结果页直接复用逆向工作区的三维融合和二维映射视图,实现显示效果一致;同时细化用户编辑弹窗,分离资料编辑和密码修改。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/UserManagement.tsx`
|
||||
- `工程分析/需求分析-2026-05-20-15-20-15.md`
|
||||
- `工程分析/实现方案-2026-05-20-15-20-15.md`
|
||||
- `工程分析/测试方案-2026-05-20-15-20-15.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 将逆向工作区内部 `FusionThreeView`、`VoxelizationMappingView`、`displayOptions`、`dicomOpacityOptions` 导出为可复用对象。
|
||||
2. 项目库逆向结果页新增 fusion volume 状态和加载逻辑,按保存结果的切片范围请求 `api.getDicomFusionVolume`。
|
||||
3. 项目库结果页替换简化 `NativeStlViewer` 和静态 DICOM Canvas,改为直接渲染 `FusionThreeView` 与 `VoxelizationMappingView`。
|
||||
4. 保留项目库二维映射视图的 DICOM 模式、旋转和 Slice Navigator 控制,将状态传入复用组件。
|
||||
5. 用户管理表单增加字段标签;编辑模式移除密码输入框;密码模式使用新密码/确认新密码双输入校验。
|
||||
6. 运行类型检查、构建、部署和接口验证。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 阅读当前项目库和逆向工作区视图组件。
|
||||
- 调整逆向工作区组件导出。
|
||||
- 更新项目库视图导入、fusion volume 加载和 JSX 结构。
|
||||
- 更新用户管理弹窗表单和校验。
|
||||
- 更新测试记录、经验记录。
|
||||
- 精确暂存、提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 如果项目库 fusion volume 加载失败,复用组件仍显示原有错误/空状态。
|
||||
- 逆向工作区原组件调用方式保持不变,只增加导出能力。
|
||||
- 用户密码后端接口保持不变,前端只增加确认密码约束。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/UserManagement.tsx`
|
||||
- 本轮工程分析文档与 `工程分析/经验记录.md`
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 暂存本轮相关代码和工程分析文档。
|
||||
- 不提交软著材料、历史删除状态、运行态导出文件。
|
||||
- commit message 包含 `2026-05-20-15-20-15`。
|
||||
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom` 并验证服务。
|
||||
@@ -1,51 +0,0 @@
|
||||
# 实现方案:右侧竖向 Slice Navigator 与影像遮挡治理
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-15-33-38.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
调整逆向分割映射视图布局:将切片导航从影像底部迁移到右侧竖向栏,并把 Overlay Label Map 的构件统计面板从影像内部移到影像下方,避免遮挡 DICOM 影像。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-20-15-33-38.md`
|
||||
- `工程分析/实现方案-2026-05-20-15-33-38.md`
|
||||
- `工程分析/测试方案-2026-05-20-15-33-38.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 定位 `VoxelizationMappingView` 中当前底部 `Slice Navigator` 与影像内 Overlay 状态面板。
|
||||
2. 将组件整体布局改为横向网格:左侧影像与独立信息区,右侧竖向切片导航栏。
|
||||
3. 保留顶部 `Base DICOM`、`Overlay Label Map`、`Z` 状态标签,但缩小为影像角落提示,避免大面积遮挡。
|
||||
4. 将构件统计列表改为影像下方独立面板,显示当前 Overlay 状态、构件数、边数、像素数和当前切片构件表。
|
||||
5. 为竖向 range 增加内联样式或类名,使用 `writingMode: 'vertical-rl'` 与 `direction: 'rtl'` 实现从上到下浏览切片。
|
||||
6. 执行类型检查、构建、部署验证。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 阅读当前 `VoxelizationMappingView` JSX 与相关样式。
|
||||
- 修改布局结构和样式。
|
||||
- 检查 `mapping-slice-input` 是否有全局 CSS 影响,必要时增加独立竖向类。
|
||||
- 运行 `npm run lint` 和 `npm run build`。
|
||||
- 重启 `tmux` 服务并验证健康接口和首页。
|
||||
- 更新测试方案与经验记录。
|
||||
- 精确暂存、提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 该改动只影响前端布局,不改变数据结构和 API。
|
||||
- 如竖向 `range` 在某浏览器显示异常,可回滚为右侧竖排按钮加数值输入,或补充 WebKit 专用 CSS。
|
||||
- 项目库复用同一组件,因此无需额外复制样式逻辑。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- 本轮工程分析文档与 `工程分析/经验记录.md`
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 暂存本轮相关代码和工程分析文档。
|
||||
- commit message 包含 `2026-05-20-15-33-38`。
|
||||
- 推送 Gitea 后重启 `revoxelseg-dicom` 服务,验证 `http://127.0.0.1:4000/api/health` 与首页。
|
||||
@@ -1,65 +0,0 @@
|
||||
# 实现方案:逆向工作区控件外置、视图复位与手动拉伸
|
||||
|
||||
实现方案文档路径:`工程分析/实现方案-2026-05-20-15-54-46.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
优化逆向工作区整体布局和交互:右侧二维映射控件白底外置,支持缩放/平移/复位;左侧三维融合视图支持复位;主布局给右侧更多空间并底部对齐;位姿工具栏新增受角度约束的模型等比例拉伸。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/index.css`
|
||||
- `工程分析/需求分析-2026-05-20-15-54-46.md`
|
||||
- `工程分析/实现方案-2026-05-20-15-54-46.md`
|
||||
- `工程分析/测试方案-2026-05-20-15-54-46.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 调整逆向工作区主网格比例,左侧略窄,右侧加宽;统一设置三列拉伸高度,减少底部错位。
|
||||
2. 改造 `VoxelizationMappingView`:
|
||||
- 顶部图层状态改成白底信息条;
|
||||
- 右侧 `DICOM 切片位置` 控件改成白底卡片,风格接近 `DICOM 切片范围`;
|
||||
- 保留竖向切片条并外置于影像右侧;
|
||||
- 添加滚轮缩放、鼠标拖拽平移和“位置重置”按钮;
|
||||
- Base DICOM 与 Overlay Canvas 共用同一 transform。
|
||||
3. 改造 `FusionThreeView`:
|
||||
- 新增 `viewResetToken` 属性;
|
||||
- 在 Three.js 场景内部保存 camera/controls 引用,token 变化时恢复相机初始位置;
|
||||
- 在视图右侧或角落添加“位置重置”按钮入口。
|
||||
4. 位姿工具栏新增模型等比例拉伸按钮:
|
||||
- 判断 `rotateX/Y/Z` 是否均为 90 度整数倍;
|
||||
- 在可用时提供 X/Y/Z 方向拉伸按钮;
|
||||
- 拉伸仅调整 `modelPose.scale`,作为手动校正,不默认自动执行。
|
||||
5. 保持保存快照和导出语义使用现有 `modelPose.scale`,不新增后端结构。
|
||||
6. 运行 lint/build,部署验证,并提交 Gitea。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 阅读 `ReverseWorkspace.tsx` 中主布局、`FusionThreeView`、`VoxelizationMappingView`、位姿工具栏实现。
|
||||
- 修改二维映射视图布局和交互状态。
|
||||
- 修改三维融合视图复位接口和按钮。
|
||||
- 修改主布局比例和位姿拉伸按钮。
|
||||
- 运行 `npm run lint` 与 `npm run build`。
|
||||
- 重新部署 `tmux` 服务并验证健康接口和首页。
|
||||
- 更新测试方案与经验记录。
|
||||
- 精确暂存本轮相关文件,commit 并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 二维缩放和平移仅影响 CSS transform,可直接回滚到原 `rotation` transform。
|
||||
- 三维复位按钮只触发相机状态,不改变数据,可安全移除。
|
||||
- 手动拉伸只复用既有 `scale` 字段,不改变保存结果结构;若用户需要按轴非等比例拉伸,可后续新增独立 scaleX/Y/Z 字段。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/index.css`
|
||||
- 本轮工程分析文档与 `工程分析/经验记录.md`
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 暂存本轮相关代码和工程分析文档。
|
||||
- commit message 包含 `2026-05-20-15-54-46`。
|
||||
- 推送到 Gitea 后重新部署并验证 `http://127.0.0.1:4000/api/health` 与首页。
|
||||
@@ -1,58 +0,0 @@
|
||||
# 实现方案-2026-05-20-21-25-19
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-21-25-19.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
1. 优化项目库“逆向分割结果”中的三维融合视图默认/复位视角,使复位后呈现用户示意的正向融合观察效果。
|
||||
2. 为 `VoxelizationMappingView` 增加项目库紧凑展示模式,去掉无意义标签,把窗宽按钮、复位按钮、视图标题、DICOM 切片位置和竖向导航重新布局。
|
||||
3. 调整项目库结果区左右面板高度与底部对齐。
|
||||
4. 为逆向工作区增加加载中页面,展示加载进度、加载速度和当前阶段,数据未就绪时不显示完整工作区。
|
||||
5. 增加 DICOM 融合体与 STL 预览数据的内存缓存,减少跨模块返回后的重复加载。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/index.css`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 在 `ReverseWorkspace.tsx` 中增加模块级缓存 Map,用于缓存 DICOM fusion volume、STL preview、映射视图预览数据。
|
||||
- 为 `FusionThreeView` 增加 `variant` 或复位视角参数,项目库复用同一组件但使用较适合结果复核的默认视角。
|
||||
- 为 `VoxelizationMappingView` 增加 `variant="workspace" | "library"`,项目库模式采用黑色画布、精简标题和窄导航栏;工作区保持完整操作信息。
|
||||
- 用加载指标组合逆向工作区所需数据状态,未完成时显示独立加载卡片。
|
||||
- 调整项目库逆向结果布局为等高网格,并保证左右图容器底部一致。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 阅读现有 `ProjectLibrary.tsx`、`ReverseWorkspace.tsx` 的视图复用和数据加载逻辑。
|
||||
2. 写入本次需求、实现、测试方案文档。
|
||||
3. 修改融合视图与映射视图组件参数,补齐紧凑模式和复位视角。
|
||||
4. 增加数据缓存与加载页,避免跨页面返回时重复拉取大数据。
|
||||
5. 调整项目库结果区布局和控件位置。
|
||||
6. 执行类型检查、构建、服务重启与健康检查。
|
||||
7. 追加经验记录并提交推送到 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 新增 UI 变体保持默认值为工作区模式,避免影响已有调用。
|
||||
- 缓存仅保存在浏览器内存中,刷新页面会自然清空;若出现旧数据问题,可删除缓存分支回退为原请求逻辑。
|
||||
- 如果紧凑模式影响项目库结果展示,可只回退 `variant="library"` 调用,不影响逆向工作区主流程。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改 `ReverseWorkspace.tsx`:组件参数、缓存、加载页、视图布局。
|
||||
- 修改 `ProjectLibrary.tsx`:调用参数、布局对齐、控件位置。
|
||||
- 视需要修改 `index.css`:竖向导航样式微调。
|
||||
- 新增本次三份工程分析文档,并追加经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次修改的源码和本次工程分析文档。
|
||||
- commit message 使用 `2026-05-20-21-25-19 项目库结果视图与加载缓存优化`。
|
||||
- 推送到 `http://192.168.31.5:5002/admin/REVOXELSEG_DICOM.git` 的 `main` 分支。
|
||||
- 使用 `tmux` 会话 `revoxelseg-dicom` 重新部署并验证。
|
||||
@@ -1,57 +0,0 @@
|
||||
# 实现方案-2026-05-20-22-07-46
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-22-07-46.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
1. 后端导出压缩包文件名改成 `项目名_时间.tar.gz`,兼容中文项目名。
|
||||
2. `VoxelizationMappingView` 支持把 Overlay Label Map 摘要放到右侧下方或底部下方。
|
||||
3. 项目库使用右侧 Overlay 摘要布局;逆向工作区使用项目库风格的黑底工具行和右侧切片导航,但 Overlay 仍在下方。
|
||||
4. 项目库“逆向分割结果”摘要只显示构件总数、最后保存时间和模型位姿。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/index.css`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 在后端新增项目名/时间文件名格式化函数,并用于 bundle 下载响应头。
|
||||
- 在 `VoxelizationMappingView` 中抽出 Overlay 摘要渲染片段,通过 `variant` 与布局参数区分项目库和逆向工作区。
|
||||
- 逆向工作区把窗宽、旋转和位置重置按钮传入映射视图内部,外层只保留模块标题或直接交给组件渲染。
|
||||
- 项目库结果摘要卡片移除切片和类别范围字段,增加位姿字段的紧凑网格。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 定位 `server.ts` 导出 bundle 的文件名生成逻辑。
|
||||
2. 定位 `ProjectLibrary.tsx` 结果摘要和映射视图调用逻辑。
|
||||
3. 调整 `ReverseWorkspace.tsx` 的映射视图 `variant`、工具栏和 Overlay 位置。
|
||||
4. 更新样式文件中的暗色竖向滑块或新增必要类名。
|
||||
5. 执行 `npm run lint`、`npm run build`。
|
||||
6. 重启 `tmux` 服务并验证 `/api/health` 与页面响应。
|
||||
7. 追加经验记录,提交并推送到 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 导出文件名只改响应头,不改变压缩包内部结构,回滚时恢复旧 `filename` 即可。
|
||||
- 视图布局通过组件参数控制,若项目库右侧摘要不合适,可回退为下方摘要或隐藏摘要。
|
||||
- 逆向工作区仍使用同一 `VoxelizationMappingView`,回滚风险集中在一个组件。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改 `server.ts` 导出文件名。
|
||||
- 修改 `ReverseWorkspace.tsx` 和 `ProjectLibrary.tsx` 视图布局与摘要字段。
|
||||
- 视需要修改 `index.css` 滑块样式。
|
||||
- 新增三份工程分析文档,追加 `经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次相关源码和本次工程分析文档。
|
||||
- commit message 使用 `2026-05-20-22-07-46 导出命名与映射视图摘要优化`。
|
||||
- 推送到 Gitea `main` 分支。
|
||||
- 构建后重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
@@ -1,61 +0,0 @@
|
||||
# 实现方案-2026-05-20-22-35-42
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-22-35-42.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 精简 Overlay Label Map 状态文案。
|
||||
- 导出项目及结果支持分割整体/分构件导出,并调整默认勾选与排序。
|
||||
- 为项目库 DICOM/STL 导入按钮接入真实上传流程,空项目展示导入提示。
|
||||
- 项目级上传资产独立存储,恢复演示环境仍使用默认数据。
|
||||
- 用户账号重名冲突给出可读反馈。
|
||||
- 逆向工作区无保存结果时初次加载默认执行 Z 轴等比例拉伸。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/UserManagement.tsx`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 后端新增项目上传资产目录 `WebSite/data/uploads/<projectId>/dicom|stl`,导入接口接收 base64 文件并写入项目专属目录。
|
||||
2. 后端统一项目 DICOM/STL 路径解析,预览、三维融合、模型文件、导出和分割生成均读取项目路径。
|
||||
3. 导出包接口增加 `segmentationExportMode=combined|separate` 参数;整体模式沿用现有 label map,分构件模式逐个构件生成独立 NII.GZ。
|
||||
4. 前端导出面板增加分割导出方式单选,调整默认选择和选项顺序。
|
||||
5. 项目库导入按钮使用隐藏文件输入,按当前视图选择 DICOM 或 STL 上传,并刷新项目状态。
|
||||
6. 用户管理编辑接口 409 时展示“账号已存在,请更换账号”等提示。
|
||||
7. 逆向工作区加载项目后,若没有保存结果,则在 fusion volume 和模型边界都就绪后触发一次 Z 拉伸。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 编写本次需求分析、实现方案、测试方案。
|
||||
2. 阅读并定位导出、导入、项目路径、用户管理和逆向工作区相关源码。
|
||||
3. 修改后端资产导入与导出逻辑。
|
||||
4. 修改前端项目库、逆向工作区和用户管理交互。
|
||||
5. 执行 `npm run lint` 与 `npm run build`。
|
||||
6. 使用 tmux 重启 `revoxelseg-dicom` 服务并验证 `/api/health` 与首页。
|
||||
7. 追加经验记录,提交并推送本次相关文件。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 未导入资产的默认项目继续读取 `Head_CT_DICOM/` 与 `Head_CT_ReConstruct/`。
|
||||
- 已保存的项目路径由状态文件保存,恢复演示环境可通过现有重置接口回到默认路径。
|
||||
- 若分构件导出失败,可回退为 `segmentationExportMode=combined` 的原有整体导出。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 前端:3 个组件与 API 封装。
|
||||
- 后端:1 个 Express 服务文件。
|
||||
- 工程文档:本次三件套与经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 仅暂存本次修改的源码与工程分析文档。
|
||||
- commit message 包含 `2026-05-20-22-35-42` 与简要描述。
|
||||
- 提交后推送到既有 Gitea 远端,并重启 4000 端口服务。
|
||||
@@ -1,58 +0,0 @@
|
||||
# 实现方案-2026-05-20-23-28-51
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-20-23-28-51.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 修复位姿按钮首次点击导致中部可视化工具栏滚动上跳。
|
||||
- 统一项目库与逆向工作区竖向 Slice Navigator 滑块居中样式。
|
||||
- 将项目库 Overlay Label Map 摘要从映射视图右侧黑色面板改为导出按钮下方浅色信息格。
|
||||
- 调整逆向分割结果状态徽标与标题同行。
|
||||
- 导入已有 DICOM/STL 时增加覆盖确认。
|
||||
- DICOM 导入时生成并保存详细信息缓存,详情接口优先读取缓存。
|
||||
- 项目库逆向分割页导出按钮移到顶部操作区。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/index.css`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. `VoxelizationMappingView` 新增 Overlay 统计回调和 `overlayPlacement='none'`,项目库使用外置浅色 Overlay 面板。
|
||||
2. 在项目库 mask 视图顶部操作区渲染“导出项目及结果”按钮和下拉菜单,删除下方旧导出入口。
|
||||
3. 调整竖向 range input CSS,使可点击层覆盖轨道但 thumb 视觉中心与轨道中心对齐。
|
||||
4. 为位姿微调按钮增加 `onMouseDown preventDefault`,并在位姿更新时保持工具栏滚动位置。
|
||||
5. 导入入口根据项目现有数据量弹出覆盖确认,再唤起文件选择。
|
||||
6. 后端 DICOM 导入后调用现有 DICOM 信息解析逻辑,将结果写入上传目录缓存文件;`/dicom-info` 优先读取缓存。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 写入本次需求、实现、测试方案。
|
||||
2. 定位映射视图、项目库 mask 页、位姿控件、CSS range 样式和导入接口。
|
||||
3. 修改前端组件结构与样式。
|
||||
4. 修改后端 DICOM 信息缓存逻辑。
|
||||
5. 执行 `npm run lint`、`npm run build`。
|
||||
6. 重启 `revoxelseg-dicom` tmux 服务并验证 `/api/health` 与首页。
|
||||
7. 追加经验记录,提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若外置 Overlay 面板出现异常,可将项目库 `overlayPlacement` 改回 `side`。
|
||||
- DICOM 信息缓存失败时不阻断默认详情接口解析,可回退到实时解析。
|
||||
- 导入覆盖确认只在前端增加,后端接口仍保持原有覆盖写入能力。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 前端组件 2 个,样式 1 个,后端 1 个,工程分析文档 4 个。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次相关文件,忽略历史删除和软著未跟踪文件。
|
||||
- commit message 包含 `2026-05-20-23-28-51` 与简要描述。
|
||||
- 推送 Gitea 后重启 4000 服务。
|
||||
@@ -1,57 +0,0 @@
|
||||
# 实现方案-2026-05-21-00-05-04
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-21-00-05-04.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 项目库导入 DICOM/STL 时显示上传进度、文件数量和体积。
|
||||
- 将项目资产导入从 base64 JSON 改为 multipart 表单上传,降低浏览器内存占用。
|
||||
- 后端接收普通文件和压缩包,并在服务端展开 ZIP/TAR/TAR.GZ/TGZ/GZ。
|
||||
- 导入完成后继续刷新项目状态、清理缓存、保留覆盖确认逻辑。
|
||||
- 对 3D 模型导入崩溃原因给出技术修复:避免 `Promise.all(fileToBase64)` 和巨型 JSON body。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/package.json`
|
||||
- `WebSite/package-lock.json`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 引入 `multer` 接收 multipart 文件,使用内存上传缓冲后统一写入项目资产目录。
|
||||
2. 引入 `adm-zip` 解压 ZIP;TAR/TAR.GZ/TGZ 使用 Node `zlib` 与简单 tar header 解析;单文件 GZ 解压为原文件。
|
||||
3. 后端新增导入文件展开函数,统一过滤 DICOM/STL 目标扩展名,并防止路径穿越。
|
||||
4. 前端新增 `uploadProjectAssets`,使用 `XMLHttpRequest.upload.onprogress` 返回上传百分比。
|
||||
5. 项目库增加导入进度状态和紧凑进度条,上传完成后显示服务器解析/解压阶段。
|
||||
6. 文件选择器 accept 增加 `.zip,.tar,.tar.gz,.tgz,.gz`。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 写入需求、实现、测试方案。
|
||||
2. 安装 multipart 和 zip 解析依赖。
|
||||
3. 修改后端导入接口,兼容新的 multipart 上传。
|
||||
4. 修改前端 API 封装和项目库导入 UI。
|
||||
5. 执行 `npm run lint`、`npm run build`。
|
||||
6. 验证健康检查、首页响应和示例项目状态。
|
||||
7. 追加经验记录,提交推送 Gitea,重新部署服务。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若 multipart 上传出现问题,可临时保留 JSON body 解析分支作为兼容回退。
|
||||
- 若压缩包解析失败,应返回明确错误,不写入半成品项目状态。
|
||||
- 如新增依赖不可用,可回退为仅支持普通文件和 TAR.GZ,ZIP 支持后续补齐。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 前端 API 1 个、项目库组件 1 个、后端服务 1 个、依赖清单 2 个、工程分析文档 4 个。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次相关代码和工程分析文档。
|
||||
- commit message 包含 `2026-05-21-00-05-04` 与“导入进度与压缩包支持”。
|
||||
- 重新构建并使用 `tmux` 会话 `revoxelseg-dicom` 运行 4000 服务。
|
||||
@@ -1,51 +0,0 @@
|
||||
# 实现方案-2026-05-21-00-43-44
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-21-00-43-44.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 复现 `3279-STL.zip` 导入时的 422 错误,获取具体失败原因。
|
||||
- 修复 ZIP 内 STL 解压/筛选/写入逻辑,使该压缩包可导入。
|
||||
- 优化导入失败 UI,将错误状态和原因显示在顶部导入进度区域。
|
||||
- 补充测试记录和经验记录。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 使用 `curl -F files=@3279-STL.zip` 对现有接口复现错误,查看响应体。
|
||||
2. 检查 `AdmZip` 解包结果、ZIP 条目大小和写入逻辑。
|
||||
3. 如果问题来自大 ZIP 一次性 `entry.getData()` 导致内存或写入失败,则改为按条目流式/逐条写入目标目录,减少内存峰值。
|
||||
4. 如果问题来自文件名扩展名、编码或路径处理,则修正筛选和安全文件名逻辑。
|
||||
5. 前端导入状态增加 `failed` phase,错误时保留进度卡并展示错误文案。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 创建本次工程分析文档。
|
||||
2. 本地复现 422,记录响应。
|
||||
3. 修改后端 ZIP 导入逻辑和前端错误反馈。
|
||||
4. 执行 `npm run lint`、`npm run build`。
|
||||
5. 用 `3279-STL.zip` 对临时项目实际导入验证。
|
||||
6. 追加经验记录,提交推送 Gitea,重新部署。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若 `AdmZip` 对该 ZIP 仍不稳定,可增加系统 `unzip` fallback 或改用更适合流式解压的库。
|
||||
- 前端错误卡只影响导入反馈,不影响其他页面。
|
||||
- 导入失败时不更新项目状态,避免半成品资产进入项目库。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 后端 1 个、前端组件 1 个、工程分析文档 4 个。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 不提交 `3279-STL.zip` 和解压资产。
|
||||
- commit message 包含 `2026-05-21-00-43-44` 与“修复 ZIP STL 导入失败”。
|
||||
- 部署后验证 `/api/health`、首页以及 ZIP 导入结果。
|
||||
61
工程分析/实现方案-2026-05-24-10-09-37.md
Normal file
61
工程分析/实现方案-2026-05-24-10-09-37.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 实现方案-2026-05-24-10-09-37
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-10-09-37.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 精简 `工程分析/` 中历史 `需求分析-*`、`实现方案-*`、`测试方案-*` 等流水文档。
|
||||
- 保留核心工程约束、整体分析、经验记录和本次流程记录。
|
||||
- 更新工作流,明确历史三件套不再作为长期堆积物,长期经验以 `经验记录.md` 为准。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `工程分析/代码编纂工作流.md`
|
||||
- `工程分析/需求分析-2026-05-24-10-09-37.md`
|
||||
- `工程分析/实现方案-2026-05-24-10-09-37.md`
|
||||
- `工程分析/测试方案-2026-05-24-10-09-37.md`
|
||||
- `工程分析/经验记录.md`
|
||||
- 历史 `工程分析/需求分析-*.md`
|
||||
- 历史 `工程分析/实现方案-*.md`
|
||||
- 历史 `工程分析/测试方案-*.md`
|
||||
- `工程分析/系统功能描述-2026-05-23-00-32-26.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 按既有工作流创建本次三件套文档。
|
||||
2. 更新 `代码编纂工作流.md`,加入工程分析目录精简策略。
|
||||
3. 删除历史三件套和一次性说明文档,保留核心文档、本次三件套和经验记录。
|
||||
4. 追加经验记录,记录本次目录治理策略。
|
||||
5. 做静态检查、构建检查和部署验证。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 记录开始时间并阅读工作流、整体分析、经验记录。
|
||||
2. 写入本次需求分析、实现方案和测试方案。
|
||||
3. 再次确认已读 `经验记录.md`。
|
||||
4. 执行历史文档删除,只保留核心文件与本次三件套。
|
||||
5. 更新工作流中的精简策略。
|
||||
6. 执行 `npm run lint` 和 `npm run build`。
|
||||
7. 沿用或重启 `tmux` 服务并验证本机与公网入口。
|
||||
8. 暂存本次文档治理变更,提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 删除的历史文档仍可通过 Git 历史恢复。
|
||||
- 若后续需要某次详细需求,可按 commit 或文件名从历史版本查找。
|
||||
- 本次不修改业务源码,程序回滚风险低。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 删除大量历史 `需求分析-*`、`实现方案-*`、`测试方案-*` 文档。
|
||||
- 删除一次性的 `系统功能描述-2026-05-23-00-32-26.md`。
|
||||
- 新增本次三件套。
|
||||
- 更新 `代码编纂工作流.md` 与 `经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 提交信息:`2026-05-24-10-09-37 精简工程分析文档`。
|
||||
- 提交范围限定为 `工程分析/` 文档治理变更。
|
||||
- 部署验证沿用项目约定:`npm run build` 后确认当前 `tmux`/frpc 服务可访问。
|
||||
68
工程分析/实现方案-2026-05-24-10-45-43.md
Normal file
68
工程分析/实现方案-2026-05-24-10-45-43.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 实现方案-2026-05-24-10-45-43
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-10-45-43.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 定位并修正分割映射显示为空心线段的问题,使二维映射区域默认填充显示。
|
||||
- 为模型位姿增加 X/Y/Z 镜像翻转开关,并贯通保存、导入、导出、前后端类型和渲染。
|
||||
- 修改“构件分别导出”包结构,将所有构件文件集中到同一目录层级。
|
||||
- 为项目库 DICOM 首页预览增加滚轮缩放、拖拽平移和位置重置。
|
||||
- 修正 DICOM 切片编号显示与滑块样式。
|
||||
- 同步更新 `Docker部署/README.md`,说明容器构建会包含这些前后端能力和生产模式注意事项。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/server.ts`
|
||||
- `Docker部署/README.md`
|
||||
- `工程分析/需求分析-2026-05-24-10-45-43.md`
|
||||
- `工程分析/实现方案-2026-05-24-10-45-43.md`
|
||||
- `工程分析/测试方案-2026-05-24-10-45-43.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 使用 `rg` 定位分割映射、DICOM 预览、位姿控件、导出包逻辑。
|
||||
2. 梳理现有 `ModelPoseValue` 与服务端导出函数,增加镜像字段和默认值。
|
||||
3. 在前端三维/二维变换路径中使用统一位姿对象,新增镜像按钮组。
|
||||
4. 调整二维分割映射绘制逻辑,优先使用闭合轮廓填充;对 sparse/edge-only 结果提供填充兜底。
|
||||
5. 修改导出包 `separate` 模式的目录结构。
|
||||
6. 为项目库 DICOM 画布加入 viewport state:scale、offset、dragging、reset。
|
||||
7. 封装或局部实现 DICOM 切片显示编号反向映射与暗色菱形 range 样式。
|
||||
8. 更新 Docker 部署说明。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 创建本次三件套,最终执行前再次阅读 `经验记录.md`。
|
||||
2. 阅读相关源码和 API,确认现有数据结构。
|
||||
3. 修改类型、服务端和前端实现。
|
||||
4. 更新 Docker 部署文档。
|
||||
5. 执行 `npm run lint`、`npm run build`。
|
||||
6. 重新部署并验证本机、公网、关键 API。
|
||||
7. 追加经验记录。
|
||||
8. 提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 新增镜像字段设置默认值,旧状态文件和旧位姿导出没有该字段时按不翻转处理。
|
||||
- 导出包结构改变只影响 `separate` 模式;如需回滚,可恢复服务端包路径生成逻辑。
|
||||
- DICOM 显示缩放/拖动仅改变前端视图,不改变后端切片、导出或空间基准。
|
||||
- 所有历史文档可从 Git 历史恢复。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 前端组件和类型文件。
|
||||
- 后端导出、位姿解析和体素坐标变换逻辑。
|
||||
- Docker 部署说明。
|
||||
- 本次工程分析与经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- commit message:`2026-05-24-10-45-43 修正分割显示镜像导出与DICOM交互`
|
||||
- 提交范围包含本次源码、Docker 文档和工程分析文档。
|
||||
- 部署使用 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000`,并保留现有 frpc 容器公网入口。
|
||||
58
工程分析/实现方案-2026-05-24-15-37-16.md
Normal file
58
工程分析/实现方案-2026-05-24-15-37-16.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 实现方案-2026-05-24-15-37-16
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-15-37-16.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 将项目库 DICOM 影像位置重置按钮移动/补充到左转、右转按钮组旁。
|
||||
- 对前端逆向分割映射的切面线段进行连通分组,避免不同组件或孤立碎片被同一行扫描线错误配对。
|
||||
- 对服务端导出 Label Map 使用同样的连通分组策略,减少不规则相连线。
|
||||
- 增加小缝隙闭合处理,缓解实体区域内部横向黑线。
|
||||
- 保留对开口/非流形模型的保守说明。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/需求分析-2026-05-24-15-37-16.md`
|
||||
- `工程分析/实现方案-2026-05-24-15-37-16.md`
|
||||
- `工程分析/测试方案-2026-05-24-15-37-16.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 项目库 DICOM 画布由父组件下发 reset token,让外部按钮触发内部 viewport 复位。
|
||||
2. 前端 overlay 按切面线段端点近邻关系分组,每个连通组独立扫描线填充。
|
||||
3. 对每个连通组的 mask 做小 gap closing,再做内部孔洞填充。
|
||||
4. 服务端 NIfTI 生成路径复用同样的分组、逐组填充、gap closing 和兜底逻辑。
|
||||
5. 对孤立或开口线段组件减少跨组件配对,避免导出长线桥接。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 创建当次三件套。
|
||||
2. 再次确认已读 `工程分析/经验记录.md`。
|
||||
3. 修改项目库 DICOM 控件。
|
||||
4. 修改前端 overlay 填充算法。
|
||||
5. 修改服务端导出算法。
|
||||
6. 执行 `npm run lint` 和 `npm run build`。
|
||||
7. 重新部署并验证本机、公网、导出包。
|
||||
8. 追加经验记录,提交并推送。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- UI reset token 只影响 DICOM 预览视窗状态,不改变 DICOM 数据。
|
||||
- 连通分组只改变轮廓填充方式,不改变项目状态结构。
|
||||
- 若新填充策略过度保守,可回滚到上一提交 `f279770` 的扫描线逻辑。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 前端组件和后端导出逻辑。
|
||||
- 本次工程分析文档和经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- commit message:`2026-05-24-15-37-16 修正mask线条桥接与DICOM复位入口`
|
||||
- 部署沿用 `tmux` 会话 `revoxelseg-dicom`,生产模式端口 `4000`。
|
||||
62
工程分析/实现方案-2026-05-24-15-55-48.md
Normal file
62
工程分析/实现方案-2026-05-24-15-55-48.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 实现方案-2026-05-24-15-55-48
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-15-55-48.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 在逆向工作区构件层级标题右侧增加全局显隐按钮。
|
||||
- 在项目库项目卡片中增加锁定/解锁能力,并将锁定位姿数据保存到 `项目数据/锁定结果/`。
|
||||
- 项目列表支持未锁定筛选,并按最后处理时间倒序排列。
|
||||
- 修正 DICOM 切片滚动条的方向与无进度色视觉。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/index.css`
|
||||
- `Docker部署/`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 后端为项目状态增加 `locked`、`lockedAt`、`lastProcessedAt`、`lockedPoseSnapshotPath` 等兼容字段,并提供锁定/解锁 API。
|
||||
- 锁定时读取当前项目位姿、构件样式和基础元数据,写入 `项目数据/锁定结果/{projectName或projectId}-{timestamp}.json`。
|
||||
- 前端项目库使用新增 API 切换锁定状态;项目卡片在进入逆向工作区前判断锁定状态并提示。
|
||||
- 逆向工作区构件层级右侧新增全局眼睛按钮,批量更新所有构件的 `visible` 字段。
|
||||
- 切片滑块保持顶部为 1、底部为 N,去掉滚动后轨道进度色。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 阅读现有项目类型、项目库、逆向工作区和后端项目 API。
|
||||
2. 扩展类型与 API 封装。
|
||||
3. 实现后端锁定状态归一化、锁定结果落盘和接口。
|
||||
4. 实现项目库锁定按钮、未锁定筛选、排序与锁定提示。
|
||||
5. 实现逆向工作区全局构件显隐按钮。
|
||||
6. 修正切片滚动条方向和 CSS。
|
||||
7. 同步 Docker 部署说明。
|
||||
8. 运行 lint/build,重新部署并验证。
|
||||
9. 提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 旧项目缺少锁定字段时默认 `locked=false`,缺少最后处理时间时回退到 `updatedAt`、`createdAt`。
|
||||
- 如锁定结果目录不存在则由后端自动创建。
|
||||
- 回滚时可移除新增 API 和前端按钮,已有锁定 JSON 文件不影响系统启动。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增本次三份工程分析文档。
|
||||
- 修改前后端项目状态、交互和样式文件。
|
||||
- 修改 Docker 部署说明,记录新增锁定结果目录和功能。
|
||||
- 追加经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 仅暂存本次相关文档、前后端源码和 Docker 文档。
|
||||
- Commit message 包含 `2026-05-24-15-55-48` 与简要描述。
|
||||
- 使用 `tmux` 会话 `revoxelseg-dicom` 重新运行 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
59
工程分析/实现方案-2026-05-24-16-28-58.md
Normal file
59
工程分析/实现方案-2026-05-24-16-28-58.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 实现方案-2026-05-24-16-28-58
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-16-28-58.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 重启并验证公网服务。
|
||||
- 修正 `visible` 导出范围被历史保存结果覆盖的问题。
|
||||
- 改进二维 overlay 和后端导出填充,减少实体区域内部条纹和薄结构线条感。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `Docker部署/README.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 先使用 `tmux` 重启 `revoxelseg-dicom`,验证本机与公网入口。
|
||||
- 排查导出链路,确认 `projectWithSegmentationResultStyles`、`segmentationScope` 与当前 `moduleStyles.visible` 的优先级。
|
||||
- 导出时让当前项目样式覆盖历史分割结果样式,确保最新眼睛状态参与过滤。
|
||||
- 必要时在前端导出请求中显式传递当前 `moduleStyles`,后端读取并归一化,避免状态同步延迟。
|
||||
- 对填充后的 mask 做轻量闭合与孤立小孔修补,前端 overlay 与后端 NIfTI 保持一致策略。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 已重启服务并验证公网、本机 HTTP 200。
|
||||
2. 阅读当前导出和填充代码,定位可见构件过滤来源。
|
||||
3. 修正导出样式优先级或新增导出样式参数。
|
||||
4. 改进实体化填充策略,重点避免可见大块区域内部横向空线。
|
||||
5. 同步 Docker 文档和经验记录。
|
||||
6. 执行 `npm run lint`、`npm run build`。
|
||||
7. 用项目 `123` 执行可见类别分别导出,检查 tar 列表只包含可见构件。
|
||||
8. 重启部署,验证公网与本机。
|
||||
9. 提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 如果前端未传 `moduleStyles`,后端回退到项目当前样式与最新结果合并。
|
||||
- 如果实体化修补导致过度连接,可回滚到仅小缝闭合和按连通组填充。
|
||||
- 回滚时移除新增参数读取即可,不影响旧导出 API。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增本次三份工程分析文档。
|
||||
- 修改后端导出样式选择与 mask 修补。
|
||||
- 修改前端导出请求参数。
|
||||
- 修改 Docker 部署说明与经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次相关源码、Docker 文档和工程分析文档。
|
||||
- Commit message 包含 `2026-05-24-16-28-58`。
|
||||
- 部署使用 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
53
工程分析/实现方案-2026-05-24-17-29-17.md
Normal file
53
工程分析/实现方案-2026-05-24-17-29-17.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 实现方案-2026-05-24-17-29-17
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-17-29-17.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 为逆向分割映射视图增加可见 STL 构件加载进度条。
|
||||
- 修正 DICOM/映射画布滚轮缩放和触摸操作中的 passive event listener 警告。
|
||||
- 同步 Docker 部署说明与本次交互修正。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `Docker部署/README.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 在逆向分割映射视图中新增 overlay 加载状态,记录 `loading/loaded/total/phase`。
|
||||
- 可见构件列表变化时清空旧预览并按构件逐个更新加载进度;加载完成后再绘制最新 overlay。
|
||||
- 将需要阻止页面滚动的 `wheel` 缩放改为原生 `addEventListener('wheel', ..., { passive: false })`,避免 React 合成事件被浏览器按 passive 处理时产生告警。
|
||||
- 移除按钮触摸开始阶段不必要的 `event.preventDefault()`,用 `touch-action` 类维持操作稳定性。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 定位 `preventDefault` 和 `onWheel/onTouchStart` 使用点。
|
||||
2. 为项目库 DICOM 画布和逆向映射画布改造 wheel 事件监听。
|
||||
3. 为逆向映射视图增加加载进度状态和 UI。
|
||||
4. 更新 Docker 部署说明。
|
||||
5. 执行类型检查、构建、部署和访问验证。
|
||||
6. 提交并推送到 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 新增进度条只依赖现有 STL preview API,不改变接口契约。
|
||||
- 如 wheel 原生监听出现兼容问题,可回退到 React `onWheel` 但必须移除 `preventDefault` 或继续保持非被动监听。
|
||||
- 回滚时可撤销本次 commit,重新构建并部署上一版本。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 2 个前端组件文件。
|
||||
- 1 个 Docker 部署说明文件。
|
||||
- 3 个工程分析当次文档。
|
||||
- 1 个经验记录追加。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message 使用 `2026-05-24-17-29-17 增加构件加载进度并修正滚轮事件`。
|
||||
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom`,使用生产模式启动服务。
|
||||
- 验证本机和公网入口可访问。
|
||||
56
工程分析/实现方案-2026-05-24-18-59-49.md
Normal file
56
工程分析/实现方案-2026-05-24-18-59-49.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 实现方案-2026-05-24-18-59-49
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-18-59-49.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 修正 rib/skin 等薄壳或细长 STL 在逆向分割映射视图中的点状、线状显示。
|
||||
- 前端 overlay 与服务端导出共用更强的实体化兜底策略。
|
||||
- 部署后让当前项目重新使用新算法实时生成映射结果。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `Docker部署/README.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 在已有连通组扫描线填充基础上增加“切面实体兜底”:
|
||||
- 对每个构件、每个切片、每个连通组独立处理。
|
||||
- 正常闭合轮廓继续使用扫描线填充。
|
||||
- 对面积过小、仅点线、薄壳或断续线段的组,按线段周围生成局部厚度,并对端点/边界做圆形或方形膨胀。
|
||||
- 最后做小缝隙闭合与内部孔洞填充。
|
||||
- 同步服务端 NIfTI 导出,让下载结果与界面展示一致。
|
||||
- 保持当前高精度 STL preview 与加载进度逻辑。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 定位前端 overlay 填充和后端导出填充函数。
|
||||
2. 抽取或补充局部 thick stroke / dilation 兜底函数。
|
||||
3. 在前端 `drawVoxelOverlayLayer` 的每个连通组处理中应用实体兜底。
|
||||
4. 在后端导出 mask 生成中应用相同实体兜底。
|
||||
5. 执行类型检查、构建、部署和访问验证。
|
||||
6. 提交并推送到 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 修改只影响显示与导出 mask 生成,不影响项目状态结构和上传数据。
|
||||
- 如发现膨胀过强,可通过减小兜底半径回滚到更保守的实体化。
|
||||
- 回滚可直接撤销本次 commit 并重新部署。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 2 个核心代码文件。
|
||||
- 1 个 Docker 部署说明文件。
|
||||
- 3 个工程分析当次文档。
|
||||
- 1 个经验记录追加。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message 使用 `2026-05-24-18-59-49 修正薄壳构件实体化映射`。
|
||||
- 通过 `npm run lint` 与 `npm run build` 后重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 验证本机与公网入口可访问。
|
||||
58
工程分析/实现方案-2026-05-24-19-11-30.md
Normal file
58
工程分析/实现方案-2026-05-24-19-11-30.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 实现方案-2026-05-24-19-11-30
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-19-11-30.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 将平移 X/Y/Z 和缩放的控件步进统一改为 `0.001`。
|
||||
- 保证项目库和逆向工作区两处位姿控件同步。
|
||||
- 保证显示格式和状态精度支持三位小数。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `Docker部署/README.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 检查现有 `poseStepConfig`、项目库位姿控件数组和格式化函数。
|
||||
- 将 `translateX/translateY/translateZ/scale` 的 `step`、按钮文案、快捷 delta 调整为 `0.001`。
|
||||
- 为平移字段补充三位小数精度处理,避免浮点累积或显示截断。
|
||||
- 服务端保存和读取模型位姿时,对平移和缩放执行三位小数归一化。
|
||||
- 保持旋转字段不变。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 定位所有平移和缩放步进配置。
|
||||
2. 修改逆向工作区位姿控件。
|
||||
3. 修改项目库位姿控件。
|
||||
4. 修改服务端位姿归一化精度。
|
||||
5. 更新 Docker 部署说明与经验记录。
|
||||
6. 运行类型检查和构建。
|
||||
7. 重新部署并验证服务。
|
||||
8. 提交并推送到 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 位姿字段结构不变,只改变前端输入步进和精度。
|
||||
- 已保存的旧位姿仍可正常读取。
|
||||
- 如需回滚,恢复本次 commit 并重新部署即可。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 2 个前端组件文件。
|
||||
- 1 个服务端文件。
|
||||
- 1 个 Docker 部署说明文件。
|
||||
- 3 个工程分析当次文档。
|
||||
- 1 个经验记录追加。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message 使用 `2026-05-24-19-11-30 调整位姿微调步进为0.001`。
|
||||
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 验证本机和公网入口。
|
||||
48
工程分析/实现方案-2026-05-24-19-17-13.md
Normal file
48
工程分析/实现方案-2026-05-24-19-17-13.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 实现方案-2026-05-24-19-17-13
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-19-17-13.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 自动拉伸成功后不再写入“已按 X/Y/Z 方向进行三维等比例拉伸”类持久状态。
|
||||
- 保留 `stretchingAxis` 按钮加载状态与 `fusionError` 错误提示。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `Docker部署/README.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 定位 `applyModelStretchByAxis` 中成功分支的 `setPoseImportStatus`。
|
||||
- 成功更新 `scale` 后清空 `poseImportStatus`,避免残留成功文案。
|
||||
- 不修改 `catch` 与前置校验中的 `setFusionError`。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 检查自动拉伸按钮与状态提示的调用路径。
|
||||
2. 修改自动拉伸成功后的状态处理。
|
||||
3. 运行类型检查与构建。
|
||||
4. 重新部署并验证服务。
|
||||
5. 作为自动拉伸提示的后续界面微调单独提交并推送。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 只影响成功提示文案,不影响位姿计算和保存数据。
|
||||
- 如需恢复提示,可回滚本次 commit 或重新加入成功状态文案。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 1 个前端组件文件。
|
||||
- 1 个 Docker 部署说明文件。
|
||||
- 3 个工程分析当次文档。
|
||||
- 1 个经验记录追加。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message 包含 `2026-05-24-19-17-13` 和自动拉伸提示调整说明。
|
||||
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 验证本机和公网入口。
|
||||
52
工程分析/实现方案-2026-05-24-19-53-00.md
Normal file
52
工程分析/实现方案-2026-05-24-19-53-00.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 实现方案-2026-05-24-19-53-00
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-19-53-00.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 提高平移和缩放加减按钮的长按连续变化速度。
|
||||
- 保持单击最小步进仍为 `0.001`。
|
||||
- 同步 Docker 部署说明和经验记录。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `Docker部署/README.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 定位位姿按钮的 `startPoseRepeat`、repeat 定时器和长按节奏常量。
|
||||
- 将长按连续移动与单击步进分离:单击继续使用 `poseStepConfig[key].step`,长按每个 tick 使用更大的 multiplier 或更短 interval。
|
||||
- 对平移和缩放使用同样的加速策略,旋转仍保持现有快捷行为。
|
||||
- 保持 `clampPoseValue` 的三位小数取整,避免高频更新产生浮点漂移。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 搜索位姿长按 repeat 逻辑。
|
||||
2. 调整长按延迟、间隔或连续步长倍率。
|
||||
3. 更新 Docker 部署说明和经验记录。
|
||||
4. 运行类型检查、构建和关键搜索。
|
||||
5. 重新部署并验证本机与公网入口。
|
||||
6. 提交并推送到 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 位姿字段结构和保存格式不变。
|
||||
- 如果长按过快,可恢复本次 repeat 参数。
|
||||
- 单击步进不变,因此既有精细配准工作流不受影响。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 1 个前端组件文件。
|
||||
- 1 个 Docker 部署说明文件。
|
||||
- 3 个工程分析当次文档。
|
||||
- 1 个经验记录追加。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message 使用 `2026-05-24-19-53-00 加快位姿长按微调速度`。
|
||||
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 验证本机和公网入口。
|
||||
53
工程分析/实现方案-2026-05-24-20-06-08.md
Normal file
53
工程分析/实现方案-2026-05-24-20-06-08.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 实现方案-2026-05-24-20-06-08
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-20-06-08.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 修复隐藏 `liver_artery` 后 `liver_segment_S2/S3` 不显示或位置错乱的问题。
|
||||
- 保证可见构件 preview 结果按 `fileName` 稳定关联,而不是依赖异步数组顺序。
|
||||
- 同步工程经验和 Docker 部署说明。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `Docker部署/README.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 定位 `visibleOverlayModules`、`overlayModelPreviews`、`VoxelizationMappingView` 的数据流。
|
||||
- 检查构件切换时 preview 加载是否按索引、请求完成顺序或旧缓存组合。
|
||||
- 若存在错配,改为用 `fileName` 做稳定 key,将 preview 结果保存在 map 中,再按当前可见构件列表重组。
|
||||
- 保留加载进度条和旧请求丢弃机制,避免旧异步结果覆盖新状态。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 搜索逆向映射视图和构件 preview 加载逻辑。
|
||||
2. 定位并修正可见构件与 STL preview 的关联方式。
|
||||
3. 视情况补充小型纯函数或 key 映射,减少索引错配风险。
|
||||
4. 更新 Docker 部署说明和经验记录。
|
||||
5. 运行类型检查、构建和关键搜索。
|
||||
6. 重新部署并验证本机与公网入口。
|
||||
7. 提交并推送到 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 不改变 API 契约和项目状态字段。
|
||||
- 若修正引入显示异常,可回滚本次 commit 恢复旧数组加载方式。
|
||||
- 旧缓存仍可继续使用,但必须通过 `fileName` 命中。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 1 个前端组件文件。
|
||||
- 1 个 Docker 部署说明文件。
|
||||
- 3 个工程分析当次文档。
|
||||
- 1 个经验记录追加。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message 使用 `2026-05-24-20-06-08 修正可见构件映射错位`。
|
||||
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 验证本机和公网入口。
|
||||
64
工程分析/实现方案-2026-05-24-22-40-13.md
Normal file
64
工程分析/实现方案-2026-05-24-22-40-13.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 实现方案-2026-05-24-22-40-13
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-22-40-13.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 新增自动微调匹配工作区和逆向工作区入口按钮。
|
||||
- 新增后端自动微调匹配接口,固定旋转,只优化允许的平移和缩放。
|
||||
- 实现骨窗/骨骼构件匹配的首版可解释评分。
|
||||
- 支持将最佳结果应用回项目位姿。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/App.tsx`
|
||||
- `WebSite/src/components/Sidebar.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/server.ts`
|
||||
- `Docker部署/README.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 路由层新增 `auto-match` 视图,左侧导航新增图标入口。
|
||||
- 逆向工作区保存按钮左侧增加“自动微调匹配”,点击进入自动匹配页面并携带当前项目 ID。
|
||||
- 后端新增 `POST /api/projects/:projectId/auto-match`,接收位姿、构件、参数开关和迭代设置,返回最佳 pose 与轨迹。
|
||||
- 后端新增 `PATCH /api/projects/:projectId/model-pose`,用于应用最佳 pose 到项目库。
|
||||
- 评分函数采用:骨窗命中奖励、离骨惩罚、移动惩罚、缩放偏移惩罚;多切片抽样并对骨骼 STL 顶点/三角采样投影评分。
|
||||
- 优化策略采用多模式坐标搜索:粗到细步长,逐轮生成候选,允许每轮只改一个参数或组合改多个参数,选择最高分收敛。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 阅读现有 App、Sidebar、ReverseWorkspace、api、server 类型与项目状态逻辑。
|
||||
2. 新增 API 类型与后端自动匹配/位姿应用接口。
|
||||
3. 新增 AutoMatchWorkspace 页面,提供项目选择、参数开关、构件选择、权重和运行结果。
|
||||
4. 在逆向工作区顶部接入入口按钮。
|
||||
5. 更新 Docker 文档和经验记录。
|
||||
6. 执行类型检查、构建和接口 smoke test。
|
||||
7. 重启服务并验证本机与公网入口。
|
||||
8. 提交并推送到 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 不改变原有保存项目和导出结果接口。
|
||||
- 自动匹配结果只有点击应用后才写入项目位姿。
|
||||
- 若评分效果不理想,可回滚本次 commit 或替换后端评分函数。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 4-5 个前端源码文件。
|
||||
- 1 个后端服务文件。
|
||||
- 1 个 Docker 部署说明文件。
|
||||
- 3 个工程分析当次文档。
|
||||
- 1 个经验记录追加。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message 使用 `2026-05-24-22-40-13 新增自动微调匹配工作区`。
|
||||
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 验证本机和公网入口。
|
||||
52
工程分析/实现方案-2026-05-24-23-24-34.md
Normal file
52
工程分析/实现方案-2026-05-24-23-24-34.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 实现方案-2026-05-24-23-24-34
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-24-23-24-34.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 在自动微调匹配工作区增加可编辑的采样切片输入。
|
||||
- 自动生成默认切片文本,允许一键恢复默认。
|
||||
- 运行自动匹配时将人工采样切片传入后端。
|
||||
- 在结果面板中区分“保持当前位姿最佳”和“位姿已改变”。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 前端新增 `sampleSlicesText` 状态,按项目 DICOM 数量生成默认 1-based 采样切片。
|
||||
- 新增解析函数:支持逗号、空格、中文顿号和范围表达;范围支持步长 `起-止:步长`。
|
||||
- 运行前解析为 0-based 切片数组,通过现有 `sampleSlices` 字段传给 `api.runAutoMatch`。
|
||||
- 结果面板中把 delta 全为 0 的情况显示为“当前位姿已是本轮最高分”,并显示最佳候选模式。
|
||||
- 更新经验记录,说明 `+0.000` 的真实含义。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 阅读 `AutoMatchWorkspace.tsx` 当前状态和运行逻辑。
|
||||
2. 添加采样切片生成、解析、校验与 UI 控件。
|
||||
3. 调整运行接口入参和结果状态字段。
|
||||
4. 执行 `npm run lint` 与 `npm run build`。
|
||||
5. 重启 tmux 服务并验证本机/公网。
|
||||
6. 提交并推送到 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 后端接口已经支持 `sampleSlices`,本次主要暴露前端入口。
|
||||
- 为空时仍使用系统默认采样,不破坏旧行为。
|
||||
- 如输入解析有问题,可回滚本次提交或临时恢复默认采样输入隐藏。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 1 个前端组件文件。
|
||||
- 3 个当次工程分析文档。
|
||||
- 1 个经验记录追加。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message 使用 `2026-05-24-23-24-34 支持自动匹配采样切片调节`。
|
||||
- 构建通过后重启 `revoxelseg-dicom` tmux 会话。
|
||||
- 验证公网 `https://revoxel.huijutec.cn/`。
|
||||
64
工程分析/实现方案-2026-05-25-00-07-30.md
Normal file
64
工程分析/实现方案-2026-05-25-00-07-30.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 实现方案-2026-05-25-00-07-30
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-00-07-30.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
- 移除自动匹配页候选轨迹表格和低价值统计卡片。
|
||||
- 在“匹配结果”下方新增双栏 DICOM/STL 映射对比视图。
|
||||
- 同一个切片滑条同时控制两个视图。
|
||||
- 显示模式支持“所选骨骼区域”和“得分可视化”。
|
||||
- 骨骼区域默认空选。
|
||||
- 采样切片改为数量输入,按数量均匀筛选。
|
||||
- 调整默认权重:非骨区域惩罚 0.1、移动惩罚 0、缩放惩罚 0。
|
||||
- 为大 STL preview JSON 响应增加 gzip 发送,缓解 HTTP/2 大响应错误。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/types.ts`
|
||||
- `Docker部署/README.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 从 `ReverseWorkspace.tsx` 导出 `VoxelizationMappingView`,在自动匹配页复用。
|
||||
- 自动匹配页维护 `previewSlice` 和 `mappingMode` 状态,双视图传入同一个切片。
|
||||
- 为对比视图构造仅包含选中骨骼构件的 `moduleStyles`。
|
||||
- 采样切片数量通过 `buildUniformSampleSlices(total, count)` 生成 0-based 数组。
|
||||
- 自动匹配页不再显示候选轨迹表,保留关键位姿差值和操作按钮。
|
||||
- 后端新增 JSON gzip helper,`/models/:fileName/preview` 使用该 helper 返回大 preview。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 查看 `VoxelizationMappingView` 当前导出和 props。
|
||||
2. 修改自动匹配页面布局、状态和默认值。
|
||||
3. 修改采样切片生成逻辑与接口入参。
|
||||
4. 修改 STL preview 路由的大 JSON 发送方式。
|
||||
5. 执行 `npm run lint`、`npm run build`。
|
||||
6. 重启服务并验证健康检查与 preview 请求。
|
||||
7. 提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 自动匹配接口保留原 `sampleSlices` 字段,前端只是从数量生成。
|
||||
- 预览 gzip helper 只在客户端声明支持 gzip 时启用,不支持时仍走普通 JSON。
|
||||
- 若双视图性能不理想,可降低显示级别或只在有选中骨骼时加载。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 2 个前端组件。
|
||||
- 1 个后端服务文件。
|
||||
- 1 个类型文件。
|
||||
- 1 个 Docker 说明。
|
||||
- 工程分析三件套和经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Commit message 使用 `2026-05-25-00-07-30 优化自动匹配对比视图与采样设置`。
|
||||
- 构建通过后重启 `tmux` 会话 `revoxelseg-dicom`。
|
||||
- 验证本机和公网入口。
|
||||
58
工程分析/实现方案-2026-05-25-10-21-17.md
Normal file
58
工程分析/实现方案-2026-05-25-10-21-17.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 实现方案-2026-05-25-10-21-17
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-10-21-17.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
完成一次无源码变更的重新部署,恢复并验证 `https://revoxel.huijutec.cn/` 服务。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/`
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/vite.config.ts`
|
||||
- `工程分析/需求分析-2026-05-25-10-21-17.md`
|
||||
- `工程分析/实现方案-2026-05-25-10-21-17.md`
|
||||
- `工程分析/测试方案-2026-05-25-10-21-17.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 检查工作区状态、`WebSite` 脚本和当前 `tmux`/端口占用情况。
|
||||
2. 在 `WebSite` 执行 `npm run build`,确认生产构建通过。
|
||||
3. 若公网首页被 Vite host 检查拦截,将 `revoxel.huijutec.cn` 加入 Vite `allowedHosts`。
|
||||
4. 使用 `tmux` 会话 `revoxelseg-dicom` 启动或重启:
|
||||
`npm run serve -- --host 0.0.0.0 --port 4000`
|
||||
5. 通过本机和公网地址验证服务。
|
||||
6. 追加经验记录,提交本次工程分析文档和明确属于本次部署修复的配置变更。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 执行前再次确认已读 `工程分析/经验记录.md`。
|
||||
- 若存在旧 `tmux` 服务,向会话发送 `Ctrl-C` 后重新启动。
|
||||
- 若不存在旧会话,创建 `tmux new-session -d -s revoxelseg-dicom`。
|
||||
- 使用 `curl` 验证:
|
||||
- `http://127.0.0.1:4000/api/health`
|
||||
- `http://127.0.0.1:4000/`
|
||||
- `https://revoxel.huijutec.cn/`
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若新服务启动失败,可保留构建日志并重新启动旧服务命令。
|
||||
- 若公网不可达但本机正常,需要继续排查 FRP、NPM 反向代理和域名链路。
|
||||
- 本次不改源码,回滚主要是停止新启动的 `tmux` 服务或恢复旧进程。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增本次三件套文档。
|
||||
- 追加 `工程分析/经验记录.md`。
|
||||
- `WebSite/server.ts` 和 `WebSite/vite.config.ts` 可能追加公网域名 allowedHosts。
|
||||
- `WebSite/dist/` 可能因构建更新,但不作为本次文档备份 commit 的目标。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 先完成构建、部署与验证。
|
||||
- 暂存本次工程分析文档、经验记录和 allowedHosts 配置修复。
|
||||
- commit message 使用:`2026-05-25-10-21-17 部署并验证 ReVoxelSeg DICOM 服务`。
|
||||
59
工程分析/实现方案-2026-05-25-12-30-27.md
Normal file
59
工程分析/实现方案-2026-05-25-12-30-27.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 实现方案-2026-05-25-12-30-27
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-12-30-27.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
将 `3. 代码汇总.docx` 整理为约 10000 行真实、连续、无省略占位的项目源码汇总文档,避免出现以 `...` 表示删减的软著材料风险。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `3. 代码汇总.docx`
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `工程分析/需求分析-2026-05-25-12-30-27.md`
|
||||
- `工程分析/实现方案-2026-05-25-12-30-27.md`
|
||||
- `工程分析/测试方案-2026-05-25-12-30-27.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 读取既有工程分析、整体分析和经验记录,确认项目约束。
|
||||
2. 统计可选源码文件行数,优先选择完整文件而非局部摘录。
|
||||
3. 使用 `python-docx` 读取并重写 docx 正文,保留为 `.docx` 格式。
|
||||
4. 正文中写入标题、整理说明、文件清单和完整源码内容;每个源码文件从第一行到最后一行连续写入。
|
||||
5. 对生成后的 docx 做结构可读性检查、行数检查和省略占位检查。
|
||||
6. 执行构建、重新启动 `tmux` 服务并验证 `/api/health` 和首页。
|
||||
7. 追加经验记录,并对本次相关文档和 docx 做 Git 备份 commit。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 确认 `工程分析/经验记录.md` 在执行修改前已再次阅读。
|
||||
2. 选定完整源码文件组合,控制总行数约 10000 行。
|
||||
3. 生成 docx:使用等宽字体、小字号、紧凑段落,每行保留行号和原始源码文本。
|
||||
4. 检查 docx 中是否存在以独立 `...` 表示的省略占位;若源码语法中存在合法 `...`,评估是否需要改选文件或在材料说明中避免误判。
|
||||
5. 运行 `npm run build`。
|
||||
6. 使用 `tmux` 会话 `revoxelseg-dicom` 运行 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
7. 使用 `curl` 验证本地 API 与页面。
|
||||
8. 追加经验记录并提交备份 commit。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若 docx 生成异常,可从 Git 工作区或修改前备份文件恢复。
|
||||
- 若选定文件中合法 `...` 过多导致材料观感不佳,可改为选择不含该字符或更少使用扩展运算符的完整源码文件组合。
|
||||
- 本次不修改项目运行源码;构建和部署异常按既有服务回滚方式恢复上一版服务。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 更新:`3. 代码汇总.docx`
|
||||
- 更新:`工程分析/经验记录.md`
|
||||
- 新增:本次需求分析、实现方案、测试方案三件套
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- Git commit message 包含 `2026-05-25-12-30-27` 和“整理软著代码汇总文档”。
|
||||
- 只暂存本次相关文件,避免提交无关未跟踪或运行态文件。
|
||||
- 构建成功后重启本地 4000 服务,并验证健康检查和首页响应。
|
||||
55
工程分析/实现方案-2026-05-25-12-45-33.md
Normal file
55
工程分析/实现方案-2026-05-25-12-45-33.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 实现方案-2026-05-25-12-45-33
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-12-45-33.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
从未推送的本地 commit 中移除 `3. 代码汇总.docx`,保留本地文件,并阻止后续误提交该 docx。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `3. 代码汇总.docx`
|
||||
- `.gitignore`
|
||||
- `工程分析/需求分析-2026-05-25-12-45-33.md`
|
||||
- `工程分析/实现方案-2026-05-25-12-45-33.md`
|
||||
- `工程分析/测试方案-2026-05-25-12-45-33.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 确认上一次 commit 尚未推送,当前分支仅本地领先远程。
|
||||
2. 使用 `git rm --cached` 从索引中移除 `3. 代码汇总.docx`,不删除工作区文件。
|
||||
3. 在 `.gitignore` 中加入 `3. 代码汇总.docx`,降低后续误提交风险。
|
||||
4. 使用 `git commit --amend` 修正未推送的上一个本地 commit,使其不包含 docx。
|
||||
5. 本次不执行 `git push`。
|
||||
6. 验证 `git status` 中 docx 不再显示为待提交文件,且 `git ls-files` 不包含 docx。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 执行前再次阅读 `工程分析/经验记录.md`。
|
||||
2. 检查 `git status --short --branch` 和 `git log -1`。
|
||||
3. 取消跟踪 `3. 代码汇总.docx`,保留本地文件。
|
||||
4. 更新 `.gitignore`。
|
||||
5. 追加经验记录。
|
||||
6. 暂存 `.gitignore`、工程分析文档和经验记录,修正上一个本地 commit。
|
||||
7. 不推送远程。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若需要恢复 docx 入库,可删除 `.gitignore` 对应规则后手动 `git add`。
|
||||
- 若 amend 后发现提交范围异常,可通过 `git reflog` 找回 amend 前的 commit。
|
||||
- 本次不修改业务源码,部署服务无需因本次 Git 治理变化重启。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增:本次需求分析、实现方案、测试方案。
|
||||
- 更新:`.gitignore`、`工程分析/经验记录.md`。
|
||||
- Git 历史:上一个未推送 commit 被 amend,不再包含 `3. 代码汇总.docx`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 本次遵循用户指令,不推送 Gitea。
|
||||
- 备份 commit 仅保留工程分析文档和忽略规则,不包含 `3. 代码汇总.docx`。
|
||||
- 因不修改运行代码,本次仅做状态验证;如服务仍在运行,可验证 4000 服务健康。
|
||||
53
工程分析/实现方案-2026-05-25-12-48-00.md
Normal file
53
工程分析/实现方案-2026-05-25-12-48-00.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 实现方案-2026-05-25-12-48-00
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-12-48-00.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
重写本地 `3. 代码汇总.docx`,移除源码行号前缀和 `文件:XXX` 标题,仅保留连续源码文本。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `3. 代码汇总.docx`
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `工程分析/需求分析-2026-05-25-12-48-00.md`
|
||||
- `工程分析/实现方案-2026-05-25-12-48-00.md`
|
||||
- `工程分析/测试方案-2026-05-25-12-48-00.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 再次确认执行前已阅读 `工程分析/经验记录.md`。
|
||||
2. 使用 `python-docx` 重新生成 `3. 代码汇总.docx`。
|
||||
3. 读取三个完整源码文件,按原始行内容写入 docx,不添加行号。
|
||||
4. 文件之间只添加空行,不写 `文件:XXX` 标题。
|
||||
5. 校验 docx 可读取、无 `文件:` 文案、无行号前缀、源码行数仍为 10835。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 写入本次三件套。
|
||||
2. 重新生成 docx。
|
||||
3. 使用 `python-docx` 读取段落文本并检查。
|
||||
4. 使用 `unzip -t` 检查 docx 包结构。
|
||||
5. 追加经验记录。
|
||||
6. 只提交工程分析文档和经验记录,不提交、不推送 `3. 代码汇总.docx`。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 如新格式不符合要求,可用同一脚本重新生成带标题或带其他格式的版本。
|
||||
- `3. 代码汇总.docx` 未入库,Git 回滚不影响本地交付文件;必要时可从源码重新生成。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 本地修改:`3. 代码汇总.docx`。
|
||||
- Git 文档变更:新增本次三件套,更新 `工程分析/经验记录.md`。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 本次不提交、不推送 `3. 代码汇总.docx`。
|
||||
- 可将本次工程分析文档 amend 到尚未推送的本地备份 commit。
|
||||
- 不修改业务源码,不需要重新构建;用健康检查确认现有服务即可。
|
||||
52
工程分析/实现方案-2026-05-25-13-42-13.md
Normal file
52
工程分析/实现方案-2026-05-25-13-42-13.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 实现方案-2026-05-25-13-42-13
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-13-42-13.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
将自动微调匹配工作区“迭代轮次”的最高可选值从 12 提升到 50,并保证后端接口同步支持。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/需求分析-2026-05-25-13-42-13.md`
|
||||
- `工程分析/实现方案-2026-05-25-13-42-13.md`
|
||||
- `工程分析/测试方案-2026-05-25-13-42-13.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 前端:把迭代轮次输入框 `max` 和 `onChange` clamp 上限改为 50。
|
||||
- 后端:把 `normalizeAutoMatchIterations` 的 clamp 上限改为 50。
|
||||
- 保持默认 `iterations = 6`,避免进入页面后默认就触发长耗时运行。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 确认已读 `工程分析/经验记录.md`。
|
||||
2. 修改前端迭代轮次输入限制。
|
||||
3. 修改后端接口参数归一化限制。
|
||||
4. 执行 `npm run build`。
|
||||
5. 重启 `tmux` 会话 `revoxelseg-dicom` 服务。
|
||||
6. 验证本机与公网访问。
|
||||
7. 追加经验记录并提交推送。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 旧请求中 `iterations <= 12` 行为不变。
|
||||
- 若 50 次导致耗时过长,可再引入运行中取消、进度显示或更严格候选限制。
|
||||
- 回滚只需恢复前后端上限为 12。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 两处代码常量级修改。
|
||||
- 新增本次工程分析三件套。
|
||||
- 追加经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次代码和文档变更。
|
||||
- commit message 包含 `2026-05-25-13-42-13` 和简要描述。
|
||||
- 提交后推送到 Gitea,并完成重新部署验证。
|
||||
62
工程分析/实现方案-2026-05-25-14-00-24.md
Normal file
62
工程分析/实现方案-2026-05-25-14-00-24.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 实现方案-2026-05-25-14-00-24
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-05-25-14-00-24.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
1. 将“导出项目及结果”从 tar.gz 改为 ZIP,消除 `PaxHeaders` 暴露问题。
|
||||
2. 统一 ZIP 内部结构:根目录直接包含 `manifest.json`、`pose/`、`segmentation/`、`segmentation-parts/`、`dicom/`、`stl/` 等目录。
|
||||
3. 分类分割导出时使用构件类别名生成 `.nii.gz` 文件名。
|
||||
4. 前端导出时显示顶部细进度条,并通过下载进度回调更新。
|
||||
5. 新增自动化 API,支持 DICOM/STL 上传、位姿参数、轴向自动拉伸、可选自动匹配、可选导出、可选记录项目库和锁定。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/server.ts`
|
||||
- `WebSite/src/lib/api.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `工程分析/需求分析-2026-05-25-14-00-24.md`
|
||||
- `工程分析/实现方案-2026-05-25-14-00-24.md`
|
||||
- `工程分析/测试方案-2026-05-25-14-00-24.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 使用项目现有 `adm-zip` 依赖生成 ZIP,而不是继续维护手写 tar.gz 的 PAX 扩展路径。
|
||||
- 在后端导出包中新增根级 `manifest.json`,记录项目、导出目标、格式、分割范围、分类导出模式、位姿等信息。
|
||||
- 后端分类导出命名为 `segmentation-parts/{类别名}.nii.gz`,若重名则自动追加序号。
|
||||
- 前端下载函数改为 `XMLHttpRequest`,支持 `onprogress` 并从 `Content-Disposition` 解析文件名。
|
||||
- `ReverseWorkspace` 增加 `exportProgress` 状态和顶部 fixed 细进度条;服务端生成阶段使用平滑进度,下载阶段使用真实进度。
|
||||
- 新增 `POST /api/reverse-pipeline`,接收 multipart 的 `dicomFiles`、`stlFiles`、可选 `metadata` JSON;复用现有资产解包、NIfTI 导出和自动匹配函数。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 调整服务端项目导出包创建逻辑,返回 ZIP buffer 并改为 `.zip` 下载。
|
||||
2. 更新导出包内部文件命名与 manifest 内容。
|
||||
3. 更新前端下载 API,加入进度回调。
|
||||
4. 在逆向工作区渲染导出进度条并接入进度回调。
|
||||
5. 增加批处理 API 的参数解析、项目创建、资产写入、位姿归一化、自动拉伸、自动匹配、导出、记录与锁定逻辑。
|
||||
6. 执行 TypeScript 构建和必要接口验证。
|
||||
7. 重新部署到 `tmux` 会话 `revoxelseg-dicom` 并验证本地与公网。
|
||||
8. 追加经验记录,提交并推送 Gitea。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 单文件 `export-nifti` 和 `dicom-archive` 接口保持原行为。
|
||||
- 若 ZIP 导出出现问题,可回滚 `createProjectExportBundle` 和 `/export-bundle` 响应相关修改。
|
||||
- 新增 API 为独立路径,不影响现有页面流程。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 后端:导出包结构、批处理 API、辅助函数。
|
||||
- 前端:下载 helper 和导出进度 UI。
|
||||
- 工程分析文档:三件套与经验记录。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 修改完成后运行 `npm run build`。
|
||||
- 使用 `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/`、`https://revoxel.huijutec.cn/`。
|
||||
- commit message 包含 `2026-05-25-14-00-24` 和简要描述,并推送到 Gitea。
|
||||
59
工程分析/实现方案-2026-06-04-09-46-36.md
Normal file
59
工程分析/实现方案-2026-06-04-09-46-36.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 实现方案-2026-06-04-09-46-36
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-06-04-09-46-36.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
完成一次无源码变更的重新部署,让用户可以通过 `http://192.168.3.11:4000/` 访问当前程序。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/`
|
||||
- `WebSite/package.json`
|
||||
- `工程分析/需求分析-2026-06-04-09-46-36.md`
|
||||
- `工程分析/实现方案-2026-06-04-09-46-36.md`
|
||||
- `工程分析/测试方案-2026-06-04-09-46-36.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
1. 检查工作区状态、部署脚本、已有 `tmux` 会话和 `4000` 端口占用。
|
||||
2. 在 `WebSite` 执行 `npm run build`,生成最新生产构建。
|
||||
3. 使用或重建 `tmux` 会话 `revoxelseg-dicom`,在其中启动:
|
||||
`npm run serve -- --host 0.0.0.0 --port 4000`
|
||||
4. 验证本机健康检查、本机首页和局域网首页。
|
||||
5. 追加经验记录,并仅提交本次工程分析文档与经验记录。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
- 执行前再次确认已读 `工程分析/经验记录.md`。
|
||||
- 若存在旧 `revoxelseg-dicom` 会话,先发送 `Ctrl-C` 停止旧服务,再启动新服务。
|
||||
- 若不存在该会话,则创建新的 detached `tmux` 会话。
|
||||
- 使用 `curl` 验证:
|
||||
- `http://127.0.0.1:4000/api/health`
|
||||
- `http://127.0.0.1:4000/`
|
||||
- `http://192.168.3.11:4000/api/health`
|
||||
- `http://192.168.3.11:4000/`
|
||||
- 检查 `tmux` 会话和端口监听状态,确认服务长期运行。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 若新服务启动失败,保留构建和启动日志,停止新会话后回滚到上一次可用部署命令。
|
||||
- 若端口被非本项目进程占用,先记录占用来源,再决定是否停止或改端口。
|
||||
- 若局域网访问失败但本机访问成功,优先排查监听地址、服务器防火墙和网络路由。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增本次三件套文档。
|
||||
- 追加 `工程分析/经验记录.md`。
|
||||
- `WebSite/dist/` 会因构建更新,但不纳入本次文档备份 commit。
|
||||
- 不预计修改源码。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 先完成构建、服务重启和访问验证。
|
||||
- 再追加经验记录。
|
||||
- 仅暂存本次工程分析文档和 `工程分析/经验记录.md`。
|
||||
- commit message 使用:`2026-06-04-09-46-36 部署并验证局域网 4000 服务`。
|
||||
71
工程分析/实现方案-2026-06-23-12-07-35.md
Normal file
71
工程分析/实现方案-2026-06-23-12-07-35.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 实现方案-2026-06-23-12-07-35
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-06-23-12-07-35.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
完成一次无业务源码变更的重新部署,使当前程序可通过 `http://192.168.3.11:4000/` 访问,并保留本次操作的工程分析记录。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `AGENTS.md`
|
||||
- `README.md`
|
||||
- `WebSite/README.md`
|
||||
- `WebSite/package.json`
|
||||
- `WebSite/vite.config.ts`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/代码编纂工作流.md`
|
||||
- `工程分析/工程整体分析.md`
|
||||
- `工程分析/经验记录.md`
|
||||
- `工程分析/需求分析-2026-06-23-12-07-35.md`
|
||||
- `工程分析/实现方案-2026-06-23-12-07-35.md`
|
||||
- `工程分析/测试方案-2026-06-23-12-07-35.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 通过仓库文档、脚本和服务入口确认项目是 Express + Vite 的前后端一体服务。
|
||||
- 使用 `npm run build` 生成生产构建产物。
|
||||
- 使用 `tmux` 会话 `revoxelseg-dicom` 启动 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
- 使用 `curl` 同时验证本机地址和目标局域网地址的 API 与首页。
|
||||
- 仅将本次工程分析文档和经验记录纳入 Git 备份提交。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 检查工作区状态、Node/npm 版本、依赖目录、目标网卡地址、`4000` 端口和 `tmux` 会话。
|
||||
2. 在 `WebSite/` 执行 `npm run build`。
|
||||
3. 如存在旧 `revoxelseg-dicom` 会话,先确认并重建该会话;如不存在则创建新会话。
|
||||
4. 在会话内执行 `npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
5. 验证:
|
||||
- `http://127.0.0.1:4000/api/health`
|
||||
- `http://127.0.0.1:4000/`
|
||||
- `http://192.168.3.11:4000/api/health`
|
||||
- `http://192.168.3.11:4000/`
|
||||
6. 检查 `tmux` 会话日志和 `4000` 端口监听状态。
|
||||
7. 追加 `工程分析/经验记录.md` 的 A/B/C/D 四段式记录。
|
||||
8. 暂存并提交本次工程分析文档,commit message 使用 `2026-06-23-12-07-35 部署并验证局域网 4000 服务`。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 如果构建失败,不重启旧服务,保留错误并汇报。
|
||||
- 如果新服务启动失败,查看 `tmux` 日志和端口占用,停止失败会话后恢复上一条可用部署命令。
|
||||
- 如果 `4000` 被非本项目进程占用,先记录占用来源,再决定是否停止或改用既有服务验证。
|
||||
- 如果 `192.168.3.11` 不在本机网卡上,应停止局域网地址部署验证并报告网络前置条件不满足。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 新增:
|
||||
- `工程分析/需求分析-2026-06-23-12-07-35.md`
|
||||
- `工程分析/实现方案-2026-06-23-12-07-35.md`
|
||||
- `工程分析/测试方案-2026-06-23-12-07-35.md`
|
||||
- 追加:
|
||||
- `工程分析/经验记录.md`
|
||||
- 运行构建可能更新或生成 `WebSite/dist/`,该目录为运行态构建产物,不纳入本次文档备份提交。
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 先完成构建、部署和访问验证,再追加经验记录。
|
||||
- 使用 Git 仅提交本次工程分析文档和经验记录。
|
||||
- 不提交 DICOM/STL 数据、`WebSite/data/`、`WebSite/exports/`、`WebSite/dist/` 或无关工作区变化。
|
||||
- 提交信息:`2026-06-23-12-07-35 部署并验证局域网 4000 服务`。
|
||||
71
工程分析/实现方案-2026-06-23-13-38-48.md
Normal file
71
工程分析/实现方案-2026-06-23-13-38-48.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 实现方案-2026-06-23-13-38-48
|
||||
|
||||
## 实现方案文档路径
|
||||
|
||||
`工程分析/实现方案-2026-06-23-13-38-48.md`
|
||||
|
||||
## 修改目标
|
||||
|
||||
新增模型位姿的单轴拉伸能力,使逆向工作区可按某一坐标轴非等比例拉伸模型,并保证缩放、保存、导出、项目库预览和自动微调匹配都使用该拉伸后的统一模型位姿。
|
||||
|
||||
## 涉及路径
|
||||
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/需求分析-2026-06-23-13-38-48.md`
|
||||
- `工程分析/实现方案-2026-06-23-13-38-48.md`
|
||||
- `工程分析/测试方案-2026-06-23-13-38-48.md`
|
||||
- `工程分析/经验记录.md`
|
||||
|
||||
## 技术路线
|
||||
|
||||
- 扩展 `ModelPose` / `ModelPoseValue`,新增 `scaleX`、`scaleY`、`scaleZ`,默认值为 `1`。
|
||||
- 统一归一化和 clamp 范围,让旧数据自动补齐新字段。
|
||||
- 前端三维视图和二维分割映射采用同一变换顺序:
|
||||
- 原始模型中心化。
|
||||
- 应用统一缩放、镜像和旋转。
|
||||
- 在融合坐标系下应用 `scaleX/scaleY/scaleZ` 单轴拉伸。
|
||||
- 应用平移。
|
||||
- 后端 `transformPointForExportPose` 使用相同变换顺序,保证导出和自动匹配评分一致。
|
||||
- 逆向工作区保留现有等比例自动拉伸,再新增单轴拉伸按钮;单轴拉伸时自动把对应平移置 0。
|
||||
- 自动微调匹配工作区不额外优化 `scaleX/scaleY/scaleZ`,但请求、评分、对比视图和保存结果保留并应用这些字段。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. 扩展前后端位姿类型、默认值、格式化、归一化和 reset 行为。
|
||||
2. 修改 `ReverseWorkspace` 的模型变换、二维映射和工具栏按钮。
|
||||
3. 修改 `ProjectLibrary` 结果预览中的模型变换,兼容新字段。
|
||||
4. 修改 `server.ts` 的位姿归一化、管线参数解析、自动匹配评分和导出分割变换。
|
||||
5. 运行 `npm run lint` 与 `npm run build`。
|
||||
6. 重启 `tmux` 会话 `revoxelseg-dicom` 并验证本机/局域网访问。
|
||||
7. 追加经验记录并提交本次文档与源码备份 commit。
|
||||
|
||||
## 兼容性与回滚方案
|
||||
|
||||
- 兼容性:旧位姿不含 `scaleX/scaleY/scaleZ` 时默认按 `1` 处理,与旧行为完全一致。
|
||||
- 回滚:如新拉伸或导出异常,可回退本次 commit 并重新构建部署。
|
||||
- 数据风险:本次不修改医学数据;运行态 `state.json` 只会在用户实际保存项目时记录新字段。
|
||||
|
||||
## 预计文件变更
|
||||
|
||||
- 修改:
|
||||
- `WebSite/src/types.ts`
|
||||
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||
- `WebSite/src/components/AutoMatchWorkspace.tsx`
|
||||
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||
- `WebSite/server.ts`
|
||||
- `工程分析/经验记录.md`
|
||||
- 新增:
|
||||
- `工程分析/需求分析-2026-06-23-13-38-48.md`
|
||||
- `工程分析/实现方案-2026-06-23-13-38-48.md`
|
||||
- `工程分析/测试方案-2026-06-23-13-38-48.md`
|
||||
|
||||
## 提交与部署策略
|
||||
|
||||
- 只暂存本次源码和工程分析文档。
|
||||
- 不提交 DICOM/STL 数据、运行态 `WebSite/data/`、导出文件或无关变更。
|
||||
- commit message 使用:`2026-06-23-13-38-48 新增逆向工作区单轴拉伸`。
|
||||
- 构建通过后使用 `tmux` 会话 `revoxelseg-dicom` 重新部署 `NODE_ENV=production npm run serve -- --host 0.0.0.0 --port 4000`。
|
||||
@@ -57,6 +57,7 @@
|
||||
- `WebSite/data/state.json`:共享登录、项目、用户和构件样式状态。
|
||||
- `WebSite/exports/`:Mask 导出文件。
|
||||
- `WebSite/dist/`:生产构建产物。
|
||||
- `Docker部署/`:Docker 容器化部署方案,包含本机 Compose、威联通 NAS Compose、Dockerfile 和部署说明。
|
||||
- 默认项目 ID 为 `head-ct-demo`,由后端扫描 `Head_CT_DICOM/` 和 `Head_CT_ReConstruct/` 生成。
|
||||
- DICOM/STL 数据和运行态输出通常不应混入文档备份提交。
|
||||
|
||||
@@ -73,8 +74,10 @@
|
||||
## 部署约定
|
||||
|
||||
- 当前 README 指向访问地址 `http://192.168.3.11:4000/`。
|
||||
- 历史经验记录显示本项目长期使用 `tmux` 会话 `revoxelseg-dicom` 托管服务,避免普通后台进程被回收。
|
||||
- 重新部署前先检查端口和已有 `tmux` 会话;部署后用 `/api/health` 和页面 HTTP 响应验证。
|
||||
- 2026-05-21 起新增 Docker 部署方案,优先使用 `Docker部署/本机/docker_compose.yaml` 在本机部署,并使用 `Docker部署/威联通NAS/docker_compose.yaml` 适配 QNAP QTS Container Station。
|
||||
- Docker 部署包含 `revoxelseg_web` 与 `revoxelseg_frpc`,frpc 将 `revoxelseg_web:4000` 映射到 FRP 服务端 `82.157.255.195:10008`,配合 NPM 反向代理后可通过 `https://revoxel.huijutec.cn/` 访问。
|
||||
- 历史 `tmux` 会话 `revoxelseg-dicom` 可作为回退方式;重新部署前先检查端口和已有服务,避免 Docker 与 tmux 同时占用 4000。
|
||||
- 部署后用 `/api/health`、页面 HTTP 响应、局域网地址和公网域名验证。
|
||||
|
||||
## 修改风险重点
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# 测试方案
|
||||
|
||||
时间戳:2026-05-04-02-38-48
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证本次工作流文档和项目基线是否可用,并确认前端项目仍可构建。
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 检查 `工程分析/` 目录是否存在。
|
||||
- 检查本次需求分析、实现方案、测试方案和经验记录是否存在。
|
||||
- 检查工作流是否包含人工审核确认节点。
|
||||
- 检查 `.gitignore` 是否排除医学影像数据、重建模型、依赖目录和构建产物。
|
||||
|
||||
## 构建验证
|
||||
|
||||
在 `WebSite/` 下执行:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- TypeScript/Vite 构建通过。
|
||||
- 生成 `WebSite/dist/`。
|
||||
|
||||
## 部署验证
|
||||
|
||||
在 `WebSite/` 下执行:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- Vite 开发服务器启动。
|
||||
- 默认端口为 `3000`。
|
||||
- 若端口占用,则记录实际端口或改用可用端口。
|
||||
|
||||
## Gitea 备份验证
|
||||
|
||||
- 初始化 Git 仓库或复用现有仓库。
|
||||
- 提交文档备份 commit。
|
||||
- 添加远程仓库 `origin`。
|
||||
- 推送到 Gitea。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm ci` 执行成功,未发现 npm audit 漏洞。
|
||||
- `npm run build` 执行成功。
|
||||
- 构建出现 Vite 大 chunk 警告,当前不影响本次文档基线。
|
||||
- `3000` 端口已被 `/home/wkmgc/Desktop/Seg_Server` 的 node 服务占用。
|
||||
- 本项目改用 `3001` 端口部署。
|
||||
- 使用 `tmux` 会话 `revoxelseg-dicom` 托管 Vite 开发服务。
|
||||
- `curl -I http://127.0.0.1:3001/` 返回 `HTTP/1.1 200 OK`。
|
||||
- 文档已推送到 Gitea `main` 分支。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
- 本文档用于建立工作流基线。
|
||||
- 后续业务代码修改时,必须在测试方案完成后等待用户二次确认。
|
||||
@@ -1,102 +0,0 @@
|
||||
# 测试方案
|
||||
|
||||
时间戳:2026-05-04-03-08-20
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证 title、favicon、登录页 logo、左侧栏 logo 修改生效,并确认项目部署到 `http://192.168.3.11:4000/`。
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 检查 `WebSite/public/logo.png` 是否存在。
|
||||
- 检查 `WebSite/index.html`:
|
||||
- `<title>` 为 `模型逆向系统`。
|
||||
- favicon 指向 `/logo.png`。
|
||||
- 检查 `WebSite/src/components/Login.tsx`:
|
||||
- 登录页顶部使用 `/logo.png`。
|
||||
- 登录页主标题为 `模型逆向系统`。
|
||||
- 检查 `WebSite/src/components/Sidebar.tsx`:
|
||||
- 左上角使用 `/logo.png`。
|
||||
- 左上角文字为 `模型逆向系统`。
|
||||
|
||||
## 构建验证
|
||||
|
||||
在 `WebSite/` 下执行:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- Vite 构建成功。
|
||||
- 不出现 TypeScript 编译错误。
|
||||
|
||||
## 部署验证
|
||||
|
||||
执行端口检查:
|
||||
|
||||
```bash
|
||||
ss -ltnp | rg ':4000\b' || true
|
||||
```
|
||||
|
||||
使用 `tmux` 启动服务:
|
||||
|
||||
```bash
|
||||
tmux new-session -d -s revoxelseg-dicom 'cd /home/wkmgc/Desktop/ReVoxelSeg_DICOM/WebSite && node ./node_modules/vite/bin/vite.js --host 0.0.0.0 --port 4000 --strictPort'
|
||||
```
|
||||
|
||||
验证 HTTP 响应:
|
||||
|
||||
```bash
|
||||
curl -I http://127.0.0.1:4000/
|
||||
curl -I http://192.168.3.11:4000/
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- 两个地址均返回 `HTTP/1.1 200 OK`。
|
||||
|
||||
## 页面验证
|
||||
|
||||
- 打开 `http://192.168.3.11:4000/`。
|
||||
- 浏览器标签标题显示 `模型逆向系统`。
|
||||
- 标签页图标显示用户提供的 logo。
|
||||
- 登录页顶部显示用户提供的 logo。
|
||||
- 登录后左上角显示用户提供的 logo。
|
||||
- 折叠侧边栏后 logo 不变形、不溢出。
|
||||
|
||||
## 回归风险
|
||||
|
||||
- logo 图片尺寸较大时可能影响布局,需要通过固定容器尺寸和 `object-contain` 控制。
|
||||
- favicon 可能受浏览器缓存影响,需要强制刷新或无痕窗口验证。
|
||||
- 端口切换可能导致旧 `3001` 地址不再访问当前项目。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
已确认并执行。
|
||||
|
||||
确认记录:
|
||||
|
||||
- 用户已回复 `确认执行`。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `WebSite/public/logo.png` 已创建,图片为用户提供的 logo,格式为 PNG RGBA,尺寸 `429 x 429`。
|
||||
- `WebSite/index.html` 已更新:
|
||||
- favicon 指向 `/logo.png`。
|
||||
- title 为 `模型逆向系统`。
|
||||
- `WebSite/src/components/Login.tsx` 已更新:
|
||||
- 登录页顶部使用 `/logo.png`。
|
||||
- 登录页主标题为 `模型逆向系统`。
|
||||
- `WebSite/src/components/Sidebar.tsx` 已更新:
|
||||
- 左上角使用 `/logo.png`。
|
||||
- 左上角文字保持为 `模型逆向系统`。
|
||||
- `npm run build` 执行成功。
|
||||
- `npm run lint` 执行成功。
|
||||
- Vite 构建仍有大 chunk 警告,当前不影响本次 logo/title 修改。
|
||||
- 已关闭旧的 `revoxelseg-dicom` 部署会话,并使用同名 `tmux` 会话部署到 `4000`。
|
||||
- `curl -I http://127.0.0.1:4000/` 返回 `HTTP/1.1 200 OK`。
|
||||
- `curl -I http://192.168.3.11:4000/` 返回 `HTTP/1.1 200 OK`。
|
||||
- `curl -I http://127.0.0.1:4000/logo.png` 返回 `HTTP/1.1 200 OK` 且 `Content-Type: image/png`。
|
||||
- 当前监听端口确认:`0.0.0.0:4000`。
|
||||
@@ -1,132 +0,0 @@
|
||||
# 测试方案
|
||||
|
||||
时间戳:2026-05-04-03-21-40
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证系统已经从纯前端静态演示升级为前后端协调系统,并确认登录同步、默认项目、恢复出厂设置、NIfTI 导出和部署均可用。
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 检查 `server.ts` 是否提供 API 和前端页面服务。
|
||||
- 检查 `src/lib/api.ts` 是否统一封装 API 请求。
|
||||
- 检查项目列表、系统管理、逆向工作区是否不再依赖硬编码假数据。
|
||||
- 检查 `.gitignore` 是否排除 `WebSite/data/`、`WebSite/exports/`、DICOM、STL、依赖和构建产物。
|
||||
|
||||
## 构建与类型检查
|
||||
|
||||
在 `WebSite/` 下执行:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- TypeScript 类型检查通过。
|
||||
- Vite 构建通过。
|
||||
|
||||
## API 验证
|
||||
|
||||
启动后端服务后验证:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:4000/api/health
|
||||
curl -s http://127.0.0.1:4000/api/session
|
||||
curl -s http://127.0.0.1:4000/api/projects
|
||||
curl -s http://127.0.0.1:4000/api/users
|
||||
curl -s -X POST http://127.0.0.1:4000/api/demo/reset
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- health 返回 `ok`。
|
||||
- projects 包含默认项目 `head-ct-demo`。
|
||||
- 默认项目 `dicomCount` 为 `300`。
|
||||
- 默认项目 STL 模块包含 `Head_CT_ReConstruct` 下的 `.stl` 文件。
|
||||
- reset 后默认项目仍存在。
|
||||
|
||||
## 登录同步验证
|
||||
|
||||
- 浏览器 A 登录 `admin / 123456`。
|
||||
- 浏览器 B 刷新或等待轮询后读取到已登录状态。
|
||||
- 浏览器 A 或 B 登出后,另一个浏览器轮询后同步到未登录状态。
|
||||
|
||||
命令级验证:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:4000/api/login -H 'Content-Type: application/json' -d '{"account":"admin","password":"123456"}'
|
||||
curl -s http://127.0.0.1:4000/api/session
|
||||
curl -s -X POST http://127.0.0.1:4000/api/logout
|
||||
curl -s http://127.0.0.1:4000/api/session
|
||||
```
|
||||
|
||||
## NIfTI 导出验证
|
||||
|
||||
执行:
|
||||
|
||||
```bash
|
||||
curl -L -o /tmp/revoxelseg-mask.nii.gz -X POST http://127.0.0.1:4000/api/projects/head-ct-demo/export-mask
|
||||
file /tmp/revoxelseg-mask.nii.gz
|
||||
gzip -t /tmp/revoxelseg-mask.nii.gz
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- 下载文件存在。
|
||||
- 文件为 gzip 压缩数据。
|
||||
- `gzip -t` 校验通过。
|
||||
- 响应文件名后缀为 `.nii.gz`。
|
||||
|
||||
## 页面验证
|
||||
|
||||
- 打开 `http://192.168.3.11:4000/`。
|
||||
- 登录页可正常登录。
|
||||
- 总体概况显示后端统计数据。
|
||||
- 项目库默认显示头部 CT 演示项目。
|
||||
- 项目详情展示 `Head_CT_DICOM` 和 `Head_CT_ReConstruct` 相关信息。
|
||||
- 系统管理页点击“恢复演示环境出厂设置”后,项目仍恢复为默认 DICOM/STL。
|
||||
- 逆向工作区可导出 `nii.gz` mask。
|
||||
|
||||
## 回归风险
|
||||
|
||||
- 引入后端后部署方式从纯 Vite 改为 Express + Vite 中间件,需要确认 `4000` 服务由 `server.ts` 托管。
|
||||
- 后端共享登录状态是演示同步方案,不等同生产级多用户 session。
|
||||
- NIfTI 文件当前为演示 mask,非医学级真实 STL 体素化结果。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次用户明确要求无需人工二次确认。
|
||||
|
||||
状态:自动确认,继续执行。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint` 执行成功。
|
||||
- `npm run build` 执行成功。
|
||||
- Vite 仍有大 chunk 警告,当前不影响本次功能。
|
||||
- 已新增 Express 后端 `server.ts`,并通过 `npm run serve -- --host 0.0.0.0 --port 4000` 托管前后端。
|
||||
- `GET /api/health` 返回 `ok: true`。
|
||||
- `GET /api/projects` 返回默认项目 `head-ct-demo`。
|
||||
- 默认项目载入结果:
|
||||
- `dicomCount: 300`
|
||||
- `modelCount: 9`
|
||||
- `dicomPath: Head_CT_DICOM`
|
||||
- `modelPath: Head_CT_ReConstruct`
|
||||
- `POST /api/demo/reset` 执行成功,重置后默认项目仍载入 `Head_CT_DICOM` 与 `Head_CT_ReConstruct`。
|
||||
- `POST /api/login` 使用 `admin / 123456` 登录成功。
|
||||
- `GET /api/session` 登录后返回 `authenticated: true`。
|
||||
- `POST /api/logout` 登出成功。
|
||||
- `GET /api/session` 登出后返回 `authenticated: false`。
|
||||
- `POST /api/projects/head-ct-demo/export-mask?format=nii.gz` 执行成功。
|
||||
- `/tmp/revoxelseg-mask.nii.gz` 通过 `gzip -t` 校验。
|
||||
- 解压后 NIfTI magic 为 `n+1\0`。
|
||||
- `POST /api/projects/head-ct-demo/export-mask?format=nii` 执行成功,生成未压缩 `.nii`。
|
||||
- `http://192.168.3.11:4000/` 返回 `HTTP/1.1 200 OK`。
|
||||
- 当前部署由 `tmux` 会话 `revoxelseg-dicom` 托管。
|
||||
|
||||
## 剩余说明
|
||||
|
||||
- 当前导出的 NIfTI mask 是可下载、格式有效的演示分割体数据。
|
||||
- 真实医学级 STL 反向体素化仍需后续加入 DICOM 空间解析、STL 坐标配准、网格体素填充和标签体系。
|
||||
@@ -1,97 +0,0 @@
|
||||
# 测试方案
|
||||
|
||||
时间戳:2026-05-04-03-50-07
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证概况统计、图表、真实 DICOM 预览、真实 STL 渲染、分割结果视图、项目创建/重命名、README 和部署均正常。
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 检查 `Overview.tsx` 图表容器是否有稳定尺寸。
|
||||
- 检查 `ProjectLibrary.tsx` 是否包含三个 tab:`DICOM 影像`、`3D 模型`、`分割结果`。
|
||||
- 检查 STL 模块列表不再对首个模块默认蓝色高亮。
|
||||
- 检查 README 是否包含项目构建和运行方案。
|
||||
|
||||
## 构建与类型检查
|
||||
|
||||
```bash
|
||||
cd WebSite
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- TypeScript 检查通过。
|
||||
- Vite 构建通过。
|
||||
|
||||
## API 验证
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:4000/api/overview
|
||||
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?slice=0'
|
||||
curl -I 'http://127.0.0.1:4000/api/projects/head-ct-demo/models/会厌.stl'
|
||||
curl -s -X POST http://127.0.0.1:4000/api/projects -H 'Content-Type: application/json' -d '{"name":"测试创建项目"}'
|
||||
curl -s -X PATCH http://127.0.0.1:4000/api/projects/head-ct-demo -H 'Content-Type: application/json' -d '{"name":"头部 CT 模型逆向体素化演示"}'
|
||||
```
|
||||
|
||||
预期结果:
|
||||
|
||||
- overview 返回平稳趋势和 `exportedMaskProjects`。
|
||||
- DICOM preview 返回 `width`、`height`、`pixels`。
|
||||
- STL 文件返回 200。
|
||||
- 创建项目返回新项目。
|
||||
- 重命名项目成功。
|
||||
|
||||
## 页面验证
|
||||
|
||||
- 总体概况不再显示“已处理项目总数 = 1”的误导文案。
|
||||
- 控制台不再出现 Recharts 宽高 -1 警告。
|
||||
- 项目库 DICOM 影像显示真实灰度切片。
|
||||
- 项目库 3D 模型显示真实 STL。
|
||||
- 项目库新增分割结果页,可下载 NII/NII.GZ。
|
||||
- 项目列表可创建项目。
|
||||
- 项目列表已有项目可点击编辑图标重命名。
|
||||
- STL 模块列表所有项目同等样式,没有“会厌”特殊蓝色突出。
|
||||
|
||||
## 回归风险
|
||||
|
||||
- DICOM 解析兼容性有限,当前先针对本项目 Little Endian CT 数据。
|
||||
- STL 文件较大时首次加载可能需要等待。
|
||||
- 新建项目默认无 DICOM/STL 数据,作为项目管理演示项。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次用户明确要求无需人工二次确认。
|
||||
|
||||
状态:自动确认,继续执行。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint` 执行成功。
|
||||
- `npm run build` 执行成功。
|
||||
- Vite 仍有大 chunk 警告,当前不影响本次功能。
|
||||
- `GET /api/overview` 返回:
|
||||
- `totalProjects: 1`
|
||||
- `exportedMaskProjects: 0`
|
||||
- `dicomCount: 300`
|
||||
- `modelCount: 9`
|
||||
- 已将“已处理项目”语义改为“已导出 Mask 项目”,避免项目总数为 1 时误导为已处理 1。
|
||||
- `GET /api/projects/head-ct-demo/dicom-preview?slice=0` 返回:
|
||||
- `width: 512`
|
||||
- `height: 512`
|
||||
- `total: 300`
|
||||
- `fileName: 1.dcm`
|
||||
- `pixels` base64 数据。
|
||||
- `GET /api/projects/head-ct-demo/models/会厌.stl` 返回 `HTTP/1.1 200 OK`。
|
||||
- `POST /api/projects` 创建项目成功。
|
||||
- `PATCH /api/projects/:id` 重命名项目成功。
|
||||
- `POST /api/demo/reset` 执行成功,测试创建项目已清理,默认项目恢复。
|
||||
- headless Chrome 打开页面后未捕获 `width(-1)`、`height(-1)` 或 Recharts 相关警告。
|
||||
- `http://192.168.3.11:4000/` 返回 `HTTP/1.1 200 OK`。
|
||||
- 当前服务由 `tmux` 会话 `revoxelseg-dicom` 托管。
|
||||
|
||||
## 本次未创建 Python/conda 环境原因
|
||||
|
||||
本次 DICOM 预览、STL 渲染和 NIfTI 演示导出均可由现有 Node/React/Three.js 技术栈完成。为了避免引入不必要的运行环境复杂度,本次不创建 conda 环境;README 已说明后续真实体素化算法阶段再引入 Python/conda。
|
||||
@@ -1,108 +0,0 @@
|
||||
# 测试方案
|
||||
|
||||
时间戳:2026-05-04-04-12-34
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证项目库导入按钮语义、DICOM 三方向切片、STL 颜色透明度控制、项目创建弹窗、编辑自动保存和删除确认能力。
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 检查 `ProjectLibrary.tsx`:
|
||||
- DICOM/3D 顶部第二按钮为“导入”。
|
||||
- 分割结果页无顶部第二按钮。
|
||||
- 创建项目通过弹窗触发。
|
||||
- 删除项目通过确认弹窗触发。
|
||||
- 编辑项目名称无保存按钮,失焦自动保存。
|
||||
- STL 模块包含颜色输入和透明度滑块。
|
||||
- 删除无意义状态点。
|
||||
- 检查 `server.ts`:
|
||||
- DICOM preview 支持 `plane`。
|
||||
- 项目支持 `DELETE`。
|
||||
|
||||
## 构建与类型检查
|
||||
|
||||
```bash
|
||||
cd WebSite
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- TypeScript 检查通过。
|
||||
- Vite 构建通过。
|
||||
|
||||
## API 验证
|
||||
|
||||
```bash
|
||||
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=axial&slice=0'
|
||||
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=sagittal&slice=256'
|
||||
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=coronal&slice=256'
|
||||
curl -s -X POST http://127.0.0.1:4000/api/projects -H 'Content-Type: application/json' -d '{"name":"删除测试项目"}'
|
||||
curl -s -X DELETE http://127.0.0.1:4000/api/projects/<id>'
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- 三方向 DICOM preview 均返回 `width`、`height`、`pixels`、`plane`。
|
||||
- 创建项目成功。
|
||||
- 删除项目成功。
|
||||
|
||||
## 页面验证
|
||||
|
||||
- DICOM 页右侧控制条圆点与轨道对齐。
|
||||
- DICOM 页显示 `第 n / 总数`。
|
||||
- 可切换横断面、矢状面、冠状面。
|
||||
- 3D 页整体眼睛可控制所有 STL 显示/隐藏。
|
||||
- 单个 STL 的颜色和透明度控制影响模型渲染。
|
||||
- 项目创建由弹窗完成。
|
||||
- 项目编辑失焦自动保存。
|
||||
- 项目删除需要二次确认。
|
||||
|
||||
## 回归风险
|
||||
|
||||
- 矢状面/冠状面每次请求会读取多张 DICOM,可能有延迟。
|
||||
- 多 STL 同时显示时首屏加载可能较慢。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次用户明确要求无需人工二次确认。
|
||||
|
||||
状态:自动确认,继续执行。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint` 执行成功。
|
||||
- `npm run build` 执行成功。
|
||||
- Vite 仍有大 chunk 警告,当前不影响本次功能。
|
||||
- `GET /api/projects/head-ct-demo/dicom-preview?plane=axial&slice=0` 返回:
|
||||
- `plane: axial`
|
||||
- `width: 512`
|
||||
- `height: 512`
|
||||
- `total: 300`
|
||||
- `GET /api/projects/head-ct-demo/dicom-preview?plane=sagittal&slice=0` 返回:
|
||||
- `plane: sagittal`
|
||||
- `width: 300`
|
||||
- `height: 512`
|
||||
- `total: 512`
|
||||
- `GET /api/projects/head-ct-demo/dicom-preview?plane=coronal&slice=0` 返回:
|
||||
- `plane: coronal`
|
||||
- `width: 300`
|
||||
- `height: 512`
|
||||
- `total: 512`
|
||||
- `POST /api/projects` 创建删除测试项目成功。
|
||||
- `DELETE /api/projects/:id` 删除测试项目成功。
|
||||
- `POST /api/demo/reset` 执行成功,演示环境已恢复默认项目。
|
||||
- headless Chrome 打开页面后未捕获 Recharts 宽高警告、`Uncaught` 或页面错误。
|
||||
- `http://192.168.3.11:4000/` 返回 `HTTP/1.1 200 OK`。
|
||||
- 当前服务由 `tmux` 会话 `revoxelseg-dicom` 托管。
|
||||
|
||||
## 页面侧验证点
|
||||
|
||||
- 顶部第二按钮在 `DICOM 影像` 和 `3D 模型` 视图显示为“导入”。
|
||||
- `分割结果` 视图顶部不显示额外导出按钮,只保留下方 NII/NII.GZ 下载按钮。
|
||||
- 项目创建入口改为点击 `+` 后弹窗。
|
||||
- 项目删除通过确认弹窗执行。
|
||||
- 项目编辑输入框失焦或回车自动保存。
|
||||
- 3D 模型侧栏每个 STL 提供颜色和透明度控制,整体眼睛控制所有 STL 可见性。
|
||||
@@ -1,78 +0,0 @@
|
||||
# 测试方案
|
||||
|
||||
时间戳:2026-05-04-04-58-36
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证项目列表按钮不重叠、DICOM 三方向和显示模式可用、3D 模型有加载进度并可见、逆向工作区显示当前项目和融合视图,以及控制台不再出现 `THREE.Clock` 警告。
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 检查项目列表标题区 `+` 与收缩按钮布局。
|
||||
- 检查 DICOM preview API 是否支持 `mode`。
|
||||
- 检查项目库是否使用原生 Three.js renderer 并显示加载进度。
|
||||
- 检查逆向工作区是否显示当前项目。
|
||||
|
||||
## 构建与类型检查
|
||||
|
||||
```bash
|
||||
cd WebSite
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- TypeScript 检查通过。
|
||||
- Vite 构建通过。
|
||||
|
||||
## API 验证
|
||||
|
||||
```bash
|
||||
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=axial&slice=0&mode=default'
|
||||
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=sagittal&slice=128&mode=bone'
|
||||
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/dicom-preview?plane=coronal&slice=128&mode=contrast'
|
||||
curl -s 'http://127.0.0.1:4000/api/projects/head-ct-demo/models/头部.stl/preview?limit=2000'
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- DICOM 均返回 `width`、`height`、`pixels`、`mode`。
|
||||
- STL 预览返回 `triangleCount`、`sampledTriangles`、`vertices`。
|
||||
|
||||
## 页面验证
|
||||
|
||||
- 项目列表标题区按钮不重叠。
|
||||
- DICOM 视图可切换多种显示模式。
|
||||
- 矢状面/冠状面滑动有图像变化。
|
||||
- 3D 视图显示加载进度条,加载后模型可见。
|
||||
- 逆向工作区显示当前项目,融合视图显示 DICOM 与模型中心对齐叠加效果。
|
||||
|
||||
## 控制台验证
|
||||
|
||||
- headless Chrome 打开页面后不捕获 `THREE.Clock`。
|
||||
- 不捕获 `Uncaught`、`Error`。
|
||||
|
||||
## 实际执行结果
|
||||
|
||||
执行时间:2026-05-04
|
||||
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:通过,仅保留 Vite chunk size 提示。
|
||||
- DICOM API:
|
||||
- axial default:`512x512 150/300 WW=360 WL=60`
|
||||
- sagittal bone:`300x512 128/512 WW=2000 WL=500`
|
||||
- coronal contrast:`300x512 256/512 WW=180 WL=80`
|
||||
- axial soft:`512x512 150/300 WW=400 WL=40`
|
||||
- STL 预览 API:`头部.stl 2571248 2000 18000`。
|
||||
- Headless Chrome 自动化:
|
||||
- 项目库进入成功。
|
||||
- 3D 模型页进入成功,模型加载/二维兜底状态可见。
|
||||
- 逆向工作区进入成功,当前项目与融合说明可见。
|
||||
- `THREE.Clock`、`non-passive`、`Uncaught` 捕获数为 0。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
本次用户明确要求无需人工二次确认。
|
||||
|
||||
状态:自动确认,继续执行。
|
||||
@@ -1,62 +0,0 @@
|
||||
# 测试方案 - 2026-05-04-05-20-16
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 执行 `npm run lint`,确认 TypeScript 类型检查通过。
|
||||
- 执行 `npm run build`,确认生产构建通过。
|
||||
|
||||
## 集成验证
|
||||
|
||||
- 调用 `/api/projects/head-ct-demo/dicom-preview` 验证:
|
||||
- `plane=axial`
|
||||
- `plane=sagittal`
|
||||
- `plane=coronal`
|
||||
- `mode=bone`
|
||||
- 调用 `/api/projects/head-ct-demo/dicom-archive` 验证压缩包响应头和文件名。
|
||||
- 调用 `/api/projects/head-ct-demo/models/:fileName/preview` 验证 STL 预览顶点数据非空。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- 项目库 DICOM 视图:
|
||||
- 长按上下箭头能连续改变切片。
|
||||
- 三个面切换后图像显示完整。
|
||||
- 左/右旋转 90 度后画布方向变化。
|
||||
- 下载当前 PNG 命名包含项目、平面、切片、模式、旋转角度。
|
||||
- 下载 DICOM 压缩包可触发。
|
||||
|
||||
- 项目库 3D 模型视图:
|
||||
- 模型加载进度可见。
|
||||
- 加载完成后模型可见,而不是空白画布。
|
||||
- 颜色、透明度、显示/隐藏状态仍可影响渲染。
|
||||
|
||||
- 逆向工作区:
|
||||
- 顶部不再显示 `Head_CT_DICOM ↔ Head_CT_ReConstruct`。
|
||||
- 当前项目上下文仍可见。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- DICOM 序列按文件名自然排序。
|
||||
- 矢状面/冠状面重建裁切保留边距,避免把真实人体区域裁掉。
|
||||
- 显示模式与三平面组合时返回的 `total`、`slice`、`width`、`height` 合理。
|
||||
|
||||
## 回归风险
|
||||
|
||||
- DICOM 预览缓存键应包含平面、模式和切片,避免错图。
|
||||
- PNG 下载应和当前旋转状态一致。
|
||||
- 3D 模型抽样数量不能过高导致页面阻塞。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已明确本次测试方案无需二次确认,记录后直接执行。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:通过;Vite 提示 bundle 超过 500 kB,为 Three.js/Recharts 等现有依赖带来的体积警告,不阻断构建。
|
||||
- `GET /api/projects/head-ct-demo/dicom-preview?plane=axial&slice=150&mode=default`:通过,返回 `512x512`,`total=300`,文件名 `151.dcm`。
|
||||
- `GET /api/projects/head-ct-demo/dicom-preview?plane=sagittal&slice=256&mode=bone`:通过,返回 `300x421`,`total=512`。
|
||||
- `GET /api/projects/head-ct-demo/dicom-preview?plane=coronal&slice=256&mode=soft`:通过,返回 `300x512`,`total=512`。
|
||||
- `HEAD /api/projects/head-ct-demo/dicom-archive`:通过,返回 `Content-Type: application/gzip`,文件名 `head-ct-demo-Head_CT_DICOM-300-files.tar.gz`。
|
||||
- `GET /api/projects/head-ct-demo/models/会厌.stl/preview?limit=1200`:通过,返回 `1159` 个抽样三角面、`10431` 个顶点数值。
|
||||
- 无头 Chrome 前端验证:登录后进入项目库 3D 模型页,在 WebGL 不可用场景自动生成二维兜底预览,canvas 尺寸 `1236x567`,像素采样 `nonBackground=67`,确认不是空白画布。
|
||||
- 已重启部署到 `http://192.168.3.11:4000/`,tmux 会话:`revoxelseg-dicom`。
|
||||
@@ -1,73 +0,0 @@
|
||||
# 测试方案 - 2026-05-04-05-41-22
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 执行 `npm run lint`,确认 TypeScript 类型检查通过。
|
||||
- 执行 `npm run build`,确认生产构建通过。
|
||||
|
||||
## 集成测试
|
||||
|
||||
- 调用 STL preview API:
|
||||
- `limit=6000`
|
||||
- `limit=16000`
|
||||
- `limit=36000`
|
||||
- 确认返回顶点数量随实体化程度增加,且响应不报错。
|
||||
- 调用 DICOM preview API,确认连续请求不同 slice 时均可返回合理 `slice/total/width/height`。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
### 3D 模型页
|
||||
|
||||
- 默认进入后模型可见。
|
||||
- 实体化程度从“预览”切换到“标准/精细”后,模型表面更连续,而不是纯点云感。
|
||||
- 开启“整体白色实体”后,显示效果接近参考图的灰白实体模型。
|
||||
- 调整旋转 X/Y/Z、平移 X/Y/Z、缩放后,模型位姿即时变化。
|
||||
- 点击重置后,位姿恢复默认。
|
||||
- 隐藏/显示单个 STL、调整颜色、透明度仍正常。
|
||||
- WebGL 不可用时二维兜底仍可见,不出现空白画布。
|
||||
|
||||
### DICOM 切片页
|
||||
|
||||
- 长按上箭头时,切片编号连续增加,图像连续变化。
|
||||
- 长按下箭头时,切片编号连续减少,图像连续变化。
|
||||
- 快速连续切换时,不出现旧请求覆盖新切片的错帧。
|
||||
- 到达首帧/末帧时不越界。
|
||||
|
||||
### 逆向工作区
|
||||
|
||||
- 顶部全局 header 仍显示“逆向工作区”。
|
||||
- 页面内容区不再重复显示第二个“逆向工作区”。
|
||||
- 页面内容区不再出现上方重复“当前项目:xxx”副标题。
|
||||
- 项目标签行字号变大,且显示当前项目、DICOM 数量、STL 数量。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- DICOM 连续切片时保留当前 plane、mode、rotation 状态。
|
||||
- STL 实体化程度不改变各构件的原始相对空间关系,只改变抽样密度和显示材质。
|
||||
- 位姿控制作用于整体模型 group,不改变单个 STL 的相对拼装位置。
|
||||
|
||||
## 回归风险
|
||||
|
||||
- 高实体化程度可能提升渲染压力,需要在 UI 中保留“预览”档位。
|
||||
- DICOM 连续请求可能造成响应乱序,需要请求序号保护。
|
||||
- 逆向工作区删除标题后仍需保证用户知道当前处于哪个页面,依赖全局 header。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已回复“确认方案”,按本测试方案执行验证。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:通过;Vite 仍提示 bundle 超过 500 kB,为现有 Three.js/Recharts 依赖带来的非阻断警告。
|
||||
- STL preview API 验证:
|
||||
- `limit=6000`:返回 `5795` 个抽样三角面、`52155` 个顶点数值。
|
||||
- `limit=16000`:返回 `8692` 个抽样三角面、`78228` 个顶点数值。
|
||||
- `limit=36000`:返回 `17384` 个抽样三角面、`156456` 个顶点数值。
|
||||
- DICOM 连续切片 API 验证:`slice=120~124` 均返回 `512x512`、`total=300`,文件名按 `121.dcm` 到 `125.dcm` 连续变化。
|
||||
- 无头 Chrome 前端验证:
|
||||
- 3D 模型页存在“模型显示、预览、标准、精细、白色实体、整体位姿、旋转 X、平移 Z、缩放”等控件。
|
||||
- WebGL 不可用场景仍生成二维预览 canvas,尺寸 `1172x567`。
|
||||
- 逆向工作区正文不再重复页面标题:`逆向工作区` 只出现 1 次。
|
||||
- `当前项目:头部 CT 模型逆向体素化演示` 只出现 1 次,并保留 `DICOM 300`、`STL 9`。
|
||||
- 已重新部署到 `http://192.168.3.11:4000/`,tmux 会话:`revoxelseg-dicom`。
|
||||
@@ -1,68 +0,0 @@
|
||||
# 测试方案 - 2026-05-04-05-56-34
|
||||
|
||||
## 静态检查
|
||||
|
||||
- 执行 `npm run lint`,确认 TypeScript 类型检查通过。
|
||||
- 执行 `npm run build`,确认生产构建通过。
|
||||
|
||||
## 集成测试
|
||||
|
||||
- DICOM preview API:
|
||||
- 验证 axial/sagittal/coronal 均返回。
|
||||
- 验证 sagittal/coronal 返回的 `spacing`、`physicalSize`、`width/height` 合理。
|
||||
- DICOM info API:
|
||||
- 验证返回 patient/study/series/image/window/spacing/sequence/source 分组。
|
||||
- 验证 Pixel Spacing、Slice Spacing、Rows、Columns、文件数量等信息存在。
|
||||
- STL preview API:
|
||||
- 验证 `limit=6000/16000/36000/72000` 返回不报错。
|
||||
|
||||
## 关键业务场景验证
|
||||
|
||||
- DICOM 影像页:
|
||||
- 矢状面/冠状面不再异常扁平。
|
||||
- 点击信息按钮弹出详情面板。
|
||||
- 详情面板展示像素间距、切片间距、切片厚度、矩阵、物理尺寸。
|
||||
- 3D 模型页:
|
||||
- 不再显示白色实体和自动旋转开关。
|
||||
- 实体化档位包含“超精细”。
|
||||
- 默认正向静止摆放。
|
||||
- 重置位姿按钮位于“整体位姿”标题右侧。
|
||||
- 左键拖拽旋转,右键/Shift 拖拽平移,滚轮缩放。
|
||||
- 画布交互后右侧整体位姿滑块数值同步变化。
|
||||
|
||||
## 医学影像数据相关边界验证
|
||||
|
||||
- DICOM tag 缺失时使用 fallback,不导致接口 500。
|
||||
- 切片间距优先 Image Position Patient 差值,再 fallback 到 Spacing Between Slices、Slice Thickness、1mm。
|
||||
- 多平面物理比例不改变切片总数和当前切片编号逻辑。
|
||||
|
||||
## 回归风险
|
||||
|
||||
- 物理比例重采样可能增大图像尺寸,需要限制最大输出尺寸。
|
||||
- 超精细 STL 预览可能变慢,需要保留低档位。
|
||||
- 鼠标交互需避免页面滚动和右键菜单干扰。
|
||||
|
||||
## 人工审核状态
|
||||
|
||||
用户已明确本次无需人工二次确认,按本方案执行验证。
|
||||
|
||||
## 执行结果
|
||||
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:通过;Vite 仍提示 bundle 超过 500 kB,为现有 Three.js/Recharts 依赖导致的非阻断警告。
|
||||
- DICOM preview API 验证:
|
||||
- `sagittal` 返回 `384x421`,`spacing.slice=1mm`、`displayY=0.78125mm`,物理尺寸约 `300mm x 328.906mm`。
|
||||
- `coronal` 返回 `384x512`,`spacing.slice=1mm`、`displayY=0.78125mm`,物理尺寸约 `300mm x 400mm`。
|
||||
- DICOM info API 验证:
|
||||
- 返回患者 `WANG FANG`、`CT`、文件数 `300`、矩阵 `512x512`。
|
||||
- 返回 `row/column spacing=0.781mm`、`slice=1mm`,来源 `ImagePositionPatient`。
|
||||
- 返回物理尺寸 `400mm x 400mm x 299mm`。
|
||||
- STL preview API 验证:
|
||||
- `limit=6000/16000/36000/72000` 均返回,首个 STL 在超精细档达到原始 `17384` 个三角面。
|
||||
- 无头 Chrome 前端验证:
|
||||
- DICOM 信息按钮存在。
|
||||
- DICOM 信息弹窗存在,并展示像素行间距、切片间距、物理尺寸。
|
||||
- 3D 页存在“超精细”,已移除“白色实体”和“自动旋转”。
|
||||
- 默认位姿滑块为 `0,0,0 / 0,0,0 / 1`。
|
||||
- 页面包含“左键旋转”和“滚轮缩放”提示。
|
||||
- 已重新部署到 `http://192.168.3.11:4000/`,tmux 会话:`revoxelseg-dicom`。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user