Loading viewer…

feeder-plan-d-gpt5-5-v2

📐 Borrador
Aún no hay artefacto compilado
"""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()
Trabajando…
0s