1472 lines
58 KiB
Python
1472 lines
58 KiB
Python
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()
|