1226 lines
45 KiB
Python
1226 lines
45 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generate the localized end mount.
|
|
|
|
The part is authored in the imported STS3250 local frame so assemblies can place
|
|
it with the same transform as the reference servo geometry.
|
|
|
|
Usage:
|
|
python STEP/servo_end_mount.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
# Keep CAD library caches inside the workspace to avoid permission issues.
|
|
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 ezdxf
|
|
import build123d
|
|
from ezdxf.units import MM as DXF_UNIT_MM
|
|
|
|
if str(TOM_DIR) not in sys.path:
|
|
sys.path.insert(0, str(TOM_DIR))
|
|
|
|
from robot_common.keyed_connection import (
|
|
KEYED_CONNECTION_REF,
|
|
KEYED_CONNECTION_ROTATION_DEG,
|
|
cut_keyed_connection_x_aligned,
|
|
sample_keyed_connection_outline,
|
|
)
|
|
from robot_common.step_import import import_as_shape
|
|
|
|
|
|
CAD_DIR = Path(__file__).resolve().parent
|
|
PART_NAME = Path(__file__).stem
|
|
DISPLAY_NAME = "Servo End Mount"
|
|
|
|
SERVO_STEP = V1_DIR / "parts" / "imports" / "sts3250.step"
|
|
|
|
SENDCUTSEND_MIN_BEND_LINE_EDGE_CLEARANCE_MM = 25.4 * 0.255
|
|
SENDCUTSEND_SHEET_THICKNESS_MM = 25.4 * 0.063
|
|
CENTER_BRIDGE_BEND_LINE_EDGE_BUFFER_MM = 25.4 * 0.010
|
|
CENTER_BRIDGE_BEND_LINE_EDGE_TARGET_MM = (
|
|
SENDCUTSEND_MIN_BEND_LINE_EDGE_CLEARANCE_MM
|
|
+ CENTER_BRIDGE_BEND_LINE_EDGE_BUFFER_MM
|
|
)
|
|
BRACKET_FACE_CLEARANCE_MM = CENTER_BRIDGE_BEND_LINE_EDGE_TARGET_MM - SENDCUTSEND_SHEET_THICKNESS_MM
|
|
SENDCUTSEND_REFERENCE_SHEET_THICKNESS_MM = 25.4 * 0.080
|
|
SENDCUTSEND_DIE_REGION_RULE_SCALE = SENDCUTSEND_SHEET_THICKNESS_MM / SENDCUTSEND_REFERENCE_SHEET_THICKNESS_MM
|
|
SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM = 25.4 * 0.035
|
|
SENDCUTSEND_K_FACTOR = 0.48
|
|
SENDCUTSEND_BEND_DEDUCTION_90_MM = (2.0 * (SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM + SENDCUTSEND_SHEET_THICKNESS_MM)) - (
|
|
(math.pi * 0.5)
|
|
* (SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM + (SENDCUTSEND_K_FACTOR * SENDCUTSEND_SHEET_THICKNESS_MM))
|
|
)
|
|
SENDCUTSEND_DIE_WIDTH_MM = 25.4 * 0.472
|
|
SENDCUTSEND_HALF_DIE_WIDTH_MM = (0.5 * SENDCUTSEND_DIE_WIDTH_MM) * SENDCUTSEND_DIE_REGION_RULE_SCALE
|
|
SENDCUTSEND_MIN_FLANGE_FLAT_MM = SENDCUTSEND_MIN_BEND_LINE_EDGE_CLEARANCE_MM
|
|
SENDCUTSEND_MIN_FLANGE_FORMED_MM = (
|
|
SENDCUTSEND_MIN_FLANGE_FLAT_MM
|
|
+ SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM
|
|
+ SENDCUTSEND_SHEET_THICKNESS_MM
|
|
)
|
|
SENDCUTSEND_BEND_RELIEF_DEPTH_MM = 25.4 * 0.118
|
|
SENDCUTSEND_MIN_CORNER_RELIEF_DISTANCE_MM = 25.4 * 0.037
|
|
SENDCUTSEND_MIN_ANODIZE_SHORTEST_DIM_MM = 25.4
|
|
BEND_DIE_REGION_TOLERANCE_MM = 0.1
|
|
# The theoretical outer radius is inner radius + thickness, but the split tab
|
|
# geometry cannot accept the full value as a direct solid fillet. Clamp the
|
|
# modeled exterior bend to a stable radius that still reads as a formed bend.
|
|
BEND_OUTER_RADIUS_MM = min(2.0, SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM + SENDCUTSEND_SHEET_THICKNESS_MM)
|
|
FLANGE_FREE_CORNER_RADIUS_MM = 1.0
|
|
FLANGE_FREE_CORNER_SAMPLE_COUNT = 8
|
|
MOUNT_PLANE_CLEARANCE_MM = 0.25
|
|
MOUNT_HOLE_EDGE_X_WEB_MM = 1.55
|
|
MOUNT_HOLE_OUTBOARD_Z_WEB_MM = 2.15
|
|
MOUNT_HOLE_INBOARD_Z_WEB_MM = 2.15
|
|
MOUNT_HOLE_TRIMMED_OUTBOARD_Z_WEB_WARN_MM = 1.0
|
|
CUT_EXTENSION_MM = 2.0
|
|
MAX_FACE_TARGET_ERROR_MM = 1.0
|
|
FACE_TARGET_CENTERS_MM: dict[str, tuple[float, float, float]] = {
|
|
"end_face": (9.61, -7.65, 0.0),
|
|
"end_wrap_neg": (8.843, -7.65, -11.593),
|
|
"end_wrap_pos": (8.843, -7.65, 11.593),
|
|
"upper_top_neg": (-6.561, 6.4, -9.484),
|
|
"upper_top_pos": (-6.561, 6.4, 9.484),
|
|
"main_bottom": (-0.881, -25.6, 0.0),
|
|
}
|
|
@dataclass(frozen=True)
|
|
class BracketLayout:
|
|
z_min: float
|
|
z_max: float
|
|
top_holes: tuple[tuple[build123d.Vector, float], ...]
|
|
bottom_holes: tuple[tuple[build123d.Vector, float], ...]
|
|
top_tab_x_min: float
|
|
bottom_tab_x_min: float
|
|
bracket_face_inner_x: float
|
|
bracket_face_outer_x: float
|
|
top_tab_y_min: float
|
|
top_tab_y_max: float
|
|
bottom_tab_y_min: float
|
|
bottom_tab_y_max: float
|
|
tab_bands: tuple[tuple[float, float], ...]
|
|
bend_bands: tuple[tuple[float, float], ...]
|
|
center_bridge_x_min: float | None
|
|
front_face_center_y: float
|
|
outer_flush_gap: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FlatPattern:
|
|
total_length_mm: float
|
|
width_mm: float
|
|
top_flat_length_mm: float
|
|
web_flat_start_mm: float
|
|
web_flat_end_mm: float
|
|
bottom_flat_start_mm: float
|
|
bend_line_positions_mm: tuple[float, float]
|
|
bend_line_segments_mm: tuple[tuple[float, float, float], ...]
|
|
cut_circles: tuple[tuple[float, float, float], ...]
|
|
cut_profile_centers: tuple[tuple[float, float], ...]
|
|
cut_segments: tuple[tuple[str, tuple[tuple[float, float], ...]], ...]
|
|
|
|
|
|
def _geom_type_name(shape: build123d.Shape) -> str:
|
|
geom_type = shape.geom_type
|
|
return geom_type.name if hasattr(geom_type, "name") else str(geom_type)
|
|
|
|
|
|
def _face_from_wire(
|
|
exterior: build123d.Wire,
|
|
interior_wires: list[build123d.Wire] | None = None,
|
|
) -> build123d.Face:
|
|
holes = list(interior_wires or [])
|
|
return build123d.Face(exterior, holes) if holes else build123d.Face(exterior)
|
|
|
|
|
|
def _make_cylinder_along(
|
|
*,
|
|
radius: float,
|
|
height: float,
|
|
origin: build123d.Vector,
|
|
direction: build123d.Vector,
|
|
) -> build123d.Solid:
|
|
return build123d.Solid.make_cylinder(
|
|
radius,
|
|
height,
|
|
plane=build123d.Plane(origin=origin, z_dir=direction),
|
|
)
|
|
|
|
|
|
def _vector_to_tuple(vector: build123d.Vector) -> tuple[float, float, float]:
|
|
return (vector.X, vector.Y, vector.Z)
|
|
|
|
|
|
def _make_box(*, x_min: float, x_max: float, y_min: float, y_max: float, z_min: float, z_max: float) -> build123d.Solid:
|
|
return build123d.Solid.make_box(
|
|
x_max - x_min,
|
|
y_max - y_min,
|
|
z_max - z_min,
|
|
plane=build123d.Plane(origin=build123d.Vector(x_min, y_min, z_min)),
|
|
)
|
|
|
|
|
|
def _assemble_wire(edges: list[build123d.Edge]) -> build123d.Wire:
|
|
wires = list(build123d.Wire.combine(edges))
|
|
if len(wires) != 1:
|
|
raise RuntimeError(f"Expected a single assembled wire, found {len(wires)}")
|
|
return wires[0]
|
|
|
|
|
|
def _arc_angles_xz(
|
|
*,
|
|
center_x: float,
|
|
center_z: float,
|
|
start_point: tuple[float, float],
|
|
end_point: tuple[float, float],
|
|
) -> tuple[float, float, build123d.AngularDirection]:
|
|
start_angle = math.degrees(math.atan2(start_point[1] - center_z, start_point[0] - center_x))
|
|
end_angle = math.degrees(math.atan2(end_point[1] - center_z, end_point[0] - center_x))
|
|
ccw_span = (end_angle - start_angle) % 360.0
|
|
if ccw_span <= 180.0:
|
|
return start_angle, end_angle, build123d.AngularDirection.COUNTER_CLOCKWISE
|
|
return start_angle, end_angle, build123d.AngularDirection.CLOCKWISE
|
|
|
|
|
|
def _make_arc_edge_xz(
|
|
*,
|
|
center_x: float,
|
|
center_z: float,
|
|
radius: float,
|
|
start_point: tuple[float, float],
|
|
end_point: tuple[float, float],
|
|
) -> build123d.Edge:
|
|
start_angle, end_angle, angular_direction = _arc_angles_xz(
|
|
center_x=center_x,
|
|
center_z=center_z,
|
|
start_point=start_point,
|
|
end_point=end_point,
|
|
)
|
|
return build123d.Edge.make_circle(
|
|
radius,
|
|
plane=build123d.Plane(origin=(center_x, 0.0, center_z), x_dir=(1.0, 0.0, 0.0), z_dir=(0.0, -1.0, 0.0)),
|
|
start_angle=start_angle,
|
|
end_angle=end_angle,
|
|
angular_direction=angular_direction,
|
|
)
|
|
|
|
|
|
def _make_wire_prism_y(
|
|
*,
|
|
edges_xz: list[build123d.Edge],
|
|
y_min: float,
|
|
y_max: float,
|
|
) -> build123d.Solid:
|
|
face = _face_from_wire(_assemble_wire(edges_xz))
|
|
solid = build123d.Solid.extrude(face, build123d.Vector(0.0, y_max - y_min, 0.0))
|
|
return solid.translate((0.0, y_min, 0.0))
|
|
|
|
|
|
def _make_rounded_flange_tab(
|
|
*,
|
|
x_min: float,
|
|
x_max: float,
|
|
y_min: float,
|
|
y_max: float,
|
|
z_min: float,
|
|
z_max: float,
|
|
radius: float,
|
|
) -> build123d.Shape:
|
|
radius = min(radius, 0.5 * (z_max - z_min), x_max - x_min)
|
|
if radius <= 1e-6:
|
|
return _make_box(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max, z_min=z_min, z_max=z_max)
|
|
|
|
lower_tangent = (x_min + radius, z_min)
|
|
right_lower = (x_max, z_min)
|
|
right_upper = (x_max, z_max)
|
|
upper_tangent = (x_min + radius, z_max)
|
|
left_upper = (x_min, z_max - radius)
|
|
left_lower = (x_min, z_min + radius)
|
|
|
|
return _make_wire_prism_y(
|
|
edges_xz=[
|
|
build123d.Edge.make_line((lower_tangent[0], 0.0, lower_tangent[1]), (right_lower[0], 0.0, right_lower[1])),
|
|
build123d.Edge.make_line((right_lower[0], 0.0, right_lower[1]), (right_upper[0], 0.0, right_upper[1])),
|
|
build123d.Edge.make_line((right_upper[0], 0.0, right_upper[1]), (upper_tangent[0], 0.0, upper_tangent[1])),
|
|
_make_arc_edge_xz(
|
|
center_x=x_min + radius,
|
|
center_z=z_max - radius,
|
|
radius=radius,
|
|
start_point=upper_tangent,
|
|
end_point=left_upper,
|
|
),
|
|
build123d.Edge.make_line((left_upper[0], 0.0, left_upper[1]), (left_lower[0], 0.0, left_lower[1])),
|
|
_make_arc_edge_xz(
|
|
center_x=x_min + radius,
|
|
center_z=z_min + radius,
|
|
radius=radius,
|
|
start_point=left_lower,
|
|
end_point=lower_tangent,
|
|
),
|
|
],
|
|
y_min=y_min,
|
|
y_max=y_max,
|
|
)
|
|
|
|
|
|
def _find_face_near(shape: build123d.Shape, target: tuple[float, float, float], *, geom_type: str | None = None) -> build123d.Face:
|
|
target_vec = build123d.Vector(*target)
|
|
best: tuple[float, build123d.Face] | None = None
|
|
for face in shape.faces():
|
|
if geom_type is not None and _geom_type_name(face) != geom_type:
|
|
continue
|
|
dist = face.center().sub(target_vec).length
|
|
if best is None or dist < best[0]:
|
|
best = (dist, face)
|
|
|
|
if best is None:
|
|
raise RuntimeError(f"No planar face found near {target}")
|
|
if best[0] > MAX_FACE_TARGET_ERROR_MM:
|
|
raise RuntimeError(
|
|
f"Resolved face near {target} was {best[0]:.3f} mm away, which exceeds the tolerance"
|
|
)
|
|
return best[1]
|
|
|
|
|
|
def _wire_area(wire: build123d.Wire) -> float:
|
|
return abs(_face_from_wire(wire).area)
|
|
|
|
|
|
def _outer_wire_and_inner_wires(face: build123d.Face) -> tuple[build123d.Wire, list[build123d.Wire]]:
|
|
wires = list(face.wires())
|
|
if not wires:
|
|
raise RuntimeError("Face has no wires")
|
|
|
|
outer_wire = max(wires, key=_wire_area)
|
|
inner_wires = [wire for wire in wires if not wire.is_same(outer_wire)]
|
|
return outer_wire, inner_wires
|
|
|
|
|
|
def _extract_inner_hole_profiles(face: build123d.Face) -> list[tuple[build123d.Vector, float]]:
|
|
_outer_wire, inner_wires = _outer_wire_and_inner_wires(face)
|
|
profiles: list[tuple[build123d.Vector, float]] = []
|
|
for wire in inner_wires:
|
|
circle_edges = [edge for edge in wire.edges() if _geom_type_name(edge) == "CIRCLE"]
|
|
if not circle_edges:
|
|
continue
|
|
center = _face_from_wire(wire).center()
|
|
radius = max(edge.radius for edge in circle_edges)
|
|
profiles.append((center, radius))
|
|
return profiles
|
|
|
|
|
|
def _select_near_end_holes(
|
|
profiles: list[tuple[build123d.Vector, float]],
|
|
*,
|
|
count: int,
|
|
) -> list[tuple[build123d.Vector, float]]:
|
|
if len(profiles) < count:
|
|
raise RuntimeError(f"Expected at least {count} mount holes, found {len(profiles)}")
|
|
|
|
selected = sorted(profiles, key=lambda item: (item[0].X, abs(item[0].Z)), reverse=True)[:count]
|
|
selected.sort(key=lambda item: item[0].Z)
|
|
return selected
|
|
|
|
|
|
def _cut_mount_holes(
|
|
body: build123d.Shape,
|
|
*,
|
|
profiles: list[tuple[build123d.Vector, float]],
|
|
start_y: float,
|
|
direction_y: float,
|
|
) -> build123d.Shape:
|
|
hole_depth = SENDCUTSEND_SHEET_THICKNESS_MM + (2.0 * CUT_EXTENSION_MM)
|
|
for center, radius in profiles:
|
|
cutter = _make_cylinder_along(
|
|
radius=radius,
|
|
height=hole_depth,
|
|
origin=build123d.Vector(center.X, start_y, center.Z),
|
|
direction=build123d.Vector(0.0, direction_y, 0.0),
|
|
)
|
|
body = body.cut(cutter)
|
|
return body
|
|
|
|
|
|
def _cut_front_face_connection_frame(
|
|
body: build123d.Shape,
|
|
*,
|
|
face_center_y: float,
|
|
x_max: float,
|
|
) -> build123d.Shape:
|
|
body_bb = body.bounding_box()
|
|
return cut_keyed_connection_x_aligned(
|
|
body,
|
|
center=build123d.Vector(x_max, face_center_y, 0.0),
|
|
start_x=x_max + CUT_EXTENSION_MM,
|
|
direction_x=-1.0,
|
|
thickness_mm=body_bb.size.X,
|
|
cut_extension_mm=CUT_EXTENSION_MM,
|
|
)
|
|
|
|
|
|
def _apply_bends_and_end_rounds(
|
|
bracket: build123d.Shape,
|
|
*,
|
|
bracket_face_inner_x: float,
|
|
bracket_face_outer_x: float,
|
|
top_tab_x_min: float,
|
|
bottom_tab_x_min: float,
|
|
top_tab_y_min: float,
|
|
top_tab_y_max: float,
|
|
bottom_tab_y_min: float,
|
|
bottom_tab_y_max: float,
|
|
bend_bands: tuple[tuple[float, float], ...],
|
|
) -> build123d.Shape:
|
|
return bracket
|
|
|
|
|
|
def _min_mount_hole_distance_to_bend_line(
|
|
profiles: list[tuple[build123d.Vector, float]],
|
|
*,
|
|
bend_line_x: float,
|
|
) -> float:
|
|
return min(abs(center.X - bend_line_x) for center, _radius in profiles)
|
|
|
|
|
|
def _merge_z_bands(
|
|
bands: list[tuple[float, float]] | tuple[tuple[float, float], ...],
|
|
*,
|
|
tolerance: float = 1e-6,
|
|
) -> tuple[tuple[float, float], ...]:
|
|
merged: list[tuple[float, float]] = []
|
|
for start, end in sorted(bands, key=lambda band: band[0]):
|
|
if end - start <= tolerance:
|
|
continue
|
|
if not merged or start > merged[-1][1] + tolerance:
|
|
merged.append((start, end))
|
|
continue
|
|
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
|
return tuple(merged)
|
|
|
|
|
|
def _build_layout(
|
|
servo_shape: build123d.Shape,
|
|
*,
|
|
include_top_center_bridge: bool = True,
|
|
) -> BracketLayout:
|
|
faces = {
|
|
"end_face": _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["end_face"], geom_type="PLANE"),
|
|
"end_wrap_neg": _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["end_wrap_neg"]),
|
|
"end_wrap_pos": _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["end_wrap_pos"]),
|
|
"upper_top_neg": _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["upper_top_neg"], geom_type="PLANE"),
|
|
"upper_top_pos": _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["upper_top_pos"], geom_type="PLANE"),
|
|
"main_bottom": _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["main_bottom"], geom_type="PLANE"),
|
|
}
|
|
|
|
end_face_bb = faces["end_face"].bounding_box()
|
|
end_wrap_neg_bb = faces["end_wrap_neg"].bounding_box()
|
|
end_wrap_pos_bb = faces["end_wrap_pos"].bounding_box()
|
|
z_min = min(
|
|
end_face_bb.min.Z,
|
|
end_wrap_neg_bb.min.Z,
|
|
end_wrap_pos_bb.min.Z,
|
|
)
|
|
z_max = max(
|
|
end_face_bb.max.Z,
|
|
end_wrap_neg_bb.max.Z,
|
|
end_wrap_pos_bb.max.Z,
|
|
)
|
|
|
|
top_holes = _select_near_end_holes(_extract_inner_hole_profiles(faces["upper_top_neg"]), count=1)
|
|
top_holes += _select_near_end_holes(_extract_inner_hole_profiles(faces["upper_top_pos"]), count=1)
|
|
top_holes.sort(key=lambda item: item[0].Z)
|
|
|
|
bottom_holes = _select_near_end_holes(_extract_inner_hole_profiles(faces["main_bottom"]), count=2)
|
|
|
|
top_mount_y = sum(center.Y for center, _radius in top_holes) / len(top_holes)
|
|
bottom_mount_y = sum(center.Y for center, _radius in bottom_holes) / len(bottom_holes)
|
|
top_tab_y_min = top_mount_y + MOUNT_PLANE_CLEARANCE_MM
|
|
top_tab_y_max = top_tab_y_min + SENDCUTSEND_SHEET_THICKNESS_MM
|
|
bottom_tab_y_max = bottom_mount_y - MOUNT_PLANE_CLEARANCE_MM
|
|
bottom_tab_y_min = bottom_tab_y_max - SENDCUTSEND_SHEET_THICKNESS_MM
|
|
|
|
side_profiles = {
|
|
"neg": [top_holes[0], bottom_holes[0]],
|
|
"pos": [top_holes[1], bottom_holes[1]],
|
|
}
|
|
tab_bands: list[tuple[float, float]] = []
|
|
for top_profile, bottom_profile in side_profiles.values():
|
|
profiles = (top_profile, bottom_profile)
|
|
if sum(center.Z for center, _radius in profiles) < 0.0:
|
|
z_band_min = min(center.Z - radius - MOUNT_HOLE_OUTBOARD_Z_WEB_MM for center, radius in profiles)
|
|
z_band_max = max(center.Z + radius + MOUNT_HOLE_INBOARD_Z_WEB_MM for center, radius in profiles)
|
|
else:
|
|
z_band_min = min(center.Z - radius - MOUNT_HOLE_INBOARD_Z_WEB_MM for center, radius in profiles)
|
|
z_band_max = max(center.Z + radius + MOUNT_HOLE_OUTBOARD_Z_WEB_MM for center, radius in profiles)
|
|
z_band_min = max(z_band_min, z_min)
|
|
z_band_max = min(z_band_max, z_max)
|
|
tab_bands.append((z_band_min, z_band_max))
|
|
|
|
sorted_tab_bands = sorted(tab_bands, key=lambda band: band[0])
|
|
bend_bands = _merge_z_bands(sorted_tab_bands)
|
|
|
|
top_tab_x_min = min(center.X - radius - MOUNT_HOLE_EDGE_X_WEB_MM for center, radius in top_holes)
|
|
bottom_tab_x_min = min(center.X - radius - MOUNT_HOLE_EDGE_X_WEB_MM for center, radius in bottom_holes)
|
|
bracket_face_clearance = BRACKET_FACE_CLEARANCE_MM
|
|
bracket_face_inner_x = end_face_bb.max.X + bracket_face_clearance
|
|
bracket_face_outer_x = bracket_face_inner_x + SENDCUTSEND_SHEET_THICKNESS_MM
|
|
center_bridge_x_min: float | None = None
|
|
if include_top_center_bridge and len(sorted_tab_bands) >= 2:
|
|
center_gap_z_min = sorted_tab_bands[0][1]
|
|
center_gap_z_max = sorted_tab_bands[-1][0]
|
|
if center_gap_z_max - center_gap_z_min > 1e-3:
|
|
candidate_center_bridge_x_min = max(
|
|
bracket_face_outer_x - CENTER_BRIDGE_BEND_LINE_EDGE_TARGET_MM,
|
|
top_tab_x_min,
|
|
)
|
|
if candidate_center_bridge_x_min < bracket_face_outer_x - 1e-3:
|
|
center_bridge_x_min = candidate_center_bridge_x_min
|
|
bend_bands = _merge_z_bands((*sorted_tab_bands, (center_gap_z_min, center_gap_z_max)))
|
|
|
|
min_hole_to_bend_line = min(
|
|
_min_mount_hole_distance_to_bend_line(top_holes, bend_line_x=bracket_face_inner_x),
|
|
_min_mount_hole_distance_to_bend_line(bottom_holes, bend_line_x=bracket_face_inner_x),
|
|
)
|
|
if min_hole_to_bend_line + BEND_DIE_REGION_TOLERANCE_MM < SENDCUTSEND_HALF_DIE_WIDTH_MM:
|
|
raise RuntimeError(
|
|
"Mount holes are too close to the bend line for the configured sheet-metal bend rule: "
|
|
f"{min_hole_to_bend_line:.3f} mm < {SENDCUTSEND_HALF_DIE_WIDTH_MM:.3f} mm"
|
|
)
|
|
|
|
front_face_center_y = 0.5 * (bottom_tab_y_min + top_tab_y_max)
|
|
outer_flush_gap = min(
|
|
min(
|
|
z_max - (center.Z + radius) if center.Z >= 0.0 else (center.Z - radius) - z_min
|
|
for center, radius in top_holes
|
|
),
|
|
min(
|
|
z_max - (center.Z + radius) if center.Z >= 0.0 else (center.Z - radius) - z_min
|
|
for center, radius in bottom_holes
|
|
),
|
|
)
|
|
if outer_flush_gap <= 0.0:
|
|
raise RuntimeError(f"Trimmed mount width cuts into a mounting hole: outboard web {outer_flush_gap:.3f} mm")
|
|
|
|
return BracketLayout(
|
|
z_min=z_min,
|
|
z_max=z_max,
|
|
top_holes=tuple(top_holes),
|
|
bottom_holes=tuple(bottom_holes),
|
|
top_tab_x_min=top_tab_x_min,
|
|
bottom_tab_x_min=bottom_tab_x_min,
|
|
bracket_face_inner_x=bracket_face_inner_x,
|
|
bracket_face_outer_x=bracket_face_outer_x,
|
|
top_tab_y_min=top_tab_y_min,
|
|
top_tab_y_max=top_tab_y_max,
|
|
bottom_tab_y_min=bottom_tab_y_min,
|
|
bottom_tab_y_max=bottom_tab_y_max,
|
|
tab_bands=tuple(sorted_tab_bands),
|
|
bend_bands=bend_bands,
|
|
center_bridge_x_min=center_bridge_x_min,
|
|
front_face_center_y=front_face_center_y,
|
|
outer_flush_gap=outer_flush_gap,
|
|
)
|
|
|
|
|
|
def build_bracket(
|
|
servo_shape: build123d.Shape,
|
|
*,
|
|
include_top_center_bridge: bool = True,
|
|
) -> tuple[build123d.Shape, BracketLayout]:
|
|
layout = _build_layout(servo_shape, include_top_center_bridge=include_top_center_bridge)
|
|
|
|
bracket: build123d.Shape = _make_box(
|
|
x_min=layout.bracket_face_inner_x,
|
|
x_max=layout.bracket_face_outer_x,
|
|
y_min=layout.bottom_tab_y_min,
|
|
y_max=layout.top_tab_y_max,
|
|
z_min=layout.z_min,
|
|
z_max=layout.z_max,
|
|
)
|
|
|
|
for z_band_min, z_band_max in layout.tab_bands:
|
|
bracket = bracket.fuse(
|
|
_make_rounded_flange_tab(
|
|
x_min=layout.top_tab_x_min,
|
|
x_max=layout.bracket_face_outer_x,
|
|
y_min=layout.top_tab_y_min,
|
|
y_max=layout.top_tab_y_max,
|
|
z_min=z_band_min,
|
|
z_max=z_band_max,
|
|
radius=FLANGE_FREE_CORNER_RADIUS_MM,
|
|
)
|
|
)
|
|
|
|
if layout.center_bridge_x_min is not None and len(layout.tab_bands) >= 2:
|
|
center_gap_z_min = layout.tab_bands[0][1]
|
|
center_gap_z_max = layout.tab_bands[-1][0]
|
|
bracket = bracket.fuse(
|
|
_make_box(
|
|
x_min=layout.center_bridge_x_min,
|
|
x_max=layout.bracket_face_outer_x,
|
|
y_min=layout.top_tab_y_min,
|
|
y_max=layout.top_tab_y_max,
|
|
z_min=center_gap_z_min,
|
|
z_max=center_gap_z_max,
|
|
)
|
|
)
|
|
|
|
bracket = bracket.fuse(
|
|
_make_rounded_flange_tab(
|
|
x_min=layout.bottom_tab_x_min,
|
|
x_max=layout.bracket_face_outer_x,
|
|
y_min=layout.bottom_tab_y_min,
|
|
y_max=layout.bottom_tab_y_max,
|
|
z_min=layout.z_min,
|
|
z_max=layout.z_max,
|
|
radius=FLANGE_FREE_CORNER_RADIUS_MM,
|
|
)
|
|
)
|
|
|
|
bracket = _apply_bends_and_end_rounds(
|
|
bracket,
|
|
bracket_face_inner_x=layout.bracket_face_inner_x,
|
|
bracket_face_outer_x=layout.bracket_face_outer_x,
|
|
top_tab_x_min=layout.top_tab_x_min,
|
|
bottom_tab_x_min=layout.bottom_tab_x_min,
|
|
top_tab_y_min=layout.top_tab_y_min,
|
|
top_tab_y_max=layout.top_tab_y_max,
|
|
bottom_tab_y_min=layout.bottom_tab_y_min,
|
|
bottom_tab_y_max=layout.bottom_tab_y_max,
|
|
bend_bands=layout.bend_bands,
|
|
)
|
|
|
|
bracket = _cut_front_face_connection_frame(
|
|
bracket,
|
|
face_center_y=layout.front_face_center_y,
|
|
x_max=layout.bracket_face_outer_x,
|
|
)
|
|
bracket = _cut_mount_holes(
|
|
bracket,
|
|
profiles=list(layout.top_holes),
|
|
start_y=layout.top_tab_y_max + CUT_EXTENSION_MM,
|
|
direction_y=-1.0,
|
|
)
|
|
bracket = _cut_mount_holes(
|
|
bracket,
|
|
profiles=list(layout.bottom_holes),
|
|
start_y=layout.bottom_tab_y_min - CUT_EXTENSION_MM,
|
|
direction_y=1.0,
|
|
)
|
|
|
|
return bracket, layout
|
|
|
|
|
|
def _sendcutsend_outside_setback_90_mm() -> float:
|
|
return SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM + SENDCUTSEND_SHEET_THICKNESS_MM
|
|
|
|
|
|
def _sendcutsend_bend_allowance_90_mm() -> float:
|
|
return (math.pi * 0.5) * (
|
|
SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM + (SENDCUTSEND_K_FACTOR * SENDCUTSEND_SHEET_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 _min_flange_outside_length(layout: BracketLayout) -> float:
|
|
return min(
|
|
layout.bracket_face_outer_x - layout.top_tab_x_min,
|
|
layout.bracket_face_outer_x - layout.bottom_tab_x_min,
|
|
)
|
|
|
|
|
|
def _supported_bend_fraction(layout: BracketLayout) -> float:
|
|
bend_width = layout.z_max - layout.z_min
|
|
if bend_width <= 0.0:
|
|
return 0.0
|
|
supported_width = sum(z_band_max - z_band_min for z_band_min, z_band_max in layout.bend_bands)
|
|
return supported_width / bend_width
|
|
|
|
|
|
def _min_bend_segment_length(layout: BracketLayout) -> float:
|
|
return min(z_band_max - z_band_min for z_band_min, z_band_max in layout.bend_bands)
|
|
|
|
|
|
def _center_bridge_z_range(layout: BracketLayout) -> tuple[float, float] | None:
|
|
if layout.center_bridge_x_min is None or len(layout.tab_bands) < 2:
|
|
return None
|
|
|
|
z_min = layout.tab_bands[0][1]
|
|
z_max = layout.tab_bands[-1][0]
|
|
if z_max - z_min <= 1e-6:
|
|
return None
|
|
return z_min, z_max
|
|
|
|
|
|
def _center_bridge_formed_length(layout: BracketLayout) -> float | None:
|
|
if layout.center_bridge_x_min is None:
|
|
return None
|
|
return layout.bracket_face_inner_x - layout.center_bridge_x_min
|
|
|
|
|
|
def _center_bridge_bend_edge_distance(layout: BracketLayout) -> float | None:
|
|
if layout.center_bridge_x_min is None:
|
|
return None
|
|
return layout.bracket_face_outer_x - layout.center_bridge_x_min
|
|
|
|
|
|
def _center_bridge_flat_length(layout: BracketLayout, flat_pattern: FlatPattern) -> float | None:
|
|
formed_length = _center_bridge_formed_length(layout)
|
|
if formed_length is None:
|
|
return None
|
|
|
|
# Keep the flat-pattern edge at least the required distance from the drawn
|
|
# bend line while preserving the formed center-bridge length in the 3D part.
|
|
bend_line_clearance_flat_length = (
|
|
CENTER_BRIDGE_BEND_LINE_EDGE_TARGET_MM
|
|
- (flat_pattern.bend_line_positions_mm[0] - flat_pattern.top_flat_length_mm)
|
|
)
|
|
return max(formed_length, bend_line_clearance_flat_length)
|
|
|
|
|
|
def _center_bridge_flat_bend_line_to_edge_distance(layout: BracketLayout, flat_pattern: FlatPattern) -> float | None:
|
|
flat_length = _center_bridge_flat_length(layout, flat_pattern)
|
|
if flat_length is None:
|
|
return None
|
|
return flat_length + (flat_pattern.bend_line_positions_mm[0] - flat_pattern.top_flat_length_mm)
|
|
|
|
|
|
def _build_flat_pattern(
|
|
layout: BracketLayout,
|
|
) -> FlatPattern:
|
|
_validate_sendcutsend_bend_rule()
|
|
|
|
outside_setback = _sendcutsend_outside_setback_90_mm()
|
|
bend_allowance = _sendcutsend_bend_allowance_90_mm()
|
|
top_outside_length = layout.bracket_face_outer_x - layout.top_tab_x_min
|
|
bottom_outside_length = layout.bracket_face_outer_x - layout.bottom_tab_x_min
|
|
top_flat_length = top_outside_length - outside_setback
|
|
bottom_flat_length = bottom_outside_length - outside_setback
|
|
if top_flat_length <= 0.0 or bottom_flat_length <= 0.0:
|
|
raise RuntimeError(
|
|
"Flat flange length implied by the bend rule is too short: "
|
|
f"top={top_flat_length:.3f} mm, bottom={bottom_flat_length:.3f} mm"
|
|
)
|
|
flange_outside_min = _min_flange_outside_length(layout)
|
|
flange_flat_min = flange_outside_min - outside_setback
|
|
center_bridge_bend_edge_distance = _center_bridge_bend_edge_distance(layout)
|
|
if (
|
|
center_bridge_bend_edge_distance is not None
|
|
and center_bridge_bend_edge_distance < CENTER_BRIDGE_BEND_LINE_EDGE_TARGET_MM
|
|
):
|
|
raise RuntimeError(
|
|
"Top center bridge bend-edge distance is below the configured clearance target: "
|
|
f"{center_bridge_bend_edge_distance:.3f} mm < {CENTER_BRIDGE_BEND_LINE_EDGE_TARGET_MM:.3f} mm"
|
|
)
|
|
if flange_outside_min < SENDCUTSEND_MIN_FLANGE_FORMED_MM:
|
|
raise RuntimeError(
|
|
"Formed flange length is below the configured bend-rule minimum: "
|
|
f"{flange_outside_min:.3f} mm < {SENDCUTSEND_MIN_FLANGE_FORMED_MM:.3f} mm"
|
|
)
|
|
if flange_flat_min < SENDCUTSEND_MIN_FLANGE_FLAT_MM:
|
|
raise RuntimeError(
|
|
"Flat flange length is below the configured bend-rule minimum: "
|
|
f"{flange_flat_min:.3f} mm < {SENDCUTSEND_MIN_FLANGE_FLAT_MM:.3f} mm"
|
|
)
|
|
|
|
top_web_tangent_y = layout.top_tab_y_min - SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM
|
|
bottom_web_tangent_y = layout.bottom_tab_y_max + SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM
|
|
web_flat_length = top_web_tangent_y - bottom_web_tangent_y
|
|
if web_flat_length <= 0.0:
|
|
raise RuntimeError(f"Web flat length implied by the bend rule is too short: {web_flat_length:.3f} mm")
|
|
|
|
web_flat_start = top_flat_length + bend_allowance
|
|
web_flat_end = web_flat_start + web_flat_length
|
|
bottom_flat_start = web_flat_end + bend_allowance
|
|
total_length = bottom_flat_start + bottom_flat_length
|
|
width = layout.z_max - layout.z_min
|
|
top_bend_line_x = top_flat_length + (0.5 * bend_allowance)
|
|
bottom_bend_line_x = web_flat_end + (0.5 * bend_allowance)
|
|
bend_line_segments = (
|
|
(top_bend_line_x, 0.0, width),
|
|
(bottom_bend_line_x, 0.0, width),
|
|
)
|
|
|
|
cut_circles: list[tuple[float, float, float]] = []
|
|
for center, radius in layout.top_holes:
|
|
cut_circles.append(
|
|
(
|
|
center.X - layout.top_tab_x_min,
|
|
center.Z - layout.z_min,
|
|
radius,
|
|
)
|
|
)
|
|
for center, radius in layout.bottom_holes:
|
|
cut_circles.append(
|
|
(
|
|
total_length - (center.X - layout.bottom_tab_x_min),
|
|
center.Z - layout.z_min,
|
|
radius,
|
|
)
|
|
)
|
|
cut_profile_centers = (
|
|
(
|
|
web_flat_start + (top_web_tangent_y - layout.front_face_center_y),
|
|
-layout.z_min,
|
|
),
|
|
)
|
|
return FlatPattern(
|
|
total_length_mm=total_length,
|
|
width_mm=width,
|
|
top_flat_length_mm=top_flat_length,
|
|
web_flat_start_mm=web_flat_start,
|
|
web_flat_end_mm=web_flat_end,
|
|
bottom_flat_start_mm=bottom_flat_start,
|
|
bend_line_positions_mm=(top_bend_line_x, bottom_bend_line_x),
|
|
bend_line_segments_mm=bend_line_segments,
|
|
cut_circles=tuple(cut_circles),
|
|
cut_profile_centers=cut_profile_centers,
|
|
cut_segments=(),
|
|
)
|
|
|
|
|
|
def _flat_rectangles(layout: BracketLayout, flat_pattern: FlatPattern) -> tuple[tuple[float, float, float, float], ...]:
|
|
rectangles: list[tuple[float, float, float, float]] = [
|
|
(
|
|
flat_pattern.top_flat_length_mm,
|
|
0.0,
|
|
flat_pattern.bottom_flat_start_mm,
|
|
flat_pattern.width_mm,
|
|
)
|
|
]
|
|
|
|
for z_band_min, z_band_max in layout.tab_bands:
|
|
y_min = z_band_min - layout.z_min
|
|
y_max = z_band_max - layout.z_min
|
|
rectangles.append((0.0, y_min, flat_pattern.top_flat_length_mm, y_max))
|
|
|
|
center_bridge_z_range = _center_bridge_z_range(layout)
|
|
center_bridge_flat_length = _center_bridge_flat_length(layout, flat_pattern)
|
|
if center_bridge_z_range is not None and center_bridge_flat_length is not None:
|
|
y_min = center_bridge_z_range[0] - layout.z_min
|
|
y_max = center_bridge_z_range[1] - layout.z_min
|
|
x_min = flat_pattern.top_flat_length_mm - center_bridge_flat_length
|
|
rectangles.append((x_min, y_min, flat_pattern.top_flat_length_mm, y_max))
|
|
|
|
rectangles.append((flat_pattern.bottom_flat_start_mm, 0.0, flat_pattern.total_length_mm, flat_pattern.width_mm))
|
|
|
|
return tuple(rectangle for rectangle in rectangles if rectangle[2] - rectangle[0] > 1e-6 and rectangle[3] - rectangle[1] > 1e-6)
|
|
|
|
|
|
def _flange_free_corner_points(
|
|
layout: BracketLayout,
|
|
flat_pattern: FlatPattern,
|
|
) -> set[tuple[float, float]]:
|
|
points: set[tuple[float, float]] = set()
|
|
for z_band_min, z_band_max in layout.tab_bands:
|
|
flat_z_min = z_band_min - layout.z_min
|
|
flat_z_max = z_band_max - layout.z_min
|
|
points.add((0.0, flat_z_min))
|
|
points.add((0.0, flat_z_max))
|
|
points.add((flat_pattern.total_length_mm, 0.0))
|
|
points.add((flat_pattern.total_length_mm, flat_pattern.width_mm))
|
|
return points
|
|
|
|
|
|
def _sample_short_arc_2d(
|
|
*,
|
|
center: tuple[float, float],
|
|
start: tuple[float, float],
|
|
end: tuple[float, float],
|
|
count: int,
|
|
) -> list[tuple[float, float]]:
|
|
start_angle = math.atan2(start[1] - center[1], start[0] - center[0])
|
|
end_angle = math.atan2(end[1] - center[1], end[0] - center[0])
|
|
ccw_span = (end_angle - start_angle) % (2.0 * math.pi)
|
|
if ccw_span <= math.pi:
|
|
span = ccw_span
|
|
else:
|
|
span = -((start_angle - end_angle) % (2.0 * math.pi))
|
|
|
|
samples = max(count, 2)
|
|
radius = math.hypot(start[0] - center[0], start[1] - center[1])
|
|
return [
|
|
(
|
|
center[0] + (radius * math.cos(start_angle + (span * index / (samples - 1)))),
|
|
center[1] + (radius * math.sin(start_angle + (span * index / (samples - 1)))),
|
|
)
|
|
for index in range(samples)
|
|
]
|
|
|
|
|
|
def _rounded_outline_loop_points(
|
|
loop: list[tuple[float, float]],
|
|
*,
|
|
rounded_corners: set[tuple[float, float]],
|
|
radius: float,
|
|
) -> list[tuple[float, float]]:
|
|
if radius <= 1e-6:
|
|
return loop
|
|
|
|
rounded_loop: list[tuple[float, float]] = []
|
|
for index, point in enumerate(loop):
|
|
if point not in rounded_corners:
|
|
rounded_loop.append(point)
|
|
continue
|
|
|
|
previous_point = loop[index - 1]
|
|
next_point = loop[(index + 1) % len(loop)]
|
|
prev_vec = (previous_point[0] - point[0], previous_point[1] - point[1])
|
|
next_vec = (next_point[0] - point[0], next_point[1] - point[1])
|
|
prev_len = math.hypot(*prev_vec)
|
|
next_len = math.hypot(*next_vec)
|
|
corner_radius = min(radius, 0.5 * prev_len, 0.5 * next_len)
|
|
if corner_radius <= 1e-6:
|
|
rounded_loop.append(point)
|
|
continue
|
|
|
|
prev_unit = (prev_vec[0] / prev_len, prev_vec[1] / prev_len)
|
|
next_unit = (next_vec[0] / next_len, next_vec[1] / next_len)
|
|
tangent_in = (
|
|
point[0] + (prev_unit[0] * corner_radius),
|
|
point[1] + (prev_unit[1] * corner_radius),
|
|
)
|
|
tangent_out = (
|
|
point[0] + (next_unit[0] * corner_radius),
|
|
point[1] + (next_unit[1] * corner_radius),
|
|
)
|
|
center = (
|
|
point[0] + ((prev_unit[0] + next_unit[0]) * corner_radius),
|
|
point[1] + ((prev_unit[1] + next_unit[1]) * corner_radius),
|
|
)
|
|
arc_points = _sample_short_arc_2d(
|
|
center=center,
|
|
start=tangent_in,
|
|
end=tangent_out,
|
|
count=FLANGE_FREE_CORNER_SAMPLE_COUNT,
|
|
)
|
|
rounded_loop.extend(arc_points)
|
|
|
|
return rounded_loop
|
|
|
|
|
|
def _outline_loops_from_rectangles(
|
|
rectangles: tuple[tuple[float, float, float, float], ...],
|
|
) -> list[list[tuple[float, float]]]:
|
|
x_coords = sorted({coord for x0, _y0, x1, _y1 in rectangles for coord in (x0, x1)})
|
|
y_coords = sorted({coord for _x0, y0, _x1, y1 in rectangles for coord in (y0, y1)})
|
|
filled_cells: set[tuple[int, int]] = set()
|
|
|
|
for x_index in range(len(x_coords) - 1):
|
|
for y_index in range(len(y_coords) - 1):
|
|
x_mid = 0.5 * (x_coords[x_index] + x_coords[x_index + 1])
|
|
y_mid = 0.5 * (y_coords[y_index] + y_coords[y_index + 1])
|
|
if any(x0 < x_mid < x1 and y0 < y_mid < y1 for x0, y0, x1, y1 in rectangles):
|
|
filled_cells.add((x_index, y_index))
|
|
|
|
edges: dict[tuple[tuple[float, float], tuple[float, float]], None] = {}
|
|
|
|
def add_edge(start: tuple[float, float], end: tuple[float, float]) -> None:
|
|
reverse = (end, start)
|
|
if reverse in edges:
|
|
del edges[reverse]
|
|
return
|
|
edges[(start, end)] = None
|
|
|
|
for x_index, y_index in filled_cells:
|
|
x0 = x_coords[x_index]
|
|
x1 = x_coords[x_index + 1]
|
|
y0 = y_coords[y_index]
|
|
y1 = y_coords[y_index + 1]
|
|
add_edge((x0, y0), (x1, y0))
|
|
add_edge((x1, y0), (x1, y1))
|
|
add_edge((x1, y1), (x0, y1))
|
|
add_edge((x0, y1), (x0, y0))
|
|
|
|
outgoing: dict[tuple[float, float], list[tuple[float, float]]] = {}
|
|
for start, end in edges:
|
|
outgoing.setdefault(start, []).append(end)
|
|
|
|
loops: list[list[tuple[float, float]]] = []
|
|
while outgoing:
|
|
start = min(outgoing)
|
|
loop = [start]
|
|
current = start
|
|
while True:
|
|
next_points = outgoing.get(current)
|
|
if not next_points:
|
|
raise RuntimeError("Flat-pattern outline could not be assembled from panel rectangles")
|
|
next_point = next_points.pop()
|
|
if not next_points:
|
|
del outgoing[current]
|
|
if next_point == start:
|
|
break
|
|
loop.append(next_point)
|
|
current = next_point
|
|
|
|
area = _polygon_area(loop)
|
|
if area < 0.0:
|
|
loop.reverse()
|
|
loops.append(loop)
|
|
|
|
return loops
|
|
|
|
|
|
def _polygon_area(points: list[tuple[float, float]]) -> float:
|
|
return 0.5 * sum(
|
|
(points[index][0] * points[(index + 1) % len(points)][1])
|
|
- (points[(index + 1) % len(points)][0] * points[index][1])
|
|
for index in range(len(points))
|
|
)
|
|
|
|
|
|
def _add_reference_arc(
|
|
msp: ezdxf.layouts.Modelspace,
|
|
*,
|
|
start: tuple[float, float],
|
|
midpoint: tuple[float, float],
|
|
end: tuple[float, float],
|
|
) -> None:
|
|
from ezdxf.math import ConstructionArc
|
|
|
|
arc = ConstructionArc.from_3p(start, end, def_point=midpoint, ccw=True)
|
|
center = (arc.center.x, arc.center.y)
|
|
start_angle = math.atan2(start[1] - center[1], start[0] - center[0])
|
|
mid_angle = math.atan2(midpoint[1] - center[1], midpoint[0] - center[0])
|
|
end_angle = math.atan2(end[1] - center[1], end[0] - center[0])
|
|
ccw_sweep = (end_angle - start_angle) % (2.0 * math.pi)
|
|
mid_sweep = (mid_angle - start_angle) % (2.0 * math.pi)
|
|
if mid_sweep <= ccw_sweep + 1e-6:
|
|
msp.add_arc(
|
|
center=center,
|
|
radius=arc.radius,
|
|
start_angle=math.degrees(start_angle),
|
|
end_angle=math.degrees(end_angle),
|
|
dxfattribs={"layer": "CUT"},
|
|
)
|
|
return
|
|
|
|
msp.add_arc(
|
|
center=center,
|
|
radius=arc.radius,
|
|
start_angle=math.degrees(end_angle),
|
|
end_angle=math.degrees(start_angle),
|
|
dxfattribs={"layer": "CUT"},
|
|
)
|
|
|
|
|
|
def _build_flat_pattern_dxf(flat_pattern: FlatPattern, layout: BracketLayout) -> ezdxf.document.Drawing:
|
|
doc = ezdxf.new("R2010", setup=True)
|
|
doc.units = DXF_UNIT_MM
|
|
if "CUT" not in doc.layers:
|
|
doc.layers.add("CUT")
|
|
if "BEND" not in doc.layers:
|
|
doc.layers.add("BEND", linetype="DASHED")
|
|
|
|
msp = doc.modelspace()
|
|
rounded_corners = _flange_free_corner_points(layout, flat_pattern)
|
|
for loop in _outline_loops_from_rectangles(_flat_rectangles(layout, flat_pattern)):
|
|
rounded_loop = _rounded_outline_loop_points(
|
|
loop,
|
|
rounded_corners=rounded_corners,
|
|
radius=FLANGE_FREE_CORNER_RADIUS_MM,
|
|
)
|
|
for index, point in enumerate(rounded_loop):
|
|
msp.add_line(point, rounded_loop[(index + 1) % len(rounded_loop)], dxfattribs={"layer": "CUT"})
|
|
|
|
for x, y, cut_radius in flat_pattern.cut_circles:
|
|
msp.add_circle((x, y), cut_radius, dxfattribs={"layer": "CUT"})
|
|
|
|
for center_x, center_y in flat_pattern.cut_profile_centers:
|
|
msp.add_lwpolyline(
|
|
sample_keyed_connection_outline(center_u=center_x, center_v=center_y, points_per_arc=16),
|
|
format="xy",
|
|
close=True,
|
|
dxfattribs={"layer": "CUT"},
|
|
)
|
|
|
|
for segment_type, points in flat_pattern.cut_segments:
|
|
if segment_type == "LINE":
|
|
msp.add_line(points[0], points[-1], dxfattribs={"layer": "CUT"})
|
|
elif segment_type == "ARC":
|
|
_add_reference_arc(msp, start=points[0], midpoint=points[1], end=points[-1])
|
|
else:
|
|
raise RuntimeError(f"Unsupported flat-pattern cut segment type: {segment_type}")
|
|
|
|
for bend_line_x, bend_y_min, bend_y_max in flat_pattern.bend_line_segments_mm:
|
|
msp.add_line(
|
|
(bend_line_x, bend_y_min),
|
|
(bend_line_x, bend_y_max),
|
|
dxfattribs={"layer": "BEND", "linetype": "DASHED"},
|
|
)
|
|
return doc
|
|
|
|
|
|
def build_step(
|
|
*,
|
|
include_top_center_bridge: bool = True,
|
|
) -> build123d.Shape:
|
|
CAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
if not SERVO_STEP.exists():
|
|
raise FileNotFoundError(f"Missing STS3250 servo STEP: {SERVO_STEP}")
|
|
|
|
servo_shape = import_as_shape(SERVO_STEP)
|
|
bracket, layout = build_bracket(servo_shape, include_top_center_bridge=include_top_center_bridge)
|
|
flat_pattern = _build_flat_pattern(layout)
|
|
|
|
solids = bracket.solids()
|
|
if len(solids) != 1:
|
|
raise RuntimeError(f"Expected one connected bracket solid, found {len(solids)}")
|
|
|
|
intersection_volume = servo_shape.intersect(bracket).volume
|
|
if intersection_volume > 1e-3:
|
|
raise RuntimeError(f"Bracket intersects the servo by {intersection_volume:.6f} mm^3")
|
|
|
|
bracket.label = PART_NAME
|
|
|
|
bracket_bb = bracket.bounding_box()
|
|
print(
|
|
"Bracket envelope "
|
|
f"X={bracket_bb.size.X:.3f} mm, "
|
|
f"Y={bracket_bb.size.Y:.3f} mm, "
|
|
f"Z={bracket_bb.size.Z:.3f} mm"
|
|
)
|
|
print(
|
|
"SendCutSend setup "
|
|
f"material=5052-H32, thickness={SENDCUTSEND_SHEET_THICKNESS_MM:.3f} mm, "
|
|
f"bend_radius={SENDCUTSEND_EFFECTIVE_BEND_RADIUS_MM:.3f} mm, "
|
|
f"k_factor={SENDCUTSEND_K_FACTOR:.2f}, "
|
|
f"bend_deduction_90={SENDCUTSEND_BEND_DEDUCTION_90_MM:.3f} mm, "
|
|
f"anodize_min_short_dim={SENDCUTSEND_MIN_ANODIZE_SHORTEST_DIM_MM:.3f} mm"
|
|
)
|
|
sendcutsend_checks = (
|
|
"SendCutSend checks "
|
|
f"hole_to_bend_min={min(abs(center.X - layout.bracket_face_inner_x) for center, _radius in [*layout.top_holes, *layout.bottom_holes]):.3f} mm, "
|
|
f"bend_segment_min={_min_bend_segment_length(layout):.3f} mm, "
|
|
f"bend_support={_supported_bend_fraction(layout):.1%}, "
|
|
f"outboard_hole_edge_web={layout.outer_flush_gap:.3f} mm, "
|
|
f"flange_free_corner_radius={FLANGE_FREE_CORNER_RADIUS_MM:.3f} mm"
|
|
)
|
|
print(sendcutsend_checks)
|
|
if layout.outer_flush_gap < MOUNT_HOLE_TRIMMED_OUTBOARD_Z_WEB_WARN_MM:
|
|
print(
|
|
"Warning: trimmed outer side leaves a small hole edge web: "
|
|
f"{layout.outer_flush_gap:.3f} mm < {MOUNT_HOLE_TRIMMED_OUTBOARD_Z_WEB_WARN_MM:.3f} mm"
|
|
)
|
|
end_face_x = _find_face_near(servo_shape, FACE_TARGET_CENTERS_MM["end_face"], geom_type="PLANE").bounding_box().max.X
|
|
print(f"Bracket face clearance: {layout.bracket_face_inner_x - end_face_x:.3f} mm")
|
|
center_bridge_bend_edge_distance = _center_bridge_bend_edge_distance(layout)
|
|
center_bridge_flat_clearance = _center_bridge_flat_bend_line_to_edge_distance(layout, flat_pattern)
|
|
if center_bridge_bend_edge_distance is not None and center_bridge_flat_clearance is not None:
|
|
print(
|
|
"Top center bridge "
|
|
f"bend_edge_distance={center_bridge_bend_edge_distance:.3f} mm, "
|
|
f"flat_bend_line_to_edge={center_bridge_flat_clearance:.3f} mm"
|
|
)
|
|
print(f"Bracket face inner X: {layout.bracket_face_inner_x:.3f} mm")
|
|
print(
|
|
"Front-face keyed connection "
|
|
f"ref={KEYED_CONNECTION_REF}, "
|
|
f"center=(x=max, y={layout.front_face_center_y:.6f}, z=0.000000) mm, "
|
|
f"rotation={KEYED_CONNECTION_ROTATION_DEG:.1f} deg, "
|
|
"profiles=1"
|
|
)
|
|
print(
|
|
"Top mount holes used (x, y, z) mm: "
|
|
+ ", ".join(str(tuple(round(value, 6) for value in _vector_to_tuple(center))) for center, _radius in layout.top_holes)
|
|
)
|
|
print(
|
|
"Bottom mount holes used (x, y, z) mm: "
|
|
+ ", ".join(str(tuple(round(value, 6) for value in _vector_to_tuple(center))) for center, _radius in layout.bottom_holes)
|
|
)
|
|
print(f"Servo/bracket interference volume (mm^3): {intersection_volume:.6f}")
|
|
return bracket
|
|
|
|
|
|
def build_dxf(
|
|
*,
|
|
include_top_center_bridge: bool = True,
|
|
) -> ezdxf.document.Drawing:
|
|
CAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
if not SERVO_STEP.exists():
|
|
raise FileNotFoundError(f"Missing STS3250 servo STEP: {SERVO_STEP}")
|
|
|
|
servo_shape = import_as_shape(SERVO_STEP)
|
|
layout = _build_layout(servo_shape, include_top_center_bridge=include_top_center_bridge)
|
|
flat_pattern = _build_flat_pattern(layout)
|
|
doc = _build_flat_pattern_dxf(flat_pattern, layout)
|
|
|
|
outside_setback = _sendcutsend_outside_setback_90_mm()
|
|
flange_outside_min = _min_flange_outside_length(layout)
|
|
print(
|
|
"Flat pattern "
|
|
f"length={flat_pattern.total_length_mm:.3f} mm, "
|
|
f"width={flat_pattern.width_mm:.3f} mm, "
|
|
f"bend_lines=({flat_pattern.bend_line_positions_mm[0]:.3f}, {flat_pattern.bend_line_positions_mm[1]:.3f}) mm, "
|
|
f"bend_segments={len(flat_pattern.bend_line_segments_mm)}, "
|
|
f"cut_circles={len(flat_pattern.cut_circles)}, "
|
|
f"front_cut_profiles={len(flat_pattern.cut_profile_centers)}, "
|
|
f"front_cut_segments={len(flat_pattern.cut_segments)}"
|
|
)
|
|
dxf_bend_checks = (
|
|
"DXF bend checks "
|
|
f"flange_outside_min={flange_outside_min:.3f} mm, "
|
|
f"flange_flat_min={flange_outside_min - outside_setback:.3f} mm, "
|
|
f"flange_flat_required={SENDCUTSEND_MIN_FLANGE_FLAT_MM:.3f} mm, "
|
|
f"bend_segment_min={_min_bend_segment_length(layout):.3f} mm, "
|
|
f"bend_support={_supported_bend_fraction(layout):.1%}, "
|
|
f"hole_to_bend_min={min(abs(center.X - layout.bracket_face_inner_x) for center, _radius in [*layout.top_holes, *layout.bottom_holes]):.3f} mm, "
|
|
f"outboard_hole_edge_web={layout.outer_flush_gap:.3f} mm"
|
|
)
|
|
print(dxf_bend_checks)
|
|
center_bridge_bend_edge_distance = _center_bridge_bend_edge_distance(layout)
|
|
center_bridge_flat_clearance = _center_bridge_flat_bend_line_to_edge_distance(layout, flat_pattern)
|
|
if center_bridge_bend_edge_distance is not None and center_bridge_flat_clearance is not None:
|
|
print(
|
|
"DXF center bridge "
|
|
f"bend_edge_distance={center_bridge_bend_edge_distance:.3f} mm, "
|
|
f"flat_bend_line_to_edge={center_bridge_flat_clearance:.3f} mm"
|
|
)
|
|
return doc
|
|
|
|
|
|
def gen_step() -> dict[str, object]:
|
|
return {
|
|
"shape": build_step(),
|
|
}
|
|
|
|
|
|
def gen_dxf() -> dict[str, object]:
|
|
return {
|
|
"document": build_dxf(),
|
|
}
|
|
|
|
|
|
def gen() -> None:
|
|
gen_step()
|
|
gen_dxf()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
gen()
|