Files
cadSet/text-to-cad/models/robots/tom/v2/parts/servo_end_mount.py
T
2026-07-22 19:38:36 +08:00

1720 lines
64 KiB
Python

#!/usr/bin/env python3
"""
Generate the localized end mount.
The part is authored in the imported STS3250 local frame so assemblies can place
it with the same transform as the reference servo geometry.
Usage:
python STEP/servo_end_mount.py
"""
from __future__ import annotations
import math
import os
import sys
from dataclasses import dataclass
from pathlib import Path
# Keep CAD library caches inside the workspace to avoid permission issues.
TOM_DIR = Path(__file__).resolve().parents[2]
V2_DIR = Path(__file__).resolve().parents[1]
_CACHE_HOME = V2_DIR / ".cache"
_CACHE_HOME.mkdir(parents=True, exist_ok=True)
os.environ.setdefault("XDG_CACHE_HOME", str(_CACHE_HOME))
os.environ.setdefault("EZDXF_CACHE_HOME", str(_CACHE_HOME / "ezdxf"))
import ezdxf
import build123d
from ezdxf.units import MM as DXF_UNIT_MM
if str(TOM_DIR) not in sys.path:
sys.path.insert(0, str(TOM_DIR))
from robot_common.materials import GRAY_ALUMINUM_COLOR
from robot_common.step_import import import_as_shape
import dxf_topology
CAD_DIR = Path(__file__).resolve().parent
PART_NAME = Path(__file__).stem
DISPLAY_NAME = "Servo End Mount"
SERVO_STEP = V2_DIR / "parts" / "imports" / "sts3250.step"
def _env_float(name: str, default: float) -> float:
value = os.environ.get(name)
if value is None:
return default
try:
parsed = float(value)
except ValueError as exc:
raise ValueError(f"{name} must be a number, got {value!r}") from exc
if parsed <= 0.0:
raise ValueError(f"{name} must be positive, got {parsed!r}")
return parsed
def _env_nonnegative_float(name: str, default: float) -> float:
value = os.environ.get(name)
if value is None:
return default
try:
parsed = float(value)
except ValueError as exc:
raise ValueError(f"{name} must be a number, got {value!r}") from exc
if parsed < 0.0:
raise ValueError(f"{name} must be non-negative, got {parsed!r}")
return parsed
def front_horn_mount_face_center_local_mm() -> tuple[float, float, float]:
if not SERVO_STEP.exists():
raise FileNotFoundError(f"Missing STS3250 servo STEP: {SERVO_STEP}")
servo_shape = import_as_shape(SERVO_STEP)
layout = _build_layout(servo_shape, include_top_center_bridge=True)
return (
layout.bracket_face_outer_x,
layout.front_face_center_y,
0.0,
)
SENDCUTSEND_MIN_BEND_LINE_EDGE_CLEARANCE_MM = 25.4 * 0.255
SENDCUTSEND_SHEET_THICKNESS_MM = _env_float("TOM_V2_SHEET_THICKNESS_MM", 25.4 * 0.063)
CENTER_BRIDGE_BEND_LINE_EDGE_BUFFER_MM = 25.4 * 0.010
CENTER_BRIDGE_FLANGE_LENGTH_EXTENSION_MM = _env_nonnegative_float(
"TOM_V2_SERVO_END_MOUNT_CENTER_BRIDGE_FLANGE_LENGTH_EXTENSION_MM",
7.9,
)
CENTER_BRIDGE_SIDE_FACE_LENGTH_MM = _env_float(
"TOM_V2_SERVO_END_MOUNT_CENTER_BRIDGE_SIDE_FACE_LENGTH_MM",
8.0,
)
CENTER_BRIDGE_BEND_LINE_EDGE_TARGET_MM = (
SENDCUTSEND_MIN_BEND_LINE_EDGE_CLEARANCE_MM
+ CENTER_BRIDGE_BEND_LINE_EDGE_BUFFER_MM
+ CENTER_BRIDGE_FLANGE_LENGTH_EXTENSION_MM
)
SENDCUTSEND_CENTER_BRIDGE_MIN_AFTER_BEND_MM = 25.4 * 0.303
CENTER_BRIDGE_BEND_EDGE_DISTANCE_BUFFER_MM = 0.035
CENTER_BRIDGE_BEND_EDGE_DISTANCE_TARGET_MM = (
SENDCUTSEND_CENTER_BRIDGE_MIN_AFTER_BEND_MM
+ CENTER_BRIDGE_BEND_EDGE_DISTANCE_BUFFER_MM
)
BRACKET_FACE_CLEARANCE_MM = (
CENTER_BRIDGE_BEND_LINE_EDGE_TARGET_MM
- SENDCUTSEND_SHEET_THICKNESS_MM
)
SENDCUTSEND_REFERENCE_SHEET_THICKNESS_MM = 25.4 * 0.080
SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM = 25.4 * 0.035
SENDCUTSEND_K_FACTOR = 0.42
SENDCUTSEND_BEND_DEDUCTION_90_MM = 25.4 * 0.096
SENDCUTSEND_DIE_WIDTH_MM = 25.4 * 0.472
SENDCUTSEND_HALF_DIE_WIDTH_MM = 0.5 * SENDCUTSEND_DIE_WIDTH_MM
SENDCUTSEND_MIN_FLANGE_FLAT_MM = SENDCUTSEND_MIN_BEND_LINE_EDGE_CLEARANCE_MM
SENDCUTSEND_MIN_FLANGE_FORMED_MM = (
25.4 * 0.303
)
SENDCUTSEND_BEND_RELIEF_DEPTH_MM = 25.4 * 0.118
SENDCUTSEND_MIN_CORNER_RELIEF_DISTANCE_MM = 25.4 * 0.030
SENDCUTSEND_MIN_ANODIZE_SHORTEST_DIM_MM = 25.4
BEND_DIE_REGION_TOLERANCE_MM = 0.1
TOPOLOGY_FLAT_PANEL_JOIN_TOLERANCE_MM = 0.02
# Display the formed bracket with sharp 90-degree bends. The SendCutSend bend
# allowance/radius constants above still drive the flat-pattern math.
BEND_OUTER_RADIUS_MM = 0.0
BEND_INNER_RADIUS_MM = 0.0
FLANGE_FREE_CORNER_RADIUS_MM = 1.0
FLANGE_FREE_CORNER_SAMPLE_COUNT = 8
YOKE_MATCHED_SLOT_WIDTH_MM = 3.0
YOKE_MATCHED_SLOT_LENGTH_MM = 13.0
SLOT_WIDTH_MM = _env_float(
"TOM_V2_SERVO_END_MOUNT_SLOT_WIDTH_MM",
YOKE_MATCHED_SLOT_WIDTH_MM,
)
SLOT_LENGTH_MM = _env_float(
"TOM_V2_SERVO_END_MOUNT_SLOT_LENGTH_MM",
YOKE_MATCHED_SLOT_LENGTH_MM,
)
SLOT_SIDE_END_GAP_MM = _env_float("TOM_V2_SERVO_END_MOUNT_SLOT_SIDE_END_GAP_MM", 4.0)
SLOT_EDGE_TO_BEND_MM = _env_float("TOM_V2_SERVO_END_MOUNT_SLOT_EDGE_TO_BEND_MM", 6.0)
SLOT_CENTER_BRIDGE_WEB_MIN_MM = _env_float(
"TOM_V2_SERVO_END_MOUNT_SLOT_CENTER_BRIDGE_WEB_MIN_MM",
2.0,
)
# Keep the servo-hole axes fixed; the default model seats the wrap directly
# against the servo faces unless an environment override adds clearance.
MOUNT_PLANE_CLEARANCE_MM = _env_float("TOM_V2_SERVO_END_MOUNT_WRAP_CLEARANCE_MM", 0.0)
TOP_MOUNT_PLANE_CLEARANCE_MM = _env_float(
"TOM_V2_SERVO_END_MOUNT_TOP_CLEARANCE_MM",
MOUNT_PLANE_CLEARANCE_MM,
)
BOTTOM_MOUNT_PLANE_CLEARANCE_MM = _env_float(
"TOM_V2_SERVO_END_MOUNT_BOTTOM_CLEARANCE_MM",
MOUNT_PLANE_CLEARANCE_MM,
)
SIDE_WRAP_CLEARANCE_MM = _env_nonnegative_float("TOM_V2_SERVO_END_MOUNT_SIDE_CLEARANCE_MM", 0.0)
TOP_CENTER_BRIDGE_SERVO_END_CLEARANCE_MM = _env_float(
"TOM_V2_SERVO_END_MOUNT_CENTER_BRIDGE_END_CLEARANCE_MM",
1.0,
)
TOP_CENTER_RELIEF_SIDE_CLEARANCE_MM = _env_float(
"TOM_V2_SERVO_END_MOUNT_CENTER_RELIEF_SIDE_CLEARANCE_MM",
0.0,
)
FLANGE_LENGTH_EXTENSION_MM = _env_nonnegative_float("TOM_V2_SERVO_END_MOUNT_FLANGE_EXTENSION_MM", 0.0)
TOP_FLANGE_LENGTH_EXTENSION_MM = _env_nonnegative_float(
"TOM_V2_SERVO_END_MOUNT_TOP_FLANGE_EXTENSION_MM",
FLANGE_LENGTH_EXTENSION_MM,
)
BOTTOM_FLANGE_LENGTH_EXTENSION_MM = _env_nonnegative_float(
"TOM_V2_SERVO_END_MOUNT_BOTTOM_FLANGE_EXTENSION_MM",
FLANGE_LENGTH_EXTENSION_MM,
)
MOUNT_HOLE_EDGE_X_WEB_MM = 1.55
MOUNT_HOLE_OUTBOARD_Z_WEB_MM = 2.15
MOUNT_HOLE_INBOARD_Z_WEB_MM = 2.15
MOUNT_HOLE_TRIMMED_OUTBOARD_Z_WEB_WARN_MM = 1.0
FRONT_HORN_SCREW_CIRCLE_RADIUS_MM = 7.0
FRONT_HORN_M3_CLEARANCE_RADIUS_MM = 1.7
FRONT_HORN_CENTER_CLEARANCE_RADIUS_MM = 3.25
CUT_EXTENSION_MM = 2.0
MAX_FACE_TARGET_ERROR_MM = 1.0
FACE_TARGET_CENTERS_MM: dict[str, tuple[float, float, float]] = {
"end_face": (9.61, -7.65, 0.0),
"end_wrap_neg": (8.843, -7.65, -11.593),
"end_wrap_pos": (8.843, -7.65, 11.593),
"upper_top_neg": (-6.561, 6.4, -9.484),
"upper_top_pos": (-6.561, 6.4, 9.484),
"main_bottom": (-0.881, -25.6, 0.0),
}
@dataclass(frozen=True)
class BracketLayout:
z_min: float
z_max: float
top_holes: tuple[tuple[build123d.Vector, float], ...]
bottom_holes: tuple[tuple[build123d.Vector, float], ...]
top_tab_x_min: float
bottom_tab_x_min: float
bracket_face_inner_x: float
bracket_face_outer_x: float
top_tab_y_min: float
top_tab_y_max: float
bottom_tab_y_min: float
bottom_tab_y_max: float
tab_bands: tuple[tuple[float, float], ...]
bend_bands: tuple[tuple[float, float], ...]
center_bridge_x_min: float | None
front_face_center_y: float
outer_flush_gap: float
@dataclass(frozen=True)
class FlatPattern:
total_length_mm: float
width_mm: float
top_flat_length_mm: float
web_flat_start_mm: float
web_flat_end_mm: float
bottom_flat_start_mm: float
bend_line_positions_mm: tuple[float, float]
bend_line_segments_mm: tuple[tuple[float, float, float], ...]
cut_circles: tuple[tuple[float, float, float], ...]
cut_segments: tuple[tuple[str, tuple[tuple[float, float], ...]], ...]
def _geom_type_name(shape: build123d.Shape) -> str:
geom_type = shape.geom_type
return geom_type.name if hasattr(geom_type, "name") else str(geom_type)
def _face_from_wire(
exterior: build123d.Wire,
interior_wires: list[build123d.Wire] | None = None,
) -> build123d.Face:
holes = list(interior_wires or [])
return build123d.Face(exterior, holes) if holes else build123d.Face(exterior)
def _make_cylinder_along(
*,
radius: float,
height: float,
origin: build123d.Vector,
direction: build123d.Vector,
) -> build123d.Solid:
return build123d.Solid.make_cylinder(
radius,
height,
plane=build123d.Plane(origin=origin, z_dir=direction),
)
def _vector_to_tuple(vector: build123d.Vector) -> tuple[float, float, float]:
return (vector.X, vector.Y, vector.Z)
def _make_box(*, x_min: float, x_max: float, y_min: float, y_max: float, z_min: float, z_max: float) -> build123d.Solid:
return build123d.Solid.make_box(
x_max - x_min,
y_max - y_min,
z_max - z_min,
plane=build123d.Plane(origin=build123d.Vector(x_min, y_min, z_min)),
)
def _slot_profile_points(
*,
center_x: float,
center_y: float,
length: float,
width: float,
segments_per_cap: int = 12,
) -> list[tuple[float, float]]:
radius = 0.5 * width
half_straight = 0.5 * length - radius
bottom_y = center_y - half_straight
top_y = center_y + half_straight
points: list[tuple[float, float]] = []
for index in range(segments_per_cap + 1):
angle = 0.0 + (180.0 * index / segments_per_cap)
points.append(
(
center_x + radius * math.cos(math.radians(angle)),
top_y + radius * math.sin(math.radians(angle)),
)
)
for index in range(segments_per_cap + 1):
angle = 180.0 + (180.0 * index / segments_per_cap)
points.append(
(
center_x + radius * math.cos(math.radians(angle)),
bottom_y + radius * math.sin(math.radians(angle)),
)
)
return points
def _z_slot_cut(
*,
center_x: float,
center_z: float,
length: float,
y_min: float,
y_max: float,
) -> build123d.Shape:
radius = 0.5 * SLOT_WIDTH_MM
half_straight = 0.5 * length - radius
y_start = y_min - CUT_EXTENSION_MM
y_height = (y_max - y_min) + (2.0 * CUT_EXTENSION_MM)
body: build123d.Shape = _make_box(
x_min=center_x - radius,
x_max=center_x + radius,
y_min=y_start,
y_max=y_start + y_height,
z_min=center_z - half_straight,
z_max=center_z + half_straight,
)
for end_z in (center_z - half_straight, center_z + half_straight):
cap = _make_cylinder_along(
radius=radius,
height=y_height,
origin=build123d.Vector(center_x, y_start, end_z),
direction=build123d.Vector(0.0, 1.0, 0.0),
)
body = body.fuse(cap)
return body
def _slot_length(layout: BracketLayout) -> float:
available_length = (layout.z_max - layout.z_min) - (2.0 * SLOT_SIDE_END_GAP_MM)
if SLOT_LENGTH_MM > available_length + 1e-6:
raise RuntimeError(
"Servo end mount slot does not fit across the flange width with the "
"configured end gaps: "
f"{SLOT_LENGTH_MM:.3f} mm > {available_length:.3f} mm"
)
if SLOT_LENGTH_MM <= SLOT_WIDTH_MM:
raise RuntimeError(
"Servo end mount slot does not fit across the flange width: "
f"{SLOT_LENGTH_MM:.3f} mm <= {SLOT_WIDTH_MM:.3f} mm"
)
return SLOT_LENGTH_MM
def _slot_center_x(layout: BracketLayout) -> float:
return layout.bracket_face_inner_x - SLOT_EDGE_TO_BEND_MM - (0.5 * SLOT_WIDTH_MM)
def _slot_specs(layout: BracketLayout) -> tuple[tuple[str, float, float, float], ...]:
center_x = _slot_center_x(layout)
center_z = 0.5 * (layout.z_min + layout.z_max)
length = _slot_length(layout)
return (
("top", center_x, center_z, length),
("bottom", center_x, center_z, length),
)
def _cut_slots(shape: build123d.Shape, layout: BracketLayout) -> build123d.Shape:
for name, center_x, center_z, length in _slot_specs(layout):
if name == "top":
y_min = layout.top_tab_y_min
y_max = layout.top_tab_y_max
else:
y_min = layout.bottom_tab_y_min
y_max = layout.bottom_tab_y_max
shape = shape.cut(
_z_slot_cut(
center_x=center_x,
center_z=center_z,
length=length,
y_min=y_min,
y_max=y_max,
)
)
return shape
def _slot_clearance_metrics(layout: BracketLayout) -> dict[str, float]:
specs = _slot_specs(layout)
min_slot_edge_to_bend = min(
layout.bracket_face_inner_x - (center_x + (0.5 * SLOT_WIDTH_MM))
for _name, center_x, _center_z, _length in specs
)
min_side_end_gap = min(
min(
(center_z - (0.5 * length)) - layout.z_min,
layout.z_max - (center_z + (0.5 * length)),
)
for _name, _center_x, center_z, length in specs
)
min_slot_to_mount_hole_x = min(
abs(center.X - _slot_center_x(layout)) - (radius + (0.5 * SLOT_WIDTH_MM))
for center, radius in [*layout.top_holes, *layout.bottom_holes]
)
center_bridge_margin = (
(_slot_center_x(layout) - (0.5 * SLOT_WIDTH_MM))
- (layout.center_bridge_x_min or layout.bracket_face_inner_x)
)
return {
"slot_side_end_gap_min_mm": min_side_end_gap,
"slot_to_mount_hole_x_min_mm": min_slot_to_mount_hole_x,
"slot_center_bridge_margin_mm": center_bridge_margin,
"slot_edge_to_bend_min_mm": min_slot_edge_to_bend,
"slot_edge_to_bend_leeway_mm": min_slot_edge_to_bend - SENDCUTSEND_HALF_DIE_WIDTH_MM,
}
def _validate_slot_clearances(layout: BracketLayout) -> None:
metrics = _slot_clearance_metrics(layout)
if metrics["slot_edge_to_bend_leeway_mm"] < -1e-6:
raise RuntimeError(
"Slot is too close to the bend for the SendCutSend die clearance: "
f"{metrics['slot_edge_to_bend_min_mm']:.3f} mm < {SENDCUTSEND_HALF_DIE_WIDTH_MM:.3f} mm"
)
if metrics["slot_center_bridge_margin_mm"] + 1e-6 < SLOT_CENTER_BRIDGE_WEB_MIN_MM:
raise RuntimeError(
"Slot is not sufficiently supported by the center bridge: "
f"{metrics['slot_center_bridge_margin_mm']:.3f} mm < "
f"{SLOT_CENTER_BRIDGE_WEB_MIN_MM:.3f} mm"
)
def _assemble_wire(edges: list[build123d.Edge]) -> build123d.Wire:
wires = list(build123d.Wire.combine(edges))
if len(wires) != 1:
raise RuntimeError(f"Expected a single assembled wire, found {len(wires)}")
return wires[0]
def _arc_angles_xz(
*,
center_x: float,
center_z: float,
start_point: tuple[float, float],
end_point: tuple[float, float],
) -> tuple[float, float, build123d.AngularDirection]:
start_angle = math.degrees(math.atan2(start_point[1] - center_z, start_point[0] - center_x))
end_angle = math.degrees(math.atan2(end_point[1] - center_z, end_point[0] - center_x))
ccw_span = (end_angle - start_angle) % 360.0
if ccw_span <= 180.0:
return start_angle, end_angle, build123d.AngularDirection.COUNTER_CLOCKWISE
return start_angle, end_angle, build123d.AngularDirection.CLOCKWISE
def _make_arc_edge_xz(
*,
center_x: float,
center_z: float,
radius: float,
start_point: tuple[float, float],
end_point: tuple[float, float],
) -> build123d.Edge:
start_angle, end_angle, angular_direction = _arc_angles_xz(
center_x=center_x,
center_z=center_z,
start_point=start_point,
end_point=end_point,
)
return build123d.Edge.make_circle(
radius,
plane=build123d.Plane(origin=(center_x, 0.0, center_z), x_dir=(1.0, 0.0, 0.0), z_dir=(0.0, -1.0, 0.0)),
start_angle=start_angle,
end_angle=end_angle,
angular_direction=angular_direction,
)
def _make_wire_prism_y(
*,
edges_xz: list[build123d.Edge],
y_min: float,
y_max: float,
) -> build123d.Solid:
face = _face_from_wire(_assemble_wire(edges_xz))
solid = build123d.Solid.extrude(face, build123d.Vector(0.0, y_max - y_min, 0.0))
return solid.translate((0.0, y_min, 0.0))
def _make_rounded_flange_tab(
*,
x_min: float,
x_max: float,
y_min: float,
y_max: float,
z_min: float,
z_max: float,
radius: float,
) -> build123d.Shape:
radius = min(radius, 0.5 * (z_max - z_min), x_max - x_min)
if radius <= 1e-6:
return _make_box(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, z_min=z_min, z_max=z_max)
lower_tangent = (x_min + radius, z_min)
right_lower = (x_max, z_min)
right_upper = (x_max, z_max)
upper_tangent = (x_min + radius, z_max)
left_upper = (x_min, z_max - radius)
left_lower = (x_min, z_min + radius)
return _make_wire_prism_y(
edges_xz=[
build123d.Edge.make_line((lower_tangent[0], 0.0, lower_tangent[1]), (right_lower[0], 0.0, right_lower[1])),
build123d.Edge.make_line((right_lower[0], 0.0, right_lower[1]), (right_upper[0], 0.0, right_upper[1])),
build123d.Edge.make_line((right_upper[0], 0.0, right_upper[1]), (upper_tangent[0], 0.0, upper_tangent[1])),
_make_arc_edge_xz(
center_x=x_min + radius,
center_z=z_max - radius,
radius=radius,
start_point=upper_tangent,
end_point=left_upper,
),
build123d.Edge.make_line((left_upper[0], 0.0, left_upper[1]), (left_lower[0], 0.0, left_lower[1])),
_make_arc_edge_xz(
center_x=x_min + radius,
center_z=z_min + radius,
radius=radius,
start_point=left_lower,
end_point=lower_tangent,
),
],
y_min=y_min,
y_max=y_max,
)
def _find_face_near(shape: build123d.Shape, target: tuple[float, float, float], *, geom_type: str | None = None) -> build123d.Face:
target_vec = build123d.Vector(*target)
best: tuple[float, build123d.Face] | None = None
for face in shape.faces():
if geom_type is not None and _geom_type_name(face) != geom_type:
continue
dist = face.center().sub(target_vec).length
if best is None or dist < best[0]:
best = (dist, face)
if best is None:
raise RuntimeError(f"No planar face found near {target}")
if best[0] > MAX_FACE_TARGET_ERROR_MM:
raise RuntimeError(
f"Resolved face near {target} was {best[0]:.3f} mm away, which exceeds the tolerance"
)
return best[1]
def _wire_area(wire: build123d.Wire) -> float:
return abs(_face_from_wire(wire).area)
def _outer_wire_and_inner_wires(face: build123d.Face) -> tuple[build123d.Wire, list[build123d.Wire]]:
wires = list(face.wires())
if not wires:
raise RuntimeError("Face has no wires")
outer_wire = max(wires, key=_wire_area)
inner_wires = [wire for wire in wires if not wire.is_same(outer_wire)]
return outer_wire, inner_wires
def _extract_inner_hole_profiles(face: build123d.Face) -> list[tuple[build123d.Vector, float]]:
_outer_wire, inner_wires = _outer_wire_and_inner_wires(face)
profiles: list[tuple[build123d.Vector, float]] = []
for wire in inner_wires:
circle_edges = [edge for edge in wire.edges() if _geom_type_name(edge) == "CIRCLE"]
if not circle_edges:
continue
center = _face_from_wire(wire).center()
radius = max(edge.radius for edge in circle_edges)
profiles.append((center, radius))
return profiles
def _select_near_end_holes(
profiles: list[tuple[build123d.Vector, float]],
*,
count: int,
) -> list[tuple[build123d.Vector, float]]:
if len(profiles) < count:
raise RuntimeError(f"Expected at least {count} mount holes, found {len(profiles)}")
selected = sorted(profiles, key=lambda item: (item[0].X, abs(item[0].Z)), reverse=True)[:count]
selected.sort(key=lambda item: item[0].Z)
return selected
def _cut_mount_holes(
body: build123d.Shape,
*,
profiles: list[tuple[build123d.Vector, float]],
start_y: float,
direction_y: float,
) -> build123d.Shape:
hole_depth = SENDCUTSEND_SHEET_THICKNESS_MM + (2.0 * CUT_EXTENSION_MM)
for center, radius in profiles:
cutter = _make_cylinder_along(
radius=radius,
height=hole_depth,
origin=build123d.Vector(center.X, start_y, center.Z),
direction=build123d.Vector(0.0, direction_y, 0.0),
)
body = body.cut(cutter)
return body
def _front_horn_mount_hole_profiles(layout: BracketLayout) -> tuple[tuple[float, float, float], ...]:
center_y = layout.front_face_center_y
center_z = 0.0
radius = FRONT_HORN_SCREW_CIRCLE_RADIUS_MM
return (
(center_y, center_z, FRONT_HORN_CENTER_CLEARANCE_RADIUS_MM),
(center_y, center_z + radius, FRONT_HORN_M3_CLEARANCE_RADIUS_MM),
(center_y, center_z - radius, FRONT_HORN_M3_CLEARANCE_RADIUS_MM),
(center_y + radius, center_z, FRONT_HORN_M3_CLEARANCE_RADIUS_MM),
(center_y - radius, center_z, FRONT_HORN_M3_CLEARANCE_RADIUS_MM),
)
def _cut_front_horn_mount_holes(body: build123d.Shape, *, layout: BracketLayout) -> build123d.Shape:
hole_depth = SENDCUTSEND_SHEET_THICKNESS_MM + (2.0 * CUT_EXTENSION_MM)
start_x = layout.bracket_face_outer_x + CUT_EXTENSION_MM
for center_y, center_z, radius in _front_horn_mount_hole_profiles(layout):
cutter = _make_cylinder_along(
radius=radius,
height=hole_depth,
origin=build123d.Vector(start_x, center_y, center_z),
direction=build123d.Vector(-1.0, 0.0, 0.0),
)
body = body.cut(cutter)
return body
def _apply_bends_and_end_rounds(
bracket: build123d.Shape,
*,
bracket_face_inner_x: float,
bracket_face_outer_x: float,
top_tab_x_min: float,
bottom_tab_x_min: float,
top_tab_y_min: float,
top_tab_y_max: float,
bottom_tab_y_min: float,
bottom_tab_y_max: float,
bend_bands: tuple[tuple[float, float], ...],
) -> build123d.Shape:
if BEND_INNER_RADIUS_MM < 0.0 or BEND_OUTER_RADIUS_MM < 0.0:
raise ValueError("Bend radii must be >= 0")
if BEND_INNER_RADIUS_MM == 0.0 and BEND_OUTER_RADIUS_MM == 0.0:
return bracket
def _long_z_edge_near(*, x: float, y: float, tolerance: float = 0.08) -> build123d.ShapeList:
return bracket.edges().filter_by(build123d.Axis.Z).filter_by(
lambda edge: abs(edge.center().X - x) <= tolerance
and abs(edge.center().Y - y) <= tolerance
and edge.length > 0.5 * (max(z_max - z_min for z_min, z_max in bend_bands))
)
inner_bend_edges = build123d.ShapeList()
for bend_y in (top_tab_y_min, bottom_tab_y_max):
inner_bend_edges.extend(_long_z_edge_near(x=bracket_face_inner_x, y=bend_y))
if len(inner_bend_edges) != 2:
raise RuntimeError(f"Expected two inside bend edges to fillet, found {len(inner_bend_edges)}")
if BEND_INNER_RADIUS_MM > 0.0:
bracket = bracket.fillet(BEND_INNER_RADIUS_MM, inner_bend_edges)
def _outer_long_z_edge_near(*, x: float, y: float, tolerance: float = 0.65) -> build123d.ShapeList:
return bracket.edges().filter_by(build123d.Axis.Z).filter_by(
lambda edge: abs(edge.center().X - x) <= tolerance
and abs(edge.center().Y - y) <= tolerance
and edge.length > 0.5 * (max(z_max - z_min for z_min, z_max in bend_bands))
)
outer_bend_edges = build123d.ShapeList()
for bend_y in (top_tab_y_max, bottom_tab_y_min):
outer_bend_edges.extend(_outer_long_z_edge_near(x=bracket_face_outer_x, y=bend_y))
if len(outer_bend_edges) != 2:
raise RuntimeError(f"Expected two outside bend edges to fillet, found {len(outer_bend_edges)}")
if BEND_OUTER_RADIUS_MM > 0.0:
bracket = bracket.fillet(BEND_OUTER_RADIUS_MM, outer_bend_edges)
return bracket
def _min_mount_hole_distance_to_bend_line(
profiles: list[tuple[build123d.Vector, float]],
*,
bend_line_x: float,
) -> float:
return min(abs(center.X - bend_line_x) for center, _radius in profiles)
def _merge_z_bands(
bands: list[tuple[float, float]] | tuple[tuple[float, float], ...],
*,
tolerance: float = 1e-6,
) -> tuple[tuple[float, float], ...]:
merged: list[tuple[float, float]] = []
for start, end in sorted(bands, key=lambda band: band[0]):
if end - start <= tolerance:
continue
if not merged or start > merged[-1][1] + tolerance:
merged.append((start, end))
continue
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
return tuple(merged)
def _build_layout(
servo_shape: build123d.Shape,
*,
include_top_center_bridge: bool = True,
validate_bend_rule: bool = True,
) -> BracketLayout:
faces = {
"end_face": _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["end_face"], geom_type="PLANE"),
"end_wrap_neg": _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["end_wrap_neg"]),
"end_wrap_pos": _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["end_wrap_pos"]),
"upper_top_neg": _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["upper_top_neg"], geom_type="PLANE"),
"upper_top_pos": _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["upper_top_pos"], geom_type="PLANE"),
"main_bottom": _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["main_bottom"], geom_type="PLANE"),
}
servo_bb = servo_shape.bounding_box()
end_face_bb = faces["end_face"].bounding_box()
servo_z_center = 0.5 * (servo_bb.min.Z + servo_bb.max.Z)
servo_half_width_z = 0.5 * (servo_bb.max.Z - servo_bb.min.Z)
mount_half_width_z = servo_half_width_z + SIDE_WRAP_CLEARANCE_MM
z_min = servo_z_center - mount_half_width_z
z_max = servo_z_center + mount_half_width_z
top_holes = _select_near_end_holes(_extract_inner_hole_profiles(faces["upper_top_neg"]), count=1)
top_holes += _select_near_end_holes(_extract_inner_hole_profiles(faces["upper_top_pos"]), count=1)
top_holes.sort(key=lambda item: item[0].Z)
bottom_holes = _select_near_end_holes(_extract_inner_hole_profiles(faces["main_bottom"]), count=2)
top_mount_y = sum(center.Y for center, _radius in top_holes) / len(top_holes)
bottom_mount_y = sum(center.Y for center, _radius in bottom_holes) / len(bottom_holes)
top_tab_y_min = top_mount_y + TOP_MOUNT_PLANE_CLEARANCE_MM
top_tab_y_max = top_tab_y_min + SENDCUTSEND_SHEET_THICKNESS_MM
bottom_tab_y_max = bottom_mount_y - BOTTOM_MOUNT_PLANE_CLEARANCE_MM
bottom_tab_y_min = bottom_tab_y_max - SENDCUTSEND_SHEET_THICKNESS_MM
side_profiles = {
"neg": [top_holes[0], bottom_holes[0]],
"pos": [top_holes[1], bottom_holes[1]],
}
tab_bands: list[tuple[float, float]] = []
for top_profile, bottom_profile in side_profiles.values():
profiles = (top_profile, bottom_profile)
if sum(center.Z for center, _radius in profiles) < 0.0:
z_band_min = min(center.Z - radius - MOUNT_HOLE_OUTBOARD_Z_WEB_MM for center, radius in profiles)
z_band_max = max(center.Z + radius + MOUNT_HOLE_INBOARD_Z_WEB_MM for center, radius in profiles)
else:
z_band_min = min(center.Z - radius - MOUNT_HOLE_INBOARD_Z_WEB_MM for center, radius in profiles)
z_band_max = max(center.Z + radius + MOUNT_HOLE_OUTBOARD_Z_WEB_MM for center, radius in profiles)
z_band_min = max(z_band_min, z_min)
z_band_max = min(z_band_max, z_max)
tab_bands.append((z_band_min, z_band_max))
sorted_tab_bands = sorted(tab_bands, key=lambda band: band[0])
if include_top_center_bridge and len(sorted_tab_bands) >= 2:
lower_z_min, lower_z_max = sorted_tab_bands[0]
upper_z_min, upper_z_max = sorted_tab_bands[-1]
adjusted_lower_z_max = lower_z_max - TOP_CENTER_RELIEF_SIDE_CLEARANCE_MM
adjusted_upper_z_min = upper_z_min + TOP_CENTER_RELIEF_SIDE_CLEARANCE_MM
if adjusted_lower_z_max <= lower_z_min or adjusted_upper_z_min >= upper_z_max:
raise RuntimeError(
"Top center relief side clearance consumes the mount tab bands: "
f"clearance={TOP_CENTER_RELIEF_SIDE_CLEARANCE_MM:.3f} mm"
)
sorted_tab_bands = [
(lower_z_min, adjusted_lower_z_max),
*sorted_tab_bands[1:-1],
(adjusted_upper_z_min, upper_z_max),
]
bend_bands = _merge_z_bands(sorted_tab_bands)
top_tab_x_min = (
min(center.X - radius - MOUNT_HOLE_EDGE_X_WEB_MM for center, radius in top_holes)
- TOP_FLANGE_LENGTH_EXTENSION_MM
)
bottom_tab_x_min = (
min(center.X - radius - MOUNT_HOLE_EDGE_X_WEB_MM for center, radius in bottom_holes)
- BOTTOM_FLANGE_LENGTH_EXTENSION_MM
)
side_face_target_center_bridge_x_min = (
top_tab_x_min
+ FLANGE_FREE_CORNER_RADIUS_MM
+ CENTER_BRIDGE_SIDE_FACE_LENGTH_MM
)
base_bracket_face_outer_x = (
end_face_bb.max.X
+ BRACKET_FACE_CLEARANCE_MM
+ SENDCUTSEND_SHEET_THICKNESS_MM
)
side_face_bracket_face_outer_x = (
side_face_target_center_bridge_x_min
+ CENTER_BRIDGE_BEND_EDGE_DISTANCE_TARGET_MM
)
bracket_face_outer_x = max(base_bracket_face_outer_x, side_face_bracket_face_outer_x)
bracket_face_inner_x = bracket_face_outer_x - SENDCUTSEND_SHEET_THICKNESS_MM
center_bridge_x_min: float | None = None
if include_top_center_bridge and len(sorted_tab_bands) >= 2:
center_gap_z_min = sorted_tab_bands[0][1]
center_gap_z_max = sorted_tab_bands[-1][0]
if center_gap_z_max - center_gap_z_min > 1e-3:
candidate_center_bridge_x_min = max(
bracket_face_outer_x - CENTER_BRIDGE_BEND_LINE_EDGE_TARGET_MM,
top_tab_x_min,
end_face_bb.max.X + TOP_CENTER_BRIDGE_SERVO_END_CLEARANCE_MM,
side_face_target_center_bridge_x_min,
)
if candidate_center_bridge_x_min < bracket_face_outer_x - 1e-3:
center_bridge_x_min = candidate_center_bridge_x_min
bend_bands = _merge_z_bands((*sorted_tab_bands, (center_gap_z_min, center_gap_z_max)))
min_hole_to_bend_line = min(
_min_mount_hole_distance_to_bend_line(top_holes, bend_line_x=bracket_face_inner_x),
_min_mount_hole_distance_to_bend_line(bottom_holes, bend_line_x=bracket_face_inner_x),
)
if (
validate_bend_rule
and min_hole_to_bend_line + BEND_DIE_REGION_TOLERANCE_MM < SENDCUTSEND_HALF_DIE_WIDTH_MM
):
raise RuntimeError(
"Mount holes are too close to the bend line for the configured sheet-metal bend rule: "
f"{min_hole_to_bend_line:.3f} mm < {SENDCUTSEND_HALF_DIE_WIDTH_MM:.3f} mm"
)
front_face_center_y = 0.5 * (bottom_tab_y_min + top_tab_y_max)
outer_flush_gap = min(
min(
z_max - (center.Z + radius) if center.Z >= 0.0 else (center.Z - radius) - z_min
for center, radius in top_holes
),
min(
z_max - (center.Z + radius) if center.Z >= 0.0 else (center.Z - radius) - z_min
for center, radius in bottom_holes
),
)
if outer_flush_gap <= 0.0:
raise RuntimeError(f"Trimmed mount width cuts into a mounting hole: outboard web {outer_flush_gap:.3f} mm")
return BracketLayout(
z_min=z_min,
z_max=z_max,
top_holes=tuple(top_holes),
bottom_holes=tuple(bottom_holes),
top_tab_x_min=top_tab_x_min,
bottom_tab_x_min=bottom_tab_x_min,
bracket_face_inner_x=bracket_face_inner_x,
bracket_face_outer_x=bracket_face_outer_x,
top_tab_y_min=top_tab_y_min,
top_tab_y_max=top_tab_y_max,
bottom_tab_y_min=bottom_tab_y_min,
bottom_tab_y_max=bottom_tab_y_max,
tab_bands=tuple(sorted_tab_bands),
bend_bands=bend_bands,
center_bridge_x_min=center_bridge_x_min,
front_face_center_y=front_face_center_y,
outer_flush_gap=outer_flush_gap,
)
def build_bracket(
servo_shape: build123d.Shape,
*,
include_top_center_bridge: bool = True,
validate_bend_rule: bool = True,
) -> tuple[build123d.Shape, BracketLayout]:
layout = _build_layout(
servo_shape,
include_top_center_bridge=include_top_center_bridge,
validate_bend_rule=validate_bend_rule,
)
bracket: build123d.Shape = _make_box(
x_min=layout.bracket_face_inner_x,
x_max=layout.bracket_face_outer_x,
y_min=layout.bottom_tab_y_min,
y_max=layout.top_tab_y_max,
z_min=layout.z_min,
z_max=layout.z_max,
)
for z_band_min, z_band_max in layout.tab_bands:
bracket = bracket.fuse(
_make_rounded_flange_tab(
x_min=layout.top_tab_x_min,
x_max=layout.bracket_face_outer_x,
y_min=layout.top_tab_y_min,
y_max=layout.top_tab_y_max,
z_min=z_band_min,
z_max=z_band_max,
radius=FLANGE_FREE_CORNER_RADIUS_MM,
)
)
if layout.center_bridge_x_min is not None and len(layout.tab_bands) >= 2:
center_gap_z_min = layout.tab_bands[0][1]
center_gap_z_max = layout.tab_bands[-1][0]
bracket = bracket.fuse(
_make_box(
x_min=layout.center_bridge_x_min,
x_max=layout.bracket_face_outer_x,
y_min=layout.top_tab_y_min,
y_max=layout.top_tab_y_max,
z_min=center_gap_z_min,
z_max=center_gap_z_max,
)
)
for z_band_min, z_band_max in layout.tab_bands:
bracket = bracket.fuse(
_make_rounded_flange_tab(
x_min=layout.bottom_tab_x_min,
x_max=layout.bracket_face_outer_x,
y_min=layout.bottom_tab_y_min,
y_max=layout.bottom_tab_y_max,
z_min=z_band_min,
z_max=z_band_max,
radius=FLANGE_FREE_CORNER_RADIUS_MM,
)
)
if layout.center_bridge_x_min is not None and len(layout.tab_bands) >= 2:
center_gap_z_min = layout.tab_bands[0][1]
center_gap_z_max = layout.tab_bands[-1][0]
bracket = bracket.fuse(
_make_box(
x_min=layout.center_bridge_x_min,
x_max=layout.bracket_face_outer_x,
y_min=layout.bottom_tab_y_min,
y_max=layout.bottom_tab_y_max,
z_min=center_gap_z_min,
z_max=center_gap_z_max,
)
)
bracket = _apply_bends_and_end_rounds(
bracket,
bracket_face_inner_x=layout.bracket_face_inner_x,
bracket_face_outer_x=layout.bracket_face_outer_x,
top_tab_x_min=layout.top_tab_x_min,
bottom_tab_x_min=layout.bottom_tab_x_min,
top_tab_y_min=layout.top_tab_y_min,
top_tab_y_max=layout.top_tab_y_max,
bottom_tab_y_min=layout.bottom_tab_y_min,
bottom_tab_y_max=layout.bottom_tab_y_max,
bend_bands=layout.bend_bands,
)
bracket = _cut_mount_holes(
bracket,
profiles=list(layout.top_holes),
start_y=layout.top_tab_y_max + CUT_EXTENSION_MM,
direction_y=-1.0,
)
bracket = _cut_mount_holes(
bracket,
profiles=list(layout.bottom_holes),
start_y=layout.bottom_tab_y_min - CUT_EXTENSION_MM,
direction_y=1.0,
)
bracket = _cut_front_horn_mount_holes(bracket, layout=layout)
return bracket, layout
def _sendcutsend_outside_setback_90_mm() -> float:
return SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM + SENDCUTSEND_SHEET_THICKNESS_MM
def _sendcutsend_bend_allowance_90_mm() -> float:
return (2.0 * _sendcutsend_outside_setback_90_mm()) - SENDCUTSEND_BEND_DEDUCTION_90_MM
def _validate_sendcutsend_bend_rule() -> None:
bend_deduction_error = abs(
SENDCUTSEND_BEND_DEDUCTION_90_MM
- ((2.0 * _sendcutsend_outside_setback_90_mm()) - _sendcutsend_bend_allowance_90_mm())
)
if bend_deduction_error > 0.05:
raise RuntimeError(
"SendCutSend bend-rule constants are inconsistent: "
f"bend deduction error {bend_deduction_error:.3f} mm is too large"
)
def _min_flange_outside_length(layout: BracketLayout) -> float:
return min(
layout.bracket_face_outer_x - layout.top_tab_x_min,
layout.bracket_face_outer_x - layout.bottom_tab_x_min,
)
def _supported_bend_fraction(layout: BracketLayout) -> float:
bend_width = layout.z_max - layout.z_min
if bend_width <= 0.0:
return 0.0
supported_width = sum(z_band_max - z_band_min for z_band_min, z_band_max in layout.bend_bands)
return supported_width / bend_width
def _min_bend_segment_length(layout: BracketLayout) -> float:
return min(z_band_max - z_band_min for z_band_min, z_band_max in layout.bend_bands)
def _center_bridge_z_range(layout: BracketLayout) -> tuple[float, float] | None:
if layout.center_bridge_x_min is None or len(layout.tab_bands) < 2:
return None
z_min = layout.tab_bands[0][1]
z_max = layout.tab_bands[-1][0]
if z_max - z_min <= 1e-6:
return None
return z_min, z_max
def _center_bridge_formed_length(layout: BracketLayout) -> float | None:
if layout.center_bridge_x_min is None:
return None
return layout.bracket_face_inner_x - layout.center_bridge_x_min
def _center_bridge_bend_edge_distance(layout: BracketLayout) -> float | None:
if layout.center_bridge_x_min is None:
return None
return layout.bracket_face_outer_x - layout.center_bridge_x_min
def _center_bridge_side_face_length(layout: BracketLayout) -> float | None:
if layout.center_bridge_x_min is None:
return None
return layout.center_bridge_x_min - (layout.top_tab_x_min + FLANGE_FREE_CORNER_RADIUS_MM)
def _center_bridge_flat_length(layout: BracketLayout, flat_pattern: FlatPattern) -> float | None:
formed_length = _center_bridge_formed_length(layout)
if formed_length is None:
return None
# Keep the flat-pattern edge at least the required distance from the drawn
# bend line while preserving the formed center-bridge length in the 3D part.
bend_line_clearance_flat_length = (
CENTER_BRIDGE_BEND_LINE_EDGE_TARGET_MM
- (flat_pattern.bend_line_positions_mm[0] - flat_pattern.top_flat_length_mm)
)
return max(formed_length, bend_line_clearance_flat_length)
def _center_bridge_flat_bend_line_to_edge_distance(layout: BracketLayout, flat_pattern: FlatPattern) -> float | None:
flat_length = _center_bridge_flat_length(layout, flat_pattern)
if flat_length is None:
return None
return flat_length + (flat_pattern.bend_line_positions_mm[0] - flat_pattern.top_flat_length_mm)
def _build_flat_pattern(
layout: BracketLayout,
) -> FlatPattern:
_validate_sendcutsend_bend_rule()
outside_setback = _sendcutsend_outside_setback_90_mm()
bend_allowance = _sendcutsend_bend_allowance_90_mm()
top_outside_length = layout.bracket_face_outer_x - layout.top_tab_x_min
bottom_outside_length = layout.bracket_face_outer_x - layout.bottom_tab_x_min
top_flat_length = top_outside_length - outside_setback
bottom_flat_length = bottom_outside_length - outside_setback
if top_flat_length <= 0.0 or bottom_flat_length <= 0.0:
raise RuntimeError(
"Flat flange length implied by the bend rule is too short: "
f"top={top_flat_length:.3f} mm, bottom={bottom_flat_length:.3f} mm"
)
flange_outside_min = _min_flange_outside_length(layout)
flange_flat_min = flange_outside_min - outside_setback
if flange_outside_min < SENDCUTSEND_MIN_FLANGE_FORMED_MM:
raise RuntimeError(
"Formed flange length is below the configured bend-rule minimum: "
f"{flange_outside_min:.3f} mm < {SENDCUTSEND_MIN_FLANGE_FORMED_MM:.3f} mm"
)
if flange_flat_min < SENDCUTSEND_MIN_FLANGE_FLAT_MM:
raise RuntimeError(
"Flat flange length is below the configured bend-rule minimum: "
f"{flange_flat_min:.3f} mm < {SENDCUTSEND_MIN_FLANGE_FLAT_MM:.3f} mm"
)
top_web_tangent_y = layout.top_tab_y_min - SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM
bottom_web_tangent_y = layout.bottom_tab_y_max + SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM
web_flat_length = top_web_tangent_y - bottom_web_tangent_y
if web_flat_length <= 0.0:
raise RuntimeError(f"Web flat length implied by the bend rule is too short: {web_flat_length:.3f} mm")
web_flat_start = top_flat_length + bend_allowance
web_flat_end = web_flat_start + web_flat_length
bottom_flat_start = web_flat_end + bend_allowance
total_length = bottom_flat_start + bottom_flat_length
width = layout.z_max - layout.z_min
top_bend_line_x = top_flat_length + (0.5 * bend_allowance)
bottom_bend_line_x = web_flat_end + (0.5 * bend_allowance)
bend_line_segments = (
(top_bend_line_x, 0.0, width),
(bottom_bend_line_x, 0.0, width),
)
cut_circles: list[tuple[float, float, float]] = []
for center, radius in layout.top_holes:
cut_circles.append(
(
center.X - layout.top_tab_x_min,
center.Z - layout.z_min,
radius,
)
)
for center, radius in layout.bottom_holes:
cut_circles.append(
(
total_length - (center.X - layout.bottom_tab_x_min),
center.Z - layout.z_min,
radius,
)
)
for center_y, center_z, radius in _front_horn_mount_hole_profiles(layout):
cut_circles.append(
(
web_flat_start + (top_web_tangent_y - center_y),
center_z - layout.z_min,
radius,
)
)
flat_pattern = FlatPattern(
total_length_mm=total_length,
width_mm=width,
top_flat_length_mm=top_flat_length,
web_flat_start_mm=web_flat_start,
web_flat_end_mm=web_flat_end,
bottom_flat_start_mm=bottom_flat_start,
bend_line_positions_mm=(top_bend_line_x, bottom_bend_line_x),
bend_line_segments_mm=bend_line_segments,
cut_circles=tuple(cut_circles),
cut_segments=(),
)
center_bridge_flat_clearance = _center_bridge_flat_bend_line_to_edge_distance(layout, flat_pattern)
if (
center_bridge_flat_clearance is not None
and center_bridge_flat_clearance + 1e-6 < CENTER_BRIDGE_BEND_LINE_EDGE_TARGET_MM
):
raise RuntimeError(
"Top center bridge flat bend-line clearance is below the configured target: "
f"{center_bridge_flat_clearance:.3f} mm < {CENTER_BRIDGE_BEND_LINE_EDGE_TARGET_MM:.3f} mm"
)
return flat_pattern
def _flat_rectangles(layout: BracketLayout, flat_pattern: FlatPattern) -> tuple[tuple[float, float, float, float], ...]:
rectangles: list[tuple[float, float, float, float]] = [
(
flat_pattern.top_flat_length_mm,
0.0,
flat_pattern.bottom_flat_start_mm,
flat_pattern.width_mm,
)
]
for z_band_min, z_band_max in layout.tab_bands:
y_min = z_band_min - layout.z_min
y_max = z_band_max - layout.z_min
rectangles.append((0.0, y_min, flat_pattern.top_flat_length_mm, y_max))
center_bridge_z_range = _center_bridge_z_range(layout)
center_bridge_flat_length = _center_bridge_flat_length(layout, flat_pattern)
if center_bridge_z_range is not None and center_bridge_flat_length is not None:
y_min = center_bridge_z_range[0] - layout.z_min
y_max = center_bridge_z_range[1] - layout.z_min
x_min = flat_pattern.top_flat_length_mm - center_bridge_flat_length
rectangles.append((x_min, y_min, flat_pattern.top_flat_length_mm, y_max))
for z_band_min, z_band_max in layout.tab_bands:
y_min = z_band_min - layout.z_min
y_max = z_band_max - layout.z_min
rectangles.append((flat_pattern.bottom_flat_start_mm, y_min, flat_pattern.total_length_mm, y_max))
if center_bridge_z_range is not None and center_bridge_flat_length is not None:
y_min = center_bridge_z_range[0] - layout.z_min
y_max = center_bridge_z_range[1] - layout.z_min
x_max = flat_pattern.bottom_flat_start_mm + center_bridge_flat_length
rectangles.append((flat_pattern.bottom_flat_start_mm, y_min, x_max, y_max))
return tuple(rectangle for rectangle in rectangles if rectangle[2] - rectangle[0] > 1e-6 and rectangle[3] - rectangle[1] > 1e-6)
def _flange_free_corner_points(
layout: BracketLayout,
flat_pattern: FlatPattern,
) -> set[tuple[float, float]]:
points: set[tuple[float, float]] = set()
for z_band_min, z_band_max in layout.tab_bands:
flat_z_min = z_band_min - layout.z_min
flat_z_max = z_band_max - layout.z_min
points.add((0.0, flat_z_min))
points.add((0.0, flat_z_max))
for z_band_min, z_band_max in layout.tab_bands:
flat_z_min = z_band_min - layout.z_min
flat_z_max = z_band_max - layout.z_min
points.add((flat_pattern.total_length_mm, flat_z_min))
points.add((flat_pattern.total_length_mm, flat_z_max))
return points
def _sample_short_arc_2d(
*,
center: tuple[float, float],
start: tuple[float, float],
end: tuple[float, float],
count: int,
) -> list[tuple[float, float]]:
start_angle = math.atan2(start[1] - center[1], start[0] - center[0])
end_angle = math.atan2(end[1] - center[1], end[0] - center[0])
ccw_span = (end_angle - start_angle) % (2.0 * math.pi)
if ccw_span <= math.pi:
span = ccw_span
else:
span = -((start_angle - end_angle) % (2.0 * math.pi))
samples = max(count, 2)
radius = math.hypot(start[0] - center[0], start[1] - center[1])
return [
(
center[0] + (radius * math.cos(start_angle + (span * index / (samples - 1)))),
center[1] + (radius * math.sin(start_angle + (span * index / (samples - 1)))),
)
for index in range(samples)
]
def _rounded_outline_loop_points(
loop: list[tuple[float, float]],
*,
rounded_corners: set[tuple[float, float]],
radius: float,
) -> list[tuple[float, float]]:
if radius <= 1e-6:
return loop
rounded_loop: list[tuple[float, float]] = []
for index, point in enumerate(loop):
if point not in rounded_corners:
rounded_loop.append(point)
continue
previous_point = loop[index - 1]
next_point = loop[(index + 1) % len(loop)]
prev_vec = (previous_point[0] - point[0], previous_point[1] - point[1])
next_vec = (next_point[0] - point[0], next_point[1] - point[1])
prev_len = math.hypot(*prev_vec)
next_len = math.hypot(*next_vec)
corner_radius = min(radius, 0.5 * prev_len, 0.5 * next_len)
if corner_radius <= 1e-6:
rounded_loop.append(point)
continue
prev_unit = (prev_vec[0] / prev_len, prev_vec[1] / prev_len)
next_unit = (next_vec[0] / next_len, next_vec[1] / next_len)
tangent_in = (
point[0] + (prev_unit[0] * corner_radius),
point[1] + (prev_unit[1] * corner_radius),
)
tangent_out = (
point[0] + (next_unit[0] * corner_radius),
point[1] + (next_unit[1] * corner_radius),
)
center = (
point[0] + ((prev_unit[0] + next_unit[0]) * corner_radius),
point[1] + ((prev_unit[1] + next_unit[1]) * corner_radius),
)
arc_points = _sample_short_arc_2d(
center=center,
start=tangent_in,
end=tangent_out,
count=FLANGE_FREE_CORNER_SAMPLE_COUNT,
)
rounded_loop.extend(arc_points)
return rounded_loop
def _outline_loops_from_rectangles(
rectangles: tuple[tuple[float, float, float, float], ...],
) -> list[list[tuple[float, float]]]:
x_coords = sorted({coord for x0, _y0, x1, _y1 in rectangles for coord in (x0, x1)})
y_coords = sorted({coord for _x0, y0, _x1, y1 in rectangles for coord in (y0, y1)})
filled_cells: set[tuple[int, int]] = set()
for x_index in range(len(x_coords) - 1):
for y_index in range(len(y_coords) - 1):
x_mid = 0.5 * (x_coords[x_index] + x_coords[x_index + 1])
y_mid = 0.5 * (y_coords[y_index] + y_coords[y_index + 1])
if any(x0 < x_mid < x1 and y0 < y_mid < y1 for x0, y0, x1, y1 in rectangles):
filled_cells.add((x_index, y_index))
edges: dict[tuple[tuple[float, float], tuple[float, float]], None] = {}
def add_edge(start: tuple[float, float], end: tuple[float, float]) -> None:
reverse = (end, start)
if reverse in edges:
del edges[reverse]
return
edges[(start, end)] = None
for x_index, y_index in filled_cells:
x0 = x_coords[x_index]
x1 = x_coords[x_index + 1]
y0 = y_coords[y_index]
y1 = y_coords[y_index + 1]
add_edge((x0, y0), (x1, y0))
add_edge((x1, y0), (x1, y1))
add_edge((x1, y1), (x0, y1))
add_edge((x0, y1), (x0, y0))
outgoing: dict[tuple[float, float], list[tuple[float, float]]] = {}
for start, end in edges:
outgoing.setdefault(start, []).append(end)
loops: list[list[tuple[float, float]]] = []
while outgoing:
start = min(outgoing)
loop = [start]
current = start
while True:
next_points = outgoing.get(current)
if not next_points:
raise RuntimeError("Flat-pattern outline could not be assembled from panel rectangles")
next_point = next_points.pop()
if not next_points:
del outgoing[current]
if next_point == start:
break
loop.append(next_point)
current = next_point
area = _polygon_area(loop)
if area < 0.0:
loop.reverse()
loops.append(loop)
return loops
def _polygon_area(points: list[tuple[float, float]]) -> float:
return 0.5 * sum(
(points[index][0] * points[(index + 1) % len(points)][1])
- (points[(index + 1) % len(points)][0] * points[index][1])
for index in range(len(points))
)
def _add_reference_arc(
msp: ezdxf.layouts.Modelspace,
*,
start: tuple[float, float],
midpoint: tuple[float, float],
end: tuple[float, float],
) -> None:
from ezdxf.math import ConstructionArc
arc = ConstructionArc.from_3p(start, end, def_point=midpoint, ccw=True)
center = (arc.center.x, arc.center.y)
start_angle = math.atan2(start[1] - center[1], start[0] - center[0])
mid_angle = math.atan2(midpoint[1] - center[1], midpoint[0] - center[0])
end_angle = math.atan2(end[1] - center[1], end[0] - center[0])
ccw_sweep = (end_angle - start_angle) % (2.0 * math.pi)
mid_sweep = (mid_angle - start_angle) % (2.0 * math.pi)
if mid_sweep <= ccw_sweep + 1e-6:
msp.add_arc(
center=center,
radius=arc.radius,
start_angle=math.degrees(start_angle),
end_angle=math.degrees(end_angle),
dxfattribs={"layer": "CUT"},
)
return
msp.add_arc(
center=center,
radius=arc.radius,
start_angle=math.degrees(end_angle),
end_angle=math.degrees(start_angle),
dxfattribs={"layer": "CUT"},
)
def _build_flat_pattern_dxf(flat_pattern: FlatPattern, layout: BracketLayout) -> ezdxf.document.Drawing:
doc = ezdxf.new("R2010", setup=True)
doc.units = DXF_UNIT_MM
if "CUT" not in doc.layers:
doc.layers.add("CUT")
if "BEND" not in doc.layers:
doc.layers.add("BEND", linetype="DASHED")
msp = doc.modelspace()
rounded_corners = _flange_free_corner_points(layout, flat_pattern)
for loop in _outline_loops_from_rectangles(_flat_rectangles(layout, flat_pattern)):
rounded_loop = _rounded_outline_loop_points(
loop,
rounded_corners=rounded_corners,
radius=FLANGE_FREE_CORNER_RADIUS_MM,
)
for index, point in enumerate(rounded_loop):
msp.add_line(point, rounded_loop[(index + 1) % len(rounded_loop)], dxfattribs={"layer": "CUT"})
for x, y, cut_radius in flat_pattern.cut_circles:
msp.add_circle((x, y), cut_radius, dxfattribs={"layer": "CUT"})
for segment_type, points in flat_pattern.cut_segments:
if segment_type == "LINE":
msp.add_line(points[0], points[-1], dxfattribs={"layer": "CUT"})
elif segment_type == "ARC":
_add_reference_arc(msp, start=points[0], midpoint=points[1], end=points[-1])
else:
raise RuntimeError(f"Unsupported flat-pattern cut segment type: {segment_type}")
for bend_line_x, bend_y_min, bend_y_max in flat_pattern.bend_line_segments_mm:
msp.add_line(
(bend_line_x, bend_y_min),
(bend_line_x, bend_y_max),
dxfattribs={"layer": "BEND", "linetype": "DASHED"},
)
return doc
def _topology_flat_pattern_dxf(
bracket: build123d.Shape,
layout: BracketLayout,
flat_pattern: FlatPattern,
) -> ezdxf.document.Drawing:
doc = ezdxf.new("R2010", setup=True)
doc.units = DXF_UNIT_MM
if "CUT" not in doc.layers:
doc.layers.add("CUT")
if "BEND" not in doc.layers:
doc.layers.add("BEND", linetype="DASHED")
web_source_span = layout.top_tab_y_min - layout.bottom_tab_y_max
web_flat_span = flat_pattern.bottom_flat_start_mm - flat_pattern.top_flat_length_mm
if web_source_span <= 0.0 or web_flat_span <= 0.0:
raise RuntimeError("Servo end mount topology DXF has invalid web span")
def project_top(point: build123d.Vector) -> tuple[float, float]:
if abs(point.X - layout.bracket_face_outer_x) <= 1e-3:
u = flat_pattern.top_flat_length_mm
else:
u = point.X - layout.top_tab_x_min
return (u, point.Z - layout.z_min)
def project_web(point: build123d.Vector) -> tuple[float, float]:
t = (layout.top_tab_y_min - point.Y) / web_source_span
return (
flat_pattern.top_flat_length_mm + (t * web_flat_span),
point.Z - layout.z_min,
)
def project_bottom(point: build123d.Vector) -> tuple[float, float]:
if abs(point.X - layout.bracket_face_outer_x) <= 1e-3:
u = flat_pattern.bottom_flat_start_mm
else:
u = flat_pattern.total_length_mm - (point.X - layout.bottom_tab_x_min)
return (u, point.Z - layout.z_min)
top_faces = dxf_topology.planar_faces(
bracket,
normal_axis="y",
normal_sign=1.0,
coordinate_axis="y",
coordinate=layout.top_tab_y_max,
)
web_faces = dxf_topology.planar_faces(
bracket,
normal_axis="x",
normal_sign=-1.0,
coordinate_axis="x",
coordinate=layout.bracket_face_inner_x,
)
bottom_faces = dxf_topology.planar_faces(
bracket,
normal_axis="y",
normal_sign=-1.0,
coordinate_axis="y",
coordinate=layout.bottom_tab_y_min,
)
geometry = dxf_topology.union_projected_faces(
(
(top_faces, project_top),
(web_faces, project_web),
(bottom_faces, project_bottom),
),
merge_touching_tolerance_mm=TOPOLOGY_FLAT_PANEL_JOIN_TOLERANCE_MM,
)
dxf_topology.add_shapely_geometry(doc.modelspace(), geometry, layer="CUT")
for bend_line_x, bend_y_min, bend_y_max in flat_pattern.bend_line_segments_mm:
doc.modelspace().add_line(
(bend_line_x, bend_y_min),
(bend_line_x, bend_y_max),
dxfattribs={"layer": "BEND", "linetype": "DASHED"},
)
return doc
def build_step(
*,
include_top_center_bridge: bool = True,
) -> build123d.Shape:
CAD_DIR.mkdir(parents=True, exist_ok=True)
if not SERVO_STEP.exists():
raise FileNotFoundError(f"Missing STS3250 servo STEP: {SERVO_STEP}")
servo_shape = import_as_shape(SERVO_STEP)
bracket, layout = build_bracket(servo_shape, include_top_center_bridge=include_top_center_bridge)
_validate_slot_clearances(layout)
bracket = _cut_slots(bracket, layout)
flat_pattern = _build_flat_pattern(layout)
solids = bracket.solids()
if len(solids) != 1:
raise RuntimeError(f"Expected one connected bracket solid, found {len(solids)}")
intersection_volume = servo_shape.intersect(bracket).volume
if intersection_volume > 1e-3:
raise RuntimeError(f"Bracket intersects the servo by {intersection_volume:.6f} mm^3")
bracket.label = PART_NAME
bracket.color = GRAY_ALUMINUM_COLOR
bracket_bb = bracket.bounding_box()
slot_metrics = _slot_clearance_metrics(layout)
print(
"Bracket envelope "
f"X={bracket_bb.size.X:.3f} mm, "
f"Y={bracket_bb.size.Y:.3f} mm, "
f"Z={bracket_bb.size.Z:.3f} mm"
)
print(
"SendCutSend setup "
f"material=5052-H32, thickness={SENDCUTSEND_SHEET_THICKNESS_MM:.3f} mm, "
f"bend_radius={SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM:.3f} mm, "
f"k_factor={SENDCUTSEND_K_FACTOR:.2f}, "
f"bend_deduction_90={SENDCUTSEND_BEND_DEDUCTION_90_MM:.3f} mm, "
f"anodize_min_short_dim={SENDCUTSEND_MIN_ANODIZE_SHORTEST_DIM_MM:.3f} mm"
)
sendcutsend_checks = (
"SendCutSend checks "
f"hole_to_bend_min={min(abs(center.X - layout.bracket_face_inner_x) for center, _radius in [*layout.top_holes, *layout.bottom_holes]):.3f} mm, "
f"bend_segment_min={_min_bend_segment_length(layout):.3f} mm, "
f"bend_support={_supported_bend_fraction(layout):.1%}, "
f"outboard_hole_edge_web={layout.outer_flush_gap:.3f} mm, "
f"flange_free_corner_radius={FLANGE_FREE_CORNER_RADIUS_MM:.3f} mm"
)
print(sendcutsend_checks)
print(
"Slot checks "
f"slot_count={len(_slot_specs(layout))}, "
f"slot={_slot_length(layout):.3f}x{SLOT_WIDTH_MM:.3f} mm, "
f"side_end_gap_min={slot_metrics['slot_side_end_gap_min_mm']:.3f} mm, "
f"slot_to_mount_hole_x_min={slot_metrics['slot_to_mount_hole_x_min_mm']:.3f} mm, "
f"center_bridge_margin={slot_metrics['slot_center_bridge_margin_mm']:.3f} mm, "
f"slot_edge_to_bend_min={slot_metrics['slot_edge_to_bend_min_mm']:.3f} mm, "
f"slot_edge_to_bend_leeway={slot_metrics['slot_edge_to_bend_leeway_mm']:.3f} mm"
)
if layout.outer_flush_gap < MOUNT_HOLE_TRIMMED_OUTBOARD_Z_WEB_WARN_MM:
print(
"Warning: trimmed outer side leaves a small hole edge web: "
f"{layout.outer_flush_gap:.3f} mm < {MOUNT_HOLE_TRIMMED_OUTBOARD_Z_WEB_WARN_MM:.3f} mm"
)
end_face_x = _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["end_face"], geom_type="PLANE").bounding_box().max.X
servo_bb = servo_shape.bounding_box()
side_gap_neg = servo_bb.min.Z - layout.z_min
side_gap_pos = layout.z_max - servo_bb.max.Z
print(f"Bracket face clearance: {layout.bracket_face_inner_x - end_face_x:.3f} mm")
print(
"Servo width check "
f"servo_z_width={servo_bb.size.Z:.3f} mm, "
f"mount_z_width={layout.z_max - layout.z_min:.3f} mm, "
f"side_gaps={side_gap_neg:.3f}/{side_gap_pos:.3f} mm"
)
print(
"Wrap clearances "
f"top={TOP_MOUNT_PLANE_CLEARANCE_MM:.3f} mm, "
f"bottom={BOTTOM_MOUNT_PLANE_CLEARANCE_MM:.3f} mm, "
f"side_each={SIDE_WRAP_CLEARANCE_MM:.3f} mm, "
f"center_bridge_end={TOP_CENTER_BRIDGE_SERVO_END_CLEARANCE_MM:.3f} mm, "
f"center_bridge_flange_extension={CENTER_BRIDGE_FLANGE_LENGTH_EXTENSION_MM:.3f} mm, "
f"center_bridge_side_face_target={CENTER_BRIDGE_SIDE_FACE_LENGTH_MM:.3f} mm, "
f"center_relief_side_extra={TOP_CENTER_RELIEF_SIDE_CLEARANCE_MM:.3f} mm, "
f"top_flange_extension={TOP_FLANGE_LENGTH_EXTENSION_MM:.3f} mm, "
f"bottom_flange_extension={BOTTOM_FLANGE_LENGTH_EXTENSION_MM:.3f} mm"
)
center_bridge_bend_edge_distance = _center_bridge_bend_edge_distance(layout)
center_bridge_flat_clearance = _center_bridge_flat_bend_line_to_edge_distance(layout, flat_pattern)
center_bridge_side_face_length = _center_bridge_side_face_length(layout)
if center_bridge_bend_edge_distance is not None and center_bridge_flat_clearance is not None:
print(
"Top center bridge "
f"bend_edge_distance={center_bridge_bend_edge_distance:.3f} mm, "
f"flat_bend_line_to_edge={center_bridge_flat_clearance:.3f} mm, "
f"side_face_length={center_bridge_side_face_length:.3f} mm, "
f"servo_end_clearance={layout.center_bridge_x_min - end_face_x:.3f} mm"
)
print(f"Bracket face inner X: {layout.bracket_face_inner_x:.3f} mm")
print(
"Top mount holes used (x, y, z) mm: "
+ ", ".join(str(tuple(round(value, 6) for value in _vector_to_tuple(center))) for center, _radius in layout.top_holes)
)
print(
"Bottom mount holes used (x, y, z) mm: "
+ ", ".join(str(tuple(round(value, 6) for value in _vector_to_tuple(center))) for center, _radius in layout.bottom_holes)
)
print(
"Front horn face holes (y, z, radius) mm: "
+ ", ".join(
str(tuple(round(value, 6) for value in profile))
for profile in _front_horn_mount_hole_profiles(layout)
)
)
print(f"Servo/bracket interference volume (mm^3): {intersection_volume:.6f}")
return bracket
def build_dxf(
*,
include_top_center_bridge: bool = True,
) -> ezdxf.document.Drawing:
CAD_DIR.mkdir(parents=True, exist_ok=True)
if not SERVO_STEP.exists():
raise FileNotFoundError(f"Missing STS3250 servo STEP: {SERVO_STEP}")
servo_shape = import_as_shape(SERVO_STEP)
bracket, layout = build_bracket(
servo_shape,
include_top_center_bridge=include_top_center_bridge,
)
_validate_slot_clearances(layout)
bracket = _cut_slots(bracket, layout)
flat_pattern = _build_flat_pattern(layout)
doc = _topology_flat_pattern_dxf(bracket, layout, flat_pattern)
outside_setback = _sendcutsend_outside_setback_90_mm()
flange_outside_min = _min_flange_outside_length(layout)
slot_metrics = _slot_clearance_metrics(layout)
print(
"Flat pattern "
f"length={flat_pattern.total_length_mm:.3f} mm, "
f"width={flat_pattern.width_mm:.3f} mm, "
f"bend_lines=({flat_pattern.bend_line_positions_mm[0]:.3f}, {flat_pattern.bend_line_positions_mm[1]:.3f}) mm, "
f"bend_segments={len(flat_pattern.bend_line_segments_mm)}, "
f"cut_circles={len(flat_pattern.cut_circles)}, "
f"front_cut_segments={len(flat_pattern.cut_segments)}"
)
dxf_bend_checks = (
"DXF bend checks "
f"flange_outside_min={flange_outside_min:.3f} mm, "
f"flange_flat_min={flange_outside_min - outside_setback:.3f} mm, "
f"flange_flat_required={SENDCUTSEND_MIN_FLANGE_FLAT_MM:.3f} mm, "
f"bend_segment_min={_min_bend_segment_length(layout):.3f} mm, "
f"bend_support={_supported_bend_fraction(layout):.1%}, "
f"hole_to_bend_min={min(abs(center.X - layout.bracket_face_inner_x) for center, _radius in [*layout.top_holes, *layout.bottom_holes]):.3f} mm, "
f"outboard_hole_edge_web={layout.outer_flush_gap:.3f} mm, "
f"center_bridge_end_clearance={TOP_CENTER_BRIDGE_SERVO_END_CLEARANCE_MM:.3f} mm, "
f"center_relief_side_extra={TOP_CENTER_RELIEF_SIDE_CLEARANCE_MM:.3f} mm"
)
print(dxf_bend_checks)
print(
"DXF slot checks "
f"slot_count={len(_slot_specs(layout))}, "
f"slot={_slot_length(layout):.3f}x{SLOT_WIDTH_MM:.3f} mm, "
f"side_end_gap_min={slot_metrics['slot_side_end_gap_min_mm']:.3f} mm, "
f"slot_to_mount_hole_x_min={slot_metrics['slot_to_mount_hole_x_min_mm']:.3f} mm, "
f"center_bridge_margin={slot_metrics['slot_center_bridge_margin_mm']:.3f} mm, "
f"slot_edge_to_bend_min={slot_metrics['slot_edge_to_bend_min_mm']:.3f} mm"
)
center_bridge_bend_edge_distance = _center_bridge_bend_edge_distance(layout)
center_bridge_flat_clearance = _center_bridge_flat_bend_line_to_edge_distance(layout, flat_pattern)
center_bridge_side_face_length = _center_bridge_side_face_length(layout)
if center_bridge_bend_edge_distance is not None and center_bridge_flat_clearance is not None:
print(
"DXF center bridge "
f"bend_edge_distance={center_bridge_bend_edge_distance:.3f} mm, "
f"flat_bend_line_to_edge={center_bridge_flat_clearance:.3f} mm, "
f"side_face_length={center_bridge_side_face_length:.3f} mm"
)
return doc
def gen_step() -> dict[str, object]:
return {
"shape": build_step(),
}
def gen_dxf() -> dict[str, object]:
return {
"document": build_dxf(),
}
def gen() -> None:
gen_step()
gen_dxf()
if __name__ == "__main__":
gen()