import math import json from OCP.STEPControl import STEPControl_Reader from OCP.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_IN, TopAbs_REVERSED, TopAbs_VERTEX from OCP.TopExp import TopExp_Explorer, TopExp from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Edge from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve from OCP.BRep import BRep_Tool from OCP.GeomAbs import GeomAbs_Cylinder, GeomAbs_Plane, GeomAbs_Circle, GeomAbs_Line from OCP.BRepGProp import BRepGProp from OCP.GProp import GProp_GProps from OCP.BRepClass3d import BRepClass3d_SolidClassifier from OCP.gp import gp_Pnt, gp_Vec import numpy as np from OCP.Bnd import Bnd_Box from OCP.BRepBndLib import BRepBndLib from collections import Counter def face_bbox_center(face): box = Bnd_Box() BRepBndLib.Add_s(face, box) xmin, ymin, zmin, xmax, ymax, zmax = box.Get() return [ (xmin + xmax) / 2, (ymin + ymax) / 2, (zmin + zmax) / 2 ] def load_step_model(step_path): """Load a STEP file and return the shape object.""" reader = STEPControl_Reader() status = reader.ReadFile(step_path) if status != 1: raise RuntimeError(f"Failed to load STEP file: {step_path}") reader.TransferRoot() shape = reader.Shape() return shape def load_step_unit(step_path): with open(step_path, "r") as f: for line in f: if "SI_UNIT" in line: print("STEP file unit info:", line.strip()) return line.strip() def detect_faces(shape): faces = [] exp = TopExp_Explorer(shape, TopAbs_FACE) idx = 0 while exp.More(): face_shape = exp.Current() face = TopoDS.Face_s(face_shape) surf = BRep_Tool.Surface_s(face) faces.append({"face": face, "surface": surf}) idx = idx+1 exp.Next() return faces def is_hole(face, shape): """ Determine if a cylindrical face is a hole (concave) rather than a boss (convex). Basic approach: check if the face normal points toward the solid interior. """ # Get a point and normal on the cylindrical surface adaptor = BRepAdaptor_Surface(face, True) u_min, u_max = adaptor.FirstUParameter(), adaptor.LastUParameter() v_min, v_max = adaptor.FirstVParameter(), adaptor.LastVParameter() u_mid = (u_min + u_max) / 2 v_mid = (v_min + v_max) / 2 # Get point and normal vector at mid-parameter point = adaptor.Value(u_mid, v_mid) d1u = gp_Vec() d1v = gp_Vec() adaptor.D1(u_mid, v_mid, point, d1u, d1v) # Calculate normal vector (cross product of partial derivatives) normal = d1u.Crossed(d1v) normal.Normalize() # Offset point along normal direction offset = 0.01 # Small offset distance test_point = gp_Pnt( point.X() + normal.X() * offset, point.Y() + normal.Y() * offset, point.Z() + normal.Z() * offset ) # Check if the offset point is inside or outside the solid classifier = BRepClass3d_SolidClassifier(shape) classifier.Perform(test_point, 1e-7) # If normal points outward, offset point should be outside -> this is a hole # If normal points outward, offset point is still inside -> this is a boss return classifier.State() == TopAbs_IN def compute_face_angle_span(face, axis_location, axis_direction): axis_loc = np.array(axis_location, dtype=float) axis_dir = np.array(axis_direction, dtype=float) axis_dir = axis_dir / np.linalg.norm(axis_dir) ref = np.array([1.0, 0.0, 0.0], dtype=float) if abs(np.dot(ref, axis_dir)) > 0.9: ref = np.array([0.0, 1.0, 0.0], dtype=float) e1 = np.cross(axis_dir, ref) e1 = e1 / np.linalg.norm(e1) e2 = np.cross(axis_dir, e1) e2 = e2 / np.linalg.norm(e2) angles = [] edge_exp = TopExp_Explorer(face, TopAbs_EDGE) while edge_exp.More(): edge_shape = edge_exp.Current() vert_exp = TopExp_Explorer(edge_shape, TopAbs_VERTEX) while vert_exp.More(): vert_shape = vert_exp.Current() vertex = TopoDS.Vertex_s(vert_shape) p = BRep_Tool.Pnt_s(vertex) v = np.array([p.X(), p.Y(), p.Z()], dtype=float) - axis_loc v_proj = v - np.dot(v, axis_dir) * axis_dir norm_v = np.linalg.norm(v_proj) if norm_v < 1e-6: vert_exp.Next() continue x = np.dot(v_proj, e1) y = np.dot(v_proj, e2) theta = math.atan2(y, x) angles.append(theta) vert_exp.Next() edge_exp.Next() if not angles: return 0.0 angles = np.array(angles) angles = np.mod(angles, 2.0 * math.pi) angles.sort() diffs = np.diff(angles) wrap_gap = (angles[0] + 2.0 * math.pi) - angles[-1] max_gap = max(diffs.max(initial=0.0), wrap_gap) coverage_rad = 2.0 * math.pi - max_gap coverage_deg = math.degrees(coverage_rad) return float(coverage_deg) def detect_circular_holes(shape, min_radius=0.5, max_radius=50.0, merge_tolerance=1e-3): """ Detect all cylindrical (round) holes, including through-holes and blind holes. Merges faces belonging to the same hole. Returns: List of dicts with center, radius, normal, area, etc. """ candidate_faces = [] idx = 0 for fdict in detect_faces(shape): face = fdict["face"] surf = fdict["surface"] # Check if surface type is cylindrical surface_type = surf.DynamicType().Name() if "Cylindrical" not in surface_type: continue # Use BRepAdaptor_Surface to get cylinder parameters adaptor = BRepAdaptor_Surface(face, True) # Confirm it's a cylinder type if adaptor.GetType() != GeomAbs_Cylinder: continue cylinder = adaptor.Cylinder() radius = cylinder.Radius() # Only consider reasonable hole sizes if not (min_radius <= radius <= max_radius): continue # **Key judgment: check if it's a hole (not a boss)** if not is_hole(face, shape): continue # Skip bosses/shafts # Get axis direction and location axis_direction = cylinder.Axis().Direction() axis_location = cylinder.Axis().Location() axis_dir_vec = [axis_direction.X(), axis_direction.Y(), axis_direction.Z()] axis_loc_vec = [axis_location.X(), axis_location.Y(), axis_location.Z()] # Calculate surface area and center gprops = GProp_GProps() BRepGProp.SurfaceProperties_s(face, gprops) area = gprops.Mass() center = gprops.CentreOfMass() angle_span = compute_face_angle_span(face, axis_loc_vec, axis_dir_vec) candidate_faces.append({ "face_id": idx, "face": face, "radius": float(radius), "center": [center.X(), center.Y(), center.Z()], "axis_direction": [axis_direction.X(), axis_direction.Y(), axis_direction.Z()], "axis_location": [axis_location.X(), axis_location.Y(), axis_location.Z()], "area": float(area), "angle_span": float(angle_span) }) idx = idx+1 # Group faces that belong to the same hole holes = merge_coaxial_cylinders(candidate_faces, merge_tolerance) print(f"Detected {len(holes)} circular holes (from {len(candidate_faces)} cylindrical faces)") return holes def merge_coaxial_cylinders(faces, tolerance=1e-3, angle_threshold_deg=350.0): """ Merge cylindrical faces that share the same axis (belong to same hole). Two cylinders belong to the same hole if: 1. They have the same radius (within tolerance) 2. They are coaxial (share the same axis) """ if not faces: return [] merged_holes = [] used = [False] * len(faces) for i, face1 in enumerate(faces): if used[i]: continue # Start a new hole group hole_group = [face1] used[i] = True # Find all faces belonging to the same hole for j, face2 in enumerate(faces): if used[j] or i == j: continue # Check if same radius if abs(face1["radius"] - face2["radius"]) > tolerance: continue # Check if coaxial if are_coaxial(face1, face2, tolerance): hole_group.append(face2) used[j] = True total_angle = sum(f.get("angle_span", 0.0) for f in hole_group) total_angle = min(total_angle, 360) if total_angle < angle_threshold_deg: continue # Compute merged hole properties merged_hole = merge_hole_group(hole_group, i) merged_holes.append(merged_hole) return merged_holes def are_coaxial(face1, face2, tolerance=1e-3): """ Check if two cylindrical faces are coaxial (share the same axis). """ import numpy as np # Get axis directions dir1 = np.array(face1["axis_direction"]) dir2 = np.array(face2["axis_direction"]) # Normalize dir1 = dir1 / np.linalg.norm(dir1) dir2 = dir2 / np.linalg.norm(dir2) # Check if parallel (dot product close to ±1) dot_product = abs(np.dot(dir1, dir2)) if abs(dot_product - 1.0) > tolerance: return False # Check if axes are coincident # Distance from point on axis1 to axis2 loc1 = np.array(face1["axis_location"]) loc2 = np.array(face2["axis_location"]) # Vector from loc1 to loc2 vec = loc2 - loc1 # Distance from loc2 to axis1 (perpendicular distance) distance = np.linalg.norm(vec - np.dot(vec, dir1) * dir1) return distance < tolerance def merge_hole_group(hole_group, id): """ Merge multiple faces belonging to the same hole into one hole feature, and record all involved faces for downstream mapping. """ import numpy as np representative = hole_group[0] total_area = sum(face["area"] for face in hole_group) centers = np.array([face["center"] for face in hole_group]) areas = np.array([face["area"] for face in hole_group]) avg_center = np.average(centers, axis=0, weights=areas) # Clean up floating point errors avg_center = np.round(avg_center, decimals=6) faces_info = [ { "face_id": face.get("face_id"), "center": [round(x, 6) for x in face["center"]], "area": round(face["area"], 6), "angle_span": round(face.get("angle_span", 0.0), 4), "normal": [round(x, 6) for x in face.get("normal", [0,0,0])] } for face in hole_group ] return { "name": f"CIRC_HOLE_{id}", "type": "circular_hole", "radius": round(representative["radius"], 6), "center": avg_center.tolist(), "normal": representative["axis_direction"], "area": round(total_area, 6), "num_faces": len(hole_group), "faces": faces_info } def detect_non_circular_holes(shape, min_area=1.0, max_area=1000.0, distance_threshold=10.0): """ Detect non-circular holes (rectangular holes, slots, elongated holes). Strategy: Find groups of planar faces that form a closed pocket. """ result = [] planar_faces = [] # 1. Collect all planar faces that might be part of a hole for fdict in detect_faces(shape): face = fdict["face"] surf = fdict["surface"] surface_type = surf.DynamicType().Name() if "Plane" not in surface_type: continue # Check if this plane is a hole (concave) if not is_hole(face, shape): continue # Get face properties adaptor = BRepAdaptor_Surface(face, True) # Confirm it's a plane type if adaptor.GetType() != GeomAbs_Plane: continue plane = adaptor.Plane() normal = plane.Axis().Direction() gprops = GProp_GProps() BRepGProp.SurfaceProperties_s(face, gprops) area = gprops.Mass() center = gprops.CentreOfMass() # Filter by area if not (min_area <= area <= max_area): continue planar_faces.append({ "face": face, "center": [center.X(), center.Y(), center.Z()], "normal": [normal.X(), normal.Y(), normal.Z()], "area": float(area) }) # 2. Group adjacent planar faces that might form a hole holes = group_planar_holes(planar_faces, distance_threshold) # 3. Classify hole type (rectangular, slot, etc.) for hole_group in holes: hole_type = classify_planar_hole(hole_group) result.append(hole_type) print(f"Detected {len(result)} non-circular holes (from {len(planar_faces)} planar faces)") return result def group_planar_holes(faces, distance_threshold=10.0): """ Group planar faces that are close to each other and might form a hole. """ if not faces: return [] groups = [] used = [False] * len(faces) for i, face1 in enumerate(faces): if used[i]: continue # Start a new group group = [face1] used[i] = True # Find nearby faces with similar normal direction for j, face2 in enumerate(faces): if used[j] or i == j: continue # Check if normals are similar (parallel faces) normal1 = np.array(face1["normal"]) normal2 = np.array(face2["normal"]) # Normalize normal1 = normal1 / np.linalg.norm(normal1) normal2 = normal2 / np.linalg.norm(normal2) dot = abs(np.dot(normal1, normal2)) if dot > 0.9: # Nearly parallel # Check distance between centers center1 = np.array(face1["center"]) center2 = np.array(face2["center"]) distance = np.linalg.norm(center2 - center1) if distance < distance_threshold: group.append(face2) used[j] = True groups.append(group) return groups def classify_planar_hole(face_group): """ Classify a group of planar faces into hole types. Simple classification based on number of faces and geometry. """ num_faces = len(face_group) # Calculate total area and average center total_area = sum(f["area"] for f in face_group) centers = np.array([f["center"] for f in face_group]) avg_center = np.mean(centers, axis=0) # Use the first face's normal as representative representative_normal = face_group[0]["normal"] # Simple classification based on number of faces if num_faces == 1: hole_type = "simple_pocket" elif num_faces == 4: hole_type = "rectangular_hole" elif num_faces == 2: hole_type = "slot" elif num_faces >= 5: hole_type = "complex_pocket" else: hole_type = "irregular_hole" return { "type": hole_type, "center": np.round(avg_center, 6).tolist(), "normal": representative_normal, "area": round(total_area, 6), "num_faces": num_faces } def detect_fillets(shape, min_radius=0.1, max_radius=50.0, classify_type=True, hole_merge_tolerance=1e-3, fillet_merge_tolerance=1e-3, min_total_angle_deg=3.0, max_total_angle_deg=330.0): hole_features = detect_circular_holes( shape, min_radius=min_radius, max_radius=max_radius, merge_tolerance=hole_merge_tolerance ) hole_cylinders = [] for h in hole_features: hole_cylinders.append({ "radius": h["radius"], "axis_direction": h["normal"], "axis_location": h["center"], }) fillet_candidates = [] face_idx = 0 for fdict in detect_faces(shape): face = fdict["face"] surf = fdict["surface"] surface_type = surf.DynamicType().Name() if "Cylindrical" not in surface_type: continue adaptor = BRepAdaptor_Surface(face, True) if adaptor.GetType() != GeomAbs_Cylinder: continue cylinder = adaptor.Cylinder() radius = cylinder.Radius() if not (min_radius <= radius <= max_radius): continue axis_direction = cylinder.Axis().Direction() axis_location = cylinder.Axis().Location() axis_dir_vec = [axis_direction.X(), axis_direction.Y(), axis_direction.Z()] axis_loc_vec = [axis_location.X(), axis_location.Y(), axis_location.Z()] is_on_hole_cylinder = False for hc in hole_cylinders: if abs(hc["radius"] - radius) > hole_merge_tolerance: continue if are_coaxial( hc, {"axis_direction": axis_dir_vec, "axis_location": axis_loc_vec}, tolerance=hole_merge_tolerance, ): is_on_hole_cylinder = True break if is_on_hole_cylinder: continue gprops = GProp_GProps() BRepGProp.SurfaceProperties_s(face, gprops) area = gprops.Mass() center = gprops.CentreOfMass() if not is_fillet_surface(face, shape, radius, area, full_angle_threshold_deg=330.0, min_angle_threshold_deg=3.0): continue angle_span = compute_face_angle_span(face, axis_loc_vec, axis_dir_vec) if classify_type: if is_internal_fillet(face, shape): fillet_type = "internal_fillet" else: fillet_type = "external_fillet" else: fillet_type = "fillet" fillet_candidates.append({ "face_id": face_idx, "face": face, "name": f"FILLET_FACE_{face_idx}", "type": fillet_type, "radius": float(radius), "center": [center.X(), center.Y(), center.Z()], "axis_direction": axis_dir_vec, "axis_location": axis_loc_vec, "area": float(area), "angle_span": float(angle_span), }) face_idx += 1 merged_fillets = merge_coaxial_fillets( fillet_candidates, tolerance=fillet_merge_tolerance, min_total_angle_deg=min_total_angle_deg, max_total_angle_deg=max_total_angle_deg, ) print(f"Detected {len(merged_fillets)} fillet features (from {len(fillet_candidates)} cylindrical faces)") return merged_fillets def is_fillet_surface(face, shape, radius, area, full_angle_threshold_deg=330.0, min_angle_threshold_deg=3.0): adaptor = BRepAdaptor_Surface(face, True) if adaptor.GetType() != GeomAbs_Cylinder: return False cylinder = adaptor.Cylinder() axis_direction = cylinder.Axis().Direction() axis_location = cylinder.Axis().Location() axis_dir_vec = [axis_direction.X(), axis_direction.Y(), axis_direction.Z()] axis_loc_vec = [axis_location.X(), axis_location.Y(), axis_location.Z()] angle_span = compute_face_angle_span(face, axis_loc_vec, axis_dir_vec) if angle_span >= full_angle_threshold_deg: return False if angle_span <= min_angle_threshold_deg: return False return True def is_internal_fillet(face, shape): """ Determine if a cylindrical fillet is internal (concave) or external (convex) WITHOUT using adaptor.Normal (which does not exist). Method: - Get point P(u,v) on surface - Compute radial direction = (P - axis_location) - projection onto axis_dir - Normal = normalized radial direction - Offset P slightly along normal, test if inside solid """ adaptor = BRepAdaptor_Surface(face, True) # Must be cylinder if adaptor.GetType() != GeomAbs_Cylinder: return False cylinder = adaptor.Cylinder() # Cylinder axis axis = cylinder.Axis() axis_loc = axis.Location() axis_dir = axis.Direction() axis_dir_vec = gp_Vec(axis_dir.X(), axis_dir.Y(), axis_dir.Z()) axis_dir_vec.Normalize() # Pick midpoint parameter u_min, u_max = adaptor.FirstUParameter(), adaptor.LastUParameter() v_min, v_max = adaptor.FirstVParameter(), adaptor.LastVParameter() u_mid = 0.5 * (u_min + u_max) v_mid = 0.5 * (v_min + v_max) # Point on surface p = adaptor.Value(u_mid, v_mid) # Compute radial vector: r = (p - axis_loc) - projection onto axis_dir vec_p_axis = gp_Vec(p.X() - axis_loc.X(), p.Y() - axis_loc.Y(), p.Z() - axis_loc.Z()) # Projection length proj_len = vec_p_axis.Dot(axis_dir_vec) # Projection vector proj_vec = gp_Vec(axis_dir_vec.X() * proj_len, axis_dir_vec.Y() * proj_len, axis_dir_vec.Z() * proj_len) # Radial direction (perpendicular to axis) radial_vec = gp_Vec(vec_p_axis.X() - proj_vec.X(), vec_p_axis.Y() - proj_vec.Y(), vec_p_axis.Z() - proj_vec.Z()) radial_vec.Normalize() # Normal = radial direction normal = radial_vec # Offset point slightly along normal offset = 1e-4 offset_p = gp_Pnt(p.X() + normal.X() * offset, p.Y() + normal.Y() * offset, p.Z() + normal.Z() * offset) # Test whether it's inside classifier = BRepClass3d_SolidClassifier(shape) classifier.Perform(offset_p, 1e-7) # If offset point is inside → normal pointing inward → concave fillet return classifier.State() == TopAbs_IN def merge_coaxial_fillets(faces, tolerance=1e-3, min_total_angle_deg=3.0, max_total_angle_deg=330.0): if not faces: return [] import numpy as np merged_fillets = [] used = [False] * len(faces) for i, f1 in enumerate(faces): if used[i]: continue group = [f1] used[i] = True for j, f2 in enumerate(faces): if used[j] or i == j: continue if f1.get("type") != f2.get("type"): continue if abs(f1["radius"] - f2["radius"]) > tolerance: continue if not are_coaxial(f1, f2, tolerance=tolerance): continue group.append(f2) used[j] = True total_angle = sum(g.get("angle_span", 0.0) for g in group) total_angle = min(total_angle, 360.0) if total_angle < min_total_angle_deg: continue if total_angle > max_total_angle_deg: continue merged = merge_fillet_coaxial_group(group, len(merged_fillets), total_angle) merged_fillets.append(merged) return merged_fillets def merge_fillet_coaxial_group(fillet_group, group_index, total_angle): if not fillet_group: return {} areas = np.array([f["area"] for f in fillet_group], dtype=float) radii = np.array([f["radius"] for f in fillet_group], dtype=float) centers = np.array([f["center"] for f in fillet_group], dtype=float) axis_dirs = np.array([f["axis_direction"] for f in fillet_group], dtype=float) axis_locs = np.array([f["axis_location"] for f in fillet_group], dtype=float) total_area = float(areas.sum()) if areas.size > 0 else 0.0 if total_area > 0: avg_center = (centers * areas[:, None]).sum(axis=0) / total_area avg_radius = float((radii * areas).sum() / total_area) avg_axis_dir = (axis_dirs * areas[:, None]).sum(axis=0) / total_area avg_axis_loc = (axis_locs * areas[:, None]).sum(axis=0) / total_area else: avg_center = centers.mean(axis=0) avg_radius = float(radii.mean()) avg_axis_dir = axis_dirs.mean(axis=0) avg_axis_loc = axis_locs.mean(axis=0) norm = np.linalg.norm(avg_axis_dir) if norm > 0: avg_axis_dir = avg_axis_dir / norm avg_center = np.round(avg_center, 6) avg_axis_dir = np.round(avg_axis_dir, 6) avg_axis_loc = np.round(avg_axis_loc, 6) faces_info = [ { "face_id": f.get("face_id"), "center": [round(x, 6) for x in f["center"]], "area": round(f["area"], 6), "angle_span": round(f.get("angle_span", 0.0), 4), "normal": [round(x, 6) for x in f.get("axis_direction", avg_axis_dir.tolist())], } for f in fillet_group ] fillet_type = fillet_group[0].get("type", "fillet") return { "name": f"FILLET_{group_index}", "type": fillet_type, "radius": round(avg_radius, 6), "center": [float(x) for x in avg_center], "normal": [float(x) for x in avg_axis_dir], "axis_direction": [float(x) for x in avg_axis_dir], "axis_location": [float(x) for x in avg_axis_loc], "area": round(total_area, 6), "num_faces": len(fillet_group), "total_angle": round(float(total_angle), 4), "faces": faces_info, } # def classify_fillets_by_size(fillets): # """ # Classify fillets into categories based on radius for FEA mesh refinement. # """ # classification = { # "small": [], # R < 2mm - need very fine mesh # "medium": [], # 2mm <= R < 5mm - need fine mesh # "large": [] # R >= 5mm - standard mesh # } # for fillet in fillets: # radius = fillet["radius"] # if radius < 2.0: # classification["small"].append(fillet) # elif radius < 5.0: # classification["medium"].append(fillet) # else: # classification["large"].append(fillet) # print(f"\nFillet Classification:") # print(f" Small fillets (R<2mm): {len(classification['small'])}") # print(f" Medium fillets (2-5mm): {len(classification['medium'])}") # print(f" Large fillets (R>5mm): {len(classification['large'])}") # return classification def detect_thickness(shape, sample_density=3, min_thickness=0.01, max_thickness=100.0): from OCP.BRepClass3d import BRepClass3d_SolidClassifier from OCP.gp import gp_Pnt thickness_results = [] exp = TopExp_Explorer(shape, TopAbs_FACE) classifier = BRepClass3d_SolidClassifier(shape) while exp.More(): face_shape = exp.Current() face = TopoDS.Face_s(face_shape) adaptor = BRepAdaptor_Surface(face, True) umin, umax = adaptor.FirstUParameter(), adaptor.LastUParameter() vmin, vmax = adaptor.FirstVParameter(), adaptor.LastVParameter() for i in range(sample_density): for j in range(sample_density): u = umin + (umax - umin) * i / (sample_density - 1) v = vmin + (vmax - vmin) * j / (sample_density - 1) pnt = adaptor.Value(u, v) p = gp_Pnt() du = gp_Vec() dv = gp_Vec() adaptor.D1(u, v, p, du, dv) normal_vec = du.Crossed(dv) normal_vec.Normalize() if normal_vec.Magnitude() < 1e-8: continue normal_vec.Normalize() thickness = shoot_ray_find_thickness( shape, pnt, normal_vec, min_thickness, max_thickness ) if thickness: thickness_results.append({ "point": [pnt.X(), pnt.Y(), pnt.Z()], "normal": [normal_vec.X(), normal_vec.Y(), normal_vec.Z()], "thickness": thickness }) exp.Next() return thickness_results def shoot_ray_find_thickness(shape, point, normal, min_thickness, max_thickness): from OCP.gp import gp_Pnt, gp_Vec from OCP.BRepClass3d import BRepClass3d_SolidClassifier step = min_thickness / 2 max_dist = max_thickness for dist in np.arange(step, max_dist, step): test_point = gp_Pnt( point.X() + normal.X() * dist, point.Y() + normal.Y() * dist, point.Z() + normal.Z() * dist, ) classifier = BRepClass3d_SolidClassifier(shape) classifier.Perform(test_point, 1e-7) if classifier.State() != TopAbs_IN: pos_thick = dist break else: pos_thick = max_dist for dist in np.arange(step, max_dist, step): test_point = gp_Pnt( point.X() - normal.X() * dist, point.Y() - normal.Y() * dist, point.Z() - normal.Z() * dist, ) classifier = BRepClass3d_SolidClassifier(shape) classifier.Perform(test_point, 1e-7) if classifier.State() != TopAbs_IN: neg_thick = dist break else: neg_thick = max_dist return pos_thick + neg_thick def mode_thickness(shape, decimal=2, threshold=0.1): thickness_results = detect_thickness(shape, sample_density=3, min_thickness=0.01, max_thickness=100.0) vals = [round(float(r['thickness']), decimal) for r in thickness_results if float(r['thickness']) >= threshold] counter = Counter(vals) mode_val, count = counter.most_common(1)[0] return mode_val def detect_all_edges(shape): """ Detect all UNIQUE edges shared by two faces. Stores: name, start point, end point, angle, normals, type. """ # 1. Collect all faces faces = [] face_exp = TopExp_Explorer(shape, TopAbs_FACE) while face_exp.More(): faces.append(TopoDS.Face_s(face_exp.Current())) face_exp.Next() results = [] seen = set() # edge deduplication via TShape() idx = 0 # MUST initialize once # 2. Iterate edges edge_exp = TopExp_Explorer(shape, TopAbs_EDGE) while edge_exp.More(): edge = TopoDS.Edge_s(edge_exp.Current()) # --- dedup by underlying TShape --- tid = hash(edge.TShape()) if tid in seen: edge_exp.Next() continue seen.add(tid) # --- find adjacent faces --- adjacent_faces = [] for face in faces: ex2 = TopExp_Explorer(face, TopAbs_EDGE) while ex2.More(): if ex2.Current().IsSame(edge): adjacent_faces.append(face) break ex2.Next() if len(adjacent_faces) != 2: edge_exp.Next() continue # --- compute normals & dihedral angle --- normals = [] for face in adjacent_faces: adaptor = BRepAdaptor_Surface(face, True) u = 0.5 * (adaptor.FirstUParameter() + adaptor.LastUParameter()) v = 0.5 * (adaptor.FirstVParameter() + adaptor.LastVParameter()) du = gp_Vec() dv = gp_Vec() temp_p = gp_Pnt() adaptor.D1(u, v, temp_p, du, dv) n = du.Crossed(dv) if n.Magnitude() > 1e-6: n.Normalize() normals.append(n) else: normals.append(None) if None in normals: edge_exp.Next() continue # angle import numpy as np dot = np.clip(normals[0].Dot(normals[1]), -1.0, 1.0) angle_deg = float(np.degrees(np.arccos(dot))) # skip perfectly smooth edges if abs(angle_deg - 180.0) < 1e-3: edge_exp.Next() continue # --- get start / end points --- verts = [] vexp = TopExp_Explorer(edge, TopAbs_VERTEX) while vexp.More(): v = TopoDS.Vertex_s(vexp.Current()) p = BRep_Tool.Pnt_s(v) verts.append([p.X(), p.Y(), p.Z()]) vexp.Next() if len(verts) < 2: edge_exp.Next() continue start = verts[0] end = verts[-1] # --- classify edge type --- curve_adaptor = BRepAdaptor_Curve(edge) from OCP.GeomAbs import GeomAbs_Line, GeomAbs_Circle t = curve_adaptor.GetType() if t == GeomAbs_Line: e_type = "line" elif t == GeomAbs_Circle: e_type = "circle" else: e_type = "other" # --- store result --- results.append({ "name": f"EDGE_{idx}", "start": start, "end": end, "angle": angle_deg, "normals": [ [normals[0].X(), normals[0].Y(), normals[0].Z()], [normals[1].X(), normals[1].Y(), normals[1].Z()] ], "edge_type": e_type }) idx += 1 # safe! edge_exp.Next() return results def detect_all_faces(shape): faces = [] face_exp = TopExp_Explorer(shape, TopAbs_FACE) face_id = 0 while face_exp.More(): face_shape = face_exp.Current() face = TopoDS.Face_s(face_shape) adaptor = BRepAdaptor_Surface(face, True) # sample param umin, umax = adaptor.FirstUParameter(), adaptor.LastUParameter() vmin, vmax = adaptor.FirstVParameter(), adaptor.LastVParameter() u = (umin + umax) * 0.5 v = (vmin + vmax) * 0.5 # center # normal du = gp_Vec() dv = gp_Vec() temp_p = gp_Pnt() adaptor.D1(u, v, temp_p, du, dv) normal = du.Crossed(dv) if normal.Magnitude() > 1e-8: normal.Normalize() # --- NEW: compute area --- gprops = GProp_GProps() BRepGProp.SurfaceProperties_s(face, gprops) area = gprops.Mass() center = face_bbox_center(face) faces.append({ "name": f"FACE_{face_id}", "face_id": face_id, "center": center, "normal": [normal.X(), normal.Y(), normal.Z()], "area": float(area) # ← added }) face_id += 1 face_exp.Next() return faces