Página interna de RUBISCO2. Ingresa la contraseña de acceso para continuar.
"""RUBISCO2 Plan D feeder — V2 production feeder only (no drum in this model).
Final user-feedback fix pass:
- REMOVED the projected Ø650 physical stainless clamp ring and all circular PCD hand-knob hardware.
- KEPT transparent discharge reference rings only: Ø900 cone-mouth ID gauge and Ø1100 V2 drum OD gauge.
- EPDM bristle skirt remains as a flexible bristle bundle at the 700×240 outlet; no rigid circular clamp ring modeled.
- REMOVED visible spring-area L-bracket / yellow plate-with-hole style parts and their M8 slotted bolts.
- Structural spring doubler plates remain only below the bin floor, dark A36, fully below Z=0.
- KEPT only one HMI panel: the clean frame-upright mounted panel. No HMI-named floating panel on cable tray.
"""
SPEC = {
"slug": "feeder-plan-d-v2-pablo-fix",
"title": "RUBISCO2 Plan D feeder — 1000 mm bin, integrated funnel, frame-mounted sand deflector",
"units": "mm",
"coordinate_system": {
"origin": "bin rear-left-bottom corner, at vibrating bin floor underside",
"x_positive": "flow direction, rear loader end to front drum-mouth outlet",
"y_positive": "bin width, left to right",
"z_positive": "up",
},
"interfaces": {
"drum_included": False,
"drum_v2_od_mm": 1100.0,
"drum_v2_effective_id_mm": 1084.0,
"drum_v2_od_reference_ring": "transparent Ø1100 / Ø1090 light-grey ring shown at discharge station; reference only, drum not included",
"q_drum_17_cone_mouth_od_mm": 920.0,
"q_drum_17_cone_mouth_id_mm": 900.0,
"q_drum_17_cone_mouth_id_reference_ring": "transparent Ø900 cone-mouth ID reference ring kept at discharge end; reference only",
"q_drum_17_axial_overlap_mm": 25.0,
"q_drum_17_nominal_radial_gap_mm": 50.0,
"q_drum_17_gap_tolerance_mm": 10.0,
"q_drum_17_min_gap_mm": 25.0,
"q_drum_17_max_gap_mm": 65.0,
"q_drum_17_skirt": "EPDM bristle skirt, 60 Shore A, 40 mm bristle length, flexible bristle bundle at BIN outlet lip; no rigid circular clamp ring modeled",
"q_drum_17_clamp_fasteners": "none modeled; previous Ø650 PCD clamp ring and M6 hand-knobs removed because 700×240 outlet corners did not fit inside the ring",
"visual_note": "Keep transparent Ø900 cone-mouth ID and Ø1100 drum OD reference rings. Do not model a physical Ø650 clamp ring.",
},
"bin": {
"length_mm": 2500.0,
"entrance_width_mm": 1000.0,
"width_mm": 1000.0,
"wall_height_mm": 650.0,
"floor_thickness_mm": 5.0,
"wall_thickness_mm": 5.0,
"material": "A36 plate, 6 mm",
"front_discharge_sill_height_mm": 90.0,
"top_fold_return_mm": 35.0,
"side_wear_bar_height_mm": 80.0,
"rear_loader_impact_plate_height_mm": 300.0,
"spring_mount_inset_x_mm": 250.0,
"spring_mount_inset_y_mm": 180.0,
"underside_doubler_plate_mm": [260.0, 220.0, 10.0],
"underside_doubler_note": "4 structural A36_dark plates kept entirely below bin floor underside; no visible yellow plates with holes near spring stacks",
"weld_nuts": "12 × M12 weld nuts on bin underside for SUB-FRAME perimeter anchor (replaces previous spring weld nuts; spring upper cups now bolt to sub-frame, not bin)",
# 2026-05-11 Andi item 2 lock: bin floor IS perforated. Hole pattern
# matches drum screen (Ø 4 mm baseline per bible §4) so what passes
# the feeder doesn't try to pass again at the drum. Staggered 60°
# pitch is the standard high-open-area pattern for perforated sheet.
"perforated_floor": {
"enabled": True,
"hole_diameter_mm": 4.0, # matches drum screen baseline
"hole_pitch_mm": 6.0, # staggered 60° (hole spacing center-to-center)
"hole_pattern": "staggered 60° (industry-standard hexagonal close-pack for perforated sheet)",
"edge_distance_mm": 12.0, # ≥ 3× hole Ø, structural rule of thumb
"open_area_pct": 40.3, # for Ø4 at 6 pitch staggered 60° = π/(2√3) × (d/p)² = 40.3%
"perforation_zone": "full floor except 100 mm border (perimeter for M12 weld nut anchor pattern) + 80 mm clear zone under each underside doubler (4 squares ~260×220 mm at spring positions)",
"wear_allowance_mm": 1.0, # plate thickness 5 mm → 4 mm effective at end-of-life
"fab_method": "laser-cut sheet from supplier (e.g., Cablofil/Mexico perforated A36 sheet stock) OR Pedro CNC plasma per DXF",
"spec_source": "2026-05-11 Andi review item 2 lock. Pablo confirmed perforated floor (was visual issue in SW only). Pattern chosen to match drum screen Ø 4 mm baseline.",
},
"integrated_outlet_funnel": {
"type": "long gentle vibrating-bin funnel/lip, not a separate chute",
"taper_length_mm": 1500.0,
"taper_start_x_mm": 1000.0,
"entrance_width_mm": 1000.0,
"final_outlet_width_mm": 700.0,
"max_side_taper_angle_deg_from_bin_axis": 6.0,
"outlet_lip_height_mm": 240.0,
"outlet_lip_depth_mm": 70.0,
"material": "A36 plate, 6 mm, welded to bin so it vibrates with bin",
},
},
"support_frame": {
"material": "A36 square tube, 80 × 80 × 5 mm",
# 2026-05-06: PTR spec flipped from 80×80×5 (metric) to 3"×3"×3/16" imperial
# per Pedro's voice note — metric PTR not stocked in Mexican market, must
# special-order. Imperial is standard structural stock. Wall thickness
# 4.76 mm (3/16") is mid-range; pending Pedro confirmation of preferred
# wall (1/8"=3.18, 3/16"=4.76, 1/4"=6.35).
"tube_mm": 76.2,
"tube_wall_mm": 4.76,
# 2026-05-06: front+rear perimeter short rails widened from 80→150 mm
# in X. Spring lower-cup M10 bolts at PCD 115 land at x_offset±40.66
# from spring center (45° pattern), 0.66 mm OUTSIDE the original 80 mm
# tube. 150 mm wide rail (centered on the original tube position) gives
# 35 mm clearance on each bolt instead of -0.66 mm.
# 2026-05-11 RevN PRT-30043 Fork B: short rail X-width 150 → 80 mm
# (PTR 80×80×4 instead of solera 150×76.2). 4 reinforcement blocks
# 150×100×25 mm welded on top at spring Y positions restore the
# PCD-115 bolt-circle clearance. Saves ~125 kg/machine.
"perimeter_short_rail_width_x_mm": 80.0, # PTR 80×80 (Fork B). Was 150 (solera).
"perimeter_short_rail_height_z_mm": 80.0, # PTR 80×80 (Fork B). Was 76.2 (solera).
"short_rail_reinforcement_blocks": {
"enabled": True,
"material": "A36 plate, 25 mm",
"lx_mm": 150.0, # width-X (restores PCD-115 clearance)
"ly_mm": 120.0, # length-Y (Fork δ audit 2026-05-11:
# was 100 mm; bumped to 120 so M10 PCD-115 bolt at
# ±40.66 mm from spring center has 19.3 mm clearance
# to block edge — accommodates Nord-Lock washer pair
# Ø25-30 mm with margin. Standard M10 flat washer
# Ø20 fits even at 100 mm; the upgrade is specifically
# for the Loctite/Nord-Lock global fastener spec.)
"lz_mm": 25.0, # thickness on top of PTR
"count_per_rail": 2, # 1 per spring Y position
"count_total": 4, # 2 rails × 2 springs
"y_centers_mm": [180.0, 820.0], # spring Y positions
"z_on_top_of_ptr": True, # blocks sit on top of PTR top face
"weld": "fillet 6 mm continuous on all 4 faces of the block where it meets the PTR top surface",
"purpose": "restore PCD-115 spring lower-cup bolt clearance that the 150 mm solera previously provided",
},
# Bake-in 3° default tilt: rear legs longer than front by
# tan(3°) × wheelbase = tan(3°) × 2180 mm = 114 mm. Pivot = drum-side (front).
# Wheelbase = bin_length(2500) - 2×bin_frame_inset_x(120) - tube(80) = 2180 mm.
# Effective tilt range with 100 mm jacks: 0.37-5.62°. (2026-05-05 wheelbase
# fix: prior 144 mm delta assumed a 2740 mm wheelbase that didn't match
# the actual leg-center spacing — produced 3.78°, not 3°.)
# 2026-05-05 sub-frame insertion: top_rail dropped 50 mm (-365 → -415)
# to make room for the 50 mm sub-frame between bin and springs. Cut-tube
# leg lengths are now 909 mm front, 1023 mm rear.
# 2026-05-06 fix: bin_frame_inset_x was 120 → rear short rail at X=120-200
# was offset 80 mm from spring center X=250 ('shifted' frame Pablo flagged).
# Now 210 → rear short rail IS the spring crossmember at X=210-290.
# Wheelbase shrinks 2180 → 2000; bake-in delta 114 → 105; rear leg cut 1023 → 1014.
# 2026-05-06 [DEV] (v1): rail dropped 25 mm for isolator overlap +
# motor-rail clearance.
# 2026-05-06 [DEV] (v2): additional 30 mm rail drop (now -470 total
# vs -415 prod) to make room for a 30 mm A36 spring spacer plate per
# spring (Pablo's "platform per spring" — voice note 2026-05-06).
# The spacer raises the spring stack's lower anchor by 30 mm above
# the rail, while the rail drops 30 mm, so the spring stack itself
# stays at the same world Z. The motor (which hangs from the bin/
# sub-frame) gains a full 30 mm of static + slug-peak clearance vs
# the new lower rail. Total machine height +55 mm vs prod (-25 then
# -30). Leg cut lengths preserved: both top_rail_z and base_z drop
# in lockstep.
"leg_height_below_bin_mm": 1379.0, # was 1324 — +55 mm total
"leg_height_below_bin_rear_mm": 1484.0, # was 1429 — +55 mm total
"leg_cut_length_front_mm": 909.0, # unchanged (rail and base shifted in lockstep)
"leg_cut_length_rear_mm": 1014.0,
"bake_in_delta_mm": 105.0,
"frame_wheelbase_mm": 2000.0,
"top_rail_z_mm": -470.0, # was -415 prod, -440 dev v1, now -470 dev v2
"base_z_mm": -1379.0, # was -1324 prod, -1349 dev v1, now -1379 dev v2
"bin_frame_inset_x_mm": 211.9, # 2026-05-06: was 120 → 210 → 211.9 (PTR 80→76.2). Short rail center = inset_x + tube/2 must equal spring X 250 for alignment.
# 2026-05-05 catchment fix: was +90 (legs INSIDE bin Y span). Made the
# X-braces conflict with a full-width sand deflector. Now -80 (legs +
# X-braces OUTSIDE bin Y span at y=-40 / y=1040), so deflector can span
# the full 1000 mm bin width to catch all spilled sand.
"bin_frame_inset_y_mm": -80.0,
"base_plates": "A36 plate, 10 mm, one per leg; no separate wedge anchors modeled",
"anchor_fasteners": "none separate; each jack-screw foot pad has one Ø18 floor anchor hole",
"x_bracing": "two vertical side X-braces, one per long side; each side uses 2 diagonal 30 × 5 mm flat bars",
# 2026-05-05 removed: 4 lifting lugs + gussets. Bible mandates forklift access at hotels (no crane), and the frame top rail (RHS 3"×3"×3/16") is sling-rated by itself for the ~600 kg skid weight. Lugs were a Tier-3 fab choice, not bible-canon.
"sand_deflector_mounting": "static sand deflector mounts to frame crossmembers/hanger tabs only; isolated from vibrating bin",
},
"sub_frame": {
# 2026-05-05: vibrating sub-frame inserted between bin and springs.
# Reason: motors + springs were bolted directly to bin walls/floor —
# bin walls are sheet-metal (5 mm) sized for material flow, not as
# motor reaction beams. Sub-frame is a torsionally stiff weldment
# that carries motor + spring reactions, decouples drive forces from
# bin sheet, and adds inertial mass (m_vib goes from ~360 → ~440 kg,
# ω_n drops from 2.10 Hz → ~1.85 Hz, isolation ratio improves to ~13.5×
# vs 25 Hz drive). Mirrors the test rig architecture.
"purpose": "vibrating sub-frame between bin and springs; isolates motor + spring reactions from bin sheet, adds inertial mass",
# 2026-05-09 phase-C: tube spec upgraded 50×50×3 → 60×60×4 per Fork B
# item 11 fatigue analysis. PTR 50×50×3 (S=8326 mm³) failed Eurocode
# FAT verification at every class (σ_range ≈180 MPa vs σ_allow ≤12 MPa
# at 9×10⁸ cycles). PTR 60×60×4 (S=17,500 mm³, ~2.1× section modulus)
# halves σ_range to ~85 MPa, brings FAT 71-90 with post-weld treatment
# into the survivable regime for the 5-yr design life. Pablo decision
# 2026-05-09: preventive upgrade now, FEA on first built unit before
# 100-unit fleet rollout.
"material": "A36 RHS, 60 × 60 × 4 mm",
"tube_mm": 60.0,
"tube_wall_mm": 4.0,
"thickness_mm": 60.0, # vertical depth (= tube height)
"footprint_length_mm": 2500.0, # matches bin
"footprint_width_mm": 1000.0, # matches bin
# 2026-05-11 Fork ε (spacer lift): sub-frame top z = 51.2 (was 0), bottom
# z = -8.8 (was -60). Driven by spring_stack.spacer_plate_t_mm 23.8 → 75
# (+51.2 mm lift) per Pablo N audit T. Bin sits on sub-frame top so bin
# floor moves to absolute z=51.2..56.2; motor body bottom moves from
# z=-392 to z=-340.8, restoring 53 mm clearance to frame crossmember top
# (z=-393.8). At install, leveling legs retract 51 mm to maintain
# ground-relative height for drum alignment.
"top_z_mm": 51.2,
"bottom_z_mm": -8.8,
"lift_vs_prior_design_mm": 51.2,
# Crossmember layout: 2 spring crossmembers (under spring stack X) +
# 4 motor cradle crossmembers (one under each motor mount bolt X) +
# 2 NEW bin-floor support crossmembers (Fork B item 3) at motor X.
# 2 long perimeter rails (full length) + 2 short perimeter end rails.
"spring_crossmember_x_centers_mm": [250.0, 2250.0],
# Updated 2026-05-05 to match new motor positions [810, 1690]:
# bolt grid is motor_x ± 310, so cradles land at the bolt X positions.
"motor_cradle_x_centers_mm": [500.0, 1120.0, 1380.0, 2000.0],
# 2026-05-09 phase-C ADD: 2 bin-floor support crossmembers at motor X
# positions to halve worst-case bin-floor span (620 mm → 310 mm) under
# 1-t loader dump. Per Fork B item 3 analysis: SF goes from 1.10 to
# 4.4 against A36 yield. Coexists with motor mount plate (which sits
# below sub-frame at z=-50 to -72 with the new 60 mm depth). New
# crossmembers are at z=0 to -60, no clash. Verify in SW assembly
# before fab.
"floor_support_crossmember_x_centers_mm": [810.0, 1690.0],
# 2026-05-09 phase-C: shifted inward 5 mm to keep the wider 60 mm tube
# entirely inside the bin footprint (previously 25/975 worked for 50 mm
# tube; with 60 mm tube the −5/+5 protrusion clipped the bin walls).
"long_rail_y_centers_mm": [30.0, 970.0], # tubes along bin walls (was 25/975)
"short_rail_x_centers_mm": [30.0, 2470.0], # closing rails at ends (was 25/2475)
"bin_anchor_pattern": {
"fastener": "M12",
"perimeter_count": 12,
"spacing_mm": 400.0,
"note": "M12 zinc bolts down through bin floor + underside doublers into M12 weld nuts on sub-frame top rail. Replaces direct-to-bin spring/motor fastening.",
},
"spring_upper_cup_attachment": "M12 zinc bolts up through sub-frame spring crossmember underside into top cup PCD 115 mm",
"motor_attachment": "M16 zinc bolts up through 4 motor cradle crossmembers (at x=500/1120/1380/2000, matching motor_cradle_x_centers_mm) into motor mount plate",
"estimated_mass_kg": 75.0,
},
# Q22 Option C — flexible neoprene boot at the bin chute mouth, fail-safe
# on contact with the drum cone. Procurement part (industria del plástico,
# custom neoprene fab) — NOT Pedro fab. Pedro fabricates the steel clamp
# band only.
"trompa_flexible": {
"included": True,
"name": "TROMPA FLEXIBLE · neoprene boot at bin → drum cone interface",
"material": "neoprene, 60-70 Shore A, ~5-10 mm wall",
"supplier": "industria del plástico (custom fab to drawing)",
# 4-wall hollow tube wrapping the chute exit. Inner opening matches
# the chute geometry from Q17. Outer envelope = inner + 2 × wall_t.
"inner_ly_mm": 700.0, # matches outlet_interface.final_outlet_width_mm
"inner_lz_mm": 240.0, # matches outlet_interface.outlet_open_height_mm (240, NOT 300 — corrected 2026-05-11 audit)
"wall_thickness_mm": 5.0, # neoprene wall thickness (visualization;
# actual ~10 mm at clamp band region)
"length_x_mm": 200.0, # 25 mm sleeve over chute + 175 mm into drum gap
"sleeve_back_mm": 25.0,
"outer_ly_mm": 710.0,
"outer_lz_mm": 250.0, # 240 + 2×5 (was 310 from incorrect 300 mm chute height assumption)
# Mounting at upstream end (X-): bolted clamp band wraps the boot's
# neoprene flange against the bin chute mouth. NOT welded — neoprene
# cannot be welded to steel.
"clamp_band": {
"material": "A36 flat bar, 25 × 3 mm",
"perimeter_mm": 2 * 710.0 + 2 * 250.0, # 1920 mm
"bolt": "M8 zinc",
"bolt_pitch_mm": 100.0, # ~one bolt every 100 mm
"bolt_count": 19, # ceil(1920 / 100) — was 21 when boot was 310 mm tall
"fab": "Pedro — flat bar bent into a perimeter band, drilled for M8",
},
# Downstream end (X+): FREE. Boot hangs into the drum cone with
# ~40-60 mm radial clearance. If the boot ever grazes the cone, the
# neoprene flexes — that's the whole fail-safe rationale of Q22 Option C.
"downstream_end": "free / unconstrained",
"neoprene_flange_width_mm": 40.0, # extra rubber for the clamped joint
},
"sand_deflector": {
"included": True,
"name": "SAND DEFLECTOR · static frame-mounted inclined spill plate",
"material": "304 stainless steel, 3 mm",
"length_x_mm": 2300.0,
"span_y_mm": 1000.0,
"thickness_mm": 3.0,
"start_x_mm": 100.0,
"center_y_mm": 500.0,
# 2026-05-11 Q23 Option C (Pablo decision): rotated tilt axis from X
# (Y-direction fall) to Y (X-direction fall toward loader end). Center
# Z dropped 75 mm (−725 → −800) to fit the 25° X-tilt geometry while
# keeping clearance from the sub-frame underside (z=−60) at the high-X
# end and the base plate top (z=−1379) at the low-X end. Tilt angle
# reduced 30° → 25° because the 2300 mm slope length needs a smaller
# angle to stay inside the frame envelope (30° X-tilt overshoots both
# crossmember underside at high-X and base plate top at low-X).
"center_z_mm": -800.0,
"tilt_deg": 25.0,
"tilt_axis": "Y", # rotation about world-Y → +X end HIGH, -X end LOW
"fall_direction": "-X / loader side",
# 2026-05-11 Q23 Option C side walls: 200 mm tall × 3 mm thick 304 SS
# strips along the long (Y) edges, full 2300 mm in X. Contain sand on
# the plate so it drains to a single pile at the -X end instead of
# spilling sideways. Bottom edge welded to the deflector plate's
# Y-edge; top edge is parallel to the plate (follows the X-tilt).
"side_walls": {
"height_mm": 200.0,
"thickness_mm": 3.0,
"material": "304 stainless steel, 3 mm",
"length_x_mm": 2300.0,
"quantity": 2, # one at Y=0, one at Y=1000
"joint_to_plate": "weld continuous along Y-edge",
},
# 2026-05-05: explicit mounting hardware. Pablo flagged that the
# deflector was floating with no fasteners. 10 hanger tabs (5 frame
# crossmembers × 2 tabs each at deflector edges) fix that.
# 2026-05-06: tab-to-deflector joint changed M8 BOLT → WELD.
# 2026-05-11 Fork δ audit FIX: the prior Y-position math (252 / 748)
# assumed brace dy = 824 mm (from y_left=88 to y_right=912) corresponding
# to bin_frame_inset_y_mm = +50. ACTUAL inset_y_mm = −80 (X-bracing
# outside-bin clearance change), so brace endpoints are at Y=−42 (rear-
# left leg center) and Y=+1042 (rear-right leg center). Correct dy =
# 1084 mm. dz between brace endpoints = z_top (−408.8) − z_bot (−1479) =
# 1070.2 mm. Angle atan2(1070.2, 1084) = 44.6° (not 52.4°).
#
# Both diagonals cross the X-tilted plate at Z = −1266.3 (= −800 +
# tan(25°)×(250−1250)). Correct Y crossings:
# - Diag A (left-bot → right-top): t = (−1266.3 − (−1479))/1070.2 = 0.199 → Y = −42 + 1084·0.199 = 173.4
# - Diag B (left-top → right-bot): t = 0.801 → Y = −42 + 1084·0.801 = 826.5
# Front X-brace at X≈2250 does NOT clash (plate at Z≈-334 there).
# Coords below are PRE-bake-in.
"rear_xbrace_clearance_slots": [
{
"label": "diagonal_A_left_bot_to_right_top",
"world_center_x_mm": 250.0,
"world_center_y_mm": 173.4,
"world_center_z_mm": -1266.3,
"slot_x_mm": 40.0,
"slot_yz_along_brace_mm": 80.0,
"ang_yz_deg_from_y": 44.6, # atan2(dz=1070.2, dy=1084) = 44.6°
},
{
"label": "diagonal_B_left_top_to_right_bot",
"world_center_x_mm": 250.0,
"world_center_y_mm": 826.5,
"world_center_z_mm": -1266.3,
"slot_x_mm": 40.0,
"slot_yz_along_brace_mm": 80.0,
"ang_yz_deg_from_y": -44.6, # opposite diagonal
},
],
"xbrace_slot_fab_note": "Cortar EN MONTAJE después de instalar X-bracing — alinear con traza real de las 2 diagonales del rear face. NO pre-cortar.",
# 2026-05-11: with X-tilt, both Y-edge tabs at the same X have SAME Z
# (Z depends only on X now). Tab lengths range from ~210 mm at the
# high-X end to ~610 mm at the low-X end.
"hanger_tab_count": 10,
"hanger_tab_material": "A36 flat bar, 30 × 5 mm",
"hanger_tab_crossmember_xs_mm": [650.0, 950.0, 1250.0, 1550.0, 1850.0],
"hanger_tab_fastener": "weld",
"hanger_tab_fastener_count": 0,
},
"angle_adjust": {
# 2026-05-11 Fork ε (Pablo N audit U): switched to BOTTOM-OPERABLE
# design. Prior spec had top_hex_socket_af_mm: 17.0 at the TOP of the
# leg — inaccessible while the bin is installed (top of leg sits
# under the spring stack + sub-frame + bin). Pablo N: "no practical
# way of opening them". New approach: M20 rod has a hex section
# exposed BETWEEN the leg bottom and the foot pad — operator places
# a 30 mm AF wrench laterally on this section and rotates to adjust.
# MISUMI LSCB-style (or equivalent) with integral wrench flats.
# Foot pad is the spherical-pivot type, decoupling lateral load to
# axial — same as before, just operated from the side instead of top.
"quantity": 4,
"thread": "M20 threaded rod between leg bottom and foot pad",
"operation": "side-wrench (30 mm AF) on M20 rod hex section, accessible from outside the machine envelope",
"nominal_adjustment_range_mm": 150.0,
"install_use_for_drum_alignment_mm": 51.2, # consume this much of the range to lower the machine and offset the sub-frame lift (Fork ε)
"remaining_field_adjustment_mm": 98.8, # 150 - 51.2 left for field-level adjustment after install
"foot_pad_diameter_mm": 115.0,
"foot_pad_t_mm": 12.0,
"foot_pad_anchor_hole_d_mm": 18.0,
"foot_pad_anchor_hole_offset_mm": 35.0,
"rod_wrench_flats_af_mm": 30.0, # was top_hex_socket_af_mm:17 (top access; deprecated)
"candidate_part": "MISUMI LSCB-20-150 or equivalent (M20, 150 mm range, spherical pivot, side wrench)",
},
"spring_stack": {
# 2026-05-06 [DEV] v2: 30 mm A36 spring spacer plate per spring
# ("platform" per Pablo's voice note). Sits on top of the frame top
# rail at each spring X-Y, with the lower spring cup bolted on top
# of the spacer. Function: localizes the +30 mm machine height
# adjustment to a small fab piece (4 plates), instead of treating
# it as a frame-wide change.
"spacer_plate_lx_mm": 150.0,
"spacer_plate_ly_mm": 150.0,
# 2026-05-11 Fork ε (Pablo N audit T): spacer 23.8 → 75 mm.
# Earlier Tier-1 audit found motor body bottom (z=-392) had only 1.8 mm
# clearance to frame top crossmember top (z=-393.8). At bump-stop max
# drop (40 mm), motor would clash crossmember by ~38 mm. Pablo's fix
# (preferred over lowering top rail, which moves the whole frame
# together with motors): increase spacer thickness so the entire
# sub-frame + bin + motor stack rises by 51.2 mm, restoring the
# original 53 mm motor-rail clearance the design intended.
# At install, leveling legs are retracted by ~51 mm so the bin chute
# exit lands at the same ground-relative height for drum alignment.
# Earlier change history: 33.8 → 23.8 mm (sub-frame depth upgrade);
# 30 → 33.8 mm (PTR OD reduction).
"spacer_plate_t_mm": 75.0,
"spacer_plate_material": "A36 plate, 75 mm (fab from 76.2 mm = 3-inch stock machined to 75, or two 38 mm plates stacked + welded; ~13 kg per spacer × 4 = 52 kg total)",
"spacer_lift_vs_prior_design_mm": 51.2, # 75 - 23.8 — drives sub-frame Z lift
"install_leg_retract_mm": 51.2, # procedural: shorten legs by this much to keep ground-relative height
"sub_frame_lift_rationale": "Pablo N feedback 2026-05-11: motor body bottom must clear frame top crossmember at full bump-stop drop. 75 mm spacer = original 53 mm clearance design intent restored (was lost when top_rail_z went -440 → -470 without spacer compensation).",
# ── geometry (modeled in CAD) ────────────────────────────────────
# 2026-05-09 phase-C: active turns 6 → 4 per Fork B engineering review.
# Item 4 analysis showed the previous k=50.27 spec failed under DAF=2 +
# 80/20 asymmetric dump (800 kg/spring → 156 mm deflection vs 104 mm
# linear range, coil-on-coil contact). New k=75.4 N/mm at n=4 puts the
# asymmetric+DAF case at 104.6 mm — at the linear-range limit, with
# bump stops as redundant safety net (Fork B item 6). Stiffer springs
# also improve isolation against drift; bump stops handle worst-case
# outliers not covered by the spring spec. Pablo decision 2026-05-09.
"quantity": 4,
"spring_od_mm": 100.0,
"spring_wire_d_mm": 12.0,
"spring_free_height_mm": 200.0,
"spring_active_turns": 4, # was 6 (k=50.27); 4 → k=75.4
"spring_total_turns": 6, # active + 2 ground/end coils
"bottom_cup_od_mm": 140.0,
"bottom_cup_height_mm": 42.0,
"top_cup_od_mm": 135.0,
"top_cup_height_mm": 32.0,
"cup_plate_t_mm": 6.0,
"cup_bolt_pcd_mm": 115.0,
"isolator_od_mm": 150.0,
"isolator_height_mm": 25.0,
"visual_l_brackets": "removed from CAD to eliminate visible plate-with-hole features between bin and frame near spring stacks",
# ── 2026-05-09 phase-C spring-stack engineering spec ──────────────
# k = G·d⁴ / (8·D³·n) with G=79.3 GPa, d=12, D=88 (=OD-d), n=4.
# k = 79,300 × 20,736 / (8 × 681,472 × 4) = 75.4 N/mm
"spring_rate_n_per_mm": 75.4, # per spring, n=4
"system_rate_n_per_mm": 301.6, # 4 springs in parallel
"natural_frequency_hz": 4.66, # at m_vib=350 kg (= 87.5 kg/spring)
"drive_frequency_hz": 25.0, # OLI MVE 800/3 at 1500 rpm
"isolation_ratio_pct": 96.4, # at drive/natural=5.36 → 1/(r²-1) = 0.036
"static_load_per_spring_kg_min": 88.0, # m_vib=350 kg empty
"static_load_per_spring_kg_max": 150.0, # m_vib=600 kg with peak sargasso
"static_deflection_at_rest_mm": 11.4, # 87.5 × 9.81 / 75.4
# Slug analysis cases (Fork B item 4):
"slug_transient_load_per_spring_kg": 300.0, # symmetric, no DAF: 300×g/75.4 = 39.0 mm
"slug_transient_deflection_mm": 39.0, # vs 104 mm linear range
"slug_asymmetric_daf2_load_per_spring_kg": 800.0, # 80/20 asymmetric × DAF=2
"slug_asymmetric_daf2_deflection_mm": 104.0, # right at the linear-range limit; bump stops cover
"max_linear_load_per_spring_kg": 800.0, # 104 × 75.4 / 9.81 ≈ 800
"max_shear_stress_at_slug_mpa": 459.0, # Wahl-corrected at slug peak (still primary slug case)
"material": "51CrV4 chrome-vanadium spring steel (EN 10089) or equivalent ASTM A229 oil-tempered",
"material_allowable_shear_mpa": 800.0, # typical 700-900 for 51CrV4 cycled
"surface_treatment": "shot-peened + zinc-rich epoxy (painted A36 cups handle corrosion; spring itself zinc-electroplated or HDG)",
"fatigue_rating_cycles": 10_000_000, # 10M cycles at 5-15% working deflection
"operating_temp_range_c": "−10 to +60", # outdoor coastal install, no heat source
"design_safety_factor_steady": 5.33, # max_linear / max_static = 800 / 150
"design_safety_factor_slug_symmetric": 2.67, # max_linear / slug_sym = 800 / 300
"design_safety_factor_slug_asymmetric_daf2": 1.0, # at the limit; bump stops absorb beyond
"candidate_suppliers": [
"Lesjöfors (SE) — vibration-rated heavy-duty compression",
"Century Spring (US) — cat. dia 100 mm × wire 12 mm series",
"Vanel (FR) — industrial vibrating-screen springs",
"Resortes Industriales (MX) — local sourcing for Pedro",
],
"deep_research_prompt": (
"Find a commercial helical compression spring meeting: OD 100 mm, "
"wire dia 12 mm, free length 200 mm, ~4 active turns (6 total), "
"spring rate 70-80 N/mm, static load capacity ≥800 kg, slug-shock "
"rating ≥800 kg momentary (asymmetric+DAF case), fatigue-rated for "
"≥10^7 cycles at 5-15% deflection, material 51CrV4 or equivalent, "
"outdoor coastal use (corrosion protection). Need 4 units. Quote MX "
"peso pricing if available; otherwise USD/EUR + lead time. Bias "
"toward suppliers reachable from Playa del Carmen, Mexico."
),
"spec_source": "2026-05-09 phase-C: derived from Fork B engineering analysis (analysis-pre-fab.html). Pablo decision 2026-05-09 to upgrade k=50.27 → 75.4 (n_active 6 → 4). Defense-in-depth posture: springs alone handle worst case at SF=1.0, bump stops are redundant safety. Vendor datasheet validation still pending — see deep_research_prompt.",
},
# ── Bump stops (Fork B item 6, Pablo decision 2026-05-09) ──────────────
"bump_stops": {
# 2026-05-11 Fork ε (Pablo N audit T): bracket length back to 295 mm.
# The Fork δ fix to 244 mm was correct when spacer was 23.8 mm and
# sub-frame underside at z=-60. With spacer 75 mm (Fork ε), sub-frame
# underside lifts to z=-8.8, so a 295 mm bracket now lands the stop
# bottom at z=-353.8 — exactly 40 mm above rail top (-393.8). Motor
# body bottom at z=-340.8 has 53 mm clearance to rail, dropping to
# 13 mm at bump-stop max engagement. Original design intent restored.
"included": True,
"purpose": "redundant safety: limit sub-frame downward travel to 40 mm beyond rest, prevent spring coil-on-coil AND keep motor body clear of frame top crossmember (clearance 53 mm rest, 13 mm at bump-stop max engagement)",
"quantity": 4,
"positions_xy_mm": [(250, 180), (250, 820), (2250, 180), (2250, 820)],
"mounted_on": "sub-frame underside (sub-frame spring crossmember at X=250/2250, directly above the short top rail of the frame below)",
# ── bracket ──
"bracket_material": "A36 RHS 60×60×4 (or A36 plate 12 mm welded as L-profile)",
"bracket_length_vertical_mm": 295.0,
"bracket_top_z_mm": -8.8, # sub-frame underside (post-lift)
"bracket_bottom_z_mm": -303.8, # 295 mm down from sub-frame underside
"bracket_weld": "fillet 6 mm continuous perimetral, both sides (TIG or MIG, vibration-loaded joint)",
# ── poly stop ──
"stop_material": "polyurethane, 60 Shore A",
"stop_diameter_mm": 80.0,
"stop_height_mm": 50.0,
"stop_attachment": "M16 axial bolt + Loctite 243",
"stop_top_z_mm": -303.8, # attaches to bracket bottom
"stop_bottom_z_mm": -353.8, # 50 mm below bracket bottom
# ── kinematics (verified against frame.top_rail_z_mm=−470 + tube=76.2) ──
"frame_top_rail_top_face_z_mm": -393.8, # top_rail_z_mm + tube_mm (unchanged)
"gap_to_frame_top_rail_at_rest_mm": 40.0, # |−353.8 − (−393.8)| = 40
"engages_at_delta_mm": 40.0, # sub-frame drops 40 mm → contact
"static_motor_to_rail_clearance_mm": 53.0, # motor body bottom (-340.8) - rail top (-393.8)
"motor_to_rail_residual_at_engage_mm": 13.0, # at full bump-stop engagement
# Cross-check vs spring deflection cases (k=75.4 N/mm):
# - Static rest: 11.4 mm spring deflection; 0 mm Δ from rest. No engage.
# - Slug nominal symmetric (300 kg/spring): 39.0 mm spring deflection;
# Δ from rest = 27.6 mm. NO engage (margin 12.4 mm to bump stop).
# - Slug asymmetric+DAF=2 (800 kg/spring): 104 mm spring deflection;
# Δ from rest = 92.6 mm. ENGAGES at Δ=40 mm; remaining 52.6 mm of
# energy absorbed by bump stop compression curve.
# ── candidate_suppliers ──
"candidate_suppliers": [
"Misumi VBSAW-80-50 (Mexico distribution)",
"Vibracon MA-80-50",
"Industrial Rubber Co. MX (custom-spec 60 ShA poly)",
],
"estimated_cost_mxn_per_unit": 1400.0, # poly stop ~$1200 + bracket ~$200
"estimated_cost_mxn_total": 5600.0, # 4 × $1400
"spec_source": "2026-05-09 Fork B item 6 analysis (analysis-pre-fab.html). Pablo decision 2026-05-09 — Config A (bracket from sub-frame, weld to spring crossmember). Δ=40 mm (was 50 mm in analysis page) due to 10 mm deeper sub-frame from 60×60×4 upgrade.",
},
"vibration_motors": {
"quantity": 2,
"type": "counter-rotating pair",
"commercial_reference": "OLI MVE 800/3 or equivalent",
"body_length_mm": 700.0,
"body_width_mm": 385.0,
"body_height_mm": 320.0,
"mount_plate_length_mm": 820.0,
"mount_plate_width_mm": 460.0,
"mount_plate_t_mm": 12.0,
# 2026-05-05 clash.py fix: motors spread from [1050, 1450] to [810, 1690].
# Old positions had 400 mm center-to-center spacing but 700 mm body length
# → 300 mm physical body overlap + 420 mm plate overlap. New 880 mm spacing
# gives 376 mm body gap + 60 mm plate gap.
"center_x_positions_mm": [810.0, 1690.0],
"center_y_mm": 500.0,
"fasteners": "4 × M16 per motor up through sub-frame motor cradle crossmembers (x=500/1120/1380/2000)",
},
"outlet_interface": {
"final_outlet_width_mm": 700.0,
"outlet_open_height_mm": 240.0,
"epdm_material": "EPDM bristle curtain, 60 Shore A",
"bristle_length_mm": 40.0,
"clamp_ring_removed": True,
"removed_physical_clamp_ring": "previous Ø650 PCD stainless ring removed; outlet rectangle corner diagonal requires about Ø740 clear and ring served no visual purpose",
"bolt_circle_pcd_mm": None,
"fasteners": "none modeled at the circular outlet interface",
"fit_check": "700×240 outlet rectangle and 40 mm bristle bundle sit comfortably inside Ø900 cone-mouth ID reference",
},
"service_access": {
"spring_hatches": "removed from CAD: no floating yellow spring-stack side inspection covers, no yellow plate-with-hole parts near springs",
# 2026-05-05 removed: motor underside placeholder guard. Bible §13.2 requires proper feeder-drive guarding (sheet-metal enclosure with tool-only removal + Spanish warning labels + LOTO-compatible hardware) — that's a V2.1 task, not the demo fab pack. The single-plate placeholder gave a false sense of compliance.
"motor_hatch": "deferred to V2.1 — proper §13.2 enclosure designed once demo unit operational behavior is observed",
"operator_visibility": "open bin top and marked fill-height gauge; feeder HMI on right-side frame upright",
},
"controls_estop": {
# 2026-05-06 phase-B: bible §13.4 mandates "feed side and discharge
# /maintenance side minimum" E-stops. The HMI panel on the front-right
# leg is the discharge-side E-stop; this is the feed-side unit.
"included": True,
"name_prefix": "ESTOP · ",
"mount_side": "rear / loader-facing leg, right side (mirrors HMI)",
"face_direction": "+Y outward (operator approach side)",
"enclosure_mm": [150.0, 80.0, 120.0], # X×Y×Z — smaller than HMI 300×100×200
"button_diameter_mm": 30.0,
"button_proud_mm": 10.0,
"wiring": "2-pole NC mushroom (Ø22), wired in series with HMI E-stop into the motor contactor hold-in circuit. Pressing either button kills both vibration motors. Twist-to-release.",
},
# Q23 / Andi item 16 noise envelope — Fork C closeout 2026-05-11.
# Hotels don't have authoritative dB limits to give us, so V2 declares the
# envelope it can deliver and the install rule that keeps us inside
# NOM-081-SEMARNAT-1994. Sites that can't satisfy the buffer trigger the
# V2.1 acoustic enclosure (separate scope, not in current fab pack).
#
# Tier-1 inputs:
# - NOM-081-SEMARNAT-1994: 65 dBA day, 55 dBA night at residential
# property line (hotels = residential category).
# - OLI MVE 800/3 datasheet: 85-92 dBA at 1 m.
# - Inverse-square attenuation: ~6 dB drop per doubling of distance.
# Tier-2 derivation:
# - 85 dBA @ 1 m → ~55 dBA at 32 m in open air.
# - With 20 m buffer + VFD low-freq tuning to target 75 dBA @ 1 m:
# reaches ~49 dBA at 20 m (16 dB margin vs day, 6 dB vs night).
"noise_envelope": {
# 2026-05-11 Fork δ audit: design target revised 75 → 82 dBA @ 1 m.
# Prior 75 target required 13 dB reduction from raw 88; VFD low-freq
# tuning + vibration damping realistically delivers 5-9 dB. 82 dBA is
# honestly achievable (5-7 dB reduction). With 20 m buffer + daytime-
# only ops, still meets NOM-081 day limit with 9 dB margin. Night
# limit not met → operating window is daytime only (the regulatory
# gate that closes the loop). V2.1 enclosure unchanged.
"design_target_dba_at_1m": 82.0, # via VFD low-freq tuning + vibration damping (5-7 dB from raw)
"raw_motor_dba_at_1m": 88.0, # OLI MVE 800/3 datasheet midpoint of 85-92 dBA
"achievable_reduction_db": "5-7 dB via VFD low-freq tuning + frame vibration damping (rubber isolators between sub-frame and motor cradle); 13 dB would require enclosure",
"min_property_line_buffer_m": 20.0, # procedural install rule
"operating_window": "07:00-18:00 daytime only (night ops requires V2.1 enclosure)",
"regulatory_reference": "NOM-081-SEMARNAT-1994 (65 dBA day / 55 dBA night at residential property line)",
"expected_dba_at_buffer": 56.0, # 82 - 20×log10(20) = 82 - 26 = 56 dBA at 20 m
"compliance_margin_day_db": 9.0, # 65 - 56
"compliance_margin_night_db": -1.0, # 55 - 56 → fails night (closed by daytime-only ops rule)
# When the buffer can't be satisfied OR night ops needed, the V2.1
# acoustic enclosure (rockwool 25 mm + sheet-metal box, 15-25 dB
# attenuation) is bundled in pre-sale. Not in current V2 fab pack scope.
# With V2.1 enclosure: 82 → 57-67 dBA at 1 m → 31-41 at 20 m → night-compliant.
"v21_enclosure_trigger": "site buffer < 20 m, OR night-shift operation required",
"v21_enclosure_attenuation_db": "15-25 dB (rockwool 25 mm + 1 mm sheet metal box)",
},
"controls_hmi": {
"included": True,
"name_prefix": "HMI · ",
"mount_side": "right side / +Y operator approach side",
"face_direction": "+Y outward, away from bin body",
"enclosure_mm": [300.0, 100.0, 200.0],
"controls": ["green START button", "yellow WARN button/light", "red E-STOP mushroom"],
"button_diameter_mm": 30.0,
"button_proud_mm": 10.0,
"mount_bracket": "A36 5 mm bracket on front-right frame upright",
"duplicate_hmi_policy": "Only this single upright-mounted HMI is modeled; no HMI-named floating box is allowed on the cable tray",
},
"cable_management": {
"tray_material": "316L perforated cable tray, 100 × 50 mm",
"route": "right-side static frame rail to junction box and HMI, flexible loops to vibrating motors",
"junction_box": "IP66 stainless junction box, clearly not an HMI panel",
# 2026-05-05: position constants. tray/box must touch the right frame outer face
# at y = bin_width - bin_frame_inset_y = 1000 - 90 = 910 mm. tray sits ON TOP of
# the right frame top rail (z = top_rail_z + tube). Box hangs OFF the frame upright.
"tray_inner_y_mm": 1085.0, # tray inner-Y wall flush with bracket FRONT face (= frame_outer + 5 mm bracket thickness)
# 2026-05-06: tray_length 2100 → 1900 to fit inside the new frame X
# extents (210-2290 = length 2080). Was sticking out 110 mm past front.
"tray_length_mm": 1900.0,
"tray_x_start_mm": 300.0,
"tray_z_offset_above_top_rail_mm": 5.0, # 5 mm clearance above tube top
"tray_bracket_count": 5,
"junction_box_dims_mm": [220.0, 90.0, 180.0], # [X, Y, Z]
"junction_box_inner_y_mm": 1085.0, # box -Y face flush with bracket FRONT face (= frame_outer + 5 mm bracket thickness)
# 2026-05-06: was 2050 → overlapped HMI enclosure (Pablo flagged the
# white box sitting on the dark-blue HMI). HMI body spans X=2100..2400;
# JB body spans (jb_x)..(jb_x+220). Moved to 1700 → JB X=1700..1920,
# 180 mm clearance forward of HMI. JB brackets at x+10 and x+150 (world
# 1710, 1850) still attach to the right top rail (rail X=210..2290).
"junction_box_x_mm": 1700.0,
# 2026-05-05 fix: was -260, but at that Z the box had no frame member to
# bolt to (top rail at -415 to -335, leg at -1324 to -415, gap in between).
# Now -435: bracket vertical leg z=30-90 → world z=-405 to -345, INSIDE
# top rail z range -415 to -335. Box body z=-435 to -255 (operator-reach).
# 2026-05-06: was -435 → box top z=-255 cut UP through the cable tray
# (tray z=-330..-280; 50 mm of overlap). Now -520: box body z=-520..-340,
# top is 5 mm below rail top (z=-335) and 10 mm below tray bottom
# (z=-330). Brackets lengthened so the vertical leg still engages the
# rail (see make_junction_box_brackets — leg now LOCAL z=-5..185).
"junction_box_z_mm": -575.0,
"junction_box_door_face": "+Y outward (operator side); door MUST NOT face the bin",
"junction_box_bracket_count": 2,
},
"fastener_styles": {
"color_note": "All modeled M8/M10/M12/M16/M20 bolts, nuts, washers use zinc/steel color; no M6 circular clamp hardware modeled",
"M6": {"shank_d_mm": 6.0, "head_af_mm": 10.0, "head_h_mm": 4.0, "washer_od_mm": 14.0},
"M8": {"shank_d_mm": 8.0, "head_af_mm": 13.0, "head_h_mm": 5.5, "washer_od_mm": 18.0},
"M10": {"shank_d_mm": 10.0, "head_af_mm": 17.0, "head_h_mm": 7.0, "washer_od_mm": 22.0},
"M12": {"shank_d_mm": 12.0, "head_af_mm": 19.0, "head_h_mm": 8.0, "washer_od_mm": 26.0},
"M16": {"shank_d_mm": 16.0, "head_af_mm": 24.0, "head_h_mm": 10.0, "washer_od_mm": 34.0},
"M20": {"shank_d_mm": 20.0, "head_af_mm": 30.0, "head_h_mm": 13.0, "washer_od_mm": 42.0},
},
"fastener_summary": {
"M6": "none at outlet interface; circular clamp ring/hand knobs removed",
"M8": "bin/funnel-wall gusset bolts + cable-tray/JBox/HMI bracket bolts (deflector-to-hanger joint switched to weld 2026-05-06)",
"M10": "spring bottom-cup-to-frame bolts/weld nuts + side X-brace endpoint bolts",
"M12": "spring top-cup-to-SUB-FRAME bolts + bin-to-SUB-FRAME perimeter anchor bolts",
"M16": "motor-mount-to-SUB-FRAME-cradle bolts",
"M20": "4 frame angle-adjust jack screws",
},
"outputs": {
"glb": "feeder-plan-d-v2-pablo-fix.glb",
"step": "feeder-plan-d-v2-pablo-fix.step",
},
}from __future__ import annotations
import math
import sys
from typing import Iterable
import cadquery as cq
from spec import SPEC
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
def color(r, g, b, a=1.0):
return cq.Color(r, g, b, a)
COLORS = {
"a36": color(0.58, 0.60, 0.62),
"a36_dark": color(0.36, 0.37, 0.39),
"stainless": color(0.82, 0.84, 0.84),
"epdm": color(0.02, 0.02, 0.02),
"spring": color(0.12, 0.18, 0.68),
"motor": color(0.15, 0.16, 0.18),
"fastener": color(0.70, 0.72, 0.75),
"safety": color(0.95, 0.72, 0.12),
"cable": color(0.03, 0.03, 0.03),
"reference_grey": color(0.78, 0.80, 0.82, 0.22),
"reference_blue": color(0.45, 0.75, 1.00, 0.28),
"hmi_box": color(0.18, 0.20, 0.22),
"green": color(0.00, 0.62, 0.18),
"red": color(0.85, 0.04, 0.03),
"amber": color(1.00, 0.55, 0.04),
}
def box_xyz(x, y, z, loc=(0, 0, 0)):
return cq.Workplane("XY").box(x, y, z, centered=(False, False, False)).translate(loc)
def cyl_y(length, radius, loc=(0, 0, 0)):
return cq.Workplane("XZ").circle(radius).extrude(length).translate(loc)
def hex_prism(height, across_flats, loc=(0, 0, 0)):
return cq.Workplane("XY").polygon(6, 2 * across_flats / math.sqrt(3)).extrude(height).translate(loc)
def washer(od, id_, t):
return cq.Workplane("XY").circle(od / 2).circle(id_ / 2).extrude(t)
def fuse(parts: Iterable[cq.Workplane]):
vals = [p.val() for p in parts]
solid = vals[0]
for v in vals[1:]:
solid = solid.fuse(v)
return cq.Workplane("XY").add(solid)
def add(asm, part, name, col):
asm.add(part, name=name, color=col)
def bolt_vertical(size, length, head_up=True):
f = SPEC["fastener_styles"][size]
shank = cq.Workplane("XY").circle(f["shank_d_mm"] / 2).extrude(length)
head = hex_prism(f["head_h_mm"], f["head_af_mm"]).translate((0, 0, length if head_up else -f["head_h_mm"]))
return fuse([shank, head])
def bolt_stack(size, length):
f = SPEC["fastener_styles"][size]
return fuse([
bolt_vertical(size, length),
washer(f["washer_od_mm"], f["shank_d_mm"] + 1, 2.5).translate((0, 0, length)),
])
def make_bin_wall_gusset(side="left", length_x=80.0, plate_t=5.0, arm=30.0):
"""L-bracket gusset reinforcing the bin wall ↔ bin floor inside corner.
Built at LOCAL origin (0, 0, 0) which goes to the inside-bin corner of
wall + floor. Two flanges:
- Vertical flange: against the wall's INSIDE face, plate_t thick in Y, arm tall in Z.
- Horizontal flange: on top of the floor, arm wide in Y, plate_t thick in Z.
side="left" → flanges extend +Y, +Z (wall is at -Y of corner).
side="right" → flanges extend -Y, +Z (wall is at +Y of corner).
"""
if side == "left":
vertical = box_xyz(length_x, plate_t, arm, (-length_x / 2, 0, 0))
horizontal = box_xyz(length_x, arm, plate_t, (-length_x / 2, 0, 0))
else: # right
vertical = box_xyz(length_x, plate_t, arm, (-length_x / 2, -plate_t, 0))
horizontal = box_xyz(length_x, arm, plate_t, (-length_x / 2, -arm, 0))
return fuse([vertical, horizontal])
def make_back_wall_gusset(length_y=80.0, plate_t=5.0, arm=30.0):
"""L-bracket gusset for the rear (loader-impact) wall ↔ bin floor inside
corner. Local origin at the inside-bin corner: caller translates to
(b.wall_thickness_mm, gy_center, b.floor_thickness_mm).
Vertical flange (against rear wall inner face): plate_t in X, length_y in Y, arm in Z.
Horizontal flange (on bin floor, extending +X into bin): arm in X, length_y in Y, plate_t in Z.
"""
vertical = box_xyz(plate_t, length_y, arm, (0, -length_y / 2, 0))
horizontal = box_xyz(arm, length_y, plate_t, (0, -length_y / 2, 0))
return fuse([vertical, horizontal])
def make_funnel_wall_gusset(side="left", length_along=80.0, plate_t=5.0, arm=30.0):
"""L-bracket gusset reinforcing the angled funnel wall ↔ bin floor corner.
Built at LOCAL origin (0, 0, 0) which is intended to land on the funnel
wall OUTER face line (the dead-zone side, between funnel wall and bin
side wall). Caller rotates by the funnel taper angle around Z and
translates to the funnel-wall outer-face position.
- Vertical flange: against funnel wall outer face, plate_t in Y, arm in Z.
- Horizontal flange: on bin floor, arm in Y (outward into dead zone), plate_t in Z.
side="left" → flanges extend toward LOCAL -Y (post-rotation: into the
dead zone between left funnel wall and left bin side wall).
side="right" → flanges extend toward LOCAL +Y (into right-side dead zone).
"""
if side == "left":
vertical = box_xyz(length_along, plate_t, arm, (-length_along / 2, -plate_t, 0))
horizontal = box_xyz(length_along, arm, plate_t, (-length_along / 2, -arm, 0))
else: # right
vertical = box_xyz(length_along, plate_t, arm, (-length_along / 2, 0, 0))
horizontal = box_xyz(length_along, arm, plate_t, (-length_along / 2, 0, 0))
return fuse([vertical, horizontal])
def horizontal_bolt_y(size, length, head_at_plus_y=True):
"""Bolt with shank along the Y axis (instead of the default Z).
Use for joints where the mating surfaces are oriented along Y (i.e. the
bolt has to go ACROSS Y, e.g. through a bracket back face into a frame
upright outer face). Origin = shank base.
head_at_plus_y=True → shank y=0..length, head at +Y end (length and beyond).
head_at_plus_y=False → shank y=0..-length, head at -Y end.
Implementation: take the existing vertical bolt_stack and rotate ±90° around
the X axis. -90° around X maps Z+ → Y+, putting head at +Y. +90° maps Z+ → Y-.
"""
angle = -90.0 if head_at_plus_y else 90.0
return bolt_stack(size, length).rotate((0, 0, 0), (1, 0, 0), angle)
def spring_positions():
b = SPEC["bin"]
return [
(b["spring_mount_inset_x_mm"], b["spring_mount_inset_y_mm"]),
(b["length_mm"] - b["spring_mount_inset_x_mm"], b["spring_mount_inset_y_mm"]),
(b["spring_mount_inset_x_mm"], b["width_mm"] - b["spring_mount_inset_y_mm"]),
(b["length_mm"] - b["spring_mount_inset_x_mm"], b["width_mm"] - b["spring_mount_inset_y_mm"]),
]
def frame_leg_lower_xy():
b, f = SPEC["bin"], SPEC["support_frame"]
tube = f["tube_mm"]
ix, iy = f["bin_frame_inset_x_mm"], f["bin_frame_inset_y_mm"]
return [
(ix, iy),
(b["length_mm"] - ix - tube, iy),
(ix, b["width_mm"] - iy - tube),
(b["length_mm"] - ix - tube, b["width_mm"] - iy - tube),
]
def frame_leg_centers_xy():
tube = SPEC["support_frame"]["tube_mm"]
return [(x + tube / 2, y + tube / 2) for x, y in frame_leg_lower_xy()]
def make_bin_floor():
b = SPEC["bin"]
return box_xyz(b["length_mm"], b["width_mm"], b["floor_thickness_mm"])
def make_bin_side_wall(left):
b = SPEC["bin"]
t = b["wall_thickness_mm"]
y = 0 if left else b["width_mm"] - t
wall = box_xyz(b["length_mm"], t, b["wall_height_mm"], (0, y, b["floor_thickness_mm"]))
fold = box_xyz(
b["length_mm"],
b["top_fold_return_mm"],
t,
(0, y if left else b["width_mm"] - b["top_fold_return_mm"], b["floor_thickness_mm"] + b["wall_height_mm"] - t),
)
return fuse([wall, fold])
def make_bin_rear_wall():
b = SPEC["bin"]
t = b["wall_thickness_mm"]
wall = box_xyz(t, b["width_mm"], b["wall_height_mm"], (0, 0, b["floor_thickness_mm"]))
impact = box_xyz(8, b["width_mm"] - 2 * t, b["rear_loader_impact_plate_height_mm"], (t, t, b["floor_thickness_mm"] + 30))
return fuse([wall, impact])
def make_funnel_side(left):
b = SPEC["bin"]
fo = b["integrated_outlet_funnel"]
t = b["wall_thickness_mm"]
x0, x1 = fo["taper_start_x_mm"], b["length_mm"]
y_out = (b["width_mm"] - fo["final_outlet_width_mm"]) / 2
y0 = t if left else b["width_mm"] - t
y1 = y_out if left else b["width_mm"] - y_out
sign = 1 if left else -1
return (
cq.Workplane("XY")
.polyline([(x0, y0), (x1, y1), (x1, y1 + sign * t), (x0, y0 + sign * t)])
.close()
# 2026-05-06: funnel side walls now extrude to match the bin wall height
# (650 mm), not the old 240 mm outlet_lip_height. Bin walls + funnel
# walls are now flush at the top.
.extrude(b["wall_height_mm"])
.translate((0, 0, b["floor_thickness_mm"]))
)
# 2026-05-06: make_front_lip() removed — see git blame for the original 5-line
# helper. Bible §11.1 only requires a 'front short edge' for material to cascade
# off; doesn't mandate a 90 mm sill. Sargazo now spills over the bare funnel-floor edge.
def make_under_doubler():
dx, dy, dz = SPEC["bin"]["underside_doubler_plate_mm"]
return box_xyz(dx, dy, dz, (-dx / 2, -dy / 2, -dz))
def make_frame():
b, f = SPEC["bin"], SPEC["support_frame"]
tube = f["tube_mm"]
# 2026-05-11 PRT-30043 Fork B: short rails (rear+front, where the spring
# lower cups land) change from solid solera 150×76.2 → PTR 80×80×4 +
# 2 reinforcement blocks 150×120×25 mm on top of each rail at the spring
# Y positions. Saves ~125 kg/machine. Geometry below approximates the
# PTR as a solid 80×80 box (wall hollowing is cosmetic for the GLB);
# the reinforcement blocks are added as separate 150×120×25 boxes on top.
# (Fork δ audit 2026-05-11: ly_mm 100→120 for Nord-Lock washer pair clearance.)
short_tube = 80.0 # PTR 80×80×4 (Fork B) — was 150-wide solera
block_lx = 150.0 # reinforcement block X width (matches PCD-115 clearance)
block_ly = 120.0 # reinforcement block Y length (Fork δ: 100→120)
block_lz = 25.0 # reinforcement block thickness
spring_y_centers = [180.0, 820.0] # 2 springs per short rail
short_x_offset = (tube - short_tube) / 2.0
ix, iy, z = f["bin_frame_inset_x_mm"], f["bin_frame_inset_y_mm"], f["top_rail_z_mm"]
length, width = b["length_mm"] - 2 * ix, b["width_mm"] - 2 * iy
parts = [
# 2 long rails (unchanged)
box_xyz(length, tube, tube, (ix, iy, z)),
box_xyz(length, tube, tube, (ix, iy + width - tube, z)),
# 2 short rails — now PTR 80×80 (was solera 150×76.2)
box_xyz(short_tube, width, short_tube, (ix + short_x_offset, iy, z)),
box_xyz(short_tube, width, short_tube, (ix + length - tube + short_x_offset, iy, z)),
]
# Fork B reinforcement blocks (4 total, 2 per short rail)
for x_rail_center in [ix + short_x_offset + short_tube/2, ix + length - tube + short_x_offset + short_tube/2]:
for y_spring in spring_y_centers:
parts.append(box_xyz(
block_lx, block_ly, block_lz,
(x_rail_center - block_lx/2, y_spring - block_ly/2, z + short_tube)
))
# 2026-05-06 (later) per Pablo: bin_frame_inset_x_mm changed 120 → 210, so
# the perimeter short rails now sit at X=210-290 (rear) and X=2210-2290
# (front), CENTERED on the spring positions at X=250 and X=2250. Removed
# the explicit X=250 / X=2250 crossmembers — they're now the perimeter
# short rails. 5 internal crossmembers remain.
for x in (650, 950, 1250, 1550, 1850):
parts.append(box_xyz(tube, width, tube, (x, iy, z)))
return fuse(parts)
def make_sub_frame_perimeter():
"""2026-05-05: vibrating sub-frame perimeter rails (A36 RHS 50×50×3).
Top at z=0 (bin underside), bottom at z=-50. Long rails span full bin
length; short end rails close the rectangle between the long rails.
"""
sf = SPEC["sub_frame"]
tube = sf["tube_mm"]
L, W = sf["footprint_length_mm"], sf["footprint_width_mm"]
z0 = sf["bottom_z_mm"]
parts = []
for y_c in sf["long_rail_y_centers_mm"]:
parts.append(box_xyz(L, tube, tube, (0, y_c - tube / 2, z0)))
for x_c in sf["short_rail_x_centers_mm"]:
parts.append(box_xyz(tube, W - 2 * tube, tube, (x_c - tube / 2, tube, z0)))
return fuse(parts)
def make_sub_frame_crossmembers(x_centers):
sf = SPEC["sub_frame"]
tube = sf["tube_mm"]
W = sf["footprint_width_mm"]
z0 = sf["bottom_z_mm"]
parts = [box_xyz(tube, W - 2 * tube, tube, (xc - tube / 2, tube, z0)) for xc in x_centers]
return fuse(parts)
def bin_anchor_bolt_positions():
"""12 M12 bin-to-sub-frame perimeter anchor bolt XY positions.
5 along each long side at the long rail centerlines, 1 mid-Y on each
short end rail. Drives 12 weld nuts on sub-frame top.
"""
sf = SPEC["sub_frame"]
long_y = sf["long_rail_y_centers_mm"]
short_x = sf["short_rail_x_centers_mm"]
long_xs = [250.0, 750.0, 1250.0, 1750.0, 2250.0]
pts = []
for y in long_y:
for x in long_xs:
pts.append((x, y))
for x in short_x:
pts.append((x, sf["footprint_width_mm"] / 2.0))
return pts
def make_leg(height=None):
"""Feeder frame leg. 2026-05-04 BAKE-IN: takes optional height arg.
Default: front-leg height (base_z to top_rail_z, ~959 mm). Rear legs
use front + 144 mm for 3° bake-in tilt over 2740 mm frame wheelbase.
"""
f = SPEC["support_frame"]
tube = f["tube_mm"]
h = height if height is not None else abs(f["base_z_mm"] - f["top_rail_z_mm"])
return fuse([box_xyz(tube, tube, h), box_xyz(180, 180, 10, (-50, -50, -10))])
def make_jack(height):
fs = SPEC["fastener_styles"]["M20"]
adj = SPEC["angle_adjust"]
rod = cq.Workplane("XY").circle(fs["shank_d_mm"] / 2).extrude(height)
foot = cq.Workplane("XY").circle(adj["foot_pad_diameter_mm"] / 2).circle(adj["foot_pad_anchor_hole_d_mm"] / 2).extrude(adj["foot_pad_t_mm"]).translate((0, 0, -adj["foot_pad_t_mm"]))
return fuse([rod, foot, hex_prism(fs["head_h_mm"], fs["head_af_mm"], (0, 0, 140)), hex_prism(fs["head_h_mm"], fs["head_af_mm"], (0, 0, height - 42))])
def make_brace(p1, p2, y):
x1, z1 = p1
x2, z2 = p2
L = math.hypot(x2 - x1, z2 - z1)
ang = -math.degrees(math.atan2(z2 - z1, x2 - x1))
return box_xyz(L, 5, 30, (0, -2.5, -15)).rotate((0, 0, 0), (0, 1, 0), ang).translate((x1, y, z1))
def make_brace_yz(p1, p2, x):
"""Y-Z plane diagonal brace at constant X. Used for front + rear
X-braces (added 2026-05-06 per Pablito's review: he wanted X-braces on
all 4 sides, not just left + right). Same 30 × 5 mm flat-bar stock as
side braces, just rotated 90° about the vertical axis.
p1, p2: (Y, Z) endpoint tuples. Brace cross-section: 5 mm in X
(out-of-plane), 30 mm in the brace-plane perpendicular direction.
"""
y1, z1 = p1
y2, z2 = p2
L = math.hypot(y2 - y1, z2 - z1)
ang = math.degrees(math.atan2(z2 - z1, y2 - y1))
# Local box: 5 mm thick (X), L long (Y), 30 mm tall (Z), centered on Y origin
return box_xyz(5, L, 30, (-2.5, 0, -15)).rotate((0, 0, 0), (1, 0, 0), ang).translate((x, y1, z1))
def make_x_brace_cross_tie(orientation, position):
"""Small 30 × 30 × 5 mm A36 plate welded at the X-intersection of an
X-brace pair to halve the unsupported diagonal length and quadruple
Euler P_cr (28 → 115 kg). Standard industrial pattern for thin-bar
bracing (Pedro/Pablito review 2026-05-06).
orientation: "side" (X-Z plane brace, plate sits in X-Z plane at constant Y)
"fb" (Y-Z plane brace, plate sits in Y-Z plane at constant X)
position: world (X, Y, Z) of the X-intersection point.
"""
px, py, pz = position
if orientation == "side":
# plate spans X-Z plane: 30 (X) × 5 (Y, out-of-plane) × 30 (Z), centered on intersection
return box_xyz(30, 5, 30, (px - 15, py - 2.5, pz - 15))
else: # fb
# plate spans Y-Z plane: 5 (X, out-of-plane) × 30 (Y) × 30 (Z)
return box_xyz(5, 30, 30, (px - 2.5, py - 15, pz - 15))
def make_spring_visual():
"""Visual spring as a stack of tori spanning the full free_height. Use
total_turns (=active + 2 ground/end coils) so the bottom + top tori
sit flush at z=0 and z=free_height — matching where the cups land.
2026-05-06: was using active_turns + pitch=free/active, which placed
the top torus at ~free*(active-1)/active + 2*minor, leaving a
~free/active gap below the upper cup (19 mm at n=6).
"""
ss = SPEC["spring_stack"]
major = (ss["spring_od_mm"] - ss["spring_wire_d_mm"]) / 2
minor = ss["spring_wire_d_mm"] / 2
n = ss.get("spring_total_turns", ss["spring_active_turns"] + 2)
free_h = ss["spring_free_height_mm"]
pitch = (free_h - 2 * minor) / (n - 1)
solids = [
cq.Solid.makeTorus(major, minor).moved(cq.Location(cq.Vector(0, 0, minor + i * pitch)))
for i in range(n)
]
s = solids[0]
for r in solids[1:]:
s = s.fuse(r)
return cq.Workplane("XY").add(s)
def make_cup(top=False):
ss = SPEC["spring_stack"]
od = ss["top_cup_od_mm"] if top else ss["bottom_cup_od_mm"]
h = ss["top_cup_height_mm"] if top else ss["bottom_cup_height_mm"]
t = ss["cup_plate_t_mm"]
cup = fuse([
cq.Workplane("XY").circle(od / 2).extrude(t),
cq.Workplane("XY").circle(od / 2).circle(od / 2 - t).extrude(h),
])
for a in (45, 135, 225, 315):
r = ss["cup_bolt_pcd_mm"] / 2
cup = cup.cut(cq.Workplane("XY").circle(6).extrude(h + 3).translate((r * math.cos(math.radians(a)), r * math.sin(math.radians(a)), -1)))
return cup
def make_spring_spacer():
"""A36 spacer plate beneath each spring lower cup ('platform per spring',
Pablo's voice note 2026-05-06 [DEV] v2). 150 × 150 × 30 mm. Welded to the
frame top rail; lower spring cup bolts to the spacer's top face. Lifts
the spring stack 30 mm above the rail, paired with a 30 mm rail drop so
the spring stack itself stays at the same world Z.
"""
ss = SPEC["spring_stack"]
lx = ss["spacer_plate_lx_mm"]
ly = ss["spacer_plate_ly_mm"]
t = ss["spacer_plate_t_mm"]
return box_xyz(lx, ly, t, (-lx / 2, -ly / 2, 0))
def make_motor_mount():
m = SPEC["vibration_motors"]
return box_xyz(m["mount_plate_length_mm"], m["mount_plate_width_mm"], m["mount_plate_t_mm"], (-m["mount_plate_length_mm"] / 2, -m["mount_plate_width_mm"] / 2, -m["mount_plate_t_mm"]))
def make_motor():
"""OLI MVE 800/3 envelope. Body top at LOCAL z=0 so that translating to
the motor mount plate's z (z=motor_origin_z) puts the body flush with
plate bottom. 2026-05-05 fix: was z_offset=-H*0.82 → body top at z=-64
locally → 64 mm gap below plate. All sub-shape z offsets shifted up by
H*0.20 to put body top at z=0 (touching plate, motor hangs below)."""
m = SPEC["vibration_motors"]
L, W, H = m["body_length_mm"], m["body_width_mm"], m["body_height_mm"]
body = box_xyz(L * 0.72, W * 0.72, H * 0.62, (-L * 0.36, -W * 0.36, -H * 0.62))
end1 = cyl_y(W * 0.16, H * 0.28, (-L * 0.46, -W * 0.08, -H * 0.30))
end2 = cyl_y(W * 0.16, H * 0.28, (L * 0.38, -W * 0.08, -H * 0.30))
terminal = box_xyz(120, 80, 70, (-60, W * 0.36, -H * 0.25))
return fuse([body, end1, end2, terminal])
def make_reference_ring(dia, tube=8):
b = SPEC["bin"]
zc = b["floor_thickness_mm"] + SPEC["outlet_interface"]["outlet_open_height_mm"] / 2
return cq.Workplane("YZ").circle(dia / 2).circle(dia / 2 - tube).extrude(5).translate((b["length_mm"] + 35, b["width_mm"] / 2, zc))
def make_cable_tray(length):
"""U-channel tray, opens UP. Bottom plate 100 mm wide × 4 mm thick;
two vertical walls 50 mm tall × 4 mm thick at y=0 and y=96.
Local origin: tray inner-bottom-rear corner (X=0, Y=0, Z=0).
Caller places origin so Y=0 sits AT the static frame outer face.
"""
return fuse([box_xyz(length, 100, 4), box_xyz(length, 4, 50), box_xyz(length, 4, 50, (0, 96, 0))])
def make_cable_tray_brackets(length, n=5):
"""L-brackets every length/n along X, hanging the tray off the frame upright.
Each bracket: 60 mm Y leg (against frame outer face) + 100 mm Y leg
(under tray bottom). 5 mm A36 plate.
Translate point matches tray_y = 1085 (= bracket FRONT face = tray inner
wall position). Vertical flange therefore lives at LOCAL y=-5..0 (= world
y=1080..1085), TOUCHING the frame outer face at y=1080. Earlier I tried
y=0..5 thinking the translate point was at the frame outer face, but it's
at the bracket front face — so the flange should extend BACKWARD (-Y) to
touch the frame, not FORWARD.
"""
parts = []
for i in range(n):
x = (i + 0.5) * (length / n)
# vertical leg local y=-5..0: bracket back face at frame outer (y=1080) after translate.
parts.append(box_xyz(60, 5, 80, (x - 30, -5, -80)))
# horizontal leg under tray bottom: 5mm thick in Z, spans X=x to x+60, Y outward 100
parts.append(box_xyz(60, 100, 5, (x - 30, 0, -5)))
return fuse(parts)
def make_junction_box(door_outward=True):
"""IP66 junction box. door_outward=True puts the door on the +Y face
(operator-facing). Local origin at body rear-left-bottom corner.
Body 220×90×180 (X×Y×Z). Door is an 8 mm proud panel on the +Y or -Y face.
"""
body = box_xyz(220, 90, 180)
if door_outward:
# Door on +Y face (Y = 90 outer surface)
door = box_xyz(230, 8, 190, (-5, 90, -5))
else:
door = box_xyz(230, 8, 190, (-5, -8, -5))
return fuse([body, door])
def make_junction_box_brackets():
"""2 L-brackets attaching junction box -Y face to frame outer face.
Translate point matches box_y = 1085 (= bracket front face = box back face).
Vertical flange at LOCAL y=-5..0 → world y=1080..1085 = touching frame
outer face at y=1080. (Earlier confusion: tried y=0..5 which floated 5 mm
past the frame.)
2026-05-06: vertical leg lengthened from 60 mm (LOCAL z=30..90) → 190 mm
(LOCAL z=-5..185) so that with junction_box_z_mm=-520 the leg spans world
z=-525..-335, engaging the right top rail at z=-415..-335 AND wrapping
around the box back face. Box top now sits at z=-340, 5 mm below rail top
and 10 mm below cable tray bottom (z=-330) — no Z overlap with the tray.
"""
parts = []
for x in (10, 150): # 2 brackets along the box X span
parts.append(box_xyz(60, 5, 190, (x, -5, -5))) # tall vertical leg: box-bottom to rail-top
parts.append(box_xyz(60, 60, 5, (x, 0, -5))) # horizontal leg under box
return fuse(parts)
def make_hmi_enclosure():
sx, sy, sz = SPEC["controls_hmi"]["enclosure_mm"]
return fuse([
box_xyz(sx, sy, sz, (-sx / 2, 0, -sz / 2)),
box_xyz(sx + 10, 6, sz + 10, (-sx / 2 - 5, sy, -sz / 2 - 5)),
])
def make_hmi_bracket(reach=55):
"""HMI mounting bracket. Back plate sits against the front leg outer face
(140 mm wide to match the 80 mm leg + small overhang). Front plate is
sized to fully back the HMI enclosure (300×200 mm) so its 4 corner mount
bolts (at ±130, ±80 from HMI center) land 30 mm and 30 mm inside plate
edges. 2026-05-06: front plate enlarged 220×160 → 320×220 because the
HMI bolts at (±130, ±80) sat 20 mm OUTSIDE the old plate's X half-extent
(110) — the screws went through air. Top/bottom caps widened 180→320 to
span the new front-plate width.
"""
return fuse([
box_xyz(140, 5, 180, (-70, 0, -90)), # back plate: against frame upright
box_xyz(320, 5, 220, (-160, reach, -110)), # front plate: backs HMI body
box_xyz(320, reach, 5, (-160, 0, 105)), # top cap (5 mm above front plate top)
box_xyz(320, reach, 5, (-160, 0, -110)), # bottom cap
])
def hmi_button(radius=15, proud=10):
return cyl_y(proud, radius)
def make_estop_enclosure():
"""Feed-side E-stop enclosure (NEMA 4X). Smaller than the HMI panel —
houses only the Ø22 red mushroom switch + wire entry, ~150×80×120 mm.
Bible §13.4 mandates "feed side and discharge/maintenance side minimum"
E-stops; this is the feed-side unit. Local origin: rear-left-bottom corner.
"""
sx, sy, sz = SPEC["controls_estop"]["enclosure_mm"]
return fuse([
box_xyz(sx, sy, sz, (-sx / 2, 0, -sz / 2)),
box_xyz(sx + 8, 5, sz + 8, (-sx / 2 - 4, sy, -sz / 2 - 4)), # door panel proud on +Y
])
def make_estop_bracket(reach=55):
"""Smaller mounting bracket for the feed-side E-stop. Same architecture
as the HMI bracket (back plate on leg + front plate backing the enclosure
+ top/bottom caps) but sized to the smaller 150×120 enclosure face."""
return fuse([
box_xyz(140, 5, 140, (-70, 0, -70)), # back plate: against frame upright
box_xyz(170, 5, 140, (-85, reach, -70)), # front plate: backs E-stop body
box_xyz(170, reach, 5, (-85, 0, 65)), # top cap
box_xyz(170, reach, 5, (-85, 0, -70)), # bottom cap
])
def make_sand_deflector(brace_xs=()):
"""Tilted spill plate, Q23 Option C (2026-05-11).
Geometry rewrite for X-tilt about Y axis. +X end HIGH, -X end LOW. Sand
drains to the -X end (loader side, "atrás del feeder") in a single pile.
Rear X-brace clearance slot: at X≈250 (rear leg center) the plate sits
at Z≈-1266, inside the brace Z range — clash. Slot dimensions from
spec.sand_deflector.rear_xbrace_clearance_slot. Front X-brace does NOT
clash (plate at +X end is at Z≈-336, above brace top -470).
brace_xs param kept for legacy callers; ignored (slot logic is now
driven by the spec block, not the caller).
"""
sd = SPEC["sand_deflector"]
L = sd["length_x_mm"]
plate = box_xyz(L, sd["span_y_mm"], sd["thickness_mm"],
(-L / 2, -sd["span_y_mm"] / 2, -sd["thickness_mm"] / 2))
plate = plate.rotate((0, 0, 0), (0, 1, 0), sd["tilt_deg"])
center_x = sd["start_x_mm"] + L / 2
plate = plate.translate((center_x, sd["center_y_mm"], sd["center_z_mm"]))
# Rear X-brace clearance slots (2 diagonals at the rear face; Q23 audit 2026-05-11)
for slot in sd.get("rear_xbrace_clearance_slots", []):
tool = box_xyz(slot["slot_x_mm"], slot["slot_yz_along_brace_mm"],
80.0, # tool thick enough to fully pierce the 3 mm plate at tilt
(-slot["slot_x_mm"] / 2, -slot["slot_yz_along_brace_mm"] / 2, -40.0))
# Rotate tool about its X axis to align with brace's Y-Z diagonal
tool = tool.rotate((0, 0, 0), (1, 0, 0), slot["ang_yz_deg_from_y"])
tool = tool.translate((slot["world_center_x_mm"],
slot["world_center_y_mm"],
slot["world_center_z_mm"]))
plate = plate.cut(tool)
return plate
def make_deflector_side_wall(y_sign: int):
"""Q23 Option C side wall — one of the 2 long-edge containment lips.
y_sign = -1 (low-Y edge, Y=0) or +1 (high-Y edge, Y=span_y).
The wall is a 2300 × 3 × 200 mm rectangle attached perpendicular to the
deflector's TOP face along its Y-edge. Rotates WITH the plate (so the
"200 mm height" is along the plate's local +Z direction, perpendicular
to the plate surface). After bake-in tilt the wall also rotates.
"""
sd = SPEC["sand_deflector"]
L = sd["length_x_mm"]
span = sd["span_y_mm"]
t_plate = sd["thickness_mm"]
sw = sd["side_walls"]
h = sw["height_mm"]
wall_t = sw["thickness_mm"]
# Pre-rotation: wall sits OUTSIDE the plate's Y span, attached to one Y-edge,
# rising in local +Z from the plate's top face.
if y_sign < 0:
y_lo = -span / 2 - wall_t
y_hi = -span / 2
else:
y_lo = +span / 2
y_hi = +span / 2 + wall_t
wall = box_xyz(L, y_hi - y_lo, h, (-L / 2, y_lo, t_plate / 2))
wall = wall.rotate((0, 0, 0), (0, 1, 0), sd["tilt_deg"])
center_x = sd["start_x_mm"] + L / 2
wall = wall.translate((center_x, sd["center_y_mm"], sd["center_z_mm"]))
return wall
def deflector_top_z_at_world_x(sd, world_x: float) -> float:
"""World Z of the deflector's top face at a given world X. Q23 Option C
geometry: tilt about Y axis, +X end HIGH, -X end LOW.
"""
center_x = sd["start_x_mm"] + sd["length_x_mm"] / 2.0
return sd["center_z_mm"] + math.tan(math.radians(sd["tilt_deg"])) * (world_x - center_x)
def deflector_edge_world_y(sd, y_sign: int) -> float:
"""World Y of one of the two Y edges of the deflector plate. With X-tilt
about Y axis the Y dimension is unchanged by rotation, so the edges sit
at center_y ± span/2. y_sign = -1 (low-Y edge) or +1 (high-Y edge).
"""
return sd["center_y_mm"] + y_sign * sd["span_y_mm"] / 2.0
def make_deflector_hanger(length: float):
"""Vertical 30×5 A36 flat bar dropping from a frame crossmember down to
the deflector edge. Top sits at the crossmember underside; bottom has a
Ø9 clearance hole for the M8 fastener that pins it to the deflector."""
return box_xyz(30, 5, length, (-15, -2.5, 0))
def tilt_for_bake_in(part):
"""3° feed-end-up rotation. Rotates around the FRONT LEG TOP (where the
rail meets the front leg), Y-axis.
2026-05-06 fix: pivot_z was at base_z (floor level) — wrong. With the
pivot 909 mm BELOW the rail, rotating tilted parts shifted them ~55 mm
in +X relative to the (un-tilted) legs. The cups ended up forward of
their legs. Now pivot_z = top_rail_z so rotation happens AT the rail
plane: the rail's front end stays put, only the rear end rises and
shifts a small ~3 mm in X (negligible visually). Legs stay vertical at
their X positions — they're un-tilted, with rear leg taller by 105 mm
to provide the 3° rake.
"""
f = SPEC["support_frame"]
b = SPEC["bin"]
pivot_x = b["length_mm"] - f["bin_frame_inset_x_mm"] - f["tube_mm"] / 2.0 # front leg center X
pivot_z = f["top_rail_z_mm"] # 2026-05-06: was f["base_z_mm"], shifted to rail level
return part.rotate((pivot_x, 0, pivot_z), (pivot_x, 1, pivot_z), 3.0)
def build_assembly(s=SPEC):
asm = cq.Assembly(name=s["slug"])
b, f, ss, m = s["bin"], s["support_frame"], s["spring_stack"], s["vibration_motors"]
add(asm, tilt_for_bake_in(make_bin_floor()), "BIN · 2500×1000 vibrating floor, A36 6mm; width verified 1000mm", COLORS["a36"])
add(asm, tilt_for_bake_in(make_bin_side_wall(True)), "BIN · left folded wall, A36 6mm", COLORS["a36"])
add(asm, tilt_for_bake_in(make_bin_side_wall(False)), "BIN · right folded wall, A36 6mm", COLORS["a36"])
add(asm, tilt_for_bake_in(make_bin_rear_wall()), "BIN · rear loader-impact wall, A36 impact plate", COLORS["a36_dark"])
add(asm, tilt_for_bake_in(make_funnel_side(True)), "BIN · long gentle integrated funnel left side, tapers to 700mm outlet", COLORS["a36"])
add(asm, tilt_for_bake_in(make_funnel_side(False)), "BIN · long gentle integrated funnel right side, tapers to 700mm outlet", COLORS["a36"])
# 2026-05-06: outlet lip/sill REMOVED. Bible §11.1 says material 'cascades
# off the front short edge' — implies an edge but doesn't mandate a 90 mm
# tall sill. Sill could pool wet sargazo behind it. Sargazo now flows over
# the bare funnel-floor edge directly into the cone.
# 2026-05-05 (later): bin wall ↔ floor gusset L-brackets per Pablo's request
# ("Ls como las del test rig"). 4 gussets per side wall, evenly spaced along
# X. Each is an 80×5 A36 L-bracket with 50 mm vertical flange against wall
# inner face + 50 mm horizontal flange on bin floor top. Welded to bin
# (vibrating). 2 visual M8 bolts per gusset (one through each flange) to
# show the bolted-pattern Pablo referenced from the test rig.
# 2026-05-06: gussets MOVED out of the funnel taper region (was at
# 500/1000/1500/2000). The X=1000 gusset's horizontal flange (X=960..1040,
# Y=5..35) crossed into the funnel sheet at X=1040 (funnel y_inner=8.87
# there) — Pablo flagged "una L que cruza la lámina del funnel". All 4 now
# in pre-taper region (X<1000); funnel walls get their own dedicated gussets
# (see make_funnel_wall_gusset block below).
bin_t = b["wall_thickness_mm"]
bin_floor_top_z = b["floor_thickness_mm"]
# 2026-05-06 evolution: bin SIDE WALL in the front taper region needs Ls
# too. First added 4 front positions (X=1400/1700/2000/2300), then Pablo
# flagged that ONLY the X=1400 one (spans 1360..1440) overlapped the first
# funnel-wall gusset at X=1450 (spans 1410..1490) — and that's where the
# dead-zone Y-gap is tightest (just 47.5 mm at X=1400). The other three
# (1700/2000/2300) sit in regions where the funnel has already pulled
# inward enough to leave room for both gussets. Removing X=1400 only.
gusset_xs = (300.0, 500.0, 700.0, 900.0, 1700.0, 2000.0, 2300.0)
n_gussets = len(gusset_xs)
for side, side_label, wall_inner_y in (("left", "L", bin_t), ("right", "R", b["width_mm"] - bin_t)):
for i, gx in enumerate(gusset_xs, 1):
add(asm, tilt_for_bake_in(make_bin_wall_gusset(side=side).translate((gx, wall_inner_y, bin_floor_top_z))),
f"BIN · wall-floor gusset L-bracket {side_label}{i}/{n_gussets}, A36 80×50×5 (welded to wall + floor inside corner; 'L' bracket per test-rig pattern)",
COLORS["a36"])
# 2 visual M8 bolts: one through vertical flange (HORIZONTAL Y, into wall),
# one through horizontal flange (VERTICAL Z, into floor).
if side == "left":
# Vertical flange at y=wall_inner_y to wall_inner_y+5. Bolt y=wall_inner_y → -Y direction (into wall): origin y=wall_inner_y+5, head_at_plus_y=False.
add(asm, tilt_for_bake_in(horizontal_bolt_y("M8", 12, head_at_plus_y=False).translate((gx, wall_inner_y + 5, bin_floor_top_z + 25))),
f"BIN · M8 gusset-to-wall bolt {side_label}{i}/{n_gussets} (horizontal -Y into wall)", COLORS["fastener"])
# Horizontal flange at z=bin_floor_top_z to +5. Bolt vertical Z, origin at flange top going DOWN through flange + floor: bolt origin at z = bin_floor_top_z + 5 - 12, head visible at flange top.
add(asm, tilt_for_bake_in(bolt_stack("M8", 12).translate((gx, wall_inner_y + 25, bin_floor_top_z))),
f"BIN · M8 gusset-to-floor bolt {side_label}{i}/{n_gussets} (vertical Z into floor)", COLORS["fastener"])
else:
# Right side: bolt y=wall_inner_y → +Y direction into wall (head_at_plus_y=True), origin y=wall_inner_y-5.
add(asm, tilt_for_bake_in(horizontal_bolt_y("M8", 12, head_at_plus_y=True).translate((gx, wall_inner_y - 5, bin_floor_top_z + 25))),
f"BIN · M8 gusset-to-wall bolt {side_label}{i}/{n_gussets} (horizontal +Y into wall)", COLORS["fastener"])
add(asm, tilt_for_bake_in(bolt_stack("M8", 12).translate((gx, wall_inner_y - 25, bin_floor_top_z))),
f"BIN · M8 gusset-to-floor bolt {side_label}{i}/{n_gussets} (vertical Z into floor)", COLORS["fastener"])
# 2026-05-06 (later): rear-wall gussets per Pablo's voice note — "the back
# wall has no Ls". The rear loader-impact wall takes the brunt of the
# incoming sargasso (loader bucket dump), so it needs L-brackets at the
# wall ↔ floor inside corner. 4 gussets spaced along the bin width
# (Y=200/400/600/800), 80 mm wide in Y, identical 80×30×5 A36 to the
# side-wall pattern. 2 visual M8 bolts each (one through the floor flange
# vertically, one through the wall flange horizontally in -X direction
# into the rear wall).
rear_wall_gusset_ys = (200.0, 400.0, 600.0, 800.0)
rear_wall_x_inner = bin_t # rear wall inner face X (= t = 5 mm)
for i, gy in enumerate(rear_wall_gusset_ys, 1):
add(asm, tilt_for_bake_in(make_back_wall_gusset().translate((rear_wall_x_inner, gy, bin_floor_top_z))),
f"BIN · rear-wall-floor gusset L-bracket {i}/4, A36 80×30×5 (welded to rear wall + bin floor inside corner)",
COLORS["a36"])
# Bolt 1: vertical Z through horizontal flange into floor (origin 25 mm into bin from rear wall inner face).
add(asm, tilt_for_bake_in(bolt_stack("M8", 12).translate((rear_wall_x_inner + 25, gy, bin_floor_top_z))),
f"BIN · M8 rear-wall-gusset-to-floor bolt {i}/4 (vertical Z into floor)", COLORS["fastener"])
# Bolt 2: horizontal -X through vertical flange into rear wall.
# bolt_stack default shank along +Z. Rotate -90° around Y so +Z → -X
# (shank points -X into the rear wall). Origin at gusset front face
# (x = rear_wall_x_inner + plate_t = 5 + 5 = 10), head visible at +X.
add(asm, tilt_for_bake_in(
bolt_stack("M8", 12).rotate((0, 0, 0), (0, 1, 0), -90.0).translate((rear_wall_x_inner + 5, gy, bin_floor_top_z + 25))
),
f"BIN · M8 rear-wall-gusset-to-wall bolt {i}/4 (horizontal -X into wall)", COLORS["fastener"])
# 2026-05-06: NEW funnel-wall ↔ bin floor gussets per Pablo's voice note —
# "le tenemos que poner Ls a esa parte" (we need to put Ls on the funnel
# walls too). The funnel walls run angled from (1000, t) to (2500, y_out)
# on each side; gussets sit on the bin floor on the OUTER side of the
# funnel wall (in the dead zone between funnel wall and bin side wall).
# Each gusset is rotated to match the funnel taper angle so the vertical
# flange lies flush against the funnel wall outer face.
fo = b["integrated_outlet_funnel"]
fx0 = fo["taper_start_x_mm"]
fdx = b["length_mm"] - fx0
y_left_outer_start = bin_t # funnel wall outer face touches bin wall inner at taper start
y_left_outer_end = (b["width_mm"] - fo["final_outlet_width_mm"]) / 2
fdy = y_left_outer_end - y_left_outer_start
theta_funnel_deg = math.degrees(math.atan2(fdy, fdx))
funnel_gusset_ss = (0.30, 0.55, 0.80)
for side, side_label, _sign in (("left", "L", 1), ("right", "R", -1)):
rot_deg = theta_funnel_deg if side == "left" else -theta_funnel_deg
for i, frac in enumerate(funnel_gusset_ss, 1):
x_pos = fx0 + frac * fdx
y_pos = y_left_outer_start + frac * fdy if side == "left" else b["width_mm"] - y_left_outer_start - frac * fdy
gusset = make_funnel_wall_gusset(side=side).rotate((0, 0, 0), (0, 0, 1), rot_deg).translate((x_pos, y_pos, bin_floor_top_z))
add(asm, tilt_for_bake_in(gusset),
f"BIN · funnel-wall-floor gusset L-bracket {side_label}{i}/3, A36 80×30×5 (welded to funnel taper wall + bin floor in dead zone)",
COLORS["a36"])
# 1 visual M8 bolt through horizontal flange into bin floor (vertical Z).
# Local (0, ly) → world offset (-ly*sin(rot), ly*cos(rot)).
ly = -15.0 if side == "left" else 15.0
sin_r = math.sin(math.radians(rot_deg))
cos_r = math.cos(math.radians(rot_deg))
bolt_x = x_pos + (-ly) * sin_r
bolt_y = y_pos + ly * cos_r
add(asm, tilt_for_bake_in(bolt_stack("M8", 12).translate((bolt_x, bolt_y, bin_floor_top_z))),
f"BIN · M8 funnel-wall-gusset-to-floor bolt {side_label}{i}/3 (vertical Z into floor)", COLORS["fastener"])
# 2026-05-05: doublers relocated from BIN underside (z=0) to SUB-FRAME
# underside (z=-50). They distribute spring upper cup load into the
# vibrating sub-frame, not into the bin sheet.
sf = SPEC["sub_frame"]
for i, (x, y) in enumerate(spring_positions(), 1):
add(asm, tilt_for_bake_in(make_under_doubler().translate((x, y, sf["bottom_z_mm"]))), f"SUB-FRAME · underside spring doubler plate {i}/4, dark A36, fully below sub-frame bottom", COLORS["a36_dark"])
# SUB-FRAME (vibrating, between bin and springs) — RevM: PTR 60×60×4
add(asm, tilt_for_bake_in(make_sub_frame_perimeter()), f"SUB-FRAME · perimeter weldment, A36 PTR {int(sf['tube_mm'])}×{int(sf['tube_mm'])}×{int(sf['tube_wall_mm'])} (long rails + short end rails, 3° tilt baked in)", COLORS["a36_dark"])
add(asm, tilt_for_bake_in(make_sub_frame_crossmembers(sf["spring_crossmember_x_centers_mm"])), f"SUB-FRAME · 2 spring crossmembers (X=250, X=2250), A36 PTR {int(sf['tube_mm'])}×{int(sf['tube_mm'])}×{int(sf['tube_wall_mm'])}", COLORS["a36_dark"])
add(asm, tilt_for_bake_in(make_sub_frame_crossmembers(sf["motor_cradle_x_centers_mm"])), f"SUB-FRAME · 4 motor cradle crossmembers (X=500/1120/1380/2000), A36 PTR {int(sf['tube_mm'])}×{int(sf['tube_mm'])}×{int(sf['tube_wall_mm'])}", COLORS["a36_dark"])
# 2026-05-09 RevM (Fork B item 3): 2 new floor support crossmembers at
# X=810/1690 to halve worst-case bin-floor span 620 → 310 mm under 1-t
# loader dump. SF against A36 yield 1.10 → 4.4.
if sf.get("floor_support_crossmember_x_centers_mm"):
add(asm, tilt_for_bake_in(make_sub_frame_crossmembers(sf["floor_support_crossmember_x_centers_mm"])), f"SUB-FRAME · 2 NEW bin-floor support crossmembers (X=810/1690, RevM Fork B item 3), A36 PTR {int(sf['tube_mm'])}×{int(sf['tube_mm'])}×{int(sf['tube_wall_mm'])}", COLORS["a36_dark"])
# 2026-05-09 RevM (Fork B item 6): 4 bump stops with brackets hanging from
# sub-frame underside. Brackets weld to spring crossmember underside;
# bump stop pads sit 40 mm above frame top rail. Engages when sub-frame
# drops Δ=40 mm beyond rest (asymmetric+DAF=2 case). Motor↔rail residual
# at engage: 13 mm safety margin.
bs = SPEC.get("bump_stops")
if bs and bs.get("included"):
bracket_l = bs["bracket_length_vertical_mm"]
stop_h = bs["stop_height_mm"]
stop_d = bs["stop_diameter_mm"]
bracket_top_z = bs["bracket_top_z_mm"]
for i, (bx, by) in enumerate(bs["positions_xy_mm"], 1):
# Bracket: 40×60×L vertical strap from sub-frame underside down
bracket = box_xyz(60, 40, bracket_l, (bx - 30, by - 20, bracket_top_z - bracket_l))
add(asm, tilt_for_bake_in(bracket), f"BUMP STOP {i}/4 · bracket A36 60×40×{int(bracket_l)} mm, soldado al underside del spring crossmember", COLORS["a36_dark"])
# Poly stop: cylinder Ø80 × height 50 attached to bracket bottom
stop_cyl = (cq.Workplane("XY").cylinder(stop_h, stop_d/2)
.translate((bx, by, bracket_top_z - bracket_l - stop_h/2)))
add(asm, tilt_for_bake_in(stop_cyl), f"BUMP STOP {i}/4 · poliuretano 60 ShA Ø{int(stop_d)}×{int(stop_h)} mm, gap 40 mm a frame top rail en reposo", COLORS["motor"])
# M12 bin-to-sub-frame perimeter anchor bolts (12 around bin perimeter)
for i, (bx, by) in enumerate(bin_anchor_bolt_positions(), 1):
# Bolt sits on top of bin floor (z=5), shank descends through bin floor (5mm)
# into sub-frame top rail (z=0 to z=-50). Length 40 mm reaches well into sub-frame.
add(asm, tilt_for_bake_in(bolt_stack("M12", 40).translate((bx, by, -35))), f"SUB-FRAME · zinc M12 bin perimeter anchor bolt {i}/12", COLORS["fastener"])
add(asm, tilt_for_bake_in(make_frame()), "FRAME · top support frame, A36 PTR 80×80×5 (3° tilt baked in)", COLORS["a36_dark"])
tube = f["tube_mm"]
b = SPEC["bin"]
# 2026-05-04 BAKE-IN: rear legs (low X = loader-side) are 144 mm taller
# than front legs (high X = drum-side) for 3° default tilt feed-end-up.
# 2026-05-05: top_rail_z dropped 50 mm for the sub-frame, AND bake-in delta
# corrected from 144 → 114 mm. Wheelbase between leg centers is 2180 mm
# (not 2740 as a prior comment claimed) so tan(3°) × 2180 = 114 mm.
front_leg_h = abs(f["base_z_mm"] - f["top_rail_z_mm"]) # 909 mm
rear_leg_h = front_leg_h + f["bake_in_delta_mm"] # 1023 mm
bin_mid_x = b["length_mm"] / 2.0 # 1250
for i, (x, y) in enumerate(frame_leg_lower_xy(), 1):
cx, cy = x + tube / 2, y + tube / 2
is_rear = x < bin_mid_x # rear (loader-side) at low X
leg_h = rear_leg_h if is_rear else front_leg_h
side_lbl = f"REAR loader-side ({int(rear_leg_h)} mm cut, +{int(f['bake_in_delta_mm'])} for 3° tilt)" if is_rear else f"FRONT drum-side ({int(front_leg_h)} mm cut, pivot)"
add(asm, make_leg(leg_h).translate((x, y, f["base_z_mm"])), f"FRAME · leg {i}/4 {side_lbl}, PTR 80×80", COLORS["a36_dark"])
# 2026-05-05: lifting lugs + gussets removed — bible mandates forklift access at hotels (no crane); top rail RHS 80×80×5 is sling-rated by itself for the ~600 kg skid.
jack_h = abs(f["base_z_mm"] - f["top_rail_z_mm"]) + s["angle_adjust"]["nominal_adjustment_range_mm"]
# Jack screw stays vertical (NOT tilted) — anchored to deck, supports a tilted leg from below
add(asm, make_jack(jack_h).translate((cx, cy, f["base_z_mm"] - s["angle_adjust"]["nominal_adjustment_range_mm"])), f"ANGLE-ADJUST · zinc M20 jack screw {i}/4", COLORS["fastener"])
centers = frame_leg_centers_xy()
rear_left, front_left, rear_right, front_right = centers
z_bot, z_top = f["base_z_mm"] + 15, f["top_rail_z_mm"] + f["tube_mm"] - 15
# ── Side (left + right) X-braces, in X-Z plane at constant Y ──
for side, y_side, xr, xf in [("left", rear_left[1], rear_left[0], front_left[0]), ("right", rear_right[1], rear_right[0], front_right[0])]:
add(asm, make_brace((xr, z_bot), (xf, z_top), y_side), f"FRAME · {side} vertical X-brace diagonal A, 30×5 flat bar", COLORS["a36_dark"])
add(asm, make_brace((xr, z_top), (xf, z_bot), y_side), f"FRAME · {side} vertical X-brace diagonal B, 30×5 flat bar", COLORS["a36_dark"])
# 2026-05-05 (later): X-brace M10 bolts re-oriented HORIZONTAL along Y.
# Brace is a 5 mm thick flat bar at y_side ±2.5, oriented with its
# broad faces normal to Y. Bolt has to go through brace + leg outer
# face perpendicular to those faces. Head visible OUTSIDE leg outer
# face: -Y for left side, +Y for right.
head_plus = (side == "right")
for endpoint_label, ex, ez in [("rear-bot", xr, z_bot), ("rear-top", xr, z_top), ("front-top", xf, z_top), ("front-bot", xf, z_bot)]:
add(asm, horizontal_bolt_y("M10", 50, head_at_plus_y=head_plus).translate((ex, y_side, ez)),
f"FRAME · M10 X-brace bolt {side} {endpoint_label} (horizontal Y, head outside leg)", COLORS["fastener"])
# 2026-05-06 [DEV] cross-tie at side X-intersection (Pablito #4):
# halves unsupported diagonal length so out-of-plane buckling P_cr
# quadruples (28 → 115 kg). Welded to both diagonals at midspan.
mid_x = (xr + xf) / 2.0
mid_z = (z_bot + z_top) / 2.0
add(asm, make_x_brace_cross_tie("side", (mid_x, y_side, mid_z)),
f"FRAME · {side} X-brace cross-tie at intersection, 30×30×5 A36 plate",
COLORS["a36_dark"])
# ── Front + rear X-braces, in Y-Z plane at constant X ──
# 2026-05-06 [DEV] Pablito #6: he wanted X-braces on ALL 4 SIDES of the
# frame, not just left + right. Front X-brace at front-leg X (≈2250),
# rear X-brace at rear-leg X (≈250). Same 30×5 flat-bar stock.
for end, x_const, yr, yf in [
("rear", rear_left[0], rear_left[1], rear_right[1]),
("front", front_left[0], front_left[1], front_right[1]),
]:
add(asm, make_brace_yz((yr, z_bot), (yf, z_top), x_const),
f"FRAME · {end} vertical X-brace diagonal A, 30×5 flat bar", COLORS["a36_dark"])
add(asm, make_brace_yz((yr, z_top), (yf, z_bot), x_const),
f"FRAME · {end} vertical X-brace diagonal B, 30×5 flat bar", COLORS["a36_dark"])
# M10 bolts at 4 endpoints — horizontal X (perpendicular to brace plane)
# head visible OUTSIDE leg: -X at rear, +X at front.
head_plus_x = (end == "front")
# Build a horizontal-X bolt: take vertical bolt and rotate 90° around Y axis.
for endpoint_label, ey, ez in [("left-bot", yr, z_bot), ("left-top", yr, z_top), ("right-top", yf, z_top), ("right-bot", yf, z_bot)]:
ang = -90.0 if head_plus_x else 90.0
add(asm, bolt_stack("M10", 50).rotate((0, 0, 0), (0, 1, 0), ang).translate((x_const, ey, ez)),
f"FRAME · M10 X-brace bolt {end} {endpoint_label} (horizontal X, head outside leg)", COLORS["fastener"])
# Cross-tie at intersection
mid_y = (yr + yf) / 2.0
mid_z = (z_bot + z_top) / 2.0
add(asm, make_x_brace_cross_tie("fb", (x_const, mid_y, mid_z)),
f"FRAME · {end} X-brace cross-tie at intersection, 30×30×5 A36 plate",
COLORS["a36_dark"])
# 2026-05-05 (later): deflector + hangers + bolts ALL wrapped in tilt_for_bake_in
# so they ride the frame's 3° tilt with the crossmembers. Earlier the deflector
# was untilted while the crossmembers were tilted → variable gap along X
# (89 mm at x=650, 27 mm at x=1850). Now the whole deflector assembly tilts
# together so hangers stay flush with the crossmember underside everywhere.
# 2026-05-06 [DEV]: pass front + rear X-brace X positions so the deflector
# gets clearance slots cut where the new front/rear X-braces pass through.
# Q23 Option C (2026-05-11): brace clearance slots removed — geometry
# changed and new slot positions need install-time measurement. See
# spec.py X-BRACE CLASH note. Pedro cuts slots in place after measuring.
add(asm, tilt_for_bake_in(make_sand_deflector()),
"SAND DEFLECTOR · static (frame-mounted, tilts with frame's 3° bake-in) 304 SS 3mm inclined plate, Q23 Option C — 25° X-tilt about Y axis, fall direction -X (loader side)", COLORS["stainless"])
sd = SPEC["sand_deflector"]
crossmember_xs = (650.0, 950.0, 1250.0, 1550.0, 1850.0)
span_half = sd["span_y_mm"] / 2.0
crossmember_underside_z = f["top_rail_z_mm"]
# 2026-05-06: M8 deflector-to-hanger bolts REMOVED per Pablo. Each hanger
# tab is now WELDED to the deflector edge (replacing 10 M8 fasteners).
# Deflector + hangers + crossmembers are all static (frame-mounted), so a
# weld here is structurally cleaner: no bolt holes through the 3 mm SS
# spill plate, no head/washer profile sticking up into the sand-cascade
# path, and one less fastener type to fab.
# 2026-05-06 (later): hanger X shifted from x_cm to x_cm + tube/2 because
# make_frame() places crossmembers with their LOWER-LEFT corner at x_cm,
# so the crossmember spans x_cm..x_cm+tube. Without the +tube/2 shift the
# hanger tab (30 mm wide) sat flush against the LEFT edge of the
# crossmember — only an edge contact for welding. Now centered on the
# crossmember's center line at x_cm + tube/2.
# Q23 Option C (2026-05-11): with X-tilt about Y axis, both Y-edge tabs at
# the same crossmember X have the SAME Z (deflector Z depends only on X
# now). Tab lengths vary by X: 84 / 199 / 314 / 429 / 544 mm at
# X = 1850 / 1550 / 1250 / 950 / 650 (high-X is the high-Z end so its tab
# is shortest).
hanger_x_offset = f["tube_mm"] / 2.0
for i, x_cm in enumerate(crossmember_xs, 1):
hx = x_cm + hanger_x_offset
edge_z = deflector_top_z_at_world_x(sd, hx)
tab_length = abs(crossmember_underside_z - edge_z)
for side_label, y_sign in (("low-y edge", -1), ("high-y edge", +1)):
edge_y = deflector_edge_world_y(sd, y_sign)
add(asm, tilt_for_bake_in(make_deflector_hanger(tab_length).translate((hx, edge_y, crossmember_underside_z - tab_length))),
f"FRAME · sand-deflector hanger tab {i}/5 {side_label} — A36 30×5 flat bar (welded to crossmember at x={int(hx)} AND welded to deflector edge)",
COLORS["a36_dark"])
# Q23 Option C side walls (2026-05-11): 200 mm SS lips on the long Y edges
# of the deflector plate. Contain sand on the plate so it drains as a
# single pile at the -X end instead of spreading laterally.
add(asm, tilt_for_bake_in(make_deflector_side_wall(-1)),
"SAND DEFLECTOR · Q23 Option C side wall low-Y edge — 304 SS 2300×200×3 mm (welded continuous along Y=0)",
COLORS["stainless"])
add(asm, tilt_for_bake_in(make_deflector_side_wall(+1)),
"SAND DEFLECTOR · Q23 Option C side wall high-Y edge — 304 SS 2300×200×3 mm (welded continuous along Y=1000)",
COLORS["stainless"])
# 2026-05-06 [DEV] v2: 30 mm spacer plate per spring lifts the lower cup
# 30 mm above the rail. Paired with a 30 mm rail drop in spec so the
# spring stack stays at the same world Z, and motor-rail clearance
# gains 30 mm. cup_z formula now adds spacer_plate_t.
spacer_t = ss.get("spacer_plate_t_mm", 0.0)
spacer_z = f["top_rail_z_mm"] + f["tube_mm"] # spacer bottom = rail top
cup_z = spacer_z + spacer_t + 5 # cup bottom = spacer top + 5 mm gap
spring_z = cup_z + ss["bottom_cup_height_mm"]
top_cup_z = spring_z + ss["spring_free_height_mm"] + 4
for i, (x, y) in enumerate(spring_positions(), 1):
if spacer_t > 0:
add(asm, tilt_for_bake_in(make_spring_spacer().translate((x, y, spacer_z))),
f"FRAME · spring spacer plate {i}/4, A36 {int(ss['spacer_plate_lx_mm'])}×{int(ss['spacer_plate_ly_mm'])}×{int(spacer_t)} (welded to top rail under lower cup)",
COLORS["a36"])
add(asm, tilt_for_bake_in(make_cup(False).translate((x, y, cup_z))), f"SPRING · lower cup {i}/4, A36 6mm (sits over spring spacer at x={int(x)})", COLORS["a36"])
add(asm, tilt_for_bake_in(make_spring_visual().translate((x, y, spring_z))), f"SPRING · helical coil {i}/4, OD100×H200×wire12", COLORS["spring"])
add(asm, tilt_for_bake_in(make_cup(True).translate((x, y, top_cup_z))), f"SPRING · upper cup {i}/4, A36 6mm", COLORS["a36"])
add(asm, tilt_for_bake_in(cq.Workplane("XY").circle(ss["isolator_od_mm"] / 2).extrude(ss["isolator_height_mm"]).translate((x, y, top_cup_z + ss["top_cup_height_mm"] + 2))), f"SPRING · EPDM isolator puck {i}/4", COLORS["epdm"])
for j, a in enumerate((45, 135, 225, 315), 1):
r = ss["cup_bolt_pcd_mm"] / 2
bx, by = x + r * math.cos(math.radians(a)), y + r * math.sin(math.radians(a))
# 2026-05-06 [DEV] v2: bolt origin shifted up by spacer_t so the
# shank engages the spacer (not the rail directly). Bolt now
# spans z=spacer_top-10..spacer_top+20 (~10 mm in spacer,
# ~20 mm above into cup). Lower cup-to-FRAME via the spacer.
add(asm, tilt_for_bake_in(bolt_stack("M10", 30).translate((bx, by, f["top_rail_z_mm"] + f["tube_mm"] + spacer_t - 10))), f"SPRING · zinc M10 lower cup-to-FRAME bolt {i}-{j}", COLORS["fastener"])
add(asm, tilt_for_bake_in(bolt_stack("M12", 34).translate((bx, by, top_cup_z + ss["cup_plate_t_mm"]))), f"SPRING · zinc M12 upper cup-to-SUB-FRAME bolt {i}-{j}", COLORS["fastener"])
# Motors hang BELOW the sub-frame (mount plate against sub-frame underside,
# motor body below). 2026-05-05: dropped 50 mm from previous bin-underside
# mount. Motor mount plate sits at z = -50 to -62.
sf_bottom_z = sf["bottom_z_mm"]
for i, mx in enumerate(m["center_x_positions_mm"], 1):
add(asm, tilt_for_bake_in(make_motor_mount().translate((mx, m["center_y_mm"], sf_bottom_z))), f"MOTOR · mounting plate {i}/2 bolted to SUB-FRAME underside", COLORS["a36_dark"])
add(asm, tilt_for_bake_in(make_motor().translate((mx, m["center_y_mm"], sf_bottom_z - m["mount_plate_t_mm"]))), f"MOTOR · vibration {i}/2, OLI MVE 800/3 envelope", COLORS["motor"])
for j, (dx, dy) in enumerate([(-310, -145), (310, -145), (-310, 145), (310, 145)], 1):
# Bolt traverses mount plate (12 mm) + sub-frame thickness (60 mm) = 72 mm.
# 2026-05-09 phase-C: was 62 mm for the 50 mm sub-frame; updated to 72 mm
# after upgrade to PTR 60×60×4.
add(asm, tilt_for_bake_in(bolt_stack("M16", 72).translate((mx + dx, m["center_y_mm"] + dy, sf_bottom_z - m["mount_plate_t_mm"]))), f"MOTOR · zinc M16 mount-to-SUB-FRAME-cradle bolt {i}-{j}", COLORS["fastener"])
# 2026-05-11 (Q22 Option C — flexible neoprene boot / "trompa"):
# 4-wall hollow tube (NOT a solid block). Outer envelope 200 × 710 × 310
# mm, inner opening 700 × 300 mm matching the Q17 chute exit. Sleeved
# 25 mm back over the bin chute mouth (X=2475..2675). Wall thickness
# 5 mm visualizing the ~10 mm neoprene at the clamp band.
# Pedro does NOT fabricate this; it's purchased from industria del
# plástico. The steel clamp band (TR-PRT-30075) at the upstream end is
# what Pedro fabs.
tr = SPEC["trompa_flexible"]
boot_l = tr["length_x_mm"] # 200
boot_lyo = tr["outer_ly_mm"] # 710
boot_lzo = tr["outer_lz_mm"] # 310
boot_wt = tr["wall_thickness_mm"] # 5
x0 = b["length_mm"] - tr["sleeve_back_mm"] # X start = 2475
y0_o = b["width_mm"]/2 - boot_lyo/2 # Y- outer = 145
y0_i = y0_o + boot_wt # Y- inner = 150
y1_i = y0_o + boot_lyo - boot_wt # Y+ inner = 850
z0_o = 0.0 # Z bottom outer = 0 (bin floor level)
z0_i = z0_o + boot_wt # Z bottom inner = 5
z1_i = z0_o + boot_lzo - boot_wt # Z top inner = 305
# 4 wall slabs forming a hollow tube (open at X- and X+ ends)
boot_bottom = box_xyz(boot_l, boot_lyo, boot_wt, (x0, y0_o, z0_o))
boot_top = box_xyz(boot_l, boot_lyo, boot_wt, (x0, y0_o, z1_i))
boot_left = box_xyz(boot_l, boot_wt, (z1_i - z0_i), (x0, y0_o, z0_i))
boot_right = box_xyz(boot_l, boot_wt, (z1_i - z0_i), (x0, y1_i, z0_i))
boot = boot_bottom.union(boot_top).union(boot_left).union(boot_right)
add(asm, tilt_for_bake_in(boot),
f"TROMPA FLEXIBLE · Q22 Option C — neoprene boot 60-70 ShA, 4-wall hollow tube {int(boot_l)}×{int(boot_lyo)}×{int(boot_lzo)} mm (inner opening {int(tr['inner_ly_mm'])}×{int(tr['inner_lz_mm'])}, wraps chute mouth, fail-safe en contacto)",
COLORS["motor"])
# 2026-05-06: EPDM bristle skirt + 4 rivets REMOVED. Reference rings
# (transparent) stay — they show the Q17 cone-mouth ID and drum OD
# the boot must fit inside.
add(asm, make_reference_ring(s["interfaces"]["q_drum_17_cone_mouth_id_mm"], 8), "INTERFACE · transparent Q17 cone-mouth ID reference ring Ø900, reference only", COLORS["reference_blue"])
add(asm, make_reference_ring(s["interfaces"]["drum_v2_od_mm"], 10), "INTERFACE · transparent V2 drum OD reference ring Ø1100/Ø1090, reference only", COLORS["reference_grey"])
# 2026-05-05: cable tray + junction box repositioned to physically touch the
# right frame outer face (y = 910 mm = bin_width - bin_frame_inset_y).
# Tray sits on top of the right top rail; brackets hang the tray off the rail.
# Junction box door flipped to face +Y (operator side); brackets connect box
# back to frame upright. Tests now cover both invariants.
cm = s["cable_management"]
tray_y = cm["tray_inner_y_mm"]
tray_z = f["top_rail_z_mm"] + f["tube_mm"] + cm["tray_z_offset_above_top_rail_mm"]
add(asm, tilt_for_bake_in(make_cable_tray(cm["tray_length_mm"]).translate((cm["tray_x_start_mm"], tray_y, tray_z))), "FRAME · right-side 316L cable tray, 100×50, sits ON top rail at y=1080 (flush with frame outer face)", COLORS["stainless"])
add(asm, tilt_for_bake_in(make_cable_tray_brackets(cm["tray_length_mm"], cm["tray_bracket_count"]).translate((cm["tray_x_start_mm"], tray_y, tray_z))), "FRAME · cable tray L-brackets (5×) hanging tray off frame upright", COLORS["a36_dark"])
# 2026-05-05 (later): 5 M8 bolts re-oriented HORIZONTAL along Y, going through
# bracket vertical leg into frame upright outer face. Bracket vertical leg at
# y=tray_y-5 to y=tray_y, frame outer face at y=tray_y. Joint is vertical
# surfaces meeting at y=tray_y, so bolt has to go across Y.
# Origin Y = tray_y - 10 (10 mm into frame), shank to tray_y + 5 (length 15),
# head at y=tray_y+5 onward (visible past the bracket vertical leg).
n_brk = cm["tray_bracket_count"]
for i in range(n_brk):
bx = cm["tray_x_start_mm"] + (i + 0.5) * (cm["tray_length_mm"] / n_brk)
add(asm, tilt_for_bake_in(horizontal_bolt_y("M8", 15, head_at_plus_y=True).translate((bx, tray_y - 10, tray_z - 40))),
f"FRAME · M8 cable-tray-bracket-to-frame bolt {i+1}/{n_brk} (horizontal Y)", COLORS["fastener"])
box_x, box_y, box_z = cm["junction_box_x_mm"], cm["junction_box_inner_y_mm"], cm["junction_box_z_mm"]
add(asm, tilt_for_bake_in(make_junction_box(door_outward=True).translate((box_x, box_y, box_z))), "FRAME · IP66 stainless junction box, door faces +Y outward (operator), -Y face flush with frame outer face", COLORS["stainless"])
add(asm, tilt_for_bake_in(make_junction_box_brackets().translate((box_x, box_y, box_z))), "FRAME · junction box L-brackets (2×) to frame upright", COLORS["a36_dark"])
# 2026-05-05 (later): JBox brackets — both joints horizontal Y bolts.
# Joint 1 (bracket-back ↔ frame-outer-face at y=box_y=1080): bolt y=1070→1085.
# 2026-05-06: bz_offs 30/90 → 120/170 because the bracket vertical leg is now
# taller (LOCAL z=-5..185) and engages the rail at LOCAL z=105..185 with
# box_z=-520. Bolts at LOCAL z=120 and 170 sit inside the rail z range
# (world -400, -350) — both inside rail [-415, -335].
for j, (bx_off, bz_off) in enumerate([(10, 120), (10, 170), (150, 120), (150, 170)], 1):
add(asm, tilt_for_bake_in(horizontal_bolt_y("M8", 15, head_at_plus_y=True).translate((box_x + bx_off, box_y - 10, box_z + bz_off))),
f"FRAME · M8 junction-box-bracket-to-frame bolt {j}/4 (horizontal Y)", COLORS["fastener"])
# Joint 2 (box-back ↔ bracket-front at y=box_y=1085): bolt origin at y=1080
# (5 mm inside bracket), shank +Y to y=1090 (5 mm into box back wall).
# Box-back/bracket joints stay at LOCAL z=30/90 (within box body z=0..180).
for j, (bx_off, bz_off) in enumerate([(10, 30), (10, 90), (150, 30), (150, 90)], 1):
add(asm, tilt_for_bake_in(horizontal_bolt_y("M6", 10, head_at_plus_y=True).translate((box_x + bx_off, box_y - 5, box_z + bz_off))),
f"FRAME · M6 junction-box-to-bracket bolt {j}/4 (horizontal Y)", COLORS["fastener"])
# 2026-05-05: motor underside placeholder guard removed. Bible §13.2 needs a real sheet-metal enclosure with tool-only removal + Spanish placards + LOTO hardware — that's V2.1 work, not the demo fab pack. The single-plate placeholder gave a false sense of compliance.
ix = f["bin_frame_inset_x_mm"]
hmi_reach = 55
hmi_x = b["length_mm"] - ix - f["tube_mm"] / 2
right_leg_outer_y = b["width_mm"] - f["bin_frame_inset_y_mm"]
hmi_y = right_leg_outer_y + hmi_reach + 5
# 2026-05-05 fix: was -120, putting bracket top 30 mm BELOW frame upright bottom.
# Now -90: bracket top edge sits AT frame bottom (z=top_rail_z), making real
# contact for the welded attachment.
hmi_z = f["top_rail_z_mm"] - 90
hmi_sy = s["controls_hmi"]["enclosure_mm"][1]
face_y = hmi_y + hmi_sy + 6
add(asm, make_hmi_bracket(hmi_reach).translate((hmi_x, right_leg_outer_y, hmi_z)), "HMI · panel mount bracket on frame upright", COLORS["a36_dark"])
# 2026-05-05 (later): bolts re-oriented HORIZONTAL along Y. The joint surfaces
# are vertical (leg outer face at y=1080, bracket back face at y=1080-1085),
# so the bolt has to go ACROSS Y, not extrude up in Z.
# Origin Y = 1070 (10 mm into leg interior). Shank y=1070→1085 passes through
# leg outer face (1080) and into bracket back face. Head visible at +Y end (~1090).
# 2026-05-06: bolt X-offsets dx ±60 → ±30 because the front leg is only
# 80 mm wide (x=2210..2290 = ±40 from hmi_x=2250). Old ±60 spread put bolts
# at x=2190 and x=2310 — both 20 mm OUTSIDE the leg, so the screws went
# nowhere. ±30 sits inside the leg with 10 mm clearance.
for j, (dx, dz) in enumerate([(-30, -70), (30, -70), (-30, 70), (30, 70)], 1):
add(asm, horizontal_bolt_y("M8", 15, head_at_plus_y=True).translate((hmi_x + dx, right_leg_outer_y - 10, hmi_z + dz)),
f"HMI · M8 bracket-to-frame bolt {j}/4 (horizontal Y, head at +Y)", COLORS["fastener"])
add(asm, make_hmi_enclosure().translate((hmi_x, hmi_y, hmi_z)), "HMI · single upright-mounted panel enclosure, NEMA 4X", COLORS["hmi_box"])
# 4 M6 bolts at the bracket-front / enclosure-back joint, HORIZONTAL along Y.
# Joint surfaces both vertical (in Y direction), so bolt goes across Y.
# Origin Y = bracket_front_y - 5 = 1130 (5 mm inside bracket front face).
# Shank y=1130→1140 passes through bracket front (1135-1140) and into the
# enclosure back face (≥1140). Head at +Y end (~1145, inside the enclosure
# interior — visible if door is opened).
bracket_front_y = right_leg_outer_y + hmi_reach
enclosure_sx, _, enclosure_sz = s["controls_hmi"]["enclosure_mm"]
for j, (dx, dz) in enumerate([(-enclosure_sx/2 + 20, -enclosure_sz/2 + 20),
(enclosure_sx/2 - 20, -enclosure_sz/2 + 20),
(-enclosure_sx/2 + 20, enclosure_sz/2 - 20),
(enclosure_sx/2 - 20, enclosure_sz/2 - 20)], 1):
add(asm, horizontal_bolt_y("M6", 10, head_at_plus_y=True).translate((hmi_x + dx, bracket_front_y - 5, hmi_z + dz)),
f"HMI · M6 enclosure-to-bracket bolt {j}/4 (horizontal Y)", COLORS["fastener"])
add(asm, hmi_button(15, 10).translate((hmi_x - 70, face_y, hmi_z + 30)), "HMI · START button green", COLORS["green"])
add(asm, hmi_button(15, 10).translate((hmi_x, face_y, hmi_z + 30)), "HMI · WARN yellow", COLORS["amber"])
add(asm, hmi_button(21, 12).translate((hmi_x + 70, face_y, hmi_z + 30)), "HMI · E-STOP red", COLORS["red"])
# ── Feed-side E-stop (bible §13.4: 2 E-stops minimum) ───────────────
# 2026-05-06 phase-B: feed-side E-stop on the rear-right leg, mirroring
# the HMI panel position across the bin centerline (X=1250). Smaller
# NEMA enclosure, single Ø22 mushroom button. Wired in series with HMI
# E-stop into motor contactor hold-in circuit (pressing either kills
# both vibration motors).
estop_reach = 55
estop_x = ix + f["tube_mm"] / 2 # rear-right leg center X
estop_y_inner = right_leg_outer_y + estop_reach + 5
estop_z = hmi_z # same Z as HMI for symmetric reach
estop_face_y = estop_y_inner + s["controls_estop"]["enclosure_mm"][1] + 6
add(asm, make_estop_bracket(estop_reach).translate((estop_x, right_leg_outer_y, estop_z)),
"ESTOP · feed-side panel mount bracket on rear frame upright", COLORS["a36_dark"])
# 4 M8 bolts horizontal-Y through bracket back plate into rear leg outer face
for j, (dx, dz) in enumerate([(-30, -50), (30, -50), (-30, 50), (30, 50)], 1):
add(asm, horizontal_bolt_y("M8", 15, head_at_plus_y=True).translate((estop_x + dx, right_leg_outer_y - 10, estop_z + dz)),
f"ESTOP · M8 bracket-to-frame bolt {j}/4 (horizontal Y, head at +Y)", COLORS["fastener"])
add(asm, make_estop_enclosure().translate((estop_x, estop_y_inner, estop_z)),
"ESTOP · feed-side single-mushroom enclosure, NEMA 4X", COLORS["hmi_box"])
# 4 M6 enclosure-to-bracket bolts (mirror HMI joint pattern)
estop_bracket_front_y = right_leg_outer_y + estop_reach
estop_sx, _, estop_sz = s["controls_estop"]["enclosure_mm"]
for j, (dx, dz) in enumerate([(-estop_sx/2 + 15, -estop_sz/2 + 15),
(estop_sx/2 - 15, -estop_sz/2 + 15),
(-estop_sx/2 + 15, estop_sz/2 - 15),
(estop_sx/2 - 15, estop_sz/2 - 15)], 1):
add(asm, horizontal_bolt_y("M6", 10, head_at_plus_y=True).translate((estop_x + dx, estop_bracket_front_y - 5, estop_z + dz)),
f"ESTOP · M6 enclosure-to-bracket bolt {j}/4 (horizontal Y)", COLORS["fastener"])
# Single Ø22 mushroom button, centered on enclosure +Y face
add(asm, hmi_button(21, 12).translate((estop_x, estop_face_y, estop_z)),
"ESTOP · feed-side E-STOP red mushroom (Ø22)", COLORS["red"])
return asm
def main():
asm = build_assembly(SPEC)
asm.save(SPEC["outputs"]["glb"])
if "step" in SPEC["outputs"]:
asm.save(SPEC["outputs"]["step"])
print(f"{SPEC['title']}: GLB exported to {SPEC['outputs']['glb']}; STEP to {SPEC['outputs'].get('step', '(none)')}.")
if __name__ == "__main__":
main()"""Plan D feeder · FRAME · SolidWorks native builder.
Produces feeder_frame.SLDPRT — a single multibody part containing all 80×80×5
A36 PTR weldment members (legs, top rails, bottom rails, X-braces, base plates).
Each member is its own Boss-Extrude in the FeatureManager tree, so Marco can
edit any individual member's length/section in SW.
Run sequence at the SW workstation:
cd projects\\feeder-plan-d-gpt5-5-v2\\
py -3.13 native.py
Requires:
- SolidWorks 2026 3DEXP installed and a default part template configured
- py -3.13 with pywin32 (win32com.client + pythoncom)
Coordinate system (matches spec.py):
+X = flow direction (long axis, 2500 mm)
+Y = bin width direction (1000 mm)
+Z = up
Origin at bin rear-left-bottom corner
Output: feeder_frame.SLDPRT (multibody, ~22 features)
Convention follows cad-twin operator 13 (simple_box_extrude). FeatureExtrusion2
works reliably for boss-extrude in SW 2026 3DEXP via out-of-process pywin32.
Cut/Pattern features are routed via macro per the FeatureCut/RunMacro2 workaround
— but the frame has only boss-extrudes, so we don't need that here.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pythoncom
import win32com.client
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from spec import SPEC # noqa: E402
OUT_SLDPRT = HERE / "feeder_frame.SLDPRT"
# SW end-condition + plane constants
swEndCondBlind = 0
# ─── Frame dimensions (millimetres → SW prefers metres) ──────────────
F = SPEC["support_frame"]
B = SPEC["bin"]
TUBE = F["tube_mm"] / 1000.0 # 0.080 m
TUBE_WALL = F["tube_wall_mm"] / 1000.0 # 0.005 m (used only if hollow tube modeled)
LEG_H = F["leg_height_below_bin_mm"] / 1000.0 # 0.930 m below bin floor
TOP_RAIL_Z = F["top_rail_z_mm"] / 1000.0 # negative — below bin underside
BASE_Z = F["base_z_mm"] / 1000.0 # most negative — floor
INSET_X = F["bin_frame_inset_x_mm"] / 1000.0
INSET_Y = F["bin_frame_inset_y_mm"] / 1000.0
BIN_LEN = B["length_mm"] / 1000.0
BIN_WIDTH = B["width_mm"] / 1000.0
# Frame footprint
FRAME_X0 = INSET_X
FRAME_X1 = BIN_LEN - INSET_X
FRAME_Y0 = INSET_Y
FRAME_Y1 = BIN_WIDTH - INSET_Y
# Leg corner positions (4)
LEG_CORNERS = [
(FRAME_X0, FRAME_Y0),
(FRAME_X1, FRAME_Y0),
(FRAME_X0, FRAME_Y1),
(FRAME_X1, FRAME_Y1),
]
def connect_sw():
pythoncom.CoInitialize()
sw = win32com.client.Dispatch("SldWorks.Application")
sw.Visible = True
return sw
def select_plane_by_name(part, plane_name: str) -> bool:
feat = part.FirstFeature
while feat:
if feat.Name == plane_name:
return feat.Select2(False, 0)
feat = feat.GetNextFeature
return False
def select_feature_by_name(part, name: str) -> bool:
return select_plane_by_name(part, name)
def add_box_extrude(part, sketch_name: str,
plane: str,
u0: float, v0: float, u1: float, v1: float,
depth_m: float,
extrude_name: str) -> None:
"""Sketch a rectangle on the named plane and Boss-Extrude it by depth_m.
plane: "Top Plane" (XY, normal Z) → extrudes along +Z
"Front Plane" (XZ, normal Y) → extrudes along +Y
"Right Plane" (YZ, normal X) → extrudes along +X
"""
if not select_plane_by_name(part, plane):
raise RuntimeError(f"Plane {plane} not found.")
part.SketchManager.InsertSketch(True)
part.SketchManager.CreateCornerRectangle(u0, v0, 0, u1, v1, 0)
part.SketchManager.InsertSketch(True)
if not select_feature_by_name(part, sketch_name):
# SW auto-named the sketch; we don't always know the name in advance.
# Fall back: select most recent sketch via FeatureManager.
pass
feat = part.FeatureManager.FeatureExtrusion2(
True, False, False,
swEndCondBlind, swEndCondBlind,
depth_m, 0.0,
False, False, False, False,
0.0, 0.0,
False, False, False, False,
True, True, True,
0, 0.0, False,
)
if not feat:
raise RuntimeError(f"Boss-Extrude failed for {extrude_name}")
feat.Name = extrude_name
print(f" [+] {extrude_name}")
def main():
sw = connect_sw()
tmpl = sw.GetUserPreferenceStringValue(8)
if not tmpl:
raise RuntimeError("No default part template configured.")
part = sw.NewDocument(tmpl, 0, 0, 0)
if not part:
raise RuntimeError("NewDocument failed.")
print("Building feeder frame as multibody SLDPRT…")
print(f" tube: {TUBE*1000:.0f} × {TUBE*1000:.0f} mm A36 PTR")
print(f" footprint: {(FRAME_X1-FRAME_X0)*1000:.0f} × {(FRAME_Y1-FRAME_Y0)*1000:.0f} mm")
print(f" leg height: {LEG_H*1000:.0f} mm")
# ─── 4 legs (vertical, extrude along +Z) ────────────────
for i, (x, y) in enumerate(LEG_CORNERS):
add_box_extrude(
part, sketch_name="",
plane="Top Plane",
u0=x - TUBE / 2, v0=y - TUBE / 2,
u1=x + TUBE / 2, v1=y + TUBE / 2,
depth_m=LEG_H,
extrude_name=f"Leg{i+1}",
)
# ─── 4 top rails (at TOP_RAIL_Z, between legs) ────────────
# Top long rails (along X)
for j, y in enumerate((FRAME_Y0, FRAME_Y1)):
# Sketch on Front Plane (XZ); extrude along +Y by TUBE
add_box_extrude(
part, sketch_name="",
plane="Front Plane",
u0=FRAME_X0, v0=TOP_RAIL_Z,
u1=FRAME_X1, v1=TOP_RAIL_Z + TUBE,
depth_m=TUBE,
extrude_name=f"TopRailLong{j+1}",
)
# Top short rails (along Y)
for j, x in enumerate((FRAME_X0, FRAME_X1)):
add_box_extrude(
part, sketch_name="",
plane="Right Plane",
u0=FRAME_Y0, v0=TOP_RAIL_Z,
u1=FRAME_Y1, v1=TOP_RAIL_Z + TUBE,
depth_m=TUBE,
extrude_name=f"TopRailShort{j+1}",
)
# ─── 4 base rails (at BASE_Z + TUBE/2, ground level) ─────
BASE_RAIL_Z = BASE_Z
for j, y in enumerate((FRAME_Y0, FRAME_Y1)):
add_box_extrude(
part, sketch_name="",
plane="Front Plane",
u0=FRAME_X0, v0=BASE_RAIL_Z,
u1=FRAME_X1, v1=BASE_RAIL_Z + TUBE,
depth_m=TUBE,
extrude_name=f"BaseRailLong{j+1}",
)
for j, x in enumerate((FRAME_X0, FRAME_X1)):
add_box_extrude(
part, sketch_name="",
plane="Right Plane",
u0=FRAME_Y0, v0=BASE_RAIL_Z,
u1=FRAME_Y1, v1=BASE_RAIL_Z + TUBE,
depth_m=TUBE,
extrude_name=f"BaseRailShort{j+1}",
)
# ─── 4 base plates (10 mm A36 plate under each leg) ─────
PLATE_T = 10.0 / 1000.0 # 10 mm
PLATE_HALF = 90.0 / 1000.0 # 180×180 plate centered on each leg
for i, (x, y) in enumerate(LEG_CORNERS):
add_box_extrude(
part, sketch_name="",
plane="Top Plane",
u0=x - PLATE_HALF, v0=y - PLATE_HALF,
u1=x + PLATE_HALF, v1=y + PLATE_HALF,
depth_m=PLATE_T,
extrude_name=f"BasePlate{i+1}",
)
# ─── X-bracing on each long side (4 diagonal flat bars) ──
# Skipped here as boss extrudes — the angled diagonal needs a 3-point
# sketch + extrude-perpendicular-to-sketch-plane. Implement once base
# frame is verified in SW. For now, the multibody frame has:
# 4 legs + 4 top rails + 4 base rails + 4 base plates = 16 bodies.
# ─── Save ───────────────────────────────────────────────
if OUT_SLDPRT.exists():
try:
OUT_SLDPRT.unlink()
except Exception:
pass
lock = OUT_SLDPRT.parent / f"~${OUT_SLDPRT.name}"
if lock.exists():
try:
lock.unlink()
except Exception:
pass
res = part.SaveAs3(str(OUT_SLDPRT), 0, 0)
if res != 0:
print(f"[WARN] SaveAs3 returned code {res}")
else:
size_kb = OUT_SLDPRT.stat().st_size // 1024
print(f"\n[OK] feeder_frame.SLDPRT saved · {size_kb} KB · 16 multibody members")
print("\nNEXT STEPS:")
print(" 1. Open feeder_frame.SLDPRT in SolidWorks 2026 3DEXP")
print(" 2. Verify dimensions match cadquery_part.py (manual measure or cad-twin equiv.py)")
print(" 3. Add weldment fillet welds at member intersections (manual SW pass)")
print(" 4. Add X-bracing diagonals (separate pass — needs 3-pt-sketch pattern)")
print(" 5. Once verified: update details.json sw_state to 'native-verified'")
if __name__ == "__main__":
main()