Files
pythonocc-step-editor/step_model.py
T

1472 lines
58 KiB
Python
Raw Normal View History

2026-07-23 18:35:02 +08:00
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable
from OCC.Core.BRep import BRep_Tool
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCC.Core.BRepCheck import BRepCheck_Analyzer
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
from OCC.Core.BRepGProp import brepgprop
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.GeomAbs import (
GeomAbs_BSplineCurve,
GeomAbs_BSplineSurface,
GeomAbs_BezierCurve,
GeomAbs_BezierSurface,
GeomAbs_Circle,
GeomAbs_Cone,
GeomAbs_Cylinder,
GeomAbs_Ellipse,
GeomAbs_Hyperbola,
GeomAbs_Line,
GeomAbs_OffsetSurface,
GeomAbs_OtherCurve,
GeomAbs_OtherSurface,
GeomAbs_Parabola,
GeomAbs_Plane,
GeomAbs_Sphere,
GeomAbs_SurfaceOfExtrusion,
GeomAbs_SurfaceOfRevolution,
GeomAbs_Torus,
)
from OCC.Core.GProp import GProp_GProps
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.Interface import Interface_Static
from OCC.Core.STEPCAFControl import STEPCAFControl_Reader
from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Reader, STEPControl_Writer
from OCC.Core.TDF import TDF_Label, TDF_LabelSequence
from OCC.Core.TDocStd import TDocStd_Document
from OCC.Core.TopAbs import (
TopAbs_EDGE,
TopAbs_EXTERNAL,
TopAbs_FACE,
TopAbs_FORWARD,
TopAbs_IN,
TopAbs_INTERNAL,
TopAbs_OUT,
TopAbs_REVERSED,
TopAbs_SOLID,
)
from OCC.Core.TopExp import TopExp_Explorer
from OCC.Core.TopLoc import TopLoc_Location
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool
from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Vec
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
SURFACE_TYPES = {
GeomAbs_Plane: "plane",
GeomAbs_Cylinder: "cylinder",
GeomAbs_Cone: "cone",
GeomAbs_Sphere: "sphere",
GeomAbs_Torus: "torus",
GeomAbs_BezierSurface: "bezier surface",
GeomAbs_BSplineSurface: "b-spline surface",
GeomAbs_SurfaceOfRevolution: "surface of revolution",
GeomAbs_SurfaceOfExtrusion: "surface of extrusion",
GeomAbs_OffsetSurface: "offset surface",
GeomAbs_OtherSurface: "other surface",
}
CURVE_TYPES = {
GeomAbs_Line: "line",
GeomAbs_Circle: "circle",
GeomAbs_Ellipse: "ellipse",
GeomAbs_Hyperbola: "hyperbola",
GeomAbs_Parabola: "parabola",
GeomAbs_BezierCurve: "bezier curve",
GeomAbs_BSplineCurve: "b-spline curve",
GeomAbs_OtherCurve: "other curve",
}
ORIENTATION_TYPES = {
TopAbs_FORWARD: "forward",
TopAbs_REVERSED: "reversed",
TopAbs_INTERNAL: "internal",
TopAbs_EXTERNAL: "external",
}
@dataclass
class PartNode:
id: int
name: str
kind: str
shape: TopoDS_Shape
parent_id: int | None = None
depth: int = 0
path: str = ""
@dataclass
class TopologyStats:
parts: int
solids: int
faces: int
edges: int
vertices: int
class StepModel:
def __init__(self, filename: Path, parts: list[PartNode], shape: TopoDS_Shape):
self.filename = filename
self.parts = parts
self.shape = shape
self.faces: list[TopoDS_Shape] = []
self.face_part_ids: list[int] = []
self.face_solid_ids: list[int] = []
self.edges: list[TopoDS_Shape] = []
self.edge_part_ids: list[int] = []
self.solids: list[tuple[int, TopoDS_Shape]] = []
self.refresh_topology()
@classmethod
def load(cls, filename: str | Path) -> "StepModel":
path = Path(filename)
if not path.exists():
raise FileNotFoundError(path)
product_names = _parse_product_names(path)
parts, whole_shape = _load_with_xcaf(path, product_names)
if not parts or whole_shape.IsNull():
whole_shape = _load_plain_step(path)
fallback_name = product_names[0] if product_names else path.stem
parts = [PartNode(1, fallback_name, "part", whole_shape, path=fallback_name)]
return cls(path, parts, whole_shape)
def display_parts(self) -> list[PartNode]:
leaf_parts = [p for p in self.parts if p.kind == "part" and not p.shape.IsNull()]
if leaf_parts:
return leaf_parts
return [p for p in self.parts if not p.shape.IsNull()]
def stats(self) -> TopologyStats:
topo = TopologyExplorer(self.shape, ignore_orientation=True)
return TopologyStats(
parts=len(self.display_parts()),
solids=len(list(topo.solids())),
faces=len(list(topo.faces())),
edges=len(list(topo.edges())),
vertices=len(list(topo.vertices())),
)
def geometry_stats(self) -> dict[str, object]:
surface_props = GProp_GProps()
brepgprop.SurfaceProperties(self.shape, surface_props)
info: dict[str, object] = {
"surface_area": surface_props.Mass(),
"surface_center": _point_tuple(surface_props.CentreOfMass()),
}
info.update(_shape_bounds_info(self.shape))
info.update(_shape_volume_info(self.shape))
return info
def refresh_topology(self) -> None:
self.shape = _compound_from_shapes([p.shape for p in self.display_parts()])
self.faces.clear()
self.face_part_ids.clear()
self.face_solid_ids.clear()
self.edges.clear()
self.edge_part_ids.clear()
self.solids.clear()
solid_id = 0
for part in self.display_parts():
part_solids = _explore(part.shape, TopAbs_SOLID)
if part_solids:
for solid in part_solids:
self.solids.append((part.id, solid))
for face in _explore(solid, TopAbs_FACE):
self.faces.append(face)
self.face_part_ids.append(part.id)
self.face_solid_ids.append(solid_id)
solid_id += 1
else:
for face in _explore(part.shape, TopAbs_FACE):
self.faces.append(face)
self.face_part_ids.append(part.id)
self.face_solid_ids.append(-1)
for edge in TopologyExplorer(part.shape, ignore_orientation=True).edges():
self.edges.append(edge)
self.edge_part_ids.append(part.id)
def part_by_id(self, part_id: int) -> PartNode | None:
return next((p for p in self.parts if p.id == part_id), None)
def snapshot(self) -> dict[int, TopoDS_Shape]:
return {part.id: part.shape for part in self.parts}
def restore_snapshot(self, snapshot: dict[int, TopoDS_Shape]) -> None:
for part in self.parts:
if part.id in snapshot:
part.shape = snapshot[part.id]
self.refresh_topology()
def face_info(self, face_id: int) -> dict[str, object]:
face = self.faces[face_id]
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
surf = BRepAdaptor_Surface(face)
surface_type = surf.GetType()
boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges()))
info: dict[str, object] = {
"kind": "face",
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"orientation": _orientation_name(face.Orientation()),
"surface": SURFACE_TYPES.get(surface_type, f"type {surface_type}"),
"area": props.Mass(),
"area_center": _point_tuple(props.CentreOfMass()),
"u_range": (surf.FirstUParameter(), surf.LastUParameter()),
"v_range": (surf.FirstVParameter(), surf.LastVParameter()),
"boundary_edges": boundary_edges,
}
info.update(_shape_bounds_info(face))
if surface_type == GeomAbs_Plane:
plane = surf.Plane()
direction = plane.Axis().Direction()
push_pull_direction = self._plane_push_pull_direction(face_id, surf)
info["plane_origin"] = _point_tuple(plane.Location())
info["normal"] = _dir_tuple(direction)
info["oriented_normal"] = _oriented_dir_tuple(direction, face)
info["push_pull_outward_direction"] = push_pull_direction["outward_direction"]
info["push_pull_inward_direction"] = push_pull_direction["inward_direction"]
info["push_pull_plus_side"] = push_pull_direction["plus_side_state"]
info["push_pull_minus_side"] = push_pull_direction["minus_side_state"]
info["push_pull_confidence"] = push_pull_direction["confidence"]
info["push_pull_note"] = push_pull_direction["note"]
elif surface_type == GeomAbs_Cylinder:
cyl = surf.Cylinder()
axis = cyl.Axis()
radius = cyl.Radius()
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
classification = self._classify_cylindrical_face(face_id, surf, detailed=True)
info["radius"] = cyl.Radius()
info["diameter"] = cyl.Radius() * 2.0
info["axis_point"] = _point_tuple(axis.Location())
info["axis"] = _dir_tuple(axis.Direction())
info["angular_span"] = u_span
info["is_full_cylinder"] = u_span >= math.tau * 0.98
info["height_estimate"] = props.Mass() / swept_area
info["feature_guess"] = classification["feature_guess"]
info["confidence"] = classification["confidence"]
info["material_toward_axis"] = classification["toward_axis"]
info["material_away_axis"] = classification["away_axis"]
info["material_vote_summary"] = classification["vote_summary"]
info["material_sample_count"] = classification["sample_count"]
info["note"] = classification["note"]
info.update(self._cylinder_end_opening_info(face_id, surf))
info.update(_cylinder_resize_readiness(info))
elif surface_type == GeomAbs_Cone:
cone = surf.Cone()
info["axis_point"] = _point_tuple(cone.Location())
info["axis"] = _dir_tuple(cone.Axis().Direction())
info["reference_radius"] = cone.RefRadius()
info["semi_angle"] = cone.SemiAngle()
elif surface_type == GeomAbs_Sphere:
sphere = surf.Sphere()
info["center"] = _point_tuple(sphere.Location())
info["radius"] = sphere.Radius()
info["diameter"] = sphere.Radius() * 2.0
elif surface_type == GeomAbs_Torus:
torus = surf.Torus()
info["center"] = _point_tuple(torus.Location())
info["axis"] = _dir_tuple(torus.Axis().Direction())
info["major_radius"] = torus.MajorRadius()
info["minor_radius"] = torus.MinorRadius()
return info
def edge_info(self, edge_id: int) -> dict[str, object]:
edge = self.edges[edge_id]
props = GProp_GProps()
brepgprop.LinearProperties(edge, props)
curve = BRepAdaptor_Curve(edge)
curve_type = curve.GetType()
info: dict[str, object] = {
"kind": "edge",
"edge_id": edge_id,
"part_id": self.edge_part_ids[edge_id],
"orientation": _orientation_name(edge.Orientation()),
"curve": CURVE_TYPES.get(curve_type, f"type {curve_type}"),
"length": props.Mass(),
"length_center": _point_tuple(props.CentreOfMass()),
"first_parameter": curve.FirstParameter(),
"last_parameter": curve.LastParameter(),
"start_point": _point_tuple(curve.Value(curve.FirstParameter())),
"end_point": _point_tuple(curve.Value(curve.LastParameter())),
}
info.update(_shape_bounds_info(edge))
if curve_type == GeomAbs_Line:
line = curve.Line()
info["line_origin"] = _point_tuple(line.Location())
info["direction"] = _dir_tuple(line.Direction())
if curve_type == GeomAbs_Circle:
circle = curve.Circle()
info["center"] = _point_tuple(circle.Location())
info["axis"] = _dir_tuple(circle.Axis().Direction())
info["radius"] = circle.Radius()
info["diameter"] = circle.Radius() * 2.0
return info
def solid_info(self, solid_id: int) -> dict[str, object]:
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
part_id, solid = self.solids[solid_id]
topo = TopologyExplorer(solid, ignore_orientation=True)
surface_props = GProp_GProps()
brepgprop.SurfaceProperties(solid, surface_props)
info: dict[str, object] = {
"kind": "solid",
"solid_id": solid_id,
"part_id": part_id,
"faces": len(list(topo.faces())),
"edges": len(list(topo.edges())),
"vertices": len(list(topo.vertices())),
"surface_area": surface_props.Mass(),
"surface_center": _point_tuple(surface_props.CentreOfMass()),
}
info.update(_shape_bounds_info(solid))
info.update(_shape_volume_info(solid))
return info
def part_info(self, part_id: int) -> dict[str, object]:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
topo = TopologyExplorer(part.shape, ignore_orientation=True)
info: dict[str, object] = {
"kind": part.kind,
"part_id": part.id,
"name": part.name,
"path": part.path,
"parent_id": part.parent_id if part.parent_id is not None else "",
"depth": part.depth,
"solids": len(list(topo.solids())),
"faces": len(list(topo.faces())),
"edges": len(list(topo.edges())),
"vertices": len(list(topo.vertices())),
}
info.update(_shape_bounds_info(part.shape))
info.update(_shape_volume_info(part.shape))
return info
def editable_feature_candidates(
self,
limit: int = 160,
detailed: bool = False,
progress_callback: Callable[[], None] | None = None,
) -> list[dict[str, object]]:
per_type_limit = max(1, limit // 2)
candidates: list[dict[str, object]] = []
for item in self.cylindrical_feature_candidates(
limit=per_type_limit,
include_end_info=False,
progress_callback=progress_callback,
):
candidates.append(
{
"operation_key": "resize_cylinder",
"operation": "调整圆柱孔径",
"face_id": item["face_id"],
"part_id": item["part_id"],
"solid_id": item["solid_id"],
"surface": "cylinder",
"feature_guess": item["feature_guess"],
"current_value": item["diameter"],
"current_value_label": "diameter",
"status": item["resize_status"],
"risk": item["resize_risk"],
"confidence": item["confidence"],
"note": item["resize_note"],
}
)
plane_count = 0
for face_id, face in enumerate(self.faces):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
if plane_count >= per_type_limit:
break
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
continue
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
if detailed:
direction_info = self._plane_push_pull_direction(face_id, surf)
confidence = str(direction_info["confidence"])
risk = "low" if confidence == "high" else "medium"
status = "ready" if confidence == "high" else "caution"
note = str(direction_info["note"])
else:
confidence = "pending"
risk = "medium"
status = "caution"
note = "快速扫描:推拉方向会在选中 face 或执行编辑前再详细判断。"
candidates.append(
{
"operation_key": "push_pull_plane",
"operation": "推拉平面",
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"surface": "plane",
"feature_guess": "planar push/pull candidate",
"current_value": props.Mass(),
"current_value_label": "area",
"status": status,
"risk": risk,
"confidence": confidence,
"note": note,
}
)
plane_count += 1
status_order = {"ready": 0, "caution": 1, "blocked": 2}
risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
operation_order = {"resize_cylinder": 0, "push_pull_plane": 1}
candidates.sort(
key=lambda item: (
status_order.get(str(item["status"]), 9),
risk_order.get(str(item["risk"]), 9),
operation_order.get(str(item["operation_key"]), 9),
int(item["face_id"]),
)
)
return candidates[:limit]
def cylindrical_feature_candidates(
self,
limit: int = 100,
include_end_info: bool = False,
progress_callback: Callable[[], None] | None = None,
) -> list[dict[str, object]]:
candidates: list[dict[str, object]] = []
for face_id, face in enumerate(self.faces):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
continue
cyl = surf.Cylinder()
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
radius = cyl.Radius()
u_span = abs(surf.LastUParameter() - surf.FirstUParameter())
v_span = abs(surf.LastVParameter() - surf.FirstVParameter())
swept_area = max(radius * max(u_span, 1e-9), 1e-9)
height_estimate = props.Mass() / swept_area
boundary_edges = len(list(TopologyExplorer(face, ignore_orientation=True).edges()))
classification = self._classify_cylindrical_face(face_id, surf)
candidate = {
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"radius": radius,
"diameter": radius * 2.0,
"axis": _dir_tuple(cyl.Axis().Direction()),
"area": props.Mass(),
"angular_span": u_span,
"height_estimate": height_estimate,
"param_height": v_span,
"boundary_edges": boundary_edges,
"feature_guess": classification["feature_guess"],
"material_toward_axis": classification["toward_axis"],
"material_away_axis": classification["away_axis"],
"material_vote_summary": classification["vote_summary"],
"material_sample_count": classification["sample_count"],
"confidence": classification["confidence"],
"note": classification["note"],
}
if include_end_info:
candidate.update(self._cylinder_end_opening_info(face_id, surf))
candidate.update(_cylinder_resize_readiness(candidate))
candidates.append(candidate)
if len(candidates) >= limit:
break
return candidates
def cylindrical_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
if info.get("surface") != "cylinder" or "diameter" not in info:
return {
"status": "blocked",
"risk": "blocked",
"message": "当前选中的 face 不是圆柱面,不能执行圆柱切削。",
}
current_diameter = float(info["diameter"])
readiness = _cylinder_resize_readiness(info, new_diameter)
resize_mode = _resize_mode(current_diameter, new_diameter)
cutter_plan = self._bounded_cylinder_cutter_plan(face_id, new_diameter)
fill_plan = self._bounded_cylinder_fill_plan(face_id) if resize_mode == "shrink" else {}
return {
"status": readiness["resize_status"],
"risk": readiness["resize_risk"],
"message": readiness["resize_note"],
"warnings": readiness["resize_warnings"],
"blockers": readiness["resize_blockers"],
"face_id": face_id,
"part_id": info["part_id"],
"solid_id": info["solid_id"],
"current_diameter": current_diameter,
"target_diameter": new_diameter,
"delta_diameter": new_diameter - current_diameter,
"resize_mode": resize_mode,
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"angular_span": info.get("angular_span"),
"height_estimate": info.get("height_estimate"),
"material_vote_summary": info.get("material_vote_summary"),
"material_sample_count": info.get("material_sample_count"),
**cutter_plan,
**fill_plan,
}
def _bounded_cylinder_cutter_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
return {
"cutter_strategy": "unavailable",
"cutter_note": "selected face is not cylindrical",
}
cyl = surf.Cylinder()
old_radius = cyl.Radius()
new_radius = new_diameter / 2.0
v1 = surf.FirstVParameter()
v2 = surf.LastVParameter()
v_min = min(v1, v2)
v_max = max(v1, v2)
span = max(v_max - v_min, 0.0)
end_info = self._cylinder_end_opening_info(face_id, surf)
base_margin = min(max(new_radius * 0.05, abs(new_radius - old_radius) * 0.5, 0.02), max(span * 0.05, 0.2))
closed_margin = min(base_margin, max(span * 0.005, 0.02))
start_margin = base_margin if end_info["start_end_open"] else closed_margin
end_margin = base_margin if end_info["end_end_open"] else closed_margin
start_parameter = v_min - start_margin
end_parameter = v_max + end_margin
height = max(end_parameter - start_parameter, 1e-6)
axis = cyl.Axis()
direction = axis.Direction()
axis_point = axis.Location()
start = gp_Pnt(
axis_point.X() + direction.X() * start_parameter,
axis_point.Y() + direction.Y() * start_parameter,
axis_point.Z() + direction.Z() * start_parameter,
)
return {
"cutter_strategy": "bounded-to-selected-cylinder-v-range",
"cutter_note": "有限长度切削:按选中圆柱面的 V 参数范围生成 cutter,减少贯穿整个零件的误切风险。",
"cutter_start_parameter": start_parameter,
"cutter_end_parameter": end_parameter,
"cutter_height": height,
"cutter_margin": base_margin,
"cutter_start_margin": start_margin,
"cutter_end_margin": end_margin,
"cutter_radius": new_radius,
"cutter_axis_point": _point_tuple(axis_point),
"cutter_axis_direction": _dir_tuple(direction),
"cutter_start_point": _point_tuple(start),
**end_info,
}
def _bounded_cylinder_fill_plan(self, face_id: int) -> dict[str, object]:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
return {
"fill_strategy": "unavailable",
"fill_note": "selected face is not cylindrical",
}
cyl = surf.Cylinder()
radius = cyl.Radius()
v1 = surf.FirstVParameter()
v2 = surf.LastVParameter()
v_min = min(v1, v2)
v_max = max(v1, v2)
height = max(v_max - v_min, 1e-6)
overlap = min(max(radius * 0.001, 0.001), 0.05)
axis = cyl.Axis()
direction = axis.Direction()
axis_point = axis.Location()
start = _point_on_axis(axis_point, direction, v_min)
return {
"fill_strategy": "bounded-fill-then-recut",
"fill_note": "缩小孔径实验策略:先在原圆柱面范围内补料,再按目标直径重切。补料不向开口端外伸。",
"fill_start_parameter": v_min,
"fill_end_parameter": v_max,
"fill_height": height,
"fill_radius": radius + overlap,
"fill_radius_overlap": overlap,
"fill_start_point": _point_tuple(start),
}
def _cylinder_end_opening_info(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]:
solid_id = self.face_solid_ids[face_id]
fallback = {
"cylinder_end_type": "unknown",
"hole_depth_estimate": abs(surf.LastVParameter() - surf.FirstVParameter()),
"start_end_state": "unknown",
"end_end_state": "unknown",
"start_end_open": False,
"end_end_open": False,
"open_end_count": 0,
"closed_end_count": 0,
"end_sample_offset": "",
"end_sample_note": "no owning solid was found",
}
if solid_id < 0 or solid_id >= len(self.solids):
return fallback
solid = self.solids[solid_id][1]
cyl = surf.Cylinder()
axis = cyl.Axis()
axis_point = axis.Location()
direction = axis.Direction()
v1 = surf.FirstVParameter()
v2 = surf.LastVParameter()
v_min = min(v1, v2)
v_max = max(v1, v2)
span = max(v_max - v_min, 0.0)
radius = cyl.Radius()
offset = min(max(radius * 0.08, span * 0.02, 0.05), max(span * 0.25, 0.2))
start_probe = _point_on_axis(axis_point, direction, v_min - offset)
end_probe = _point_on_axis(axis_point, direction, v_max + offset)
start_state = _solid_state(solid, start_probe)
end_state = _solid_state(solid, end_probe)
start_open = start_state == "outside"
end_open = end_state == "outside"
start_closed = start_state == "inside"
end_closed = end_state == "inside"
open_count = int(start_open) + int(end_open)
closed_count = int(start_closed) + int(end_closed)
if open_count == 2:
end_type = "through/open-ended"
note = "both axis-end probes are outside material"
elif open_count == 1 and closed_count == 1:
end_type = "blind"
note = "one axis-end probe is outside material and the other is inside material"
elif closed_count == 2:
end_type = "closed/internal"
note = "both axis-end probes are inside material"
else:
end_type = "unclear"
note = "axis-end probes did not produce a clear open/closed pattern"
return {
"cylinder_end_type": end_type,
"hole_depth_estimate": span,
"start_end_state": start_state,
"end_end_state": end_state,
"start_end_open": start_open,
"end_end_open": end_open,
"open_end_count": open_count,
"closed_end_count": closed_count,
"end_sample_offset": offset,
"end_sample_note": note,
}
def _classify_cylindrical_face(
self,
face_id: int,
surf: BRepAdaptor_Surface,
detailed: bool = False,
) -> dict[str, object]:
solid_id = self.face_solid_ids[face_id]
if solid_id < 0 or solid_id >= len(self.solids):
return {
"feature_guess": "cylindrical face",
"toward_axis": "unknown",
"away_axis": "unknown",
"vote_summary": "hole=0, boss=0, unclear=0",
"sample_count": 0,
"confidence": "low",
"note": "no owning solid was found",
}
solid = self.solids[solid_id][1]
radius = surf.Cylinder().Radius()
angular_span = abs(surf.LastUParameter() - surf.FirstUParameter())
boundary_edges = len(list(TopologyExplorer(self.faces[face_id], ignore_orientation=True).edges()))
solid_diagonal = _shape_diagonal(solid)
is_partial_cylinder = angular_span < math.tau * 0.92
is_small_radius = solid_diagonal > 0 and radius <= solid_diagonal * 0.04
samples = self._sample_cylinder_material_states(surf, solid, detailed=detailed)
sample_count = len(samples)
if sample_count == 0:
return {
"feature_guess": "cylindrical face",
"toward_axis": "unknown",
"away_axis": "unknown",
"vote_summary": "hole=0, boss=0, unclear=0",
"sample_count": 0,
"confidence": "low",
"note": "could not sample cylinder material sides",
}
toward_states = [sample["toward"] for sample in samples]
away_states = [sample["away"] for sample in samples]
hole_votes = sum(1 for sample in samples if sample["toward"] == "outside" and sample["away"] == "inside")
boss_votes = sum(1 for sample in samples if sample["toward"] == "inside" and sample["away"] == "outside")
unclear_votes = sample_count - hole_votes - boss_votes
vote_summary = f"hole={hole_votes}, boss={boss_votes}, unclear={unclear_votes}"
threshold = max(1, math.ceil(sample_count * 0.6))
base = {
"toward_axis": _state_summary(toward_states),
"away_axis": _state_summary(away_states),
"vote_summary": vote_summary,
"sample_count": sample_count,
}
if hole_votes >= threshold:
confidence = "high" if hole_votes == sample_count and not is_partial_cylinder else "medium"
return {
"feature_guess": "hole/groove candidate",
"confidence": confidence,
"note": "axis side is mostly empty and outer side is mostly material",
**base,
}
if is_partial_cylinder and is_small_radius:
return {
"feature_guess": "round/fillet candidate",
"confidence": "medium" if boundary_edges >= 4 else "low",
"note": "partial small-radius cylinder; may be a fillet or blend",
**base,
}
if boss_votes >= threshold:
return {
"feature_guess": "boss/outer-round candidate",
"confidence": "high" if boss_votes == sample_count and not is_partial_cylinder else "medium",
"note": "axis side is mostly material and outer side is mostly empty",
**base,
}
return {
"feature_guess": "cylindrical face",
"confidence": "low",
"note": "material sampling did not produce a clear inside/outside pattern",
**base,
}
def _sample_cylinder_material_states(
self,
surf: BRepAdaptor_Surface,
solid: TopoDS_Shape,
detailed: bool = False,
) -> list[dict[str, str]]:
cyl = surf.Cylinder()
axis = cyl.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
radius = cyl.Radius()
u_first = surf.FirstUParameter()
u_last = surf.LastUParameter()
v = (surf.FirstVParameter() + surf.LastVParameter()) / 2.0
u_span = u_last - u_first
fractions = [0.5]
if detailed and abs(u_span) > 0.2:
fractions = [0.25, 0.5, 0.75]
samples: list[dict[str, str]] = []
for fraction in fractions:
u = u_first + u_span * fraction
point = surf.Value(u, v)
axis_to_point = _vec_from_points(axis_point, point)
projection = _dot(axis_to_point, axis_dir)
center = gp_Pnt(
axis_point.X() + axis_dir.X() * projection,
axis_point.Y() + axis_dir.Y() * projection,
axis_point.Z() + axis_dir.Z() * projection,
)
radial = _vec_from_points(center, point)
radial_len = radial.Magnitude()
if radial_len <= 1e-9:
continue
unit = gp_Vec(radial.X() / radial_len, radial.Y() / radial_len, radial.Z() / radial_len)
epsilon = min(max(radius * 0.03, 0.05), 1.0)
toward_point = gp_Pnt(
point.X() - unit.X() * epsilon,
point.Y() - unit.Y() * epsilon,
point.Z() - unit.Z() * epsilon,
)
away_point = gp_Pnt(
point.X() + unit.X() * epsilon,
point.Y() + unit.Y() * epsilon,
point.Z() + unit.Z() * epsilon,
)
samples.append(
{
"toward": _solid_state(solid, toward_point),
"away": _solid_state(solid, away_point),
}
)
return samples
def _plane_push_pull_direction(self, face_id: int, surf: BRepAdaptor_Surface) -> dict[str, object]:
face = self.faces[face_id]
direction = surf.Plane().Axis().Direction()
axis_tuple = _dir_tuple(direction)
oriented_tuple = _oriented_dir_tuple(direction, face)
fallback = {
"outward_direction": oriented_tuple,
"inward_direction": _neg_tuple(oriented_tuple),
"plus_side_state": "unknown",
"minus_side_state": "unknown",
"confidence": "low",
"note": "falling back to topology-oriented plane normal",
}
solid_id = self.face_solid_ids[face_id]
if solid_id < 0 or solid_id >= len(self.solids):
fallback["note"] = "no owning solid was found; using topology-oriented plane normal"
return fallback
solid = self.solids[solid_id][1]
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
sample = props.CentreOfMass()
diagonal = _shape_diagonal(solid)
epsilon = min(max(diagonal * 1e-4, 0.05), 1.0)
plus_point = gp_Pnt(
sample.X() + direction.X() * epsilon,
sample.Y() + direction.Y() * epsilon,
sample.Z() + direction.Z() * epsilon,
)
minus_point = gp_Pnt(
sample.X() - direction.X() * epsilon,
sample.Y() - direction.Y() * epsilon,
sample.Z() - direction.Z() * epsilon,
)
plus_state = _solid_state(solid, plus_point)
minus_state = _solid_state(solid, minus_point)
if plus_state == "outside" and minus_state == "inside":
return {
"outward_direction": axis_tuple,
"inward_direction": _neg_tuple(axis_tuple),
"plus_side_state": plus_state,
"minus_side_state": minus_state,
"confidence": "high",
"note": "positive plane normal side is outside material",
}
if plus_state == "inside" and minus_state == "outside":
return {
"outward_direction": _neg_tuple(axis_tuple),
"inward_direction": axis_tuple,
"plus_side_state": plus_state,
"minus_side_state": minus_state,
"confidence": "high",
"note": "negative plane normal side is outside material",
}
fallback["plus_side_state"] = plus_state
fallback["minus_side_state"] = minus_state
fallback["note"] = "inside/outside sampling was unclear; using topology-oriented plane normal"
return fallback
def export_all(self, filename: str | Path) -> None:
_write_step(self.shape, Path(filename))
def export_part(self, part_id: int, filename: str | Path) -> None:
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
_write_step(part.shape, Path(filename))
def export_solid(self, solid_id: int, filename: str | Path) -> None:
if solid_id < 0 or solid_id >= len(self.solids):
raise ValueError(f"Unknown solid id {solid_id}")
_write_step(self.solids[solid_id][1], Path(filename))
def export_face(self, face_id: int, filename: str | Path) -> None:
if face_id < 0 or face_id >= len(self.faces):
raise ValueError(f"Unknown face id {face_id}")
_write_step(self.faces[face_id], Path(filename))
def push_pull_preview_polydata(self, face_id: int, distance: float, deflection: float = 0.8):
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
raise ValueError("Push/pull preview currently supports planar faces only.")
direction_info = self._plane_push_pull_direction(face_id, surf)
outward = direction_info["outward_direction"]
vec = gp_Vec(
float(outward[0]) * distance,
float(outward[1]) * distance,
float(outward[2]) * distance,
)
preview_shape = BRepPrimAPI_MakePrism(face, vec).Shape()
BRepMesh_IncrementalMesh(preview_shape, deflection)
return _shape_faces_polydata(preview_shape)
def push_pull_face(self, face_id: int, distance: float) -> str:
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
raise ValueError("Push/pull currently supports planar faces only.")
direction_info = self._plane_push_pull_direction(face_id, surf)
outward = direction_info["outward_direction"]
vec = gp_Vec(
float(outward[0]) * distance,
float(outward[1]) * distance,
float(outward[2]) * distance,
)
tool_shape = BRepPrimAPI_MakePrism(face, vec).Shape()
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
op = BRepAlgoAPI_Fuse(part.shape, tool_shape) if distance >= 0 else BRepAlgoAPI_Cut(part.shape, tool_shape)
op.Build()
if not op.IsDone():
raise RuntimeError("Boolean operation failed.")
result = op.Shape()
_ensure_valid_shape(result)
part.shape = result
self.refresh_topology()
action = "fused outward prism" if distance >= 0 else "cut inward prism"
return (
"Planar face push/pull completed: "
f"{action}, semantic_distance={distance:g}, "
f"outward_direction={_format_tuple(outward)}, "
f"direction_confidence={direction_info['confidence']}."
)
def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
return self.resize_cylindrical_hole(face_id, new_diameter)
def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
plan = self.cylindrical_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
face = self.faces[face_id]
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Cylinder:
raise ValueError("Hole resize currently supports cylindrical faces only.")
cyl = surf.Cylinder()
old_radius = cyl.Radius()
new_radius = new_diameter / 2.0
if new_radius <= 0:
raise ValueError("Target diameter must be greater than 0.")
part_id = self.face_part_ids[face_id]
part = self.part_by_id(part_id)
if part is None:
raise ValueError(f"Unknown part id {part_id}")
direction = cyl.Axis().Direction()
source_shape = part.shape
if plan["resize_mode"] == "shrink":
fill_start = gp_Pnt(*plan["fill_start_point"])
fill_axis = gp_Ax2(fill_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
filler = BRepPrimAPI_MakeCylinder(
fill_axis,
float(plan["fill_radius"]),
float(plan["fill_height"]),
).Shape()
fuse = BRepAlgoAPI_Fuse(part.shape, filler)
fuse.Build()
if not fuse.IsDone():
raise RuntimeError("Cylinder fill/fuse failed.")
source_shape = fuse.Shape()
_ensure_valid_shape(source_shape)
cutter_start = gp_Pnt(*plan["cutter_start_point"])
cutter_axis = gp_Ax2(cutter_start, gp_Dir(direction.X(), direction.Y(), direction.Z()))
cutter = BRepPrimAPI_MakeCylinder(cutter_axis, new_radius, float(plan["cutter_height"])).Shape()
op = BRepAlgoAPI_Cut(source_shape, cutter)
op.Build()
if not op.IsDone():
raise RuntimeError("Cylinder cut failed.")
result = op.Shape()
_ensure_valid_shape(result)
part.shape = result
self.refresh_topology()
action = "enlarged by bounded cut" if plan["resize_mode"] == "enlarge" else "shrunk by fill and recut"
return (
f"Cylindrical resize completed: diameter {old_radius * 2.0:g} -> {new_diameter:g}, "
f"mode={plan['resize_mode']}, action={action}, "
f"risk={plan['risk']}, feature={plan['feature_guess']}, "
f"cutter={plan['cutter_strategy']}, height={float(plan['cutter_height']):g}."
)
def build_face_polydata(
self,
face_ids: Iterable[int] | None = None,
part_ids: Iterable[int] | None = None,
deflection: float = 0.8,
):
import vtk
selected_faces = set(face_ids) if face_ids is not None else None
selected_parts = set(part_ids) if part_ids is not None else None
BRepMesh_IncrementalMesh(self.shape, deflection)
points = vtk.vtkPoints()
polys = vtk.vtkCellArray()
face_arr = vtk.vtkIntArray()
face_arr.SetName("face_id")
part_arr = vtk.vtkIntArray()
part_arr.SetName("part_id")
solid_arr = vtk.vtkIntArray()
solid_arr.SetName("solid_id")
for face_id, face in enumerate(self.faces):
part_id = self.face_part_ids[face_id]
if selected_faces is not None and face_id not in selected_faces:
continue
if selected_parts is not None and part_id not in selected_parts:
continue
loc = TopLoc_Location()
tri = BRep_Tool.Triangulation(topods.Face(face), loc)
if tri is None:
continue
transform = loc.Transformation()
node_offset = points.GetNumberOfPoints()
for node_index in range(1, tri.NbNodes() + 1):
pnt = tri.Node(node_index).Transformed(transform)
points.InsertNextPoint(pnt.X(), pnt.Y(), pnt.Z())
reversed_face = face.Orientation() == TopAbs_REVERSED
for tri_index in range(1, tri.NbTriangles() + 1):
n1, n2, n3 = tri.Triangle(tri_index).Get()
if reversed_face:
n2, n3 = n3, n2
vtk_tri = vtk.vtkTriangle()
vtk_tri.GetPointIds().SetId(0, node_offset + n1 - 1)
vtk_tri.GetPointIds().SetId(1, node_offset + n2 - 1)
vtk_tri.GetPointIds().SetId(2, node_offset + n3 - 1)
polys.InsertNextCell(vtk_tri)
face_arr.InsertNextValue(face_id)
part_arr.InsertNextValue(part_id)
solid_arr.InsertNextValue(self.face_solid_ids[face_id])
poly = vtk.vtkPolyData()
poly.SetPoints(points)
poly.SetPolys(polys)
poly.GetCellData().AddArray(face_arr)
poly.GetCellData().AddArray(part_arr)
poly.GetCellData().AddArray(solid_arr)
return poly
def build_snapshot_polydata(self, snapshot: dict[int, TopoDS_Shape], deflection: float = 0.8):
shape = _compound_from_shapes(snapshot.values())
BRepMesh_IncrementalMesh(shape, deflection)
return _shape_faces_polydata(shape)
def build_edge_polydata(self, deflection: float = 0.8):
import vtk
points = vtk.vtkPoints()
lines = vtk.vtkCellArray()
edge_arr = vtk.vtkIntArray()
edge_arr.SetName("edge_id")
part_arr = vtk.vtkIntArray()
part_arr.SetName("part_id")
for edge_id, edge in enumerate(self.edges):
samples = discretize_edge(edge, deflection)
if len(samples) < 2:
continue
polyline = vtk.vtkPolyLine()
polyline.GetPointIds().SetNumberOfIds(len(samples))
for i, coords in enumerate(samples):
point_id = points.InsertNextPoint(float(coords[0]), float(coords[1]), float(coords[2]))
polyline.GetPointIds().SetId(i, point_id)
lines.InsertNextCell(polyline)
edge_arr.InsertNextValue(edge_id)
part_arr.InsertNextValue(self.edge_part_ids[edge_id])
poly = vtk.vtkPolyData()
poly.SetPoints(points)
poly.SetLines(lines)
poly.GetCellData().AddArray(edge_arr)
poly.GetCellData().AddArray(part_arr)
return poly
def _load_with_xcaf(path: Path, product_names: list[str]) -> tuple[list[PartNode], TopoDS_Shape]:
doc = TDocStd_Document("pythonocc-step-document")
shape_tool = XCAFDoc_DocumentTool.ShapeTool(doc.Main())
reader = STEPCAFControl_Reader()
reader.SetColorMode(True)
reader.SetLayerMode(True)
reader.SetNameMode(True)
reader.SetMatMode(True)
reader.SetGDTMode(True)
status = reader.ReadFile(str(path))
if status != IFSelect_RetDone:
raise ValueError(f"Could not read STEP file: {path}")
if not reader.Transfer(doc):
raise ValueError(f"Could not transfer STEP document: {path}")
parts: list[PartNode] = []
free_shapes = TDF_LabelSequence()
shape_tool.GetFreeShapes(free_shapes)
def next_name(label: TDF_Label, index: int) -> str:
label_name = str(label.GetLabelName()).strip()
if label_name:
return label_name
if index - 1 < len(product_names):
return product_names[index - 1]
return f"Part {index}"
def add_node(
name: str,
kind: str,
shape: TopoDS_Shape,
parent_id: int | None,
depth: int,
path_text: str,
) -> PartNode:
node = PartNode(len(parts) + 1, name, kind, shape, parent_id, depth, path_text)
parts.append(node)
return node
def transformed_shape(label: TDF_Label, locations: list[TopLoc_Location]) -> TopoDS_Shape:
shape = shape_tool.GetShape(label)
if shape.IsNull() or not locations:
return shape
location = TopLoc_Location()
for loc in locations:
location = location.Multiplied(loc)
return BRepBuilderAPI_Transform(shape, location.Transformation()).Shape()
def walk(label: TDF_Label, parent_id: int | None, depth: int, locations: list[TopLoc_Location], path_names: list[str]):
name = next_name(label, len(parts) + 1)
label_path = " / ".join(path_names + [name])
if shape_tool.IsAssembly(label):
node = add_node(name, "assembly", transformed_shape(label, locations), parent_id, depth, label_path)
components = TDF_LabelSequence()
shape_tool.GetComponents(label, components)
for i in range(1, components.Length() + 1):
component = components.Value(i)
if shape_tool.IsReference(component):
referred = TDF_Label()
shape_tool.GetReferredShape(component, referred)
loc = shape_tool.GetLocation(component)
walk(referred, node.id, depth + 1, locations + [loc], path_names + [name])
else:
walk(component, node.id, depth + 1, locations, path_names + [name])
return
if shape_tool.IsSimpleShape(label) or shape_tool.IsShape(label):
add_node(name, "part", transformed_shape(label, locations), parent_id, depth, label_path)
for i in range(1, free_shapes.Length() + 1):
walk(free_shapes.Value(i), None, 0, [], [])
display_shapes = [p.shape for p in parts if p.kind == "part" and not p.shape.IsNull()]
if not display_shapes:
display_shapes = [p.shape for p in parts if not p.shape.IsNull()]
return parts, _compound_from_shapes(display_shapes)
def _load_plain_step(path: Path) -> TopoDS_Shape:
reader = STEPControl_Reader()
status = reader.ReadFile(str(path))
if status != IFSelect_RetDone:
raise ValueError(f"Could not read STEP file: {path}")
if not reader.TransferRoots():
raise ValueError(f"Could not transfer STEP roots: {path}")
return reader.Shape()
def _parse_product_names(path: Path) -> list[str]:
text = path.read_text(errors="ignore")
names = re.findall(r"PRODUCT\('((?:''|[^'])*)'", text)
return [name.replace("''", "'") for name in names if name.strip()]
def _write_step(shape: TopoDS_Shape, filename: Path) -> None:
if shape.IsNull():
raise ValueError("Cannot export a null shape.")
filename.parent.mkdir(parents=True, exist_ok=True)
Interface_Static.SetCVal("write.step.schema", "AP214IS")
writer = STEPControl_Writer()
writer.Transfer(shape, STEPControl_AsIs)
status = writer.Write(str(filename))
if status != IFSelect_RetDone:
raise IOError(f"Could not write STEP file: {filename}")
def _compound_from_shapes(shapes: Iterable[TopoDS_Shape]) -> TopoDS_Shape:
from OCC.Core.BRep import BRep_Builder
valid_shapes = [shape for shape in shapes if not shape.IsNull()]
if len(valid_shapes) == 1:
return valid_shapes[0]
compound = TopoDS_Compound()
builder = BRep_Builder()
builder.MakeCompound(compound)
for shape in valid_shapes:
builder.Add(compound, shape)
return compound
def _explore(shape: TopoDS_Shape, shape_type: int) -> list[TopoDS_Shape]:
items: list[TopoDS_Shape] = []
explorer = TopExp_Explorer(shape, shape_type)
while explorer.More():
current = explorer.Current()
if shape_type == TopAbs_FACE:
items.append(topods.Face(current))
elif shape_type == TopAbs_EDGE:
items.append(topods.Edge(current))
elif shape_type == TopAbs_SOLID:
items.append(topods.Solid(current))
else:
items.append(current)
explorer.Next()
return items
def _shape_faces_polydata(shape: TopoDS_Shape):
import vtk
points = vtk.vtkPoints()
polys = vtk.vtkCellArray()
for face in _explore(shape, TopAbs_FACE):
loc = TopLoc_Location()
tri = BRep_Tool.Triangulation(topods.Face(face), loc)
if tri is None:
continue
transform = loc.Transformation()
node_offset = points.GetNumberOfPoints()
for node_index in range(1, tri.NbNodes() + 1):
pnt = tri.Node(node_index).Transformed(transform)
points.InsertNextPoint(pnt.X(), pnt.Y(), pnt.Z())
reversed_face = face.Orientation() == TopAbs_REVERSED
for tri_index in range(1, tri.NbTriangles() + 1):
n1, n2, n3 = tri.Triangle(tri_index).Get()
if reversed_face:
n2, n3 = n3, n2
vtk_tri = vtk.vtkTriangle()
vtk_tri.GetPointIds().SetId(0, node_offset + n1 - 1)
vtk_tri.GetPointIds().SetId(1, node_offset + n2 - 1)
vtk_tri.GetPointIds().SetId(2, node_offset + n3 - 1)
polys.InsertNextCell(vtk_tri)
poly = vtk.vtkPolyData()
poly.SetPoints(points)
poly.SetPolys(polys)
return poly
def _shape_bounds(shape: TopoDS_Shape) -> tuple[float, float, float, float, float, float]:
box = Bnd_Box()
brepbndlib.Add(shape, box)
return box.Get()
def _shape_bounds_info(shape: TopoDS_Shape) -> dict[str, object]:
xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape)
dx = xmax - xmin
dy = ymax - ymin
dz = zmax - zmin
return {
"bbox_min": (xmin, ymin, zmin),
"bbox_max": (xmax, ymax, zmax),
"bbox_size": (dx, dy, dz),
"bbox_diagonal": math.sqrt(dx * dx + dy * dy + dz * dz),
}
def _shape_volume_info(shape: TopoDS_Shape) -> dict[str, object]:
props = GProp_GProps()
try:
brepgprop.VolumeProperties(shape, props)
except Exception:
return {"volume": "unavailable"}
volume = props.Mass()
info: dict[str, object] = {"volume": volume}
if abs(volume) > 1e-9:
info["center_of_mass"] = _point_tuple(props.CentreOfMass())
return info
def _shape_diagonal(shape: TopoDS_Shape) -> float:
xmin, ymin, zmin, xmax, ymax, zmax = _shape_bounds(shape)
return math.sqrt((xmax - xmin) ** 2 + (ymax - ymin) ** 2 + (zmax - zmin) ** 2)
def _ensure_valid_shape(shape: TopoDS_Shape) -> None:
if shape.IsNull():
raise RuntimeError("Operation returned a null shape.")
analyzer = BRepCheck_Analyzer(shape)
if not analyzer.IsValid():
raise RuntimeError("Operation returned an invalid B-Rep shape.")
def _solid_state(solid: TopoDS_Shape, point: gp_Pnt) -> str:
classifier = BRepClass3d_SolidClassifier(solid, point, 1e-6)
state = classifier.State()
if state == TopAbs_IN:
return "inside"
if state == TopAbs_OUT:
return "outside"
return "on/unknown"
def _state_summary(states: list[str]) -> str:
if not states:
return "unknown"
counts: dict[str, int] = {}
for state in states:
counts[state] = counts.get(state, 0) + 1
if len(counts) == 1:
return states[0]
return ", ".join(f"{state}:{count}" for state, count in sorted(counts.items()))
def _cylinder_resize_readiness(
info: dict[str, object],
new_diameter: float | None = None,
) -> dict[str, object]:
risk = "low"
status = "ready"
warnings: list[str] = []
blockers: list[str] = []
guess = str(info.get("feature_guess", "cylindrical face"))
confidence = str(info.get("confidence", "low"))
angular_span = float(info.get("angular_span", 0.0))
if guess == "round/fillet candidate":
risk = "high"
warnings.append("当前圆柱面更像圆角/倒圆,调整圆柱孔径很可能误切圆角。")
elif guess == "boss/outer-round candidate":
risk = "high"
warnings.append("当前圆柱面更像凸柱或外圆,调整圆柱孔径可能切掉外部结构。")
elif guess != "hole/groove candidate":
risk = "high"
warnings.append("当前圆柱面还没有被识别为孔/槽候选。")
if guess == "hole/groove candidate" and confidence == "low":
risk = _max_risk(risk, "medium")
warnings.append("孔/槽判断置信度较低。")
if guess == "hole/groove candidate" and angular_span < math.tau * 0.92:
risk = _max_risk(risk, "medium")
warnings.append("这是局部圆柱面,更像槽或半孔,不是完整圆孔。")
if new_diameter is not None:
current_diameter = float(info.get("diameter", 0.0))
if new_diameter <= 0:
status = "blocked"
risk = "blocked"
blockers.append("目标直径必须大于 0。")
elif abs(new_diameter - current_diameter) <= max(current_diameter * 1e-5, 1e-6):
status = "blocked"
risk = "blocked"
blockers.append("目标直径与当前直径几乎相同,不需要修改。")
elif new_diameter < current_diameter:
if guess != "hole/groove candidate":
status = "blocked"
risk = "blocked"
blockers.append("缩小孔径第一版只支持孔/槽候选,不支持圆角、凸柱或未明确圆柱面。")
else:
risk = _max_risk(risk, "high")
warnings.append("缩小孔径会先补料再重切,属于高风险实验功能。")
if risk in {"medium", "high"} and status != "blocked":
status = "caution"
if not warnings and not blockers:
note = "可以尝试调整圆柱孔径。"
else:
note = " ".join(blockers + warnings)
return {
"resize_status": status,
"resize_risk": risk,
"resize_warnings": "".join(warnings),
"resize_blockers": "".join(blockers),
"resize_note": note,
}
def _max_risk(current: str, candidate: str) -> str:
levels = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
return candidate if levels[candidate] > levels[current] else current
def _resize_mode(current_diameter: float, target_diameter: float) -> str:
return "enlarge" if target_diameter > current_diameter else "shrink"
def _dir_tuple(direction) -> tuple[float, float, float]:
return (direction.X(), direction.Y(), direction.Z())
def _oriented_dir_tuple(direction, shape: TopoDS_Shape) -> tuple[float, float, float]:
values = _dir_tuple(direction)
if shape.Orientation() == TopAbs_REVERSED:
return (-values[0], -values[1], -values[2])
return values
def _neg_tuple(values: tuple[float, float, float]) -> tuple[float, float, float]:
return (-values[0], -values[1], -values[2])
def _point_tuple(point) -> tuple[float, float, float]:
return (point.X(), point.Y(), point.Z())
def _point_on_axis(axis_point: gp_Pnt, direction, parameter: float) -> gp_Pnt:
return gp_Pnt(
axis_point.X() + direction.X() * parameter,
axis_point.Y() + direction.Y() * parameter,
axis_point.Z() + direction.Z() * parameter,
)
def _orientation_name(orientation) -> str:
return ORIENTATION_TYPES.get(orientation, f"type {orientation}")
def _format_tuple(values: tuple[float, float, float]) -> str:
return "(" + ", ".join(f"{float(value):.6g}" for value in values) + ")"
def _vec_from_points(a: gp_Pnt, b: gp_Pnt) -> gp_Vec:
return gp_Vec(b.X() - a.X(), b.Y() - a.Y(), b.Z() - a.Z())
def _dot(vec: gp_Vec, direction) -> float:
return vec.X() * direction.X() + vec.Y() * direction.Y() + vec.Z() * direction.Z()