This commit is contained in:
2026-05-25 10:16:05 +08:00
parent 459ad50c08
commit 63bee26ab8
7 changed files with 369 additions and 32 deletions
+197 -16
View File
@@ -1,17 +1,28 @@
from pathlib import Path
import os
from datetime import datetime
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from services.auth_service import get_current_active_user
from services.redis_task_manager import redis_task_manager
from services.processing_service import processing_service
from services.storage_integration_rustfs import StorageIntegrationService
from services.task_query_service import TaskQueryService
from database.database import get_db_session
from models.database import User
from models.database import ProcessingTask, STPFile
from core.cad_exporter import CADExporter
from utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter()
cad_exporter = CADExporter()
storage_service = StorageIntegrationService()
_api_routes_cache = {}
@@ -51,6 +62,125 @@ async def _get_task_data(task_id: str) -> dict:
return None
async def _ensure_task_access(
db_session: AsyncSession,
task_id: str,
user_id: int,
):
row = await db_session.execute(
select(ProcessingTask, STPFile)
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
.where(ProcessingTask.task_id == task_id)
)
row = row.first()
if not row:
raise HTTPException(404, "任务不存在")
_, stp_file = row
owner_id = getattr(stp_file, "user_id", None)
if owner_id is not None and owner_id != user_id:
raise HTTPException(403, "无权访问该任务的导出文件")
return row
def _get_export_artifacts(task_data: dict) -> dict:
if not isinstance(task_data, dict):
return {}
direct = task_data.get("export_artifacts")
if isinstance(direct, dict):
return direct
parameters = task_data.get("parameters")
if isinstance(parameters, dict) and isinstance(parameters.get("export_artifacts"), dict):
return parameters.get("export_artifacts")
return {}
def _expand_components(components):
requested = components or ["cavity", "core"]
if "all" in requested:
return ["cavity", "core", "parting_surface"]
return list(dict.fromkeys(requested))
def _augment_export_files(task_id: str, files):
items = []
for file in files or []:
item = dict(file)
relative_path = item.get("relative_path")
if not relative_path and item.get("filepath"):
relative_path = cad_exporter.get_relative_path(item["filepath"])
if relative_path:
relative_path = str(relative_path).replace("\\", "/").strip("/")
item["relative_path"] = relative_path
item["download_path"] = f"/api/export-download/{quote(relative_path, safe='/')}?task_id={task_id}"
items.append(item)
return items
def _merge_export_artifacts(existing: dict, export_result: dict) -> dict:
merged = dict(existing or {})
schemes = dict(merged.get("schemes") or {})
scheme_id = export_result.get("scheme_id") or "default"
previous = dict(schemes.get(scheme_id) or {})
file_map = {}
for file in previous.get("files", []):
file_map[(file.get("component"), file.get("format"))] = file
for file in export_result.get("files", []):
file_map[(file.get("component"), file.get("format"))] = file
schemes[scheme_id] = {
"base_filename": export_result.get("base_filename") or previous.get("base_filename"),
"generated_at": datetime.now().isoformat(),
"files": sorted(
file_map.values(),
key=lambda item: (item.get("component", ""), item.get("format", "")),
),
"errors": export_result.get("errors", []),
"total_files": len(file_map),
"total_errors": len(export_result.get("errors", [])),
}
merged["version"] = 1
merged["task_id"] = export_result.get("task_id") or merged.get("task_id")
merged["generated_at"] = merged.get("generated_at") or datetime.now().isoformat()
merged["schemes"] = schemes
return merged
def _select_persisted_files(task_id: str, task_data: dict, scheme_id: str, formats, components):
artifacts = _get_export_artifacts(task_data)
scheme_data = (artifacts.get("schemes") or {}).get(scheme_id)
if not scheme_data:
return None
component_list = _expand_components(components)
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
expected = {(component, fmt) for component in component_list for fmt in format_list}
available = []
available_keys = set()
for file in scheme_data.get("files", []):
component = file.get("component")
fmt = file.get("format")
if component not in component_list or fmt not in format_list:
continue
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
if not relative_path:
continue
full_path = os.path.join(cad_exporter.output_dir, relative_path.replace("/", os.sep))
if not os.path.exists(full_path):
continue
available.append(file)
available_keys.add((component, fmt))
if expected and not expected.issubset(available_keys):
return None
return _augment_export_files(task_id, available)
@router.post("/optimize-layout")
async def optimize_cavity_layout(
request: Request,
@@ -279,6 +409,7 @@ async def simulate_machining(
async def export_mold_results(
request: Request,
current_user: User = Depends(get_current_active_user),
db_session: AsyncSession = Depends(get_db_session),
):
body = await request.json()
task_id = body.get("task_id")
@@ -289,13 +420,37 @@ async def export_mold_results(
if not task_id:
raise HTTPException(404, "缺少 task_id")
task_data = await _get_task_data(task_id)
await _ensure_task_access(db_session, task_id, current_user.id)
task_data = await TaskQueryService.get_task_view(db_session, task_id)
if not task_data:
raise HTTPException(404, "任务不存在")
resolved_scheme_id = scheme_id or task_data.get("best_scheme_id") or "default"
persisted_files = _select_persisted_files(
task_id=task_id,
task_data=task_data,
scheme_id=resolved_scheme_id,
formats=formats,
components=components,
)
if persisted_files:
return {
"status": "success",
"data": {
"base_filename": Path(task_data.get("filename", f"mold_{task_id}")).stem,
"task_id": task_id,
"scheme_id": resolved_scheme_id,
"files": persisted_files,
"errors": [],
"total_files": len(persisted_files),
"total_errors": 0,
"source": "persisted",
},
}
cavity_shapes = processing_service.get_export_shapes(
task_id,
scheme_id or task_data.get("best_scheme_id"),
resolved_scheme_id,
)
filename = task_data.get("filename", f"mold_{task_id}")
@@ -305,33 +460,62 @@ async def export_mold_results(
"导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
)
exporter = _get_cached("cad_exporter")
if not exporter:
raise HTTPException(503, "服务不可用:核心模块未加载,请检查 PythonOCC 环境")
base_filename = Path(filename).stem
result = exporter.export_mold_results(
result = cad_exporter.export_mold_results(
cavity_data=cavity_shapes,
base_filename=base_filename,
formats=formats,
components=components,
task_id=task_id,
scheme_id=resolved_scheme_id,
)
result["files"] = _augment_export_files(task_id, result.get("files", []))
result["source"] = "generated"
merged_artifacts = _merge_export_artifacts(_get_export_artifacts(task_data), result)
await storage_service.update_task_parameters(
db_session,
task_id,
{"export_artifacts": merged_artifacts},
)
await redis_task_manager.update_task(task_id, {"export_artifacts": merged_artifacts})
return {"status": "success", "data": result}
@router.get("/export-download/{filepath:path}")
async def download_export_file(
filepath: str,
task_id: str,
current_user: User = Depends(get_current_active_user),
db_session: AsyncSession = Depends(get_db_session),
):
from fastapi.responses import FileResponse
exporter = _get_cached("cad_exporter")
if not exporter:
raise HTTPException(503, "服务不可用")
full_path = os.path.join(exporter.output_dir, filepath)
if not task_id:
raise HTTPException(400, "缺少 task_id")
await _ensure_task_access(db_session, task_id, current_user.id)
task_data = await TaskQueryService.get_task_view(db_session, task_id)
if not task_data:
raise HTTPException(404, "任务不存在")
allowed_paths = set()
artifacts = _get_export_artifacts(task_data)
for scheme in (artifacts.get("schemes") or {}).values():
for file in scheme.get("files", []):
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
if relative_path:
allowed_paths.add(relative_path)
normalized_path = str(filepath or "").replace("\\", "/").strip("/")
if normalized_path not in allowed_paths:
raise HTTPException(403, "该文件不在任务允许下载清单中")
full_path = os.path.join(cad_exporter.output_dir, normalized_path.replace("/", os.sep))
if not os.path.exists(full_path):
raise HTTPException(404, "文件不存在")
if not os.path.abspath(full_path).startswith(os.path.abspath(exporter.output_dir)):
if not os.path.abspath(full_path).startswith(os.path.abspath(cad_exporter.output_dir)):
raise HTTPException(403, "禁止访问")
media_types = {
".step": "application/step", ".stp": "application/step",
@@ -348,10 +532,7 @@ async def get_export_recommendations(
target: str = "ug",
current_user: User = Depends(get_current_active_user),
):
exporter = _get_cached("cad_exporter")
if not exporter:
raise HTTPException(503, "服务不可用")
result = exporter.get_export_recommendations(target)
result = cad_exporter.get_export_recommendations(target)
return {"status": "success", "data": result}
+42 -2
View File
@@ -26,6 +26,7 @@ FreeCAD 导入建议:
"""
import os
import re
from typing import Dict, List, Any, Optional, Tuple
from pathlib import Path
from utils.logger import get_logger
@@ -40,6 +41,35 @@ class CADExporter:
self.output_dir = output_dir
os.makedirs(output_dir, exist_ok=True)
@staticmethod
def _safe_segment(value: Optional[str], fallback: str) -> str:
text = str(value or "").strip()
if not text:
text = fallback
text = re.sub(r"[^A-Za-z0-9._-]+", "_", text)
return text[:80] or fallback
def build_export_dir(
self,
base_filename: str,
task_id: Optional[str] = None,
scheme_id: Optional[str] = None,
) -> str:
if task_id:
task_segment = self._safe_segment(task_id, "task")
scheme_segment = self._safe_segment(scheme_id, "default")
return os.path.join(self.output_dir, task_segment, scheme_segment)
return os.path.join(self.output_dir, self._safe_segment(base_filename, "mold"))
def get_relative_path(self, filepath: str) -> str:
full_path = Path(filepath).resolve()
output_root = Path(self.output_dir).resolve()
try:
relative = full_path.relative_to(output_root)
except ValueError:
relative = Path(os.path.basename(filepath))
return relative.as_posix()
def export_step(self, shape: Any, filepath: str,
schema: str = "AP214") -> bool:
"""
@@ -207,7 +237,9 @@ class CADExporter:
def export_mold_results(self, cavity_data: Dict,
base_filename: str,
formats: List[str] = None,
components: List[str] = None) -> Dict[str, Any]:
components: List[str] = None,
task_id: Optional[str] = None,
scheme_id: Optional[str] = None) -> Dict[str, Any]:
"""
批量导出模具设计结果
@@ -225,11 +257,17 @@ class CADExporter:
if components is None:
components = ["cavity", "core"]
export_dir = os.path.join(self.output_dir, base_filename)
export_dir = self.build_export_dir(
base_filename=base_filename,
task_id=task_id,
scheme_id=scheme_id,
)
os.makedirs(export_dir, exist_ok=True)
results = {
"base_filename": base_filename,
"task_id": task_id,
"scheme_id": scheme_id,
"export_dir": export_dir,
"files": [],
"errors": [],
@@ -277,11 +315,13 @@ class CADExporter:
if success:
file_size = os.path.getsize(filepath)
relative_path = self.get_relative_path(filepath)
results["files"].append({
"component": comp_name,
"component_label": label,
"format": fmt,
"filepath": filepath,
"relative_path": relative_path,
"filename": os.path.basename(filepath),
"size_bytes": file_size,
"size_readable": self._format_file_size(file_size),
+61 -7
View File
@@ -9,12 +9,14 @@ class PartingSchemeScorer:
scored = []
for scheme in schemes:
score_breakdown = self._score_scheme(scheme)
undercut_priority_bonus = self._build_undercut_priority_bonus(scheme, score_breakdown)
total_score = round(
score_breakdown["manufacturability"] * 0.30
+ score_breakdown["undercut_complexity"] * 0.25
+ score_breakdown["parting_quality"] * 0.20
score_breakdown["manufacturability"] * 0.25
+ score_breakdown["undercut_complexity"] * 0.35
+ score_breakdown["parting_quality"] * 0.15
+ score_breakdown["machining_cost"] * 0.15
+ score_breakdown["risk"] * 0.10,
+ score_breakdown["risk"] * 0.10
+ undercut_priority_bonus,
2,
)
@@ -22,6 +24,7 @@ class PartingSchemeScorer:
fallback = self._assess_fallback(scored_scheme, score_breakdown)
scored_scheme["score_breakdown"] = score_breakdown
scored_scheme["score"] = total_score
scored_scheme["undercut_priority_bonus"] = round(undercut_priority_bonus, 2)
scored_scheme["is_fallback"] = fallback["is_fallback"]
scored_scheme["fallback_reason"] = fallback["fallback_reason"]
scored_scheme["dfm_violations"] = self._build_dfm_violations(scored_scheme)
@@ -61,10 +64,34 @@ class PartingSchemeScorer:
summary = side_actions.get("summary", {})
slider_count = len(side_actions.get("slider_mechanisms", []))
lifter_count = len(side_actions.get("lifter_mechanisms", []))
total_mechanism_count = int(summary.get("total_mechanism_count", slider_count + lifter_count) or 0)
total_undercut_area = float(
(side_actions.get("undercut_analysis", {}) or {}).get("total_undercut_area")
or 0.0
)
has_pneumatic = any(
str(item.get("actuation", "")).lower() == "pneumatic"
for item in side_actions.get("slider_mechanisms", [])
)
undercut_count = len(undercut_regions)
undercut_complexity = max(35.0, 100.0 - undercut_count * 12.0 - slider_count * 8.0 - lifter_count * 6.0)
if summary.get("complexity") == "high":
undercut_complexity = max(30.0, undercut_complexity - 10.0)
complexity = str(summary.get("complexity", "")).lower()
undercut_penalty = 0.0
if total_mechanism_count > 0:
undercut_penalty += 20.0
undercut_penalty += undercut_count * 10.0
undercut_penalty += slider_count * 6.0
undercut_penalty += lifter_count * 5.0
if has_pneumatic:
undercut_penalty += 10.0
if complexity == "moderate":
undercut_penalty += 6.0
elif complexity == "complex":
undercut_penalty += 14.0
elif complexity == "very_complex":
undercut_penalty += 24.0
undercut_penalty += min(total_undercut_area / 500.0, 12.0)
undercut_complexity = max(20.0, 100.0 - undercut_penalty)
parting_line = scheme.get("parting", {}).get("line", [])
parting_length = self._calculate_polyline_length(parting_line)
@@ -106,6 +133,33 @@ class PartingSchemeScorer:
"risk": round(risk, 2),
}
@staticmethod
def _build_undercut_priority_bonus(
scheme: Dict[str, Any],
score_breakdown: Dict[str, float],
) -> float:
cavity_data = scheme.get("cavity_data", {})
quality_checks = cavity_data.get("quality_checks", {})
side_actions = quality_checks.get("side_actions") or cavity_data.get("side_actions", {})
summary = side_actions.get("summary", {})
slider_count = len(side_actions.get("slider_mechanisms", []))
lifter_count = len(side_actions.get("lifter_mechanisms", []))
total_mechanism_count = int(summary.get("total_mechanism_count", slider_count + lifter_count) or 0)
has_pneumatic = any(
str(item.get("actuation", "")).lower() == "pneumatic"
for item in side_actions.get("slider_mechanisms", [])
)
if total_mechanism_count == 0:
return 18.0
penalty = 12.0 + total_mechanism_count * 4.0
if has_pneumatic:
penalty += 8.0
if float(score_breakdown.get("manufacturability", 0.0)) < 80.0:
penalty += 4.0
return -penalty
def _assess_fallback(self, scheme: Dict[str, Any], score_breakdown: Dict[str, float]) -> Dict[str, Any]:
cavity_data = scheme.get("cavity_data", {})
mold_cavities = cavity_data.get("mold_cavities", {})
+4 -5
View File
@@ -253,7 +253,7 @@ class SliderMechanismDesigner:
"block_size": slide_block_size,
"guide_type": guide_type,
"locking_mechanism": self._select_locking(slide_angle),
"actuation": "hydraulic" if slide_stroke > 50 else "mechanical",
"actuation": "pneumatic" if slide_stroke > 50 else "mechanical",
"components": self._generate_components(slide_block_size, guide_type),
"manufacturing_notes": self._generate_slider_notes(slide_angle, slide_stroke),
}
@@ -341,7 +341,7 @@ class SliderMechanismDesigner:
if angle > 25:
notes.append("滑块角度较大,需确保锁紧可靠")
if stroke > 50:
notes.append("抽芯行程较长,建议使用液压抽芯")
notes.append("抽芯行程较长,建议使用气动抽芯")
if stroke > 80:
notes.append("大行程抽芯,需校核导滑槽强度")
notes.append("滑块需设置限位装置,防止脱出")
@@ -485,7 +485,6 @@ class SideActionDesigner:
"total_lifter_count": len(lifter_mechanisms),
"total_mechanism_count": total_mechanisms,
"complexity": undercut_result["complexity"],
"has_hydraulic": any(s.get("actuation") == "hydraulic" for s in slider_mechanisms),
}
recommendations = self._generate_overall_recommendations(summary, undercut_result)
@@ -518,8 +517,8 @@ class SideActionDesigner:
if summary["total_lifter_count"] > 0:
recs.append(f"需要 {summary['total_lifter_count']} 个斜顶机构处理内侧倒扣")
if summary["has_hydraulic"]:
recs.append("大行程抽芯需使用液压系统,需配置液压站")
if summary["total_slider_count"] > 0:
recs.append("如存在大行程滑块,建议优先评估气动抽芯回路并预留稳定供气")
if summary["complexity"] == "very_complex":
recs.append("侧向分型机构复杂,建议评估是否可通过产品修改简化")
+59
View File
@@ -17,6 +17,7 @@ from core.aluminum_foam_mold import AluminumFoamMoldGenerator
from core.mold_quality_inspector import AluminumFoamMoldQualityInspector
from core.mesh_generator import MeshGenerator
from core.multi_scheme_planner import MultiSchemeMoldPlanner
from core.cad_exporter import CADExporter
from services.storage_integration_rustfs import StorageIntegrationService
from services.redis_task_manager import redis_task_manager
from services.material_service import MaterialService
@@ -43,6 +44,7 @@ class ProcessingService:
self.html_generator = HTMLGenerator()
self.storage_service = StorageIntegrationService()
self.multi_scheme_planner = MultiSchemeMoldPlanner()
self.cad_exporter = CADExporter()
self._export_shapes_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
# ─── 对外入口 ───
@@ -145,10 +147,16 @@ class ProcessingService:
)
stage_timings["generate_cavity"] = round(time.perf_counter() - stage_started, 3)
export_shapes = {}
export_artifacts = None
if plan_result:
export_shapes = plan_result.pop("_export_shapes", {}) or {}
if export_shapes:
self._cache_export_shapes(task_id, export_shapes)
export_artifacts = self._persist_step_exports(
task_id=task_id,
original_filename=Path(file_path).name,
export_shapes=export_shapes,
)
# 4. 生成详细JSON数据 — 委托 CalculationService
await self.storage_service.update_task_status(
@@ -315,6 +323,7 @@ class ProcessingService:
"material": requested_material,
"verification": verification_result,
"llm_report": llm_report,
"export_artifacts": export_artifacts,
**process_params,
},
)
@@ -334,6 +343,7 @@ class ProcessingService:
"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,
"export_artifacts": export_artifacts,
"status": ProcessingStatus.COMPLETED,
"completed_at": str(datetime.now()),
})
@@ -447,6 +457,55 @@ class ProcessingService:
def _cache_export_shapes(self, task_id: str, export_shapes: Dict[str, Dict[str, Any]]):
self._export_shapes_cache[task_id] = export_shapes
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": {},
}
components = ["cavity", "core", "parting_surface"]
for scheme_id, cavity_data in export_shapes.items():
try:
result = self.cad_exporter.export_mold_results(
cavity_data=cavity_data,
base_filename=base_filename,
formats=["step"],
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
def get_export_shapes(self, task_id: str, scheme_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
scheme_map = self._export_shapes_cache.get(task_id, {})
if not scheme_map:
+1
View File
@@ -112,6 +112,7 @@ class TaskQueryService:
"html_file": html_file_url,
"material": task_parameters.get("material"),
"parameters": task_parameters,
"export_artifacts": task_parameters.get("export_artifacts"),
"stage_timings": task_parameters.get("stage_timings", {}),
"verification": task_parameters.get("verification")
or file_with_data.get("analysis_metrics", {}).get("verification_details"),
+5 -2
View File
@@ -1598,7 +1598,7 @@ const ResultView = {
reasons: [
totalCount > 0 ? `规则分析识别出 ${totalCount} 处侧向机构需求` : '规则分析未识别出明确侧向机构需求',
summary.complexity ? `当前复杂度判定为 ${summary.complexity}` : '当前复杂度信息不足',
summary.has_hydraulic ? '规则分析提示可能涉及液压抽芯' : '未发现液压抽芯硬性提示'
'当前倒扣方案若存在大行程滑块,优先按气动抽芯条件评估'
].filter(Boolean),
standard_advice: (sideActions.recommendations || []).slice(0, 4),
manual_review_items: [
@@ -1826,7 +1826,10 @@ const ResultView = {
if (result.status === 'success' && result.data.files) {
for (const file of result.data.files) {
const downloadUrl = `/api/export-download/${file.filepath.replace(/\\/g, '/').split('/').slice(-2).join('/')}`;
const relativePath = file.relative_path
|| (file.filepath ? file.filepath.replace(/\\/g, '/').split('/').slice(-2).join('/') : '');
const downloadUrl = file.download_path
|| `/api/export-download/${encodeURI(relativePath)}?task_id=${encodeURIComponent(taskId)}`;
await downloadWithAuth(downloadUrl, file.filename);
}
addNotification(`已导出 ${result.data.files.length} 个 ${format.toUpperCase()} 文件`, 'success');