Source code for yapcad.brep_edge_select

"""Edge selection helper functions for yapCAD BREP operations.

This module provides functions to select edges from BREP solids based on
various geometric criteria such as direction, length, position, and
association with specific faces.

These functions are particularly useful for applying selective fillet or
chamfer operations to specific edges rather than all edges of a solid.

Example usage:
    from yapcad.brep import brep_from_solid, fillet_edges
    from yapcad.brep_edge_select import select_vertical_edges
    from yapcad.geom3d_util import prism

    # Create a pocket (prism with a hole)
    solid = prism(20, 20, 10)
    brep = brep_from_solid(solid)

    # Select only vertical edges
    vertical_edges = select_vertical_edges(brep)

    # Apply fillet only to vertical edges
    filleted = fillet_edges(brep, vertical_edges, radius=1.0)

Copyright (c) 2025 Richard W. DeVaul
Copyright (c) 2025 yapCAD contributors
MIT License
"""

import math
from typing import List, Optional, Tuple, Union

from yapcad.brep import (
    BrepSolid,
    BrepEdge,
    require_occ,
    occ_available,
    _get_all_edges,
)
from yapcad.geom import point, vclose, epsilon

# Lazy imports for OCC modules
_BRepAdaptor_Curve = None
_GCPnts_AbscissaPoint = None
_GProp_GProps = None
_brepgprop = None
_gp_Vec = None
_gp_Pnt = None
_TopAbs_EDGE = None
_TopAbs_FACE = None
_TopExp_Explorer = None
_TopExp = None
_TopTools_IndexedDataMapOfShapeListOfShape = None
_topods = None
_BRep_Tool = None


def _ensure_occ_imports():
    """Lazily import OCC modules when needed."""
    global _BRepAdaptor_Curve, _GCPnts_AbscissaPoint, _GProp_GProps
    global _brepgprop, _gp_Vec, _gp_Pnt, _TopAbs_EDGE, _TopAbs_FACE
    global _TopExp_Explorer, _TopExp, _TopTools_IndexedDataMapOfShapeListOfShape
    global _topods, _BRep_Tool

    if _BRepAdaptor_Curve is not None:
        return  # Already imported

    require_occ()

    from OCC.Core.BRepAdaptor import BRepAdaptor_Curve
    from OCC.Core.GProp import GProp_GProps
    from OCC.Core.BRepGProp import brepgprop
    from OCC.Core.gp import gp_Vec, gp_Pnt
    from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE
    from OCC.Core.TopExp import TopExp_Explorer
    from OCC.Core.TopoDS import topods
    from OCC.Core.BRep import BRep_Tool

    _BRepAdaptor_Curve = BRepAdaptor_Curve
    _GProp_GProps = GProp_GProps
    _brepgprop = brepgprop
    _gp_Vec = gp_Vec
    _gp_Pnt = gp_Pnt
    _TopAbs_EDGE = TopAbs_EDGE
    _TopAbs_FACE = TopAbs_FACE
    _TopExp_Explorer = TopExp_Explorer
    _topods = topods
    _BRep_Tool = BRep_Tool

    # These may not be available in all OCC versions
    try:
        from OCC.Core.TopExp import TopExp
        _TopExp = TopExp
    except ImportError:
        _TopExp = None

    try:
        from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
        _TopTools_IndexedDataMapOfShapeListOfShape = TopTools_IndexedDataMapOfShapeListOfShape
    except ImportError:
        _TopTools_IndexedDataMapOfShapeListOfShape = None

    try:
        from OCC.Core.GCPnts import GCPnts_AbscissaPoint
        _GCPnts_AbscissaPoint = GCPnts_AbscissaPoint
    except ImportError:
        _GCPnts_AbscissaPoint = None


def _cast_to_edge(edge):
    """Ensure edge is cast to TopoDS_Edge for use with BRepAdaptor."""
    _ensure_occ_imports()
    if _topods is not None:
        try:
            return _topods.Edge(edge)
        except Exception:
            pass
    return edge


def _get_edge_direction(edge) -> Optional[Tuple[float, float, float]]:
    """Get the direction vector of an edge.

    For linear edges, returns the normalized direction vector.
    For curved edges, returns None (curves don't have a single direction).

    Args:
        edge: TopoDS_Edge object

    Returns:
        Normalized direction vector as (x, y, z) tuple, or None for curved edges
    """
    _ensure_occ_imports()

    try:
        # Cast to TopoDS_Edge if needed
        edge = _cast_to_edge(edge)
        adaptor = _BRepAdaptor_Curve(edge)

        # Get start and end parameters
        first = adaptor.FirstParameter()
        last = adaptor.LastParameter()

        # Check if edge is linear by comparing midpoint to line
        p1 = adaptor.Value(first)
        p2 = adaptor.Value(last)
        pmid = adaptor.Value((first + last) / 2.0)

        # Expected midpoint for a line
        expected_mid_x = (p1.X() + p2.X()) / 2.0
        expected_mid_y = (p1.Y() + p2.Y()) / 2.0
        expected_mid_z = (p1.Z() + p2.Z()) / 2.0

        # Check if actual midpoint matches expected (indicating linear edge)
        tol = 1e-6
        if (abs(pmid.X() - expected_mid_x) > tol or
            abs(pmid.Y() - expected_mid_y) > tol or
            abs(pmid.Z() - expected_mid_z) > tol):
            # Non-linear edge
            return None

        # Calculate direction vector
        dx = p2.X() - p1.X()
        dy = p2.Y() - p1.Y()
        dz = p2.Z() - p1.Z()

        length = math.sqrt(dx*dx + dy*dy + dz*dz)
        if length < 1e-12:
            return None

        return (dx/length, dy/length, dz/length)

    except Exception:
        return None


def _get_edge_endpoints(edge) -> Optional[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]:
    """Get the start and end points of an edge.

    Args:
        edge: TopoDS_Edge object

    Returns:
        Tuple of ((x1, y1, z1), (x2, y2, z2)) or None on error
    """
    _ensure_occ_imports()

    try:
        # Cast to TopoDS_Edge if needed
        edge = _cast_to_edge(edge)
        adaptor = _BRepAdaptor_Curve(edge)
        first = adaptor.FirstParameter()
        last = adaptor.LastParameter()

        p1 = adaptor.Value(first)
        p2 = adaptor.Value(last)

        return ((p1.X(), p1.Y(), p1.Z()), (p2.X(), p2.Y(), p2.Z()))

    except Exception:
        return None


def _get_edge_length(edge) -> float:
    """Calculate the length of an edge.

    Args:
        edge: TopoDS_Edge object

    Returns:
        Length of the edge, or 0.0 on error
    """
    _ensure_occ_imports()

    try:
        # Cast to TopoDS_Edge if needed
        edge = _cast_to_edge(edge)
        props = _GProp_GProps()
        _brepgprop.LinearProperties(edge, props)
        return props.Mass()  # For linear properties, Mass() returns length
    except Exception:
        return 0.0


def _get_edge_midpoint(edge) -> Optional[Tuple[float, float, float]]:
    """Get the midpoint of an edge.

    Args:
        edge: TopoDS_Edge object

    Returns:
        Midpoint as (x, y, z) tuple, or None on error
    """
    _ensure_occ_imports()

    try:
        # Cast to TopoDS_Edge if needed
        edge = _cast_to_edge(edge)
        adaptor = _BRepAdaptor_Curve(edge)
        first = adaptor.FirstParameter()
        last = adaptor.LastParameter()

        pmid = adaptor.Value((first + last) / 2.0)
        return (pmid.X(), pmid.Y(), pmid.Z())

    except Exception:
        return None


def _get_unique_edges(shape) -> list:
    """Get unique edges from a shape using TopTools_IndexedMapOfShape.

    This avoids the duplicate edges that TopExp_Explorer returns
    (which visits each edge once per adjacent face).

    Args:
        shape: TopoDS_Shape object

    Returns:
        List of unique TopoDS_Edge objects
    """
    _ensure_occ_imports()

    try:
        from OCC.Core.TopTools import TopTools_IndexedMapOfShape
        from OCC.Core.TopExp import TopExp

        edge_map = TopTools_IndexedMapOfShape()
        TopExp.MapShapes_s(shape, _TopAbs_EDGE, edge_map)

        edges = []
        for i in range(1, edge_map.Extent() + 1):
            edges.append(edge_map.FindKey(i))
        return edges
    except ImportError:
        # Fall back to explorer if TopTools not available
        return _get_all_edges(shape)


[docs] def get_all_edges(brep_solid: BrepSolid) -> List[BrepEdge]: """Get all unique edges from a BREP solid as BrepEdge objects. Args: brep_solid: A BrepSolid object Returns: List of BrepEdge objects (unique, no duplicates) """ require_occ() raw_edges = _get_unique_edges(brep_solid.shape) return [BrepEdge(e) for e in raw_edges]
[docs] def select_vertical_edges(brep_solid: BrepSolid, tolerance_deg: float = 1.0) -> List[BrepEdge]: """Select edges that are parallel to the Z axis (vertical). Args: brep_solid: A BrepSolid object tolerance_deg: Angular tolerance in degrees (default 1.0) Returns: List of BrepEdge objects that are vertical """ return select_edges_by_direction(brep_solid, (0, 0, 1), tolerance_deg)
[docs] def select_horizontal_edges(brep_solid: BrepSolid, tolerance_deg: float = 1.0) -> List[BrepEdge]: """Select edges that are perpendicular to the Z axis (horizontal). This includes edges in the XY plane at any Z height. Args: brep_solid: A BrepSolid object tolerance_deg: Angular tolerance in degrees (default 1.0) Returns: List of BrepEdge objects that are horizontal """ require_occ() _ensure_occ_imports() tolerance_rad = math.radians(tolerance_deg) z_axis = (0.0, 0.0, 1.0) result = [] raw_edges = _get_unique_edges(brep_solid.shape) for edge in raw_edges: direction = _get_edge_direction(edge) if direction is None: continue # Skip curved edges # Calculate dot product with Z axis # For horizontal edges, this should be near 0 dot = abs(direction[0] * z_axis[0] + direction[1] * z_axis[1] + direction[2] * z_axis[2]) # dot = cos(angle), so for perpendicular edges, dot should be near 0 # angle = 90 degrees means cos(90) = 0 # We check if angle is within tolerance of 90 degrees # cos(90 - tol) = sin(tol) ~ tol for small angles if dot <= math.sin(tolerance_rad): result.append(BrepEdge(edge)) return result
[docs] def select_edges_by_direction(brep_solid: BrepSolid, direction: Union[Tuple[float, float, float], list], tolerance_deg: float = 1.0) -> List[BrepEdge]: """Select linear edges parallel to a given direction. Args: brep_solid: A BrepSolid object direction: Direction vector as (x, y, z) tuple or list tolerance_deg: Angular tolerance in degrees (default 1.0) Returns: List of BrepEdge objects parallel to the direction """ require_occ() _ensure_occ_imports() # Normalize the input direction dx, dy, dz = direction[0], direction[1], direction[2] length = math.sqrt(dx*dx + dy*dy + dz*dz) if length < 1e-12: return [] target_dir = (dx/length, dy/length, dz/length) tolerance_rad = math.radians(tolerance_deg) cos_tol = math.cos(tolerance_rad) result = [] raw_edges = _get_unique_edges(brep_solid.shape) for edge in raw_edges: edge_dir = _get_edge_direction(edge) if edge_dir is None: continue # Skip curved edges # Calculate absolute dot product (parallel edges can be in either direction) dot = abs(edge_dir[0] * target_dir[0] + edge_dir[1] * target_dir[1] + edge_dir[2] * target_dir[2]) if dot >= cos_tol: result.append(BrepEdge(edge)) return result
[docs] def select_edges_by_length(brep_solid: BrepSolid, min_length: Optional[float] = None, max_length: Optional[float] = None) -> List[BrepEdge]: """Select edges within a length range. Args: brep_solid: A BrepSolid object min_length: Minimum edge length (inclusive), None for no minimum max_length: Maximum edge length (inclusive), None for no maximum Returns: List of BrepEdge objects within the length range """ require_occ() _ensure_occ_imports() result = [] raw_edges = _get_unique_edges(brep_solid.shape) for edge in raw_edges: length = _get_edge_length(edge) if min_length is not None and length < min_length: continue if max_length is not None and length > max_length: continue result.append(BrepEdge(edge)) return result
[docs] def select_edges_at_z(brep_solid: BrepSolid, z_value: float, tolerance: float = 0.001) -> List[BrepEdge]: """Select edges that lie entirely at a specific Z height. This selects horizontal edges where both endpoints are at the given Z value. Args: brep_solid: A BrepSolid object z_value: The Z coordinate to match tolerance: Position tolerance (default 0.001) Returns: List of BrepEdge objects at the specified Z height """ require_occ() _ensure_occ_imports() result = [] raw_edges = _get_unique_edges(brep_solid.shape) for edge in raw_edges: endpoints = _get_edge_endpoints(edge) if endpoints is None: continue p1, p2 = endpoints # Check if both endpoints are at the target Z if (abs(p1[2] - z_value) <= tolerance and abs(p2[2] - z_value) <= tolerance): result.append(BrepEdge(edge)) return result
[docs] def select_edges_in_z_range(brep_solid: BrepSolid, z_min: float, z_max: float, tolerance: float = 0.001) -> List[BrepEdge]: """Select edges where both endpoints are within a Z range. Args: brep_solid: A BrepSolid object z_min: Minimum Z coordinate z_max: Maximum Z coordinate tolerance: Position tolerance (default 0.001) Returns: List of BrepEdge objects within the Z range """ require_occ() _ensure_occ_imports() result = [] raw_edges = _get_unique_edges(brep_solid.shape) for edge in raw_edges: endpoints = _get_edge_endpoints(edge) if endpoints is None: continue p1, p2 = endpoints # Check if both endpoints are within the Z range if (z_min - tolerance <= p1[2] <= z_max + tolerance and z_min - tolerance <= p2[2] <= z_max + tolerance): result.append(BrepEdge(edge)) return result
[docs] def select_edges_crossing_z(brep_solid: BrepSolid, z_value: float, tolerance: float = 0.001) -> List[BrepEdge]: """Select edges that cross a specific Z height (vertical edges spanning Z). This is useful for selecting vertical edges of a pocket or boss. Args: brep_solid: A BrepSolid object z_value: The Z coordinate that edges must span tolerance: Position tolerance (default 0.001) Returns: List of BrepEdge objects that cross the Z value """ require_occ() _ensure_occ_imports() result = [] raw_edges = _get_unique_edges(brep_solid.shape) for edge in raw_edges: endpoints = _get_edge_endpoints(edge) if endpoints is None: continue p1, p2 = endpoints z1, z2 = p1[2], p2[2] # Check if the edge spans the Z value z_low = min(z1, z2) - tolerance z_high = max(z1, z2) + tolerance if z_low < z_value < z_high: result.append(BrepEdge(edge)) return result
[docs] def select_top_edges(brep_solid: BrepSolid, tolerance: float = 0.001) -> List[BrepEdge]: """Select edges at the maximum Z height of the solid. Args: brep_solid: A BrepSolid object tolerance: Position tolerance (default 0.001) Returns: List of BrepEdge objects at the top of the solid """ require_occ() _ensure_occ_imports() # First, find the maximum Z coordinate max_z = None raw_edges = _get_unique_edges(brep_solid.shape) for edge in raw_edges: endpoints = _get_edge_endpoints(edge) if endpoints is None: continue p1, p2 = endpoints edge_max_z = max(p1[2], p2[2]) if max_z is None or edge_max_z > max_z: max_z = edge_max_z if max_z is None: return [] return select_edges_at_z(brep_solid, max_z, tolerance)
[docs] def select_bottom_edges(brep_solid: BrepSolid, tolerance: float = 0.001) -> List[BrepEdge]: """Select edges at the minimum Z height of the solid. Args: brep_solid: A BrepSolid object tolerance: Position tolerance (default 0.001) Returns: List of BrepEdge objects at the bottom of the solid """ require_occ() _ensure_occ_imports() # First, find the minimum Z coordinate min_z = None raw_edges = _get_unique_edges(brep_solid.shape) for edge in raw_edges: endpoints = _get_edge_endpoints(edge) if endpoints is None: continue p1, p2 = endpoints edge_min_z = min(p1[2], p2[2]) if min_z is None or edge_min_z < min_z: min_z = edge_min_z if min_z is None: return [] return select_edges_at_z(brep_solid, min_z, tolerance)
[docs] def select_edges_near_point(brep_solid: BrepSolid, target_point: Union[Tuple[float, float, float], list], max_distance: float) -> List[BrepEdge]: """Select edges whose midpoint is within a distance of a target point. Args: brep_solid: A BrepSolid object target_point: The reference point as (x, y, z) max_distance: Maximum distance from target point Returns: List of BrepEdge objects near the target point """ require_occ() _ensure_occ_imports() tx, ty, tz = target_point[0], target_point[1], target_point[2] result = [] raw_edges = _get_unique_edges(brep_solid.shape) for edge in raw_edges: midpoint = _get_edge_midpoint(edge) if midpoint is None: continue mx, my, mz = midpoint dist = math.sqrt((mx-tx)**2 + (my-ty)**2 + (mz-tz)**2) if dist <= max_distance: result.append(BrepEdge(edge)) return result
[docs] def select_edges_in_cylinder(brep_solid: BrepSolid, center: Union[Tuple[float, float, float], list], radius: float, axis: Union[Tuple[float, float, float], list] = (0, 0, 1)) -> List[BrepEdge]: """Select edges whose midpoint is within a cylindrical region. Useful for selecting edges around a hole or boss. Args: brep_solid: A BrepSolid object center: Center point of cylinder axis (x, y, z) radius: Radius of the cylindrical region axis: Cylinder axis direction (default Z axis) Returns: List of BrepEdge objects within the cylinder """ require_occ() _ensure_occ_imports() cx, cy, cz = center[0], center[1], center[2] # Normalize axis ax, ay, az = axis[0], axis[1], axis[2] alen = math.sqrt(ax*ax + ay*ay + az*az) if alen < 1e-12: return [] ax, ay, az = ax/alen, ay/alen, az/alen result = [] raw_edges = _get_unique_edges(brep_solid.shape) for edge in raw_edges: midpoint = _get_edge_midpoint(edge) if midpoint is None: continue mx, my, mz = midpoint # Vector from center to midpoint vx, vy, vz = mx - cx, my - cy, mz - cz # Project onto axis proj = vx*ax + vy*ay + vz*az # Perpendicular component px = vx - proj * ax py = vy - proj * ay pz = vz - proj * az # Distance from axis dist = math.sqrt(px*px + py*py + pz*pz) if dist <= radius: result.append(BrepEdge(edge)) return result
[docs] def filter_curved_edges(edges: List[BrepEdge]) -> List[BrepEdge]: """Filter to keep only curved (non-linear) edges. Args: edges: List of BrepEdge objects to filter Returns: List of curved BrepEdge objects """ _ensure_occ_imports() result = [] for brep_edge in edges: direction = _get_edge_direction(brep_edge.shape) if direction is None: # Curved edge result.append(brep_edge) return result
[docs] def filter_linear_edges(edges: List[BrepEdge]) -> List[BrepEdge]: """Filter to keep only linear (straight) edges. Args: edges: List of BrepEdge objects to filter Returns: List of linear BrepEdge objects """ _ensure_occ_imports() result = [] for brep_edge in edges: direction = _get_edge_direction(brep_edge.shape) if direction is not None: # Linear edge result.append(brep_edge) return result
[docs] def edge_info(edge: BrepEdge) -> dict: """Get detailed information about an edge. Args: edge: A BrepEdge object Returns: Dictionary with edge properties: - length: Edge length - endpoints: ((x1, y1, z1), (x2, y2, z2)) - midpoint: (x, y, z) - direction: (dx, dy, dz) for linear edges, None for curved - is_linear: True if edge is straight - is_vertical: True if parallel to Z axis - is_horizontal: True if perpendicular to Z axis """ _ensure_occ_imports() raw_edge = edge.shape if isinstance(edge, BrepEdge) else edge length = _get_edge_length(raw_edge) endpoints = _get_edge_endpoints(raw_edge) midpoint = _get_edge_midpoint(raw_edge) direction = _get_edge_direction(raw_edge) is_linear = direction is not None is_vertical = False is_horizontal = False if direction is not None: # Check if vertical (parallel to Z) z_dot = abs(direction[2]) if z_dot >= 0.9998: # ~1 degree tolerance is_vertical = True # Check if horizontal (perpendicular to Z) if z_dot <= 0.0175: # ~1 degree tolerance is_horizontal = True return { 'length': length, 'endpoints': endpoints, 'midpoint': midpoint, 'direction': direction, 'is_linear': is_linear, 'is_vertical': is_vertical, 'is_horizontal': is_horizontal, }
# Convenience functions for combining selections
[docs] def union_edges(*edge_lists: List[BrepEdge]) -> List[BrepEdge]: """Combine multiple edge lists, removing duplicates. Args: *edge_lists: Variable number of BrepEdge lists Returns: Combined list of unique BrepEdge objects """ seen = set() result = [] for edge_list in edge_lists: for edge in edge_list: # Use the shape's hash for uniqueness edge_hash = hash(edge.shape.HashCode(2147483647)) if edge_hash not in seen: seen.add(edge_hash) result.append(edge) return result
[docs] def intersect_edges(*edge_lists: List[BrepEdge]) -> List[BrepEdge]: """Find edges common to all input lists. Args: *edge_lists: Variable number of BrepEdge lists Returns: List of BrepEdge objects present in all input lists """ if not edge_lists: return [] # Build hash sets for each list hash_sets = [] edge_by_hash = {} for edge_list in edge_lists: hash_set = set() for edge in edge_list: edge_hash = hash(edge.shape.HashCode(2147483647)) hash_set.add(edge_hash) edge_by_hash[edge_hash] = edge hash_sets.append(hash_set) # Find intersection common_hashes = hash_sets[0] for hash_set in hash_sets[1:]: common_hashes = common_hashes & hash_set return [edge_by_hash[h] for h in common_hashes]
[docs] def subtract_edges(base_edges: List[BrepEdge], edges_to_remove: List[BrepEdge]) -> List[BrepEdge]: """Remove edges from a list. Args: base_edges: The original list of edges edges_to_remove: Edges to remove from the base list Returns: List of BrepEdge objects from base_edges not in edges_to_remove """ remove_hashes = set() for edge in edges_to_remove: remove_hashes.add(hash(edge.shape.HashCode(2147483647))) return [e for e in base_edges if hash(e.shape.HashCode(2147483647)) not in remove_hashes]
__all__ = [ # Core selection functions 'get_all_edges', 'select_vertical_edges', 'select_horizontal_edges', 'select_edges_by_direction', 'select_edges_by_length', 'select_edges_at_z', 'select_edges_in_z_range', 'select_edges_crossing_z', 'select_top_edges', 'select_bottom_edges', 'select_edges_near_point', 'select_edges_in_cylinder', # Filter functions 'filter_curved_edges', 'filter_linear_edges', # Utility functions 'edge_info', 'union_edges', 'intersect_edges', 'subtract_edges', ]