mesh-refinement-agent / mesh_service /step_feature_detection.py
ecopus's picture
Upload 4 files
b8f6abf verified
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