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

1150 lines
43 KiB
Python

#!/usr/bin/env python3
"""
Generate the tom v2 wrap link plate: one sheet part with two bend lines
connecting the two servos along the +Y side of the link.
Geometry per the side-view draft and the referenced features:
- foot: lies on the bottom servo's case-bottom (up-facing) plane,
connecting the two +Y mount holes at (X = 8.3 | 32.75, Y = 10.25),
its inner edge wrapping around the cable bay with a 0.4 mm overhang
and clearing the raised center zone,
- bend 1 (axis X): split into two segments over the hole zones, each
with a 7.7 mm formed flange,
- riser leg: lies flat against the top servo's +Y side face (the
referenced face spans X 7.9..15.2, Z 102.1..129.3), full foot width
at the bottom and cut partway up to the face's width ("finishing
width"), with a corner extension past the servo's case-top corner,
- bend 2 (axis Z, full length): wraps onto the case-top face plane and
connects to its two +Y screw holes at (Y = 10.25, Z = 106 | 126.7).
The flange stands 1.5 mm off the face on spacers (5x M2 washers or a
1.5 mm spacer per screw) so it clears the case-top center ridge
(1.1 mm proud) and the output horn boss (1.3 mm proud), allowing a
full-depth rectangular flange that meets the published minimum
flange length.
Advisory (not in SendCutSend's published ruleset): the servo's holes
sit 2.1 mm from its case corners, so both bends run 2.7-3.2 mm from
screw holes - inside the press-brake die span. Expect minor hole
distortion; ream after forming if screws bind.
Usage:
python v2/link_bracket.py
"""
from __future__ import annotations
import math
import os
import sys
from dataclasses import dataclass
from pathlib import Path
V2_DIR = Path(__file__).resolve().parent
if str(V2_DIR) not in sys.path:
sys.path.insert(0, str(V2_DIR))
import build123d
import dxf_topology
import link_common as lc
PART_NAME = Path(__file__).stem
CUT_EXTENSION_MM = 2.0
@dataclass(frozen=True)
class FlatPattern:
outside_setback_mm: float
bend_allowance_mm: float
bend_deduction_mm: float
panel_join_shift_mm: float
foot_bend_line_y_mm: float
wrap_bend_line_x_mm: float
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name)
if raw is None:
return default
try:
return float(raw)
except ValueError as exc:
raise ValueError(f"{name} must be a floating point number, got {raw!r}") from exc
def _env_float_any(names: tuple[str, ...], default: float) -> float:
for name in names:
raw = os.environ.get(name)
if raw is None:
continue
try:
return float(raw)
except ValueError as exc:
raise ValueError(f"{name} must be a floating point number, got {raw!r}") from exc
return default
# Plate planes. The paired brackets are spaced to accept off-the-shelf
# female-female M3 cross standoffs. The flange seats close to the case-top
# face (0.25 mm) so it bolts down flush rather than floating; its inner edge
# stays outboard of the case-top center ridge (proud only over Y 5-7). The
# bend position follows FLANGE_INNER_X parametrically.
CASE_SPAN_CENTERING_OFFSET_MM = _env_float(
"TOM_V2_CASE_SPAN_CENTERING_OFFSET_MM",
lc.CASE_SPAN_CENTERING_OFFSET_MM,
)
BASELINE_LINK_BRACKET_OVERALL_HEIGHT_MM = lc.TOP_PIVOT_Z_MM - lc.FOOT_BOTTOM_Z_MM
DEFAULT_LINK_BRACKET_OVERALL_HEIGHT_MM = 150.0
LINK_BRACKET_OVERALL_HEIGHT_MM = _env_float_any(
(
"TOM_V2_LINK_BRACKET_OVERALL_HEIGHT_MM",
"TOM_V2_LINK_BRACKET_HEIGHT_MM",
),
DEFAULT_LINK_BRACKET_OVERALL_HEIGHT_MM,
)
if LINK_BRACKET_OVERALL_HEIGHT_MM < 100.0:
raise ValueError(
"TOM_V2_LINK_BRACKET_OVERALL_HEIGHT_MM must be at least 100 mm "
"to keep the top servo mount above the sloped web"
)
HEIGHT_SCALE = LINK_BRACKET_OVERALL_HEIGHT_MM / BASELINE_LINK_BRACKET_OVERALL_HEIGHT_MM
TOP_PIVOT_Z_MM = lc.FOOT_BOTTOM_Z_MM + LINK_BRACKET_OVERALL_HEIGHT_MM
TOP_BODY_DOWN_Z_OFFSET_MM = TOP_PIVOT_Z_MM + lc.SERVO_SHAFT_LOCAL_X_MM
CASE_FLUSH_HOLE_Z_MM = TOP_BODY_DOWN_Z_OFFSET_MM - lc.SERVO_MOUNT_HOLE_LOCAL_X_MM[1]
CASE_OFFSET_HOLE_Z_MM = TOP_BODY_DOWN_Z_OFFSET_MM - lc.SERVO_TOP_MOUNT_HOLE_LOCAL_X_MM[1]
LINK_STANDOFF_LENGTH_MM = _env_float("TOM_V2_LINK_STANDOFF_LENGTH_MM", 35.0)
LEG_INNER_Y_MM = 0.5 * LINK_STANDOFF_LENGTH_MM
LEG_FACE_CLEARANCE_MM = LEG_INNER_Y_MM - lc.SERVO_HALF_WIDTH_MM
if LEG_FACE_CLEARANCE_MM < 1.5:
raise ValueError(
"TOM_V2_LINK_STANDOFF_LENGTH_MM leaves too little side clearance around the servo body"
)
FLANGE_FACE_CLEARANCE_MM = 0.25
LEG_OUTER_Y_MM = LEG_INNER_Y_MM + lc.SHEET_THICKNESS_MM
CASE_TOP_FACE_X_MM = lc.SERVO_CASE_TOP_LOCAL_Y_MM + CASE_SPAN_CENTERING_OFFSET_MM
FLANGE_INNER_X_MM = CASE_TOP_FACE_X_MM + FLANGE_FACE_CLEARANCE_MM
FLANGE_OUTER_X_MM = FLANGE_INNER_X_MM + lc.SHEET_THICKNESS_MM
# Output-horn relief: concentric with the servo's output horn boss on the pitch
# axis, so the flange's curved cut is inline with the horn and symmetric.
# Keep this tunable because this gap is a tight tradeoff: reducing the relief
# radius gives the top flange screw more web, while increasing it gives the horn
# more radial clearance.
OUTPUT_HORN_RADIUS_MM = lc.SERVO_OUTPUT_HORN_BOSS_RADIUS_MM
DEFAULT_OUTPUT_HORN_RELIEF_RADIUS_MM = 11.0
OUTPUT_HORN_RELIEF_RADIUS_MM = _env_float(
"TOM_V2_OUTPUT_HORN_RELIEF_RADIUS_MM",
DEFAULT_OUTPUT_HORN_RELIEF_RADIUS_MM,
)
# Foot outline. The foot keeps a short Y=7.5 tab around each servo screw
# (left X 6.0-10.6, right X 30.4-35.0 - matching widths) and is set back to
# Y=9.6 everywhere between them, clearing both the cable hole and the
# raised center panel. The left tab is sized to match the right tab.
FOOT_X_MIN_MM = 6.0
FOOT_X_MAX_MM = 35.0
FOOT_EDGE_DEFAULT_Y_MM = 7.5
FOOT_EDGE_RAISED_Y_MM = 9.6
LEFT_TAB_RIGHT_X_MM = 10.6 # 4.6 mm tab (X 6.0-10.6) around the left screw at 8.3
RAISED_ZONE_X_MM = (LEFT_TAB_RIGHT_X_MM, 30.4)
# Leg profile (in the X-Z plane). The side-view layout is three constant-width
# bands from the bottom-servo mounting plane to the 180 mm top pitch axis:
# lower vertical section, centered sloped transition, and upper vertical
# section that carries the top-servo face features. A local 2t rectangular web
# ear gives the interrupted wrap bend room before the one-thickness flange turns
# onto the servo case face.
SECTION_WIDTH_MM = FOOT_X_MAX_MM - FOOT_X_MIN_MM
TOP_FLANGE_BEND_LEEWAY_MM = 2.0 * lc.SHEET_THICKNESS_MM
# Keep the top servo body vertically in line with the yoke-mounted servo body:
# both servos use the same local orientation, so matching the case transform's
# X translation makes their body centers share the same world X coordinate.
TOP_SERVO_ALIGNED_CASE_SPAN_CENTERING_OFFSET_MM = lc.HORN_SPAN_CENTERING_OFFSET_MM
FLANGE_WRAP_INNER_X_MM = (
TOP_SERVO_ALIGNED_CASE_SPAN_CENTERING_OFFSET_MM + lc.SERVO_CASE_TOP_LOCAL_Y_MM
)
FLANGE_WRAP_OUTER_X_MM = FLANGE_WRAP_INNER_X_MM + lc.SHEET_THICKNESS_MM
TOP_FLANGE_EAR_X_MAX_MM = FLANGE_WRAP_INNER_X_MM
TOP_FLANGE_EAR_X_MIN_MM = TOP_FLANGE_EAR_X_MAX_MM - TOP_FLANGE_BEND_LEEWAY_MM
UPPER_X_MAX_MM = TOP_FLANGE_EAR_X_MIN_MM
UPPER_X_MIN_MM = UPPER_X_MAX_MM - SECTION_WIDTH_MM
TOP_SERVO_MATED_CASE_SPAN_CENTERING_OFFSET_MM = (
FLANGE_WRAP_INNER_X_MM - lc.SERVO_CASE_TOP_LOCAL_Y_MM
)
TOP_SERVO_FACE_TO_FLANGE_X_OFFSET_MM = (
TOP_SERVO_MATED_CASE_SPAN_CENTERING_OFFSET_MM - CASE_SPAN_CENTERING_OFFSET_MM
)
RIGHT_SLOPE_BOTTOM_Z_MM = lc.FOOT_BOTTOM_Z_MM + (60.0 - lc.FOOT_BOTTOM_Z_MM) * HEIGHT_SCALE
RIGHT_SLOPE_TOP_Z_MM = lc.FOOT_BOTTOM_Z_MM + (120.0 - lc.FOOT_BOTTOM_Z_MM) * HEIGHT_SCALE
SLOPE_SECTION_HEIGHT_MM = RIGHT_SLOPE_TOP_Z_MM - RIGHT_SLOPE_BOTTOM_Z_MM
SLOPE_RUN_X_MM = abs(FOOT_X_MAX_MM - UPPER_X_MAX_MM)
LOW_X_SLOPE_Z_SHIFT_DOWN_MM = SECTION_WIDTH_MM * (
math.hypot(SLOPE_RUN_X_MM, SLOPE_SECTION_HEIGHT_MM) - SLOPE_SECTION_HEIGHT_MM
) / SLOPE_RUN_X_MM
LEFT_SLOPE_BOTTOM_Z_MM = RIGHT_SLOPE_BOTTOM_Z_MM - LOW_X_SLOPE_Z_SHIFT_DOWN_MM
LEFT_SLOPE_TOP_Z_MM = RIGHT_SLOPE_TOP_Z_MM - LOW_X_SLOPE_Z_SHIFT_DOWN_MM
SLOPE_DX_PER_Z_MM = (UPPER_X_MAX_MM - FOOT_X_MAX_MM) / SLOPE_SECTION_HEIGHT_MM
SLOPE_HIGH_X_INTERCEPT_MM = FOOT_X_MAX_MM - SLOPE_DX_PER_Z_MM * RIGHT_SLOPE_BOTTOM_Z_MM
SLOPE_LOW_X_INTERCEPT_MM = FOOT_X_MIN_MM - SLOPE_DX_PER_Z_MM * LEFT_SLOPE_BOTTOM_Z_MM
SLOPED_BAND_NORMAL_WIDTH_MM = abs(
SLOPE_HIGH_X_INTERCEPT_MM - SLOPE_LOW_X_INTERCEPT_MM
) / math.hypot(1.0, SLOPE_DX_PER_Z_MM)
SLOPE_SECTION_CENTER_Z_MM = 0.25 * (
LEFT_SLOPE_BOTTOM_Z_MM
+ RIGHT_SLOPE_BOTTOM_Z_MM
+ LEFT_SLOPE_TOP_Z_MM
+ RIGHT_SLOPE_TOP_Z_MM
)
# The local bend ear carries the wrap lead-in; the flange box itself remains
# one sheet thickness.
LEG_RUN_X_MM = (
min(FOOT_X_MIN_MM, UPPER_X_MIN_MM),
max(FOOT_X_MAX_MM, FLANGE_WRAP_OUTER_X_MM),
)
# Servo face #o1.1.1.1.f19 in link_assembly, expressed in STS3250 local X.
# Keeping this in local coordinates makes the bracket top track height changes.
TOP_SERVO_INLINE_FACE_LOCAL_X_MM = -20.038516
LEG_TOP_Z_MM = TOP_BODY_DOWN_Z_OFFSET_MM - TOP_SERVO_INLINE_FACE_LOCAL_X_MM
OUTPUT_HORN_TOP_EDGE_GAP_MM = TOP_PIVOT_Z_MM - LEG_TOP_Z_MM
FLANGE_BOTTOM_Z_MM = CASE_OFFSET_HOLE_Z_MM - 4.0
BASELINE_TRANSITION_CORNER_FILLET_RADIUS_MM = 24.0
DEFAULT_TRANSITION_CORNER_FILLET_RADIUS_MM = 0.5 * BASELINE_TRANSITION_CORNER_FILLET_RADIUS_MM
TRANSITION_CORNER_FILLET_RADIUS_MM = _env_float(
"TOM_V2_TRANSITION_CORNER_FILLET_RADIUS_MM",
DEFAULT_TRANSITION_CORNER_FILLET_RADIUS_MM,
)
FORMED_FLANGE_CORNER_FILLET_RADIUS_MM = 0.8
# Uniform flange inner edge. Place it at the case-top ridge clearance datum
# (ridge half-width 7.0 mm plus 0.5 mm tolerance clearance) so the wrap flange
# reaches closer to the servo face while leaving the horn relief, not the free
# edge, as the limiting boundary near the top screw hole.
FLANGE_EDGE_Y_MM = lc.CASE_OFFSET_RELIEF_HALF_WIDTH_MM
TOP_FLANGE_UPPER_HOLE_Z_MM = TOP_BODY_DOWN_Z_OFFSET_MM + 17.2
TOP_FLANGE_HOLE_TO_RELIEF_EDGE_GAP_MM = (
math.hypot(lc.HOLE_SIDE_OFFSET_MM, TOP_PIVOT_Z_MM - TOP_FLANGE_UPPER_HOLE_Z_MM)
- lc.SERVO_MOUNT_HOLE_RADIUS_MM
- OUTPUT_HORN_RELIEF_RADIUS_MM
)
# Side-web lightening slots, paired per sketch in the lower, sloped, and
# upper bands. Slots cut through the sheet along Y and are kept away from
# the bend reliefs and servo screw holes.
SLOT_CUT_Y_OFFSET_MM = LEG_OUTER_Y_MM + CUT_EXTENSION_MM
SLOT_CUT_Y_SPAN_MM = lc.SHEET_THICKNESS_MM + 2.0 * CUT_EXTENSION_MM
SLOT_WIDTH_MM = 4.0
TOP_SLOT_WIDTH_MM = 4.0
BASELINE_LOWER_SLOT_LENGTH_MM = 38.0
BASELINE_MIDDLE_SLOT_LENGTH_MM = 38.0
BASELINE_TOP_SLOT_LENGTH_MM = 34.0
LOWER_SLOT_LENGTH_MM = BASELINE_LOWER_SLOT_LENGTH_MM * HEIGHT_SCALE
MIDDLE_SLOT_LENGTH_MM = BASELINE_MIDDLE_SLOT_LENGTH_MM * HEIGHT_SCALE
TOP_SLOT_LENGTH_MM = BASELINE_TOP_SLOT_LENGTH_MM * HEIGHT_SCALE
LOWER_SLOT_CENTER_Z_MM = 0.5 * (lc.FOOT_BOTTOM_Z_MM + RIGHT_SLOPE_BOTTOM_Z_MM)
MIDDLE_SLOT_CENTER_Z_MM = SLOPE_SECTION_CENTER_Z_MM
TOP_SLOT_CENTER_Z_MM = 0.5 * (RIGHT_SLOPE_TOP_Z_MM + LEG_TOP_Z_MM)
LOWER_SLOT_CENTER_X_MM = (14.0, 27.0)
MIDDLE_SLOT_CENTER_SPACING_X_MM = 13.0
MIDDLE_SLOT_PAIR_CENTER_X_MM = 0.5 * (
(SLOPE_DX_PER_Z_MM * MIDDLE_SLOT_CENTER_Z_MM + SLOPE_LOW_X_INTERCEPT_MM)
+ (SLOPE_DX_PER_Z_MM * MIDDLE_SLOT_CENTER_Z_MM + SLOPE_HIGH_X_INTERCEPT_MM)
)
MIDDLE_SLOT_CENTER_X_MM = (
MIDDLE_SLOT_PAIR_CENTER_X_MM - 0.5 * MIDDLE_SLOT_CENTER_SPACING_X_MM,
MIDDLE_SLOT_PAIR_CENTER_X_MM + 0.5 * MIDDLE_SLOT_CENTER_SPACING_X_MM,
)
MIDDLE_SLOT_CENTER_XZ_MM = tuple(
(slot_x, MIDDLE_SLOT_CENTER_Z_MM) for slot_x in MIDDLE_SLOT_CENTER_X_MM
)
MIDDLE_SLOT_CENTER_NORMAL_MM = tuple(
(slot_x - SLOPE_DX_PER_Z_MM * MIDDLE_SLOT_CENTER_Z_MM - SLOPE_LOW_X_INTERCEPT_MM)
/ math.hypot(1.0, SLOPE_DX_PER_Z_MM)
for slot_x in MIDDLE_SLOT_CENTER_X_MM
)
MIDDLE_SLOT_LOW_EDGE_MARGIN_MM = MIDDLE_SLOT_CENTER_NORMAL_MM[0] - 0.5 * SLOT_WIDTH_MM
MIDDLE_SLOT_HIGH_EDGE_MARGIN_MM = (
SLOPED_BAND_NORMAL_WIDTH_MM - MIDDLE_SLOT_CENTER_NORMAL_MM[1] - 0.5 * SLOT_WIDTH_MM
)
MIDDLE_SLOT_ROTATION_DEG = math.degrees(
math.atan2(
0.5 * (LEFT_SLOPE_TOP_Z_MM + RIGHT_SLOPE_TOP_Z_MM)
- 0.5 * (LEFT_SLOPE_BOTTOM_Z_MM + RIGHT_SLOPE_BOTTOM_Z_MM),
UPPER_X_MAX_MM - FOOT_X_MAX_MM,
)
)
VERTICAL_SLOT_ROTATION_DEG = 90.0
TOP_SLOT_PAIR_CENTER_X_MM = 0.5 * (UPPER_X_MIN_MM + UPPER_X_MAX_MM)
TOP_SLOT_CENTER_SPACING_X_MM = 13.0
TOP_SLOT_CENTER_X_MM = (
TOP_SLOT_PAIR_CENTER_X_MM - 0.5 * TOP_SLOT_CENTER_SPACING_X_MM,
TOP_SLOT_PAIR_CENTER_X_MM + 0.5 * TOP_SLOT_CENTER_SPACING_X_MM,
)
M3_STANDOFF_CLEARANCE_RADIUS_MM = 1.7
MIDDLE_SLOT_HALF_DX_MM = (
math.cos(math.radians(MIDDLE_SLOT_ROTATION_DEG)) * 0.5 * MIDDLE_SLOT_LENGTH_MM
)
MIDDLE_SLOT_HALF_DZ_MM = (
math.sin(math.radians(MIDDLE_SLOT_ROTATION_DEG)) * 0.5 * MIDDLE_SLOT_LENGTH_MM
)
LOWER_SLOT_TOP_Z_MM = LOWER_SLOT_CENTER_Z_MM + 0.5 * LOWER_SLOT_LENGTH_MM
MIDDLE_SLOT_LOWER_END_X_MM = tuple(
slot_x - MIDDLE_SLOT_HALF_DX_MM for slot_x in MIDDLE_SLOT_CENTER_X_MM
)
MIDDLE_SLOT_LOWER_END_Z_MM = MIDDLE_SLOT_CENTER_Z_MM - MIDDLE_SLOT_HALF_DZ_MM
MIDDLE_SLOT_UPPER_END_X_MM = tuple(
slot_x + MIDDLE_SLOT_HALF_DX_MM for slot_x in MIDDLE_SLOT_CENTER_X_MM
)
MIDDLE_SLOT_UPPER_END_Z_MM = MIDDLE_SLOT_CENTER_Z_MM + MIDDLE_SLOT_HALF_DZ_MM
TOP_SLOT_BOTTOM_Z_MM = TOP_SLOT_CENTER_Z_MM - 0.5 * TOP_SLOT_LENGTH_MM
def _side_profile_x_bounds_at_z(z_mm: float) -> tuple[float, float]:
if z_mm < LEFT_SLOPE_BOTTOM_Z_MM:
left_x = FOOT_X_MIN_MM
elif z_mm <= LEFT_SLOPE_TOP_Z_MM:
left_x = SLOPE_DX_PER_Z_MM * z_mm + SLOPE_LOW_X_INTERCEPT_MM
else:
left_x = UPPER_X_MIN_MM
if z_mm < RIGHT_SLOPE_BOTTOM_Z_MM:
right_x = FOOT_X_MAX_MM
elif z_mm <= RIGHT_SLOPE_TOP_Z_MM:
right_x = SLOPE_DX_PER_Z_MM * z_mm + SLOPE_HIGH_X_INTERCEPT_MM
else:
right_x = UPPER_X_MAX_MM
return left_x, right_x
def _centered_pair_x_at_z(z_mm: float) -> tuple[float, float]:
left_x, right_x = _side_profile_x_bounds_at_z(z_mm)
width = right_x - left_x
return left_x + 0.25 * width, left_x + 0.75 * width
LOWER_INTER_SLOT_STANDOFF_CENTER_Z_MM = 0.5 * (
LOWER_SLOT_TOP_Z_MM + MIDDLE_SLOT_LOWER_END_Z_MM
)
LOWER_INTER_SLOT_STANDOFF_CENTER_X_MM = _centered_pair_x_at_z(
LOWER_INTER_SLOT_STANDOFF_CENTER_Z_MM
)
UPPER_INTER_SLOT_STANDOFF_CENTER_Z_MM = 0.5 * (
MIDDLE_SLOT_UPPER_END_Z_MM + TOP_SLOT_BOTTOM_Z_MM
)
UPPER_INTER_SLOT_STANDOFF_CENTER_X_MM = _centered_pair_x_at_z(
UPPER_INTER_SLOT_STANDOFF_CENTER_Z_MM
)
STANDOFF_CENTER_XZ_MM = (
("lower_left", LOWER_INTER_SLOT_STANDOFF_CENTER_X_MM[0], LOWER_INTER_SLOT_STANDOFF_CENTER_Z_MM),
("lower_right", LOWER_INTER_SLOT_STANDOFF_CENTER_X_MM[1], LOWER_INTER_SLOT_STANDOFF_CENTER_Z_MM),
("upper_left", UPPER_INTER_SLOT_STANDOFF_CENTER_X_MM[0], UPPER_INTER_SLOT_STANDOFF_CENTER_Z_MM),
("upper_right", UPPER_INTER_SLOT_STANDOFF_CENTER_X_MM[1], UPPER_INTER_SLOT_STANDOFF_CENTER_Z_MM),
)
def top_servo_case_transform(
case_span_centering_offset_mm: float = CASE_SPAN_CENTERING_OFFSET_MM,
) -> tuple[float, ...]:
return (
0.0, 1.0, 0.0, case_span_centering_offset_mm,
0.0, 0.0, -1.0, 0.0,
-1.0, 0.0, 0.0, TOP_BODY_DOWN_Z_OFFSET_MM,
0.0, 0.0, 0.0, 1.0,
)
def _verify_top_servo_case_holes(
placed_servo: build123d.Shape,
*,
case_span_centering_offset_mm: float,
) -> None:
lc._verify_holes(
lc._planar_hole_centers(
placed_servo,
axis="x",
plane_coordinate=lc.SERVO_CASE_BOTTOM_LOCAL_Y_MM + case_span_centering_offset_mm,
),
expected=tuple(
(side * lc.HOLE_SIDE_OFFSET_MM, CASE_FLUSH_HOLE_Z_MM)
for side in (-1.0, 1.0)
),
radius=lc.SERVO_MOUNT_HOLE_RADIUS_MM,
label="case-bottom-face",
)
lc._verify_holes(
lc._planar_hole_centers(
placed_servo,
axis="x",
plane_coordinate=lc.SERVO_CASE_TOP_LOCAL_Y_MM + case_span_centering_offset_mm,
),
expected=tuple(
(side * lc.HOLE_SIDE_OFFSET_MM, CASE_OFFSET_HOLE_Z_MM)
for side in (-1.0, 1.0)
),
radius=lc.SERVO_MOUNT_HOLE_RADIUS_MM,
label="case-top-face",
)
def _placed_link_servos_case() -> tuple[build123d.Shape, build123d.Shape]:
top = lc.place_servo(
lc._load_servo_shape(lc.SERVO_STEP),
top_servo_case_transform(TOP_SERVO_MATED_CASE_SPAN_CENTERING_OFFSET_MM),
)
bottom = lc.place_servo(
lc._load_servo_shape(lc.SERVO_NO_REAR_HORN_STEP),
lc.BOTTOM_SERVO_TRANSFORM,
)
_verify_top_servo_case_holes(
top,
case_span_centering_offset_mm=TOP_SERVO_MATED_CASE_SPAN_CENTERING_OFFSET_MM,
)
lc.verify_bottom_servo_mount_holes(bottom)
return top, bottom
def _xz_unit(vector: tuple[float, float]) -> tuple[float, float]:
length = math.hypot(vector[0], vector[1])
if length <= 1e-9:
raise ValueError("Cannot normalize a zero-length X-Z vector")
return (vector[0] / length, vector[1] / length)
def _xz_add(
left: tuple[float, float],
right: tuple[float, float],
) -> tuple[float, float]:
return (left[0] + right[0], left[1] + right[1])
def _xz_scale(vector: tuple[float, float], scale: float) -> tuple[float, float]:
return (vector[0] * scale, vector[1] * scale)
def _profile_point(point: tuple[float, float]) -> tuple[float, float, float]:
return (point[0], LEG_INNER_Y_MM, point[1])
def _rounded_profile_edges(
points: list[tuple[float, float]],
*,
rounded_indices: set[int],
radius: float,
) -> list[build123d.Edge]:
if radius < 0.0:
raise ValueError("TRANSITION_CORNER_FILLET_RADIUS_MM must be >= 0")
corner_data: list[tuple[tuple[float, float], tuple[float, float], tuple[float, float] | None]] = []
for index, corner in enumerate(points):
if index not in rounded_indices or radius <= 0.0:
corner_data.append((corner, corner, None))
continue
previous = points[index - 1]
next_point = points[(index + 1) % len(points)]
toward_previous = _xz_unit((previous[0] - corner[0], previous[1] - corner[1]))
toward_next = _xz_unit((next_point[0] - corner[0], next_point[1] - corner[1]))
dot = max(
-1.0,
min(1.0, toward_previous[0] * toward_next[0] + toward_previous[1] * toward_next[1]),
)
angle = math.acos(dot)
tangent = radius / math.tan(0.5 * angle)
previous_length = math.dist(previous, corner)
next_length = math.dist(next_point, corner)
if tangent >= min(previous_length, next_length):
raise ValueError(
"TRANSITION_CORNER_FILLET_RADIUS_MM is too large for the current "
f"profile: radius={radius:.3f} mm at point {corner}"
)
start = _xz_add(corner, _xz_scale(toward_previous, tangent))
end = _xz_add(corner, _xz_scale(toward_next, tangent))
bisector = _xz_unit(_xz_add(toward_previous, toward_next))
center_distance = radius / math.sin(0.5 * angle)
center = _xz_add(corner, _xz_scale(bisector, center_distance))
start_radius = (start[0] - center[0], start[1] - center[1])
end_radius = (end[0] - center[0], end[1] - center[1])
mid_radius = _xz_unit(_xz_add(start_radius, end_radius))
midpoint = _xz_add(center, _xz_scale(mid_radius, radius))
corner_data.append((start, end, midpoint))
edges: list[build123d.Edge] = []
for index, (_start, end, _midpoint) in enumerate(corner_data):
next_start, next_end, next_midpoint = corner_data[(index + 1) % len(corner_data)]
if math.dist(end, next_start) > 1e-6:
edges.append(build123d.Edge.make_line(_profile_point(end), _profile_point(next_start)))
if next_midpoint is not None:
edges.append(
build123d.Edge.make_three_point_arc(
_profile_point(next_start),
_profile_point(next_midpoint),
_profile_point(next_end),
)
)
return edges
def _leg_profile() -> build123d.Face:
points = [
(FOOT_X_MIN_MM, lc.FOOT_BOTTOM_Z_MM),
(FOOT_X_MAX_MM, lc.FOOT_BOTTOM_Z_MM),
(FOOT_X_MAX_MM, RIGHT_SLOPE_BOTTOM_Z_MM),
(UPPER_X_MAX_MM, RIGHT_SLOPE_TOP_Z_MM),
(UPPER_X_MAX_MM, LEG_TOP_Z_MM),
(UPPER_X_MIN_MM, LEG_TOP_Z_MM),
(UPPER_X_MIN_MM, LEFT_SLOPE_TOP_Z_MM),
(FOOT_X_MIN_MM, LEFT_SLOPE_BOTTOM_Z_MM),
]
profile_edges = _rounded_profile_edges(
points,
rounded_indices={2, 3, 6, 7},
radius=TRANSITION_CORNER_FILLET_RADIUS_MM,
)
wires = list(build123d.Wire.combine(profile_edges))
if len(wires) != 1:
raise RuntimeError(f"Leg profile produced {len(wires)} wires")
return build123d.Face(wires[0])
def _slot_cut(
*,
x: float,
z: float,
length: float,
width: float,
rotation: float,
) -> build123d.Shape:
with build123d.BuildPart() as slot:
with build123d.BuildSketch(build123d.Plane.XZ):
with build123d.Locations((x, z)):
build123d.SlotOverall(width=length, height=width, rotation=rotation)
build123d.extrude(amount=SLOT_CUT_Y_SPAN_MM)
return slot.part.moved(build123d.Location((0.0, SLOT_CUT_Y_OFFSET_MM, 0.0)))
def _slot_cuts() -> list[build123d.Shape]:
slots: list[build123d.Shape] = []
for slot_x in LOWER_SLOT_CENTER_X_MM:
slots.append(
_slot_cut(
x=slot_x,
z=LOWER_SLOT_CENTER_Z_MM,
length=LOWER_SLOT_LENGTH_MM,
width=SLOT_WIDTH_MM,
rotation=VERTICAL_SLOT_ROTATION_DEG,
)
)
for slot_x, slot_z in MIDDLE_SLOT_CENTER_XZ_MM:
slots.append(
_slot_cut(
x=slot_x,
z=slot_z,
length=MIDDLE_SLOT_LENGTH_MM,
width=SLOT_WIDTH_MM,
rotation=MIDDLE_SLOT_ROTATION_DEG,
)
)
for slot_x in TOP_SLOT_CENTER_X_MM:
slots.append(
_slot_cut(
x=slot_x,
z=TOP_SLOT_CENTER_Z_MM,
length=TOP_SLOT_LENGTH_MM,
width=TOP_SLOT_WIDTH_MM,
rotation=VERTICAL_SLOT_ROTATION_DEG,
)
)
return slots
def _transition_corner_edges(bracket: build123d.Shape) -> list[build123d.Edge]:
corner_points = (
(FOOT_X_MAX_MM, RIGHT_SLOPE_BOTTOM_Z_MM),
(UPPER_X_MAX_MM, RIGHT_SLOPE_TOP_Z_MM),
(UPPER_X_MIN_MM, LEFT_SLOPE_TOP_Z_MM),
(FOOT_X_MIN_MM, LEFT_SLOPE_BOTTOM_Z_MM),
)
return list(
bracket.edges().filter_by(build123d.Axis.Y).filter_by(
lambda e: any(
abs(e.center().X - x) < 0.05
and abs(e.center().Z - z) < 0.05
and abs(e.length - lc.SHEET_THICKNESS_MM) < 0.05
for x, z in corner_points
)
)
)
def build_bracket() -> build123d.Shape:
leg = build123d.Solid.extrude(
_leg_profile(), build123d.Vector(0.0, lc.SHEET_THICKNESS_MM, 0.0)
)
foot = lc.make_box(
x_min=FOOT_X_MIN_MM,
x_max=FOOT_X_MAX_MM,
y_min=FOOT_EDGE_DEFAULT_Y_MM,
y_max=LEG_OUTER_Y_MM,
z_min=lc.FOOT_BOTTOM_Z_MM,
z_max=lc.FOOT_TOP_Z_MM,
)
bend_ear = lc.make_box(
x_min=TOP_FLANGE_EAR_X_MIN_MM,
x_max=TOP_FLANGE_EAR_X_MAX_MM,
y_min=LEG_INNER_Y_MM,
y_max=LEG_OUTER_Y_MM,
z_min=FLANGE_BOTTOM_Z_MM,
z_max=LEG_TOP_Z_MM,
)
flange = lc.make_box(
x_min=FLANGE_WRAP_INNER_X_MM,
x_max=FLANGE_WRAP_OUTER_X_MM,
y_min=FLANGE_EDGE_Y_MM,
y_max=LEG_OUTER_Y_MM,
z_min=FLANGE_BOTTOM_Z_MM,
z_max=LEG_TOP_Z_MM,
)
bracket: build123d.Shape = leg.fuse(foot).fuse(bend_ear).fuse(flange)
# Raised-panel clearance: the only setback in the otherwise straight,
# flush foot inner edge. The servo's raised center panel protrudes to
# Z=1.9 over this X-span, so the foot edge steps out to clear it. The
# bay is a recess, so the foot covers it flush with no setback.
bracket = bracket.cut(
lc.make_box(
x_min=RAISED_ZONE_X_MM[0],
x_max=RAISED_ZONE_X_MM[1],
y_min=FOOT_EDGE_DEFAULT_Y_MM - CUT_EXTENSION_MM,
y_max=FOOT_EDGE_RAISED_Y_MM,
z_min=lc.FOOT_BOTTOM_Z_MM - CUT_EXTENSION_MM,
z_max=lc.FOOT_TOP_Z_MM + CUT_EXTENSION_MM,
)
)
for slot in _slot_cuts():
bracket = bracket.cut(slot)
# M3 cross-standoff holes. These bridge the left/right plates with
# 35 mm female-female standoffs, turning the pair into a ladder frame.
for _label, standoff_x, standoff_z in STANDOFF_CENTER_XZ_MM:
bracket = bracket.cut(
lc.make_y_cylinder(
x=standoff_x,
z=standoff_z,
radius=M3_STANDOFF_CLEARANCE_RADIUS_MM,
y_min=LEG_INNER_Y_MM - CUT_EXTENSION_MM,
y_max=LEG_OUTER_Y_MM + CUT_EXTENSION_MM,
)
)
# Foot servo holes.
for hole_x in (lc.HOLE_NEAR_OFFSET_MM, lc.HOLE_FAR_OFFSET_MM):
bracket = bracket.cut(
lc.make_z_cylinder(
x=hole_x,
y=lc.HOLE_SIDE_OFFSET_MM,
radius=lc.SERVO_MOUNT_HOLE_RADIUS_MM,
z_min=lc.FOOT_BOTTOM_Z_MM - CUT_EXTENSION_MM,
z_max=lc.FOOT_TOP_Z_MM + CUT_EXTENSION_MM,
)
)
# Horn clearance: the output horn boss sits on the pitch axis and reaches
# toward the wrap flange. The relief arc stays concentric with the horn so
# the remaining screw web can be measured directly from that axis.
bracket = bracket.cut(
lc.make_x_cylinder(
y=0.0,
z=TOP_PIVOT_Z_MM,
radius=OUTPUT_HORN_RELIEF_RADIUS_MM,
x_min=FLANGE_WRAP_INNER_X_MM - CUT_EXTENSION_MM,
x_max=FLANGE_WRAP_OUTER_X_MM + CUT_EXTENSION_MM,
)
)
# Flange servo holes (the case-top +Y pair).
for hole_z in (CASE_OFFSET_HOLE_Z_MM, TOP_FLANGE_UPPER_HOLE_Z_MM):
bracket = bracket.cut(
lc.make_x_cylinder(
y=lc.HOLE_SIDE_OFFSET_MM,
z=hole_z,
radius=lc.SERVO_MOUNT_HOLE_RADIUS_MM,
x_min=FLANGE_WRAP_INNER_X_MM - CUT_EXTENSION_MM,
x_max=FLANGE_WRAP_OUTER_X_MM + CUT_EXTENSION_MM,
)
)
# Round the foot-tab inside corners (Z-direction edges at Y=7.5 where
# the tabs meet the setback) and the leg top corner.
foot_tab_x = (FOOT_X_MIN_MM, LEFT_TAB_RIGHT_X_MM, RAISED_ZONE_X_MM[1], FOOT_X_MAX_MM)
leg_x = LEG_RUN_X_MM[0]
foot_tab_corners = bracket.edges().filter_by(build123d.Axis.Z).filter_by(
lambda e: abs(e.center().Y - FOOT_EDGE_DEFAULT_Y_MM) < 0.05
and any(abs(e.center().X - x) < 0.05 for x in foot_tab_x)
and abs(e.length - lc.SHEET_THICKNESS_MM) < 0.05
)
bracket = bracket.fillet(FORMED_FLANGE_CORNER_FILLET_RADIUS_MM, foot_tab_corners)
wrap_flange_bottom_free_edges = bracket.edges().filter_by(build123d.Axis.X).filter_by(
lambda e: abs(e.center().Y - FLANGE_EDGE_Y_MM) < 0.05
and abs(e.center().Z - FLANGE_BOTTOM_Z_MM) < 0.05
and abs(e.length - lc.SHEET_THICKNESS_MM) < 0.05
)
if len(wrap_flange_bottom_free_edges) != 1:
raise RuntimeError(
"Expected one wrap flange bottom free corner to fillet, "
f"found {len(wrap_flange_bottom_free_edges)}"
)
bracket = bracket.fillet(
FORMED_FLANGE_CORNER_FILLET_RADIUS_MM,
wrap_flange_bottom_free_edges,
)
leg_top_corners = bracket.edges().filter_by(build123d.Axis.Y).filter_by(
lambda e: abs(e.center().X - leg_x) < 0.05
and abs(e.center().Z - LEG_TOP_Z_MM) < 0.05
and abs(e.length - lc.SHEET_THICKNESS_MM) < 0.05
)
bracket = bracket.fillet(1.5, leg_top_corners)
return bracket
def build_step() -> build123d.Shape:
bracket = build_bracket()
solids = bracket.solids()
if len(solids) != 1:
raise RuntimeError(f"Expected one connected plate solid, found {len(solids)}")
top_servo, bottom_servo = _placed_link_servos_case()
top_volume = lc.verify_no_interference(bracket, top_servo, label="the top servo")
bottom_volume = lc.verify_no_interference(bracket, bottom_servo, label="the bottom servo")
bracket.label = PART_NAME
bracket.color = lc.BRACKET_COLOR
bb = bracket.bounding_box()
foot_flange = LEG_OUTER_Y_MM - FOOT_EDGE_DEFAULT_Y_MM
foot_flange_raised = LEG_OUTER_Y_MM - FOOT_EDGE_RAISED_Y_MM
wrap_flange = LEG_OUTER_Y_MM - FLANGE_EDGE_Y_MM
# Worst-case wrap-flange depth at the horn-relief intrusion (top of flange).
horn_intrusion_y = (
OUTPUT_HORN_RELIEF_RADIUS_MM**2 - (TOP_PIVOT_Z_MM - LEG_TOP_Z_MM) ** 2
) ** 0.5
wrap_flange_at_horn = LEG_OUTER_Y_MM - horn_intrusion_y
hole_to_bend = LEG_OUTER_Y_MM - lc.HOLE_SIDE_OFFSET_MM
half_die = lc.SCS_HALF_DIE_WIDTH_MM
min_flange = lc.SCS_MIN_FLANGE_AFTER_BEND_MM
flange_check_status = (
"PASS"
if min(foot_flange, foot_flange_raised, wrap_flange, wrap_flange_at_horn)
+ 1e-6
>= min_flange
else "CHECK"
)
hole_to_bend_status = "PASS" if hole_to_bend + 1e-6 >= half_die else "CHECK"
print(
"Wrap plate envelope "
f"X={bb.size.X:.3f} mm, Y={bb.size.Y:.3f} mm, Z={bb.size.Z:.3f} mm"
)
print(
f"Height parameter: overall={LINK_BRACKET_OVERALL_HEIGHT_MM:.3f} mm "
f"(scale {HEIGHT_SCALE:.4f} from the 180 mm baseline)"
)
print(
"Top case centering offset "
f"{TOP_SERVO_MATED_CASE_SPAN_CENTERING_OFFSET_MM:.3f} mm "
f"(face-to-flange shift {TOP_SERVO_FACE_TO_FLANGE_X_OFFSET_MM:.3f} mm); "
f"case-top face X={FLANGE_WRAP_INNER_X_MM:.3f} mm"
)
print(
f"Side profile bands: lower to low/high-X Z="
f"{LEFT_SLOPE_BOTTOM_Z_MM:.1f}/{RIGHT_SLOPE_BOTTOM_Z_MM:.1f} mm, "
f"steeper slope to low/high-X Z="
f"{LEFT_SLOPE_TOP_Z_MM:.1f}/{RIGHT_SLOPE_TOP_Z_MM:.1f} mm, "
f"upper to axis Z={TOP_PIVOT_Z_MM:.1f} mm; "
f"top servo pitch axis Z={TOP_PIVOT_Z_MM:.1f} mm"
)
print(
f"Web width check: lower/upper bands {SECTION_WIDTH_MM:.3f} mm, "
f"local flange bend leeway {TOP_FLANGE_BEND_LEEWAY_MM:.3f} mm, "
f"sloped band normal width {SLOPED_BAND_NORMAL_WIDTH_MM:.3f} mm, "
f"low-X transition shift {LOW_X_SLOPE_Z_SHIFT_DOWN_MM:.3f} mm down"
)
print(
"Transition corner radius: "
+ (
"disabled"
if TRANSITION_CORNER_FILLET_RADIUS_MM == 0.0
else f"{TRANSITION_CORNER_FILLET_RADIUS_MM:.1f} mm"
)
)
print(
"Slots: 2x lower vertical, 2x sloped transition, 2x upper vertical "
f"(lengths {LOWER_SLOT_LENGTH_MM:.1f}/{MIDDLE_SLOT_LENGTH_MM:.1f}/"
f"{TOP_SLOT_LENGTH_MM:.1f} mm, widths {SLOT_WIDTH_MM:.1f}/{TOP_SLOT_WIDTH_MM:.1f} mm)"
)
print(
"Cross standoffs: "
f"{len(STANDOFF_CENTER_XZ_MM)}x M3 female-female, length={LINK_STANDOFF_LENGTH_MM:.3f} mm, "
f"inner bracket gap={2.0 * LEG_INNER_Y_MM:.3f} mm, "
f"clearance per servo side={LEG_FACE_CLEARANCE_MM:.3f} mm"
)
print(
f"Sloped slot margins: low edge {MIDDLE_SLOT_LOW_EDGE_MARGIN_MM:.3f} mm, "
f"high edge {MIDDLE_SLOT_HIGH_EDGE_MARGIN_MM:.3f} mm"
)
print(
"Sheet setup "
f"material=5052-H32 (ALU-063), thickness={lc.SHEET_THICKNESS_MM:.4f} mm, "
"bends=2 (continuous foot bend, full-length wrap bend)"
)
print(
"SendCutSend flange checks (min after-bend "
f"{min_flange:.3f} mm): "
f"foot default {foot_flange:.2f} mm, "
f"foot raised-zone setback {foot_flange_raised:.2f} mm, "
f"wrap main {wrap_flange:.2f} mm, "
f"wrap at horn-relief Z={LEG_TOP_Z_MM:.1f} {wrap_flange_at_horn:.2f} mm "
f"-- {flange_check_status}"
)
print(
f"SendCutSend hole-to-bend checks (half die {half_die:.3f} mm): "
f"foot screws {hole_to_bend:.2f} mm, wrap-flange screws {hole_to_bend:.2f} mm "
f"-- {hole_to_bend_status}"
)
print(
"Top flange hole-to-horn-relief web: "
f"{TOP_FLANGE_HOLE_TO_RELIEF_EDGE_GAP_MM:.3f} mm "
f"(relief radius {OUTPUT_HORN_RELIEF_RADIUS_MM:.3f} mm)"
)
print(
f"Clearances: leg gap {LEG_FACE_CLEARANCE_MM:.1f} mm off the servo side face; "
f"bend-ear gives {TOP_FLANGE_BEND_LEEWAY_MM:.3f} mm of flat lead-in; "
"flange inner face seats on the aligned top servo case face; "
f"flange inner edge at Y={FLANGE_EDGE_Y_MM} clears the case-top ridge"
)
print(f"Top servo interference volume (mm^3): {top_volume:.6f}")
print(f"Bottom servo interference volume (mm^3): {bottom_volume:.6f}")
return bracket
def _polyline_points_from_ring(ring) -> list[tuple[float, float]]:
points = [(float(x), float(y)) for x, y in ring.coords]
if points and points[0] == points[-1]:
points.pop()
return points
def _add_closed_polyline(msp, points: list[tuple[float, float]], *, layer: str) -> None:
if len(points) < 3:
return
msp.add_lwpolyline(points, close=True, dxfattribs={"layer": layer})
def _axis_value(vector: build123d.Vector, axis: str) -> float:
return {"x": vector.X, "y": vector.Y, "z": vector.Z}[axis]
def _select_planar_face(
shape: build123d.Shape,
*,
normal_axis: str,
normal_sign: float,
coordinate_axis: str,
coordinate: float,
) -> build123d.Face:
candidates = []
for face in shape.faces():
if face.geom_type != build123d.GeomType.PLANE:
continue
normal = face.normal_at()
center = face.center()
if abs(_axis_value(normal, normal_axis) - normal_sign) > 0.01:
continue
if abs(_axis_value(center, coordinate_axis) - coordinate) > 0.05:
continue
candidates.append(face)
if not candidates:
raise RuntimeError(
"Could not find topology face for DXF unfolding: "
f"normal {normal_sign:+.0f}{normal_axis}, {coordinate_axis}={coordinate:.3f}"
)
return max(candidates, key=lambda face: face.area)
def _project_wire_points(
wire: build123d.Wire,
project,
*,
max_segment_mm: float = 0.25,
) -> list[tuple[float, float]]:
sample_count = max(16, int(math.ceil(wire.length / max_segment_mm)))
points: list[tuple[float, float]] = []
for index in range(sample_count):
point = project(wire.position_at(index / sample_count))
if not points or math.dist(points[-1], point) > 1e-6:
points.append(point)
if len(points) >= 2 and math.dist(points[0], points[-1]) < 1e-6:
points.pop()
return points
def _project_face_polygon(face: build123d.Face, project):
from shapely.geometry import Polygon
outer = _project_wire_points(face.outer_wire(), project)
holes = [
_project_wire_points(wire, project)
for wire in face.inner_wires()
if wire.length > 1e-6
]
polygon = Polygon(outer, holes)
if not polygon.is_valid:
polygon = polygon.buffer(0)
if polygon.is_empty:
raise RuntimeError("Projected STEP topology face produced an empty flat-pattern polygon")
return polygon
def _validate_sendcutsend_bend_rule() -> None:
expected_bend_allowance = (
2.0 * lc.SCS_OUTSIDE_SETBACK_90_MM
) - lc.SCS_BEND_DEDUCTION_90_MM
if abs(expected_bend_allowance - lc.SCS_BEND_ALLOWANCE_90_MM) > 0.01:
raise RuntimeError(
"Inconsistent SendCutSend bend rule constants: "
f"outside_setback={lc.SCS_OUTSIDE_SETBACK_90_MM:.3f} mm, "
f"bend_deduction={lc.SCS_BEND_DEDUCTION_90_MM:.3f} mm, "
f"bend_allowance={lc.SCS_BEND_ALLOWANCE_90_MM:.3f} mm, "
f"expected={expected_bend_allowance:.3f} mm"
)
def _build_flat_pattern() -> FlatPattern:
_validate_sendcutsend_bend_rule()
outside_setback = lc.SCS_OUTSIDE_SETBACK_90_MM
bend_allowance = lc.SCS_BEND_ALLOWANCE_90_MM
panel_join_shift = bend_allowance - outside_setback
return FlatPattern(
outside_setback_mm=outside_setback,
bend_allowance_mm=bend_allowance,
bend_deduction_mm=lc.SCS_BEND_DEDUCTION_90_MM,
panel_join_shift_mm=panel_join_shift,
foot_bend_line_y_mm=lc.FOOT_BOTTOM_Z_MM + (0.5 * bend_allowance),
wrap_bend_line_x_mm=(
FLANGE_WRAP_INNER_X_MM + panel_join_shift + (0.5 * bend_allowance)
),
)
def _flat_pattern_geometry_from_step_topology():
from shapely.ops import unary_union
flat_pattern = _build_flat_pattern()
outside_setback = flat_pattern.outside_setback_mm
panel_join_shift = flat_pattern.panel_join_shift_mm
bracket = build_bracket()
leg_face = _select_planar_face(
bracket,
normal_axis="y",
normal_sign=1.0,
coordinate_axis="y",
coordinate=LEG_OUTER_Y_MM,
)
foot_face = _select_planar_face(
bracket,
normal_axis="z",
normal_sign=-1.0,
coordinate_axis="z",
coordinate=lc.FOOT_BOTTOM_Z_MM,
)
wrap_flange_face = _select_planar_face(
bracket,
normal_axis="x",
normal_sign=1.0,
coordinate_axis="x",
coordinate=FLANGE_WRAP_OUTER_X_MM,
)
def project_leg(point: build123d.Vector) -> tuple[float, float]:
flat_x = point.X
flat_z = point.Z
if abs(point.X - FLANGE_WRAP_INNER_X_MM) <= 1e-3:
flat_x = FLANGE_WRAP_INNER_X_MM + panel_join_shift
if abs(point.Z - lc.FOOT_BOTTOM_Z_MM) <= 1e-3:
flat_z = lc.FOOT_BOTTOM_Z_MM
elif point.Z > lc.FOOT_BOTTOM_Z_MM:
flat_z = point.Z + panel_join_shift
return (flat_x, flat_z)
def project_foot(point: build123d.Vector) -> tuple[float, float]:
if abs(point.Y - LEG_OUTER_Y_MM) <= 1e-3:
flat_y = lc.FOOT_BOTTOM_Z_MM
else:
flat_y = point.Y - LEG_OUTER_Y_MM + outside_setback
return (point.X, flat_y)
def project_wrap_flange(point: build123d.Vector) -> tuple[float, float]:
if abs(point.Y - LEG_OUTER_Y_MM) <= 1e-3:
flat_x = FLANGE_WRAP_INNER_X_MM + panel_join_shift
else:
flat_x = (
FLANGE_WRAP_INNER_X_MM
+ panel_join_shift
+ (LEG_OUTER_Y_MM - point.Y)
- outside_setback
+ flat_pattern.bend_allowance_mm
)
flat_z = point.Z + panel_join_shift if point.Z > lc.FOOT_BOTTOM_Z_MM else point.Z
return (flat_x, flat_z)
leg = _project_face_polygon(leg_face, project_leg)
foot = _project_face_polygon(
foot_face,
project_foot,
)
wrap_flange = _project_face_polygon(
wrap_flange_face,
project_wrap_flange,
)
_validate_projected_flange_depths(foot, wrap_flange, flat_pattern)
flat_geometry = unary_union([leg, foot, wrap_flange]).buffer(0)
if not flat_geometry.is_valid:
flat_geometry = flat_geometry.buffer(0)
# Keep the linework compact while preserving the sampled STEP curves within
# a few microns. The samples still originate from the formed solid topology.
flat_geometry = flat_geometry.simplify(0.002, preserve_topology=True)
from shapely.geometry import LineString
foot_bounds = foot_face.bounding_box()
wrap_bounds = wrap_flange_face.bounding_box()
bend_lines = (
LineString(
[
(foot_bounds.min.X, lc.FOOT_BOTTOM_Z_MM),
(foot_bounds.max.X, lc.FOOT_BOTTOM_Z_MM),
]
),
LineString(
[
(
flat_pattern.wrap_bend_line_x_mm,
wrap_bounds.min.Z + panel_join_shift,
),
(
flat_pattern.wrap_bend_line_x_mm,
wrap_bounds.max.Z + panel_join_shift,
),
]
),
)
bend_lines = (
LineString(
[
(foot_bounds.min.X, flat_pattern.foot_bend_line_y_mm),
(foot_bounds.max.X, flat_pattern.foot_bend_line_y_mm),
]
),
bend_lines[1],
)
return flat_geometry, bend_lines, flat_pattern
def _validate_projected_flange_depths(
foot,
wrap_flange,
flat_pattern: FlatPattern,
) -> None:
foot_formed_depth = LEG_OUTER_Y_MM - FOOT_EDGE_DEFAULT_Y_MM
wrap_formed_depth = LEG_OUTER_Y_MM - FLANGE_EDGE_Y_MM
foot_expected = (
foot_formed_depth
- flat_pattern.outside_setback_mm
+ (0.5 * flat_pattern.bend_allowance_mm)
)
wrap_expected = (
wrap_formed_depth
- flat_pattern.outside_setback_mm
+ (0.5 * flat_pattern.bend_allowance_mm)
)
foot_actual = flat_pattern.foot_bend_line_y_mm - foot.bounds[1]
wrap_actual = wrap_flange.bounds[2] - flat_pattern.wrap_bend_line_x_mm
checks = (
("bottom flange", foot_actual, foot_expected),
("top wrap flange", wrap_actual, wrap_expected),
)
for name, actual, expected in checks:
if abs(actual - expected) > 0.01:
raise RuntimeError(
f"Projected {name} DXF depth is inconsistent with bend math: "
f"actual={actual:.3f} mm, expected={expected:.3f} mm"
)
if abs(foot_formed_depth - wrap_formed_depth) <= 0.01 and abs(foot_actual - wrap_actual) > 0.01:
raise RuntimeError(
"Projected link bracket flange depths are not equal despite equal formed depths: "
f"bottom={foot_actual:.3f} mm, top={wrap_actual:.3f} mm"
)
def _add_flat_pattern_entities(msp, flat_geometry, bend_lines) -> None:
polygons = [flat_geometry] if flat_geometry.geom_type == "Polygon" else list(flat_geometry.geoms)
for polygon in polygons:
dxf_topology.add_ring(msp, polygon.exterior, layer="CUT")
for interior in polygon.interiors:
dxf_topology.add_ring(msp, interior, layer="CUT", prefer_circle=True)
for line in bend_lines:
coords = list(line.coords)
msp.add_line(
coords[0],
coords[-1],
dxfattribs={"layer": "BEND", "linetype": "DASHED"},
)
def build_dxf(*, part_name: str = PART_NAME):
import ezdxf
from ezdxf.units import MM as DXF_UNIT_MM
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")
flat_geometry, bend_lines, flat_pattern = _flat_pattern_geometry_from_step_topology()
_add_flat_pattern_entities(doc.modelspace(), flat_geometry, bend_lines)
min_x, min_y, max_x, max_y = flat_geometry.bounds
print(
f"{part_name} flat DXF "
f"overall_height={LINK_BRACKET_OVERALL_HEIGHT_MM:.3f} mm, "
f"bounds=({max_x - min_x:.3f} x {max_y - min_y:.3f}) mm, "
f"topology_unfold_faces=3, bend_deduction_90={flat_pattern.bend_deduction_mm:.3f} mm, "
f"bend_allowance_90={flat_pattern.bend_allowance_mm:.3f} mm, "
f"bend_lines={len(bend_lines)}, layers=CUT/BEND"
)
return doc
def gen_step() -> dict[str, object]:
return {
"shape": build_step(),
}
def gen_dxf() -> dict[str, object]:
return {
"document": build_dxf(),
}
if __name__ == "__main__":
gen_step()