后端模块拆分
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter
|
||||
import importlib
|
||||
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _safe_include(module_path: str, label: str):
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
router_obj = getattr(module, "router", None)
|
||||
if router_obj is None:
|
||||
raise ValueError("未找到 router 对象")
|
||||
router.include_router(router_obj)
|
||||
logger.info(f"{label} 路由加载成功")
|
||||
except Exception as exc:
|
||||
logger.warning(f"{label} 路由加载失败,已跳过: {exc}")
|
||||
|
||||
|
||||
_safe_include("moldinsight.api.health_router", "健康检查")
|
||||
_safe_include("moldinsight.api.upload_router", "上传")
|
||||
_safe_include("moldinsight.api.task_router", "任务")
|
||||
_safe_include("moldinsight.api.history_router", "历史")
|
||||
_safe_include("moldinsight.api.debug_router", "调试")
|
||||
_safe_include("moldinsight.api.cam_router", "CAM")
|
||||
_safe_include("moldinsight.api.advanced_router", "高级")
|
||||
@@ -0,0 +1,538 @@
|
||||
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()
|
||||
|
||||
_api_routes_cache = {}
|
||||
|
||||
|
||||
def _get_cached(key):
|
||||
global _api_routes_cache
|
||||
if key not in _api_routes_cache:
|
||||
try:
|
||||
from api import routes
|
||||
except Exception as e:
|
||||
logger.warning(f"api.routes 模块加载失败: {e}")
|
||||
_api_routes_cache["__error__"] = str(e)
|
||||
return None
|
||||
_api_routes_cache.clear()
|
||||
_api_routes_cache.update({
|
||||
"tasks": routes.tasks,
|
||||
"cavity_layout_optimizer": routes.cavity_layout_optimizer,
|
||||
"mold_system_designer": routes.mold_system_designer,
|
||||
"side_action_designer": routes.side_action_designer,
|
||||
"mold_cam_designer": routes.mold_cam_designer,
|
||||
"collision_detector": routes.collision_detector,
|
||||
"toolpath_optimizer": routes.toolpath_optimizer,
|
||||
"edm_designer": routes.edm_designer,
|
||||
"machining_simulator": routes.machining_simulator,
|
||||
"cad_exporter": routes.cad_exporter,
|
||||
})
|
||||
return _api_routes_cache.get(key)
|
||||
|
||||
|
||||
async def _get_task_data(task_id: str) -> dict:
|
||||
task = await redis_task_manager.get_task(task_id)
|
||||
if task:
|
||||
return task
|
||||
tasks = _get_cached("tasks")
|
||||
if tasks and task_id in tasks:
|
||||
return tasks[task_id]
|
||||
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,
|
||||
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("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("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("/ai-parting-detect")
|
||||
async def ai_parting_surface_detect(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
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, "任务不存在")
|
||||
geometry_data = task_data.get("geometry_data")
|
||||
if not geometry_data:
|
||||
raise HTTPException(400, "该任务尚未完成几何分析")
|
||||
from moldinsight.core.ai_parting_detector import AIPartingSurfaceDetectorV2
|
||||
detector = AIPartingSurfaceDetectorV2(use_gnn=True)
|
||||
result = detector._detect_with_geometry(None, geometry_data)
|
||||
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("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("/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("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("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("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("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("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}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
铝金属价格API路由
|
||||
|
||||
提供铝金属价格的当前报价和历史走势数据。
|
||||
路由前缀: /api/aluminum-price
|
||||
不需要认证,公开访问。
|
||||
"""
|
||||
from fastapi import APIRouter, Query
|
||||
from moldinsight.services.aluminum_price_service import get_aluminum_current_price, get_aluminum_price_history
|
||||
|
||||
router = APIRouter(prefix="/aluminum-price", tags=["铝金属价格"])
|
||||
|
||||
|
||||
@router.get("/current")
|
||||
async def aluminum_current_price():
|
||||
return get_aluminum_current_price()
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def aluminum_price_history(days: int = Query(default=30, ge=7, le=365)):
|
||||
return get_aluminum_price_history(days=days)
|
||||
@@ -0,0 +1,103 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.database import User, ProcessingTask
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from moldinsight.services.cam_bundle_service import cam_bundle_service
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
DEFAULT_CAM_PREFERENCES = {
|
||||
"mold_steel": "P20",
|
||||
"surface_quality": "standard",
|
||||
"controller": "fanuc",
|
||||
"include_gcode": False,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/cam/plan")
|
||||
async def generate_cam_plan(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""基于任务分模结果生成 CAM 准备包(MVP)。"""
|
||||
_ = current_user
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
scheme_id = body.get("scheme_id")
|
||||
|
||||
if not task_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 task_id")
|
||||
|
||||
task_result = await db_session.execute(
|
||||
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
processing_task = task_result.scalar_one_or_none()
|
||||
|
||||
persisted_preferences = {}
|
||||
if processing_task and isinstance(processing_task.parameters, dict):
|
||||
persisted_preferences = (
|
||||
processing_task.parameters.get("cam_preferences", {}) or {}
|
||||
)
|
||||
|
||||
mold_steel = body.get(
|
||||
"mold_steel",
|
||||
persisted_preferences.get("mold_steel", DEFAULT_CAM_PREFERENCES["mold_steel"]),
|
||||
)
|
||||
surface_quality = body.get(
|
||||
"surface_quality",
|
||||
persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"]),
|
||||
)
|
||||
controller = body.get(
|
||||
"controller",
|
||||
persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"]),
|
||||
)
|
||||
include_gcode = bool(
|
||||
body.get(
|
||||
"include_gcode",
|
||||
persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"]),
|
||||
)
|
||||
)
|
||||
|
||||
task_view = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_view:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if task_view.get("status") != "completed":
|
||||
raise HTTPException(status_code=400, detail="任务尚未完成,无法生成CAM计划")
|
||||
|
||||
try:
|
||||
data = cam_bundle_service.build_bundle(
|
||||
task_view=task_view,
|
||||
scheme_id=scheme_id,
|
||||
mold_steel=mold_steel,
|
||||
surface_quality=surface_quality,
|
||||
controller=controller,
|
||||
include_gcode=include_gcode,
|
||||
)
|
||||
cam_preferences = {
|
||||
"mold_steel": mold_steel,
|
||||
"surface_quality": surface_quality,
|
||||
"controller": controller,
|
||||
"include_gcode": include_gcode,
|
||||
}
|
||||
if processing_task:
|
||||
parameters = processing_task.parameters if isinstance(processing_task.parameters, dict) else {}
|
||||
parameters["cam_preferences"] = cam_preferences
|
||||
parameters["cam_last_plan"] = {
|
||||
"scheme_id": data.get("scheme_id"),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
}
|
||||
processing_task.parameters = parameters
|
||||
await db_session.commit()
|
||||
|
||||
return {"status": "success", "data": data, "cam_preferences": cam_preferences}
|
||||
except Exception as exc:
|
||||
logger.error(f"生成CAM准备包失败 task_id={task_id}: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"生成CAM准备包失败: {exc}")
|
||||
@@ -0,0 +1,18 @@
|
||||
# api/v1/debug_router.py
|
||||
from fastapi import APIRouter
|
||||
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/debug/tasks")
|
||||
@router.post("/debug/tasks")
|
||||
async def debug_tasks():
|
||||
"""调试接口:查看所有任务"""
|
||||
all_tasks = await redis_task_manager.get_all_tasks()
|
||||
return {
|
||||
"total_tasks": len(all_tasks),
|
||||
"tasks": all_tasks,
|
||||
"redis_connected": redis_task_manager.is_connected
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# api/v1/health_router.py
|
||||
from fastapi import APIRouter
|
||||
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
@router.post("/health")
|
||||
async def health():
|
||||
task_count = await redis_task_manager.get_task_count()
|
||||
return {
|
||||
"status": "healthy",
|
||||
"pythonocc": True,
|
||||
"total_tasks": task_count,
|
||||
"redis_connected": redis_task_manager.is_connected
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# api/v1/history_router.py
|
||||
from fastapi import APIRouter, Depends
|
||||
import urllib.parse
|
||||
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from shared.database.database import get_db_session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
@router.post("/history")
|
||||
async def get_file_history(db_session: AsyncSession = Depends(get_db_session)):
|
||||
"""获取按文件名分组的文件历史记录(支持多上传)"""
|
||||
storage_service = StorageIntegrationService()
|
||||
file_groups = await storage_service.get_all_file_groups(db_session)
|
||||
|
||||
return {
|
||||
"total_files": len(file_groups),
|
||||
"files": file_groups
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history/{filename}")
|
||||
@router.post("/history/{filename}")
|
||||
async def get_file_records(filename: str, db_session: AsyncSession = Depends(get_db_session)):
|
||||
"""获取指定文件名的所有上传记录(支持多上传历史)"""
|
||||
decoded_filename = urllib.parse.unquote(filename)
|
||||
|
||||
storage_service = StorageIntegrationService()
|
||||
file_records = await storage_service.get_file_history_by_filename(
|
||||
db_session,
|
||||
decoded_filename
|
||||
)
|
||||
|
||||
return file_records
|
||||
@@ -0,0 +1,79 @@
|
||||
# api/v1/task_router.py
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from shared.database.database import get_db_session
|
||||
from shared.utils.logger import get_logger
|
||||
from shared.models.database import ProcessingTask, STPFile
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/status/{task_id}")
|
||||
@router.post("/status/{task_id}")
|
||||
async def get_status(task_id: str, db_session: AsyncSession = Depends(get_db_session)):
|
||||
"""
|
||||
获取任务状态
|
||||
|
||||
优先返回内存中的任务信息;
|
||||
如果内存中不存在,则从 PostgreSQL + RustFS 组装一个持久化的任务视图,
|
||||
结构与内存任务保持尽量一致,便于前端集中展示总结性信息。
|
||||
"""
|
||||
try:
|
||||
task_view = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if task_view is None:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
return task_view
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取任务状态失败: {e}")
|
||||
raise HTTPException(500, f"获取任务状态失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/result/{task_id}")
|
||||
@router.post("/result/{task_id}")
|
||||
async def result_page(request: Request, task_id: str, db_session: AsyncSession = Depends(get_db_session)):
|
||||
"""结果详情页面"""
|
||||
# 从数据库查询任务详情
|
||||
result = await db_session.execute(
|
||||
select(ProcessingTask, STPFile)
|
||||
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
|
||||
task_record = result.first()
|
||||
|
||||
if not task_record:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
task, stp_file = task_record
|
||||
|
||||
# 构建任务详情数据
|
||||
task_data = {
|
||||
"task_id": task.task_id,
|
||||
"filename": stp_file.original_filename if stp_file else "",
|
||||
"file_size": stp_file.file_size if stp_file else 0,
|
||||
"status": task.status,
|
||||
"progress": task.progress,
|
||||
"current_step": task.current_step,
|
||||
"created_at": task.created_time.isoformat() if task.created_time else "",
|
||||
"completed_at": task.completed_time.isoformat() if task.completed_time else "",
|
||||
"error": task.error_message if task.error_message else ""
|
||||
}
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import os
|
||||
templates_dir = os.path.join(os.getcwd(), "templates")
|
||||
templates = Jinja2Templates(directory=templates_dir)
|
||||
return templates.TemplateResponse("result.html", {
|
||||
"request": request,
|
||||
"task": task_data,
|
||||
"pythonocc_available": True,
|
||||
"version": "3.0.0"
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
# api/v1/upload_router.py
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, Form
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from shared.models.schemas import ProcessingStatus, create_task_info
|
||||
from shared.utils.file_handler import FileHandler
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.database.database import get_db_session
|
||||
from shared.utils.logger import get_logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
|
||||
try:
|
||||
from celery_tasks import process_stp_task
|
||||
_use_celery = True
|
||||
except ImportError:
|
||||
process_stp_task = None
|
||||
_use_celery = False
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
file_handler = FileHandler()
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_stp(
|
||||
file: UploadFile = File(...),
|
||||
material: str = Form(...),
|
||||
draft_angle: float = Form(...),
|
||||
shrinkage_rate: float = Form(...),
|
||||
parting_precision: float = Form(...),
|
||||
cavity_match: int = Form(...),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""上传STP文件并存储到数据库"""
|
||||
process_params = {
|
||||
"material": material,
|
||||
"draft_angle": float(draft_angle),
|
||||
"shrinkage_rate": float(shrinkage_rate),
|
||||
"parting_precision": float(parting_precision),
|
||||
"cavity_match": int(cavity_match),
|
||||
}
|
||||
logger.info(
|
||||
f"[UPLOAD] 用户={current_user.username}(id={current_user.id}) "
|
||||
f"文件={file.filename} 参数={process_params} "
|
||||
f"大小={file.size if hasattr(file, 'size') else 'unknown'}"
|
||||
)
|
||||
|
||||
if not file.filename.lower().endswith(('.stp', '.step')):
|
||||
logger.warning(f"[UPLOAD] 拒绝: 不支持的文件类型 - {file.filename}")
|
||||
raise HTTPException(400, "只支持STP/STEP文件")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
file_path, file_size, file_meta = await file_handler.save_uploaded_file(file)
|
||||
except ValueError as exc:
|
||||
logger.warning(f"[UPLOAD] 拒绝非法文件: {file.filename}, 原因={exc}")
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
logger.info(f"[UPLOAD] 文件已保存: {file_path} ({file_size} bytes), task_id={task_id}")
|
||||
|
||||
storage_service = StorageIntegrationService()
|
||||
|
||||
stp_file = await storage_service.save_stp_file(
|
||||
session=db_session,
|
||||
file_path=file_path,
|
||||
original_filename=file_meta["safe_original_name"],
|
||||
user_id=current_user.id
|
||||
)
|
||||
logger.info(f"[UPLOAD] STP文件已存入RustFS+PG: stp_file.id={stp_file.id}")
|
||||
|
||||
await storage_service.create_processing_task(
|
||||
db_session,
|
||||
task_id,
|
||||
stp_file.id,
|
||||
parameters=process_params,
|
||||
)
|
||||
|
||||
task_info = create_task_info(
|
||||
task_id=task_id,
|
||||
status=ProcessingStatus.PROCESSING,
|
||||
filename=file.filename,
|
||||
file_path=str(file_path),
|
||||
file_size=file_size,
|
||||
upload_time=str(datetime.now())
|
||||
)
|
||||
task_info["material"] = material
|
||||
task_info["parameters"] = process_params
|
||||
task_info["file_hash"] = file_meta["sha256"]
|
||||
await redis_task_manager.set_task(task_id, task_info)
|
||||
|
||||
if _use_celery:
|
||||
process_stp_task.delay(task_id, str(file_path), stp_file.id, process_params)
|
||||
logger.info(f"[UPLOAD] Celery 任务已调度: task_id={task_id}")
|
||||
else:
|
||||
import asyncio
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
asyncio.create_task(processing_service.process_file_with_storage(
|
||||
task_id, str(file_path), stp_file.id, process_params
|
||||
))
|
||||
logger.info(f"[UPLOAD] 直接后台处理: task_id={task_id} (celery 未安装)")
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "processing",
|
||||
"message": "文件上传成功,开始处理并存储到数据库",
|
||||
"file_info": {
|
||||
"filename": file.filename,
|
||||
"size": file_size,
|
||||
"pythonocc_available": True,
|
||||
"database_file_id": stp_file.id,
|
||||
"sha256": file_meta["sha256"],
|
||||
},
|
||||
"parameters": process_params,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Core 模块
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
AI 分模辅助模型接口示例
|
||||
|
||||
此文件展示了如何创建 AI 模型来辅助分模过程。
|
||||
实际使用时需要替换为真实的 AI 模型。
|
||||
"""
|
||||
from typing import Dict, Any, Optional
|
||||
import numpy as np
|
||||
from OCC.Core.TopoDS import TopoDS_Shape, TopoDS_Face
|
||||
|
||||
|
||||
class AIPartingSurfaceDetector:
|
||||
"""
|
||||
AI 分型面检测器(示例接口)
|
||||
|
||||
功能:
|
||||
- 分析产品 3D 几何
|
||||
- 预测最优分型面位置和方向
|
||||
- 识别倒扣区域
|
||||
"""
|
||||
|
||||
def __init__(self, model_path: Optional[str] = None):
|
||||
"""
|
||||
初始化 AI 分型面检测器
|
||||
|
||||
Args:
|
||||
model_path: 训练好的模型路径
|
||||
"""
|
||||
self.model_path = model_path
|
||||
self.model = None
|
||||
|
||||
# 如果提供了模型路径,加载模型
|
||||
if model_path:
|
||||
self._load_model(model_path)
|
||||
|
||||
def _load_model(self, model_path: str):
|
||||
"""加载训练好的 AI 模型"""
|
||||
# TODO: 实现模型加载逻辑
|
||||
# 示例:
|
||||
# import torch
|
||||
# self.model = torch.load(model_path)
|
||||
print(f"AI 模型加载:{model_path}")
|
||||
|
||||
def detect(self, product_shape: TopoDS_Shape, analysis: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
检测最优分型面
|
||||
|
||||
Args:
|
||||
product_shape: OpenCASCADE 形状对象
|
||||
analysis: 几何分析结果(包含 bounding_box, volume 等)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"origin": [x, y, z], # 分型面原点
|
||||
"normal": [nx, ny, nz], # 分型面法向量
|
||||
"confidence": 0.95, # 置信度
|
||||
"parting_line": [...] # 可选的分型线
|
||||
}
|
||||
"""
|
||||
# TODO: 使用 AI 模型进行预测
|
||||
# 这里是示例返回
|
||||
|
||||
# 1. 将产品形状转换为 AI 模型输入
|
||||
# - 体素化 (voxelization)
|
||||
# - 点云 (point cloud)
|
||||
# - 多视图 (multi-view images)
|
||||
input_data = self._preprocess_shape(product_shape, analysis)
|
||||
|
||||
# 2. 使用模型预测
|
||||
# prediction = self.model.predict(input_data)
|
||||
|
||||
# 3. 返回预测结果
|
||||
return {
|
||||
"origin": [0, 0, analysis["bounding_box"]["center"][2]],
|
||||
"normal": [0, 0, 1], # Z 方向
|
||||
"confidence": 0.85,
|
||||
"undercut_regions": [] # 倒扣区域
|
||||
}
|
||||
|
||||
def _preprocess_shape(self, shape: TopoDS_Shape, analysis: Dict) -> TopoDS_Shape:
|
||||
"""
|
||||
预处理产品形状为 AI 模型输入
|
||||
|
||||
可能的预处理方式:
|
||||
1. 体素化:将 3D 模型转换为 3D 网格
|
||||
2. 点云:采样表面点
|
||||
3. 多视图:渲染多个角度的 2D 图像
|
||||
"""
|
||||
# TODO: 实现预处理逻辑
|
||||
return None
|
||||
|
||||
|
||||
class AIDraftAnalyzer:
|
||||
"""
|
||||
AI 拔模分析器(示例接口)
|
||||
|
||||
功能:
|
||||
- 分析哪些面需要拔模
|
||||
- 预测最优拔模角度
|
||||
- 检测脱模干涉
|
||||
"""
|
||||
|
||||
def __init__(self, model_path: Optional[str] = None):
|
||||
self.model_path = model_path
|
||||
self.model = None
|
||||
|
||||
if model_path:
|
||||
self._load_model(model_path)
|
||||
|
||||
def _load_model(self, model_path: str):
|
||||
"""加载训练好的 AI 模型"""
|
||||
print(f"AI 拔模分析模型加载:{model_path}")
|
||||
|
||||
def analyze(self, product_shape: TopoDS_Shape, parting_surface: TopoDS_Face,
|
||||
base_draft_angle: float) -> Optional[Dict]:
|
||||
"""
|
||||
分析拔模需求
|
||||
|
||||
Args:
|
||||
product_shape: 产品形状
|
||||
parting_surface: 分型面
|
||||
base_draft_angle: 基础拔模角(度)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"drafted_shape": ..., # 应用拔模后的形状
|
||||
"draft_angles": {...}, # 各面的拔模角
|
||||
"interference_areas": [...], # 干涉区域
|
||||
"recommendations": [...] # 优化建议
|
||||
}
|
||||
"""
|
||||
# TODO: 使用 AI 模型分析拔模
|
||||
|
||||
# 示例返回
|
||||
return {
|
||||
"drafted_shape": product_shape, # 简化:返回原始形状
|
||||
"draft_angles": {"default": base_draft_angle},
|
||||
"interference_areas": [],
|
||||
"recommendations": ["建议增加圆角", "壁厚均匀化"]
|
||||
}
|
||||
|
||||
|
||||
class AICavityLayoutOptimizer:
|
||||
"""
|
||||
AI 型腔布局优化器(示例接口)
|
||||
|
||||
功能:
|
||||
- 优化多型腔排列
|
||||
- 设计流道系统
|
||||
- 平衡材料流动
|
||||
"""
|
||||
|
||||
def __init__(self, model_path: Optional[str] = None):
|
||||
self.model_path = model_path
|
||||
self.model = None
|
||||
|
||||
if model_path:
|
||||
self._load_model(model_path)
|
||||
|
||||
def optimize(self, product_shape: TopoDS_Shape, cavity_count: int,
|
||||
mold_base_size: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
优化型腔布局
|
||||
|
||||
Args:
|
||||
product_shape: 产品形状
|
||||
cavity_count: 型腔数量
|
||||
mold_base_size: 模架尺寸
|
||||
|
||||
Returns:
|
||||
{
|
||||
"cavity_positions": [...], # 各型腔位置
|
||||
"runner_system": {...}, # 流道系统设计
|
||||
"balance_score": 0.92, # 流动平衡评分
|
||||
"material_efficiency": 0.85 # 材料利用率
|
||||
}
|
||||
"""
|
||||
# TODO: 使用 AI 优化型腔布局
|
||||
|
||||
return {
|
||||
"cavity_positions": [[0, 0, 0]], # 示例
|
||||
"runner_system": {"type": "cold_runner"},
|
||||
"balance_score": 0.85,
|
||||
"material_efficiency": 0.80
|
||||
}
|
||||
|
||||
|
||||
# ==================== 使用示例 ====================
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 示例:如何使用 AI 模型接口
|
||||
|
||||
# 1. 创建 AI 模型实例
|
||||
parting_detector = AIPartingSurfaceDetector(model_path="models/parting_surface.pth")
|
||||
draft_analyzer = AIDraftAnalyzer(model_path="models/draft_analysis.pth")
|
||||
|
||||
# 2. 设置到 MoldCavityGenerator
|
||||
from moldinsight.core.mold_generator import MoldCavityGenerator
|
||||
|
||||
generator = MoldCavityGenerator()
|
||||
generator.set_ai_model(
|
||||
parting_detector=parting_detector,
|
||||
draft_analyzer=draft_analyzer
|
||||
)
|
||||
|
||||
# 3. 使用(AI 模型会自动介入)
|
||||
# result = generator.generate_mold_cavities(product_shape)
|
||||
|
||||
print("AI 模型接口已配置,分模时将自动使用 AI 辅助")
|
||||
@@ -0,0 +1,547 @@
|
||||
"""
|
||||
AI 分型面检测模块 - 基于 GNN 的分型面预测框架
|
||||
|
||||
架构设计:
|
||||
1. ShapeGraphBuilder - 将 OCC 形状转换为图表示(面为节点,共享边为图边)
|
||||
2. PartingSurfaceGNN - 图神经网络模型定义
|
||||
3. AIPartingSurfaceDetectorV2 - 增强版分型面检测器(集成 GNN)
|
||||
|
||||
图构建策略:
|
||||
- 节点:每个 TopoDS_Face 作为一个节点
|
||||
- 节点特征:法向量(3) + 面积(1) + 曲率(2) + 面类型(1) = 7维
|
||||
- 边:共享 TopoDS_Edge 的面之间建立边
|
||||
- 边特征:共享边长度(1) + 二面角(1) = 2维
|
||||
|
||||
GNN 模型:
|
||||
- 3层 GraphConv + 全局池化 + MLP 分类头
|
||||
- 输出:每个面的分型面归属概率 + 分型方向
|
||||
|
||||
依赖:
|
||||
- PyTorch + PyTorch Geometric(可选,缺失时回退到几何方法)
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import numpy as np
|
||||
from OCC.Core.TopoDS import TopoDS_Shape
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_TORCH_AVAILABLE = False
|
||||
_TORCH_GEOMETRIC_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
_TORCH_AVAILABLE = True
|
||||
try:
|
||||
from torch_geometric.nn import GCNConv, global_mean_pool
|
||||
from torch_geometric.data import Data
|
||||
_TORCH_GEOMETRIC_AVAILABLE = True
|
||||
except ImportError:
|
||||
logger.info("PyTorch Geometric 未安装,GNN 模型不可用")
|
||||
except ImportError:
|
||||
logger.info("PyTorch 未安装,AI 分型面检测将使用几何回退方法")
|
||||
|
||||
|
||||
class ShapeGraphBuilder:
|
||||
"""将 OCC 形状转换为图表示"""
|
||||
|
||||
def build_graph(self, shape: TopoDS_Shape) -> Optional[Dict]:
|
||||
"""
|
||||
从 OCC 形状构建图数据
|
||||
|
||||
Returns:
|
||||
{
|
||||
"node_features": np.ndarray (N, 7),
|
||||
"edge_index": np.ndarray (2, E),
|
||||
"edge_features": np.ndarray (E, 2),
|
||||
"face_map": List[TopoDS_Face],
|
||||
"num_nodes": int,
|
||||
"num_edges": int
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
|
||||
from OCC.Core.TopExp import topexp_MapShapesAndAncestors
|
||||
from OCC.Core.TopoDS import TopoDS_Face, TopoDS_Edge, topods
|
||||
|
||||
faces = []
|
||||
face_features = []
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
face = topods.Face(explorer.Current())
|
||||
features = self._extract_face_features(face)
|
||||
if features is not None:
|
||||
faces.append(face)
|
||||
face_features.append(features)
|
||||
explorer.Next()
|
||||
|
||||
if not faces:
|
||||
logger.warning("未找到面,无法构建图")
|
||||
return None
|
||||
|
||||
node_features = np.array(face_features, dtype=np.float32)
|
||||
|
||||
edge_map = TopTools_IndexedDataMapOfShapeListOfShape()
|
||||
topexp_MapShapesAndAncestors(shape, TopAbs_EDGE, TopAbs_FACE, edge_map)
|
||||
|
||||
edge_list = []
|
||||
edge_features_list = []
|
||||
|
||||
for i in range(1, edge_map.Extent() + 1):
|
||||
edge = topods.Edge(edge_map.FindKey(i))
|
||||
face_list = edge_map.FindFromIndex(i)
|
||||
|
||||
connected_faces = []
|
||||
it = face_list.begin()
|
||||
while it != face_list.end():
|
||||
f = topods.Face(it.Value())
|
||||
try:
|
||||
idx = faces.index(f)
|
||||
connected_faces.append(idx)
|
||||
except ValueError:
|
||||
pass
|
||||
it.next_ptr()
|
||||
|
||||
if len(connected_faces) >= 2:
|
||||
edge_feat = self._extract_edge_features(edge, connected_faces, faces)
|
||||
for j in range(len(connected_faces)):
|
||||
for k in range(j + 1, len(connected_faces)):
|
||||
edge_list.append([connected_faces[j], connected_faces[k]])
|
||||
edge_features_list.append(edge_feat)
|
||||
|
||||
if not edge_list:
|
||||
logger.warning("未找到边连接,返回无图边的图")
|
||||
edge_index = np.zeros((2, 0), dtype=np.int64)
|
||||
edge_features_arr = np.zeros((0, 2), dtype=np.float32)
|
||||
else:
|
||||
edge_index = np.array(edge_list, dtype=np.int64).T
|
||||
rev_edges = np.array([[e[1], e[0]] for e in edge_list], dtype=np.int64).T
|
||||
edge_index = np.concatenate([edge_index, rev_edges], axis=1)
|
||||
edge_features_arr = np.array(edge_features_list, dtype=np.float32)
|
||||
edge_features_arr = np.concatenate([edge_features_arr, edge_features_arr], axis=0)
|
||||
|
||||
return {
|
||||
"node_features": node_features,
|
||||
"edge_index": edge_index,
|
||||
"edge_features": edge_features_arr,
|
||||
"face_map": faces,
|
||||
"num_nodes": len(faces),
|
||||
"num_edges": edge_index.shape[1]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"图构建失败: {e}")
|
||||
return None
|
||||
|
||||
def _extract_face_features(self, face: Any) -> Optional[np.ndarray]:
|
||||
"""
|
||||
提取面特征:[nx, ny, nz, area, u_curvature, v_curvature, face_type]
|
||||
"""
|
||||
try:
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
|
||||
surface = BRepAdaptor_Surface(face)
|
||||
|
||||
u = (surface.FirstUParameter() + surface.LastUParameter()) / 2
|
||||
v = (surface.FirstVParameter() + surface.LastVParameter()) / 2
|
||||
|
||||
if surface.GetType() == 0:
|
||||
normal = surface.Plane().Position().Direction()
|
||||
face_type = 0.0
|
||||
u_curv = 0.0
|
||||
v_curv = 0.0
|
||||
elif surface.GetType() == 1:
|
||||
normal = surface.Cylinder().Position().Direction()
|
||||
face_type = 1.0
|
||||
radius = surface.Cylinder().Radius()
|
||||
u_curv = 1.0 / radius if radius > 0.001 else 0.0
|
||||
v_curv = 0.0
|
||||
elif surface.GetType() == 2:
|
||||
normal = surface.Cone().Position().Direction()
|
||||
face_type = 2.0
|
||||
u_curv = 0.0
|
||||
v_curv = 0.0
|
||||
elif surface.GetType() == 3:
|
||||
normal = surface.Sphere().Position().Direction()
|
||||
face_type = 3.0
|
||||
radius = surface.Sphere().Radius()
|
||||
u_curv = 1.0 / radius if radius > 0.001 else 0.0
|
||||
v_curv = 1.0 / radius if radius > 0.001 else 0.0
|
||||
elif surface.GetType() == 4:
|
||||
normal = surface.Torus().Position().Direction()
|
||||
face_type = 4.0
|
||||
u_curv = 0.0
|
||||
v_curv = 0.0
|
||||
else:
|
||||
from OCC.Core.BRepLProp import BRepLProp_SLProps
|
||||
props = BRepLProp_SLProps(surface, 2, 0.001)
|
||||
props.SetParameters(u, v)
|
||||
if props.IsNormalDefined():
|
||||
normal = props.Normal()
|
||||
else:
|
||||
normal = gp_Dir(0, 0, 1)
|
||||
face_type = 5.0
|
||||
u_curv = 0.0
|
||||
v_curv = 0.0
|
||||
|
||||
face_props = GProp_GProps()
|
||||
brepgprop.SurfaceProperties(face, face_props)
|
||||
area = face_props.Mass()
|
||||
|
||||
return np.array([
|
||||
normal.X(), normal.Y(), normal.Z(),
|
||||
area,
|
||||
u_curv, v_curv,
|
||||
face_type
|
||||
], dtype=np.float32)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"面特征提取失败: {e}")
|
||||
return None
|
||||
|
||||
def _extract_edge_features(self, edge: Any, connected_faces: List[int],
|
||||
faces: List) -> np.ndarray:
|
||||
"""
|
||||
提取边特征:[edge_length, dihedral_angle]
|
||||
"""
|
||||
try:
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
|
||||
curve = BRepAdaptor_Curve(edge)
|
||||
first = curve.FirstParameter()
|
||||
last = curve.LastParameter()
|
||||
|
||||
edge_len = abs(last - first)
|
||||
|
||||
dihedral = 0.0
|
||||
if len(connected_faces) >= 2:
|
||||
n1 = self._get_face_normal_fast(faces[connected_faces[0]])
|
||||
n2 = self._get_face_normal_fast(faces[connected_faces[1]])
|
||||
if n1 is not None and n2 is not None:
|
||||
dot = np.clip(np.dot(n1, n2), -1.0, 1.0)
|
||||
dihedral = np.arccos(dot)
|
||||
|
||||
return np.array([edge_len, dihedral], dtype=np.float32)
|
||||
|
||||
except Exception:
|
||||
return np.array([0.0, 0.0], dtype=np.float32)
|
||||
|
||||
def _get_face_normal_fast(self, face: Any) -> Optional[np.ndarray]:
|
||||
"""快速获取面法向量(numpy数组)"""
|
||||
try:
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
||||
surface = BRepAdaptor_Surface(face)
|
||||
if surface.GetType() == 0:
|
||||
n = surface.Plane().Position().Direction()
|
||||
return np.array([n.X(), n.Y(), n.Z()])
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
if _TORCH_GEOMETRIC_AVAILABLE:
|
||||
|
||||
class PartingSurfaceGNN(nn.Module):
|
||||
"""
|
||||
分型面检测 GNN 模型
|
||||
|
||||
架构:
|
||||
- 3层 GCNConv (hidden_dim=64)
|
||||
- 全局平均池化
|
||||
- 3层 MLP 分类头
|
||||
- 输出:每个面的分型面归属概率 (0-1)
|
||||
"""
|
||||
|
||||
def __init__(self, input_dim: int = 7, hidden_dim: int = 64,
|
||||
num_layers: int = 3, dropout: float = 0.3):
|
||||
super().__init__()
|
||||
|
||||
self.input_dim = input_dim
|
||||
self.hidden_dim = hidden_dim
|
||||
self.num_layers = num_layers
|
||||
|
||||
self.input_proj = nn.Linear(input_dim, hidden_dim)
|
||||
|
||||
self.convs = nn.ModuleList()
|
||||
self.bns = nn.ModuleList()
|
||||
for _ in range(num_layers):
|
||||
self.convs.append(GCNConv(hidden_dim, hidden_dim))
|
||||
self.bns.append(nn.BatchNorm1d(hidden_dim))
|
||||
|
||||
self.dropout = dropout
|
||||
|
||||
self.mlp = nn.Sequential(
|
||||
nn.Linear(hidden_dim, hidden_dim),
|
||||
nn.ReLU(),
|
||||
nn.Dropout(dropout),
|
||||
nn.Linear(hidden_dim, hidden_dim // 2),
|
||||
nn.ReLU(),
|
||||
nn.Dropout(dropout),
|
||||
nn.Linear(hidden_dim // 2, 1),
|
||||
)
|
||||
|
||||
def forward(self, data: Data) -> torch.Tensor:
|
||||
x, edge_index = data.x, data.edge_index
|
||||
|
||||
x = self.input_proj(x)
|
||||
x = F.relu(x)
|
||||
|
||||
for conv, bn in zip(self.convs, self.bns):
|
||||
x = conv(x, edge_index)
|
||||
x = bn(x)
|
||||
x = F.relu(x)
|
||||
x = F.dropout(x, p=self.dropout, training=self.training)
|
||||
|
||||
out = self.mlp(x)
|
||||
return torch.sigmoid(out).squeeze(-1)
|
||||
|
||||
class PartingDirectionHead(nn.Module):
|
||||
"""
|
||||
分型方向预测头
|
||||
|
||||
基于全局池化的面特征,预测分型方向向量
|
||||
"""
|
||||
|
||||
def __init__(self, hidden_dim: int = 64):
|
||||
super().__init__()
|
||||
self.direction_mlp = nn.Sequential(
|
||||
nn.Linear(hidden_dim, hidden_dim),
|
||||
nn.ReLU(),
|
||||
nn.Linear(hidden_dim, 3),
|
||||
)
|
||||
|
||||
def forward(self, node_embeddings: torch.Tensor,
|
||||
batch: torch.Tensor) -> torch.Tensor:
|
||||
pooled = global_mean_pool(node_embeddings, batch)
|
||||
direction = self.direction_mlp(pooled)
|
||||
direction = F.normalize(direction, p=2, dim=-1)
|
||||
return direction
|
||||
|
||||
|
||||
class AIPartingSurfaceDetectorV2:
|
||||
"""
|
||||
增强版 AI 分型面检测器
|
||||
|
||||
支持:
|
||||
1. GNN 模型推理(需要 PyTorch + PyG)
|
||||
2. 几何方法回退(无需任何 AI 依赖)
|
||||
3. 模型训练数据收集
|
||||
"""
|
||||
|
||||
def __init__(self, model_path: Optional[str] = None,
|
||||
use_gnn: bool = True,
|
||||
device: str = "cpu"):
|
||||
self.model = None
|
||||
self.direction_head = None
|
||||
self.graph_builder = ShapeGraphBuilder()
|
||||
self.device = device
|
||||
self.use_gnn = use_gnn and _TORCH_GEOMETRIC_AVAILABLE
|
||||
|
||||
if model_path and self.use_gnn:
|
||||
self._load_model(model_path)
|
||||
|
||||
def _load_model(self, model_path: str):
|
||||
"""加载训练好的 GNN 模型"""
|
||||
if not _TORCH_GEOMETRIC_AVAILABLE:
|
||||
logger.warning("PyTorch Geometric 不可用,无法加载 GNN 模型")
|
||||
return
|
||||
|
||||
try:
|
||||
checkpoint = torch.load(model_path, map_location=self.device)
|
||||
self.model = PartingSurfaceGNN(
|
||||
input_dim=checkpoint.get("input_dim", 7),
|
||||
hidden_dim=checkpoint.get("hidden_dim", 64),
|
||||
)
|
||||
self.model.load_state_dict(checkpoint["model_state_dict"])
|
||||
self.model.to(self.device)
|
||||
self.model.eval()
|
||||
|
||||
if "direction_head_state_dict" in checkpoint:
|
||||
self.direction_head = PartingDirectionHead(
|
||||
hidden_dim=checkpoint.get("hidden_dim", 64)
|
||||
)
|
||||
self.direction_head.load_state_dict(checkpoint["direction_head_state_dict"])
|
||||
self.direction_head.to(self.device)
|
||||
self.direction_head.eval()
|
||||
|
||||
logger.info(f"GNN 模型加载成功: {model_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"GNN 模型加载失败: {e}")
|
||||
self.model = None
|
||||
|
||||
def detect(self, product_shape: TopoDS_Shape, analysis: Dict) -> Optional[Dict]:
|
||||
"""
|
||||
检测最优分型面
|
||||
|
||||
Args:
|
||||
product_shape: OpenCASCADE 形状对象
|
||||
analysis: 几何分析结果
|
||||
|
||||
Returns:
|
||||
{
|
||||
"origin": [x, y, z],
|
||||
"normal": [nx, ny, nz],
|
||||
"confidence": float,
|
||||
"parting_line": [...],
|
||||
"method": "gnn" | "geometric"
|
||||
}
|
||||
"""
|
||||
if self.use_gnn and self.model is not None:
|
||||
result = self._detect_with_gnn(product_shape, analysis)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
return self._detect_with_geometry(product_shape, analysis)
|
||||
|
||||
def _detect_with_gnn(self, shape: TopoDS_Shape, analysis: Dict) -> Optional[Dict]:
|
||||
"""使用 GNN 模型检测分型面"""
|
||||
if not _TORCH_GEOMETRIC_AVAILABLE:
|
||||
return None
|
||||
|
||||
try:
|
||||
graph_data = self.graph_builder.build_graph(shape)
|
||||
if graph_data is None:
|
||||
return None
|
||||
|
||||
node_features = torch.tensor(
|
||||
graph_data["node_features"], dtype=torch.float32
|
||||
).to(self.device)
|
||||
edge_index = torch.tensor(
|
||||
graph_data["edge_index"], dtype=torch.long
|
||||
).to(self.device)
|
||||
|
||||
data = Data(x=node_features, edge_index=edge_index)
|
||||
|
||||
with torch.no_grad():
|
||||
face_probs = self.model(data)
|
||||
|
||||
if self.direction_head is not None:
|
||||
batch = torch.zeros(
|
||||
data.num_nodes, dtype=torch.long, device=self.device
|
||||
)
|
||||
direction = self.direction_head(data.x, batch)
|
||||
normal = direction.cpu().numpy().tolist()
|
||||
else:
|
||||
normal = [0, 0, 1]
|
||||
|
||||
parting_face_mask = face_probs.cpu().numpy() > 0.5
|
||||
confidence = float(face_probs.mean().cpu().numpy())
|
||||
|
||||
bbox = analysis.get("bounding_box", {})
|
||||
center = bbox.get("center", [0, 0, 0])
|
||||
|
||||
return {
|
||||
"origin": center,
|
||||
"normal": normal,
|
||||
"confidence": confidence,
|
||||
"method": "gnn",
|
||||
"face_probabilities": face_probs.cpu().numpy().tolist(),
|
||||
"parting_face_count": int(parting_face_mask.sum()),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"GNN 检测失败,回退到几何方法: {e}")
|
||||
return None
|
||||
|
||||
def _detect_with_geometry(self, shape: TopoDS_Shape, analysis: Dict) -> Dict:
|
||||
"""几何方法回退:基于法向量统计的分型面检测"""
|
||||
try:
|
||||
graph_data = self.graph_builder.build_graph(shape)
|
||||
if graph_data is not None:
|
||||
node_features = graph_data["node_features"]
|
||||
normals = node_features[:, :3]
|
||||
areas = node_features[:, 3]
|
||||
|
||||
total_area = areas.sum()
|
||||
if total_area > 0:
|
||||
weights = areas / total_area
|
||||
weighted_normal = np.sum(normals * weights[:, np.newaxis], axis=0)
|
||||
else:
|
||||
weighted_normal = np.mean(normals, axis=0)
|
||||
|
||||
length = np.linalg.norm(weighted_normal)
|
||||
if length > 0.001:
|
||||
weighted_normal /= length
|
||||
else:
|
||||
weighted_normal = np.array([0, 0, 1])
|
||||
|
||||
dot_products = np.abs(np.dot(normals, weighted_normal))
|
||||
confidence = float(np.mean(dot_products))
|
||||
|
||||
bbox = analysis.get("bounding_box", {})
|
||||
center = bbox.get("center", [0, 0, 0])
|
||||
|
||||
return {
|
||||
"origin": center,
|
||||
"normal": weighted_normal.tolist(),
|
||||
"confidence": confidence,
|
||||
"method": "geometric",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"几何方法检测失败: {e}")
|
||||
|
||||
bbox = analysis.get("bounding_box", {})
|
||||
center = bbox.get("center", [0, 0, 0])
|
||||
return {
|
||||
"origin": center,
|
||||
"normal": [0, 0, 1],
|
||||
"confidence": 0.5,
|
||||
"method": "fallback",
|
||||
}
|
||||
|
||||
def collect_training_sample(self, shape: TopoDS_Shape, analysis: Dict,
|
||||
ground_truth_normal: List[float],
|
||||
ground_truth_origin: List[float]) -> Optional[Dict]:
|
||||
"""
|
||||
收集训练样本
|
||||
|
||||
Args:
|
||||
shape: OCC 形状
|
||||
analysis: 几何分析
|
||||
ground_truth_normal: 人工标注的分型方向
|
||||
ground_truth_origin: 人工标注的分型面原点
|
||||
|
||||
Returns:
|
||||
可序列化的训练样本
|
||||
"""
|
||||
graph_data = self.graph_builder.build_graph(shape)
|
||||
if graph_data is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"node_features": graph_data["node_features"].tolist(),
|
||||
"edge_index": graph_data["edge_index"].tolist(),
|
||||
"edge_features": graph_data["edge_features"].tolist(),
|
||||
"label_normal": ground_truth_normal,
|
||||
"label_origin": ground_truth_origin,
|
||||
"bounding_box": analysis.get("bounding_box", {}),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_model(input_dim: int = 7, hidden_dim: int = 64,
|
||||
num_layers: int = 3) -> Optional[Any]:
|
||||
"""创建新的 GNN 模型实例"""
|
||||
if not _TORCH_GEOMETRIC_AVAILABLE:
|
||||
logger.warning("PyTorch Geometric 不可用,无法创建模型")
|
||||
return None
|
||||
return PartingSurfaceGNN(
|
||||
input_dim=input_dim,
|
||||
hidden_dim=hidden_dim,
|
||||
num_layers=num_layers,
|
||||
)
|
||||
@@ -0,0 +1,625 @@
|
||||
"""
|
||||
增强版铝制家电包装泡沫模具分模算法
|
||||
|
||||
本模块实现了针对铝泡沫模具的优化分模算法,包括:
|
||||
1. 改进的法向量分析 - 高斯权重、多点采样
|
||||
2. 多分型面检测 - 支持复杂产品
|
||||
3. 倒扣区域检测 - 自动识别
|
||||
4. 铝泡沫收缩补偿 - 基于发泡倍率
|
||||
5. 优化的型腔分离 - 精确布尔运算
|
||||
6. 模具块生成 - A/B板结构
|
||||
7. 分型线平滑处理 - B样条拟合
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
import numpy as np
|
||||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
|
||||
from OCC.Core.gp import gp_Pln, gp_Dir, gp_Pnt
|
||||
from OCC.Core.TopoDS import TopoDS_Face, TopoDS_Shape, topods
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
|
||||
from shared.models.schemas import create_mold_cavity_data, create_mold_key_info
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.core.base_mold_generator import BaseMoldGenerator
|
||||
from moldinsight.core.side_action_designer import SideActionDesigner
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AluminumFoamMoldGenerator(BaseMoldGenerator):
|
||||
"""铝制家电包装泡沫模具分模生成器"""
|
||||
|
||||
def __init__(self,
|
||||
shrinkage_rate: float = 0.015,
|
||||
draft_angle: float = 3.0,
|
||||
material_density: float = 0.5,
|
||||
foam_material: str = "AlSi10Mg"):
|
||||
"""
|
||||
初始化铝泡沫模具生成器
|
||||
|
||||
Args:
|
||||
shrinkage_rate: 收缩率(铝泡沫默认 1.5%)
|
||||
draft_angle: 拔模角(铝泡沫建议 3-5°)
|
||||
material_density: 材料密度 g/cm³(铝泡沫 0.3-0.8)
|
||||
foam_material: 泡沫材料类型
|
||||
"""
|
||||
super().__init__(shrinkage_rate, draft_angle, material_density)
|
||||
|
||||
self.foam_material = foam_material
|
||||
|
||||
self.foam_materials = {
|
||||
"AlSi10Mg": {
|
||||
"density": 0.45,
|
||||
"expansion_ratio": 2.5,
|
||||
"shrinkage_rate": 0.015,
|
||||
"molding_temp": 380,
|
||||
"description": "常用铝硅泡沫"
|
||||
},
|
||||
"AlSi12": {
|
||||
"density": 0.50,
|
||||
"expansion_ratio": 2.2,
|
||||
"shrinkage_rate": 0.012,
|
||||
"molding_temp": 360,
|
||||
"description": "高强度铝泡沫"
|
||||
},
|
||||
"Pure Al Foam": {
|
||||
"density": 0.35,
|
||||
"expansion_ratio": 3.0,
|
||||
"shrinkage_rate": 0.020,
|
||||
"molding_temp": 400,
|
||||
"description": "纯铝泡沫"
|
||||
},
|
||||
"AlSi7Mg": {
|
||||
"density": 0.40,
|
||||
"expansion_ratio": 2.8,
|
||||
"shrinkage_rate": 0.018,
|
||||
"molding_temp": 390,
|
||||
"description": "轻质铝镁泡沫"
|
||||
}
|
||||
}
|
||||
|
||||
self.plastic_materials = {
|
||||
"ABS": {"density": 1.05, "shrinkage": 0.005},
|
||||
"PP": {"density": 0.90, "shrinkage": 0.016},
|
||||
"PC": {"density": 1.20, "shrinkage": 0.005},
|
||||
"PE": {"density": 0.95, "shrinkage": 0.025},
|
||||
"PS": {"density": 1.05, "shrinkage": 0.004},
|
||||
"PA": {"density": 1.14, "shrinkage": 0.015},
|
||||
"POM": {"density": 1.42, "shrinkage": 0.020},
|
||||
"PMMA": {"density": 1.18, "shrinkage": 0.004}
|
||||
}
|
||||
|
||||
self.parting_line_tolerance = 0.1
|
||||
self.max_draft_angle = 5.0
|
||||
self.min_draft_angle = 1.0
|
||||
|
||||
self.cavity_count = 1
|
||||
self.parting_precision = 0.1
|
||||
self.cavity_match_rate = 95.0
|
||||
self.side_action_designer = SideActionDesigner()
|
||||
|
||||
def set_foam_material(self, material: str):
|
||||
"""设置铝泡沫材料"""
|
||||
if material in self.foam_materials:
|
||||
props = self.foam_materials[material]
|
||||
self.foam_material = material
|
||||
self.material_density = props["density"]
|
||||
self.shrinkage_rate = props["shrinkage_rate"]
|
||||
logger.info(f"铝泡沫材料设置为 {material}, 密度: {props['density']} g/cm³")
|
||||
else:
|
||||
logger.warning(f"未知材料 {material}, 使用当前设置")
|
||||
|
||||
def set_material(self, material: str):
|
||||
"""设置材料(自动识别类型)"""
|
||||
if material in self.foam_materials:
|
||||
self.set_foam_material(material)
|
||||
elif material in self.plastic_materials:
|
||||
props = self.plastic_materials[material]
|
||||
self.material_density = props["density"]
|
||||
self.shrinkage_rate = props["shrinkage"]
|
||||
logger.info(f"塑料材料设置为 {material}, 密度: {props['density']} g/cm³")
|
||||
else:
|
||||
logger.warning(f"未知材料 {material}")
|
||||
|
||||
def generate_mold_cavities(self, product_shape: TopoDS_Shape) -> Dict[str, Any]:
|
||||
"""
|
||||
从产品的3D模型生成型腔和型芯
|
||||
|
||||
完整流程:
|
||||
1. 分析产品几何
|
||||
2. 检测分型面(支持多分型面)
|
||||
3. 检测倒扣区域
|
||||
4. 应用收缩率补偿
|
||||
5. 应用拔模角
|
||||
6. 分离型腔和型芯
|
||||
7. 生成模具块
|
||||
"""
|
||||
logger.info(f"开始生成铝泡沫模具型腔 (材料: {self.foam_material})...")
|
||||
|
||||
try:
|
||||
analysis = self._analyze_product_geometry(product_shape)
|
||||
|
||||
parting_result = self._detect_parting_surfaces(product_shape, analysis)
|
||||
primary_parting_surface = parting_result["primary_surface"]
|
||||
primary_parting_line = parting_result["primary_line"]
|
||||
primary_parting_direction = parting_result["primary_direction"]
|
||||
|
||||
side_action_result = self.side_action_designer.analyze_and_design(
|
||||
shape=product_shape,
|
||||
parting_direction=primary_parting_direction,
|
||||
mold_size=self._calculate_mold_size(analysis),
|
||||
parting_surface=primary_parting_surface,
|
||||
)
|
||||
undercut_regions = self._build_undercut_regions(
|
||||
side_action_result.get("undercut_analysis", {})
|
||||
)
|
||||
|
||||
scaled_shape = self._apply_shrinkage_compensation(product_shape)
|
||||
|
||||
drafted_shape = self._apply_draft_angles(scaled_shape, primary_parting_surface)
|
||||
|
||||
cavity, core = self._split_cavity_core(drafted_shape, primary_parting_surface)
|
||||
|
||||
mold_block = self._generate_mold_block(cavity, analysis)
|
||||
|
||||
smoothed_parting_line = self._smooth_parting_line(primary_parting_line)
|
||||
|
||||
logger.info("铝泡沫模具型腔生成完成")
|
||||
|
||||
return {
|
||||
"cavity": cavity,
|
||||
"core": core,
|
||||
"parting_surface": primary_parting_surface,
|
||||
"parting_line": smoothed_parting_line,
|
||||
"mold_block": mold_block,
|
||||
"analysis": analysis,
|
||||
"undercut_regions": undercut_regions,
|
||||
"side_actions": side_action_result,
|
||||
"parting_surfaces": parting_result,
|
||||
"material": self.foam_material,
|
||||
"shrinkage_applied": self.shrinkage_rate,
|
||||
"draft_angle_applied": self.draft_angle
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"模具型腔生成失败: {e}")
|
||||
raise
|
||||
|
||||
def generate_detailed_cavity_json(self, cavity_data: Dict) -> Dict[str, Any]:
|
||||
"""生成详细的型腔三维JSON数据"""
|
||||
cavity = cavity_data["cavity"]
|
||||
core = cavity_data["core"]
|
||||
parting_surface = cavity_data["parting_surface"]
|
||||
analysis = cavity_data["analysis"]
|
||||
|
||||
cavity_geometry = self._extract_shape_geometry(cavity, "cavity")
|
||||
core_geometry = self._extract_shape_geometry(core, "core")
|
||||
|
||||
parting_geometry = self._extract_parting_surface_geometry(parting_surface)
|
||||
|
||||
material_info = self.foam_materials.get(self.foam_material, {})
|
||||
|
||||
detailed_json = {
|
||||
"metadata": {
|
||||
"version": "3.0",
|
||||
"generated_at": str(np.datetime64('now')),
|
||||
"mold_type": "aluminum_foam",
|
||||
"shrinkage_rate": self.shrinkage_rate,
|
||||
"draft_angle": self.draft_angle,
|
||||
"unit": "mm",
|
||||
"foam_material": self.foam_material
|
||||
},
|
||||
"product_analysis": {
|
||||
"bounding_box": analysis.get("bounding_box", {}),
|
||||
"volume": analysis.get("volume", 0),
|
||||
"surface_area": analysis.get("surface_area", 0),
|
||||
"center_of_mass": analysis.get("center_of_mass", [0, 0, 0])
|
||||
},
|
||||
"mold_cavities": {
|
||||
"cavity": cavity_geometry,
|
||||
"core": core_geometry
|
||||
},
|
||||
"parting_surface": parting_geometry,
|
||||
"manufacturing_info": {
|
||||
"estimated_mold_size": self._calculate_mold_size(analysis),
|
||||
"estimated_clamping_force": self._calculate_clamping_force(analysis),
|
||||
"clamping_force_formula": "投影面积(cm²) × 0.3 (泡沫材料系数)",
|
||||
"recommended_material": material_info.get("description", "Aluminum Foam Mold"),
|
||||
"molding_temperature": material_info.get("molding_temp", 380),
|
||||
"expansion_ratio": material_info.get("expansion_ratio", 2.5),
|
||||
"parting_direction": "Z",
|
||||
"parting_description": "Z轴上下开模,分型面位于包围盒Z中心",
|
||||
},
|
||||
"quality_checks": {
|
||||
"undercut_regions": cavity_data.get("undercut_regions", []),
|
||||
"side_actions": cavity_data.get("side_actions", {}),
|
||||
"parting_line_smoothness": self._assess_parting_line_smoothness(
|
||||
cavity_data.get("parting_line", [])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return detailed_json
|
||||
|
||||
def generate_cavity_key_info(self, cavity_data: Dict) -> Dict[str, Any]:
|
||||
"""生成模具型腔的关键信息"""
|
||||
analysis = cavity_data["analysis"]
|
||||
material_info = self.foam_materials.get(self.foam_material, {})
|
||||
|
||||
key_info = {
|
||||
"mold_parameters": {
|
||||
"shrinkage_rate": f"{self.shrinkage_rate * 100:.2f}%",
|
||||
"draft_angle": f"{self.draft_angle}°",
|
||||
"parting_line_length": self._calculate_parting_line_length(
|
||||
cavity_data.get("parting_line", [])
|
||||
),
|
||||
"cavity_depth": analysis.get("bounding_box", {}).get("dimensions", [0, 0, 0])[2],
|
||||
"foam_material": self.foam_material,
|
||||
"molding_temp": f"{material_info.get('molding_temp', 380)} °C"
|
||||
},
|
||||
"geometric_characteristics": {
|
||||
"product_volume": f"{analysis.get('volume', 0) / 1000:.2f} cm³",
|
||||
"product_weight": self._calculate_product_weight(analysis),
|
||||
"wall_thickness_range": self._estimate_wall_thickness(analysis),
|
||||
"complexity_score": self._calculate_complexity_score(analysis)
|
||||
},
|
||||
"manufacturing_requirements": {
|
||||
"cavity_material": "Aluminum Alloy 7075",
|
||||
"hardness": "HRC 30-35",
|
||||
"surface_finish": "SPI A2",
|
||||
"estimated_cycle_time": self._estimate_cycle_time(analysis),
|
||||
"recommended_injection_pressure": "60-100 MPa",
|
||||
"mold_base": "FUTABA standard"
|
||||
},
|
||||
"quality_considerations": {
|
||||
"undercut_count": len(cavity_data.get("undercut_regions", [])),
|
||||
"undercut_regions": cavity_data.get("undercut_regions", []),
|
||||
"side_action_summary": cavity_data.get("side_actions", {}).get("summary", {}),
|
||||
"sink_mark_risk": self._identify_sink_mark_risk(analysis),
|
||||
"warpage_risk": self._assess_warpage_risk(analysis),
|
||||
"venting_requirement": self._assess_venting_requirement(analysis)
|
||||
}
|
||||
}
|
||||
|
||||
return key_info
|
||||
|
||||
# ==================== 核心算法实现 ====================
|
||||
|
||||
def _analyze_product_geometry(self, shape: TopoDS_Shape) -> Dict[str, Any]:
|
||||
"""分析产品几何属性(扩展基类版本,增加法向量统计)"""
|
||||
result = super()._analyze_product_geometry(shape)
|
||||
result["normal_statistics"] = self._analyze_parting_direction(shape)
|
||||
return result
|
||||
|
||||
def _analyze_parting_direction(self, shape: TopoDS_Shape) -> Dict[str, float]:
|
||||
"""分析产品法向量分布,按面积加权统计各轴方向强度"""
|
||||
stats = {"X": 0.0, "Y": 0.0, "Z": 0.0}
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
|
||||
while explorer.More():
|
||||
face = topods.Face(explorer.Current())
|
||||
explorer.Next()
|
||||
try:
|
||||
normal = self._get_face_normal(face)
|
||||
if normal is None:
|
||||
continue
|
||||
props = GProp_GProps()
|
||||
brepgprop.SurfaceProperties(face, props)
|
||||
area = max(float(props.Mass()), 1.0)
|
||||
stats["X"] += abs(float(normal.X())) * area
|
||||
stats["Y"] += abs(float(normal.Y())) * area
|
||||
stats["Z"] += abs(float(normal.Z())) * area
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
total = stats["X"] + stats["Y"] + stats["Z"]
|
||||
if total <= 0:
|
||||
return {"X": 33.3, "Y": 33.3, "Z": 33.4}
|
||||
|
||||
return {
|
||||
axis: round(value / total * 100, 2)
|
||||
for axis, value in stats.items()
|
||||
}
|
||||
|
||||
def _split_cavity_core(self, shape: TopoDS_Shape, parting_surface: TopoDS_Face) -> Tuple[TopoDS_Shape, TopoDS_Shape]:
|
||||
"""分离型腔和型芯(铝泡沫使用更大余量)"""
|
||||
return super()._split_cavity_core(shape, parting_surface, margin=25)
|
||||
|
||||
def _detect_parting_surfaces(self, shape: TopoDS_Shape, analysis: Dict) -> Dict[str, Any]:
|
||||
"""
|
||||
检测分型面(泡沫模具专用)
|
||||
|
||||
规则:
|
||||
1. 优先选择 Z 轴方向分型(上下开模)
|
||||
2. 分型面位置选在产品的最大轮廓处,即包围盒的 Z 方向中心
|
||||
"""
|
||||
bbox = analysis["bounding_box"]
|
||||
center = bbox["center"]
|
||||
primary_direction = [0, 0, 1] # Z 轴方向
|
||||
|
||||
# 分型面位于包围盒 Z 方向中心(最大轮廓处)
|
||||
parting_z = center[2]
|
||||
parting_plane = gp_Pln(gp_Pnt(center[0], center[1], parting_z), gp_Dir(0, 0, 1))
|
||||
|
||||
try:
|
||||
parting_surface = BRepBuilderAPI_MakeFace(parting_plane).Face()
|
||||
except Exception:
|
||||
# 回退到默认平面
|
||||
parting_plane = gp_Pln(gp_Pnt(0, 0, parting_z), gp_Dir(0, 0, 1))
|
||||
parting_surface = BRepBuilderAPI_MakeFace(parting_plane).Face()
|
||||
|
||||
logger.info(f"泡沫模具 Z 轴分型面: Z={parting_z:.2f} mm (包围盒中心)")
|
||||
|
||||
parting_line = self.optimize_parting_line(
|
||||
self._calculate_parting_line(shape, parting_surface)
|
||||
)
|
||||
|
||||
additional_surfaces = []
|
||||
dims = bbox["dimensions"]
|
||||
max_dim = max(dims)
|
||||
min_dim = min(dims)
|
||||
|
||||
if min_dim > 0 and max_dim / min_dim > 5:
|
||||
vertical_plane = gp_Pln(
|
||||
gp_Pnt(center[0], center[1], center[2]),
|
||||
gp_Dir(1, 0, 0),
|
||||
)
|
||||
try:
|
||||
vertical_surface = BRepBuilderAPI_MakeFace(vertical_plane).Face()
|
||||
additional_surfaces.append({
|
||||
"surface": vertical_surface,
|
||||
"direction": [1, 0, 0],
|
||||
"reason": "产品扁平,需要辅助垂直分型参考",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"primary_surface": parting_surface,
|
||||
"primary_line": parting_line,
|
||||
"primary_direction": primary_direction,
|
||||
"confidence": 0.95, # Z 轴分型置信度高
|
||||
"method": "z_axis_rule",
|
||||
"additional_surfaces": additional_surfaces,
|
||||
"surface_count": 1 + len(additional_surfaces),
|
||||
"parting_direction": "Z",
|
||||
"parting_position_z": parting_z,
|
||||
}
|
||||
|
||||
def _build_undercut_regions(self, undercut_analysis: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""将侧向机构分析结果转换为兼容旧结构的倒扣区域列表。"""
|
||||
undercut_faces = undercut_analysis.get("undercut_faces", [])
|
||||
regions = []
|
||||
|
||||
for face in undercut_faces:
|
||||
regions.append({
|
||||
"type": "negative_draft",
|
||||
"location": face.get("center", [0, 0, 0]),
|
||||
"severity": face.get("severity", "medium"),
|
||||
"area": face.get("area", 0),
|
||||
"is_outer": face.get("is_outer", False),
|
||||
"face_index": face.get("face_index"),
|
||||
})
|
||||
|
||||
logger.info(f"转换得到 {len(regions)} 个兼容倒扣区域")
|
||||
return regions
|
||||
|
||||
def _smooth_parting_line(self, parting_line: List[List[float]]) -> List[List[float]]:
|
||||
"""
|
||||
分型线平滑处理 - 使用B样条拟合
|
||||
"""
|
||||
if len(parting_line) < 4:
|
||||
return parting_line
|
||||
|
||||
try:
|
||||
points = np.array(parting_line)
|
||||
|
||||
smoothed = []
|
||||
window_size = 3
|
||||
|
||||
for i in range(len(points)):
|
||||
start = max(0, i - window_size // 2)
|
||||
end = min(len(points), i + window_size // 2 + 1)
|
||||
window = points[start:end]
|
||||
|
||||
if len(window) > 0:
|
||||
avg = np.mean(window, axis=0)
|
||||
smoothed.append(avg.tolist())
|
||||
|
||||
return smoothed
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"分型线平滑失败: {e}")
|
||||
return parting_line
|
||||
|
||||
def _assess_parting_line_smoothness(self, parting_line: List[List[float]]) -> float:
|
||||
"""评估分型线平滑度"""
|
||||
if len(parting_line) < 3:
|
||||
return 0.0
|
||||
|
||||
try:
|
||||
points = np.array(parting_line)
|
||||
|
||||
angles = []
|
||||
for i in range(1, len(points) - 1):
|
||||
v1 = points[i] - points[i-1]
|
||||
v2 = points[i+1] - points[i]
|
||||
|
||||
len1 = np.linalg.norm(v1)
|
||||
len2 = np.linalg.norm(v2)
|
||||
|
||||
if len1 > 0.001 and len2 > 0.001:
|
||||
cos_angle = np.dot(v1, v2) / (len1 * len2)
|
||||
cos_angle = max(-1, min(1, cos_angle))
|
||||
angle = np.arccos(cos_angle)
|
||||
angles.append(np.degrees(angle))
|
||||
|
||||
if angles:
|
||||
avg_angle_change = np.mean(angles)
|
||||
smoothness = max(0, 100 - avg_angle_change * 2)
|
||||
return smoothness
|
||||
|
||||
return 50.0
|
||||
|
||||
except Exception:
|
||||
return 50.0
|
||||
def _generate_mold_block(self, cavity: TopoDS_Shape, analysis: Dict) -> TopoDS_Shape:
|
||||
"""生成完整的模具块(包含A/B板结构)"""
|
||||
try:
|
||||
bbox = analysis["bounding_box"]
|
||||
dims = bbox["dimensions"]
|
||||
|
||||
margin = 30
|
||||
length = dims[0] + 2 * margin
|
||||
width = dims[1] + 2 * margin
|
||||
height = dims[2] + margin + 80
|
||||
|
||||
mold_block = BRepPrimAPI_MakeBox(
|
||||
gp_Pnt(-length/2, -width/2, -80),
|
||||
gp_Pnt(length/2, width/2, height)
|
||||
).Shape()
|
||||
|
||||
logger.info(f"模具块生成: {length}x{width}x{height} mm")
|
||||
return mold_block
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"模具块生成失败: {e}")
|
||||
return cavity
|
||||
|
||||
def _extract_parting_surface_geometry(self, surface: TopoDS_Face) -> Dict[str, Any]:
|
||||
"""提取分型面几何数据"""
|
||||
metadata = self._extract_plane_metadata(surface)
|
||||
return {
|
||||
"type": "plane",
|
||||
"normal": metadata["normal"],
|
||||
"origin": metadata["origin"],
|
||||
"bounds": metadata["bounds"],
|
||||
}
|
||||
|
||||
def _create_parting_surface_from_ai(self, ai_result: Dict, analysis: Dict,
|
||||
shape: Optional[TopoDS_Shape] = None) -> Dict:
|
||||
"""从 AI 结果创建分型面"""
|
||||
origin = ai_result.get("origin", [0, 0, 0])
|
||||
normal = ai_result.get("normal", [0, 0, 1])
|
||||
|
||||
parting_plane = gp_Pln(
|
||||
gp_Pnt(origin[0], origin[1], origin[2]),
|
||||
gp_Dir(normal[0], normal[1], normal[2])
|
||||
)
|
||||
|
||||
try:
|
||||
parting_surface = BRepBuilderAPI_MakeFace(parting_plane).Face()
|
||||
except Exception:
|
||||
parting_plane = gp_Pln(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1))
|
||||
parting_surface = BRepBuilderAPI_MakeFace(parting_plane).Face()
|
||||
|
||||
if shape is not None:
|
||||
parting_line = self._calculate_parting_line(shape, parting_surface)
|
||||
else:
|
||||
parting_line = []
|
||||
|
||||
return {
|
||||
"primary_surface": parting_surface,
|
||||
"primary_line": parting_line,
|
||||
"primary_direction": normal,
|
||||
"confidence": ai_result.get("confidence", 0.8),
|
||||
"additional_surfaces": [],
|
||||
"surface_count": 1
|
||||
}
|
||||
|
||||
# ==================== 辅助方法 ====================
|
||||
|
||||
def _calculate_mold_size(self, analysis: Dict) -> Dict[str, float]:
|
||||
"""估算模具尺寸"""
|
||||
dims = analysis["bounding_box"]["dimensions"]
|
||||
margin = 30
|
||||
|
||||
return {
|
||||
"length": dims[0] + 2 * margin,
|
||||
"width": dims[1] + 2 * margin,
|
||||
"height": dims[2] + margin + 80,
|
||||
"margin": margin
|
||||
}
|
||||
|
||||
def _calculate_clamping_force(self, analysis: Dict) -> str:
|
||||
"""
|
||||
估算锁模力(泡沫模具专用)
|
||||
|
||||
公式: 锁模力(吨) = 投影面积(cm²) × 0.3 (泡沫材料系数)
|
||||
投影面积 = 长度 × 宽度 (Z轴开模)
|
||||
"""
|
||||
bbox = analysis.get("bounding_box", {})
|
||||
dims = bbox.get("dimensions", [0, 0, 0])
|
||||
|
||||
# 投影面积 = 长度 × 宽度 (mm² → cm²)
|
||||
projected_area_cm2 = (dims[0] * dims[1]) / 100 if len(dims) >= 2 else 0
|
||||
|
||||
# 锁模力(吨) = 投影面积(cm²) × 0.3
|
||||
clamping_force_ton = int(projected_area_cm2 * 0.3)
|
||||
clamping_force_ton = max(30, clamping_force_ton)
|
||||
|
||||
return f"{clamping_force_ton} 吨 (投影面积 {projected_area_cm2:.1f} cm² × 0.3)"
|
||||
|
||||
def _calculate_product_weight(self, analysis: Dict) -> str:
|
||||
"""计算产品重量"""
|
||||
volume_cm3 = analysis.get("volume", 0) / 1000
|
||||
weight_g = volume_cm3 * self.material_density
|
||||
return f"{weight_g:.2f} g"
|
||||
|
||||
def _estimate_wall_thickness(self, analysis: Dict) -> str:
|
||||
"""估算壁厚范围"""
|
||||
volume = analysis.get("volume", 0)
|
||||
surface_area = analysis.get("surface_area", 0)
|
||||
|
||||
if surface_area > 0 and volume > 0:
|
||||
avg_thickness = (volume / surface_area) * 0.6
|
||||
return f"{avg_thickness * 0.7:.2f} - {avg_thickness * 1.3:.2f} mm"
|
||||
|
||||
return "10.0 - 30.0 mm (铝泡沫典型)"
|
||||
|
||||
def _calculate_complexity_score(self, analysis: Dict) -> float:
|
||||
"""计算复杂度评分"""
|
||||
volume = analysis.get("volume", 0)
|
||||
surface_area = analysis.get("surface_area", 0)
|
||||
|
||||
if surface_area > 0 and volume > 0:
|
||||
thickness_ratio = (volume / surface_area) * 0.6
|
||||
complexity = min(thickness_ratio / 5.0, 10.0)
|
||||
return round(complexity, 1)
|
||||
|
||||
return 5.0
|
||||
|
||||
def _estimate_cycle_time(self, analysis: Dict) -> str:
|
||||
"""估算成型周期"""
|
||||
volume_cm3 = analysis.get("volume", 0) / 1000
|
||||
|
||||
if volume_cm3 < 10:
|
||||
return "60-90 秒"
|
||||
elif volume_cm3 < 50:
|
||||
return "90-120 秒"
|
||||
elif volume_cm3 < 200:
|
||||
return "120-180 秒"
|
||||
else:
|
||||
return "180-300 秒"
|
||||
|
||||
def _identify_sink_mark_risk(self, analysis: Dict) -> str:
|
||||
"""识别缩痕风险"""
|
||||
return "中 - 铝泡沫壁厚大,需控制发泡均匀性"
|
||||
|
||||
def _assess_venting_requirement(self, analysis: Dict) -> str:
|
||||
"""评估排气需求"""
|
||||
volume = analysis.get("volume", 0)
|
||||
|
||||
if volume > 50000000:
|
||||
return "高 - 需要加强排气系统"
|
||||
elif volume > 10000000:
|
||||
return "中 - 建议标准排气"
|
||||
else:
|
||||
return "低 - 常规排气即可"
|
||||
@@ -0,0 +1,993 @@
|
||||
from typing import Dict, List, Any, Tuple, Optional, TYPE_CHECKING
|
||||
import math
|
||||
import numpy as np
|
||||
from OCC.Core.BRepOffsetAPI import BRepOffsetAPI_DraftAngle
|
||||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Section, BRepAlgoAPI_Common, BRepAlgoAPI_Fuse
|
||||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_Transform
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeHalfSpace
|
||||
from OCC.Core.gp import gp_Pln, gp_Dir, gp_Pnt, gp_Trsf, gp_Ax2
|
||||
from OCC.Core.TopoDS import TopoDS_Shape, TopoDS_Face, TopoDS_Compound, topods
|
||||
from OCC.Core.BRep import BRep_Tool, BRep_Builder
|
||||
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
from OCC.Core.TopLoc import TopLoc_Location
|
||||
|
||||
from shared.models.schemas import create_mold_cavity_data, create_mold_key_info
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BaseMoldGenerator:
|
||||
"""模具生成器基类 - 提供共用方法"""
|
||||
|
||||
def __init__(self, shrinkage_rate: float = 0.005, draft_angle: float = 2.0,
|
||||
material_density: float = 1.05):
|
||||
self.shrinkage_rate = shrinkage_rate
|
||||
self.draft_angle = draft_angle
|
||||
self.material_density = material_density
|
||||
|
||||
self.ai_parting_detector: Optional[Any] = None
|
||||
self.ai_draft_analyzer: Optional[Any] = None
|
||||
|
||||
def set_ai_model(self, parting_detector: Any = None, draft_analyzer: Any = None):
|
||||
self.ai_parting_detector = parting_detector
|
||||
self.ai_draft_analyzer = draft_analyzer
|
||||
logger.info("AI 模型接口已设置")
|
||||
|
||||
def _apply_shrinkage_compensation(self, shape: TopoDS_Shape) -> TopoDS_Shape:
|
||||
scale_factor = 1.0 + self.shrinkage_rate
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetScale(gp_Pnt(0, 0, 0), scale_factor)
|
||||
try:
|
||||
scaled_shape = BRepBuilderAPI_Transform(shape, trsf, True).Shape()
|
||||
logger.info(f"收缩率补偿: {self.shrinkage_rate*100:.2f}%, 缩放因子: {scale_factor:.4f}")
|
||||
return scaled_shape
|
||||
except Exception as e:
|
||||
logger.warning(f"收缩率补偿失败: {e}")
|
||||
return shape
|
||||
|
||||
def _apply_draft_angles(self, shape: TopoDS_Shape, parting_surface: TopoDS_Face) -> TopoDS_Shape:
|
||||
try:
|
||||
draft_direction = self._get_draft_direction(parting_surface)
|
||||
if draft_direction is None:
|
||||
logger.warning("无法确定拔模方向,跳过拔模处理")
|
||||
return shape
|
||||
|
||||
draft_angle_rad = math.radians(self.draft_angle)
|
||||
draftable_faces = self._find_draftable_faces(shape, draft_direction)
|
||||
|
||||
if not draftable_faces:
|
||||
logger.info("未找到需要拔模的面,跳过拔模处理")
|
||||
return shape
|
||||
|
||||
logger.info(f"应用拔模角: {self.draft_angle}°, {len(draftable_faces)} 个面")
|
||||
|
||||
drafted_shape = self._execute_draft(shape, draftable_faces, draft_direction, draft_angle_rad)
|
||||
return drafted_shape
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"拔模角处理失败,返回原始形状: {e}")
|
||||
return shape
|
||||
|
||||
def _get_draft_direction(self, parting_surface: TopoDS_Face) -> Optional[gp_Dir]:
|
||||
try:
|
||||
surface = BRepAdaptor_Surface(parting_surface)
|
||||
if surface.GetType() == 0:
|
||||
return surface.Plane().Position().Direction()
|
||||
return gp_Dir(0, 0, 1)
|
||||
except Exception:
|
||||
return gp_Dir(0, 0, 1)
|
||||
|
||||
def _find_draftable_faces(self, shape: TopoDS_Shape, draft_direction: gp_Dir) -> List[TopoDS_Face]:
|
||||
draftable = []
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
|
||||
while explorer.More():
|
||||
face = topods.Face(explorer.Current())
|
||||
normal = self._get_face_normal(face)
|
||||
|
||||
if normal is not None:
|
||||
dot = abs(normal.Dot(draft_direction))
|
||||
angle = math.degrees(math.acos(min(dot, 1.0)))
|
||||
if 5.0 < angle < 85.0:
|
||||
draftable.append(face)
|
||||
|
||||
explorer.Next()
|
||||
|
||||
return draftable
|
||||
|
||||
def _get_face_normal(self, face: TopoDS_Face) -> Optional[gp_Dir]:
|
||||
try:
|
||||
surface = BRepAdaptor_Surface(face)
|
||||
u = (surface.FirstUParameter() + surface.LastUParameter()) / 2
|
||||
v = (surface.FirstVParameter() + surface.LastVParameter()) / 2
|
||||
|
||||
if surface.GetType() == 0:
|
||||
return surface.Plane().Position().Direction()
|
||||
|
||||
from OCC.Core.BRepLProp import BRepLProp_SLProps
|
||||
props = BRepLProp_SLProps(surface, 1, 0.001)
|
||||
props.SetParameters(u, v)
|
||||
if props.IsNormalDefined():
|
||||
return props.Normal()
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _execute_draft(self, shape: TopoDS_Shape, faces: List[TopoDS_Face],
|
||||
draft_direction: gp_Dir, draft_angle_rad: float) -> TopoDS_Shape:
|
||||
try:
|
||||
draft = BRepOffsetAPI_DraftAngle(shape)
|
||||
|
||||
for face in faces:
|
||||
try:
|
||||
normal = self._get_face_normal(face)
|
||||
if normal is None:
|
||||
continue
|
||||
|
||||
dot = normal.Dot(draft_direction)
|
||||
if dot > 0:
|
||||
face_dir = draft_direction
|
||||
else:
|
||||
face_dir = gp_Dir(-draft_direction.X(), -draft_direction.Y(), -draft_direction.Z())
|
||||
|
||||
draft.Add(face, face_dir, draft_angle_rad, True)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
draft.Build()
|
||||
|
||||
if draft.IsDone():
|
||||
logger.info(f"拔模角应用成功: {len(faces)} 个面, {self.draft_angle}°")
|
||||
return draft.Shape()
|
||||
else:
|
||||
logger.warning("BRepOffsetAPI_DraftAngle 构建失败,尝试逐面拔模")
|
||||
return self._draft_faces_sequentially(shape, faces, draft_direction, draft_angle_rad)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"拔模执行失败: {e}")
|
||||
return shape
|
||||
|
||||
def _draft_faces_sequentially(self, shape: TopoDS_Shape, faces: List[TopoDS_Face],
|
||||
draft_direction: gp_Dir, draft_angle_rad: float) -> TopoDS_Shape:
|
||||
current_shape = shape
|
||||
success_count = 0
|
||||
|
||||
for face in faces:
|
||||
try:
|
||||
draft = BRepOffsetAPI_DraftAngle(current_shape)
|
||||
normal = self._get_face_normal(face)
|
||||
if normal is None:
|
||||
continue
|
||||
|
||||
dot = normal.Dot(draft_direction)
|
||||
if dot > 0:
|
||||
face_dir = draft_direction
|
||||
else:
|
||||
face_dir = gp_Dir(-draft_direction.X(), -draft_direction.Y(), -draft_direction.Z())
|
||||
|
||||
draft.Add(face, face_dir, draft_angle_rad, True)
|
||||
draft.Build()
|
||||
|
||||
if draft.IsDone():
|
||||
current_shape = draft.Shape()
|
||||
success_count += 1
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if success_count > 0:
|
||||
logger.info(f"逐面拔模完成: {success_count}/{len(faces)} 个面成功")
|
||||
else:
|
||||
logger.warning("逐面拔模全部失败,返回原始形状")
|
||||
|
||||
return current_shape
|
||||
|
||||
def _analyze_product_geometry(self, shape: TopoDS_Shape) -> Dict[str, Any]:
|
||||
try:
|
||||
props = GProp_GProps()
|
||||
brepgprop.VolumeProperties(shape, props)
|
||||
volume = props.Mass()
|
||||
|
||||
surface_props = GProp_GProps()
|
||||
brepgprop.SurfaceProperties(shape, surface_props)
|
||||
surface_area = surface_props.Mass()
|
||||
|
||||
center = props.CentreOfMass()
|
||||
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
|
||||
inertia = props.MatrixOfInertia()
|
||||
|
||||
return {
|
||||
"volume": volume,
|
||||
"surface_area": surface_area,
|
||||
"center_of_mass": [float(center.X()), float(center.Y()), float(center.Z())],
|
||||
"bounding_box": {
|
||||
"min": [float(xmin), float(ymin), float(zmin)],
|
||||
"max": [float(xmax), float(ymax), float(zmax)],
|
||||
"center": [float((xmin+xmax)/2), float((ymin+ymax)/2), float((zmin+zmax)/2)],
|
||||
"dimensions": [float(xmax-xmin), float(ymax-ymin), float(zmax-zmin)]
|
||||
},
|
||||
"inertia_matrix": self._get_inertia_matrix(props)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"产品几何分析失败: {e}")
|
||||
raise
|
||||
|
||||
def _split_cavity_core(self, shape: TopoDS_Shape, parting_surface: TopoDS_Face, margin: int = 20) -> Tuple[TopoDS_Shape, TopoDS_Shape]:
|
||||
"""
|
||||
分离型腔和型芯 — 完全嵌入 + 突出贴合方式。
|
||||
|
||||
型腔(凹模)= 完整模具块 - 产品 → 产品形状完全嵌入型腔块中
|
||||
型芯(凸模)= 底座平板 + 产品融合 → 产品从底座面突出,与型腔凹入完美贴合
|
||||
|
||||
不再将模具块沿分型面一分为二。
|
||||
"""
|
||||
try:
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
|
||||
mold_xmin = xmin - margin
|
||||
mold_ymin = ymin - margin
|
||||
mold_zmin = zmin - margin
|
||||
mold_xmax = xmax + margin
|
||||
mold_ymax = ymax + margin
|
||||
mold_zmax = zmax + margin
|
||||
|
||||
mold_block = BRepPrimAPI_MakeBox(
|
||||
gp_Pnt(mold_xmin, mold_ymin, mold_zmin),
|
||||
gp_Pnt(mold_xmax, mold_ymax, mold_zmax)
|
||||
).Shape()
|
||||
|
||||
parting_plane = self._get_parting_plane(parting_surface, shape)
|
||||
if parting_plane is None:
|
||||
center_z = (zmin + zmax) / 2
|
||||
parting_plane = gp_Pln(gp_Pnt(0, 0, center_z), gp_Dir(0, 0, 1))
|
||||
|
||||
cavity = self._subtract_product_from_plate(mold_block, shape, "型腔")
|
||||
if cavity is None:
|
||||
cavity = mold_block
|
||||
|
||||
core = self._build_core_with_base(
|
||||
shape, mold_block, parting_plane,
|
||||
mold_xmin, mold_ymin, mold_zmin,
|
||||
mold_xmax, mold_ymax, mold_zmax,
|
||||
xmin, ymin, zmin, xmax, ymax, zmax
|
||||
)
|
||||
|
||||
logger.info("型腔/型芯分离完成(完全嵌入 + 突出贴合)")
|
||||
return cavity, core
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"型腔分离失败: {e}")
|
||||
return self._split_cavity_core_fallback(shape, None)
|
||||
|
||||
@staticmethod
|
||||
def _extract_parting_normal(parting_surface: TopoDS_Face) -> List[float]:
|
||||
"""从分型面提取法向量"""
|
||||
try:
|
||||
surface = BRepAdaptor_Surface(parting_surface)
|
||||
if surface.GetType() == 0:
|
||||
plane = surface.Plane()
|
||||
n = plane.Axis().Direction()
|
||||
return [float(n.X()), float(n.Y()), float(n.Z())]
|
||||
except Exception:
|
||||
pass
|
||||
return [0.0, 0.0, 1.0]
|
||||
|
||||
def _build_core_with_base(
|
||||
self,
|
||||
shape: TopoDS_Shape,
|
||||
mold_block: TopoDS_Shape,
|
||||
parting_plane: gp_Pln,
|
||||
mold_xmin: float, mold_ymin: float, mold_zmin: float,
|
||||
mold_xmax: float, mold_ymax: float, mold_zmax: float,
|
||||
xmin: float, ymin: float, zmin: float,
|
||||
xmax: float, ymax: float, zmax: float,
|
||||
) -> TopoDS_Shape:
|
||||
"""
|
||||
构建带底座的型芯。
|
||||
|
||||
核心逻辑:底座平板沿分型方向覆盖模具半空间,
|
||||
与产品形状做布尔融合,形成"底座+产品突出体"。
|
||||
融合失败时用 TopoDS_Compound 兜底,确保底座永不会丢失。
|
||||
"""
|
||||
normal = parting_plane.Axis().Direction()
|
||||
origin = parting_plane.Location()
|
||||
nx, ny, nz = float(normal.X()), float(normal.Y()), float(normal.Z())
|
||||
|
||||
prod_span = max((xmax - xmin), (ymax - ymin), (zmax - zmin))
|
||||
overlap = max(prod_span * 0.15, 8.0)
|
||||
|
||||
base_p1 = [mold_xmin, mold_ymin, mold_zmin]
|
||||
base_p2 = [mold_xmax, mold_ymax, mold_zmax]
|
||||
|
||||
for i in range(3):
|
||||
n = [nx, ny, nz][i]
|
||||
o = [float(origin.X()), float(origin.Y()), float(origin.Z())][i]
|
||||
if abs(n) < 0.001:
|
||||
continue
|
||||
if n > 0:
|
||||
base_p2[i] = o + overlap
|
||||
else:
|
||||
base_p1[i] = o - overlap
|
||||
|
||||
base_plate = None
|
||||
try:
|
||||
base_plate = BRepPrimAPI_MakeBox(
|
||||
gp_Pnt(base_p1[0], base_p1[1], base_p1[2]),
|
||||
gp_Pnt(base_p2[0], base_p2[1], base_p2[2])
|
||||
).Shape()
|
||||
logger.info(f"型芯底座构建: 重叠量={overlap:.1f}mm")
|
||||
except Exception as e:
|
||||
logger.warning(f"底座构建失败: {e}")
|
||||
return shape
|
||||
|
||||
try:
|
||||
fuse_op = BRepAlgoAPI_Fuse(base_plate, shape)
|
||||
if fuse_op.IsDone():
|
||||
core = fuse_op.Shape()
|
||||
explorer = TopExp_Explorer(core, TopAbs_FACE)
|
||||
face_count = 0
|
||||
while explorer.More():
|
||||
face_count += 1
|
||||
explorer.Next()
|
||||
if face_count > 0:
|
||||
logger.info(f"型芯融合成功 (面数={face_count})")
|
||||
return core
|
||||
logger.warning("Fuse 结果无几何,尝试备用方案")
|
||||
except Exception as e:
|
||||
logger.warning(f"底座融合失败: {e}")
|
||||
|
||||
return self._build_core_compound(base_plate, shape)
|
||||
|
||||
@staticmethod
|
||||
def _build_core_compound(base_plate: TopoDS_Shape, shape: TopoDS_Shape) -> TopoDS_Shape:
|
||||
"""
|
||||
兜底方案:构建 TopoDS_Compound 包含底座平板 + 产品。
|
||||
即使布尔融合失败,底座也绝不会丢失。
|
||||
"""
|
||||
compound = TopoDS_Compound()
|
||||
builder = BRep_Builder()
|
||||
builder.MakeCompound(compound)
|
||||
builder.Add(compound, base_plate)
|
||||
builder.Add(compound, shape)
|
||||
logger.info("型芯 Compound 兜底构建 (底座+产品)")
|
||||
return compound
|
||||
|
||||
def _get_parting_plane(self, parting_surface: TopoDS_Face, shape: TopoDS_Shape) -> Optional[gp_Pln]:
|
||||
"""从分型面提取平面方程"""
|
||||
try:
|
||||
surface = BRepAdaptor_Surface(parting_surface)
|
||||
if surface.GetType() == 0:
|
||||
return surface.Plane()
|
||||
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
center_z = (zmin + zmax) / 2
|
||||
return gp_Pln(gp_Pnt(0, 0, center_z), gp_Dir(0, 0, 1))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"分型面平面提取失败: {e}")
|
||||
return None
|
||||
|
||||
def _split_mold_block_by_plane(self, mold_block: TopoDS_Shape,
|
||||
parting_plane: gp_Pln) -> Tuple[TopoDS_Shape, TopoDS_Shape]:
|
||||
"""
|
||||
用分型面将模具块切分为A板(上模)和B板(下模)
|
||||
|
||||
方法:使用半空间体与模具块的布尔交集运算
|
||||
- A板 = 模具块 ∩ 分型面上方半空间
|
||||
- B板 = 模具块 ∩ 分型面下方半空间
|
||||
"""
|
||||
try:
|
||||
plane_origin = parting_plane.Location()
|
||||
plane_normal = parting_plane.Axis().Direction()
|
||||
|
||||
ref_point_above = gp_Pnt(
|
||||
plane_origin.X() + plane_normal.X() * 10,
|
||||
plane_origin.Y() + plane_normal.Y() * 10,
|
||||
plane_origin.Z() + plane_normal.Z() * 10
|
||||
)
|
||||
ref_point_below = gp_Pnt(
|
||||
plane_origin.X() - plane_normal.X() * 10,
|
||||
plane_origin.Y() - plane_normal.Y() * 10,
|
||||
plane_origin.Z() - plane_normal.Z() * 10
|
||||
)
|
||||
|
||||
half_space_above = BRepPrimAPI_MakeHalfSpace(
|
||||
BRepBuilderAPI_MakeFace(parting_plane).Face(),
|
||||
ref_point_above
|
||||
).Shape()
|
||||
|
||||
half_space_below = BRepPrimAPI_MakeHalfSpace(
|
||||
BRepBuilderAPI_MakeFace(parting_plane).Face(),
|
||||
ref_point_below
|
||||
).Shape()
|
||||
|
||||
a_plate_op = BRepAlgoAPI_Common(mold_block, half_space_above)
|
||||
a_plate = None
|
||||
if a_plate_op.IsDone():
|
||||
a_plate = a_plate_op.Shape()
|
||||
logger.info("A板(上模)切分成功")
|
||||
else:
|
||||
logger.warning("A板切分失败")
|
||||
|
||||
b_plate_op = BRepAlgoAPI_Common(mold_block, half_space_below)
|
||||
b_plate = None
|
||||
if b_plate_op.IsDone():
|
||||
b_plate = b_plate_op.Shape()
|
||||
logger.info("B板(下模)切分成功")
|
||||
else:
|
||||
logger.warning("B板切分失败")
|
||||
|
||||
return a_plate, b_plate
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"A/B板分离失败: {e}")
|
||||
return None, None
|
||||
|
||||
def _subtract_product_from_plate(self, plate: TopoDS_Shape, product: TopoDS_Shape,
|
||||
plate_name: str) -> TopoDS_Shape:
|
||||
"""从模板中减去产品形状,生成型腔或型芯"""
|
||||
try:
|
||||
cut_op = BRepAlgoAPI_Cut(plate, product)
|
||||
if cut_op.IsDone():
|
||||
result = cut_op.Shape()
|
||||
logger.info(f"{plate_name}减去产品成功")
|
||||
return result
|
||||
else:
|
||||
logger.warning(f"{plate_name}布尔减运算失败")
|
||||
return plate
|
||||
except Exception as e:
|
||||
logger.warning(f"{plate_name}减产品失败: {e}")
|
||||
return plate
|
||||
|
||||
def _split_cavity_core_fallback(self, shape: TopoDS_Shape,
|
||||
mold_block: Optional[TopoDS_Shape] = None) -> Tuple[TopoDS_Shape, TopoDS_Shape]:
|
||||
"""
|
||||
分模回退方案:完全嵌入 + 突出贴合,用 Z 中心面做分型基准。
|
||||
"""
|
||||
logger.warning("使用分模回退方案(完全嵌入 + 突出贴合)")
|
||||
|
||||
try:
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
|
||||
margin = 20
|
||||
if mold_block is None:
|
||||
mold_block = BRepPrimAPI_MakeBox(
|
||||
gp_Pnt(xmin - margin, ymin - margin, zmin - margin),
|
||||
gp_Pnt(xmax + margin, ymax + margin, zmax + margin)
|
||||
).Shape()
|
||||
|
||||
cavity = self._subtract_product_from_plate(mold_block, shape, "型腔(回退)")
|
||||
if cavity is None:
|
||||
cavity = mold_block
|
||||
|
||||
center_z = (zmin + zmax) / 2
|
||||
parting_plane = gp_Pln(gp_Pnt(0, 0, center_z), gp_Dir(0, 0, 1))
|
||||
|
||||
core = self._build_core_with_base(
|
||||
shape, mold_block, parting_plane,
|
||||
xmin - margin, ymin - margin, zmin - margin,
|
||||
xmax + margin, ymax + margin, zmax + margin,
|
||||
xmin, ymin, zmin, xmax, ymax, zmax
|
||||
)
|
||||
|
||||
logger.info("回退方案型腔/型芯分离完成")
|
||||
return cavity or mold_block, core
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分模回退方案失败: {e}")
|
||||
try:
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
margin = 20
|
||||
cavity_block = BRepPrimAPI_MakeBox(
|
||||
gp_Pnt(xmin - margin, ymin - margin, zmin - margin),
|
||||
gp_Pnt(xmax + margin, ymax + margin, zmax + margin)
|
||||
).Shape()
|
||||
cavity = self._subtract_product_from_plate(cavity_block, shape, "型腔(兜底)")
|
||||
return cavity or cavity_block, shape
|
||||
except Exception:
|
||||
return shape, shape
|
||||
|
||||
def detect_insert_regions(self, shape: TopoDS_Shape, analysis: Dict,
|
||||
depth_threshold: float = 30.0,
|
||||
aspect_threshold: float = 3.0) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
检测需要独立镶件的区域
|
||||
|
||||
镶件判定条件:
|
||||
1. 深腔区域(深度超过阈值)
|
||||
2. 细长特征(长径比超过阈值)
|
||||
3. 易磨损区域(尖锐角落、薄壁)
|
||||
4. 精密特征(高精度要求的局部区域)
|
||||
|
||||
Args:
|
||||
shape: 产品形状
|
||||
analysis: 几何分析结果
|
||||
depth_threshold: 深腔深度阈值 mm
|
||||
aspect_threshold: 长径比阈值
|
||||
|
||||
Returns:
|
||||
镶件区域列表
|
||||
"""
|
||||
inserts = []
|
||||
|
||||
try:
|
||||
bbox = analysis.get("bounding_box", {})
|
||||
dims = bbox.get("dimensions", [0, 0, 0])
|
||||
center = bbox.get("center", [0, 0, 0])
|
||||
|
||||
if dims[2] > depth_threshold:
|
||||
inserts.append({
|
||||
"type": "deep_cavity_insert",
|
||||
"location": center,
|
||||
"depth": dims[2],
|
||||
"reason": f"型腔深度 {dims[2]:.1f}mm 超过阈值 {depth_threshold}mm",
|
||||
"insert_type": "core_pin",
|
||||
"priority": "high"
|
||||
})
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
face_idx = 0
|
||||
|
||||
while explorer.More():
|
||||
face = topods.Face(explorer.Current())
|
||||
face_idx += 1
|
||||
|
||||
try:
|
||||
surface = BRepAdaptor_Surface(face)
|
||||
|
||||
face_props = GProp_GProps()
|
||||
brepgprop.SurfaceProperties(face, face_props)
|
||||
area = face_props.Mass()
|
||||
|
||||
if area < 1.0 and area > 0.001:
|
||||
bbox_face = Bnd_Box()
|
||||
brepbndlib.Add(face, bbox_face)
|
||||
try:
|
||||
fxmin, fymin, fzmin, fxmax, fymax, fzmax = bbox_face.Get()
|
||||
f_dims = [fxmax - fxmin, fymax - fymin, fzmax - fzmin]
|
||||
max_dim = max(f_dims)
|
||||
min_dim = min(f_dims)
|
||||
|
||||
if min_dim > 0.01 and max_dim / min_dim > aspect_threshold:
|
||||
face_center = [
|
||||
float((fxmin + fxmax) / 2),
|
||||
float((fymin + fymax) / 2),
|
||||
float((fzmin + fzmax) / 2)
|
||||
]
|
||||
|
||||
inserts.append({
|
||||
"type": "slender_feature_insert",
|
||||
"location": face_center,
|
||||
"aspect_ratio": max_dim / min_dim,
|
||||
"reason": f"细长特征,长径比 {max_dim/min_dim:.1f}",
|
||||
"insert_type": "core_pin",
|
||||
"priority": "medium",
|
||||
"face_index": face_idx
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if surface.GetType() == 1:
|
||||
radius = surface.Cylinder().Radius()
|
||||
if radius < 3.0 and radius > 0.1:
|
||||
cyl_axis = surface.Cylinder().Position().Axis()
|
||||
cyl_loc = cyl_axis.Location()
|
||||
|
||||
inserts.append({
|
||||
"type": "small_hole_insert",
|
||||
"location": [float(cyl_loc.X()), float(cyl_loc.Y()), float(cyl_loc.Z())],
|
||||
"radius": float(radius),
|
||||
"reason": f"小孔特征,半径 {radius:.2f}mm",
|
||||
"insert_type": "core_pin",
|
||||
"priority": "high",
|
||||
"face_index": face_idx
|
||||
})
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
explorer.Next()
|
||||
|
||||
if not inserts:
|
||||
logger.info("未检测到需要镶件的区域")
|
||||
else:
|
||||
logger.info(f"检测到 {len(inserts)} 个镶件区域")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"镶件检测失败: {e}")
|
||||
|
||||
return inserts
|
||||
|
||||
def _extract_shape_geometry(self, shape: TopoDS_Shape, shape_type: str) -> Dict[str, Any]:
|
||||
try:
|
||||
mesh = BRepMesh_IncrementalMesh(shape, 0.1)
|
||||
mesh.Perform()
|
||||
|
||||
vertices = []
|
||||
faces = []
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
vertex_index = 0
|
||||
|
||||
while explorer.More():
|
||||
face = explorer.Current()
|
||||
location = TopLoc_Location()
|
||||
triangulation = BRep_Tool.Triangulation(face, location)
|
||||
|
||||
if triangulation:
|
||||
nb_nodes = triangulation.NbNodes()
|
||||
for i in range(1, nb_nodes + 1):
|
||||
node = triangulation.Node(i)
|
||||
transformed = node.Transformed(location.Transformation())
|
||||
vertices.extend([
|
||||
float(transformed.X()),
|
||||
float(transformed.Y()),
|
||||
float(transformed.Z())
|
||||
])
|
||||
|
||||
nb_triangles = triangulation.NbTriangles()
|
||||
for i in range(1, nb_triangles + 1):
|
||||
triangle = triangulation.Triangle(i)
|
||||
idx1 = triangle.Value(1) + vertex_index - 1
|
||||
idx2 = triangle.Value(2) + vertex_index - 1
|
||||
idx3 = triangle.Value(3) + vertex_index - 1
|
||||
faces.extend([int(idx1), int(idx2), int(idx3)])
|
||||
|
||||
vertex_index += nb_nodes
|
||||
|
||||
explorer.Next()
|
||||
|
||||
vertex_count = len(vertices) // 3
|
||||
face_count = len(faces) // 3
|
||||
|
||||
return {
|
||||
"type": shape_type,
|
||||
"vertices": vertices,
|
||||
"faces": faces,
|
||||
"vertex_count": vertex_count,
|
||||
"face_count": face_count,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{shape_type}几何提取失败: {e}")
|
||||
return {
|
||||
"type": shape_type,
|
||||
"vertices": [],
|
||||
"faces": [],
|
||||
"vertex_count": 0,
|
||||
"face_count": 0,
|
||||
}
|
||||
|
||||
def _extract_plane_metadata(self, surface: TopoDS_Shape) -> Dict[str, Any]:
|
||||
"""从分型面提取平面元数据(法向量、原点、边界)"""
|
||||
metadata = {
|
||||
"normal": [0.0, 0.0, 1.0],
|
||||
"origin": [0.0, 0.0, 0.0],
|
||||
"bounds": {"min": [0.0, 0.0, 0.0], "max": [0.0, 0.0, 0.0]},
|
||||
}
|
||||
try:
|
||||
surface_adaptor = BRepAdaptor_Surface(surface)
|
||||
if surface_adaptor.GetType() == 0:
|
||||
plane = surface_adaptor.Plane()
|
||||
axis = plane.Axis()
|
||||
normal = axis.Direction()
|
||||
origin = plane.Location()
|
||||
metadata["normal"] = [float(normal.X()), float(normal.Y()), float(normal.Z())]
|
||||
metadata["origin"] = [float(origin.X()), float(origin.Y()), float(origin.Z())]
|
||||
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(surface, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
metadata["bounds"] = {
|
||||
"min": [float(xmin), float(ymin), float(zmin)],
|
||||
"max": [float(xmax), float(ymax), float(zmax)],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"提取平面元数据失败: {e}")
|
||||
return metadata
|
||||
|
||||
def _calculate_product_weight(self, analysis: Dict) -> str:
|
||||
volume_cm3 = analysis.get("volume", 0) / 1000
|
||||
weight_g = volume_cm3 * self.material_density
|
||||
return f"{weight_g:.2f} g"
|
||||
|
||||
def _assess_warpage_risk(self, analysis: Dict) -> str:
|
||||
bbox = analysis.get("bounding_box", {}).get("dimensions", [1, 1, 1])
|
||||
aspect_ratio = max(bbox) / min(bbox) if min(bbox) > 0 else 1
|
||||
|
||||
if aspect_ratio > 5:
|
||||
return "高 - 建议增加加强筋"
|
||||
elif aspect_ratio > 3:
|
||||
return "中 - 需优化冷却"
|
||||
else:
|
||||
return "低"
|
||||
|
||||
def _get_inertia_matrix(self, props: GProp_GProps) -> List[List[float]]:
|
||||
inertia = props.MatrixOfInertia()
|
||||
return [
|
||||
[inertia.Value(1, 1), inertia.Value(1, 2), inertia.Value(1, 3)],
|
||||
[inertia.Value(2, 1), inertia.Value(2, 2), inertia.Value(2, 3)],
|
||||
[inertia.Value(3, 1), inertia.Value(3, 2), inertia.Value(3, 3)]
|
||||
]
|
||||
|
||||
def _calculate_parting_line_length(self, parting_line: List) -> float:
|
||||
if not parting_line or len(parting_line) < 2:
|
||||
return 0.0
|
||||
|
||||
total_length = 0.0
|
||||
for i in range(1, len(parting_line)):
|
||||
p1 = np.array(parting_line[i-1])
|
||||
p2 = np.array(parting_line[i])
|
||||
segment_length = np.linalg.norm(p2 - p1)
|
||||
total_length += segment_length
|
||||
|
||||
return total_length
|
||||
|
||||
def _calculate_parting_line(self, shape: TopoDS_Shape, parting_surface: TopoDS_Face) -> List[List[float]]:
|
||||
try:
|
||||
section = BRepAlgoAPI_Section(shape, parting_surface)
|
||||
section.Build()
|
||||
|
||||
if not section.IsDone():
|
||||
logger.warning("截面运算未完成,使用简化分型线")
|
||||
return self._simple_parting_line(shape)
|
||||
|
||||
edges = []
|
||||
explorer = TopExp_Explorer(section.Shape(), TopAbs_EDGE)
|
||||
|
||||
while explorer.More():
|
||||
edge = explorer.Current()
|
||||
|
||||
curve = BRepAdaptor_Curve(edge)
|
||||
first_param = curve.FirstParameter()
|
||||
last_param = curve.LastParameter()
|
||||
|
||||
num_points = max(10, int((last_param - first_param) / 0.5))
|
||||
step = (last_param - first_param) / num_points
|
||||
|
||||
for i in range(num_points + 1):
|
||||
param = first_param + i * step
|
||||
point = curve.Value(param)
|
||||
edges.append([point.X(), point.Y(), point.Z()])
|
||||
|
||||
explorer.Next()
|
||||
|
||||
if not edges:
|
||||
logger.warning("未找到交线,使用简化分型线")
|
||||
return self._simple_parting_line(shape)
|
||||
|
||||
logger.info(f"计算得到 {len(edges)} 个分型线点")
|
||||
return edges
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分型线计算失败: {e}")
|
||||
return self._simple_parting_line(shape)
|
||||
|
||||
def _simple_parting_line(self, shape: TopoDS_Shape) -> List[List[float]]:
|
||||
try:
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
center_z = (zmin + zmax) / 2
|
||||
|
||||
return [
|
||||
[xmin, ymin, center_z],
|
||||
[xmax, ymin, center_z],
|
||||
[xmax, ymax, center_z],
|
||||
[xmin, ymax, center_z],
|
||||
[xmin, ymin, center_z]
|
||||
]
|
||||
except Exception:
|
||||
return [[-50, -50, 0], [50, -50, 0], [50, 50, 0], [-50, 50, 0], [-50, -50, 0]]
|
||||
|
||||
def extend_parting_surface(self, parting_surface: TopoDS_Face, shape: TopoDS_Shape,
|
||||
extension: float = 30.0) -> TopoDS_Face:
|
||||
"""
|
||||
将分型面延伸到模具块边界
|
||||
|
||||
分型面通常只覆盖产品轮廓,需要延伸到模具块边缘
|
||||
才能正确分离A板和B板
|
||||
|
||||
Args:
|
||||
parting_surface: 原始分型面
|
||||
shape: 产品形状
|
||||
extension: 延伸距离 mm
|
||||
|
||||
Returns:
|
||||
延伸后的分型面
|
||||
"""
|
||||
try:
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
|
||||
surface = BRepAdaptor_Surface(parting_surface)
|
||||
if surface.GetType() != 0:
|
||||
logger.info("分型面非平面,延伸操作跳过")
|
||||
return parting_surface
|
||||
|
||||
plane = surface.Plane()
|
||||
origin = plane.Location()
|
||||
normal = plane.Axis().Direction()
|
||||
|
||||
extended_xmin = xmin - extension
|
||||
extended_ymin = ymin - extension
|
||||
extended_xmax = xmax + extension
|
||||
extended_ymax = ymax + extension
|
||||
|
||||
extended_plane = gp_Pln(origin, normal)
|
||||
extended_surface = BRepBuilderAPI_MakeFace(
|
||||
extended_plane,
|
||||
extended_xmin, extended_xmax,
|
||||
extended_ymin, extended_ymax
|
||||
).Face()
|
||||
|
||||
logger.info(f"分型面延伸完成: 延伸距离={extension}mm")
|
||||
return extended_surface
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"分型面延伸失败: {e}")
|
||||
return parting_surface
|
||||
|
||||
def optimize_parting_line(self, parting_line: List[List[float]],
|
||||
smooth_window: int = 5,
|
||||
min_segment_length: float = 0.5,
|
||||
angle_threshold: float = 150.0) -> List[List[float]]:
|
||||
"""
|
||||
优化分型线
|
||||
|
||||
优化内容:
|
||||
1. 平滑处理 - 消除噪声点
|
||||
2. 去除短线段 - 合并过短的线段
|
||||
3. 尖角处理 - 在尖角处添加过渡圆弧
|
||||
4. 点密度均匀化 - 重采样使点间距均匀
|
||||
|
||||
Args:
|
||||
parting_line: 原始分型线点列表
|
||||
smooth_window: 平滑窗口大小
|
||||
min_segment_length: 最小线段长度
|
||||
angle_threshold: 尖角判定角度(度)
|
||||
|
||||
Returns:
|
||||
优化后的分型线
|
||||
"""
|
||||
if len(parting_line) < 3:
|
||||
return parting_line
|
||||
|
||||
try:
|
||||
smoothed = self._smooth_parting_line(parting_line, smooth_window)
|
||||
|
||||
filtered = self._filter_short_segments(smoothed, min_segment_length)
|
||||
|
||||
optimized = self._round_sharp_corners(filtered, angle_threshold)
|
||||
|
||||
resampled = self._resample_parting_line(optimized, target_spacing=2.0)
|
||||
|
||||
logger.info(f"分型线优化: {len(parting_line)} → {len(resampled)} 点")
|
||||
return resampled
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"分型线优化失败: {e}")
|
||||
return parting_line
|
||||
|
||||
def _smooth_parting_line(self, points: List[List[float]],
|
||||
window: int = 5) -> List[List[float]]:
|
||||
"""移动平均平滑"""
|
||||
if len(points) < window:
|
||||
return points
|
||||
|
||||
arr = np.array(points, dtype=np.float64)
|
||||
smoothed = []
|
||||
|
||||
for i in range(len(arr)):
|
||||
start = max(0, i - window // 2)
|
||||
end = min(len(arr), i + window // 2 + 1)
|
||||
avg = np.mean(arr[start:end], axis=0)
|
||||
smoothed.append(avg.tolist())
|
||||
|
||||
return smoothed
|
||||
|
||||
def _filter_short_segments(self, points: List[List[float]],
|
||||
min_length: float) -> List[List[float]]:
|
||||
"""去除过短线段"""
|
||||
if not points:
|
||||
return points
|
||||
|
||||
filtered = [points[0]]
|
||||
for i in range(1, len(points)):
|
||||
dist = np.linalg.norm(np.array(points[i]) - np.array(filtered[-1]))
|
||||
if dist >= min_length:
|
||||
filtered.append(points[i])
|
||||
|
||||
return filtered
|
||||
|
||||
def _round_sharp_corners(self, points: List[List[float]],
|
||||
angle_threshold: float) -> List[List[float]]:
|
||||
"""在尖角处添加过渡点"""
|
||||
if len(points) < 3:
|
||||
return points
|
||||
|
||||
result = [points[0]]
|
||||
|
||||
for i in range(1, len(points) - 1):
|
||||
v1 = np.array(points[i]) - np.array(points[i - 1])
|
||||
v2 = np.array(points[i + 1]) - np.array(points[i])
|
||||
|
||||
len1 = np.linalg.norm(v1)
|
||||
len2 = np.linalg.norm(v2)
|
||||
|
||||
if len1 > 0.001 and len2 > 0.001:
|
||||
cos_angle = np.clip(np.dot(v1, v2) / (len1 * len2), -1, 1)
|
||||
angle = math.degrees(math.acos(cos_angle))
|
||||
|
||||
if angle < angle_threshold:
|
||||
mid1 = (np.array(points[i - 1]) + np.array(points[i])) / 2
|
||||
mid2 = (np.array(points[i]) + np.array(points[i + 1])) / 2
|
||||
result.append(mid1.tolist())
|
||||
result.append(mid2.tolist())
|
||||
else:
|
||||
result.append(points[i])
|
||||
else:
|
||||
result.append(points[i])
|
||||
|
||||
result.append(points[-1])
|
||||
return result
|
||||
|
||||
def _resample_parting_line(self, points: List[List[float]],
|
||||
target_spacing: float) -> List[List[float]]:
|
||||
"""重采样使点间距均匀"""
|
||||
if len(points) < 2:
|
||||
return points
|
||||
|
||||
arr = np.array(points, dtype=np.float64)
|
||||
|
||||
cumulative_dist = [0.0]
|
||||
for i in range(1, len(arr)):
|
||||
dist = np.linalg.norm(arr[i] - arr[i - 1])
|
||||
cumulative_dist.append(cumulative_dist[-1] + dist)
|
||||
|
||||
total_length = cumulative_dist[-1]
|
||||
if total_length < target_spacing:
|
||||
return points
|
||||
|
||||
num_points = max(3, int(total_length / target_spacing))
|
||||
new_distances = np.linspace(0, total_length, num_points)
|
||||
|
||||
resampled = []
|
||||
for d in new_distances:
|
||||
idx = np.searchsorted(cumulative_dist, d) - 1
|
||||
idx = max(0, min(idx, len(arr) - 2))
|
||||
|
||||
seg_start = cumulative_dist[idx]
|
||||
seg_end = cumulative_dist[idx + 1]
|
||||
seg_length = seg_end - seg_start
|
||||
|
||||
if seg_length > 0:
|
||||
t = (d - seg_start) / seg_length
|
||||
else:
|
||||
t = 0
|
||||
|
||||
point = arr[idx] + t * (arr[idx + 1] - arr[idx])
|
||||
resampled.append(point.tolist())
|
||||
|
||||
return resampled
|
||||
@@ -0,0 +1,478 @@
|
||||
"""
|
||||
CAD 文件导出模块
|
||||
|
||||
支持导出格式:
|
||||
1. STEP (ISO 10303) - 推荐,UG/NX、FreeCAD、SolidWorks 通用
|
||||
2. IGES (Initial Graphics Exchange Specification) - 兼容旧系统
|
||||
3. STL (STereoLithography) - 网格格式,3D打印/快速预览
|
||||
4. BRep (Boundary Representation) - OpenCASCADE 原生格式
|
||||
|
||||
导出内容:
|
||||
- 型腔 (Cavity)
|
||||
- 型芯 (Core)
|
||||
- 分型面 (Parting Surface)
|
||||
- A板/B板
|
||||
- 模具块
|
||||
- 完整模具装配体(多形状合并)
|
||||
|
||||
UG/NX 导入建议:
|
||||
- 优先使用 STEP AP214 或 AP242 格式
|
||||
- IGES 作为备选
|
||||
- STL 仅用于预览,不可编辑
|
||||
|
||||
FreeCAD 导入建议:
|
||||
- STEP AP214 最佳兼容性
|
||||
- BRep 可直接在 FreeCAD 的 OpenCASCADE 内核中打开
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from OCC.Core.TopoDS import TopoDS_Shape
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CADExporter:
|
||||
"""CAD 文件导出器"""
|
||||
|
||||
def __init__(self, output_dir: str = "./exports"):
|
||||
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: TopoDS_Shape, filepath: str,
|
||||
schema: str = "AP214") -> bool:
|
||||
"""
|
||||
导出 STEP 文件
|
||||
|
||||
Args:
|
||||
shape: OpenCASCADE TopoDS_Shape
|
||||
filepath: 输出文件路径
|
||||
schema: STEP 应用协议 (AP203/AP214/AP242)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
from OCC.Core.STEPControl import (
|
||||
STEPControl_Writer,
|
||||
STEPControl_AsIs,
|
||||
)
|
||||
from OCC.Core.Interface import Interface_Static
|
||||
|
||||
writer = STEPControl_Writer()
|
||||
|
||||
if schema == "AP203":
|
||||
Interface_Static.SetCVal("write.step.schema", "AP203")
|
||||
elif schema == "AP242":
|
||||
Interface_Static.SetCVal("write.step.schema", "AP242")
|
||||
else:
|
||||
Interface_Static.SetCVal("write.step.schema", "AP214")
|
||||
|
||||
writer.Transfer(shape, STEPControl_AsIs)
|
||||
|
||||
status = writer.Write(filepath)
|
||||
|
||||
if status == 1:
|
||||
file_size = os.path.getsize(filepath) if os.path.exists(filepath) else 0
|
||||
logger.info(f"STEP 导出成功: {filepath} ({file_size} bytes, {schema})")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"STEP 导出失败: 写入状态={status}")
|
||||
return False
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"STEP 导出依赖缺失: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"STEP 导出失败: {e}")
|
||||
return False
|
||||
|
||||
def export_iges(self, shape: TopoDS_Shape, filepath: str) -> bool:
|
||||
"""
|
||||
导出 IGES 文件
|
||||
|
||||
Args:
|
||||
shape: OpenCASCADE TopoDS_Shape
|
||||
filepath: 输出文件路径
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
from OCC.Core.IGESControl import IGESControl_Writer
|
||||
from OCC.Core.Interface import Interface_Static
|
||||
|
||||
Interface_Static.SetCVal("write.iges.brep.mode", "0")
|
||||
|
||||
writer = IGESControl_Writer()
|
||||
writer.AddShape(shape)
|
||||
writer.ComputeModel()
|
||||
|
||||
status = writer.Write(filepath)
|
||||
|
||||
if status:
|
||||
file_size = os.path.getsize(filepath) if os.path.exists(filepath) else 0
|
||||
logger.info(f"IGES 导出成功: {filepath} ({file_size} bytes)")
|
||||
return True
|
||||
else:
|
||||
logger.error("IGES 导出失败: 写入返回 False")
|
||||
return False
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"IGES 导出依赖缺失: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"IGES 导出失败: {e}")
|
||||
return False
|
||||
|
||||
def export_stl(self, shape: TopoDS_Shape, filepath: str,
|
||||
ascii_mode: bool = True,
|
||||
deflection: float = 0.1) -> bool:
|
||||
"""
|
||||
导出 STL 文件
|
||||
|
||||
Args:
|
||||
shape: OpenCASCADE TopoDS_Shape
|
||||
filepath: 输出文件路径
|
||||
ascii_mode: True=ASCII格式, False=二进制格式
|
||||
deflection: 网格偏差(越小越精细)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
from OCC.Core.StlAPI import StlAPI_Writer
|
||||
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
|
||||
|
||||
mesh = BRepMesh_IncrementalMesh(shape, deflection)
|
||||
mesh.Perform()
|
||||
|
||||
if not mesh.IsDone():
|
||||
logger.warning("STL 网格化未完成,尝试继续导出")
|
||||
|
||||
writer = StlAPI_Writer()
|
||||
writer.AsciiMode = ascii_mode
|
||||
|
||||
writer.Write(shape, filepath)
|
||||
|
||||
if os.path.exists(filepath) and os.path.getsize(filepath) > 0:
|
||||
file_size = os.path.getsize(filepath)
|
||||
logger.info(f"STL 导出成功: {filepath} ({file_size} bytes)")
|
||||
return True
|
||||
else:
|
||||
logger.error("STL 导出失败: 文件为空或不存在")
|
||||
return False
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"STL 导出依赖缺失: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"STL 导出失败: {e}")
|
||||
return False
|
||||
|
||||
def export_brep(self, shape: TopoDS_Shape, filepath: str) -> bool:
|
||||
"""
|
||||
导出 BRep 文件(OpenCASCADE 原生格式)
|
||||
|
||||
FreeCAD 可直接导入此格式
|
||||
|
||||
Args:
|
||||
shape: OpenCASCADE TopoDS_Shape
|
||||
filepath: 输出文件路径
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
from OCC.Core.BRepTools import BRepTools_Write
|
||||
|
||||
BRepTools_Write(shape, filepath)
|
||||
|
||||
if os.path.exists(filepath) and os.path.getsize(filepath) > 0:
|
||||
file_size = os.path.getsize(filepath)
|
||||
logger.info(f"BRep 导出成功: {filepath} ({file_size} bytes)")
|
||||
return True
|
||||
else:
|
||||
logger.error("BRep 导出失败: 文件为空或不存在")
|
||||
return False
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"BRep 导出依赖缺失: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"BRep 导出失败: {e}")
|
||||
return False
|
||||
|
||||
def export_mold_results(self, cavity_data: Dict,
|
||||
base_filename: str,
|
||||
formats: List[str] = None,
|
||||
components: List[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
scheme_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
if formats is None:
|
||||
formats = ["step", "stl"]
|
||||
if components is None:
|
||||
components = ["cavity", "core"]
|
||||
|
||||
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": [],
|
||||
}
|
||||
|
||||
shape_map = {
|
||||
"cavity": ("cavity", "型腔"),
|
||||
"core": ("core", "型芯"),
|
||||
"parting_surface": ("parting_surface", "分型面"),
|
||||
}
|
||||
|
||||
shapes_to_export: List[Tuple[str, str, TopoDS_Shape]] = []
|
||||
assembly_shapes: List[Tuple[TopoDS_Shape, str]] = []
|
||||
|
||||
for comp in components:
|
||||
if comp == "all":
|
||||
for key, (data_key, label) in shape_map.items():
|
||||
shape = cavity_data.get(data_key)
|
||||
if shape is not None:
|
||||
shapes_to_export.append((key, label, shape))
|
||||
assembly_shapes.append((shape, label))
|
||||
break
|
||||
elif comp in shape_map:
|
||||
data_key, label = shape_map[comp]
|
||||
shape = cavity_data.get(data_key)
|
||||
if shape is not None:
|
||||
shapes_to_export.append((comp, label, shape))
|
||||
assembly_shapes.append((shape, label))
|
||||
else:
|
||||
results["errors"].append(f"{label}形状不可用")
|
||||
|
||||
# STEP: 所有组件合并为一个装配体文件
|
||||
if "step" in formats and assembly_shapes:
|
||||
filepath = os.path.join(export_dir, f"{base_filename}_mold.step")
|
||||
success = self.export_assembly_step(assembly_shapes, filepath)
|
||||
if success:
|
||||
file_size = os.path.getsize(filepath)
|
||||
relative_path = self.get_relative_path(filepath)
|
||||
results["files"].append({
|
||||
"component": "assembly",
|
||||
"component_label": "模具装配体",
|
||||
"format": "step",
|
||||
"filepath": filepath,
|
||||
"relative_path": relative_path,
|
||||
"filename": os.path.basename(filepath),
|
||||
"size_bytes": file_size,
|
||||
"size_readable": self._format_file_size(file_size),
|
||||
})
|
||||
else:
|
||||
results["errors"].append("装配体 STEP 导出失败")
|
||||
|
||||
# IGES / STL / BRep: 逐组件导出
|
||||
non_assembly_formats = [f for f in formats if f != "step"]
|
||||
for comp_name, label, shape in shapes_to_export:
|
||||
for fmt in non_assembly_formats:
|
||||
filepath = os.path.join(export_dir, f"{base_filename}_{comp_name}.{fmt}")
|
||||
|
||||
success = False
|
||||
if fmt == "iges":
|
||||
success = self.export_iges(shape, filepath)
|
||||
elif fmt == "stl":
|
||||
success = self.export_stl(shape, filepath)
|
||||
elif fmt == "brep":
|
||||
success = self.export_brep(shape, filepath)
|
||||
else:
|
||||
results["errors"].append(f"不支持的格式: {fmt}")
|
||||
continue
|
||||
|
||||
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),
|
||||
})
|
||||
else:
|
||||
results["errors"].append(f"{label} ({fmt}) 导出失败")
|
||||
|
||||
results["total_files"] = len(results["files"])
|
||||
results["total_errors"] = len(results["errors"])
|
||||
|
||||
logger.info(f"模具导出完成: {results['total_files']} 个文件, "
|
||||
f"{results['total_errors']} 个错误")
|
||||
|
||||
return results
|
||||
|
||||
def export_assembly_step(self, shapes_with_names: List[Tuple[TopoDS_Shape, str]],
|
||||
filepath: str,
|
||||
schema: str = "AP214") -> bool:
|
||||
"""
|
||||
导出装配体 STEP 文件(多个形状写入同一个 STEP 文件)
|
||||
|
||||
UG/NX 和 FreeCAD 可以识别装配体中的各个零件
|
||||
|
||||
Args:
|
||||
shapes_with_names: [(shape, name), ...] 形状和名称列表
|
||||
filepath: 输出文件路径
|
||||
schema: STEP 协议版本
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
try:
|
||||
from OCC.Core.STEPControl import (
|
||||
STEPControl_Writer,
|
||||
STEPControl_AsIs,
|
||||
)
|
||||
from OCC.Core.Interface import Interface_Static
|
||||
|
||||
writer = STEPControl_Writer()
|
||||
|
||||
if schema == "AP203":
|
||||
Interface_Static.SetCVal("write.step.schema", "AP203")
|
||||
elif schema == "AP242":
|
||||
Interface_Static.SetCVal("write.step.schema", "AP242")
|
||||
else:
|
||||
Interface_Static.SetCVal("write.step.schema", "AP214")
|
||||
|
||||
for shape, name in shapes_with_names:
|
||||
try:
|
||||
writer.Transfer(shape, STEPControl_AsIs)
|
||||
logger.info(f"已添加到装配体: {name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"添加形状 {name} 失败: {e}")
|
||||
|
||||
status = writer.Write(filepath)
|
||||
|
||||
if status == 1:
|
||||
file_size = os.path.getsize(filepath) if os.path.exists(filepath) else 0
|
||||
logger.info(f"装配体 STEP 导出成功: {filepath} ({file_size} bytes)")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"装配体 STEP 导出失败: 状态={status}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"装配体 STEP 导出失败: {e}")
|
||||
return False
|
||||
|
||||
def get_export_recommendations(self, target_software: str = "ug") -> Dict[str, Any]:
|
||||
"""
|
||||
获取针对目标软件的导出建议
|
||||
|
||||
Args:
|
||||
target_software: 目标软件 (ug/nx, freecad, solidworks, autocad)
|
||||
|
||||
Returns:
|
||||
导出建议
|
||||
"""
|
||||
recommendations = {
|
||||
"ug": {
|
||||
"name": "UG/NX",
|
||||
"primary_format": "step",
|
||||
"step_schema": "AP242",
|
||||
"secondary_format": "iges",
|
||||
"notes": [
|
||||
"推荐 STEP AP242 格式,支持颜色和装配信息",
|
||||
"IGES 作为备选,但可能丢失拓扑信息",
|
||||
"STL 仅用于预览,不可参数化编辑",
|
||||
"导入时选择 '保留原始坐标系'",
|
||||
],
|
||||
"import_settings": {
|
||||
"step": "File → Import → STEP203/214/242",
|
||||
"iges": "File → Import → IGES",
|
||||
"stl": "File → Import → STL (仅可视化)",
|
||||
},
|
||||
},
|
||||
"freecad": {
|
||||
"name": "FreeCAD",
|
||||
"primary_format": "step",
|
||||
"step_schema": "AP214",
|
||||
"secondary_format": "brep",
|
||||
"notes": [
|
||||
"STEP AP214 最佳兼容性",
|
||||
"BRep 是 OpenCASCADE 原生格式,FreeCAD 可直接打开",
|
||||
"导入后可在 Part 工作台中编辑",
|
||||
"推荐使用 FreeCAD 0.21+ 版本",
|
||||
],
|
||||
"import_settings": {
|
||||
"step": "File → Import → 选择 STEP 文件",
|
||||
"iges": "File → Import → 选择 IGES 文件",
|
||||
"brep": "File → Open → 选择 BRep 文件",
|
||||
"stl": "File → Import → Mesh 格式",
|
||||
},
|
||||
},
|
||||
"solidworks": {
|
||||
"name": "SolidWorks",
|
||||
"primary_format": "step",
|
||||
"step_schema": "AP214",
|
||||
"secondary_format": "iges",
|
||||
"notes": [
|
||||
"STEP AP214 最佳兼容性",
|
||||
"导入后自动识别为实体",
|
||||
"IGES 可能产生曲面而非实体",
|
||||
],
|
||||
"import_settings": {
|
||||
"step": "File → Open → STEP 文件",
|
||||
"iges": "File → Open → IGES 文件",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return recommendations.get(target_software, recommendations["ug"])
|
||||
|
||||
@staticmethod
|
||||
def _format_file_size(size_bytes: int) -> str:
|
||||
"""格式化文件大小"""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
多型腔布局优化模块
|
||||
|
||||
功能:
|
||||
1. 支持矩形、圆形、H型等常见多型腔排列方式
|
||||
2. 基于产品尺寸和模架尺寸自动计算最优布局
|
||||
3. 流道系统自动设计
|
||||
4. 流动平衡评估
|
||||
5. 材料利用率计算
|
||||
|
||||
布局策略:
|
||||
- 1穴:中心单型腔
|
||||
- 2穴:对称排列
|
||||
- 4穴:2x2 矩阵排列
|
||||
- 8穴:2x4 矩阵排列
|
||||
- 16穴:4x4 矩阵排列
|
||||
- 圆形排列:适用于圆形产品
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import math
|
||||
import numpy as np
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CavityLayoutOptimizer:
|
||||
"""多型腔布局优化器"""
|
||||
|
||||
LAYOUT_RECTANGULAR = "rectangular"
|
||||
LAYOUT_CIRCULAR = "circular"
|
||||
LAYOUT_H_SHAPE = "h_shape"
|
||||
LAYOUT_INLINE = "inline"
|
||||
|
||||
def __init__(self):
|
||||
self.runner_diameter = 5.0
|
||||
self.gate_diameter = 1.5
|
||||
self.cavity_margin = 15.0
|
||||
self.runner_margin = 25.0
|
||||
|
||||
def optimize_layout(self, product_bbox: Dict, cavity_count: int,
|
||||
mold_base_size: Optional[Dict] = None,
|
||||
layout_type: str = "auto",
|
||||
product_shape: Any = None) -> Dict[str, Any]:
|
||||
"""
|
||||
优化多型腔布局
|
||||
|
||||
Args:
|
||||
product_bbox: 产品边界框 {"dimensions": [dx, dy, dz]}
|
||||
cavity_count: 型腔数量
|
||||
mold_base_size: 模架尺寸 {"length": L, "width": W}
|
||||
layout_type: 布局类型 (auto/rectangular/circular/h_shape/inline)
|
||||
product_shape: 产品形状(可选,用于精确计算)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"layout_type": str,
|
||||
"cavity_positions": List[[x, y, z]],
|
||||
"cavity_rotations": List[[rx, ry, rz]],
|
||||
"runner_system": Dict,
|
||||
"balance_score": float,
|
||||
"material_efficiency": float,
|
||||
"mold_size": Dict,
|
||||
"recommendations": List[str]
|
||||
}
|
||||
"""
|
||||
logger.info(f"开始多型腔布局优化: {cavity_count}穴, 布局={layout_type}")
|
||||
|
||||
dims = product_bbox.get("dimensions", [100, 100, 50])
|
||||
|
||||
if layout_type == "auto":
|
||||
layout_type = self._recommend_layout(cavity_count, dims)
|
||||
|
||||
if layout_type == self.LAYOUT_RECTANGULAR:
|
||||
result = self._layout_rectangular(dims, cavity_count, mold_base_size)
|
||||
elif layout_type == self.LAYOUT_CIRCULAR:
|
||||
result = self._layout_circular(dims, cavity_count, mold_base_size)
|
||||
elif layout_type == self.LAYOUT_H_SHAPE:
|
||||
result = self._layout_h_shape(dims, cavity_count, mold_base_size)
|
||||
elif layout_type == self.LAYOUT_INLINE:
|
||||
result = self._layout_inline(dims, cavity_count, mold_base_size)
|
||||
else:
|
||||
result = self._layout_rectangular(dims, cavity_count, mold_base_size)
|
||||
|
||||
result["runner_system"] = self._design_runner_system(
|
||||
result["cavity_positions"], cavity_count, layout_type
|
||||
)
|
||||
result["balance_score"] = self._evaluate_flow_balance(
|
||||
result["cavity_positions"], result["runner_system"]
|
||||
)
|
||||
result["material_efficiency"] = self._calculate_material_efficiency(
|
||||
dims, cavity_count, result["mold_size"]
|
||||
)
|
||||
result["recommendations"] = self._generate_recommendations(
|
||||
result, cavity_count, dims
|
||||
)
|
||||
|
||||
logger.info(f"布局优化完成: {layout_type}, 平衡度={result['balance_score']:.2f}, "
|
||||
f"材料利用率={result['material_efficiency']:.2%}")
|
||||
|
||||
return result
|
||||
|
||||
def _recommend_layout(self, cavity_count: int, dims: List[float]) -> str:
|
||||
"""根据型腔数量和产品尺寸推荐布局方式"""
|
||||
aspect_ratio = max(dims[:2]) / min(dims[:2]) if min(dims[:2]) > 0 else 1
|
||||
|
||||
if cavity_count == 1:
|
||||
return self.LAYOUT_RECTANGULAR
|
||||
elif cavity_count == 2:
|
||||
if aspect_ratio > 2:
|
||||
return self.LAYOUT_INLINE
|
||||
return self.LAYOUT_RECTANGULAR
|
||||
elif cavity_count <= 4:
|
||||
return self.LAYOUT_RECTANGULAR
|
||||
elif cavity_count <= 8:
|
||||
if aspect_ratio > 2:
|
||||
return self.LAYOUT_H_SHAPE
|
||||
return self.LAYOUT_RECTANGULAR
|
||||
else:
|
||||
return self.LAYOUT_H_SHAPE
|
||||
|
||||
def _layout_rectangular(self, dims: List[float], cavity_count: int,
|
||||
mold_base_size: Optional[Dict]) -> Dict:
|
||||
"""矩形矩阵排列"""
|
||||
rows, cols = self._calculate_grid(cavity_count)
|
||||
|
||||
spacing_x = dims[0] + 2 * self.cavity_margin
|
||||
spacing_y = dims[1] + 2 * self.cavity_margin
|
||||
|
||||
positions = []
|
||||
rotations = []
|
||||
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
if len(positions) >= cavity_count:
|
||||
break
|
||||
x = (c - (cols - 1) / 2) * spacing_x
|
||||
y = (r - (rows - 1) / 2) * spacing_y
|
||||
positions.append([x, y, 0])
|
||||
rotations.append([0, 0, 0])
|
||||
|
||||
total_length = cols * spacing_x + 2 * self.runner_margin
|
||||
total_width = rows * spacing_y + 2 * self.runner_margin
|
||||
|
||||
mold_size = {
|
||||
"length": max(total_length, mold_base_size.get("length", 0)) if mold_base_size else total_length,
|
||||
"width": max(total_width, mold_base_size.get("width", 0)) if mold_base_size else total_width,
|
||||
}
|
||||
|
||||
return {
|
||||
"layout_type": self.LAYOUT_RECTANGULAR,
|
||||
"cavity_positions": positions,
|
||||
"cavity_rotations": rotations,
|
||||
"grid": {"rows": rows, "cols": cols},
|
||||
"spacing": {"x": spacing_x, "y": spacing_y},
|
||||
"mold_size": mold_size,
|
||||
}
|
||||
|
||||
def _layout_circular(self, dims: List[float], cavity_count: int,
|
||||
mold_base_size: Optional[Dict]) -> Dict:
|
||||
"""圆形排列"""
|
||||
max_dim = max(dims[:2])
|
||||
radius = max_dim / 2 + self.cavity_margin + self.runner_margin
|
||||
|
||||
positions = []
|
||||
rotations = []
|
||||
|
||||
for i in range(cavity_count):
|
||||
angle = 2 * math.pi * i / cavity_count
|
||||
x = radius * math.cos(angle)
|
||||
y = radius * math.sin(angle)
|
||||
rot_z = -math.degrees(angle)
|
||||
positions.append([x, y, 0])
|
||||
rotations.append([0, 0, rot_z])
|
||||
|
||||
total_diameter = 2 * radius + max_dim + 2 * self.cavity_margin
|
||||
|
||||
mold_size = {
|
||||
"length": total_diameter,
|
||||
"width": total_diameter,
|
||||
}
|
||||
|
||||
return {
|
||||
"layout_type": self.LAYOUT_CIRCULAR,
|
||||
"cavity_positions": positions,
|
||||
"cavity_rotations": rotations,
|
||||
"radius": radius,
|
||||
"mold_size": mold_size,
|
||||
}
|
||||
|
||||
def _layout_h_shape(self, dims: List[float], cavity_count: int,
|
||||
mold_base_size: Optional[Dict]) -> Dict:
|
||||
"""H型排列(适用于多型腔,流道平衡性好)"""
|
||||
left_count = cavity_count // 2
|
||||
right_count = cavity_count - left_count
|
||||
|
||||
spacing_x = dims[0] + 2 * self.cavity_margin
|
||||
spacing_y = dims[1] + 2 * self.cavity_margin
|
||||
|
||||
positions = []
|
||||
rotations = []
|
||||
|
||||
left_rows, left_cols = self._calculate_grid(left_count)
|
||||
for r in range(left_rows):
|
||||
for c in range(left_cols):
|
||||
if len(positions) >= left_count:
|
||||
break
|
||||
x = -(c + 1) * spacing_x - spacing_x / 2
|
||||
y = (r - (left_rows - 1) / 2) * spacing_y
|
||||
positions.append([x, y, 0])
|
||||
rotations.append([0, 0, 0])
|
||||
|
||||
right_rows, right_cols = self._calculate_grid(right_count)
|
||||
for r in range(right_rows):
|
||||
for c in range(right_cols):
|
||||
if len(positions) >= cavity_count:
|
||||
break
|
||||
x = (c + 1) * spacing_x + spacing_x / 2
|
||||
y = (r - (right_rows - 1) / 2) * spacing_y
|
||||
positions.append([x, y, 0])
|
||||
rotations.append([0, 0, 0])
|
||||
|
||||
total_length = (max(left_cols, right_cols) + 1) * spacing_x * 2 + 2 * self.runner_margin
|
||||
total_width = max(left_rows, right_rows) * spacing_y + 2 * self.runner_margin
|
||||
|
||||
mold_size = {
|
||||
"length": total_length,
|
||||
"width": total_width,
|
||||
}
|
||||
|
||||
return {
|
||||
"layout_type": self.LAYOUT_H_SHAPE,
|
||||
"cavity_positions": positions,
|
||||
"cavity_rotations": rotations,
|
||||
"mold_size": mold_size,
|
||||
}
|
||||
|
||||
def _layout_inline(self, dims: List[float], cavity_count: int,
|
||||
mold_base_size: Optional[Dict]) -> Dict:
|
||||
"""直线排列(适用于细长产品)"""
|
||||
spacing = max(dims[:2]) + 2 * self.cavity_margin
|
||||
|
||||
positions = []
|
||||
rotations = []
|
||||
|
||||
for i in range(cavity_count):
|
||||
offset = (i - (cavity_count - 1) / 2) * spacing
|
||||
if dims[0] > dims[1]:
|
||||
positions.append([offset, 0, 0])
|
||||
else:
|
||||
positions.append([0, offset, 0])
|
||||
rotations.append([0, 0, 0])
|
||||
|
||||
if dims[0] > dims[1]:
|
||||
total_length = cavity_count * spacing + 2 * self.runner_margin
|
||||
total_width = dims[1] + 2 * self.cavity_margin + 2 * self.runner_margin
|
||||
else:
|
||||
total_length = dims[0] + 2 * self.cavity_margin + 2 * self.runner_margin
|
||||
total_width = cavity_count * spacing + 2 * self.runner_margin
|
||||
|
||||
mold_size = {
|
||||
"length": total_length,
|
||||
"width": total_width,
|
||||
}
|
||||
|
||||
return {
|
||||
"layout_type": self.LAYOUT_INLINE,
|
||||
"cavity_positions": positions,
|
||||
"cavity_rotations": rotations,
|
||||
"mold_size": mold_size,
|
||||
}
|
||||
|
||||
def _calculate_grid(self, count: int) -> Tuple[int, int]:
|
||||
"""计算最接近正方形的网格排列"""
|
||||
if count <= 0:
|
||||
return 1, 1
|
||||
|
||||
best_rows = 1
|
||||
best_cols = count
|
||||
best_ratio = float("inf")
|
||||
|
||||
for r in range(1, count + 1):
|
||||
if count % r == 0:
|
||||
c = count // r
|
||||
ratio = abs(r - c)
|
||||
if ratio < best_ratio:
|
||||
best_ratio = ratio
|
||||
best_rows = r
|
||||
best_cols = c
|
||||
|
||||
return best_rows, best_cols
|
||||
|
||||
def _design_runner_system(self, positions: List[List[float]],
|
||||
cavity_count: int,
|
||||
layout_type: str) -> Dict:
|
||||
"""
|
||||
设计流道系统
|
||||
|
||||
Returns:
|
||||
{
|
||||
"type": "cold_runner" | "hot_runner",
|
||||
"main_runner": Dict,
|
||||
"sub_runners": List[Dict],
|
||||
"gates": List[Dict],
|
||||
"total_volume": float
|
||||
}
|
||||
"""
|
||||
if cavity_count == 1:
|
||||
return self._design_single_cavity_runner(positions[0])
|
||||
|
||||
main_runner = {
|
||||
"start": [0, -positions[0][1] - 20, 0],
|
||||
"end": [0, positions[0][1] + 20, 0] if len(positions) > 0 else [0, 20, 0],
|
||||
"diameter": self.runner_diameter,
|
||||
"length": 0,
|
||||
}
|
||||
|
||||
sub_runners = []
|
||||
gates = []
|
||||
total_volume = 0
|
||||
|
||||
for i, pos in enumerate(positions):
|
||||
sub_runner = {
|
||||
"start": [0, pos[1], 0],
|
||||
"end": pos,
|
||||
"diameter": self.runner_diameter * 0.8,
|
||||
"length": float(np.linalg.norm(np.array(pos))),
|
||||
}
|
||||
sub_runners.append(sub_runner)
|
||||
total_volume += math.pi * (sub_runner["diameter"] / 2) ** 2 * sub_runner["length"]
|
||||
|
||||
gate = {
|
||||
"position": pos,
|
||||
"diameter": self.gate_diameter,
|
||||
"type": "side_gate",
|
||||
"length": 2.0,
|
||||
}
|
||||
gates.append(gate)
|
||||
total_volume += math.pi * (gate["diameter"] / 2) ** 2 * gate["length"]
|
||||
|
||||
main_runner["length"] = max(
|
||||
abs(p[1]) for p in positions
|
||||
) * 2 + 40 if positions else 40
|
||||
total_volume += math.pi * (main_runner["diameter"] / 2) ** 2 * main_runner["length"]
|
||||
|
||||
return {
|
||||
"type": "cold_runner",
|
||||
"main_runner": main_runner,
|
||||
"sub_runners": sub_runners,
|
||||
"gates": gates,
|
||||
"total_volume": total_volume,
|
||||
}
|
||||
|
||||
def _design_single_cavity_runner(self, position: List[float]) -> Dict:
|
||||
"""单型腔流道设计"""
|
||||
gate = {
|
||||
"position": position,
|
||||
"diameter": self.gate_diameter,
|
||||
"type": "center_gate",
|
||||
"length": 3.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"type": "cold_runner",
|
||||
"main_runner": None,
|
||||
"sub_runners": [],
|
||||
"gates": [gate],
|
||||
"total_volume": math.pi * (gate["diameter"] / 2) ** 2 * gate["length"],
|
||||
}
|
||||
|
||||
def _evaluate_flow_balance(self, positions: List[List[float]],
|
||||
runner_system: Dict) -> float:
|
||||
"""
|
||||
评估流动平衡度 (0-1)
|
||||
|
||||
基于各型腔到主流道的距离差异
|
||||
"""
|
||||
if len(positions) <= 1:
|
||||
return 1.0
|
||||
|
||||
distances = []
|
||||
for pos in positions:
|
||||
dist = float(np.linalg.norm(np.array(pos)))
|
||||
distances.append(dist)
|
||||
|
||||
max_dist = max(distances)
|
||||
min_dist = min(distances)
|
||||
|
||||
if max_dist == 0:
|
||||
return 1.0
|
||||
|
||||
imbalance = (max_dist - min_dist) / max_dist
|
||||
balance_score = max(0, 1.0 - imbalance)
|
||||
|
||||
return round(balance_score, 3)
|
||||
|
||||
def _calculate_material_efficiency(self, product_dims: List[float],
|
||||
cavity_count: int,
|
||||
mold_size: Dict) -> float:
|
||||
"""计算材料利用率"""
|
||||
product_area = product_dims[0] * product_dims[1]
|
||||
total_product_area = product_area * cavity_count
|
||||
mold_area = mold_size.get("length", 0) * mold_size.get("width", 0)
|
||||
|
||||
if mold_area <= 0:
|
||||
return 0.0
|
||||
|
||||
return min(1.0, total_product_area / mold_area)
|
||||
|
||||
def _generate_recommendations(self, result: Dict, cavity_count: int,
|
||||
dims: List[float]) -> List[str]:
|
||||
"""生成优化建议"""
|
||||
recommendations = []
|
||||
|
||||
balance = result.get("balance_score", 0)
|
||||
if balance < 0.8:
|
||||
recommendations.append("流动平衡度偏低,建议调整型腔间距或使用热流道系统")
|
||||
|
||||
efficiency = result.get("material_efficiency", 0)
|
||||
if efficiency < 0.4:
|
||||
recommendations.append("材料利用率偏低,建议减少模架尺寸或增加型腔数量")
|
||||
|
||||
if cavity_count > 8:
|
||||
recommendations.append("多型腔模具建议使用热流道系统以保证填充平衡")
|
||||
|
||||
if cavity_count > 16:
|
||||
recommendations.append("型腔数量过多,建议分模评估加工可行性")
|
||||
|
||||
aspect = max(dims[:2]) / min(dims[:2]) if min(dims[:2]) > 0 else 1
|
||||
if aspect > 3:
|
||||
recommendations.append("产品长宽比大,建议使用侧浇口或扇形浇口")
|
||||
|
||||
if not recommendations:
|
||||
recommendations.append("布局方案合理,建议进行模流分析验证")
|
||||
|
||||
return recommendations
|
||||
@@ -0,0 +1,835 @@
|
||||
from typing import Dict, List, Any, Optional
|
||||
import math
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import numpy as np
|
||||
from OCC.Core.TopoDS import TopoDS_Shape
|
||||
from shared.models.schemas import (
|
||||
create_mold_feature,
|
||||
create_design_recommendation,
|
||||
create_analysis_result
|
||||
)
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GeometryAnalyzer:
|
||||
"""几何分析器 - 基于 OCC Shape 的精确分析"""
|
||||
|
||||
def __init__(self):
|
||||
self.feature_thresholds = {
|
||||
"thin_wall": 2.0,
|
||||
"thick_wall": 8.0,
|
||||
"small_feature": 5.0,
|
||||
"large_feature": 1000.0,
|
||||
"high_complexity": 50,
|
||||
}
|
||||
|
||||
self.product_materials = {
|
||||
"ABS": {"shrinkage": 0.005, "min_wall": 1.2},
|
||||
"PP": {"shrinkage": 0.016, "min_wall": 1.0},
|
||||
"PC": {"shrinkage": 0.007, "min_wall": 1.5},
|
||||
}
|
||||
|
||||
self.mold_materials = {
|
||||
"Aluminum": {"thermal_conductivity": 200, "hardness": "HB80", "cost": "low"},
|
||||
"P20_Steel": {"thermal_conductivity": 30, "hardness": "HRC30", "cost": "medium"},
|
||||
"H13_Steel": {"thermal_conductivity": 25, "hardness": "HRC48", "cost": "high"}
|
||||
}
|
||||
|
||||
def analyze_mold_design(self, geometry_data: Dict[str, Any],
|
||||
product_material: str = "ABS",
|
||||
mold_material: str = "Aluminum",
|
||||
shape: Optional[TopoDS_Shape] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""分析模具设计
|
||||
|
||||
Args:
|
||||
geometry_data: 几何数据字典(来自 stp_parser)
|
||||
product_material: 产品材料
|
||||
mold_material: 模具材料
|
||||
shape: OCC TopoDS_Shape 对象(可选,提供后启用精确分析)
|
||||
"""
|
||||
logger.info("开始模具设计分析")
|
||||
|
||||
features = self._detect_features(geometry_data, shape)
|
||||
|
||||
product_props = self.product_materials.get(product_material, {})
|
||||
mold_props = self.mold_materials.get(mold_material, {})
|
||||
|
||||
recommendations = self._generate_recommendations(
|
||||
geometry_data, features, product_material
|
||||
)
|
||||
|
||||
quality_metrics = self._calculate_quality_metrics(geometry_data, features)
|
||||
|
||||
analysis_summary = self._generate_analysis_summary(geometry_data, features, recommendations)
|
||||
|
||||
return create_analysis_result(
|
||||
geometry_data=geometry_data,
|
||||
detected_features=features,
|
||||
design_recommendations=recommendations,
|
||||
quality_metrics=quality_metrics,
|
||||
analysis_summary=analysis_summary
|
||||
)
|
||||
|
||||
def _detect_features(self, geometry_data: Dict[str, Any],
|
||||
shape: Optional[TopoDS_Shape] = None) -> List[Dict[str, Any]]:
|
||||
"""检测模具特征 — 独立检测并行执行"""
|
||||
features: List[Dict[str, Any]] = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4, thread_name_prefix="feat") as pool:
|
||||
futures = {
|
||||
pool.submit(self._detect_wall_features, geometry_data, shape): "wall",
|
||||
pool.submit(self._detect_rib_features, geometry_data, shape): "rib",
|
||||
pool.submit(self._detect_boss_features, geometry_data, shape): "boss",
|
||||
pool.submit(self._analyze_draft_angles, geometry_data, shape): "draft",
|
||||
}
|
||||
if shape is not None:
|
||||
futures[pool.submit(self._detect_curvature_features, shape)] = "curvature"
|
||||
futures[pool.submit(self._detect_fillet_features, shape)] = "fillet"
|
||||
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
features.extend(future.result())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"检测到 {len(features)} 个特征")
|
||||
return features
|
||||
|
||||
def _detect_wall_features(self, geometry_data: Dict[str, Any],
|
||||
shape: Optional[TopoDS_Shape] = None) -> List[Dict[str, Any]]:
|
||||
"""检测壁厚特征"""
|
||||
features = []
|
||||
|
||||
if shape is not None:
|
||||
precise_result = self._compute_precise_wall_thickness(shape)
|
||||
if precise_result is not None:
|
||||
min_thickness = precise_result["min_thickness"]
|
||||
max_thickness = precise_result["max_thickness"]
|
||||
avg_thickness = precise_result["avg_thickness"]
|
||||
thickness_map = precise_result.get("thickness_map", {})
|
||||
estimation_method = "precise"
|
||||
|
||||
if min_thickness < self.feature_thresholds["thin_wall"]:
|
||||
features.append(create_mold_feature(
|
||||
feature_type="thin_wall",
|
||||
confidence=0.92,
|
||||
location=precise_result.get("min_location",
|
||||
geometry_data.get("center_of_mass", [0, 0, 0])),
|
||||
dimensions=[min_thickness, avg_thickness, max_thickness],
|
||||
parameters={
|
||||
"min_thickness": round(min_thickness, 3),
|
||||
"max_thickness": round(max_thickness, 3),
|
||||
"avg_thickness": round(avg_thickness, 3),
|
||||
"estimation_method": estimation_method,
|
||||
"measured_pairs": len(thickness_map),
|
||||
},
|
||||
recommendations=[
|
||||
f"最小壁厚 {min_thickness:.2f}mm 过薄,建议增加到 {self.feature_thresholds['thin_wall']}mm 以上",
|
||||
"薄壁区域可能导致注塑填充不充分",
|
||||
"考虑增加加强筋以提高结构强度"
|
||||
]
|
||||
))
|
||||
elif max_thickness > self.feature_thresholds["thick_wall"]:
|
||||
features.append(create_mold_feature(
|
||||
feature_type="thick_wall",
|
||||
confidence=0.88,
|
||||
location=precise_result.get("max_location",
|
||||
geometry_data.get("center_of_mass", [0, 0, 0])),
|
||||
dimensions=[min_thickness, avg_thickness, max_thickness],
|
||||
parameters={
|
||||
"min_thickness": round(min_thickness, 3),
|
||||
"max_thickness": round(max_thickness, 3),
|
||||
"avg_thickness": round(avg_thickness, 3),
|
||||
"estimation_method": estimation_method,
|
||||
"measured_pairs": len(thickness_map),
|
||||
},
|
||||
recommendations=[
|
||||
f"最大壁厚 {max_thickness:.2f}mm 过厚,可能产生缩痕",
|
||||
"考虑减薄壁厚或增加加强筋",
|
||||
"优化冷却系统设计"
|
||||
]
|
||||
))
|
||||
|
||||
if min_thickness > 0 and max_thickness > 0:
|
||||
uniformity = min_thickness / max_thickness if max_thickness > 0 else 1.0
|
||||
if uniformity < 0.5:
|
||||
features.append(create_mold_feature(
|
||||
feature_type="wall_non_uniform",
|
||||
confidence=0.80,
|
||||
location=geometry_data.get("center_of_mass", [0, 0, 0]),
|
||||
dimensions=[min_thickness, max_thickness, uniformity],
|
||||
parameters={
|
||||
"uniformity_ratio": round(uniformity, 3),
|
||||
"min_thickness": round(min_thickness, 3),
|
||||
"max_thickness": round(max_thickness, 3),
|
||||
"estimation_method": estimation_method,
|
||||
},
|
||||
recommendations=[
|
||||
f"壁厚均匀性比 {uniformity:.2f} 偏低(建议 > 0.5)",
|
||||
"壁厚差异过大可能导致翘曲和缩痕",
|
||||
"建议逐步过渡壁厚,避免突变"
|
||||
]
|
||||
))
|
||||
|
||||
return features
|
||||
|
||||
volume = geometry_data.get("volume", 0)
|
||||
surface_area = geometry_data.get("surface_area", 0)
|
||||
|
||||
if volume > 0 and surface_area > 0:
|
||||
avg_thickness = (volume / surface_area) * 0.6
|
||||
|
||||
if avg_thickness < self.feature_thresholds["thin_wall"]:
|
||||
features.append(create_mold_feature(
|
||||
feature_type="thin_wall",
|
||||
confidence=0.85,
|
||||
location=geometry_data.get("center_of_mass", [0, 0, 0]),
|
||||
dimensions=[avg_thickness, avg_thickness, avg_thickness],
|
||||
parameters={"average_thickness": avg_thickness, "estimation_method": "heuristic"},
|
||||
recommendations=[
|
||||
f"平均壁厚 {avg_thickness:.2f}mm 过薄,建议增加到 {self.feature_thresholds['thin_wall']}mm 以上",
|
||||
"考虑增加加强筋以提高结构强度",
|
||||
"检查注塑填充是否充分"
|
||||
]
|
||||
))
|
||||
elif avg_thickness > self.feature_thresholds["thick_wall"]:
|
||||
features.append(create_mold_feature(
|
||||
feature_type="thick_wall",
|
||||
confidence=0.75,
|
||||
location=geometry_data.get("center_of_mass", [0, 0, 0]),
|
||||
dimensions=[avg_thickness, avg_thickness, avg_thickness],
|
||||
parameters={"average_thickness": avg_thickness, "estimation_method": "heuristic"},
|
||||
recommendations=[
|
||||
f"平均壁厚 {avg_thickness:.2f}mm 过厚,可能产生缩痕",
|
||||
"考虑减薄壁厚或增加加强筋",
|
||||
"优化冷却系统设计"
|
||||
]
|
||||
))
|
||||
elif volume > 0:
|
||||
bbox = geometry_data.get("bounding_box", {})
|
||||
dimensions = bbox.get("dimensions", [100, 100, 100])
|
||||
bbox_volume = dimensions[0] * dimensions[1] * dimensions[2]
|
||||
if bbox_volume > 0:
|
||||
volume_efficiency = volume / bbox_volume
|
||||
avg_thickness = (dimensions[0] + dimensions[1]) / 2 * volume_efficiency
|
||||
if avg_thickness < self.feature_thresholds["thin_wall"]:
|
||||
features.append(create_mold_feature(
|
||||
feature_type="thin_wall",
|
||||
confidence=0.7,
|
||||
location=bbox.get("center", [50, 50, 50]),
|
||||
dimensions=[avg_thickness, avg_thickness, avg_thickness],
|
||||
parameters={"average_thickness": avg_thickness, "estimation_method": "bbox_based"},
|
||||
recommendations=[
|
||||
f"估算平均壁厚 {avg_thickness:.2f}mm 过薄,建议检查表面积数据",
|
||||
"考虑增加加强筋以提高结构强度"
|
||||
]
|
||||
))
|
||||
|
||||
return features
|
||||
|
||||
def _compute_precise_wall_thickness(self, shape: TopoDS_Shape) -> Optional[Dict[str, Any]]:
|
||||
"""使用 BRepExtrema_DistShapeShape 精确计算壁厚"""
|
||||
try:
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE
|
||||
from OCC.Core.TopoDS import TopoDS_Face, topods
|
||||
from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
from OCC.Core.gp import gp_Pnt
|
||||
|
||||
faces = []
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
faces.append(topods.Face(explorer.Current()))
|
||||
explorer.Next()
|
||||
|
||||
if len(faces) < 2:
|
||||
return None
|
||||
|
||||
face_areas = []
|
||||
for face in faces:
|
||||
props = GProp_GProps()
|
||||
brepgprop.SurfaceProperties(face, props)
|
||||
face_areas.append(props.Mass())
|
||||
|
||||
indexed_faces = sorted(enumerate(faces), key=lambda x: face_areas[x[0]], reverse=True)
|
||||
|
||||
max_faces_to_check = min(len(indexed_faces), 30)
|
||||
|
||||
min_thickness = float('inf')
|
||||
max_thickness = 0.0
|
||||
thickness_values = []
|
||||
min_location = [0, 0, 0]
|
||||
max_location = [0, 0, 0]
|
||||
|
||||
for i in range(max_faces_to_check):
|
||||
for j in range(i + 1, max_faces_to_check):
|
||||
idx_i, face_i = indexed_faces[i]
|
||||
idx_j, face_j = indexed_faces[j]
|
||||
|
||||
try:
|
||||
dist_calc = BRepExtrema_DistShapeShape(face_i, face_j)
|
||||
if dist_calc.IsDone():
|
||||
dist = dist_calc.Value()
|
||||
if 0.1 < dist < 50.0:
|
||||
thickness_values.append(dist)
|
||||
if dist < min_thickness:
|
||||
min_thickness = dist
|
||||
try:
|
||||
p1 = dist_calc.PointOnShape1(1)
|
||||
min_location = [float(p1.X()), float(p1.Y()), float(p1.Z())]
|
||||
except Exception:
|
||||
pass
|
||||
if dist > max_thickness:
|
||||
max_thickness = dist
|
||||
try:
|
||||
p2 = dist_calc.PointOnShape2(1)
|
||||
max_location = [float(p2.X()), float(p2.Y()), float(p2.Z())]
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not thickness_values:
|
||||
return None
|
||||
|
||||
avg_thickness = sum(thickness_values) / len(thickness_values)
|
||||
|
||||
return {
|
||||
"min_thickness": min_thickness,
|
||||
"max_thickness": max_thickness,
|
||||
"avg_thickness": avg_thickness,
|
||||
"thickness_map": {f"pair_{i}": v for i, v in enumerate(thickness_values[:50])},
|
||||
"measured_pairs": len(thickness_values),
|
||||
"min_location": min_location,
|
||||
"max_location": max_location,
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
logger.warning("pythonOCC 不可用,无法进行精确壁厚检测")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"精确壁厚检测失败: {e}")
|
||||
return None
|
||||
|
||||
def _detect_rib_features(self, geometry_data: Dict[str, Any],
|
||||
shape: Optional[TopoDS_Shape] = None) -> List[Dict[str, Any]]:
|
||||
"""检测加强筋特征"""
|
||||
features = []
|
||||
topology = geometry_data.get("topology", {})
|
||||
|
||||
face_count = topology.get("faces", 0)
|
||||
edge_count = topology.get("edges", 0)
|
||||
complexity_ratio = edge_count / max(face_count, 1)
|
||||
|
||||
if complexity_ratio > 3.0:
|
||||
confidence = 0.7
|
||||
if shape is not None:
|
||||
confidence = 0.78
|
||||
|
||||
features.append(create_mold_feature(
|
||||
feature_type="rib_structure",
|
||||
confidence=confidence,
|
||||
location=geometry_data.get("center_of_mass", [0, 0, 0]),
|
||||
dimensions=[2.0, 8.0, 2.0],
|
||||
parameters={"complexity_ratio": complexity_ratio},
|
||||
recommendations=[
|
||||
"检测到可能的加强筋结构",
|
||||
"建议加强筋厚度为壁厚的50-80%",
|
||||
"加强筋高度不超过壁厚的3倍",
|
||||
"加强筋根部增加圆角避免应力集中"
|
||||
]
|
||||
))
|
||||
|
||||
return features
|
||||
|
||||
def _detect_boss_features(self, geometry_data: Dict[str, Any],
|
||||
shape: Optional[TopoDS_Shape] = None) -> List[Dict[str, Any]]:
|
||||
"""检测BOSS柱特征"""
|
||||
features = []
|
||||
volume = geometry_data.get("volume", 0)
|
||||
bbox = geometry_data.get("bounding_box", {})
|
||||
|
||||
dimensions = bbox.get("dimensions", [100, 100, 100])
|
||||
volume_efficiency = volume / (dimensions[0] * dimensions[1] * dimensions[2])
|
||||
|
||||
if volume_efficiency < 0.3:
|
||||
confidence = 0.65
|
||||
if shape is not None:
|
||||
confidence = 0.72
|
||||
|
||||
features.append(create_mold_feature(
|
||||
feature_type="boss_feature",
|
||||
confidence=confidence,
|
||||
location=bbox.get("center", [50, 50, 50]),
|
||||
dimensions=[6.0, 12.0, 6.0],
|
||||
parameters={"volume_efficiency": volume_efficiency},
|
||||
recommendations=[
|
||||
"检测到可能的BOSS柱结构",
|
||||
"建议BOSS柱外径为螺钉直径的2-2.5倍",
|
||||
"BOSS柱高度不超过直径的2倍",
|
||||
"增加拔模角度1-2度",
|
||||
"根部增加圆角R0.5-R1.0"
|
||||
]
|
||||
))
|
||||
|
||||
return features
|
||||
|
||||
def _analyze_draft_angles(self, geometry_data: Dict[str, Any],
|
||||
shape: Optional[TopoDS_Shape] = None) -> List[Dict[str, Any]]:
|
||||
"""分析拔模角度"""
|
||||
features = []
|
||||
|
||||
if shape is not None:
|
||||
draft_result = self._compute_draft_angles_from_shape(shape)
|
||||
if draft_result is not None:
|
||||
min_draft = draft_result["min_draft_angle"]
|
||||
max_draft = draft_result["max_draft_angle"]
|
||||
undrafted_count = draft_result["undrafted_faces"]
|
||||
total_side_faces = draft_result["total_side_faces"]
|
||||
|
||||
if undrafted_count > 0:
|
||||
features.append(create_mold_feature(
|
||||
feature_type="draft_angle",
|
||||
confidence=0.90,
|
||||
location=geometry_data.get("center_of_mass", [0, 0, 0]),
|
||||
dimensions=[min_draft, max_draft, undrafted_count],
|
||||
parameters={
|
||||
"min_draft_angle": round(min_draft, 2),
|
||||
"max_draft_angle": round(max_draft, 2),
|
||||
"undrafted_faces": undrafted_count,
|
||||
"total_side_faces": total_side_faces,
|
||||
"estimation_method": "precise",
|
||||
},
|
||||
recommendations=[
|
||||
f"检测到 {undrafted_count} 个面需要拔模(当前最小拔模角 {min_draft:.1f}°)",
|
||||
"建议所有垂直面添加1-2度拔模角度",
|
||||
"纹理表面需要3-5度拔模角度",
|
||||
"深腔结构需要更大的拔模角度"
|
||||
]
|
||||
))
|
||||
else:
|
||||
features.append(create_mold_feature(
|
||||
feature_type="draft_angle",
|
||||
confidence=0.90,
|
||||
location=geometry_data.get("center_of_mass", [0, 0, 0]),
|
||||
dimensions=[min_draft, max_draft, 0],
|
||||
parameters={
|
||||
"min_draft_angle": round(min_draft, 2),
|
||||
"max_draft_angle": round(max_draft, 2),
|
||||
"undrafted_faces": 0,
|
||||
"total_side_faces": total_side_faces,
|
||||
"estimation_method": "precise",
|
||||
},
|
||||
recommendations=[
|
||||
f"所有侧壁面已有拔模角(最小 {min_draft:.1f}°)",
|
||||
"拔模角度满足要求"
|
||||
]
|
||||
))
|
||||
|
||||
return features
|
||||
|
||||
features.append(create_mold_feature(
|
||||
feature_type="draft_angle",
|
||||
confidence=0.8,
|
||||
location=geometry_data.get("center_of_mass", [0, 0, 0]),
|
||||
dimensions=[1.0, 2.0, 1.0],
|
||||
parameters={"recommended_angle": 2.0, "estimation_method": "heuristic"},
|
||||
recommendations=[
|
||||
"建议所有垂直面添加1-2度拔模角度",
|
||||
"纹理表面需要3-5度拔模角度",
|
||||
"深腔结构需要更大的拔模角度"
|
||||
]
|
||||
))
|
||||
|
||||
return features
|
||||
|
||||
def _compute_draft_angles_from_shape(self, shape: TopoDS_Shape) -> Optional[Dict[str, Any]]:
|
||||
"""基于面法向量分析计算各面的拔模角度"""
|
||||
try:
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE
|
||||
from OCC.Core.TopoDS import TopoDS_Face, topods
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCC.Core.BRepLProp import BRepLProp_SLProps
|
||||
from OCC.Core.gp import gp_Dir
|
||||
|
||||
draft_direction = gp_Dir(0, 0, 1)
|
||||
|
||||
draft_angles = []
|
||||
side_face_count = 0
|
||||
undrafted_count = 0
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
face = topods.Face(explorer.Current())
|
||||
surface = BRepAdaptor_Surface(face)
|
||||
|
||||
try:
|
||||
u = (surface.FirstUParameter() + surface.LastUParameter()) / 2
|
||||
v = (surface.FirstVParameter() + surface.LastVParameter()) / 2
|
||||
|
||||
normal = None
|
||||
if surface.GetType() == 0:
|
||||
normal = surface.Plane().Position().Direction()
|
||||
else:
|
||||
props = BRepLProp_SLProps(surface, 1, 0.001)
|
||||
props.SetParameters(u, v)
|
||||
if props.IsNormalDefined():
|
||||
normal = props.Normal()
|
||||
|
||||
if normal is not None:
|
||||
dot = abs(normal.Dot(draft_direction))
|
||||
angle_from_vertical = math.degrees(math.acos(min(dot, 1.0)))
|
||||
|
||||
if 5.0 < angle_from_vertical < 85.0:
|
||||
side_face_count += 1
|
||||
draft_angle = 90.0 - angle_from_vertical
|
||||
draft_angles.append(draft_angle)
|
||||
if draft_angle < 0.5:
|
||||
undrafted_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
explorer.Next()
|
||||
|
||||
if not draft_angles:
|
||||
return None
|
||||
|
||||
return {
|
||||
"min_draft_angle": min(draft_angles),
|
||||
"max_draft_angle": max(draft_angles),
|
||||
"avg_draft_angle": sum(draft_angles) / len(draft_angles),
|
||||
"undrafted_faces": undrafted_count,
|
||||
"total_side_faces": side_face_count,
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"拔模角度计算失败: {e}")
|
||||
return None
|
||||
|
||||
def _detect_curvature_features(self, shape: TopoDS_Shape) -> List[Dict[str, Any]]:
|
||||
"""检测高曲率区域(可能导致应力集中)"""
|
||||
features = []
|
||||
try:
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE
|
||||
from OCC.Core.TopoDS import TopoDS_Face, topods
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCC.Core.BRepLProp import BRepLProp_SLProps
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
|
||||
high_curvature_count = 0
|
||||
max_curvature_overall = 0.0
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
face = topods.Face(explorer.Current())
|
||||
surface = BRepAdaptor_Surface(face)
|
||||
|
||||
if surface.GetType() == 0:
|
||||
explorer.Next()
|
||||
continue
|
||||
|
||||
try:
|
||||
props = GProp_GProps()
|
||||
brepgprop.SurfaceProperties(face, props)
|
||||
face_area = props.Mass()
|
||||
|
||||
u_range = (surface.FirstUParameter(), surface.LastUParameter())
|
||||
v_range = (surface.FirstVParameter(), surface.LastVParameter())
|
||||
|
||||
max_curvature = 0.0
|
||||
sample_count = 5
|
||||
|
||||
for ui in range(sample_count):
|
||||
for vi in range(sample_count):
|
||||
u = u_range[0] + (u_range[1] - u_range[0]) * (ui + 0.5) / sample_count
|
||||
v = v_range[0] + (v_range[1] - v_range[0]) * (vi + 0.5) / sample_count
|
||||
|
||||
try:
|
||||
lprops = BRepLProp_SLProps(surface, 2, 0.001)
|
||||
lprops.SetParameters(u, v)
|
||||
if lprops.IsCurvatureDefined():
|
||||
k1 = abs(lprops.MinCurvature())
|
||||
k2 = abs(lprops.MaxCurvature())
|
||||
max_curvature = max(max_curvature, k1, k2)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if max_curvature > max_curvature_overall:
|
||||
max_curvature_overall = max_curvature
|
||||
|
||||
if max_curvature > 0.5:
|
||||
high_curvature_count += 1
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
explorer.Next()
|
||||
|
||||
if high_curvature_count > 0:
|
||||
risk_level = "high" if high_curvature_count > 5 else "medium"
|
||||
features.append(create_mold_feature(
|
||||
feature_type="high_curvature",
|
||||
confidence=0.82,
|
||||
location=[0, 0, 0],
|
||||
dimensions=[high_curvature_count, max_curvature_overall, 0],
|
||||
parameters={
|
||||
"high_curvature_faces": high_curvature_count,
|
||||
"max_curvature": round(max_curvature_overall, 4),
|
||||
"risk_level": risk_level,
|
||||
},
|
||||
recommendations=[
|
||||
f"检测到 {high_curvature_count} 个高曲率区域",
|
||||
"高曲率区域可能导致应力集中和填充困难",
|
||||
"建议增加圆角半径以降低曲率",
|
||||
"注意这些区域的冷却设计"
|
||||
]
|
||||
))
|
||||
|
||||
except ImportError:
|
||||
logger.debug("pythonOCC 不可用,跳过曲率检测")
|
||||
except Exception as e:
|
||||
logger.warning(f"曲率检测失败: {e}")
|
||||
|
||||
return features
|
||||
|
||||
def _detect_fillet_features(self, shape: TopoDS_Shape) -> List[Dict[str, Any]]:
|
||||
"""检测圆角/倒角特征"""
|
||||
features = []
|
||||
try:
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_EDGE
|
||||
from OCC.Core.TopoDS import TopoDS_Edge, topods
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
|
||||
|
||||
fillet_count = 0
|
||||
small_fillet_count = 0
|
||||
min_fillet_radius = float('inf')
|
||||
radii = []
|
||||
|
||||
edge_explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||
while edge_explorer.More():
|
||||
edge = topods.Edge(edge_explorer.Current())
|
||||
try:
|
||||
curve = BRepAdaptor_Curve(edge)
|
||||
curve_type = curve.GetType()
|
||||
|
||||
if curve_type == 2: # GeomAbs_Circle
|
||||
circle = curve.Circle()
|
||||
radius = circle.Radius()
|
||||
if 0.05 < radius < 50:
|
||||
fillet_count += 1
|
||||
radii.append(radius)
|
||||
if radius < min_fillet_radius:
|
||||
min_fillet_radius = radius
|
||||
if radius < 0.5:
|
||||
small_fillet_count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
edge_explorer.Next()
|
||||
|
||||
if fillet_count > 0:
|
||||
avg_radius = sum(radii) / len(radii)
|
||||
features.append(create_mold_feature(
|
||||
feature_type="fillet",
|
||||
confidence=0.85,
|
||||
location=[0, 0, 0],
|
||||
dimensions=[min_fillet_radius, avg_radius, max(radii)],
|
||||
parameters={
|
||||
"fillet_count": fillet_count,
|
||||
"min_radius": round(min_fillet_radius, 3),
|
||||
"max_radius": round(max(radii), 3),
|
||||
"avg_radius": round(avg_radius, 3),
|
||||
"small_fillet_count": small_fillet_count,
|
||||
},
|
||||
recommendations=[
|
||||
f"检测到 {fillet_count} 个圆角特征" +
|
||||
(f",其中 {small_fillet_count} 个半径过小" if small_fillet_count > 0 else ""),
|
||||
"小圆角(R<0.5mm)可能导致应力集中" if small_fillet_count > 0 else "",
|
||||
"建议圆角半径不小于 0.5mm" if small_fillet_count > 0 else "",
|
||||
] if small_fillet_count > 0 else [
|
||||
f"检测到 {fillet_count} 个圆角特征",
|
||||
"圆角半径范围合理"
|
||||
]
|
||||
))
|
||||
|
||||
except ImportError:
|
||||
logger.debug("pythonOCC 不可用,跳过圆角检测")
|
||||
except Exception as e:
|
||||
logger.warning(f"圆角检测失败: {e}")
|
||||
|
||||
return features
|
||||
|
||||
def _generate_recommendations(self, geometry_data: Dict[str, Any],
|
||||
features: List[Dict[str, Any]],
|
||||
material: str) -> List[Dict[str, Any]]:
|
||||
"""生成设计建议"""
|
||||
recommendations = []
|
||||
|
||||
wall_rec = self._get_wall_thickness_recommendation(geometry_data, material, features)
|
||||
if wall_rec:
|
||||
recommendations.append(wall_rec)
|
||||
|
||||
recommendations.append(create_design_recommendation(
|
||||
rec_type="draft_angle",
|
||||
priority="high",
|
||||
description="添加拔模角度",
|
||||
parameters={"min_angle": 1.0, "preferred_angle": 2.0},
|
||||
reason="确保顺利脱模"
|
||||
))
|
||||
|
||||
for feature in features:
|
||||
if feature["feature_type"] == "thin_wall":
|
||||
params = feature.get("parameters", {})
|
||||
current = params.get("min_thickness", params.get("average_thickness", 0))
|
||||
rec = create_design_recommendation(
|
||||
rec_type="wall_thickness",
|
||||
priority="high",
|
||||
description="增加壁厚",
|
||||
parameters={
|
||||
"current": current,
|
||||
"recommended": self.feature_thresholds["thin_wall"]
|
||||
},
|
||||
reason="壁厚不足影响结构强度"
|
||||
)
|
||||
recommendations.append(rec)
|
||||
elif feature["feature_type"] == "high_curvature":
|
||||
recommendations.append(create_design_recommendation(
|
||||
rec_type="curvature",
|
||||
priority="medium",
|
||||
description="优化高曲率区域",
|
||||
parameters={"max_curvature": feature["parameters"].get("max_curvature", 0)},
|
||||
reason="高曲率区域可能导致应力集中"
|
||||
))
|
||||
elif feature["feature_type"] == "fillet" and feature["parameters"].get("small_fillet_count", 0) > 0:
|
||||
recommendations.append(create_design_recommendation(
|
||||
rec_type="fillet",
|
||||
priority="medium",
|
||||
description="增大过小圆角半径",
|
||||
parameters={"min_radius": feature["parameters"].get("min_radius", 0)},
|
||||
reason="小圆角导致应力集中和加工困难"
|
||||
))
|
||||
|
||||
return recommendations
|
||||
|
||||
def _get_wall_thickness_recommendation(self, geometry_data: Dict[str, Any],
|
||||
material: str,
|
||||
features: List[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""获取壁厚建议"""
|
||||
avg_thickness = None
|
||||
|
||||
if features:
|
||||
for f in features:
|
||||
if f["feature_type"] in ("thin_wall", "thick_wall"):
|
||||
params = f.get("parameters", {})
|
||||
avg_thickness = params.get("avg_thickness", params.get("average_thickness"))
|
||||
break
|
||||
|
||||
if avg_thickness is None:
|
||||
volume = geometry_data.get("volume", 0)
|
||||
surface_area = geometry_data.get("surface_area", 0)
|
||||
if volume > 0 and surface_area > 0:
|
||||
avg_thickness = (volume / surface_area) * 0.6
|
||||
|
||||
if avg_thickness is not None:
|
||||
material_props = self.product_materials.get(material, self.product_materials["ABS"])
|
||||
min_wall = material_props["min_wall"]
|
||||
|
||||
if avg_thickness < min_wall:
|
||||
return create_design_recommendation(
|
||||
rec_type="wall_thickness",
|
||||
priority="high",
|
||||
description=f"增加壁厚至{min_wall}mm以上",
|
||||
parameters={"current": avg_thickness, "recommended": min_wall},
|
||||
reason=f"{material}材料最小壁厚要求"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def _calculate_quality_metrics(self, geometry_data: Dict[str, Any],
|
||||
features: List[Dict[str, Any]]) -> Dict[str, float]:
|
||||
"""计算质量指标"""
|
||||
metrics = {}
|
||||
|
||||
bbox = geometry_data.get("bounding_box", {})
|
||||
dimensions = bbox.get("dimensions", [100, 100, 100])
|
||||
volume = geometry_data.get("volume", 0)
|
||||
bbox_volume = dimensions[0] * dimensions[1] * dimensions[2]
|
||||
|
||||
metrics["volume_utilization"] = volume / bbox_volume if bbox_volume > 0 else 0
|
||||
|
||||
topology = geometry_data.get("topology", {})
|
||||
face_count = topology.get("faces", 0)
|
||||
metrics["topology_complexity"] = face_count / 100.0
|
||||
|
||||
wall_uniformity = 0.5
|
||||
for f in features:
|
||||
if f["feature_type"] in ("thin_wall", "thick_wall", "wall_non_uniform"):
|
||||
params = f.get("parameters", {})
|
||||
if "uniformity_ratio" in params:
|
||||
wall_uniformity = params["uniformity_ratio"]
|
||||
break
|
||||
min_t = params.get("min_thickness", params.get("average_thickness", 0))
|
||||
max_t = params.get("max_thickness", params.get("average_thickness", 0))
|
||||
if min_t > 0 and max_t > 0:
|
||||
wall_uniformity = min_t / max_t
|
||||
break
|
||||
|
||||
if wall_uniformity == 0.5:
|
||||
surface_area = geometry_data.get("surface_area", 0)
|
||||
if volume > 0 and surface_area > 0:
|
||||
thickness_ratio = (volume / surface_area) * 0.6
|
||||
ideal_thickness = 3.0
|
||||
wall_uniformity = 1.0 - abs(thickness_ratio - ideal_thickness) / ideal_thickness
|
||||
|
||||
metrics["wall_uniformity"] = max(0, min(1, wall_uniformity))
|
||||
|
||||
draft_score = 1.0
|
||||
for f in features:
|
||||
if f["feature_type"] == "draft_angle":
|
||||
params = f.get("parameters", {})
|
||||
undrafted = params.get("undrafted_faces", None)
|
||||
total = params.get("total_side_faces", 1)
|
||||
if undrafted is not None and total > 0:
|
||||
draft_score = 1.0 - (undrafted / total)
|
||||
break
|
||||
metrics["draft_score"] = round(draft_score, 3)
|
||||
|
||||
return metrics
|
||||
|
||||
def _generate_analysis_summary(self, geometry_data: Dict[str, Any],
|
||||
features: List[Dict[str, Any]],
|
||||
recommendations: List[Dict[str, Any]]) -> str:
|
||||
"""生成分析摘要"""
|
||||
volume = geometry_data.get("volume", 0)
|
||||
high_priority_recs = len([r for r in recommendations if r["priority"] == "high"])
|
||||
|
||||
summary_parts = []
|
||||
|
||||
if volume > 0:
|
||||
summary_parts.append(f"模型体积: {volume / 1000:.1f} cm³")
|
||||
|
||||
if features:
|
||||
feature_types = set(f["feature_type"] for f in features)
|
||||
summary_parts.append(f"检测到 {len(feature_types)} 类特征")
|
||||
|
||||
precise_features = [f for f in features if f.get("parameters", {}).get("estimation_method") == "precise"]
|
||||
if precise_features:
|
||||
summary_parts.append(f"其中 {len(precise_features)} 个特征为精确检测")
|
||||
|
||||
if high_priority_recs > 0:
|
||||
summary_parts.append(f"有 {high_priority_recs} 个高优先级建议")
|
||||
|
||||
return " | ".join(summary_parts) if summary_parts else "分析完成"
|
||||
@@ -0,0 +1,241 @@
|
||||
# src/core/mesh_generator.py
|
||||
import logging
|
||||
import numpy as np
|
||||
from typing import Dict, List, Optional, Any
|
||||
import trimesh
|
||||
from trimesh import sample as trimesh_sample
|
||||
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE
|
||||
from OCC.Core.BRep import BRep_Tool
|
||||
from OCC.Core.TopoDS import TopoDS_Shape
|
||||
from OCC.Core.TopLoc import TopLoc_Location
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MeshGenerator:
|
||||
"""网格生成器 - 从PythonOCC形状生成点云,支持多级LOD"""
|
||||
|
||||
def __init__(self, quality: str = "medium"):
|
||||
self.quality_settings = {
|
||||
"low": 1.0,
|
||||
"medium": 0.3,
|
||||
"high": 0.1
|
||||
}
|
||||
self.quality = self.quality_settings.get(quality, 0.3)
|
||||
|
||||
def generate_mesh_from_shape(self, shape: TopoDS_Shape, num_points: int = 20000) -> Dict:
|
||||
"""从PythonOCC形状生成点云数据"""
|
||||
try:
|
||||
mesh = BRepMesh_IncrementalMesh(shape, self.quality, False, 0.5, True)
|
||||
mesh.Perform()
|
||||
logger.info(f"OCC网格生成完成, 网格状态: {mesh.IsDone()}")
|
||||
|
||||
all_vertices = []
|
||||
all_faces = []
|
||||
vertex_offset = 0
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
face_count = 0
|
||||
|
||||
while explorer.More():
|
||||
face = explorer.Current()
|
||||
face_count += 1
|
||||
|
||||
location = TopLoc_Location()
|
||||
face_triangulation = BRep_Tool.Triangulation(face, location)
|
||||
|
||||
if face_triangulation is None:
|
||||
logger.warning(f"面 {face_count} 没有三角剖分数据")
|
||||
explorer.Next()
|
||||
continue
|
||||
|
||||
trsf = location.Transformation()
|
||||
|
||||
nb_nodes = face_triangulation.NbNodes()
|
||||
nb_triangles = face_triangulation.NbTriangles()
|
||||
|
||||
logger.debug(f"面 {face_count}: {nb_nodes} 个顶点, {nb_triangles} 个三角形")
|
||||
|
||||
face_vertices = []
|
||||
for i in range(1, nb_nodes + 1):
|
||||
pnt = face_triangulation.Node(i)
|
||||
transformed = pnt.Transformed(trsf)
|
||||
face_vertices.append([
|
||||
float(transformed.X()),
|
||||
float(transformed.Y()),
|
||||
float(transformed.Z()),
|
||||
])
|
||||
|
||||
face_indices = []
|
||||
for i in range(1, nb_triangles + 1):
|
||||
tri = face_triangulation.Triangle(i)
|
||||
idx1 = tri.Value(1)
|
||||
idx2 = tri.Value(2)
|
||||
idx3 = tri.Value(3)
|
||||
face_indices.append([
|
||||
vertex_offset + idx1 - 1,
|
||||
vertex_offset + idx2 - 1,
|
||||
vertex_offset + idx3 - 1
|
||||
])
|
||||
|
||||
all_vertices.extend(face_vertices)
|
||||
all_faces.extend(face_indices)
|
||||
vertex_offset += len(face_vertices)
|
||||
|
||||
explorer.Next()
|
||||
|
||||
if len(all_vertices) == 0:
|
||||
logger.warning("未提取到任何顶点,使用示例数据")
|
||||
return self._create_sample_pointcloud()
|
||||
|
||||
vertices = np.array(all_vertices, dtype=np.float32)
|
||||
faces = np.array(all_faces, dtype=np.int32)
|
||||
|
||||
logger.info(f"总共提取了 {len(vertices)} 个顶点, {len(faces)} 个三角形面, {face_count} 个面")
|
||||
|
||||
tri_mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=True)
|
||||
|
||||
actual_num_points = min(num_points, len(faces) * 2)
|
||||
logger.info(f"采样点数: {actual_num_points}")
|
||||
|
||||
points, face_idx = trimesh.sample.sample_surface(tri_mesh, actual_num_points)
|
||||
|
||||
normals = tri_mesh.face_normals[face_idx]
|
||||
|
||||
logger.info(f"生成了 {len(points)} 个点云点")
|
||||
|
||||
return {
|
||||
"vertices": vertices.tolist(),
|
||||
"faces": faces.tolist(),
|
||||
"points": points.tolist(),
|
||||
"normals": normals.tolist(),
|
||||
"point_count": int(len(points)),
|
||||
"vertex_count": int(len(vertices)),
|
||||
"face_count": int(len(faces))
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"网格生成失败: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return self._create_sample_pointcloud()
|
||||
|
||||
def generate_multi_lod_mesh(self, shape: TopoDS_Shape) -> Dict:
|
||||
"""生成多级LOD网格 - 一次OCC剖分,trimesh简化,避免重复计算
|
||||
|
||||
返回结构:
|
||||
{
|
||||
"lods": {
|
||||
"0": { "vertices": [...], "faces": [...], "vertex_count": N, "face_count": N },
|
||||
"1": { ... 50%简化 ... },
|
||||
"2": { ... 80%简化 ... }
|
||||
},
|
||||
"points": [...], "normals": [...], "point_count": N,
|
||||
"vertex_count": N, "face_count": N
|
||||
}
|
||||
"""
|
||||
try:
|
||||
full_mesh_result = self.generate_mesh_from_shape(shape, num_points=20000)
|
||||
|
||||
vertices = np.array(full_mesh_result["vertices"], dtype=np.float32)
|
||||
faces = np.array(full_mesh_result["faces"], dtype=np.int32)
|
||||
|
||||
if len(vertices) == 0 or len(faces) == 0:
|
||||
sample = self._create_sample_pointcloud()
|
||||
return self._wrap_sample_as_lod(sample)
|
||||
|
||||
tri_mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=True)
|
||||
full_face_count = len(tri_mesh.faces)
|
||||
logger.info(f"全精度网格: {len(tri_mesh.vertices)} 顶点, {full_face_count} 面")
|
||||
|
||||
lods = {
|
||||
"0": self._mesh_to_lod_entry(tri_mesh, "LOD0-全精度")
|
||||
}
|
||||
|
||||
lod_ratios = {"1": 0.50, "2": 0.20}
|
||||
for lod_level, ratio in lod_ratios.items():
|
||||
if full_face_count < 300:
|
||||
lods[lod_level] = lods["0"]
|
||||
continue
|
||||
|
||||
target_faces = max(int(full_face_count * ratio), 200)
|
||||
try:
|
||||
simplified = tri_mesh.simplify_quadric_decimation(target_faces)
|
||||
if simplified is None or len(simplified.faces) < 3:
|
||||
simplified = self._fast_decimate(tri_mesh, target_faces)
|
||||
lods[lod_level] = self._mesh_to_lod_entry(simplified, f"LOD{lod_level}-简化{int((1-ratio)*100)}%")
|
||||
logger.info(f"LOD{lod_level}: {len(simplified.vertices)} 顶点, {len(simplified.faces)} 面 (目标{target_faces})")
|
||||
except Exception as dec_err:
|
||||
logger.warning(f"LOD{lod_level} 简化失败,回退到全精度: {dec_err}")
|
||||
lods[lod_level] = lods["0"]
|
||||
|
||||
result = {
|
||||
"lods": lods,
|
||||
"points": full_mesh_result["points"],
|
||||
"normals": full_mesh_result["normals"],
|
||||
"point_count": full_mesh_result["point_count"],
|
||||
"vertex_count": full_mesh_result["vertex_count"],
|
||||
"face_count": full_mesh_result["face_count"],
|
||||
}
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"多级LOD网格生成失败: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
sample = self._create_sample_pointcloud()
|
||||
return self._wrap_sample_as_lod(sample)
|
||||
|
||||
def _mesh_to_lod_entry(self, mesh: trimesh.Trimesh, label: str) -> Dict:
|
||||
return {
|
||||
"vertices": mesh.vertices.tolist(),
|
||||
"faces": mesh.faces.tolist(),
|
||||
"vertex_count": int(len(mesh.vertices)),
|
||||
"face_count": int(len(mesh.faces)),
|
||||
}
|
||||
|
||||
def _fast_decimate(self, mesh: trimesh.Trimesh, target_faces: int) -> trimesh.Trimesh:
|
||||
"""快速回退降采样:按面索引均匀采样"""
|
||||
if target_faces >= len(mesh.faces):
|
||||
return mesh
|
||||
step = max(len(mesh.faces) // target_faces, 1)
|
||||
indices = np.arange(0, len(mesh.faces), step)[:target_faces]
|
||||
return mesh.submesh([np.array(indices)], only_watertight=False, append=True)
|
||||
|
||||
def _wrap_sample_as_lod(self, sample: Dict) -> Dict:
|
||||
lods = {
|
||||
"0": {
|
||||
"vertices": sample["vertices"],
|
||||
"faces": sample["faces"],
|
||||
"vertex_count": sample["vertex_count"],
|
||||
"face_count": sample["face_count"],
|
||||
}
|
||||
}
|
||||
lods["1"] = lods["0"]
|
||||
lods["2"] = lods["0"]
|
||||
return {
|
||||
"lods": lods,
|
||||
"points": sample["points"],
|
||||
"normals": sample["normals"],
|
||||
"point_count": sample["point_count"],
|
||||
"vertex_count": sample["vertex_count"],
|
||||
"face_count": sample["face_count"],
|
||||
}
|
||||
|
||||
def _create_sample_pointcloud(self) -> Dict:
|
||||
"""创建示例点云(备用)"""
|
||||
mesh = trimesh.creation.box([100, 80, 50])
|
||||
points, _ = trimesh.sample.sample_surface(mesh, 5000)
|
||||
normals = mesh.face_normals[:len(points)]
|
||||
|
||||
return {
|
||||
"vertices": mesh.vertices.tolist(),
|
||||
"faces": mesh.faces.tolist(),
|
||||
"points": points.tolist(),
|
||||
"normals": normals.tolist(),
|
||||
"point_count": len(points),
|
||||
"vertex_count": len(mesh.vertices),
|
||||
"face_count": len(mesh.faces)
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
"""
|
||||
模具刀路设计与G代码生成模块
|
||||
|
||||
架构:
|
||||
1. ToolLibrary - 刀具库与切削参数管理
|
||||
2. CuttingParamsCalculator - 切削参数自动计算
|
||||
3. RoughingToolpathGenerator - 粗加工刀路生成
|
||||
4. FinishingToolpathGenerator - 精加工刀路生成
|
||||
5. GCodePostProcessor - G代码后处理器
|
||||
6. MoldCAMDesigner - 模具CAM综合设计器
|
||||
|
||||
加工策略:
|
||||
- 粗加工:Z层等高粗加工(自适应清根)
|
||||
- 半精加工:等高线铣削
|
||||
- 精加工:平行铣削/螺旋铣削/等高线精加工
|
||||
- 清角:笔式清角
|
||||
- 钻孔:冷却水路/顶针孔/螺丝孔
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import math
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ToolLibrary:
|
||||
"""刀具库"""
|
||||
|
||||
TOOLS = {
|
||||
"endmill_20mm": {
|
||||
"type": "endmill", "diameter": 20.0, "flute_length": 60.0,
|
||||
"cutting_edges": 4, "material": "carbide",
|
||||
"corner_radius": 0.0,
|
||||
"speeds_feeds": {
|
||||
"cutting_speed": 100, "feed_per_tooth": 0.15,
|
||||
"axial_depth": 10.0, "radial_depth": 15.0
|
||||
}
|
||||
},
|
||||
"endmill_16mm": {
|
||||
"type": "endmill", "diameter": 16.0, "flute_length": 50.0,
|
||||
"cutting_edges": 4, "material": "carbide",
|
||||
"corner_radius": 0.0,
|
||||
"speeds_feeds": {
|
||||
"cutting_speed": 120, "feed_per_tooth": 0.12,
|
||||
"axial_depth": 8.0, "radial_depth": 12.0
|
||||
}
|
||||
},
|
||||
"endmill_10mm": {
|
||||
"type": "endmill", "diameter": 10.0, "flute_length": 35.0,
|
||||
"cutting_edges": 4, "material": "carbide",
|
||||
"corner_radius": 0.0,
|
||||
"speeds_feeds": {
|
||||
"cutting_speed": 130, "feed_per_tooth": 0.10,
|
||||
"axial_depth": 5.0, "radial_depth": 8.0
|
||||
}
|
||||
},
|
||||
"endmill_6mm": {
|
||||
"type": "endmill", "diameter": 6.0, "flute_length": 22.0,
|
||||
"cutting_edges": 3, "material": "carbide",
|
||||
"corner_radius": 0.0,
|
||||
"speeds_feeds": {
|
||||
"cutting_speed": 140, "feed_per_tooth": 0.06,
|
||||
"axial_depth": 3.0, "radial_depth": 4.0
|
||||
}
|
||||
},
|
||||
"ballnose_10mm": {
|
||||
"type": "ballnose", "diameter": 10.0, "flute_length": 30.0,
|
||||
"cutting_edges": 2, "material": "carbide",
|
||||
"corner_radius": 5.0,
|
||||
"speeds_feeds": {
|
||||
"cutting_speed": 150, "feed_per_tooth": 0.08,
|
||||
"axial_depth": 0.5, "radial_depth": 1.0
|
||||
}
|
||||
},
|
||||
"ballnose_6mm": {
|
||||
"type": "ballnose", "diameter": 6.0, "flute_length": 22.0,
|
||||
"cutting_edges": 2, "material": "carbide",
|
||||
"corner_radius": 3.0,
|
||||
"speeds_feeds": {
|
||||
"cutting_speed": 160, "feed_per_tooth": 0.06,
|
||||
"axial_depth": 0.3, "radial_depth": 0.5
|
||||
}
|
||||
},
|
||||
"ballnose_3mm": {
|
||||
"type": "ballnose", "diameter": 3.0, "flute_length": 12.0,
|
||||
"cutting_edges": 2, "material": "carbide",
|
||||
"corner_radius": 1.5,
|
||||
"speeds_feeds": {
|
||||
"cutting_speed": 180, "feed_per_tooth": 0.03,
|
||||
"axial_depth": 0.15, "radial_depth": 0.3
|
||||
}
|
||||
},
|
||||
"ballnose_1mm": {
|
||||
"type": "ballnose", "diameter": 1.0, "flute_length": 5.0,
|
||||
"cutting_edges": 2, "material": "carbide",
|
||||
"corner_radius": 0.5,
|
||||
"speeds_feeds": {
|
||||
"cutting_speed": 200, "feed_per_tooth": 0.01,
|
||||
"axial_depth": 0.05, "radial_depth": 0.1
|
||||
}
|
||||
},
|
||||
"drill_8mm": {
|
||||
"type": "drill", "diameter": 8.0, "flute_length": 50.0,
|
||||
"cutting_edges": 2, "material": "carbide",
|
||||
"corner_radius": 0.0,
|
||||
"speeds_feeds": {
|
||||
"cutting_speed": 80, "feed_per_tooth": 0.10,
|
||||
"axial_depth": 50.0, "radial_depth": 0.0
|
||||
}
|
||||
},
|
||||
"drill_5mm": {
|
||||
"type": "drill", "diameter": 5.0, "flute_length": 35.0,
|
||||
"cutting_edges": 2, "material": "carbide",
|
||||
"corner_radius": 0.0,
|
||||
"speeds_feeds": {
|
||||
"cutting_speed": 90, "feed_per_tooth": 0.08,
|
||||
"axial_depth": 35.0, "radial_depth": 0.0
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
MOLD_STEEL = {
|
||||
"P20": {"hardness_hrc": 30, "cutting_speed_factor": 1.0, "feed_factor": 1.0},
|
||||
"718H": {"hardness_hrc": 35, "cutting_speed_factor": 0.85, "feed_factor": 0.9},
|
||||
"NAK80": {"hardness_hrc": 38, "cutting_speed_factor": 0.75, "feed_factor": 0.85},
|
||||
"S136": {"hardness_hrc": 50, "cutting_speed_factor": 0.5, "feed_factor": 0.7},
|
||||
"H13": {"hardness_hrc": 48, "cutting_speed_factor": 0.55, "feed_factor": 0.75},
|
||||
"Al7075": {"hardness_hrc": 15, "cutting_speed_factor": 2.0, "feed_factor": 1.5},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_tool(cls, tool_id: str) -> Optional[Dict]:
|
||||
return cls.TOOLS.get(tool_id)
|
||||
|
||||
@classmethod
|
||||
def select_roughing_tool(cls, cavity_volume_mm3: float,
|
||||
min_corner_radius: float = 0.0,
|
||||
steel: str = "P20") -> Dict:
|
||||
"""根据型腔体积和最小圆角选择粗加工刀具"""
|
||||
if cavity_volume_mm3 > 500000:
|
||||
tool_id = "endmill_20mm"
|
||||
elif cavity_volume_mm3 > 100000:
|
||||
tool_id = "endmill_16mm"
|
||||
elif cavity_volume_mm3 > 20000:
|
||||
tool_id = "endmill_10mm"
|
||||
else:
|
||||
tool_id = "endmill_6mm"
|
||||
|
||||
tool = cls.TOOLS[tool_id].copy()
|
||||
steel_props = cls.MOLD_STEEL.get(steel, cls.MOLD_STEEL["P20"])
|
||||
|
||||
tool["speeds_feeds"] = cls._adjust_for_steel(tool["speeds_feeds"], steel_props)
|
||||
tool["tool_id"] = tool_id
|
||||
return tool
|
||||
|
||||
@classmethod
|
||||
def select_finishing_tool(cls, surface_quality: str = "standard",
|
||||
min_corner_radius: float = 0.0,
|
||||
steel: str = "P20") -> Dict:
|
||||
"""根据表面质量要求选择精加工刀具"""
|
||||
if surface_quality == "mirror":
|
||||
tool_id = "ballnose_3mm" if min_corner_radius <= 3 else "ballnose_6mm"
|
||||
elif surface_quality == "fine":
|
||||
tool_id = "ballnose_6mm" if min_corner_radius <= 6 else "ballnose_10mm"
|
||||
else:
|
||||
tool_id = "ballnose_10mm"
|
||||
|
||||
tool = cls.TOOLS[tool_id].copy()
|
||||
steel_props = cls.MOLD_STEEL.get(steel, cls.MOLD_STEEL["P20"])
|
||||
|
||||
tool["speeds_feeds"] = cls._adjust_for_steel(tool["speeds_feeds"], steel_props)
|
||||
tool["tool_id"] = tool_id
|
||||
return tool
|
||||
|
||||
@classmethod
|
||||
def _adjust_for_steel(cls, speeds_feeds: Dict, steel_props: Dict) -> Dict:
|
||||
"""根据模具钢调整切削参数"""
|
||||
adjusted = speeds_feeds.copy()
|
||||
adjusted["cutting_speed"] *= steel_props["cutting_speed_factor"]
|
||||
adjusted["feed_per_tooth"] *= steel_props["feed_factor"]
|
||||
adjusted["axial_depth"] *= steel_props["feed_factor"]
|
||||
adjusted["radial_depth"] *= steel_props["feed_factor"]
|
||||
return adjusted
|
||||
|
||||
|
||||
class CuttingParamsCalculator:
|
||||
"""切削参数计算器"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_spindle_speed(cutting_speed_m_min: float, tool_diameter: float) -> int:
|
||||
"""N = (1000 × Vc) / (π × D)"""
|
||||
if tool_diameter <= 0:
|
||||
return 1000
|
||||
rpm = (1000 * cutting_speed_m_min) / (math.pi * tool_diameter)
|
||||
return int(min(max(rpm, 500), 24000))
|
||||
|
||||
@staticmethod
|
||||
def calculate_feed_rate(spindle_speed: int, feed_per_tooth: float,
|
||||
cutting_edges: int) -> float:
|
||||
"""F = N × fz × z"""
|
||||
return spindle_speed * feed_per_tooth * cutting_edges
|
||||
|
||||
@staticmethod
|
||||
def calculate_mrr(feed_rate: float, axial_depth: float,
|
||||
radial_depth: float) -> float:
|
||||
"""材料去除率 Q = ae × ap × F / 1000 (cm³/min)"""
|
||||
return axial_depth * radial_depth * feed_rate / 1000
|
||||
|
||||
@staticmethod
|
||||
def estimate_machining_time(toolpath_length: float, feed_rate: float,
|
||||
rapid_distance: float = 0,
|
||||
rapid_speed: float = 15000) -> float:
|
||||
"""估算加工时间(分钟)"""
|
||||
cutting_time = toolpath_length / feed_rate / 60 if feed_rate > 0 else 0
|
||||
rapid_time = rapid_distance / rapid_speed / 60 if rapid_speed > 0 else 0
|
||||
return cutting_time + rapid_time
|
||||
|
||||
@classmethod
|
||||
def calculate_all(cls, tool: Dict) -> Dict:
|
||||
"""计算完整切削参数"""
|
||||
sf = tool["speeds_feeds"]
|
||||
rpm = cls.calculate_spindle_speed(sf["cutting_speed"], tool["diameter"])
|
||||
feed = cls.calculate_feed_rate(rpm, sf["feed_per_tooth"], tool["cutting_edges"])
|
||||
mrr = cls.calculate_mrr(feed, sf["axial_depth"], sf["radial_depth"])
|
||||
|
||||
return {
|
||||
"tool_id": tool.get("tool_id", "unknown"),
|
||||
"tool_type": tool["type"],
|
||||
"tool_diameter": tool["diameter"],
|
||||
"spindle_speed_rpm": rpm,
|
||||
"feed_rate_mm_min": round(feed, 1),
|
||||
"axial_depth_mm": sf["axial_depth"],
|
||||
"radial_depth_mm": sf["radial_depth"],
|
||||
"material_removal_rate_cm3_min": round(mrr, 2),
|
||||
"cutting_speed_m_min": round(sf["cutting_speed"], 1),
|
||||
}
|
||||
|
||||
|
||||
class RoughingToolpathGenerator:
|
||||
"""粗加工刀路生成器"""
|
||||
|
||||
def generate_z_level_roughing(self, stock_bbox: Dict, cavity_bbox: Dict,
|
||||
tool: Dict, cutting_params: Dict,
|
||||
stock_allowance: float = 0.5) -> Dict[str, Any]:
|
||||
"""
|
||||
Z层等高粗加工
|
||||
|
||||
策略:从顶面逐层向下铣削,每层切深为 axial_depth
|
||||
|
||||
Args:
|
||||
stock_bbox: 毛坯边界框
|
||||
cavity_bbox: 型腔边界框
|
||||
tool: 刀具参数
|
||||
cutting_params: 切削参数
|
||||
stock_allowance: 精加工余量 mm
|
||||
|
||||
Returns:
|
||||
粗加工刀路方案
|
||||
"""
|
||||
z_min = cavity_bbox.get("min", [0, 0, 0])[2]
|
||||
z_max = cavity_bbox.get("max", [0, 0, 0])[2]
|
||||
total_depth = z_max - z_min
|
||||
|
||||
axial_depth = cutting_params["axial_depth_mm"]
|
||||
num_levels = max(1, math.ceil(total_depth / axial_depth))
|
||||
|
||||
actual_depth = total_depth / num_levels
|
||||
|
||||
stepover = cutting_params["radial_depth_mm"]
|
||||
|
||||
levels = []
|
||||
for i in range(num_levels):
|
||||
z_level = z_max - (i + 1) * actual_depth + stock_allowance
|
||||
levels.append({
|
||||
"z": round(z_level, 2),
|
||||
"depth": round(actual_depth, 2),
|
||||
"level_index": i + 1,
|
||||
})
|
||||
|
||||
toolpath_length = self._estimate_roughing_length(
|
||||
cavity_bbox, num_levels, stepover
|
||||
)
|
||||
|
||||
machining_time = CuttingParamsCalculator.estimate_machining_time(
|
||||
toolpath_length, cutting_params["feed_rate_mm_min"]
|
||||
)
|
||||
|
||||
return {
|
||||
"strategy": "z_level_roughing",
|
||||
"tool": cutting_params,
|
||||
"levels": levels,
|
||||
"num_levels": num_levels,
|
||||
"stepover": stepover,
|
||||
"stock_allowance": stock_allowance,
|
||||
"total_depth": round(total_depth, 2),
|
||||
"estimated_toolpath_length": round(toolpath_length, 1),
|
||||
"estimated_time_min": round(machining_time, 1),
|
||||
"approach_type": "helical_ramp",
|
||||
"ramp_angle": 2.0,
|
||||
}
|
||||
|
||||
def _estimate_roughing_length(self, cavity_bbox: Dict, num_levels: int,
|
||||
stepover: float) -> float:
|
||||
"""估算粗加工刀路总长度"""
|
||||
dims = cavity_bbox.get("dimensions", [100, 100, 50])
|
||||
width = dims[0]
|
||||
length = dims[1]
|
||||
|
||||
passes_per_level = max(1, int(width / stepover))
|
||||
length_per_pass = length
|
||||
length_per_level = passes_per_level * length_per_pass * 1.1
|
||||
|
||||
return length_per_level * num_levels
|
||||
|
||||
|
||||
class FinishingToolpathGenerator:
|
||||
"""精加工刀路生成器"""
|
||||
|
||||
def generate_parallel_finishing(self, cavity_bbox: Dict, tool: Dict,
|
||||
cutting_params: Dict,
|
||||
stepover: float = 0.3,
|
||||
angle: float = 0.0) -> Dict[str, Any]:
|
||||
"""
|
||||
平行铣削精加工
|
||||
|
||||
Args:
|
||||
cavity_bbox: 型腔边界框
|
||||
tool: 刀具参数
|
||||
cutting_params: 切削参数
|
||||
stepover: 步距 mm
|
||||
angle: 加工角度
|
||||
|
||||
Returns:
|
||||
精加工刀路方案
|
||||
"""
|
||||
dims = cavity_bbox.get("dimensions", [100, 100, 50])
|
||||
width = dims[0]
|
||||
length = dims[1]
|
||||
|
||||
num_passes = max(1, int(width / stepover) + 1)
|
||||
|
||||
surface_roughness = self._estimate_surface_roughness(
|
||||
tool["diameter"], stepover
|
||||
)
|
||||
|
||||
toolpath_length = num_passes * length * 1.05
|
||||
|
||||
machining_time = CuttingParamsCalculator.estimate_machining_time(
|
||||
toolpath_length, cutting_params["feed_rate_mm_min"]
|
||||
)
|
||||
|
||||
return {
|
||||
"strategy": "parallel_finishing",
|
||||
"tool": cutting_params,
|
||||
"stepover": stepover,
|
||||
"angle": angle,
|
||||
"num_passes": num_passes,
|
||||
"surface_roughness_ra": round(surface_roughness, 3),
|
||||
"estimated_toolpath_length": round(toolpath_length, 1),
|
||||
"estimated_time_min": round(machining_time, 1),
|
||||
"cutting_direction": "one_way",
|
||||
"stepover_type": "scallop",
|
||||
}
|
||||
|
||||
def generate_contour_finishing(self, cavity_bbox: Dict, tool: Dict,
|
||||
cutting_params: Dict,
|
||||
z_step: float = 0.5) -> Dict[str, Any]:
|
||||
"""
|
||||
等高线精加工
|
||||
|
||||
Args:
|
||||
cavity_bbox: 型腔边界框
|
||||
tool: 刀具参数
|
||||
cutting_params: 切削参数
|
||||
z_step: Z方向步距 mm
|
||||
|
||||
Returns:
|
||||
等高线精加工方案
|
||||
"""
|
||||
z_min = cavity_bbox.get("min", [0, 0, 0])[2]
|
||||
z_max = cavity_bbox.get("max", [0, 0, 0])[2]
|
||||
total_depth = z_max - z_min
|
||||
|
||||
num_levels = max(1, int(total_depth / z_step) + 1)
|
||||
|
||||
dims = cavity_bbox.get("dimensions", [100, 100, 50])
|
||||
perimeter = 2 * (dims[0] + dims[1])
|
||||
|
||||
toolpath_length = num_levels * perimeter * 1.1
|
||||
|
||||
machining_time = CuttingParamsCalculator.estimate_machining_time(
|
||||
toolpath_length, cutting_params["feed_rate_mm_min"]
|
||||
)
|
||||
|
||||
return {
|
||||
"strategy": "contour_finishing",
|
||||
"tool": cutting_params,
|
||||
"z_step": z_step,
|
||||
"num_levels": num_levels,
|
||||
"estimated_toolpath_length": round(toolpath_length, 1),
|
||||
"estimated_time_min": round(machining_time, 1),
|
||||
}
|
||||
|
||||
def _estimate_surface_roughness(self, tool_diameter: float,
|
||||
stepover: float) -> float:
|
||||
"""估算表面粗糙度 Ra"""
|
||||
if tool_diameter <= 0:
|
||||
return 1.0
|
||||
r = tool_diameter / 2
|
||||
h = stepover ** 2 / (8 * r) if r > 0 else stepover
|
||||
return h * 0.25
|
||||
|
||||
|
||||
class GCodePostProcessor:
|
||||
"""G代码后处理器"""
|
||||
|
||||
def __init__(self, controller: str = "fanuc"):
|
||||
self.controller = controller
|
||||
self.dialects = {
|
||||
"fanuc": {
|
||||
"rapid": "G00", "linear": "G01",
|
||||
"cw_arc": "G02", "ccw_arc": "G03",
|
||||
"absolute": "G90", "incremental": "G91",
|
||||
"tool_change": "M06", "spindle_on": "M03",
|
||||
"spindle_off": "M05", "coolant_on": "M08",
|
||||
"coolant_off": "M09", "program_end": "M30",
|
||||
"length_comp": "G43", "xy_plane": "G17",
|
||||
"cancel_comp": "G40", "cancel_canned": "G80",
|
||||
},
|
||||
"siemens": {
|
||||
"rapid": "G00", "linear": "G01",
|
||||
"cw_arc": "G02", "ccw_arc": "G03",
|
||||
"absolute": "G90", "incremental": "G91",
|
||||
"tool_change": "M06", "spindle_on": "M03",
|
||||
"spindle_off": "M05", "coolant_on": "M08",
|
||||
"coolant_off": "M09", "program_end": "M30",
|
||||
"length_comp": "G43", "xy_plane": "G17",
|
||||
"cancel_comp": "G40", "cancel_canned": "G80",
|
||||
},
|
||||
}
|
||||
|
||||
def generate_gcode(self, operations: List[Dict],
|
||||
program_number: int = 1000,
|
||||
program_name: str = "MOLD_CAVITY") -> str:
|
||||
"""
|
||||
生成完整G代码程序
|
||||
|
||||
Args:
|
||||
operations: 加工操作列表
|
||||
program_number: 程序号
|
||||
program_name: 程序名
|
||||
|
||||
Returns:
|
||||
G代码字符串
|
||||
"""
|
||||
d = self.dialects.get(self.controller, self.dialects["fanuc"])
|
||||
lines = []
|
||||
|
||||
lines.append(f"%")
|
||||
lines.append(f"O{program_number} ({program_name})")
|
||||
lines.append(f"{d['xy_plane']} {d['cancel_comp']} {d['cancel_canned']} {d['absolute']}")
|
||||
lines.append(f"G54")
|
||||
lines.append("")
|
||||
|
||||
for op_idx, op in enumerate(operations):
|
||||
strategy = op.get("strategy", "unknown")
|
||||
tool_info = op.get("tool", {})
|
||||
tool_id = tool_info.get("tool_id", "T01")
|
||||
tool_num = op_idx + 1
|
||||
|
||||
lines.append(f"(=== 操作 {tool_num}: {strategy} ===)")
|
||||
|
||||
tool_type = tool_info.get("tool_type", "endmill")
|
||||
tool_dia = tool_info.get("tool_diameter", 10)
|
||||
lines.append(f"(刀具: {tool_type} D{tool_dia:.1f}mm)")
|
||||
|
||||
lines.append(f"T{tool_num:02d} {d['tool_change']}")
|
||||
lines.append(f"{d['length_comp']} H{tool_num:02d} Z100.0")
|
||||
|
||||
rpm = tool_info.get("spindle_speed_rpm", 3000)
|
||||
lines.append(f"S{rpm} {d['spindle_on']}")
|
||||
|
||||
lines.append(f"{d['rapid']} X0 Y0 Z10.0")
|
||||
lines.append(f"{d['coolant_on']}")
|
||||
lines.append("")
|
||||
|
||||
feed = tool_info.get("feed_rate_mm_min", 500)
|
||||
levels = op.get("levels", [])
|
||||
|
||||
if strategy == "z_level_roughing" and levels:
|
||||
for level in levels:
|
||||
z = level["z"]
|
||||
lines.append(f"(--- Z层 {level['level_index']}: Z={z:.2f} ---)")
|
||||
lines.append(f"{d['linear']} Z{z:.2f} F{int(feed * 0.5)}")
|
||||
lines.append(f"{d['linear']} X50.0 Y30.0 F{feed}")
|
||||
lines.append(f"{d['linear']} X-50.0 Y30.0")
|
||||
lines.append(f"{d['linear']} X-50.0 Y-30.0")
|
||||
lines.append(f"{d['linear']} X50.0 Y-30.0")
|
||||
lines.append(f"{d['rapid']} Z10.0")
|
||||
lines.append("")
|
||||
|
||||
elif strategy in ("parallel_finishing", "contour_finishing"):
|
||||
num_passes = op.get("num_passes", 10)
|
||||
stepover = op.get("stepover", 0.3)
|
||||
|
||||
for i in range(num_passes):
|
||||
y = i * stepover - 30
|
||||
lines.append(f"{d['linear']} Z-5.0 F{int(feed * 0.3)}")
|
||||
lines.append(f"{d['linear']} X50.0 Y{y:.2f} F{feed}")
|
||||
lines.append(f"{d['linear']} X-50.0 Y{y:.2f}")
|
||||
lines.append(f"{d['rapid']} Z5.0")
|
||||
lines.append("")
|
||||
|
||||
else:
|
||||
lines.append(f"(策略 {strategy} 的刀路数据)")
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"{d['coolant_off']}")
|
||||
lines.append(f"{d['spindle_off']}")
|
||||
lines.append(f"{d['rapid']} Z100.0")
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"{d['coolant_off']}")
|
||||
lines.append(f"{d['spindle_off']}")
|
||||
lines.append(f"G28 G91 Z0")
|
||||
lines.append(f"G28 G91 X0 Y0")
|
||||
lines.append(f"{d['program_end']}")
|
||||
lines.append(f"%")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class MoldCAMDesigner:
|
||||
"""模具CAM综合设计器"""
|
||||
|
||||
def __init__(self):
|
||||
self.tool_lib = ToolLibrary()
|
||||
self.params_calc = CuttingParamsCalculator()
|
||||
self.roughing_gen = RoughingToolpathGenerator()
|
||||
self.finishing_gen = FinishingToolpathGenerator()
|
||||
self.post_processor = GCodePostProcessor()
|
||||
|
||||
def design_mold_cam(self, cavity_bbox: Dict, stock_bbox: Dict,
|
||||
mold_steel: str = "P20",
|
||||
surface_quality: str = "standard",
|
||||
controller: str = "fanuc",
|
||||
program_number: int = 1000) -> Dict[str, Any]:
|
||||
"""
|
||||
综合设计模具CAM方案
|
||||
|
||||
Args:
|
||||
cavity_bbox: 型腔边界框
|
||||
stock_bbox: 毛坯边界框
|
||||
mold_steel: 模具钢材料
|
||||
surface_quality: 表面质量要求
|
||||
controller: 数控系统
|
||||
program_number: 程序号
|
||||
|
||||
Returns:
|
||||
完整的CAM方案
|
||||
"""
|
||||
logger.info(f"开始模具CAM设计: 钢材={mold_steel}, 质量={surface_quality}")
|
||||
|
||||
roughing_tool = ToolLibrary.select_roughing_tool(
|
||||
self._estimate_cavity_volume(cavity_bbox),
|
||||
steel=mold_steel
|
||||
)
|
||||
roughing_params = CuttingParamsCalculator.calculate_all(roughing_tool)
|
||||
|
||||
finishing_tool = ToolLibrary.select_finishing_tool(
|
||||
surface_quality=surface_quality,
|
||||
steel=mold_steel
|
||||
)
|
||||
finishing_params = CuttingParamsCalculator.calculate_all(finishing_tool)
|
||||
|
||||
roughing_op = self.roughing_gen.generate_z_level_roughing(
|
||||
stock_bbox, cavity_bbox, roughing_tool, roughing_params
|
||||
)
|
||||
|
||||
finishing_op = self.finishing_gen.generate_parallel_finishing(
|
||||
cavity_bbox, finishing_tool, finishing_params
|
||||
)
|
||||
|
||||
operations = [roughing_op, finishing_op]
|
||||
|
||||
gcode = self.post_processor.generate_gcode(
|
||||
operations, program_number=program_number
|
||||
)
|
||||
|
||||
total_time = (
|
||||
roughing_op.get("estimated_time_min", 0) +
|
||||
finishing_op.get("estimated_time_min", 0)
|
||||
)
|
||||
|
||||
result = {
|
||||
"operations": operations,
|
||||
"tools": {
|
||||
"roughing": roughing_params,
|
||||
"finishing": finishing_params,
|
||||
},
|
||||
"gcode": gcode,
|
||||
"gcode_lines": len(gcode.split("\n")),
|
||||
"summary": {
|
||||
"total_operations": len(operations),
|
||||
"total_estimated_time_min": round(total_time, 1),
|
||||
"mold_steel": mold_steel,
|
||||
"surface_quality": surface_quality,
|
||||
"controller": controller,
|
||||
},
|
||||
"recommendations": self._generate_cam_recommendations(
|
||||
roughing_op, finishing_op, mold_steel
|
||||
),
|
||||
}
|
||||
|
||||
logger.info(f"CAM设计完成: {len(operations)} 个工序, "
|
||||
f"预计 {total_time:.1f} 分钟")
|
||||
|
||||
return result
|
||||
|
||||
def _estimate_cavity_volume(self, cavity_bbox: Dict) -> float:
|
||||
"""估算型腔体积"""
|
||||
dims = cavity_bbox.get("dimensions", [100, 100, 50])
|
||||
return dims[0] * dims[1] * dims[2]
|
||||
|
||||
def _generate_cam_recommendations(self, roughing: Dict, finishing: Dict,
|
||||
steel: str) -> List[str]:
|
||||
"""生成CAM建议"""
|
||||
recs = []
|
||||
|
||||
roughing_time = roughing.get("estimated_time_min", 0)
|
||||
if roughing_time > 120:
|
||||
recs.append("粗加工时间较长,建议使用更大直径刀具或增加切削深度")
|
||||
|
||||
finishing_roughness = finishing.get("surface_roughness_ra", 0)
|
||||
if finishing_roughness > 0.8:
|
||||
recs.append("表面粗糙度偏高,建议减小步距或使用更小直径球头刀")
|
||||
|
||||
if steel in ("S136", "H13"):
|
||||
recs.append(f"高硬度钢材({steel}),建议使用涂层刀具并降低切削速度")
|
||||
recs.append("建议增加半精加工工序减少精加工余量")
|
||||
|
||||
recs.append("加工前需确认工件坐标系零点位置")
|
||||
recs.append("首件加工建议降低进给率20%进行试切")
|
||||
|
||||
return recs
|
||||
@@ -0,0 +1,511 @@
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
import numpy as np
|
||||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
||||
from OCC.Core.gp import gp_Pln, gp_Dir, gp_Pnt
|
||||
from OCC.Core.TopoDS import TopoDS_Face, TopoDS_Shape, topods
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
|
||||
from shared.models.schemas import create_mold_cavity_data, create_mold_key_info
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.core.base_mold_generator import BaseMoldGenerator
|
||||
from moldinsight.core.side_action_designer import SideActionDesigner
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MoldCavityGenerator(BaseMoldGenerator):
|
||||
"""模具型腔生成器 - 基于产品模型生成Cavity和Core"""
|
||||
|
||||
def __init__(self, shrinkage_rate: float = 0.005, draft_angle: float = 2.0,
|
||||
material_density: float = 1.05):
|
||||
super().__init__(shrinkage_rate, draft_angle, material_density)
|
||||
|
||||
self.material_densities = {
|
||||
"ABS": 1.05,
|
||||
"PP": 0.90,
|
||||
"PC": 1.20,
|
||||
"PE": 0.95,
|
||||
"PS": 1.05,
|
||||
"PA": 1.14,
|
||||
"POM": 1.42,
|
||||
"PMMA": 1.18
|
||||
}
|
||||
|
||||
self.parting_line_tolerance = 0.1
|
||||
self.max_draft_angle = 5.0
|
||||
self.side_action_designer = SideActionDesigner()
|
||||
|
||||
def set_material(self, material: str):
|
||||
"""设置产品材料"""
|
||||
if material in self.material_densities:
|
||||
self.material_density = self.material_densities[material]
|
||||
logger.info(f"材料设置为 {material}, 密度: {self.material_density} g/cm³")
|
||||
else:
|
||||
logger.warning(f"未知材料 {material}, 使用默认密度 {self.material_density} g/cm³")
|
||||
|
||||
def generate_mold_cavities(self, product_shape: TopoDS_Shape) -> Dict[str, Any]:
|
||||
"""
|
||||
从产品的3D模型生成型腔和型芯
|
||||
|
||||
Returns:
|
||||
{
|
||||
"cavity": cavity_shape,
|
||||
"core": core_shape,
|
||||
"parting_surface": parting_surface,
|
||||
"parting_line": parting_line
|
||||
}
|
||||
"""
|
||||
logger.info("开始生成模具型腔...")
|
||||
|
||||
try:
|
||||
analysis = self._analyze_product_geometry(product_shape)
|
||||
|
||||
parting_result = self._detect_primary_parting(product_shape, analysis)
|
||||
parting_surface = parting_result["surface"]
|
||||
parting_line = self.optimize_parting_line(parting_result["line"])
|
||||
parting_direction = parting_result["direction"]
|
||||
|
||||
side_action_result = self.side_action_designer.analyze_and_design(
|
||||
shape=product_shape,
|
||||
parting_direction=parting_direction,
|
||||
mold_size=self._calculate_mold_size(analysis),
|
||||
parting_surface=parting_surface,
|
||||
)
|
||||
undercut_regions = self._build_undercut_regions(
|
||||
side_action_result.get("undercut_analysis", {})
|
||||
)
|
||||
|
||||
scaled_shape = self._apply_shrinkage_compensation(product_shape)
|
||||
|
||||
drafted_shape = self._apply_draft_angles(scaled_shape, parting_surface)
|
||||
|
||||
cavity, core = self._split_cavity_core(drafted_shape, parting_surface)
|
||||
|
||||
logger.info("模具型腔生成完成")
|
||||
|
||||
return {
|
||||
"cavity": cavity,
|
||||
"core": core,
|
||||
"parting_surface": parting_surface,
|
||||
"parting_line": parting_line,
|
||||
"analysis": analysis,
|
||||
"undercut_regions": undercut_regions,
|
||||
"side_actions": side_action_result,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"模具型腔生成失败: {e}")
|
||||
raise
|
||||
|
||||
def generate_detailed_cavity_json(self, cavity_data: Dict) -> Dict[str, Any]:
|
||||
"""
|
||||
生成详细的型腔三维JSON数据
|
||||
|
||||
Returns:
|
||||
包含完整几何信息的JSON结构
|
||||
"""
|
||||
cavity = cavity_data["cavity"]
|
||||
core = cavity_data["core"]
|
||||
parting_surface = cavity_data["parting_surface"]
|
||||
analysis = cavity_data["analysis"]
|
||||
|
||||
cavity_geometry = self._extract_shape_geometry(cavity, "cavity")
|
||||
core_geometry = self._extract_shape_geometry(core, "core")
|
||||
|
||||
parting_geometry = self._extract_parting_surface_geometry(
|
||||
parting_surface
|
||||
)
|
||||
|
||||
detailed_json = {
|
||||
"metadata": {
|
||||
"version": "2.0",
|
||||
"generated_at": str(np.datetime64('now')),
|
||||
"shrinkage_rate": self.shrinkage_rate,
|
||||
"draft_angle": self.draft_angle,
|
||||
"unit": "mm"
|
||||
},
|
||||
"product_analysis": {
|
||||
"bounding_box": analysis.get("bounding_box", {}),
|
||||
"volume": analysis.get("volume", 0),
|
||||
"surface_area": analysis.get("surface_area", 0),
|
||||
"center_of_mass": analysis.get("center_of_mass", [0, 0, 0])
|
||||
},
|
||||
"mold_cavities": {
|
||||
"cavity": cavity_geometry,
|
||||
"core": core_geometry
|
||||
},
|
||||
"parting_surface": parting_geometry,
|
||||
"quality_checks": {
|
||||
"undercut_regions": cavity_data.get("undercut_regions", []),
|
||||
"side_actions": cavity_data.get("side_actions", {}),
|
||||
},
|
||||
"manufacturing_info": {
|
||||
"estimated_mold_size": self._calculate_mold_size(analysis),
|
||||
"estimated_clamping_force": self._calculate_clamping_force(analysis),
|
||||
"recommended_material": self._get_recommended_material()
|
||||
}
|
||||
}
|
||||
|
||||
return detailed_json
|
||||
|
||||
def generate_cavity_key_info(self, cavity_data: Dict) -> Dict[str, Any]:
|
||||
"""
|
||||
生成模具型腔的关键信息
|
||||
|
||||
Returns:
|
||||
关键参数摘要
|
||||
"""
|
||||
analysis = cavity_data["analysis"]
|
||||
|
||||
key_info = {
|
||||
"mold_parameters": {
|
||||
"shrinkage_rate": f"{self.shrinkage_rate * 100:.2f}%",
|
||||
"draft_angle": f"{self.draft_angle}°",
|
||||
"parting_line_length": self._calculate_parting_line_length(
|
||||
cavity_data["parting_line"]
|
||||
),
|
||||
"cavity_depth": analysis.get("bounding_box", {}).get("dimensions", [0, 0, 0])[2]
|
||||
},
|
||||
"geometric_characteristics": {
|
||||
"product_volume": f"{analysis.get('volume', 0) / 1000:.2f} cm³",
|
||||
"product_weight": self._calculate_product_weight(analysis),
|
||||
"wall_thickness_range": self._estimate_wall_thickness(analysis),
|
||||
"complexity_score": self._calculate_complexity_score(analysis)
|
||||
},
|
||||
"manufacturing_requirements": {
|
||||
"cavity_material": "Aluminum Alloy 7075",
|
||||
"hardness": "HRC 30-35",
|
||||
"surface_finish": "SPI A2",
|
||||
"estimated_cycle_time": self._estimate_cycle_time(analysis),
|
||||
"recommended_injection_pressure": "80-120 MPa"
|
||||
},
|
||||
"quality_considerations": {
|
||||
"undercut_count": len(cavity_data.get("undercut_regions", [])),
|
||||
"side_action_summary": cavity_data.get("side_actions", {}).get("summary", {}),
|
||||
"potential_weld_lines": self._identify_weld_line_risk(analysis),
|
||||
"sink_mark_areas": self._identify_sink_mark_risk(analysis),
|
||||
"warpage_risk": self._assess_warpage_risk(analysis)
|
||||
}
|
||||
}
|
||||
|
||||
return key_info
|
||||
|
||||
# ==================== 内部方法 ====================
|
||||
|
||||
def _detect_parting_surface(self, shape: TopoDS_Shape, analysis: Dict) -> Tuple[TopoDS_Face, List]:
|
||||
"""
|
||||
检测分型面和分型线
|
||||
|
||||
优先级:
|
||||
1. AI 模型检测(如果已设置)
|
||||
2. 基于法向量分析的几何方法
|
||||
3. 简化方法(基于边界框)
|
||||
"""
|
||||
try:
|
||||
parting_result = self._detect_primary_parting(shape, analysis)
|
||||
logger.info(
|
||||
f"使用 {parting_result['method']} 方法检测分型面,"
|
||||
f"置信度={parting_result['confidence']:.3f}"
|
||||
)
|
||||
return parting_result["surface"], self.optimize_parting_line(parting_result["line"])
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"法向量分析失败,使用简化方法:{e}")
|
||||
|
||||
logger.info("使用简化方法检测分型面")
|
||||
return self._simple_parting_surface(shape, analysis)
|
||||
|
||||
def _detect_primary_parting(self, shape: TopoDS_Shape, analysis: Dict) -> Dict[str, Any]:
|
||||
"""检测主分型面(AI优先 → 几何法向量 → 简化回退)"""
|
||||
if self.ai_parting_detector is not None:
|
||||
try:
|
||||
ai_result = self.ai_parting_detector.detect(shape, analysis)
|
||||
if ai_result is not None:
|
||||
surface, line = self._create_parting_surface_from_ai(ai_result, analysis, shape)
|
||||
return {
|
||||
"surface": surface,
|
||||
"line": line,
|
||||
"direction": ai_result.get("normal", [0, 0, 1]),
|
||||
"method": ai_result.get("method", "ai"),
|
||||
"confidence": ai_result.get("confidence", 0.8),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"AI 分型面检测失败: {e}")
|
||||
|
||||
try:
|
||||
normal_dir = self._analyze_face_normals(shape)
|
||||
parting_plane = self._create_optimal_parting_plane(shape, analysis, normal_dir)
|
||||
dims = analysis.get("bounding_box", {}).get("dimensions", [100, 100, 100])
|
||||
span = max(dims) * 1.5 + 30
|
||||
parting_surface = BRepBuilderAPI_MakeFace(
|
||||
parting_plane, -span, span, -span, span
|
||||
).Face()
|
||||
parting_surface = self.extend_parting_surface(parting_surface, shape, extension=30.0)
|
||||
parting_line = self._calculate_parting_line(shape, parting_surface)
|
||||
return {
|
||||
"surface": parting_surface,
|
||||
"line": parting_line,
|
||||
"direction": [float(normal_dir.X()), float(normal_dir.Y()), float(normal_dir.Z())],
|
||||
"method": "face_normal_analysis",
|
||||
"confidence": 0.85,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"法向量分析失败,使用简化方法:{e}")
|
||||
|
||||
surface, line = self._simple_parting_surface(shape, analysis)
|
||||
return {
|
||||
"surface": surface,
|
||||
"line": line,
|
||||
"direction": [0, 0, 1],
|
||||
"method": "simple",
|
||||
"confidence": 0.6,
|
||||
}
|
||||
|
||||
def _build_undercut_regions(self, undercut_analysis: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""将侧向机构分析结果转换为兼容旧结构的倒扣区域列表。"""
|
||||
undercut_faces = undercut_analysis.get("undercut_faces", [])
|
||||
regions = []
|
||||
|
||||
for face in undercut_faces:
|
||||
regions.append({
|
||||
"type": "negative_draft",
|
||||
"location": face.get("center", [0, 0, 0]),
|
||||
"severity": face.get("severity", "medium"),
|
||||
"area": face.get("area", 0),
|
||||
"is_outer": face.get("is_outer", False),
|
||||
"face_index": face.get("face_index"),
|
||||
})
|
||||
|
||||
logger.info(f"转换得到 {len(regions)} 个兼容倒扣区域")
|
||||
return regions
|
||||
|
||||
def _analyze_face_normals(self, shape: TopoDS_Shape) -> gp_Dir:
|
||||
"""
|
||||
分析产品表面的法向量分布,找出最优分型方向
|
||||
|
||||
原理:
|
||||
- 统计所有面的法向量
|
||||
- 选择法向量变化最小的方向作为分型方向
|
||||
- 避免倒扣(undercut)区域
|
||||
"""
|
||||
face_normals = []
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
|
||||
while explorer.More():
|
||||
face = topods.Face(explorer.Current())
|
||||
surface = BRepAdaptor_Surface(face)
|
||||
|
||||
try:
|
||||
if surface.GetType() == 0:
|
||||
normal = surface.Plane().Position().Direction()
|
||||
else:
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(face, bbox)
|
||||
normal = gp_Dir(0, 0, 1)
|
||||
|
||||
face_normals.append(normal)
|
||||
except Exception as e:
|
||||
logger.debug(f"面法向量计算失败:{e}")
|
||||
|
||||
explorer.Next()
|
||||
|
||||
if not face_normals:
|
||||
return gp_Dir(0, 0, 1)
|
||||
|
||||
avg_x = sum(n.X() for n in face_normals) / len(face_normals)
|
||||
avg_y = sum(n.Y() for n in face_normals) / len(face_normals)
|
||||
avg_z = sum(n.Z() for n in face_normals) / len(face_normals)
|
||||
|
||||
length = np.sqrt(avg_x**2 + avg_y**2 + avg_z**2)
|
||||
if length > 0.001:
|
||||
return gp_Dir(avg_x/length, avg_y/length, avg_z/length)
|
||||
else:
|
||||
return gp_Dir(0, 0, 1)
|
||||
|
||||
def _create_optimal_parting_plane(self, shape: TopoDS_Shape, analysis: Dict,
|
||||
direction: gp_Dir) -> gp_Pln:
|
||||
"""
|
||||
创建最优分型面
|
||||
|
||||
Args:
|
||||
shape: 产品形状
|
||||
analysis: 几何分析结果
|
||||
direction: 分型方向(法向量)
|
||||
|
||||
Returns:
|
||||
gp_Pln: 分型面方程
|
||||
"""
|
||||
bbox = analysis["bounding_box"]
|
||||
center = bbox["center"]
|
||||
|
||||
parting_plane = gp_Pln(
|
||||
gp_Pnt(center[0], center[1], center[2]),
|
||||
direction
|
||||
)
|
||||
|
||||
logger.info(f"创建分型面:原点=({center[0]:.2f}, {center[1]:.2f}, {center[2]:.2f}), "
|
||||
f"法向量=({direction.X():.3f}, {direction.Y():.3f}, {direction.Z():.3f})")
|
||||
|
||||
return parting_plane
|
||||
|
||||
def _simple_parting_surface(self, shape: TopoDS_Shape, analysis: Dict) -> Tuple[TopoDS_Face, List]:
|
||||
"""简化的分型面检测(回退方案)"""
|
||||
bbox = analysis["bounding_box"]
|
||||
center_z = bbox["center"][2]
|
||||
|
||||
parting_plane = gp_Pln(
|
||||
gp_Pnt(0, 0, center_z),
|
||||
gp_Dir(0, 0, 1)
|
||||
)
|
||||
parting_surface = BRepBuilderAPI_MakeFace(
|
||||
parting_plane,
|
||||
bbox["min"][0] - 10, bbox["max"][0] + 10,
|
||||
bbox["min"][1] - 10, bbox["max"][1] + 10
|
||||
).Face()
|
||||
|
||||
parting_line = self._simple_parting_line(shape)
|
||||
|
||||
return parting_surface, parting_line
|
||||
|
||||
def _create_parting_surface_from_ai(self, ai_result: Dict,
|
||||
analysis: Dict, shape: Optional[TopoDS_Shape] = None) -> Tuple[TopoDS_Face, List]:
|
||||
"""
|
||||
从 AI 模型结果创建分型面(预留接口)
|
||||
|
||||
Args:
|
||||
ai_result: AI 模型输出,应包含:
|
||||
- origin: [x, y, z] 平面原点
|
||||
- normal: [nx, ny, nz] 法向量
|
||||
analysis: 几何分析结果
|
||||
shape: 产品形状(用于计算分型线)
|
||||
|
||||
Returns:
|
||||
(parting_surface, parting_line)
|
||||
"""
|
||||
origin = ai_result.get("origin", [0, 0, 0])
|
||||
normal = ai_result.get("normal", [0, 0, 1])
|
||||
|
||||
parting_plane = gp_Pln(
|
||||
gp_Pnt(origin[0], origin[1], origin[2]),
|
||||
gp_Dir(normal[0], normal[1], normal[2])
|
||||
)
|
||||
parting_surface = BRepBuilderAPI_MakeFace(parting_plane).Face()
|
||||
|
||||
if "parting_line" in ai_result:
|
||||
parting_line = ai_result["parting_line"]
|
||||
elif shape is not None:
|
||||
parting_line = self._calculate_parting_line(shape, parting_surface)
|
||||
else:
|
||||
parting_line = []
|
||||
|
||||
logger.info(f"从 AI 结果创建分型面:原点={origin}, 法向量={normal}")
|
||||
return parting_surface, parting_line
|
||||
|
||||
def _extract_parting_surface_geometry(self, surface: TopoDS_Face) -> Dict[str, Any]:
|
||||
"""提取分型面几何数据"""
|
||||
metadata = self._extract_plane_metadata(surface)
|
||||
|
||||
return {
|
||||
"type": "plane",
|
||||
"normal": metadata["normal"],
|
||||
"origin": metadata["origin"],
|
||||
"bounds": metadata["bounds"],
|
||||
}
|
||||
|
||||
def _calculate_mold_size(self, analysis: Dict) -> Dict[str, float]:
|
||||
"""估算模具尺寸"""
|
||||
product_bbox = analysis["bounding_box"]["dimensions"]
|
||||
margin = 30
|
||||
|
||||
return {
|
||||
"length": product_bbox[0] + 2 * margin,
|
||||
"width": product_bbox[1] + 2 * margin,
|
||||
"height": product_bbox[2] + 2 * margin + 100,
|
||||
"margin": margin
|
||||
}
|
||||
|
||||
def _calculate_clamping_force(self, analysis: Dict) -> str:
|
||||
"""估算锁模力"""
|
||||
volume_cm3 = analysis.get("volume", 0) / 1000
|
||||
|
||||
if volume_cm3 < 10:
|
||||
return "50-100 吨"
|
||||
elif volume_cm3 < 100:
|
||||
return "150-300 吨"
|
||||
elif volume_cm3 < 500:
|
||||
return "400-600 吨"
|
||||
else:
|
||||
return "800+ 吨"
|
||||
|
||||
def _get_recommended_material(self) -> str:
|
||||
"""推荐模具材料"""
|
||||
return "Aluminum Alloy 7075 (铝合金模具)"
|
||||
|
||||
def _estimate_wall_thickness(self, analysis: Dict) -> str:
|
||||
"""估算壁厚范围"""
|
||||
volume = analysis.get("volume", 0)
|
||||
surface_area = analysis.get("surface_area", 0)
|
||||
|
||||
if surface_area > 0 and volume > 0:
|
||||
avg_thickness = (volume / surface_area) * 0.6
|
||||
return f"{avg_thickness * 0.7:.2f} - {avg_thickness * 1.3:.2f} mm"
|
||||
elif volume > 0:
|
||||
bbox_dims = analysis.get("bounding_box", {}).get("dimensions", [1, 1, 1])
|
||||
bbox_volume = bbox_dims[0] * bbox_dims[1] * bbox_dims[2]
|
||||
if bbox_volume > 0:
|
||||
efficiency = volume / bbox_volume
|
||||
avg_thickness = (bbox_dims[0] + bbox_dims[1]) / 2 * efficiency
|
||||
return f"{avg_thickness * 0.7:.2f} - {avg_thickness * 1.3:.2f} mm"
|
||||
|
||||
return "2.0 - 4.0 mm (默认)"
|
||||
|
||||
def _calculate_complexity_score(self, analysis: Dict) -> float:
|
||||
"""计算复杂度评分(0-10)"""
|
||||
volume = analysis.get("volume", 0)
|
||||
surface_area = analysis.get("surface_area", 0)
|
||||
|
||||
if surface_area > 0 and volume > 0:
|
||||
thickness_ratio = (volume / surface_area) * 0.6
|
||||
complexity = min(thickness_ratio / 5.0, 10.0)
|
||||
return round(complexity, 1)
|
||||
elif volume > 0:
|
||||
bbox_dims = analysis.get("bounding_box", {}).get("dimensions", [100, 100, 100])
|
||||
bbox_volume = bbox_dims[0] * bbox_dims[1] * bbox_dims[2]
|
||||
if bbox_volume > 0:
|
||||
volume_ratio = volume / bbox_volume
|
||||
complexity = (1.0 - volume_ratio) * 10
|
||||
return round(min(max(complexity, 0), 10), 1)
|
||||
|
||||
return 5.0
|
||||
|
||||
def _estimate_cycle_time(self, analysis: Dict) -> str:
|
||||
"""估算成型周期"""
|
||||
volume_cm3 = analysis.get("volume", 0) / 1000
|
||||
|
||||
if volume_cm3 < 10:
|
||||
return "15-25 秒"
|
||||
elif volume_cm3 < 50:
|
||||
return "25-40 秒"
|
||||
elif volume_cm3 < 200:
|
||||
return "40-60 秒"
|
||||
else:
|
||||
return "60-90 秒"
|
||||
|
||||
def _identify_weld_line_risk(self, analysis: Dict) -> str:
|
||||
"""识别熔接痕风险"""
|
||||
complexity = self._calculate_complexity_score(analysis)
|
||||
|
||||
if complexity > 7:
|
||||
return "高 - 建议优化浇口位置"
|
||||
elif complexity > 4:
|
||||
return "中 - 需仿真验证"
|
||||
else:
|
||||
return "低"
|
||||
|
||||
def _identify_sink_mark_risk(self, analysis: Dict) -> str:
|
||||
"""识别缩痕风险"""
|
||||
return "中 - 建议壁厚均匀性检查"
|
||||
@@ -0,0 +1,762 @@
|
||||
"""
|
||||
模具加工碰撞检测与刀路优化模块
|
||||
|
||||
功能:
|
||||
1. CollisionDetector - 碰撞检测器
|
||||
- 刀柄干涉检测
|
||||
- 快速移动碰撞检测
|
||||
- 机床行程限制验证
|
||||
- 安全区域计算
|
||||
|
||||
2. ToolpathOptimizer - 刀路优化器
|
||||
- 进给率自适应优化
|
||||
- 空走刀路径最小化
|
||||
- 拐角减速处理
|
||||
- 切入切出优化
|
||||
|
||||
3. EDMElectrodeDesigner - EDM电极设计器
|
||||
- 电极自动生成
|
||||
- 放电间隙计算
|
||||
- 电极加工路径
|
||||
|
||||
4. MachiningSimulator - 加工仿真器
|
||||
- 材料去除模拟
|
||||
- 过切检测
|
||||
- 残余材料分析
|
||||
- 加工质量评估
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import math
|
||||
import numpy as np
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CollisionDetector:
|
||||
"""碰撞检测器"""
|
||||
|
||||
def __init__(self):
|
||||
self.machine_limits = {
|
||||
"x_min": -500, "x_max": 500,
|
||||
"y_min": -400, "y_max": 400,
|
||||
"z_min": -300, "z_max": 300,
|
||||
}
|
||||
self.safety_margin = 5.0
|
||||
self.retract_height = 50.0
|
||||
|
||||
def check_toolpath_safety(self, toolpath_points: List[List[float]],
|
||||
tool: Dict, stock_bbox: Dict,
|
||||
clamp_positions: Optional[List[Dict]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
综合检查刀路安全性
|
||||
|
||||
Args:
|
||||
toolpath_points: 刀路点列表 [[x,y,z], ...]
|
||||
tool: 刀具参数
|
||||
stock_bbox: 毛坯边界框
|
||||
clamp_positions: 压板位置列表
|
||||
|
||||
Returns:
|
||||
安全检查结果
|
||||
"""
|
||||
holder_collisions = self._check_holder_collision(toolpath_points, tool, stock_bbox)
|
||||
|
||||
rapid_collisions = self._check_rapid_move_collisions(toolpath_points, stock_bbox)
|
||||
|
||||
limit_violations = self._check_machine_limits(toolpath_points)
|
||||
|
||||
clamp_collisions = []
|
||||
if clamp_positions:
|
||||
clamp_collisions = self._check_clamp_collisions(
|
||||
toolpath_points, tool, clamp_positions
|
||||
)
|
||||
|
||||
all_issues = holder_collisions + rapid_collisions + limit_violations + clamp_collisions
|
||||
|
||||
safe_retract_points = self._calculate_safe_retract_points(
|
||||
toolpath_points, stock_bbox
|
||||
)
|
||||
|
||||
is_safe = len(all_issues) == 0
|
||||
|
||||
return {
|
||||
"is_safe": is_safe,
|
||||
"total_issues": len(all_issues),
|
||||
"holder_collisions": holder_collisions,
|
||||
"rapid_collisions": rapid_collisions,
|
||||
"limit_violations": limit_violations,
|
||||
"clamp_collisions": clamp_collisions,
|
||||
"safe_retract_points": safe_retract_points,
|
||||
"recommendations": self._generate_safety_recommendations(all_issues),
|
||||
}
|
||||
|
||||
def _check_holder_collision(self, points: List[List[float]],
|
||||
tool: Dict, stock_bbox: Dict) -> List[Dict]:
|
||||
"""检测刀柄干涉"""
|
||||
collisions = []
|
||||
tool_diameter = tool.get("diameter", 10)
|
||||
flute_length = tool.get("flute_length", 30)
|
||||
shank_diameter = tool.get("shank_diameter", tool_diameter)
|
||||
holder_diameter = tool.get("holder_diameter", shank_diameter * 2)
|
||||
|
||||
stock_z_max = stock_bbox.get("max", [0, 0, 0])[2]
|
||||
|
||||
for i, pt in enumerate(points):
|
||||
if len(pt) < 3:
|
||||
continue
|
||||
|
||||
z = pt[2]
|
||||
depth_below_stock = stock_z_max - z
|
||||
|
||||
if depth_below_stock > flute_length:
|
||||
holder_z = z + flute_length
|
||||
holder_clearance = holder_diameter / 2 + self.safety_margin
|
||||
|
||||
stock_xmin = stock_bbox.get("min", [0, 0, 0])[0]
|
||||
stock_xmax = stock_bbox.get("max", [0, 0, 0])[0]
|
||||
stock_ymin = stock_bbox.get("min", [0, 0, 0])[1]
|
||||
stock_ymax = stock_bbox.get("max", [0, 0, 0])[1]
|
||||
|
||||
if (stock_xmin - holder_clearance < pt[0] < stock_xmax + holder_clearance and
|
||||
stock_ymin - holder_clearance < pt[1] < stock_ymax + holder_clearance):
|
||||
collisions.append({
|
||||
"type": "holder_collision",
|
||||
"point_index": i,
|
||||
"position": pt,
|
||||
"depth": round(depth_below_stock, 2),
|
||||
"flute_length": flute_length,
|
||||
"severity": "high",
|
||||
"message": f"点{i}: 切深{depth_below_stock:.1f}mm超过刃长{flute_length}mm,刀柄可能干涉"
|
||||
})
|
||||
|
||||
return collisions
|
||||
|
||||
def _check_rapid_move_collisions(self, points: List[List[float]],
|
||||
stock_bbox: Dict) -> List[Dict]:
|
||||
"""检测快速移动碰撞"""
|
||||
collisions = []
|
||||
|
||||
stock_xmin = stock_bbox.get("min", [0, 0, 0])[0]
|
||||
stock_xmax = stock_bbox.get("max", [0, 0, 0])[0]
|
||||
stock_ymin = stock_bbox.get("min", [0, 0, 0])[1]
|
||||
stock_ymax = stock_bbox.get("max", [0, 0, 0])[1]
|
||||
stock_zmin = stock_bbox.get("min", [0, 0, 0])[2]
|
||||
stock_zmax = stock_bbox.get("max", [0, 0, 0])[2]
|
||||
|
||||
for i in range(1, len(points)):
|
||||
prev = points[i - 1]
|
||||
curr = points[i]
|
||||
|
||||
if len(prev) < 3 or len(curr) < 3:
|
||||
continue
|
||||
|
||||
z_change = abs(curr[2] - prev[2])
|
||||
xy_change = math.sqrt((curr[0] - prev[0])**2 + (curr[1] - prev[1])**2)
|
||||
|
||||
if z_change < 1.0 and xy_change > 5.0:
|
||||
min_z = min(prev[2], curr[2])
|
||||
|
||||
if min_z < stock_zmax + self.safety_margin:
|
||||
mid_x = (prev[0] + curr[0]) / 2
|
||||
mid_y = (prev[1] + curr[1]) / 2
|
||||
|
||||
if (stock_xmin < mid_x < stock_xmax and
|
||||
stock_ymin < mid_y < stock_ymax):
|
||||
collisions.append({
|
||||
"type": "rapid_collision",
|
||||
"segment": [i - 1, i],
|
||||
"start": prev,
|
||||
"end": curr,
|
||||
"severity": "high",
|
||||
"message": f"段{i-1}-{i}: 水平快速移动可能穿过毛坯"
|
||||
})
|
||||
|
||||
return collisions
|
||||
|
||||
def _check_machine_limits(self, points: List[List[float]]) -> List[Dict]:
|
||||
"""验证机床行程限制"""
|
||||
violations = []
|
||||
|
||||
for i, pt in enumerate(points):
|
||||
if len(pt) < 3:
|
||||
continue
|
||||
|
||||
if not (self.machine_limits["x_min"] <= pt[0] <= self.machine_limits["x_max"]):
|
||||
violations.append({
|
||||
"type": "machine_limit",
|
||||
"point_index": i,
|
||||
"axis": "X",
|
||||
"value": pt[0],
|
||||
"limit": [self.machine_limits["x_min"], self.machine_limits["x_max"]],
|
||||
"severity": "critical",
|
||||
})
|
||||
if not (self.machine_limits["y_min"] <= pt[1] <= self.machine_limits["y_max"]):
|
||||
violations.append({
|
||||
"type": "machine_limit",
|
||||
"point_index": i,
|
||||
"axis": "Y",
|
||||
"value": pt[1],
|
||||
"limit": [self.machine_limits["y_min"], self.machine_limits["y_max"]],
|
||||
"severity": "critical",
|
||||
})
|
||||
if not (self.machine_limits["z_min"] <= pt[2] <= self.machine_limits["z_max"]):
|
||||
violations.append({
|
||||
"type": "machine_limit",
|
||||
"point_index": i,
|
||||
"axis": "Z",
|
||||
"value": pt[2],
|
||||
"limit": [self.machine_limits["z_min"], self.machine_limits["z_max"]],
|
||||
"severity": "critical",
|
||||
})
|
||||
|
||||
return violations
|
||||
|
||||
def _check_clamp_collisions(self, points: List[List[float]], tool: Dict,
|
||||
clamps: List[Dict]) -> List[Dict]:
|
||||
"""检测压板碰撞"""
|
||||
collisions = []
|
||||
tool_radius = tool.get("diameter", 10) / 2
|
||||
|
||||
for i, pt in enumerate(points):
|
||||
if len(pt) < 3:
|
||||
continue
|
||||
|
||||
for j, clamp in enumerate(clamps):
|
||||
clamp_center = clamp.get("center", [0, 0, 0])
|
||||
clamp_size = clamp.get("size", [50, 30, 20])
|
||||
clamp_z_top = clamp_center[2] + clamp_size[2] / 2
|
||||
|
||||
if pt[2] < clamp_z_top + self.safety_margin:
|
||||
dx = abs(pt[0] - clamp_center[0])
|
||||
dy = abs(pt[1] - clamp_center[1])
|
||||
|
||||
if (dx < clamp_size[0] / 2 + tool_radius + self.safety_margin and
|
||||
dy < clamp_size[1] / 2 + tool_radius + self.safety_margin):
|
||||
collisions.append({
|
||||
"type": "clamp_collision",
|
||||
"point_index": i,
|
||||
"clamp_index": j,
|
||||
"severity": "high",
|
||||
"message": f"点{i}: 可能与压板{j}碰撞"
|
||||
})
|
||||
|
||||
return collisions
|
||||
|
||||
def _calculate_safe_retract_points(self, points: List[List[float]],
|
||||
stock_bbox: Dict) -> List[Dict]:
|
||||
"""计算安全抬刀点"""
|
||||
retract_points = []
|
||||
stock_zmax = stock_bbox.get("max", [0, 0, 0])[2]
|
||||
safe_z = stock_zmax + self.retract_height
|
||||
|
||||
for i in range(0, len(points), max(1, len(points) // 10)):
|
||||
pt = points[i]
|
||||
if len(pt) >= 3:
|
||||
retract_points.append({
|
||||
"index": i,
|
||||
"from": pt,
|
||||
"retract_to": [pt[0], pt[1], safe_z],
|
||||
"safe_z": safe_z,
|
||||
})
|
||||
|
||||
return retract_points
|
||||
|
||||
def _generate_safety_recommendations(self, issues: List[Dict]) -> List[str]:
|
||||
"""生成安全建议"""
|
||||
recs = []
|
||||
|
||||
holder_issues = [i for i in issues if i["type"] == "holder_collision"]
|
||||
if holder_issues:
|
||||
recs.append(f"发现 {len(holder_issues)} 处刀柄干涉,建议加长刀具或减少切深")
|
||||
|
||||
rapid_issues = [i for i in issues if i["type"] == "rapid_collision"]
|
||||
if rapid_issues:
|
||||
recs.append(f"发现 {len(rapid_issues)} 处快速移动碰撞风险,建议增加抬刀高度")
|
||||
|
||||
limit_issues = [i for i in issues if i["type"] == "machine_limit"]
|
||||
if limit_issues:
|
||||
recs.append(f"发现 {len(limit_issues)} 处超出机床行程,需调整工件位置")
|
||||
|
||||
clamp_issues = [i for i in issues if i["type"] == "clamp_collision"]
|
||||
if clamp_issues:
|
||||
recs.append(f"发现 {len(clamp_issues)} 处压板碰撞,建议调整压板位置")
|
||||
|
||||
if not issues:
|
||||
recs.append("刀路安全检查通过,无碰撞风险")
|
||||
|
||||
return recs
|
||||
|
||||
|
||||
class ToolpathOptimizer:
|
||||
"""刀路优化器"""
|
||||
|
||||
def optimize_toolpath(self, toolpath_points: List[List[float]],
|
||||
cutting_params: Dict,
|
||||
stock_bbox: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
综合优化刀路
|
||||
|
||||
优化内容:
|
||||
1. 进给率自适应优化
|
||||
2. 拐角减速处理
|
||||
3. 空走刀路径优化
|
||||
4. 切入切出优化
|
||||
|
||||
Args:
|
||||
toolpath_points: 原始刀路点
|
||||
cutting_params: 切削参数
|
||||
stock_bbox: 毛坯边界框
|
||||
|
||||
Returns:
|
||||
优化后的刀路和参数
|
||||
"""
|
||||
feed_optimized = self._optimize_feed_rates(toolpath_points, cutting_params)
|
||||
|
||||
corner_optimized = self._optimize_corner_speeds(toolpath_points, feed_optimized)
|
||||
|
||||
entry_exit_optimized = self._optimize_entry_exit(toolpath_points, stock_bbox)
|
||||
|
||||
stats = self._calculate_optimization_stats(
|
||||
toolpath_points, feed_optimized, corner_optimized
|
||||
)
|
||||
|
||||
return {
|
||||
"original_point_count": len(toolpath_points),
|
||||
"optimized_feeds": feed_optimized,
|
||||
"corner_slowdowns": corner_optimized,
|
||||
"entry_exit": entry_exit_optimized,
|
||||
"stats": stats,
|
||||
"recommendations": self._generate_optimization_recommendations(stats),
|
||||
}
|
||||
|
||||
def _optimize_feed_rates(self, points: List[List[float]],
|
||||
params: Dict) -> List[Dict]:
|
||||
"""进给率自适应优化"""
|
||||
base_feed = params.get("feed_rate_mm_min", 500)
|
||||
optimized = []
|
||||
|
||||
for i in range(len(points)):
|
||||
if i < 2 or i >= len(points) - 2:
|
||||
feed = base_feed * 0.8
|
||||
else:
|
||||
v1 = np.array(points[i]) - np.array(points[i - 1])
|
||||
v2 = np.array(points[i + 1]) - np.array(points[i])
|
||||
|
||||
len1 = np.linalg.norm(v1)
|
||||
len2 = np.linalg.norm(v2)
|
||||
|
||||
if len1 > 0.001 and len2 > 0.001:
|
||||
cos_angle = np.clip(np.dot(v1, v2) / (len1 * len2), -1, 1)
|
||||
angle = math.degrees(math.acos(cos_angle))
|
||||
|
||||
if angle < 30:
|
||||
feed = base_feed * 0.3
|
||||
elif angle < 60:
|
||||
feed = base_feed * 0.5
|
||||
elif angle < 120:
|
||||
feed = base_feed * 0.7
|
||||
else:
|
||||
feed = base_feed
|
||||
else:
|
||||
feed = base_feed
|
||||
|
||||
optimized.append({
|
||||
"index": i,
|
||||
"feed_rate": round(feed, 1),
|
||||
"feed_ratio": round(feed / base_feed, 2),
|
||||
})
|
||||
|
||||
return optimized
|
||||
|
||||
def _optimize_corner_speeds(self, points: List[List[float]],
|
||||
feed_data: List[Dict]) -> List[Dict]:
|
||||
"""拐角减速处理"""
|
||||
slowdowns = []
|
||||
base_feed = 500
|
||||
|
||||
for i in range(1, len(points) - 1):
|
||||
if i >= len(feed_data):
|
||||
break
|
||||
|
||||
v1 = np.array(points[i]) - np.array(points[i - 1])
|
||||
v2 = np.array(points[i + 1]) - np.array(points[i])
|
||||
|
||||
len1 = np.linalg.norm(v1)
|
||||
len2 = np.linalg.norm(v2)
|
||||
|
||||
if len1 > 0.001 and len2 > 0.001:
|
||||
cos_angle = np.clip(np.dot(v1, v2) / (len1 * len2), -1, 1)
|
||||
angle = math.degrees(math.acos(cos_angle))
|
||||
|
||||
if angle < 90:
|
||||
decel_distance = max(2.0, 10.0 * (1 - angle / 90))
|
||||
slowdowns.append({
|
||||
"index": i,
|
||||
"angle": round(angle, 1),
|
||||
"decel_distance": round(decel_distance, 2),
|
||||
"min_feed_ratio": 0.3 if angle < 45 else 0.5,
|
||||
})
|
||||
|
||||
return slowdowns
|
||||
|
||||
def _optimize_entry_exit(self, points: List[List[float]],
|
||||
stock_bbox: Optional[Dict]) -> Dict[str, Any]:
|
||||
"""切入切出优化"""
|
||||
entry = {"type": "arc_tangent", "radius": 5.0, "angle": 90}
|
||||
exit_ = {"type": "arc_tangent", "radius": 5.0, "angle": 90}
|
||||
|
||||
if stock_bbox:
|
||||
z_max = stock_bbox.get("max", [0, 0, 0])[2]
|
||||
entry["approach_z"] = z_max + 10
|
||||
exit_["retract_z"] = z_max + 50
|
||||
|
||||
return {"entry": entry, "exit": exit_}
|
||||
|
||||
def _calculate_optimization_stats(self, points: List, feeds: List,
|
||||
corners: List) -> Dict:
|
||||
"""计算优化统计"""
|
||||
if not feeds:
|
||||
return {"time_reduction_percent": 0}
|
||||
|
||||
feed_values = [f["feed_rate"] for f in feeds]
|
||||
avg_feed = sum(feed_values) / len(feed_values) if feed_values else 500
|
||||
base_feed = max(feed_values) if feed_values else 500
|
||||
|
||||
time_reduction = 0
|
||||
if base_feed > 0:
|
||||
time_reduction = (1 - avg_feed / base_feed) * 100
|
||||
|
||||
return {
|
||||
"avg_feed_rate": round(avg_feed, 1),
|
||||
"base_feed_rate": base_feed,
|
||||
"corner_slowdown_count": len(corners),
|
||||
"time_reduction_percent": round(abs(time_reduction), 1),
|
||||
}
|
||||
|
||||
def _generate_optimization_recommendations(self, stats: Dict) -> List[str]:
|
||||
"""生成优化建议"""
|
||||
recs = []
|
||||
|
||||
if stats.get("corner_slowdown_count", 0) > 10:
|
||||
recs.append("拐角减速点较多,建议优化刀路方向减少急转弯")
|
||||
|
||||
if stats.get("time_reduction_percent", 0) > 30:
|
||||
recs.append("进给率降低幅度较大,建议优化加工策略")
|
||||
|
||||
if not recs:
|
||||
recs.append("刀路优化完成,进给率分布合理")
|
||||
|
||||
return recs
|
||||
|
||||
|
||||
class EDMElectrodeDesigner:
|
||||
"""EDM电极设计器"""
|
||||
|
||||
ELECTRODE_MATERIALS = {
|
||||
"copper": {
|
||||
"density": 8.96, "wear_rate": 1.0,
|
||||
"machinability": "good", "cost": "medium"
|
||||
},
|
||||
"graphite": {
|
||||
"density": 1.75, "wear_rate": 0.5,
|
||||
"machinability": "excellent", "cost": "low"
|
||||
},
|
||||
"copper_tungsten": {
|
||||
"density": 14.0, "wear_rate": 0.3,
|
||||
"machinability": "poor", "cost": "high"
|
||||
},
|
||||
}
|
||||
|
||||
def design_electrodes(self, undercut_regions: List[Dict],
|
||||
cavity_bbox: Dict,
|
||||
material: str = "copper",
|
||||
spark_gap: float = 0.05,
|
||||
overburn: float = 0.1) -> Dict[str, Any]:
|
||||
"""
|
||||
设计EDM电极
|
||||
|
||||
Args:
|
||||
undercut_regions: 倒扣区域列表
|
||||
cavity_bbox: 型腔边界框
|
||||
material: 电极材料
|
||||
spark_gap: 放电间隙 mm
|
||||
overburn: 过切量 mm
|
||||
|
||||
Returns:
|
||||
电极设计方案
|
||||
"""
|
||||
mat_props = self.ELECTRODE_MATERIALS.get(material, self.ELECTRODE_MATERIALS["copper"])
|
||||
|
||||
electrodes = []
|
||||
for i, region in enumerate(undercut_regions):
|
||||
electrode = self._design_single_electrode(
|
||||
region, i + 1, material, spark_gap, overburn, cavity_bbox
|
||||
)
|
||||
electrodes.append(electrode)
|
||||
|
||||
total_volume = sum(e["volume_mm3"] for e in electrodes)
|
||||
total_weight = total_volume * mat_props["density"] / 1000
|
||||
|
||||
return {
|
||||
"electrodes": electrodes,
|
||||
"material": material,
|
||||
"material_properties": mat_props,
|
||||
"spark_gap": spark_gap,
|
||||
"overburn": overburn,
|
||||
"total_electrode_count": len(electrodes),
|
||||
"total_volume_cm3": round(total_volume / 1000, 2),
|
||||
"total_weight_g": round(total_weight, 2),
|
||||
"machining_strategy": self._generate_electrode_machining_strategy(
|
||||
electrodes, material
|
||||
),
|
||||
"recommendations": self._generate_electrode_recommendations(
|
||||
electrodes, material
|
||||
),
|
||||
}
|
||||
|
||||
def _design_single_electrode(self, region: Dict, index: int,
|
||||
material: str, spark_gap: float,
|
||||
overburn: float, cavity_bbox: Dict) -> Dict:
|
||||
"""设计单个电极"""
|
||||
center = region.get("center", [0, 0, 0])
|
||||
area = region.get("area", 100)
|
||||
|
||||
feature_size = math.sqrt(area)
|
||||
|
||||
electrode_size = {
|
||||
"width": round(feature_size * 1.3 + 2 * (spark_gap + overburn), 2),
|
||||
"length": round(feature_size * 1.3 + 2 * (spark_gap + overburn), 2),
|
||||
"height": round(cavity_bbox.get("dimensions", [0, 0, 50])[2] * 0.8 + 20, 2),
|
||||
}
|
||||
|
||||
volume = electrode_size["width"] * electrode_size["length"] * electrode_size["height"]
|
||||
|
||||
return {
|
||||
"index": index,
|
||||
"type": region.get("type", "undercut"),
|
||||
"location": center,
|
||||
"size": electrode_size,
|
||||
"volume_mm3": round(volume, 1),
|
||||
"spark_gap": spark_gap,
|
||||
"overburn": overburn,
|
||||
"material": material,
|
||||
"roughing_passes": 3,
|
||||
"finishing_passes": 2,
|
||||
}
|
||||
|
||||
def _generate_electrode_machining_strategy(self, electrodes: List,
|
||||
material: str) -> List[Dict]:
|
||||
"""生成电极加工策略"""
|
||||
strategies = []
|
||||
|
||||
for elec in electrodes:
|
||||
size = elec["size"]
|
||||
is_small = min(size["width"], size["length"]) < 5
|
||||
|
||||
strategy = {
|
||||
"electrode_index": elec["index"],
|
||||
"operations": [
|
||||
{
|
||||
"operation": "roughing",
|
||||
"tool": "endmill_6mm" if not is_small else "endmill_3mm",
|
||||
"stock_allowance": 0.3,
|
||||
},
|
||||
{
|
||||
"operation": "finishing",
|
||||
"tool": "ballnose_3mm" if not is_small else "ballnose_1mm",
|
||||
"stepover": 0.2,
|
||||
},
|
||||
],
|
||||
}
|
||||
strategies.append(strategy)
|
||||
|
||||
return strategies
|
||||
|
||||
def _generate_electrode_recommendations(self, electrodes: List,
|
||||
material: str) -> List[str]:
|
||||
"""生成电极建议"""
|
||||
recs = []
|
||||
|
||||
if material == "copper":
|
||||
recs.append("铜电极加工性良好,建议使用高速钢刀具")
|
||||
elif material == "graphite":
|
||||
recs.append("石墨电极易加工但易碎,注意切削力控制")
|
||||
elif material == "copper_tungsten":
|
||||
recs.append("铜钨合金硬度高,建议使用金刚石刀具")
|
||||
|
||||
if len(electrodes) > 4:
|
||||
recs.append("电极数量较多,建议评估是否可合并电极设计")
|
||||
|
||||
recs.append("电极加工后需检测尺寸精度和表面质量")
|
||||
recs.append("放电加工时需根据材料调整电参数")
|
||||
|
||||
return recs
|
||||
|
||||
|
||||
class MachiningSimulator:
|
||||
"""加工仿真器"""
|
||||
|
||||
def simulate_machining(self, operations: List[Dict],
|
||||
stock_bbox: Dict,
|
||||
resolution: float = 1.0) -> Dict[str, Any]:
|
||||
"""
|
||||
模拟加工过程
|
||||
|
||||
Args:
|
||||
operations: 加工操作列表
|
||||
stock_bbox: 毛坯边界框
|
||||
resolution: 仿真精度 mm
|
||||
|
||||
Returns:
|
||||
仿真结果
|
||||
"""
|
||||
stock_dims = stock_bbox.get("dimensions", [100, 100, 50])
|
||||
|
||||
nx = max(2, int(stock_dims[0] / resolution))
|
||||
ny = max(2, int(stock_dims[1] / resolution))
|
||||
nz = max(2, int(stock_dims[2] / resolution))
|
||||
|
||||
stock = np.ones((nx, ny, nz), dtype=np.float32)
|
||||
|
||||
total_removed = 0
|
||||
operation_results = []
|
||||
|
||||
for op in operations:
|
||||
removed = self._simulate_operation(stock, op, stock_bbox, resolution)
|
||||
total_removed += removed
|
||||
|
||||
operation_results.append({
|
||||
"strategy": op.get("strategy", "unknown"),
|
||||
"volume_removed_mm3": removed,
|
||||
"remaining_stock_percent": round(
|
||||
(1 - total_removed / (nx * ny * nz)) * 100, 1
|
||||
),
|
||||
})
|
||||
|
||||
total_voxels = nx * ny * nz
|
||||
remaining = np.sum(stock > 0)
|
||||
removal_efficiency = (1 - remaining / total_voxels) * 100 if total_voxels > 0 else 0
|
||||
|
||||
gouging = self._detect_gouging(stock, operations, stock_bbox, resolution)
|
||||
|
||||
residual = self._analyze_residual_material(stock, stock_bbox, resolution)
|
||||
|
||||
return {
|
||||
"resolution": resolution,
|
||||
"grid_size": {"nx": nx, "ny": ny, "nz": nz},
|
||||
"operations": operation_results,
|
||||
"total_volume_removed_percent": round(removal_efficiency, 1),
|
||||
"gouging_detected": gouging,
|
||||
"residual_analysis": residual,
|
||||
"quality_assessment": self._assess_quality(gouging, residual),
|
||||
"recommendations": self._generate_simulation_recommendations(
|
||||
gouging, residual, removal_efficiency
|
||||
),
|
||||
}
|
||||
|
||||
def _simulate_operation(self, stock: np.ndarray, op: Dict,
|
||||
bbox: Dict, resolution: float) -> int:
|
||||
"""模拟单个加工操作的材料去除"""
|
||||
strategy = op.get("strategy", "")
|
||||
removed = 0
|
||||
|
||||
nx, ny, nz = stock.shape
|
||||
|
||||
if strategy == "z_level_roughing":
|
||||
levels = op.get("levels", [])
|
||||
for level in levels:
|
||||
z_level = level.get("z", 0)
|
||||
z_idx = int((z_level - bbox.get("min", [0, 0, 0])[2]) / resolution)
|
||||
z_idx = max(0, min(z_idx, nz - 1))
|
||||
|
||||
for iz in range(z_idx, nz):
|
||||
removed += int(np.sum(stock[:, :, iz] > 0))
|
||||
stock[:, :, iz] = 0
|
||||
|
||||
elif strategy in ("parallel_finishing", "contour_finishing"):
|
||||
stepover = op.get("stepover", 0.3)
|
||||
step_idx = max(1, int(stepover / resolution))
|
||||
|
||||
for ix in range(0, nx, step_idx):
|
||||
for iy in range(0, ny, step_idx):
|
||||
if stock[ix, iy, :].any():
|
||||
removed += int(np.sum(stock[ix, iy, :] > 0))
|
||||
stock[ix, iy, :] = 0
|
||||
|
||||
return removed
|
||||
|
||||
def _detect_gouging(self, stock: np.ndarray, operations: List,
|
||||
bbox: Dict, resolution: float) -> List[Dict]:
|
||||
"""检测过切"""
|
||||
gouging = []
|
||||
|
||||
for op in operations:
|
||||
stock_allowance = op.get("stock_allowance", 0)
|
||||
if stock_allowance < 0:
|
||||
gouging.append({
|
||||
"operation": op.get("strategy", "unknown"),
|
||||
"type": "negative_allowance",
|
||||
"severity": "high",
|
||||
"message": f"工序 {op.get('strategy')} 余量为负值,存在过切风险"
|
||||
})
|
||||
|
||||
return gouging
|
||||
|
||||
def _analyze_residual_material(self, stock: np.ndarray,
|
||||
bbox: Dict, resolution: float) -> Dict:
|
||||
"""分析残余材料"""
|
||||
total_voxels = stock.size
|
||||
remaining = int(np.sum(stock > 0))
|
||||
remaining_percent = (remaining / total_voxels) * 100 if total_voxels > 0 else 0
|
||||
|
||||
return {
|
||||
"remaining_voxels": remaining,
|
||||
"remaining_percent": round(remaining_percent, 2),
|
||||
"estimated_residual_volume_cm3": round(
|
||||
remaining * resolution ** 3 / 1000, 2
|
||||
),
|
||||
}
|
||||
|
||||
def _assess_quality(self, gouging: List, residual: Dict) -> Dict:
|
||||
"""评估加工质量"""
|
||||
has_gouging = len(gouging) > 0
|
||||
residual_pct = residual.get("remaining_percent", 100)
|
||||
|
||||
if has_gouging:
|
||||
grade = "FAIL"
|
||||
elif residual_pct < 5:
|
||||
grade = "GOOD"
|
||||
elif residual_pct < 15:
|
||||
grade = "ACCEPTABLE"
|
||||
else:
|
||||
grade = "INSUFFICIENT"
|
||||
|
||||
return {
|
||||
"grade": grade,
|
||||
"has_gouging": has_gouging,
|
||||
"residual_percent": residual_pct,
|
||||
}
|
||||
|
||||
def _generate_simulation_recommendations(self, gouging: List, residual: Dict,
|
||||
efficiency: float) -> List[str]:
|
||||
"""生成仿真建议"""
|
||||
recs = []
|
||||
|
||||
if gouging:
|
||||
recs.append("检测到过切,需调整加工参数")
|
||||
|
||||
residual_pct = residual.get("remaining_percent", 0)
|
||||
if residual_pct > 20:
|
||||
recs.append("残余材料较多,建议增加精加工工序")
|
||||
elif residual_pct > 5:
|
||||
recs.append("残余材料适中,需检查关键区域是否加工到位")
|
||||
|
||||
if efficiency < 50:
|
||||
recs.append("材料去除率偏低,建议优化粗加工策略")
|
||||
|
||||
if not recs:
|
||||
recs.append("仿真结果良好,加工方案可行")
|
||||
|
||||
return recs
|
||||
@@ -0,0 +1,414 @@
|
||||
"""
|
||||
铝泡沫模具质量检测模块
|
||||
|
||||
提供分模面质量检测、模具结构合理性评估、生产可行性分析等功能
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any
|
||||
import numpy as np
|
||||
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AluminumFoamMoldQualityInspector:
|
||||
"""铝泡沫模具质量检测器"""
|
||||
|
||||
def __init__(self):
|
||||
self.quality_threshold = {
|
||||
"smoothness_score": 80.0,
|
||||
"continuity_score": 95.0,
|
||||
"structure_score": 90.0
|
||||
}
|
||||
|
||||
def inspect_mold(self, cavity_data: Dict, params: Dict) -> Dict[str, Any]:
|
||||
"""
|
||||
完整的模具质量检测
|
||||
|
||||
Args:
|
||||
cavity_data: 模具型腔数据
|
||||
params: 分模参数
|
||||
|
||||
Returns:
|
||||
质量检测报告
|
||||
"""
|
||||
logger.info("开始模具质量检测...")
|
||||
|
||||
report = {
|
||||
"surface_quality": self.inspect_surface_quality(cavity_data),
|
||||
"structure_quality": self.inspect_structure_quality(cavity_data, params),
|
||||
"feasibility": self.assess_production_feasibility(cavity_data, params),
|
||||
"overall_score": 0.0,
|
||||
"passed": False,
|
||||
"warnings": [],
|
||||
"recommendations": []
|
||||
}
|
||||
|
||||
# 计算综合评分
|
||||
scores = [
|
||||
report["surface_quality"]["overall_score"],
|
||||
report["structure_quality"]["overall_score"],
|
||||
report["feasibility"]["score"]
|
||||
]
|
||||
report["overall_score"] = sum(scores) / len(scores)
|
||||
report["passed"] = report["overall_score"] >= 80.0
|
||||
|
||||
logger.info(f"质量检测完成,综合评分: {report['overall_score']:.1f}%")
|
||||
|
||||
return report
|
||||
|
||||
def inspect_surface_quality(self, cavity_data: Dict) -> Dict[str, Any]:
|
||||
"""
|
||||
检测分模面质量
|
||||
|
||||
检测项目:
|
||||
- 平滑度:曲率分析
|
||||
- 连续性:边界检查
|
||||
- 完整性:破面检测
|
||||
"""
|
||||
parting_line = cavity_data.get("parting_line", [])
|
||||
parting_surface = cavity_data.get("parting_surface")
|
||||
|
||||
# 1. 平滑度检测
|
||||
smoothness = self._check_smoothness(parting_line)
|
||||
|
||||
# 2. 连续性检测
|
||||
continuity = self._check_continuity(parting_line)
|
||||
|
||||
# 3. 完整性检测
|
||||
completeness = self._check_completeness(cavity_data)
|
||||
|
||||
overall = (smoothness["score"] * 0.4 +
|
||||
continuity["score"] * 0.3 +
|
||||
completeness["score"] * 0.3)
|
||||
|
||||
return {
|
||||
"smoothness": smoothness,
|
||||
"continuity": continuity,
|
||||
"completeness": completeness,
|
||||
"overall_score": overall,
|
||||
"passed": overall >= self.quality_threshold["smoothness_score"]
|
||||
}
|
||||
|
||||
def _check_smoothness(self, parting_line: List) -> Dict[str, Any]:
|
||||
"""检查分型线平滑度"""
|
||||
if len(parting_line) < 3:
|
||||
return {"score": 50.0, "issues": ["分型线点数不足"]}
|
||||
|
||||
try:
|
||||
points = np.array(parting_line)
|
||||
|
||||
# 计算相邻线段角度变化
|
||||
angle_changes = []
|
||||
for i in range(1, len(points) - 1):
|
||||
v1 = points[i] - points[i-1]
|
||||
v2 = points[i+1] - points[i]
|
||||
|
||||
len1, len2 = np.linalg.norm(v1), np.linalg.norm(v2)
|
||||
if len1 > 0.001 and len2 > 0.001:
|
||||
cos_angle = np.clip(np.dot(v1, v2) / (len1 * len2), -1, 1)
|
||||
angle = np.degrees(np.arccos(cos_angle))
|
||||
angle_changes.append(angle)
|
||||
|
||||
if not angle_changes:
|
||||
return {"score": 70.0, "issues": []}
|
||||
|
||||
# 计算角度变化统计
|
||||
max_angle = max(angle_changes)
|
||||
avg_angle = np.mean(angle_changes)
|
||||
|
||||
# 评分:角度变化越小越好
|
||||
score = max(0, 100 - avg_angle * 2 - max_angle * 0.5)
|
||||
|
||||
issues = []
|
||||
if max_angle > 30:
|
||||
issues.append(f"存在尖角,最大角度变化: {max_angle:.1f}°")
|
||||
if avg_angle > 15:
|
||||
issues.append(f"分型线不够平滑,平均角度变化: {avg_angle:.1f}°")
|
||||
|
||||
return {"score": score, "issues": issues, "max_angle": max_angle, "avg_angle": avg_angle}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"平滑度检测失败: {e}")
|
||||
return {"score": 50.0, "issues": ["检测过程出错"]}
|
||||
|
||||
def _check_continuity(self, parting_line: List) -> Dict[str, Any]:
|
||||
"""检查分型线连续性"""
|
||||
if len(parting_line) < 2:
|
||||
return {"score": 0.0, "issues": ["分型线不完整"]}
|
||||
|
||||
try:
|
||||
# 检查是否有明显的间隙
|
||||
points = np.array(parting_line)
|
||||
gaps = []
|
||||
|
||||
for i in range(1, len(points)):
|
||||
gap = np.linalg.norm(points[i] - points[i-1])
|
||||
if gap > 10.0: # 10mm 以上认为有间隙
|
||||
gaps.append(gap)
|
||||
|
||||
# 评分
|
||||
if not gaps:
|
||||
score = 100.0
|
||||
issues = []
|
||||
elif len(gaps) == 1 and max(gaps) < 20:
|
||||
score = 80.0
|
||||
issues = [f"存在轻微间隙: {max(gaps):.1f}mm"]
|
||||
else:
|
||||
score = max(0, 100 - len(gaps) * 20)
|
||||
issues = [f"存在 {len(gaps)} 处间隙"]
|
||||
|
||||
return {"score": score, "issues": issues, "gap_count": len(gaps)}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"连续性检测失败: {e}")
|
||||
return {"score": 50.0, "issues": ["检测过程出错"]}
|
||||
|
||||
def _check_completeness(self, cavity_data: Dict) -> Dict[str, Any]:
|
||||
"""检查分模完整性"""
|
||||
issues = []
|
||||
|
||||
# 检查必要的组件是否存在
|
||||
required_keys = ["cavity", "core", "parting_surface", "parting_line"]
|
||||
missing = [k for k in required_keys if k not in cavity_data]
|
||||
|
||||
if missing:
|
||||
issues.append(f"缺少组件: {', '.join(missing)}")
|
||||
return {"score": 0.0, "issues": issues}
|
||||
|
||||
# 检查分型线点数
|
||||
parting_line = cavity_data.get("parting_line", [])
|
||||
if len(parting_line) < 4:
|
||||
issues.append("分型线点数不足")
|
||||
score = len(parting_line) * 20
|
||||
else:
|
||||
score = 100.0
|
||||
|
||||
return {"score": score, "issues": issues}
|
||||
|
||||
def inspect_structure_quality(self, cavity_data: Dict, params: Dict) -> Dict[str, Any]:
|
||||
"""
|
||||
检测模具结构合理性
|
||||
|
||||
检测项目:
|
||||
- 模具尺寸
|
||||
- 壁厚
|
||||
- 拔模角
|
||||
- 倒扣处理
|
||||
"""
|
||||
analysis = cavity_data.get("analysis", {})
|
||||
bbox = analysis.get("bounding_box", {}).get("dimensions", [0, 0, 0])
|
||||
|
||||
issues = []
|
||||
recommendations = []
|
||||
|
||||
# 1. 模具尺寸检查
|
||||
mold_size = cavity_data.get("mold_block")
|
||||
if mold_size:
|
||||
# 检查尺寸是否足够
|
||||
min_dimension = min(bbox)
|
||||
if min_dimension < 20:
|
||||
issues.append("产品尺寸过小,可能影响模具强度")
|
||||
recommendations.append("建议增加产品尺寸或使用嵌件")
|
||||
|
||||
# 2. 拔模角检查
|
||||
draft_angle = params.get("draft_angle", 0)
|
||||
if draft_angle < 2.0:
|
||||
issues.append("拔模角偏小,可能导致脱模困难")
|
||||
recommendations.append("建议增大拔模角到 2-5°")
|
||||
|
||||
# 3. 倒扣区域检查
|
||||
undercut_regions = cavity_data.get("undercut_regions", [])
|
||||
if undercut_regions:
|
||||
issues.append(f"存在 {len(undercut_regions)} 个倒扣区域")
|
||||
recommendations.append("建议添加滑块或斜顶机构")
|
||||
|
||||
# 4. 铝泡沫特殊检查
|
||||
foam_material = params.get("foam_material", "")
|
||||
if foam_material:
|
||||
# 检查排气系统需求
|
||||
volume = analysis.get("volume", 0)
|
||||
if volume > 50000000: # > 50 cm³
|
||||
issues.append("大型铝泡沫产品,需要加强排气系统")
|
||||
recommendations.append("建议增加排气槽或排气针")
|
||||
|
||||
# 评分
|
||||
issue_count = len(issues)
|
||||
score = max(0, 100 - issue_count * 15)
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"issues": issues,
|
||||
"recommendations": recommendations,
|
||||
"overall_score": score,
|
||||
"passed": score >= self.quality_threshold["structure_score"]
|
||||
}
|
||||
|
||||
def assess_production_feasibility(self, cavity_data: Dict, params: Dict) -> Dict[str, Any]:
|
||||
"""
|
||||
评估生产可行性
|
||||
|
||||
评估项目:
|
||||
- 注塑压力
|
||||
- 锁模力
|
||||
- 成型周期
|
||||
- 材料利用率
|
||||
"""
|
||||
analysis = cavity_data.get("analysis", {})
|
||||
|
||||
# 计算投影面积 (mm²)
|
||||
bbox = analysis.get("bounding_box", {}).get("dimensions", [0, 0, 0])
|
||||
projected_area = bbox[0] * bbox[1] # X * Y
|
||||
|
||||
# 体积 (mm³)
|
||||
volume = analysis.get("volume", 0)
|
||||
volume_cm3 = volume / 1000
|
||||
|
||||
# 1. 注塑压力估算 (MPa)
|
||||
injection_pressure = 30 + projected_area / 1000 # 简化估算
|
||||
|
||||
# 2. 锁模力估算 (吨)
|
||||
# 铝泡沫需要较低的压力
|
||||
clamping_force_ton = projected_area * 0.0015 # 简化估算
|
||||
|
||||
# 3. 成型周期估算 (秒)
|
||||
# 铝泡沫成型周期较长
|
||||
if volume_cm3 < 10:
|
||||
cycle_time = 60
|
||||
elif volume_cm3 < 50:
|
||||
cycle_time = 90
|
||||
elif volume_cm3 < 200:
|
||||
cycle_time = 120
|
||||
else:
|
||||
cycle_time = 180
|
||||
|
||||
# 4. 材料利用率
|
||||
material_utilization = min(95, 85 + volume_cm3 / 10)
|
||||
|
||||
# 评估结果
|
||||
feasibility_items = []
|
||||
|
||||
if injection_pressure < 100:
|
||||
feasibility_items.append({
|
||||
"item": "注塑压力",
|
||||
"value": f"{injection_pressure:.1f} MPa",
|
||||
"status": "ok",
|
||||
"message": "压力在设备范围内"
|
||||
})
|
||||
else:
|
||||
feasibility_items.append({
|
||||
"item": "注塑压力",
|
||||
"value": f"{injection_pressure:.1f} MPa",
|
||||
"status": "warning",
|
||||
"message": "压力较高,需要高压设备"
|
||||
})
|
||||
|
||||
if clamping_force_ton < 300:
|
||||
feasibility_items.append({
|
||||
"item": "锁模力",
|
||||
"value": f"{clamping_force_ton:.1f} 吨",
|
||||
"status": "ok",
|
||||
"message": "锁模力在设备范围内"
|
||||
})
|
||||
else:
|
||||
feasibility_items.append({
|
||||
"item": "锁模力",
|
||||
"value": f"{clamping_force_ton:.1f} 吨",
|
||||
"status": "warning",
|
||||
"message": "需要大型注塑机"
|
||||
})
|
||||
|
||||
feasibility_items.append({
|
||||
"item": "成型周期",
|
||||
"value": f"{cycle_time} 秒",
|
||||
"status": "ok",
|
||||
"message": "周期正常"
|
||||
})
|
||||
|
||||
feasibility_items.append({
|
||||
"item": "材料利用率",
|
||||
"value": f"{material_utilization:.1f}%",
|
||||
"status": "ok",
|
||||
"message": "材料利用率良好" if material_utilization > 80 else "材料利用率偏低"
|
||||
})
|
||||
|
||||
# 综合评分
|
||||
ok_count = sum(1 for item in feasibility_items if item["status"] == "ok")
|
||||
score = (ok_count / len(feasibility_items)) * 100
|
||||
|
||||
return {
|
||||
"items": feasibility_items,
|
||||
"score": score,
|
||||
"projected_area": f"{projected_area:.0f} mm²",
|
||||
"volume": f"{volume_cm3:.1f} cm³",
|
||||
"injection_pressure": f"{injection_pressure:.1f} MPa",
|
||||
"clamping_force": f"{clamping_force_ton:.1f} 吨",
|
||||
"cycle_time": f"{cycle_time} 秒",
|
||||
"material_utilization": f"{material_utilization:.1f}%",
|
||||
"passed": score >= 75.0
|
||||
}
|
||||
|
||||
def generate_quality_report(self, cavity_data: Dict, params: Dict) -> str:
|
||||
"""
|
||||
生成质量检测报告文本
|
||||
|
||||
Returns:
|
||||
Markdown 格式的报告文本
|
||||
"""
|
||||
report = self.inspect_mold(cavity_data, params)
|
||||
|
||||
lines = [
|
||||
"# 铝泡沫模具质量检测报告",
|
||||
"",
|
||||
f"**综合评分**: {report['overall_score']:.1f}%",
|
||||
f"**检测结果**: {'✅ 通过' if report['passed'] else '❌ 未通过'}",
|
||||
"",
|
||||
"## 一、分模面质量",
|
||||
"",
|
||||
f"- 平滑度: {report['surface_quality']['smoothness']['score']:.1f}分",
|
||||
f"- 连续性: {report['surface_quality']['continuity']['score']:.1f}分",
|
||||
f"- 完整性: {report['surface_quality']['completeness']['score']:.1f}分",
|
||||
"",
|
||||
]
|
||||
|
||||
# 添加问题列表
|
||||
if report["surface_quality"]["smoothness"].get("issues"):
|
||||
lines.append("**发现的问题**:")
|
||||
for issue in report["surface_quality"]["smoothness"]["issues"]:
|
||||
lines.append(f"- {issue}")
|
||||
lines.append("")
|
||||
|
||||
# 添加结构质量
|
||||
lines.extend([
|
||||
"## 二、模具结构质量",
|
||||
"",
|
||||
f"- 评分: {report['structure_quality']['score']:.1f}分",
|
||||
"",
|
||||
])
|
||||
|
||||
if report["structure_quality"].get("issues"):
|
||||
lines.append("**结构问题**:")
|
||||
for issue in report["structure_quality"]["issues"]:
|
||||
lines.append(f"- {issue}")
|
||||
lines.append("")
|
||||
|
||||
if report["structure_quality"].get("recommendations"):
|
||||
lines.append("**改进建议**:")
|
||||
for rec in report["structure_quality"]["recommendations"]:
|
||||
lines.append(f"- {rec}")
|
||||
lines.append("")
|
||||
|
||||
# 添加生产可行性
|
||||
lines.extend([
|
||||
"## 三、生产可行性",
|
||||
"",
|
||||
])
|
||||
|
||||
for item in report["feasibility"]["items"]:
|
||||
status_icon = "✅" if item["status"] == "ok" else "⚠️"
|
||||
lines.append(f"{status_icon} **{item['item']}**: {item['value']} - {item['message']}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,588 @@
|
||||
"""
|
||||
冷却/浇注系统自动设计模块
|
||||
|
||||
功能:
|
||||
1. 冷却系统设计 - 水路布局、直径、间距
|
||||
2. 浇注系统设计 - 主流道、分流道、浇口
|
||||
3. 热力学估算 - 冷却时间、温度分布
|
||||
4. 排气系统设计 - 排气槽、排气针位置
|
||||
|
||||
设计依据:
|
||||
- 模具尺寸和产品几何
|
||||
- 材料热物性参数
|
||||
- 生产节拍要求
|
||||
- 行业标准规范
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
import math
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MaterialThermalDB:
|
||||
"""材料热物性数据库"""
|
||||
|
||||
PLASTICS = {
|
||||
"ABS": {"density": 1.05, "specific_heat": 1.47, "thermal_cond": 0.17,
|
||||
"melt_temp": 230, "mold_temp": 60, "eject_temp": 85},
|
||||
"PP": {"density": 0.90, "specific_heat": 1.90, "thermal_cond": 0.15,
|
||||
"melt_temp": 220, "mold_temp": 40, "eject_temp": 80},
|
||||
"PC": {"density": 1.20, "specific_heat": 1.25, "thermal_cond": 0.20,
|
||||
"melt_temp": 300, "mold_temp": 80, "eject_temp": 120},
|
||||
"PE": {"density": 0.95, "specific_heat": 2.30, "thermal_cond": 0.50,
|
||||
"melt_temp": 200, "mold_temp": 30, "eject_temp": 70},
|
||||
"PS": {"density": 1.05, "specific_heat": 1.34, "thermal_cond": 0.12,
|
||||
"melt_temp": 220, "mold_temp": 50, "eject_temp": 80},
|
||||
"PA": {"density": 1.14, "specific_heat": 1.70, "thermal_cond": 0.25,
|
||||
"melt_temp": 260, "mold_temp": 70, "eject_temp": 100},
|
||||
"POM": {"density": 1.42, "specific_heat": 1.47, "thermal_cond": 0.31,
|
||||
"melt_temp": 200, "mold_temp": 70, "eject_temp": 100},
|
||||
"PMMA": {"density": 1.18, "specific_heat": 1.47, "thermal_cond": 0.19,
|
||||
"melt_temp": 240, "mold_temp": 60, "eject_temp": 90},
|
||||
}
|
||||
|
||||
FOAM = {
|
||||
"AlSi10Mg": {"density": 0.45, "specific_heat": 0.90, "thermal_cond": 0.05,
|
||||
"melt_temp": 380, "mold_temp": 150, "eject_temp": 200},
|
||||
"AlSi12": {"density": 0.50, "specific_heat": 0.88, "thermal_cond": 0.06,
|
||||
"melt_temp": 360, "mold_temp": 140, "eject_temp": 190},
|
||||
}
|
||||
|
||||
COOLANT = {
|
||||
"water": {"specific_heat": 4.18, "density": 1.0, "thermal_cond": 0.60},
|
||||
"oil": {"specific_heat": 2.00, "density": 0.85, "thermal_cond": 0.15},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_material(cls, material: str) -> Optional[Dict]:
|
||||
if material in cls.PLASTICS:
|
||||
return cls.PLASTICS[material]
|
||||
if material in cls.FOAM:
|
||||
return cls.FOAM[material]
|
||||
return None
|
||||
|
||||
|
||||
class CoolingSystemDesigner:
|
||||
"""冷却系统设计器"""
|
||||
|
||||
def design_cooling_system(self, mold_size: Dict, product_bbox: Dict,
|
||||
material: str = "ABS",
|
||||
cavity_count: int = 1,
|
||||
cycle_time_target: Optional[float] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
设计冷却系统
|
||||
|
||||
Args:
|
||||
mold_size: {"length": L, "width": W, "height": H}
|
||||
product_bbox: {"dimensions": [dx, dy, dz]}
|
||||
material: 材料名称
|
||||
cavity_count: 型腔数量
|
||||
cycle_time_target: 目标成型周期(秒)
|
||||
|
||||
Returns:
|
||||
冷却系统设计方案
|
||||
"""
|
||||
logger.info(f"开始冷却系统设计: 材料={material}, {cavity_count}穴")
|
||||
|
||||
mat_props = MaterialThermalDB.get_material(material)
|
||||
if mat_props is None:
|
||||
mat_props = MaterialThermalDB.PLASTICS["ABS"]
|
||||
logger.warning(f"未知材料 {material},使用 ABS 默认参数")
|
||||
|
||||
dims = product_bbox.get("dimensions", [100, 100, 50])
|
||||
max_wall = max(dims) * 0.6
|
||||
|
||||
cooling_time = self._estimate_cooling_time(
|
||||
max_wall, mat_props, mold_size.get("height", 100)
|
||||
)
|
||||
|
||||
layout = self._design_channel_layout(mold_size, dims, cavity_count)
|
||||
|
||||
channels = self._generate_channel_positions(layout, mold_size, dims)
|
||||
|
||||
flow_rate = self._calculate_flow_rate(channels, mat_props)
|
||||
|
||||
thermal_check = self._check_thermal_performance(
|
||||
cooling_time, channels, mat_props, mold_size, cycle_time_target
|
||||
)
|
||||
|
||||
return {
|
||||
"cooling_time": round(cooling_time, 1),
|
||||
"channels": channels,
|
||||
"layout": layout,
|
||||
"flow_rate": flow_rate,
|
||||
"thermal_check": thermal_check,
|
||||
"material_properties": mat_props,
|
||||
"recommendations": self._generate_cooling_recommendations(
|
||||
cooling_time, thermal_check, channels, cycle_time_target
|
||||
),
|
||||
}
|
||||
|
||||
def _estimate_cooling_time(self, max_wall_thickness: float,
|
||||
mat_props: Dict, mold_height: float) -> float:
|
||||
"""估算冷却时间(基于一维热传导简化模型)"""
|
||||
k = mat_props["thermal_cond"]
|
||||
rho = mat_props["density"] * 1000
|
||||
cp = mat_props["specific_heat"] * 1000
|
||||
|
||||
alpha = k / (rho * cp)
|
||||
|
||||
t_melt = mat_props["melt_temp"]
|
||||
t_mold = mat_props["mold_temp"]
|
||||
t_eject = mat_props["eject_temp"]
|
||||
|
||||
if t_melt <= t_eject:
|
||||
return 10.0
|
||||
|
||||
theta = (t_eject - t_mold) / (t_melt - t_mold) if (t_melt - t_mold) != 0 else 0.5
|
||||
theta = max(0.01, min(0.99, abs(theta)))
|
||||
|
||||
L = max_wall_thickness / 1000.0
|
||||
|
||||
cooling_time = (L ** 2 / (alpha * math.pi ** 2)) * math.log(4 / (math.pi * theta))
|
||||
|
||||
return max(5.0, cooling_time)
|
||||
|
||||
def _design_channel_layout(self, mold_size: Dict, dims: List[float],
|
||||
cavity_count: int) -> Dict:
|
||||
"""设计水路布局方案"""
|
||||
length = mold_size.get("length", 300)
|
||||
width = mold_size.get("width", 300)
|
||||
|
||||
channel_diameter = 8.0
|
||||
channel_spacing = 30.0
|
||||
wall_distance = 15.0
|
||||
|
||||
num_channels_length = max(2, int((width - 2 * wall_distance) / channel_spacing))
|
||||
num_channels_width = max(2, int((length - 2 * wall_distance) / channel_spacing))
|
||||
|
||||
if cavity_count <= 4:
|
||||
layout_type = "straight"
|
||||
num_channels = num_channels_length
|
||||
else:
|
||||
layout_type = "spiral"
|
||||
num_channels = max(num_channels_length, num_channels_width)
|
||||
|
||||
return {
|
||||
"type": layout_type,
|
||||
"diameter": channel_diameter,
|
||||
"spacing": channel_spacing,
|
||||
"wall_distance": wall_distance,
|
||||
"num_channels": num_channels,
|
||||
"num_channels_length": num_channels_length,
|
||||
"num_channels_width": num_channels_width,
|
||||
}
|
||||
|
||||
def _generate_channel_positions(self, layout: Dict, mold_size: Dict,
|
||||
dims: List[float]) -> List[Dict]:
|
||||
"""生成水路位置"""
|
||||
channels = []
|
||||
length = mold_size.get("length", 300)
|
||||
width = mold_size.get("width", 300)
|
||||
wall_dist = layout["wall_distance"]
|
||||
diameter = layout["diameter"]
|
||||
|
||||
if layout["type"] == "straight":
|
||||
num = layout["num_channels_length"]
|
||||
spacing = (width - 2 * wall_dist) / max(num - 1, 1)
|
||||
|
||||
for i in range(num):
|
||||
y = wall_dist + i * spacing - width / 2
|
||||
channels.append({
|
||||
"id": i + 1,
|
||||
"type": "straight",
|
||||
"start": [-length / 2 + wall_dist, y, 0],
|
||||
"end": [length / 2 - wall_dist, y, 0],
|
||||
"diameter": diameter,
|
||||
"side": "A" if i % 2 == 0 else "B",
|
||||
})
|
||||
else:
|
||||
num = layout["num_channels"]
|
||||
for i in range(num):
|
||||
offset = (i - (num - 1) / 2) * layout["spacing"]
|
||||
channels.append({
|
||||
"id": i + 1,
|
||||
"type": "spiral",
|
||||
"center": [0, offset, 0],
|
||||
"radius": min(length, width) / 2 - wall_dist,
|
||||
"diameter": diameter,
|
||||
"side": "A" if i % 2 == 0 else "B",
|
||||
})
|
||||
|
||||
return channels
|
||||
|
||||
def _calculate_flow_rate(self, channels: List[Dict],
|
||||
mat_props: Dict) -> Dict:
|
||||
"""计算冷却液流量"""
|
||||
total_length = 0
|
||||
diameter = 8.0
|
||||
|
||||
for ch in channels:
|
||||
if ch["type"] == "straight":
|
||||
start = ch["start"]
|
||||
end = ch["end"]
|
||||
total_length += math.sqrt(sum((s - e) ** 2 for s, e in zip(start, end)))
|
||||
elif ch["type"] == "spiral":
|
||||
total_length += 2 * math.pi * ch.get("radius", 100)
|
||||
|
||||
velocity = 1.5
|
||||
area = math.pi * (diameter / 2 / 1000) ** 2
|
||||
flow_rate_lpm = velocity * area * 60000
|
||||
|
||||
reynolds = 1000 * velocity * (diameter / 1000) / 0.001
|
||||
|
||||
return {
|
||||
"velocity_m_s": velocity,
|
||||
"flow_rate_lpm": round(flow_rate_lpm, 1),
|
||||
"total_channel_length": round(total_length, 1),
|
||||
"reynolds_number": round(reynolds, 0),
|
||||
"flow_regime": "turbulent" if reynolds > 4000 else "laminar",
|
||||
}
|
||||
|
||||
def _check_thermal_performance(self, cooling_time: float,
|
||||
channels: List[Dict],
|
||||
mat_props: Dict,
|
||||
mold_size: Dict,
|
||||
target_cycle: Optional[float]) -> Dict:
|
||||
"""检查热力学性能"""
|
||||
num_channels = len(channels)
|
||||
total_heat = mat_props["specific_heat"] * mat_props["density"] * 100
|
||||
|
||||
heat_removal_rate = num_channels * 0.5 * 4.18 * 1.5 * 10
|
||||
|
||||
adequacy = "adequate" if num_channels >= 4 else "insufficient"
|
||||
|
||||
if target_cycle is not None:
|
||||
if cooling_time <= target_cycle * 0.6:
|
||||
adequacy = "excellent"
|
||||
elif cooling_time <= target_cycle * 0.8:
|
||||
adequacy = "adequate"
|
||||
else:
|
||||
adequacy = "insufficient"
|
||||
|
||||
return {
|
||||
"cooling_time": round(cooling_time, 1),
|
||||
"estimated_heat_removal_rate": round(heat_removal_rate, 1),
|
||||
"channel_count": num_channels,
|
||||
"adequacy": adequacy,
|
||||
}
|
||||
|
||||
def _generate_cooling_recommendations(self, cooling_time: float,
|
||||
thermal_check: Dict,
|
||||
channels: List[Dict],
|
||||
target_cycle: Optional[float]) -> List[str]:
|
||||
"""生成冷却系统建议"""
|
||||
recs = []
|
||||
|
||||
if thermal_check["adequacy"] == "insufficient":
|
||||
recs.append("冷却能力不足,建议增加水路数量或增大水路直径")
|
||||
recs.append("考虑使用铍铜镶件提高局部冷却效率")
|
||||
|
||||
if cooling_time > 30:
|
||||
recs.append("冷却时间较长,建议优化水路布局使水路更靠近型腔")
|
||||
|
||||
if len(channels) < 4:
|
||||
recs.append("水路数量偏少,建议至少4条水路")
|
||||
|
||||
flow_regime = "turbulent"
|
||||
if flow_regime == "laminar":
|
||||
recs.append("冷却液流速偏低,建议提高流速以达到湍流状态(Re>4000)")
|
||||
|
||||
if not recs:
|
||||
recs.append("冷却系统设计合理,建议进行热分析验证")
|
||||
|
||||
return recs
|
||||
|
||||
|
||||
class GatingSystemDesigner:
|
||||
"""浇注系统设计器"""
|
||||
|
||||
def design_gating_system(self, product_bbox: Dict, material: str = "ABS",
|
||||
cavity_count: int = 1,
|
||||
gate_type: str = "auto",
|
||||
layout_positions: Optional[List] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
设计浇注系统
|
||||
|
||||
Args:
|
||||
product_bbox: {"dimensions": [dx, dy, dz]}
|
||||
material: 材料名称
|
||||
cavity_count: 型腔数量
|
||||
gate_type: 浇口类型 (auto/side/center/submarine/fan)
|
||||
layout_positions: 型腔位置列表
|
||||
|
||||
Returns:
|
||||
浇注系统设计方案
|
||||
"""
|
||||
logger.info(f"开始浇注系统设计: 材料={material}, {cavity_count}穴, 浇口={gate_type}")
|
||||
|
||||
mat_props = MaterialThermalDB.get_material(material)
|
||||
if mat_props is None:
|
||||
mat_props = MaterialThermalDB.PLASTICS["ABS"]
|
||||
|
||||
dims = product_bbox.get("dimensions", [100, 100, 50])
|
||||
|
||||
if gate_type == "auto":
|
||||
gate_type = self._recommend_gate_type(dims, cavity_count)
|
||||
|
||||
sprue = self._design_sprue(dims, mat_props)
|
||||
runner = self._design_runner(dims, cavity_count, layout_positions)
|
||||
gate = self._design_gate(dims, gate_type, cavity_count, mat_props)
|
||||
|
||||
venting = self._design_venting(dims, cavity_count)
|
||||
|
||||
return {
|
||||
"sprue": sprue,
|
||||
"runner": runner,
|
||||
"gate": gate,
|
||||
"gate_type": gate_type,
|
||||
"venting": venting,
|
||||
"material": material,
|
||||
"recommendations": self._generate_gating_recommendations(
|
||||
gate_type, cavity_count, dims, mat_props
|
||||
),
|
||||
}
|
||||
|
||||
def _recommend_gate_type(self, dims: List[float], cavity_count: int) -> str:
|
||||
"""推荐浇口类型"""
|
||||
aspect = max(dims[:2]) / min(dims[:2]) if min(dims[:2]) > 0 else 1
|
||||
|
||||
if cavity_count == 1:
|
||||
if aspect > 2:
|
||||
return "side"
|
||||
return "center"
|
||||
else:
|
||||
return "side"
|
||||
|
||||
def _design_sprue(self, dims: List[float], mat_props: Dict) -> Dict:
|
||||
"""设计主流道"""
|
||||
max_dim = max(dims)
|
||||
volume = dims[0] * dims[1] * dims[2]
|
||||
|
||||
if volume > 500000:
|
||||
sprue_d_top = 4.0
|
||||
sprue_d_bottom = 8.0
|
||||
elif volume > 50000:
|
||||
sprue_d_top = 3.0
|
||||
sprue_d_bottom = 6.0
|
||||
else:
|
||||
sprue_d_top = 2.5
|
||||
sprue_d_bottom = 5.0
|
||||
|
||||
sprue_length = max_dim * 0.5 + 20
|
||||
|
||||
taper_angle = math.degrees(
|
||||
math.atan((sprue_d_bottom / 2 - sprue_d_top / 2) / sprue_length)
|
||||
)
|
||||
|
||||
return {
|
||||
"diameter_top": sprue_d_top,
|
||||
"diameter_bottom": sprue_d_bottom,
|
||||
"length": round(sprue_length, 1),
|
||||
"taper_angle": round(taper_angle, 2),
|
||||
"volume": round(
|
||||
math.pi / 3 * sprue_length * (
|
||||
(sprue_d_top / 2) ** 2 + (sprue_d_top / 2) * (sprue_d_bottom / 2) + (sprue_d_bottom / 2) ** 2
|
||||
), 1
|
||||
),
|
||||
}
|
||||
|
||||
def _design_runner(self, dims: List[float], cavity_count: int,
|
||||
positions: Optional[List]) -> Dict:
|
||||
"""设计分流道"""
|
||||
if cavity_count <= 1:
|
||||
return {
|
||||
"type": "none",
|
||||
"diameter": 0,
|
||||
"total_length": 0,
|
||||
"volume": 0,
|
||||
}
|
||||
|
||||
runner_diameter = max(4.0, min(dims[:2]) * 0.04)
|
||||
|
||||
if positions and len(positions) > 1:
|
||||
total_length = 0
|
||||
for pos in positions:
|
||||
total_length += 2 * math.sqrt(pos[0] ** 2 + pos[1] ** 2)
|
||||
else:
|
||||
total_length = cavity_count * max(dims[:2]) * 1.5
|
||||
|
||||
cross_area = math.pi * (runner_diameter / 2) ** 2
|
||||
|
||||
return {
|
||||
"type": "trapezoid",
|
||||
"diameter": round(runner_diameter, 1),
|
||||
"total_length": round(total_length, 1),
|
||||
"volume": round(cross_area * total_length, 1),
|
||||
"cross_section": {
|
||||
"top_width": round(runner_diameter * 1.2, 1),
|
||||
"bottom_width": round(runner_diameter * 0.8, 1),
|
||||
"depth": round(runner_diameter * 0.9, 1),
|
||||
},
|
||||
}
|
||||
|
||||
def _design_gate(self, dims: List[float], gate_type: str,
|
||||
cavity_count: int, mat_props: Dict) -> Dict:
|
||||
"""设计浇口"""
|
||||
min_dim = min(dims[:2])
|
||||
wall_thickness = dims[2] * 0.6
|
||||
|
||||
if gate_type == "center":
|
||||
gate_diameter = max(1.0, wall_thickness * 0.5)
|
||||
return {
|
||||
"type": "center",
|
||||
"diameter": round(gate_diameter, 1),
|
||||
"length": 1.5,
|
||||
"position": "top_center",
|
||||
}
|
||||
elif gate_type == "submarine":
|
||||
gate_diameter = max(0.8, wall_thickness * 0.3)
|
||||
return {
|
||||
"type": "submarine",
|
||||
"diameter": round(gate_diameter, 1),
|
||||
"length": 2.0,
|
||||
"angle": 45,
|
||||
"position": "bottom_side",
|
||||
}
|
||||
elif gate_type == "fan":
|
||||
return {
|
||||
"type": "fan",
|
||||
"width": round(min_dim * 0.3, 1),
|
||||
"depth": round(wall_thickness * 0.5, 1),
|
||||
"length": 1.5,
|
||||
"position": "side",
|
||||
}
|
||||
else:
|
||||
gate_diameter = max(1.0, wall_thickness * 0.4)
|
||||
return {
|
||||
"type": "side",
|
||||
"diameter": round(gate_diameter, 1),
|
||||
"length": 2.0,
|
||||
"position": "side_center",
|
||||
}
|
||||
|
||||
def _design_venting(self, dims: List[float], cavity_count: int) -> Dict:
|
||||
"""设计排气系统"""
|
||||
volume = dims[0] * dims[1] * dims[2]
|
||||
|
||||
if volume > 500000:
|
||||
vent_count = max(4, cavity_count * 2)
|
||||
vent_depth = 0.03
|
||||
vent_width = 8.0
|
||||
elif volume > 50000:
|
||||
vent_count = max(2, cavity_count)
|
||||
vent_depth = 0.02
|
||||
vent_width = 5.0
|
||||
else:
|
||||
vent_count = cavity_count
|
||||
vent_depth = 0.015
|
||||
vent_width = 3.0
|
||||
|
||||
return {
|
||||
"type": "vent_slot",
|
||||
"count": vent_count,
|
||||
"depth_mm": vent_depth,
|
||||
"width_mm": vent_width,
|
||||
"length_mm": 10.0,
|
||||
"positions": "parting_line",
|
||||
}
|
||||
|
||||
def _generate_gating_recommendations(self, gate_type: str, cavity_count: int,
|
||||
dims: List[float], mat_props: Dict) -> List[str]:
|
||||
"""生成浇注系统建议"""
|
||||
recs = []
|
||||
|
||||
if cavity_count > 1:
|
||||
recs.append("多型腔模具建议使用平衡式流道布局")
|
||||
|
||||
if mat_props.get("melt_temp", 0) > 260:
|
||||
recs.append("高熔点材料,建议使用热流道系统减少废料")
|
||||
|
||||
if gate_type == "center":
|
||||
recs.append("中心浇口适用于单型腔,注意浇口痕处理")
|
||||
elif gate_type == "side":
|
||||
recs.append("侧浇口适用于多型腔,需注意流动平衡")
|
||||
|
||||
aspect = max(dims[:2]) / min(dims[:2]) if min(dims[:2]) > 0 else 1
|
||||
if aspect > 3:
|
||||
recs.append("产品长宽比大,建议使用多点进浇或扇形浇口")
|
||||
|
||||
if not recs:
|
||||
recs.append("浇注系统设计合理,建议进行模流分析验证")
|
||||
|
||||
return recs
|
||||
|
||||
|
||||
class MoldSystemDesigner:
|
||||
"""模具系统综合设计器(冷却+浇注)"""
|
||||
|
||||
def __init__(self):
|
||||
self.cooling_designer = CoolingSystemDesigner()
|
||||
self.gating_designer = GatingSystemDesigner()
|
||||
|
||||
def design_complete_system(self, mold_size: Dict, product_bbox: Dict,
|
||||
material: str = "ABS",
|
||||
cavity_count: int = 1,
|
||||
gate_type: str = "auto",
|
||||
cycle_time_target: Optional[float] = None,
|
||||
layout_positions: Optional[List] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
综合设计冷却和浇注系统
|
||||
|
||||
Returns:
|
||||
{
|
||||
"cooling": Dict,
|
||||
"gating": Dict,
|
||||
"overall_assessment": Dict,
|
||||
"recommendations": List[str]
|
||||
}
|
||||
"""
|
||||
cooling = self.cooling_designer.design_cooling_system(
|
||||
mold_size, product_bbox, material, cavity_count, cycle_time_target
|
||||
)
|
||||
|
||||
gating = self.gating_designer.design_gating_system(
|
||||
product_bbox, material, cavity_count, gate_type, layout_positions
|
||||
)
|
||||
|
||||
cooling_time = cooling["cooling_time"]
|
||||
gating_fill_time = self._estimate_fill_time(product_bbox, material)
|
||||
|
||||
total_cycle = cooling_time + gating_fill_time + 5.0
|
||||
|
||||
assessment = {
|
||||
"estimated_cycle_time": round(total_cycle, 1),
|
||||
"cooling_time": cooling_time,
|
||||
"fill_time": round(gating_fill_time, 1),
|
||||
"ejection_time": 3.0,
|
||||
"buffer_time": 2.0,
|
||||
"meets_target": True if cycle_time_target is None else total_cycle <= cycle_time_target,
|
||||
}
|
||||
|
||||
all_recs = cooling.get("recommendations", []) + gating.get("recommendations", [])
|
||||
if assessment["meets_target"] is False:
|
||||
all_recs.insert(0, f"成型周期({total_cycle:.0f}s)超出目标({cycle_time_target}s),需优化冷却系统")
|
||||
|
||||
return {
|
||||
"cooling": cooling,
|
||||
"gating": gating,
|
||||
"overall_assessment": assessment,
|
||||
"recommendations": all_recs,
|
||||
}
|
||||
|
||||
def _estimate_fill_time(self, product_bbox: Dict, material: str) -> float:
|
||||
"""估算填充时间"""
|
||||
dims = product_bbox.get("dimensions", [100, 100, 50])
|
||||
volume = dims[0] * dims[1] * dims[2]
|
||||
|
||||
mat_props = MaterialThermalDB.get_material(material)
|
||||
if mat_props is None:
|
||||
mat_props = MaterialThermalDB.PLASTICS["ABS"]
|
||||
|
||||
fill_rate = 50.0
|
||||
|
||||
fill_time = volume / fill_rate
|
||||
|
||||
return max(0.5, min(fill_time, 10.0))
|
||||
@@ -0,0 +1,343 @@
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.gp import gp_Dir, gp_Pln, gp_Pnt
|
||||
from OCC.Core.TopAbs import TopAbs_FACE
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopoDS import TopoDS_Face, TopoDS_Shape, topods
|
||||
|
||||
from moldinsight.core.mold_generator import MoldCavityGenerator
|
||||
from moldinsight.core.aluminum_foam_mold import AluminumFoamMoldGenerator
|
||||
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
|
||||
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MultiSchemeMoldPlanner:
|
||||
"""针对单个产品生成最多三套候选分模方案并排序。"""
|
||||
|
||||
def __init__(self):
|
||||
self.candidate_generator = PartingCandidateGenerator()
|
||||
self.scheme_scorer = PartingSchemeScorer()
|
||||
self.mold_generator = MoldCavityGenerator(shrinkage_rate=0.005)
|
||||
self.aluminum_foam_generator = AluminumFoamMoldGenerator(
|
||||
shrinkage_rate=0.015,
|
||||
draft_angle=3.0,
|
||||
)
|
||||
|
||||
def generate_plan(
|
||||
self,
|
||||
shape: TopoDS_Shape,
|
||||
material: Dict[str, Any],
|
||||
is_foam_material: bool = False,
|
||||
max_schemes: int = 3,
|
||||
process_params: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
generator = self.aluminum_foam_generator if is_foam_material else self.mold_generator
|
||||
generator.set_material(material["name"])
|
||||
self._apply_process_params(generator, material, process_params)
|
||||
|
||||
analysis = generator._analyze_product_geometry(shape)
|
||||
analysis["axis_normal_stats"] = self._collect_axis_normal_stats(generator, shape)
|
||||
candidates = self.candidate_generator.generate_candidates(
|
||||
analysis=analysis,
|
||||
is_foam_material=is_foam_material,
|
||||
max_candidates=max_schemes,
|
||||
)
|
||||
|
||||
schemes = []
|
||||
for candidate in candidates:
|
||||
for offset_variant in self._build_offset_variants(candidate, is_foam_material):
|
||||
try:
|
||||
scheme = self._build_scheme(
|
||||
generator=generator,
|
||||
shape=shape,
|
||||
analysis=analysis,
|
||||
candidate=offset_variant,
|
||||
is_foam_material=is_foam_material,
|
||||
)
|
||||
if scheme is not None:
|
||||
schemes.append(scheme)
|
||||
except Exception as exc:
|
||||
logger.warning(f"候选方案 {offset_variant.get('scheme_id')} 生成失败: {exc}")
|
||||
|
||||
if not schemes:
|
||||
raise ValueError("未能生成任何可用分模方案")
|
||||
|
||||
scored_schemes = self.scheme_scorer.score_schemes(schemes)[:max_schemes]
|
||||
export_shapes = {}
|
||||
for idx, scheme in enumerate(scored_schemes, start=1):
|
||||
scheme["raw_scheme_id"] = scheme.get("scheme_id")
|
||||
scheme["scheme_id"] = f"scheme_{idx}"
|
||||
if scheme.get("cavity_data", {}).get("metadata") is not None:
|
||||
scheme["cavity_data"]["metadata"]["scheme_id"] = scheme["scheme_id"]
|
||||
scheme["cavity_data"]["metadata"]["process_parameters"] = dict(process_params or {})
|
||||
export_shapes[scheme["scheme_id"]] = scheme.pop("_export_shapes", {})
|
||||
best_scheme = scored_schemes[0]
|
||||
|
||||
return {
|
||||
"best_scheme_id": best_scheme["scheme_id"],
|
||||
"candidate_schemes": scored_schemes,
|
||||
"_export_shapes": export_shapes,
|
||||
"global_summary": {
|
||||
"scheme_count": len(scored_schemes),
|
||||
"recommended_reason": best_scheme.get("summary", ""),
|
||||
},
|
||||
}
|
||||
|
||||
def _build_scheme(
|
||||
self,
|
||||
generator: Any,
|
||||
shape: TopoDS_Shape,
|
||||
analysis: Dict[str, Any],
|
||||
candidate: Dict[str, Any],
|
||||
is_foam_material: bool,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
parting_surface = self._build_parting_surface(
|
||||
generator,
|
||||
analysis,
|
||||
candidate["direction"],
|
||||
shape,
|
||||
candidate.get("offset_ratio", 0.0),
|
||||
candidate.get("opening_span_mm"),
|
||||
)
|
||||
parting_line = generator.optimize_parting_line(
|
||||
generator._calculate_parting_line(shape, parting_surface)
|
||||
)
|
||||
|
||||
parting_direction = candidate["direction"]
|
||||
side_action_result = generator.side_action_designer.analyze_and_design(
|
||||
shape=shape,
|
||||
parting_direction=parting_direction,
|
||||
mold_size=generator._calculate_mold_size(analysis),
|
||||
parting_surface=parting_surface,
|
||||
)
|
||||
undercut_regions = generator._build_undercut_regions(
|
||||
side_action_result.get("undercut_analysis", {})
|
||||
)
|
||||
mold_structure = self._determine_mold_structure(analysis, undercut_regions)
|
||||
|
||||
scaled_shape = generator._apply_shrinkage_compensation(shape)
|
||||
drafted_shape = generator._apply_draft_angles(scaled_shape, parting_surface)
|
||||
cavity, core = generator._split_cavity_core(drafted_shape, parting_surface)
|
||||
|
||||
cavity_result = {
|
||||
"cavity": cavity,
|
||||
"core": core,
|
||||
"parting_surface": parting_surface,
|
||||
"parting_line": parting_line,
|
||||
"analysis": analysis,
|
||||
"undercut_regions": undercut_regions,
|
||||
"side_actions": side_action_result,
|
||||
}
|
||||
|
||||
if is_foam_material:
|
||||
cavity_result["mold_block"] = generator._generate_mold_block(cavity, analysis)
|
||||
cavity_result["parting_surfaces"] = {
|
||||
"primary_surface": parting_surface,
|
||||
"primary_line": parting_line,
|
||||
"primary_direction": parting_direction,
|
||||
"method": candidate["method"],
|
||||
"offset_ratio": candidate.get("offset_ratio", 0.0),
|
||||
}
|
||||
cavity_result["material"] = generator.foam_material
|
||||
cavity_result["shrinkage_applied"] = generator.shrinkage_rate
|
||||
cavity_result["draft_angle_applied"] = generator.draft_angle
|
||||
|
||||
cavity_data = generator.generate_detailed_cavity_json(cavity_result)
|
||||
key_info = generator.generate_cavity_key_info(cavity_result)
|
||||
cavity_data.setdefault("metadata", {})
|
||||
cavity_data["metadata"]["scheme_id"] = candidate["scheme_id"]
|
||||
cavity_data["metadata"]["scheme_method"] = candidate["method"]
|
||||
cavity_data["metadata"]["scheme_axis"] = candidate["axis"]
|
||||
cavity_data["metadata"]["scheme_reason"] = candidate["reason"]
|
||||
cavity_data["metadata"]["scheme_offset_ratio"] = candidate.get("offset_ratio", 0.0)
|
||||
cavity_data["metadata"]["scheme_offset_label"] = candidate.get("offset_label", "中面")
|
||||
cavity_data["metadata"]["mold_structure_type"] = mold_structure["mold_structure_type"]
|
||||
cavity_data["metadata"]["core_required"] = mold_structure["core_required"]
|
||||
cavity_data["metadata"]["structure_decision_reason"] = mold_structure["decision_reason"]
|
||||
|
||||
return {
|
||||
"scheme_id": candidate["scheme_id"],
|
||||
"method": candidate["method"],
|
||||
"axis": candidate["axis"],
|
||||
"title": candidate["title"],
|
||||
"reason": candidate["reason"],
|
||||
"priority_score": candidate.get("priority_score"),
|
||||
"normal_alignment_score": candidate.get("normal_alignment_score"),
|
||||
"offset_ratio": candidate.get("offset_ratio", 0.0),
|
||||
"offset_label": candidate.get("offset_label", "中面"),
|
||||
"mold_structure_type": mold_structure["mold_structure_type"],
|
||||
"core_required": mold_structure["core_required"],
|
||||
"decision_reason": mold_structure["decision_reason"],
|
||||
"parting": {
|
||||
"axis": candidate["axis"],
|
||||
"direction": parting_direction,
|
||||
"line": parting_line,
|
||||
"surface": cavity_data.get("parting_surface", {}),
|
||||
},
|
||||
"undercut_regions": undercut_regions,
|
||||
"side_actions": side_action_result,
|
||||
"cavity_data": cavity_data,
|
||||
"key_info": key_info,
|
||||
"_export_shapes": {
|
||||
"cavity": cavity,
|
||||
"core": core,
|
||||
"parting_surface": parting_surface,
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _apply_process_params(generator: Any, material: Dict[str, Any], process_params: Optional[Dict[str, Any]]):
|
||||
params = process_params or {}
|
||||
draft_angle = float(params.get("draft_angle", getattr(generator, "draft_angle", 2.0)))
|
||||
shrinkage_rate = float(params.get("shrinkage_rate", material.get("shrinkage", 0.005) * 100.0)) / 100.0
|
||||
parting_precision = float(params.get("parting_precision", getattr(generator, "parting_line_tolerance", 0.1)))
|
||||
cavity_match = float(params.get("cavity_match", getattr(generator, "cavity_match_rate", 95.0)))
|
||||
|
||||
generator.draft_angle = draft_angle
|
||||
generator.shrinkage_rate = shrinkage_rate
|
||||
generator.parting_line_tolerance = parting_precision
|
||||
generator.cavity_match_rate = cavity_match
|
||||
|
||||
def _build_parting_surface(
|
||||
self,
|
||||
generator: Any,
|
||||
analysis: Dict[str, Any],
|
||||
direction_vector: List[float],
|
||||
shape: TopoDS_Shape,
|
||||
offset_ratio: float = 0.0,
|
||||
opening_span_mm: Optional[float] = None,
|
||||
) -> TopoDS_Face:
|
||||
center = analysis.get("bounding_box", {}).get("center", [0, 0, 0])
|
||||
dims = analysis.get("bounding_box", {}).get("dimensions", [100, 100, 100])
|
||||
span = max(dims) * 1.5 + 30
|
||||
opening_span = opening_span_mm or max(dims)
|
||||
offset_distance = float(opening_span) * float(offset_ratio)
|
||||
origin = [
|
||||
center[0] + direction_vector[0] * offset_distance,
|
||||
center[1] + direction_vector[1] * offset_distance,
|
||||
center[2] + direction_vector[2] * offset_distance,
|
||||
]
|
||||
|
||||
plane = gp_Pln(
|
||||
gp_Pnt(origin[0], origin[1], origin[2]),
|
||||
gp_Dir(direction_vector[0], direction_vector[1], direction_vector[2]),
|
||||
)
|
||||
|
||||
parting_surface = BRepBuilderAPI_MakeFace(
|
||||
plane,
|
||||
-span,
|
||||
span,
|
||||
-span,
|
||||
span,
|
||||
).Face()
|
||||
|
||||
return generator.extend_parting_surface(parting_surface, shape, extension=30.0)
|
||||
|
||||
def _build_offset_variants(
|
||||
self,
|
||||
candidate: Dict[str, Any],
|
||||
is_foam_material: bool,
|
||||
) -> List[Dict[str, Any]]:
|
||||
opening_span = float(candidate.get("opening_span_mm", 0.0))
|
||||
if opening_span <= 0:
|
||||
return [dict(candidate)]
|
||||
|
||||
ratios = [0.0, -0.12, 0.12]
|
||||
if is_foam_material and candidate.get("axis") == "Z":
|
||||
ratios = [0.0, -0.08, 0.08]
|
||||
|
||||
variants = []
|
||||
for ratio in ratios:
|
||||
variant = dict(candidate)
|
||||
label = "中面"
|
||||
id_label = "center"
|
||||
if ratio < 0:
|
||||
label = "偏下" if candidate.get("axis") == "Z" else "负向偏移"
|
||||
id_label = "neg"
|
||||
elif ratio > 0:
|
||||
label = "偏上" if candidate.get("axis") == "Z" else "正向偏移"
|
||||
id_label = "pos"
|
||||
|
||||
variant["scheme_id"] = f"{candidate.get('axis', 'A').lower()}_{id_label}_{abs(ratio):.2f}"
|
||||
variant["offset_ratio"] = ratio
|
||||
variant["offset_label"] = label
|
||||
variant["reason"] = f"{candidate.get('reason', '')},分型面位置: {label}"
|
||||
variants.append(variant)
|
||||
|
||||
return variants
|
||||
|
||||
def _collect_axis_normal_stats(self, generator: Any, shape: TopoDS_Shape) -> Dict[str, float]:
|
||||
"""按坐标轴统计面法向分布强度,用于候选方向排序。"""
|
||||
stats = {"X": 0.0, "Y": 0.0, "Z": 0.0}
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
|
||||
while explorer.More():
|
||||
face = topods.Face(explorer.Current())
|
||||
explorer.Next()
|
||||
|
||||
try:
|
||||
normal = generator._get_face_normal(face)
|
||||
if normal is None:
|
||||
continue
|
||||
|
||||
props = GProp_GProps()
|
||||
brepgprop.SurfaceProperties(face, props)
|
||||
area = max(float(props.Mass()), 1.0)
|
||||
|
||||
stats["X"] += abs(float(normal.X())) * area
|
||||
stats["Y"] += abs(float(normal.Y())) * area
|
||||
stats["Z"] += abs(float(normal.Z())) * area
|
||||
except Exception as exc:
|
||||
logger.debug(f"统计面法向失败: {exc}")
|
||||
|
||||
total = stats["X"] + stats["Y"] + stats["Z"]
|
||||
if total <= 0:
|
||||
return {"X": 33.3, "Y": 33.3, "Z": 33.4}
|
||||
|
||||
return {
|
||||
axis: round(value / total * 100, 2)
|
||||
for axis, value in stats.items()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _determine_mold_structure(analysis: Dict[str, Any], undercut_regions: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""
|
||||
判定是否需要独立模芯。
|
||||
规则为工程启发式:
|
||||
- 实心度高 + 平均厚度占比高 + 无明显倒扣:倾向两板半腔(无独立凸芯)
|
||||
- 否则:采用型腔+模芯结构
|
||||
"""
|
||||
dims = analysis.get("bounding_box", {}).get("dimensions", [0.0, 0.0, 0.0])
|
||||
valid_dims = [float(d) for d in dims if float(d) > 1e-6]
|
||||
min_dim = min(valid_dims) if valid_dims else 1.0
|
||||
bbox_volume = 1.0
|
||||
for dim in valid_dims[:3]:
|
||||
bbox_volume *= dim
|
||||
if bbox_volume <= 0:
|
||||
bbox_volume = 1.0
|
||||
|
||||
volume = float(analysis.get("volume", 0.0))
|
||||
surface_area = float(analysis.get("surface_area", 0.0))
|
||||
solid_ratio = max(0.0, min(volume / bbox_volume, 1.0))
|
||||
avg_wall = (2.0 * volume / surface_area) if surface_area > 1e-6 else min_dim
|
||||
wall_ratio = max(0.0, min(avg_wall / max(min_dim, 1e-6), 1.0))
|
||||
undercut_count = len(undercut_regions or [])
|
||||
|
||||
core_required = not (solid_ratio > 0.62 and wall_ratio > 0.38 and undercut_count == 0)
|
||||
mold_structure_type = "cavity_core" if core_required else "two_half_cavity"
|
||||
decision_reason = (
|
||||
f"solid_ratio={solid_ratio:.2f}, wall_ratio={wall_ratio:.2f}, "
|
||||
f"undercut_count={undercut_count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"core_required": core_required,
|
||||
"mold_structure_type": mold_structure_type,
|
||||
"decision_reason": decision_reason,
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
from typing import Dict, Any, List
|
||||
|
||||
|
||||
class PartingCandidateGenerator:
|
||||
"""生成候选分型方向,供多方案分模规划器使用。"""
|
||||
|
||||
_AXIS_DEFS = {
|
||||
"X": {"direction": [1.0, 0.0, 0.0], "title": "X轴侧向开模方案"},
|
||||
"Y": {"direction": [0.0, 1.0, 0.0], "title": "Y轴侧向开模方案"},
|
||||
"Z": {"direction": [0.0, 0.0, 1.0], "title": "Z轴上下开模方案"},
|
||||
}
|
||||
|
||||
def generate_candidates(
|
||||
self,
|
||||
analysis: Dict[str, Any],
|
||||
is_foam_material: bool = False,
|
||||
max_candidates: int = 3,
|
||||
) -> List[Dict[str, Any]]:
|
||||
bbox_dims = analysis.get("bounding_box", {}).get("dimensions", [0, 0, 0])
|
||||
|
||||
axis_metrics = self._build_axis_metrics(bbox_dims, analysis, is_foam_material)
|
||||
axis_order = [item["axis"] for item in sorted(
|
||||
axis_metrics,
|
||||
key=lambda item: item["priority_score"],
|
||||
reverse=True,
|
||||
)]
|
||||
|
||||
candidates = []
|
||||
for idx, axis in enumerate(axis_order[:max_candidates], start=1):
|
||||
axis_def = self._AXIS_DEFS[axis]
|
||||
metrics = next(item for item in axis_metrics if item["axis"] == axis)
|
||||
candidates.append({
|
||||
"scheme_id": f"scheme_{idx}",
|
||||
"rank_hint": idx,
|
||||
"axis": axis,
|
||||
"direction": axis_def["direction"],
|
||||
"title": axis_def["title"] if idx > 1 else "推荐候选方向",
|
||||
"method": metrics["method"],
|
||||
"projected_area_cm2": metrics["projected_area_cm2"],
|
||||
"opening_span_mm": metrics["opening_span_mm"],
|
||||
"priority_score": metrics["priority_score"],
|
||||
"reason": self._build_reason(metrics, is_foam_material),
|
||||
})
|
||||
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _projected_area_for_axis(bbox_dims: List[float], axis: str) -> float:
|
||||
if len(bbox_dims) < 3:
|
||||
return 0.0
|
||||
|
||||
if axis == "X":
|
||||
return (bbox_dims[1] * bbox_dims[2]) / 100
|
||||
if axis == "Y":
|
||||
return (bbox_dims[0] * bbox_dims[2]) / 100
|
||||
return (bbox_dims[0] * bbox_dims[1]) / 100
|
||||
|
||||
def _build_axis_metrics(
|
||||
self,
|
||||
bbox_dims: List[float],
|
||||
analysis: Dict[str, Any],
|
||||
is_foam_material: bool,
|
||||
) -> List[Dict[str, Any]]:
|
||||
padded_dims = (bbox_dims + [0.0, 0.0, 0.0])[:3]
|
||||
max_dim = max(max(padded_dims), 1.0)
|
||||
max_area = max(
|
||||
self._projected_area_for_axis(padded_dims, axis)
|
||||
for axis in ("X", "Y", "Z")
|
||||
) or 1.0
|
||||
inertia_matrix = analysis.get("inertia_matrix", [])
|
||||
inertia_diag = [
|
||||
float(inertia_matrix[i][i]) if i < len(inertia_matrix) and i < len(inertia_matrix[i]) else 0.0
|
||||
for i in range(3)
|
||||
]
|
||||
max_inertia = max(max(inertia_diag), 1.0)
|
||||
axis_normal_stats = analysis.get("axis_normal_stats", {})
|
||||
|
||||
metrics = []
|
||||
for axis, idx in (("X", 0), ("Y", 1), ("Z", 2)):
|
||||
opening_span = float(padded_dims[idx])
|
||||
projected_area = self._projected_area_for_axis(padded_dims, axis)
|
||||
thin_axis_score = (max_dim - opening_span) / max_dim
|
||||
compact_projection_score = 1.0 - min(projected_area / max_area, 1.0)
|
||||
inertia_score = 1.0 - min((inertia_diag[idx] if idx < len(inertia_diag) else 0.0) / max_inertia, 1.0)
|
||||
normal_alignment_score = min(float(axis_normal_stats.get(axis, 0.0)) / 100.0, 1.0)
|
||||
|
||||
priority_score = (
|
||||
thin_axis_score * 0.30
|
||||
+ compact_projection_score * 0.25
|
||||
+ inertia_score * 0.15
|
||||
+ normal_alignment_score * 0.30
|
||||
)
|
||||
method = "geometric_primary"
|
||||
if normal_alignment_score >= thin_axis_score and normal_alignment_score >= compact_projection_score:
|
||||
method = "face_normal_primary"
|
||||
elif compact_projection_score >= thin_axis_score and compact_projection_score >= inertia_score:
|
||||
method = "projected_area_backup"
|
||||
elif inertia_score > thin_axis_score:
|
||||
method = "balanced_backup"
|
||||
|
||||
if is_foam_material and axis == "Z":
|
||||
priority_score += 0.25
|
||||
method = "foam_axis_rule"
|
||||
|
||||
metrics.append({
|
||||
"axis": axis,
|
||||
"opening_span_mm": round(opening_span, 2),
|
||||
"projected_area_cm2": round(projected_area, 2),
|
||||
"thin_axis_score": round(thin_axis_score * 100, 2),
|
||||
"compact_projection_score": round(compact_projection_score * 100, 2),
|
||||
"inertia_score": round(inertia_score * 100, 2),
|
||||
"normal_alignment_score": round(normal_alignment_score * 100, 2),
|
||||
"priority_score": round(priority_score * 100, 2),
|
||||
"method": method,
|
||||
})
|
||||
|
||||
return metrics
|
||||
|
||||
@staticmethod
|
||||
def _build_reason(metrics: Dict[str, Any], is_foam_material: bool) -> str:
|
||||
axis = metrics["axis"]
|
||||
projected_area = metrics["projected_area_cm2"]
|
||||
opening_span = metrics["opening_span_mm"]
|
||||
if is_foam_material and axis == "Z":
|
||||
return (
|
||||
f"泡沫模具优先上下开模,开模跨度 {opening_span:.2f} mm,"
|
||||
f"投影面积约 {projected_area:.2f} cm²"
|
||||
)
|
||||
return (
|
||||
f"{axis} 轴方向开模跨度 {opening_span:.2f} mm,"
|
||||
f"投影面积约 {projected_area:.2f} cm²,"
|
||||
f"法向匹配度 {metrics.get('normal_alignment_score', 0):.2f},"
|
||||
f"综合几何优先级 {metrics['priority_score']:.2f}"
|
||||
)
|
||||
@@ -0,0 +1,300 @@
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
import re
|
||||
|
||||
|
||||
class PartingSchemeScorer:
|
||||
"""对候选分模方案打分并排序。"""
|
||||
|
||||
def score_schemes(self, schemes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
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.25
|
||||
+ score_breakdown["undercut_complexity"] * 0.35
|
||||
+ score_breakdown["parting_quality"] * 0.15
|
||||
+ score_breakdown["machining_cost"] * 0.15
|
||||
+ score_breakdown["risk"] * 0.10
|
||||
+ undercut_priority_bonus,
|
||||
2,
|
||||
)
|
||||
|
||||
scored_scheme = dict(scheme)
|
||||
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)
|
||||
scored_scheme["dfm_violation_count"] = len(scored_scheme["dfm_violations"])
|
||||
scored_scheme["confidence_score"] = self._build_confidence_score(
|
||||
total_score,
|
||||
score_breakdown,
|
||||
fallback["is_fallback"],
|
||||
scored_scheme["dfm_violation_count"],
|
||||
)
|
||||
scored_scheme["summary"] = self._build_summary(scored_scheme)
|
||||
scored.append(scored_scheme)
|
||||
|
||||
scored.sort(key=lambda item: item["score"], reverse=True)
|
||||
for rank, scheme in enumerate(scored, start=1):
|
||||
scheme["rank"] = rank
|
||||
scheme["title"] = "推荐方案" if rank == 1 else f"备选方案 {rank}"
|
||||
return scored
|
||||
|
||||
def _score_scheme(self, scheme: Dict[str, Any]) -> Dict[str, float]:
|
||||
cavity_data = scheme.get("cavity_data", {})
|
||||
key_info = scheme.get("key_info", {})
|
||||
candidate_priority = float(scheme.get("priority_score", 60.0))
|
||||
offset_ratio = abs(float(scheme.get("offset_ratio", 0.0)))
|
||||
|
||||
mold_cavities = cavity_data.get("mold_cavities", {})
|
||||
quality_checks = cavity_data.get("quality_checks", {})
|
||||
quality_considerations = key_info.get("quality_considerations", {})
|
||||
manufacturing_info = cavity_data.get("manufacturing_info", {})
|
||||
|
||||
cavity_vertices = mold_cavities.get("cavity", {}).get("vertex_count", 0)
|
||||
core_vertices = mold_cavities.get("core", {}).get("vertex_count", 0)
|
||||
manufacturability = 95.0 if cavity_vertices > 0 and core_vertices > 0 else 55.0
|
||||
|
||||
undercut_regions = quality_checks.get("undercut_regions") or cavity_data.get("undercut_regions", [])
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
smoothness = quality_checks.get("parting_line_smoothness", 85.0)
|
||||
parting_quality = max(
|
||||
40.0,
|
||||
min(
|
||||
100.0,
|
||||
smoothness - min(parting_length / 100.0, 20.0) + 10.0 + candidate_priority * 0.10 - offset_ratio * 25.0
|
||||
)
|
||||
)
|
||||
|
||||
mold_size = manufacturing_info.get("estimated_mold_size", {})
|
||||
mold_volume_factor = (
|
||||
float(mold_size.get("length", 0))
|
||||
* float(mold_size.get("width", 0))
|
||||
* float(mold_size.get("height", 0))
|
||||
) / 1_000_000 if mold_size else 0.0
|
||||
machining_cost = max(35.0, 95.0 - min(mold_volume_factor / 10.0, 25.0) - slider_count * 5.0)
|
||||
|
||||
warpage_risk = str(quality_considerations.get("warpage_risk", "low")).lower()
|
||||
risk_base = 92.0
|
||||
if "高" in warpage_risk or "high" in warpage_risk:
|
||||
risk_base = 55.0
|
||||
elif "中" in warpage_risk or "medium" in warpage_risk:
|
||||
risk_base = 75.0
|
||||
clamping_force = self._parse_first_number(manufacturing_info.get("estimated_clamping_force", "0"))
|
||||
if clamping_force > 500:
|
||||
risk_base -= 8.0
|
||||
if scheme.get("method") == "foam_axis_rule":
|
||||
risk_base += 4.0
|
||||
risk = max(35.0, risk_base)
|
||||
|
||||
return {
|
||||
"manufacturability": round(manufacturability, 2),
|
||||
"undercut_complexity": round(undercut_complexity, 2),
|
||||
"parting_quality": round(parting_quality, 2),
|
||||
"machining_cost": round(machining_cost, 2),
|
||||
"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", {})
|
||||
cavity_mesh = mold_cavities.get("cavity", {})
|
||||
core_mesh = mold_cavities.get("core", {})
|
||||
core_required = bool(scheme.get("core_required", True))
|
||||
|
||||
cavity_v = int(cavity_mesh.get("vertex_count", 0) or 0)
|
||||
core_v = int(core_mesh.get("vertex_count", 0) or 0)
|
||||
reasons = []
|
||||
|
||||
if cavity_v <= 0:
|
||||
reasons.append("型腔网格为空")
|
||||
if core_required and core_v <= 0:
|
||||
reasons.append("型芯网格为空")
|
||||
if float(score_breakdown.get("manufacturability", 0.0)) < 70.0:
|
||||
reasons.append("可制造性评分偏低")
|
||||
|
||||
return {
|
||||
"is_fallback": len(reasons) > 0,
|
||||
"fallback_reason": ";".join(reasons) if reasons else "",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_confidence_score(
|
||||
total_score: float,
|
||||
score_breakdown: Dict[str, float],
|
||||
is_fallback: bool,
|
||||
dfm_violation_count: int = 0,
|
||||
) -> float:
|
||||
confidence = float(total_score)
|
||||
confidence += (float(score_breakdown.get("manufacturability", 0.0)) - 70.0) * 0.25
|
||||
confidence += (float(score_breakdown.get("parting_quality", 0.0)) - 70.0) * 0.15
|
||||
if is_fallback:
|
||||
confidence -= 18.0
|
||||
confidence -= min(max(dfm_violation_count, 0) * 3.0, 15.0)
|
||||
return round(max(20.0, min(99.0, confidence)), 2)
|
||||
|
||||
def _build_dfm_violations(self, scheme: Dict[str, Any]) -> List[Dict[str, str]]:
|
||||
cavity_data = scheme.get("cavity_data", {})
|
||||
manufacturing_info = cavity_data.get("manufacturing_info", {})
|
||||
key_info = scheme.get("key_info", {})
|
||||
geometric = key_info.get("geometric_characteristics", {})
|
||||
quality = key_info.get("quality_considerations", {})
|
||||
metadata = cavity_data.get("metadata", {})
|
||||
|
||||
violations: List[Dict[str, str]] = []
|
||||
|
||||
wall_min, wall_max = self._parse_wall_range(
|
||||
geometric.get("wall_thickness_range", "")
|
||||
)
|
||||
if wall_min is not None and wall_min < 1.2:
|
||||
violations.append({
|
||||
"rule": "最小壁厚",
|
||||
"level": "high",
|
||||
"message": f"最小壁厚 {wall_min:.2f}mm 偏薄,可能导致短射/强度不足",
|
||||
})
|
||||
if wall_max is not None and wall_max > 6.0:
|
||||
violations.append({
|
||||
"rule": "最大壁厚",
|
||||
"level": "medium",
|
||||
"message": f"最大壁厚 {wall_max:.2f}mm 偏厚,存在缩痕与冷却不均风险",
|
||||
})
|
||||
|
||||
draft_angle = self._parse_first_number(metadata.get("draft_angle"))
|
||||
if draft_angle and draft_angle < 1.0:
|
||||
violations.append({
|
||||
"rule": "拔模角",
|
||||
"level": "medium",
|
||||
"message": f"拔模角 {draft_angle:.2f}° 偏小,脱模阻力较大",
|
||||
})
|
||||
|
||||
warpage = str(quality.get("warpage_risk", "")).lower()
|
||||
if "high" in warpage or "高" in warpage:
|
||||
violations.append({
|
||||
"rule": "翘曲风险",
|
||||
"level": "high",
|
||||
"message": "当前方案翘曲风险高,建议优化壁厚与浇口位置",
|
||||
})
|
||||
|
||||
clamping_force = self._parse_first_number(
|
||||
manufacturing_info.get("estimated_clamping_force")
|
||||
)
|
||||
if clamping_force > 1200:
|
||||
violations.append({
|
||||
"rule": "锁模力",
|
||||
"level": "medium",
|
||||
"message": f"预估锁模力 {clamping_force:.0f} 吨,设备适配窗口较窄",
|
||||
})
|
||||
|
||||
return violations
|
||||
|
||||
def _build_summary(self, scheme: Dict[str, Any]) -> str:
|
||||
cavity_data = scheme.get("cavity_data", {})
|
||||
quality_checks = cavity_data.get("quality_checks", {})
|
||||
manufacturing_info = cavity_data.get("manufacturing_info", {})
|
||||
side_actions = quality_checks.get("side_actions") or cavity_data.get("side_actions", {})
|
||||
undercut_count = len(quality_checks.get("undercut_regions") or cavity_data.get("undercut_regions", []))
|
||||
slider_count = len(side_actions.get("slider_mechanisms", []))
|
||||
lifter_count = len(side_actions.get("lifter_mechanisms", []))
|
||||
axis = scheme.get("parting", {}).get("axis", "Z")
|
||||
offset_label = scheme.get("offset_label", "中面")
|
||||
clamping_force = manufacturing_info.get("estimated_clamping_force", "自动计算")
|
||||
structure_type = scheme.get("mold_structure_type", "cavity_core")
|
||||
structure_text = "型腔+模芯" if structure_type == "cavity_core" else "两板半腔(无独立模芯)"
|
||||
return (
|
||||
f"{axis} 轴开模,结构 {structure_text},分型面位置 {offset_label},倒扣 {undercut_count} 处,"
|
||||
f"滑块 {slider_count} 组,斜顶 {lifter_count} 组,"
|
||||
f"预估锁模力 {clamping_force}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _calculate_polyline_length(points: List[List[float]]) -> float:
|
||||
total = 0.0
|
||||
for idx in range(1, len(points)):
|
||||
p1 = points[idx - 1]
|
||||
p2 = points[idx]
|
||||
total += ((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2 + (p2[2] - p1[2]) ** 2) ** 0.5
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
def _parse_first_number(value: Any) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
matches = re.findall(r"\d+(?:\.\d+)?", str(value))
|
||||
return float(matches[0]) if matches else 0.0
|
||||
|
||||
@staticmethod
|
||||
def _parse_wall_range(value: Any) -> Tuple[Optional[float], Optional[float]]:
|
||||
if value is None:
|
||||
return None, None
|
||||
nums = re.findall(r"\d+(?:\.\d+)?", str(value))
|
||||
if not nums:
|
||||
return None, None
|
||||
if len(nums) == 1:
|
||||
v = float(nums[0])
|
||||
return v, v
|
||||
return float(nums[0]), float(nums[1])
|
||||
@@ -0,0 +1,533 @@
|
||||
"""
|
||||
侧壁/倒扣面滑块机构检测与设计模块
|
||||
|
||||
功能:
|
||||
1. 倒扣区域检测 - 识别无法直接脱模的侧壁凹槽
|
||||
2. 滑块机构设计 - 侧向分型抽芯机构
|
||||
3. 斜顶机构设计 - 内侧倒扣的斜顶脱模机构
|
||||
4. 机构运动学分析 - 抽芯行程、脱模角度计算
|
||||
|
||||
倒扣检测原理:
|
||||
- 分型方向确定后,检查每个面的法向量
|
||||
- 如果面的法向量与脱模方向的点积为负(面朝向脱模反方向)
|
||||
且该面不在分型面上,则判定为倒扣面
|
||||
- 根据倒扣面的位置(外侧/内侧)选择滑块或斜顶
|
||||
|
||||
滑块 vs 斜顶:
|
||||
- 滑块:外侧倒扣,沿导滑槽侧向运动
|
||||
- 斜顶:内侧倒扣,沿斜导柱内侧运动
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import math
|
||||
import numpy as np
|
||||
from OCC.Core.TopoDS import TopoDS_Shape, TopoDS_Face
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UndercutDetector:
|
||||
"""倒扣区域检测器"""
|
||||
|
||||
def detect_undercuts(self, shape: TopoDS_Shape, parting_direction: List[float],
|
||||
parting_surface: Optional[TopoDS_Face] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
检测产品中的倒扣区域
|
||||
|
||||
Args:
|
||||
shape: OCC 产品形状
|
||||
parting_direction: 分型方向 [nx, ny, nz]
|
||||
parting_surface: 分型面(可选)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"undercut_faces": List[Dict],
|
||||
"slider_regions": List[Dict],
|
||||
"lifter_regions": List[Dict],
|
||||
"total_undercut_area": float,
|
||||
"requires_slider": bool,
|
||||
"requires_lifter": bool,
|
||||
"complexity": str
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE
|
||||
from OCC.Core.TopoDS import TopoDS_Face, topods
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
from OCC.Core.gp import gp_Dir
|
||||
|
||||
dir_vec = np.array(parting_direction, dtype=np.float64)
|
||||
dir_norm = np.linalg.norm(dir_vec)
|
||||
if dir_norm < 1e-6:
|
||||
dir_vec = np.array([0, 0, 1])
|
||||
else:
|
||||
dir_vec /= dir_norm
|
||||
|
||||
parting_dir = gp_Dir(dir_vec[0], dir_vec[1], dir_vec[2])
|
||||
|
||||
undercut_faces = []
|
||||
slider_regions = []
|
||||
lifter_regions = []
|
||||
total_undercut_area = 0.0
|
||||
|
||||
parting_z = 0.0
|
||||
if parting_surface is not None:
|
||||
try:
|
||||
surface = BRepAdaptor_Surface(parting_surface)
|
||||
if surface.GetType() == 0:
|
||||
parting_z = surface.Plane().Location().Z()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
face_idx = 0
|
||||
|
||||
while explorer.More():
|
||||
face = topods.Face(explorer.Current())
|
||||
face_idx += 1
|
||||
|
||||
try:
|
||||
surface = BRepAdaptor_Surface(face)
|
||||
u = (surface.FirstUParameter() + surface.LastUParameter()) / 2
|
||||
v = (surface.FirstVParameter() + surface.LastVParameter()) / 2
|
||||
|
||||
face_normal = None
|
||||
if surface.GetType() == 0:
|
||||
face_normal = surface.Plane().Position().Direction()
|
||||
else:
|
||||
from OCC.Core.BRepLProp import BRepLProp_SLProps
|
||||
props = BRepLProp_SLProps(surface, 1, 0.001)
|
||||
props.SetParameters(u, v)
|
||||
if props.IsNormalDefined():
|
||||
face_normal = props.Normal()
|
||||
|
||||
if face_normal is None:
|
||||
explorer.Next()
|
||||
continue
|
||||
|
||||
dot = face_normal.Dot(parting_dir)
|
||||
|
||||
face_props = GProp_GProps()
|
||||
brepgprop.SurfaceProperties(face, face_props)
|
||||
area = face_props.Mass()
|
||||
center = face_props.CentreOfMass()
|
||||
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(face, bbox)
|
||||
try:
|
||||
fxmin, fymin, fzmin, fxmax, fymax, fzmax = bbox.Get()
|
||||
except Exception:
|
||||
fxmin, fymin, fzmin, fxmax, fymax, fzmax = 0, 0, 0, 0, 0, 0
|
||||
|
||||
if dot < -0.1:
|
||||
face_center_z = center.Z()
|
||||
is_outer = face_center_z >= parting_z
|
||||
|
||||
undercut_info = {
|
||||
"face_index": face_idx,
|
||||
"normal": [face_normal.X(), face_normal.Y(), face_normal.Z()],
|
||||
"dot_product": float(dot),
|
||||
"area": float(area),
|
||||
"center": [float(center.X()), float(center.Y()), float(center.Z())],
|
||||
"bbox": {
|
||||
"min": [float(fxmin), float(fymin), float(fzmin)],
|
||||
"max": [float(fxmax), float(fymax), float(fzmax)]
|
||||
},
|
||||
"severity": "high" if dot < -0.5 else "medium",
|
||||
"is_outer": is_outer,
|
||||
}
|
||||
undercut_faces.append(undercut_info)
|
||||
total_undercut_area += area
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
explorer.Next()
|
||||
|
||||
for uf in undercut_faces:
|
||||
normal = np.array(uf["normal"])
|
||||
lateral_component = normal - np.dot(normal, dir_vec) * dir_vec
|
||||
lateral_norm = np.linalg.norm(lateral_component)
|
||||
|
||||
if lateral_norm > 0.01:
|
||||
slide_direction = lateral_component / lateral_norm
|
||||
else:
|
||||
slide_direction = np.array([1, 0, 0])
|
||||
|
||||
mechanism = {
|
||||
"face_indices": [uf["face_index"]],
|
||||
"slide_direction": slide_direction.tolist(),
|
||||
"area": uf["area"],
|
||||
"center": uf["center"],
|
||||
"severity": uf["severity"],
|
||||
}
|
||||
|
||||
if uf["is_outer"]:
|
||||
slider_regions.append(mechanism)
|
||||
else:
|
||||
lifter_regions.append(mechanism)
|
||||
|
||||
requires_slider = len(slider_regions) > 0
|
||||
requires_lifter = len(lifter_regions) > 0
|
||||
|
||||
total_count = len(slider_regions) + len(lifter_regions)
|
||||
if total_count == 0:
|
||||
complexity = "simple"
|
||||
elif total_count <= 2:
|
||||
complexity = "moderate"
|
||||
elif total_count <= 4:
|
||||
complexity = "complex"
|
||||
else:
|
||||
complexity = "very_complex"
|
||||
|
||||
result = {
|
||||
"undercut_faces": undercut_faces,
|
||||
"slider_regions": slider_regions,
|
||||
"lifter_regions": lifter_regions,
|
||||
"total_undercut_area": total_undercut_area,
|
||||
"requires_slider": requires_slider,
|
||||
"requires_lifter": requires_lifter,
|
||||
"complexity": complexity,
|
||||
"parting_direction": parting_direction,
|
||||
}
|
||||
|
||||
logger.info(f"倒扣检测完成: {len(undercut_faces)} 个倒扣面, "
|
||||
f"{len(slider_regions)} 个滑块, {len(lifter_regions)} 个斜顶, "
|
||||
f"复杂度={complexity}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"倒扣检测失败: {e}")
|
||||
return {
|
||||
"undercut_faces": [],
|
||||
"slider_regions": [],
|
||||
"lifter_regions": [],
|
||||
"total_undercut_area": 0,
|
||||
"requires_slider": False,
|
||||
"requires_lifter": False,
|
||||
"complexity": "unknown",
|
||||
"parting_direction": parting_direction,
|
||||
}
|
||||
|
||||
|
||||
class SliderMechanismDesigner:
|
||||
"""滑块机构设计器"""
|
||||
|
||||
def design_slider(self, slider_region: Dict, mold_size: Dict,
|
||||
parting_direction: List[float]) -> Dict[str, Any]:
|
||||
"""
|
||||
设计滑块机构
|
||||
|
||||
Args:
|
||||
slider_region: 倒扣区域信息
|
||||
mold_size: 模具尺寸
|
||||
parting_direction: 分型方向
|
||||
|
||||
Returns:
|
||||
滑块机构设计方案
|
||||
"""
|
||||
center = slider_region["center"]
|
||||
area = slider_region["area"]
|
||||
slide_dir = slider_region["slide_direction"]
|
||||
|
||||
slide_stroke = self._calculate_slide_stroke(slider_region, mold_size)
|
||||
|
||||
slide_angle = self._calculate_slide_angle(slide_dir, parting_direction)
|
||||
|
||||
slide_block_size = self._calculate_slide_block_size(area, slide_stroke)
|
||||
|
||||
guide_type = self._select_guide_type(slide_stroke, slide_angle)
|
||||
|
||||
return {
|
||||
"type": "slider",
|
||||
"location": center,
|
||||
"slide_direction": slide_dir,
|
||||
"slide_stroke": slide_stroke,
|
||||
"slide_angle": slide_angle,
|
||||
"block_size": slide_block_size,
|
||||
"guide_type": guide_type,
|
||||
"locking_mechanism": self._select_locking(slide_angle),
|
||||
"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),
|
||||
}
|
||||
|
||||
def _calculate_slide_stroke(self, region: Dict, mold_size: Dict) -> float:
|
||||
"""计算抽芯行程"""
|
||||
bbox = region.get("bbox", {})
|
||||
if "max" in bbox and "min" in bbox:
|
||||
max_dim = max(
|
||||
abs(bbox["max"][0] - bbox["min"][0]),
|
||||
abs(bbox["max"][1] - bbox["min"][1]),
|
||||
abs(bbox["max"][2] - bbox["min"][2])
|
||||
)
|
||||
else:
|
||||
max_dim = 10.0
|
||||
|
||||
stroke = max_dim + 5.0
|
||||
return round(max(stroke, 10.0), 1)
|
||||
|
||||
def _calculate_slide_angle(self, slide_dir: List[float],
|
||||
parting_dir: List[float]) -> float:
|
||||
"""计算滑块倾斜角度"""
|
||||
s = np.array(slide_dir)
|
||||
p = np.array(parting_dir)
|
||||
|
||||
s_norm = np.linalg.norm(s)
|
||||
p_norm = np.linalg.norm(p)
|
||||
|
||||
if s_norm < 1e-6 or p_norm < 1e-6:
|
||||
return 90.0
|
||||
|
||||
cos_angle = np.clip(np.dot(s, p) / (s_norm * p_norm), -1, 1)
|
||||
angle = math.degrees(math.acos(abs(cos_angle)))
|
||||
return round(angle, 1)
|
||||
|
||||
def _calculate_slide_block_size(self, area: float, stroke: float) -> Dict[str, float]:
|
||||
"""计算滑块尺寸"""
|
||||
width = max(math.sqrt(area) * 1.5, 15.0)
|
||||
height = max(math.sqrt(area) * 1.2, 12.0)
|
||||
length = stroke + width * 0.5
|
||||
|
||||
return {
|
||||
"width": round(width, 1),
|
||||
"height": round(height, 1),
|
||||
"length": round(length, 1),
|
||||
}
|
||||
|
||||
def _select_guide_type(self, stroke: float, angle: float) -> str:
|
||||
"""选择导滑方式"""
|
||||
if stroke > 80:
|
||||
return "T_slot_guide"
|
||||
elif angle > 20:
|
||||
return "angled_guide_pin"
|
||||
else:
|
||||
return "dovetail_guide"
|
||||
|
||||
def _select_locking(self, angle: float) -> str:
|
||||
"""选择锁紧方式"""
|
||||
if angle > 25:
|
||||
return "wedge_block"
|
||||
else:
|
||||
return "lock_block"
|
||||
|
||||
def _generate_components(self, block_size: Dict, guide_type: str) -> List[Dict]:
|
||||
"""生成滑块组件清单"""
|
||||
components = [
|
||||
{"name": "slide_block", "material": "P20", "hardness": "HRC 28-32"},
|
||||
{"name": "guide_strip", "material": "bronze", "hardness": "HB 80-100"},
|
||||
{"name": "wear_plate", "material": "T8", "hardness": "HRC 45-50"},
|
||||
{"name": "return_spring", "material": "spring_steel", "spec": "standard"},
|
||||
]
|
||||
|
||||
if guide_type == "T_slot_guide":
|
||||
components.append({"name": "T_slot_insert", "material": "P20", "hardness": "HRC 28-32"})
|
||||
elif guide_type == "angled_guide_pin":
|
||||
components.append({"name": "guide_pin", "material": "SUJ2", "hardness": "HRC 58-62"})
|
||||
elif guide_type == "dovetail_guide":
|
||||
components.append({"name": "dovetail_block", "material": "P20", "hardness": "HRC 28-32"})
|
||||
|
||||
return components
|
||||
|
||||
def _generate_slider_notes(self, angle: float, stroke: float) -> List[str]:
|
||||
"""生成滑块加工注意事项"""
|
||||
notes = []
|
||||
if angle > 25:
|
||||
notes.append("滑块角度较大,需确保锁紧可靠")
|
||||
if stroke > 50:
|
||||
notes.append("抽芯行程较长,建议使用气动抽芯")
|
||||
if stroke > 80:
|
||||
notes.append("大行程抽芯,需校核导滑槽强度")
|
||||
notes.append("滑块需设置限位装置,防止脱出")
|
||||
notes.append("配合面需做耐磨处理")
|
||||
return notes
|
||||
|
||||
|
||||
class LifterMechanismDesigner:
|
||||
"""斜顶机构设计器"""
|
||||
|
||||
def design_lifter(self, lifter_region: Dict, mold_size: Dict,
|
||||
parting_direction: List[float]) -> Dict[str, Any]:
|
||||
"""
|
||||
设计斜顶机构
|
||||
|
||||
Args:
|
||||
lifter_region: 内侧倒扣区域信息
|
||||
mold_size: 模具尺寸
|
||||
parting_direction: 分型方向
|
||||
|
||||
Returns:
|
||||
斜顶机构设计方案
|
||||
"""
|
||||
center = lifter_region["center"]
|
||||
area = lifter_region["area"]
|
||||
|
||||
lifter_angle = self._calculate_lifter_angle(lifter_region)
|
||||
|
||||
lifter_stroke = self._calculate_lifter_stroke(lifter_region)
|
||||
|
||||
lifter_size = self._calculate_lifter_size(area, lifter_stroke, lifter_angle)
|
||||
|
||||
return {
|
||||
"type": "lifter",
|
||||
"location": center,
|
||||
"lifter_angle": lifter_angle,
|
||||
"lifter_stroke": lifter_stroke,
|
||||
"block_size": lifter_size,
|
||||
"guide_type": "angled_hole",
|
||||
"return_mechanism": "spring_return",
|
||||
"components": self._generate_lifter_components(lifter_size),
|
||||
"manufacturing_notes": self._generate_lifter_notes(lifter_angle),
|
||||
}
|
||||
|
||||
def _calculate_lifter_angle(self, region: Dict) -> float:
|
||||
"""计算斜顶角度(通常5-15度)"""
|
||||
return 8.0
|
||||
|
||||
def _calculate_lifter_stroke(self, region: Dict) -> float:
|
||||
"""计算斜顶行程"""
|
||||
bbox = region.get("bbox", {})
|
||||
if "max" in bbox and "min" in bbox:
|
||||
max_dim = max(
|
||||
abs(bbox["max"][i] - bbox["min"][i]) for i in range(3)
|
||||
)
|
||||
else:
|
||||
max_dim = 5.0
|
||||
|
||||
return round(max(max_dim + 3.0, 8.0), 1)
|
||||
|
||||
def _calculate_lifter_size(self, area: float, stroke: float,
|
||||
angle: float) -> Dict[str, float]:
|
||||
"""计算斜顶尺寸"""
|
||||
width = max(math.sqrt(area) * 1.2, 10.0)
|
||||
height = stroke / math.sin(math.radians(angle)) if angle > 0 else stroke * 3
|
||||
thickness = max(width * 0.6, 8.0)
|
||||
|
||||
return {
|
||||
"width": round(width, 1),
|
||||
"height": round(height, 1),
|
||||
"thickness": round(thickness, 1),
|
||||
}
|
||||
|
||||
def _generate_lifter_components(self, size: Dict) -> List[Dict]:
|
||||
"""生成斜顶组件清单"""
|
||||
return [
|
||||
{"name": "lifter_body", "material": "P20", "hardness": "HRC 28-32"},
|
||||
{"name": "guide_pin", "material": "SUJ2", "hardness": "HRC 58-62"},
|
||||
{"name": "return_spring", "material": "spring_steel", "spec": "standard"},
|
||||
{"name": "wear_bushing", "material": "bronze", "hardness": "HB 80-100"},
|
||||
]
|
||||
|
||||
def _generate_lifter_notes(self, angle: float) -> List[str]:
|
||||
"""生成斜顶加工注意事项"""
|
||||
notes = []
|
||||
if angle > 12:
|
||||
notes.append("斜顶角度偏大,需校核脱模力")
|
||||
notes.append("斜顶导滑孔需精确加工")
|
||||
notes.append("斜顶头部需做耐磨处理")
|
||||
notes.append("需设置限位防止斜顶脱出")
|
||||
return notes
|
||||
|
||||
|
||||
class SideActionDesigner:
|
||||
"""侧向分型机构综合设计器"""
|
||||
|
||||
def __init__(self):
|
||||
self.undercut_detector = UndercutDetector()
|
||||
self.slider_designer = SliderMechanismDesigner()
|
||||
self.lifter_designer = LifterMechanismDesigner()
|
||||
|
||||
def analyze_and_design(self, shape: TopoDS_Shape, parting_direction: List[float],
|
||||
mold_size: Dict, parting_surface: Optional[TopoDS_Face] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
综合分析倒扣并设计侧向分型机构
|
||||
|
||||
Returns:
|
||||
{
|
||||
"undercut_analysis": Dict,
|
||||
"slider_mechanisms": List[Dict],
|
||||
"lifter_mechanisms": List[Dict],
|
||||
"summary": Dict,
|
||||
"recommendations": List[str]
|
||||
}
|
||||
"""
|
||||
logger.info("开始侧向分型机构分析...")
|
||||
|
||||
undercut_result = self.undercut_detector.detect_undercuts(
|
||||
shape, parting_direction, parting_surface
|
||||
)
|
||||
|
||||
slider_mechanisms = []
|
||||
for region in undercut_result["slider_regions"]:
|
||||
slider = self.slider_designer.design_slider(
|
||||
region, mold_size, parting_direction
|
||||
)
|
||||
slider_mechanisms.append(slider)
|
||||
|
||||
lifter_mechanisms = []
|
||||
for region in undercut_result["lifter_regions"]:
|
||||
lifter = self.lifter_designer.design_lifter(
|
||||
region, mold_size, parting_direction
|
||||
)
|
||||
lifter_mechanisms.append(lifter)
|
||||
|
||||
total_mechanisms = len(slider_mechanisms) + len(lifter_mechanisms)
|
||||
|
||||
summary = {
|
||||
"total_undercut_faces": len(undercut_result["undercut_faces"]),
|
||||
"total_slider_count": len(slider_mechanisms),
|
||||
"total_lifter_count": len(lifter_mechanisms),
|
||||
"total_mechanism_count": total_mechanisms,
|
||||
"complexity": undercut_result["complexity"],
|
||||
}
|
||||
|
||||
recommendations = self._generate_overall_recommendations(summary, undercut_result)
|
||||
|
||||
result = {
|
||||
"undercut_analysis": undercut_result,
|
||||
"slider_mechanisms": slider_mechanisms,
|
||||
"lifter_mechanisms": lifter_mechanisms,
|
||||
"summary": summary,
|
||||
"recommendations": recommendations,
|
||||
}
|
||||
|
||||
logger.info(f"侧向分型机构设计完成: {len(slider_mechanisms)} 个滑块, "
|
||||
f"{len(lifter_mechanisms)} 个斜顶")
|
||||
|
||||
return result
|
||||
|
||||
def _generate_overall_recommendations(self, summary: Dict,
|
||||
undercut: Dict) -> List[str]:
|
||||
"""生成总体建议"""
|
||||
recs = []
|
||||
|
||||
if summary["total_mechanism_count"] == 0:
|
||||
recs.append("无倒扣区域,模具结构简单,无需侧向分型机构")
|
||||
return recs
|
||||
|
||||
if summary["total_slider_count"] > 0:
|
||||
recs.append(f"需要 {summary['total_slider_count']} 个滑块机构处理外侧倒扣")
|
||||
|
||||
if summary["total_lifter_count"] > 0:
|
||||
recs.append(f"需要 {summary['total_lifter_count']} 个斜顶机构处理内侧倒扣")
|
||||
|
||||
if summary["total_slider_count"] > 0:
|
||||
recs.append("如存在大行程滑块,建议优先评估气动抽芯回路并预留稳定供气")
|
||||
|
||||
if summary["complexity"] == "very_complex":
|
||||
recs.append("侧向分型机构复杂,建议评估是否可通过产品修改简化")
|
||||
recs.append("考虑使用二次分型或旋转脱模替代方案")
|
||||
|
||||
if summary["total_mechanism_count"] > 3:
|
||||
recs.append("侧向机构较多,建议优化模具结构减少机构数量")
|
||||
|
||||
recs.append("所有侧向机构需做运动仿真验证干涉")
|
||||
|
||||
return recs
|
||||
@@ -0,0 +1,289 @@
|
||||
# core/stp_parser.py
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
import numpy as np
|
||||
import json
|
||||
from shared.utils.logger import get_logger
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
from OCC.Core.TopoDS import TopoDS_Shape
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class STPParser:
|
||||
"""STP文件解析器"""
|
||||
|
||||
def __init__(self):
|
||||
# 强制要求PythonOCC必须可用
|
||||
self._verify_occ_availability()
|
||||
|
||||
def _verify_occ_availability(self):
|
||||
"""验证PythonOCC是否可用,不可用则抛出异常"""
|
||||
try:
|
||||
from OCC.Core.STEPControl import STEPControl_Reader
|
||||
from OCC.Core.IFSelect import IFSelect_RetDone
|
||||
logger.info("PythonOCC验证通过")
|
||||
except ImportError as e:
|
||||
logger.error("PythonOCC不可用,服务无法运行")
|
||||
raise RuntimeError("PythonOCC未安装,请安装PythonOCC后再运行服务") from e
|
||||
|
||||
|
||||
|
||||
def load_step_file(self, file_path: Path) -> TopoDS_Shape:
|
||||
"""加载STP文件"""
|
||||
try:
|
||||
from OCC.Core.STEPControl import STEPControl_Reader
|
||||
from OCC.Core.IFSelect import IFSelect_RetDone
|
||||
|
||||
logger.info(f"加载STP文件: {file_path}")
|
||||
reader = STEPControl_Reader()
|
||||
status = reader.ReadFile(str(file_path))
|
||||
|
||||
if status == IFSelect_RetDone:
|
||||
reader.TransferRoots()
|
||||
shape = reader.OneShape()
|
||||
logger.info("STP文件加载成功")
|
||||
return shape
|
||||
else:
|
||||
raise ValueError(f"STP文件读取失败,状态码: {status}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"STP解析失败: {e}")
|
||||
raise
|
||||
|
||||
def analyze_geometry(self, shape: TopoDS_Shape) -> Dict[str, Any]:
|
||||
"""分析几何属性"""
|
||||
|
||||
try:
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
|
||||
|
||||
logger.info("开始几何分析...")
|
||||
|
||||
# 计算边界框
|
||||
bbox = self._compute_bounding_box(shape)
|
||||
|
||||
# 计算体积和表面积
|
||||
volume = self._compute_volume(shape)
|
||||
area = self._compute_surface_area(shape)
|
||||
|
||||
# 分析拓扑
|
||||
topology = self._analyze_topology(shape)
|
||||
|
||||
# 计算质心
|
||||
center_of_mass = self._compute_center_of_mass(shape)
|
||||
|
||||
# 计算惯性属性
|
||||
inertia_properties = self._compute_inertia_properties(shape)
|
||||
|
||||
result = {
|
||||
"bounding_box": bbox,
|
||||
"volume": float(volume),
|
||||
"surface_area": float(area),
|
||||
"topology": topology,
|
||||
"center_of_mass": center_of_mass,
|
||||
"inertia_properties": inertia_properties,
|
||||
"analysis_method": "pythonocc"
|
||||
}
|
||||
|
||||
logger.info("几何分析完成")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"几何分析失败: {e}")
|
||||
raise
|
||||
|
||||
def _compute_bounding_box(self, shape: TopoDS_Shape) -> Dict[str, Any]:
|
||||
"""计算边界框"""
|
||||
try:
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
|
||||
return {
|
||||
"min": [float(xmin), float(ymin), float(zmin)],
|
||||
"max": [float(xmax), float(ymax), float(zmax)],
|
||||
"dimensions": [
|
||||
float(xmax - xmin),
|
||||
float(ymax - ymin),
|
||||
float(zmax - zmin)
|
||||
],
|
||||
"center": [
|
||||
float((xmin + xmax) / 2),
|
||||
float((ymin + ymax) / 2),
|
||||
float((zmin + zmax) / 2)
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"边界框计算失败: {e}")
|
||||
return self._default_bounding_box()
|
||||
|
||||
def _compute_volume(self, shape: TopoDS_Shape) -> float:
|
||||
"""计算体积"""
|
||||
try:
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
|
||||
props = GProp_GProps()
|
||||
brepgprop.VolumeProperties(shape, props)
|
||||
volume = props.Mass()
|
||||
if volume <= 0:
|
||||
raise ValueError("计算得到的体积为0或负数,形状可能无效")
|
||||
return volume
|
||||
except Exception as e:
|
||||
logger.error(f"体积计算失败: {e}")
|
||||
raise RuntimeError(f"体积计算失败: {e}") from e
|
||||
|
||||
def _compute_surface_area(self, shape: TopoDS_Shape) -> float:
|
||||
"""计算表面积"""
|
||||
try:
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
|
||||
props = GProp_GProps()
|
||||
brepgprop.SurfaceProperties(shape, props)
|
||||
area = props.Mass()
|
||||
|
||||
# 如果计算结果为0,使用备选估算方法
|
||||
if area <= 0:
|
||||
logger.warning("表面积计算结果为0,使用边界框估算")
|
||||
raise ValueError("Surface area is zero")
|
||||
|
||||
return area
|
||||
except ValueError:
|
||||
# 基于边界框估算表面积
|
||||
try:
|
||||
bbox = self._compute_bounding_box(shape)
|
||||
dims = bbox.get("dimensions", [0, 0, 0])
|
||||
if any(d <= 0 for d in dims):
|
||||
raise RuntimeError("边界框尺寸无效,无法估算表面积")
|
||||
# 简化的估算公式:2*(lw + lh + wh)
|
||||
estimated_area = 2 * (dims[0]*dims[1] + dims[0]*dims[2] + dims[1]*dims[2])
|
||||
logger.warning(f"使用边界框估算表面积: {estimated_area:.2f} mm²")
|
||||
return estimated_area
|
||||
except Exception as e:
|
||||
logger.error(f"表面积估算失败: {e}")
|
||||
raise RuntimeError(f"表面积计算失败: {e}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"表面积计算失败: {e}")
|
||||
raise RuntimeError(f"表面积计算失败: {e}") from e
|
||||
|
||||
def _compute_center_of_mass(self, shape: TopoDS_Shape) -> List[float]:
|
||||
"""计算质心"""
|
||||
try:
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
|
||||
props = GProp_GProps()
|
||||
brepgprop.VolumeProperties(shape, props)
|
||||
center = props.CentreOfMass()
|
||||
return [float(center.X()), float(center.Y()), float(center.Z())]
|
||||
except Exception as e:
|
||||
logger.error(f"质心计算失败: {e}")
|
||||
# 回退到边界框中心
|
||||
try:
|
||||
bbox = self._compute_bounding_box(shape)
|
||||
return bbox.get("center", [0.0, 0.0, 0.0])
|
||||
except Exception:
|
||||
raise RuntimeError(f"质心计算失败且边界框回退也失败: {e}") from e
|
||||
|
||||
def _compute_inertia_properties(self, shape: TopoDS_Shape) -> Dict[str, Any]:
|
||||
"""计算惯性属性"""
|
||||
try:
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
|
||||
props = GProp_GProps()
|
||||
brepgprop.VolumeProperties(shape, props)
|
||||
|
||||
inertia = props.MatrixOfInertia()
|
||||
return {
|
||||
"mass": float(props.Mass()),
|
||||
"moment_of_inertia": [
|
||||
[float(inertia.Value(1, 1)), float(inertia.Value(1, 2)), float(inertia.Value(1, 3))],
|
||||
[float(inertia.Value(2, 1)), float(inertia.Value(2, 2)), float(inertia.Value(2, 3))],
|
||||
[float(inertia.Value(3, 1)), float(inertia.Value(3, 2)), float(inertia.Value(3, 3))]
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"惯性属性计算失败: {e}")
|
||||
return {}
|
||||
|
||||
def _analyze_topology(self, shape: TopoDS_Shape) -> Dict[str, int]:
|
||||
"""分析拓扑"""
|
||||
try:
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX
|
||||
|
||||
def count_elements(element_type):
|
||||
explorer = TopExp_Explorer(shape, element_type)
|
||||
count = 0
|
||||
while explorer.More():
|
||||
count += 1
|
||||
explorer.Next()
|
||||
return count
|
||||
|
||||
return {
|
||||
"faces": count_elements(TopAbs_FACE),
|
||||
"edges": count_elements(TopAbs_EDGE),
|
||||
"vertices": count_elements(TopAbs_VERTEX)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"拓扑分析失败: {e}")
|
||||
raise
|
||||
|
||||
def _default_bounding_box(self) -> Dict[str, Any]:
|
||||
"""默认边界框(边界框计算失败时的回退值,标注为估算)"""
|
||||
return {
|
||||
"min": [0.0, 0.0, 0.0],
|
||||
"max": [0.0, 0.0, 0.0],
|
||||
"dimensions": [0.0, 0.0, 0.0],
|
||||
"center": [0.0, 0.0, 0.0],
|
||||
"estimated": True
|
||||
}
|
||||
|
||||
def export_to_json(self, geometry_data: Dict[str, Any], output_path: Path) -> str:
|
||||
"""将几何数据导出为JSON文件"""
|
||||
try:
|
||||
# 确保输出目录存在
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 添加元数据
|
||||
json_data = {
|
||||
"metadata": {
|
||||
"export_time": str(np.datetime64('now')),
|
||||
"analysis_method": geometry_data.get("analysis_method", "unknown"),
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"geometry_data": geometry_data
|
||||
}
|
||||
|
||||
# 保存JSON文件
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(json_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"几何数据已导出到: {output_path}")
|
||||
return str(output_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"JSON导出失败: {e}")
|
||||
raise
|
||||
|
||||
def get_json_data(self, geometry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""获取JSON格式的几何数据"""
|
||||
return {
|
||||
"metadata": {
|
||||
"export_time": str(np.datetime64('now')),
|
||||
"analysis_method": geometry_data.get("analysis_method", "unknown"),
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"geometry_data": geometry_data
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Services 模块
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
铝金属价格数据服务
|
||||
|
||||
提供铝金属的当前价格和历史价格走势数据。
|
||||
数据来源优先级:
|
||||
1. 外部API(预留接口)
|
||||
2. 模拟真实走势数据(当前使用)
|
||||
|
||||
数据基于上海期货交易所(SHFE)铝期货价格走势特征生成。
|
||||
"""
|
||||
import random
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
BASE_PRICE = 18950.0
|
||||
PRICE_VOLATILITY = 120.0
|
||||
TREND_DRIFT = 0.3
|
||||
|
||||
|
||||
def _daily_seed(date_str: str) -> float:
|
||||
h = hashlib.md5(date_str.encode()).hexdigest()
|
||||
seed = int(h[:8], 16) / (16 ** 8)
|
||||
return seed
|
||||
|
||||
|
||||
def get_aluminum_current_price() -> Dict:
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
seed = _daily_seed(today)
|
||||
random.seed(int(seed * 1_000_000))
|
||||
|
||||
price = BASE_PRICE + (seed - 0.5) * PRICE_VOLATILITY * 2
|
||||
price = round(price, 0)
|
||||
|
||||
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
prev_seed = _daily_seed(yesterday)
|
||||
prev_price = BASE_PRICE + (prev_seed - 0.5) * PRICE_VOLATILITY * 2
|
||||
prev_price = round(prev_price, 0)
|
||||
|
||||
change = price - prev_price
|
||||
change_percent = round((change / prev_price) * 100, 2)
|
||||
|
||||
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
week_seed = _daily_seed(week_ago)
|
||||
week_price = BASE_PRICE + (week_seed - 0.5) * PRICE_VOLATILITY * 2
|
||||
|
||||
random.seed()
|
||||
|
||||
return {
|
||||
"price": price,
|
||||
"unit": "元/吨",
|
||||
"currency": "CNY",
|
||||
"date": today,
|
||||
"change": round(change, 0),
|
||||
"change_percent": change_percent,
|
||||
"open": round(price - random.uniform(10, 50), 0),
|
||||
"high": round(price + random.uniform(10, 60), 0),
|
||||
"low": round(price - random.uniform(10, 60), 0),
|
||||
"prev_close": prev_price,
|
||||
"week_ago_price": round(week_price, 0),
|
||||
}
|
||||
|
||||
|
||||
def get_aluminum_price_history(days: int = 30) -> List[Dict]:
|
||||
history = []
|
||||
random.seed(42)
|
||||
|
||||
price_line = BASE_PRICE
|
||||
for i in range(days, -1, -1):
|
||||
date = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
date_seed = _daily_seed(date)
|
||||
|
||||
drift = (date_seed - 0.5) * TREND_DRIFT
|
||||
noise = (date_seed - 0.5) * PRICE_VOLATILITY * 1.5
|
||||
price_line = price_line + drift + noise * 0.3
|
||||
price_line = max(18200, min(19800, price_line))
|
||||
|
||||
open_price = round(price_line + (date_seed - 0.5) * 80, 0)
|
||||
high_price = round(open_price + abs(date_seed - 0.5) * 160, 0)
|
||||
low_price = round(open_price - abs(date_seed - 0.5) * 140, 0)
|
||||
close_price = round(price_line, 0)
|
||||
|
||||
history.append({
|
||||
"date": date,
|
||||
"open": open_price,
|
||||
"high": high_price,
|
||||
"low": low_price,
|
||||
"close": close_price,
|
||||
})
|
||||
|
||||
random.seed()
|
||||
return history
|
||||
@@ -0,0 +1,427 @@
|
||||
# services/calculation_service.py
|
||||
"""模具工程参数计算服务 — 从 process_file_core 中抽取的纯计算逻辑"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class CalculationService:
|
||||
"""将 process_file_core 中的工程计算逻辑抽取为独立服务,方便单测和复用"""
|
||||
|
||||
# ─── 基础计算 ───
|
||||
|
||||
@staticmethod
|
||||
def calculate_product_weight(volume_mm3: float, density: float) -> float:
|
||||
"""计算产品重量(克)"""
|
||||
volume_cm3 = volume_mm3 / 1000
|
||||
return volume_cm3 * density
|
||||
|
||||
@staticmethod
|
||||
def calculate_projected_area(bbox_dims: List[float], parting_direction: str = "Z") -> float:
|
||||
"""
|
||||
计算投影面积(cm²)
|
||||
|
||||
Args:
|
||||
bbox_dims: [长度, 宽度, 高度] (mm)
|
||||
parting_direction: 开模方向,"Z" 表示上下开模(投影到XY平面),
|
||||
"Y" 表示前后开模(投影到XZ平面),
|
||||
"X" 表示左右开模(投影到YZ平面)
|
||||
"""
|
||||
if len(bbox_dims) < 3:
|
||||
return 0.0
|
||||
if parting_direction == "Z":
|
||||
# Z轴开模 → 投影面积 = 长度 × 宽度
|
||||
return (bbox_dims[0] * bbox_dims[1]) / 100
|
||||
elif parting_direction == "Y":
|
||||
return (bbox_dims[0] * bbox_dims[2]) / 100
|
||||
elif parting_direction == "X":
|
||||
return (bbox_dims[1] * bbox_dims[2]) / 100
|
||||
# 默认 Z 轴
|
||||
return (bbox_dims[0] * bbox_dims[1]) / 100
|
||||
|
||||
@staticmethod
|
||||
def calculate_cavity_count(product_weight_g: float, projected_area_cm2: float) -> int:
|
||||
"""
|
||||
计算最优型腔数量
|
||||
基于产品重量和投影面积:
|
||||
- 小产品(< 50g)可以多型腔
|
||||
- 大产品(> 1000g)通常单型腔
|
||||
"""
|
||||
if product_weight_g < 50:
|
||||
cavity_count = 8
|
||||
elif product_weight_g < 100:
|
||||
cavity_count = 4
|
||||
elif product_weight_g < 300:
|
||||
cavity_count = 2
|
||||
else:
|
||||
cavity_count = 1
|
||||
|
||||
# 根据投影面积调整
|
||||
if projected_area_cm2 > 400:
|
||||
cavity_count = 1
|
||||
elif projected_area_cm2 > 200 and cavity_count > 2:
|
||||
cavity_count = 2
|
||||
|
||||
return cavity_count
|
||||
|
||||
@staticmethod
|
||||
def calculate_clamping_force(
|
||||
projected_area_cm2: float,
|
||||
cavity_count: int,
|
||||
runner_ratio: float = 0.20,
|
||||
injection_pressure: float = 700,
|
||||
is_foam: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
计算所需夹紧力(吨)
|
||||
|
||||
塑料模具: 锁模力 = 投影面积 × 型腔数 × (1+流道比) × 注塑压力 / 1000
|
||||
泡沫模具: 锁模力 = 投影面积(cm²) × 0.3 (泡沫材料系数)
|
||||
|
||||
Args:
|
||||
projected_area_cm2: 投影面积 cm²
|
||||
cavity_count: 型腔数
|
||||
runner_ratio: 流道系统占型腔投影面积比(0.15-0.25)
|
||||
injection_pressure: 注塑压力 kg/cm²
|
||||
is_foam: 是否泡沫材料
|
||||
"""
|
||||
if is_foam:
|
||||
# 泡沫模具: 锁模力(吨) = 投影面积(cm²) × 0.3
|
||||
clamping_force_ton = int(projected_area_cm2 * 0.3)
|
||||
else:
|
||||
total_projected_area = projected_area_cm2 * cavity_count * (1 + runner_ratio)
|
||||
clamping_force_ton = int(total_projected_area * injection_pressure / 1000)
|
||||
return max(50, min(clamping_force_ton, 3000))
|
||||
|
||||
@staticmethod
|
||||
def calculate_wall_thickness(volume_mm3: float, surface_area_mm2: float) -> Dict[str, float]:
|
||||
"""计算壁厚范围"""
|
||||
if surface_area_mm2 > 0 and volume_mm3 > 0:
|
||||
avg = (volume_mm3 / surface_area_mm2) * 0.6
|
||||
return {
|
||||
"avg_thickness_mm": avg,
|
||||
"wall_thickness_min": avg * 0.7,
|
||||
"wall_thickness_max": avg * 1.3,
|
||||
}
|
||||
return {
|
||||
"avg_thickness_mm": 2.5,
|
||||
"wall_thickness_min": 2.0,
|
||||
"wall_thickness_max": 3.0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def calculate_complexity(avg_thickness_mm: float) -> float:
|
||||
"""计算复杂度评分(0~1)"""
|
||||
return min((avg_thickness_mm / 5.0), 1.0) if avg_thickness_mm > 0 else 0.5
|
||||
|
||||
@staticmethod
|
||||
def calculate_mold_size(
|
||||
bbox_dims: List[float], cavity_count: int,
|
||||
cavity_spacing: float = 30, edge_margin: float = 50,
|
||||
) -> Dict[str, float]:
|
||||
"""计算模具尺寸(长×宽×高),单位 mm"""
|
||||
dim_x = max(bbox_dims[0] if len(bbox_dims) > 0 else 120, 120)
|
||||
dim_y = max(bbox_dims[1] if len(bbox_dims) > 1 else 100, 100)
|
||||
dim_z = max(bbox_dims[2] if len(bbox_dims) > 2 else 60, 60)
|
||||
|
||||
if cavity_count == 1:
|
||||
length = dim_x + 2 * edge_margin
|
||||
width = dim_y + 2 * edge_margin
|
||||
elif cavity_count == 2:
|
||||
length = 2 * dim_x + cavity_spacing + 2 * edge_margin
|
||||
width = dim_y + 2 * edge_margin
|
||||
elif cavity_count == 4:
|
||||
length = 2 * dim_x + cavity_spacing + 2 * edge_margin
|
||||
width = 2 * dim_y + cavity_spacing + 2 * edge_margin
|
||||
else: # 8 型腔: 2x4
|
||||
length = 4 * dim_x + 3 * cavity_spacing + 2 * edge_margin
|
||||
width = 2 * dim_y + cavity_spacing + 2 * edge_margin
|
||||
|
||||
height = dim_z + 80 # 包含冷却系统
|
||||
|
||||
return {"length": length, "width": width, "height": height}
|
||||
|
||||
@staticmethod
|
||||
def calculate_parting_line_length(bbox_dims: List[float], cavity_count: int) -> float:
|
||||
"""计算分型线长度(mm)"""
|
||||
if len(bbox_dims) >= 2:
|
||||
return 2 * (bbox_dims[0] + bbox_dims[1]) * cavity_count
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def calculate_cycle_time(
|
||||
wall_thickness_max: float, volume_cm3: float, cavity_count: int,
|
||||
) -> int:
|
||||
"""
|
||||
估算成型周期(秒)
|
||||
周期 = 冷却时间 + 注塑时间 + 顶出时间 + 开合模时间
|
||||
"""
|
||||
cooling_time = (wall_thickness_max ** 2) * 4
|
||||
injection_time = max(3, volume_cm3 / 100)
|
||||
ejection_time = 3
|
||||
cycle_time = cooling_time + injection_time + ejection_time + 5
|
||||
|
||||
# 多型腔需要更长冷却时间
|
||||
if cavity_count > 1:
|
||||
cycle_time = cycle_time * (1 + 0.1 * (cavity_count - 1))
|
||||
|
||||
return int(cycle_time)
|
||||
|
||||
# ─── 组装方法 ───
|
||||
|
||||
@classmethod
|
||||
def build_detailed_cavity_json(
|
||||
cls,
|
||||
geometry_data: Dict[str, Any],
|
||||
material: Dict[str, Any],
|
||||
file_path: str,
|
||||
cavity_mesh_data: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
组装完整的 detailed_cavity_json(整合以上所有计算结果)
|
||||
|
||||
Args:
|
||||
geometry_data: STP 解析得到的几何数据
|
||||
material: MaterialService.get_material() 返回的材料属性字典
|
||||
file_path: STP 文件路径
|
||||
cavity_mesh_data: 型腔网格数据(可选)
|
||||
"""
|
||||
volume_mm3 = geometry_data.get("volume", 0)
|
||||
surface_area_mm2 = geometry_data.get("surface_area", 0)
|
||||
bbox = geometry_data.get("bounding_box", {})
|
||||
bbox_dims = bbox.get("dimensions", [0, 0, 0])
|
||||
|
||||
material_density = material["density"]
|
||||
shrinkage_rate = material["shrinkage"]
|
||||
is_foam = material.get("is_foam", False)
|
||||
|
||||
# 泡沫模具优先 Z 轴开模(上下开模)
|
||||
parting_direction = "Z"
|
||||
|
||||
# 各项计算
|
||||
volume_cm3 = volume_mm3 / 1000
|
||||
product_weight_g = cls.calculate_product_weight(volume_mm3, material_density)
|
||||
projected_area_cm2 = cls.calculate_projected_area(bbox_dims, parting_direction)
|
||||
cavity_count = cls.calculate_cavity_count(product_weight_g, projected_area_cm2)
|
||||
clamping_force_ton = cls.calculate_clamping_force(
|
||||
projected_area_cm2, cavity_count, is_foam=is_foam
|
||||
)
|
||||
wall = cls.calculate_wall_thickness(volume_mm3, surface_area_mm2)
|
||||
complexity_score = cls.calculate_complexity(wall["avg_thickness_mm"])
|
||||
mold_size = cls.calculate_mold_size(bbox_dims, cavity_count)
|
||||
parting_line_length = cls.calculate_parting_line_length(bbox_dims, cavity_count)
|
||||
cycle_time = cls.calculate_cycle_time(wall["wall_thickness_max"], volume_cm3, cavity_count)
|
||||
|
||||
injection_pressure = 700 # kg/cm²
|
||||
|
||||
detailed_cavity_json = {
|
||||
"metadata": {
|
||||
"file_name": Path(file_path).name,
|
||||
"analysis_date": datetime.now().isoformat(),
|
||||
"shrinkage_rate": shrinkage_rate,
|
||||
"draft_angle": 2.0,
|
||||
"selected_material": material["name"],
|
||||
"is_foam": is_foam,
|
||||
"parting_direction": parting_direction,
|
||||
},
|
||||
"product_analysis": {
|
||||
"volume": volume_mm3,
|
||||
"surface_area": surface_area_mm2,
|
||||
"bounding_box": bbox,
|
||||
},
|
||||
"manufacturing_info": {
|
||||
"recommended_material": material["name"],
|
||||
"material_density": f"{material_density} g/cm³",
|
||||
"estimated_clamping_force": f"{clamping_force_ton} 吨",
|
||||
"clamping_force_formula": (
|
||||
"投影面积(cm²) × 0.3" if is_foam
|
||||
else "投影面积 × 型腔数 × (1+流道比) × 注塑压力 / 1000"
|
||||
),
|
||||
"estimated_mold_size": {
|
||||
"length": int(mold_size["length"]),
|
||||
"width": int(mold_size["width"]),
|
||||
"height": int(mold_size["height"]),
|
||||
},
|
||||
"mold_material": "铝合金7075" if clamping_force_ton < 200 else "P20钢材",
|
||||
"mold_hardness": "HB 150-170" if clamping_force_ton < 200 else "HRC 28-32",
|
||||
"surface_finish": "Ra 0.8 μm",
|
||||
"parting_line_length": f"{parting_line_length:.2f} mm",
|
||||
"estimated_cycle_time": f"{cycle_time} 秒",
|
||||
"injection_pressure": f"{injection_pressure} kg/cm²",
|
||||
"parting_direction": parting_direction,
|
||||
},
|
||||
"mold_cavities": {
|
||||
"cavity_count": cavity_count,
|
||||
},
|
||||
}
|
||||
|
||||
# 合并型腔网格数据
|
||||
if cavity_mesh_data and "mold_cavities" in cavity_mesh_data:
|
||||
mold_cavities = cavity_mesh_data["mold_cavities"]
|
||||
for key in ("cavity", "core", "parting_surface"):
|
||||
if key in mold_cavities:
|
||||
detailed_cavity_json["mold_cavities"][key] = mold_cavities[key]
|
||||
|
||||
# 合并分模附加信息,保持普通模具与铝泡沫模具输出结构一致
|
||||
if cavity_mesh_data:
|
||||
if cavity_mesh_data.get("parting_surface"):
|
||||
detailed_cavity_json["parting_surface"] = cavity_mesh_data["parting_surface"]
|
||||
|
||||
quality_checks = cavity_mesh_data.get("quality_checks", {})
|
||||
if quality_checks:
|
||||
detailed_cavity_json["quality_checks"] = quality_checks
|
||||
|
||||
undercut_regions = quality_checks.get("undercut_regions")
|
||||
if undercut_regions:
|
||||
detailed_cavity_json["undercut_regions"] = undercut_regions
|
||||
|
||||
side_actions = quality_checks.get("side_actions")
|
||||
if side_actions:
|
||||
detailed_cavity_json["side_actions"] = side_actions
|
||||
|
||||
# 添加型腔关键信息
|
||||
detailed_cavity_json["mold_cavities"]["cavity_key_info"] = {
|
||||
"geometric_characteristics": {
|
||||
"product_weight": f"{product_weight_g:.2f} g",
|
||||
"wall_thickness_range": f"{wall['wall_thickness_min']:.2f} - {wall['wall_thickness_max']:.2f} mm",
|
||||
"complexity_score": round(complexity_score, 2),
|
||||
"product_volume": f"{volume_cm3:.2f} cm³",
|
||||
"projected_area": f"{projected_area_cm2:.2f} cm²",
|
||||
},
|
||||
"quality_considerations": {
|
||||
"undercut_count": len(detailed_cavity_json.get("undercut_regions", [])),
|
||||
"side_action_summary": detailed_cavity_json.get("side_actions", {}).get("summary", {}),
|
||||
"potential_weld_lines": "center" if cavity_count > 1 else "minimal",
|
||||
"sink_mark_areas": "thick_sections" if wall["wall_thickness_max"] > 4 else "minimal",
|
||||
"warpage_risk": "medium" if wall["wall_thickness_max"] > 5 else "low",
|
||||
},
|
||||
}
|
||||
|
||||
return detailed_cavity_json
|
||||
|
||||
@classmethod
|
||||
def build_plan_result(
|
||||
cls,
|
||||
geometry_data: Dict[str, Any],
|
||||
material: Dict[str, Any],
|
||||
file_path: str,
|
||||
plan_result: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""构建多方案分模结果,并保留单方案兼容字段。"""
|
||||
if not plan_result or not plan_result.get("candidate_schemes"):
|
||||
legacy = cls.build_detailed_cavity_json(
|
||||
geometry_data=geometry_data,
|
||||
material=material,
|
||||
file_path=file_path,
|
||||
cavity_mesh_data=None,
|
||||
)
|
||||
result = {
|
||||
"best_scheme_id": "scheme_1",
|
||||
"candidate_schemes": [
|
||||
{
|
||||
"scheme_id": "scheme_1",
|
||||
"rank": 1,
|
||||
"title": "推荐方案",
|
||||
"method": "legacy_fallback",
|
||||
"score": 60.0,
|
||||
"confidence_score": 45.0,
|
||||
"is_fallback": True,
|
||||
"fallback_reason": "多方案生成失败,已降级为兼容单方案输出",
|
||||
"score_breakdown": {},
|
||||
"summary": "当前模型未生成多方案,返回兼容单方案结果",
|
||||
"parting": {
|
||||
"axis": legacy.get("metadata", {}).get("parting_direction", "Z"),
|
||||
"direction": None,
|
||||
"surface": legacy.get("parting_surface", {}),
|
||||
"line": [],
|
||||
},
|
||||
"cavity_data": legacy,
|
||||
"key_info": legacy.get("mold_cavities", {}).get("cavity_key_info", {}),
|
||||
"undercut_regions": legacy.get("undercut_regions", []),
|
||||
"side_actions": legacy.get("side_actions", {}),
|
||||
}
|
||||
],
|
||||
"global_summary": {
|
||||
"scheme_count": 1,
|
||||
"recommended_reason": "兼容旧版单方案结果",
|
||||
},
|
||||
"cavity_data": legacy,
|
||||
"key_info": legacy.get("mold_cavities", {}).get("cavity_key_info", {}),
|
||||
}
|
||||
cls.attach_injection_system_summaries(result, material["name"])
|
||||
return result
|
||||
|
||||
candidate_schemes = plan_result.get("candidate_schemes", [])
|
||||
best_scheme = cls.get_best_scheme(plan_result)
|
||||
result = {
|
||||
"best_scheme_id": plan_result.get("best_scheme_id"),
|
||||
"candidate_schemes": candidate_schemes,
|
||||
"global_summary": plan_result.get("global_summary", {}),
|
||||
"cavity_data": best_scheme.get("cavity_data", {}) if best_scheme else {},
|
||||
"key_info": best_scheme.get("key_info", {}) if best_scheme else {},
|
||||
}
|
||||
cls.attach_injection_system_summaries(result, material["name"])
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def attach_injection_system_summaries(
|
||||
cls,
|
||||
plan_result: Dict[str, Any],
|
||||
material_name: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""为每个候选方案补充注塑模冷却/浇注摘要。"""
|
||||
from moldinsight.core.mold_system_designer import MoldSystemDesigner
|
||||
|
||||
designer = MoldSystemDesigner()
|
||||
for scheme in plan_result.get("candidate_schemes", []):
|
||||
cavity_data = scheme.get("cavity_data") or {}
|
||||
product_bbox = cavity_data.get("product_analysis", {}).get("bounding_box", {})
|
||||
mold_size = cavity_data.get("manufacturing_info", {}).get("estimated_mold_size", {})
|
||||
cavity_count = cavity_data.get("mold_cavities", {}).get("cavity_count", 1)
|
||||
|
||||
if not product_bbox or not mold_size:
|
||||
continue
|
||||
|
||||
system_result = designer.design_complete_system(
|
||||
mold_size=mold_size,
|
||||
product_bbox=product_bbox,
|
||||
material=material_name,
|
||||
cavity_count=cavity_count,
|
||||
)
|
||||
|
||||
cavity_data["injection_system"] = system_result
|
||||
cavity_data.setdefault("manufacturing_info", {})
|
||||
cavity_data["manufacturing_info"]["cooling_summary"] = {
|
||||
"cooling_time": system_result.get("cooling", {}).get("cooling_time"),
|
||||
"channel_count": system_result.get("cooling", {}).get("thermal_check", {}).get("channel_count"),
|
||||
"flow_rate_lpm": system_result.get("cooling", {}).get("flow_rate", {}).get("flow_rate_lpm"),
|
||||
}
|
||||
cavity_data["manufacturing_info"]["gating_summary"] = {
|
||||
"gate_type": system_result.get("gating", {}).get("gate_type"),
|
||||
"runner_type": system_result.get("gating", {}).get("runner", {}).get("type"),
|
||||
"estimated_cycle_time": system_result.get("overall_assessment", {}).get("estimated_cycle_time"),
|
||||
}
|
||||
|
||||
best_scheme = cls.get_best_scheme(plan_result)
|
||||
if best_scheme:
|
||||
plan_result["injection_system"] = best_scheme.get("cavity_data", {}).get("injection_system")
|
||||
|
||||
return plan_result
|
||||
|
||||
@staticmethod
|
||||
def get_best_scheme(plan_result: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if not plan_result:
|
||||
return None
|
||||
|
||||
schemes = plan_result.get("candidate_schemes", [])
|
||||
if not schemes:
|
||||
return None
|
||||
|
||||
best_scheme_id = plan_result.get("best_scheme_id")
|
||||
if best_scheme_id:
|
||||
for scheme in schemes:
|
||||
if scheme.get("scheme_id") == best_scheme_id:
|
||||
return scheme
|
||||
|
||||
return schemes[0]
|
||||
@@ -0,0 +1,187 @@
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from moldinsight.core.mold_cam import MoldCAMDesigner
|
||||
|
||||
|
||||
class CAMBundleService:
|
||||
"""将分模任务结果组装为 CAM 准备包(MVP 骨架)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.cam_designer = MoldCAMDesigner()
|
||||
|
||||
def build_bundle(
|
||||
self,
|
||||
task_view: Dict[str, Any],
|
||||
scheme_id: Optional[str] = None,
|
||||
mold_steel: str = "P20",
|
||||
surface_quality: str = "standard",
|
||||
controller: str = "fanuc",
|
||||
include_gcode: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
scheme = self._select_scheme(task_view, scheme_id)
|
||||
cavity_data = scheme.get("cavity_data", {}) if scheme else {}
|
||||
cavity_bbox = self._extract_cavity_bbox(cavity_data)
|
||||
stock_bbox = self._build_stock_bbox(cavity_data, cavity_bbox)
|
||||
|
||||
cam_result = self.cam_designer.design_mold_cam(
|
||||
cavity_bbox=cavity_bbox,
|
||||
stock_bbox=stock_bbox,
|
||||
mold_steel=mold_steel,
|
||||
surface_quality=surface_quality,
|
||||
controller=controller,
|
||||
)
|
||||
|
||||
process_plan = self._build_process_plan(cam_result.get("operations", []))
|
||||
tooling_suggestion = self._build_tooling_suggestion(cam_result.get("tools", {}))
|
||||
manufacturing_warnings = self._build_warnings(
|
||||
scheme=scheme,
|
||||
cam_recommendations=cam_result.get("recommendations", []),
|
||||
cavity_data=cavity_data,
|
||||
)
|
||||
|
||||
bundle = {
|
||||
"task_id": task_view.get("task_id"),
|
||||
"scheme_id": (scheme or {}).get("scheme_id"),
|
||||
"process_plan": process_plan,
|
||||
"tooling_suggestion": tooling_suggestion,
|
||||
"manufacturing_warnings": manufacturing_warnings,
|
||||
"summary": cam_result.get("summary", {}),
|
||||
"confidence": {
|
||||
"score": (scheme or {}).get("confidence_score"),
|
||||
"is_fallback": bool((scheme or {}).get("is_fallback", False)),
|
||||
"fallback_reason": (scheme or {}).get("fallback_reason", ""),
|
||||
},
|
||||
}
|
||||
if include_gcode:
|
||||
bundle["gcode"] = cam_result.get("gcode", "")
|
||||
bundle["gcode_lines"] = cam_result.get("gcode_lines", 0)
|
||||
return bundle
|
||||
|
||||
@staticmethod
|
||||
def _select_scheme(task_view: Dict[str, Any], scheme_id: Optional[str]) -> Dict[str, Any]:
|
||||
schemes = task_view.get("candidate_schemes") or []
|
||||
if not schemes:
|
||||
return {
|
||||
"scheme_id": "legacy",
|
||||
"confidence_score": None,
|
||||
"is_fallback": True,
|
||||
"fallback_reason": "无候选分模方案,使用默认加工包",
|
||||
"cavity_data": task_view.get("cavity_data", {}),
|
||||
}
|
||||
|
||||
if scheme_id:
|
||||
for scheme in schemes:
|
||||
if scheme.get("scheme_id") == scheme_id:
|
||||
return scheme
|
||||
|
||||
best_scheme_id = task_view.get("best_scheme_id")
|
||||
if best_scheme_id:
|
||||
for scheme in schemes:
|
||||
if scheme.get("scheme_id") == best_scheme_id:
|
||||
return scheme
|
||||
|
||||
return schemes[0]
|
||||
|
||||
@staticmethod
|
||||
def _extract_cavity_bbox(cavity_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
bbox = cavity_data.get("product_analysis", {}).get("bounding_box", {}) or {}
|
||||
dims = bbox.get("dimensions") or [100.0, 100.0, 50.0]
|
||||
if len(dims) < 3:
|
||||
dims = [100.0, 100.0, 50.0]
|
||||
|
||||
center = bbox.get("center") or [0.0, 0.0, 0.0]
|
||||
half_x = float(dims[0]) / 2.0
|
||||
half_y = float(dims[1]) / 2.0
|
||||
half_z = float(dims[2]) / 2.0
|
||||
|
||||
return {
|
||||
"dimensions": [float(dims[0]), float(dims[1]), float(dims[2])],
|
||||
"min": [float(center[0]) - half_x, float(center[1]) - half_y, float(center[2]) - half_z],
|
||||
"max": [float(center[0]) + half_x, float(center[1]) + half_y, float(center[2]) + half_z],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_stock_bbox(cavity_data: Dict[str, Any], cavity_bbox: Dict[str, Any]) -> Dict[str, Any]:
|
||||
mold_size = cavity_data.get("manufacturing_info", {}).get("estimated_mold_size", {}) or {}
|
||||
dims = mold_size.get("length"), mold_size.get("width"), mold_size.get("height")
|
||||
|
||||
if not all(v is not None for v in dims):
|
||||
dims = cavity_bbox.get("dimensions", [100.0, 100.0, 50.0])
|
||||
dims = [float(dims[0]) * 1.4, float(dims[1]) * 1.4, max(80.0, float(dims[2]) * 1.8)]
|
||||
else:
|
||||
dims = [float(dims[0]), float(dims[1]), float(dims[2])]
|
||||
|
||||
return {
|
||||
"dimensions": dims,
|
||||
"min": [-dims[0] / 2.0, -dims[1] / 2.0, -dims[2] / 2.0],
|
||||
"max": [dims[0] / 2.0, dims[1] / 2.0, dims[2] / 2.0],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_process_plan(operations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
plan = []
|
||||
for idx, op in enumerate(operations, start=1):
|
||||
plan.append({
|
||||
"seq": idx,
|
||||
"operation": op.get("strategy", "unknown"),
|
||||
"estimated_time_min": op.get("estimated_time_min", 0),
|
||||
"tool_id": op.get("tool", {}).get("tool_id"),
|
||||
"feed_rate_mm_min": op.get("tool", {}).get("feed_rate_mm_min"),
|
||||
"spindle_speed_rpm": op.get("tool", {}).get("spindle_speed_rpm"),
|
||||
})
|
||||
return plan
|
||||
|
||||
@staticmethod
|
||||
def _build_tooling_suggestion(tools: Dict[str, Any]) -> Dict[str, Any]:
|
||||
roughing = tools.get("roughing", {})
|
||||
finishing = tools.get("finishing", {})
|
||||
return {
|
||||
"roughing_tool": {
|
||||
"tool_id": roughing.get("tool_id"),
|
||||
"tool_type": roughing.get("tool_type"),
|
||||
"diameter_mm": roughing.get("tool_diameter"),
|
||||
},
|
||||
"finishing_tool": {
|
||||
"tool_id": finishing.get("tool_id"),
|
||||
"tool_type": finishing.get("tool_type"),
|
||||
"diameter_mm": finishing.get("tool_diameter"),
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_warnings(
|
||||
scheme: Dict[str, Any],
|
||||
cam_recommendations: List[str],
|
||||
cavity_data: Dict[str, Any],
|
||||
) -> List[str]:
|
||||
warnings: List[str] = []
|
||||
for violation in scheme.get("dfm_violations", []) or []:
|
||||
level = str(violation.get("level", "medium")).upper()
|
||||
message = violation.get("message")
|
||||
if message:
|
||||
warnings.append(f"DFM[{level}]: {message}")
|
||||
|
||||
if scheme.get("is_fallback"):
|
||||
reason = scheme.get("fallback_reason") or "分模结果使用回退路径"
|
||||
warnings.append(f"分模回退: {reason}")
|
||||
|
||||
confidence_score = float(scheme.get("confidence_score") or 0.0)
|
||||
if confidence_score and confidence_score < 60.0:
|
||||
warnings.append(f"方案可信度偏低({confidence_score:.1f}),建议人工复核分型面与倒扣机构")
|
||||
|
||||
force_text = str(cavity_data.get("manufacturing_info", {}).get("estimated_clamping_force", ""))
|
||||
if "吨" in force_text:
|
||||
try:
|
||||
force_val = float(force_text.replace("吨", "").strip())
|
||||
if force_val > 1000:
|
||||
warnings.append("预估锁模力较高,建议复核设备吨位与模板强度")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
warnings.extend(cam_recommendations[:3])
|
||||
if not warnings:
|
||||
warnings.append("未发现明显制造风险,建议进入工艺评审")
|
||||
return warnings
|
||||
|
||||
|
||||
cam_bundle_service = CAMBundleService()
|
||||
@@ -0,0 +1,467 @@
|
||||
"""
|
||||
LLM 增强分析服务
|
||||
|
||||
提供两个核心能力:
|
||||
1. generate_design_report — 将分析 JSON 转换为结构化评审报告
|
||||
2. recommend_parting_direction — 基于几何 + 制造约束推荐最优分型方向
|
||||
|
||||
适配层:OpenAI 兼容 API(支持 OpenAI / DeepSeek / vLLM / Ollama 等)
|
||||
未配置 LLM 时静默降级,不影响主流程。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import httpx
|
||||
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
_DESIGN_REPORT_SYSTEM = """你是一位资深注塑模具设计工程师,拥有 20 年模具 DFM 评审经验。
|
||||
请根据提供的模具分析数据,生成一份专业的模具设计评审报告。
|
||||
|
||||
要求:
|
||||
1. 使用中文
|
||||
2. 按 "关键问题 → 工艺参数建议 → 改进建议" 结构组织
|
||||
3. 技术术语准确(如:锁模力、投影面积、分型面、滑块、斜顶、拔模角、缩痕、熔接痕)
|
||||
4. 每个问题标注优先级(high / medium / low)
|
||||
5. 如果数据不足以判断某项,明确标注"数据不足,需人工确认"
|
||||
|
||||
严格输出 JSON,不要输出其他内容。JSON 格式:
|
||||
{
|
||||
"title": "模具设计评审报告",
|
||||
"overview": "一段 1-2 句话的整体概述",
|
||||
"sections": [
|
||||
{
|
||||
"heading": "关键问题",
|
||||
"type": "issues",
|
||||
"items": [
|
||||
{"level": "high", "content": "拔模角不足,建议增加到 2° 以上"},
|
||||
{"level": "medium", "content": "壁厚偏差较大,可能产生缩痕"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "工艺参数建议",
|
||||
"type": "params_table",
|
||||
"headers": ["参数", "推荐值", "说明"],
|
||||
"rows": [
|
||||
["锁模力", "150 吨", "基于投影面积计算"],
|
||||
["注塑温度", "230°C", "ABS 材料推荐值"]
|
||||
]
|
||||
},
|
||||
{
|
||||
"heading": "改进建议",
|
||||
"type": "recommendations",
|
||||
"items": [
|
||||
"建议将主流道直径从 4mm 增加到 6mm",
|
||||
"建议在所有垂直面增加 1-2° 拔模角"
|
||||
]
|
||||
}
|
||||
],
|
||||
"overall_score": 7.5
|
||||
}"""
|
||||
|
||||
_DESIGN_REPORT_USER = """请根据以下模具分析数据生成评审报告:
|
||||
|
||||
## 产品信息
|
||||
- 文件:{filename}
|
||||
- 材料:{material}
|
||||
- 体积:{volume}
|
||||
- 表面积:{surface_area}
|
||||
- 边界框:{bbox}
|
||||
|
||||
## 检测特征
|
||||
{features}
|
||||
|
||||
## 质量指标
|
||||
{quality_metrics}
|
||||
|
||||
## 分模方案
|
||||
{schemes}
|
||||
|
||||
## 制造参数
|
||||
- 推荐模具材料:{mold_material}
|
||||
- 推荐模具硬度:{mold_hardness}
|
||||
- 预估锁模力:{clamping_force}
|
||||
- 模具尺寸(长×宽×高):{mold_size}
|
||||
- 预估成型周期:{cycle_time}
|
||||
- 拔模角:{draft_angle}
|
||||
- 收缩率:{shrinkage_rate}
|
||||
|
||||
## 原始设计建议
|
||||
{recommendations}
|
||||
|
||||
请生成 JSON 格式评审报告。issues 部分不要超过 8 条,每条内容简洁在一行内;
|
||||
params_table 至少要包含锁模力、成型周期、模仁材料、推荐型腔数 4 行;
|
||||
如果某项数据标记为"自动计算"或"自动选择",请在说明中注明"需人工确认";
|
||||
overall_score 范围 1-10。"""
|
||||
|
||||
_SIDE_ACTION_ANALYSIS_SYSTEM = """你是一位资深注塑模具结构工程师。
|
||||
请根据提供的 STP 分析结果,判断当前产品是否需要倒扣/抽芯机构,并输出标准化结论。
|
||||
|
||||
输出要求:
|
||||
1. 严格输出 JSON,不要输出其他内容
|
||||
2. 只允许基于已给数据判断,数据不足时必须标记为 manual_review
|
||||
3. 结论面向工程评审,避免坐标、面索引、底层算法术语堆砌
|
||||
4. 建议必须标准化、简洁、可执行
|
||||
|
||||
JSON 格式:
|
||||
{
|
||||
"status": "required|not_required|manual_review",
|
||||
"confidence": 0.0,
|
||||
"conclusion": "一句中文结论",
|
||||
"mechanism_recommendation": "slider|lifter|mixed|none|manual_review",
|
||||
"summary": "一段 40-80 字中文摘要",
|
||||
"reasons": ["原因1", "原因2"],
|
||||
"standard_advice": ["建议1", "建议2"],
|
||||
"manual_review_items": ["复核项1", "复核项2"]
|
||||
}"""
|
||||
|
||||
_SIDE_ACTION_ANALYSIS_USER = """请分析当前注塑件是否需要倒扣/抽芯机构:
|
||||
|
||||
## 产品信息
|
||||
- 文件:{filename}
|
||||
- 材料:{material}
|
||||
- 边界框:{bbox}
|
||||
|
||||
## 特征检测
|
||||
{features}
|
||||
|
||||
## 最优方案
|
||||
{best_scheme}
|
||||
|
||||
## 规则分析结果
|
||||
{side_actions}
|
||||
|
||||
## DFM 风险
|
||||
{dfm_violations}
|
||||
|
||||
判断要求:
|
||||
1. 如果规则结果明确显示无倒扣,可输出 not_required
|
||||
2. 如果存在外侧倒扣,优先考虑 slider
|
||||
3. 如果存在内侧倒扣,优先考虑 lifter
|
||||
4. 如果内外侧倒扣同时存在,可输出 mixed
|
||||
5. 如果数据不够支撑明确判断,输出 manual_review"""
|
||||
|
||||
_PARTING_SYSTEM = """你是一位注塑模具分模专家。
|
||||
根据产品几何特征和多个候选分模方向的评分数据,推荐最优分模方向。
|
||||
|
||||
输出要求:严格输出 JSON,不要输出其他内容。
|
||||
JSON 格式:
|
||||
{
|
||||
"recommended_axis": "Z",
|
||||
"confidence": 0.85,
|
||||
"reasoning": "详细的中文推理过程...",
|
||||
"risk_notes": ["风险1", "风险2"],
|
||||
"rankings": [{"axis":"Z","rank":1,"score":92,"note":"..."}]
|
||||
}"""
|
||||
|
||||
_PARTING_USER = """请评估以下候选分模方向并推荐最优方案:
|
||||
|
||||
产品几何:
|
||||
- 边界框 (mm):{bbox}
|
||||
- 面法向分布:{normal_stats}
|
||||
- 惯性矩:{inertia}
|
||||
|
||||
约束条件:
|
||||
- 材料:{material}
|
||||
- 型腔数:{cavity_count}
|
||||
- 最大锁模力 (吨):{max_clamping_force}
|
||||
- 泡沫材料:{is_foam}
|
||||
|
||||
候选方案:
|
||||
{schemes}
|
||||
|
||||
请综合评估制造可行性、成本和风险,给出推荐。"""
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""LLM 增强分析服务(单例)"""
|
||||
|
||||
def __init__(self):
|
||||
self._enabled = settings.LLM_ENABLED
|
||||
self._api_url = settings.LLM_API_URL.rstrip("/")
|
||||
self._api_key = settings.LLM_API_KEY
|
||||
self._model = settings.LLM_MODEL
|
||||
self._timeout = settings.LLM_TIMEOUT
|
||||
self._max_tokens = settings.LLM_MAX_TOKENS
|
||||
|
||||
if self._enabled:
|
||||
logger.info(
|
||||
"LLM 增强分析已启用: model=%s endpoint=%s",
|
||||
self._model, self._api_url,
|
||||
)
|
||||
else:
|
||||
logger.info("LLM 增强分析未启用(设置 LLM_ENABLED=true 启用)")
|
||||
|
||||
async def generate_design_report(
|
||||
self,
|
||||
analysis_result: Dict[str, Any],
|
||||
detailed_cavity_json: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""生成模具设计评审报告 (结构化 JSON)"""
|
||||
if not self._enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
prompt = self._build_design_report_prompt(analysis_result, detailed_cavity_json)
|
||||
response = await self._chat(
|
||||
_DESIGN_REPORT_SYSTEM,
|
||||
prompt,
|
||||
self._max_tokens,
|
||||
expect_json=True,
|
||||
)
|
||||
if not response:
|
||||
return None
|
||||
result = self._parse_json_response(response)
|
||||
if result:
|
||||
logger.info("LLM 设计报告生成成功 (%d sections)", len(result.get("sections", [])))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning("LLM 设计报告生成失败(不影响主流程): %s", e)
|
||||
return None
|
||||
|
||||
async def generate_side_action_analysis(
|
||||
self,
|
||||
analysis_result: Dict[str, Any],
|
||||
detailed_cavity_json: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""生成倒扣/抽芯 AI 标准化分析"""
|
||||
if not self._enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
prompt = self._build_side_action_prompt(analysis_result, detailed_cavity_json)
|
||||
response = await self._chat(
|
||||
_SIDE_ACTION_ANALYSIS_SYSTEM,
|
||||
prompt,
|
||||
min(self._max_tokens, 1200),
|
||||
expect_json=True,
|
||||
)
|
||||
if not response:
|
||||
return None
|
||||
result = self._parse_json_response(response)
|
||||
if result:
|
||||
logger.info(
|
||||
"LLM 倒扣/抽芯分析生成成功: status=%s confidence=%s",
|
||||
result.get("status"),
|
||||
result.get("confidence"),
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning("LLM 倒扣/抽芯分析失败(不影响主流程): %s", e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def compose_llm_report(
|
||||
design_report: Optional[Dict[str, Any]],
|
||||
side_action_analysis: Optional[Dict[str, Any]],
|
||||
) -> Optional[str]:
|
||||
"""将结构化报告和倒扣分析打包进 llm_report 字段,避免改动外部协议。
|
||||
|
||||
设计报告以 <!--DESIGN_REPORT_BEGIN--> / <!--DESIGN_REPORT_END--> 包裹的 JSON 嵌入,
|
||||
倒扣分析以 <!--SIDE_ACTION_AI_BEGIN--> / <!--SIDE_ACTION_AI_END--> 包裹的 JSON 嵌入。
|
||||
"""
|
||||
sections: List[str] = []
|
||||
if side_action_analysis:
|
||||
payload = json.dumps(side_action_analysis, ensure_ascii=False)
|
||||
sections.append(
|
||||
"<!--SIDE_ACTION_AI_BEGIN-->\n"
|
||||
f"{payload}\n"
|
||||
"<!--SIDE_ACTION_AI_END-->"
|
||||
)
|
||||
if design_report:
|
||||
payload = json.dumps(design_report, ensure_ascii=False)
|
||||
sections.append(
|
||||
"<!--DESIGN_REPORT_BEGIN-->\n"
|
||||
f"{payload}\n"
|
||||
"<!--DESIGN_REPORT_END-->"
|
||||
)
|
||||
merged = "\n\n".join(sections).strip()
|
||||
return merged or None
|
||||
|
||||
async def recommend_parting_direction(
|
||||
self,
|
||||
geometry_data: Dict[str, Any],
|
||||
candidate_schemes: List[Dict[str, Any]],
|
||||
material: Dict[str, Any],
|
||||
cavity_count: int = 1,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""推荐最优分型方向"""
|
||||
if not self._enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
prompt = self._build_parting_prompt(geometry_data, candidate_schemes, material, cavity_count)
|
||||
response = await self._chat(_PARTING_SYSTEM, prompt, min(self._max_tokens, 1200), expect_json=True)
|
||||
if response:
|
||||
result = self._parse_json_response(response)
|
||||
if result:
|
||||
logger.info("LLM 分型推荐: %s (%.2f)", result.get("recommended_axis", "?"), result.get("confidence", 0))
|
||||
return result
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("LLM 分型推荐失败(不影响主流程): %s", e)
|
||||
return None
|
||||
|
||||
def _build_design_report_prompt(self, analysis_result, detailed_cavity_json) -> str:
|
||||
features = json.dumps(analysis_result.get("detected_features", []), ensure_ascii=False, indent=2)
|
||||
if len(features) > 4000:
|
||||
features = features[:4000] + "\n... (已截断)"
|
||||
|
||||
schemes_text = ""
|
||||
if detailed_cavity_json:
|
||||
schemes = detailed_cavity_json.get("candidate_schemes", [])
|
||||
if schemes:
|
||||
schemes_text = json.dumps([{
|
||||
"scheme_id": s.get("scheme_id"), "rank": s.get("rank"), "title": s.get("title"),
|
||||
"score": s.get("score"), "summary": s.get("summary"),
|
||||
"parting_axis": s.get("parting", {}).get("axis"),
|
||||
"mold_structure_type": s.get("mold_structure_type"),
|
||||
"dfm_violations": s.get("dfm_violations", []),
|
||||
} for s in schemes], ensure_ascii=False, indent=2)
|
||||
|
||||
best = detailed_cavity_json.get("candidate_schemes", [{}])[0] if detailed_cavity_json else {}
|
||||
cd = best.get("cavity_data", {}) if isinstance(best, dict) else {}
|
||||
mfg = cd.get("manufacturing_info", {})
|
||||
meta = cd.get("metadata", {})
|
||||
|
||||
return _DESIGN_REPORT_USER.format(
|
||||
filename=meta.get("file_name", "unknown.stp"),
|
||||
material=meta.get("selected_material", "ABS"),
|
||||
volume=f"{analysis_result.get('geometry_data', {}).get('volume', 0):.1f} mm³",
|
||||
surface_area=f"{analysis_result.get('geometry_data', {}).get('surface_area', 0):.1f} mm²",
|
||||
bbox=json.dumps(analysis_result.get("geometry_data", {}).get("bounding_box", {}), ensure_ascii=False),
|
||||
features=features or "无特征检测数据",
|
||||
quality_metrics=json.dumps(analysis_result.get("quality_metrics", {}), ensure_ascii=False, indent=2),
|
||||
schemes=schemes_text or "无分模方案数据",
|
||||
mold_material=mfg.get("mold_material", "自动选择"),
|
||||
mold_hardness=mfg.get("mold_hardness", "自动选择"),
|
||||
clamping_force=mfg.get("estimated_clamping_force", "自动计算"),
|
||||
mold_size=json.dumps(mfg.get("estimated_mold_size", {}), ensure_ascii=False),
|
||||
cycle_time=mfg.get("estimated_cycle_time", "自动计算"),
|
||||
draft_angle=f"{meta.get('draft_angle', 2.0)}°",
|
||||
shrinkage_rate="自动计算",
|
||||
recommendations=json.dumps(analysis_result.get("design_recommendations", []), ensure_ascii=False, indent=2) or "无",
|
||||
)
|
||||
|
||||
def _build_side_action_prompt(self, analysis_result, detailed_cavity_json) -> str:
|
||||
features = json.dumps(
|
||||
analysis_result.get("detected_features", []),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
if len(features) > 2500:
|
||||
features = features[:2500] + "\n... (已截断)"
|
||||
|
||||
best_scheme = {}
|
||||
if detailed_cavity_json:
|
||||
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
|
||||
best_scheme_id = detailed_cavity_json.get("best_scheme_id")
|
||||
if candidate_schemes:
|
||||
best_scheme = candidate_schemes[0]
|
||||
if best_scheme_id:
|
||||
for scheme in candidate_schemes:
|
||||
if scheme.get("scheme_id") == best_scheme_id:
|
||||
best_scheme = scheme
|
||||
break
|
||||
|
||||
cavity_data = best_scheme.get("cavity_data", {}) if isinstance(best_scheme, dict) else {}
|
||||
metadata = cavity_data.get("metadata", {}) if isinstance(cavity_data, dict) else {}
|
||||
side_actions = (
|
||||
best_scheme.get("side_actions")
|
||||
or cavity_data.get("side_actions")
|
||||
or {}
|
||||
)
|
||||
best_scheme_view = {
|
||||
"scheme_id": best_scheme.get("scheme_id"),
|
||||
"title": best_scheme.get("title"),
|
||||
"score": best_scheme.get("score"),
|
||||
"parting_axis": best_scheme.get("parting", {}).get("axis"),
|
||||
"mold_structure_type": best_scheme.get("mold_structure_type"),
|
||||
"undercut_regions_count": len(best_scheme.get("undercut_regions", []) or []),
|
||||
}
|
||||
side_actions_view = {
|
||||
"summary": side_actions.get("summary", {}),
|
||||
"recommendations": side_actions.get("recommendations", []),
|
||||
"slider_count": len(side_actions.get("slider_mechanisms", []) or []),
|
||||
"lifter_count": len(side_actions.get("lifter_mechanisms", []) or []),
|
||||
}
|
||||
dfm_violations = best_scheme.get("dfm_violations", []) if isinstance(best_scheme, dict) else []
|
||||
|
||||
return _SIDE_ACTION_ANALYSIS_USER.format(
|
||||
filename=metadata.get("file_name", "unknown.stp"),
|
||||
material=metadata.get("selected_material", "ABS"),
|
||||
bbox=json.dumps(
|
||||
analysis_result.get("geometry_data", {}).get("bounding_box", {}),
|
||||
ensure_ascii=False,
|
||||
),
|
||||
features=features or "无特征检测数据",
|
||||
best_scheme=json.dumps(best_scheme_view, ensure_ascii=False, indent=2),
|
||||
side_actions=json.dumps(side_actions_view, ensure_ascii=False, indent=2),
|
||||
dfm_violations=json.dumps(dfm_violations[:6], ensure_ascii=False, indent=2),
|
||||
)
|
||||
|
||||
def _build_parting_prompt(self, geometry_data, candidate_schemes, material, cavity_count) -> str:
|
||||
bbox = geometry_data.get("bounding_box", {})
|
||||
axis_normal_stats = geometry_data.get("axis_normal_stats", {})
|
||||
inertia = geometry_data.get("inertia_matrix", [])
|
||||
inertia_diag = [inertia[i][i] if i < len(inertia) and i < len(inertia[i]) else 0.0 for i in range(3)]
|
||||
|
||||
schemes_text = json.dumps([{
|
||||
"axis": s.get("parting", {}).get("axis") or s.get("axis"),
|
||||
"score": s.get("score"), "summary": s.get("summary"),
|
||||
"mold_structure_type": s.get("mold_structure_type"),
|
||||
"core_required": s.get("core_required"),
|
||||
"dfm_violations": s.get("dfm_violations", []),
|
||||
"undercut_regions_count": len(s.get("undercut_regions", [])),
|
||||
"score_breakdown": s.get("score_breakdown", {}),
|
||||
} for s in candidate_schemes], ensure_ascii=False, indent=2)
|
||||
|
||||
return _PARTING_USER.format(
|
||||
bbox=json.dumps(bbox, ensure_ascii=False),
|
||||
normal_stats=json.dumps(axis_normal_stats, ensure_ascii=False),
|
||||
inertia=json.dumps(inertia_diag, ensure_ascii=False),
|
||||
material=material.get("name", "ABS"),
|
||||
cavity_count=cavity_count,
|
||||
max_clamping_force="3000 吨(最大)",
|
||||
is_foam="是" if material.get("is_foam") else "否",
|
||||
schemes=schemes_text,
|
||||
)
|
||||
|
||||
async def _chat(self, system, user, max_tokens=2000, expect_json=False, temperature=0.3):
|
||||
url = f"{self._api_url}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"}
|
||||
payload = {
|
||||
"model": self._model,
|
||||
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
|
||||
"max_tokens": max_tokens, "temperature": temperature,
|
||||
}
|
||||
if expect_json:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
resp = await client.post(url, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
content = resp.json()["choices"][0]["message"]["content"]
|
||||
return content.strip() if content else None
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_response(raw):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
m = re.search(r"\{[\s\S]*\}", raw)
|
||||
if m:
|
||||
try:
|
||||
return json.loads(m.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
logger.warning("LLM JSON 解析失败: %s...", raw[:200])
|
||||
return None
|
||||
|
||||
|
||||
llm_service = LLMService()
|
||||
@@ -0,0 +1,45 @@
|
||||
# services/material_service.py
|
||||
"""材料属性管理服务"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
|
||||
MATERIAL_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
||||
"ABS": {"density": 1.05, "shrinkage": 0.005, "name": "ABS", "is_foam": False},
|
||||
"PP": {"density": 0.90, "shrinkage": 0.016, "name": "PP", "is_foam": False},
|
||||
"PE": {"density": 0.95, "shrinkage": 0.020, "name": "PE", "is_foam": False},
|
||||
"PC": {"density": 1.20, "shrinkage": 0.007, "name": "PC", "is_foam": False},
|
||||
"PA": {"density": 1.14, "shrinkage": 0.010, "name": "PA", "is_foam": False},
|
||||
"POM": {"density": 1.41, "shrinkage": 0.020, "name": "POM", "is_foam": False},
|
||||
"PMMA": {"density": 1.18, "shrinkage": 0.005, "name": "PMMA", "is_foam": False},
|
||||
"PBT": {"density": 1.31, "shrinkage": 0.015, "name": "PBT", "is_foam": False},
|
||||
"AlSi10Mg": {"density": 0.45, "shrinkage": 0.015, "name": "AlSi10Mg", "is_foam": True},
|
||||
"AlSi12": {"density": 0.50, "shrinkage": 0.012, "name": "AlSi12", "is_foam": True},
|
||||
"Pure Al Foam": {"density": 0.35, "shrinkage": 0.020, "name": "Pure Al Foam", "is_foam": True},
|
||||
"AlSi7Mg": {"density": 0.40, "shrinkage": 0.018, "name": "AlSi7Mg", "is_foam": True},
|
||||
}
|
||||
|
||||
# 默认回退材料
|
||||
_DEFAULT_MATERIAL = MATERIAL_PROPERTIES["ABS"]
|
||||
|
||||
|
||||
class MaterialService:
|
||||
"""材料属性管理服务 — 集中管理材料字典,便于扩展和单测"""
|
||||
|
||||
@staticmethod
|
||||
def get_material(material_name: str) -> Dict[str, Any]:
|
||||
"""获取材料属性,不存在则回退到 ABS"""
|
||||
return MATERIAL_PROPERTIES.get(material_name, _DEFAULT_MATERIAL)
|
||||
|
||||
@staticmethod
|
||||
def is_foam_material(material_name: str) -> bool:
|
||||
return MATERIAL_PROPERTIES.get(material_name, _DEFAULT_MATERIAL).get("is_foam", False)
|
||||
|
||||
@staticmethod
|
||||
def list_all_materials() -> List[str]:
|
||||
return list(MATERIAL_PROPERTIES.keys())
|
||||
|
||||
@staticmethod
|
||||
def resolve_material(requested: str) -> str:
|
||||
"""解析请求的材料名,若不在字典中则回退为 ABS"""
|
||||
return requested if requested in MATERIAL_PROPERTIES else "ABS"
|
||||
@@ -0,0 +1,665 @@
|
||||
# services/processing_service.py
|
||||
"""STP 文件处理流程编排器 — 协调解析、网格生成、型腔生成、保存、验证"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from moldinsight.core.stp_parser import STPParser
|
||||
from moldinsight.core.geometry_analyzer import GeometryAnalyzer
|
||||
from moldinsight.core.mold_generator import MoldCavityGenerator
|
||||
from moldinsight.core.aluminum_foam_mold import AluminumFoamMoldGenerator
|
||||
from moldinsight.core.mold_quality_inspector import AluminumFoamMoldQualityInspector
|
||||
from moldinsight.core.mesh_generator import MeshGenerator
|
||||
from moldinsight.core.multi_scheme_planner import MultiSchemeMoldPlanner
|
||||
from moldinsight.core.cad_exporter import CADExporter
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
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
|
||||
from shared.database.database import db_manager
|
||||
from shared.utils.html_generator import HTMLGenerator
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ProcessingService:
|
||||
"""核心处理流程编排 — 协调 STP 解析、网格、型腔、计算、保存、验证"""
|
||||
|
||||
def __init__(self):
|
||||
self.stp_parser = STPParser()
|
||||
self.geometry_analyzer = GeometryAnalyzer()
|
||||
self.mold_generator = MoldCavityGenerator(shrinkage_rate=0.005)
|
||||
self.aluminum_foam_generator = AluminumFoamMoldGenerator(shrinkage_rate=0.015, draft_angle=3.0)
|
||||
self.mold_quality_inspector = AluminumFoamMoldQualityInspector()
|
||||
self.mesh_generator = MeshGenerator(quality="medium")
|
||||
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]]] = {}
|
||||
self._occ_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="occ")
|
||||
|
||||
# ─── 对外入口 ───
|
||||
|
||||
async def process_file_with_storage(
|
||||
self,
|
||||
task_id: str,
|
||||
file_path: str,
|
||||
stp_file_id: int,
|
||||
process_params: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""处理文件的后台任务 — 使用独立数据库会话"""
|
||||
|
||||
# 创建独立的数据库会话,避免请求范围会话关闭
|
||||
async with db_manager.session() as db_session:
|
||||
try:
|
||||
logger.info(f"开始处理文件并生成模具型腔: {file_path}")
|
||||
|
||||
from shared.config.settings import settings
|
||||
|
||||
file_size_bytes = Path(file_path).stat().st_size if Path(file_path).exists() else 0
|
||||
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)")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self.process_file_core(
|
||||
task_id, file_path, stp_file_id, db_session, process_params
|
||||
),
|
||||
timeout_seconds,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"处理超时: {task_id}")
|
||||
raise Exception(f"处理超时,超过{timeout_seconds}秒未完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"模具型腔生成失败: {e}")
|
||||
|
||||
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed")
|
||||
await self.storage_service.update_task_status(
|
||||
db_session, task_id, "failed", error_message=str(e)
|
||||
)
|
||||
|
||||
# 安全更新 Redis 任务状态
|
||||
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 process_file_core(
|
||||
self,
|
||||
task_id: str,
|
||||
file_path: str,
|
||||
stp_file_id: int,
|
||||
db_session: AsyncSession,
|
||||
process_params: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""核心处理逻辑"""
|
||||
|
||||
try:
|
||||
logger.info(f"开始处理文件并生成模具型腔: {file_path}")
|
||||
process_params = self._normalize_process_params(process_params)
|
||||
stage_timings: Dict[str, float] = {}
|
||||
|
||||
# 1. 解析STP文件
|
||||
await self.storage_service.update_task_status(
|
||||
db_session, task_id, "processing", 20, "解析STP文件"
|
||||
)
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
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
|
||||
)
|
||||
stage_timings["parse_stp"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 2. 生成网格数据并持久化
|
||||
await self.storage_service.update_task_status(
|
||||
db_session, task_id, "processing", 30, "生成网格数据"
|
||||
)
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
mesh_result = await self._step_generate_mesh(
|
||||
shape, geometry_data, file_path, db_session, stp_file_id, task_id
|
||||
)
|
||||
stage_timings["generate_mesh"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 3. 生成模具型腔
|
||||
await self.storage_service.update_task_status(
|
||||
db_session, task_id, "processing", 40, "生成模具型腔"
|
||||
)
|
||||
|
||||
# 材料属性 — 通过 MaterialService 集中管理
|
||||
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
|
||||
is_foam_material = MaterialService.is_foam_material(requested_material)
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
plan_result = await self._step_generate_cavity(
|
||||
shape, selected_material, is_foam_material, process_params
|
||||
)
|
||||
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(
|
||||
db_session, task_id, "processing", 60, "生成型腔详细数据"
|
||||
)
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
detailed_cavity_json = CalculationService.build_plan_result(
|
||||
geometry_data=geometry_data,
|
||||
material=selected_material,
|
||||
file_path=str(file_path),
|
||||
plan_result=plan_result,
|
||||
)
|
||||
stage_timings["build_plan_result"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
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. 保存几何数据到数据库
|
||||
await self.storage_service.update_task_status(
|
||||
db_session, task_id, "processing", 70, "保存几何数据"
|
||||
)
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
await self.storage_service.save_geometry_data(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
geometry_data,
|
||||
geometry_data.get("analysis_method", "mold_cavity"),
|
||||
)
|
||||
|
||||
# 7. 生成HTML可视化
|
||||
await self.storage_service.update_task_status(
|
||||
db_session, task_id, "processing", 85, "生成可视化报告"
|
||||
)
|
||||
|
||||
pointcloud_data = None
|
||||
lod_data = None
|
||||
if mesh_result:
|
||||
lod0 = mesh_result.get("lods", {}).get("0", {})
|
||||
pointcloud_data = {
|
||||
"points": mesh_result.get("points", []),
|
||||
"normals": mesh_result.get("normals", []),
|
||||
"vertices": lod0.get("vertices", []),
|
||||
"faces": lod0.get("faces", []),
|
||||
"point_count": mesh_result.get("point_count", 0),
|
||||
"vertex_count": mesh_result.get("vertex_count", 0),
|
||||
"face_count": mesh_result.get("face_count", 0),
|
||||
}
|
||||
|
||||
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())]})")
|
||||
|
||||
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. 保存模具型腔数据(包含方案级预览链接)
|
||||
await self.storage_service.save_mold_cavity_data(
|
||||
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,
|
||||
)
|
||||
|
||||
await self.storage_service.save_html_file(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
Path(html_file_path).name,
|
||||
html_file_path,
|
||||
)
|
||||
stage_timings["persist_artifacts"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 9. 分析模具设计
|
||||
stage_started = time.perf_counter()
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
if analysis_result:
|
||||
await self.storage_service.save_features_and_recommendations(
|
||||
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)
|
||||
stage_timings["analyze_design"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 9.6 更新STP文件的分析摘要字段
|
||||
await self.storage_service.update_stp_file_analysis_summary(
|
||||
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"]
|
||||
),
|
||||
)
|
||||
|
||||
# 9.7 FreeCAD 几何验证
|
||||
stage_started = time.perf_counter()
|
||||
verification_result = await self._step_verify(
|
||||
file_path, db_session, task_id, stp_file_id, analysis_result
|
||||
)
|
||||
stage_timings["verify_geometry"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 9.8 LLM 增强分析
|
||||
llm_report = None
|
||||
stage_started = time.perf_counter()
|
||||
if analysis_result:
|
||||
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
|
||||
)
|
||||
stage_timings["generate_llm_report"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 10. 完成处理
|
||||
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "completed")
|
||||
await self.storage_service.update_task_status(
|
||||
db_session, task_id, "completed", 100, "模具型腔生成完成"
|
||||
)
|
||||
await self.storage_service.update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{
|
||||
"stage_timings": stage_timings,
|
||||
"material": requested_material,
|
||||
"verification": verification_result,
|
||||
"llm_report": llm_report,
|
||||
"export_artifacts": export_artifacts,
|
||||
**process_params,
|
||||
},
|
||||
)
|
||||
|
||||
# 更新任务缓存状态(仅保留轻量摘要,完整数据由PG+RustFS持久化)
|
||||
await redis_task_manager.update_task(task_id, {
|
||||
"status": ProcessingStatus.COMPLETED,
|
||||
"completed_at": str(datetime.now()),
|
||||
"geometry_data": geometry_data,
|
||||
"analysis_result": analysis_result,
|
||||
"key_info": best_key_info,
|
||||
"best_scheme_id": detailed_cavity_json.get("best_scheme_id"),
|
||||
"material": requested_material,
|
||||
"parameters": process_params,
|
||||
"stage_timings": stage_timings,
|
||||
"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,
|
||||
})
|
||||
|
||||
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}")
|
||||
|
||||
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed")
|
||||
await self.storage_service.update_task_status(
|
||||
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]]:
|
||||
"""生成多级LOD网格并持久化,一次OCC剖分+trimesh简化,失败不影响主流程"""
|
||||
mesh_result = None
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
mesh_result = await loop.run_in_executor(
|
||||
self._occ_executor, self.mesh_generator.generate_multi_lod_mesh, shape
|
||||
)
|
||||
|
||||
lod0 = mesh_result.get("lods", {}).get("0", {})
|
||||
vertices = lod0.get("vertices", [])
|
||||
faces = lod0.get("faces", [])
|
||||
points = mesh_result.get("points", [])
|
||||
normals = mesh_result.get("normals", [])
|
||||
point_count = mesh_result.get("point_count", 0)
|
||||
vertex_count = lod0.get("vertex_count", mesh_result.get("vertex_count", 0))
|
||||
face_count = lod0.get("face_count", mesh_result.get("face_count", 0))
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
await self.storage_service.save_mesh_data(
|
||||
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(
|
||||
self, shape, selected_material: dict, is_foam_material: bool, process_params: Dict[str, Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""生成多方案分模结果"""
|
||||
plan_result = None
|
||||
try:
|
||||
if shape:
|
||||
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', []))} 套方案"
|
||||
)
|
||||
except Exception as cavity_err:
|
||||
logger.warning(f"多方案分模失败,使用简化数据: {cavity_err}")
|
||||
traceback.print_exc()
|
||||
plan_result = None
|
||||
|
||||
return plan_result
|
||||
|
||||
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:
|
||||
return None
|
||||
if scheme_id:
|
||||
return scheme_map.get(scheme_id)
|
||||
return next(iter(scheme_map.values()), None)
|
||||
|
||||
@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)),
|
||||
}
|
||||
|
||||
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 几何验证(可通过配置禁用)"""
|
||||
from shared.config.settings import settings
|
||||
|
||||
if not settings.ENABLE_FREECAD_VERIFICATION:
|
||||
logger.info("FreeCAD验证已禁用(设置 ENABLE_FREECAD_VERIFICATION=true 启用)")
|
||||
return {"status": "disabled", "reason": "FreeCAD验证已禁用"}
|
||||
|
||||
await self.storage_service.update_task_status(
|
||||
db_session, task_id, "processing", 90, "FreeCAD几何验证"
|
||||
)
|
||||
|
||||
try:
|
||||
from moldinsight.services.verification_service import GeometryVerificationService
|
||||
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]:
|
||||
"""为候选分模方案生成轻量摘要链接(完整HTML仅最优方案按需生成)"""
|
||||
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")
|
||||
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
|
||||
)
|
||||
self.html_generator.save_data_file(summary_content, summary_name)
|
||||
scheme["summary_file"] = f"/html/{summary_name}"
|
||||
|
||||
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):
|
||||
"""保存分析指标到数据库"""
|
||||
from shared.models.database import AnalysisMetrics
|
||||
|
||||
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)
|
||||
await session.commit()
|
||||
logger.info(f"分析指标保存成功: {metrics.id}")
|
||||
|
||||
async def _save_verification_metrics(self, session: AsyncSession, stp_file_id: int, verification_result: dict):
|
||||
"""保存验证指标到数据库"""
|
||||
from shared.models.database import AnalysisMetrics
|
||||
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)
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"验证指标保存成功: stp_file_id={stp_file_id}")
|
||||
|
||||
|
||||
# 模块级单例,供路由层直接使用
|
||||
processing_service = ProcessingService()
|
||||
@@ -0,0 +1,376 @@
|
||||
# services/storage_integration.py
|
||||
"""存储集成服务 - 协调 PostgreSQL 和 MinIO"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
import json
|
||||
|
||||
from shared.models.database import (
|
||||
STPFile, GeometryData, MoldCavityData,
|
||||
HTMLFile, ProcessingTask, User,
|
||||
FeatureDetection, DesignRecommendation,
|
||||
UserActivity, SystemLog
|
||||
)
|
||||
from moldinsight.storage.object_storage import storage_manager
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class StorageIntegrationService:
|
||||
"""存储集成服务"""
|
||||
|
||||
async def save_stp_file(self, session: AsyncSession,
|
||||
file_path: Path,
|
||||
original_filename: str,
|
||||
user_id: Optional[int] = None) -> STPFile:
|
||||
"""保存STP文件到PostgreSQL元数据 + MinIO对象存储"""
|
||||
|
||||
# 1. 上传到MinIO
|
||||
upload_result = await storage_manager.upload_stp_file(
|
||||
file_path,
|
||||
original_filename
|
||||
)
|
||||
|
||||
# 2. 创建PostgreSQL记录
|
||||
stp_file = STPFile(
|
||||
user_id=user_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=storage_manager.buckets['stp_files'],
|
||||
original_filename=original_filename,
|
||||
file_size=upload_result['file_size'],
|
||||
file_hash=upload_result['file_hash'],
|
||||
status="uploaded",
|
||||
file_path=str(file_path) # 保留本地路径以兼容
|
||||
)
|
||||
|
||||
session.add(stp_file)
|
||||
await session.commit()
|
||||
await session.refresh(stp_file)
|
||||
|
||||
logger.info(f"STP文件保存成功: {stp_file.id}")
|
||||
return stp_file
|
||||
|
||||
async def save_geometry_data(self, session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
geometry_json: Dict[str, Any],
|
||||
analysis_method: str = "pythonocc") -> GeometryData:
|
||||
"""保存几何数据到PostgreSQL元数据 + MinIO对象存储"""
|
||||
|
||||
# 1. 获取文件哈希
|
||||
stp_file = await session.get(STPFile, stp_file_id)
|
||||
file_hash = stp_file.file_hash
|
||||
|
||||
# 2. 上传到MinIO
|
||||
upload_result = await storage_manager.upload_geometry_data(
|
||||
geometry_json,
|
||||
file_hash
|
||||
)
|
||||
|
||||
# 3. 创建PostgreSQL记录
|
||||
geometry_data = GeometryData(
|
||||
stp_file_id=stp_file_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=storage_manager.buckets['geometry_data'],
|
||||
analysis_method=analysis_method,
|
||||
|
||||
# 提取摘要字段
|
||||
volume=geometry_json.get('geometry_data', {}).get('volume'),
|
||||
surface_area=geometry_json.get('geometry_data', {}).get('surface_area'),
|
||||
bounding_box_min=geometry_json.get('geometry_data', {}).get('bounding_box', {}).get('min'),
|
||||
bounding_box_max=geometry_json.get('geometry_data', {}).get('bounding_box', {}).get('max'),
|
||||
center_of_mass=geometry_json.get('geometry_data', {}).get('center_of_mass'),
|
||||
topology_faces=geometry_json.get('geometry_data', {}).get('topology', {}).get('faces'),
|
||||
topology_edges=geometry_json.get('geometry_data', {}).get('topology', {}).get('edges'),
|
||||
topology_vertices=geometry_json.get('geometry_data', {}).get('topology', {}).get('vertices')
|
||||
)
|
||||
|
||||
session.add(geometry_data)
|
||||
await session.commit()
|
||||
await session.refresh(geometry_data)
|
||||
|
||||
logger.info(f"几何数据保存成功: {geometry_data.id}")
|
||||
return geometry_data
|
||||
|
||||
async def save_mold_cavity_data(self, session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
cavity_json: Dict[str, Any]) -> MoldCavityData:
|
||||
"""保存模具型腔数据到PostgreSQL元数据 + MinIO对象存储"""
|
||||
|
||||
# 1. 获取文件哈希
|
||||
stp_file = await session.get(STPFile, stp_file_id)
|
||||
file_hash = stp_file.file_hash
|
||||
|
||||
# 2. 上传到MinIO
|
||||
upload_result = await storage_manager.upload_mold_cavity_data(
|
||||
cavity_json,
|
||||
file_hash
|
||||
)
|
||||
|
||||
# 3. 提取关键信息
|
||||
metadata = cavity_json.get('metadata', {})
|
||||
product_analysis = cavity_json.get('product_analysis', {})
|
||||
manufacturing_info = cavity_json.get('manufacturing_info', {})
|
||||
mold_size = manufacturing_info.get('estimated_mold_size', {})
|
||||
key_info = cavity_json.get('mold_cavities', {}).get('cavity_key_info', {})
|
||||
|
||||
# 4. 创建PostgreSQL记录
|
||||
mold_cavity = MoldCavityData(
|
||||
stp_file_id=stp_file_id,
|
||||
detailed_object_key=upload_result['object_key'],
|
||||
storage_bucket=storage_manager.buckets['mold_cavities'],
|
||||
|
||||
# 模具参数
|
||||
mold_material=manufacturing_info.get('recommended_material', 'Aluminum Alloy 7075'),
|
||||
shrinkage_rate=metadata.get('shrinkage_rate', 0.005),
|
||||
draft_angle=metadata.get('draft_angle', 2.0),
|
||||
|
||||
# 提取的摘要字段
|
||||
cavity_key_info=key_info,
|
||||
mold_size_length=mold_size.get('length'),
|
||||
mold_size_width=mold_size.get('width'),
|
||||
mold_size_height=mold_size.get('height'),
|
||||
estimated_clamping_force=manufacturing_info.get('estimated_clamping_force'),
|
||||
product_volume=product_analysis.get('volume'),
|
||||
|
||||
# 从key_info中提取(如果存在)
|
||||
product_weight=key_info.get('geometric_characteristics', {}).get('product_weight'),
|
||||
wall_thickness_range=key_info.get('geometric_characteristics', {}).get('wall_thickness_range'),
|
||||
complexity_score=key_info.get('geometric_characteristics', {}).get('complexity_score'),
|
||||
|
||||
# 质量评估
|
||||
weld_line_risk=key_info.get('quality_considerations', {}).get('potential_weld_lines'),
|
||||
sink_mark_risk=key_info.get('quality_considerations', {}).get('sink_mark_areas'),
|
||||
warpage_risk=key_info.get('quality_considerations', {}).get('warpage_risk')
|
||||
)
|
||||
|
||||
session.add(mold_cavity)
|
||||
await session.commit()
|
||||
await session.refresh(mold_cavity)
|
||||
|
||||
logger.info(f"模具型腔数据保存成功: {mold_cavity.id}")
|
||||
return mold_cavity
|
||||
|
||||
async def save_html_file(self, session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
html_content: str,
|
||||
filename: str) -> HTMLFile:
|
||||
"""保存HTML文件到PostgreSQL元数据 + MinIO对象存储"""
|
||||
|
||||
# 1. 获取文件哈希
|
||||
stp_file = await session.get(STPFile, stp_file_id)
|
||||
file_hash = stp_file.file_hash
|
||||
|
||||
# 2. 上传到MinIO
|
||||
upload_result = await storage_manager.upload_html_file(
|
||||
html_content,
|
||||
filename,
|
||||
file_hash
|
||||
)
|
||||
|
||||
# 3. 创建PostgreSQL记录
|
||||
html_file = HTMLFile(
|
||||
stp_file_id=stp_file_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=storage_manager.buckets['html_files'],
|
||||
filename=filename,
|
||||
file_path=str(Path('html_output') / filename), # 保留本地路径
|
||||
html_content=html_content # 保留内容以兼容
|
||||
)
|
||||
|
||||
session.add(html_file)
|
||||
await session.commit()
|
||||
await session.refresh(html_file)
|
||||
|
||||
logger.info(f"HTML文件保存成功: {html_file.id}")
|
||||
return html_file
|
||||
|
||||
async def save_features_and_recommendations(
|
||||
self, session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
features: list,
|
||||
recommendations: list
|
||||
):
|
||||
"""保存特征检测结果和设计建议"""
|
||||
|
||||
# 1. 保存特征
|
||||
for feature in features:
|
||||
feature_record = FeatureDetection(
|
||||
stp_file_id=stp_file_id,
|
||||
feature_type=feature.get('feature_type'),
|
||||
confidence=feature.get('confidence'),
|
||||
location=feature.get('location'),
|
||||
dimensions=feature.get('dimensions'),
|
||||
parameters=feature.get('parameters')
|
||||
)
|
||||
session.add(feature_record)
|
||||
|
||||
# 2. 保存建议
|
||||
for rec in recommendations:
|
||||
rec_record = DesignRecommendation(
|
||||
stp_file_id=stp_file_id,
|
||||
rec_type=rec.get('rec_type'),
|
||||
priority=rec.get('priority'),
|
||||
description=rec.get('description'),
|
||||
reason=rec.get('reason'),
|
||||
parameters=rec.get('parameters')
|
||||
)
|
||||
session.add(rec_record)
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"保存了 {len(features)} 个特征和 {len(recommendations)} 个建议")
|
||||
|
||||
async def log_user_activity(self, session: AsyncSession,
|
||||
user_id: int,
|
||||
activity_type: str,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[int] = None,
|
||||
description: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None):
|
||||
"""记录用户活动"""
|
||||
|
||||
activity = UserActivity(
|
||||
user_id=user_id,
|
||||
activity_type=activity_type,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
description=description,
|
||||
metadata=metadata,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
session.add(activity)
|
||||
await session.commit()
|
||||
logger.debug(f"用户活动记录: {activity_type} by user {user_id}")
|
||||
|
||||
async def get_stp_file_with_data(self, session: AsyncSession,
|
||||
stp_file_id: int) -> Dict[str, Any]:
|
||||
"""获取STP文件及其所有关联数据"""
|
||||
|
||||
# 1. 获取STP文件记录
|
||||
stp_file = await session.get(STPFile, stp_file_id)
|
||||
if not stp_file:
|
||||
raise ValueError(f"STP文件不存在: {stp_file_id}")
|
||||
|
||||
result = {
|
||||
'metadata': {
|
||||
'id': stp_file.id,
|
||||
'original_filename': stp_file.original_filename,
|
||||
'file_size': stp_file.file_size,
|
||||
'file_hash': stp_file.file_hash,
|
||||
'upload_time': stp_file.upload_time.isoformat() if stp_file.upload_time else None,
|
||||
'status': stp_file.status,
|
||||
'user_id': stp_file.user_id
|
||||
},
|
||||
'geometry_data': None,
|
||||
'mold_cavity_data': None,
|
||||
'html_file': None,
|
||||
'features': [],
|
||||
'recommendations': []
|
||||
}
|
||||
|
||||
# 2. 从MinIO获取数据
|
||||
try:
|
||||
# 几何数据
|
||||
if stp_file.geometry_data:
|
||||
geo_data_bytes = await storage_manager.download_file(
|
||||
'geometry_data',
|
||||
stp_file.geometry_data.object_key
|
||||
)
|
||||
result['geometry_data'] = json.loads(geo_data_bytes.decode('utf-8'))
|
||||
|
||||
# 模具型腔数据
|
||||
if stp_file.mold_cavity_data:
|
||||
cavity_data_bytes = await storage_manager.download_file(
|
||||
'mold_cavities',
|
||||
stp_file.mold_cavity_data.detailed_object_key
|
||||
)
|
||||
result['mold_cavity_data'] = json.loads(cavity_data_bytes.decode('utf-8'))
|
||||
|
||||
# HTML文件
|
||||
if stp_file.html_file:
|
||||
html_bytes = await storage_manager.download_file(
|
||||
'html_files',
|
||||
stp_file.html_file.object_key
|
||||
)
|
||||
result['html_content'] = html_bytes.decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"从MinIO获取数据失败: {e}")
|
||||
|
||||
# 3. 从PostgreSQL获取特征和建议
|
||||
features = await session.execute(
|
||||
select(FeatureDetection).where(FeatureDetection.stp_file_id == stp_file_id)
|
||||
)
|
||||
result['features'] = [
|
||||
{
|
||||
'feature_type': f.feature_type,
|
||||
'confidence': f.confidence,
|
||||
'location': f.location,
|
||||
'dimensions': f.dimensions,
|
||||
'parameters': f.parameters
|
||||
}
|
||||
for f in features.scalars().all()
|
||||
]
|
||||
|
||||
recommendations = await session.execute(
|
||||
select(DesignRecommendation).where(DesignRecommendation.stp_file_id == stp_file_id)
|
||||
)
|
||||
result['recommendations'] = [
|
||||
{
|
||||
'rec_type': r.rec_type,
|
||||
'priority': r.priority,
|
||||
'description': r.description,
|
||||
'reason': r.reason,
|
||||
'parameters': r.parameters
|
||||
}
|
||||
for r in recommendations.scalars().all()
|
||||
]
|
||||
|
||||
return result
|
||||
|
||||
async def delete_stp_file_cascade(self, session: AsyncSession,
|
||||
stp_file_id: int):
|
||||
"""级联删除STP文件及其所有关联数据"""
|
||||
|
||||
stp_file = await session.get(STPFile, stp_file_id)
|
||||
if not stp_file:
|
||||
raise ValueError(f"STP文件不存在: {stp_file_id}")
|
||||
|
||||
# 1. 删除MinIO中的文件
|
||||
try:
|
||||
if stp_file.object_key:
|
||||
await storage_manager.delete_file('stp_files', stp_file.object_key)
|
||||
except Exception as e:
|
||||
logger.error(f"删除MinIO文件失败: {e}")
|
||||
|
||||
try:
|
||||
if stp_file.geometry_data:
|
||||
await storage_manager.delete_file('geometry_data', stp_file.geometry_data.object_key)
|
||||
except Exception as e:
|
||||
logger.error(f"删除几何数据失败: {e}")
|
||||
|
||||
try:
|
||||
if stp_file.mold_cavity_data:
|
||||
await storage_manager.delete_file('mold_cavities', stp_file.mold_cavity_data.detailed_object_key)
|
||||
except Exception as e:
|
||||
logger.error(f"删除型腔数据失败: {e}")
|
||||
|
||||
try:
|
||||
if stp_file.html_file:
|
||||
await storage_manager.delete_file('html_files', stp_file.html_file.object_key)
|
||||
except Exception as e:
|
||||
logger.error(f"删除HTML文件失败: {e}")
|
||||
|
||||
# 2. 级联删除PostgreSQL记录(通过外键自动处理)
|
||||
await session.delete(stp_file)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"STP文件及其关联数据已删除: {stp_file_id}")
|
||||
|
||||
|
||||
# 全局存储集成服务实例
|
||||
storage_integration = StorageIntegrationService()
|
||||
@@ -0,0 +1,850 @@
|
||||
# services/storage_integration_rustfs.py
|
||||
"""存储集成服务 - 协调 PostgreSQL 和 RustFS"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
import json
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from shared.models.database import (
|
||||
STPFile, GeometryData, MeshData, MoldCavityData,
|
||||
HTMLFile, ProcessingTask, User,
|
||||
FeatureDetection, DesignRecommendation,
|
||||
UserActivity, SystemLog
|
||||
)
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class StorageIntegrationService:
|
||||
"""存储集成服务 - PostgreSQL + RustFS"""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_best_scheme_payload(cavity_json: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""从新多方案/旧单方案结构中解析推荐方案和型腔详情。"""
|
||||
if not isinstance(cavity_json, dict):
|
||||
return {
|
||||
"best_scheme_id": None,
|
||||
"best_scheme": {},
|
||||
"best_cavity_data": {},
|
||||
"key_info": {},
|
||||
}
|
||||
|
||||
candidate_schemes = cavity_json.get("candidate_schemes") or []
|
||||
if not candidate_schemes:
|
||||
key_info = cavity_json.get("mold_cavities", {}).get("cavity_key_info", {})
|
||||
return {
|
||||
"best_scheme_id": cavity_json.get("best_scheme_id"),
|
||||
"best_scheme": {},
|
||||
"best_cavity_data": cavity_json,
|
||||
"key_info": key_info,
|
||||
}
|
||||
|
||||
best_scheme_id = cavity_json.get("best_scheme_id")
|
||||
best_scheme = candidate_schemes[0]
|
||||
if best_scheme_id:
|
||||
for scheme in candidate_schemes:
|
||||
if scheme.get("scheme_id") == best_scheme_id:
|
||||
best_scheme = scheme
|
||||
break
|
||||
|
||||
best_cavity_data = best_scheme.get("cavity_data", {}) if isinstance(best_scheme, dict) else {}
|
||||
key_info = best_scheme.get("key_info", {}) if isinstance(best_scheme, dict) else {}
|
||||
if not key_info:
|
||||
key_info = best_cavity_data.get("mold_cavities", {}).get("cavity_key_info", {})
|
||||
return {
|
||||
"best_scheme_id": best_scheme.get("scheme_id") or best_scheme_id,
|
||||
"best_scheme": best_scheme,
|
||||
"best_cavity_data": best_cavity_data,
|
||||
"key_info": key_info,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_first_number(value: Any) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
import re
|
||||
matches = re.findall(r"\d+(?:\.\d+)?", str(value))
|
||||
if not matches:
|
||||
return None
|
||||
try:
|
||||
return float(matches[0])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
async def save_stp_file(self, session: AsyncSession,
|
||||
file_path: Path,
|
||||
original_filename: str,
|
||||
user_id: Optional[int] = None,
|
||||
upload_batch: Optional[str] = None) -> STPFile:
|
||||
"""保存STP文件到PostgreSQL元数据 + RustFS对象存储
|
||||
|
||||
支持同一文件多次上传,每次上传都会创建新记录
|
||||
"""
|
||||
|
||||
# 1. 上传到RustFS
|
||||
upload_result = await rustfs_manager.upload_file(
|
||||
file_type='stp_files',
|
||||
file_path=file_path,
|
||||
original_filename=original_filename,
|
||||
metadata={
|
||||
'original_filename': original_filename,
|
||||
'user_id': str(user_id) if user_id else 'anonymous',
|
||||
'upload_batch': upload_batch or str(uuid.uuid4())
|
||||
}
|
||||
)
|
||||
|
||||
file_hash = upload_result['file_hash']
|
||||
batch_id = upload_batch or str(uuid.uuid4())
|
||||
|
||||
# 2. 创建新PostgreSQL记录(每次上传都创建新记录)
|
||||
from datetime import datetime
|
||||
stp_file = STPFile(
|
||||
user_id=user_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=upload_result['bucket'],
|
||||
original_filename=original_filename,
|
||||
file_size=upload_result['file_size'],
|
||||
file_hash=file_hash,
|
||||
upload_batch=batch_id,
|
||||
status="uploaded",
|
||||
file_path=str(file_path),
|
||||
upload_time=datetime.now()
|
||||
)
|
||||
|
||||
session.add(stp_file)
|
||||
await session.commit()
|
||||
await session.refresh(stp_file)
|
||||
|
||||
logger.info(f"STP文件保存成功 RustFS: {stp_file.id}, 批次: {batch_id}")
|
||||
return stp_file
|
||||
|
||||
async def create_processing_task(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
stp_file_id: int,
|
||||
task_type: str = "stp_parsing",
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
) -> ProcessingTask:
|
||||
"""创建处理任务记录"""
|
||||
try:
|
||||
task = ProcessingTask(
|
||||
task_id=task_id,
|
||||
stp_file_id=stp_file_id,
|
||||
task_type=task_type,
|
||||
status="pending",
|
||||
started_time=datetime.now(),
|
||||
parameters=parameters or {},
|
||||
)
|
||||
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
|
||||
logger.info(f"处理任务创建成功: {task_id}")
|
||||
return task
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"创建处理任务失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_task_status(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: Optional[int] = None,
|
||||
current_step: Optional[str] = None,
|
||||
error_message: Optional[str] = None
|
||||
):
|
||||
"""更新任务状态"""
|
||||
try:
|
||||
update_data = {
|
||||
"status": status,
|
||||
"completed_time": datetime.now() if status in ["completed", "failed"] else None,
|
||||
"error_message": error_message
|
||||
}
|
||||
|
||||
if progress is not None:
|
||||
update_data["progress"] = progress
|
||||
if current_step is not None:
|
||||
update_data["current_step"] = current_step
|
||||
|
||||
await session.execute(
|
||||
update(ProcessingTask)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"任务状态更新: {task_id} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新任务状态失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_task_parameters(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
parameters: Dict[str, Any],
|
||||
):
|
||||
"""合并更新任务参数,便于保存阶段耗时等元数据。"""
|
||||
try:
|
||||
task = await session.execute(
|
||||
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
task = task.scalar_one_or_none()
|
||||
if task is None:
|
||||
return
|
||||
|
||||
merged = dict(task.parameters or {})
|
||||
merged.update(parameters or {})
|
||||
task.parameters = merged
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新任务参数失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_stp_file_status(self, session: AsyncSession, stp_file_id: int, status: str):
|
||||
"""更新STP文件状态"""
|
||||
try:
|
||||
await session.execute(
|
||||
update(STPFile)
|
||||
.where(STPFile.id == stp_file_id)
|
||||
.values(
|
||||
status=status,
|
||||
processed_time=datetime.now() if status in ["completed", "failed"] else None
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"STP文件状态更新: ID {stp_file_id} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新STP文件状态失败: {e}")
|
||||
raise
|
||||
|
||||
async def save_geometry_data(self, session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
geometry_json: Dict[str, Any],
|
||||
analysis_method: str = "pythonocc") -> GeometryData:
|
||||
"""保存几何数据到PostgreSQL元数据 + RustFS对象存储"""
|
||||
|
||||
# 1. 获取文件哈希
|
||||
stp_file = await session.get(STPFile, stp_file_id)
|
||||
file_hash = stp_file.file_hash
|
||||
|
||||
# 2. 上传到RustFS
|
||||
upload_result = await rustfs_manager.upload_json_data(
|
||||
file_type='geometry_data',
|
||||
json_data=geometry_json,
|
||||
file_hash=file_hash
|
||||
)
|
||||
|
||||
# 3. 提取几何数据
|
||||
if 'geometry_data' in geometry_json:
|
||||
geo_data = geometry_json['geometry_data']
|
||||
else:
|
||||
geo_data = geometry_json
|
||||
|
||||
# 4. 创建PostgreSQL记录
|
||||
geometry_data = GeometryData(
|
||||
stp_file_id=stp_file_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=upload_result['bucket'],
|
||||
analysis_method=analysis_method,
|
||||
|
||||
# 提取摘要字段
|
||||
volume=geo_data.get('volume'),
|
||||
surface_area=geo_data.get('surface_area'),
|
||||
bounding_box_min=geo_data.get('bounding_box', {}).get('min'),
|
||||
bounding_box_max=geo_data.get('bounding_box', {}).get('max'),
|
||||
center_of_mass=geo_data.get('center_of_mass'),
|
||||
topology_faces=geo_data.get('topology', {}).get('faces'),
|
||||
topology_edges=geo_data.get('topology', {}).get('edges'),
|
||||
topology_vertices=geo_data.get('topology', {}).get('vertices')
|
||||
)
|
||||
|
||||
session.add(geometry_data)
|
||||
await session.commit()
|
||||
await session.refresh(geometry_data)
|
||||
|
||||
logger.info(f"几何数据保存成功 RustFS: {geometry_data.id}")
|
||||
return geometry_data
|
||||
|
||||
async def save_mesh_data(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
mesh_json: Dict[str, Any],
|
||||
quality: str = "medium"
|
||||
) -> MeshData:
|
||||
"""保存网格数据到 PostgreSQL 元数据 + RustFS 对象存储
|
||||
|
||||
mesh_json 为完整网格 JSON(顶点、面、点云等),
|
||||
PostgreSQL 只存 object_key 和一些摘要字段,详细数据放在 RustFS。
|
||||
"""
|
||||
|
||||
# 1. 获取文件哈希
|
||||
stp_file = await session.get(STPFile, stp_file_id)
|
||||
file_hash = stp_file.file_hash
|
||||
|
||||
# 2. 上传网格 JSON 到 RustFS
|
||||
upload_result = await rustfs_manager.upload_json_data(
|
||||
file_type='mesh_data',
|
||||
json_data=mesh_json,
|
||||
file_hash=file_hash
|
||||
)
|
||||
|
||||
# 3. 提取摘要信息
|
||||
mesh_section = mesh_json.get('mesh', {})
|
||||
pointcloud_section = mesh_json.get('pointcloud', {})
|
||||
bbox = mesh_json.get('bounding_box', {})
|
||||
|
||||
vertices = mesh_section.get('vertices') or []
|
||||
faces = mesh_section.get('faces') or []
|
||||
|
||||
vertex_count = len(vertices)
|
||||
face_count = len(faces)
|
||||
point_count = pointcloud_section.get('count')
|
||||
|
||||
# 4. 创建 PostgreSQL 记录
|
||||
mesh_data = MeshData(
|
||||
stp_file_id=stp_file_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=upload_result['bucket'],
|
||||
quality=quality,
|
||||
vertex_count=vertex_count,
|
||||
face_count=face_count,
|
||||
point_count=point_count,
|
||||
bounding_box_min=bbox.get('min'),
|
||||
bounding_box_max=bbox.get('max')
|
||||
)
|
||||
|
||||
session.add(mesh_data)
|
||||
await session.commit()
|
||||
await session.refresh(mesh_data)
|
||||
|
||||
logger.info(f"网格数据保存成功 RustFS: {mesh_data.id}")
|
||||
return mesh_data
|
||||
|
||||
async def save_mold_cavity_data(self, session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
cavity_json: Dict[str, Any]) -> MoldCavityData:
|
||||
"""保存模具型腔数据到PostgreSQL元数据 + RustFS对象存储"""
|
||||
|
||||
# 1. 获取文件哈希
|
||||
stp_file = await session.get(STPFile, stp_file_id)
|
||||
file_hash = stp_file.file_hash
|
||||
|
||||
# 2. 上传到RustFS
|
||||
upload_result = await rustfs_manager.upload_json_data(
|
||||
file_type='mold_cavities',
|
||||
json_data=cavity_json,
|
||||
file_hash=file_hash
|
||||
)
|
||||
|
||||
# 3. 提取关键信息(兼容多方案与单方案结构)
|
||||
payload = self._resolve_best_scheme_payload(cavity_json)
|
||||
best_scheme_id = payload.get("best_scheme_id")
|
||||
best_scheme = payload.get("best_scheme") or {}
|
||||
best_cavity_data = payload.get("best_cavity_data") or {}
|
||||
metadata = best_cavity_data.get('metadata', {})
|
||||
product_analysis = best_cavity_data.get('product_analysis', {})
|
||||
manufacturing_info = best_cavity_data.get('manufacturing_info', {})
|
||||
mold_size = manufacturing_info.get('estimated_mold_size', {})
|
||||
key_info = payload.get("key_info") or {}
|
||||
if not key_info:
|
||||
key_info = best_cavity_data.get('mold_cavities', {}).get('cavity_key_info', {})
|
||||
mold_material = (
|
||||
metadata.get("selected_material")
|
||||
or manufacturing_info.get("recommended_material")
|
||||
or 'Aluminum Alloy 7075'
|
||||
)
|
||||
parting_line_length = self._parse_first_number(
|
||||
manufacturing_info.get("parting_line_length")
|
||||
)
|
||||
|
||||
# 4. 创建PostgreSQL记录
|
||||
mold_cavity = MoldCavityData(
|
||||
stp_file_id=stp_file_id,
|
||||
detailed_object_key=upload_result['object_key'],
|
||||
storage_bucket=upload_result['bucket'],
|
||||
|
||||
# 模具参数
|
||||
mold_material=mold_material,
|
||||
shrinkage_rate=metadata.get('shrinkage_rate', 0.005),
|
||||
draft_angle=metadata.get('draft_angle', 2.0),
|
||||
parting_line_length=parting_line_length,
|
||||
|
||||
# 提取的摘要字段
|
||||
cavity_key_info=key_info,
|
||||
mold_size_length=mold_size.get('length'),
|
||||
mold_size_width=mold_size.get('width'),
|
||||
mold_size_height=mold_size.get('height'),
|
||||
estimated_clamping_force=manufacturing_info.get('estimated_clamping_force'),
|
||||
product_volume=product_analysis.get('volume'),
|
||||
|
||||
# 从key_info中提取(如果存在)
|
||||
product_weight=key_info.get('geometric_characteristics', {}).get('product_weight'),
|
||||
wall_thickness_range=key_info.get('geometric_characteristics', {}).get('wall_thickness_range'),
|
||||
complexity_score=key_info.get('geometric_characteristics', {}).get('complexity_score'),
|
||||
|
||||
# 质量评估
|
||||
weld_line_risk=key_info.get('quality_considerations', {}).get('potential_weld_lines'),
|
||||
sink_mark_risk=key_info.get('quality_considerations', {}).get('sink_mark_areas'),
|
||||
warpage_risk=key_info.get('quality_considerations', {}).get('warpage_risk'),
|
||||
|
||||
# 多方案可信化摘要
|
||||
best_scheme_id=best_scheme_id,
|
||||
confidence_score=best_scheme.get("confidence_score"),
|
||||
is_fallback=best_scheme.get("is_fallback"),
|
||||
fallback_reason=best_scheme.get("fallback_reason"),
|
||||
)
|
||||
|
||||
session.add(mold_cavity)
|
||||
await session.commit()
|
||||
await session.refresh(mold_cavity)
|
||||
|
||||
logger.info(f"模具型腔数据保存成功 RustFS: {mold_cavity.id}")
|
||||
return mold_cavity
|
||||
|
||||
async def save_html_file(self, session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
filename: str,
|
||||
file_path: str,
|
||||
html_content: Optional[str] = None,
|
||||
visualization_type: str = "3d_viewer") -> HTMLFile:
|
||||
"""保存HTML文件到PostgreSQL元数据 + RustFS对象存储"""
|
||||
|
||||
# 1. 获取文件哈希
|
||||
stp_file = await session.get(STPFile, stp_file_id)
|
||||
file_hash = stp_file.file_hash
|
||||
|
||||
# 2. 读取HTML内容(如果未提供)
|
||||
if html_content is None:
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
html_content = f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"读取HTML文件失败: {e}")
|
||||
html_content = ""
|
||||
|
||||
# 3. 上传到RustFS
|
||||
html_json = {'content': html_content, 'filename': filename}
|
||||
upload_result = await rustfs_manager.upload_json_data(
|
||||
file_type='html_files',
|
||||
json_data=html_json,
|
||||
file_hash=file_hash
|
||||
)
|
||||
|
||||
# 4. 创建PostgreSQL记录
|
||||
html_file = HTMLFile(
|
||||
stp_file_id=stp_file_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=upload_result['bucket'],
|
||||
filename=filename,
|
||||
file_path=file_path, # 保留本地路径
|
||||
html_content=html_content, # 保留内容以兼容
|
||||
visualization_type=visualization_type
|
||||
)
|
||||
|
||||
session.add(html_file)
|
||||
await session.commit()
|
||||
await session.refresh(html_file)
|
||||
|
||||
logger.info(f"HTML文件保存成功 RustFS: {html_file.id}")
|
||||
return html_file
|
||||
|
||||
async def save_features_and_recommendations(
|
||||
self, session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
features: list,
|
||||
recommendations: list
|
||||
):
|
||||
"""保存特征检测结果和设计建议"""
|
||||
|
||||
# 1. 保存特征
|
||||
for feature in features:
|
||||
feature_record = FeatureDetection(
|
||||
stp_file_id=stp_file_id,
|
||||
feature_type=feature.get('feature_type'),
|
||||
confidence=feature.get('confidence'),
|
||||
location=feature.get('location'),
|
||||
dimensions=feature.get('dimensions'),
|
||||
parameters=feature.get('parameters')
|
||||
)
|
||||
session.add(feature_record)
|
||||
|
||||
# 2. 保存建议
|
||||
for rec in recommendations:
|
||||
rec_record = DesignRecommendation(
|
||||
stp_file_id=stp_file_id,
|
||||
rec_type=rec.get('type') or rec.get('rec_type'),
|
||||
priority=rec.get('priority'),
|
||||
description=rec.get('description'),
|
||||
reason=rec.get('reason'),
|
||||
parameters=rec.get('parameters')
|
||||
)
|
||||
session.add(rec_record)
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"保存了 {len(features)} 个特征和 {len(recommendations)} 个建议")
|
||||
|
||||
async def log_user_activity(self, session: AsyncSession,
|
||||
user_id: int,
|
||||
activity_type: str,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[int] = None,
|
||||
description: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None):
|
||||
"""记录用户活动"""
|
||||
|
||||
activity = UserActivity(
|
||||
user_id=user_id,
|
||||
activity_type=activity_type,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
description=description,
|
||||
meta_data=metadata,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
session.add(activity)
|
||||
await session.commit()
|
||||
logger.debug(f"用户活动记录: {activity_type} by user {user_id}")
|
||||
|
||||
async def get_stp_file_with_data(self, session: AsyncSession,
|
||||
stp_file_id: int) -> Dict[str, Any]:
|
||||
"""获取STP文件及其所有关联数据"""
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
try:
|
||||
# 1. 获取STP文件记录(使用 joinedload 预加载关联数据)
|
||||
result = await session.execute(
|
||||
select(STPFile).options(
|
||||
joinedload(STPFile.geometry_data),
|
||||
joinedload(STPFile.mesh_data),
|
||||
joinedload(STPFile.mold_cavity_data),
|
||||
joinedload(STPFile.html_file),
|
||||
joinedload(STPFile.analysis_metrics)
|
||||
).where(STPFile.id == stp_file_id)
|
||||
)
|
||||
stp_file = result.scalar_one_or_none()
|
||||
if not stp_file:
|
||||
raise ValueError(f"STP文件不存在: {stp_file_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取STP文件记录失败: {e}")
|
||||
raise
|
||||
|
||||
result = {
|
||||
'metadata': {
|
||||
'id': stp_file.id,
|
||||
'original_filename': stp_file.original_filename,
|
||||
'file_size': stp_file.file_size,
|
||||
'file_hash': stp_file.file_hash,
|
||||
'upload_time': stp_file.upload_time.isoformat() if stp_file.upload_time else None,
|
||||
'status': stp_file.status,
|
||||
'user_id': stp_file.user_id
|
||||
},
|
||||
'geometry_data': None,
|
||||
'mesh_data': None,
|
||||
'mold_cavity_data': None,
|
||||
'html_content': None,
|
||||
'features': [],
|
||||
'recommendations': [],
|
||||
'analysis_metrics': None # 新增分析指标字段
|
||||
}
|
||||
|
||||
# 2. 从RustFS获取数据
|
||||
try:
|
||||
# 几何数据
|
||||
if stp_file.geometry_data:
|
||||
geo_data_bytes = await rustfs_manager.download_file(
|
||||
file_type='geometry_data',
|
||||
object_key=stp_file.geometry_data.object_key
|
||||
)
|
||||
result['geometry_data'] = json.loads(geo_data_bytes.decode('utf-8'))
|
||||
|
||||
# 模具型腔数据
|
||||
if stp_file.mold_cavity_data:
|
||||
cavity_data_bytes = await rustfs_manager.download_file(
|
||||
file_type='mold_cavities',
|
||||
object_key=stp_file.mold_cavity_data.detailed_object_key
|
||||
)
|
||||
result['mold_cavity_data'] = json.loads(cavity_data_bytes.decode('utf-8'))
|
||||
|
||||
# 网格数据
|
||||
if stp_file.mesh_data:
|
||||
mesh_bytes = await rustfs_manager.download_file(
|
||||
file_type='mesh_data',
|
||||
object_key=stp_file.mesh_data.object_key
|
||||
)
|
||||
result['mesh_data'] = json.loads(mesh_bytes.decode('utf-8'))
|
||||
|
||||
# HTML文件
|
||||
if stp_file.html_file:
|
||||
html_bytes = await rustfs_manager.download_file(
|
||||
file_type='html_files',
|
||||
object_key=stp_file.html_file.object_key
|
||||
)
|
||||
html_json = json.loads(html_bytes.decode('utf-8'))
|
||||
result['html_content'] = html_json.get('content', '')
|
||||
except Exception as e:
|
||||
logger.error(f"从RustFS获取数据失败: {e}")
|
||||
|
||||
# 3. 从PostgreSQL获取特征和建议
|
||||
features = await session.execute(
|
||||
select(FeatureDetection).where(FeatureDetection.stp_file_id == stp_file_id)
|
||||
)
|
||||
result['features'] = [
|
||||
{
|
||||
'feature_type': f.feature_type,
|
||||
'confidence': f.confidence,
|
||||
'location': f.location,
|
||||
'dimensions': f.dimensions,
|
||||
'parameters': f.parameters
|
||||
}
|
||||
for f in features.scalars().all()
|
||||
]
|
||||
|
||||
recommendations = await session.execute(
|
||||
select(DesignRecommendation).where(DesignRecommendation.stp_file_id == stp_file_id)
|
||||
)
|
||||
result['recommendations'] = [
|
||||
{
|
||||
'type': r.rec_type, # 改为 type 以匹配前端期望的字段名
|
||||
'priority': r.priority,
|
||||
'description': r.description,
|
||||
'reason': r.reason,
|
||||
'parameters': r.parameters
|
||||
}
|
||||
for r in recommendations.scalars().all()
|
||||
]
|
||||
|
||||
# 4. 获取分析指标
|
||||
if stp_file.analysis_metrics:
|
||||
result['analysis_metrics'] = {
|
||||
'volume_utilization': stp_file.analysis_metrics.volume_utilization,
|
||||
'topology_complexity': stp_file.analysis_metrics.topology_complexity,
|
||||
'wall_uniformity': stp_file.analysis_metrics.wall_uniformity,
|
||||
'analysis_summary': stp_file.analysis_metrics.analysis_summary,
|
||||
'verification_status': stp_file.analysis_metrics.verification_status,
|
||||
'verification_volume_diff': stp_file.analysis_metrics.verification_volume_diff,
|
||||
'verification_area_diff': stp_file.analysis_metrics.verification_area_diff,
|
||||
'verification_details': stp_file.analysis_metrics.verification_details,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def get_file_history_by_filename(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
filename: str,
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 50
|
||||
) -> list:
|
||||
"""获取同一文件名的所有上传历史记录"""
|
||||
from shared.models.database import ProcessingTask
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
query = select(STPFile).options(
|
||||
joinedload(STPFile.processing_tasks)
|
||||
).where(
|
||||
STPFile.original_filename == filename
|
||||
).order_by(STPFile.upload_time.desc())
|
||||
|
||||
if user_id:
|
||||
query = query.where(STPFile.user_id == user_id)
|
||||
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
files = result.unique().scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': f.id,
|
||||
'task_id': f.processing_tasks[0].task_id if f.processing_tasks else None,
|
||||
'upload_batch': f.upload_batch,
|
||||
'upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None,
|
||||
'file_size': f.file_size,
|
||||
'status': f.status,
|
||||
'volume': f.volume,
|
||||
'surface_area': f.surface_area,
|
||||
'product_weight': f.product_weight,
|
||||
'has_analysis': f.status == 'completed'
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
|
||||
async def get_all_file_groups(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 100
|
||||
) -> list:
|
||||
"""获取所有文件分组(按文件名分组),包含每个文件的最新分析结果"""
|
||||
|
||||
from sqlalchemy import func, desc
|
||||
from sqlalchemy.orm import joinedload
|
||||
from shared.models.database import ProcessingTask
|
||||
|
||||
# 子查询:获取每个文件名的最新上传
|
||||
subquery = (
|
||||
select(
|
||||
STPFile.original_filename,
|
||||
func.max(STPFile.upload_time).label('latest_upload')
|
||||
)
|
||||
.group_by(STPFile.original_filename)
|
||||
.order_by(desc('latest_upload'))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id:
|
||||
subquery = subquery.where(STPFile.user_id == user_id)
|
||||
|
||||
subquery = subquery.subquery()
|
||||
|
||||
# 主查询:获取最新记录和统计信息
|
||||
query = (
|
||||
select(STPFile).options(
|
||||
joinedload(STPFile.processing_tasks)
|
||||
)
|
||||
.join(
|
||||
subquery,
|
||||
(STPFile.original_filename == subquery.c.original_filename) &
|
||||
(STPFile.upload_time == subquery.c.latest_upload)
|
||||
)
|
||||
.order_by(STPFile.upload_time.desc())
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
latest_files = result.unique().scalars().all()
|
||||
|
||||
# 获取每个文件名的上传次数
|
||||
file_groups = []
|
||||
for f in latest_files:
|
||||
count_query = select(func.count()).where(
|
||||
STPFile.original_filename == f.original_filename
|
||||
)
|
||||
if user_id:
|
||||
count_query = count_query.where(STPFile.user_id == user_id)
|
||||
|
||||
count_result = await session.execute(count_query)
|
||||
upload_count = count_result.scalar()
|
||||
|
||||
task_id = f.processing_tasks[0].task_id if f.processing_tasks else None
|
||||
|
||||
file_groups.append({
|
||||
'filename': f.original_filename,
|
||||
'latest_id': f.id,
|
||||
'latest_task_id': task_id,
|
||||
'latest_upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None,
|
||||
'latest_status': f.status,
|
||||
'upload_count': upload_count,
|
||||
'file_size': f.file_size,
|
||||
'volume': f.volume,
|
||||
'surface_area': f.surface_area,
|
||||
'product_weight': f.product_weight
|
||||
})
|
||||
|
||||
return file_groups
|
||||
|
||||
async def update_stp_file_analysis_summary(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
volume: Optional[float] = None,
|
||||
surface_area: Optional[float] = None,
|
||||
product_weight: Optional[float] = None
|
||||
):
|
||||
"""更新STP文件的分析摘要字段(用于快速查询)"""
|
||||
try:
|
||||
update_data = {}
|
||||
if volume is not None:
|
||||
update_data['volume'] = volume
|
||||
if surface_area is not None:
|
||||
update_data['surface_area'] = surface_area
|
||||
if product_weight is not None:
|
||||
update_data['product_weight'] = product_weight
|
||||
|
||||
if update_data:
|
||||
await session.execute(
|
||||
update(STPFile)
|
||||
.where(STPFile.id == stp_file_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"STP文件分析摘要更新: ID {stp_file_id}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新STP文件分析摘要失败: {e}")
|
||||
raise
|
||||
|
||||
async def delete_stp_file_cascade(self, session: AsyncSession,
|
||||
stp_file_id: int):
|
||||
"""级联删除STP文件及其所有关联数据"""
|
||||
|
||||
stp_file = await session.get(STPFile, stp_file_id)
|
||||
if not stp_file:
|
||||
raise ValueError(f"STP文件不存在: {stp_file_id}")
|
||||
|
||||
# 1. 删除RustFS中的文件
|
||||
try:
|
||||
if stp_file.object_key:
|
||||
await rustfs_manager.delete_file('stp_files', stp_file.object_key)
|
||||
except Exception as e:
|
||||
logger.error(f"删除RustFS文件失败: {e}")
|
||||
|
||||
try:
|
||||
if stp_file.geometry_data:
|
||||
await rustfs_manager.delete_file('geometry_data', stp_file.geometry_data.object_key)
|
||||
except Exception as e:
|
||||
logger.error(f"删除几何数据失败: {e}")
|
||||
|
||||
try:
|
||||
if stp_file.mold_cavity_data:
|
||||
await rustfs_manager.delete_file('mold_cavities', stp_file.mold_cavity_data.detailed_object_key)
|
||||
except Exception as e:
|
||||
logger.error(f"删除型腔数据失败: {e}")
|
||||
|
||||
try:
|
||||
if stp_file.mesh_data:
|
||||
await rustfs_manager.delete_file('mesh_data', stp_file.mesh_data.object_key)
|
||||
except Exception as e:
|
||||
logger.error(f"删除网格数据失败: {e}")
|
||||
|
||||
try:
|
||||
if stp_file.html_file:
|
||||
await rustfs_manager.delete_file('html_files', stp_file.html_file.object_key)
|
||||
except Exception as e:
|
||||
logger.error(f"删除HTML文件失败: {e}")
|
||||
|
||||
# 2. 级联删除PostgreSQL记录(通过外键自动处理)
|
||||
await session.delete(stp_file)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"STP文件及其关联数据已删除: {stp_file_id}")
|
||||
|
||||
|
||||
# 全局存储集成服务实例
|
||||
storage_integration = StorageIntegrationService()
|
||||
@@ -0,0 +1,296 @@
|
||||
# services/storage_service.py
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from shared.models.database import STPFile, GeometryData, HTMLFile, ProcessingTask
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
from shared.models.database import MoldCavityData
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class StorageService:
|
||||
"""数据存储服务"""
|
||||
|
||||
def __init__(self, db_session: AsyncSession):
|
||||
self.db_session = db_session
|
||||
|
||||
async def save_stp_file(
|
||||
self,
|
||||
filename: str,
|
||||
file_path: str,
|
||||
file_size: int,
|
||||
file_content: Optional[bytes] = None
|
||||
) -> STPFile:
|
||||
"""保存STP文件信息到数据库"""
|
||||
try:
|
||||
# 计算文件哈希
|
||||
file_hash = self._calculate_file_hash(file_path, file_content)
|
||||
|
||||
# 检查是否已存在相同文件
|
||||
existing_file = await self.db_session.execute(
|
||||
select(STPFile).where(STPFile.file_hash == file_hash)
|
||||
)
|
||||
existing_file = existing_file.scalar_one_or_none()
|
||||
|
||||
if existing_file:
|
||||
logger.info(f"文件已存在,跳过保存: {filename}")
|
||||
return existing_file
|
||||
|
||||
# 创建新的STP文件记录
|
||||
stp_file = STPFile(
|
||||
filename=filename,
|
||||
original_filename=filename,
|
||||
file_path=file_path,
|
||||
file_size=file_size,
|
||||
file_hash=file_hash,
|
||||
file_content=file_content,
|
||||
upload_time=datetime.now(),
|
||||
status="pending",
|
||||
# 必填字段提供默认值
|
||||
object_key=f"stp_files/{file_hash}",
|
||||
storage_bucket="default",
|
||||
object_url=None
|
||||
)
|
||||
|
||||
self.db_session.add(stp_file)
|
||||
await self.db_session.commit()
|
||||
await self.db_session.refresh(stp_file)
|
||||
|
||||
logger.info(f"STP文件保存成功: {filename} (ID: {stp_file.id})")
|
||||
return stp_file
|
||||
|
||||
except Exception as e:
|
||||
await self.db_session.rollback()
|
||||
logger.error(f"保存STP文件失败: {e}")
|
||||
raise
|
||||
|
||||
async def save_geometry_data(
|
||||
self,
|
||||
stp_file_id: int,
|
||||
geometry_json: Dict[str, Any],
|
||||
analysis_method: str
|
||||
) -> GeometryData:
|
||||
"""保存几何数据JSON到数据库"""
|
||||
try:
|
||||
# 提取关键几何属性用于快速查询
|
||||
volume = geometry_json.get("volume")
|
||||
surface_area = geometry_json.get("surface_area")
|
||||
bounding_box = geometry_json.get("bounding_box", {})
|
||||
|
||||
geometry_data = GeometryData(
|
||||
stp_file_id=stp_file_id,
|
||||
analysis_method=analysis_method,
|
||||
volume=volume,
|
||||
surface_area=surface_area,
|
||||
bounding_box_min=bounding_box.get("min"),
|
||||
bounding_box_max=bounding_box.get("max"),
|
||||
created_time=datetime.now(),
|
||||
# 必填字段提供默认值
|
||||
object_key=f"geometry_data/{stp_file_id}",
|
||||
storage_bucket="default",
|
||||
object_url=None
|
||||
)
|
||||
|
||||
self.db_session.add(geometry_data)
|
||||
await self.db_session.commit()
|
||||
await self.db_session.refresh(geometry_data)
|
||||
|
||||
logger.info(f"几何数据保存成功: STP文件ID {stp_file_id}")
|
||||
return geometry_data
|
||||
|
||||
except Exception as e:
|
||||
await self.db_session.rollback()
|
||||
logger.error(f"保存几何数据失败: {e}")
|
||||
raise
|
||||
|
||||
async def save_html_file(
|
||||
self,
|
||||
stp_file_id: int,
|
||||
filename: str,
|
||||
file_path: str,
|
||||
html_content: Optional[str] = None,
|
||||
visualization_type: str = "3d_viewer"
|
||||
) -> HTMLFile:
|
||||
"""保存HTML文件信息到数据库"""
|
||||
try:
|
||||
html_file = HTMLFile(
|
||||
stp_file_id=stp_file_id,
|
||||
filename=filename,
|
||||
file_path=file_path,
|
||||
html_content=html_content,
|
||||
visualization_type=visualization_type,
|
||||
has_interactive_elements=True,
|
||||
generated_time=datetime.now(),
|
||||
# 必填字段提供默认值
|
||||
object_key=f"html_files/{stp_file_id}",
|
||||
storage_bucket="default",
|
||||
object_url=None
|
||||
)
|
||||
|
||||
self.db_session.add(html_file)
|
||||
await self.db_session.commit()
|
||||
await self.db_session.refresh(html_file)
|
||||
|
||||
logger.info(f"HTML文件保存成功: {filename} (STP文件ID: {stp_file_id})")
|
||||
return html_file
|
||||
|
||||
except Exception as e:
|
||||
await self.db_session.rollback()
|
||||
logger.error(f"保存HTML文件失败: {e}")
|
||||
raise
|
||||
|
||||
async def create_processing_task(
|
||||
self,
|
||||
task_id: str,
|
||||
stp_file_id: int,
|
||||
task_type: str = "stp_parsing"
|
||||
) -> ProcessingTask:
|
||||
"""创建处理任务记录"""
|
||||
try:
|
||||
task = ProcessingTask(
|
||||
task_id=task_id,
|
||||
stp_file_id=stp_file_id,
|
||||
task_type=task_type,
|
||||
status="pending",
|
||||
started_time=datetime.now()
|
||||
)
|
||||
|
||||
self.db_session.add(task)
|
||||
await self.db_session.commit()
|
||||
await self.db_session.refresh(task)
|
||||
|
||||
logger.info(f"处理任务创建成功: {task_id}")
|
||||
return task
|
||||
|
||||
except Exception as e:
|
||||
await self.db_session.rollback()
|
||||
logger.error(f"创建处理任务失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_task_status(
|
||||
self,
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: Optional[int] = None,
|
||||
current_step: Optional[str] = None,
|
||||
error_message: Optional[str] = None
|
||||
):
|
||||
"""更新任务状态"""
|
||||
try:
|
||||
update_data = {
|
||||
"status": status,
|
||||
"completed_time": datetime.now() if status in ["completed", "failed"] else None,
|
||||
"error_message": error_message
|
||||
}
|
||||
|
||||
if progress is not None:
|
||||
update_data["progress"] = progress
|
||||
if current_step is not None:
|
||||
update_data["current_step"] = current_step
|
||||
|
||||
await self.db_session.execute(
|
||||
update(ProcessingTask)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await self.db_session.commit()
|
||||
|
||||
logger.info(f"任务状态更新: {task_id} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
await self.db_session.rollback()
|
||||
logger.error(f"更新任务状态失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_stp_file_status(self, stp_file_id: int, status: str):
|
||||
"""更新STP文件状态"""
|
||||
try:
|
||||
await self.db_session.execute(
|
||||
update(STPFile)
|
||||
.where(STPFile.id == stp_file_id)
|
||||
.values(
|
||||
status=status,
|
||||
processed_time=datetime.now() if status in ["completed", "failed"] else None
|
||||
)
|
||||
)
|
||||
await self.db_session.commit()
|
||||
|
||||
logger.info(f"STP文件状态更新: ID {stp_file_id} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
await self.db_session.rollback()
|
||||
logger.error(f"更新STP文件状态失败: {e}")
|
||||
raise
|
||||
|
||||
async def get_stp_file_by_id(self, stp_file_id: int) -> Optional[STPFile]:
|
||||
"""根据ID获取STP文件"""
|
||||
try:
|
||||
result = await self.db_session.execute(
|
||||
select(STPFile).where(STPFile.id == stp_file_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(f"获取STP文件失败: {e}")
|
||||
return None
|
||||
|
||||
async def get_geometry_data_by_stp_file_id(self, stp_file_id: int) -> Optional[GeometryData]:
|
||||
"""根据STP文件ID获取几何数据"""
|
||||
try:
|
||||
result = await self.db_session.execute(
|
||||
select(GeometryData).where(GeometryData.stp_file_id == stp_file_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(f"获取几何数据失败: {e}")
|
||||
return None
|
||||
|
||||
def _calculate_file_hash(self, file_path: str, file_content: Optional[bytes] = None) -> str:
|
||||
"""计算文件哈希值"""
|
||||
sha256_hash = hashlib.sha256()
|
||||
|
||||
if file_content:
|
||||
sha256_hash.update(file_content)
|
||||
else:
|
||||
# 从文件路径读取内容计算哈希
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(chunk)
|
||||
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
async def save_mold_cavity_data(
|
||||
self,
|
||||
stp_file_id: int,
|
||||
cavity_json: Dict[str, Any],
|
||||
key_info: Dict[str, Any]
|
||||
) -> MoldCavityData:
|
||||
"""保存模具型腔数据"""
|
||||
try:
|
||||
mold_data = MoldCavityData(
|
||||
stp_file_id=stp_file_id,
|
||||
cavity_key_info=key_info,
|
||||
shrinkage_rate=cavity_json["metadata"]["shrinkage_rate"],
|
||||
draft_angle=cavity_json["metadata"]["draft_angle"],
|
||||
generated_time=datetime.now(),
|
||||
# 必填字段提供默认值
|
||||
detailed_object_key=f"mold_cavity/{stp_file_id}",
|
||||
storage_bucket="default"
|
||||
)
|
||||
|
||||
self.db_session.add(mold_data)
|
||||
await self.db_session.commit()
|
||||
await self.db_session.refresh(mold_data)
|
||||
|
||||
logger.info(f"模具型腔数据保存成功: STP文件ID {stp_file_id}")
|
||||
return mold_data
|
||||
|
||||
except Exception as e:
|
||||
await self.db_session.rollback()
|
||||
logger.error(f"保存模具型腔数据失败: {e}")
|
||||
raise
|
||||
@@ -0,0 +1,201 @@
|
||||
# services/task_query_service.py
|
||||
"""任务状态查询服务 — 从 task_router.py 中的持久化任务组装逻辑抽取"""
|
||||
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.models.database import ProcessingTask, STPFile, MeshData, HTMLFile
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TaskQueryService:
|
||||
"""任务状态查询与视图组装"""
|
||||
|
||||
@staticmethod
|
||||
async def get_task_view(db_session: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取任务视图 — 优先返回 Redis 缓存,否则从 PostgreSQL + RustFS 组装
|
||||
|
||||
Returns:
|
||||
任务视图字典,如果任务不存在返回 None
|
||||
"""
|
||||
# 1. Redis/内存任务(进行中的任务直接返回,已完成/失败的走DB路径获取完整数据)
|
||||
task = await redis_task_manager.get_task(task_id)
|
||||
if task:
|
||||
status = task.get("status")
|
||||
if status and status not in ("completed", "failed"):
|
||||
logger.info(f"返回缓存任务状态:{task_id} - {status}")
|
||||
return task
|
||||
|
||||
# 2. 持久化任务(已完成/失败,或服务重启后的任务)
|
||||
storage_service = StorageIntegrationService()
|
||||
|
||||
# 查询任务和文件元数据(预加载 html_file 关联)
|
||||
result = await db_session.execute(
|
||||
select(ProcessingTask, STPFile)
|
||||
.join(STPFile, ProcessingTask.stp_file_id == STPFile.id)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
.options(joinedload(STPFile.html_file))
|
||||
)
|
||||
row = result.unique().first()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
processing_task, stp_file = row
|
||||
|
||||
# 从 RustFS 取几何 / 型腔 / 网格详细 JSON
|
||||
try:
|
||||
file_with_data = await storage_service.get_stp_file_with_data(
|
||||
db_session, stp_file_id=stp_file.id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"获取文件数据失败: {e}")
|
||||
file_with_data = {}
|
||||
|
||||
# 解析 geometry_json
|
||||
geometry_json = TaskQueryService._extract_geometry_json(file_with_data)
|
||||
cavity_json: Optional[Dict[str, Any]] = file_with_data.get("mold_cavity_data")
|
||||
features_json: List[Dict[str, Any]] = file_with_data.get("features", [])
|
||||
recommendations_json: List[Dict[str, Any]] = file_with_data.get("recommendations", [])
|
||||
cavity_view = TaskQueryService._extract_cavity_view(cavity_json)
|
||||
|
||||
# 组装网格摘要
|
||||
mesh_summary = await TaskQueryService._get_mesh_summary(db_session, stp_file.id)
|
||||
|
||||
# 构造 html_file 路径(与即时分析的 /html/xxx.html 格式保持一致)
|
||||
html_file_url = None
|
||||
html_file_record = None
|
||||
try:
|
||||
html_file_record = await db_session.execute(
|
||||
select(HTMLFile).where(HTMLFile.stp_file_id == stp_file.id)
|
||||
)
|
||||
html_file_record = html_file_record.scalar_one_or_none()
|
||||
except Exception:
|
||||
pass
|
||||
if html_file_record and html_file_record.filename:
|
||||
html_file_url = f"/html/{html_file_record.filename}"
|
||||
if cavity_view.get("html_file"):
|
||||
html_file_url = cavity_view.get("html_file")
|
||||
|
||||
# 构造与内存任务兼容的任务视图
|
||||
cam_preferences = {}
|
||||
task_parameters = {}
|
||||
if isinstance(processing_task.parameters, dict):
|
||||
cam_preferences = processing_task.parameters.get("cam_preferences", {}) or {}
|
||||
task_parameters = dict(processing_task.parameters)
|
||||
|
||||
task_view = {
|
||||
"task_id": processing_task.task_id,
|
||||
"status": processing_task.status,
|
||||
"filename": stp_file.original_filename if stp_file else "",
|
||||
"file_path": stp_file.file_path or "",
|
||||
"file_size": stp_file.file_size if stp_file else 0,
|
||||
"upload_time": processing_task.created_time.isoformat()
|
||||
if processing_task.created_time
|
||||
else "",
|
||||
"completed_at": processing_task.completed_time.isoformat()
|
||||
if processing_task.completed_time
|
||||
else "",
|
||||
"geometry_data": geometry_json,
|
||||
"key_info": cavity_view.get("key_info"),
|
||||
"cavity_data": cavity_view.get("cavity_data"),
|
||||
"candidate_schemes": cavity_view.get("candidate_schemes", []),
|
||||
"best_scheme_id": cavity_view.get("best_scheme_id"),
|
||||
"cam_preferences": cam_preferences,
|
||||
"plan_result": cavity_json,
|
||||
"mesh_summary": mesh_summary,
|
||||
"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"),
|
||||
"llm_report": task_parameters.get("llm_report"),
|
||||
"analysis_result": {
|
||||
"geometry_data": geometry_json,
|
||||
"detected_features": features_json,
|
||||
"design_recommendations": recommendations_json,
|
||||
"quality_metrics": {
|
||||
"volume_utilization": file_with_data.get("analysis_metrics", {}).get("volume_utilization", 0),
|
||||
"topology_complexity": file_with_data.get("analysis_metrics", {}).get("topology_complexity", 0),
|
||||
"wall_uniformity": file_with_data.get("analysis_metrics", {}).get("wall_uniformity", 0)
|
||||
},
|
||||
"analysis_summary": file_with_data.get("analysis_metrics", {}).get("analysis_summary", "分析完成")
|
||||
} if geometry_json or features_json or recommendations_json else None,
|
||||
"error": processing_task.error_message or stp_file.error_message or None,
|
||||
}
|
||||
|
||||
logger.info(f"返回持久化任务状态: {task_id} - {processing_task.status}")
|
||||
return task_view
|
||||
|
||||
@staticmethod
|
||||
def _extract_geometry_json(file_with_data: dict) -> Optional[Dict[str, Any]]:
|
||||
"""从 file_with_data 中提取 geometry_json"""
|
||||
if not file_with_data.get("geometry_data"):
|
||||
return None
|
||||
geo_raw = file_with_data["geometry_data"]
|
||||
if isinstance(geo_raw, dict):
|
||||
if "geometry_data" in geo_raw:
|
||||
return geo_raw["geometry_data"]
|
||||
return geo_raw
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _get_mesh_summary(db_session: AsyncSession, stp_file_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""从数据库查询网格摘要"""
|
||||
mesh_record = await db_session.execute(
|
||||
select(MeshData).where(MeshData.stp_file_id == stp_file_id)
|
||||
)
|
||||
mesh_record = mesh_record.scalar_one_or_none()
|
||||
if mesh_record:
|
||||
return {
|
||||
"vertex_count": mesh_record.vertex_count,
|
||||
"face_count": mesh_record.face_count,
|
||||
"point_count": mesh_record.point_count,
|
||||
"quality": mesh_record.quality,
|
||||
}
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_cavity_view(cavity_json: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""兼容旧单方案与新多方案结果视图。"""
|
||||
if not cavity_json:
|
||||
return {
|
||||
"cavity_data": None,
|
||||
"key_info": None,
|
||||
"candidate_schemes": [],
|
||||
"best_scheme_id": None,
|
||||
}
|
||||
|
||||
candidate_schemes = cavity_json.get("candidate_schemes")
|
||||
if candidate_schemes:
|
||||
best_scheme_id = cavity_json.get("best_scheme_id")
|
||||
best_scheme = candidate_schemes[0]
|
||||
if best_scheme_id:
|
||||
for scheme in candidate_schemes:
|
||||
if scheme.get("scheme_id") == best_scheme_id:
|
||||
best_scheme = scheme
|
||||
break
|
||||
return {
|
||||
"cavity_data": best_scheme.get("cavity_data"),
|
||||
"key_info": best_scheme.get("key_info"),
|
||||
"candidate_schemes": candidate_schemes,
|
||||
"best_scheme_id": best_scheme_id or best_scheme.get("scheme_id"),
|
||||
"html_file": best_scheme.get("html_file"),
|
||||
}
|
||||
|
||||
return {
|
||||
"cavity_data": cavity_json,
|
||||
"key_info": cavity_json,
|
||||
"candidate_schemes": [],
|
||||
"best_scheme_id": None,
|
||||
"html_file": cavity_json.get("html_file"),
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
几何验证服务
|
||||
使用 FreeCAD 和 PythonOCC 交叉验证几何数据准确性
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GeometryVerificationService:
|
||||
"""几何验证服务"""
|
||||
|
||||
def __init__(self, timeout: int = 60):
|
||||
# 验证脚本在项目根目录的 scripts 文件夹下
|
||||
# __file__ = /path/to/project/src/services/verification_service.py
|
||||
# parent = /path/to/project/src/services
|
||||
# parent.parent = /path/to/project/src
|
||||
# parent.parent.parent = /path/to/project (正确)
|
||||
self.verification_script = Path(__file__).parent.parent.parent / "scripts" / "verify_stp.py"
|
||||
self.timeout = timeout # 超时时间(秒),默认60秒
|
||||
|
||||
async def verify_stp_file(self, stp_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
验证 STP 文件几何数据
|
||||
|
||||
Args:
|
||||
stp_path: STP 文件路径
|
||||
|
||||
Returns:
|
||||
验证结果字典
|
||||
"""
|
||||
try:
|
||||
logger.info(f"开始验证 STP 文件: {stp_path}")
|
||||
|
||||
# 运行验证脚本
|
||||
result = await self._run_verification_script(stp_path)
|
||||
|
||||
if result:
|
||||
logger.info(f"验证完成: {result.get('status', 'unknown')}")
|
||||
else:
|
||||
logger.warning("验证脚本未返回结果")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"验证失败: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
async def _run_verification_script(self, stp_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""运行验证脚本"""
|
||||
import tempfile
|
||||
import os
|
||||
import shutil
|
||||
|
||||
# 确保 stp_path 是字符串
|
||||
stp_path_str = str(stp_path)
|
||||
|
||||
# 创建临时输出文件
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
||||
output_path = f.name
|
||||
|
||||
try:
|
||||
# 构建命令 - 使用 FreeCAD 命令行模式
|
||||
cmd = None
|
||||
|
||||
# 使用 shutil.which 查找 FreeCAD 命令(更快)
|
||||
for cmd_name in ['freecad', 'freecadcmd', 'freecad-daily']:
|
||||
cmd_path = shutil.which(cmd_name)
|
||||
if cmd_path:
|
||||
cmd = [cmd_path, str(self.verification_script), str(Path(stp_path_str).absolute())]
|
||||
logger.info(f"找到 FreeCAD 命令: {cmd_path}")
|
||||
break
|
||||
|
||||
# 检查 Flatpak 版本
|
||||
if not cmd and shutil.which('flatpak'):
|
||||
try:
|
||||
result = subprocess.run(['flatpak', 'list', '--app'], capture_output=True, text=True)
|
||||
if 'org.freecad.FreeCAD' in result.stdout:
|
||||
cmd = ['flatpak', 'run', 'org.freecad.FreeCAD', str(self.verification_script), str(Path(stp_path_str).absolute())]
|
||||
logger.info("找到 FreeCAD Flatpak 版本")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果 which 找不到,尝试直接检查常见路径
|
||||
if not cmd:
|
||||
for cmd_path in ['/usr/local/bin/freecad', '/usr/bin/freecad', '/usr/bin/freecad-daily', '/usr/local/bin/freecadcmd', '/usr/bin/freecadcmd', '/snap/bin/freecad.cmd']:
|
||||
if Path(cmd_path).exists():
|
||||
cmd = [cmd_path, str(self.verification_script), str(Path(stp_path_str).absolute())]
|
||||
logger.info(f"找到 FreeCAD 命令路径: {cmd_path}")
|
||||
break
|
||||
|
||||
# 尝试使用 xvfb-run 运行 AppImage
|
||||
if not cmd and shutil.which('xvfb-run'):
|
||||
for appimage_path in ['/usr/local/bin/freecad', '/opt/freecad.AppImage']:
|
||||
if Path(appimage_path).exists():
|
||||
cmd = ['xvfb-run', appimage_path, str(self.verification_script), str(Path(stp_path_str).absolute())]
|
||||
logger.info(f"使用 xvfb-run 运行: {appimage_path}")
|
||||
break
|
||||
|
||||
if not cmd:
|
||||
logger.warning("FreeCAD 命令行工具不可用,跳过验证")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "FreeCAD not available",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
logger.info(f"执行验证命令: {' '.join(cmd)}")
|
||||
|
||||
# 获取 STP 文件的绝对路径和目录
|
||||
stp_abs_path = Path(stp_path_str).absolute()
|
||||
stp_dir = stp_abs_path.parent
|
||||
|
||||
# 设置环境变量禁用图形界面
|
||||
env = os.environ.copy()
|
||||
env['QT_QPA_PLATFORM'] = 'offscreen'
|
||||
env['DISPLAY'] = ''
|
||||
env['FREECAD_USER_HOME'] = '/tmp/freecad_home'
|
||||
|
||||
logger.info(f"工作目录: {stp_dir}")
|
||||
logger.info(f"STP 文件: {stp_abs_path}")
|
||||
logger.info(f"验证脚本: {self.verification_script}")
|
||||
|
||||
# 异步运行子进程
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(stp_dir),
|
||||
env=env
|
||||
)
|
||||
|
||||
# 使用配置的超时时间
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=self.timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"验证脚本执行超时({self.timeout}秒)")
|
||||
process.kill()
|
||||
await process.wait()
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"验证脚本执行超时({self.timeout}秒)",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
stdout_text = stdout.decode('utf-8') if stdout else ''
|
||||
stderr_text = stderr.decode('utf-8') if stderr else ''
|
||||
|
||||
logger.info(f"验证脚本 stdout (前1000字符): {stdout_text[:1000]}")
|
||||
if stderr_text:
|
||||
logger.warning(f"验证脚本 stderr: {stderr_text[:500]}")
|
||||
|
||||
# 检查是否有 FreeCAD 错误
|
||||
has_error = (
|
||||
'Cannot read STEP file' in stderr_text or
|
||||
'Cannot read STEP file' in stdout_text or
|
||||
'Exception while processing file' in stderr_text or
|
||||
'Exception while processing file' in stdout_text or
|
||||
'所有导入方法都失败' in stdout_text or
|
||||
process.returncode != 0
|
||||
)
|
||||
|
||||
if has_error:
|
||||
logger.error("FreeCAD 验证失败,无法进行交叉验证")
|
||||
return {
|
||||
"status": "error",
|
||||
"reason": "FreeCAD 无法读取 STP 文件,无法进行交叉验证",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
if process.returncode == 0:
|
||||
# 尝试读取生成的报告文件(在 STP 文件目录下)
|
||||
report_path = stp_dir / (stp_abs_path.stem + "_verification_report.json")
|
||||
if report_path.exists():
|
||||
with open(report_path, 'r', encoding='utf-8') as f:
|
||||
result = json.load(f)
|
||||
# 删除临时报告文件
|
||||
report_path.unlink()
|
||||
return result
|
||||
else:
|
||||
# 解析 stdout 获取结果
|
||||
logger.warning(f"验证报告文件不存在: {report_path}")
|
||||
return self._parse_verification_output(stdout_text)
|
||||
else:
|
||||
logger.error(f"验证脚本执行失败 (returncode={process.returncode}): {stderr_text}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": stderr_text,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning("FreeCAD 命令行工具不可用,跳过验证")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "FreeCAD not available",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"运行验证脚本失败: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if os.path.exists(output_path):
|
||||
os.unlink(output_path)
|
||||
|
||||
def _parse_verification_output(self, output: str) -> Dict[str, Any]:
|
||||
"""解析验证脚本输出"""
|
||||
result = {
|
||||
"status": "unknown",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"comparison": {}
|
||||
}
|
||||
|
||||
lines = output.split('\n')
|
||||
current_section = None
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
# 检测验证结果
|
||||
if '验证结果:' in line:
|
||||
if '✅ 通过' in line or '通过' in line:
|
||||
result['status'] = 'passed'
|
||||
elif '❌ 失败' in line or '失败' in line:
|
||||
result['status'] = 'failed'
|
||||
|
||||
# 检测当前段落
|
||||
if '体积对比:' in line:
|
||||
current_section = 'volume'
|
||||
elif '表面积对比:' in line:
|
||||
current_section = 'surface_area'
|
||||
|
||||
# 解析差异百分比
|
||||
if '差异:' in line and '%' in line:
|
||||
try:
|
||||
# 格式: "差异: 123.4567 mm³ (0.1234%)"
|
||||
parts = line.split('(')
|
||||
if len(parts) >= 2:
|
||||
percent_str = parts[-1].split('%')[0].strip()
|
||||
percent = float(percent_str)
|
||||
|
||||
if current_section == 'volume':
|
||||
result['comparison']['volume'] = {'difference_percent': percent}
|
||||
elif current_section == 'surface_area':
|
||||
result['comparison']['surface_area'] = {'difference_percent': percent}
|
||||
except Exception as e:
|
||||
logger.debug(f"解析差异百分比失败: {e}")
|
||||
|
||||
# 如果没有找到验证结果,但有 comparison 数据,则根据差异判断
|
||||
if result['status'] == 'unknown' and result['comparison']:
|
||||
vol_diff = result['comparison'].get('volume', {}).get('difference_percent', 100)
|
||||
area_diff = result['comparison'].get('surface_area', {}).get('difference_percent', 100)
|
||||
if vol_diff < 1 and area_diff < 2:
|
||||
result['status'] = 'passed'
|
||||
else:
|
||||
result['status'] = 'failed'
|
||||
|
||||
return result
|
||||
|
||||
def _verify_with_pythonocc_only(self, stp_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
仅使用 PythonOCC 验证(当 FreeCAD 不可用时)
|
||||
|
||||
Args:
|
||||
stp_path: STP 文件路径
|
||||
|
||||
Returns:
|
||||
验证结果
|
||||
"""
|
||||
try:
|
||||
from OCC.Core.STEPControl import STEPControl_Reader
|
||||
from OCC.Core.IFSelect import IFSelect_RetDone
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop_VolumeProperties, brepgprop_SurfaceProperties
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX, TopAbs_SOLID
|
||||
|
||||
logger.info(f"使用 PythonOCC 进行验证: {stp_path}")
|
||||
|
||||
# 读取 STP 文件
|
||||
reader = STEPControl_Reader()
|
||||
status = reader.ReadFile(stp_path)
|
||||
|
||||
if status != IFSelect_RetDone:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "无法读取 STP 文件",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
reader.TransferRoots()
|
||||
shape = reader.OneShape()
|
||||
|
||||
# 计算体积
|
||||
vol_props = GProp_GProps()
|
||||
brepgprop_VolumeProperties(shape, vol_props)
|
||||
volume_mm3 = vol_props.Mass()
|
||||
com = vol_props.CentreOfMass()
|
||||
|
||||
# 计算表面积
|
||||
surf_props = GProp_GProps()
|
||||
brepgprop_SurfaceProperties(shape, surf_props)
|
||||
surface_area_mm2 = surf_props.Mass()
|
||||
|
||||
# 计算边界框
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
|
||||
# 拓扑统计
|
||||
def count_topology(shape, top_type):
|
||||
explorer = TopExp_Explorer(shape, top_type)
|
||||
count = 0
|
||||
while explorer.More():
|
||||
count += 1
|
||||
explorer.Next()
|
||||
return count
|
||||
|
||||
return {
|
||||
"status": "passed",
|
||||
"method": "pythonocc_only",
|
||||
"reason": "FreeCAD 验证失败,仅使用 PythonOCC 验证",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"pythonocc": {
|
||||
"volume_mm3": float(volume_mm3),
|
||||
"volume_cm3": float(volume_mm3 / 1000),
|
||||
"surface_area_mm2": float(surface_area_mm2),
|
||||
"surface_area_cm2": float(surface_area_mm2 / 100),
|
||||
"bounding_box": {
|
||||
"x_min": float(xmin),
|
||||
"x_max": float(xmax),
|
||||
"y_min": float(ymin),
|
||||
"y_max": float(ymax),
|
||||
"z_min": float(zmin),
|
||||
"z_max": float(zmax),
|
||||
"x_length": float(xmax - xmin),
|
||||
"y_length": float(ymax - ymin),
|
||||
"z_length": float(zmax - zmin),
|
||||
"center": [float((xmin + xmax) / 2), float((ymin + ymax) / 2), float((zmin + zmax) / 2)]
|
||||
},
|
||||
"center_of_mass": [float(com.X()), float(com.Y()), float(com.Z())],
|
||||
"topology": {
|
||||
"faces": count_topology(shape, TopAbs_FACE),
|
||||
"edges": count_topology(shape, TopAbs_EDGE),
|
||||
"vertices": count_topology(shape, TopAbs_VERTEX),
|
||||
"solids": count_topology(shape, TopAbs_SOLID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PythonOCC 验证失败: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
def verify_with_pythonocc(self, shape) -> Dict[str, Any]:
|
||||
"""
|
||||
使用 PythonOCC 验证几何数据(同步方法,用于内部验证)
|
||||
|
||||
Args:
|
||||
shape: OCC 形状对象
|
||||
|
||||
Returns:
|
||||
验证结果
|
||||
"""
|
||||
try:
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.BRepGProp import brepgprop_VolumeProperties, brepgprop_SurfaceProperties
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
from OCC.Core.TopExp import TopExp_Explorer
|
||||
from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX, TopAbs_SOLID
|
||||
|
||||
# 计算体积
|
||||
vol_props = GProp_GProps()
|
||||
brepgprop_VolumeProperties(shape, vol_props)
|
||||
volume_mm3 = vol_props.Mass()
|
||||
com = vol_props.CentreOfMass()
|
||||
|
||||
# 计算表面积
|
||||
surf_props = GProp_GProps()
|
||||
brepgprop_SurfaceProperties(shape, surf_props)
|
||||
surface_area_mm2 = surf_props.Mass()
|
||||
|
||||
# 计算边界框
|
||||
bbox = Bnd_Box()
|
||||
brepbndlib.Add(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
|
||||
# 拓扑统计
|
||||
def count_topology(shape, top_type):
|
||||
explorer = TopExp_Explorer(shape, top_type)
|
||||
count = 0
|
||||
while explorer.More():
|
||||
count += 1
|
||||
explorer.Next()
|
||||
return count
|
||||
|
||||
return {
|
||||
"volume_mm3": float(volume_mm3),
|
||||
"volume_cm3": float(volume_mm3 / 1000),
|
||||
"surface_area_mm2": float(surface_area_mm2),
|
||||
"surface_area_cm2": float(surface_area_mm2 / 100),
|
||||
"bounding_box": {
|
||||
"x_min": float(xmin),
|
||||
"x_max": float(xmax),
|
||||
"y_min": float(ymin),
|
||||
"y_max": float(ymax),
|
||||
"z_min": float(zmin),
|
||||
"z_max": float(zmax),
|
||||
"x_length": float(xmax - xmin),
|
||||
"y_length": float(ymax - ymin),
|
||||
"z_length": float(zmax - zmin),
|
||||
"center": [float((xmin + xmax) / 2), float((ymin + ymax) / 2), float((zmin + zmax) / 2)]
|
||||
},
|
||||
"center_of_mass": [float(com.X()), float(com.Y()), float(com.Z())],
|
||||
"topology": {
|
||||
"faces": count_topology(shape, TopAbs_FACE),
|
||||
"edges": count_topology(shape, TopAbs_EDGE),
|
||||
"vertices": count_topology(shape, TopAbs_VERTEX),
|
||||
"solids": count_topology(shape, TopAbs_SOLID)
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PythonOCC 验证失败: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# 单例实例
|
||||
verification_service = GeometryVerificationService()
|
||||
@@ -0,0 +1,5 @@
|
||||
# storage/__init__.py
|
||||
from .rustfs_storage import RustFSManager, rustfs_manager
|
||||
|
||||
__all__ = ['RustFSManager', 'rustfs_manager']
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# storage/init_storage.py
|
||||
"""初始化 RustFS 对象存储"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录和 src 目录到 Python 路径
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
src_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
sys.path.insert(0, str(src_root))
|
||||
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def init_rustfs_storage():
|
||||
"""初始化 RustFS 对象存储"""
|
||||
try:
|
||||
# 连接到 RustFS (S3v4 API)
|
||||
await rustfs_manager.connect(
|
||||
endpoint=settings.RUSTFS_ENDPOINT,
|
||||
access_key=settings.RUSTFS_ACCESS_KEY,
|
||||
secret_key=settings.RUSTFS_SECRET_KEY,
|
||||
timeout=settings.RUSTFS_TIMEOUT
|
||||
)
|
||||
|
||||
logger.info("RustFS 对象存储初始化完成")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"RustFS 对象存储初始化失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_storage():
|
||||
"""测试 RustFS 对象存储功能"""
|
||||
try:
|
||||
import json
|
||||
|
||||
# 测试上传 JSON
|
||||
test_data = {"test": True, "timestamp": "2024-01-01", "storage": "rustfs"}
|
||||
result = await rustfs_manager.upload_json_data(
|
||||
file_type='stp_files',
|
||||
json_data=test_data,
|
||||
file_hash='test-hash'
|
||||
)
|
||||
|
||||
logger.info(f"RustFS 测试上传成功: {result['object_key']}")
|
||||
|
||||
# 测试下载
|
||||
downloaded_bytes = await rustfs_manager.download_file(
|
||||
file_type='stp_files',
|
||||
object_key=result['object_key']
|
||||
)
|
||||
downloaded_data = json.loads(downloaded_bytes.decode('utf-8'))
|
||||
logger.info(f"RustFS 测试下载成功: {downloaded_data}")
|
||||
|
||||
# 测试预签名 URL
|
||||
url = await rustfs_manager.generate_presigned_url(
|
||||
file_type='stp_files',
|
||||
object_key=result['object_key'],
|
||||
expires=3600
|
||||
)
|
||||
logger.info(f"RustFS 预签名URL: {url}")
|
||||
|
||||
# 清理测试文件
|
||||
await rustfs_manager.delete_file(
|
||||
file_type='stp_files',
|
||||
object_key=result['object_key']
|
||||
)
|
||||
logger.info("RustFS 测试文件已清理")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"RustFS 存储测试失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
async def main():
|
||||
try:
|
||||
print("=== 初始化 RustFS 对象存储 ===")
|
||||
# 初始化存储
|
||||
init_result = await init_rustfs_storage()
|
||||
if init_result:
|
||||
print("[OK] RustFS 连接成功")
|
||||
|
||||
print("\n=== 测试 RustFS 功能 ===")
|
||||
# 运行测试
|
||||
test_result = await test_storage()
|
||||
if test_result:
|
||||
print("[OK] RustFS 测试全部通过")
|
||||
else:
|
||||
print("[FAIL] RustFS 测试失败")
|
||||
|
||||
finally:
|
||||
# 关闭连接
|
||||
await rustfs_manager.close()
|
||||
print("\n=== 连接已关闭 ===")
|
||||
|
||||
# 运行主函数
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,361 @@
|
||||
# storage/object_storage.py
|
||||
"""MinIO/S3 对象存储服务"""
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
from pathlib import Path
|
||||
from typing import Optional, BinaryIO
|
||||
from io import BytesIO
|
||||
from shared.utils.logger import get_logger
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ObjectStorageManager:
|
||||
"""对象存储管理器 - MinIO/S3兼容"""
|
||||
|
||||
def __init__(self):
|
||||
self.client: Optional[Minio] = None
|
||||
self.is_connected = False
|
||||
|
||||
# 桶名称
|
||||
self.buckets = {
|
||||
'stp_files': 'moldinsight-stp-files', # STP/STEP文件
|
||||
'geometry_data': 'moldinsight-geometry', # 几何数据JSON
|
||||
'mold_cavities': 'moldinsight-mold-cavities', # 模具型腔数据
|
||||
'html_files': 'moldinsight-html', # HTML报告文件
|
||||
'user_files': 'moldinsight-user-files' # 用户上传的其他文件
|
||||
}
|
||||
|
||||
async def connect(self, endpoint: str, access_key: str, secret_key: str,
|
||||
secure: bool = False):
|
||||
"""连接到MinIO/S3服务"""
|
||||
try:
|
||||
self.client = Minio(
|
||||
endpoint,
|
||||
access_key=access_key,
|
||||
secret_key=secret_key,
|
||||
secure=secure
|
||||
)
|
||||
|
||||
# 测试连接
|
||||
self.client.list_buckets()
|
||||
|
||||
self.is_connected = True
|
||||
logger.info(f"对象存储连接成功: {endpoint}")
|
||||
|
||||
# 确保所有桶都存在
|
||||
await self._ensure_buckets()
|
||||
|
||||
except S3Error as e:
|
||||
logger.error(f"对象存储连接失败: {e}")
|
||||
self.is_connected = False
|
||||
raise
|
||||
|
||||
async def _ensure_buckets(self):
|
||||
"""确保所有必要的桶都存在"""
|
||||
for bucket_name in self.buckets.values():
|
||||
try:
|
||||
if not self.client.bucket_exists(bucket_name):
|
||||
self.client.make_bucket(bucket_name)
|
||||
logger.info(f"创建存储桶: {bucket_name}")
|
||||
else:
|
||||
logger.debug(f"存储桶已存在: {bucket_name}")
|
||||
except S3Error as e:
|
||||
logger.error(f"创建存储桶失败 {bucket_name}: {e}")
|
||||
|
||||
def _generate_object_key(self, original_filename: str, prefix: str = '') -> str:
|
||||
"""生成对象存储的唯一键名"""
|
||||
# 提取文件扩展名
|
||||
ext = Path(original_filename).suffix
|
||||
|
||||
# 生成唯一ID
|
||||
unique_id = str(uuid.uuid4())
|
||||
|
||||
# 生成键名: prefix/unique_id + original_ext
|
||||
if prefix:
|
||||
return f"{prefix}/{unique_id}{ext}"
|
||||
return f"{unique_id}{ext}"
|
||||
|
||||
async def upload_stp_file(self, file_path: Path,
|
||||
original_filename: str) -> dict:
|
||||
"""上传STP文件到对象存储"""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("对象存储未连接")
|
||||
|
||||
bucket_name = self.buckets['stp_files']
|
||||
|
||||
# 计算文件哈希
|
||||
file_hash = self._calculate_file_hash(file_path)
|
||||
|
||||
# 检查是否已存在
|
||||
existing_key = await self._find_file_by_hash(bucket_name, file_hash)
|
||||
if existing_key:
|
||||
logger.info(f"文件已存在,跳过上传: {existing_key}")
|
||||
return {
|
||||
'object_key': existing_key,
|
||||
'file_hash': file_hash,
|
||||
'already_exists': True
|
||||
}
|
||||
|
||||
# 生成唯一键名
|
||||
object_key = self._generate_object_key(
|
||||
original_filename,
|
||||
prefix='stp'
|
||||
)
|
||||
|
||||
# 上传文件
|
||||
try:
|
||||
result = self.client.fput_object(
|
||||
bucket_name,
|
||||
object_key,
|
||||
str(file_path),
|
||||
content_type='application/octet-stream'
|
||||
)
|
||||
|
||||
logger.info(f"STP文件上传成功: {object_key}")
|
||||
|
||||
return {
|
||||
'object_key': object_key,
|
||||
'file_hash': file_hash,
|
||||
'file_size': result.size,
|
||||
'etag': result.etag,
|
||||
'already_exists': False
|
||||
}
|
||||
except S3Error as e:
|
||||
logger.error(f"STP文件上传失败: {e}")
|
||||
raise
|
||||
|
||||
async def upload_geometry_data(self, geometry_json: dict,
|
||||
file_hash: str) -> dict:
|
||||
"""上传几何数据JSON到对象存储"""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("对象存储未连接")
|
||||
|
||||
bucket_name = self.buckets['geometry_data']
|
||||
|
||||
# 使用文件哈希作为键名的一部分
|
||||
object_key = f"geometry/{file_hash}.json"
|
||||
|
||||
# 转换为字节
|
||||
import json
|
||||
json_bytes = json.dumps(geometry_json, ensure_ascii=False).encode('utf-8')
|
||||
|
||||
# 上传
|
||||
try:
|
||||
result = self.client.put_object(
|
||||
bucket_name,
|
||||
object_key,
|
||||
BytesIO(json_bytes),
|
||||
length=len(json_bytes),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
logger.info(f"几何数据上传成功: {object_key}")
|
||||
|
||||
return {
|
||||
'object_key': object_key,
|
||||
'file_size': result.size,
|
||||
'etag': result.etag
|
||||
}
|
||||
except S3Error as e:
|
||||
logger.error(f"几何数据上传失败: {e}")
|
||||
raise
|
||||
|
||||
async def upload_mold_cavity_data(self, cavity_json: dict,
|
||||
file_hash: str) -> dict:
|
||||
"""上传模具型腔数据到对象存储"""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("对象存储未连接")
|
||||
|
||||
bucket_name = self.buckets['mold_cavities']
|
||||
object_key = f"mold-cavity/{file_hash}.json"
|
||||
|
||||
import json
|
||||
json_bytes = json.dumps(cavity_json, ensure_ascii=False).encode('utf-8')
|
||||
|
||||
try:
|
||||
result = self.client.put_object(
|
||||
bucket_name,
|
||||
object_key,
|
||||
BytesIO(json_bytes),
|
||||
length=len(json_bytes),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
logger.info(f"模具型腔数据上传成功: {object_key}")
|
||||
|
||||
return {
|
||||
'object_key': object_key,
|
||||
'file_size': result.size,
|
||||
'etag': result.etag
|
||||
}
|
||||
except S3Error as e:
|
||||
logger.error(f"模具型腔数据上传失败: {e}")
|
||||
raise
|
||||
|
||||
async def upload_html_file(self, html_content: str,
|
||||
original_filename: str,
|
||||
file_hash: str) -> dict:
|
||||
"""上传HTML文件到对象存储"""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("对象存储未连接")
|
||||
|
||||
bucket_name = self.buckets['html_files']
|
||||
object_key = f"html/{file_hash}.html"
|
||||
|
||||
html_bytes = html_content.encode('utf-8')
|
||||
|
||||
try:
|
||||
result = self.client.put_object(
|
||||
bucket_name,
|
||||
object_key,
|
||||
BytesIO(html_bytes),
|
||||
length=len(html_bytes),
|
||||
content_type='text/html; charset=utf-8'
|
||||
)
|
||||
|
||||
logger.info(f"HTML文件上传成功: {object_key}")
|
||||
|
||||
return {
|
||||
'object_key': object_key,
|
||||
'file_size': result.size,
|
||||
'etag': result.etag
|
||||
}
|
||||
except S3Error as e:
|
||||
logger.error(f"HTML文件上传失败: {e}")
|
||||
raise
|
||||
|
||||
async def download_file(self, bucket_type: str,
|
||||
object_key: str) -> bytes:
|
||||
"""从对象存储下载文件"""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("对象存储未连接")
|
||||
|
||||
bucket_name = self.buckets.get(bucket_type)
|
||||
if not bucket_name:
|
||||
raise ValueError(f"未知的桶类型: {bucket_type}")
|
||||
|
||||
try:
|
||||
response = self.client.get_object(bucket_name, object_key)
|
||||
data = response.read()
|
||||
response.close()
|
||||
response.release_conn()
|
||||
|
||||
logger.debug(f"文件下载成功: {object_key}")
|
||||
return data
|
||||
except S3Error as e:
|
||||
logger.error(f"文件下载失败 {object_key}: {e}")
|
||||
raise
|
||||
|
||||
async def get_presigned_url(self, bucket_type: str,
|
||||
object_key: str,
|
||||
expires: int = 3600) -> str:
|
||||
"""生成预签名URL(临时访问链接)"""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("对象存储未连接")
|
||||
|
||||
bucket_name = self.buckets.get(bucket_type)
|
||||
if not bucket_name:
|
||||
raise ValueError(f"未知的桶类型: {bucket_type}")
|
||||
|
||||
try:
|
||||
url = self.client.presigned_get_object(
|
||||
bucket_name,
|
||||
object_key,
|
||||
expires=expires
|
||||
)
|
||||
return url
|
||||
except S3Error as e:
|
||||
logger.error(f"生成预签名URL失败: {e}")
|
||||
raise
|
||||
|
||||
async def delete_file(self, bucket_type: str, object_key: str):
|
||||
"""删除对象存储中的文件"""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("对象存储未连接")
|
||||
|
||||
bucket_name = self.buckets.get(bucket_type)
|
||||
if not bucket_name:
|
||||
raise ValueError(f"未知的桶类型: {bucket_type}")
|
||||
|
||||
try:
|
||||
self.client.remove_object(bucket_name, object_key)
|
||||
logger.info(f"文件删除成功: {object_key}")
|
||||
except S3Error as e:
|
||||
logger.error(f"文件删除失败 {object_key}: {e}")
|
||||
raise
|
||||
|
||||
def _calculate_file_hash(self, file_path: Path) -> str:
|
||||
"""计算文件的SHA256哈希"""
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, 'rb') as f:
|
||||
for byte_block in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
async def _find_file_by_hash(self, bucket_name: str,
|
||||
file_hash: str) -> Optional[str]:
|
||||
"""根据哈希查找已存在的文件"""
|
||||
try:
|
||||
objects = self.client.list_objects(bucket_name, recursive=True)
|
||||
for obj in objects:
|
||||
# 从对象键中提取哈希(如果有)
|
||||
if file_hash in obj.object_name:
|
||||
return obj.object_name
|
||||
return None
|
||||
except S3Error as e:
|
||||
logger.warning(f"查找文件哈希失败: {e}")
|
||||
return None
|
||||
|
||||
async def get_file_info(self, bucket_type: str,
|
||||
object_key: str) -> dict:
|
||||
"""获取文件信息"""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("对象存储未连接")
|
||||
|
||||
bucket_name = self.buckets.get(bucket_type)
|
||||
if not bucket_name:
|
||||
raise ValueError(f"未知的桶类型: {bucket_type}")
|
||||
|
||||
try:
|
||||
stat = self.client.stat_object(bucket_name, object_key)
|
||||
return {
|
||||
'size': stat.size,
|
||||
'etag': stat.etag,
|
||||
'content_type': stat.content_type,
|
||||
'last_modified': stat.last_modified
|
||||
}
|
||||
except S3Error as e:
|
||||
logger.error(f"获取文件信息失败: {e}")
|
||||
raise
|
||||
|
||||
async def list_files(self, bucket_type: str,
|
||||
prefix: str = '') -> list:
|
||||
"""列出存储桶中的文件"""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("对象存储未连接")
|
||||
|
||||
bucket_name = self.buckets.get(bucket_type)
|
||||
if not bucket_name:
|
||||
raise ValueError(f"未知的桶类型: {bucket_type}")
|
||||
|
||||
try:
|
||||
objects = self.client.list_objects(bucket_name, prefix=prefix)
|
||||
return [
|
||||
{
|
||||
'object_key': obj.object_name,
|
||||
'size': obj.size,
|
||||
'etag': obj.etag,
|
||||
'last_modified': obj.last_modified
|
||||
}
|
||||
for obj in objects
|
||||
]
|
||||
except S3Error as e:
|
||||
logger.error(f"列出文件失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# 全局对象存储管理器实例
|
||||
storage_manager = ObjectStorageManager()
|
||||
@@ -0,0 +1,398 @@
|
||||
# storage/rustfs_storage.py
|
||||
"""RustFS 对象存储服务 (S3v4 API 兼容)"""
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from io import BytesIO
|
||||
from shared.utils.logger import get_logger
|
||||
from datetime import timedelta
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RustFSManager:
|
||||
"""RustFS 对象存储管理器 (使用 MinIO S3 客户端)"""
|
||||
|
||||
def __init__(self, project_name: str = "moldinsight"):
|
||||
self.client: Optional[Minio] = None
|
||||
self.is_connected = False
|
||||
self.project_name = project_name
|
||||
|
||||
# 使用单个项目桶,按类型组织文件
|
||||
self.bucket_name = f"{project_name}"
|
||||
|
||||
# 文件类型前缀(子目录结构)
|
||||
self.file_types = {
|
||||
'stp_files': 'stp-files',
|
||||
'geometry_data': 'geometry',
|
||||
'mesh_data': 'mesh',
|
||||
'mold_cavities': 'mold-cavities',
|
||||
'html_files': 'html',
|
||||
'user_files': 'user-files'
|
||||
}
|
||||
|
||||
async def connect(self, endpoint: str, access_key: str, secret_key: str, timeout: int = 30):
|
||||
"""连接到 RustFS 服务"""
|
||||
try:
|
||||
# 提取端口号和主机
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(endpoint)
|
||||
host = parsed.netloc or parsed.path
|
||||
|
||||
# 创建 MinIO 客户端(S3v4 兼容)
|
||||
self.client = Minio(
|
||||
host,
|
||||
access_key=access_key,
|
||||
secret_key=secret_key,
|
||||
secure=False, # HTTP 而不是 HTTPS
|
||||
region='us-east-1'
|
||||
)
|
||||
|
||||
# 测试连接
|
||||
import asyncio
|
||||
await asyncio.to_thread(self.client.list_buckets)
|
||||
|
||||
self.is_connected = True
|
||||
logger.info(f"RustFS 连接成功: {endpoint}")
|
||||
|
||||
# 确保所有桶都存在
|
||||
await self._ensure_buckets()
|
||||
|
||||
except S3Error as e:
|
||||
logger.error(f"RustFS 连接失败: {e}")
|
||||
self.is_connected = False
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"RustFS 初始化失败: {e}")
|
||||
self.is_connected = False
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
"""关闭连接"""
|
||||
# MinIO 客户端不需要显式关闭
|
||||
self.is_connected = False
|
||||
logger.info("RustFS 连接已关闭")
|
||||
|
||||
async def _ensure_buckets(self):
|
||||
"""确保项目存储桶存在"""
|
||||
import asyncio
|
||||
|
||||
def _check_and_create():
|
||||
if not self.client.bucket_exists(self.bucket_name):
|
||||
self.client.make_bucket(self.bucket_name)
|
||||
return True
|
||||
return False
|
||||
|
||||
try:
|
||||
created = await asyncio.to_thread(_check_and_create)
|
||||
if created:
|
||||
logger.info(f"创建项目存储桶: {self.bucket_name}")
|
||||
else:
|
||||
logger.debug(f"项目存储桶已存在: {self.bucket_name}")
|
||||
except S3Error as e:
|
||||
logger.error(f"创建存储桶失败 {self.bucket_name}: {e}")
|
||||
|
||||
def _generate_object_key(self, original_filename: str, file_type: str = '') -> str:
|
||||
"""生成对象存储的唯一键名"""
|
||||
ext = Path(original_filename).suffix
|
||||
unique_id = str(uuid.uuid4())
|
||||
|
||||
# 格式: {文件类型}/{唯一ID}.扩展名 (去掉项目名前缀)
|
||||
if file_type and file_type in self.file_types:
|
||||
type_prefix = self.file_types[file_type]
|
||||
return f"{type_prefix}/{unique_id}{ext}"
|
||||
|
||||
# 默认格式
|
||||
return f"misc/{unique_id}{ext}"
|
||||
|
||||
def _calculate_file_hash(self, file_path: Path) -> str:
|
||||
"""计算文件的SHA256哈希"""
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, 'rb') as f:
|
||||
for byte_block in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
async def upload_file(self, file_type: str, file_path: Path,
|
||||
original_filename: str,
|
||||
metadata: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""上传文件到 RustFS"""
|
||||
import asyncio
|
||||
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("RustFS 未连接")
|
||||
|
||||
if file_type not in self.file_types:
|
||||
raise ValueError(f"未知的文件类型: {file_type}")
|
||||
|
||||
# 计算文件哈希
|
||||
file_hash = self._calculate_file_hash(file_path)
|
||||
|
||||
# 生成唯一键名
|
||||
object_key = self._generate_object_key(original_filename, file_type)
|
||||
|
||||
# 上传文件
|
||||
def _upload():
|
||||
return self.client.fput_object(
|
||||
self.bucket_name,
|
||||
object_key,
|
||||
str(file_path),
|
||||
content_type='application/octet-stream',
|
||||
metadata=metadata or {}
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(_upload)
|
||||
|
||||
logger.info(f"文件上传成功 RustFS: {self.bucket_name}/{object_key}")
|
||||
|
||||
# 获取文件大小
|
||||
file_size = file_path.stat().st_size
|
||||
|
||||
return {
|
||||
'object_key': object_key,
|
||||
'bucket': self.bucket_name,
|
||||
'file_hash': file_hash,
|
||||
'file_size': file_size,
|
||||
'etag': result.etag if hasattr(result, 'etag') else None
|
||||
}
|
||||
|
||||
except S3Error as e:
|
||||
logger.error(f"RustFS 上传失败: {e}")
|
||||
raise
|
||||
|
||||
async def upload_json_data(self, file_type: str,
|
||||
json_data: Dict[str, Any],
|
||||
file_hash: str) -> Dict[str, Any]:
|
||||
"""上传JSON数据到 RustFS"""
|
||||
import asyncio
|
||||
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("RustFS 未连接")
|
||||
|
||||
if file_type not in self.file_types:
|
||||
raise ValueError(f"未知的文件类型: {file_type}")
|
||||
|
||||
# 格式: {文件类型}/{文件哈希}.json (去掉项目名前缀)
|
||||
type_prefix = self.file_types[file_type]
|
||||
object_key = f"{type_prefix}/{file_hash}.json"
|
||||
|
||||
# 转换为字节
|
||||
import json
|
||||
json_bytes = json.dumps(json_data, ensure_ascii=False).encode('utf-8')
|
||||
|
||||
def _upload():
|
||||
return self.client.put_object(
|
||||
self.bucket_name,
|
||||
object_key,
|
||||
BytesIO(json_bytes),
|
||||
length=len(json_bytes),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(_upload)
|
||||
|
||||
logger.info(f"JSON数据上传成功 RustFS: {self.bucket_name}/{object_key}")
|
||||
|
||||
return {
|
||||
'object_key': object_key,
|
||||
'bucket': self.bucket_name,
|
||||
'file_size': len(json_bytes),
|
||||
'etag': result.etag if hasattr(result, 'etag') else None
|
||||
}
|
||||
|
||||
except S3Error as e:
|
||||
logger.error(f"RustFS JSON上传失败: {e}")
|
||||
raise
|
||||
|
||||
async def download_file(self, file_type: str, object_key: str) -> bytes:
|
||||
"""从 RustFS 下载文件"""
|
||||
import asyncio
|
||||
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("RustFS 未连接")
|
||||
|
||||
if file_type not in self.file_types:
|
||||
raise ValueError(f"未知的文件类型: {file_type}")
|
||||
|
||||
def _download():
|
||||
response = self.client.get_object(self.bucket_name, object_key)
|
||||
data = response.read()
|
||||
response.close()
|
||||
response.release_conn()
|
||||
return data
|
||||
|
||||
try:
|
||||
data = await asyncio.to_thread(_download)
|
||||
logger.debug(f"文件下载成功: {self.bucket_name}/{object_key}")
|
||||
return data
|
||||
|
||||
except S3Error as e:
|
||||
logger.error(f"RustFS 下载失败: {e}")
|
||||
raise
|
||||
|
||||
async def get_file_info(self, file_type: str, object_key: str) -> Dict[str, Any]:
|
||||
"""获取文件信息"""
|
||||
import asyncio
|
||||
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("RustFS 未连接")
|
||||
|
||||
if file_type not in self.file_types:
|
||||
raise ValueError(f"未知的文件类型: {file_type}")
|
||||
|
||||
def _get_stat():
|
||||
return self.client.stat_object(self.bucket_name, object_key)
|
||||
|
||||
try:
|
||||
stat = await asyncio.to_thread(_get_stat)
|
||||
return {
|
||||
'size': stat.size,
|
||||
'etag': stat.etag,
|
||||
'content_type': stat.content_type,
|
||||
'last_modified': stat.last_modified
|
||||
}
|
||||
|
||||
except S3Error as e:
|
||||
logger.error(f"RustFS 获取文件信息失败: {e}")
|
||||
raise
|
||||
|
||||
async def delete_file(self, file_type: str, object_key: str):
|
||||
"""删除 RustFS 中的文件"""
|
||||
import asyncio
|
||||
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("RustFS 未连接")
|
||||
|
||||
if file_type not in self.file_types:
|
||||
raise ValueError(f"未知的文件类型: {file_type}")
|
||||
|
||||
def _delete():
|
||||
self.client.remove_object(self.bucket_name, object_key)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_delete)
|
||||
logger.info(f"文件删除成功: {self.bucket_name}/{object_key}")
|
||||
|
||||
except S3Error as e:
|
||||
logger.error(f"RustFS 删除失败: {e}")
|
||||
raise
|
||||
|
||||
async def list_files(self, file_type: str, prefix: str = '') -> list:
|
||||
"""列出存储桶中的文件"""
|
||||
import asyncio
|
||||
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("RustFS 未连接")
|
||||
|
||||
if file_type not in self.file_types:
|
||||
raise ValueError(f"未知的文件类型: {file_type}")
|
||||
|
||||
# 构建完整前缀:{文件类型}/... (去掉项目名前缀)
|
||||
type_prefix = self.file_types[file_type]
|
||||
full_prefix = f"{type_prefix}/"
|
||||
if prefix:
|
||||
full_prefix += prefix
|
||||
|
||||
def _list():
|
||||
return list(self.client.list_objects(self.bucket_name, prefix=full_prefix, recursive=True))
|
||||
|
||||
try:
|
||||
objects = await asyncio.to_thread(_list)
|
||||
return [
|
||||
{
|
||||
'object_key': obj.object_name,
|
||||
'size': obj.size,
|
||||
'etag': obj.etag,
|
||||
'last_modified': obj.last_modified
|
||||
}
|
||||
for obj in objects
|
||||
]
|
||||
|
||||
except S3Error as e:
|
||||
logger.error(f"RustFS 列出文件失败: {e}")
|
||||
raise
|
||||
|
||||
async def generate_presigned_url(self, file_type: str,
|
||||
object_key: str,
|
||||
expires: int = 3600,
|
||||
method: str = 'GET') -> str:
|
||||
"""生成预签名URL(临时访问链接)"""
|
||||
import asyncio
|
||||
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("RustFS 未连接")
|
||||
|
||||
if file_type not in self.file_types:
|
||||
raise ValueError(f"未知的文件类型: {file_type}")
|
||||
|
||||
def _generate_url():
|
||||
return self.client.presigned_get_object(
|
||||
self.bucket_name,
|
||||
object_key,
|
||||
expires=timedelta(seconds=expires)
|
||||
)
|
||||
|
||||
try:
|
||||
url = await asyncio.to_thread(_generate_url)
|
||||
return url
|
||||
|
||||
except S3Error as e:
|
||||
logger.error(f"RustFS 生成预签名URL失败: {e}")
|
||||
raise
|
||||
|
||||
async def file_exists(self, file_type: str, object_key: str) -> bool:
|
||||
"""检查文件是否存在"""
|
||||
try:
|
||||
await self.get_file_info(file_type, object_key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_storage_stats(self) -> Dict[str, Any]:
|
||||
"""获取存储统计信息"""
|
||||
import asyncio
|
||||
|
||||
def _get_stats():
|
||||
buckets = self.client.list_buckets()
|
||||
total_objects = 0
|
||||
total_size = 0
|
||||
namespace_stats = {}
|
||||
|
||||
for bucket in buckets:
|
||||
objects = list(self.client.list_objects(bucket.name, recursive=True))
|
||||
bucket_count = 0
|
||||
bucket_size = 0
|
||||
|
||||
for obj in objects:
|
||||
bucket_count += 1
|
||||
bucket_size += obj.size
|
||||
|
||||
namespace_stats[bucket.name] = {
|
||||
'object_count': bucket_count,
|
||||
'total_size': bucket_size
|
||||
}
|
||||
|
||||
total_objects += bucket_count
|
||||
total_size += bucket_size
|
||||
|
||||
return {
|
||||
'total_objects': total_objects,
|
||||
'total_size': total_size,
|
||||
'namespace_stats': namespace_stats
|
||||
}
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_get_stats)
|
||||
|
||||
except S3Error as e:
|
||||
logger.error(f"RustFS 获取统计信息失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# 全局 RustFS 管理器实例
|
||||
rustfs_manager = RustFSManager()
|
||||
Reference in New Issue
Block a user