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

766 lines
25 KiB
Python

#!/usr/bin/env python3
"""
Generate a bent base clamp that wraps under the base servo footprint.
The upper plate stays in the servo base frame, a side wall rises from the
positive-x side, and a lower return closes the U so the part can clamp to a
desk or fixture from below.
"""
from __future__ import annotations
from dataclasses import dataclass
import math
import os
import sys
from pathlib import Path
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
V1_DIR = Path(__file__).resolve().parents[1]
TOM_DIR = V1_DIR.parent
_CACHE_HOME = V1_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 build123d
import ezdxf
from ezdxf.units import MM as DXF_UNIT_MM
if str(TOM_DIR) not in sys.path:
sys.path.insert(0, str(TOM_DIR))
DISPLAY_NAME = "Base Clamp"
PLATE_THICKNESS_MM = 25.4 * 0.125
SADDLE_FOOTPRINT_X_BUFFER_MM = 2.0
SIDE_WALL_THICKNESS_MM = PLATE_THICKNESS_MM
BOOLEAN_OVERLAP_MM = 0.05
CUT_EXTENSION_MM = 0.5
CORNER_RADIUS_MM = 3.0
RETURN_FACE_LENGTH_RATIO = 2.0 / 3.0
M3_CLEARANCE_RADIUS_MM = 1.6
BASE_CENTER_HORN_FACE_RADIUS_MM = 2.8
BASE_CENTER_HORN_FACE_CLEARANCE_BUFFER_MM = 0.25
BASE_CENTER_HORN_FACE_CLEARANCE_RADIUS_MM = (
BASE_CENTER_HORN_FACE_RADIUS_MM + BASE_CENTER_HORN_FACE_CLEARANCE_BUFFER_MM
)
M8_CLEARANCE_DIAMETER_MM = 8.5
M8_CLEARANCE_RADIUS_MM = 0.5 * M8_CLEARANCE_DIAMETER_MM
SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM = 25.4 * 0.125
SENDCUTSEND_K_FACTOR = 0.44
SENDCUTSEND_BEND_DEDUCTION_90_MM = 25.4 * 0.216
SENDCUTSEND_HALF_DIE_WIDTH_MM = 0.5 * (25.4 * 0.630)
SENDCUTSEND_MIN_FLANGE_FORMED_MM = 25.4 * 0.476
SENDCUTSEND_MOUNT_HOLE_TO_BEND_BUFFER_MM = 0.1
# Base servo footprint bounds in robot-arm local coordinates.
SADDLE_BASE_FACE_Y_MM = -29.8002
SADDLE_BASE_X_MIN_MM = -35.61
SADDLE_BASE_X_MAX_MM = 6.338516
SADDLE_BASE_Z_MIN_MM = -15.960226
SADDLE_BASE_Z_MAX_MM = 15.960224
# 5x M3 lower-horn clearance pattern on the base plate.
BASE_HORN_CONNECTION_HOLE_SPECS_MM: tuple[tuple[float, float, float], ...] = (
(-32.5, 0.0, M3_CLEARANCE_RADIUS_MM),
(-25.5, -7.0, M3_CLEARANCE_RADIUS_MM),
(-25.5, 0.0, BASE_CENTER_HORN_FACE_CLEARANCE_RADIUS_MM),
(-25.5, 7.0, M3_CLEARANCE_RADIUS_MM),
(-18.5, 0.0, M3_CLEARANCE_RADIUS_MM),
)
# Resolved from:
# @cad[STEP/imports/sts3250#o1.2.e920,o1.2.e922]
# @cad[STEP/imports/sts3250#o1.2.e921,o1.2.e923]
M2_CLEARANCE_RADIUS_MM = 1.1
BASE_SERVO_M2_HOLE_SPECS_MM: tuple[tuple[float, float, float], ...] = (
(-17.2, -10.25, M2_CLEARANCE_RADIUS_MM),
(7.25, -10.25, M2_CLEARANCE_RADIUS_MM),
(-17.2, 10.25, M2_CLEARANCE_RADIUS_MM),
(7.25, 10.25, M2_CLEARANCE_RADIUS_MM),
)
BASE_ATTACHMENT_HOLES_MM: tuple[tuple[float, float, float], ...] = (
BASE_HORN_CONNECTION_HOLE_SPECS_MM + BASE_SERVO_M2_HOLE_SPECS_MM
)
# Resolved from the four PCB spacer hole centers in the original SO-101 bus board.
SERVO_BUS_BOARD_SPACER_CENTERS_XY_MM: tuple[tuple[float, float], ...] = (
(2.4930065, 2.4999875),
(2.4930065, 30.4999925),
(39.493009, 2.4999875),
(39.505008000000004, 30.4999925),
)
SERVO_BUS_SPACER_CLEARANCE_RADIUS_MM = M3_CLEARANCE_RADIUS_MM
SERVO_BUS_MOUNT_LOCAL_X_MIN_MM = min(x for x, _y in SERVO_BUS_BOARD_SPACER_CENTERS_XY_MM)
SERVO_BUS_MOUNT_LOCAL_X_MAX_MM = max(x for x, _y in SERVO_BUS_BOARD_SPACER_CENTERS_XY_MM)
SERVO_BUS_MOUNT_LOCAL_X_CENTER_MM = 0.5 * (SERVO_BUS_MOUNT_LOCAL_X_MIN_MM + SERVO_BUS_MOUNT_LOCAL_X_MAX_MM)
SERVO_BUS_MOUNT_LOCAL_Y_MIN_MM = min(y for _x, y in SERVO_BUS_BOARD_SPACER_CENTERS_XY_MM)
SERVO_BUS_MOUNT_LOCAL_Y_MAX_MM = max(y for _x, y in SERVO_BUS_BOARD_SPACER_CENTERS_XY_MM)
SERVO_BUS_MOUNT_LOCAL_Y_CENTER_MM = 0.5 * (SERVO_BUS_MOUNT_LOCAL_Y_MIN_MM + SERVO_BUS_MOUNT_LOCAL_Y_MAX_MM)
SENDCUTSEND_MIN_HOLE_TO_EDGE_QUOTE_MM = 0.97
BASE_CLAMP_HOLE_TO_EDGE_BUFFER_MM = 0.53
BASE_CLAMP_HOLE_TO_EDGE_TARGET_MM = (
SENDCUTSEND_MIN_HOLE_TO_EDGE_QUOTE_MM + BASE_CLAMP_HOLE_TO_EDGE_BUFFER_MM
)
BASE_CLAMP_SERVO_BUS_SPACER_WIDTH_MM = (
(SERVO_BUS_MOUNT_LOCAL_Y_MAX_MM - SERVO_BUS_MOUNT_LOCAL_Y_MIN_MM)
+ (2.0 * (SERVO_BUS_SPACER_CLEARANCE_RADIUS_MM + BASE_CLAMP_HOLE_TO_EDGE_TARGET_MM))
)
# SendCutSend's current .125" 5052 guideline uses a 0.630" die width for
# bending; cut features should stay outside half of that die width from the
# bend line. We size the wall from the mount-hole span plus that offset.
SENDCUTSEND_5052_125_DIE_WIDTH_MM = 0.630 * 25.4
SENDCUTSEND_FEATURE_TO_BEND_MIN_MM = (0.5 * SENDCUTSEND_5052_125_DIE_WIDTH_MM) + SERVO_BUS_SPACER_CLEARANCE_RADIUS_MM
U_BRACKET_CLEARANCE_MM = (
(SERVO_BUS_MOUNT_LOCAL_X_MAX_MM - SERVO_BUS_MOUNT_LOCAL_X_MIN_MM)
+ (2.0 * SENDCUTSEND_FEATURE_TO_BEND_MIN_MM)
- (2.0 * PLATE_THICKNESS_MM)
)
# Resolved from @cad[STEP/imports/servo_bus].
SERVO_BUS_LOCAL_X_MIN_MM = -0.390013
SERVO_BUS_LOCAL_X_MAX_MM = 42.401989
SERVO_BUS_LOCAL_Y_MIN_MM = -0.012095
SERVO_BUS_LOCAL_Y_MAX_MM = 33.012075
SERVO_BUS_LOCAL_Z_MIN_MM = -6.0
SERVO_BUS_LOCAL_Z_MAX_MM = 1.599999
BASE_PLATE_Z_WIDTH_MM = max(
SERVO_BUS_LOCAL_Y_MAX_MM - SERVO_BUS_LOCAL_Y_MIN_MM,
BASE_CLAMP_SERVO_BUS_SPACER_WIDTH_MM,
)
@dataclass(frozen=True)
class BracketLayout:
x_min: float
x_max: float
y_min: float
y_max: float
z_min: float
z_max: float
width: float
height: float
return_width: float
return_x_min: float
return_y_min: float
return_y_max: float
side_x_min: float
side_x_max: float
overall_y_min: float
face_y: float
attachment_holes: tuple[tuple[float, float, float], ...]
return_mount_holes: tuple[tuple[float, float, float], ...]
servo_bus_mount_holes_yzr: tuple[tuple[float, float, float], ...]
@dataclass(frozen=True)
class FlatPattern:
total_length_mm: float
width_mm: float
top_flange_flat_length_mm: float
return_flange_flat_length_mm: float
web_flat_start_mm: float
web_flat_end_mm: float
bend_line_positions_mm: tuple[float, float]
cut_circles: tuple[tuple[float, float, float], ...]
rounded_corner_radius_mm: float
min_feature_to_bend_mm: float
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 _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 _sendcutsend_outside_setback_90_mm() -> float:
return SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM + PLATE_THICKNESS_MM
def _sendcutsend_bend_allowance_90_mm() -> float:
return (math.pi * 0.5) * (
SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM + (SENDCUTSEND_K_FACTOR * PLATE_THICKNESS_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 _cast_shape(obj: object) -> build123d.Shape:
from build123d.topology import downcast
from build123d.topology.shape_core import shapetype
occt_shape = downcast(obj)
shape_name = build123d.Shape.shape_LUT[shapetype(occt_shape)]
shape_type = getattr(build123d, shape_name)
return shape_type.cast(occt_shape)
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 _build_bracket_layout() -> BracketLayout:
x_min = SADDLE_BASE_X_MIN_MM - SADDLE_FOOTPRINT_X_BUFFER_MM
x_max = SADDLE_BASE_X_MAX_MM + SADDLE_FOOTPRINT_X_BUFFER_MM
required_x_max_for_mount_holes = max(
x
+ radius
+ SENDCUTSEND_HALF_DIE_WIDTH_MM
+ SENDCUTSEND_MOUNT_HOLE_TO_BEND_BUFFER_MM
for x, _z, radius in BASE_ATTACHMENT_HOLES_MM
)
x_max = max(x_max, required_x_max_for_mount_holes)
z_center = 0.5 * (SADDLE_BASE_Z_MIN_MM + SADDLE_BASE_Z_MAX_MM)
z_min = z_center - (0.5 * BASE_PLATE_Z_WIDTH_MM)
z_max = z_center + (0.5 * BASE_PLATE_Z_WIDTH_MM)
y_min = SADDLE_BASE_FACE_Y_MM - PLATE_THICKNESS_MM
y_max = SADDLE_BASE_FACE_Y_MM
return_y_max = y_min - U_BRACKET_CLEARANCE_MM
return_y_min = return_y_max - PLATE_THICKNESS_MM
side_x_min = x_max - BOOLEAN_OVERLAP_MM
side_x_max = x_max + SIDE_WALL_THICKNESS_MM
return_x_min = side_x_min - (RETURN_FACE_LENGTH_RATIO * (side_x_max - x_min))
return_mount_holes = (
(
0.5 * (return_x_min + side_x_min),
0.5 * (z_min + z_max),
M8_CLEARANCE_RADIUS_MM,
),
)
servo_bus_mount_holes_yzr = _servo_bus_mount_holes_yzr(
y_min=return_y_min,
y_max=y_max,
z_min=z_min,
z_max=z_max,
)
return BracketLayout(
x_min=x_min,
x_max=x_max,
y_min=y_min,
y_max=y_max,
z_min=z_min,
z_max=z_max,
width=x_max - x_min,
height=z_max - z_min,
return_width=side_x_max - return_x_min,
return_x_min=return_x_min,
return_y_min=return_y_min,
return_y_max=return_y_max,
side_x_min=side_x_min,
side_x_max=side_x_max,
overall_y_min=return_y_min,
face_y=y_max,
attachment_holes=BASE_ATTACHMENT_HOLES_MM,
return_mount_holes=return_mount_holes,
servo_bus_mount_holes_yzr=servo_bus_mount_holes_yzr,
)
def _fillet_plan_corners(
plate: build123d.Solid,
*,
x_min: float,
x_max: float,
y_min: float,
y_max: float,
z_min: float,
z_max: float,
include_min_x: bool = True,
include_max_x: bool = True,
) -> build123d.Shape:
matches: list[build123d.Edge] = []
for edge in plate.edges():
if _geom_type_name(edge) != "LINE":
continue
bb = edge.bounding_box()
if abs((bb.max.Y - bb.min.Y) - (y_max - y_min)) > 1e-3:
continue
if (bb.max.X - bb.min.X) > 1e-3 or (bb.max.Z - bb.min.Z) > 1e-3:
continue
center = edge.center()
if abs(abs(center.X - ((x_min + x_max) * 0.5)) - ((x_max - x_min) * 0.5)) > 1e-3:
continue
if abs(abs(center.Z - ((z_min + z_max) * 0.5)) - ((z_max - z_min) * 0.5)) > 1e-3:
continue
at_min_x = abs(center.X - x_min) <= 1e-3
at_max_x = abs(center.X - x_max) <= 1e-3
if at_min_x and not include_min_x:
continue
if at_max_x and not include_max_x:
continue
matches.append(edge)
expected_count = (2 if include_min_x else 0) + (2 if include_max_x else 0)
if len(matches) != expected_count:
raise RuntimeError(f"Expected {expected_count} plan-view corner edges to fillet, found {len(matches)}")
mk = BRepFilletAPI_MakeFillet(plate.wrapped)
for edge in matches:
mk.Add(CORNER_RADIUS_MM, edge.wrapped)
mk.Build()
if not mk.IsDone():
raise RuntimeError("Failed to round U-bracket plate corners")
return _cast_shape(mk.Shape())
def _cut_y_holes(
plate: build123d.Shape,
*,
holes_xzr: tuple[tuple[float, float, float], ...],
x_min: float,
x_max: float,
y_min: float,
y_max: float,
z_min: float,
z_max: float,
) -> build123d.Shape:
hole_depth = (y_max - y_min) + (2.0 * CUT_EXTENSION_MM)
for x, z, radius in holes_xzr:
if not (x_min + radius <= x <= x_max - radius):
raise RuntimeError(f"Hole at x={x:.3f} mm does not fit within the bracket x span")
if not (z_min + radius <= z <= z_max - radius):
raise RuntimeError(f"Hole at z={z:.3f} mm does not fit within the bracket z span")
plate = plate.cut(
_make_cylinder_along(
radius=radius,
height=hole_depth,
origin=build123d.Vector(x, y_min - CUT_EXTENSION_MM, z),
direction=build123d.Vector(0.0, 1.0, 0.0),
)
)
return plate
def _cut_x_holes(
bracket: build123d.Shape,
*,
holes_yzr: tuple[tuple[float, float, float], ...],
x_min: float,
x_max: float,
y_min: float,
y_max: float,
z_min: float,
z_max: float,
) -> build123d.Shape:
hole_depth = (x_max - x_min) + (2.0 * CUT_EXTENSION_MM)
for y, z, radius in holes_yzr:
if not (y_min + radius <= y <= y_max - radius):
raise RuntimeError(f"Hole at y={y:.3f} mm does not fit within the bracket y span")
if not (z_min + radius <= z <= z_max - radius):
raise RuntimeError(f"Hole at z={z:.3f} mm does not fit within the bracket z span")
bracket = bracket.cut(
_make_cylinder_along(
radius=radius,
height=hole_depth,
origin=build123d.Vector(x_min - CUT_EXTENSION_MM, y, z),
direction=build123d.Vector(1.0, 0.0, 0.0),
)
)
return bracket
def _cut_base_plate_mount_features(
plate: build123d.Shape,
*,
holes_xzr: tuple[tuple[float, float, float], ...],
x_min: float,
x_max: float,
y_min: float,
y_max: float,
z_min: float,
z_max: float,
) -> build123d.Shape:
return _cut_y_holes(
plate,
holes_xzr=holes_xzr,
x_min=x_min,
x_max=x_max,
y_min=y_min,
y_max=y_max,
z_min=z_min,
z_max=z_max,
)
def _as_single_solid(shape: build123d.Shape) -> build123d.Solid:
solids = list(shape.solids())
if len(solids) != 1:
raise RuntimeError(f"Expected a single solid, found {len(solids)}")
return solids[0]
def _make_lower_plate(layout: BracketLayout) -> build123d.Shape:
plate = _make_box(
x_min=layout.x_min,
x_max=layout.x_max,
y_min=layout.y_min,
y_max=layout.y_max,
z_min=layout.z_min,
z_max=layout.z_max,
)
plate = _fillet_plan_corners(
plate,
x_min=layout.x_min,
x_max=layout.x_max,
y_min=layout.y_min,
y_max=layout.y_max,
z_min=layout.z_min,
z_max=layout.z_max,
include_min_x=True,
include_max_x=False,
)
plate = _cut_base_plate_mount_features(
plate,
holes_xzr=layout.attachment_holes,
x_min=layout.x_min,
x_max=layout.x_max,
y_min=layout.y_min,
y_max=layout.y_max,
z_min=layout.z_min,
z_max=layout.z_max,
)
return _as_single_solid(plate)
def _servo_bus_mount_holes_yzr(
*,
y_min: float,
y_max: float,
z_min: float,
z_max: float,
) -> tuple[tuple[float, float, float], ...]:
target_center_y = 0.5 * (y_min + y_max)
target_center_z = 0.5 * (z_min + z_max)
bus_to_bracket_y_offset = target_center_y - SERVO_BUS_MOUNT_LOCAL_X_CENTER_MM
bus_to_bracket_z_offset = target_center_z + SERVO_BUS_MOUNT_LOCAL_Y_CENTER_MM
return tuple(
(
bus_x + bus_to_bracket_y_offset,
bus_to_bracket_z_offset - bus_y,
SERVO_BUS_SPACER_CLEARANCE_RADIUS_MM,
)
for bus_x, bus_y in SERVO_BUS_BOARD_SPACER_CENTERS_XY_MM
)
def _make_return_plate(layout: BracketLayout) -> build123d.Shape:
plate = _make_box(
x_min=layout.return_x_min,
x_max=layout.side_x_max,
y_min=layout.return_y_min,
y_max=layout.return_y_max,
z_min=layout.z_min,
z_max=layout.z_max,
)
plate = _fillet_plan_corners(
plate,
x_min=layout.return_x_min,
x_max=layout.side_x_max,
y_min=layout.return_y_min,
y_max=layout.return_y_max,
z_min=layout.z_min,
z_max=layout.z_max,
include_min_x=True,
include_max_x=False,
)
plate = _cut_y_holes(
plate,
holes_xzr=layout.return_mount_holes,
x_min=layout.return_x_min,
x_max=layout.side_x_max,
y_min=layout.return_y_min,
y_max=layout.return_y_max,
z_min=layout.z_min,
z_max=layout.z_max,
)
return _as_single_solid(plate)
def _make_side_wall(layout: BracketLayout) -> build123d.Shape:
return _make_box(
x_min=layout.side_x_min,
x_max=layout.side_x_max,
y_min=layout.return_y_min,
y_max=layout.y_max,
z_min=layout.z_min,
z_max=layout.z_max,
)
def build_bracket() -> build123d.Solid:
layout = _build_bracket_layout()
bracket = _make_lower_plate(layout)
bracket = _as_single_solid(bracket.fuse(_make_side_wall(layout)))
bracket = _as_single_solid(bracket.fuse(_make_return_plate(layout)))
bracket = _as_single_solid(
_cut_x_holes(
bracket,
holes_yzr=layout.servo_bus_mount_holes_yzr,
x_min=layout.side_x_min,
x_max=layout.side_x_max,
y_min=layout.return_y_min,
y_max=layout.y_max,
z_min=layout.z_min,
z_max=layout.z_max,
)
)
print(
f"{DISPLAY_NAME}: "
f"lower_width={layout.width:.3f} mm, "
f"return_width={layout.return_width:.3f} mm, "
f"lower_height={layout.height:.3f} mm, "
f"plate_thickness={PLATE_THICKNESS_MM:.3f} mm, "
f"clearance={U_BRACKET_CLEARANCE_MM:.3f} mm, "
f"overall_y_min={layout.overall_y_min:.4f} mm, "
f"horn_m3_holes={len(BASE_HORN_CONNECTION_HOLE_SPECS_MM)}, "
f"m2_mount_holes={len(BASE_SERVO_M2_HOLE_SPECS_MM)}, "
f"return_mount_holes={len(layout.return_mount_holes)}, "
f"servo_bus_holes={len(layout.servo_bus_mount_holes_yzr)}, "
f"flush_y={layout.face_y:.4f} mm"
)
return bracket
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_flange_outside_length = layout.side_x_max - layout.x_min
return_flange_outside_length = layout.return_width
top_flange_flat_length = top_flange_outside_length - outside_setback
return_flange_flat_length = return_flange_outside_length - outside_setback
min_flange_outside_length = min(top_flange_outside_length, return_flange_outside_length)
min_flange_flat_length = min(top_flange_flat_length, return_flange_flat_length)
if min_flange_outside_length < SENDCUTSEND_MIN_FLANGE_FORMED_MM:
raise RuntimeError(
"Flange length is below the configured bend-rule minimum: "
f"{min_flange_outside_length:.3f} mm < {SENDCUTSEND_MIN_FLANGE_FORMED_MM:.3f} mm"
)
if min_flange_flat_length <= 0.0:
raise RuntimeError(
"Flat flange length implied by the bend rule is too short: "
f"{min_flange_flat_length:.3f} mm"
)
web_outside_length = layout.y_max - layout.return_y_min
web_flat_length = web_outside_length - (2.0 * SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM)
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_flange_flat_length + bend_allowance
web_flat_end = web_flat_start + web_flat_length
total_length = web_flat_end + bend_allowance + return_flange_flat_length
width = layout.z_max - layout.z_min
bend_line_positions = (
top_flange_flat_length + (0.5 * bend_allowance),
web_flat_end + (0.5 * bend_allowance),
)
top_tangent_y = layout.y_max - SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM
bottom_tangent_y = layout.return_y_min + SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM
feature_extents: list[tuple[float, float]] = []
cut_circles: list[tuple[float, float, float]] = []
for x, z, radius in layout.attachment_holes:
circle = (x - layout.x_min, z - layout.z_min, radius)
cut_circles.append(circle)
feature_extents.append((circle[0], radius))
for y, z, radius in layout.servo_bus_mount_holes_yzr:
circle = (web_flat_start + (top_tangent_y - y), z - layout.z_min, radius)
cut_circles.append(circle)
feature_extents.append((circle[0], radius))
for x, z, radius in layout.return_mount_holes:
circle = (total_length - (x - layout.return_x_min), z - layout.z_min, radius)
cut_circles.append(circle)
feature_extents.append((circle[0], radius))
min_feature_to_bend = min(
min(abs(center_u - bend_u) - feature_radius for bend_u in bend_line_positions)
for center_u, feature_radius in feature_extents
)
return FlatPattern(
total_length_mm=total_length,
width_mm=width,
top_flange_flat_length_mm=top_flange_flat_length,
return_flange_flat_length_mm=return_flange_flat_length,
web_flat_start_mm=web_flat_start,
web_flat_end_mm=web_flat_end,
bend_line_positions_mm=bend_line_positions,
cut_circles=tuple(cut_circles),
rounded_corner_radius_mm=CORNER_RADIUS_MM,
min_feature_to_bend_mm=min_feature_to_bend,
)
def _add_rounded_rect_outline(
msp: ezdxf.layouts.Modelspace,
*,
length: float,
width: float,
radius: float,
) -> None:
if radius <= 0.0:
raise RuntimeError(f"Corner radius must be positive, got {radius:.3f} mm")
if width <= (2.0 * radius):
raise RuntimeError(
"Corner radius is too large for the base clamp flat-pattern width: "
f"{radius:.3f} mm"
)
if length <= (2.0 * radius):
raise RuntimeError(
"Corner radius is too large for the base clamp flat-pattern length: "
f"{radius:.3f} mm"
)
msp.add_line((radius, 0.0), (length - radius, 0.0), dxfattribs={"layer": "CUT"})
msp.add_arc(
center=(length - radius, radius),
radius=radius,
start_angle=-90.0,
end_angle=0.0,
dxfattribs={"layer": "CUT"},
)
msp.add_line((length, radius), (length, width - radius), dxfattribs={"layer": "CUT"})
msp.add_arc(
center=(length - radius, width - radius),
radius=radius,
start_angle=0.0,
end_angle=90.0,
dxfattribs={"layer": "CUT"},
)
msp.add_line((length - radius, width), (radius, width), dxfattribs={"layer": "CUT"})
msp.add_arc(
center=(radius, width - radius),
radius=radius,
start_angle=90.0,
end_angle=180.0,
dxfattribs={"layer": "CUT"},
)
msp.add_line((0.0, width - radius), (0.0, radius), dxfattribs={"layer": "CUT"})
msp.add_arc(
center=(radius, radius),
radius=radius,
start_angle=180.0,
end_angle=270.0,
dxfattribs={"layer": "CUT"},
)
def build_dxf() -> ezdxf.document.Drawing:
layout = _build_bracket_layout()
flat_pattern = _build_flat_pattern(layout)
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()
_add_rounded_rect_outline(
msp,
length=flat_pattern.total_length_mm,
width=flat_pattern.width_mm,
radius=flat_pattern.rounded_corner_radius_mm,
)
for center_u, center_v, radius in flat_pattern.cut_circles:
msp.add_circle((center_u, center_v), radius, dxfattribs={"layer": "CUT"})
for bend_line_u in flat_pattern.bend_line_positions_mm:
msp.add_line(
(bend_line_u, 0.0),
(bend_line_u, flat_pattern.width_mm),
dxfattribs={"layer": "BEND", "linetype": "DASHED"},
)
print(
f"{DISPLAY_NAME} DXF: "
f"stock_thickness={PLATE_THICKNESS_MM:.3f} mm, "
f"flat_length={flat_pattern.total_length_mm:.3f} mm, "
f"flat_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"cut_circles={len(flat_pattern.cut_circles)}"
)
print(
"DXF bend checks "
f"top_flange_flat={flat_pattern.top_flange_flat_length_mm:.3f} mm, "
f"return_flange_flat={flat_pattern.return_flange_flat_length_mm:.3f} mm, "
f"feature_to_bend_min={flat_pattern.min_feature_to_bend_mm:.3f} mm, "
f"feature_to_bend_required={SENDCUTSEND_HALF_DIE_WIDTH_MM:.3f} mm"
)
if flat_pattern.min_feature_to_bend_mm < SENDCUTSEND_HALF_DIE_WIDTH_MM:
print(
"Warning: flat-pattern features enter the bend die region by "
f"{SENDCUTSEND_HALF_DIE_WIDTH_MM - flat_pattern.min_feature_to_bend_mm:.3f} mm"
)
return doc
def gen_step() -> dict[str, object]:
return {
"shape": build_bracket(),
}
def gen_dxf() -> dict[str, object]:
return {
"document": build_dxf(),
}
def gen() -> None:
gen_step()
gen_dxf()
if __name__ == "__main__":
gen()