- arc improvements, fillets, operations, bodys

This commit is contained in:
bklronin
2026-08-05 16:47:25 +02:00
parent a7e7a9f2c3
commit 1e9b0cab1c
15 changed files with 3506 additions and 92 deletions
+426 -1
View File
@@ -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
+33
View File
@@ -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).