"""Ring generation and female hole creation for manufacturing.
This module provides functions to create base and cradle rings
with female holes that receive terminal connectors from arcs.
"""
import math
from typing import Any, Dict, List, Optional, Tuple
from .data import SweptElementProvenance
from .connectors import (
FIT_CLEARANCE,
compute_connector_profile_dimensions,
create_connector_region2d,
)
[docs]
def create_ring_spine(
radius: float,
center: Tuple[float, float, float] = (0.0, 0.0, 0.0),
tilt_angle_deg: float = 0.0,
tilt_axis: str = "x",
num_segments: int = 72,
) -> Dict[str, Any]:
"""Create a circular path3d for a ring.
Args:
radius: Radius of the ring (to centerline of profile)
center: Center point of the ring (x, y, z)
tilt_angle_deg: Tilt angle in degrees (0 = horizontal)
tilt_axis: Axis to tilt around ("x", "y", or "z")
num_segments: Number of line segments to approximate the circle
Returns:
yapCAD path3d dictionary with 'segments' key
"""
# Build ring as a sequence of line segments in XY plane
# This matches the path3d dict format used elsewhere in yapCAD
segments = []
angle_step = 2 * math.pi / num_segments
for i in range(num_segments):
angle_start = i * angle_step
angle_end = (i + 1) * angle_step
# Start and end points on circle in XY plane
x0 = radius * math.cos(angle_start)
y0 = radius * math.sin(angle_start)
z0 = 0.0
x1 = radius * math.cos(angle_end)
y1 = radius * math.sin(angle_end)
z1 = 0.0
segments.append({
'type': 'line',
'start': [x0, y0, z0],
'end': [x1, y1, z1]
})
ring_path = {'segments': segments}
# Apply tilt rotation if needed
if abs(tilt_angle_deg) > 0.001:
ring_path = _rotate_path3d(ring_path, tilt_angle_deg, tilt_axis)
# Apply translation to center
if center != (0.0, 0.0, 0.0):
ring_path = _translate_path3d(ring_path, center)
return ring_path
def _rotate_path3d(
path: Dict[str, Any],
angle_deg: float,
axis: str,
) -> Dict[str, Any]:
"""Rotate a path3d around an axis through the origin.
Args:
path: path3d dict with 'segments' key
angle_deg: rotation angle in degrees
axis: "x", "y", or "z"
Returns:
Rotated path3d dict
"""
angle_rad = math.radians(angle_deg)
c = math.cos(angle_rad)
s = math.sin(angle_rad)
def rotate_point(x, y, z):
if axis.lower() == "x":
return (x, y * c - z * s, y * s + z * c)
elif axis.lower() == "y":
return (x * c + z * s, y, -x * s + z * c)
else: # z axis
return (x * c - y * s, x * s + y * c, z)
new_segments = []
for seg in path.get('segments', []):
start = seg['start']
end = seg['end']
new_start = rotate_point(start[0], start[1], start[2])
new_end = rotate_point(end[0], end[1], end[2])
new_segments.append({
'type': seg['type'],
'start': list(new_start),
'end': list(new_end)
})
return {'segments': new_segments}
def _translate_path3d(
path: Dict[str, Any],
offset: Tuple[float, float, float],
) -> Dict[str, Any]:
"""Translate a path3d by an offset.
Args:
path: path3d dict with 'segments' key
offset: (dx, dy, dz) translation
Returns:
Translated path3d dict
"""
dx, dy, dz = offset
new_segments = []
for seg in path.get('segments', []):
start = seg['start']
end = seg['end']
new_segments.append({
'type': seg['type'],
'start': [start[0] + dx, start[1] + dy, start[2] + dz],
'end': [end[0] + dx, end[1] + dy, end[2] + dz]
})
return {'segments': new_segments}
[docs]
def create_ring_profile(
outer_width: float,
outer_height: float,
wall_thickness: float,
) -> List:
"""Create a hollow rectangular profile (region2d) for a ring.
Args:
outer_width: Width of outer profile
outer_height: Height of outer profile
wall_thickness: Wall thickness
Returns:
yapCAD region2d (outer boundary, inner hole)
"""
from yapcad.geom import line, point
# Outer rectangle
hw = outer_width / 2
hh = outer_height / 2
outer = [
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)),
]
# Inner rectangle (hole)
inner_hw = hw - wall_thickness
inner_hh = hh - wall_thickness
if inner_hw <= 0 or inner_hh <= 0:
# Solid profile if wall too thick
return [outer]
# Inner winding is opposite (clockwise for hole)
inner = [
line(point(-inner_hw, -inner_hh, 0), point(-inner_hw, inner_hh, 0)),
line(point(-inner_hw, inner_hh, 0), point(inner_hw, inner_hh, 0)),
line(point(inner_hw, inner_hh, 0), point(inner_hw, -inner_hh, 0)),
line(point(inner_hw, -inner_hh, 0), point(-inner_hw, -inner_hh, 0)),
]
return [outer, inner]
[docs]
def create_ring_solid(
radius: float,
outer_width: float,
outer_height: float,
wall_thickness: float,
center: Tuple[float, float, float] = (0.0, 0.0, 0.0),
tilt_angle_deg: float = 0.0,
tilt_axis: str = "x",
ring_id: str = "ring",
) -> Tuple[Any, SweptElementProvenance]:
"""Create a hollow box-beam ring with provenance tracking.
Args:
radius: Radius to centerline of profile
outer_width: Width of beam profile
outer_height: Height of beam profile
wall_thickness: Wall thickness for hollow profile
center: Center point of ring
tilt_angle_deg: Tilt angle in degrees
tilt_axis: Axis to tilt around
ring_id: Identifier for the ring
Returns:
Tuple of (ring_solid, SweptElementProvenance)
"""
from yapcad.geom3d_util import sweep_adaptive
# Create ring spine
spine = create_ring_spine(
radius, center, tilt_angle_deg, tilt_axis
)
# Create hollow profile
profile = create_ring_profile(outer_width, outer_height, wall_thickness)
# Sweep profile along ring spine
ring_solid = sweep_adaptive(
profile[0], # outer boundary
spine,
angle_threshold_deg=5.0,
inner_profiles=[profile[1]] if len(profile) > 1 else None,
)
# Create provenance
provenance = SweptElementProvenance(
id=ring_id,
operation="sweep_adaptive",
outer_profile=profile,
spine=spine,
inner_profile=profile[1] if len(profile) > 1 else None,
wall_thickness=wall_thickness,
semantic_type="ring",
metadata={
'solid': ring_solid,
'radius': radius,
'center': center,
'tilt_angle_deg': tilt_angle_deg,
},
)
return ring_solid, provenance
[docs]
def create_female_hole_solid(
position: Tuple[float, float, float],
direction: Tuple[float, float, float],
outer_width: float,
outer_height: float,
wall_thickness: float,
hole_depth: float,
fit_clearance: float = FIT_CLEARANCE['press'],
) -> Any:
"""Create a solid to subtract from a ring for a female connector hole.
The hole allows a male terminal connector to slot in.
Args:
position: Starting position of hole (on ring surface)
direction: Direction hole extends (into ring)
outer_width: Width of beam profile (for sizing hole)
outer_height: Height of beam profile (for sizing hole)
wall_thickness: Wall thickness of beam
hole_depth: How deep the hole extends
fit_clearance: Clearance to add for fit
Returns:
yapCAD solid representing the hole volume to subtract
"""
from yapcad.geom3d_util import sweep_profile_along_path
# Compute hole dimensions - should match connector profile but with
# small additional clearance for the hole itself
hole_clearance = fit_clearance / 2 # Extra clearance for hole
conn_width, conn_height = compute_connector_profile_dimensions(
outer_width, outer_height, wall_thickness, fit_clearance
)
# Add extra clearance for the female hole
hole_width = conn_width + 2 * hole_clearance
hole_height = conn_height + 2 * hole_clearance
# Create hole profile
hole_profile = create_connector_region2d(hole_width, hole_height)
# Normalize direction
dx, dy, dz = direction
mag = math.sqrt(dx*dx + dy*dy + dz*dz)
if mag > 0:
dx, dy, dz = dx/mag, dy/mag, dz/mag
# Create linear path for hole (using dict format)
end_pos = (
position[0] + dx * hole_depth,
position[1] + dy * hole_depth,
position[2] + dz * hole_depth,
)
hole_spine = {
'segments': [{
'type': 'line',
'start': [position[0], position[1], position[2]],
'end': [end_pos[0], end_pos[1], end_pos[2]]
}]
}
# Sweep hole profile to create hole solid
hole_solid = sweep_profile_along_path(
hole_profile[0], # outer boundary
hole_spine,
)
return hole_solid
[docs]
def compute_arc_attachment_point(
ring_radius: float,
attachment_angle_deg: float,
ring_center: Tuple[float, float, float] = (0.0, 0.0, 0.0),
ring_tilt_deg: float = 0.0,
ring_tilt_axis: str = "x",
) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
"""Compute where an arc attaches to a ring.
Returns the position on the ring centerline and the radial direction
pointing inward (toward ring center).
Args:
ring_radius: Radius of ring
attachment_angle_deg: Angle around ring where arc attaches
ring_center: Center of ring
ring_tilt_deg: Ring tilt angle
ring_tilt_axis: Axis ring is tilted around
Returns:
Tuple of (position, inward_direction)
"""
# Compute position on untilted ring in XY plane
angle_rad = math.radians(attachment_angle_deg)
px = ring_radius * math.cos(angle_rad)
py = ring_radius * math.sin(angle_rad)
pz = 0.0
# Radial direction (pointing inward, toward center)
dx = -math.cos(angle_rad)
dy = -math.sin(angle_rad)
dz = 0.0
# Apply tilt rotation
if abs(ring_tilt_deg) > 0.001:
px, py, pz, dx, dy, dz = _rotate_point_and_direction(
px, py, pz, dx, dy, dz, ring_tilt_deg, ring_tilt_axis
)
# Apply translation
position = (
px + ring_center[0],
py + ring_center[1],
pz + ring_center[2],
)
return position, (dx, dy, dz)
def _rotate_point_and_direction(
px: float, py: float, pz: float,
dx: float, dy: float, dz: float,
angle_deg: float,
axis: str,
) -> Tuple[float, float, float, float, float, float]:
"""Rotate a point and direction vector around an axis."""
angle_rad = math.radians(angle_deg)
c = math.cos(angle_rad)
s = math.sin(angle_rad)
if axis.lower() == "x":
# Rotate around X axis
py_new = py * c - pz * s
pz_new = py * s + pz * c
py, pz = py_new, pz_new
dy_new = dy * c - dz * s
dz_new = dy * s + dz * c
dy, dz = dy_new, dz_new
elif axis.lower() == "y":
# Rotate around Y axis
px_new = px * c + pz * s
pz_new = -px * s + pz * c
px, pz = px_new, pz_new
dx_new = dx * c + dz * s
dz_new = -dx * s + dz * c
dx, dz = dx_new, dz_new
else: # z axis
# Rotate around Z axis
px_new = px * c - py * s
py_new = px * s + py * c
px, py = px_new, py_new
dx_new = dx * c - dy * s
dy_new = dx * s + dy * c
dx, dy = dx_new, dy_new
return px, py, pz, dx, dy, dz
[docs]
def add_female_holes_to_ring(
ring_solid: Any,
attachment_points: List[Tuple[Tuple[float, float, float], Tuple[float, float, float]]],
outer_width: float,
outer_height: float,
wall_thickness: float,
hole_depth: Optional[float] = None,
fit_clearance: float = FIT_CLEARANCE['press'],
) -> Any:
"""Subtract female holes at arc attachment positions.
Args:
ring_solid: Ring solid to modify
attachment_points: List of (position, direction) tuples
outer_width: Beam profile width
outer_height: Beam profile height
wall_thickness: Wall thickness
hole_depth: Depth of holes (auto-computed if None)
fit_clearance: Fit clearance for holes
Returns:
Ring solid with female holes subtracted
"""
from yapcad.geom3d import solid_boolean
if hole_depth is None:
# Default hole depth matches terminal connector length
from .connectors import DEFAULT_LENGTH_FACTOR
hole_depth = max(outer_width, outer_height) * DEFAULT_LENGTH_FACTOR / 2
result = ring_solid
for position, direction in attachment_points:
hole_solid = create_female_hole_solid(
position,
direction,
outer_width,
outer_height,
wall_thickness,
hole_depth,
fit_clearance,
)
try:
result = solid_boolean(result, hole_solid, 'difference')
except Exception as e:
# Log warning but continue
print(f"Warning: Failed to subtract hole at {position}: {e}")
return result
[docs]
def trim_segment_against_ring(
segment_solid: Any,
ring_solid: Any,
) -> Any:
"""Trim a segment by subtracting the ring solid from it.
This creates a clean interface where the arc meets the ring,
removing any overlap between the arc segment and ring geometry.
Args:
segment_solid: Arc segment to trim
ring_solid: Ring solid to subtract
Returns:
Trimmed segment solid
"""
from yapcad.geom3d import solid_boolean
from yapcad.brep import brep_from_solid, attach_brep_to_solid
result = solid_boolean(segment_solid, ring_solid, 'difference')
# Ensure BREP data is attached to the result
brep = brep_from_solid(result)
if brep is not None:
attach_brep_to_solid(result, brep)
return result
[docs]
def compute_ring_cuts_avoiding_holes(
ring_circumference: float,
max_segment_length: float,
hole_angles_deg: List[float],
min_distance_from_hole: float = 30.0,
*,
target_segments: Optional[int] = None,
) -> List[float]:
"""Compute cut parameters for ring that avoid female hole positions.
Produces evenly-spaced segments with cuts placed to avoid holes.
The algorithm finds cut positions that result in equal-length segments
(relative to t=0) while keeping cuts away from hole locations.
For equal segments on a closed ring, we need cuts at positions that
divide [0, 1) into equal parts. With N segments, cuts should be at
1/N, 2/N, ..., (N-1)/N. If holes are at these positions, we find
alternate cut positions that still produce equal segments.
Args:
ring_circumference: Total circumference of ring in mm
max_segment_length: Maximum segment length in mm
hole_angles_deg: Angles where holes are located (0-360)
min_distance_from_hole: Minimum distance from hole center in mm
target_segments: If specified, use this many segments instead of
computing from max_segment_length. Useful when build volume
constraints allow fewer segments than the length calculation.
Returns:
List of cut parameters (0-1) that avoid hole locations
"""
# Compute number of segments needed
if target_segments is not None:
num_segments = target_segments
else:
num_segments = math.ceil(ring_circumference / max_segment_length)
if num_segments < 2:
return [] # No cuts needed
num_cuts = num_segments - 1
# Convert hole angles to parameters (0-360 -> 0-1)
hole_params = sorted([angle / 360.0 for angle in hole_angles_deg])
# Convert min_distance to parameter space
min_param_distance = min_distance_from_hole / ring_circumference
# Build exclusion zones around holes
exclusion_zones = []
for hole_param in hole_params:
zone_start = hole_param - min_param_distance
zone_end = hole_param + min_param_distance
exclusion_zones.append((zone_start, zone_end))
def is_cut_valid(cut_param: float) -> bool:
"""Check if a cut position avoids all holes."""
for zone_start, zone_end in exclusion_zones:
# Handle wrap-around
if zone_start < 0:
# Zone wraps around 0
if cut_param < zone_end or cut_param > (1.0 + zone_start):
return False
elif zone_end > 1.0:
# Zone wraps around 1
if cut_param > zone_start or cut_param < (zone_end - 1.0):
return False
else:
# Normal case
if zone_start <= cut_param <= zone_end:
return False
return True
def compute_segment_sizes(cuts: List[float]) -> List[float]:
"""Compute segment sizes from sorted cut positions."""
if not cuts:
return [1.0]
sorted_cuts = sorted(cuts)
sizes = []
# First segment: from 0 to first cut
sizes.append(sorted_cuts[0])
# Middle segments
for i in range(1, len(sorted_cuts)):
sizes.append(sorted_cuts[i] - sorted_cuts[i-1])
# Last segment: from last cut to 1.0
sizes.append(1.0 - sorted_cuts[-1])
return sizes
def segment_size_variance(cuts: List[float]) -> float:
"""Compute variance of segment sizes (lower is better)."""
sizes = compute_segment_sizes(cuts)
mean_size = sum(sizes) / len(sizes)
return sum((s - mean_size) ** 2 for s in sizes)
# For equal segments, cuts MUST be at exactly k/num_segments for k=1..num_cuts
# E.g., for 3 segments: cuts at 1/3 and 2/3 give equal segments [0,1/3], [1/3,2/3], [2/3,1]
#
# If holes block these positions, we find cuts as close as possible to minimize variance.
segment_size = 1.0 / num_segments
# Ideal cut positions for equal segments
ideal_cuts = [segment_size * (i + 1) for i in range(num_cuts)]
# First, check if ideal cuts are all valid
if all(is_cut_valid(c) for c in ideal_cuts):
return ideal_cuts
# Ideal cuts are blocked by holes. Find cuts as close as possible to ideal positions.
# Strategy: for each ideal cut position, find the nearest valid position.
def find_nearest_valid_position(ideal_pos: float, search_range: float = 0.5) -> Optional[float]:
"""Find the nearest valid position to ideal_pos within search_range."""
if is_cut_valid(ideal_pos):
return ideal_pos
# Search in both directions with fine resolution
best_pos = None
best_dist = float('inf')
# Search 1000 positions within range
for i in range(1000):
offset = (i / 1000.0) * search_range
# Try position above ideal
pos_above = ideal_pos + offset
if pos_above < 1.0 and is_cut_valid(pos_above):
dist = abs(pos_above - ideal_pos)
if dist < best_dist:
best_dist = dist
best_pos = pos_above
break # Found closest valid above
# Try position below ideal
pos_below = ideal_pos - offset
if pos_below > 0.0 and is_cut_valid(pos_below):
dist = abs(pos_below - ideal_pos)
if dist < best_dist:
best_dist = dist
best_pos = pos_below
break # Found closest valid below
return best_pos
# Find nearest valid position for each ideal cut
adjusted_cuts = []
for ideal_pos in ideal_cuts:
nearest = find_nearest_valid_position(ideal_pos)
if nearest is not None:
adjusted_cuts.append(nearest)
else:
# No valid position found for this cut - fall back to search
break
if len(adjusted_cuts) == num_cuts:
# Verify no duplicates and proper ordering
adjusted_cuts = sorted(set(adjusted_cuts))
if len(adjusted_cuts) == num_cuts:
return adjusted_cuts
# Fallback: exhaustive search for minimum variance solution
# Find valid regions (gaps between exclusion zones)
exclusion_points = []
for zone_start, zone_end in exclusion_zones:
if zone_start < 0:
exclusion_points.append((zone_start + 1.0, 1.0))
exclusion_points.append((0.0, zone_end))
elif zone_end > 1.0:
exclusion_points.append((zone_start, 1.0))
exclusion_points.append((0.0, zone_end - 1.0))
else:
exclusion_points.append((zone_start, zone_end))
exclusion_points.sort()
merged = []
for start, end in exclusion_points:
if merged and start <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
valid_regions = []
if not merged:
valid_regions = [(0.0, 1.0)]
else:
if merged[0][0] > 0:
valid_regions.append((0.0, merged[0][0]))
for i in range(len(merged) - 1):
valid_regions.append((merged[i][1], merged[i+1][0]))
if merged[-1][1] < 1.0:
valid_regions.append((merged[-1][1], 1.0))
# Generate candidate positions: boundaries of valid regions and ideal cut positions
candidate_cuts = []
# Add boundary positions (just inside valid regions)
for region_start, region_end in valid_regions:
candidate_cuts.append(region_start + 0.001)
candidate_cuts.append(region_end - 0.001)
# Also add midpoint
candidate_cuts.append((region_start + region_end) / 2)
# Add positions near ideal cuts
for ideal in ideal_cuts:
for offset in [0.0, 0.01, 0.02, 0.05, 0.1, -0.01, -0.02, -0.05, -0.1]:
pos = ideal + offset
if 0 < pos < 1:
candidate_cuts.append(pos)
# Filter to valid positions only
candidate_cuts = sorted(set(c for c in candidate_cuts if 0 < c < 1 and is_cut_valid(c)))
if len(candidate_cuts) >= num_cuts:
from itertools import combinations
best_cuts = None
best_variance = float('inf')
for combo in combinations(candidate_cuts, num_cuts):
cuts = sorted(combo)
variance = segment_size_variance(cuts)
if variance < best_variance:
best_variance = variance
best_cuts = cuts
if best_cuts:
return list(best_cuts)
# Last resort: return evenly spaced cuts, even if they hit holes
return [segment_size * (i + 1) for i in range(num_cuts)]