#!/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()