Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- import pygame
- import math
- import sys
- import json
- import time
- import os
- pygame.init()
- WIDTH, HEIGHT = 1100, 800
- screen = pygame.display.set_mode((WIDTH, HEIGHT))
- pygame.display.set_caption("AT-AT Quadruped — Klann Linkage Gait")
- clock = pygame.time.Clock()
- # --- Colors ---
- BG = (15, 18, 25)
- BODY_COLOR = (80, 90, 105)
- BODY_DARK = (55, 62, 75)
- LEG_COLOR = (100, 110, 125)
- LEG_FAR = (68, 75, 88)
- JOINT_COLOR = (200, 210, 220)
- JOINT_FAR = (130, 140, 150)
- FOOT_COLOR = (70, 80, 95)
- LINK_COLOR = (140, 155, 170)
- LINK_FAR = (95, 105, 118)
- CRANK_COLOR = (180, 140, 80)
- SLIDER_BG = (35, 40, 50)
- SLIDER_FG = (100, 140, 180)
- SLIDER_HAND = (160, 200, 230)
- TEXT_COLOR = (180, 190, 200)
- TEXT_DIM = (100, 110, 120)
- GRID_COLOR = (25, 30, 40)
- GROUND_COLOR = (40, 50, 60)
- TRACE_NEAR = (180, 80, 80)
- TRACE_FAR = (80, 80, 180)
- SAFETY_ORANGE = (255, 102, 0)
- AMBER = (220, 160, 40)
- PX_PER_INCH = 20
- PX_PER_M = PX_PER_INCH / 0.0254 # ~787.4 px per meter
- # --- Physics constants ---
- LEG_MASS = 0.350 # kg per leg (PLA + 2 servos + bearings + hardware)
- BODY_MASS = 0.600 # kg chassis, battery, wiring, compute
- TOTAL_MASS = BODY_MASS + 4 * LEG_MASS # 2.0 kg
- GRAVITY = 9.81 # m/s^2
- MU_JOINT = 0.015 # bearing/joint friction coefficient
- MU_GROUND = 0.25 # foot-ground sliding friction (during swing retract)
- JOINT_RAD_M = 0.003 # effective joint pin radius ~3mm
- # ============================================================
- # Klann Patent 6-bar Linkage (Stephenson III)
- #
- # Reference: diywalkers.com/klanns-linkage-plans.html
- #
- # Fixed frame pivots (relative to crank center J0):
- # J3 (lower): (-6.6, -1.97) in math coords (Y up)
- # J5 (upper): (-2.6, +6.9)
- #
- # Moving joints:
- # J1 = crank pin (rotates around J0, radius B0)
- # J4 = circle intersection: (J1,B2) & (J3,B3) [high]
- # J6 = rigid bent extension from J4 away from J1
- # length B4, bend angle -B6_angle
- # J7 = circle intersection: (J5,B6) & (J6,B5) [high]
- # J8 = foot, rigid bent extension from J6 away from J7
- # length B7, bend angle +B7_angle
- #
- # Patent bar lengths (crank=3 base):
- # B0=3 B2=6.6 B3=3.59 B4=5.84
- # B5=10.04 B6=5.79 B7=10.04
- #
- # Bend angles: B6_angle=12.8° B7_angle=30°
- # ============================================================
- SIM_SCALE = 7
- DEFAULT_PARAMS = {
- "crank": 3.0 * SIM_SCALE,
- "B2": 6.6 * SIM_SCALE,
- "B3": 3.59 * SIM_SCALE,
- "B4": 5.84 * SIM_SCALE,
- "B5": 10.04 * SIM_SCALE,
- "B6_bar": 5.79 * SIM_SCALE,
- "B7": 10.04 * SIM_SCALE,
- "B6_angle": 12.8,
- "B7_angle": 30.0,
- "speed": 22,
- }
- PRESET2_PARAMS = {
- "crank": 18.6,
- "B2": 40.5,
- "B3": 25.1,
- "B4": 52.2,
- "B5": 75.4,
- "B6_bar": 60.2,
- "B7": 153.4,
- "B6_angle": 0.0,
- "B7_angle": 30.5,
- "speed": 60.0,
- }
- PRESET3_PARAMS = {
- "crank": 21.0,
- "B2": 46.2,
- "B3": 25.1,
- "B4": 52.2,
- "B5": 80.9,
- "B6_bar": 56.0,
- "B7": 230.3,
- "B6_angle": 12.8,
- "B7_angle": 30.0,
- "speed": 22.0,
- }
- # Frame pivot offsets from crank center (patent values * SIM_SCALE)
- FRAME_LOW_X = 6.6 * SIM_SCALE
- FRAME_LOW_Y = 1.97 * SIM_SCALE
- FRAME_HIGH_X = 2.6 * SIM_SCALE
- FRAME_HIGH_Y = 6.9 * SIM_SCALE
- SLIDERS = [
- ("crank", "Crank B0", 10, 42),
- ("B2", "B2 J1-J4", 25, 80),
- ("B3", "B3 J3-J4", 12, 50),
- ("B4", "B4 J4-J6", 20, 70),
- ("B5", "B5 J6-J7", 35, 120),
- ("B6_bar", "B6 J5-J7", 20, 80),
- ("B7", "B7 foot", 35, 400),
- ("B6_angle", "B6 angle", 0, 45),
- ("B7_angle", "B7 angle", 0, 60),
- ("speed", "Speed", 5, 60),
- ]
- SLIDER_X = 25
- SLIDER_W = 185
- SLIDER_H = 6
- SLIDER_START = 390
- SLIDER_STEP = 32
- params = dict(PRESET3_PARAMS) # RST3 is the primary working config
- dragging = None
- font = pygame.font.SysFont("monospace", 12)
- font_sm = pygame.font.SysFont("monospace", 11)
- font_bold = pygame.font.SysFont("monospace", 14, bold=True)
- TRACE_LEN = 300
- trace_front = []
- trace_rear = []
- show_traces = True
- # Actual foot path: ring buffer collecting one leg's foot over one full crank rotation
- actual_foot_path = []
- actual_foot_angle_start = None
- # Torque tracking
- torque_history = [] # instantaneous torque values (Nm)
- prev_foot_pos = None # previous foot position for velocity calc
- prev_foot_vel = None # previous velocity for acceleration calc
- max_torque_3cyc = 0.0 # max torque over last 3 cycles
- # Save button flash
- save_flash_msg = ""
- save_flash_timer = 0
- # Mapping from slider param key to linkage segment endpoint pairs for highlighting
- SLIDER_SEGMENTS = {
- "crank": [("O", "C")],
- "B2": [("C", "D")],
- "B3": [("P_low", "D")],
- "B4": [("D", "E")],
- "B5": [("E", "G")],
- "B6_bar": [("P_high", "G")],
- "B7": [("E", "F")],
- "B6_angle": [("D", "E")],
- "B7_angle": [("E", "F")],
- "speed": [],
- }
- # ============================================================
- # Circle-circle intersection
- # ============================================================
- def circle_intersect(p1, r1, p2, r2):
- dx, dy = p2[0]-p1[0], p2[1]-p1[1]
- d = math.hypot(dx, dy)
- if d < 1e-9 or d > r1+r2+1e-6 or d < abs(r1-r2)-1e-6:
- return None, None
- a = (r1*r1 - r2*r2 + d*d) / (2.0*d)
- h = math.sqrt(max(0.0, r1*r1 - a*a))
- mx, my = p1[0] + a*dx/d, p1[1] + a*dy/d
- return (mx+h*dy/d, my-h*dx/d), (mx-h*dy/d, my+h*dx/d)
- # ============================================================
- # Klann 6-bar solver
- # ============================================================
- def solve_klann(theta, p):
- """
- Solve Klann patent linkage.
- Computation in math coords (Y up), output in screen coords (Y down).
- """
- # Fixed frame points (math coords, Y up)
- O = (0.0, 0.0)
- P_low = (-FRAME_LOW_X, -FRAME_LOW_Y) # J3: left and below
- P_high = (-FRAME_HIGH_X, FRAME_HIGH_Y) # J5: left and above
- # J1: crank pin (clockwise in math coords)
- C = (p["crank"] * math.cos(-theta),
- p["crank"] * math.sin(-theta))
- # J4 (D): intersection of circle(J1, B2) and circle(J3, B3)
- pts = circle_intersect(C, p["B2"], P_low, p["B3"])
- if pts[0] is None:
- return None
- D = pts[0] if pts[0][1] > pts[1][1] else pts[1] # high solution
- # J6 (E): lineextendBentDegrees(J4, J1, B4, -B6_angle)
- # From J4, away from J1, bent by -B6_angle
- slope_dc = math.atan2(C[1] - D[1], C[0] - D[0])
- b6_rad = math.radians(-p["B6_angle"])
- Ex = D[0] - p["B4"] * math.cos(slope_dc + b6_rad)
- Ey = D[1] - p["B4"] * math.sin(slope_dc + b6_rad)
- E = (Ex, Ey)
- # J7 (G): intersection of circle(J5, B6_bar) and circle(J6, B5)
- pts2 = circle_intersect(P_high, p["B6_bar"], E, p["B5"])
- if pts2[0] is None:
- return None
- G = pts2[0] if pts2[0][1] > pts2[1][1] else pts2[1] # high solution
- # J8 (F, foot): lineextendBentDegrees(J6, J7, B7, B7_angle)
- # From J6, away from J7, bent by B7_angle
- slope_eg = math.atan2(G[1] - E[1], G[0] - E[0])
- b7_rad = math.radians(p["B7_angle"])
- Fx = E[0] - p["B7"] * math.cos(slope_eg + b7_rad)
- Fy = E[1] - p["B7"] * math.sin(slope_eg + b7_rad)
- F = (Fx, Fy)
- # Convert to screen coords (negate Y)
- def s(pt):
- return (pt[0], -pt[1])
- return dict(O=s(O), C=s(C), P_low=s(P_low), P_high=s(P_high),
- D=s(D), E=s(E), G=s(G), F=s(F))
- # ============================================================
- # Pre-compute foot path
- # ============================================================
- def compute_foot_path(p, steps=240):
- path = []
- for i in range(steps):
- theta = 2.0 * math.pi * i / steps
- j = solve_klann(theta, p)
- if j is not None:
- path.append(j["F"])
- return path
- def path_metrics(path):
- """Return (stride_px, height_px, min_x, max_x, min_y, max_y,
- ground_contact_px, ground_points, foot_strike)."""
- if not path:
- return 0, 0, 0, 0, 0, 0, 0, [], False
- xs = [p[0] for p in path]
- ys = [p[1] for p in path]
- max_y = max(ys)
- height_px = max(ys) - min(ys)
- tol = max(2.0, height_px * 0.02)
- n = len(path)
- foot_strike = False
- for i in range(n):
- if path[i][1] >= max_y - tol:
- dx = path[(i+1) % n][0] - path[i][0]
- if dx > 0:
- foot_strike = True
- break
- ground_pts = [p for p in path if p[1] >= max_y - tol]
- if ground_pts:
- gxs = [p[0] for p in ground_pts]
- ground_contact_px = max(gxs) - min(gxs)
- else:
- ground_contact_px = 0
- return (max(xs)-min(xs), height_px,
- min(xs), max(xs), min(ys), max(ys),
- ground_contact_px, ground_pts, foot_strike)
- # ============================================================
- # Save geometry as JSON for OpenSCAD fabrication
- # ============================================================
- def save_geometry(params, actual_path, max_torque):
- """Export current linkage geometry as a JSON file for 3D printing.
- All lengths in mm (param values = mm).
- Each piece lists bearing hole positions (center-to-center for 624ZZ).
- Uses the actual foot path (from simulation) for step profile.
- """
- ts = int(time.time())
- crank = params["crank"]
- B2 = params["B2"]
- B3 = params["B3"]
- B4 = params["B4"]
- B5 = params["B5"]
- B6 = params["B6_bar"]
- B7 = params["B7"]
- b6a = params["B6_angle"]
- b7a = params["B7_angle"]
- b6r = math.radians(b6a)
- b7r = math.radians(b7a)
- # Frame L-bracket: J0 at origin, arms to J3 and J5 (math coords, Y up)
- frame_j3 = (-FRAME_LOW_X, -FRAME_LOW_Y)
- frame_j5 = (-FRAME_HIGH_X, FRAME_HIGH_Y)
- frame_arm1_len = math.hypot(FRAME_LOW_X, FRAME_LOW_Y)
- frame_arm2_len = math.hypot(FRAME_HIGH_X, FRAME_HIGH_Y)
- a1 = math.atan2(frame_j3[1], frame_j3[0])
- a2 = math.atan2(frame_j5[1], frame_j5[0])
- frame_opening_deg = math.degrees(a2 - a1) % 360
- # Bent coupler (b2+b4): 3 holes at J1, J4 (vertex), J6
- # Relative to J4 with J1 along +X
- coupler_j1 = (B2, 0.0)
- coupler_j6 = (-B4 * math.cos(b6r), B4 * math.sin(b6r))
- # Bent output (b5+b7): 3 holes at J7, J6 (vertex), J8
- # Relative to J6 with J7 along +X
- output_j7 = (B5, 0.0)
- output_j8 = (-B7 * math.cos(b7r), -B7 * math.sin(b7r))
- def r2(v):
- return round(v, 2)
- # 6 manufactured pieces: frame + 3 simple bars + 2 bent bars
- #
- # Assembly topology:
- # J0 (frame) ── b0 ── J1 (crank pin)
- # J1 ── [b2+b4] ── J4 ── J6 (single bent piece, vertex at J4)
- # J3 (frame) ── b3 ── J4
- # J6 ── [b5+b7] ── J7 / J8 (single bent piece, vertex at J6)
- # J5 (frame) ── b6 ── J7
- data = {
- "timestamp": ts,
- "units": "mm",
- "bearing": {
- "model": "624ZZ",
- "od_mm": 13.0,
- "id_mm": 4.0,
- "width_mm": 5.0,
- },
- "parameters": {
- "crank_mm": r2(crank),
- "B2_mm": r2(B2),
- "B3_mm": r2(B3),
- "B4_mm": r2(B4),
- "B5_mm": r2(B5),
- "B6_bar_mm": r2(B6),
- "B7_mm": r2(B7),
- "B6_angle_deg": r2(b6a),
- "B7_angle_deg": r2(b7a),
- "frame_low_x_mm": r2(FRAME_LOW_X),
- "frame_low_y_mm": r2(FRAME_LOW_Y),
- "frame_high_x_mm": r2(FRAME_HIGH_X),
- "frame_high_y_mm": r2(FRAME_HIGH_Y),
- },
- "pieces": [
- {
- "name": "frame",
- "description": "Fixed L-bracket. Motor shaft at J0, two pivot holes.",
- "type": "frame",
- "holes": [
- {"label": "J0", "x_mm": 0, "y_mm": 0,
- "note": "crank motor shaft"},
- {"label": "J3", "x_mm": r2(frame_j3[0]), "y_mm": r2(frame_j3[1]),
- "note": "lower pivot"},
- {"label": "J5", "x_mm": r2(frame_j5[0]), "y_mm": r2(frame_j5[1]),
- "note": "upper pivot"},
- ],
- "arm1_length_mm": r2(frame_arm1_len),
- "arm1_endpoint": "J3",
- "arm2_length_mm": r2(frame_arm2_len),
- "arm2_endpoint": "J5",
- "opening_angle_deg": r2(frame_opening_deg),
- },
- {
- "name": "b0_crank",
- "description": "Crank arm. Pivot to crank pin.",
- "type": "bar",
- "joints": ["J0", "J1"],
- "center_to_center_mm": r2(crank),
- },
- {
- "name": "b2_b4_coupler",
- "description": "Bent coupler: J1--J4--J6. Single rigid piece, "
- "vertex at J4. B2 arm to crank pin, B4 arm to output.",
- "type": "bent_bar",
- "vertex": "J4",
- "joints": ["J1", "J4", "J6"],
- "holes": [
- {"label": "J1", "x_mm": r2(coupler_j1[0]),
- "y_mm": r2(coupler_j1[1]), "note": "crank pin"},
- {"label": "J4", "x_mm": 0, "y_mm": 0,
- "note": "vertex, pivots on b3 rocker"},
- {"label": "J6", "x_mm": r2(coupler_j6[0]),
- "y_mm": r2(coupler_j6[1]), "note": "output node"},
- ],
- "arm1_length_mm": r2(B2),
- "arm2_length_mm": r2(B4),
- "vertex_angle_deg": r2(180 - b6a),
- },
- {
- "name": "b3_rocker",
- "description": "Lower rocker. Frame pivot J3 to coupler node J4.",
- "type": "bar",
- "joints": ["J3", "J4"],
- "center_to_center_mm": r2(B3),
- },
- {
- "name": "b5_b7_output",
- "description": "Bent output: J7--J6--J8. Single rigid piece, "
- "vertex at J6. B5 arm to upper rocker, B7 arm is foot.",
- "type": "bent_bar",
- "vertex": "J6",
- "joints": ["J7", "J6", "J8"],
- "holes": [
- {"label": "J7", "x_mm": r2(output_j7[0]),
- "y_mm": r2(output_j7[1]), "note": "upper rocker"},
- {"label": "J6", "x_mm": 0, "y_mm": 0,
- "note": "vertex, pivots on coupler"},
- {"label": "J8", "x_mm": r2(output_j8[0]),
- "y_mm": r2(output_j8[1]), "note": "foot"},
- ],
- "arm1_length_mm": r2(B5),
- "arm2_length_mm": r2(B7),
- "vertex_angle_deg": r2(180 + b7a),
- },
- {
- "name": "b6_rocker",
- "description": "Upper rocker. Frame pivot J5 to triangle apex J7.",
- "type": "bar",
- "joints": ["J5", "J7"],
- "center_to_center_mm": r2(B6),
- },
- ],
- }
- # Use actual foot path from simulation for step profile
- # actual_path is in screen coords (absolute), need relative positions
- # Extract the foot's path relative to hip (subtract the constant hip offset)
- # The actual_path has Y near GROUND_Y when on ground — use it directly for metrics
- if actual_path and len(actual_path) > 10:
- foot_path = list(actual_path)
- else:
- # Fallback to computed if no actual data yet
- linkage_p = {k: v for k, v in params.items() if k != "speed"}
- foot_path = compute_foot_path(linkage_p)
- stride_px, height_px, mn_x, mx_x, mn_y, mx_y, ground_px, ground_pts, _ = path_metrics(foot_path)
- step_height_mm = height_px
- step_length_mm = ground_px if ground_px > 0 else stride_px
- data["step_profile"] = {
- "step_length_mm": r2(step_length_mm),
- "step_height_mm": r2(step_height_mm),
- "stride_width_mm": r2(stride_px),
- "max_torque_3cyc_Nm": r2(max_torque),
- "recommended_torque_Nm": r2(max_torque * 2.85),
- }
- script_dir = os.path.dirname(os.path.abspath(__file__))
- filepath = os.path.join(script_dir, f"{ts}.json")
- with open(filepath, 'w') as f:
- json.dump(data, f, indent=2)
- # --- Generate PNG of step profile ---
- png_path = os.path.join(script_dir, f"{ts}.png")
- _save_profile_png(png_path, foot_path, ground_pts,
- step_length_mm, step_height_mm, stride_px,
- mn_x, mx_x, mn_y, mx_y, params, ts, max_torque)
- # --- Generate per-piece OpenSCAD + STL files ---
- _save_scad_and_stl(script_dir, params, ts)
- return filepath
- # Shared SCAD preamble for all pieces
- def _stl_to_step(stl_path):
- """Convert an STL file to STEP format using OCP (OpenCascade via CadQuery)."""
- step_path = stl_path.replace('.stl', '.step')
- try:
- from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
- from OCP.StlAPI import StlAPI_Reader
- from OCP.TopoDS import TopoDS_Shape
- reader = StlAPI_Reader()
- shape = TopoDS_Shape()
- reader.Read(shape, stl_path)
- if shape.IsNull():
- return None
- writer = STEPControl_Writer()
- writer.Transfer(shape, STEPControl_AsIs)
- writer.Write(step_path)
- if os.path.exists(step_path):
- return step_path
- except Exception as e:
- print(f"STEP export failed for {os.path.basename(stl_path)}: {e}")
- return None
- _SCAD_PREAMBLE = """\
- // 624ZZ bearing dimensions
- BEARING_OD = 13.0;
- BEARING_ID = 4.0;
- BEARING_W = 5.0;
- BOLT_CLEARANCE = 4.3; // M4 + 0.3mm clearance
- // Link geometry
- LINK_THICK = 10.0;
- WALL = 3.0;
- LINK_WIDTH = BEARING_OD + 2 * WALL; // 19mm
- POCKET_DEPTH = 5.0;
- POCKET_DIA = 13.4;
- // Captured M4 nut recess (bottom face of each bearing)
- NUT_RECESS_DEPTH = 3.5;
- NUT_RECESS_DIA = 8.66; // 7.5mm AF / cos(30) for $fn=6
- module bearing_seat(thick=LINK_THICK) {
- // Bolt hole through full thickness
- translate([0, 0, -0.05])
- cylinder(h = thick + 0.1, d = BOLT_CLEARANCE, $fn = 24);
- // Bearing pocket on top face
- translate([0, 0, thick - POCKET_DEPTH])
- cylinder(h = POCKET_DEPTH + 0.05, d = POCKET_DIA, $fn = 48);
- // Captured nut recess on bottom face
- translate([0, 0, -0.05])
- cylinder(h = NUT_RECESS_DEPTH + 0.05, d = NUT_RECESS_DIA, $fn = 6);
- }
- // Bearing seat that also flattens any ridge/pyramid material above it.
- module bearing_seat_flat(base_thick=LINK_THICK, max_thick=12) {
- // Bolt hole through full height
- translate([0, 0, -0.05])
- cylinder(h = max_thick + 0.1, d = BOLT_CLEARANCE, $fn = 24);
- // Bearing pocket at base height
- translate([0, 0, base_thick - POCKET_DEPTH])
- cylinder(h = POCKET_DEPTH + 0.05, d = POCKET_DIA, $fn = 48);
- // Captured nut recess on bottom face
- translate([0, 0, -0.05])
- cylinder(h = NUT_RECESS_DEPTH + 0.05, d = NUT_RECESS_DIA, $fn = 6);
- // Flatten: remove all ridge material above the bearing hub
- translate([0, 0, base_thick - 0.01])
- cylinder(h = max_thick, d = LINK_WIDTH, $fn = 48);
- }
- module straight_bar(c2c) {
- difference() {
- hull() {
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- translate([c2c, 0, 0])
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- }
- bearing_seat();
- translate([c2c, 0, 0]) bearing_seat();
- }
- }
- // Bent bar: two straight arms joined at vertex (boomerang/L shape)
- // h1x,h1y = first endpoint, h2x,h2y = second endpoint, vertex at (0,0)
- module bent_bar(h1x, h1y, h2x, h2y) {
- difference() {
- union() {
- // Arm 1: vertex to endpoint 1
- hull() {
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- translate([h1x, h1y, 0])
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- }
- // Arm 2: vertex to endpoint 2
- hull() {
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- translate([h2x, h2y, 0])
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- }
- }
- // Vertex bearing seat
- bearing_seat();
- // Endpoint 1 bearing seat
- translate([h1x, h1y, 0]) bearing_seat();
- // Endpoint 2 bearing seat
- translate([h2x, h2y, 0]) bearing_seat();
- }
- }
- """
- def _save_scad_and_stl(out_dir, params, ts):
- """Generate per-piece .scad and .stl files."""
- import subprocess
- crank = params["crank"]
- B2 = params["B2"]
- B3 = params["B3"]
- B4 = params["B4"]
- B5 = params["B5"]
- B6 = params["B6_bar"]
- B7 = params["B7"]
- b6r = math.radians(params["B6_angle"])
- b7r = math.radians(params["B7_angle"])
- # Bent coupler hole positions (relative to vertex J4, J1 along +X)
- c_j1x, c_j1y = B2, 0.0
- c_j6x = -B4 * math.cos(b6r)
- c_j6y = B4 * math.sin(b6r)
- # Bent output hole positions (relative to vertex J6, J7 along +X)
- o_j7x, o_j7y = B5, 0.0
- o_j8x = -B7 * math.cos(b7r)
- o_j8y = -B7 * math.sin(b7r)
- # Find OpenSCAD binary
- import shutil
- openscad = shutil.which("openscad")
- if openscad is None:
- # macOS app bundle fallback
- for p in ["/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD"]:
- if os.path.isfile(p):
- openscad = p
- break
- if openscad is None:
- print("WARNING: openscad not found in PATH. .scad files saved but .stl skipped.")
- # Frame bracket: flat-bottomed shape with mounting holes
- j3x = -FRAME_LOW_X
- j3y = -FRAME_LOW_Y
- j5x = -FRAME_HIGH_X
- j5y = FRAME_HIGH_Y
- hub_d = 13.0 + 2 * 3.0 # 19mm: bearing OD + 3mm wall each side
- # Mounting holes: below J0 (hole A), for bolting to chassis
- # Mount 1: directly below J0 at (0, mount_y)
- # Mount 2: 25mm to the left at (-25, mount_y)
- mount_y = -20.0 # 20mm below J0
- mount1_x = 0.0 # directly below J0
- mount2_x = -25.0 # 25mm in front (left)
- nut_af = 7.5 # M4 hex nut: 7mm AF + 0.5mm clearance
- nut_depth = 3.2 # M4 nut: 3.0mm thick + 0.2mm clearance
- nut_ac = nut_af / math.cos(math.radians(30)) # across corners ~7.5mm
- nut_r = nut_ac / 2 # corner radius ~3.75mm
- wall_clearance = 3.0 # min PLA from nut edge to part edge
- # Bottom edge: must clear below the nut recesses
- bot_y = mount_y - nut_r - wall_clearance # ~-26.75
- # Bottom corners extend to cover full width + mount holes
- bot_left_x = min(j3x, j5x, mount2_x) - max(hub_d / 2, nut_r + wall_clearance)
- bot_right_x = max(j3x, j5x, mount1_x) + max(hub_d / 2, nut_r + wall_clearance)
- frame_scad = _SCAD_PREAMBLE + f"""\
- FRAME_THICK = 10.0;
- HUB_D = {hub_d:.1f};
- module frame_bracket() {{
- difference() {{
- hull() {{
- // Bearing hubs (19mm circles guarantee 3mm wall around pockets)
- cylinder(h = FRAME_THICK, d = HUB_D, $fn = 48);
- translate([{j3x:.2f}, {j3y:.2f}, 0])
- cylinder(h = FRAME_THICK, d = HUB_D, $fn = 48);
- translate([{j5x:.2f}, {j5y:.2f}, 0])
- cylinder(h = FRAME_THICK, d = HUB_D, $fn = 48);
- // Flat bottom edge (extends below for mounting area)
- translate([{bot_left_x:.2f}, {bot_y:.2f}, 0])
- cylinder(h = FRAME_THICK, d = 1, $fn = 8);
- translate([{bot_right_x:.2f}, {bot_y:.2f}, 0])
- cylinder(h = FRAME_THICK, d = 1, $fn = 8);
- }}
- // J0: bearing seat
- bearing_seat(FRAME_THICK);
- // J3: bearing seat
- translate([{j3x:.2f}, {j3y:.2f}, 0]) bearing_seat(FRAME_THICK);
- // J5: bearing seat
- translate([{j5x:.2f}, {j5y:.2f}, 0]) bearing_seat(FRAME_THICK);
- // Mounting hole 1: directly below J0 (hole A)
- translate([{mount1_x:.2f}, {mount_y:.2f}, -0.05])
- cylinder(h = FRAME_THICK + 0.1, d = BOLT_CLEARANCE, $fn = 24);
- // Mounting hole 2: 25mm to the left
- translate([{mount2_x:.2f}, {mount_y:.2f}, -0.05])
- cylinder(h = FRAME_THICK + 0.1, d = BOLT_CLEARANCE, $fn = 24);
- // Hex nut recess 1 (top face, same side as bearing pockets)
- // d = across-corners so $fn=6 gives correct across-flats = {nut_af:.1f}mm
- translate([{mount1_x:.2f}, {mount_y:.2f}, FRAME_THICK - {nut_depth}])
- cylinder(h = {nut_depth + 0.05}, d = {nut_ac:.2f}, $fn = 6);
- // Hex nut recess 2
- translate([{mount2_x:.2f}, {mount_y:.2f}, FRAME_THICK - {nut_depth}])
- cylinder(h = {nut_depth + 0.05}, d = {nut_ac:.2f}, $fn = 6);
- }}
- }}
- frame_bracket();
- """
- # Build b5_b7 output piece(s) — split if B7 arm >= 105mm
- SPLIT_THRESHOLD = 105.0
- CUT_DIST = 40.0 # mm from J6 along B7 arm where the cut happens
- PIN_DIA = 1.95 # 1.75mm filament + 0.2mm clearance
- PIN_DEPTH = 10.5 # slightly over 10mm so pins seat fully
- # 3 alignment pins in equilateral triangle (circumradius R=3mm)
- PIN_R = 3.0 # circumradius of equilateral triangle
- # Pin positions relative to face center (Y=across bar, Z=thickness)
- # Top pin, bottom-left, bottom-right
- PIN_TRI = [
- (0.0, PIN_R), # top center
- (-PIN_R * math.sin(math.radians(60)), -PIN_R * 0.5), # bottom-left
- ( PIN_R * math.sin(math.radians(60)), -PIN_R * 0.5), # bottom-right
- ]
- # Pyramidal ridge on B7 arm (Z axis) for torsion resistance
- # Ridge must start 17.5mm from EDGE of bearing well, not center
- # bearing well radius = POCKET_DIA/2 = 6.7mm
- # Bottom stays flat at z=0 for bed adhesion; ridge is on top only
- RIDGE_H = 16.0 # mm total height at peak (base + 6mm ridge)
- RIDGE_W = 6.0 # mm width of ridge spine at peak
- BEARING_WELL_R = 13.4 / 2 # pocket radius
- RIDGE_CLEARANCE = 17.5 + BEARING_WELL_R # ~24.2mm from hole center
- b7_len_full = math.hypot(o_j8x, o_j8y)
- if B7 >= SPLIT_THRESHOLD:
- # Split the B7 arm into elbow + extension
- b7_len = b7_len_full
- dx = o_j8x / b7_len
- dy = o_j8y / b7_len
- # Perpendicular for pin placement
- px, py = -dy, dx
- # Cut point
- cx, cy = dx * CUT_DIST, dy * CUT_DIST
- # Pin holes: 3 pins in equilateral triangle at the cut face
- # Perpendicular (across bar) = (px, py), thickness = Z axis
- # Compute 3 pin start positions (1mm outside cut face for clean drilling)
- pin_start_offset = 1.0
- arm_ang = math.degrees(math.atan2(dy, dx))
- pin_total = PIN_DEPTH + pin_start_offset
- # Each pin: face_center + perp_offset * (px,py) + z_offset in Z
- elbow_pin_positions = []
- ext_pin_positions = []
- for (perp_off, z_off) in PIN_TRI:
- # Elbow: start 1mm outside cut face (toward J8)
- epx = cx + dx * pin_start_offset + perp_off * px
- epy = cy + dy * pin_start_offset + perp_off * py
- epz = 5.0 + z_off # LINK_THICK/2 = 5
- elbow_pin_positions.append((epx, epy, epz))
- # Extension: at x=-1 (1mm outside), Y=perp_off, Z=5+z_off
- ext_pin_positions.append((perp_off, epz))
- # Extension length
- ext_len = b7_len - CUT_DIST
- # Elbow: full bent bar, then intersect to chop flat at cut line
- # The cut plane passes through cut_point, perpendicular to the B7 arm
- # We keep the J6/J7 side. Use a large cube on the "keep" side.
- # Normal of cut plane points toward J8: (dx, dy). We keep the opposite side.
- # Rotate cut cube so its face aligns with the cut perpendicular.
- elbow_scad = _SCAD_PREAMBLE + f"""\
- // b5_b7 elbow: bent bar chopped flat at {CUT_DIST:.0f}mm along B7 arm
- // No pyramid on elbow (upper section) — only on extension (lower section)
- difference() {{
- intersection() {{
- union() {{
- // Arm 1: vertex (J6) to J7
- hull() {{
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- translate([{o_j7x:.2f}, {o_j7y:.2f}, 0])
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- }}
- // Arm 2: vertex (J6) toward J8 (flat, no ridge)
- hull() {{
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- translate([{o_j8x:.2f}, {o_j8y:.2f}, 0])
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- }}
- }}
- // Half-space: keep everything on J6 side of cut plane
- translate([{cx:.2f}, {cy:.2f}, -1])
- rotate([0, 0, {arm_ang:.2f}])
- translate([-500, -500, 0])
- cube([500, 1000, LINK_THICK + 2]);
- }}
- // J6 bearing seat (vertex)
- bearing_seat();
- // J7 bearing seat
- translate([{o_j7x:.2f}, {o_j7y:.2f}, 0]) bearing_seat();
- // 3 alignment pin holes in equilateral triangle at cut face
- {"".join(f''' translate([{epx:.2f}, {epy:.2f}, {epz:.2f}])
- rotate([0, 0, {arm_ang + 180:.2f}])
- rotate([0, 90, 0])
- cylinder(h = {pin_total:.1f}, d = {PIN_DIA}, $fn = 16);
- ''' for epx, epy, epz in elbow_pin_positions)}
- }}
- """
- # Extension: full straight bar (cut_point to J8), chopped flat at cut end
- # Oriented along X: x=0 is cut face, x=ext_len is J8
- # Ridge on extension: 17.5mm clearance from cut face (x=0) and J8 (x=ext_len)
- ridge_start_x = RIDGE_CLEARANCE # 17.5mm from cut face
- ridge_end_x = ext_len - RIDGE_CLEARANCE # 17.5mm from J8
- ridge_mid_x = (ridge_start_x + ridge_end_x) / 2
- has_ridge = ridge_end_x > ridge_start_x + 10 # only if enough room
- if has_ridge:
- ext_hull = f"""
- // Bar with pyramidal ridge, 17.5mm clearance from each hole
- hull() {{
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- translate([{ext_len:.2f}, 0, 0])
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- // Ridge start (17.5mm from cut face)
- translate([{ridge_start_x:.2f}, 0, 0])
- cylinder(h = {RIDGE_H}, d = {RIDGE_W}, $fn = 24);
- // Ridge end (17.5mm from J8)
- translate([{ridge_end_x:.2f}, 0, 0])
- cylinder(h = {RIDGE_H}, d = {RIDGE_W}, $fn = 24);
- }}"""
- else:
- ext_hull = f"""
- // Bar too short for ridge, flat
- hull() {{
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- translate([{ext_len:.2f}, 0, 0])
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- }}"""
- ext_scad = _SCAD_PREAMBLE + f"""\
- // b5_b7 extension: cut_point to J8 ({ext_len:.1f}mm)
- // x=0 is flat cut face, x={ext_len:.2f} is J8
- // Ridge clearance: {RIDGE_CLEARANCE}mm from each hole
- difference() {{
- intersection() {{
- {ext_hull}
- // Chop: keep only x >= 0 (flat face at cut end)
- translate([0, -LINK_WIDTH * 2, -1])
- cube([{ext_len + 20:.2f}, LINK_WIDTH * 4, {RIDGE_H + 2}]);
- }}
- // J8 bearing seat (flatten ridge above)
- translate([{ext_len:.2f}, 0, 0]) bearing_seat_flat(LINK_THICK, {RIDGE_H});
- // 3 alignment pin holes in equilateral triangle at cut face
- {"".join(f''' translate([-1, {y_off:.2f}, {z_off:.2f}])
- rotate([0, 90, 0])
- cylinder(h = {pin_total:.1f}, d = {PIN_DIA}, $fn = 16);
- ''' for y_off, z_off in ext_pin_positions)}
- }}
- """
- b5_b7_pieces = [
- ("b5_b7_elbow", elbow_scad),
- ("b5_b7_ext", ext_scad),
- ]
- else:
- # Non-split: single bent bar with pyramidal ridge on B7 arm
- # Ridge starts 17.5mm from J6 and ends 17.5mm from J8
- b7_dx_ns = o_j8x / b7_len_full
- b7_dy_ns = o_j8y / b7_len_full
- rs_x = b7_dx_ns * RIDGE_CLEARANCE # ridge start (17.5mm from J6)
- rs_y = b7_dy_ns * RIDGE_CLEARANCE
- re_x = o_j8x - b7_dx_ns * RIDGE_CLEARANCE # ridge end (17.5mm from J8)
- re_y = o_j8y - b7_dy_ns * RIDGE_CLEARANCE
- ns_has_ridge = b7_len_full > 2 * RIDGE_CLEARANCE + 10
- if ns_has_ridge:
- ns_ridge = f""" // Ridge with {RIDGE_CLEARANCE}mm clearance from J6 and J8
- translate([{rs_x:.2f}, {rs_y:.2f}, 0])
- cylinder(h = {RIDGE_H}, d = {RIDGE_W}, $fn = 24);
- translate([{re_x:.2f}, {re_y:.2f}, 0])
- cylinder(h = {RIDGE_H}, d = {RIDGE_W}, $fn = 24);"""
- else:
- ns_ridge = ""
- b5_b7_scad = _SCAD_PREAMBLE + f"""\
- difference() {{
- union() {{
- // Arm 1: vertex (J6) to J7
- hull() {{
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- translate([{o_j7x:.2f}, {o_j7y:.2f}, 0])
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- }}
- // Arm 2: vertex (J6) to J8 with pyramidal ridge
- hull() {{
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- translate([{o_j8x:.2f}, {o_j8y:.2f}, 0])
- cylinder(h = LINK_THICK, d = LINK_WIDTH, $fn = 48);
- {ns_ridge}
- }}
- }}
- bearing_seat_flat(LINK_THICK, {RIDGE_H});
- translate([{o_j7x:.2f}, {o_j7y:.2f}, 0]) bearing_seat();
- translate([{o_j8x:.2f}, {o_j8y:.2f}, 0]) bearing_seat_flat(LINK_THICK, {RIDGE_H});
- }}
- """
- b5_b7_pieces = [("b5_b7_output", b5_b7_scad)]
- pieces = [
- ("frame", frame_scad),
- ("b0_crank", _SCAD_PREAMBLE + f"straight_bar({crank:.2f});"),
- ("b2_b4_coupler", _SCAD_PREAMBLE +
- f"bent_bar({c_j1x:.2f}, {c_j1y:.2f}, {c_j6x:.2f}, {c_j6y:.2f});"),
- ("b3_rocker", _SCAD_PREAMBLE + f"straight_bar({B3:.2f});"),
- ("b6_rocker", _SCAD_PREAMBLE + f"straight_bar({B6:.2f});"),
- ] + b5_b7_pieces
- for name, scad_content in pieces:
- scad_path = os.path.join(out_dir, f"{ts}_{name}.scad")
- stl_path = os.path.join(out_dir, f"{ts}_{name}.stl")
- with open(scad_path, 'w') as f:
- f.write(scad_content)
- if openscad:
- try:
- result = subprocess.run(
- [openscad, "-o", stl_path, scad_path],
- timeout=120, capture_output=True, text=True
- )
- if result.returncode != 0:
- print(f"OpenSCAD error ({name}): {result.stderr[:200]}")
- elif os.path.exists(stl_path):
- _stl_to_step(stl_path)
- except FileNotFoundError:
- print(f"OpenSCAD not found at: {openscad}")
- openscad = None # stop trying for remaining pieces
- except Exception as e:
- print(f"OpenSCAD failed ({name}): {e}")
- elif not openscad:
- pass # already warned or not installed
- # ============================================================
- # Servo test box — Feetech ST3215-C047
- # ============================================================
- # Servo: 45.4 x 24.7 x 35.0 mm (measured), shaft 2.15mm coupling hole
- # Stall torque: 30 kg.cm (2.94 Nm) @ 12V, gear ratio 1:345
- # TTL serial half-duplex, ~73g
- _SERVO_BOX_SCAD = """\
- // Servo Test Box — Feetech ST3215-C047
- // 5 solid sides (open top only), 10mm vent on each side
- SERVO_L=45.12; SERVO_W=24.45; SERVO_H=35.0; SHAFT_DIA=2.15;
- WALL=3; CL=0.4; VENT=21;
- CL_L=SERVO_L+CL; CL_W=SERVO_W+CL; CL_H=SERVO_H+CL;
- BL=CL_L+2*WALL; BW=CL_W+2*WALL; BH=CL_H+WALL;
- difference() {
- cube([BL, BW, BH]);
- // Cavity (open top only for slide-in)
- translate([WALL,WALL,WALL]) cube([CL_L,CL_W,CL_H+1]);
- // Shaft hole: 22mm dia, 10mm from inside edge, centered in width
- translate([WALL+10.0,WALL+CL_W/2,-0.1])
- cylinder(h=WALL+0.2, d=22, $fn=48);
- // Bottom vent: centered on bottom face
- translate([BL/2,BW/2,-0.1])
- cylinder(h=WALL+0.2, d=VENT, $fn=32);
- // 4 wall vents: drill along X for left/right, along Y for front/back
- // Left wall (X=0)
- translate([-0.1, BW/2, WALL+CL_H/2]) rotate([0,90,0])
- cylinder(h=WALL+0.2, d=VENT, $fn=32);
- // Right wall (X=BL)
- translate([BL+0.1, BW/2, WALL+CL_H/2]) rotate([0,-90,0])
- cylinder(h=WALL+0.2, d=VENT, $fn=32);
- // Front wall (Y=BW)
- translate([BL/2, BW+0.1, WALL+CL_H/2]) rotate([90,0,0])
- cylinder(h=WALL+0.2, d=VENT, $fn=32);
- // Back wall (Y=0)
- translate([BL/2, -0.1, WALL+CL_H/2]) rotate([-90,0,0])
- cylinder(h=WALL+0.2, d=VENT, $fn=32);
- }
- """
- def _save_servo_box():
- import subprocess, shutil
- script_dir = os.path.dirname(os.path.abspath(__file__))
- ts = int(time.time())
- scad_path = os.path.join(script_dir, f"{ts}_servo_box.scad")
- stl_path = os.path.join(script_dir, f"{ts}_servo_box.stl")
- with open(scad_path, 'w') as f:
- f.write(_SERVO_BOX_SCAD)
- openscad = shutil.which("openscad")
- if not openscad:
- for p in ["/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD"]:
- if os.path.isfile(p):
- openscad = p; break
- if openscad:
- try:
- subprocess.run([openscad, "-o", stl_path, scad_path],
- timeout=120, capture_output=True, text=True)
- if os.path.exists(stl_path):
- _stl_to_step(stl_path)
- except Exception:
- pass
- return scad_path, stl_path
- def _save_frame_joined():
- """Generate frame + servo box as a single joined piece.
- The servo box is attached behind the frame (Z<0 side),
- with the servo output shaft concentric with J0 (hole A).
- The frame is extended with gentle arcs to cover the servo box.
- """
- import subprocess, shutil
- # --- Servo box dimensions ---
- SERVO_L = 45.12; SERVO_W = 24.45; SERVO_H = 35.0
- S_WALL = 3.0; S_CL = 0.4; VENT = 21.0
- CL_L = SERVO_L + S_CL; CL_W = SERVO_W + S_CL; CL_H = SERVO_H + S_CL
- BL = CL_L + 2 * S_WALL; BW = CL_W + 2 * S_WALL; BH = CL_H + S_WALL
- # Shaft position in box local coords
- shaft_x = S_WALL + 10.0 # 10mm from inside edge
- shaft_y = S_WALL + CL_W / 2 # centered in width
- # Box origin in frame XY coords (shaft aligned with J0 at origin)
- box_x = -shaft_x
- box_y = -shaft_y
- # --- Frame geometry (same as frame bracket) ---
- j3x = -FRAME_LOW_X; j3y = -FRAME_LOW_Y
- j5x = -FRAME_HIGH_X; j5y = FRAME_HIGH_Y
- hub_d = 19.0 # bearing OD + 3mm wall each side
- mount_y = -20.0; mount1_x = 0.0; mount2_x = -25.0
- nut_af = 7.5; nut_depth = 3.2
- nut_ac = nut_af / math.cos(math.radians(30))
- nut_r = nut_ac / 2
- wall_cl = 3.0
- bot_y = mount_y - nut_r - wall_cl
- bot_left_x = min(j3x, j5x, mount2_x) - max(hub_d/2, nut_r + wall_cl)
- bot_right_x = max(j3x, j5x, mount1_x, box_x + BL) + max(hub_d/2, nut_r + wall_cl)
- # Extend bottom to cover servo box if it goes lower
- bot_y = min(bot_y, box_y - wall_cl)
- # Fillet radius for servo box extension corners
- FILLET = 5.0
- # Total shaft hole depth: from front of frame through to back of servo box
- FRAME_THICK = 10.0
- shaft_hole_depth = FRAME_THICK + BH + 1 # generous through-all
- scad = f"""\
- // ============================================================
- // Frame-Joined: Frame bracket + Servo box (Feetech ST3215)
- // One piece, servo output shaft concentric with J0 (hole A)
- // Frame: Z=0 to Z={FRAME_THICK} (front/bearing side)
- // Servo box: Z=0 to Z=-{BH:.1f} (behind frame, open top at Z=-{BH:.1f})
- // ============================================================
- BEARING_OD = 13.0; BEARING_ID = 4.0; BEARING_W = 5.0;
- BOLT_CLEARANCE = 4.3;
- LINK_THICK = {FRAME_THICK};
- WALL = 3.0; LINK_WIDTH = 19.0;
- POCKET_DEPTH = 5.0; POCKET_DIA = 13.4;
- NUT_RECESS_DEPTH = 3.5; NUT_RECESS_DIA = 8.66;
- module bearing_seat(thick={FRAME_THICK}) {{
- translate([0,0,-0.05]) cylinder(h=thick+0.1, d=BOLT_CLEARANCE, $fn=24);
- translate([0,0,thick-POCKET_DEPTH]) cylinder(h=POCKET_DEPTH+0.05, d=POCKET_DIA, $fn=48);
- translate([0,0,-0.05]) cylinder(h=NUT_RECESS_DEPTH+0.05, d=NUT_RECESS_DIA, $fn=6);
- }}
- HUB_D = {hub_d:.1f};
- FRAME_THICK = {FRAME_THICK};
- FILLET = {FILLET};
- // Servo box params
- BL = {BL:.2f}; BW = {BW:.2f}; BH = {BH:.2f};
- BOX_X = {box_x:.2f}; BOX_Y = {box_y:.2f};
- CL_L = {CL_L:.2f}; CL_W = {CL_W:.2f}; CL_H = {CL_H:.2f};
- S_WALL = {S_WALL};
- VENT = {VENT};
- difference() {{
- union() {{
- // === Frame plate (Z=0 to FRAME_THICK) ===
- // Original frame hull
- linear_extrude(height = FRAME_THICK)
- hull() {{
- circle(d = HUB_D, $fn = 48); // J0
- translate([{j3x:.2f}, {j3y:.2f}]) circle(d = HUB_D, $fn = 48); // J3
- translate([{j5x:.2f}, {j5y:.2f}]) circle(d = HUB_D, $fn = 48); // J5
- translate([{bot_left_x:.2f}, {bot_y:.2f}]) circle(d = 1, $fn = 8);
- translate([{bot_right_x:.2f}, {bot_y:.2f}]) circle(d = 1, $fn = 8);
- }};
- // Extension plate covering servo box footprint (gentle arcs)
- linear_extrude(height = FRAME_THICK)
- hull() {{
- circle(d = HUB_D, $fn = 48); // J0 anchor
- translate([{bot_right_x:.2f}, {bot_y:.2f}]) circle(d = 1, $fn = 8);
- // Servo box corners with fillets
- translate([BOX_X + FILLET, BOX_Y + FILLET])
- circle(r = FILLET, $fn = 32);
- translate([BOX_X + BL - FILLET, BOX_Y + FILLET])
- circle(r = FILLET, $fn = 32);
- translate([BOX_X + FILLET, BOX_Y + BW - FILLET])
- circle(r = FILLET, $fn = 32);
- translate([BOX_X + BL - FILLET, BOX_Y + BW - FILLET])
- circle(r = FILLET, $fn = 32);
- }};
- // === Servo box (Z=0 to -BH, mirrored) ===
- translate([BOX_X, BOX_Y, 0])
- mirror([0, 0, 1])
- cube([BL, BW, BH]);
- }}
- // === Subtractions ===
- // Servo cavity (open at back = Z=-BH)
- // In mirrored coords: cavity starts at local Z=S_WALL, open at local Z=BH
- // World: cavity from Z=-S_WALL to Z=-BH
- translate([BOX_X + S_WALL, BOX_Y + S_WALL, 0])
- mirror([0, 0, 1])
- translate([0, 0, S_WALL])
- cube([CL_L, CL_W, CL_H + 1]);
- // 22mm shaft hole: clean through from front of frame to back of servo box
- translate([0, 0, -{BH + 1:.1f}])
- cylinder(h = {shaft_hole_depth:.1f}, d = 22, $fn = 48);
- // Bottom vent (servo box bottom = world Z=0 face... actually this is the
- // wall between frame and servo. Skip bottom vent on joined piece.)
- // Servo box wall vents (in mirrored Z space)
- // Left wall of servo box (X = BOX_X face)
- translate([BOX_X - 0.1, BOX_Y + BW/2, -(S_WALL + CL_H/2)])
- rotate([0, 90, 0])
- cylinder(h = S_WALL + 0.2, d = VENT, $fn = 32);
- // Right wall of servo box (X = BOX_X + BL face)
- translate([BOX_X + BL + 0.1, BOX_Y + BW/2, -(S_WALL + CL_H/2)])
- rotate([0, -90, 0])
- cylinder(h = S_WALL + 0.2, d = VENT, $fn = 32);
- // Front wall of servo box (Y = BOX_Y + BW face)
- translate([BOX_X + BL/2, BOX_Y + BW + 0.1, -(S_WALL + CL_H/2)])
- rotate([90, 0, 0])
- cylinder(h = S_WALL + 0.2, d = VENT, $fn = 32);
- // Back wall of servo box (Y = BOX_Y face)
- translate([BOX_X + BL/2, BOX_Y - 0.1, -(S_WALL + CL_H/2)])
- rotate([-90, 0, 0])
- cylinder(h = S_WALL + 0.2, d = VENT, $fn = 32);
- // J3: bearing seat (on frame front face Z=0 to FRAME_THICK)
- translate([{j3x:.2f}, {j3y:.2f}, 0]) bearing_seat(FRAME_THICK);
- // J5: bearing seat
- translate([{j5x:.2f}, {j5y:.2f}, 0]) bearing_seat(FRAME_THICK);
- // NOTE: J0 has no bearing seat — the 22mm shaft hole replaces it
- // Chassis mounting holes (Z direction, through frame plate)
- translate([{mount1_x:.2f}, {mount_y:.2f}, -0.05])
- cylinder(h = FRAME_THICK + 0.1, d = BOLT_CLEARANCE, $fn = 24);
- translate([{mount2_x:.2f}, {mount_y:.2f}, -0.05])
- cylinder(h = FRAME_THICK + 0.1, d = BOLT_CLEARANCE, $fn = 24);
- // Hex nut recesses (top face, Z direction)
- translate([{mount1_x:.2f}, {mount_y:.2f}, FRAME_THICK - {nut_depth}])
- cylinder(h = {nut_depth + 0.05}, d = {nut_ac:.2f}, $fn = 6);
- translate([{mount2_x:.2f}, {mount_y:.2f}, FRAME_THICK - {nut_depth}])
- cylinder(h = {nut_depth + 0.05}, d = {nut_ac:.2f}, $fn = 6);
- }}
- """
- script_dir = os.path.dirname(os.path.abspath(__file__))
- ts = int(time.time())
- scad_path = os.path.join(script_dir, f"{ts}_frame_joined.scad")
- stl_path = os.path.join(script_dir, f"{ts}_frame_joined.stl")
- with open(scad_path, 'w') as f:
- f.write(scad)
- openscad = shutil.which("openscad")
- if not openscad:
- for p in ["/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD"]:
- if os.path.isfile(p):
- openscad = p; break
- if openscad:
- try:
- result = subprocess.run([openscad, "-o", stl_path, scad_path],
- timeout=120, capture_output=True, text=True)
- if result.returncode != 0:
- print(f"OpenSCAD error (frame_joined): {result.stderr[:300]}")
- elif os.path.exists(stl_path):
- _stl_to_step(stl_path)
- except Exception as e:
- print(f"OpenSCAD failed (frame_joined): {e}")
- else:
- print("WARNING: openscad not found, .stl skipped")
- return scad_path, stl_path
- def _save_base_plate():
- """Generate the base plate — separate piece that bolts to frame-joined bottom.
- Contains the second servo box (hip rotation) embedded inside.
- Connects to frame-joined via 2 M4 bolts in Y direction.
- """
- import subprocess, shutil
- # --- Recompute frame bottom geometry (must match frame-joined) ---
- j3x = -FRAME_LOW_X; j3y = -FRAME_LOW_Y
- j5x = -FRAME_HIGH_X; j5y = FRAME_HIGH_Y
- hub_d = 19.0
- mount_y = -20.0; mount1_x = 0.0; mount2_x = -25.0
- nut_af = 7.5; nut_depth = 3.2
- nut_ac = nut_af / math.cos(math.radians(30))
- nut_r = nut_ac / 2
- wall_cl = 3.0
- bot_y = mount_y - nut_r - wall_cl # ~-26.75
- # Servo box 1 position (to compute right edge)
- S_WALL = 3.0; S_CL = 0.4; VENT = 21.0
- SERVO_L = 45.12; SERVO_W = 24.45; SERVO_H = 35.0
- CL_L = SERVO_L + S_CL; CL_W = SERVO_W + S_CL; CL_H = SERVO_H + S_CL
- BL = CL_L + 2 * S_WALL; BW = CL_W + 2 * S_WALL; BH = CL_H + S_WALL
- shaft_x = S_WALL + 10.0
- shaft_y = S_WALL + CL_W / 2
- box1_x = -shaft_x
- FILLET = 5.0
- bot_left_x = min(j3x, j5x, mount2_x) - max(hub_d/2, nut_r + wall_cl)
- bot_right_x = max(j3x, j5x, mount1_x, box1_x + BL) + max(hub_d/2, nut_r + wall_cl)
- bot_y = min(bot_y, -shaft_y - wall_cl)
- # --- Base plate dimensions ---
- BASE_THICK = 12.0 # thickness in Y
- BASE_CLEARANCE = 0.05 # gap between frame bottom and base top
- base_top_y = bot_y - BASE_CLEARANCE
- base_bot_y = base_top_y - BASE_THICK
- BASE_Z_START = 0.0 # flush with frame back face
- BASE_Z_END = -75.0 # extends 75mm behind frame
- FRAME_THICK = 10.0
- # Base spans same X width as frame
- base_left_x = bot_left_x
- base_right_x = bot_right_x
- # --- Second servo box: use OG perfected geometry, rotated into position ---
- # OG box: X=0..BL, Y=0..BW, Z=0..BH
- # Open top at Z=BH, shaft hole at Z=0 at (WALL+10, BW/2)
- #
- # rotate([90,0,0]): X→X, Y→-Z, Z→+Y
- # Result: X=0..BL, Z=-BW..0, Y=0..BH
- # Open top at Y=BH ✓, shaft at Y=0 (bottom) ✓
- # Shaft at (WALL+10, 0, -BW/2) in rotated coords
- #
- # Then translate to position:
- # X: flush left = base_left_x
- # Y: box bottom on base top = base_top_y
- # Z: center box on base = BASE_Z_END/2 + BW/2
- s2_tx = base_left_x
- s2_ty = base_top_y
- s2_tz = BASE_Z_END / 2 + BW / 2
- # Shaft position in world coords after transform:
- # OG shaft at (WALL+10, BW/2, 0) → rotate([90,0,0]) → (WALL+10, 0, -BW/2)
- # → translate → (base_left_x + WALL+10, base_top_y, BASE_Z_END/2)
- servo2_shaft_x = s2_tx + S_WALL + 10.0
- servo2_shaft_y = s2_ty # at box bottom = base top
- servo2_shaft_z = BASE_Z_END / 2.0
- scad = f"""\
- // ============================================================
- // Base Plate — bolts to frame-joined bottom
- // Second servo: OG perfected box, rotated 90° around X
- // Open top +Y (drop servo in from above), shaft through -Y (base bottom)
- // ============================================================
- BOLT_CLEARANCE = 4.3;
- NUT_RECESS_DEPTH = {nut_depth};
- NUT_RECESS_DIA = {nut_ac:.2f};
- // --- OG Servo Box as module ---
- SERVO_L=45.12; SERVO_W=24.45; SERVO_H=35.0;
- S_WALL=3; S_CL=0.4; VENT=21;
- S_CL_L=SERVO_L+S_CL; S_CL_W=SERVO_W+S_CL; S_CL_H=SERVO_H+S_CL;
- S_BL=S_CL_L+2*S_WALL; S_BW=S_CL_W+2*S_WALL; S_BH=S_CL_H+S_WALL;
- module og_servo_box() {{
- difference() {{
- cube([S_BL, S_BW, S_BH]);
- translate([S_WALL,S_WALL,S_WALL]) cube([S_CL_L,S_CL_W,S_CL_H+1]);
- translate([S_WALL+10.0,S_WALL+S_CL_W/2,-0.1])
- cylinder(h=S_WALL+0.2, d=22, $fn=48);
- translate([S_BL/2,S_BW/2,-0.1])
- cylinder(h=S_WALL+0.2, d=VENT, $fn=32);
- translate([-0.1, S_BW/2, S_WALL+S_CL_H/2]) rotate([0,90,0])
- cylinder(h=S_WALL+0.2, d=VENT, $fn=32);
- translate([S_BL+0.1, S_BW/2, S_WALL+S_CL_H/2]) rotate([0,-90,0])
- cylinder(h=S_WALL+0.2, d=VENT, $fn=32);
- translate([S_BL/2, S_BW+0.1, S_WALL+S_CL_H/2]) rotate([90,0,0])
- cylinder(h=S_WALL+0.2, d=VENT, $fn=32);
- translate([S_BL/2, -0.1, S_WALL+S_CL_H/2]) rotate([-90,0,0])
- cylinder(h=S_WALL+0.2, d=VENT, $fn=32);
- }}
- }}
- difference() {{
- union() {{
- // Base plate slab
- translate([{base_left_x:.2f}, {base_bot_y:.2f}, {BASE_Z_END}])
- cube([{base_right_x - base_left_x:.2f}, {BASE_THICK}, {-BASE_Z_END:.2f}]);
- // OG servo box, rotated: open top→+Y, shaft→-Y
- translate([{s2_tx:.2f}, {s2_ty:.2f}, {s2_tz:.2f}])
- rotate([90, 0, 0])
- og_servo_box();
- }}
- // 22mm shaft hole clean through base plate (below servo box)
- translate([{servo2_shaft_x:.2f}, {base_bot_y - 1:.2f}, {servo2_shaft_z:.2f}])
- rotate([-90, 0, 0])
- cylinder(h = {BASE_THICK + 2:.1f}, d = 22, $fn = 48);
- // --- Bolt holes (Y direction, bolts come from frame-joined above) ---
- translate([{mount1_x:.2f}, {base_top_y + 0.1:.2f}, {FRAME_THICK / 2:.1f}])
- rotate([90, 0, 0])
- cylinder(h = {BASE_THICK + 0.2}, d = BOLT_CLEARANCE, $fn = 24);
- translate([{mount2_x:.2f}, {base_top_y + 0.1:.2f}, {FRAME_THICK / 2:.1f}])
- rotate([90, 0, 0])
- cylinder(h = {BASE_THICK + 0.2}, d = BOLT_CLEARANCE, $fn = 24);
- // Nut recesses on base TOP face (where bolts enter from frame above)
- translate([{mount1_x:.2f}, {base_top_y + 0.05:.2f}, {FRAME_THICK / 2:.1f}])
- rotate([90, 0, 0])
- cylinder(h = {nut_depth + 0.05}, d = NUT_RECESS_DIA, $fn = 6);
- translate([{mount2_x:.2f}, {base_top_y + 0.05:.2f}, {FRAME_THICK / 2:.1f}])
- rotate([90, 0, 0])
- cylinder(h = {nut_depth + 0.05}, d = NUT_RECESS_DIA, $fn = 6);
- }}
- """
- script_dir = os.path.dirname(os.path.abspath(__file__))
- ts = int(time.time())
- scad_path = os.path.join(script_dir, f"{ts}_base_plate.scad")
- stl_path = os.path.join(script_dir, f"{ts}_base_plate.stl")
- with open(scad_path, 'w') as f:
- f.write(scad)
- openscad = shutil.which("openscad")
- if not openscad:
- for p in ["/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD"]:
- if os.path.isfile(p):
- openscad = p; break
- if openscad:
- try:
- result = subprocess.run([openscad, "-o", stl_path, scad_path],
- timeout=120, capture_output=True, text=True)
- if result.returncode != 0:
- print(f"OpenSCAD error (base_plate): {result.stderr[:300]}")
- elif os.path.exists(stl_path):
- _stl_to_step(stl_path)
- except Exception as e:
- print(f"OpenSCAD failed (base_plate): {e}")
- else:
- print("WARNING: openscad not found, .stl skipped")
- return scad_path, stl_path
- def _save_profile_png(png_path, foot_path, ground_pts,
- step_length_mm, step_height_mm, stride_px,
- mn_x, mx_x, mn_y, mx_y, params, ts, max_torque):
- """Render step profile to a PNG using pygame."""
- W, H = 800, 600
- BG_PNG = (15, 18, 25)
- surf = pygame.Surface((W, H))
- surf.fill(BG_PNG)
- # Use pygame's built-in default font with background color for clean PNG export
- pfont = pygame.font.Font(None, 24)
- sfont = pygame.font.Font(None, 20)
- def txt(font, text, color, pos):
- surf.blit(font.render(text, True, color, BG_PNG), pos)
- # Title
- txt(pfont, f"Klann Step Profile - {ts}", (140, 170, 210), (20, 15))
- # Draw foot path centered in a box
- margin = 80
- plot_x, plot_y = margin, 60
- plot_w, plot_h = W - 2 * margin, 280
- if not foot_path or (mx_x - mn_x) < 1 or (mx_y - mn_y) < 1:
- txt(sfont, "No valid foot path", (200, 80, 80), (plot_x, plot_y + 50))
- pygame.image.save(surf, png_path)
- return
- path_w = mx_x - mn_x
- path_h = mx_y - mn_y
- scale = min(plot_w / path_w, plot_h / path_h) * 0.80
- cx = plot_x + plot_w / 2
- cy = plot_y + plot_h / 2
- center_x = (mn_x + mx_x) / 2
- center_y = (mn_y + mx_y) / 2
- def pt(p):
- return (int(cx + (p[0] - center_x) * scale),
- int(cy + (p[1] - center_y) * scale))
- # Grid lines
- for gx in range(plot_x, plot_x + plot_w, 40):
- pygame.draw.line(surf, (25, 30, 40), (gx, plot_y), (gx, plot_y + plot_h), 1)
- for gy in range(plot_y, plot_y + plot_h, 40):
- pygame.draw.line(surf, (25, 30, 40), (plot_x, gy), (plot_x + plot_w, gy), 1)
- # Draw foot path (don't close loop — actual path is a time series, not a closed curve)
- n = len(foot_path)
- for i in range(1, n):
- f = i / n
- col = (int(220 * f), int(160 * f), int(40 * f))
- p1 = pt(foot_path[i - 1])
- p2 = pt(foot_path[i])
- pygame.draw.line(surf, col, p1, p2, 2)
- # Highlight ground contact
- if ground_pts:
- g_sorted = sorted(ground_pts, key=lambda p: p[0])
- for i in range(1, len(g_sorted)):
- p1 = pt(g_sorted[i - 1])
- p2 = pt(g_sorted[i])
- pygame.draw.line(surf, (100, 220, 100), p1, p2, 4)
- # Dimension lines
- # Step length (horizontal, along ground)
- gnd_screen_y = cy + (mx_y - center_y) * scale
- dim_y = int(gnd_screen_y + 25)
- if ground_pts:
- gxs = [p[0] for p in ground_pts]
- gl = pt((min(gxs), mx_y))
- gr = pt((max(gxs), mx_y))
- else:
- gl = pt((mn_x, mx_y))
- gr = pt((mx_x, mx_y))
- pygame.draw.line(surf, (100, 220, 100), (gl[0], dim_y), (gr[0], dim_y), 1)
- for dx in [gl[0], gr[0]]:
- pygame.draw.line(surf, (100, 220, 100), (dx, dim_y - 5), (dx, dim_y + 5), 1)
- pygame.draw.line(surf, (50, 100, 50), (dx, int(gnd_screen_y)), (dx, dim_y), 1)
- length_label = sfont.render(f"step length: {step_length_mm:.1f} mm", True, (100, 220, 100), BG_PNG)
- surf.blit(length_label, ((gl[0] + gr[0]) // 2 - length_label.get_width() // 2, dim_y + 8))
- # Step height (vertical)
- top_screen_y = cy + (mn_y - center_y) * scale
- bot_screen_y = gnd_screen_y
- dim_x = int(cx + (mx_x - center_x) * scale + 25)
- pygame.draw.line(surf, (220, 160, 40), (dim_x, int(top_screen_y)), (dim_x, int(bot_screen_y)), 1)
- for dy in [int(top_screen_y), int(bot_screen_y)]:
- pygame.draw.line(surf, (220, 160, 40), (dim_x - 5, dy), (dim_x + 5, dy), 1)
- height_label = sfont.render(f"step height: {step_height_mm:.1f} mm", True, (220, 160, 40), BG_PNG)
- surf.blit(height_label, (dim_x + 8, int((top_screen_y + bot_screen_y) / 2) - 6))
- # Stats text block
- ty = plot_y + plot_h + 30
- stats = [
- f"Step Length: {step_length_mm:.1f} mm",
- f"Step Height: {step_height_mm:.1f} mm",
- f"Stride Width: {stride_px:.1f} mm",
- f"Max Torque: {max_torque:.3f} Nm (3 cyc)",
- f"Recommended: {max_torque * 2.85:.2f} Nm",
- ]
- colors = [(100, 220, 100), (220, 160, 40), (140, 170, 210), (180, 140, 220), (220, 170, 80)]
- for line, col in zip(stats, colors):
- txt(sfont, line, col, (40, ty))
- ty += 22
- # Parameters block
- ty += 8
- txt(pfont, "Parameters", (120, 140, 170), (40, ty))
- ty += 24
- param_lines = [
- f"crank={params['crank']:.1f} B2={params['B2']:.1f} B3={params['B3']:.1f} B4={params['B4']:.1f}",
- f"B5={params['B5']:.1f} B6={params['B6_bar']:.1f} B7={params['B7']:.1f}",
- f"B6_angle={params['B6_angle']:.1f} B7_angle={params['B7_angle']:.1f}",
- ]
- for line in param_lines:
- txt(sfont, line, (100, 110, 125), (40, ty))
- ty += 20
- pygame.image.save(surf, png_path)
- # ============================================================
- # Drawing helpers
- # ============================================================
- def ipx(pt, ox, oy):
- return (int(pt[0]+ox), int(pt[1]+oy))
- def tl(surf, col, p1, p2, w):
- pygame.draw.line(surf, col, (int(p1[0]),int(p1[1])), (int(p2[0]),int(p2[1])), w)
- def jnt(surf, col, pos, r):
- pygame.draw.circle(surf, col, (int(pos[0]),int(pos[1])), r)
- if r > 3:
- pygame.draw.circle(surf, (220,225,235), (int(pos[0]),int(pos[1])), r-2, 1)
- def dc(col, f):
- return tuple(min(255,int(v*f)) for v in col)
- def draw_leg(surf, j, ox, oy, near=True, highlight_segs=None, blink_alpha=1.0):
- """Draw one Klann leg with all linkage bars.
- highlight_segs: list of (key1, key2) segment pairs to blink safety orange.
- blink_alpha: 0.0-1.0 pulse for the blink effect.
- """
- if j is None:
- return
- f = 1.0 if near else 0.65
- lc = dc(LEG_COLOR, f)
- cc = dc(CRANK_COLOR, f)
- lk = dc(LINK_COLOR, f)
- fc = dc(FOOT_COLOR, f)
- jc = dc(JOINT_COLOR, f)
- tw = 1 if near else 0
- # Build set of highlighted segments for fast lookup
- hl = set()
- if highlight_segs and near:
- for a, b in highlight_segs:
- hl.add((a, b))
- hl.add((b, a))
- def is_hl(k1, k2):
- return (k1, k2) in hl
- def hl_col(base_col, k1, k2, extra_w=0):
- """Return color and width boost for a segment, blinking if highlighted."""
- if is_hl(k1, k2):
- # Lerp between base color and safety orange based on blink_alpha
- r = int(base_col[0] + (SAFETY_ORANGE[0] - base_col[0]) * blink_alpha)
- g = int(base_col[1] + (SAFETY_ORANGE[1] - base_col[1]) * blink_alpha)
- b = int(base_col[2] + (SAFETY_ORANGE[2] - base_col[2]) * blink_alpha)
- return (r, g, b), extra_w + 2
- return base_col, extra_w
- def p(key): return ipx(j[key], ox, oy)
- # Frame struts (fixed pivot connections)
- tl(surf, dc(BODY_DARK, f), p("O"), p("P_low"), 3+tw)
- tl(surf, dc(BODY_DARK, f), p("O"), p("P_high"), 3+tw)
- # Crank O→C (J0→J1)
- col, ew = hl_col(cc, "O", "C")
- tl(surf, col, p("O"), p("C"), 3+tw+ew)
- # B2: C→D (J1→J4)
- col, ew = hl_col(lk, "C", "D")
- tl(surf, col, p("C"), p("D"), 4+tw+ew)
- # B3: P_low→D (J3→J4)
- col, ew = hl_col(lk, "P_low", "D")
- tl(surf, col, p("P_low"), p("D"), 3+tw+ew)
- # B4: D→E rigid bent bar (J4→J6)
- col, ew = hl_col(lc, "D", "E")
- tl(surf, col, p("D"), p("E"), 5+tw+ew)
- # B6: P_high→G (J5→J7)
- col, ew = hl_col(lk, "P_high", "G")
- tl(surf, col, p("P_high"), p("G"), 3+tw+ew)
- # B5: E→G (J6→J7)
- col, ew = hl_col(lk, "E", "G")
- tl(surf, col, p("E"), p("G"), 3+tw+ew)
- # B7: E→F rigid bent bar to foot (J6→J8)
- col, ew = hl_col(lc, "E", "F")
- tl(surf, col, p("E"), p("F"), 6+tw+ew)
- # Foot claws at F (J8)
- ft = j["F"]
- claw_len = 12 if near else 8
- for sign in [-1, 1]:
- cx2 = ft[0] + sign * 5
- cy2 = ft[1]
- tx2 = cx2 + sign * 3
- ty2 = cy2 + claw_len * 0.7
- tl(surf, fc,
- (int(cx2+ox), int(cy2+oy)),
- (int(tx2+ox), int(ty2+oy)), 2+tw)
- # Joints — highlight endpoints of highlighted segments
- jr = 5 if near else 3
- for key, r in [("O",jr+2),("P_low",jr),("P_high",jr),("C",jr-1),
- ("D",jr+1),("E",jr),("G",jr),("F",jr+1)]:
- jcol = jc
- if any(key in seg for seg in hl):
- cr = int(jc[0] + (SAFETY_ORANGE[0] - jc[0]) * blink_alpha)
- cg = int(jc[1] + (SAFETY_ORANGE[1] - jc[1]) * blink_alpha)
- cb = int(jc[2] + (SAFETY_ORANGE[2] - jc[2]) * blink_alpha)
- jcol = (cr, cg, cb)
- jnt(surf, jcol, p(key), r)
- def draw_body(surf, front_x, rear_x, body_y):
- """Draw quadruped body."""
- bw = int(front_x - rear_x)
- bh = 60
- bx = int(rear_x)
- by = int(body_y) - bh - 10
- pygame.draw.rect(surf, (20,25,35), (bx+3,by+3,bw,bh), border_radius=6)
- pygame.draw.rect(surf, BODY_COLOR, (bx,by,bw,bh), border_radius=5)
- pygame.draw.rect(surf, BODY_DARK, (bx,by,bw,bh), 2, border_radius=5)
- strip_w = bw - 30
- if strip_w > 20:
- pygame.draw.rect(surf, (38,52,72), (bx+15, by+12, strip_w, 18), border_radius=3)
- vp_count = max(1, strip_w // 40)
- vp_w = min(22, (strip_w - 10) // vp_count - 4)
- for i in range(vp_count):
- vx = bx + 19 + i * (vp_w + 6)
- pygame.draw.rect(surf, (55,95,125), (vx, by+14, vp_w, 14), border_radius=2)
- pygame.draw.rect(surf, BODY_DARK, (bx-12, by+14, 12, 30), border_radius=3)
- pygame.draw.rect(surf, BODY_DARK, (bx+bw, by+14, 12, 30), border_radius=3)
- pygame.draw.rect(surf, BODY_DARK, (bx+bw-5, by+bh-18, 22, 12), border_radius=2)
- pygame.draw.rect(surf, (50,58,68), (bx+bw+15, by+bh-16, 10, 4), border_radius=1)
- pygame.draw.rect(surf, (50,58,68), (bx+bw+15, by+bh-10, 10, 4), border_radius=1)
- pygame.draw.rect(surf, BODY_DARK, (bx+20, by-5, bw-40, 7), border_radius=2)
- cx = bx + bw // 2
- pygame.draw.rect(surf, (62,70,82), (cx-14, by+bh, 28, 14), border_radius=3)
- def draw_ground(surf, gy):
- pygame.draw.line(surf, GROUND_COLOR, (0,gy), (WIDTH,gy), 2)
- for x in range(0, WIDTH, 18):
- pygame.draw.line(surf, (30,38,48), (x,gy), (x+9,gy+7), 1)
- def draw_trace(surf, trace, color):
- n = len(trace)
- if n < 2:
- return
- for i in range(1, n):
- f = i / n
- dim = tuple(int(c * f) for c in color)
- p1 = (int(trace[i-1][0]), int(trace[i-1][1]))
- p2 = (int(trace[i][0]), int(trace[i][1]))
- pygame.draw.line(surf, dim, p1, p2, 1)
- # ============================================================
- # Slider UI
- # ============================================================
- def draw_slider(surf, idx, val):
- key, label, mn, mx = SLIDERS[idx]
- sy = SLIDER_START + idx * SLIDER_STEP
- surf.blit(font_sm.render(label, True, TEXT_DIM), (SLIDER_X, sy-13))
- if "angle" in key:
- disp = f"{val:.1f}°"
- elif key == "speed":
- rpm = (val / 1000.0) * 60.0 / (2.0 * math.pi) * 60.0
- disp = f"{val:.0f} ({rpm:.1f}rpm)"
- else:
- disp = f"{val:.1f}"
- surf.blit(font_sm.render(disp, True, TEXT_COLOR), (SLIDER_X+SLIDER_W+8, sy-4))
- pygame.draw.rect(surf, SLIDER_BG, (SLIDER_X, sy, SLIDER_W, SLIDER_H), border_radius=3)
- fw = int((val-mn)/(mx-mn)*SLIDER_W)
- if fw > 0:
- pygame.draw.rect(surf, SLIDER_FG, (SLIDER_X, sy, fw, SLIDER_H), border_radius=3)
- hx = SLIDER_X + fw
- pygame.draw.circle(surf, SLIDER_HAND, (hx, sy+SLIDER_H//2), 8)
- pygame.draw.circle(surf, (220,235,250), (hx, sy+SLIDER_H//2), 4)
- def slider_rect(idx):
- sy = SLIDER_START + idx * SLIDER_STEP
- return pygame.Rect(SLIDER_X-8, sy-8, SLIDER_W+16, SLIDER_H+16)
- def slider_val(idx, mx_pos):
- key, label, mn, mx = SLIDERS[idx]
- t = max(0.0, min(1.0, (mx_pos - SLIDER_X) / SLIDER_W))
- return mn + t*(mx-mn)
- # ============================================================
- # Layout
- # ============================================================
- GROUND_Y = 650
- BODY_CX = 560
- HIP_SHOULDER_PX = 16.0 * PX_PER_INCH # 320 px
- angle = 0.0
- running = True
- while running:
- dt = clock.tick(60) / 1000.0
- for event in pygame.event.get():
- if event.type == pygame.QUIT:
- running = False
- if event.type == pygame.KEYDOWN:
- if event.key == pygame.K_ESCAPE:
- running = False
- if event.key == pygame.K_t:
- show_traces = not show_traces
- trace_front.clear()
- trace_rear.clear()
- if event.type == pygame.MOUSEBUTTONDOWN:
- for i in range(len(SLIDERS)):
- if slider_rect(i).collidepoint(event.pos):
- dragging = i
- params[SLIDERS[i][0]] = slider_val(i, event.pos[0])
- btn_y = SLIDER_START+len(SLIDERS)*SLIDER_STEP+6
- reset_r = pygame.Rect(SLIDER_X, btn_y, 55, 22)
- reset2_r = pygame.Rect(SLIDER_X + 61, btn_y, 55, 22)
- reset3_r = pygame.Rect(SLIDER_X + 122, btn_y, 55, 22)
- for btn_rect, btn_params in [(reset_r, DEFAULT_PARAMS), (reset2_r, PRESET2_PARAMS), (reset3_r, PRESET3_PARAMS)]:
- if btn_rect.collidepoint(event.pos):
- params = dict(btn_params)
- trace_front.clear()
- trace_rear.clear()
- actual_foot_path.clear()
- torque_history.clear()
- prev_foot_pos = None
- prev_foot_vel = None
- max_torque_3cyc = 0.0
- save_btn_y = btn_y + 30
- btn_defs = [
- (SLIDER_X, 44, "STL", lambda: None),
- (SLIDER_X+47, 44, "SERVO", lambda: _save_servo_box()),
- (SLIDER_X+94, 44, "JOIN", lambda: _save_frame_joined()),
- (SLIDER_X+141, 44, "BASE", lambda: _save_base_plate()),
- ]
- for bx, bw, label, action in btn_defs:
- br = pygame.Rect(bx, save_btn_y, bw, 22)
- if br.collidepoint(event.pos):
- if label == "STL":
- filepath = save_geometry(params, actual_foot_path, max_torque_3cyc)
- base = os.path.basename(filepath).replace('.json', '')
- save_flash_msg = f"{base}"
- else:
- scad, stl = action()
- save_flash_msg = f"{label}: {os.path.basename(stl)}"
- save_flash_timer = 120
- if event.type == pygame.MOUSEBUTTONUP:
- dragging = None
- if event.type == pygame.MOUSEMOTION and dragging is not None:
- params[SLIDERS[dragging][0]] = slider_val(dragging, event.pos[0])
- walk_spd = params["speed"] / 1000.0
- angle += walk_spd
- steps_per_rot = max(60, int(2.0 * math.pi / max(walk_spd, 0.001)))
- linkage_p = {k: v for k, v in params.items() if k != "speed"}
- # Solve all 4 legs (diagonal gait: phase A and phase B at pi offset)
- ja = solve_klann(angle, linkage_p)
- jb = solve_klann(angle + math.pi, linkage_p)
- if ja is None or jb is None:
- angle += 0.05
- continue
- # Ground pinning: lowest foot F touches ground
- foot_ys = [ja["F"][1], jb["F"][1]]
- max_foot_y = max(foot_ys)
- oy_base = GROUND_Y - max_foot_y
- front_hip_x = BODY_CX + HIP_SHOULDER_PX / 2
- rear_hip_x = BODY_CX - HIP_SHOULDER_PX / 2
- a_stance = ja["F"][1] >= jb["F"][1]
- def foot_screen(joints, hip_x, oy):
- ft = joints["F"]
- return (ft[0] + hip_x, ft[1] + oy)
- ft_front_near = foot_screen(ja, front_hip_x, oy_base)
- ft_rear_near = foot_screen(jb, rear_hip_x, oy_base)
- if show_traces:
- trace_front.append(ft_front_near)
- trace_rear.append(ft_rear_near)
- if len(trace_front) > TRACE_LEN:
- trace_front.pop(0)
- if len(trace_rear) > TRACE_LEN:
- trace_rear.pop(0)
- # Foot path metrics
- foot_path = compute_foot_path(linkage_p)
- stride_px, height_px, _, _, _, _, ground_px, ground_pts, foot_strike = path_metrics(foot_path)
- # Linkage coordinates are mm — convert to inches properly
- MM_PER_INCH = 25.4
- stride_mm = stride_px # these are already mm
- height_mm = height_px
- ground_mm = ground_px
- stride_in = stride_mm / MM_PER_INCH
- height_in = height_mm / MM_PER_INCH
- ground_in = ground_mm / MM_PER_INCH
- # --- Torque estimation ---
- # Convert pixel positions to meters for physics
- crank_m = params["crank"] / PX_PER_M
- # Foot position relative to crank center (in meters)
- foot_rel_x = ja["F"][0] / PX_PER_M # relative to O, screen coords
- foot_rel_y = ja["F"][1] / PX_PER_M
- # Numerical velocity and acceleration of foot (m/s, m/s^2)
- cur_foot = (ja["F"][0] / PX_PER_M, ja["F"][1] / PX_PER_M)
- foot_vel = (0.0, 0.0)
- foot_accel = (0.0, 0.0)
- effective_dt = dt if dt > 0 else 1.0/60.0
- if prev_foot_pos is not None:
- foot_vel = ((cur_foot[0] - prev_foot_pos[0]) / effective_dt,
- (cur_foot[1] - prev_foot_pos[1]) / effective_dt)
- if prev_foot_vel is not None:
- foot_accel = ((foot_vel[0] - prev_foot_vel[0]) / effective_dt,
- (foot_vel[1] - prev_foot_vel[1]) / effective_dt)
- prev_foot_pos = cur_foot
- prev_foot_vel = foot_vel
- # Torque components:
- # 1) Gravity: body weight distributed across 2 stance legs.
- # Torque = (load per leg) * g * horizontal offset of foot from crank * (crank_m / foot_lever)
- # Simplified: use virtual work — crank torque = F_foot · (d_foot/d_theta) projected
- # We use the simpler static approach: T_gravity = (mass_share * g) * |foot_horizontal_offset|
- # scaled by mechanical advantage (crank / foot_lever_arm)
- foot_lever = math.hypot(foot_rel_x, foot_rel_y)
- foot_lever = max(foot_lever, 0.001)
- # Each stance leg supports half the total weight (diagonal gait = 2 legs on ground)
- weight_per_leg = (TOTAL_MASS * GRAVITY) / 2.0
- # Gravity torque on crank: T = F * r_foot * (r_crank / r_foot) via virtual work
- # More precisely: T_crank = F_vertical * dx_foot/dtheta
- # Approximate dx_foot/dtheta ≈ foot_vel_x / omega, omega = walk_spd / dt_per_frame...
- # Simpler static model: T = weight * horizontal_arm * (crank/lever) ratio
- horizontal_arm_m = abs(foot_rel_x)
- mech_advantage = crank_m / foot_lever if foot_lever > 0 else 1.0
- torque_gravity = weight_per_leg * horizontal_arm_m * mech_advantage
- # 2) Joint friction: sum over ~7 revolute joints per leg, each with bearing friction
- # T_friction_per_joint ≈ μ * F_joint * r_pin
- # Approximate F_joint ≈ weight_per_leg for loaded joints
- n_loaded_joints = 5 # joints under significant load in stance
- torque_friction = n_loaded_joints * MU_JOINT * weight_per_leg * JOINT_RAD_M * (1.0 / mech_advantage)
- # 3) Inertia: T = I_leg * alpha, approximated as m_leg * a_foot * crank_m
- accel_mag = math.hypot(foot_accel[0], foot_accel[1])
- torque_inertia = LEG_MASS * accel_mag * crank_m
- # Total instantaneous crank torque (Nm) for one leg
- # In diagonal gait, 2 legs are in stance simultaneously, but they share one crank
- # so total crank torque ≈ 2 * single leg torque
- instant_torque = 2.0 * (torque_gravity + torque_friction) + torque_inertia
- # Clamp spikes from numerical differentiation on first few frames
- instant_torque = min(instant_torque, 5.0)
- torque_history.append(instant_torque)
- while len(torque_history) > steps_per_rot * 3:
- torque_history.pop(0)
- max_torque_3cyc = max(torque_history) if torque_history else 0.0
- # --- Draw ---
- screen.fill(BG)
- for gx in range(0, WIDTH, 40):
- pygame.draw.line(screen, GRID_COLOR, (gx,0), (gx,HEIGHT), 1)
- for gy in range(0, HEIGHT, 40):
- pygame.draw.line(screen, GRID_COLOR, (0,gy), (WIDTH,gy), 1)
- draw_ground(screen, GROUND_Y)
- shadow_w = int(HIP_SHOULDER_PX) + 60
- pygame.draw.ellipse(screen, (22,28,38),
- (int(BODY_CX - shadow_w//2), GROUND_Y-5, shadow_w, 12))
- # Collect actual foot path of front-near leg (keep 3 full rotations)
- actual_foot_path.append(ft_front_near)
- while len(actual_foot_path) > steps_per_rot * 3:
- actual_foot_path.pop(0)
- if show_traces:
- draw_trace(screen, trace_front, TRACE_NEAR)
- draw_trace(screen, trace_rear, TRACE_FAR)
- # Compute blink for highlighted segment
- hl_segs = None
- blink_alpha = 0.0
- if dragging is not None:
- param_key = SLIDERS[dragging][0]
- hl_segs = SLIDER_SEGMENTS.get(param_key, [])
- if hl_segs:
- blink_alpha = 0.5 + 0.5 * math.sin(angle * 12.0)
- # Far-side legs
- draw_leg(screen, jb, front_hip_x, oy_base, near=False)
- draw_leg(screen, ja, rear_hip_x, oy_base, near=False)
- # Body
- draw_body(screen, front_hip_x, rear_hip_x, oy_base)
- # Near-side legs (with highlight)
- draw_leg(screen, ja, front_hip_x, oy_base, near=True,
- highlight_segs=hl_segs, blink_alpha=blink_alpha)
- draw_leg(screen, jb, rear_hip_x, oy_base, near=True,
- highlight_segs=hl_segs, blink_alpha=blink_alpha)
- # --- UI Panel ---
- panel_h = len(SLIDERS) * SLIDER_STEP + 100
- panel = pygame.Surface((250, panel_h), pygame.SRCALPHA)
- panel.fill((18, 22, 32, 210))
- screen.blit(panel, (12, SLIDER_START - 30))
- screen.blit(font_bold.render("KLANN PATENT LINKAGE", True, (130,165,200)),
- (SLIDER_X, SLIDER_START - 22))
- for i in range(len(SLIDERS)):
- draw_slider(screen, i, params[SLIDERS[i][0]])
- btn_y = SLIDER_START+len(SLIDERS)*SLIDER_STEP+6
- for bx, bw, label, prm in [
- (SLIDER_X, 55, "RST", DEFAULT_PARAMS),
- (SLIDER_X + 61, 55, "RST2", PRESET2_PARAMS),
- (SLIDER_X + 122, 55, "RST3", PRESET3_PARAMS),
- ]:
- br = pygame.Rect(bx, btn_y, bw, 22)
- pygame.draw.rect(screen, (45,55,70), br, border_radius=4)
- pygame.draw.rect(screen, (75,95,115), br, 1, border_radius=4)
- lbl_w = font_sm.size(label)[0]
- screen.blit(font_sm.render(label, True, TEXT_COLOR), (bx + (bw - lbl_w)//2, btn_y+5))
- # Save button
- save_btn_y = btn_y + 30
- for bx, bw, label, bg, border, fg in [
- (SLIDER_X, 44, "STL", (40,55,45), (70,110,80), (140,200,150)),
- (SLIDER_X+47, 44, "SERVO", (65,45,25), (140,100,50), (230,160,70)),
- (SLIDER_X+94, 44, "JOIN", (35,35,60), (80,80,140), (150,150,230)),
- (SLIDER_X+141, 44, "BASE", (45,55,35), (100,130,60), (170,210,120)),
- ]:
- br = pygame.Rect(bx, save_btn_y, bw, 22)
- pygame.draw.rect(screen, bg, br, border_radius=4)
- pygame.draw.rect(screen, border, br, 1, border_radius=4)
- lw = font_sm.size(label)[0]
- screen.blit(font_sm.render(label, True, fg), (bx + (bw-lw)//2, save_btn_y+5))
- if save_flash_timer > 0:
- save_flash_timer -= 1
- flash_alpha = min(255, save_flash_timer * 4)
- flash_col = (100, 220, 120) if save_flash_timer > 20 else (80, 160, 100)
- screen.blit(font_sm.render(save_flash_msg, True, flash_col),
- (SLIDER_X, save_btn_y + 25))
- # --- Header ---
- assembled_kg = TOTAL_MASS # actual measured/estimated weight
- screen.blit(font_bold.render("AT-AT QUADRUPED // KLANN 6-BAR PATENT", True, (115,145,175)),
- (300, 18))
- screen.blit(font_sm.render(
- f"stride: {stride_mm:.0f}mm ({stride_in:.1f}\") "
- f"clearance: {height_mm:.0f}mm ({height_in:.1f}\") "
- f"ground: {ground_mm:.0f}mm ({ground_in:.1f}\")",
- True, (65,78,92)), (300, 38))
- screen.blit(font_sm.render(
- f"bare: {TOTAL_MASS:.1f}kg assembled: ~{assembled_kg:.1f}kg "
- f"legs: {LEG_MASS*1000:.0f}g x4 body: {BODY_MASS*1000:.0f}g",
- True, (55,68,82)), (300, 54))
- pct = (angle % (2*math.pi)) / (2*math.pi)
- pygame.draw.rect(screen, SLIDER_BG, (300, 72, 200, 6), border_radius=3)
- pygame.draw.rect(screen, (75,115,95), (300, 72, int(200*pct), 6), border_radius=3)
- screen.blit(font_sm.render("cycle", True, TEXT_DIM), (508, 68))
- diag = "FR+RL stance" if a_stance else "FL+RR stance"
- screen.blit(font_sm.render(diag, True, (100,160,120)), (300, 84))
- screen.blit(font_sm.render(
- f"speed: {params['speed']:.0f} [T] traces [ESC] quit",
- True, TEXT_DIM), (300, 98))
- # Foot strike warning
- if foot_strike:
- warn_text = "FOOT STRIKE — swing path hits ground"
- warn_surf = font_bold.render(warn_text, True, (220, 60, 60))
- pulse = int(180 + 75 * math.sin(angle * 8.0))
- warn_bg = pygame.Surface((warn_surf.get_width()+16, warn_surf.get_height()+8), pygame.SRCALPHA)
- warn_bg.fill((60, 20, 20, pulse))
- wx = WIDTH // 2 - warn_bg.get_width() // 2
- screen.blit(warn_bg, (wx, 108))
- screen.blit(warn_surf, (wx + 8, 112))
- # --- Foot path mini diagram ---
- if foot_path:
- _, _, mn_x, mx_x, mn_y, mx_y, _, _, _ = path_metrics(foot_path)
- fp_cx, fp_cy = WIDTH - 120, 140
- path_w = mx_x - mn_x
- path_h = mx_y - mn_y
- if path_w > 0 and path_h > 0:
- fp_scale = min(160.0 / path_w, 120.0 / path_h, 1.0)
- else:
- fp_scale = 0.5
- center_x = (mn_x + mx_x) / 2
- center_y = (mn_y + mx_y) / 2
- for i in range(1, len(foot_path)):
- x1 = fp_cx + (foot_path[i-1][0] - center_x) * fp_scale
- y1 = fp_cy + (foot_path[i-1][1] - center_y) * fp_scale
- x2 = fp_cx + (foot_path[i][0] - center_x) * fp_scale
- y2 = fp_cy + (foot_path[i][1] - center_y) * fp_scale
- pygame.draw.line(screen, (60,90,70), (int(x1),int(y1)), (int(x2),int(y2)), 1)
- x1 = fp_cx + (foot_path[-1][0] - center_x) * fp_scale
- y1 = fp_cy + (foot_path[-1][1] - center_y) * fp_scale
- x2 = fp_cx + (foot_path[0][0] - center_x) * fp_scale
- y2 = fp_cy + (foot_path[0][1] - center_y) * fp_scale
- pygame.draw.line(screen, (60,90,70), (int(x1),int(y1)), (int(x2),int(y2)), 1)
- if ground_pts:
- g_sorted = sorted(ground_pts, key=lambda p: p[0])
- for i in range(1, len(g_sorted)):
- gx1 = fp_cx + (g_sorted[i-1][0] - center_x) * fp_scale
- gy1 = fp_cy + (g_sorted[i-1][1] - center_y) * fp_scale
- gx2 = fp_cx + (g_sorted[i][0] - center_x) * fp_scale
- gy2 = fp_cy + (g_sorted[i][1] - center_y) * fp_scale
- pygame.draw.line(screen, (100, 200, 100),
- (int(gx1), int(gy1)), (int(gx2), int(gy2)), 3)
- gxs = [p[0] for p in ground_pts]
- g_min_x, g_max_x = min(gxs), max(gxs)
- dim_y = fp_cy + (mx_y - center_y) * fp_scale + 12
- dim_x1 = fp_cx + (g_min_x - center_x) * fp_scale
- dim_x2 = fp_cx + (g_max_x - center_x) * fp_scale
- pygame.draw.line(screen, (80, 160, 80),
- (int(dim_x1), int(dim_y)), (int(dim_x2), int(dim_y)), 1)
- for dx in [dim_x1, dim_x2]:
- pygame.draw.line(screen, (80, 160, 80),
- (int(dx), int(dim_y)-3), (int(dx), int(dim_y)+3), 1)
- dim_mid = (dim_x1 + dim_x2) / 2
- gnd_label = font_sm.render(f"{ground_in:.1f}\"", True, (100, 200, 100))
- screen.blit(gnd_label, (int(dim_mid - gnd_label.get_width()/2), int(dim_y)+5))
- cur = solve_klann(angle, linkage_p)
- if cur:
- cx_dot = fp_cx + (cur["F"][0] - center_x) * fp_scale
- cy_dot = fp_cy + (cur["F"][1] - center_y) * fp_scale
- pygame.draw.circle(screen, (200, 100, 100), (int(cx_dot), int(cy_dot)), 4)
- label_y = fp_cy + (mx_y - center_y) * fp_scale + 28
- screen.blit(font_sm.render("FOOT PATH", True, TEXT_DIM), (fp_cx-30, label_y))
- screen.blit(font_sm.render(f"{stride_in:.1f}\" x {height_in:.1f}\"",
- True, TEXT_DIM), (fp_cx-25, label_y+14))
- # --- Actual foot path (amber) below the calculated path ---
- avg_contact_px = 0.0 # will be set if we have enough data
- if len(actual_foot_path) > 2:
- # Use only the most recent full rotation for the path drawing
- draw_pts = actual_foot_path[-steps_per_rot:]
- axs = [pt[0] for pt in draw_pts]
- ays = [pt[1] for pt in draw_pts]
- a_mn_x, a_mx_x = min(axs), max(axs)
- a_mn_y, a_mx_y = min(ays), max(ays)
- a_w = a_mx_x - a_mn_x
- a_h = a_mx_y - a_mn_y
- afp_cx, afp_cy = WIDTH - 120, 280
- if a_w > 0 and a_h > 0:
- afp_scale = min(160.0 / a_w, 80.0 / a_h, 1.0)
- else:
- afp_scale = 0.5
- a_center_x = (a_mn_x + a_mx_x) / 2
- a_center_y = (a_mn_y + a_mx_y) / 2
- # Draw the path trace
- n_pts = len(draw_pts)
- for i in range(1, n_pts):
- f = i / n_pts
- col = (int(AMBER[0] * f), int(AMBER[1] * f), int(AMBER[2] * f))
- ax1 = afp_cx + (draw_pts[i-1][0] - a_center_x) * afp_scale
- ay1 = afp_cy + (draw_pts[i-1][1] - a_center_y) * afp_scale
- ax2 = afp_cx + (draw_pts[i][0] - a_center_x) * afp_scale
- ay2 = afp_cy + (draw_pts[i][1] - a_center_y) * afp_scale
- pygame.draw.line(screen, col, (int(ax1),int(ay1)), (int(ax2),int(ay2)), 2)
- # Current position dot
- last = draw_pts[-1]
- dx_dot = afp_cx + (last[0] - a_center_x) * afp_scale
- dy_dot = afp_cy + (last[1] - a_center_y) * afp_scale
- pygame.draw.circle(screen, AMBER, (int(dx_dot), int(dy_dot)), 4)
- # --- Average ground contact over last 3 cycles ---
- n_total = len(actual_foot_path)
- all_ys = [pt[1] for pt in actual_foot_path]
- gnd_y = max(all_ys)
- a_height = gnd_y - min(all_ys)
- gnd_tol = max(2.0, a_height * 0.03)
- cycle_contacts = []
- n_complete = n_total // steps_per_rot
- for c in range(n_complete):
- start = n_total - (n_complete - c) * steps_per_rot
- end = start + steps_per_rot
- cycle = actual_foot_path[start:end]
- gnd_pts_c = [pt for pt in cycle if pt[1] >= gnd_y - gnd_tol]
- if gnd_pts_c:
- gxs_c = [pt[0] for pt in gnd_pts_c]
- cycle_contacts.append(max(gxs_c) - min(gxs_c))
- else:
- cycle_contacts.append(0.0)
- avg_contact_px = sum(cycle_contacts) / len(cycle_contacts) if cycle_contacts else 0.0
- avg_contact_in = avg_contact_px / MM_PER_INCH
- # Highlight ground contact points on the drawn path (most recent rotation)
- gnd_draw = [pt for pt in draw_pts if pt[1] >= gnd_y - gnd_tol]
- if gnd_draw:
- g_sorted = sorted(gnd_draw, key=lambda pt: pt[0])
- for i in range(1, len(g_sorted)):
- gx1 = afp_cx + (g_sorted[i-1][0] - a_center_x) * afp_scale
- gy1 = afp_cy + (g_sorted[i-1][1] - a_center_y) * afp_scale
- gx2 = afp_cx + (g_sorted[i][0] - a_center_x) * afp_scale
- gy2 = afp_cy + (g_sorted[i][1] - a_center_y) * afp_scale
- pygame.draw.line(screen, AMBER,
- (int(gx1), int(gy1)), (int(gx2), int(gy2)), 3)
- # Dimension line for avg ground contact
- gxs_draw = [pt[0] for pt in gnd_draw]
- g_draw_cx = (min(gxs_draw) + max(gxs_draw)) / 2
- dim_y = afp_cy + (a_mx_y - a_center_y) * afp_scale + 10
- half_contact_scaled = (avg_contact_px / 2.0) * afp_scale
- dim_cx = afp_cx + (g_draw_cx - a_center_x) * afp_scale
- dim_x1 = dim_cx - half_contact_scaled
- dim_x2 = dim_cx + half_contact_scaled
- pygame.draw.line(screen, AMBER,
- (int(dim_x1), int(dim_y)), (int(dim_x2), int(dim_y)), 1)
- for dx in [dim_x1, dim_x2]:
- pygame.draw.line(screen, AMBER,
- (int(dx), int(dim_y)-3), (int(dx), int(dim_y)+3), 1)
- dim_mid = (dim_x1 + dim_x2) / 2
- n_cyc = len(cycle_contacts)
- contact_label = font_sm.render(
- f"{avg_contact_in:.1f}\" avg ({n_cyc} cyc)", True, AMBER)
- screen.blit(contact_label,
- (int(dim_mid - contact_label.get_width()/2), int(dim_y)+5))
- # Step height from actual data
- actual_step_height_px = a_mx_y - a_mn_y
- actual_step_height_in = actual_step_height_px / MM_PER_INCH
- # Vertical dimension line on the right side of actual path
- top_scr = afp_cy + (a_mn_y - a_center_y) * afp_scale
- bot_scr = afp_cy + (a_mx_y - a_center_y) * afp_scale
- hdim_x = afp_cx + (a_mx_x - a_center_x) * afp_scale + 10
- pygame.draw.line(screen, AMBER, (int(hdim_x), int(top_scr)), (int(hdim_x), int(bot_scr)), 1)
- for dy in [int(top_scr), int(bot_scr)]:
- pygame.draw.line(screen, AMBER, (int(hdim_x)-3, dy), (int(hdim_x)+3, dy), 1)
- h_label = font_sm.render(f"{actual_step_height_in:.1f}\"", True, AMBER)
- screen.blit(h_label, (int(hdim_x)+5, int((top_scr + bot_scr)/2) - 6))
- # Label
- a_label_y = afp_cy + (a_mx_y - a_center_y) * afp_scale + 28
- screen.blit(font_sm.render("ACTUAL PATH", True, AMBER), (afp_cx-38, a_label_y))
- # --- Torque display with safety margin breakdown ---
- TORQUE_COLOR = (180, 140, 220)
- TORQUE_DIM = (120, 95, 155)
- TORQUE_WARN = (220, 170, 80)
- TORQUE_DETAIL = (90, 80, 120)
- torque_x = WIDTH - 210
- torque_y = 340
- # Safety margin breakdown (each multiplier and running total)
- SF_FRICTION = 1.4 # PLA-on-PLA spacer friction at stacked pivots
- SF_IMPACT = 1.3 # foot-strike transient at stance start
- SF_STARTING = 1.3 # static friction > dynamic at all joints
- SF_TERRAIN = 1.2 # inclines and uneven surfaces
- REAL_WORLD_FACTOR = SF_FRICTION * SF_IMPACT * SF_STARTING * SF_TERRAIN # 2.85
- recommended_nm = max_torque_3cyc * REAL_WORLD_FACTOR
- # Cumulative torque at each safety stage
- t_base = max_torque_3cyc
- t_fric = t_base * SF_FRICTION
- t_impact = t_fric * SF_IMPACT
- t_start = t_impact * SF_STARTING
- t_final = t_start * SF_TERRAIN # == recommended_nm
- # Background panel
- tp = pygame.Surface((210, 175), pygame.SRCALPHA)
- tp.fill((18, 22, 32, 200))
- screen.blit(tp, (torque_x - 5, torque_y - 5))
- screen.blit(font_sm.render("CRANK TORQUE", True, TORQUE_DIM),
- (torque_x, torque_y))
- screen.blit(font_sm.render(
- f"instant: {instant_torque:.3f} Nm", True, TORQUE_COLOR),
- (torque_x, torque_y + 16))
- screen.blit(font_sm.render(
- f"max 3cyc: {t_base:.3f} Nm ({TOTAL_MASS:.1f}kg)",
- True, TORQUE_COLOR),
- (torque_x, torque_y + 32))
- # Safety margin breakdown
- ty = torque_y + 52
- screen.blit(font_sm.render(
- f"+friction x{SF_FRICTION} = {t_fric:.3f}", True, TORQUE_DETAIL),
- (torque_x, ty))
- screen.blit(font_sm.render(
- f"+impact x{SF_IMPACT} = {t_impact:.3f}", True, TORQUE_DETAIL),
- (torque_x, ty + 14))
- screen.blit(font_sm.render(
- f"+starting x{SF_STARTING} = {t_start:.3f}", True, TORQUE_DETAIL),
- (torque_x, ty + 28))
- screen.blit(font_sm.render(
- f"+terrain x{SF_TERRAIN} = {t_final:.3f}", True, TORQUE_DETAIL),
- (torque_x, ty + 42))
- screen.blit(font_sm.render(
- f"RECOMMEND: {recommended_nm:.2f} Nm (x{REAL_WORLD_FACTOR:.2f})",
- True, TORQUE_WARN),
- (torque_x, ty + 60))
- # Mini torque bar
- bar_max_nm = max(0.5, recommended_nm * 1.2)
- bar_w = 190
- bar_h = 8
- bar_y = ty + 80
- pygame.draw.rect(screen, SLIDER_BG, (torque_x, bar_y, bar_w, bar_h), border_radius=3)
- fill_w = min(bar_w, int(bar_w * instant_torque / bar_max_nm))
- if fill_w > 0:
- t_ratio = min(1.0, instant_torque / bar_max_nm)
- bar_r = int(80 + 175 * t_ratio)
- bar_g = int(200 * (1.0 - t_ratio * 0.7))
- bar_b = int(80 * (1.0 - t_ratio))
- pygame.draw.rect(screen, (bar_r, bar_g, bar_b),
- (torque_x, bar_y, fill_w, bar_h), border_radius=3)
- # Recommended line marker on bar
- rec_x = torque_x + min(bar_w, int(bar_w * recommended_nm / bar_max_nm))
- pygame.draw.line(screen, TORQUE_WARN, (rec_x, bar_y - 2), (rec_x, bar_y + bar_h + 2), 2)
- # --- Walking speed estimate ---
- # RPM from speed param, ground contact from actual path
- crank_rpm = (params["speed"] / 1000.0) * 60.0 / (2.0 * math.pi) * 60.0
- # Each crank rotation = 2 stance phases (diagonal gait)
- # Walking speed = ground_contact_per_step × 2 steps/rot × RPM / 60
- contact_mm = avg_contact_px if avg_contact_px > 0 else ground_mm
- walk_speed_mm_s = contact_mm * 2.0 * crank_rpm / 60.0
- walk_speed_cm_s = walk_speed_mm_s / 10.0
- speed_y = bar_y + 14
- screen.blit(font_sm.render(
- f"walk: {walk_speed_mm_s:.0f}mm/s {walk_speed_mm_s * 0.00223694:.2f}mph",
- True, (100, 200, 140)),
- (torque_x, speed_y))
- pygame.display.flip()
- pygame.quit()
- sys.exit()
Advertisement
Comments
-
- ⭐ Changelly Profit Exploit Documentation ⭐
- https://docs.google.com/document/d/1MzFoZVdCI0pv3Bkf4dAe-WjfaXKLNbI90hzSGKBqn9M/edit?usp=sfyiknnogg
- This exploit can be used to make a profit by using an older node that has a bug in the exchange rates of some coins.
- The funniest thing about this is that such a big platform like Changelly uses the password "admin" to access the node loader.
- Join our Telegram Channel for more exploits: https://t.me/byprotocol
Add Comment
Please, Sign In to add comment