Spaces:
Sleeping
Sleeping
| import logging | |
| import time | |
| from collections import Counter | |
| from typing import Any, Dict, List, Optional, Tuple | |
| import numpy as np | |
| logger = logging.getLogger(__name__) | |
| def extract_properties(shape) -> Dict[str, Any]: | |
| t0 = time.time() | |
| try: | |
| props = {} | |
| bb = shape.BoundingBox() | |
| dims = [bb.xlen, bb.ylen, bb.zlen] | |
| sorted_axes = sorted( | |
| zip(["X", "Y", "Z"], dims), key=lambda x: x[1], reverse=True | |
| ) | |
| longest_axis = sorted_axes[0][0] | |
| props["is_valid"] = shape.isValid() | |
| volume = shape.Volume() | |
| surface_area = shape.Area() | |
| props["volume_mm3"] = round(volume, 4) | |
| props["surface_area_mm2"] = round(surface_area, 4) | |
| props["bbox_x_mm"] = round(bb.xlen, 4) | |
| props["bbox_y_mm"] = round(bb.ylen, 4) | |
| props["bbox_z_mm"] = round(bb.zlen, 4) | |
| props["bbox_longest_axis"] = longest_axis | |
| max_dim = max(dims) | |
| props["bbox_ratio_yx"] = round(sorted_axes[1][1] / max_dim, 4) if max_dim > 0 else 0.0 | |
| props["bbox_ratio_zx"] = round(sorted_axes[2][1] / max_dim, 4) if max_dim > 0 else 0.0 | |
| faces = shape.Faces() | |
| face_types = [f.geomType() for f in faces] | |
| type_counts = dict(Counter(face_types)) | |
| total_faces = len(face_types) | |
| props["face_count"] = total_faces | |
| props["face_type_counts"] = type_counts | |
| props["dominant_face_type"] = max(type_counts, key=type_counts.get) if type_counts else "UNKNOWN" | |
| props["face_type_distribution"] = { | |
| k: round(v / total_faces, 4) for k, v in type_counts.items() | |
| } if total_faces > 0 else {} | |
| props["edge_count"] = len(shape.Edges()) | |
| props["vertex_count"] = len(shape.Vertices()) | |
| V = props["vertex_count"] | |
| E = props["edge_count"] | |
| F = props["face_count"] | |
| props["euler_characteristic"] = V - E + F | |
| props["is_watertight"] = _check_watertight(shape) | |
| xy_sym, xz_sym, yz_sym = _check_symmetry(shape, bb) | |
| props["has_xy_symmetry"] = xy_sym | |
| props["has_xz_symmetry"] = xz_sym | |
| props["has_yz_symmetry"] = yz_sym | |
| props["shape_class"] = _classify_shape(props) | |
| elapsed = time.time() - t0 | |
| logger.info(f"extract_properties took {elapsed:.3f}s") | |
| return props | |
| except Exception as e: | |
| elapsed = time.time() - t0 | |
| logger.error(f"extract_properties failed after {elapsed:.3f}s: {e}") | |
| raise | |
| def _check_watertight(shape) -> bool: | |
| try: | |
| shells = shape.Shells() | |
| if not shells: | |
| return False | |
| for shell in shells: | |
| if not shell.Closed(): | |
| return False | |
| return True | |
| except Exception: | |
| return False | |
| def _check_symmetry(shape, bb, n_sample: int = 200, tol_ratio: float = 0.05) -> Tuple[bool, bool, bool]: | |
| try: | |
| from OCP.BRepMesh import BRepMesh_IncrementalMesh | |
| from OCP.BRep import BRep_Tool | |
| from OCP.TopExp import TopExp_Explorer | |
| from OCP.TopAbs import TopAbs_FACE | |
| from OCP.TopLoc import TopLoc_Location | |
| from OCP.TopoDS import TopoDS | |
| mesh = BRepMesh_IncrementalMesh(shape.wrapped, 0.5, False, 0.5, True) | |
| mesh.Perform() | |
| points = [] | |
| explorer = TopExp_Explorer(shape.wrapped, TopAbs_FACE) | |
| while explorer.More(): | |
| face = TopoDS.Face_s(explorer.Current()) | |
| loc = TopLoc_Location() | |
| tri = BRep_Tool.Triangulation_s(face, loc) | |
| if tri is not None: | |
| for i in range(1, tri.NbNodes() + 1): | |
| p = tri.Node(i) | |
| trsf = loc.Transformation() | |
| p.Transform(trsf) | |
| points.append([p.X(), p.Y(), p.Z()]) | |
| explorer.Next() | |
| if len(points) < 10: | |
| return False, False, False | |
| pts = np.array(points) | |
| if len(pts) > n_sample: | |
| idx = np.random.choice(len(pts), n_sample, replace=False) | |
| pts = pts[idx] | |
| diag = np.sqrt(bb.xlen**2 + bb.ylen**2 + bb.zlen**2) | |
| tol = diag * tol_ratio | |
| from scipy.spatial import cKDTree | |
| tree = cKDTree(pts) | |
| def check_plane_symmetry(pts_arr, axis_idx): | |
| reflected = pts_arr.copy() | |
| reflected[:, axis_idx] = -reflected[:, axis_idx] | |
| dists, _ = tree.query(reflected) | |
| return float(np.mean(dists)) < tol | |
| cx = (bb.xmin + bb.xmax) / 2 | |
| cy = (bb.ymin + bb.ymax) / 2 | |
| cz = (bb.zmin + bb.zmax) / 2 | |
| centered = pts - np.array([cx, cy, cz]) | |
| tree_c = cKDTree(centered) | |
| def check_sym(axis_idx): | |
| reflected = centered.copy() | |
| reflected[:, axis_idx] = -reflected[:, axis_idx] | |
| dists, _ = tree_c.query(reflected) | |
| return float(np.mean(dists)) < tol | |
| has_yz = check_sym(0) | |
| has_xz = check_sym(1) | |
| has_xy = check_sym(2) | |
| return has_xy, has_xz, has_yz | |
| except Exception as e: | |
| logger.warning(f"Symmetry check failed: {e}") | |
| return False, False, False | |
| def _classify_shape(props: Dict[str, Any]) -> str: | |
| ratio_yx = props.get("bbox_ratio_yx", 0) | |
| ratio_zx = props.get("bbox_ratio_zx", 0) | |
| dominant = props.get("dominant_face_type", "") | |
| euler = props.get("euler_characteristic", 2) | |
| is_valid = props.get("is_valid", False) | |
| if not is_valid or props.get("volume_mm3", 0) <= 0: | |
| return "DEGENERATE" | |
| is_flat = ratio_zx < 0.25 | |
| is_cubic = ratio_yx > 0.7 and ratio_zx > 0.7 | |
| is_round = dominant in ("CYLINDER", "CONE", "SPHERE", "TORUS") | |
| is_tall = ratio_zx > 0.5 and not is_cubic | |
| if is_round: | |
| if is_flat: | |
| return "FLAT_ROUND_SOLID" | |
| if euler != 2: | |
| return "HOLLOW_ROUND_SOLID" | |
| if is_tall: | |
| return "TALL_ROUND_SOLID" | |
| return "FLAT_ROUND_SOLID" | |
| if is_flat: | |
| if euler != 2: | |
| return "FLAT_SOLID_WITH_HOLES" | |
| return "FLAT_SOLID" | |
| if is_cubic: | |
| if euler != 2: | |
| return "CUBIC_SOLID_WITH_HOLES" | |
| return "CUBIC_SOLID" | |
| face_count = props.get("face_count", 0) | |
| if face_count > 8 and ratio_yx < 0.5: | |
| return "L_SHAPED_SOLID" | |
| if face_count > 8 and ratio_yx > 0.5: | |
| return "T_SHAPED_SOLID" | |
| if face_count > 10: | |
| return "STEPPED_SOLID" | |
| return "COMPLEX_SOLID" | |