feat: 服务化改造STP到GLB转换器
- 🚀 新增FastAPI Web服务支持 - ⚡ 实现异步任务处理和并发转换 - 📊 添加实时进度追踪(0-100%) - 🏗️ 重构为模块化架构:core/api/services/utils - 🔧 完整的任务管理系统和状态追踪 - 📖 自动生成API文档(Swagger/ReDoc) - 🔄 保持CLI模式100%向后兼容 - 🛡️ 增强错误处理和文件验证 - 📝 更新完整文档(README/CLAUDE.md) 技术栈: FastAPI + uvicorn + pydantic + asyncio API端点: /health, /api/v1/convert, /api/v1/status, /api/v1/tasks 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
218d3bc474
commit
5102bd57e8
77
CLAUDE.md
77
CLAUDE.md
@ -4,27 +4,64 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个Python工具,用于将STP格式的3D模型文件转换为GLB格式。项目实现了STP → STL → GLB的转换流程。
|
||||
这是一个Python服务,用于将STP格式的3D模型文件转换为GLB格式。项目提供CLI和Web API两种使用方式,实现了STP → STL → GLB的转换流程。
|
||||
|
||||
## 核心架构
|
||||
|
||||
- **main.py**: 单文件应用,包含完整的转换逻辑
|
||||
- `stp2stl()`: 使用pythonocc-core读取STP文件并转换为二进制STL
|
||||
- `stl2glb()`: 使用trimesh库将STL转换为GLB
|
||||
- 主流程:处理命令行参数,执行转换,清理临时文件
|
||||
### 模块化设计
|
||||
- **main.py**: CLI入口,保持向后兼容
|
||||
- **app.py**: FastAPI服务入口
|
||||
- **core/**: 核心转换引擎
|
||||
- `converter.py`: 转换器类(封装原有逻辑)
|
||||
- `models.py`: 数据模型定义
|
||||
- **api/**: HTTP接口层
|
||||
- `routes.py`: API路由定义
|
||||
- `schemas.py`: API数据模式
|
||||
- **services/**: 业务服务层
|
||||
- `task_manager.py`: 异步任务管理
|
||||
- **utils/**: 工具函数库
|
||||
|
||||
## 依赖要求
|
||||
|
||||
- pythonocc-core: 用于STP/STEP文件读取和STL写入
|
||||
- trimesh: 用于STL到GLB格式转换
|
||||
- fastapi: Web框架
|
||||
- uvicorn: ASGI服务器
|
||||
- pydantic: 数据验证
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 运行转换
|
||||
### CLI模式转换
|
||||
```bash
|
||||
python main.py input.stp output.glb
|
||||
```
|
||||
|
||||
### 启动Web服务
|
||||
```bash
|
||||
# 开发模式
|
||||
python app.py
|
||||
|
||||
# 或使用uvicorn
|
||||
uvicorn app:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# 生产模式
|
||||
uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
```
|
||||
|
||||
### API使用示例
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# 提交转换任务
|
||||
curl -X POST http://localhost:8000/api/v1/convert \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input_path": "/path/to/input.stp", "output_path": "/path/to/output.glb"}'
|
||||
|
||||
# 查询任务状态
|
||||
curl http://localhost:8000/api/v1/status/{task_id}
|
||||
```
|
||||
|
||||
### 测试转换
|
||||
```bash
|
||||
python main.py test.stp test.glb
|
||||
@ -32,13 +69,27 @@ python main.py test.stp test.glb
|
||||
|
||||
## 关键技术细节
|
||||
|
||||
1. **转换流程**: STP → STL(临时文件) → GLB → 清理STL临时文件
|
||||
2. **STL设置**: 使用二进制模式,线性偏差0.01,角度偏差0.1
|
||||
3. **GLB转换**: 使用trimesh库直接处理STL到GLB转换
|
||||
4. **自动优化**:
|
||||
- 智能缩放:超过1000单位的模型自动缩放到50单位以内
|
||||
- 自动居中:模型质心移动到原点,便于Blender查看
|
||||
5. **错误处理**: 包含文件存在检查、网格验证和临时文件清理
|
||||
### 转换流程
|
||||
1. **STP → STL**: 使用pythonocc-core读取STP文件,生成临时STL文件
|
||||
2. **STL → GLB**: 使用trimesh库将STL转换为GLB格式
|
||||
3. **自动清理**: 转换完成后自动删除临时STL文件
|
||||
|
||||
### 服务化特性
|
||||
1. **异步处理**: 使用asyncio实现并发转换任务
|
||||
2. **进度追踪**: 实时更新转换进度(0-100%)
|
||||
3. **任务管理**: 内存存储任务状态,支持查询和管理
|
||||
4. **RESTful API**: 标准HTTP接口,支持跨语言调用
|
||||
|
||||
### 转换配置
|
||||
- **STL设置**: 二进制模式,线性偏差0.01,角度偏差0.1
|
||||
- **自动优化**:
|
||||
- 智能缩放:超过1000单位的模型自动缩放到50单位以内
|
||||
- 自动居中:模型质心移动到原点,便于Blender查看
|
||||
|
||||
### 错误处理
|
||||
- 完整的文件验证和网格检查
|
||||
- 异常捕获和错误信息反馈
|
||||
- 临时文件自动清理机制
|
||||
|
||||
## 重要注意事项
|
||||
|
||||
|
||||
228
README.md
Normal file
228
README.md
Normal file
@ -0,0 +1,228 @@
|
||||
# STP to GLB Converter Service
|
||||
|
||||
一个强大的STP到GLB转换服务,提供CLI和Web API两种使用方式,特别优化了在Blender中的显示效果。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **双模式支持**: CLI命令行工具 + Web API服务
|
||||
- **异步处理**: 支持并发转换请求,实时进度追踪
|
||||
- **格式转换**: STP → STL → GLB 完整转换流程
|
||||
- **智能缩放**: 自动将过大模型缩放到适合3D软件的尺寸
|
||||
- **自动居中**: 将模型质心移动到原点,便于查看
|
||||
- **RESTful API**: 标准HTTP接口,支持跨语言调用
|
||||
- **任务管理**: 完整的任务生命周期管理和状态追踪
|
||||
- **样式兼容**: 正确处理STP文件中的样式信息,不影响几何转换
|
||||
- **Blender优化**: 生成的GLB文件可直接在Blender中正常显示
|
||||
|
||||
## 系统要求
|
||||
|
||||
- Python 3.7+
|
||||
- Windows/Linux/macOS
|
||||
|
||||
## 安装步骤
|
||||
|
||||
### 使用requirements.txt(推荐)
|
||||
|
||||
```bash
|
||||
# 克隆或下载项目
|
||||
git clone <项目地址>
|
||||
cd TellmeStpToGlb
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 手动安装依赖
|
||||
|
||||
```bash
|
||||
# 安装所有依赖
|
||||
pip install pythonocc-core trimesh fastapi uvicorn pydantic
|
||||
```
|
||||
|
||||
### 方法三:使用conda(可选)
|
||||
|
||||
```bash
|
||||
# 创建虚拟环境
|
||||
conda create -n stp2glb python=3.9
|
||||
conda activate stp2glb
|
||||
|
||||
# 安装pythonocc-core
|
||||
conda install -c conda-forge pythonocc-core
|
||||
|
||||
# 安装trimesh
|
||||
pip install trimesh
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### CLI模式(命令行)
|
||||
|
||||
```bash
|
||||
python main.py input.stp output.glb
|
||||
```
|
||||
|
||||
#### CLI示例
|
||||
|
||||
```bash
|
||||
# 转换单个文件
|
||||
python main.py model.stp model.glb
|
||||
|
||||
# 转换测试文件
|
||||
python main.py test.stp result.glb
|
||||
```
|
||||
|
||||
### API模式(Web服务)
|
||||
|
||||
#### 启动服务
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
python app.py
|
||||
|
||||
# 或使用uvicorn
|
||||
uvicorn app:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# 生产模式
|
||||
uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
```
|
||||
|
||||
#### API接口
|
||||
|
||||
**健康检查**
|
||||
```bash
|
||||
GET http://localhost:8000/health
|
||||
```
|
||||
|
||||
**提交转换任务**
|
||||
```bash
|
||||
POST http://localhost:8000/api/v1/convert
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"input_path": "/path/to/input.stp",
|
||||
"output_path": "/path/to/output.glb",
|
||||
"options": {
|
||||
"auto_scale": true,
|
||||
"auto_center": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**查询任务状态**
|
||||
```bash
|
||||
GET http://localhost:8000/api/v1/status/{task_id}
|
||||
```
|
||||
|
||||
**获取任务列表**
|
||||
```bash
|
||||
GET http://localhost:8000/api/v1/tasks
|
||||
```
|
||||
|
||||
#### API文档
|
||||
|
||||
服务启动后可访问:
|
||||
- Swagger UI: `http://localhost:8000/docs`
|
||||
- ReDoc: `http://localhost:8000/redoc`
|
||||
|
||||
## 在Blender中使用
|
||||
|
||||
1. 打开Blender
|
||||
2. 删除默认立方体(可选)
|
||||
3. 文件 → 导入 → glTF 2.0 (.glb/.gltf)
|
||||
4. 选择转换生成的GLB文件
|
||||
5. 模型应该在视口中心正常显示
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 转换流程
|
||||
1. **STP读取**: 使用pythonocc-core读取STP文件
|
||||
2. **STL生成**: 转换为高质量STL网格(线性偏差0.01)
|
||||
3. **智能处理**: 自动缩放和居中处理
|
||||
4. **GLB导出**: 使用trimesh生成最终GLB文件
|
||||
5. **清理**: 自动删除临时STL文件
|
||||
|
||||
### 服务化特性
|
||||
- **异步处理**: 使用asyncio实现并发转换任务
|
||||
- **进度追踪**: 实时更新转换进度(0-100%)
|
||||
- **任务管理**: 内存存储任务状态,支持查询和管理
|
||||
- **RESTful API**: 标准HTTP接口,支持跨语言调用
|
||||
- **错误处理**: 完整的异常捕获和错误信息反馈
|
||||
|
||||
### 智能优化
|
||||
- **尺寸检测**: 自动检测模型最大尺寸
|
||||
- **智能缩放**: 超过1000单位的模型自动缩放到50单位以内
|
||||
- **质心居中**: 模型自动移动到坐标原点
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
**1. 导入错误**
|
||||
```
|
||||
ImportError: cannot import name 'xxx' from 'OCC.Core'
|
||||
```
|
||||
- 解决方案: 确保安装了正确版本的pythonocc-core
|
||||
- 使用conda安装: `conda install -c conda-forge pythonocc-core`
|
||||
|
||||
**2. 样式警告**
|
||||
```
|
||||
OVER_RIDING_STYLED_ITEM warnings
|
||||
```
|
||||
- 这是正常现象,不影响几何转换
|
||||
- 警告信息可以忽略
|
||||
|
||||
**3. Blender中看不见模型**
|
||||
- 本工具已自动处理此问题
|
||||
- 如仍有问题,在Blender中按数字键盘 `.` 键聚焦到对象
|
||||
|
||||
**4. 模型太大或太小**
|
||||
- 工具已内置智能缩放功能
|
||||
- 大模型会自动缩放到合适尺寸
|
||||
|
||||
### 依赖问题
|
||||
|
||||
如果遇到依赖安装问题:
|
||||
|
||||
```bash
|
||||
# 升级pip
|
||||
pip install --upgrade pip
|
||||
|
||||
# 清理缓存重新安装
|
||||
pip cache purge
|
||||
pip install -r requirements.txt --no-cache-dir
|
||||
```
|
||||
|
||||
## 支持的格式
|
||||
|
||||
- **输入**: STP/STEP文件
|
||||
- **输出**: GLB文件(glTF 2.0二进制格式)
|
||||
|
||||
## 性能说明
|
||||
|
||||
- 转换速度取决于模型复杂度
|
||||
- 大模型(几万面片)通常需要几秒到几十秒
|
||||
- 内存使用量与模型大小成正比
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目使用MIT许可证,详见LICENSE文件。
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交Issue和Pull Request来改进这个工具。
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v2.0.0
|
||||
- 🚀 服务化改造,支持Web API
|
||||
- ⚡ 异步处理和并发转换
|
||||
- 📊 实时进度追踪
|
||||
- 🔧 任务管理系统
|
||||
- 📖 自动生成API文档
|
||||
- 🔄 保持CLI模式向后兼容
|
||||
|
||||
### v1.0.0
|
||||
- 初始版本发布
|
||||
- 支持STP到GLB转换
|
||||
- 智能缩放和居中功能
|
||||
- Blender显示优化
|
||||
BIN
__pycache__/app.cpython-39.pyc
Normal file
BIN
__pycache__/app.cpython-39.pyc
Normal file
Binary file not shown.
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
BIN
api/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
api/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/routes.cpython-39.pyc
Normal file
BIN
api/__pycache__/routes.cpython-39.pyc
Normal file
Binary file not shown.
BIN
api/__pycache__/schemas.cpython-39.pyc
Normal file
BIN
api/__pycache__/schemas.cpython-39.pyc
Normal file
Binary file not shown.
134
api/routes.py
Normal file
134
api/routes.py
Normal file
@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
API路由定义
|
||||
"""
|
||||
|
||||
import os
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from api.schemas import (
|
||||
ConvertRequestSchema,
|
||||
ConvertResponseSchema,
|
||||
TaskStatusSchema,
|
||||
HealthResponseSchema
|
||||
)
|
||||
from services.task_manager import task_manager
|
||||
from core.models import ConvertOptions, TaskStatus
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponseSchema)
|
||||
async def health_check():
|
||||
"""健康检查接口"""
|
||||
return HealthResponseSchema()
|
||||
|
||||
|
||||
@router.post("/api/v1/convert", response_model=ConvertResponseSchema)
|
||||
async def convert_file(request: ConvertRequestSchema):
|
||||
"""转换文件接口"""
|
||||
|
||||
# 验证输入文件是否存在
|
||||
if not os.path.isfile(request.input_path):
|
||||
raise HTTPException(status_code=400, detail=f"输入文件不存在: {request.input_path}")
|
||||
|
||||
# 验证输入文件扩展名
|
||||
if not request.input_path.lower().endswith(('.stp', '.step')):
|
||||
raise HTTPException(status_code=400, detail="输入文件必须是STP或STEP格式")
|
||||
|
||||
# 验证输出文件扩展名
|
||||
if not request.output_path.lower().endswith('.glb'):
|
||||
raise HTTPException(status_code=400, detail="输出文件必须是GLB格式")
|
||||
|
||||
# 确保输出目录存在
|
||||
output_dir = os.path.dirname(request.output_path)
|
||||
if output_dir and not os.path.exists(output_dir):
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"无法创建输出目录: {str(e)}")
|
||||
|
||||
# 创建转换选项
|
||||
options = ConvertOptions(
|
||||
auto_scale=request.options.auto_scale,
|
||||
auto_center=request.options.auto_center
|
||||
)
|
||||
|
||||
# 创建任务
|
||||
task = task_manager.create_task(
|
||||
input_path=request.input_path,
|
||||
output_path=request.output_path,
|
||||
options=options
|
||||
)
|
||||
|
||||
# 启动任务
|
||||
success = await task_manager.start_task(task.task_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="任务启动失败")
|
||||
|
||||
return ConvertResponseSchema(
|
||||
task_id=task.task_id,
|
||||
status=task.status.value,
|
||||
message="任务已创建并开始处理"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/status/{task_id}", response_model=TaskStatusSchema)
|
||||
async def get_task_status(task_id: str):
|
||||
"""获取任务状态接口"""
|
||||
|
||||
task = task_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
return TaskStatusSchema(
|
||||
task_id=task.task_id,
|
||||
status=task.status.value,
|
||||
progress=task.progress,
|
||||
message=task.message,
|
||||
created_at=task.created_at,
|
||||
started_at=task.started_at,
|
||||
completed_at=task.completed_at,
|
||||
error_message=task.error_message
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api/v1/task/{task_id}")
|
||||
async def cancel_task(task_id: str):
|
||||
"""取消任务接口"""
|
||||
|
||||
task = task_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED]:
|
||||
raise HTTPException(status_code=400, detail="任务已完成,无法取消")
|
||||
|
||||
success = task_manager.cancel_task(task_id)
|
||||
if success:
|
||||
return {"message": "任务已取消"}
|
||||
else:
|
||||
return {"message": "任务未在运行中"}
|
||||
|
||||
|
||||
@router.get("/api/v1/tasks")
|
||||
async def list_tasks():
|
||||
"""获取所有任务列表接口"""
|
||||
|
||||
tasks = task_manager.get_all_tasks()
|
||||
task_list = []
|
||||
|
||||
for task_id, task in tasks.items():
|
||||
task_list.append({
|
||||
"task_id": task.task_id,
|
||||
"status": task.status.value,
|
||||
"progress": task.progress,
|
||||
"input_path": task.input_path,
|
||||
"output_path": task.output_path,
|
||||
"created_at": task.created_at,
|
||||
"message": task.message
|
||||
})
|
||||
|
||||
# 按创建时间倒序排列
|
||||
task_list.sort(key=lambda x: x["created_at"], reverse=True)
|
||||
|
||||
return {"tasks": task_list, "total": len(task_list)}
|
||||
47
api/schemas.py
Normal file
47
api/schemas.py
Normal file
@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
API数据模式定义
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ConvertOptionsSchema(BaseModel):
|
||||
"""转换选项模式"""
|
||||
auto_scale: bool = Field(default=True, description="是否自动缩放")
|
||||
auto_center: bool = Field(default=True, description="是否自动居中")
|
||||
|
||||
|
||||
class ConvertRequestSchema(BaseModel):
|
||||
"""转换请求模式"""
|
||||
input_path: str = Field(..., description="输入STP文件路径")
|
||||
output_path: str = Field(..., description="输出GLB文件路径")
|
||||
options: ConvertOptionsSchema = Field(default_factory=ConvertOptionsSchema, description="转换选项")
|
||||
|
||||
|
||||
class ConvertResponseSchema(BaseModel):
|
||||
"""转换响应模式"""
|
||||
task_id: str = Field(..., description="任务ID")
|
||||
status: str = Field(..., description="任务状态")
|
||||
message: str = Field(default="", description="状态消息")
|
||||
|
||||
|
||||
class TaskStatusSchema(BaseModel):
|
||||
"""任务状态模式"""
|
||||
task_id: str = Field(..., description="任务ID")
|
||||
status: str = Field(..., description="任务状态")
|
||||
progress: int = Field(..., description="进度百分比(0-100)")
|
||||
message: str = Field(default="", description="状态消息")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
started_at: Optional[datetime] = Field(default=None, description="开始时间")
|
||||
completed_at: Optional[datetime] = Field(default=None, description="完成时间")
|
||||
error_message: Optional[str] = Field(default=None, description="错误信息")
|
||||
|
||||
|
||||
class HealthResponseSchema(BaseModel):
|
||||
"""健康检查响应模式"""
|
||||
status: str = Field(default="healthy", description="服务状态")
|
||||
timestamp: datetime = Field(default_factory=datetime.now, description="检查时间")
|
||||
version: str = Field(default="1.0.0", description="服务版本")
|
||||
88
app.py
Normal file
88
app.py
Normal file
@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FastAPI应用入口
|
||||
STP到GLB转换服务
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from api.routes import router
|
||||
from services.task_manager import task_manager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
# 启动时的初始化
|
||||
print("🚀 STP到GLB转换服务启动中...")
|
||||
|
||||
# 启动定期清理任务
|
||||
cleanup_task = asyncio.create_task(periodic_cleanup())
|
||||
|
||||
yield
|
||||
|
||||
# 关闭时的清理
|
||||
print("🛑 STP到GLB转换服务关闭中...")
|
||||
cleanup_task.cancel()
|
||||
try:
|
||||
await cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
async def periodic_cleanup():
|
||||
"""定期清理已完成的任务"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(300) # 每5分钟清理一次
|
||||
task_manager.cleanup_completed_tasks(max_tasks=100)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"定期清理任务出错: {e}")
|
||||
|
||||
|
||||
# 创建FastAPI应用
|
||||
app = FastAPI(
|
||||
title="STP到GLB转换服务",
|
||||
description="提供STP格式到GLB格式的3D模型转换服务",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# 配置CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 在生产环境中应该限制具体的域名
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(router)
|
||||
|
||||
# 全局异常处理
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
"""全局异常处理器"""
|
||||
print(f"未处理的异常: {exc}")
|
||||
return HTTPException(status_code=500, detail="服务器内部错误")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
print("启动STP到GLB转换服务...")
|
||||
print("API文档地址: http://localhost:8000/docs")
|
||||
print("健康检查: http://localhost:8000/health")
|
||||
|
||||
uvicorn.run(
|
||||
"app:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True, # 开发模式,生产环境应设为False
|
||||
log_level="info"
|
||||
)
|
||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
BIN
core/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
core/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/converter.cpython-39.pyc
Normal file
BIN
core/__pycache__/converter.cpython-39.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/models.cpython-39.pyc
Normal file
BIN
core/__pycache__/models.cpython-39.pyc
Normal file
Binary file not shown.
98
core/converter.py
Normal file
98
core/converter.py
Normal file
@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
STP到GLB转换器类
|
||||
封装原有转换逻辑,支持进度回调
|
||||
"""
|
||||
|
||||
import os
|
||||
import trimesh
|
||||
from typing import Callable, Optional
|
||||
from OCC.Extend.DataExchange import read_step_file, write_stl_file
|
||||
|
||||
|
||||
class StpToGlbConverter:
|
||||
"""STP到GLB转换器"""
|
||||
|
||||
def __init__(self):
|
||||
self.progress_callback: Optional[Callable[[int, str], None]] = None
|
||||
|
||||
def set_progress_callback(self, callback: Callable[[int, str], None]) -> None:
|
||||
"""设置进度回调函数"""
|
||||
self.progress_callback = callback
|
||||
|
||||
def _update_progress(self, progress: int, message: str) -> None:
|
||||
"""更新进度"""
|
||||
if self.progress_callback:
|
||||
self.progress_callback(progress, message)
|
||||
|
||||
def stp_to_stl(self, stp_path: str, stl_path: str) -> None:
|
||||
"""pythonocc 读 STP 写二进制 STL"""
|
||||
self._update_progress(5, "检查输入文件...")
|
||||
|
||||
if not os.path.isfile(stp_path):
|
||||
raise FileNotFoundError(f"找不到文件:{stp_path}")
|
||||
|
||||
self._update_progress(20, "读取STP文件...")
|
||||
shape = read_step_file(stp_path)
|
||||
|
||||
if shape is None:
|
||||
raise ValueError(f"无法读取STP文件:{stp_path}")
|
||||
|
||||
self._update_progress(40, "转换为STL格式...")
|
||||
write_stl_file(shape, stl_path, mode="binary", linear_deflection=0.01, angular_deflection=0.1)
|
||||
|
||||
self._update_progress(60, "STL文件生成完成")
|
||||
|
||||
def stl_to_glb(self, stl_path: str, glb_path: str, auto_scale: bool = True, auto_center: bool = True) -> None:
|
||||
"""使用 trimesh 转换 STL 到 GLB,并优化用于Blender"""
|
||||
self._update_progress(65, "检查STL文件...")
|
||||
|
||||
if not os.path.isfile(stl_path):
|
||||
raise FileNotFoundError(f"找不到STL文件:{stl_path}")
|
||||
|
||||
self._update_progress(70, "加载STL网格...")
|
||||
mesh = trimesh.load(stl_path)
|
||||
|
||||
if mesh.is_empty:
|
||||
raise ValueError(f"STL文件为空或无效:{stl_path}")
|
||||
|
||||
if len(mesh.vertices) == 0 or len(mesh.faces) == 0:
|
||||
raise ValueError("网格数据无效")
|
||||
|
||||
self._update_progress(80, "优化网格...")
|
||||
|
||||
# 自动缩放和居中
|
||||
if auto_scale or auto_center:
|
||||
bounds = mesh.bounds
|
||||
max_dimension = max(bounds[1] - bounds[0])
|
||||
|
||||
if auto_scale and max_dimension > 1000:
|
||||
scale_factor = 50.0 / max_dimension
|
||||
mesh.apply_scale(scale_factor)
|
||||
|
||||
if auto_center:
|
||||
mesh.apply_translation(-mesh.centroid)
|
||||
|
||||
self._update_progress(90, "导出GLB文件...")
|
||||
|
||||
try:
|
||||
mesh.export(glb_path)
|
||||
if os.path.getsize(glb_path) == 0:
|
||||
raise ValueError("生成的GLB文件为空")
|
||||
except Exception as e:
|
||||
raise ValueError(f"GLB导出失败:{e}")
|
||||
|
||||
self._update_progress(100, "转换完成")
|
||||
|
||||
def convert(self, stp_path: str, glb_path: str, auto_scale: bool = True, auto_center: bool = True) -> None:
|
||||
"""完整的STP到GLB转换流程"""
|
||||
stl_tmp = os.path.splitext(glb_path)[0] + ".tmp.stl"
|
||||
|
||||
try:
|
||||
self._update_progress(0, "开始转换...")
|
||||
self.stp_to_stl(stp_path, stl_tmp)
|
||||
self.stl_to_glb(stl_tmp, glb_path, auto_scale, auto_center)
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if os.path.isfile(stl_tmp):
|
||||
os.remove(stl_tmp)
|
||||
44
core/models.py
Normal file
44
core/models.py
Normal file
@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
核心数据模型定义
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
"""任务状态枚举"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertOptions:
|
||||
"""转换选项"""
|
||||
auto_scale: bool = True
|
||||
auto_center: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertTask:
|
||||
"""转换任务"""
|
||||
task_id: str
|
||||
input_path: str
|
||||
output_path: str
|
||||
options: ConvertOptions
|
||||
status: TaskStatus = TaskStatus.PENDING
|
||||
progress: int = 0
|
||||
message: str = ""
|
||||
created_at: datetime = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.created_at is None:
|
||||
self.created_at = datetime.now()
|
||||
70
main.py
70
main.py
@ -2,67 +2,37 @@
|
||||
"""
|
||||
stp2glb.py 一键 STP → STL → GLB
|
||||
依赖:pythonocc-core + trimesh
|
||||
用法:python stp2glb.py model.stp model.glb
|
||||
用法:python main.py model.stp model.glb
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import trimesh
|
||||
from OCC.Extend.DataExchange import read_step_file, write_stl_file
|
||||
from core.converter import StpToGlbConverter
|
||||
|
||||
def stp2stl(stp_path, stl_path):
|
||||
"""pythonocc 读 STP 写二进制 STL"""
|
||||
if not os.path.isfile(stp_path):
|
||||
sys.exit(f"❌ 找不到文件:{stp_path}")
|
||||
|
||||
shape = read_step_file(stp_path)
|
||||
|
||||
if shape is None:
|
||||
sys.exit(f"❌ 无法读取STP文件:{stp_path}")
|
||||
|
||||
write_stl_file(shape, stl_path, mode="binary", linear_deflection=0.01, angular_deflection=0.1)
|
||||
|
||||
def stl2glb(stl_path, glb_path):
|
||||
"""使用 trimesh 转换 STL 到 GLB,并优化用于Blender"""
|
||||
if not os.path.isfile(stl_path):
|
||||
sys.exit(f"❌ 找不到STL文件:{stl_path}")
|
||||
def main():
|
||||
"""CLI主函数,保持向后兼容"""
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit("用法: python main.py input.stp output.glb")
|
||||
|
||||
mesh = trimesh.load(stl_path)
|
||||
stp_file, glb_file = sys.argv[1], sys.argv[2]
|
||||
|
||||
if mesh.is_empty:
|
||||
sys.exit(f"❌ STL文件为空或无效:{stl_path}")
|
||||
# 创建转换器实例
|
||||
converter = StpToGlbConverter()
|
||||
|
||||
if len(mesh.vertices) == 0 or len(mesh.faces) == 0:
|
||||
sys.exit(f"❌ 网格数据无效")
|
||||
# 设置进度回调(CLI模式显示简单进度)
|
||||
def show_progress(progress: int, message: str):
|
||||
if progress % 20 == 0 or progress == 100: # 每20%显示一次
|
||||
print(f"[{progress:3d}%] {message}")
|
||||
|
||||
# 自动缩放和居中
|
||||
bounds = mesh.bounds
|
||||
max_dimension = max(bounds[1] - bounds[0])
|
||||
|
||||
if max_dimension > 1000:
|
||||
scale_factor = 50.0 / max_dimension
|
||||
mesh.apply_scale(scale_factor)
|
||||
|
||||
mesh.apply_translation(-mesh.centroid)
|
||||
converter.set_progress_callback(show_progress)
|
||||
|
||||
try:
|
||||
mesh.export(glb_path)
|
||||
if os.path.getsize(glb_path) == 0:
|
||||
sys.exit(f"❌ 生成的GLB文件为空")
|
||||
# 执行转换
|
||||
converter.convert(stp_file, glb_file)
|
||||
print(f"✅ 完成:{glb_file}")
|
||||
except Exception as e:
|
||||
sys.exit(f"❌ GLB导出失败:{e}")
|
||||
sys.exit(f"❌ 转换失败:{e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit("用法: python stp2glb.py input.stp output.glb")
|
||||
stp_file, glb_file = sys.argv[1], sys.argv[2]
|
||||
stl_tmp = os.path.splitext(glb_file)[0] + ".tmp.stl"
|
||||
|
||||
try:
|
||||
stp2stl(stp_file, stl_tmp)
|
||||
stl2glb(stl_tmp, glb_file)
|
||||
finally:
|
||||
if os.path.isfile(stl_tmp):
|
||||
os.remove(stl_tmp)
|
||||
|
||||
print(f"✅ 完成:{glb_file}")
|
||||
main()
|
||||
BIN
output.glb
Normal file
BIN
output.glb
Normal file
Binary file not shown.
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
pythonocc-core>=7.7.0
|
||||
trimesh>=3.15.0
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
pydantic>=2.5.0
|
||||
0
services/__init__.py
Normal file
0
services/__init__.py
Normal file
BIN
services/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
services/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
services/__pycache__/task_manager.cpython-39.pyc
Normal file
BIN
services/__pycache__/task_manager.cpython-39.pyc
Normal file
Binary file not shown.
142
services/task_manager.py
Normal file
142
services/task_manager.py
Normal file
@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
任务管理服务
|
||||
负责任务的创建、跟踪和状态管理
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
from core.models import ConvertTask, TaskStatus, ConvertOptions
|
||||
from core.converter import StpToGlbConverter
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""任务管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.tasks: Dict[str, ConvertTask] = {}
|
||||
self.running_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
def create_task(self, input_path: str, output_path: str, options: ConvertOptions) -> ConvertTask:
|
||||
"""创建新任务"""
|
||||
task_id = str(uuid.uuid4())
|
||||
task = ConvertTask(
|
||||
task_id=task_id,
|
||||
input_path=input_path,
|
||||
output_path=output_path,
|
||||
options=options
|
||||
)
|
||||
self.tasks[task_id] = task
|
||||
return task
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[ConvertTask]:
|
||||
"""获取任务"""
|
||||
return self.tasks.get(task_id)
|
||||
|
||||
def get_all_tasks(self) -> Dict[str, ConvertTask]:
|
||||
"""获取所有任务"""
|
||||
return self.tasks.copy()
|
||||
|
||||
async def process_task(self, task_id: str) -> None:
|
||||
"""处理转换任务"""
|
||||
task = self.tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
|
||||
# 更新任务状态
|
||||
task.status = TaskStatus.PROCESSING
|
||||
task.started_at = datetime.now()
|
||||
task.message = "开始处理转换任务"
|
||||
|
||||
converter = StpToGlbConverter()
|
||||
|
||||
# 设置进度回调
|
||||
def progress_callback(progress: int, message: str):
|
||||
task.progress = progress
|
||||
task.message = message
|
||||
|
||||
converter.set_progress_callback(progress_callback)
|
||||
|
||||
try:
|
||||
# 执行转换
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
converter.convert,
|
||||
task.input_path,
|
||||
task.output_path,
|
||||
task.options.auto_scale,
|
||||
task.options.auto_center
|
||||
)
|
||||
|
||||
# 标记完成
|
||||
task.status = TaskStatus.COMPLETED
|
||||
task.completed_at = datetime.now()
|
||||
task.progress = 100
|
||||
task.message = "转换完成"
|
||||
|
||||
except Exception as e:
|
||||
# 标记失败
|
||||
task.status = TaskStatus.FAILED
|
||||
task.completed_at = datetime.now()
|
||||
task.error_message = str(e)
|
||||
task.message = f"转换失败: {str(e)}"
|
||||
|
||||
finally:
|
||||
# 清理运行任务记录
|
||||
if task_id in self.running_tasks:
|
||||
del self.running_tasks[task_id]
|
||||
|
||||
async def start_task(self, task_id: str) -> bool:
|
||||
"""启动任务处理"""
|
||||
if task_id not in self.tasks:
|
||||
return False
|
||||
|
||||
if task_id in self.running_tasks:
|
||||
return False # 任务已在运行
|
||||
|
||||
# 创建异步任务
|
||||
asyncio_task = asyncio.create_task(self.process_task(task_id))
|
||||
self.running_tasks[task_id] = asyncio_task
|
||||
|
||||
return True
|
||||
|
||||
def cancel_task(self, task_id: str) -> bool:
|
||||
"""取消任务"""
|
||||
if task_id in self.running_tasks:
|
||||
self.running_tasks[task_id].cancel()
|
||||
del self.running_tasks[task_id]
|
||||
|
||||
task = self.tasks.get(task_id)
|
||||
if task:
|
||||
task.status = TaskStatus.FAILED
|
||||
task.error_message = "任务被取消"
|
||||
task.message = "任务已取消"
|
||||
task.completed_at = datetime.now()
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
def cleanup_completed_tasks(self, max_tasks: int = 100) -> None:
|
||||
"""清理已完成的任务,保持任务数量在限制内"""
|
||||
if len(self.tasks) <= max_tasks:
|
||||
return
|
||||
|
||||
# 按完成时间排序,保留最新的任务
|
||||
completed_tasks = [
|
||||
(task_id, task) for task_id, task in self.tasks.items()
|
||||
if task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED] and task.completed_at
|
||||
]
|
||||
|
||||
if len(completed_tasks) > max_tasks // 2:
|
||||
# 删除最老的已完成任务
|
||||
completed_tasks.sort(key=lambda x: x[1].completed_at)
|
||||
to_remove = completed_tasks[:len(completed_tasks) - max_tasks // 2]
|
||||
|
||||
for task_id, _ in to_remove:
|
||||
del self.tasks[task_id]
|
||||
|
||||
|
||||
# 全局任务管理器实例
|
||||
task_manager = TaskManager()
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
88
utils/helpers.py
Normal file
88
utils/helpers.py
Normal file
@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
工具函数
|
||||
提供通用的辅助功能
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def generate_task_id() -> str:
|
||||
"""生成唯一任务ID"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def validate_file_path(file_path: str, must_exist: bool = True) -> bool:
|
||||
"""验证文件路径"""
|
||||
if not file_path:
|
||||
return False
|
||||
|
||||
if must_exist:
|
||||
return os.path.isfile(file_path)
|
||||
|
||||
# 检查父目录是否存在或可创建
|
||||
parent_dir = os.path.dirname(file_path)
|
||||
if parent_dir and not os.path.exists(parent_dir):
|
||||
try:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_file_extension(file_path: str) -> str:
|
||||
"""获取文件扩展名(小写)"""
|
||||
return os.path.splitext(file_path)[1].lower()
|
||||
|
||||
|
||||
def is_stp_file(file_path: str) -> bool:
|
||||
"""检查是否为STP文件"""
|
||||
ext = get_file_extension(file_path)
|
||||
return ext in ['.stp', '.step']
|
||||
|
||||
|
||||
def is_glb_file(file_path: str) -> bool:
|
||||
"""检查是否为GLB文件"""
|
||||
ext = get_file_extension(file_path)
|
||||
return ext == '.glb'
|
||||
|
||||
|
||||
def format_datetime(dt: Optional[datetime]) -> Optional[str]:
|
||||
"""格式化日期时间"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def get_file_size_mb(file_path: str) -> float:
|
||||
"""获取文件大小(MB)"""
|
||||
if not os.path.isfile(file_path):
|
||||
return 0.0
|
||||
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
return size_bytes / (1024 * 1024)
|
||||
|
||||
|
||||
def cleanup_temp_files(base_path: str) -> None:
|
||||
"""清理临时文件"""
|
||||
temp_extensions = ['.tmp', '.temp', '.tmp.stl']
|
||||
base_dir = os.path.dirname(base_path)
|
||||
base_name = os.path.splitext(os.path.basename(base_path))[0]
|
||||
|
||||
if not os.path.exists(base_dir):
|
||||
return
|
||||
|
||||
for file_name in os.listdir(base_dir):
|
||||
if file_name.startswith(base_name):
|
||||
for ext in temp_extensions:
|
||||
if file_name.endswith(ext):
|
||||
temp_file = os.path.join(base_dir, file_name)
|
||||
try:
|
||||
os.remove(temp_file)
|
||||
except Exception:
|
||||
pass # 忽略删除失败的情况
|
||||
Loading…
Reference in New Issue
Block a user