fix: 恢复 init_db.py 等全部缺失文件 + LLM 集成

This commit is contained in:
2026-05-11 15:35:54 +08:00
parent 4667346669
commit c9672d9685
8 changed files with 2243 additions and 156 deletions
+85
View File
@@ -0,0 +1,85 @@
src/api/__init__.py
src/api/aluminum_price_routes.py
src/api/auth_routes.py
src/api/inventory/__init__.py
src/api/inventory/customer_routes.py
src/api/inventory/dashboard_routes.py
src/api/inventory/finance_routes.py
src/api/inventory/inventory_routes.py
src/api/inventory/material_routes.py
src/api/inventory/product_routes.py
src/api/inventory/purchase_order_routes.py
src/api/inventory/sales_order_routes.py
src/api/inventory/schemas/__init__.py
src/api/inventory/schemas/common_schemas.py
src/api/inventory/schemas/customer_schemas.py
src/api/inventory/schemas/finance_schemas.py
src/api/inventory/schemas/inventory_schemas.py
src/api/inventory/schemas/material_schemas.py
src/api/inventory/schemas/product_schemas.py
src/api/inventory/schemas/purchase_order_schemas.py
src/api/inventory/schemas/sales_order_schemas.py
src/api/inventory/schemas/stock_movement_schemas.py
src/api/inventory/schemas/supplier_schemas.py
src/api/inventory/schemas/warehouse_schemas.py
src/api/inventory/stock_movement_routes.py
src/api/inventory/supplier_routes.py
src/api/inventory/utils.py
src/api/inventory/warehouse_routes.py
src/api/routes.py
src/api/v1/__init__.py
src/api/v1/advanced_router.py
src/api/v1/cam_router.py
src/api/v1/debug_router.py
src/api/v1/health_router.py
src/api/v1/history_router.py
src/api/v1/task_router.py
src/api/v1/upload_router.py
src/core/__init__.py
src/core/ai_mold_assistant.py
src/core/ai_parting_detector.py
src/core/aluminum_foam_mold.py
src/core/base_mold_generator.py
src/core/cad_exporter.py
src/core/cavity_layout_optimizer.py
src/core/geometry_analyzer.py
src/core/mesh_generator.py
src/core/mold_cam.py
src/core/mold_generator.py
src/core/mold_machining.py
src/core/mold_quality_inspector.py
src/core/mold_system_designer.py
src/core/multi_scheme_planner.py
src/core/parting_candidate_generator.py
src/core/parting_scheme_scorer.py
src/core/side_action_designer.py
src/core/stp_parser.py
src/database/__init__.py
src/database/database.py
src/database/init_db.py
src/database/migrate_db.py
src/main.py
src/models/database.py
src/models/schemas.py
src/scripts/create_admin.py
src/services/__init__.py
src/services/aluminum_price_service.py
src/services/auth_service.py
src/services/calculation_service.py
src/services/cam_bundle_service.py
src/services/material_service.py
src/services/processing_service.py
src/services/redis_task_manager.py
src/services/storage_integration.py
src/services/storage_integration_rustfs.py
src/services/storage_service.py
src/services/task_query_service.py
src/services/verification_service.py
src/storage/__init__.py
src/storage/init_storage.py
src/storage/object_storage.py
src/storage/rustfs_storage.py
src/utils/__init__.py
src/utils/file_handler.py
src/utils/html_generator.py
src/utils/logger.py
+110
View File
@@ -0,0 +1,110 @@
src/main.py
src/api/aluminum_price_routes.py
src/api/auth_routes.py
src/api/routes.py
src/api/__init__.py
src/api/inventory/customer_routes.py
src/api/inventory/dashboard_routes.py
src/api/inventory/finance_routes.py
src/api/inventory/inventory_routes.py
src/api/inventory/material_routes.py
src/api/inventory/product_routes.py
src/api/inventory/purchase_order_routes.py
src/api/inventory/sales_order_routes.py
src/api/inventory/stock_movement_routes.py
src/api/inventory/supplier_routes.py
src/api/inventory/utils.py
src/api/inventory/warehouse_routes.py
src/api/inventory/__init__.py
src/api/inventory/schemas/common_schemas.py
src/api/inventory/schemas/customer_schemas.py
src/api/inventory/schemas/finance_schemas.py
src/api/inventory/schemas/inventory_schemas.py
src/api/inventory/schemas/material_schemas.py
src/api/inventory/schemas/product_schemas.py
src/api/inventory/schemas/purchase_order_schemas.py
src/api/inventory/schemas/sales_order_schemas.py
src/api/inventory/schemas/stock_movement_schemas.py
src/api/inventory/schemas/supplier_schemas.py
src/api/inventory/schemas/warehouse_schemas.py
src/api/inventory/schemas/__init__.py
src/api/inventory/__pycache__/__init__.cpython-312.pyc
src/api/v1/advanced_router.py
src/api/v1/cam_router.py
src/api/v1/debug_router.py
src/api/v1/health_router.py
src/api/v1/history_router.py
src/api/v1/task_router.py
src/api/v1/upload_router.py
src/api/v1/__init__.py
src/api/v1/__pycache__/advanced_router.cpython-312.pyc
src/api/v1/__pycache__/upload_router.cpython-312.pyc
src/api/v1/__pycache__/__init__.cpython-312.pyc
src/api/__pycache__/aluminum_price_routes.cpython-312.pyc
src/api/__pycache__/routes.cpython-312.pyc
src/api/__pycache__/__init__.cpython-312.pyc
src/core/ai_mold_assistant.py
src/core/ai_parting_detector.py
src/core/aluminum_foam_mold.py
src/core/cad_exporter.py
src/core/cavity_layout_optimizer.py
src/core/geometry_analyzer.py
src/core/mesh_generator.py
src/core/mold_cam.py
src/core/mold_generator.py
src/core/mold_machining.py
src/core/mold_quality_inspector.py
src/core/mold_system_designer.py
src/core/multi_scheme_planner.py
src/core/parting_candidate_generator.py
src/core/parting_scheme_scorer.py
src/core/side_action_designer.py
src/core/stp_parser.py
src/core/__init__.py
src/core/__pycache__/ai_parting_detector.cpython-312.pyc
src/core/__pycache__/aluminum_foam_mold.cpython-312.pyc
src/core/__pycache__/base_mold_generator.cpython-312.pyc
src/core/__pycache__/cad_exporter.cpython-312.pyc
src/core/__pycache__/mold_generator.cpython-312.pyc
src/core/__pycache__/multi_scheme_planner.cpython-312.pyc
src/core/__pycache__/side_action_designer.cpython-312.pyc
src/core/__pycache__/stp_parser.cpython-312.pyc
src/core/__pycache__/__init__.cpython-312.pyc
src/database/database.py
src/database/migrate_db.py
src/database/__init__.py
src/database/__pycache__/database.cpython-312.pyc
src/database/__pycache__/__init__.cpython-312.pyc
src/models/database.py
src/models/schemas.py
src/models/__pycache__/schemas.cpython-312.pyc
src/scripts/create_admin.py
src/services/aluminum_price_service.py
src/services/auth_service.py
src/services/calculation_service.py
src/services/cam_bundle_service.py
src/services/llm_service.py
src/services/material_service.py
src/services/processing_service.py
src/services/redis_task_manager.py
src/services/storage_integration.py
src/services/storage_integration_rustfs.py
src/services/storage_service.py
src/services/task_query_service.py
src/services/verification_service.py
src/services/__init__.py
src/services/__pycache__/aluminum_price_service.cpython-312.pyc
src/services/__pycache__/processing_service.cpython-312.pyc
src/services/__pycache__/verification_service.cpython-312.pyc
src/services/__pycache__/__init__.cpython-312.pyc
src/storage/init_storage.py
src/storage/object_storage.py
src/storage/rustfs_storage.py
src/storage/__init__.py
src/utils/file_handler.py
src/utils/logger.py
src/utils/__init__.py
src/utils/__pycache__/html_generator.cpython-312.pyc
src/utils/__pycache__/logger.cpython-312.pyc
src/utils/__pycache__/__init__.cpython-312.pyc
src/__pycache__/main.cpython-312.pyc
+2 -13
View File
@@ -1116,20 +1116,10 @@ async def process_file_core(
logger.info("FreeCAD验证已禁用(设置 ENABLE_FREECAD_VERIFICATION=true 启用)") logger.info("FreeCAD验证已禁用(设置 ENABLE_FREECAD_VERIFICATION=true 启用)")
verification_result = {"status": "disabled", "reason": "FreeCAD验证已禁用"} verification_result = {"status": "disabled", "reason": "FreeCAD验证已禁用"}
# 9.8 LLM 增强分析(可选,不影响主流程) # 9.8 LLM 增强分析
llm_report = None llm_report = None
llm_parting = None
if analysis_result: if analysis_result:
llm_report = await llm_service.generate_design_report( llm_report = await llm_service.generate_design_report(analysis_result, detailed_cavity_json)
analysis_result, detailed_cavity_json
)
# 分型推荐:当前无候选方案时跳过
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
if candidate_schemes:
llm_parting = await llm_service.recommend_parting_direction(
geometry_data, candidate_schemes, selected_material,
cavity_count=cavity_count,
)
# 10. 完成处理 # 10. 完成处理
await storage_service.update_stp_file_status(db_session, stp_file_id, "completed") await storage_service.update_stp_file_status(db_session, stp_file_id, "completed")
@@ -1146,7 +1136,6 @@ async def process_file_core(
tasks[task_id]["html_file"] = f"/html/{Path(html_file_path).name}" # 只使用文件名 tasks[task_id]["html_file"] = f"/html/{Path(html_file_path).name}" # 只使用文件名
tasks[task_id]["verification"] = verification_result # 添加验证结果 tasks[task_id]["verification"] = verification_result # 添加验证结果
tasks[task_id]["llm_report"] = llm_report tasks[task_id]["llm_report"] = llm_report
tasks[task_id]["llm_parting_recommendation"] = llm_parting
tasks[task_id]["status"] = ProcessingStatus.COMPLETED tasks[task_id]["status"] = ProcessingStatus.COMPLETED
tasks[task_id]["completed_at"] = str(datetime.now()) tasks[task_id]["completed_at"] = str(datetime.now())
+905
View File
@@ -0,0 +1,905 @@
from typing import Dict, List, Any, Tuple, Optional
import math
import numpy as np
from OCC.Core.BRepOffsetAPI import BRepOffsetAPI_DraftAngle
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Section, BRepAlgoAPI_Common
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_Face, topods
from OCC.Core.BRep import BRep_Tool
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 models.schemas import create_mold_cavity_data, create_mold_key_info
from 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: 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}%, 缩放因子: {scale_factor:.4f}")
return scaled_shape
except Exception as e:
logger.warning(f"收缩率补偿失败: {e}")
return shape
def _apply_draft_angles(self, shape: Any, parting_surface: Any) -> Any:
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: Any) -> 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: Any, draft_direction: gp_Dir) -> List[Any]:
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: Any) -> 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: Any, faces: List[Any],
draft_direction: gp_Dir, draft_angle_rad: float) -> Any:
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: Any, faces: List[Any],
draft_direction: gp_Dir, draft_angle_rad: float) -> Any:
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: Any) -> 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: Any, parting_surface: Any, margin: int = 20) -> Tuple[Any, Any]:
"""
用分型面将模具块切分为A板(上模/型腔)和B板(下模/型芯),
然后从每个板中减去产品形状的对应部分。
流程:
1. 创建完整模具块(产品包围盒 + 全方向余量)
2. 用分型面将模具块切分为 A板 和 B板
3. A板 - 产品 = 型腔(凹模)
4. B板 - 产品 = 型芯(凸模)
"""
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:
logger.warning("无法提取分型面平面,使用回退方案")
center_z = (zmin + zmax) / 2
parting_plane = gp_Pln(gp_Pnt(0, 0, center_z), gp_Dir(0, 0, 1))
a_plate, b_plate = self._split_mold_block_by_plane(mold_block, parting_plane)
if a_plate is not None and b_plate is not None:
cavity = self._subtract_product_from_plate(a_plate, shape, "型腔(A板)")
core = self._subtract_product_from_plate(b_plate, shape, "型芯(B板)")
if cavity is not None and core is not None:
logger.info("型腔/型芯分离完成(分型面切分+布尔减)")
return cavity, core
elif cavity is not None:
logger.warning("型芯生成失败,使用产品形状")
return cavity, shape
elif core is not None:
logger.warning("型腔生成失败,使用模具块")
return mold_block, core
logger.warning("A/B板切分不完全,回退到原方案")
return self._split_cavity_core_fallback(shape, mold_block)
except Exception as e:
logger.error(f"型腔分离失败: {e}")
return self._split_cavity_core_fallback(shape, None)
def _get_parting_plane(self, parting_surface: Any, shape: Any) -> 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: Any,
parting_plane: gp_Pln) -> Tuple[Any, Any]:
"""
用分型面将模具块切分为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: Any, product: Any,
plate_name: str) -> Any:
"""从模板中减去产品形状,生成型腔或型芯"""
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: Any,
mold_block: Optional[Any] = None) -> Tuple[Any, Any]:
"""
分模回退方案:用边界框中心面作为分型面切分模具块。
"""
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()
center_z = (zmin + zmax) / 2
parting_plane = gp_Pln(gp_Pnt(0, 0, center_z), gp_Dir(0, 0, 1))
a_plate, b_plate = self._split_mold_block_by_plane(mold_block, parting_plane)
if a_plate is not None and b_plate is not None:
cavity = self._subtract_product_from_plate(a_plate, shape, "型腔(回退)")
core = self._subtract_product_from_plate(b_plate, shape, "型芯(回退)")
if cavity is not None and core is not None:
logger.info("回退方案型腔/型芯分离完成")
return cavity, core
cavity = self._subtract_product_from_plate(mold_block, shape, "型腔(兜底)")
return cavity or mold_block, shape
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: Any, 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: Any, 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: Any) -> 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: Any, parting_surface: Any) -> 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: Any) -> 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: Any, shape: Any,
extension: float = 30.0) -> Any:
"""
将分型面延伸到模具块边界
分型面通常只覆盖产品轮廓,需要延伸到模具块边缘
才能正确分离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
+256
View File
@@ -0,0 +1,256 @@
import asyncio
import sys
from pathlib import Path
from sqlalchemy import text
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 sqlalchemy import select
from database.database import db_manager
from models.database import User, Role, Permission, UserRole, RolePermission
from services.auth_service import get_password_hash
from config.settings import settings
from utils.logger import get_logger
logger = get_logger(__name__)
DEFAULT_PERMISSIONS = [
{"code": "view_dashboard", "name": "查看仪表盘", "module": "dashboard"},
{"code": "view_moldinsight", "name": "使用模具分析", "module": "moldinsight"},
{"code": "upload_file", "name": "上传文件", "module": "moldinsight"},
{"code": "view_history", "name": "查看历史记录", "module": "moldinsight"},
{"code": "view_inventory", "name": "查看库存", "module": "inventory"},
{"code": "manage_inventory", "name": "管理库存", "module": "inventory"},
{"code": "view_products", "name": "查看产品", "module": "inventory"},
{"code": "manage_products", "name": "管理产品", "module": "inventory"},
{"code": "view_suppliers", "name": "查看供应商", "module": "inventory"},
{"code": "manage_suppliers", "name": "管理供应商", "module": "inventory"},
{"code": "view_customers", "name": "查看客户", "module": "inventory"},
{"code": "manage_customers", "name": "管理客户", "module": "inventory"},
{"code": "view_finance", "name": "查看财务", "module": "finance"},
{"code": "manage_receipts", "name": "管理收款", "module": "finance"},
{"code": "manage_payments", "name": "管理付款", "module": "finance"},
{"code": "void_finance_transaction", "name": "作废财务单据", "module": "finance"},
{"code": "view_users", "name": "查看用户", "module": "admin"},
{"code": "manage_users", "name": "管理用户", "module": "admin"},
{"code": "manage_roles", "name": "管理角色", "module": "admin"},
]
DEFAULT_ROLES = [
{"code": "admin", "name": "管理员", "description": "系统管理员,拥有所有权限", "is_system": True, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_inventory", "manage_inventory", "view_products", "manage_products", "view_suppliers", "manage_suppliers", "view_customers", "manage_customers", "view_finance", "manage_receipts", "manage_payments", "void_finance_transaction", "view_users", "manage_users", "manage_roles"]},
{"code": "user", "name": "普通用户", "description": "普通用户,可使用模具分析和查看库存", "is_system": False, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_inventory", "view_products", "view_suppliers", "view_customers", "view_finance", "manage_receipts", "manage_payments"]},
{"code": "viewer", "name": "只读用户", "description": "只读用户,只能查看数据", "is_system": False, "permissions": ["view_dashboard", "view_moldinsight", "view_history", "view_inventory", "view_products", "view_suppliers", "view_customers", "view_finance"]},
]
async def init_permissions(session):
"""初始化权限"""
result = await session.execute(select(Permission))
existing_perms = result.scalars().all()
if existing_perms:
logger.info("权限已初始化")
return
perm_map = {}
for perm_data in DEFAULT_PERMISSIONS:
perm = Permission(**perm_data)
session.add(perm)
await session.flush()
perm_map[perm.code] = perm.id
logger.info(f"创建了 {len(DEFAULT_PERMISSIONS)} 个权限")
return perm_map
async def init_roles(session, perm_map):
"""初始化角色"""
result = await session.execute(select(Role))
existing_roles = result.scalars().all()
if existing_roles:
logger.info("角色已初始化")
return
for role_data in DEFAULT_ROLES:
perm_ids = [perm_map[code] for code in role_data.pop("permissions")]
role = Role(**role_data)
session.add(role)
await session.flush()
for perm_id in perm_ids:
rp = RolePermission(role_id=role.id, permission_id=perm_id)
session.add(rp)
logger.info(f"创建了 {len(DEFAULT_ROLES)} 个角色")
async def create_admin_user(session):
"""创建默认管理员"""
result = await session.execute(select(User).where(User.username == settings.ADMIN_USERNAME))
existing_admin = result.scalar_one_or_none()
if existing_admin:
logger.info("管理员账户已存在")
return
admin = User(
username=settings.ADMIN_USERNAME,
email=settings.ADMIN_EMAIL,
hashed_password=get_password_hash(settings.ADMIN_PASSWORD),
full_name=settings.ADMIN_FULL_NAME,
is_active=True
)
session.add(admin)
await session.flush()
result = await session.execute(select(Role).where(Role.code == "admin"))
admin_role = result.scalar_one_or_none()
if admin_role:
user_role = UserRole(user_id=admin.id, role_id=admin_role.id)
session.add(user_role)
await session.commit()
logger.info(f"创建了管理员账户: {settings.ADMIN_USERNAME}")
async def init_database(keep_connected: bool = True):
"""初始化数据库"""
try:
await db_manager.connect()
await db_manager.create_tables()
await ensure_schema_updates()
async with db_manager.session() as session:
perm_map = await init_permissions(session)
if perm_map is None:
# Permissions already existed, fetch them from database
result = await session.execute(select(Permission))
perms = result.scalars().all()
perm_map = {perm.code: perm.id for perm in perms}
await init_roles(session, perm_map)
await create_admin_user(session)
logger.info("数据库初始化完成")
print("=" * 60)
print("数据库初始化成功!")
print("=" * 60)
print(f"管理员用户名: {settings.ADMIN_USERNAME}")
print(f"管理员邮箱: {settings.ADMIN_EMAIL}")
print("=" * 60)
print("可以在 .env 文件中修改管理员配置:")
print(" ADMIN_USERNAME")
print(" ADMIN_EMAIL")
print(" ADMIN_FULL_NAME")
print("=" * 60)
return True
except Exception as e:
logger.error(f"数据库初始化失败: {e}")
print(f"数据库初始化失败: {e}")
return False
finally:
if not keep_connected:
await db_manager.disconnect()
async def ensure_schema_updates():
async with db_manager.engine.begin() as conn:
await conn.execute(text("ALTER TABLE products ADD COLUMN IF NOT EXISTS item_type VARCHAR(20) DEFAULT 'finished'"))
await conn.execute(text("UPDATE products SET item_type = 'finished' WHERE item_type IS NULL"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS production_status VARCHAR(20) DEFAULT 'not_started'"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS production_no VARCHAR(50)"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS planned_material_cost DOUBLE PRECISION DEFAULT 0"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS actual_material_cost DOUBLE PRECISION DEFAULT 0"))
await conn.execute(text("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS received_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS paid_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS product_materials (
id SERIAL PRIMARY KEY,
finished_product_id INTEGER NOT NULL REFERENCES products(id),
material_product_id INTEGER NOT NULL REFERENCES products(id),
quantity DOUBLE PRECISION NOT NULL,
loss_rate DOUBLE PRECISION DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
"""))
await conn.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_product_material_unique
ON product_materials (finished_product_id, material_product_id)
"""))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS best_scheme_id VARCHAR(64)"))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS confidence_score DOUBLE PRECISION"))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS is_fallback BOOLEAN"))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS fallback_reason TEXT"))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_mold_cavity_best_scheme_id
ON mold_cavity_data (best_scheme_id)
"""))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_mold_cavity_is_fallback
ON mold_cavity_data (is_fallback)
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_inventory_product_warehouse'
) THEN
ALTER TABLE inventory
ADD CONSTRAINT uq_inventory_product_warehouse UNIQUE (product_id, warehouse_id);
END IF;
END $$;
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_qty_nonnegative'
) THEN
ALTER TABLE inventory
ADD CONSTRAINT ck_inventory_qty_nonnegative
CHECK (quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity);
END IF;
END $$;
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_purchase_order_items_qty'
) THEN
ALTER TABLE purchase_order_items
ADD CONSTRAINT ck_purchase_order_items_qty
CHECK (quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity);
END IF;
END $$;
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_sales_order_items_qty'
) THEN
ALTER TABLE sales_order_items
ADD CONSTRAINT ck_sales_order_items_qty
CHECK (quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity);
END IF;
END $$;
"""))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS manufacturing_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("ALTER TABLE sales_orders ALTER COLUMN manufacturing_date TYPE TIMESTAMP WITHOUT TIME ZONE"))
if __name__ == "__main__":
asyncio.run(init_database(keep_connected=False))
+52 -126
View File
@@ -74,7 +74,7 @@ JSON 格式:
"confidence": 0.85, "confidence": 0.85,
"reasoning": "详细的中文推理过程...", "reasoning": "详细的中文推理过程...",
"risk_notes": ["风险1", "风险2"], "risk_notes": ["风险1", "风险2"],
"rankings": [{"axis":"Z","rank":1,"score":92,"note":"..."},{"axis":"X","rank":2,"score":78,"note":"..."}] "rankings": [{"axis":"Z","rank":1,"score":92,"note":"..."}]
}""" }"""
_PARTING_USER = """请评估以下候选分模方向并推荐最优方案: _PARTING_USER = """请评估以下候选分模方向并推荐最优方案:
@@ -126,11 +126,7 @@ class LLMService:
try: try:
prompt = self._build_design_report_prompt(analysis_result, detailed_cavity_json) prompt = self._build_design_report_prompt(analysis_result, detailed_cavity_json)
response = await self._chat( response = await self._chat(_DESIGN_REPORT_SYSTEM, prompt, self._max_tokens)
system=_DESIGN_REPORT_SYSTEM,
user=prompt,
max_tokens=self._max_tokens,
)
if response: if response:
logger.info("LLM 设计报告生成成功 (%d 字符)", len(response)) logger.info("LLM 设计报告生成成功 (%d 字符)", len(response))
return response return response
@@ -150,128 +146,74 @@ class LLMService:
return None return None
try: try:
prompt = self._build_parting_prompt( prompt = self._build_parting_prompt(geometry_data, candidate_schemes, material, cavity_count)
geometry_data, candidate_schemes, material, cavity_count, response = await self._chat(_PARTING_SYSTEM, prompt, min(self._max_tokens, 1200), expect_json=True)
)
response = await self._chat(
system=_PARTING_SYSTEM,
user=prompt,
max_tokens=min(self._max_tokens, 1200),
expect_json=True,
)
if response: if response:
result = self._parse_json_response(response) result = self._parse_json_response(response)
if result: if result:
logger.info( logger.info("LLM 分型推荐: %s (%.2f)", result.get("recommended_axis", "?"), result.get("confidence", 0))
"LLM 分型推荐: %s (置信度 %.2f)",
result.get("recommended_axis", "?"),
result.get("confidence", 0),
)
return result return result
return None return None
except Exception as e: except Exception as e:
logger.warning("LLM 分型推荐失败(不影响主流程): %s", e) logger.warning("LLM 分型推荐失败(不影响主流程): %s", e)
return None return None
def _build_design_report_prompt( def _build_design_report_prompt(self, analysis_result, detailed_cavity_json) -> str:
self, features = json.dumps(analysis_result.get("detected_features", []), ensure_ascii=False, indent=2)
analysis_result: Dict[str, Any], if len(features) > 4000:
detailed_cavity_json: Optional[Dict[str, Any]], features = features[:4000] + "\n... (已截断)"
) -> str:
detected_features = analysis_result.get("detected_features", [])
quality_metrics = analysis_result.get("quality_metrics", {})
recommendations = analysis_result.get("design_recommendations", [])
feature_text = json.dumps(detected_features, ensure_ascii=False, indent=2)
if len(feature_text) > 4000:
feature_text = feature_text[:4000] + "\n... (已截断)"
schemes_text = "" schemes_text = ""
if detailed_cavity_json: if detailed_cavity_json:
schemes = detailed_cavity_json.get("candidate_schemes", []) schemes = detailed_cavity_json.get("candidate_schemes", [])
if schemes: if schemes:
schemes_text = json.dumps( 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"),
"scheme_id": s.get("scheme_id"), "parting_axis": s.get("parting", {}).get("axis"),
"rank": s.get("rank"), "mold_structure_type": s.get("mold_structure_type"),
"title": s.get("title"), "dfm_violations": s.get("dfm_violations", []),
"score": s.get("score"), } for s in schemes], ensure_ascii=False, indent=2)
"confidence_score": s.get("confidence_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_scheme = ( best = detailed_cavity_json.get("candidate_schemes", [{}])[0] if detailed_cavity_json else {}
detailed_cavity_json.get("candidate_schemes", [{}])[0] cd = best.get("cavity_data", {}) if isinstance(best, dict) else {}
if detailed_cavity_json mfg = cd.get("manufacturing_info", {})
else {} meta = cd.get("metadata", {})
)
cavity_data = best_scheme.get("cavity_data", {}) if isinstance(best_scheme, dict) else {}
mfg_info = cavity_data.get("manufacturing_info", {})
metadata = cavity_data.get("metadata", {})
return _DESIGN_REPORT_USER.format( return _DESIGN_REPORT_USER.format(
filename=metadata.get("file_name", "unknown.stp"), filename=meta.get("file_name", "unknown.stp"),
material=metadata.get("selected_material", "ABS"), material=meta.get("selected_material", "ABS"),
volume=f"{analysis_result.get('geometry_data', {}).get('volume', 0):.1f} mm³", 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²", 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), bbox=json.dumps(analysis_result.get("geometry_data", {}).get("bounding_box", {}), ensure_ascii=False),
features=feature_text or "无特征检测数据", features=features or "无特征检测数据",
quality_metrics=json.dumps(quality_metrics, ensure_ascii=False, indent=2), quality_metrics=json.dumps(analysis_result.get("quality_metrics", {}), ensure_ascii=False, indent=2),
schemes=schemes_text or "无分模方案数据", schemes=schemes_text or "无分模方案数据",
mold_material=mfg_info.get("mold_material", "自动选择"), mold_material=mfg.get("mold_material", "自动选择"),
mold_hardness=mfg_info.get("mold_hardness", "自动选择"), mold_hardness=mfg.get("mold_hardness", "自动选择"),
clamping_force=mfg_info.get("estimated_clamping_force", "自动计算"), clamping_force=mfg.get("estimated_clamping_force", "自动计算"),
mold_size=json.dumps(mfg_info.get("estimated_mold_size", {}), ensure_ascii=False), mold_size=json.dumps(mfg.get("estimated_mold_size", {}), ensure_ascii=False),
cycle_time=mfg_info.get("estimated_cycle_time", "自动计算"), cycle_time=mfg.get("estimated_cycle_time", "自动计算"),
draft_angle=f"{metadata.get('draft_angle', 2.0)}°", draft_angle=f"{meta.get('draft_angle', 2.0)}°",
shrinkage_rate=f"{metadata.get('shrinkage_rate', 0.0) * 100:.2f}%" shrinkage_rate="自动计算",
if isinstance(metadata.get("shrinkage_rate"), (int, float)) recommendations=json.dumps(analysis_result.get("design_recommendations", []), ensure_ascii=False, indent=2) or "无",
else "自动计算",
recommendations=json.dumps(recommendations, ensure_ascii=False, indent=2) if recommendations else "无",
) )
def _build_parting_prompt( def _build_parting_prompt(self, geometry_data, candidate_schemes, material, cavity_count) -> str:
self,
geometry_data: Dict[str, Any],
candidate_schemes: List[Dict[str, Any]],
material: Dict[str, Any],
cavity_count: int,
) -> str:
bbox = geometry_data.get("bounding_box", {}) bbox = geometry_data.get("bounding_box", {})
axis_normal_stats = geometry_data.get("axis_normal_stats", {}) axis_normal_stats = geometry_data.get("axis_normal_stats", {})
inertia = geometry_data.get("inertia_matrix", []) inertia = geometry_data.get("inertia_matrix", [])
inertia_diag = [ inertia_diag = [inertia[i][i] if i < len(inertia) and i < len(inertia[i]) else 0.0 for i in range(3)]
inertia[i][i] if i < len(inertia) and i < len(inertia[i]) else 0.0
for i in range(3)
]
schemes_text = json.dumps( schemes_text = json.dumps([{
[ "axis": s.get("parting", {}).get("axis") or s.get("axis"),
{ "score": s.get("score"), "summary": s.get("summary"),
"axis": s.get("parting", {}).get("axis") or s.get("axis"), "mold_structure_type": s.get("mold_structure_type"),
"score": s.get("score"), "core_required": s.get("core_required"),
"confidence_score": s.get("confidence_score"), "dfm_violations": s.get("dfm_violations", []),
"summary": s.get("summary"), "undercut_regions_count": len(s.get("undercut_regions", [])),
"mold_structure_type": s.get("mold_structure_type"), "score_breakdown": s.get("score_breakdown", {}),
"core_required": s.get("core_required"), } for s in candidate_schemes], ensure_ascii=False, indent=2)
"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( return _PARTING_USER.format(
bbox=json.dumps(bbox, ensure_ascii=False), bbox=json.dumps(bbox, ensure_ascii=False),
@@ -284,47 +226,31 @@ class LLMService:
schemes=schemes_text, schemes=schemes_text,
) )
async def _chat( async def _chat(self, system, user, max_tokens=2000, expect_json=False, temperature=0.3):
self,
system: str,
user: str,
max_tokens: int = 2000,
expect_json: bool = False,
temperature: float = 0.3,
) -> Optional[str]:
url = f"{self._api_url}/chat/completions" url = f"{self._api_url}/chat/completions"
headers = { headers = {"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"}
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
payload = { payload = {
"model": self._model, "model": self._model,
"messages": [ "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
{"role": "system", "content": system}, "max_tokens": max_tokens, "temperature": temperature,
{"role": "user", "content": user},
],
"max_tokens": max_tokens,
"temperature": temperature,
} }
if expect_json: if expect_json:
payload["response_format"] = {"type": "json_object"} payload["response_format"] = {"type": "json_object"}
async with httpx.AsyncClient(timeout=self._timeout) as client: async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(url, json=payload, headers=headers) resp = await client.post(url, json=payload, headers=headers)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() content = resp.json()["choices"][0]["message"]["content"]
content = data["choices"][0]["message"]["content"]
return content.strip() if content else None return content.strip() if content else None
@staticmethod @staticmethod
def _parse_json_response(raw: str) -> Optional[Dict[str, Any]]: def _parse_json_response(raw):
try: try:
return json.loads(raw) return json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
match = re.search(r"\{[\s\S]*\}", raw) m = re.search(r"\{[\s\S]*\}", raw)
if match: if m:
try: try:
return json.loads(match.group()) return json.loads(m.group())
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
logger.warning("LLM JSON 解析失败: %s...", raw[:200]) logger.warning("LLM JSON 解析失败: %s...", raw[:200])
+2 -17
View File
@@ -259,24 +259,10 @@ class ProcessingService:
file_path, db_session, task_id, stp_file_id, analysis_result file_path, db_session, task_id, stp_file_id, analysis_result
) )
# 9.8 LLM 增强分析(可选,不影响主流程) # 9.8 LLM 增强分析
llm_report = None llm_report = None
llm_parting = None
if analysis_result: if analysis_result:
llm_report = await llm_service.generate_design_report( llm_report = await llm_service.generate_design_report(analysis_result, detailed_cavity_json)
analysis_result, detailed_cavity_json
)
candidate_schemes = detailed_cavity_json.get("candidate_schemes", [])
if candidate_schemes:
cavity_count = detailed_cavity_json.get("mold_cavities", {}).get("cavity_count", 1)
if isinstance(cavity_count, (int, float)):
cavity_count = int(cavity_count)
else:
cavity_count = 1
llm_parting = await llm_service.recommend_parting_direction(
geometry_data, candidate_schemes, selected_material,
cavity_count=cavity_count,
)
# 10. 完成处理 # 10. 完成处理
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "completed") await self.storage_service.update_stp_file_status(db_session, stp_file_id, "completed")
@@ -296,7 +282,6 @@ class ProcessingService:
"html_file": best_scheme.get("html_file", f"/html/{Path(html_file_path).name}") if best_scheme else f"/html/{Path(html_file_path).name}", "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, "verification": verification_result,
"llm_report": llm_report, "llm_report": llm_report,
"llm_parting_recommendation": llm_parting,
"status": ProcessingStatus.COMPLETED, "status": ProcessingStatus.COMPLETED,
"completed_at": str(datetime.now()), "completed_at": str(datetime.now()),
}) })
+831
View File
@@ -0,0 +1,831 @@
# utils/html_generator.py
from pathlib import Path
from typing import Dict, Any, Optional
from datetime import datetime
from utils.logger import get_logger
logger = get_logger(__name__)
try:
import orjson
def _json_dumps(obj: Any) -> bytes:
return orjson.dumps(obj, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS)
def _json_dumps_str(obj: Any) -> str:
return orjson.dumps(obj, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS).decode("utf-8")
_JSON_FAST = True
except ImportError:
import json
def _json_dumps(obj: Any) -> bytes:
return json.dumps(obj, ensure_ascii=False).encode("utf-8")
def _json_dumps_str(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False)
_JSON_FAST = False
class HTMLGenerator:
"""HTML文件生成器 — 数据分离架构,Three.js 0.170 + PBR渲染"""
def __init__(self, output_dir: str = "./html_output"):
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
def generate_3d_viewer_html(self, stp_filename: str, data_filename: str) -> str:
"""生成3D可视化HTML页面 — 通过fetch异步加载companion JSON数据"""
cavity_html = self._build_cavity_info_panel_template()
html_content = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D模具几何可视化 - {stp_filename}</title>
<script type="importmap">
{{
"imports": {{
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}}
}}
</script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ overflow: hidden; font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f0f2f5; }}
#container {{ position: relative; width: 100vw; height: 100vh; }}
#canvas {{ display: block; }}
#loading-overlay {{
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; background: rgba(240,242,245,0.95);
z-index: 100; transition: opacity 0.4s;
}}
#loading-overlay.hidden {{ opacity: 0; pointer-events: none; }}
.spinner {{
width: 48px; height: 48px; border: 3px solid rgba(0,0,0,0.1);
border-top-color: #4CAF50; border-radius: 50%; animation: spin 0.8s linear infinite;
}}
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
.loading-text {{ color: #666; margin-top: 16px; font-size: 14px; }}
#info-panel {{
position: absolute; top: 10px; left: 10px; background: rgba(255,255,255,0.92);
color: #333; padding: 12px 16px; border-radius: 10px; font-size: 13px;
max-width: 340px; backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}}
#info-panel h3 {{ margin: 0 0 6px 0; font-size: 14px; color: #2E7D32; }}
#info-panel .info-row {{ display: flex; justify-content: space-between; padding: 2px 0; }}
#info-panel .info-label {{ color: #888; }}
#info-panel .info-value {{ color: #111; font-weight: 500; }}
#cavity-info-panel {{
position: absolute; top: 10px; right: 10px; background: rgba(255,255,255,0.92);
color: #333; padding: 15px; border-radius: 10px; font-size: 12px;
max-width: 310px; backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
display: none;
}}
#cavity-info-panel h3 {{ margin: 0 0 10px 0; font-size: 14px; color: #E65100; }}
#cavity-info-panel .metric {{ display: flex; justify-content: space-between; padding: 3px 0; }}
#cavity-info-panel .metric-label {{ color: #888; }}
#cavity-info-panel .metric-value {{ color: #111; font-weight: 500; }}
#toolbar {{
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; gap: 6px; background: rgba(255,255,255,0.92); padding: 8px 12px;
border-radius: 24px; backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
flex-wrap: wrap; justify-content: center;
}}
#toolbar button {{
background: rgba(0,0,0,0.04); color: #555; border: 1px solid rgba(0,0,0,0.1);
padding: 6px 14px; border-radius: 18px; cursor: pointer; font-size: 12px;
transition: all 0.2s; white-space: nowrap;
}}
#toolbar button:hover {{ background: rgba(0,0,0,0.1); color: #222; }}
#toolbar button.active {{ background: rgba(76,175,80,0.18); border-color: #4CAF50; color: #2E7D32; }}
#toolbar button.accent {{
background: rgba(255,87,34,0.15); border-color: #FF5722; color: #D84315;
font-weight: bold;
}}
#toolbar button.accent:hover {{ background: rgba(255,87,34,0.28); }}
#view-presets {{
position: absolute; bottom: 75px; left: 50%; transform: translateX(-50%);
display: flex; gap: 4px; background: rgba(255,255,255,0.88); padding: 6px 8px;
border-radius: 20px; backdrop-filter: blur(8px); border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 1px 8px rgba(0,0,0,0.06);
}}
#view-presets button {{
background: rgba(0,0,0,0.03); color: #777; border: none;
padding: 5px 10px; border-radius: 14px; cursor: pointer; font-size: 11px;
transition: all 0.2s;
}}
#view-presets button:hover {{ background: rgba(0,0,0,0.1); color: #222; }}
@media (max-width: 768px) {{
#info-panel {{ max-width: 240px; font-size: 11px; padding: 8px 12px; }}
#cavity-info-panel {{ max-width: 220px; font-size: 10px; padding: 10px; }}
#toolbar {{ gap: 3px; padding: 6px 8px; }}
#toolbar button {{ padding: 5px 10px; font-size: 10px; }}
#view-presets {{ bottom: 68px; }}
}}
</style>
</head>
<body>
<div id="container">
<canvas id="canvas"></canvas>
<div id="loading-overlay">
<div class="spinner"></div>
<div class="loading-text" id="loading-status">加载几何数据...</div>
</div>
<div id="info-panel">
<h3>📐 {stp_filename}</h3>
<div class="info-row"><span class="info-label">顶点</span><span class="info-value" id="info-verts">-</span></div>
<div class="info-row"><span class="info-label">三角面</span><span class="info-value" id="info-faces">-</span></div>
<div class="info-row"><span class="info-label">点云</span><span class="info-value" id="info-points">-</span></div>
<div class="info-row"><span class="info-label">体积</span><span class="info-value" id="info-vol">-</span></div>
</div>
{cavity_html}
<div id="view-presets">
<button onclick="setView('front')" title="前视">前</button>
<button onclick="setView('back')" title="后视">后</button>
<button onclick="setView('left')" title="左视">左</button>
<button onclick="setView('right')" title="右视">右</button>
<button onclick="setView('top')" title="俯视">俯</button>
<button onclick="setView('bottom')" title="仰视">仰</button>
<button onclick="setView('iso')" title="等轴测" style="font-weight:bold;color:#FF9800;">3D</button>
</div>
<div id="toolbar">
<button id="btn-product" class="active" onclick="toggleProduct()">产品</button>
<button id="btn-mold" class="active" onclick="toggleMold()">模具</button>
<button id="btn-parting" class="active" onclick="toggleParting()">分型面</button>
<button id="btn-pointcloud" onclick="togglePointcloud()">点云</button>
<button onclick="toggleWireframe()">线框</button>
<button onclick="resetView()">重置</button>
<button id="splitBtn" class="accent" onclick="splitMold()">分模拆分</button>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import {{ OrbitControls }} from 'three/addons/controls/OrbitControls.js';
const DATA_URL = '{data_filename}';
let productMesh, cavityMesh, coreMesh, partingMesh, pointcloudMesh;
let productVisible = true, moldVisible = true, partingVisible = true, pointcloudVisible = false;
let isSplit = false, splitAnimId = null;
let sceneBox = null;
let cavityDataGlobal = null;
const renderer = new THREE.WebGLRenderer({{ canvas: document.getElementById('canvas'), antialias: true, alpha: true }});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f2f5);
scene.fog = new THREE.Fog(0xf0f2f5, 500, 3000);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 10000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.12;
controls.minDistance = 1;
controls.maxDistance = 5000;
controls.target.set(0, 0, 0);
function setupLighting() {{
const ambient = new THREE.AmbientLight(0xccccdd, 4);
scene.add(ambient);
const keyLight = new THREE.DirectionalLight(0xffffff, 7);
keyLight.position.set(1, 1.2, 0.8);
keyLight.castShadow = true;
keyLight.shadow.mapSize.width = 2048;
keyLight.shadow.mapSize.height = 2048;
keyLight.shadow.camera.near = 0.5;
keyLight.shadow.camera.far = 500;
keyLight.shadow.bias = -0.0001;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0xccddff, 3);
fillLight.position.set(-0.6, 0.3, -0.4);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(0xffffff, 4);
rimLight.position.set(0, -0.3, -1);
scene.add(rimLight);
const bottomLight = new THREE.DirectionalLight(0x8899cc, 1.5);
bottomLight.position.set(0, -1, 0.2);
scene.add(bottomLight);
const pmremGenerator = new THREE.PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();
const envScene = new THREE.Scene();
envScene.background = new THREE.Color(0xddeeff);
const envMap = pmremGenerator.fromScene(envScene).texture;
scene.environment = envMap;
scene.background = new THREE.Color(0xf0f2f5);
}}
setupLighting();
const axesHelper = new THREE.AxesHelper(50);
scene.add(axesHelper);
const gridHelper = new THREE.GridHelper(400, 40, 0xccccdd, 0xe8e8f0);
scene.add(gridHelper);
function toFlatArray(data) {{
if (!Array.isArray(data)) return [];
if (data.length === 0) return [];
return Array.isArray(data[0]) ? data.flat(Infinity) : data;
}}
function normalizePositions(rawPositions, center) {{
const flat = toFlatArray(rawPositions);
if (!flat.length) return [];
const cx = Number(center[0] || 0), cy = Number(center[1] || 0), cz = Number(center[2] || 0);
const normalized = [];
for (let i = 0; i + 2 < flat.length; i += 3) {{
const x = Number(flat[i]), y = Number(flat[i + 1]), z = Number(flat[i + 2]);
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {{
normalized.push(x - cx, y - cy, z - cz);
}}
}}
return normalized;
}}
function computeBounds(rawPositionsList) {{
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
let hasPoint = false;
for (const raw of rawPositionsList) {{
const flat = toFlatArray(raw);
for (let i = 0; i + 2 < flat.length; i += 3) {{
const x = Number(flat[i]), y = Number(flat[i + 1]), z = Number(flat[i + 2]);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue;
hasPoint = true;
minX = Math.min(minX, x); minY = Math.min(minY, y); minZ = Math.min(minZ, z);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); maxZ = Math.max(maxZ, z);
}}
}}
if (!hasPoint) return null;
return {{
center: [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2],
dimensions: [Math.max(maxX - minX, 1), Math.max(maxY - minY, 1), Math.max(maxZ - minZ, 1)],
}};
}}
function isValidIndexedGeometry(positions, indices) {{
if (!positions || !indices) return false;
if (positions.length < 9 || indices.length < 3) return false;
if (positions.length % 3 !== 0 || indices.length % 3 !== 0) return false;
const vertexCount = positions.length / 3;
for (let i = 0; i < indices.length; i++) {{
const idx = Number(indices[i]);
if (!Number.isFinite(idx) || idx < 0 || idx >= vertexCount) return false;
}}
return true;
}}
function createPBRMaterial(colorHex, opts = {{}}) {{
return new THREE.MeshStandardMaterial({{
color: new THREE.Color(colorHex),
metalness: opts.metalness ?? 0.05,
roughness: opts.roughness ?? 0.35,
transparent: true,
opacity: opts.opacity ?? 0.55,
side: THREE.DoubleSide,
depthWrite: opts.depthWrite ?? true,
}});
}}
function registerInitialPose(mesh) {{
if (!mesh) return;
mesh.userData.initialPosition = mesh.position.clone();
mesh.userData.initialVisible = mesh.visible;
}}
function fitCameraToScene() {{
const objects = [productMesh, cavityMesh, coreMesh, partingMesh, pointcloudMesh].filter(Boolean);
if (!objects.length) return;
sceneBox = new THREE.Box3();
objects.forEach(obj => sceneBox.expandByObject(obj));
if (sceneBox.isEmpty()) return;
const center = new THREE.Vector3();
const size = new THREE.Vector3();
sceneBox.getCenter(center);
sceneBox.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z) || 100;
const distance = Math.max(maxDim * 1.6, 30);
camera.near = Math.max(maxDim / 2000, 0.01);
camera.far = Math.max(maxDim * 200, 10000);
camera.updateProjectionMatrix();
camera.position.set(center.x + distance * 0.7, center.y + distance * 0.7, center.z + distance * 0.8);
controls.target.copy(center);
controls.minDistance = Math.max(maxDim * 0.03, 0.5);
controls.maxDistance = Math.max(maxDim * 30, 5000);
controls.update();
}}
function setView(direction) {{
if (!sceneBox) return;
const center = new THREE.Vector3();
const size = new THREE.Vector3();
sceneBox.getCenter(center);
sceneBox.getSize(size);
const dist = Math.max(size.x, size.y, size.z) * 1.5;
const positions = {{
front: [0, 0, dist],
back: [0, 0, -dist],
left: [-dist, 0, 0],
right: [dist, 0, 0],
top: [0, dist, 0],
bottom: [0, -dist, 0],
iso: [dist * 0.7, dist * 0.7, dist * 0.8],
}};
const pos = positions[direction] || positions.iso;
camera.position.set(center.x + pos[0], center.y + pos[1], center.z + pos[2]);
controls.target.copy(center);
controls.update();
}}
window.setView = setView;
function buildScene(geometryData, cavityData, pointcloudData) {{
cavityDataGlobal = cavityData;
const bbox = geometryData.bounding_box || computeBounds([
pointcloudData?.points,
cavityData?.mold_cavities?.cavity?.vertices,
cavityData?.mold_cavities?.core?.vertices
]) || {{ center: [0, 0, 0], dimensions: [100, 100, 100] }};
const centerOffset = bbox.center || [0, 0, 0];
const width = (bbox.dimensions && bbox.dimensions[0]) || 100;
const height = (bbox.dimensions && bbox.dimensions[1]) || 100;
const depth = (bbox.dimensions && bbox.dimensions[2]) || 100;
const coreRequired = cavityData?.metadata?.core_required !== false;
const maxDim = Math.max(width, height, depth) || 100;
const lods = pointcloudData?.lods;
if (lods && lods["0"] && lods["0"].vertices && lods["0"].faces) {{
const lodGroup = new THREE.LOD();
const lodKeys = Object.keys(lods).sort((a, b) => Number(a) - Number(b));
for (const key of lodKeys) {{
const entry = lods[key];
if (!entry.vertices || !entry.faces || entry.vertices.length === 0 || entry.faces.length === 0) continue;
const productVerts = normalizePositions(entry.vertices, centerOffset);
const productFaces = toFlatArray(entry.faces);
if (productVerts.length < 9 || productFaces.length < 3) continue;
const lodGeo = new THREE.BufferGeometry();
lodGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(productVerts), 3));
lodGeo.setIndex(new THREE.BufferAttribute(new Uint32Array(productFaces), 1));
lodGeo.computeVertexNormals();
const lodMat = createPBRMaterial(0xFFFFFF, {{ metalness: 0.0, roughness: 0.20, opacity: 0.55 }});
const lodMesh = new THREE.Mesh(lodGeo, lodMat);
lodMesh.castShadow = true;
lodMesh.receiveShadow = true;
const dist = key === "0" ? 0 : key === "1" ? maxDim * 3 : maxDim * 8;
lodGroup.addLevel(lodMesh, dist);
}}
productMesh = lodGroup;
scene.add(productMesh);
registerInitialPose(productMesh);
}} else if (pointcloudData && pointcloudData.vertices && pointcloudData.faces && pointcloudData.vertices.length > 0 && pointcloudData.faces.length > 0) {{
const productVerts = normalizePositions(pointcloudData.vertices, centerOffset);
const productFaces = toFlatArray(pointcloudData.faces);
if (productVerts.length >= 9 && productFaces.length >= 3) {{
const productGeometry = new THREE.BufferGeometry();
productGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(productVerts), 3));
productGeometry.setIndex(new THREE.BufferAttribute(new Uint32Array(productFaces), 1));
productGeometry.computeVertexNormals();
const productMaterial = createPBRMaterial(0xFFFFFF, {{ metalness: 0.0, roughness: 0.20, opacity: 0.55 }});
productMesh = new THREE.Mesh(productGeometry, productMaterial);
productMesh.castShadow = true;
productMesh.receiveShadow = true;
scene.add(productMesh);
registerInitialPose(productMesh);
}}
}}
if (!productMesh && pointcloudData && pointcloudData.points && pointcloudData.points.length > 0) {{
const pointPositions = normalizePositions(pointcloudData.points, centerOffset);
if (pointPositions.length >= 3) {{
const ptGeometry = new THREE.BufferGeometry();
ptGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pointPositions), 3));
if (pointcloudData.normals && pointcloudData.normals.length > 0) {{
const normals = new Float32Array(toFlatArray(pointcloudData.normals));
if (normals.length === pointPositions.length) {{
ptGeometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
}}
}}
const ptMaterial = new THREE.PointsMaterial({{
color: 0xFFFFFF, size: 0.5, sizeAttenuation: true,
transparent: true, opacity: 0.90, blending: THREE.NormalBlending,
depthWrite: false,
}});
pointcloudMesh = new THREE.Points(ptGeometry, ptMaterial);
pointcloudMesh.visible = pointcloudVisible;
scene.add(pointcloudMesh);
registerInitialPose(pointcloudMesh);
}}
}}
if (!productMesh && !pointcloudMesh) {{
const productGeometry = new THREE.BoxGeometry(width * 0.9, height * 0.9, depth * 0.9);
const productMaterial = createPBRMaterial(0xFFFFFF, {{ metalness: 0.0, roughness: 0.25, opacity: 0.65 }});
productMesh = new THREE.Mesh(productGeometry, productMaterial);
productMesh.position.set(0, 0, 0);
scene.add(productMesh);
registerInitialPose(productMesh);
}}
if (cavityData && cavityData.mold_cavities && cavityData.mold_cavities.cavity) {{
const cd = cavityData.mold_cavities.cavity;
if (cd.vertices && cd.faces && cd.vertices.length > 0 && cd.faces.length > 0) {{
const cavityGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(normalizePositions(cd.vertices, centerOffset));
const indices = new Uint32Array(toFlatArray(cd.faces));
if (isValidIndexedGeometry(positions, indices)) {{
cavityGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
cavityGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
cavityGeometry.computeVertexNormals();
const cavityMaterial = createPBRMaterial(0x4488cc, {{ metalness: 0.7, roughness: 0.3, opacity: 0.45, depthWrite: false }});
cavityMesh = new THREE.Mesh(cavityGeometry, cavityMaterial);
cavityMesh.renderOrder = 1;
scene.add(cavityMesh);
registerInitialPose(cavityMesh);
addWireframe(cavityMesh, cavityGeometry, 0x3388bb);
}}
}}
}}
if (!cavityMesh) createSimpleCavity(width, height, depth, centerOffset);
if (coreRequired && cavityData && cavityData.mold_cavities && cavityData.mold_cavities.core) {{
const cd = cavityData.mold_cavities.core;
if (cd.vertices && cd.faces && cd.vertices.length > 0 && cd.faces.length > 0) {{
const coreGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(normalizePositions(cd.vertices, centerOffset));
const indices = new Uint32Array(toFlatArray(cd.faces));
if (isValidIndexedGeometry(positions, indices)) {{
coreGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
coreGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
coreGeometry.computeVertexNormals();
const coreMaterial = createPBRMaterial(0xdd8822, {{ metalness: 0.7, roughness: 0.3, opacity: 0.45, depthWrite: false }});
coreMesh = new THREE.Mesh(coreGeometry, coreMaterial);
coreMesh.renderOrder = 1;
scene.add(coreMesh);
registerInitialPose(coreMesh);
addWireframe(coreMesh, coreGeometry, 0xcc6600);
}}
}}
}}
if (!coreMesh && coreRequired) createSimpleCore(width, height, depth, centerOffset);
const partingGeometry = new THREE.PlaneGeometry(width * 1.2, height * 1.2);
const partingMaterial = new THREE.MeshBasicMaterial({{
color: 0xF44336, transparent: true, opacity: 0.25, side: THREE.DoubleSide, depthWrite: false,
}});
partingMesh = new THREE.Mesh(partingGeometry, partingMaterial);
partingMesh.position.set(centerOffset[0], centerOffset[1], centerOffset[2]);
partingMesh.renderOrder = 2;
scene.add(partingMesh);
registerInitialPose(partingMesh);
if (productMesh) {{
if (productMesh.isLOD) {{
productMesh.traverse(child => {{
if (child.isMesh && child.geometry) {{
addWireframe(child, child.geometry, 0xCCCCCC);
}}
}});
}} else {{
addWireframe(productMesh, productMesh.geometry, 0xCCCCCC);
}}
}}
updateInfoPanel(pointcloudData, cavityData);
fitCameraToScene();
}}
function addWireframe(parent, geometry, colorHex) {{
const wf = new THREE.WireframeGeometry(geometry);
const line = new THREE.LineSegments(wf, new THREE.LineBasicMaterial({{
color: colorHex, transparent: true, opacity: 0.25, depthTest: true, depthWrite: false,
}}));
line.renderOrder = 3;
parent.add(line);
}}
function createSimpleCavity(width, height, depth, center) {{
const halfDepth = depth / 2;
const cGeo = new THREE.BoxGeometry(width * 1.2, height * 1.2, halfDepth + 10);
const cMat = createPBRMaterial(0x4488cc, {{ metalness: 0.6, roughness: 0.35, opacity: 0.35, depthWrite: false }});
cavityMesh = new THREE.Mesh(cGeo, cMat);
cavityMesh.position.set(center[0], center[1], center[2] + halfDepth / 2 + 5);
cavityMesh.renderOrder = 1;
scene.add(cavityMesh);
registerInitialPose(cavityMesh);
addWireframe(cavityMesh, cGeo, 0x3388bb);
}}
function createSimpleCore(width, height, depth, center) {{
const halfDepth = depth / 2;
const cGeo = new THREE.BoxGeometry(width * 1.2, height * 1.2, halfDepth + 10);
const cMat = createPBRMaterial(0xdd8822, {{ metalness: 0.6, roughness: 0.35, opacity: 0.35, depthWrite: false }});
coreMesh = new THREE.Mesh(cGeo, cMat);
coreMesh.position.set(center[0], center[1], center[2] - halfDepth / 2 - 5);
coreMesh.renderOrder = 1;
scene.add(coreMesh);
registerInitialPose(coreMesh);
addWireframe(coreMesh, cGeo, 0xcc6600);
}}
function updateInfoPanel(pointcloudData, cavityData) {{
const verts = pointcloudData?.vertex_count || cavityData?.mold_cavities?.cavity?.vertex_count || '-';
const faces = pointcloudData?.face_count || cavityData?.mold_cavities?.cavity?.face_count || '-';
const pts = pointcloudData?.point_count || '-';
const vol = cavityData?.mold_cavities?.cavity_key_info?.geometric_characteristics?.product_volume || '-';
document.getElementById('info-verts').textContent = typeof verts === 'number' ? verts.toLocaleString() : verts;
document.getElementById('info-faces').textContent = typeof faces === 'number' ? faces.toLocaleString() : faces;
document.getElementById('info-points').textContent = typeof pts === 'number' ? pts.toLocaleString() : pts;
document.getElementById('info-vol').textContent = vol;
const panel = document.getElementById('cavity-info-panel');
if (panel && cavityData) {{
panel.style.display = 'block';
const meta = cavityData.metadata || {{}};
const mfg = cavityData.manufacturing_info || {{}};
const geo = cavityData.mold_cavities?.cavity_key_info?.geometric_characteristics || {{}};
const setVal = (id, val) => {{ const el = document.getElementById(id); if (el) el.textContent = val || 'N/A'; }};
setVal('cp-shrink', meta.shrinkage_rate);
setVal('cp-draft', meta.draft_angle != null ? meta.draft_angle + '°' : null);
setVal('cp-parting', mfg.parting_line_length);
setVal('cp-vol', geo.product_volume);
setVal('cp-weight', geo.product_weight);
setVal('cp-wall', geo.wall_thickness_range);
setVal('cp-material', mfg.mold_material);
setVal('cp-hardness', mfg.mold_hardness);
setVal('cp-finish', mfg.surface_finish);
setVal('cp-cycle', mfg.estimated_cycle_time);
}}
}}
async function loadData() {{
const statusEl = document.getElementById('loading-status');
try {{
statusEl.textContent = '正在加载几何数据...';
const resp = await fetch(DATA_URL);
if (!resp.ok) throw new Error(`HTTP ${{resp.status}}`);
const data = await resp.json();
statusEl.textContent = '正在构建3D场景...';
await new Promise(r => setTimeout(r, 30));
buildScene(
data.geometry || {{}},
data.cavity || null,
data.pointcloud || null
);
statusEl.textContent = '完成';
document.getElementById('loading-overlay').classList.add('hidden');
}} catch (err) {{
console.error('数据加载失败:', err);
statusEl.textContent = '加载失败: ' + err.message;
statusEl.style.color = '#F44336';
}}
}}
function animate() {{
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}}
loadData().then(() => animate());
window.addEventListener('resize', () => {{
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}});
window.resetView = function() {{
if (splitAnimId) {{ cancelAnimationFrame(splitAnimId); splitAnimId = null; }}
isSplit = false;
const btn = document.getElementById('splitBtn');
if (btn) btn.textContent = '分模拆分';
[productMesh, cavityMesh, coreMesh, partingMesh, pointcloudMesh].forEach(mesh => {{
if (!mesh) return;
if (mesh.userData.initialPosition) mesh.position.copy(mesh.userData.initialPosition);
else mesh.position.set(0, 0, 0);
mesh.visible = mesh.userData.initialVisible !== false;
}});
if (partingMesh && partingMesh.material) {{ partingMesh.material.opacity = 0.25; partingMesh.visible = true; }}
productVisible = true; moldVisible = true; partingVisible = true;
document.getElementById('btn-product').classList.add('active');
document.getElementById('btn-mold').classList.add('active');
document.getElementById('btn-parting').classList.add('active');
fitCameraToScene();
}};
window.toggleWireframe = function() {{
scene.traverse(child => {{ if (child.isMesh) child.material.wireframe = !child.material.wireframe; }});
}};
window.toggleProduct = function() {{
if (!productMesh && !pointcloudMesh) return;
productVisible = !productVisible;
if (productMesh) productMesh.visible = productVisible;
document.getElementById('btn-product').classList.toggle('active', productVisible);
}};
window.toggleMold = function() {{
moldVisible = !moldVisible;
if (cavityMesh) cavityMesh.visible = moldVisible;
if (coreMesh) coreMesh.visible = moldVisible;
document.getElementById('btn-mold').classList.toggle('active', moldVisible);
}};
window.toggleParting = function() {{
partingVisible = !partingVisible;
if (partingMesh) partingMesh.visible = partingVisible;
document.getElementById('btn-parting').classList.toggle('active', partingVisible);
}};
window.togglePointcloud = function() {{
pointcloudVisible = !pointcloudVisible;
if (pointcloudMesh) pointcloudMesh.visible = pointcloudVisible;
document.getElementById('btn-pointcloud').classList.toggle('active', pointcloudVisible);
}};
function getPartingDirection() {{
if (cavityDataGlobal?.metadata?.parting_direction) return cavityDataGlobal.metadata.parting_direction;
if (cavityDataGlobal?.manufacturing_info?.parting_direction) return cavityDataGlobal.manufacturing_info.parting_direction;
if (cavityDataGlobal?.metadata?.is_foam) return 'Z';
return 'Z';
}}
window.splitMold = function() {{
if (!cavityMesh && !coreMesh) return;
isSplit = !isSplit;
const btn = document.getElementById('splitBtn');
btn.textContent = isSplit ? '合模' : '分模拆分';
const dir = getPartingDirection();
let splitDist, axis;
if (dir === 'Z') {{ splitDist = (sceneBox ? sceneBox.getSize(new THREE.Vector3()).z : 100) * 0.4; axis = 'z'; }}
else if (dir === 'Y') {{ splitDist = (sceneBox ? sceneBox.getSize(new THREE.Vector3()).y : 100) * 0.4; axis = 'y'; }}
else {{ splitDist = (sceneBox ? sceneBox.getSize(new THREE.Vector3()).x : 100) * 0.4; axis = 'x'; }}
const partingTargetOpacity = isSplit ? 0 : 0.25;
const cavityStart = cavityMesh ? cavityMesh.position[axis] : 0;
const coreStart = coreMesh ? coreMesh.position[axis] : 0;
const partingStartOpacity = partingMesh ? partingMesh.material.opacity : 0.25;
const cavityTarget = isSplit ? splitDist : 0;
const coreTarget = isSplit ? -splitDist : 0;
const duration = 900;
const startTime = performance.now();
if (splitAnimId) cancelAnimationFrame(splitAnimId);
function animateSplit(now) {{
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
if (cavityMesh) cavityMesh.position[axis] = cavityStart + (cavityTarget - cavityStart) * ease;
if (coreMesh) coreMesh.position[axis] = coreStart + (coreTarget - coreStart) * ease;
if (partingMesh) {{
partingMesh.material.opacity = partingStartOpacity + (partingTargetOpacity - partingStartOpacity) * ease;
partingMesh.visible = !(isSplit && t >= 1);
}}
if (t < 1) splitAnimId = requestAnimationFrame(animateSplit);
else splitAnimId = null;
}}
splitAnimId = requestAnimationFrame(animateSplit);
}};
</script>
</body>
</html>"""
return html_content
def _build_cavity_info_panel_template(self) -> str:
"""构建型腔信息面板 — 由JS动态填充,这里放置容器"""
return """
<div id="cavity-info-panel">
<h3>🔧 关键工艺参数</h3>
<div style="margin: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">
<strong style="color: #FF9800;">模具参数</strong>
</div>
<div class="metric"><span class="metric-label">收缩率</span><span class="metric-value" id="cp-shrink">-</span></div>
<div class="metric"><span class="metric-label">拔模角</span><span class="metric-value" id="cp-draft">-</span></div>
<div class="metric"><span class="metric-label">分型线长度</span><span class="metric-value" id="cp-parting">-</span></div>
<div style="margin: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">
<strong style="color: #FF9800;">几何特性</strong>
</div>
<div class="metric"><span class="metric-label">产品体积</span><span class="metric-value" id="cp-vol">-</span></div>
<div class="metric"><span class="metric-label">产品重量</span><span class="metric-value" id="cp-weight">-</span></div>
<div class="metric"><span class="metric-label">壁厚范围</span><span class="metric-value" id="cp-wall">-</span></div>
<div style="margin: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">
<strong style="color: #FF9800;">制造要求</strong>
</div>
<div class="metric"><span class="metric-label">模仁材料</span><span class="metric-value" id="cp-material">-</span></div>
<div class="metric"><span class="metric-label">硬度</span><span class="metric-value" id="cp-hardness">-</span></div>
<div class="metric"><span class="metric-label">表面光洁度</span><span class="metric-value" id="cp-finish">-</span></div>
<div class="metric"><span class="metric-label">预估周期</span><span class="metric-value" id="cp-cycle">-</span></div>
</div>
"""
def generate_3d_viewer_data(
self,
geometry_data: Dict[str, Any],
cavity_data: Optional[Dict[str, Any]] = None,
pointcloud_data: Optional[Dict[str, Any]] = None,
lod_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""生成companion JSON数据文件内容,支持多级LOD"""
pc = dict(pointcloud_data) if pointcloud_data else {}
if lod_data and lod_data.get("lods"):
pc["lods"] = lod_data["lods"]
data = {
"version": "4.0.0",
"generated_at": datetime.now().isoformat(),
"geometry": geometry_data,
"cavity": cavity_data,
"pointcloud": pc if pc else pointcloud_data,
}
return data
def save_html_file(self, html_content: str, filename: str) -> str:
"""保存HTML文件到磁盘"""
try:
file_path = self.output_dir / filename
file_path.write_text(html_content, encoding='utf-8')
logger.info(f"HTML文件保存成功: {file_path}")
return str(file_path)
except Exception as e:
logger.error(f"保存HTML文件失败: {e}")
raise
def save_data_file(self, data_content: Dict[str, Any], filename: str) -> str:
"""保存JSON数据文件到磁盘 — 使用orjson高速序列化"""
try:
file_path = self.output_dir / filename
file_path.write_bytes(_json_dumps(data_content))
logger.info(f"数据文件保存成功: {file_path} (orjson={_JSON_FAST})")
return str(file_path)
except Exception as e:
logger.error(f"保存数据文件失败: {e}")
raise
def generate_and_save_visualization(
self,
geometry_data: Dict[str, Any],
stp_filename: str,
cavity_data: Optional[Dict[str, Any]] = None,
pointcloud_data: Optional[Dict[str, Any]] = None,
suffix: Optional[str] = None,
lod_data: Optional[Dict[str, Any]] = None,
) -> str:
"""生成并保存可视化HTML + companion JSON数据文件。返回HTML文件路径(向后兼容)"""
try:
base_stem = Path(stp_filename).stem.replace(" ", "_")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix_part = f"_{suffix}" if suffix else ""
base_name = f"mold_{base_stem}{suffix_part}_{ts}"
html_filename = f"{base_name}.html"
data_filename = f"{base_name}_data.json"
data_content = self.generate_3d_viewer_data(
geometry_data, cavity_data, pointcloud_data, lod_data=lod_data
)
self.save_data_file(data_content, data_filename)
html_content = self.generate_3d_viewer_html(stp_filename, data_filename)
html_file_path = self.save_html_file(html_content, html_filename)
return html_file_path
except Exception as e:
logger.error(f"生成可视化文件失败: {e}")
raise