From c1f6b6e827eb70a8b946144e933b9b6ff4e85a6c Mon Sep 17 00:00:00 2001 From: SZCJW <792430652@qq.com> Date: Thu, 23 Apr 2026 23:37:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E6=A8=A1=E5=80=99=E9=80=89=E6=96=B9?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/routes.py | 10 +- src/api/v1/__init__.py | 17 +- src/api/v1/advanced_router.py | 352 ++++++++++++++++++++++++ src/api/v1/upload_router.py | 2 + src/core/aluminum_foam_mold.py | 208 ++------------ src/core/base_mold_generator.py | 174 ------------ src/core/mold_generator.py | 82 ------ src/core/multi_scheme_planner.py | 275 ++++++++++++++++++ src/core/parting_candidate_generator.py | 134 +++++++++ src/core/parting_scheme_scorer.py | 129 +++++++++ src/main.py | 2 +- src/services/calculation_service.py | 75 +++++ src/services/processing_service.py | 118 +++++--- src/services/task_query_service.py | 46 +++- src/utils/html_generator.py | 6 +- static/vue-app.js | 169 ++++++++++-- 16 files changed, 1302 insertions(+), 497 deletions(-) create mode 100644 src/api/v1/advanced_router.py create mode 100644 src/core/multi_scheme_planner.py create mode 100644 src/core/parting_candidate_generator.py create mode 100644 src/core/parting_scheme_scorer.py diff --git a/src/api/routes.py b/src/api/routes.py index 81cc611..7230392 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -102,7 +102,7 @@ async def upload_stp( # 创建处理任务记录 await storage_service.create_processing_task(db_session, task_id, stp_file.id) - # 创建任务记录(Redis 为主,内存为兼容回退) + # 创建任务记录(Redis 为主,内存作为兼容回退) task_info = create_task_info( task_id=task_id, status=ProcessingStatus.PROCESSING, @@ -114,10 +114,13 @@ async def upload_stp( await redis_task_manager.set_task(task_id, task_info) tasks[task_id] = task_info - # 后台处理(统一走 ProcessingService,使用独立数据库会话) + # 后台处理走统一编排服务,避免请求会话在后台失效 background_tasks.add_task( processing_service.process_file_with_storage, - task_id, file_path, stp_file.id, material + task_id, + file_path, + stp_file.id, + material, ) return { @@ -146,7 +149,6 @@ async def get_status(task_id: str, db_session: AsyncSession = Depends(get_db_ses try: task_view = await TaskQueryService.get_task_view(db_session, task_id) if task_view is None: - # 兼容老的仅内存任务 if task_id in tasks: return tasks[task_id] raise HTTPException(404, "任务不存在") diff --git a/src/api/v1/__init__.py b/src/api/v1/__init__.py index a8d2c40..9d07e23 100644 --- a/src/api/v1/__init__.py +++ b/src/api/v1/__init__.py @@ -1 +1,16 @@ -# api/v1/__init__.py +from fastapi import APIRouter + +from api.v1.health_router import router as health_router +from api.v1.upload_router import router as upload_router +from api.v1.task_router import router as task_router +from api.v1.history_router import router as history_router +from api.v1.debug_router import router as debug_router +from api.v1.advanced_router import router as advanced_router + +router = APIRouter() +router.include_router(health_router) +router.include_router(upload_router) +router.include_router(task_router) +router.include_router(history_router) +router.include_router(debug_router) +router.include_router(advanced_router) diff --git a/src/api/v1/advanced_router.py b/src/api/v1/advanced_router.py new file mode 100644 index 0000000..1743073 --- /dev/null +++ b/src/api/v1/advanced_router.py @@ -0,0 +1,352 @@ +from pathlib import Path +import os + +from fastapi import APIRouter, Depends, HTTPException, Request + +from services.auth_service import get_current_active_user +from models.database import User +from utils.logger import get_logger +from api.routes import ( + tasks, + cavity_layout_optimizer, + mold_system_designer, + side_action_designer, + mold_cam_designer, + collision_detector, + toolpath_optimizer, + edm_designer, + machining_simulator, + cad_exporter, +) + +logger = get_logger(__name__) + +router = APIRouter() + + +@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 之间") + + result = cavity_layout_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 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 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") + + result = mold_system_designer.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), +): + """AI 分型面检测""" + body = await request.json() + task_id = body.get("task_id") + + if not task_id or task_id not in tasks: + raise HTTPException(404, "任务不存在") + + task_data = tasks[task_id] + geometry_data = task_data.get("geometry_data") + if not geometry_data: + raise HTTPException(400, "该任务尚未完成几何分析") + + from 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 or task_id not in tasks: + raise HTTPException(404, "任务不存在") + + task_data = tasks[task_id] + geometry_data = task_data.get("geometry_data") + if not geometry_data: + raise HTTPException(400, "该任务尚未完成几何分析") + + result = side_action_designer.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), +): + """模具CAM刀路设计""" + 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") + + result = mold_cam_designer.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") + + result = collision_detector.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") + + result = toolpath_optimizer.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), +): + """EDM电极设计""" + 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) + + result = edm_designer.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) + + result = machining_simulator.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), +): + """导出模具设计结果(STEP/IGES/STL/BRep)""" + body = await request.json() + task_id = body.get("task_id") + formats = body.get("formats", ["step", "stl"]) + components = body.get("components", ["cavity", "core"]) + + if not task_id or task_id not in tasks: + raise HTTPException(404, "任务不存在") + + task_data = tasks[task_id] + cavity_shapes = task_data.get("cavity_shapes") + if not cavity_shapes: + raise HTTPException(400, "该任务尚未完成模具生成或形状数据不可用") + + base_filename = Path(task_data.get("filename", f"mold_{task_id}")).stem + result = cad_exporter.export_mold_results( + cavity_data=cavity_shapes, + base_filename=base_filename, + formats=formats, + components=components, + ) + + return {"status": "success", "data": result} + + +@router.get("/export-download/{filepath:path}") +async def download_export_file( + filepath: str, + current_user: User = Depends(get_current_active_user), +): + """下载导出的CAD文件""" + from fastapi.responses import FileResponse + + full_path = os.path.join(cad_exporter.output_dir, filepath) + + 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), +): + """获取导出格式建议(UG/FreeCAD/SolidWorks)""" + result = cad_exporter.get_export_recommendations(target) + return {"status": "success", "data": result} diff --git a/src/api/v1/upload_router.py b/src/api/v1/upload_router.py index b0663c6..87f1fe6 100644 --- a/src/api/v1/upload_router.py +++ b/src/api/v1/upload_router.py @@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from services.auth_service import get_current_active_user from services.processing_service import processing_service from models.database import User +from api.routes import tasks as legacy_tasks logger = get_logger(__name__) @@ -65,6 +66,7 @@ async def upload_stp( upload_time=str(datetime.now()) ) await redis_task_manager.set_task(task_id, task_info) + legacy_tasks[task_id] = task_info # 后台处理(使用独立数据库会话,避免请求会话关闭问题) background_tasks.add_task( diff --git a/src/core/aluminum_foam_mold.py b/src/core/aluminum_foam_mold.py index c14a68a..90dae93 100644 --- a/src/core/aluminum_foam_mold.py +++ b/src/core/aluminum_foam_mold.py @@ -310,43 +310,6 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator): 1. 优先选择 Z 轴方向分型(上下开模) 2. 分型面位置选在产品的最大轮廓处,即包围盒的 Z 方向中心 """ -<<<<<<< HEAD - primary_parting = self._detect_primary_parting(shape, analysis) - parting_surface = primary_parting["surface"] - parting_line = self.optimize_parting_line(primary_parting["line"]) - primary_direction = primary_parting["direction"] - - additional_surfaces = [] - - bbox = analysis["bounding_box"] - center = bbox["center"] - - dims = bbox["dimensions"] - max_dim = max(dims) - min_dim = min(dims) - - if 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 -======= - # 1. 尝试 AI 模型 - if self.ai_parting_detector is not None: - try: - ai_result = self.ai_parting_detector.detect(shape, analysis) - if ai_result: - return self._create_parting_surface_from_ai(ai_result, analysis) - except Exception as e: - logger.warning(f"AI 分型面检测失败: {e}") - - # 2. 泡沫模具强制 Z 轴方向分型(上下开模) bbox = analysis["bounding_box"] center = bbox["center"] primary_direction = [0, 0, 1] # Z 轴方向 @@ -364,26 +327,40 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator): logger.info(f"泡沫模具 Z 轴分型面: Z={parting_z:.2f} mm (包围盒中心)") - # 3. 计算分型线 - parting_line = self._calculate_parting_line(shape, parting_surface) ->>>>>>> 77c4885a4020b609f4964184661f42e936814c4f + 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, -<<<<<<< HEAD - "confidence": primary_parting["confidence"], - "method": primary_parting["method"], - "additional_surfaces": additional_surfaces, - "surface_count": 1 + len(additional_surfaces) -======= "confidence": 0.95, # Z 轴分型置信度高 - "additional_surfaces": [], - "surface_count": 1, + "method": "z_axis_rule", + "additional_surfaces": additional_surfaces, + "surface_count": 1 + len(additional_surfaces), "parting_direction": "Z", "parting_position_z": parting_z, ->>>>>>> 77c4885a4020b609f4964184661f42e936814c4f } def _build_undercut_regions(self, undercut_analysis: Dict[str, Any]) -> List[Dict[str, Any]]: @@ -463,120 +440,6 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator): except Exception: return 50.0 -<<<<<<< HEAD - -======= - - def _apply_shrinkage_compensation(self, shape: Any) -> Any: - """应用收缩率补偿(铝泡沫版本)""" - # 铝泡沫收缩率通常较大 - 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}%") - return scaled_shape - except Exception as e: - logger.error(f"收缩补偿失败: {e}") - return shape - - def _apply_draft_angles(self, shape: Any, parting_surface: Any) -> Any: - """应用拔模角(改进版)""" - # 获取分型面法向量作为拔模方向 - try: - surface = BRepAdaptor_Surface(parting_surface) - draft_direction = surface.Plane().Position().Direction() - - logger.info(f"应用拔模角: {self.draft_angle}°, 方向: ({draft_direction.X():.3f}, {draft_direction.Y():.3f}, {draft_direction.Z():.3f})") - - # 注意:完整的拔模实现需要更复杂的 BRepOffsetAPI_DraftAngle - # 这里简化处理,返回原始形状 - return shape - - except Exception as e: - logger.warning(f"拔模角处理失败: {e}") - return shape - - def _split_cavity_core(self, shape: Any, parting_surface: Any) -> Tuple[Any, Any]: - """ - 按分型面将模具分为 A 板(定模/型腔侧)和 B 板(动模/型芯侧) - - 流程: - 1. 创建模具块(包围产品的长方体) - 2. 用分型面水平切出上半块和下半块 - 3. 从上半块减去产品 → A 板(含型腔负形) - 4. 从下半块减去产品 → B 板(含型芯负形) - """ - try: - # 获取产品边界框 - bbox = Bnd_Box() - brepbndlib_Add(shape, bbox) - xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() - - # 获取分型面 Z 位置(包围盒 Z 中心) - parting_z = (zmin + zmax) / 2 - - # 模具块尺寸(比产品大一圈余量) - margin = 25 - mold_xmin = xmin - margin - mold_ymin = ymin - margin - mold_zmin = zmin - margin - mold_xmax = xmax + margin - mold_ymax = ymax + margin - mold_zmax = zmax + margin - - # Step 1: 创建整块模具 - mold_block = BRepPrimAPI_MakeBox( - gp_Pnt(mold_xmin, mold_ymin, mold_zmin), - gp_Pnt(mold_xmax, mold_ymax, mold_zmax) - ).Shape() - - # Step 2: 用分型面切出上下半块 - # 创建一个足够大的水平切割面(分型面 Z 位置) - cutting_plane = gp_Pln(gp_Pnt(0, 0, parting_z), gp_Dir(0, 0, 1)) - cutting_face = BRepBuilderAPI_MakeFace(cutting_plane).Face() - - # 创建上半空间和下半空间的实体 - # 上半块: 从 parting_z 到 mold_zmax - upper_block = BRepPrimAPI_MakeBox( - gp_Pnt(mold_xmin, mold_ymin, parting_z), - gp_Pnt(mold_xmax, mold_ymax, mold_zmax) - ).Shape() - - # 下半块: 从 mold_zmin 到 parting_z - lower_block = BRepPrimAPI_MakeBox( - gp_Pnt(mold_xmin, mold_ymin, mold_zmin), - gp_Pnt(mold_xmax, mold_ymax, parting_z) - ).Shape() - - # Step 3: 从上半块减去产品 → A 板(定模) - a_plate_op = BRepAlgoAPI_Cut(upper_block, shape) - if a_plate_op.IsDone(): - a_plate = a_plate_op.Shape() - logger.info(f"A板(定模)生成成功: Z={parting_z:.1f} ~ {mold_zmax:.1f}") - else: - logger.warning("A板布尔减法失败,使用上半块") - a_plate = upper_block - - # Step 4: 从下半块减去产品 → B 板(动模) - b_plate_op = BRepAlgoAPI_Cut(lower_block, shape) - if b_plate_op.IsDone(): - b_plate = b_plate_op.Shape() - logger.info(f"B板(动模)生成成功: Z={mold_zmin:.1f} ~ {parting_z:.1f}") - else: - logger.warning("B板布尔减法失败,使用下半块") - b_plate = lower_block - - return a_plate, b_plate - - except Exception as e: - logger.error(f"分模失败: {e}") - return shape, shape - ->>>>>>> 77c4885a4020b609f4964184661f42e936814c4f def _generate_mold_block(self, cavity: Any, analysis: Dict) -> Any: """生成完整的模具块(包含A/B板结构)""" try: @@ -656,20 +519,6 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator): } def _calculate_clamping_force(self, analysis: Dict) -> str: -<<<<<<< HEAD - """估算锁模力""" - volume_cm3 = analysis.get("volume", 0) / 1000 - - if volume_cm3 < 10: - return "30-50 吨" - elif volume_cm3 < 50: - return "50-100 吨" - elif volume_cm3 < 200: - return "100-200 吨" - else: - return "200+ 吨" - -======= """ 估算锁模力(泡沫模具专用) @@ -687,14 +536,13 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator): 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" - ->>>>>>> 77c4885a4020b609f4964184661f42e936814c4f + def _estimate_wall_thickness(self, analysis: Dict) -> str: """估算壁厚范围""" volume = analysis.get("volume", 0) diff --git a/src/core/base_mold_generator.py b/src/core/base_mold_generator.py index f4aea9d..98feff9 100644 --- a/src/core/base_mold_generator.py +++ b/src/core/base_mold_generator.py @@ -224,180 +224,6 @@ class BaseMoldGenerator: logger.error(f"产品几何分析失败: {e}") raise - def _analyze_parting_direction(self, shape: Any) -> Dict[str, Any]: - """ - 分析主分型方向。 - - 使用面积加权的面法向统计,作为普通模具与铝泡沫模具的统一几何回退。 - """ - face_normals = [] - face_areas = [] - 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 - - face_props = GProp_GProps() - brepgprop.SurfaceProperties(face, face_props) - area = max(float(face_props.Mass()), 1e-6) - - face_normals.append(np.array([normal.X(), normal.Y(), normal.Z()], dtype=np.float64)) - face_areas.append(area) - except Exception as e: - logger.debug(f"分型方向面分析失败: {e}") - - if not face_normals: - return { - "primary_direction": [0.0, 0.0, 1.0], - "confidence": 0.5, - "face_count": 0, - } - - normals = np.array(face_normals, dtype=np.float64) - areas = np.array(face_areas, dtype=np.float64) - total_area = float(areas.sum()) - if total_area > 1e-6: - weights = areas / total_area - weighted_normal = np.sum(normals * weights[:, np.newaxis], axis=0) - else: - weighted_normal = np.mean(normals, axis=0) - - norm = np.linalg.norm(weighted_normal) - if norm > 1e-6: - weighted_normal /= norm - else: - weighted_normal = np.array([0.0, 0.0, 1.0], dtype=np.float64) - - confidence = float(np.mean(np.abs(np.dot(normals, weighted_normal)))) - - return { - "primary_direction": weighted_normal.tolist(), - "confidence": confidence, - "face_count": len(face_normals), - "normal_distribution": normals.tolist(), - } - - def _create_parting_surface_from_direction( - self, - shape: Any, - analysis: Dict[str, Any], - direction: Any, - extension: float = 10.0, - ) -> Any: - """按给定方向创建覆盖产品边界的分型面。""" - bbox = analysis.get("bounding_box", {}) - center = bbox.get("center", [0.0, 0.0, 0.0]) - dims = bbox.get("dimensions", [100.0, 100.0, 100.0]) - - dir_obj = self._normalize_direction(direction) - plane = gp_Pln(gp_Pnt(center[0], center[1], center[2]), dir_obj) - - span = max(max(dims), 1.0) + extension * 2 - try: - return BRepBuilderAPI_MakeFace(plane, -span, span, -span, span).Face() - except Exception: - return BRepBuilderAPI_MakeFace(plane).Face() - - def _normalize_direction(self, direction: Any) -> gp_Dir: - """归一化分型方向,异常时回退到 Z 轴。""" - try: - if isinstance(direction, gp_Dir): - return direction - - if isinstance(direction, np.ndarray): - values = direction.tolist() - else: - values = list(direction) - - if len(values) < 3: - raise ValueError("direction 维度不足") - - vec = np.array(values[:3], dtype=np.float64) - norm = np.linalg.norm(vec) - if norm <= 1e-6: - raise ValueError("direction 长度为 0") - - vec /= norm - return gp_Dir(float(vec[0]), float(vec[1]), float(vec[2])) - except Exception: - return gp_Dir(0, 0, 1) - - def _detect_primary_parting(self, shape: Any, analysis: Dict[str, Any]) -> Dict[str, Any]: - """ - 统一主分型面检测。 - - 返回统一结构,便于子类按需追加多分型面、倒扣与平滑逻辑。 - """ - if self.ai_parting_detector is not None: - try: - ai_result = self.ai_parting_detector.detect(shape, analysis) - if ai_result: - parting_surface = self._create_parting_surface_from_direction( - shape, - analysis, - ai_result.get("normal", [0, 0, 1]), - ) - parting_line = ai_result.get("parting_line") or self._calculate_parting_line(shape, parting_surface) - return { - "surface": parting_surface, - "line": parting_line, - "direction": ai_result.get("normal", [0, 0, 1]), - "confidence": ai_result.get("confidence", 0.8), - "method": ai_result.get("method", "ai"), - } - except Exception as e: - logger.warning(f"AI 分型面检测失败,回退到几何方法:{e}") - - normal_stats = self._analyze_parting_direction(shape) - parting_surface = self._create_parting_surface_from_direction( - shape, - analysis, - normal_stats.get("primary_direction", [0, 0, 1]), - ) - parting_line = self._calculate_parting_line(shape, parting_surface) - - return { - "surface": parting_surface, - "line": parting_line, - "direction": normal_stats.get("primary_direction", [0, 0, 1]), - "confidence": normal_stats.get("confidence", 0.5), - "method": "geometric", - } - - def _extract_plane_metadata(self, surface: Any, analysis: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - """提取分型面法向、原点和参数范围。""" - default_origin = [0.0, 0.0, 0.0] - if analysis: - default_origin = analysis.get("bounding_box", {}).get("center", default_origin) - - try: - adaptor = BRepAdaptor_Surface(surface) - plane = adaptor.Plane() - origin = plane.Location() - normal = plane.Axis().Direction() - - return { - "normal": [float(normal.X()), float(normal.Y()), float(normal.Z())], - "origin": [float(origin.X()), float(origin.Y()), float(origin.Z())], - "bounds": { - "u_range": [float(adaptor.FirstUParameter()), float(adaptor.LastUParameter())], - "v_range": [float(adaptor.FirstVParameter()), float(adaptor.LastVParameter())], - }, - } - except Exception as e: - logger.warning(f"分型面几何提取失败,使用默认值: {e}") - return { - "normal": [0.0, 0.0, 1.0], - "origin": [float(default_origin[0]), float(default_origin[1]), float(default_origin[2])], - "bounds": {"u_range": [-200.0, 200.0], "v_range": [-200.0, 200.0]}, - } - def _split_cavity_core(self, shape: Any, parting_surface: Any, margin: int = 20) -> Tuple[Any, Any]: """ 分离型腔和型芯 diff --git a/src/core/mold_generator.py b/src/core/mold_generator.py index 871f62f..a9c3a57 100644 --- a/src/core/mold_generator.py +++ b/src/core/mold_generator.py @@ -237,7 +237,6 @@ class MoldCavityGenerator(BaseMoldGenerator): logger.info(f"转换得到 {len(regions)} 个兼容倒扣区域") return regions -<<<<<<< HEAD def _analyze_face_normals(self, shape: Any) -> gp_Dir: """ 分析产品表面的法向量分布,找出最优分型方向 @@ -249,87 +248,6 @@ class MoldCavityGenerator(BaseMoldGenerator): """ face_normals = [] explorer = TopExp_Explorer(shape, TopAbs_FACE) -======= - return scaled_shape - - def _apply_draft_angles(self, shape: Any, parting_surface: Any) -> Any: - """添加拔模角(简化实现)""" - # 实际实现需要复杂的拔模面处理 - # 这里返回原始形状(假设已在CAD中处理) - logger.warning("拔模角处理为简化实现,建议在设计阶段处理") - return shape - - def _split_cavity_core(self, shape: Any, parting_surface: Any) -> Tuple[Any, Any]: - """ - 按分型面将模具分为 A 板(定模/型腔侧)和 B 板(动模/型芯侧) - - 流程: - 1. 创建模具块(包围产品的长方体) - 2. 用分型面 Z 位置切出上半块和下半块 - 3. 从上半块减去产品 → A 板(含型腔负形) - 4. 从下半块减去产品 → B 板(含型芯负形) - """ - try: - from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox - from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut - from OCC.Core.TopExp import TopExp_Explorer - from OCC.Core.TopAbs import TopAbs_SOLID - - # 获取产品边界框 - 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() - - # 获取分型面 Z 位置(包围盒 Z 中心) - parting_z = (zmin + zmax) / 2 - - # 计算模具块尺寸(比产品大一定余量) - margin = 20 # mm - mold_xmin = xmin - margin - mold_ymin = ymin - margin - mold_zmin = zmin - margin - mold_xmax = xmax + margin - mold_ymax = ymax + margin - mold_zmax = zmax + margin - - # 上半块: 从 parting_z 到 mold_zmax - upper_block = BRepPrimAPI_MakeBox( - gp_Pnt(mold_xmin, mold_ymin, parting_z), - gp_Pnt(mold_xmax, mold_ymax, mold_zmax) - ).Shape() - - # 下半块: 从 mold_zmin 到 parting_z - lower_block = BRepPrimAPI_MakeBox( - gp_Pnt(mold_xmin, mold_ymin, mold_zmin), - gp_Pnt(mold_xmax, mold_ymax, parting_z) - ).Shape() - - # A 板(定模)= 上半块 - 产品 - a_plate_op = BRepAlgoAPI_Cut(upper_block, shape) - if a_plate_op.IsDone(): - a_plate = a_plate_op.Shape() - logger.info(f"A板(定模)生成成功: Z={parting_z:.1f} ~ {mold_zmax:.1f}") - else: - logger.warning("A板布尔减法失败,使用上半块") - a_plate = upper_block - - # B 板(动模)= 下半块 - 产品 - b_plate_op = BRepAlgoAPI_Cut(lower_block, shape) - if b_plate_op.IsDone(): - b_plate = b_plate_op.Shape() - logger.info(f"B板(动模)生成成功: Z={mold_zmin:.1f} ~ {parting_z:.1f}") - else: - logger.warning("B板布尔减法失败,使用下半块") - b_plate = lower_block - - return a_plate, b_plate - - except Exception as e: - logger.error(f"分模失败: {e}") - return shape, shape ->>>>>>> 77c4885a4020b609f4964184661f42e936814c4f while explorer.More(): face = TopoDS_Face(explorer.Current()) diff --git a/src/core/multi_scheme_planner.py b/src/core/multi_scheme_planner.py new file mode 100644 index 0000000..906685e --- /dev/null +++ b/src/core/multi_scheme_planner.py @@ -0,0 +1,275 @@ +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 + +from core.mold_generator import MoldCavityGenerator +from core.aluminum_foam_mold import AluminumFoamMoldGenerator +from core.parting_candidate_generator import PartingCandidateGenerator +from core.parting_scheme_scorer import PartingSchemeScorer +from 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: Any, + material: Dict[str, Any], + is_foam_material: bool = False, + max_schemes: int = 3, + ) -> Dict[str, Any]: + generator = self.aluminum_foam_generator if is_foam_material else self.mold_generator + generator.set_material(material["name"]) + + 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] + 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"] + best_scheme = scored_schemes[0] + + return { + "best_scheme_id": best_scheme["scheme_id"], + "candidate_schemes": scored_schemes, + "global_summary": { + "scheme_count": len(scored_schemes), + "recommended_reason": best_scheme.get("summary", ""), + }, + } + + def _build_scheme( + self, + generator: Any, + shape: Any, + 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", {}) + ) + + 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", "中面") + + 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", "中面"), + "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, + } + + def _build_parting_surface( + self, + generator: Any, + analysis: Dict[str, Any], + direction_vector: List[float], + shape: Any, + offset_ratio: float = 0.0, + opening_span_mm: Optional[float] = None, + ) -> Any: + 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: Any) -> 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() + } diff --git a/src/core/parting_candidate_generator.py b/src/core/parting_candidate_generator.py new file mode 100644 index 0000000..137ae21 --- /dev/null +++ b/src/core/parting_candidate_generator.py @@ -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}" + ) diff --git a/src/core/parting_scheme_scorer.py b/src/core/parting_scheme_scorer.py new file mode 100644 index 0000000..79a60d3 --- /dev/null +++ b/src/core/parting_scheme_scorer.py @@ -0,0 +1,129 @@ +from typing import Dict, Any, List +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) + total_score = round( + score_breakdown["manufacturability"] * 0.30 + + score_breakdown["undercut_complexity"] * 0.25 + + score_breakdown["parting_quality"] * 0.20 + + score_breakdown["machining_cost"] * 0.15 + + score_breakdown["risk"] * 0.10, + 2, + ) + + scored_scheme = dict(scheme) + scored_scheme["score_breakdown"] = score_breakdown + scored_scheme["score"] = total_score + 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", [])) + undercut_count = len(undercut_regions) + undercut_complexity = max(35.0, 100.0 - undercut_count * 12.0 - slider_count * 8.0 - lifter_count * 6.0) + if summary.get("complexity") == "high": + undercut_complexity = max(30.0, undercut_complexity - 10.0) + + 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), + } + + 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", "自动计算") + return ( + f"{axis} 轴开模,分型面位置 {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 diff --git a/src/main.py b/src/main.py index 0a5bffd..a0e9e1f 100644 --- a/src/main.py +++ b/src/main.py @@ -124,7 +124,7 @@ app.mount("/html", StaticFiles(directory=html_output_dir), name="html") app.include_router(auth_router) app.include_router(inventory_router) try: - from api.routes import router as moldinsight_router + from api.v1 import router as moldinsight_router except Exception as e: moldinsight_router = None print(f"[WARN] MoldInsight路由未加载: {e}") diff --git a/src/services/calculation_service.py b/src/services/calculation_service.py index eae5852..7277637 100644 --- a/src/services/calculation_service.py +++ b/src/services/calculation_service.py @@ -299,3 +299,78 @@ class CalculationService: } 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, + ) + return { + "best_scheme_id": "scheme_1", + "candidate_schemes": [ + { + "scheme_id": "scheme_1", + "rank": 1, + "title": "推荐方案", + "method": "legacy_fallback", + "score": 60.0, + "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", {}), + } + + 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 {}, + } + return 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] diff --git a/src/services/processing_service.py b/src/services/processing_service.py index e68f95f..4928a7a 100644 --- a/src/services/processing_service.py +++ b/src/services/processing_service.py @@ -15,6 +15,7 @@ from core.mold_generator import MoldCavityGenerator from core.aluminum_foam_mold import AluminumFoamMoldGenerator from core.mold_quality_inspector import AluminumFoamMoldQualityInspector from core.mesh_generator import MeshGenerator +from core.multi_scheme_planner import MultiSchemeMoldPlanner from services.storage_integration_rustfs import StorageIntegrationService from services.redis_task_manager import redis_task_manager from services.material_service import MaterialService @@ -39,6 +40,7 @@ class ProcessingService: self.mesh_generator = MeshGenerator(quality="medium") self.html_generator = HTMLGenerator() self.storage_service = StorageIntegrationService() + self.multi_scheme_planner = MultiSchemeMoldPlanner() # ─── 对外入口 ─── @@ -127,7 +129,7 @@ class ProcessingService: selected_material = MaterialService.get_material(requested_material) is_foam_material = MaterialService.is_foam_material(requested_material) - cavity_mesh_data = await self._step_generate_cavity( + plan_result = await self._step_generate_cavity( shape, selected_material, is_foam_material ) @@ -136,19 +138,25 @@ class ProcessingService: db_session, task_id, "processing", 60, "生成型腔详细数据" ) - detailed_cavity_json = CalculationService.build_detailed_cavity_json( + detailed_cavity_json = CalculationService.build_plan_result( geometry_data=geometry_data, material=selected_material, file_path=str(file_path), - cavity_mesh_data=cavity_mesh_data, + plan_result=plan_result, ) - if cavity_mesh_data and "mold_cavities" in cavity_mesh_data: - mold_cavities = cavity_mesh_data["mold_cavities"] - logger.info(f"型腔网格数据已合并: cavity {mold_cavities.get('cavity', {}).get('vertex_count', 0)} 顶点") + 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 = detailed_cavity_json["mold_cavities"]["cavity_key_info"] + cavity_key_info = best_key_info # 6. 保存几何数据到数据库 await self.storage_service.update_task_status( @@ -162,12 +170,7 @@ class ProcessingService: geometry_data.get("analysis_method", "mold_cavity"), ) - # 7. 保存模具型腔数据 - await self.storage_service.save_mold_cavity_data( - db_session, stp_file_id, detailed_cavity_json - ) - - # 8. 生成HTML可视化 + # 7. 生成HTML可视化 await self.storage_service.update_task_status( db_session, task_id, "processing", 85, "生成可视化报告" ) @@ -180,10 +183,26 @@ class ProcessingService: "point_count": mesh_result.get("point_count", 0), } + 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, + ) + + 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=detailed_cavity_json, + cavity_data=best_cavity_data, pointcloud_data=pointcloud_data, ) @@ -233,9 +252,12 @@ class ProcessingService: await redis_task_manager.update_task(task_id, { "geometry_data": geometry_data, "analysis_result": analysis_result, - "cavity_data": detailed_cavity_json, - "key_info": detailed_cavity_json, - "html_file": f"/html/{Path(html_file_path).name}", + "plan_result": detailed_cavity_json, + "candidate_schemes": detailed_cavity_json.get("candidate_schemes", []), + "best_scheme_id": detailed_cavity_json.get("best_scheme_id"), + "cavity_data": best_cavity_data, + "key_info": best_key_info, + "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, "status": ProcessingStatus.COMPLETED, "completed_at": str(datetime.now()), @@ -327,28 +349,24 @@ class ProcessingService: async def _step_generate_cavity( self, shape, selected_material: dict, is_foam_material: bool, ) -> Optional[Dict[str, Any]]: - """生成模具型腔数据""" - cavity_mesh_data = None + """生成多方案分模结果""" + plan_result = None try: if shape: - if is_foam_material: - self.aluminum_foam_generator.set_material(selected_material["name"]) - cavity_result = self.aluminum_foam_generator.generate_mold_cavities(shape) - cavity_mesh_data = self.aluminum_foam_generator.generate_detailed_cavity_json(cavity_result) - logger.info(f"使用铝泡沫模具生成器: {selected_material['name']}") - else: - cavity_result = self.mold_generator.generate_mold_cavities(shape) - cavity_mesh_data = self.mold_generator.generate_detailed_cavity_json(cavity_result) - logger.info(f"使用普通塑料模具生成器: {selected_material['name']}") - - if cavity_mesh_data: - logger.info(f"型腔网格数据生成完成: {cavity_mesh_data.get('mold_cavities', {}).get('cavity', {}).get('vertex_count', 0)} 顶点") + plan_result = self.multi_scheme_planner.generate_plan( + shape=shape, + material=selected_material, + is_foam_material=is_foam_material, + ) + logger.info( + f"多方案分模完成: 生成 {len(plan_result.get('candidate_schemes', []))} 套方案" + ) except Exception as cavity_err: - logger.warning(f"型腔生成失败,使用简化数据: {cavity_err}") + logger.warning(f"多方案分模失败,使用简化数据: {cavity_err}") traceback.print_exc() - cavity_mesh_data = None + plan_result = None - return cavity_mesh_data + return plan_result async def _step_verify( self, file_path: str, db_session: AsyncSession, @@ -379,6 +397,38 @@ class ProcessingService: 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, + ) -> 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") + html_path = self.html_generator.generate_and_save_visualization( + geometry_data, + stp_filename, + cavity_data=cavity_data, + pointcloud_data=pointcloud_data, + suffix=suffix, + ) + scheme["html_file"] = f"/html/{Path(html_path).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): diff --git a/src/services/task_query_service.py b/src/services/task_query_service.py index 07a7b76..9188f8a 100644 --- a/src/services/task_query_service.py +++ b/src/services/task_query_service.py @@ -62,6 +62,7 @@ class TaskQueryService: 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) @@ -78,6 +79,8 @@ class TaskQueryService: 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") # 构造与内存任务兼容的任务视图 task_view = { @@ -93,8 +96,11 @@ class TaskQueryService: if processing_task.completed_time else "", "geometry_data": geometry_json, - "key_info": cavity_json, - "cavity_data": cavity_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"), + "plan_result": cavity_json, "mesh_summary": mesh_summary, "html_file": html_file_url, "analysis_result": { @@ -141,3 +147,39 @@ class TaskQueryService: "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"), + } diff --git a/src/utils/html_generator.py b/src/utils/html_generator.py index d53b773..d2e6338 100644 --- a/src/utils/html_generator.py +++ b/src/utils/html_generator.py @@ -620,7 +620,8 @@ class HTMLGenerator: geometry_data: Dict[str, Any], stp_filename: str, cavity_data: Optional[Dict[str, Any]] = None, - pointcloud_data: Optional[Dict[str, Any]] = None + pointcloud_data: Optional[Dict[str, Any]] = None, + suffix: Optional[str] = None, ) -> str: """生成并保存可视化HTML文件""" try: @@ -635,7 +636,8 @@ class HTMLGenerator: # 创建文件名 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") safe_filename = stp_filename.replace('.', '_').replace(' ', '_') - html_filename = f"{safe_filename}_{timestamp}.html" + suffix_part = f"_{suffix}" if suffix else "" + html_filename = f"{safe_filename}{suffix_part}_{timestamp}.html" # 保存文件 file_path = self.save_html_file(html_content, html_filename) diff --git a/static/vue-app.js b/static/vue-app.js index 52fe304..6fd6efe 100644 --- a/static/vue-app.js +++ b/static/vue-app.js @@ -1220,7 +1220,8 @@ const ResultView = { const state = reactive({ task: null, loading: true, - error: '' + error: '', + selectedSchemeId: null }); const loadTask = async () => { @@ -1232,6 +1233,11 @@ const ResultView = { console.log('cavity_data:', state.task.cavity_data); console.log('analysis_result:', state.task.analysis_result); console.log('geometry_data:', state.task.geometry_data); + if (state.task?.best_scheme_id) { + state.selectedSchemeId = state.task.best_scheme_id; + } else if (state.task?.candidate_schemes?.length) { + state.selectedSchemeId = state.task.candidate_schemes[0].scheme_id; + } } catch (e) { state.error = handleApiError(e, '加载任务详情'); } finally { @@ -1261,6 +1267,27 @@ const ResultView = { return material === 'aluminum_foam'; }; + const candidateSchemes = computed(() => state.task?.candidate_schemes || []); + const selectedScheme = computed(() => { + if (!candidateSchemes.value.length) return null; + return candidateSchemes.value.find(s => s.scheme_id === state.selectedSchemeId) || candidateSchemes.value[0]; + }); + const selectedCavityData = computed(() => selectedScheme.value?.cavity_data || state.task?.cavity_data || null); + const selectedKeyInfo = computed(() => selectedScheme.value?.key_info || state.task?.key_info || null); + const selectedHtmlFile = computed(() => selectedScheme.value?.html_file || state.task?.html_file || ''); + + const selectScheme = (schemeId) => { + state.selectedSchemeId = schemeId; + }; + + const formatSchemeDirection = (scheme) => { + if (!scheme) return 'N/A'; + if (scheme.parting?.axis) return `${scheme.parting.axis} 轴`; + const dir = scheme.parting?.direction; + if (!Array.isArray(dir)) return 'N/A'; + return `[${dir.map(v => Number(v).toFixed(2)).join(', ')}]`; + }; + const exportCAD = async (format) => { try { const taskId = route.params.taskId; @@ -1293,7 +1320,22 @@ const ResultView = { } }; - return { state, formatFileSize, formatDateTime, formatNumber, getPriorityText, isFoamMaterial, exportCAD }; + return { + state, + candidateSchemes, + selectedScheme, + selectedCavityData, + selectedKeyInfo, + selectedHtmlFile, + formatFileSize, + formatDateTime, + formatNumber, + getPriorityText, + isFoamMaterial, + exportCAD, + selectScheme, + formatSchemeDirection + }; }, template: `
| 方案 | +方向 | +位置 | +总分 | +法向匹配 | +可制造性 | +分型质量 | +倒扣数 | +锁模力 | +
|---|---|---|---|---|---|---|---|---|
| {{ scheme.title || scheme.scheme_id }} | +{{ formatSchemeDirection(scheme) }} | +{{ scheme.offset_label || '中面' }} | +{{ formatNumber(scheme.score) }} | +{{ formatNumber(scheme.normal_alignment_score || 0) }} | +{{ formatNumber(scheme.score_breakdown?.manufacturability || 0) }} | +{{ formatNumber(scheme.score_breakdown?.parting_quality || 0) }} | +{{ scheme.key_info?.quality_considerations?.undercut_count || 0 }} | +{{ scheme.cavity_data?.manufacturing_info?.estimated_clamping_force || 'N/A' }} | +