497 lines
16 KiB
Python
497 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Bent-sheet end bracket for wrapping a 2020 extrusion end and two side faces.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
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))
|
|
|
|
from robot_common.keyed_connection import (
|
|
KEYED_CONNECTION_HALF_SPAN_MM,
|
|
KEYED_CONNECTION_REF,
|
|
KEYED_CONNECTION_ROTATION_DEG,
|
|
make_keyed_connection_face,
|
|
sample_keyed_connection_outline,
|
|
)
|
|
|
|
|
|
PART_NAME = Path(__file__).stem
|
|
DISPLAY_NAME = "Extrusion Bracket 2020"
|
|
|
|
EXTRUSION_X_SPAN_MM = 20.0
|
|
EXTRUSION_X_HALF_SPAN_MM = 0.5 * EXTRUSION_X_SPAN_MM
|
|
EXTRUSION_Y_SPAN_MM = 20.0
|
|
EXTRUSION_Y_HALF_SPAN_MM = 0.5 * EXTRUSION_Y_SPAN_MM
|
|
# Keep the bracket seated tightly on the 2020 extrusion; any additional offset
|
|
# should come from a future face redesign rather than opening the full wrap gap.
|
|
EXTRUSION_CLEARANCE_MM = 0.5
|
|
SHORT_SIDE_MM = EXTRUSION_X_SPAN_MM
|
|
SHORT_SIDE_HALF_MM = 0.5 * SHORT_SIDE_MM
|
|
SHEET_THICKNESS_MM = 1.6
|
|
BRACKET_DEPTH_MM = 30.0
|
|
FREE_END_CORNER_RADIUS_MM = 1.0
|
|
M5_CLEARANCE_RADIUS_MM = 2.75
|
|
# Keep the inner row a bit farther off the bend die region for SendCutSend.
|
|
SIDE_FASTENER_Z_POSITIONS_MM = (10.0, 22.0)
|
|
CUT_EXTENSION_MM = 1.0
|
|
EDGE_MATCH_TOLERANCE_MM = 1e-3
|
|
|
|
SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM = 25.4 * 0.038
|
|
SENDCUTSEND_K_FACTOR = 0.48
|
|
SENDCUTSEND_BEND_DEDUCTION_90_MM = (
|
|
2.0 * (SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM + SHEET_THICKNESS_MM)
|
|
) - (
|
|
(math.pi * 0.5)
|
|
* (SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM + (SENDCUTSEND_K_FACTOR * SHEET_THICKNESS_MM))
|
|
)
|
|
SENDCUTSEND_MIN_FLANGE_FORMED_MM = 25.4 * 0.313
|
|
SENDCUTSEND_MIN_FLANGE_FLAT_MM = 25.4 * 0.255
|
|
SENDCUTSEND_HALF_DIE_WIDTH_MM = 0.5 * (25.4 * 0.472)
|
|
|
|
INNER_X_MIN = -(EXTRUSION_X_HALF_SPAN_MM + EXTRUSION_CLEARANCE_MM)
|
|
INNER_X_MAX = -INNER_X_MIN
|
|
INNER_Y_MIN = -(EXTRUSION_Y_HALF_SPAN_MM + EXTRUSION_CLEARANCE_MM)
|
|
INNER_Y_MAX = -INNER_Y_MIN
|
|
OUTER_X_MIN = -SHORT_SIDE_HALF_MM
|
|
OUTER_X_MAX = SHORT_SIDE_HALF_MM
|
|
|
|
BASE_Z_MIN = -SHEET_THICKNESS_MM
|
|
BASE_Z_MAX = 0.0
|
|
SIDE_Z_MIN = BASE_Z_MIN
|
|
SIDE_Z_MAX = BRACKET_DEPTH_MM
|
|
|
|
LOWER_Y_MIN = INNER_Y_MIN - SHEET_THICKNESS_MM
|
|
LOWER_Y_MAX = INNER_Y_MIN
|
|
UPPER_Y_MIN = INNER_Y_MAX
|
|
UPPER_Y_MAX = INNER_Y_MAX + SHEET_THICKNESS_MM
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FlatPattern:
|
|
total_length_mm: float
|
|
width_mm: float
|
|
flange_flat_length_mm: float
|
|
web_flat_start_mm: float
|
|
web_flat_end_mm: float
|
|
bend_line_positions_mm: tuple[float, float]
|
|
cut_profile_centers: tuple[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 _make_keyed_connection_cutter_along_z(
|
|
*,
|
|
center_x: float,
|
|
center_y: float,
|
|
start_z: float,
|
|
height: float,
|
|
) -> build123d.Solid:
|
|
face = make_keyed_connection_face(
|
|
origin=build123d.Vector(center_x, center_y, start_z),
|
|
u_dir=build123d.Vector(1.0, 0.0, 0.0),
|
|
v_dir=build123d.Vector(0.0, 1.0, 0.0),
|
|
)
|
|
return build123d.Solid.extrude(face, build123d.Vector(0.0, 0.0, height))
|
|
|
|
|
|
def _sendcutsend_outside_setback_90_mm() -> float:
|
|
return SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM + SHEET_THICKNESS_MM
|
|
|
|
|
|
def _sendcutsend_bend_allowance_90_mm() -> float:
|
|
return (math.pi * 0.5) * (
|
|
SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM + (SENDCUTSEND_K_FACTOR * SHEET_THICKNESS_MM)
|
|
)
|
|
|
|
|
|
def _is_close(value: float, expected: float, tolerance: float = EDGE_MATCH_TOLERANCE_MM) -> bool:
|
|
return abs(value - expected) <= tolerance
|
|
|
|
|
|
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 build_bracket() -> build123d.Shape:
|
|
end_face = _make_box(
|
|
x_min=OUTER_X_MIN,
|
|
x_max=OUTER_X_MAX,
|
|
y_min=INNER_Y_MIN,
|
|
y_max=INNER_Y_MAX,
|
|
z_min=BASE_Z_MIN,
|
|
z_max=BASE_Z_MAX,
|
|
)
|
|
|
|
lower_flange = _make_box(
|
|
x_min=OUTER_X_MIN,
|
|
x_max=OUTER_X_MAX,
|
|
y_min=LOWER_Y_MIN,
|
|
y_max=LOWER_Y_MAX,
|
|
z_min=SIDE_Z_MIN,
|
|
z_max=SIDE_Z_MAX,
|
|
)
|
|
|
|
upper_flange = _make_box(
|
|
x_min=OUTER_X_MIN,
|
|
x_max=OUTER_X_MAX,
|
|
y_min=UPPER_Y_MIN,
|
|
y_max=UPPER_Y_MAX,
|
|
z_min=SIDE_Z_MIN,
|
|
z_max=SIDE_Z_MAX,
|
|
)
|
|
|
|
bracket = end_face.fuse(lower_flange).fuse(upper_flange)
|
|
|
|
hole_length = SHEET_THICKNESS_MM + (2.0 * CUT_EXTENSION_MM)
|
|
bracket = bracket.cut(
|
|
_make_keyed_connection_cutter_along_z(
|
|
center_x=0.0,
|
|
center_y=0.0,
|
|
start_z=BASE_Z_MIN - CUT_EXTENSION_MM,
|
|
height=hole_length,
|
|
)
|
|
)
|
|
|
|
for z_position in SIDE_FASTENER_Z_POSITIONS_MM:
|
|
bracket = bracket.cut(
|
|
_make_cylinder_along(
|
|
radius=M5_CLEARANCE_RADIUS_MM,
|
|
height=hole_length,
|
|
origin=build123d.Vector(0.0, LOWER_Y_MIN - CUT_EXTENSION_MM, z_position),
|
|
direction=build123d.Vector(0.0, 1.0, 0.0),
|
|
)
|
|
)
|
|
bracket = bracket.cut(
|
|
_make_cylinder_along(
|
|
radius=M5_CLEARANCE_RADIUS_MM,
|
|
height=hole_length,
|
|
origin=build123d.Vector(0.0, UPPER_Y_MAX + CUT_EXTENSION_MM, z_position),
|
|
direction=build123d.Vector(0.0, -1.0, 0.0),
|
|
)
|
|
)
|
|
|
|
target_edge_centers = (
|
|
build123d.Vector(
|
|
-SHORT_SIDE_HALF_MM,
|
|
LOWER_Y_MIN + (0.5 * SHEET_THICKNESS_MM),
|
|
BRACKET_DEPTH_MM,
|
|
),
|
|
build123d.Vector(
|
|
SHORT_SIDE_HALF_MM,
|
|
LOWER_Y_MIN + (0.5 * SHEET_THICKNESS_MM),
|
|
BRACKET_DEPTH_MM,
|
|
),
|
|
build123d.Vector(
|
|
-SHORT_SIDE_HALF_MM,
|
|
UPPER_Y_MAX - (0.5 * SHEET_THICKNESS_MM),
|
|
BRACKET_DEPTH_MM,
|
|
),
|
|
build123d.Vector(
|
|
SHORT_SIDE_HALF_MM,
|
|
UPPER_Y_MAX - (0.5 * SHEET_THICKNESS_MM),
|
|
BRACKET_DEPTH_MM,
|
|
),
|
|
)
|
|
target_edges: list[build123d.Edge] = []
|
|
for edge in bracket.edges():
|
|
if edge.geom_type != build123d.GeomType.LINE:
|
|
continue
|
|
center = edge.center()
|
|
if any(
|
|
_is_close(center.X, target_center.X)
|
|
and _is_close(center.Y, target_center.Y)
|
|
and _is_close(center.Z, target_center.Z)
|
|
for target_center in target_edge_centers
|
|
):
|
|
target_edges.append(edge)
|
|
|
|
if len(target_edges) != len(target_edge_centers):
|
|
raise RuntimeError(
|
|
"Expected to find the four flange free-end corners for rounding, "
|
|
f"found {len(target_edges)}"
|
|
)
|
|
|
|
bracket = build123d.fillet(target_edges, FREE_END_CORNER_RADIUS_MM)
|
|
|
|
solids = list(bracket.solids())
|
|
if len(solids) != 1:
|
|
raise RuntimeError(f"Expected one wrap bracket solid, found {len(solids)}")
|
|
|
|
bracket = solids[0]
|
|
bracket.label = PART_NAME
|
|
|
|
bbox = bracket.bounding_box()
|
|
print(
|
|
"Bent-sheet 2020 end bracket: "
|
|
f"clearance={EXTRUSION_CLEARANCE_MM:.3f} mm, "
|
|
f"thickness={SHEET_THICKNESS_MM:.3f} mm, "
|
|
f"depth={BRACKET_DEPTH_MM:.3f} mm, "
|
|
f"free_corner_radius={FREE_END_CORNER_RADIUS_MM:.3f} mm, "
|
|
f"size=({bbox.size.X:.3f}, {bbox.size.Y:.3f}, {bbox.size.Z:.3f}) mm, "
|
|
f"plate_fasteners={2 * len(SIDE_FASTENER_Z_POSITIONS_MM)}, "
|
|
f"connection_ref={KEYED_CONNECTION_REF}, "
|
|
f"connection_rotation={KEYED_CONNECTION_ROTATION_DEG:.1f} deg, "
|
|
f"hole_diameter={2.0 * M5_CLEARANCE_RADIUS_MM:.3f} mm"
|
|
)
|
|
return bracket
|
|
|
|
|
|
def _build_flat_pattern() -> FlatPattern:
|
|
_validate_sendcutsend_bend_rule()
|
|
|
|
outside_setback = _sendcutsend_outside_setback_90_mm()
|
|
bend_allowance = _sendcutsend_bend_allowance_90_mm()
|
|
flange_outside_length = BRACKET_DEPTH_MM + SHEET_THICKNESS_MM
|
|
flange_flat_length = flange_outside_length - outside_setback
|
|
if flange_outside_length < SENDCUTSEND_MIN_FLANGE_FORMED_MM:
|
|
raise RuntimeError(
|
|
"Formed flange length is below the configured bend-rule minimum: "
|
|
f"{flange_outside_length:.3f} mm < {SENDCUTSEND_MIN_FLANGE_FORMED_MM:.3f} mm"
|
|
)
|
|
if flange_flat_length < SENDCUTSEND_MIN_FLANGE_FLAT_MM:
|
|
raise RuntimeError(
|
|
"Flat flange length is below the configured bend-rule minimum: "
|
|
f"{flange_flat_length:.3f} mm < {SENDCUTSEND_MIN_FLANGE_FLAT_MM:.3f} mm"
|
|
)
|
|
|
|
web_outside_length = INNER_Y_MAX - INNER_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 = flange_flat_length + bend_allowance
|
|
web_flat_end = web_flat_start + web_flat_length
|
|
total_length = web_flat_end + bend_allowance + flange_flat_length
|
|
width = OUTER_X_MAX - OUTER_X_MIN
|
|
bend_line_positions = (
|
|
flange_flat_length + (0.5 * bend_allowance),
|
|
web_flat_end + (0.5 * bend_allowance),
|
|
)
|
|
|
|
def _v_from_x(x_coord: float) -> float:
|
|
return x_coord - OUTER_X_MIN
|
|
|
|
keyed_connection_center_u = web_flat_start + (-INNER_Y_MIN - SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM)
|
|
cut_profile_centers = ((keyed_connection_center_u, _v_from_x(0.0)),)
|
|
cut_circles: list[tuple[float, float, float]] = []
|
|
for z_position in SIDE_FASTENER_Z_POSITIONS_MM:
|
|
lower_u = BRACKET_DEPTH_MM - z_position
|
|
upper_u = total_length - (BRACKET_DEPTH_MM - z_position)
|
|
cut_circles.append((lower_u, _v_from_x(0.0), M5_CLEARANCE_RADIUS_MM))
|
|
cut_circles.append((upper_u, _v_from_x(0.0), M5_CLEARANCE_RADIUS_MM))
|
|
|
|
min_feature_to_bend = min(
|
|
abs(keyed_connection_center_u - bend_u) - KEYED_CONNECTION_HALF_SPAN_MM
|
|
for bend_u in bend_line_positions
|
|
)
|
|
min_feature_to_bend = min(
|
|
min_feature_to_bend,
|
|
min(
|
|
abs(center_u - bend_u) - radius
|
|
for center_u, _center_v, radius in cut_circles
|
|
for bend_u in bend_line_positions
|
|
),
|
|
)
|
|
return FlatPattern(
|
|
total_length_mm=total_length,
|
|
width_mm=width,
|
|
flange_flat_length_mm=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_profile_centers=cut_profile_centers,
|
|
cut_circles=tuple(cut_circles),
|
|
rounded_corner_radius_mm=FREE_END_CORNER_RADIUS_MM,
|
|
min_feature_to_bend_mm=min_feature_to_bend,
|
|
)
|
|
|
|
|
|
def build_dxf() -> ezdxf.document.Drawing:
|
|
flat_pattern = _build_flat_pattern()
|
|
|
|
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()
|
|
corner_radius = flat_pattern.rounded_corner_radius_mm
|
|
if corner_radius <= 0.0:
|
|
outline = (
|
|
(0.0, 0.0),
|
|
(flat_pattern.total_length_mm, 0.0),
|
|
(flat_pattern.total_length_mm, flat_pattern.width_mm),
|
|
(0.0, flat_pattern.width_mm),
|
|
)
|
|
msp.add_lwpolyline(outline, format="xy", close=True, dxfattribs={"layer": "CUT"})
|
|
else:
|
|
if (2.0 * corner_radius) >= flat_pattern.width_mm:
|
|
raise RuntimeError(
|
|
"Rounded flange corners exceed the flat-pattern width: "
|
|
f"diameter {2.0 * corner_radius:.3f} mm >= width {flat_pattern.width_mm:.3f} mm"
|
|
)
|
|
msp.add_line(
|
|
(corner_radius, 0.0),
|
|
(flat_pattern.total_length_mm - corner_radius, 0.0),
|
|
dxfattribs={"layer": "CUT"},
|
|
)
|
|
msp.add_arc(
|
|
(corner_radius, corner_radius),
|
|
corner_radius,
|
|
180.0,
|
|
270.0,
|
|
dxfattribs={"layer": "CUT"},
|
|
)
|
|
msp.add_arc(
|
|
(flat_pattern.total_length_mm - corner_radius, corner_radius),
|
|
corner_radius,
|
|
270.0,
|
|
360.0,
|
|
dxfattribs={"layer": "CUT"},
|
|
)
|
|
msp.add_line(
|
|
(flat_pattern.total_length_mm, corner_radius),
|
|
(flat_pattern.total_length_mm, flat_pattern.width_mm - corner_radius),
|
|
dxfattribs={"layer": "CUT"},
|
|
)
|
|
msp.add_arc(
|
|
(flat_pattern.total_length_mm - corner_radius, flat_pattern.width_mm - corner_radius),
|
|
corner_radius,
|
|
0.0,
|
|
90.0,
|
|
dxfattribs={"layer": "CUT"},
|
|
)
|
|
msp.add_line(
|
|
(flat_pattern.total_length_mm - corner_radius, flat_pattern.width_mm),
|
|
(corner_radius, flat_pattern.width_mm),
|
|
dxfattribs={"layer": "CUT"},
|
|
)
|
|
msp.add_arc(
|
|
(corner_radius, flat_pattern.width_mm - corner_radius),
|
|
corner_radius,
|
|
90.0,
|
|
180.0,
|
|
dxfattribs={"layer": "CUT"},
|
|
)
|
|
msp.add_line(
|
|
(0.0, flat_pattern.width_mm - corner_radius),
|
|
(0.0, corner_radius),
|
|
dxfattribs={"layer": "CUT"},
|
|
)
|
|
|
|
for center_u, center_v, radius in flat_pattern.cut_circles:
|
|
msp.add_circle((center_u, center_v), radius, dxfattribs={"layer": "CUT"})
|
|
for center_u, center_v in flat_pattern.cut_profile_centers:
|
|
msp.add_lwpolyline(
|
|
sample_keyed_connection_outline(center_u=center_u, center_v=center_v, points_per_arc=16),
|
|
format="xy",
|
|
close=True,
|
|
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(
|
|
"Flat pattern "
|
|
f"length={flat_pattern.total_length_mm:.3f} mm, "
|
|
f"width={flat_pattern.width_mm:.3f} mm, "
|
|
f"bend_lines=({flat_pattern.bend_line_positions_mm[0]:.3f}, {flat_pattern.bend_line_positions_mm[1]:.3f}) mm, "
|
|
f"cut_profiles={len(flat_pattern.cut_profile_centers)}, "
|
|
f"cut_circles={len(flat_pattern.cut_circles)}"
|
|
)
|
|
print(
|
|
"DXF bend checks "
|
|
f"flange_flat={flat_pattern.flange_flat_length_mm:.3f} mm, "
|
|
f"flange_flat_required={SENDCUTSEND_MIN_FLANGE_FLAT_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 keyed feature enters 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(),
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
gen_step()
|