2026-05-11 14:15:39 +08:00
|
|
|
|
# services/processing_service.py
|
|
|
|
|
|
"""STP 文件处理流程编排器 — 协调解析、网格生成、型腔生成、保存、验证"""
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2026-08-31 18:01:34 +08:00
|
|
|
|
import os
|
2026-09-16 17:55:04 +08:00
|
|
|
|
import shutil
|
|
|
|
|
|
import tempfile
|
2026-05-14 16:56:18 +08:00
|
|
|
|
import time
|
2026-08-31 18:01:34 +08:00
|
|
|
|
from collections import OrderedDict
|
2026-05-28 17:41:02 +08:00
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
2026-05-11 14:15:39 +08:00
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from pathlib import Path
|
2026-09-16 17:55:04 +08:00
|
|
|
|
from typing import Optional, Dict, Any, List, Tuple
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
2026-09-16 17:55:04 +08:00
|
|
|
|
from sqlalchemy import select
|
2026-05-11 14:15:39 +08:00
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
2026-05-29 18:10:08 +08:00
|
|
|
|
from moldinsight.core.stp_parser import STPParser
|
|
|
|
|
|
from moldinsight.core.geometry_analyzer import GeometryAnalyzer
|
|
|
|
|
|
from moldinsight.core.mesh_generator import MeshGenerator
|
|
|
|
|
|
from moldinsight.core.multi_scheme_planner import MultiSchemeMoldPlanner
|
|
|
|
|
|
from moldinsight.core.cad_exporter import CADExporter
|
2026-09-17 16:15:49 +08:00
|
|
|
|
from moldinsight.services.task_storage_service import TaskStorageService
|
|
|
|
|
|
from moldinsight.services.analysis_storage_service import AnalysisStorageService
|
2026-09-16 17:55:04 +08:00
|
|
|
|
from moldinsight.storage.rustfs_storage import rustfs_manager
|
2026-05-29 18:10:08 +08:00
|
|
|
|
from shared.services.redis_task_manager import redis_task_manager
|
|
|
|
|
|
from moldinsight.services.material_service import MaterialService
|
|
|
|
|
|
from moldinsight.services.calculation_service import CalculationService
|
|
|
|
|
|
from moldinsight.services.llm_service import llm_service
|
|
|
|
|
|
from shared.models.schemas import ProcessingStatus
|
2026-09-17 16:15:49 +08:00
|
|
|
|
from moldinsight.models import STPFile
|
2026-05-29 18:10:08 +08:00
|
|
|
|
from shared.database.database import db_manager
|
|
|
|
|
|
from shared.utils.html_generator import HTMLGenerator
|
|
|
|
|
|
from shared.utils.logger import get_logger
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProcessingService:
|
|
|
|
|
|
"""核心处理流程编排 — 协调 STP 解析、网格、型腔、计算、保存、验证"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.stp_parser = STPParser()
|
|
|
|
|
|
self.geometry_analyzer = GeometryAnalyzer()
|
|
|
|
|
|
self.mesh_generator = MeshGenerator(quality="medium")
|
|
|
|
|
|
self.html_generator = HTMLGenerator()
|
2026-09-17 16:15:49 +08:00
|
|
|
|
# 批次 3 按职责拆分:任务/文件生命周期 与 分析结果数据(原 StorageIntegrationService)
|
|
|
|
|
|
self.task_storage = TaskStorageService()
|
|
|
|
|
|
self.analysis_storage = AnalysisStorageService()
|
2026-05-11 14:15:39 +08:00
|
|
|
|
self.multi_scheme_planner = MultiSchemeMoldPlanner()
|
2026-05-25 10:16:05 +08:00
|
|
|
|
self.cad_exporter = CADExporter()
|
2026-08-31 18:01:34 +08:00
|
|
|
|
# TopoDS_Shape 为 C++ 原生内存对象,LRU 上限防止长期运行内存只涨不降
|
|
|
|
|
|
self._export_shapes_cache: "OrderedDict[str, Dict[str, Dict[str, Any]]]" = OrderedDict()
|
|
|
|
|
|
self._export_shapes_cache_max = 32
|
2026-06-09 16:19:15 +08:00
|
|
|
|
# OCC 非线程安全,max_workers=1 保证所有 OCC 操作序列化执行,避免偶发崩溃
|
|
|
|
|
|
self._occ_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="occ")
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
# ─── 对外入口 ───
|
|
|
|
|
|
|
2026-08-31 18:01:34 +08:00
|
|
|
|
def _reset_occ_executor(self):
|
|
|
|
|
|
"""超时后重建 OCC executor。
|
|
|
|
|
|
|
|
|
|
|
|
asyncio.wait_for 只能取消协程,正在执行 OCC 布尔运算的线程无法中断;
|
|
|
|
|
|
单 worker executor 中一个挂死线程会让后续任务永久排队直至重启。
|
2026-09-17 16:15:49 +08:00
|
|
|
|
cancel_futures=True 丢弃旧 executor 中尚未开跑的排队任务(否则旧线程
|
|
|
|
|
|
恢复后仍会继续消化旧队列,与新 executor 并发操作 OCC 必然崩溃)。
|
|
|
|
|
|
已在运行中的 C++ 线程在 Python 层不可杀,仍会滞留——这是已知残留
|
|
|
|
|
|
泄漏(每次超时 1 线程),根治需进程级 OCC 隔离,见 docs/OCC_THROUGHPUT.md。
|
2026-08-31 18:01:34 +08:00
|
|
|
|
"""
|
|
|
|
|
|
old = self._occ_executor
|
|
|
|
|
|
self._occ_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="occ")
|
2026-09-17 16:15:49 +08:00
|
|
|
|
old.shutdown(wait=False, cancel_futures=True)
|
|
|
|
|
|
logger.warning("OCC executor 已因处理超时重建(排队任务已丢弃,运行中线程可能滞留 1 个)")
|
2026-08-31 18:01:34 +08:00
|
|
|
|
|
|
|
|
|
|
async def run_occ(self, fn, *args):
|
|
|
|
|
|
"""在 OCC 单线程 executor 中执行同步几何操作。
|
|
|
|
|
|
|
|
|
|
|
|
OCC 非线程安全,所有几何计算(解析/布尔/三角化)统一经由本入口串行执行,
|
|
|
|
|
|
避免各调用方自行创建线程池造成并发崩溃。
|
|
|
|
|
|
"""
|
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
return await loop.run_in_executor(self._occ_executor, fn, *args)
|
|
|
|
|
|
|
2026-09-16 17:55:04 +08:00
|
|
|
|
async def _materialize_source_file(self, stp_file: STPFile) -> Tuple[Path, Optional[Path]]:
|
|
|
|
|
|
"""把待处理文件落到本地磁盘,返回 (本地路径, 临时目录或 None)。
|
|
|
|
|
|
|
|
|
|
|
|
RustFS 为主存储:处理方按 object_key 下载到任务专属临时目录
|
|
|
|
|
|
(文件名保留原始名——下游产物命名依赖 Path(file_path).name)。
|
|
|
|
|
|
RustFS 不可用或对象缺失时回退 STPFile.file_path 记录的节点本地路径
|
|
|
|
|
|
(依赖 compose 共享卷,属过渡方案);两者皆不可用则抛错置任务失败。
|
|
|
|
|
|
"""
|
|
|
|
|
|
original_name = Path(stp_file.original_filename or "model.stp").name or "model.stp"
|
|
|
|
|
|
|
|
|
|
|
|
if stp_file.object_key:
|
|
|
|
|
|
temp_dir = Path(tempfile.mkdtemp(prefix=f"moldinsight_{stp_file.id}_"))
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = await rustfs_manager.download_file(
|
|
|
|
|
|
file_type="stp_files", object_key=stp_file.object_key
|
|
|
|
|
|
)
|
|
|
|
|
|
target = temp_dir / original_name
|
|
|
|
|
|
target.write_bytes(data)
|
|
|
|
|
|
return target, temp_dir
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"RustFS 源文件下载失败 (object_key={stp_file.object_key}),"
|
|
|
|
|
|
f"回退节点本地路径: {exc}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
local = Path(stp_file.file_path) if stp_file.file_path else None
|
|
|
|
|
|
if local and local.exists():
|
|
|
|
|
|
return local, None
|
|
|
|
|
|
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f"源文件不可用:RustFS 对象 {stp_file.object_key!r} 下载失败,"
|
|
|
|
|
|
f"且节点本地路径不存在: {stp_file.file_path!r}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-11 14:15:39 +08:00
|
|
|
|
async def process_file_with_storage(
|
|
|
|
|
|
self,
|
|
|
|
|
|
task_id: str,
|
|
|
|
|
|
stp_file_id: int,
|
2026-05-14 16:56:18 +08:00
|
|
|
|
process_params: Optional[Dict[str, Any]] = None,
|
2026-05-11 14:15:39 +08:00
|
|
|
|
):
|
2026-09-16 17:55:04 +08:00
|
|
|
|
"""处理文件的后台任务 — 使用独立数据库会话
|
|
|
|
|
|
|
|
|
|
|
|
分派入参只带 stp_file_id(D6):源文件由本方法按 PG 元数据中的
|
|
|
|
|
|
object_key 从 RustFS 获取,不再依赖分派方传入节点本地路径
|
|
|
|
|
|
(API 与 Celery worker 容器文件系统不互通)。
|
|
|
|
|
|
"""
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
# 创建独立的数据库会话,避免请求范围会话关闭
|
|
|
|
|
|
async with db_manager.session() as db_session:
|
2026-09-16 17:55:04 +08:00
|
|
|
|
temp_dir: Optional[Path] = None
|
2026-05-11 14:15:39 +08:00
|
|
|
|
try:
|
2026-09-16 17:55:04 +08:00
|
|
|
|
result = await db_session.execute(
|
|
|
|
|
|
select(STPFile).where(STPFile.id == stp_file_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
stp_file = result.scalar_one_or_none()
|
|
|
|
|
|
if not stp_file:
|
|
|
|
|
|
raise RuntimeError(f"STPFile 记录不存在: stp_file_id={stp_file_id}")
|
|
|
|
|
|
|
|
|
|
|
|
source_path, temp_dir = await self._materialize_source_file(stp_file)
|
|
|
|
|
|
file_path = str(source_path)
|
|
|
|
|
|
|
2026-05-11 14:15:39 +08:00
|
|
|
|
logger.info(f"开始处理文件并生成模具型腔: {file_path}")
|
|
|
|
|
|
|
2026-05-29 18:10:08 +08:00
|
|
|
|
from shared.config.settings import settings
|
2026-05-28 17:59:39 +08:00
|
|
|
|
|
2026-09-16 17:55:04 +08:00
|
|
|
|
file_size_bytes = source_path.stat().st_size
|
2026-05-28 17:59:39 +08:00
|
|
|
|
file_size_mb = max(file_size_bytes / (1024 * 1024), 1)
|
|
|
|
|
|
timeout_seconds = min(
|
|
|
|
|
|
max(settings.PROCESSING_TIMEOUT_BASE, int(file_size_mb * settings.PROCESSING_TIMEOUT_PER_MB)),
|
|
|
|
|
|
1800,
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(f"处理超时设置为 {timeout_seconds}s (文件 {file_size_mb:.1f}MB)")
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
await asyncio.wait_for(
|
|
|
|
|
|
self.process_file_core(
|
2026-05-14 16:56:18 +08:00
|
|
|
|
task_id, file_path, stp_file_id, db_session, process_params
|
2026-05-11 14:15:39 +08:00
|
|
|
|
),
|
|
|
|
|
|
timeout_seconds,
|
|
|
|
|
|
)
|
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
|
logger.error(f"处理超时: {task_id}")
|
2026-08-31 18:01:34 +08:00
|
|
|
|
# OCC 线程无法取消:抛弃整个 executor,避免挂死线程堵死后续所有任务
|
|
|
|
|
|
self._reset_occ_executor()
|
2026-05-11 14:15:39 +08:00
|
|
|
|
raise Exception(f"处理超时,超过{timeout_seconds}秒未完成")
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"模具型腔生成失败: {e}")
|
|
|
|
|
|
|
2026-09-16 17:55:04 +08:00
|
|
|
|
# D9:先丢弃未提交的数据本体,失败状态单独提交,
|
|
|
|
|
|
# 避免 failed 更新把半成品 flush 数据一起带上
|
|
|
|
|
|
await db_session.rollback()
|
|
|
|
|
|
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.task_storage.update_stp_file_status(db_session, stp_file_id, "failed")
|
|
|
|
|
|
await self.task_storage.update_task_status(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session, task_id, "failed", error_message=str(e)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-16 17:55:04 +08:00
|
|
|
|
# 安全更新 Redis 任务状态(Redis 仅热缓存,写失败不影响 PG 事实)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
task = await redis_task_manager.get_task(task_id)
|
|
|
|
|
|
if task:
|
|
|
|
|
|
await redis_task_manager.update_task(task_id, {
|
|
|
|
|
|
"status": ProcessingStatus.FAILED,
|
|
|
|
|
|
"error": str(e),
|
|
|
|
|
|
"completed_at": str(datetime.now()),
|
|
|
|
|
|
})
|
2026-09-16 17:55:04 +08:00
|
|
|
|
finally:
|
|
|
|
|
|
# 任务专属临时目录必须清理,长期运行不允许残留下载副本
|
|
|
|
|
|
if temp_dir:
|
|
|
|
|
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
async def process_file_core(
|
|
|
|
|
|
self,
|
|
|
|
|
|
task_id: str,
|
|
|
|
|
|
file_path: str,
|
|
|
|
|
|
stp_file_id: int,
|
|
|
|
|
|
db_session: AsyncSession,
|
2026-05-14 16:56:18 +08:00
|
|
|
|
process_params: Optional[Dict[str, Any]] = None,
|
2026-05-11 14:15:39 +08:00
|
|
|
|
):
|
|
|
|
|
|
"""核心处理逻辑"""
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
logger.info(f"开始处理文件并生成模具型腔: {file_path}")
|
2026-05-14 16:56:18 +08:00
|
|
|
|
process_params = self._normalize_process_params(process_params)
|
|
|
|
|
|
stage_timings: Dict[str, float] = {}
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
# 1. 解析STP文件
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.task_storage.update_task_status(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session, task_id, "processing", 20, "解析STP文件"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_started = time.perf_counter()
|
2026-05-28 17:41:02 +08:00
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
shape = await loop.run_in_executor(
|
|
|
|
|
|
self._occ_executor, self.stp_parser.load_step_file, Path(file_path)
|
|
|
|
|
|
)
|
|
|
|
|
|
geometry_data = await loop.run_in_executor(
|
|
|
|
|
|
self._occ_executor, self.stp_parser.analyze_geometry, shape
|
|
|
|
|
|
)
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_timings["parse_stp"] = round(time.perf_counter() - stage_started, 3)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
# 2. 生成网格数据并持久化
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.task_storage.update_task_status(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session, task_id, "processing", 30, "生成网格数据"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_started = time.perf_counter()
|
2026-05-11 14:15:39 +08:00
|
|
|
|
mesh_result = await self._step_generate_mesh(
|
|
|
|
|
|
shape, geometry_data, file_path, db_session, stp_file_id, task_id
|
|
|
|
|
|
)
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_timings["generate_mesh"] = round(time.perf_counter() - stage_started, 3)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
# 3. 生成模具型腔
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.task_storage.update_task_status(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session, task_id, "processing", 40, "生成模具型腔"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 材料属性 — 通过 MaterialService 集中管理
|
2026-05-14 16:56:18 +08:00
|
|
|
|
requested_material = MaterialService.resolve_material(process_params["material"])
|
|
|
|
|
|
selected_material = dict(MaterialService.get_material(requested_material))
|
|
|
|
|
|
selected_material["shrinkage"] = process_params["shrinkage_rate"] / 100.0
|
2026-05-11 14:15:39 +08:00
|
|
|
|
is_foam_material = MaterialService.is_foam_material(requested_material)
|
|
|
|
|
|
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_started = time.perf_counter()
|
2026-05-11 14:15:39 +08:00
|
|
|
|
plan_result = await self._step_generate_cavity(
|
2026-05-14 16:56:18 +08:00
|
|
|
|
shape, selected_material, is_foam_material, process_params
|
2026-05-11 14:15:39 +08:00
|
|
|
|
)
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_timings["generate_cavity"] = round(time.perf_counter() - stage_started, 3)
|
|
|
|
|
|
export_shapes = {}
|
2026-05-25 10:16:05 +08:00
|
|
|
|
export_artifacts = None
|
2026-05-14 16:56:18 +08:00
|
|
|
|
if plan_result:
|
|
|
|
|
|
export_shapes = plan_result.pop("_export_shapes", {}) or {}
|
|
|
|
|
|
if export_shapes:
|
|
|
|
|
|
self._cache_export_shapes(task_id, export_shapes)
|
2026-05-25 10:16:05 +08:00
|
|
|
|
export_artifacts = self._persist_step_exports(
|
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
|
original_filename=Path(file_path).name,
|
|
|
|
|
|
export_shapes=export_shapes,
|
|
|
|
|
|
)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
# 4. 生成详细JSON数据 — 委托 CalculationService
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.task_storage.update_task_status(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session, task_id, "processing", 60, "生成型腔详细数据"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_started = time.perf_counter()
|
2026-05-11 14:15:39 +08:00
|
|
|
|
detailed_cavity_json = CalculationService.build_plan_result(
|
|
|
|
|
|
geometry_data=geometry_data,
|
|
|
|
|
|
material=selected_material,
|
|
|
|
|
|
file_path=str(file_path),
|
|
|
|
|
|
plan_result=plan_result,
|
|
|
|
|
|
)
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_timings["build_plan_result"] = round(time.perf_counter() - stage_started, 3)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
|
|
|
|
|
|
best_cavity_data = best_scheme.get("cavity_data", {}) if best_scheme else {}
|
|
|
|
|
|
best_key_info = best_scheme.get("key_info", {}) if best_scheme else {}
|
|
|
|
|
|
|
|
|
|
|
|
if best_cavity_data.get("mold_cavities"):
|
|
|
|
|
|
cavity_geometry = best_cavity_data["mold_cavities"].get("cavity", {})
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"推荐方案型腔数据已合并: cavity {cavity_geometry.get('vertex_count', 0)} 顶点"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 5. 生成关键信息
|
|
|
|
|
|
cavity_key_info = best_key_info
|
|
|
|
|
|
|
|
|
|
|
|
# 6. 保存几何数据到数据库
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.task_storage.update_task_status(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session, task_id, "processing", 70, "保存几何数据"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_started = time.perf_counter()
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.analysis_storage.save_geometry_data(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session,
|
|
|
|
|
|
stp_file_id,
|
|
|
|
|
|
geometry_data,
|
|
|
|
|
|
geometry_data.get("analysis_method", "mold_cavity"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-16 17:55:04 +08:00
|
|
|
|
# 阶段 A 提交(D9):几何 + 网格原子落库——解析后的确定成果,
|
|
|
|
|
|
# 后续型腔失败任务标 failed 时这些数据仍完整保留
|
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
2026-05-11 14:15:39 +08:00
|
|
|
|
# 7. 生成HTML可视化
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.task_storage.update_task_status(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session, task_id, "processing", 85, "生成可视化报告"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
pointcloud_data = None
|
|
|
|
|
|
lod_data = None
|
|
|
|
|
|
if mesh_result:
|
2026-05-28 17:41:02 +08:00
|
|
|
|
lod0 = mesh_result.get("lods", {}).get("0", {})
|
2026-05-11 14:15:39 +08:00
|
|
|
|
pointcloud_data = {
|
|
|
|
|
|
"points": mesh_result.get("points", []),
|
|
|
|
|
|
"normals": mesh_result.get("normals", []),
|
2026-05-28 17:41:02 +08:00
|
|
|
|
"vertices": lod0.get("vertices", []),
|
|
|
|
|
|
"faces": lod0.get("faces", []),
|
2026-05-11 14:15:39 +08:00
|
|
|
|
"point_count": mesh_result.get("point_count", 0),
|
|
|
|
|
|
"vertex_count": mesh_result.get("vertex_count", 0),
|
|
|
|
|
|
"face_count": mesh_result.get("face_count", 0),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 17:41:02 +08:00
|
|
|
|
if mesh_result and mesh_result.get("lods"):
|
|
|
|
|
|
lods = mesh_result["lods"]
|
|
|
|
|
|
lod_data = mesh_result
|
|
|
|
|
|
logger.info(f"LOD数据复用成功: {len(lods)} 级 (面数: {[lods[k]['face_count'] for k in sorted(lods.keys())]})")
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
detailed_cavity_json = await self._attach_scheme_previews(
|
|
|
|
|
|
detailed_cavity_json=detailed_cavity_json,
|
|
|
|
|
|
geometry_data=geometry_data,
|
|
|
|
|
|
stp_filename=Path(file_path).name,
|
|
|
|
|
|
pointcloud_data=pointcloud_data,
|
|
|
|
|
|
lod_data=lod_data,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
|
|
|
|
|
|
best_cavity_data = best_scheme.get("cavity_data", {}) if best_scheme else best_cavity_data
|
|
|
|
|
|
best_key_info = best_scheme.get("key_info", {}) if best_scheme else best_key_info
|
|
|
|
|
|
|
|
|
|
|
|
# 8. 保存模具型腔数据(包含方案级预览链接)
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.analysis_storage.save_mold_cavity_data(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session, stp_file_id, detailed_cavity_json
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
html_file_path = self.html_generator.generate_and_save_visualization(
|
|
|
|
|
|
geometry_data,
|
|
|
|
|
|
Path(file_path).name,
|
|
|
|
|
|
cavity_data=best_cavity_data,
|
|
|
|
|
|
pointcloud_data=pointcloud_data,
|
|
|
|
|
|
lod_data=lod_data,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.analysis_storage.save_html_file(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session,
|
|
|
|
|
|
stp_file_id,
|
|
|
|
|
|
Path(html_file_path).name,
|
|
|
|
|
|
html_file_path,
|
|
|
|
|
|
)
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_timings["persist_artifacts"] = round(time.perf_counter() - stage_started, 3)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
# 9. 分析模具设计
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_started = time.perf_counter()
|
2026-05-28 17:41:02 +08:00
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
analysis_result = await loop.run_in_executor(
|
|
|
|
|
|
self._occ_executor,
|
|
|
|
|
|
lambda: self.geometry_analyzer.analyze_mold_design(
|
|
|
|
|
|
geometry_data,
|
|
|
|
|
|
product_material=requested_material,
|
|
|
|
|
|
shape=shape,
|
|
|
|
|
|
),
|
2026-05-14 16:56:18 +08:00
|
|
|
|
)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
if analysis_result:
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.analysis_storage.save_features_and_recommendations(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session,
|
|
|
|
|
|
stp_file_id,
|
|
|
|
|
|
analysis_result.get("detected_features", []),
|
|
|
|
|
|
analysis_result.get("design_recommendations", []),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await self._save_analysis_metrics(db_session, stp_file_id, analysis_result)
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_timings["analyze_design"] = round(time.perf_counter() - stage_started, 3)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
# 9.6 更新STP文件的分析摘要字段
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.task_storage.update_stp_file_analysis_summary(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session,
|
|
|
|
|
|
stp_file_id,
|
|
|
|
|
|
volume=geometry_data.get("volume", 0),
|
|
|
|
|
|
surface_area=geometry_data.get("surface_area", 0),
|
|
|
|
|
|
product_weight=CalculationService.calculate_product_weight(
|
|
|
|
|
|
geometry_data.get("volume", 0), selected_material["density"]
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-16 17:55:04 +08:00
|
|
|
|
# 9.65 阶段 B 提交(D9):型腔 / HTML / 特征 / 指标 / 摘要 / 验证指标
|
|
|
|
|
|
# 作为完整结果包原子落库——置 completed 前必须全部就位,
|
|
|
|
|
|
# 期间任一步失败回滚后任务标 failed,不会出现"completed 但数据残缺"
|
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
2026-05-11 14:15:39 +08:00
|
|
|
|
# 9.7 FreeCAD 几何验证
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_started = time.perf_counter()
|
2026-05-11 14:15:39 +08:00
|
|
|
|
verification_result = await self._step_verify(
|
|
|
|
|
|
file_path, db_session, task_id, stp_file_id, analysis_result
|
|
|
|
|
|
)
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_timings["verify_geometry"] = round(time.perf_counter() - stage_started, 3)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
2026-05-11 15:35:54 +08:00
|
|
|
|
# 9.8 LLM 增强分析
|
2026-05-11 14:15:39 +08:00
|
|
|
|
llm_report = None
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_started = time.perf_counter()
|
2026-05-11 14:15:39 +08:00
|
|
|
|
if analysis_result:
|
2026-05-18 16:45:17 +08:00
|
|
|
|
side_action_ai = await llm_service.generate_side_action_analysis(
|
|
|
|
|
|
analysis_result, detailed_cavity_json
|
|
|
|
|
|
)
|
|
|
|
|
|
design_report = await llm_service.generate_design_report(
|
|
|
|
|
|
analysis_result, detailed_cavity_json
|
|
|
|
|
|
)
|
|
|
|
|
|
llm_report = llm_service.compose_llm_report(
|
|
|
|
|
|
design_report, side_action_ai
|
|
|
|
|
|
)
|
2026-05-14 16:56:18 +08:00
|
|
|
|
stage_timings["generate_llm_report"] = round(time.perf_counter() - stage_started, 3)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
2026-09-16 17:55:04 +08:00
|
|
|
|
# 10. 完成处理——先 flush 任务参数,完成状态提交时一并原子落库(D9)
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.task_storage.update_task_parameters(
|
2026-05-14 16:56:18 +08:00
|
|
|
|
db_session,
|
|
|
|
|
|
task_id,
|
|
|
|
|
|
{
|
|
|
|
|
|
"stage_timings": stage_timings,
|
|
|
|
|
|
"material": requested_material,
|
|
|
|
|
|
"verification": verification_result,
|
|
|
|
|
|
"llm_report": llm_report,
|
2026-05-25 10:16:05 +08:00
|
|
|
|
"export_artifacts": export_artifacts,
|
2026-05-14 16:56:18 +08:00
|
|
|
|
**process_params,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.task_storage.update_stp_file_status(db_session, stp_file_id, "completed")
|
|
|
|
|
|
await self.task_storage.update_task_status(
|
2026-09-16 17:55:04 +08:00
|
|
|
|
db_session, task_id, "completed", 100, "模具型腔生成完成"
|
|
|
|
|
|
)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
2026-08-31 18:01:34 +08:00
|
|
|
|
# 更新任务缓存状态(仅保留轻量摘要,完整数据由PG+RustFS持久化;
|
|
|
|
|
|
# 完成态视图由 TaskQueryService 从 PG+RustFS 组装,Redis 不再存
|
|
|
|
|
|
# geometry_data / analysis_result 等 MB 级大对象)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
await redis_task_manager.update_task(task_id, {
|
2026-05-28 17:41:02 +08:00
|
|
|
|
"status": ProcessingStatus.COMPLETED,
|
|
|
|
|
|
"completed_at": str(datetime.now()),
|
2026-05-11 14:15:39 +08:00
|
|
|
|
"key_info": best_key_info,
|
2026-05-28 17:41:02 +08:00
|
|
|
|
"best_scheme_id": detailed_cavity_json.get("best_scheme_id"),
|
2026-05-14 16:56:18 +08:00
|
|
|
|
"material": requested_material,
|
|
|
|
|
|
"parameters": process_params,
|
|
|
|
|
|
"stage_timings": stage_timings,
|
2026-05-11 14:15:39 +08:00
|
|
|
|
"html_file": best_scheme.get("html_file", f"/html/{Path(html_file_path).name}") if best_scheme else f"/html/{Path(html_file_path).name}",
|
|
|
|
|
|
"verification": verification_result,
|
|
|
|
|
|
"llm_report": llm_report,
|
2026-05-25 10:16:05 +08:00
|
|
|
|
"export_artifacts": export_artifacts,
|
2026-05-11 14:15:39 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"模具型腔生成完成: {task_id}")
|
|
|
|
|
|
logger.info(f"key_info metadata: {detailed_cavity_json.get('metadata', {})}")
|
|
|
|
|
|
logger.info(f"key_info manufacturing_info: {detailed_cavity_json.get('manufacturing_info', {})}")
|
|
|
|
|
|
logger.info(f"key_info geometric_characteristics: {detailed_cavity_json.get('mold_cavities', {}).get('cavity_key_info', {}).get('geometric_characteristics', {})}")
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"模具型腔生成失败: {e}")
|
|
|
|
|
|
|
2026-09-16 17:55:04 +08:00
|
|
|
|
# D9:先丢弃未提交的数据本体再置失败(同外层说明)
|
|
|
|
|
|
await db_session.rollback()
|
|
|
|
|
|
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.task_storage.update_stp_file_status(db_session, stp_file_id, "failed")
|
|
|
|
|
|
await self.task_storage.update_task_status(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session, task_id, "failed", error_message=str(e)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
task = await redis_task_manager.get_task(task_id)
|
|
|
|
|
|
if task:
|
|
|
|
|
|
await redis_task_manager.update_task(task_id, {
|
|
|
|
|
|
"status": ProcessingStatus.FAILED,
|
|
|
|
|
|
"error": str(e),
|
|
|
|
|
|
"completed_at": str(datetime.now()),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# ─── 内部步骤 ───
|
|
|
|
|
|
|
|
|
|
|
|
async def _step_generate_mesh(
|
|
|
|
|
|
self, shape, geometry_data: dict, file_path: str,
|
|
|
|
|
|
db_session: AsyncSession, stp_file_id: int, task_id: str,
|
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
2026-05-28 17:41:02 +08:00
|
|
|
|
"""生成多级LOD网格并持久化,一次OCC剖分+trimesh简化,失败不影响主流程"""
|
2026-05-11 14:15:39 +08:00
|
|
|
|
mesh_result = None
|
|
|
|
|
|
try:
|
2026-05-28 17:41:02 +08:00
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
mesh_result = await loop.run_in_executor(
|
|
|
|
|
|
self._occ_executor, self.mesh_generator.generate_multi_lod_mesh, shape
|
|
|
|
|
|
)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
2026-05-28 17:41:02 +08:00
|
|
|
|
lod0 = mesh_result.get("lods", {}).get("0", {})
|
|
|
|
|
|
vertices = lod0.get("vertices", [])
|
|
|
|
|
|
faces = lod0.get("faces", [])
|
2026-05-11 14:15:39 +08:00
|
|
|
|
points = mesh_result.get("points", [])
|
|
|
|
|
|
normals = mesh_result.get("normals", [])
|
|
|
|
|
|
point_count = mesh_result.get("point_count", 0)
|
2026-05-28 17:41:02 +08:00
|
|
|
|
vertex_count = lod0.get("vertex_count", mesh_result.get("vertex_count", 0))
|
|
|
|
|
|
face_count = lod0.get("face_count", mesh_result.get("face_count", 0))
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
if vertices and faces:
|
|
|
|
|
|
bbox = geometry_data.get("bounding_box", {})
|
|
|
|
|
|
|
|
|
|
|
|
mesh_json = {
|
|
|
|
|
|
"metadata": {
|
|
|
|
|
|
"file_name": Path(file_path).name,
|
|
|
|
|
|
"generated_at": datetime.now().isoformat(),
|
|
|
|
|
|
"quality": "medium",
|
|
|
|
|
|
"vertex_count": vertex_count,
|
|
|
|
|
|
"face_count": face_count,
|
|
|
|
|
|
"point_count": point_count,
|
|
|
|
|
|
},
|
|
|
|
|
|
"mesh": {
|
|
|
|
|
|
"vertices": vertices,
|
|
|
|
|
|
"faces": faces,
|
|
|
|
|
|
},
|
|
|
|
|
|
"pointcloud": {
|
|
|
|
|
|
"points": points,
|
|
|
|
|
|
"normals": normals,
|
|
|
|
|
|
"count": point_count,
|
|
|
|
|
|
},
|
|
|
|
|
|
"bounding_box": bbox,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.analysis_storage.save_mesh_data(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session,
|
|
|
|
|
|
stp_file_id=stp_file_id,
|
|
|
|
|
|
mesh_json=mesh_json,
|
|
|
|
|
|
quality="medium",
|
|
|
|
|
|
)
|
|
|
|
|
|
await redis_task_manager.update_task(task_id, {
|
|
|
|
|
|
"mesh_summary": {
|
|
|
|
|
|
"vertex_count": vertex_count,
|
|
|
|
|
|
"face_count": face_count,
|
|
|
|
|
|
"point_count": point_count,
|
|
|
|
|
|
"quality": "medium",
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
except Exception as mesh_err:
|
|
|
|
|
|
logger.warning(f"网格生成或保存失败,不影响主流程: {mesh_err}")
|
|
|
|
|
|
|
|
|
|
|
|
return mesh_result
|
|
|
|
|
|
|
|
|
|
|
|
async def _step_generate_cavity(
|
2026-05-14 16:56:18 +08:00
|
|
|
|
self, shape, selected_material: dict, is_foam_material: bool, process_params: Dict[str, Any],
|
2026-09-16 17:55:04 +08:00
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
|
"""生成多方案分模结果。
|
|
|
|
|
|
|
|
|
|
|
|
D8:型腔是任务的核心产出,生成失败必须让任务 failed——
|
|
|
|
|
|
此前异常在此被吞掉置 plan_result=None 继续主流程,最终任务
|
|
|
|
|
|
completed,"完成"状态不可信。异常直接向编排层传播。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not shape:
|
|
|
|
|
|
raise RuntimeError("无有效几何 shape,无法生成模具型腔")
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
2026-09-16 17:55:04 +08:00
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
plan_result = await loop.run_in_executor(
|
|
|
|
|
|
self._occ_executor,
|
|
|
|
|
|
lambda: self.multi_scheme_planner.generate_plan(
|
|
|
|
|
|
shape=shape,
|
|
|
|
|
|
material=selected_material,
|
|
|
|
|
|
is_foam_material=is_foam_material,
|
|
|
|
|
|
process_params=process_params,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"多方案分模完成: 生成 {len(plan_result.get('candidate_schemes', []))} 套方案"
|
|
|
|
|
|
)
|
2026-05-11 14:15:39 +08:00
|
|
|
|
return plan_result
|
|
|
|
|
|
|
2026-05-14 16:56:18 +08:00
|
|
|
|
def _cache_export_shapes(self, task_id: str, export_shapes: Dict[str, Dict[str, Any]]):
|
|
|
|
|
|
self._export_shapes_cache[task_id] = export_shapes
|
2026-08-31 18:01:34 +08:00
|
|
|
|
self._export_shapes_cache.move_to_end(task_id)
|
|
|
|
|
|
# 逐出最旧任务的形状缓存(连原生 OCC shape 引用一起释放)
|
|
|
|
|
|
while len(self._export_shapes_cache) > self._export_shapes_cache_max:
|
|
|
|
|
|
self._export_shapes_cache.popitem(last=False)
|
2026-05-14 16:56:18 +08:00
|
|
|
|
|
2026-05-25 10:16:05 +08:00
|
|
|
|
def _persist_step_exports(
|
|
|
|
|
|
self,
|
|
|
|
|
|
task_id: str,
|
|
|
|
|
|
original_filename: str,
|
|
|
|
|
|
export_shapes: Dict[str, Dict[str, Any]],
|
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
|
if not export_shapes:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
base_filename = Path(original_filename).stem or f"mold_{task_id}"
|
|
|
|
|
|
manifest = {
|
|
|
|
|
|
"version": 1,
|
|
|
|
|
|
"task_id": task_id,
|
|
|
|
|
|
"generated_at": datetime.now().isoformat(),
|
|
|
|
|
|
"schemes": {},
|
|
|
|
|
|
}
|
2026-06-16 11:15:03 +08:00
|
|
|
|
components = ["cavity", "core", "parting_surface", "product", "a_plate", "b_plate"]
|
2026-05-25 10:16:05 +08:00
|
|
|
|
|
|
|
|
|
|
for scheme_id, cavity_data in export_shapes.items():
|
|
|
|
|
|
try:
|
2026-08-31 18:01:34 +08:00
|
|
|
|
# 持久化装配体 STEP + 逐组件 STEP(后者是重启后按需
|
|
|
|
|
|
# 重导出其他格式的几何来源,见 regenerate_export_from_persisted)
|
|
|
|
|
|
result = self.cad_exporter.export_persisted_steps(
|
2026-05-25 10:16:05 +08:00
|
|
|
|
cavity_data=cavity_data,
|
|
|
|
|
|
base_filename=base_filename,
|
|
|
|
|
|
components=components,
|
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
|
scheme_id=scheme_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
manifest["schemes"][scheme_id] = {
|
|
|
|
|
|
"base_filename": result.get("base_filename"),
|
|
|
|
|
|
"generated_at": datetime.now().isoformat(),
|
|
|
|
|
|
"files": result.get("files", []),
|
|
|
|
|
|
"errors": result.get("errors", []),
|
|
|
|
|
|
"total_files": result.get("total_files", 0),
|
|
|
|
|
|
"total_errors": result.get("total_errors", 0),
|
|
|
|
|
|
}
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning("持久化 STEP 导出失败: task=%s scheme=%s error=%s", task_id, scheme_id, exc)
|
|
|
|
|
|
manifest["schemes"][scheme_id] = {
|
|
|
|
|
|
"base_filename": base_filename,
|
|
|
|
|
|
"generated_at": datetime.now().isoformat(),
|
|
|
|
|
|
"files": [],
|
|
|
|
|
|
"errors": [str(exc)],
|
|
|
|
|
|
"total_files": 0,
|
|
|
|
|
|
"total_errors": 1,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return manifest
|
|
|
|
|
|
|
2026-05-14 16:56:18 +08:00
|
|
|
|
def get_export_shapes(self, task_id: str, scheme_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
2026-08-31 18:01:34 +08:00
|
|
|
|
scheme_map = self._export_shapes_cache.get(task_id)
|
2026-05-14 16:56:18 +08:00
|
|
|
|
if not scheme_map:
|
|
|
|
|
|
return None
|
2026-08-31 18:01:34 +08:00
|
|
|
|
self._export_shapes_cache.move_to_end(task_id) # LRU 热点保活
|
2026-05-14 16:56:18 +08:00
|
|
|
|
if scheme_id:
|
|
|
|
|
|
return scheme_map.get(scheme_id)
|
|
|
|
|
|
return next(iter(scheme_map.values()), None)
|
|
|
|
|
|
|
2026-08-31 18:01:34 +08:00
|
|
|
|
async def regenerate_export_from_persisted(
|
|
|
|
|
|
self,
|
|
|
|
|
|
task_id: str,
|
|
|
|
|
|
scheme_id: str,
|
|
|
|
|
|
formats: Optional[List[str]],
|
|
|
|
|
|
components: List[str],
|
|
|
|
|
|
base_filename: str,
|
|
|
|
|
|
scheme_files: List[Dict[str, Any]],
|
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
|
"""内存导出缓存失效(如服务重启)后,从持久化 STEP 重建导出文件。
|
|
|
|
|
|
|
|
|
|
|
|
分析期已为每个方案持久化装配体 + 逐组件 STEP;
|
|
|
|
|
|
缺失格式(STL/IGES/BRep)读回单组件 STEP 现场转换,
|
|
|
|
|
|
用户无需重新分析。所有组件均不可用时返回 None。
|
|
|
|
|
|
"""
|
|
|
|
|
|
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
|
|
|
|
|
|
step_files = {
|
|
|
|
|
|
f.get("component"): f
|
|
|
|
|
|
for f in scheme_files or []
|
|
|
|
|
|
if f.get("format") == "step" and f.get("component")
|
|
|
|
|
|
}
|
|
|
|
|
|
if not step_files:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
files: List[Dict[str, Any]] = []
|
|
|
|
|
|
errors: List[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
if "step" in format_list:
|
|
|
|
|
|
assembly = step_files.get("assembly")
|
|
|
|
|
|
if assembly:
|
|
|
|
|
|
files.append(assembly)
|
|
|
|
|
|
else:
|
|
|
|
|
|
errors.append("模具装配体 (step) 不可用")
|
|
|
|
|
|
|
|
|
|
|
|
for comp in components:
|
|
|
|
|
|
comp_file = step_files.get(comp)
|
|
|
|
|
|
if comp_file is None:
|
|
|
|
|
|
errors.append(f"组件 {comp} 的持久化 STEP 不可用")
|
|
|
|
|
|
continue
|
|
|
|
|
|
for fmt in format_list:
|
|
|
|
|
|
if fmt == "step":
|
|
|
|
|
|
files.append(comp_file)
|
|
|
|
|
|
continue
|
|
|
|
|
|
step_path = os.path.join(
|
|
|
|
|
|
self.cad_exporter.output_dir,
|
|
|
|
|
|
str(comp_file.get("relative_path") or "").replace("/", os.sep),
|
|
|
|
|
|
)
|
|
|
|
|
|
out_path = os.path.join(
|
|
|
|
|
|
os.path.dirname(step_path), f"{base_filename}_{comp}.{fmt}"
|
|
|
|
|
|
)
|
|
|
|
|
|
ok = await self.run_occ(
|
|
|
|
|
|
self.cad_exporter.convert_component_step, step_path, out_path, fmt
|
|
|
|
|
|
)
|
|
|
|
|
|
if ok:
|
|
|
|
|
|
files.append(
|
|
|
|
|
|
self.cad_exporter.build_file_entry(comp, fmt, out_path)
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
errors.append(f"组件 {comp} ({fmt}) 转换失败")
|
|
|
|
|
|
|
|
|
|
|
|
if not files:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"base_filename": base_filename,
|
|
|
|
|
|
"task_id": task_id,
|
|
|
|
|
|
"scheme_id": scheme_id,
|
|
|
|
|
|
"files": files,
|
|
|
|
|
|
"errors": errors,
|
|
|
|
|
|
"total_files": len(files),
|
|
|
|
|
|
"total_errors": len(errors),
|
|
|
|
|
|
"source": "regenerated",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 16:56:18 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _normalize_process_params(process_params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
|
|
|
payload = dict(process_params or {})
|
|
|
|
|
|
return {
|
|
|
|
|
|
"material": MaterialService.resolve_material(str(payload.get("material", "ABS"))),
|
|
|
|
|
|
"draft_angle": float(payload.get("draft_angle", 2.0)),
|
|
|
|
|
|
"shrinkage_rate": float(payload.get("shrinkage_rate", 0.5)),
|
|
|
|
|
|
"parting_precision": float(payload.get("parting_precision", 0.1)),
|
|
|
|
|
|
"cavity_match": int(payload.get("cavity_match", 95)),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 14:15:39 +08:00
|
|
|
|
async def _step_verify(
|
|
|
|
|
|
self, file_path: str, db_session: AsyncSession,
|
|
|
|
|
|
task_id: str, stp_file_id: int, analysis_result: Optional[dict],
|
|
|
|
|
|
) -> Optional[Dict[str, Any]]:
|
|
|
|
|
|
"""FreeCAD 几何验证(可通过配置禁用)"""
|
2026-05-29 18:10:08 +08:00
|
|
|
|
from shared.config.settings import settings
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
if not settings.ENABLE_FREECAD_VERIFICATION:
|
|
|
|
|
|
logger.info("FreeCAD验证已禁用(设置 ENABLE_FREECAD_VERIFICATION=true 启用)")
|
|
|
|
|
|
return {"status": "disabled", "reason": "FreeCAD验证已禁用"}
|
|
|
|
|
|
|
2026-09-17 16:15:49 +08:00
|
|
|
|
await self.task_storage.update_task_status(
|
2026-05-11 14:15:39 +08:00
|
|
|
|
db_session, task_id, "processing", 90, "FreeCAD几何验证"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-05-29 18:10:08 +08:00
|
|
|
|
from moldinsight.services.verification_service import GeometryVerificationService
|
2026-05-11 14:15:39 +08:00
|
|
|
|
verification_svc = GeometryVerificationService(timeout=settings.FREECAD_VERIFICATION_TIMEOUT)
|
|
|
|
|
|
verification_result = await verification_svc.verify_stp_file(file_path)
|
|
|
|
|
|
|
|
|
|
|
|
if verification_result and analysis_result:
|
|
|
|
|
|
await self._save_verification_metrics(db_session, stp_file_id, verification_result)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"FreeCAD验证完成: {verification_result.get('status', 'unknown') if verification_result else 'failed'}")
|
|
|
|
|
|
return verification_result
|
|
|
|
|
|
except Exception as ve:
|
|
|
|
|
|
logger.warning(f"FreeCAD验证失败(不影响主流程): {ve}")
|
|
|
|
|
|
return {"status": "error", "error": str(ve)}
|
|
|
|
|
|
|
|
|
|
|
|
async def _attach_scheme_previews(
|
|
|
|
|
|
self,
|
|
|
|
|
|
detailed_cavity_json: Dict[str, Any],
|
|
|
|
|
|
geometry_data: Dict[str, Any],
|
|
|
|
|
|
stp_filename: str,
|
|
|
|
|
|
pointcloud_data: Optional[Dict[str, Any]] = None,
|
|
|
|
|
|
lod_data: Optional[Dict[str, Any]] = None,
|
|
|
|
|
|
) -> Dict[str, Any]:
|
2026-05-28 17:59:39 +08:00
|
|
|
|
"""为候选分模方案生成轻量摘要链接(完整HTML仅最优方案按需生成)"""
|
2026-05-11 14:15:39 +08:00
|
|
|
|
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
|
|
|
|
|
|
if not candidate_schemes:
|
|
|
|
|
|
return detailed_cavity_json
|
|
|
|
|
|
|
|
|
|
|
|
for scheme in candidate_schemes:
|
|
|
|
|
|
cavity_data = scheme.get("cavity_data")
|
|
|
|
|
|
if not cavity_data:
|
|
|
|
|
|
continue
|
|
|
|
|
|
suffix = scheme.get("scheme_id")
|
2026-05-28 17:59:39 +08:00
|
|
|
|
base_stem = Path(stp_filename).stem.replace(" ", "_")
|
|
|
|
|
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
|
summary_name = f"mold_{base_stem}_{suffix}_{ts}_summary.json"
|
|
|
|
|
|
summary_content = self.html_generator.generate_3d_viewer_summary(
|
|
|
|
|
|
geometry_data, cavity_data
|
2026-05-11 14:15:39 +08:00
|
|
|
|
)
|
2026-05-28 17:59:39 +08:00
|
|
|
|
self.html_generator.save_data_file(summary_content, summary_name)
|
|
|
|
|
|
scheme["summary_file"] = f"/html/{summary_name}"
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
best_scheme = CalculationService.get_best_scheme(detailed_cavity_json)
|
|
|
|
|
|
if best_scheme:
|
|
|
|
|
|
detailed_cavity_json["html_file"] = best_scheme.get("html_file")
|
|
|
|
|
|
|
|
|
|
|
|
return detailed_cavity_json
|
|
|
|
|
|
|
|
|
|
|
|
# ─── 指标持久化 ───
|
|
|
|
|
|
|
|
|
|
|
|
async def _save_analysis_metrics(self, session: AsyncSession, stp_file_id: int, analysis_result: dict):
|
|
|
|
|
|
"""保存分析指标到数据库"""
|
2026-09-17 16:15:49 +08:00
|
|
|
|
from moldinsight.models import AnalysisMetrics
|
2026-05-11 14:15:39 +08:00
|
|
|
|
|
|
|
|
|
|
quality_metrics = analysis_result.get("quality_metrics", {})
|
|
|
|
|
|
analysis_summary = analysis_result.get("analysis_summary", "")
|
|
|
|
|
|
|
|
|
|
|
|
metrics = AnalysisMetrics(
|
|
|
|
|
|
stp_file_id=stp_file_id,
|
|
|
|
|
|
volume_utilization=quality_metrics.get("volume_utilization", 0),
|
|
|
|
|
|
topology_complexity=quality_metrics.get("topology_complexity", 0),
|
|
|
|
|
|
wall_uniformity=quality_metrics.get("wall_uniformity", 0),
|
|
|
|
|
|
analysis_summary=analysis_summary,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
session.add(metrics)
|
2026-09-16 17:55:04 +08:00
|
|
|
|
# D9:flush 不 commit,随结果包(阶段 B)由编排层统一提交
|
|
|
|
|
|
await session.flush()
|
2026-05-11 14:15:39 +08:00
|
|
|
|
logger.info(f"分析指标保存成功: {metrics.id}")
|
|
|
|
|
|
|
|
|
|
|
|
async def _save_verification_metrics(self, session: AsyncSession, stp_file_id: int, verification_result: dict):
|
|
|
|
|
|
"""保存验证指标到数据库"""
|
2026-09-17 16:15:49 +08:00
|
|
|
|
from moldinsight.models import AnalysisMetrics
|
2026-05-11 14:15:39 +08:00
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
|
select(AnalysisMetrics).where(AnalysisMetrics.stp_file_id == stp_file_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
metrics = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
comparison = verification_result.get("comparison", {})
|
|
|
|
|
|
volume_comparison = comparison.get("volume", {})
|
|
|
|
|
|
area_comparison = comparison.get("surface_area", {})
|
|
|
|
|
|
|
|
|
|
|
|
if metrics:
|
|
|
|
|
|
metrics.verification_status = verification_result.get("status", "unknown")
|
|
|
|
|
|
metrics.verification_volume_diff = volume_comparison.get("difference_percent", 0)
|
|
|
|
|
|
metrics.verification_area_diff = area_comparison.get("difference_percent", 0)
|
|
|
|
|
|
metrics.verification_details = verification_result
|
|
|
|
|
|
else:
|
|
|
|
|
|
metrics = AnalysisMetrics(
|
|
|
|
|
|
stp_file_id=stp_file_id,
|
|
|
|
|
|
verification_status=verification_result.get("status", "unknown"),
|
|
|
|
|
|
verification_volume_diff=volume_comparison.get("difference_percent", 0),
|
|
|
|
|
|
verification_area_diff=area_comparison.get("difference_percent", 0),
|
|
|
|
|
|
verification_details=verification_result,
|
|
|
|
|
|
)
|
|
|
|
|
|
session.add(metrics)
|
|
|
|
|
|
|
2026-09-16 17:55:04 +08:00
|
|
|
|
# D9:flush 不 commit,随结果包(阶段 B)由编排层统一提交
|
|
|
|
|
|
await session.flush()
|
2026-05-11 14:15:39 +08:00
|
|
|
|
logger.info(f"验证指标保存成功: stp_file_id={stp_file_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 模块级单例,供路由层直接使用
|
|
|
|
|
|
processing_service = ProcessingService()
|