From 06b3e54a39cbe3a21bf79e06d6536f4c10919488 Mon Sep 17 00:00:00 2001 From: SZCJW <792430652@qq.com> Date: Sat, 2 May 2026 02:38:56 +0800 Subject: [PATCH] =?UTF-8?q?UI=E5=8D=87=E7=BA=A7=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 1 + src/core/mesh_generator.py | 165 +++- src/services/processing_service.py | 16 + src/utils/html_generator.py | 1203 ++++++++++++++-------------- 4 files changed, 760 insertions(+), 625 deletions(-) diff --git a/requirements.txt b/requirements.txt index b88df5b..9002e61 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,6 +59,7 @@ email-validator>=2.0.0 # 工具库 # ============================================ aiofiles>=23.0.0 +orjson>=3.9.0 python-dotenv>=1.0.0 jinja2>=3.1.0 pyyaml>=6.0 diff --git a/src/core/mesh_generator.py b/src/core/mesh_generator.py index 5111e0b..acc1426 100644 --- a/src/core/mesh_generator.py +++ b/src/core/mesh_generator.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) class MeshGenerator: - """网格生成器 - 从PythonOCC形状生成点云""" + """网格生成器 - 从PythonOCC形状生成点云,支持多级LOD""" def __init__(self, quality: str = "medium"): self.quality_settings = { @@ -27,99 +27,84 @@ class MeshGenerator: def generate_mesh_from_shape(self, shape, num_points: int = 20000) -> Dict: """从PythonOCC形状生成点云数据""" try: - # 生成网格 - 使用更精细的网格 mesh = BRepMesh_IncrementalMesh(shape, self.quality, False, 0.5, True) mesh.Perform() logger.info(f"OCC网格生成完成, 网格状态: {mesh.IsDone()}") - - # 提取三角形面数据 + all_vertices = [] all_faces = [] vertex_offset = 0 - - # 遍历所有面 + explorer = TopExp_Explorer(shape, TopAbs_FACE) face_count = 0 - + while explorer.More(): face = explorer.Current() face_count += 1 - - # 获取面的三角形剖分 + location = TopLoc_Location() face_triangulation = BRep_Tool.Triangulation(face, location) - + if face_triangulation is None: logger.warning(f"面 {face_count} 没有三角剖分数据") explorer.Next() continue - - # 获取变换矩阵 + trsf = location.Transformation() - - # 获取顶点数量 + nb_nodes = face_triangulation.NbNodes() nb_triangles = face_triangulation.NbTriangles() - + logger.info(f"面 {face_count}: {nb_nodes} 个顶点, {nb_triangles} 个三角形") - - # 提取顶点并应用变换 + face_vertices = [] for i in range(1, nb_nodes + 1): pnt = face_triangulation.Node(i) - # 使用变换后的拷贝点,避免潜在的原地变换副作用 transformed = pnt.Transformed(trsf) face_vertices.append([ float(transformed.X()), float(transformed.Y()), float(transformed.Z()), ]) - - # 提取三角形索引 + face_indices = [] for i in range(1, nb_triangles + 1): tri = face_triangulation.Triangle(i) - # 三角形索引从1开始,需要转换为从0开始 idx1 = tri.Value(1) idx2 = tri.Value(2) idx3 = tri.Value(3) - # 转换为全局索引 face_indices.append([ vertex_offset + idx1 - 1, vertex_offset + idx2 - 1, vertex_offset + idx3 - 1 ]) - + all_vertices.extend(face_vertices) all_faces.extend(face_indices) vertex_offset += len(face_vertices) - + explorer.Next() - + if len(all_vertices) == 0: logger.warning("未提取到任何顶点,使用示例数据") return self._create_sample_pointcloud() - + vertices = np.array(all_vertices, dtype=np.float32) faces = np.array(all_faces, dtype=np.int32) - + logger.info(f"总共提取了 {len(vertices)} 个顶点, {len(faces)} 个三角形面, {face_count} 个面") - - # 创建Trimesh对象 + tri_mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=True) - - # 根据网格大小动态调整采样点数 + actual_num_points = min(num_points, len(faces) * 2) logger.info(f"采样点数: {actual_num_points}") - - # 在网格表面采样点云 + points, face_idx = trimesh.sample.sample_surface(tri_mesh, actual_num_points) - - # 获取法向量 + normals = tri_mesh.face_normals[face_idx] - + logger.info(f"生成了 {len(points)} 个点云点") - + return { "vertices": vertices.tolist(), "faces": faces.tolist(), @@ -129,19 +114,121 @@ class MeshGenerator: "vertex_count": int(len(vertices)), "face_count": int(len(faces)) } - + except Exception as e: logger.error(f"网格生成失败: {e}") import traceback logger.error(traceback.format_exc()) return self._create_sample_pointcloud() + def generate_multi_lod_mesh(self, shape) -> Dict: + """生成多级LOD网格 - 一次OCC剖分,trimesh简化,避免重复计算 + + 返回结构: + { + "lods": { + "0": { "vertices": [...], "faces": [...], "vertex_count": N, "face_count": N }, + "1": { ... 50%简化 ... }, + "2": { ... 80%简化 ... } + }, + "points": [...], "normals": [...], "point_count": N, + "vertex_count": N, "face_count": N + } + """ + try: + full_mesh_result = self.generate_mesh_from_shape(shape, num_points=20000) + + vertices = np.array(full_mesh_result["vertices"], dtype=np.float32) + faces = np.array(full_mesh_result["faces"], dtype=np.int32) + + if len(vertices) == 0 or len(faces) == 0: + sample = self._create_sample_pointcloud() + return self._wrap_sample_as_lod(sample) + + tri_mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=True) + full_face_count = len(tri_mesh.faces) + logger.info(f"全精度网格: {len(tri_mesh.vertices)} 顶点, {full_face_count} 面") + + lods = { + "0": self._mesh_to_lod_entry(tri_mesh, "LOD0-全精度") + } + + lod_ratios = {"1": 0.50, "2": 0.20} + for lod_level, ratio in lod_ratios.items(): + if full_face_count < 300: + lods[lod_level] = lods["0"] + continue + + target_faces = max(int(full_face_count * ratio), 200) + try: + simplified = tri_mesh.simplify_quadric_decimation(target_faces) + if simplified is None or len(simplified.faces) < 3: + simplified = self._fast_decimate(tri_mesh, target_faces) + lods[lod_level] = self._mesh_to_lod_entry(simplified, f"LOD{lod_level}-简化{int((1-ratio)*100)}%") + logger.info(f"LOD{lod_level}: {len(simplified.vertices)} 顶点, {len(simplified.faces)} 面 (目标{target_faces})") + except Exception as dec_err: + logger.warning(f"LOD{lod_level} 简化失败,回退到全精度: {dec_err}") + lods[lod_level] = lods["0"] + + result = { + "lods": lods, + "points": full_mesh_result["points"], + "normals": full_mesh_result["normals"], + "point_count": full_mesh_result["point_count"], + "vertex_count": full_mesh_result["vertex_count"], + "face_count": full_mesh_result["face_count"], + } + return result + + except Exception as e: + logger.error(f"多级LOD网格生成失败: {e}") + import traceback + logger.error(traceback.format_exc()) + sample = self._create_sample_pointcloud() + return self._wrap_sample_as_lod(sample) + + def _mesh_to_lod_entry(self, mesh: trimesh.Trimesh, label: str) -> Dict: + return { + "vertices": mesh.vertices.tolist(), + "faces": mesh.faces.tolist(), + "vertex_count": int(len(mesh.vertices)), + "face_count": int(len(mesh.faces)), + } + + def _fast_decimate(self, mesh: trimesh.Trimesh, target_faces: int) -> trimesh.Trimesh: + """快速回退降采样:按面索引均匀采样""" + if target_faces >= len(mesh.faces): + return mesh + step = max(len(mesh.faces) // target_faces, 1) + indices = np.arange(0, len(mesh.faces), step)[:target_faces] + return mesh.submesh([np.array(indices)], only_watertight=False, append=True) + + def _wrap_sample_as_lod(self, sample: Dict) -> Dict: + lods = { + "0": { + "vertices": sample["vertices"], + "faces": sample["faces"], + "vertex_count": sample["vertex_count"], + "face_count": sample["face_count"], + } + } + lods["1"] = lods["0"] + lods["2"] = lods["0"] + return { + "lods": lods, + "points": sample["points"], + "normals": sample["normals"], + "point_count": sample["point_count"], + "vertex_count": sample["vertex_count"], + "face_count": sample["face_count"], + } + def _create_sample_pointcloud(self) -> Dict: """创建示例点云(备用)""" mesh = trimesh.creation.box([100, 80, 50]) points, _ = trimesh.sample.sample_surface(mesh, 5000) normals = mesh.face_normals[:len(points)] - + return { "vertices": mesh.vertices.tolist(), "faces": mesh.faces.tolist(), diff --git a/src/services/processing_service.py b/src/services/processing_service.py index b2075a7..91f7d20 100644 --- a/src/services/processing_service.py +++ b/src/services/processing_service.py @@ -176,6 +176,7 @@ class ProcessingService: ) pointcloud_data = None + lod_data = None if mesh_result: pointcloud_data = { "points": mesh_result.get("points", []), @@ -183,13 +184,25 @@ class ProcessingService: "vertices": mesh_result.get("vertices", []), "faces": mesh_result.get("faces", []), "point_count": mesh_result.get("point_count", 0), + "vertex_count": mesh_result.get("vertex_count", 0), + "face_count": mesh_result.get("face_count", 0), } + # 生成多级LOD数据(用于前端按距离切换精度) + try: + lod_result = self.mesh_generator.generate_multi_lod_mesh(shape) + if lod_result and lod_result.get("lods"): + lod_data = lod_result + logger.info(f"LOD数据生成成功: {len(lod_result['lods'])} 级 (面数: {[lod_result['lods'][k]['face_count'] for k in sorted(lod_result['lods'].keys())]})") + except Exception as lod_err: + logger.warning(f"LOD数据生成失败,使用单级精度: {lod_err}") + detailed_cavity_json = await self._attach_scheme_previews( detailed_cavity_json=detailed_cavity_json, geometry_data=geometry_data, stp_filename=Path(file_path).name, pointcloud_data=pointcloud_data, + lod_data=lod_data, ) best_scheme = CalculationService.get_best_scheme(detailed_cavity_json) @@ -206,6 +219,7 @@ class ProcessingService: Path(file_path).name, cavity_data=best_cavity_data, pointcloud_data=pointcloud_data, + lod_data=lod_data, ) await self.storage_service.save_html_file( @@ -405,6 +419,7 @@ class ProcessingService: geometry_data: Dict[str, Any], stp_filename: str, pointcloud_data: Optional[Dict[str, Any]] = None, + lod_data: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """为每个候选分模方案生成独立HTML预览链接。""" candidate_schemes = detailed_cavity_json.get("candidate_schemes", []) @@ -422,6 +437,7 @@ class ProcessingService: cavity_data=cavity_data, pointcloud_data=pointcloud_data, suffix=suffix, + lod_data=lod_data, ) scheme["html_file"] = f"/html/{Path(html_path).name}" diff --git a/src/utils/html_generator.py b/src/utils/html_generator.py index 9067266..2bd5611 100644 --- a/src/utils/html_generator.py +++ b/src/utils/html_generator.py @@ -1,279 +1,274 @@ # utils/html_generator.py from pathlib import Path from typing import Dict, Any, Optional -import json 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文件生成器""" - + """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, - geometry_data: Dict[str, Any], - stp_filename: str, - cavity_data: Optional[Dict[str, Any]] = None, - pointcloud_data: Optional[Dict[str, Any]] = None - ) -> str: - """生成3D可视化HTML页面""" - - # 提取几何数据 - bounding_box = geometry_data.get("bounding_box", {}) - volume = geometry_data.get("volume", 0) or 0 - surface_area = geometry_data.get("surface_area", 0) or 0 - topology = geometry_data.get("topology", {}) - center_of_mass = geometry_data.get("center_of_mass", [0, 0, 0]) - cavity_html = "" - # 强制测试:无论cavity_data如何,都显示面板 - if True: - # 安全获取嵌套数据 - metadata = cavity_data.get("metadata", {}) if cavity_data else {} - manufacturing_info = cavity_data.get("manufacturing_info", {}) if cavity_data else {} - mold_cavities = cavity_data.get("mold_cavities", {}) if cavity_data else {} - cavity_key_info = mold_cavities.get("cavity_key_info", {}) - geo_chars = cavity_key_info.get("geometric_characteristics", {}) - logger.info(f"生成HTML - 收缩率: {metadata.get('shrinkage_rate')}") - logger.info(f"生成HTML - 型腔材料: {manufacturing_info.get('mold_material')}") - logger.info(f"生成HTML - 产品体积: {geo_chars.get('product_volume')}") + def generate_3d_viewer_html(self, stp_filename: str, data_filename: str) -> str: + """生成3D可视化HTML页面 — 通过fetch异步加载companion JSON数据""" - cavity_html = f""" -
-

🔧 关键工艺参数

-
- 模具参数 -
-
- 收缩率: - {metadata.get("shrinkage_rate", "N/A")} -
-
- 拔模角: - {metadata.get("draft_angle", "N/A")}° -
-
- 分型线长度: - {manufacturing_info.get("parting_line_length", "N/A")} -
-
- 几何特性 -
-
- 产品体积: - {geo_chars.get("product_volume", "N/A")} -
-
- 产品重量: - {geo_chars.get("product_weight", "N/A")} -
-
- 壁厚范围: - {geo_chars.get("wall_thickness_range", "N/A")} -
-
- 制造要求 -
-
- 模仁材料: - {manufacturing_info.get("mold_material", "N/A")} -
-
- 硬度: - {manufacturing_info.get("mold_hardness", "N/A")} -
-
- 表面光洁度: - {manufacturing_info.get("surface_finish", "N/A")} -
-
- 预估周期: - {manufacturing_info.get("estimated_cycle_time", "N/A")} -
-
- """ - else: - logger.warning(f"cavity_data 为空,无法显示工艺参数 - 文件: {stp_filename}") - cavity_html = "" - html_content = f""" - + cavity_html = self._build_cavity_info_panel_template() + + html_content = f""" 3D模具几何可视化 - {stp_filename} - - +
- {cavity_html} -
-

模具几何信息

-
- 文件名: - {stp_filename} -
-
- 体积: - {volume:.2f} mm³ -
-
- 表面积: - {surface_area:.2f} mm² -
-
- 边界框: - {bounding_box.get('dimensions', [0, 0, 0])[0]:.1f} × {bounding_box.get('dimensions', [0, 0, 0])[1]:.1f} × {bounding_box.get('dimensions', [0, 0, 0])[2]:.1f} mm -
-
- 面数: - {topology.get('faces', 0)} -
-
- 边数: - {topology.get('edges', 0)} -
-
- 顶点数: - {topology.get('vertices', 0)} -
+ +
+
+
加载几何数据...
- -
- - - - - - - + +
+

📐 {stp_filename}

+
顶点-
+
三角面-
+
点云-
+
体积-
+
+ + {cavity_html} + +
+ + + + + + + +
+ +
+ + + + + + +
- - -""" - +""" return html_content - + + def _build_cavity_info_panel_template(self) -> str: + """构建型腔信息面板 — 由JS动态填充,这里放置容器""" + return """ +
+

🔧 关键工艺参数

+
+ 模具参数 +
+
收缩率-
+
拔模角-
+
分型线长度-
+
+ 几何特性 +
+
产品体积-
+
产品重量-
+
壁厚范围-
+
+ 制造要求 +
+
模仁材料-
+
硬度-
+
表面光洁度-
+
预估周期-
+
+ """ + + 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 - - with open(file_path, 'w', encoding='utf-8') as f: - f.write(html_content) - + 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], @@ -768,29 +800,28 @@ class HTMLGenerator: 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文件""" + """生成并保存可视化HTML + companion JSON数据文件。返回HTML文件路径(向后兼容)""" try: - # 生成HTML内容 - html_content = self.generate_3d_viewer_html( - geometry_data, - stp_filename, - cavity_data=cavity_data, - pointcloud_data=pointcloud_data - ) - - # 创建文件名 - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - safe_filename = stp_filename.replace('.', '_').replace(' ', '_') + base_stem = Path(stp_filename).stem.replace(" ", "_") + ts = datetime.now().strftime("%Y%m%d_%H%M%S") suffix_part = f"_{suffix}" if suffix else "" - html_filename = f"{safe_filename}{suffix_part}_{timestamp}.html" + base_name = f"mold_{base_stem}{suffix_part}_{ts}" - # 保存文件 - file_path = self.save_html_file(html_content, html_filename) + html_filename = f"{base_name}.html" + data_filename = f"{base_name}_data.json" - return file_path + 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 -