553 lines
21 KiB
Python
553 lines
21 KiB
Python
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 shared.services.auth_service import get_current_active_user
|
||
from shared.services.redis_task_manager import redis_task_manager
|
||
from moldinsight.services.processing_service import processing_service
|
||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||
from moldinsight.services.task_query_service import TaskQueryService
|
||
from shared.database.database import get_db_session
|
||
from shared.models.database import User
|
||
from shared.models.database import ProcessingTask, STPFile
|
||
from moldinsight.core.cad_exporter import CADExporter
|
||
from shared.utils.logger import get_logger
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
router = APIRouter()
|
||
cad_exporter = CADExporter()
|
||
storage_service = StorageIntegrationService()
|
||
|
||
_cached_instances = {}
|
||
|
||
|
||
def _get_cached_import(key: str):
|
||
"""惰性导入核心模块,避免路由器模块级加载时的循环依赖。"""
|
||
if key in _cached_instances:
|
||
return _cached_instances[key]
|
||
try:
|
||
if key == "side_action_designer":
|
||
from moldinsight.core.side_action_designer import SideActionDesigner
|
||
instance = SideActionDesigner()
|
||
elif key == "cavity_layout_optimizer":
|
||
from moldinsight.core.cavity_layout_optimizer import CavityLayoutOptimizer
|
||
instance = CavityLayoutOptimizer()
|
||
elif key == "mold_system_designer":
|
||
from moldinsight.core.mold_system_designer import MoldSystemDesigner
|
||
instance = MoldSystemDesigner()
|
||
elif key == "mold_cam_designer":
|
||
from moldinsight.core.mold_cam import MoldCAMDesigner
|
||
instance = MoldCAMDesigner()
|
||
elif key == "collision_detector":
|
||
from moldinsight.core.mold_machining import CollisionDetector
|
||
instance = CollisionDetector()
|
||
elif key == "toolpath_optimizer":
|
||
from moldinsight.core.mold_machining import ToolpathOptimizer
|
||
instance = ToolpathOptimizer()
|
||
elif key == "edm_designer":
|
||
from moldinsight.core.mold_machining import EDMElectrodeDesigner
|
||
instance = EDMElectrodeDesigner()
|
||
elif key == "machining_simulator":
|
||
from moldinsight.core.mold_machining import MachiningSimulator
|
||
instance = MachiningSimulator()
|
||
else:
|
||
return None
|
||
_cached_instances[key] = instance
|
||
return instance
|
||
except Exception as e:
|
||
logger.warning(f"核心模块 {key} 加载失败: {e}")
|
||
return None
|
||
|
||
|
||
async def _get_task_data(task_id: str) -> dict:
|
||
return await redis_task_manager.get_task(task_id)
|
||
|
||
|
||
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,
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
body = await request.json()
|
||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||
cavity_count = body.get("cavity_count", 1)
|
||
mold_base_size = body.get("mold_base_size")
|
||
layout_type = body.get("layout_type", "auto")
|
||
if cavity_count < 1 or cavity_count > 64:
|
||
raise HTTPException(400, "型腔数量必须在 1-64 之间")
|
||
optimizer = _get_cached_import("cavity_layout_optimizer")
|
||
if not optimizer:
|
||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||
result = optimizer.optimize_layout(
|
||
product_bbox=product_bbox,
|
||
cavity_count=cavity_count,
|
||
mold_base_size=mold_base_size,
|
||
layout_type=layout_type,
|
||
)
|
||
return {"status": "success", "data": result}
|
||
|
||
|
||
@router.post("/design-cooling")
|
||
async def design_cooling_system(
|
||
request: Request,
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
body = await request.json()
|
||
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
|
||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||
material = body.get("material", "ABS")
|
||
cavity_count = body.get("cavity_count", 1)
|
||
cycle_time_target = body.get("cycle_time_target")
|
||
from moldinsight.core.mold_system_designer import CoolingSystemDesigner
|
||
designer = CoolingSystemDesigner()
|
||
result = designer.design_cooling_system(
|
||
mold_size=mold_size, product_bbox=product_bbox,
|
||
material=material, cavity_count=cavity_count,
|
||
cycle_time_target=cycle_time_target,
|
||
)
|
||
return {"status": "success", "data": result}
|
||
|
||
|
||
@router.post("/design-gating")
|
||
async def design_gating_system(
|
||
request: Request,
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
body = await request.json()
|
||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||
material = body.get("material", "ABS")
|
||
cavity_count = body.get("cavity_count", 1)
|
||
gate_type = body.get("gate_type", "auto")
|
||
layout_positions = body.get("layout_positions")
|
||
from moldinsight.core.mold_system_designer import GatingSystemDesigner
|
||
designer = GatingSystemDesigner()
|
||
result = designer.design_gating_system(
|
||
product_bbox=product_bbox, material=material,
|
||
cavity_count=cavity_count, gate_type=gate_type,
|
||
layout_positions=layout_positions,
|
||
)
|
||
return {"status": "success", "data": result}
|
||
|
||
|
||
@router.post("/design-mold-system")
|
||
async def design_complete_mold_system(
|
||
request: Request,
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
body = await request.json()
|
||
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
|
||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||
material = body.get("material", "ABS")
|
||
cavity_count = body.get("cavity_count", 1)
|
||
gate_type = body.get("gate_type", "auto")
|
||
cycle_time_target = body.get("cycle_time_target")
|
||
layout_positions = body.get("layout_positions")
|
||
ds = _get_cached_import("mold_system_designer")
|
||
if not ds:
|
||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||
result = ds.design_complete_system(
|
||
mold_size=mold_size, product_bbox=product_bbox,
|
||
material=material, cavity_count=cavity_count,
|
||
gate_type=gate_type, cycle_time_target=cycle_time_target,
|
||
layout_positions=layout_positions,
|
||
)
|
||
return {"status": "success", "data": result}
|
||
|
||
|
||
@router.post("/detect-undercuts")
|
||
async def detect_undercuts(
|
||
request: Request,
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
body = await request.json()
|
||
task_id = body.get("task_id")
|
||
parting_direction = body.get("parting_direction", [0, 0, 1])
|
||
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
|
||
if not task_id:
|
||
raise HTTPException(404, "缺少 task_id")
|
||
task_data = await _get_task_data(task_id)
|
||
if not task_data:
|
||
raise HTTPException(404, "任务不存在")
|
||
sd = _get_cached_import("side_action_designer")
|
||
if not sd:
|
||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||
result = sd.analyze_and_design(
|
||
shape=None, parting_direction=parting_direction, mold_size=mold_size,
|
||
)
|
||
return {"status": "success", "data": result}
|
||
|
||
|
||
@router.post("/cost-estimate")
|
||
async def estimate_cost(
|
||
request: Request,
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
"""LLM 模具成本估算(P2-2:真 AI 落地,需启用 LLM)"""
|
||
body = await request.json()
|
||
task_id = body.get("task_id")
|
||
if not task_id:
|
||
raise HTTPException(404, "缺少 task_id")
|
||
task_data = await _get_task_data(task_id)
|
||
if not task_data:
|
||
raise HTTPException(404, "任务不存在")
|
||
analysis_result = task_data.get("analysis_result")
|
||
if not analysis_result:
|
||
raise HTTPException(400, "该任务尚未完成分析")
|
||
detailed_context = {
|
||
"candidate_schemes": task_data.get("candidate_schemes", []),
|
||
"geometry_data": task_data.get("geometry_data", {}),
|
||
"metadata": {"selected_material": task_data.get("material")},
|
||
}
|
||
from moldinsight.services.llm_service import llm_service
|
||
result = await llm_service.estimate_cost(analysis_result, detailed_context)
|
||
if result is None:
|
||
raise HTTPException(503, "成本估算不可用(LLM 未启用或生成失败)")
|
||
return {"status": "success", "data": result}
|
||
|
||
|
||
@router.post("/design-cam")
|
||
async def design_mold_cam(
|
||
request: Request,
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
body = await request.json()
|
||
cavity_bbox = body.get("cavity_bbox", {"dimensions": [100, 100, 50], "min": [-50, -50, -25], "max": [50, 50, 25]})
|
||
stock_bbox = body.get("stock_bbox", {"dimensions": [150, 150, 100], "min": [-75, -75, -50], "max": [75, 75, 50]})
|
||
mold_steel = body.get("mold_steel", "P20")
|
||
surface_quality = body.get("surface_quality", "standard")
|
||
controller = body.get("controller", "fanuc")
|
||
cam = _get_cached_import("mold_cam_designer")
|
||
if not cam:
|
||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||
result = cam.design_mold_cam(
|
||
cavity_bbox=cavity_bbox, stock_bbox=stock_bbox,
|
||
mold_steel=mold_steel, surface_quality=surface_quality,
|
||
controller=controller,
|
||
)
|
||
return {"status": "success", "data": result}
|
||
|
||
|
||
@router.post("/check-collision")
|
||
async def check_toolpath_collision(
|
||
request: Request,
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
body = await request.json()
|
||
toolpath_points = body.get("toolpath_points", [[0, 0, 50], [10, 10, -5], [20, 20, -10]])
|
||
tool = body.get("tool", {"diameter": 10, "flute_length": 30, "shank_diameter": 10})
|
||
stock_bbox = body.get("stock_bbox", {"min": [-50, -50, -25], "max": [50, 50, 25]})
|
||
clamp_positions = body.get("clamp_positions")
|
||
cd = _get_cached_import("collision_detector")
|
||
if not cd:
|
||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||
result = cd.check_toolpath_safety(toolpath_points, tool, stock_bbox, clamp_positions)
|
||
return {"status": "success", "data": result}
|
||
|
||
|
||
@router.post("/optimize-toolpath")
|
||
async def optimize_toolpath(
|
||
request: Request,
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
body = await request.json()
|
||
toolpath_points = body.get("toolpath_points", [[0, 0, 50], [10, 10, -5], [20, 20, -10]])
|
||
cutting_params = body.get("cutting_params", {"feed_rate_mm_min": 500})
|
||
stock_bbox = body.get("stock_bbox")
|
||
to = _get_cached_import("toolpath_optimizer")
|
||
if not to:
|
||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||
result = to.optimize_toolpath(toolpath_points, cutting_params, stock_bbox)
|
||
return {"status": "success", "data": result}
|
||
|
||
|
||
@router.post("/design-electrodes")
|
||
async def design_edm_electrodes(
|
||
request: Request,
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
body = await request.json()
|
||
undercut_regions = body.get("undercut_regions", [{"center": [0, 0, 0], "area": 100, "type": "undercut"}])
|
||
cavity_bbox = body.get("cavity_bbox", {"dimensions": [100, 100, 50]})
|
||
material = body.get("material", "copper")
|
||
spark_gap = body.get("spark_gap", 0.05)
|
||
overburn = body.get("overburn", 0.1)
|
||
ed = _get_cached_import("edm_designer")
|
||
if not ed:
|
||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||
result = ed.design_electrodes(undercut_regions, cavity_bbox, material, spark_gap, overburn)
|
||
return {"status": "success", "data": result}
|
||
|
||
|
||
@router.post("/simulate-machining")
|
||
async def simulate_machining(
|
||
request: Request,
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
body = await request.json()
|
||
operations = body.get("operations", [{"strategy": "z_level_roughing", "levels": [{"z": -5}]}])
|
||
stock_bbox = body.get("stock_bbox", {"dimensions": [100, 100, 50], "min": [-50, -50, -25], "max": [50, 50, 25]})
|
||
resolution = body.get("resolution", 2.0)
|
||
ms = _get_cached_import("machining_simulator")
|
||
if not ms:
|
||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||
result = ms.simulate_machining(operations, stock_bbox, resolution)
|
||
return {"status": "success", "data": result}
|
||
|
||
|
||
@router.post("/export-mold")
|
||
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")
|
||
scheme_id = body.get("scheme_id")
|
||
formats = body.get("formats", ["step", "stl"])
|
||
components = body.get("components", ["cavity", "core"])
|
||
|
||
if not task_id:
|
||
raise HTTPException(404, "缺少 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,
|
||
resolved_scheme_id,
|
||
)
|
||
filename = task_data.get("filename", f"mold_{task_id}")
|
||
|
||
if not cavity_shapes:
|
||
raise HTTPException(
|
||
409,
|
||
"导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
|
||
)
|
||
|
||
base_filename = Path(filename).stem
|
||
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
|
||
|
||
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(cad_exporter.output_dir)):
|
||
raise HTTPException(403, "禁止访问")
|
||
media_types = {
|
||
".step": "application/step", ".stp": "application/step",
|
||
".iges": "application/iges", ".igs": "application/iges",
|
||
".stl": "model/stl", ".brep": "application/octet-stream",
|
||
}
|
||
ext = Path(full_path).suffix.lower()
|
||
media_type = media_types.get(ext, "application/octet-stream")
|
||
return FileResponse(full_path, media_type=media_type, filename=os.path.basename(full_path))
|
||
|
||
|
||
@router.get("/export-recommendations")
|
||
async def get_export_recommendations(
|
||
target: str = "ug",
|
||
current_user: User = Depends(get_current_active_user),
|
||
):
|
||
result = cad_exporter.get_export_recommendations(target)
|
||
return {"status": "success", "data": result}
|
||
|
||
|