- arc improvements, fillets, operations, bodys
This commit is contained in:
@@ -5,6 +5,7 @@ This module provides a concrete implementation of the geometry kernel
|
||||
using OCP (OpenCASCADE Python bindings).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Tuple, Optional, Any, Dict
|
||||
import numpy as np
|
||||
|
||||
@@ -15,6 +16,8 @@ from fluency.geometry.base import (
|
||||
Point3D,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OCCGeometryObject(GeometryObject):
|
||||
"""Geometry object wrapper for OpenCASCADE shapes."""
|
||||
@@ -480,10 +483,11 @@ class OCGeometryKernel(GeometryKernel):
|
||||
else:
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_EDGE
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
|
||||
while explorer.More():
|
||||
chamfer.Add(size, explorer.Current())
|
||||
chamfer.Add(size, TopoDS.Edge_s(explorer.Current()))
|
||||
explorer.Next()
|
||||
|
||||
chamfer.Build()
|
||||
@@ -576,6 +580,67 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"})
|
||||
|
||||
def pattern(
|
||||
self,
|
||||
body: GeometryObject,
|
||||
pattern_type: str = "linear",
|
||||
count: int = 2,
|
||||
direction: Tuple[float, float, float] = (1, 0, 0),
|
||||
spacing: float = 10.0,
|
||||
axis: Tuple[float, float, float] = (0, 0, 1),
|
||||
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
angle: float = 360.0,
|
||||
) -> GeometryObject:
|
||||
"""Repeat *body* in a linear or circular array (pattern).
|
||||
|
||||
Linear: *count* copies spaced *spacing* mm apart along
|
||||
*direction* (a negative spacing reverses the direction).
|
||||
|
||||
Circular: *count* copies rotated evenly around *axis* passing
|
||||
through *origin*, distributed over a total angular span of
|
||||
*angle* degrees (step = angle / count). ``angle=360`` gives the
|
||||
classic evenly-spaced full-circle bolt pattern.
|
||||
|
||||
Returns the union (compound when the copies don't touch) of the
|
||||
original solid and all its copies — disjoint copies keep their
|
||||
separate volumes inside one result object, touching copies fuse.
|
||||
"""
|
||||
count = max(1, int(count))
|
||||
if count <= 1:
|
||||
return body
|
||||
|
||||
import math as _math
|
||||
|
||||
instances: list = [body]
|
||||
if pattern_type == "circular":
|
||||
# Normalize the rotation axis.
|
||||
ax = float(axis[0]), float(axis[1]), float(axis[2])
|
||||
norm = _math.sqrt(ax[0] * ax[0] + ax[1] * ax[1] + ax[2] * ax[2])
|
||||
if norm < 1e-12:
|
||||
ax = (0.0, 0.0, 1.0)
|
||||
else:
|
||||
ax = (ax[0] / norm, ax[1] / norm, ax[2] / norm)
|
||||
step = _math.radians(float(angle)) / count
|
||||
for i in range(1, count):
|
||||
instances.append(self.rotate(body, ax, step * i, origin))
|
||||
else:
|
||||
d = float(direction[0]), float(direction[1]), float(direction[2])
|
||||
norm = _math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2])
|
||||
if norm < 1e-12:
|
||||
d = (1.0, 0.0, 0.0)
|
||||
else:
|
||||
d = (d[0] / norm, d[1] / norm, d[2] / norm)
|
||||
step = float(spacing)
|
||||
for i in range(1, count):
|
||||
instances.append(
|
||||
self.translate(
|
||||
body,
|
||||
(d[0] * step * i, d[1] * step * i, d[2] * step * i),
|
||||
)
|
||||
)
|
||||
|
||||
return self.boolean_union(*instances)
|
||||
|
||||
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
|
||||
"""Export to STEP format."""
|
||||
try:
|
||||
@@ -868,3 +933,363 @@ class OCGeometryKernel(GeometryKernel):
|
||||
|
||||
cg = props.CentreOfMass()
|
||||
return Point3D(cg.X(), cg.Y(), cg.Z())
|
||||
|
||||
def create_thread(
|
||||
self,
|
||||
body: GeometryObject,
|
||||
cylindrical_face: Any,
|
||||
nominal_diameter: float,
|
||||
pitch: float,
|
||||
thread_length: Optional[float] = None,
|
||||
internal: bool = False,
|
||||
) -> Optional[GeometryObject]:
|
||||
"""Cut (or add) an ISO metric thread on the cylindrical face of *body*.
|
||||
|
||||
The geometry is driven by the PICKED face's actual radius and axis
|
||||
(``nominal_diameter`` is only metadata used for the feature record).
|
||||
External threads cut the ISO groove trapezoid (7P/8 at the surface,
|
||||
P/4 at the root, 5H/8 deep) out of the shaft; internal threads fuse
|
||||
the ISO ridge trapezoid (3P/4 at the wall, P/8 crest) into the hole.
|
||||
"""
|
||||
import math
|
||||
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.GeomAbs import GeomAbs_Cylinder
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.gp import gp_Pnt, gp_Pnt2d, gp_Dir2d
|
||||
from OCP.BRepBuilderAPI import (
|
||||
BRepBuilderAPI_MakeEdge,
|
||||
BRepBuilderAPI_MakeWire,
|
||||
)
|
||||
|
||||
# ── 1. Cylinder parameters from the picked face ─────────────────
|
||||
try:
|
||||
surf = BRepAdaptor_Surface(cylindrical_face)
|
||||
except Exception:
|
||||
try:
|
||||
surf = BRepAdaptor_Surface(TopoDS.Face_s(cylindrical_face))
|
||||
except Exception as exc:
|
||||
logger.warning(f"create_thread: cannot adapt face: {exc}")
|
||||
return None
|
||||
|
||||
if surf.GetType() != GeomAbs_Cylinder:
|
||||
logger.warning("create_thread: face is not cylindrical")
|
||||
return None
|
||||
|
||||
cyl = surf.Cylinder() # gp_Cylinder
|
||||
radius = cyl.Radius() # ACTUAL picked radius
|
||||
ax3 = cyl.Position() # gp_Ax3 (location, Z, X)
|
||||
loc = ax3.Location()
|
||||
zdir = ax3.Direction()
|
||||
xdir = ax3.XDirection()
|
||||
axis_origin = np.array([loc.X(), loc.Y(), loc.Z()])
|
||||
axis_dir = np.array([zdir.X(), zdir.Y(), zdir.Z()])
|
||||
axis_dir = axis_dir / np.linalg.norm(axis_dir)
|
||||
axis_x = np.array([xdir.X(), xdir.Y(), xdir.Z()])
|
||||
axis_x = axis_x / np.linalg.norm(axis_x)
|
||||
axis_y = np.cross(axis_dir, axis_x)
|
||||
|
||||
u_start = surf.FirstUParameter() # angular start of face
|
||||
v1, v2 = surf.FirstVParameter(), surf.LastVParameter()
|
||||
v_lo, v_hi = min(v1, v2), max(v1, v2)
|
||||
face_height = v_hi - v_lo
|
||||
|
||||
if not thread_length or thread_length <= 0:
|
||||
thread_length = face_height
|
||||
thread_length = min(thread_length, face_height)
|
||||
|
||||
num_turns = thread_length / pitch
|
||||
if num_turns < 0.05:
|
||||
logger.warning("create_thread: thread too short for one turn")
|
||||
return None
|
||||
|
||||
# ── 2. ISO metric profile dimensions ────────────────────────────
|
||||
# Basic profile (H = P·√3/2, thread engagement depth 5H/8):
|
||||
# • external shaft: groove cut is a trapezoid 7P/8 wide at the
|
||||
# surface narrowing to P/4 at the root.
|
||||
# • internal hole: ridge fused onto the wall is a trapezoid 3P/4
|
||||
# wide at the wall narrowing to P/8 at the inner crest, leaving
|
||||
# the 7P/8-wide groove open at the bore.
|
||||
H = pitch * math.sqrt(3.0) / 2.0
|
||||
depth = (5.0 / 8.0) * H
|
||||
overcut = max(0.1 * depth, 0.02) # overhang past the surface
|
||||
if internal:
|
||||
w_surf = 3.0 * pitch / 4.0
|
||||
w_deep = pitch / 8.0
|
||||
else:
|
||||
w_surf = 7.0 * pitch / 8.0
|
||||
w_deep = pitch / 4.0
|
||||
|
||||
# ── 3. Helix spine ON the picked cylinder's surface ─────────────
|
||||
# The swept profile sits in the helix's normal plane, tilted by the
|
||||
# lead angle; its end caps therefore stick out past the spine ends
|
||||
# by roughly half the profile width along the axis. For a CUT that
|
||||
# is harmless (the groove simply runs to the part edge), but a FUSE
|
||||
# would leave the protruding cap as floating material outside the
|
||||
# part, so inset the internal helix by exactly that amount.
|
||||
lead = math.atan2(pitch, 2.0 * math.pi * radius)
|
||||
cap_axial = (w_surf / 2.0) * math.cos(lead) # cap half-extent along axis
|
||||
|
||||
if internal:
|
||||
v_start = v_lo + cap_axial
|
||||
v_end = min(v_lo + thread_length, v_hi) - cap_axial
|
||||
else:
|
||||
# extend one pitch past each face end so the groove runs off
|
||||
# the part edges cleanly
|
||||
v_start = v_lo - pitch
|
||||
v_end = min(v_lo + thread_length + pitch, v_hi + pitch)
|
||||
thread_span = v_end - v_start
|
||||
if thread_span < 0.5 * pitch:
|
||||
logger.warning("create_thread: part too short for a thread")
|
||||
return None
|
||||
turns_ext = thread_span / pitch
|
||||
|
||||
spine_wire = None
|
||||
|
||||
# 3a. TRUE helix: a 2D straight line on the cylinder surface.
|
||||
#
|
||||
# NOTE 1: gp_Dir2d NORMALIZES its argument, so the 2D line
|
||||
# parameter t advances the point by t·|(2π, pitch)| in (u, v)
|
||||
# space — scale the trim range so t = n turns covers exactly
|
||||
# n revolutions plus n·pitch of axial travel.
|
||||
# NOTE 2: the edge from a pcurve has no 3D curve; the pipe sweep
|
||||
# needs one, so force it with BRepLib.BuildCurves3d.
|
||||
spine_wire = None
|
||||
try:
|
||||
from OCP.Geom import Geom_CylindricalSurface
|
||||
from OCP.Geom2d import Geom2d_Line, Geom2d_TrimmedCurve
|
||||
from OCP.BRepLib import BRepLib
|
||||
|
||||
dir_len = math.hypot(2.0 * math.pi, pitch)
|
||||
cyl_surf = Geom_CylindricalSurface(cyl)
|
||||
line2d = Geom2d_Line(
|
||||
gp_Pnt2d(u_start, v_start), gp_Dir2d(2.0 * math.pi, pitch)
|
||||
)
|
||||
seg = Geom2d_TrimmedCurve(line2d, 0.0, turns_ext * dir_len)
|
||||
helix_edge = BRepBuilderAPI_MakeEdge(seg, cyl_surf).Edge()
|
||||
BRepLib.BuildCurves3d_s(helix_edge)
|
||||
spine_wire = BRepBuilderAPI_MakeWire(helix_edge).Wire()
|
||||
logger.info("create_thread: using exact helix spine")
|
||||
except Exception as exc:
|
||||
logger.info(f"create_thread: exact helix failed ({exc})")
|
||||
|
||||
# 3b. Fallback: smooth BSpline through sampled helix points
|
||||
# (only if the exact construction is unavailable).
|
||||
if spine_wire is None:
|
||||
try:
|
||||
from OCP.GeomAPI import GeomAPI_PointsToBSpline
|
||||
from OCP.TColgp import TColgp_Array1OfPnt
|
||||
from OCP.GeomAbs import GeomAbs_C2
|
||||
|
||||
pts_per_turn = 96
|
||||
n_total = max(int(turns_ext * pts_per_turn) + 1, 2)
|
||||
arr = TColgp_Array1OfPnt(1, n_total)
|
||||
for i in range(1, n_total + 1):
|
||||
u = u_start + ((i - 1) / pts_per_turn) * 2.0 * math.pi
|
||||
v = v_start + ((i - 1) / pts_per_turn) * pitch
|
||||
p = (
|
||||
axis_origin
|
||||
+ radius * (math.cos(u) * axis_x + math.sin(u) * axis_y)
|
||||
+ v * axis_dir
|
||||
)
|
||||
arr.SetValue(i, gp_Pnt(float(p[0]), float(p[1]), float(p[2])))
|
||||
bspline = GeomAPI_PointsToBSpline(arr, 3, 8, GeomAbs_C2, 1e-5)
|
||||
bs_edge = BRepBuilderAPI_MakeEdge(bspline.Curve()).Edge()
|
||||
spine_wire = BRepBuilderAPI_MakeWire(bs_edge).Wire()
|
||||
logger.info("create_thread: using BSpline helix fallback")
|
||||
except Exception as exc:
|
||||
logger.warning(f"create_thread: BSpline helix failed ({exc})")
|
||||
|
||||
if spine_wire is None:
|
||||
logger.warning("create_thread: no usable helix spine")
|
||||
return None
|
||||
|
||||
# Start frame (same for both spine types — computed analytically).
|
||||
def _cyl_pt(u: float, v: float) -> np.ndarray:
|
||||
return (
|
||||
axis_origin
|
||||
+ radius * (math.cos(u) * axis_x + math.sin(u) * axis_y)
|
||||
+ v * axis_dir
|
||||
)
|
||||
|
||||
start_S = _cyl_pt(u_start, v_start)
|
||||
start_T = (
|
||||
2.0 * math.pi * radius
|
||||
* (-math.sin(u_start) * axis_x + math.cos(u_start) * axis_y)
|
||||
+ pitch * axis_dir
|
||||
)
|
||||
start_T = start_T / np.linalg.norm(start_T)
|
||||
start_R = math.cos(u_start) * axis_x + math.sin(u_start) * axis_y # outward
|
||||
|
||||
# Profile width direction: perpendicular to tangent in the surface
|
||||
# plane (≈ axial direction). Trapezoid is symmetric so sign is fine.
|
||||
binormal = np.cross(start_T, start_R)
|
||||
binormal = binormal / np.linalg.norm(binormal)
|
||||
|
||||
# ── 4. Trapezoidal profile at the spine start ───────────────────
|
||||
# Built directly in world coords: base sits *overcut* OUTSIDE the
|
||||
# surface so the boolean fuses/cuts cleanly across it; the working
|
||||
# end reaches *depth* INSIDE the surface.
|
||||
def _mk(b: float, r: float) -> gp_Pnt:
|
||||
p = start_S + b * binormal + r * start_R
|
||||
return gp_Pnt(float(p[0]), float(p[1]), float(p[2]))
|
||||
|
||||
p0 = _mk(-w_surf / 2.0, overcut)
|
||||
p1 = _mk(-w_deep / 2.0, -depth)
|
||||
p2 = _mk(+w_deep / 2.0, -depth)
|
||||
p3 = _mk(+w_surf / 2.0, overcut)
|
||||
|
||||
prof_wb = BRepBuilderAPI_MakeWire()
|
||||
for a, b in ((p0, p1), (p1, p2), (p2, p3), (p3, p0)):
|
||||
prof_wb.Add(BRepBuilderAPI_MakeEdge(a, b).Edge())
|
||||
profile_wire = prof_wb.Wire()
|
||||
|
||||
# ── 5. Sweep the profile along the helix ────────────────────────
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell
|
||||
|
||||
try:
|
||||
pipe = BRepOffsetAPI_MakePipeShell(spine_wire)
|
||||
pipe.SetMode(True) # Frenet frame
|
||||
pipe.Add(profile_wire, False, False)
|
||||
pipe.Build()
|
||||
if not pipe.IsDone():
|
||||
logger.warning("create_thread: pipe sweep failed")
|
||||
return None
|
||||
solid_ok = False
|
||||
try:
|
||||
solid_ok = bool(pipe.MakeSolid()) # cap the tube ends
|
||||
except Exception as exc:
|
||||
logger.info(f"create_thread: MakeSolid unavailable ({exc})")
|
||||
tool_shape = pipe.Shape()
|
||||
if not solid_ok:
|
||||
logger.warning("create_thread: sweep is not a solid")
|
||||
except Exception as exc:
|
||||
logger.warning(f"create_thread: sweep failed: {exc}")
|
||||
return None
|
||||
|
||||
# ── 6. Boolean cut (shaft) or fuse (hole) ───────────────────────
|
||||
body_shape = self._get_shape(body)
|
||||
if body_shape is None:
|
||||
logger.warning("create_thread: body has no shape")
|
||||
return None
|
||||
|
||||
tool = OCCGeometryObject(tool_shape)
|
||||
vol_before = self.get_volume(body)
|
||||
|
||||
if internal:
|
||||
result = self.boolean_union(body, tool)
|
||||
else:
|
||||
result = self.boolean_difference(body, tool)
|
||||
|
||||
if result is None or self._get_shape(result) is None:
|
||||
logger.warning("create_thread: boolean op produced no shape")
|
||||
return None
|
||||
|
||||
try:
|
||||
vol_after = self.get_volume(result)
|
||||
except Exception:
|
||||
vol_after = -1.0
|
||||
|
||||
if internal and vol_after <= vol_before:
|
||||
logger.warning(
|
||||
f"create_thread: fuse did not add volume "
|
||||
f"({vol_before:.4f} → {vol_after:.4f}) — tool missed the body?"
|
||||
)
|
||||
return None
|
||||
if not internal and vol_after >= vol_before:
|
||||
logger.warning(
|
||||
f"create_thread: cut did not remove volume "
|
||||
f"({vol_before:.4f} → {vol_after:.4f}) — tool missed the body?"
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f"create_thread: {'internal' if internal else 'external'} thread OK, "
|
||||
f"volume {vol_before:.4f} → {vol_after:.4f}"
|
||||
)
|
||||
return result
|
||||
|
||||
def detect_cylindrical_face(
|
||||
self,
|
||||
face: Any,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Check if *face* is cylindrical and return its parameters.
|
||||
|
||||
The *face* can be a ``TopoDS_Face`` (from the picker) or a
|
||||
``TopoDS_Shape`` that contains a face. We try several paths to
|
||||
extract the underlying cylindrical surface.
|
||||
|
||||
Returns a dict with keys ``radius``, ``axis_origin``, ``axis_dir``,
|
||||
``height``, or *None* if the face isn't cylindrical.
|
||||
"""
|
||||
import logging
|
||||
import numpy as np
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.GeomAbs import GeomAbs_Cylinder
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# ── Resolve the actual face from whatever the caller handed us ──
|
||||
actual_face: Any = None
|
||||
|
||||
# Try direct BRepAdaptor_Surface first — the picker already returns
|
||||
# a valid TopoDS_Face, and calling TopoDS.Face_s() again on an
|
||||
# already-downcast face can fail in some OCP versions.
|
||||
try:
|
||||
surf = BRepAdaptor_Surface(face)
|
||||
surf_type_test = surf.GetType()
|
||||
actual_face = face
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if actual_face is None:
|
||||
# Fallback: try the explicit TopoDS.Face_s downcast path.
|
||||
try:
|
||||
candidate = TopoDS.Face_s(face)
|
||||
_ = BRepAdaptor_Surface(candidate)
|
||||
actual_face = candidate
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if actual_face is None:
|
||||
_log.warning("detect_cylindrical_face: could not resolve face from pick result")
|
||||
return None
|
||||
|
||||
# ── Probe the surface type ──
|
||||
try:
|
||||
surf = BRepAdaptor_Surface(actual_face)
|
||||
surf_type = surf.GetType()
|
||||
if surf_type != GeomAbs_Cylinder:
|
||||
type_names = {
|
||||
0: "Plane", 1: "Cylinder", 2: "Cone", 3: "Sphere",
|
||||
4: "Torus", 5: "Bezier", 6: "BSpline", 7: "Revolution",
|
||||
8: "Extrusion", 9: "Offset", 10: "Other",
|
||||
}
|
||||
type_name = type_names.get(int(surf_type), f"Unknown({int(surf_type)})")
|
||||
_log.warning(
|
||||
f"detect_cylindrical_face: face is {type_name}, not a Cylinder"
|
||||
)
|
||||
return None
|
||||
|
||||
cyl = surf.Cylinder()
|
||||
radius = cyl.Radius()
|
||||
axis = cyl.Axis()
|
||||
origin = axis.Location()
|
||||
direction = axis.Direction()
|
||||
# BRepAdaptor_Surface uses FirstUParameter/LastUParameter etc.
|
||||
u1 = surf.FirstUParameter()
|
||||
u2 = surf.LastUParameter()
|
||||
v1 = surf.FirstVParameter()
|
||||
v2 = surf.LastVParameter()
|
||||
height = abs(v2 - v1)
|
||||
return {
|
||||
"radius": radius,
|
||||
"diameter": 2.0 * radius,
|
||||
"axis_origin": (origin.X(), origin.Y(), origin.Z()),
|
||||
"axis_dir": (direction.X(), direction.Y(), direction.Z()),
|
||||
"height": height,
|
||||
}
|
||||
except Exception as exc:
|
||||
_log.warning(f"detect_cylindrical_face: surface probe failed: {exc}")
|
||||
return None
|
||||
|
||||
@@ -1779,6 +1779,39 @@ class OCCSketch(SketchInterface):
|
||||
|
||||
return points
|
||||
|
||||
def get_line_axis(self, line_id: int) -> Optional[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]:
|
||||
"""Return ``((origin_x, origin_y, origin_z), (dir_x, dir_y, dir_z))`` for a line.
|
||||
|
||||
The axis is computed from the line's solved endpoints mapped into world
|
||||
coordinates on the sketch workplane: origin = line start, direction =
|
||||
normalized start→end. Returns ``None`` if the id is missing, not a
|
||||
line, or degenerate. Used by the revolve tool and feature replay so
|
||||
the revolve axis tracks the sketch line even after it is dragged.
|
||||
"""
|
||||
import math
|
||||
|
||||
ent = self._entities.get(line_id)
|
||||
if ent is None or ent.entity_type != "line" or ent.is_external:
|
||||
return None
|
||||
geom = ent.geometry
|
||||
if not geom or len(geom) != 2 or not geom[0] or not geom[1]:
|
||||
return None
|
||||
try:
|
||||
start = self._uv_to_world(*geom[0])
|
||||
end = self._uv_to_world(*geom[1])
|
||||
except Exception:
|
||||
return None
|
||||
dx = end.X() - start.X()
|
||||
dy = end.Y() - start.Y()
|
||||
dz = end.Z() - start.Z()
|
||||
length = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if length < 1e-9:
|
||||
return None
|
||||
return (
|
||||
(float(start.X()), float(start.Y()), float(start.Z())),
|
||||
(dx / length, dy / length, dz / length),
|
||||
)
|
||||
|
||||
def get_polygon_points(self) -> List[Point2D]:
|
||||
"""Get ordered polygon points from connected lines (uses solved positions).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user