Files
pythonocc-step-editor/step_model.py
T

1472 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()