"""Interior connector generation for beam segmentation.
This module provides functions to create interior connectors that join
segmented swept elements. Connectors fit inside hollow profiles and
provide structural continuity across cut planes.
"""
import math
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
from .data import ConnectorSpec, SweptElementProvenance
from .path_utils import (
extract_sub_path,
path_length,
length_to_parameter,
parameter_to_length,
)
# Default fit clearance values (mm per side)
FIT_CLEARANCE = {
'press': 0.18, # Press-fit (structural)
'slip': 0.30, # Slip-fit (easy assembly)
'loose': 0.45, # Loose-fit (adjustable)
}
# Connector length factor (multiple of largest profile dimension)
DEFAULT_LENGTH_FACTOR = 3.0
[docs]
def offset_rectangular_profile(
width: float,
height: float,
clearance: float
) -> Tuple[float, float]:
"""Offset a rectangular profile inward by clearance.
Args:
width: Original profile width
height: Original profile height
clearance: Amount to shrink per side
Returns:
Tuple of (new_width, new_height)
Raises:
ValueError: If clearance would result in zero or negative dimensions
"""
new_width = width - 2 * clearance
new_height = height - 2 * clearance
if new_width <= 0 or new_height <= 0:
raise ValueError(
f"Clearance {clearance} too large for profile {width}x{height}. "
f"Would result in {new_width}x{new_height}"
)
return new_width, new_height
[docs]
def compute_inner_profile_dimensions(
outer_width: float,
outer_height: float,
wall_thickness: float
) -> Tuple[float, float]:
"""Compute inner void dimensions from outer profile and wall thickness.
Args:
outer_width: Outer profile width
outer_height: Outer profile height
wall_thickness: Wall thickness
Returns:
Tuple of (inner_width, inner_height)
"""
inner_width = outer_width - 2 * wall_thickness
inner_height = outer_height - 2 * wall_thickness
if inner_width <= 0 or inner_height <= 0:
raise ValueError(
f"Wall thickness {wall_thickness} too large for profile "
f"{outer_width}x{outer_height}"
)
return inner_width, inner_height
[docs]
def compute_connector_profile_dimensions(
outer_width: float,
outer_height: float,
wall_thickness: float,
fit_clearance: float
) -> Tuple[float, float]:
"""Compute connector cross-section dimensions.
The connector fits inside the hollow interior with specified clearance.
Args:
outer_width: Outer profile width
outer_height: Outer profile height
wall_thickness: Wall thickness of hollow profile
fit_clearance: Clearance per side for desired fit
Returns:
Tuple of (connector_width, connector_height)
"""
inner_w, inner_h = compute_inner_profile_dimensions(
outer_width, outer_height, wall_thickness
)
return offset_rectangular_profile(inner_w, inner_h, fit_clearance)
[docs]
def compute_connector_length(
profile_width: float,
profile_height: float,
spine: Dict[str, Any],
center_parameter: float,
*,
length_factor: float = DEFAULT_LENGTH_FACTOR,
min_arc_degrees: float = 15.0
) -> float:
"""Compute the appropriate connector length.
For straight sections, length is based on profile size.
For curved sections, length may be extended to span a minimum arc.
Args:
profile_width: Profile width
profile_height: Profile height
spine: Path3d dict
center_parameter: Where connector is centered (t in [0, 1])
length_factor: Multiple of largest profile dimension
min_arc_degrees: Minimum arc span for curved sections
Returns:
Connector length in mm
"""
max_dim = max(profile_width, profile_height)
base_length = length_factor * max_dim
# Check curvature at cut point
# For now, use the base length
# Future: analyze local curvature and extend for tight curves
return base_length
[docs]
def create_connector_region2d(
width: float,
height: float,
*,
corner_radius: float = 0.0
) -> List:
"""Create a rectangular region2d for the connector profile.
Args:
width: Connector width
height: Connector height
corner_radius: Optional fillet radius for corners
Returns:
yapCAD region2d (list of polylines with proper winding)
"""
from yapcad.geom import line, point
hw = width / 2
hh = height / 2
if corner_radius > 0:
# Rectangle with filleted corners - use RoundRect from poly
from yapcad.poly import RoundRect
poly = RoundRect(width, height, corner_radius * 2) # chamf = diameter
outline = poly.geom # geom is a property that returns the geometry list
else:
# Simple rectangle centered at origin
# yapCAD points have format [x, y, z, 1]
outline = [
line(point(-hw, -hh, 0), point(hw, -hh, 0)),
line(point(hw, -hh, 0), point(hw, hh, 0)),
line(point(hw, hh, 0), point(-hw, hh, 0)),
line(point(-hw, hh, 0), point(-hw, -hh, 0)),
]
return [outline]
[docs]
def create_interior_connector(
outer_profile_width: float,
outer_profile_height: float,
spine: Dict[str, Any],
center_parameter: float,
*,
wall_thickness: float,
connector_length: Optional[float] = None,
fit_clearance: float = FIT_CLEARANCE['press'],
corner_radius: float = 0.0,
) -> Any:
"""Create an interior connector solid.
The connector fits inside the hollow interior of a swept beam element,
spanning across a cut plane to join two segments.
Args:
outer_profile_width: Width of outer beam profile
outer_profile_height: Height of outer beam profile
spine: Path3d that the original beam follows
center_parameter: Location along spine (t in [0, 1]) where cut occurs
wall_thickness: Wall thickness of hollow beam
connector_length: Length of connector (auto-computed if None)
fit_clearance: Clearance for desired fit (mm per side)
corner_radius: Optional fillet radius for connector corners
Returns:
yapCAD solid representing the connector
"""
from yapcad.geom3d_util import sweep_adaptive
# Compute connector profile dimensions
conn_width, conn_height = compute_connector_profile_dimensions(
outer_profile_width, outer_profile_height,
wall_thickness, fit_clearance
)
# Compute connector length if not specified
if connector_length is None:
connector_length = compute_connector_length(
outer_profile_width, outer_profile_height,
spine, center_parameter
)
# Create connector profile (region2d)
connector_profile = create_connector_region2d(
conn_width, conn_height, corner_radius=corner_radius
)
# Extract spine segment for connector
# Connector extends half-length on each side of center
total_spine_length = path_length(spine)
half_connector_length = connector_length / 2
# Convert lengths to parameters
center_length = parameter_to_length(spine, center_parameter)
start_length = max(0, center_length - half_connector_length)
end_length = min(total_spine_length, center_length + half_connector_length)
start_t = length_to_parameter(spine, start_length)
end_t = length_to_parameter(spine, end_length)
# Extract sub-spine
connector_spine = extract_sub_path(spine, start_t, end_t)
# Sweep connector profile along spine segment
# Note: sweep_adaptive expects the outer boundary polyline, not the full region2d
connector_solid = sweep_adaptive(
connector_profile[0], # Extract outer boundary polyline from region2d
connector_spine,
angle_threshold_deg=5.0
)
return connector_solid
[docs]
def compute_connector_spec(
element_id: str,
cut_parameter: float,
outer_width: float,
outer_height: float,
wall_thickness: float,
spine: Dict[str, Any],
*,
fit_clearance: float = FIT_CLEARANCE['press'],
connector_id: Optional[str] = None,
) -> 'ConnectorSpec':
"""Compute full connector specification for a cut point.
Args:
element_id: ID of parent swept element
cut_parameter: Where cut occurs (t in [0, 1])
outer_width: Outer profile width
outer_height: Outer profile height
wall_thickness: Wall thickness
spine: Path3d of parent element
fit_clearance: Desired fit clearance
connector_id: Optional ID (auto-generated if None)
Returns:
ConnectorSpec object with all computed values
"""
from .data import ConnectorSpec
length = compute_connector_length(
outer_width, outer_height, spine, cut_parameter
)
if connector_id is None:
connector_id = f"{element_id}_conn_{int(cut_parameter * 100)}"
return ConnectorSpec(
id=connector_id,
parent_element_id=element_id,
center_parameter=cut_parameter,
length=length,
fit_clearance=fit_clearance,
profile_type="box"
)
[docs]
def create_terminal_connector(
outer_profile_width: float,
outer_profile_height: float,
spine: Dict[str, Any],
end: str, # "start" or "end"
*,
wall_thickness: float,
connector_length: Optional[float] = None,
fit_clearance: float = FIT_CLEARANCE['press'],
corner_radius: float = 0.0,
) -> Any:
"""Create a terminal connector tab at the start or end of a spine.
Terminal connectors extend outward from arc endpoints and are designed
to slot into female holes in rings or other structures.
Args:
outer_profile_width: Width of outer beam profile
outer_profile_height: Height of outer beam profile
spine: Path3d that the beam follows
end: "start" for t=0 end, "end" for t=1 end
wall_thickness: Wall thickness of hollow beam
connector_length: Length of connector tab (auto-computed if None)
fit_clearance: Clearance for desired fit (mm per side)
corner_radius: Optional fillet radius for connector corners
Returns:
yapCAD solid representing the terminal connector tab
"""
from yapcad.geom3d_util import sweep_profile_along_path
if end not in ("start", "end"):
raise ValueError(f"end must be 'start' or 'end', got '{end}'")
# Compute connector profile dimensions (same as interior connector)
conn_width, conn_height = compute_connector_profile_dimensions(
outer_profile_width, outer_profile_height,
wall_thickness, fit_clearance
)
# Compute connector length if not specified
if connector_length is None:
# For terminal connectors, use half the interior connector length
# since they only extend one direction
connector_length = compute_connector_length(
outer_profile_width, outer_profile_height,
spine, 0.5 # Use midpoint for length calculation
) / 2
# Create connector profile
connector_profile = create_connector_region2d(
conn_width, conn_height, corner_radius=corner_radius
)
# Extract endpoint position and tangent from spine
endpoint, tangent = _get_spine_endpoint_and_tangent(spine, end)
# Create linear spine extending outward from endpoint
# Tangent points along spine direction, so:
# - At "start" (t=0), tangent points toward t=1, we want to extend OPPOSITE
# - At "end" (t=1), tangent points toward t=1 (continuation), we want same dir
if end == "start":
# Extend in opposite direction of tangent (outward from start)
direction = [-tangent[0], -tangent[1], -tangent[2]]
else:
# Extend in same direction as tangent (outward from end)
direction = tangent
# Create linear path from endpoint extending outward
end_point = [
endpoint[0] + direction[0] * connector_length,
endpoint[1] + direction[1] * connector_length,
endpoint[2] + direction[2] * connector_length,
]
# Build linear path3d as dict (same format as rings.py)
connector_spine = {
'segments': [{
'type': 'line',
'start': [endpoint[0], endpoint[1], endpoint[2]],
'end': [end_point[0], end_point[1], end_point[2]]
}]
}
# Sweep connector profile along linear spine
connector_solid = sweep_profile_along_path(
connector_profile[0], # Outer boundary polyline
connector_spine,
)
return connector_solid
def _get_spine_endpoint_and_tangent(
spine: Dict[str, Any],
end: str,
) -> Tuple[List[float], List[float]]:
"""Extract endpoint position and tangent direction from spine.
Args:
spine: Path3d dict
end: "start" for t=0, "end" for t=1
Returns:
Tuple of (position [x,y,z], tangent [dx,dy,dz] normalized)
"""
from .path_utils import evaluate_path3d_at_t
if end == "start":
t = 0.0
else:
t = 1.0
# Get position and tangent at endpoint
pos, tangent = evaluate_path3d_at_t(spine, t)
# Normalize tangent (should already be normalized, but just in case)
mag = math.sqrt(tangent[0]**2 + tangent[1]**2 + tangent[2]**2)
if mag > 0:
tangent = [tangent[0]/mag, tangent[1]/mag, tangent[2]/mag]
return pos, tangent
[docs]
def add_terminal_connectors_to_segment(
segment_solid: Any,
provenance: 'SweptElementProvenance',
*,
add_start: bool = False,
add_end: bool = False,
connector_length: Optional[float] = None,
fit_clearance: float = FIT_CLEARANCE['press'],
) -> Any:
"""Union terminal connector tabs with a segment's endpoints.
Use this to add male tabs to the ends of arc segments that will
slot into female holes in rings or other structures.
Args:
segment_solid: The segment solid to modify
provenance: SweptElementProvenance with profile and spine data
add_start: Add terminal tab at start (t=0) of spine
add_end: Add terminal tab at end (t=1) of spine
connector_length: Length of terminal tabs (auto-computed if None)
fit_clearance: Clearance for fit
Returns:
Modified segment solid with terminal tabs unioned
Raises:
ValueError: If provenance lacks required data
RuntimeError: If boolean union fails
"""
from yapcad.geom3d import solid_boolean
from yapcad.geom import geomlistbbox
if not add_start and not add_end:
return segment_solid # Nothing to do
# Get profile dimensions
outer_profile = provenance.outer_profile
if not outer_profile or not isinstance(outer_profile, list):
raise ValueError("Provenance must have valid outer_profile")
outer = outer_profile[0] # First element is outer boundary
bbox = geomlistbbox(outer)
if not bbox or len(bbox) != 2:
raise ValueError("Cannot extract dimensions from outer_profile")
outer_w = bbox[1][0] - bbox[0][0]
outer_h = bbox[1][1] - bbox[0][1]
wall_thickness = provenance.wall_thickness
if wall_thickness is None:
raise ValueError("Wall thickness required for terminal connectors")
result = segment_solid
if add_start:
start_tab = create_terminal_connector(
outer_w, outer_h,
provenance.spine,
"start",
wall_thickness=wall_thickness,
connector_length=connector_length,
fit_clearance=fit_clearance,
)
try:
result = solid_boolean(result, start_tab, 'union')
except Exception as e:
raise RuntimeError(f"Terminal connector union (start) failed: {e}") from e
if add_end:
end_tab = create_terminal_connector(
outer_w, outer_h,
provenance.spine,
"end",
wall_thickness=wall_thickness,
connector_length=connector_length,
fit_clearance=fit_clearance,
)
try:
result = solid_boolean(result, end_tab, 'union')
except Exception as e:
raise RuntimeError(f"Terminal connector union (end) failed: {e}") from e
return result