Loading viewer…

V2 drum

Fresh · 1d 📐 Draft
No compiled artifact yet
📌 Current spec snapshot — auto from spec.py
Drum: Ø1100 × 4000 mm, shell 8 mm, ID 1084 mm.
Screen: 316L Ø4 mm.
Structural: 3 rings (OD 1140 mm), 2 raceway bands (OD 1140 mm, width 120 mm).
Lifters: 28 (7 × 4).
Drive: 3.0 kW gearmotor, Ø200 mm lagged roller.
Operating: material flow +X; incline 3.0° nominal (1.67–4.33°), Nc 40.3 RPM, VFD cap 24 RPM.
Profiles: axis feed-high/discharge-low · frame body feed-high/discharge-low · rails feed-low/discharge-high.
Top stabilizers: per-wheel side cap frames; ARH-90023 leg bottoms miter-seat on the longitudinal PTR top faces; no separate lower shoes.
"""V2 full machine spec — RUBISCO2 production sand-separator drum.

Canonical source: Bible v3.0 §3-§10 + Q-Drum design-space map.
Units: millimetres.
"""

SPEC = {
    "slug": "v2-drum-full-machine",
    "title": "V2 full sand-separator machine (RUBISCO2 production · Bible v3.0 §3-§10)",
    "units": "mm",

    "drum_od_mm": 1100.0,
    "drum_id_mm": 1084.0,
    "drum_length_mm": 4000.0,
    "shell_thickness_mm": 8.0,
    "screen_panel_material": "316L stainless steel, passivated after fabrication",
    "screen_aperture_mm": 4.0,
    "screen_pattern": "60° staggered perforation pattern; full perforation field represented by callout, not modeled hole-by-hole",

    "ring_count": 3,  # 2026-04-30: added midspan ring (was 2). L/D = 3.6 → 3 rings is canonical for trommel proportions
    # 2026-05-05 clash.py fix: ring_od was 1160 vs band_od 1140 — ring protruded
    # 10 mm radially beyond band, anti-blinding brush bristles tangent to band
    # hit the ring (8897 mm³ each side). Aligned ring OD with band OD; ring
    # still has 20 mm radial wall (550-570) which is structurally adequate for
    # the demo unit. Production V2.1 may revisit the flange-vs-band geometry.
    "ring_od_mm": 1140.0,
    "ring_id_mm": 1100.0,
    "ring_thickness_mm": 14.0,
    "ring_x_inset_mm": 400.0,
    "ring_material": "A36 / A572 Gr.50 structural steel",
    "ring_bolt_count": 12,
    "ring_bolt_clearance_d_mm": 11.0,
    "ring_bolt_pcd_mm": 1120.0,
    "ring_drain_count": 2,
    "ring_drain_d_mm": 12.0,
    "ring_drain_pcd_mm": 1112.0,

    "band_count": 2,
    # 2026-05-05 fix: band_od was 1120 but band_bolt_pcd is also 1120 — bolt
    # holes were placed AT the OD surface, bisecting the edge. Increased OD
    # to 1140 so PCD 1120 sits 10 mm inside the OD (proper bolt-hole clearance).
    # Radial wall now 20 mm (1140-1100)/2, matching the structural ring (1160-1100)/2 = 30 mm.
    "band_od_mm": 1140.0,
    "band_id_mm": 1100.0,
    "band_axial_width_mm": 120.0,
    "band_x_inset_mm": 400.0,
    "band_material": "AISI 1045 carbon steel + chromium-carbide hardfacing, Rc 54-60",
    "band_bolt_count": 12,
    "band_bolt_clearance_d_mm": 11.0,
    "band_bolt_pcd_mm": 1120.0,

    "side_wall_count": 4,
    "side_wall_od_mm": 1170.0,
    "side_wall_id_mm": 1100.0,
    "side_wall_thickness_mm": 6.0,
    "side_wall_material": "A36 steel, marine coated",
    "side_wall_no_bolt_holes": True,

    "include_inlet_cone": True,
    "cone_length_mm": 300.0,
    "cone_drum_end_od_mm": 1100.0,
    "cone_drum_end_id_mm": 1084.0,
    "cone_mouth_od_mm": 920.0,
    "cone_mouth_id_mm": 900.0,
    "cone_material": "A36 rolled plate, 4-6 mm, marine coated",

    "stringer_count": 8,
    "stringer_width_mm": 40.0,
    "stringer_height_mm": 10.0,
    # Stringers extend ~200 mm past each end-ring (rings at x=400/3600, span 3200).
    # Length = 3200 + 400 = 3600 to support screen panels through the ring zones.
    "stringer_length_mm": 3600.0,
    "stringer_material": "A36 flat bar, chamfered/rounded leading edges",
    "stringer_end_fastener": "M8",
    "stringer_end_clearance_d_mm": 9.0,

    "lifter_angular_cols": 7,
    "lifter_axial_rows": 4,
    "lifter_count": 28,
    "lifter_axial_length_mm": 150.0,
    "lifter_inward_height_mm": 80.0,
    "lifter_plate_thickness_mm": 5.0,
    "lifter_helix_angle_deg_per_row": 8.0,
    "lifter_x_start_mm": 650.0,
    "lifter_x_end_mm": 3350.0,
    "lifter_body_material": "A36 plate, rounded/deburred",
    "lifter_wear_bar_material": "Hardox 450 bolted replaceable wear bar",
    "lifter_wear_bar_height_mm": 12.0,
    "lifter_wear_bar_thickness_mm": 8.0,
    "lifter_body_to_rail_fastener": "M8 clearance Ø9",
    "lifter_wear_bar_fastener": "M10 countersunk clearance Ø11",
    "lifter_tab_mm": [70.0, 6.0, 32.0],
    "lifter_tab_bolt_clearance_d_mm": 9.0,

    "machine_nominal_incline_deg": 3.0,
    # 2026-05-04 audit correction: jacks have 100 mm travel; effective range
    # with 3° baked into legs is 1.67-4.33°, NOT the previously claimed [0, 6]°.
    # If 6° is needed in field, swap to 200 mm travel jacks (effective 1° to 5.6°).
    "machine_incline_adjustment_range_deg": [1.67, 4.33],
    "drum_axis_height_reference_mm": 1450.0,
    "drum_axis_height_reference_x_mm": 2000.0,  # midspan reference; feed end is high, discharge end is low at nominal 3°.
    "ground_z_mm": -1450.0,

    "coordinate_contract": {
        "material_flow_axis": "+X",
        "feed_end_x_mm": 0.0,
        "discharge_end_x_mm": 4000.0,
        "frame_feed_support_x_mm": -400.0,
        "frame_discharge_support_x_mm": 4150.0,
        "feed_end_label": "upstream/feed, drum inlet cone, feeder handoff",
        "discharge_end_label": "downstream/discharge, open oversize fall into cart",
        "note": "Drum material flow is increasing X. Do not infer this from copied feeder front/rear names.",
    },

    "height_profiles": {
        "rotating_drum_axis": {
            "feed_x_mm": 0.0,
            "discharge_x_mm": 4000.0,
            "feed_z_above_ground_mm": 1554.8,
            "discharge_z_above_ground_mm": 1345.2,
            "z_reference": "axis centerline",
            "applies_to": "drum shell, rings, raceways, rollers, drive roller, hold-downs, guide rollers, brushes",
            "note": "True operating axis: feed high, discharge low.",
        },
        "frame_body": {
            "feed_x_mm": -400.0,
            "discharge_x_mm": 4150.0,
            "feed_z_above_ground_mm": 938.0,
            "discharge_z_above_ground_mm": 700.0,
            "z_reference": "leg/crossmember top",
            "applies_to": "vertical legs and transverse crossmembers",
            "note": "Copied feeder-style frame body: feed support high, discharge support low.",
        },
        "longitudinal_rails": {
            "feed_x_mm": -400.0,
            "discharge_x_mm": 4150.0,
            "feed_z_above_ground_mm": 740.0,
            "discharge_z_above_ground_mm": 978.0,
            "z_reference": "PTR centerline",
            "applies_to": "FRM-90001 long side PTR rails only",
            "note": "Intentional opposite slope: feed-low/discharge-high, decoupled from the rotating drum axis.",
        },
    },

    "frame_material": "Painted/coated square steel tube, PTR 80 × 80 baseline",
    # 2026-05-15 fix (Pablo screenshot audit): upstream leg moved from x=-150
    # to x=-400 so the inlet cone (x=-300..0) sits OVER the long rail instead
    # of cantilevering 150 mm past the upstream foot. Frame length grew
    # 4400 → 4700 mm. Wheelbase 4300 → 4550 mm. US leg height recomputed to
    # maintain 3° baked-in tilt: tan(3°) × 4550 = 238 mm vs prior 225 mm.
    "frame_length_mm": 4700.0,
    # 2026-05-16 Telegram correction: frame was too narrow for the top
    # stabilizer portal to land cleanly outside the rotating side-wall/raceway
    # stack. Open the long rails / legs to ±700 centerline; crossmembers now
    # terminate at the inner leg faces instead of sharing leg volume.
    "frame_width_mm": 1400.0,
    "frame_rail_y_mm": 700.0,
    # 2026-05-15 PM (Pablo voice): switch from rectangular 80×40 PTR to SQUARE
    # 80×80 to match the feeder's PTR convention. Feeder uses PTR 60×60 + 80×80
    # square sections; drum was using 80×40 rectangular ("thinner"). Same
    # 80 mm height, but width doubled — more material, more weight, but
    # uniform with feeder + structurally stiffer in both axes.
    "frame_ptr_w_mm": 80.0,    # SQUARE PTR 80×80 (was 40, rectangular)
    "frame_ptr_h_mm": 80.0,
    "frame_crossmember_count": 4,
    # 2026-05-05 clash.py fix: crossmembers were at [-150, 400, 3600, 4150].
    # 400 and 3600 are the structural ring X positions — crossmembers passed
    # through the ring's bottom annulus (~82,000 mm³ overlap, real fab clash).
    # Moved inner crossmembers to 1100 and 2900 (clear of all 3 rings at 400/2000/3600).
    # Support-roller pedestals at x=400 / 3600 now bolt to the long rail directly.
    # 2026-05-15: leftmost crossmember moved -150 → -400 to track the new upstream leg.
    "frame_crossmember_x_mm": [-400.0, 1100.0, 2900.0, 4150.0],
    "frame_leg_count": 4,
    "frame_leg_x_mm": [-400.0, 4150.0],
    "frame_leg_y_mm": [-700.0, 700.0],
    # Bake-in 3° default tilt: upstream legs longer than downstream by
    # tan(3°) × wheelbase. New wheelbase = 4550 mm → 238 mm leg-height delta.
    # Pivot = discharge end at X=4150.
    # 2026-05-15 PM: legs kept at original heights (US tall, DS short).
    "frame_leg_height_ds_mm": 700.0,    # downstream/discharge legs (SHORT); mirrors height_profiles.frame_body
    "frame_leg_height_us_mm": 938.0,    # upstream/feed legs (TALL); mirrors height_profiles.frame_body
    # 2026-05-15 late Pablo annotated-image override: rail Z stays explicit
    # and intentionally decoupled from the rotating drum axis. The drum shell
    # and rollers keep the true feed-high/discharge-low 3° axis, but the long
    # side PTR rails return to the visual frame contract Pablo marked in red:
    # lower at the upstream/feed end and higher at the downstream/discharge end.
    # 2026-05-17 correction: do NOT carry the temporary station-swap into the
    # frame axis. The long side rails keep Pablo's annotated visual contract
    # through height_profiles.longitudinal_rails: upstream/feed LOW and
    # downstream/discharge HIGH. Top stabilizer wheel stations stay fixed;
    # their adapters absorb the mismatch.
    # Delta = 238 mm over 4550 wheelbase -> tan(3°); opposite sign to the
    # rotating drum-axis incline by deliberate visual/frame override.
    "frame_foot_plate_mm": [220.0, 160.0, 12.0],
    "frame_jack_screw_d_mm": 38.0,
    "frame_jack_screw_height_mm": 180.0,
    "frame_jack_screw_adjustment_range_mm": [100.0, 200.0],   # 100 mm travel — was 0.0 (bug). 2026-05-04 fix.
    "frame_anchor_bolt": "M16 drop-in anchor to 12 mm steel checker-plate deck",
    "frame_anchor_clearance_d_mm": 18.0,
    "frame_tilt_adjustment_note": "Four corner jack screws + 3° baked into frame_body leg heights (feed legs 238 mm taller than discharge legs). Effective tilt range 1.67-4.33° via 100 mm jack travel. Drum, running bands, roller axes, drive roller, hold-downs, guide rollers, and brushes share the feed-high/discharge-low rotating_drum_axis profile. Long side PTR rails intentionally use the separate longitudinal_rails profile: feed-low/discharge-high, with pedestals/adapters bridging from rail to tilted rotating hardware. 2026-05-04: corrected from aspirational [0,6]° claim.",

    "support_station_count": 2,
    "support_station_x_mm": [400.0, 3600.0],
    "support_roller_per_station": 2,
    "support_roller_d_mm": 140.0,
    "support_roller_face_width_mm": 100.0,
    "support_roller_angle_from_bottom_deg": 30.0,
    "support_roller_material": "Plain hardened steel tread, approx. Rc 45",
    "support_roller_bearing": "Sealed pillow-block bearings, 2RS, sand/fibre shielded",
    "support_roller_shaft_d_mm": 40.0,

    "drive_roller_x_mm": 3600.0,
    "drive_roller_side": "+Y drive side",
    "drive_roller_d_mm": 200.0,
    "drive_roller_face_width_mm": 140.0,
    "drive_roller_lagging": "Rubber or polyurethane lagging for wet/sandy raceway traction",
    "drive_roller_nominal_preload_kn": [3.7, 6.3],
    "drive_roller_shaft_d_mm": 50.0,
    "drive_preload_hardware": "Adjustable screw/spring preload mechanism, lockable",

    "guide_roller_count": 4,
    "guide_roller_d_mm": 80.0,
    "guide_roller_face_width_mm": 30.0,
    "guide_roller_material": "Hardened steel or sealed bearing-mounted roller; adjustable and lockable",
    "guide_roller_positions": "Two guide rollers at each drum end, acting on side-wall lips only; not radial supports",

    # 2026-05-15 PM ADDITION (Pablo voice note): hold-down / top stabilizer
    # rollers. V1 had wheels on top to keep the drum from lifting off the
    # support rollers under DAF events. V2 was missing them — bible §7.1 only
    # specified bottom 30° V-cradle. Adding mirror-pair at top: 2 rollers per
    # station × 2 stations = 4 hold-down rollers, mounted to top-arch frame
    # (portal over the drum).
    "hold_down_station_count": 2,
    "hold_down_station_x_mm": [400.0, 3600.0],   # mirrors support_station_x_mm
    "hold_down_roller_per_station": 2,
    "hold_down_roller_d_mm": 140.0,              # same Ø as bottom support rollers
    "hold_down_roller_face_width_mm": 100.0,
    "hold_down_roller_angle_from_top_deg": 30.0, # mirror of bottom 30° V-cradle
    "hold_down_roller_material": "Plain hardened steel tread, ~Rc 45 (mirrors bottom support roller)",
    "hold_down_roller_engagement": "Contacts raceway band OD from above; restrains radial uplift under DAF / vibration",
    "hold_down_preload_kn": [1.0, 2.0],          # adjustable spring preload, smaller than drive (no torque transmission)
    # Top stabilizer frames: each wheel gets a side cap beam from the long
    # rail/post into the wheel saddle. Do not use a single full-width portal
    # beam across the whole drum; that reads as the wrong "tail/cross-frame"
    # member in side views.
    "top_arch_post_count": 4,                    # 2 stations × 2 sides
    "top_arch_post_y_mm": 700.0,                 # centered over widened long rail; clears rotating side-wall/raceway envelope
    "top_arch_rail_foot_y_mm": 700.0,            # lower rail-bearing foot sits fully on widened long rail top
    "top_arch_rail_foot_height_mm": 120.0,
    "top_arch_upper_post_height_mm": 980.0,       # legacy fallback; explicit piece rows below now drive tg1/tg2 stabilizer geometry
    "top_arch_beam_z_above_drum_top_mm": 140.0,  # clears Ø140 top stabilizer tread + gives room for bearing hangers
    # 2026-05-17 tg2: piece-level rows. These duplicate the current values on
    # purpose so each physical stabilizer piece has its own editable row instead
    # of being moved as one station/side bundle by shared loop state.
    "top_arch_crossbeam_parts": [
        {"station": 1, "side": "passive -Y", "x_mm": 400.0, "outer_post_center_y_mm": -700.0, "inner_end_y_mm": -300.0, "beam_clearance_above_drum_top_mm": 140.0},
        {"station": 1, "side": "drive +Y", "x_mm": 400.0, "outer_post_center_y_mm": 700.0, "inner_end_y_mm": 300.0, "beam_clearance_above_drum_top_mm": 140.0},
        {"station": 2, "side": "passive -Y", "x_mm": 3600.0, "outer_post_center_y_mm": -700.0, "inner_end_y_mm": -300.0, "beam_clearance_above_drum_top_mm": 140.0},
        {"station": 2, "side": "drive +Y", "x_mm": 3600.0, "outer_post_center_y_mm": 700.0, "inner_end_y_mm": 300.0, "beam_clearance_above_drum_top_mm": 140.0},
    ],
    "top_arch_side_parts": [
        {"station": 1, "side": "passive -Y", "x_mm": 400.0, "rail_foot_center_y_mm": -700.0, "upper_post_center_y_mm": -700.0, "rail_foot_min_height_mm": 254.0, "upper_post_height_mm": 980.0},
        {"station": 1, "side": "drive +Y", "x_mm": 400.0, "rail_foot_center_y_mm": 700.0, "upper_post_center_y_mm": 700.0, "rail_foot_min_height_mm": 254.0, "upper_post_height_mm": 980.0},
        {"station": 2, "side": "passive -Y", "x_mm": 3600.0, "rail_foot_center_y_mm": -700.0, "upper_post_center_y_mm": -700.0, "rail_foot_min_height_mm": 254.0, "upper_post_height_mm": 980.0},
        {"station": 2, "side": "drive +Y", "x_mm": 3600.0, "rail_foot_center_y_mm": 700.0, "upper_post_center_y_mm": 700.0, "rail_foot_min_height_mm": 254.0, "upper_post_height_mm": 980.0},
    ],
    "hold_down_roller_parts": [
        {"station": 1, "side": "passive -Y", "x_mm": 400.0, "side_sign": -1.0, "angle_from_top_deg": 30.0},
        {"station": 1, "side": "drive +Y", "x_mm": 400.0, "side_sign": 1.0, "angle_from_top_deg": 30.0},
        {"station": 2, "side": "passive -Y", "x_mm": 3600.0, "side_sign": -1.0, "angle_from_top_deg": 30.0},
        {"station": 2, "side": "drive +Y", "x_mm": 3600.0, "side_sign": 1.0, "angle_from_top_deg": 30.0},
    ],
    "top_arch_material": "PTR 80×80 square painted/coated steel (matches main frame square convention 2026-05-15)",
    "hold_down_bracket_ptr_mm": 40.0,
    "hold_down_bearing_offset_from_face_mm": 45.0,
    "hold_down_bearing_block_mm": [50.0, 90.0, 60.0],

    "motor_power_kw": 3.0,
    "gearmotor_envelope_mm": [400.0, 280.0, 300.0],
    "gearmotor_material": "Inverter-duty TEFC/severe-duty motor + helical-bevel gearbox, IP55 minimum",
    "gearmotor_mount_bolts": "M12 slotted base / tensioning bolts",
    "gearmotor_mount_clearance_d_mm": 13.0,
    "gearmotor_location_mm": [3600.0, 930.0, -542.0],
    "motor_cover_material": "2 mm A36 bolt-on 5-side wrap, bottom open for ventilation",
    "motor_cover_clearance_mm": 60.0,
    "motor_cover_slot_vent_count": 6,
    "pulley_d_mm": 200.0,
    "pulley_face_width_mm": 45.0,
    "belt_type": "Supplier-selected V-belt or wedge belt; guard required; spare belt on site",
    "belt_guard_material": "Painted sheet-metal guard, tool-only removable",
    "shaft_bolt": "M10 shaft/pulley visual fasteners",
    "shaft_bolt_clearance_d_mm": 11.0,
    "vfd_enclosure_mm": [420.0, 220.0, 520.0],
    "vfd_location_mm": [1500.0, 900.0, -390.0],   # 2026-05-16 frame-widen pass: stays outboard of widened +Y rail
    "vfd_spec": "Local NEMA 4X panel: VFD, lockable disconnect, 2× e-stops, 3-stage light tower, Spanish labels",

    # 2026-05-15 PM round 10 (GPT review #1): the existing "brush" was BAND-
    # only (130 mm long, matching band width). Bible §10.2 requires a SCREEN
    # anti-blinding brush over the first 40-60% of drum length. Adding a
    # separate longitudinal screen brush. The band brush + screen brush are
    # both retained; different functions (raceway cleaning vs screen pore
    # cleaning).
    "screen_brush_included": True,
    "screen_brush_length_mm": 2000.0,                # total active bristle length, segmented to clear rings/bands
    # Segment into screen-only spans. A single 2000 mm solid cylinder would
    # cross the upstream raceway band and midspan structural ring, violating
    # Bible §10.2 / FAT F09 ("Brush contacts only screen").
    "screen_brush_segments_mm": [[540.0, 1900.0], [2070.0, 2710.0]],
    "screen_brush_x_start_mm": 500.0,                # legacy/display start = first screen-only segment start
    "screen_brush_bristle_length_mm": 90.0,          # procurement/adjustment bristle projection
    "screen_brush_bristle_d_mm": 60.0,
    "screen_brush_overlap_mm": 5.0,
    "screen_brush_clock_deg": 225.0,
    "screen_brush_clock_position": "External lower passive-side quadrant, approx 7:30 o'clock (opposite the drive-side raceway brush, clear of motor envelope)",
    "screen_brush_material": "Wet-sand/salt-compatible polymer bristle bundle, replaceable, no metal wire baseline",

    "brush_per_running_band": 1,
    "brush_location_clock": "External lower/drive-side quadrant at approx. 4 o'clock, one tangential brush per running band",
    "brush_bristle_d_mm": 80.0,
    # 2026-05-05 clash.py fix: bristle length was 600 mm (5× the band's 120 mm
    # axial width) — bristles extended into screen + ring zones. For V2 demo,
    # this is a band-cleaning brush, length matches band width + 5 mm margin.
    # If a separate screen-anti-blinding brush is needed it gets its own PRT
    # number positioned BETWEEN rings.
    "brush_bristle_length_mm": 130.0,
    "brush_bristle_overlap_mm": 5.0,
    "brush_pivot_shaft_d_mm": 32.0,
    "brush_pivot_arm_flat_bar_mm": [70.0, 18.0],
    "brush_frame_pivot_radial_mm": 916.0,
    "brush_spring_d_mm": 50.0,
    "brush_spring_nominal_length_mm": 200.0,
    "brush_material": "Replaceable wet-sand/salt-compatible cylindrical bristle bundle; spring adjustable; no wire unless tested",

    # 2026-05-15 late Pablo voice override: return closer to the prior
    # envelope. The plate must not run past the drum in X, otherwise falling
    # sand covers legs/frame and turns the base into a storage tray. Slots for
    # X-braces/legs are still cut wherever the frame projects through it.
    "sand_deflector_length_mm": 4000.0,    # same X-length as drum: spans x=0..4000, not past the legs.
    "sand_deflector_span_mm": 1500.0,   # 2026-05-15 PM round 9: 1300→1500 to match feeder width; drainage past passive rail (~500 mm overhang matches feeder ratio).
    "sand_deflector_thickness_mm": 3.0,
    "sand_deflector_tilt_deg": 25.0,   # V2 envelope override: Bible 45° ideal does not fit without burying legs/frame; Pablo accepted ~25°.
    "sand_deflector_x_center_mm": 2000.0,    # plate ends exactly where the 4000 mm drum ends.
    "sand_deflector_y_center_mm": -50.0,     # +Y high edge stays inside drive-side envelope; -Y lip drains past passive rail.
    "sand_deflector_z_center_mm": -940.0,    # lowered so the 25° high edge clears the tilted drum bottom.
    "sand_deflector_slope_side": "-Y passive side collection cart",
    "sand_deflector_material": "3 mm 304 stainless steel single inclined deflector plate",
    "sand_deflector_low_lip_overhang_mm": 80.0,
    # Side wall on +Y edge (drive side) — contains sand from spilling toward
    # the control panel / operator. Vertical, 100 mm tall, welded to plate
    # top edge. Matches feeder pattern (Q23 Option C → A1 → Fork C).
    "sand_deflector_side_wall": {
        "included": True,
        "height_mm": 100.0,
        "thickness_mm": 3.0,
        "y_edge": "+Y",
        "material": "304 stainless steel, 3 mm",
        "joint": "welded along plate +Y top edge, vertical orientation in world Z",
    },
    # Hanger tabs for plate-to-crossmember attachment (matches feeder hanger
    # pattern). One row of tabs at each crossmember X, plus one at the
    # midspan of each bay.
    "sand_deflector_hanger_tabs": {
        "tab_count": 5,
        "tab_material": "A36 flat bar, 30 × 5 mm",
        "tab_crossmember_xs_mm": [550.0, 1100.0, 2000.0, 2900.0, 3450.0],
        "tab_fastener": "M8 bolt through tab → crossmember underside",
        "tab_fastener_clearance_d_mm": 9.0,
    },

    # 2026-05-15 PM ROTATE 90° + MOVE PAST FRAME (Pablo voice: "bin sharing
    # volume with frame; rotate 90° and pull it away"). Cart was at x=4400
    # with X-length 1500 — overlapping DS leg/crossmember at x=4150.
    # Now: long axis along Y (rotated 90°), placed past frame at x=4750.
    # Round 13 incline pass lowers the bin from 800→740 mm and widens X
    # 600→650 mm so capacity stays ~720 L while the inclined discharge end
    # keeps ≥50 mm top clearance.
    # Legacy key name retained for tests/UI compatibility; Q-Drum-13 is now a
    # 720 L low-profile OPEN-TOP industrial catch bin, not the old 660 L wheelie cart.
    "cart_660l_envelope_mm": [650.0, 1500.0, 740.0],
    "cart_660l_location_mm": [4750.0, 0.0, -1080.0],   # bottom on ground z=-1450; X range 4425..5075 clears DS leg at x=4150
    "cart_660l_note": "REF-90060 reference envelope: low-profile industrial catch bin ~720 L (1500 × 650 × 740 mm). Replaces original 660 L wheelie bin (was 1200 mm tall, did not fit under drum bottom). Sourced as off-the-shelf industrial sand-collection bin or fabbed from 3 mm steel; on wheels for swap-out cycle.",
    "cart_bin_open_top": True,
    "cart_bin_wall_thickness_mm": 35.0,
    "cart_bin_wheel_d_mm": 120.0,

    "include_guards": True,
    "guards": {
        "belt_guard": "Full belt and pulley guard, tool-only removable",
        "drive_nip_guard": "Nip guard and debris shield at drive roller/raceway contact",
        "support_roller_shields": "Side lips/shields to prevent sand entering support rollers",
        "motor_guard": "5-side bolt-on A36 gearmotor cover with slot vents, bottom open",
    },

    "details_comments": {
        "roundness": "Screen cylinder radial runout target ≤4 mm TIR; rework/reject >8 mm.",
        "raceway_runout": "Raceway radial and axial runout target ≤2 mm TIR; rework >4 mm.",
        "ring_coaxiality": "Ring-to-ring coaxiality target ≤3 mm; rework/reject >6 mm.",
        "bolt_clearance": "M10 clearance Ø11, M8 clearance Ø9, M12 clearance Ø13, M16 anchor clearance Ø18.",
        "side_walls": "PRT-80002 side walls are annular axial flanges; visual connection callout uses same 12 × M10 PCD ring/raceway bolt story.",
        "drainage": "Structural rings include Ø12 drain holes near ID to prevent leachate pooling.",
        "screen": "316L modular panels with Ø4 mm round holes on 60° staggered pitch; perforation callout only in this core model.",
        "support_rollers": "Two stations × two Ø140 hardened steel rollers; pillow-block brackets bridge from the decoupled long rails up to the true 3° feed-high drum axis.",
        "drive": "3.0 kW gearmotor + belt/sheave set drives Ø200 lagged friction roller on downstream raceway.",
        "controls": "NEMA 4X local VFD panel with 15/18/20 RPM presets and 24 RPM hard limit.",
        "anti_blinding_brush": "Two-brush system: (1) AB-10001 band brush — 130 × Ø80 tangential bristle bundle at 4 o'clock, ~5 mm bristle overlap on raceway band OD, one per running band (2 total). (2) AB-10010 SCREEN brush — segmented 2000 mm total active length × Ø60 longitudinal bristle bundle at 7:30 o'clock (passive lower quadrant), split into screen-only tilted-clearance spans x=540..1900 and x=2070..2710 so it clears bands/rings/side walls per Bible §10.2 + FAT F09 while retaining 50% active coverage (2026-05-15 round 13).",
        "sand_deflector": "Single 4000 × 1500 × 3 mm 304 stainless inclined plate (tilted 25° toward -Y passive side by V2 envelope override), centered x=2000 y=-50 z=-940, ends with the drum at x=0..4000, retains X-brace/leg clearance slots, and drains past the passive rail; old multi-plate pan removed.",
        "clearance": "Ground-to-drum-centerline reference is 1450 mm at midspan; inclined discharge bottom clears the 740 mm low-profile open-top catch bin by ≥50 mm.",
    },

    "outputs": {
        "step": "build/v2-drum-full-machine.step",
        "glb": "build/v2-drum-full-machine.glb",
    },
}
from __future__ import annotations

import json
import math
import sys
from datetime import datetime, timezone
from pathlib import Path

import cadquery as cq

from spec import SPEC

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")


# Drum axis is +X. Drum shell runs from x=0 to x=drum_length.
# +Y = drive side. +Z = up.


def hollow_cylinder_x(length: float, od: float, id_: float) -> cq.Workplane:
    outer = cq.Workplane("YZ").cylinder(length, od / 2.0, centered=(True, True, True))
    inner = cq.Workplane("YZ").cylinder(length + 2.0, id_ / 2.0, centered=(True, True, True))
    return outer.cut(inner)


def cyl_x(length: float, diameter: float) -> cq.Workplane:
    return cq.Workplane("YZ").cylinder(length, diameter / 2.0, centered=(True, True, True))


def cyl_y(length: float, diameter: float) -> cq.Workplane:
    return cq.Workplane("XZ").cylinder(length, diameter / 2.0, centered=(True, True, True))


def cyl_z(length: float, diameter: float) -> cq.Workplane:
    return cq.Workplane("XY").cylinder(length, diameter / 2.0, centered=(True, True, True))


def box_xyz(x: float, y: float, z: float) -> cq.Workplane:
    return cq.Workplane("XY").box(x, y, z)


def radial_hole_cutter_x(hole_d: float, radial_length: float, axial_x: float, radial_center_r: float, theta_deg: float) -> cq.Workplane:
    return (
        cq.Workplane("XZ")
        .cylinder(radial_length, hole_d / 2.0, centered=(True, True, True))
        .translate((axial_x, radial_center_r, 0))
        .rotate((0, 0, 0), (1, 0, 0), theta_deg)
    )


def axial_hole_cutter_x(hole_d: float, axial_length: float, y: float, z: float) -> cq.Workplane:
    return cq.Workplane("YZ").cylinder(axial_length, hole_d / 2.0, centered=(True, True, True)).translate((0, y, z))


def cut_radial_bolt_pattern(part: cq.Workplane, count: int, hole_d: float, pcd: float, radial_length: float) -> cq.Workplane:
    for i in range(count):
        part = part.cut(radial_hole_cutter_x(hole_d, radial_length, 0.0, pcd / 2.0, 360.0 * i / count))
    return part


def make_structural_ring(s: dict) -> cq.Workplane:
    ring = hollow_cylinder_x(s["ring_thickness_mm"], s["ring_od_mm"], s["ring_id_mm"])
    ring = cut_radial_bolt_pattern(
        ring,
        int(s["ring_bolt_count"]),
        float(s["ring_bolt_clearance_d_mm"]),
        float(s["ring_bolt_pcd_mm"]),
        float(s["ring_od_mm"] - s["ring_id_mm"] + 40.0),
    )
    drain_r = float(s["ring_drain_pcd_mm"]) / 2.0
    for theta in (-98.0, -82.0)[: int(s["ring_drain_count"])]:
        y = drain_r * math.cos(math.radians(theta))
        z = drain_r * math.sin(math.radians(theta))
        ring = ring.cut(axial_hole_cutter_x(float(s["ring_drain_d_mm"]), float(s["ring_thickness_mm"]) + 4.0, y, z))
    return ring


def make_running_band(s: dict) -> cq.Workplane:
    band = hollow_cylinder_x(s["band_axial_width_mm"], s["band_od_mm"], s["band_id_mm"])
    return cut_radial_bolt_pattern(
        band,
        int(s["band_bolt_count"]),
        float(s["band_bolt_clearance_d_mm"]),
        float(s["band_bolt_pcd_mm"]),
        float(s["band_od_mm"] - s["band_id_mm"] + 40.0),
    )


def make_side_wall(s: dict) -> cq.Workplane:
    return hollow_cylinder_x(float(s["side_wall_thickness_mm"]), float(s["side_wall_od_mm"]), float(s["side_wall_id_mm"]))


def make_inlet_cone(s: dict) -> cq.Workplane:
    length = float(s["cone_length_mm"])
    outer = (
        cq.Workplane("YZ")
        .circle(float(s["cone_mouth_od_mm"]) / 2.0)
        .workplane(offset=length)
        .circle(float(s["cone_drum_end_od_mm"]) / 2.0)
        .loft(combine=True)
    )
    inner = (
        cq.Workplane("YZ")
        .circle(float(s["cone_mouth_id_mm"]) / 2.0)
        .workplane(offset=length)
        .circle(float(s["cone_drum_end_id_mm"]) / 2.0)
        .loft(combine=True)
    )
    return outer.cut(inner).translate((-length, 0, 0))


def make_stringer(s: dict) -> cq.Workplane:
    length = float(s["stringer_length_mm"])
    width = float(s["stringer_width_mm"])
    height = float(s["stringer_height_mm"])
    clearance = float(s["stringer_end_clearance_d_mm"])
    return (
        cq.Workplane("XY")
        .box(length, width, height)
        .faces(">Z")
        .workplane()
        .pushPoints([(-length / 2.0 + 55.0, 0), (length / 2.0 - 55.0, 0)])
        .hole(clearance)
    )


def oriented_internal_part(part: cq.Workplane, x_pos: float, theta_deg: float, radial_z_local: float) -> cq.Workplane:
    return part.translate((0, 0, radial_z_local)).rotate((0, 0, 0), (1, 0, 0), theta_deg).translate((x_pos, 0, 0))


def make_lifter_body(s: dict) -> cq.Workplane:
    return cq.Workplane("XY").box(
        float(s["lifter_axial_length_mm"]),
        float(s["lifter_plate_thickness_mm"]),
        float(s["lifter_inward_height_mm"]),
    )


def make_lifter_wear_bar(s: dict) -> cq.Workplane:
    bar = cq.Workplane("XY").box(
        float(s["lifter_axial_length_mm"]),
        float(s["lifter_wear_bar_thickness_mm"]),
        float(s["lifter_wear_bar_height_mm"]),
    )
    x_span = float(s["lifter_axial_length_mm"]) * 0.32
    return bar.faces(">Y").workplane().pushPoints([(-x_span, 0), (x_span, 0)]).hole(11.0)


def make_lifter_tab(s: dict) -> cq.Workplane:
    lx, ly, lz = [float(v) for v in s["lifter_tab_mm"]]
    tab = cq.Workplane("XY").box(lx, ly, lz)
    return tab.faces(">Y").workplane().pushPoints([(-18.0, 0.0), (18.0, 0.0)]).hole(float(s["lifter_tab_bolt_clearance_d_mm"]))


def add_box(asm: cq.Assembly, shape: cq.Workplane, loc: tuple[float, float, float], name: str, color: cq.Color) -> None:
    asm.add(shape.translate(loc), name=name, color=color)


def top_arch_crossbeam_part_specs(s: dict) -> list[dict]:
    rows = s.get("top_arch_crossbeam_parts")
    if rows:
        return [dict(row) for row in rows]
    return [
        {
            "station": idx,
            "x_mm": float(x),
            "beam_half_span_y_mm": float(s["top_arch_post_y_mm"]),
            "beam_clearance_above_drum_top_mm": float(s["top_arch_beam_z_above_drum_top_mm"]),
        }
        for idx, x in enumerate(s["hold_down_station_x_mm"], start=1)
    ]


def top_arch_side_part_specs(s: dict) -> list[dict]:
    rows = s.get("top_arch_side_parts")
    if rows:
        return [dict(row) for row in rows]
    result = []
    post_y = float(s["top_arch_post_y_mm"])
    foot_y = float(s.get("top_arch_rail_foot_y_mm", post_y))
    for idx, x in enumerate(s["hold_down_station_x_mm"], start=1):
        for sign, side in ((-1.0, "passive -Y"), (1.0, "drive +Y")):
            result.append(
                {
                    "station": idx,
                    "side": side,
                    "x_mm": float(x),
                    "rail_foot_center_y_mm": sign * foot_y,
                    "upper_post_center_y_mm": sign * post_y,
                    "rail_foot_min_height_mm": float(s.get("top_arch_rail_foot_height_mm", 120.0)),
                    "upper_post_height_mm": float(s.get("top_arch_upper_post_height_mm", 0.0)),
                }
            )
    return result


def hold_down_roller_part_specs(s: dict) -> list[dict]:
    rows = s.get("hold_down_roller_parts")
    if rows:
        return [dict(row) for row in rows]
    result = []
    for idx, x in enumerate(s["hold_down_station_x_mm"], start=1):
        for sign, side in ((-1.0, "passive -Y"), (1.0, "drive +Y")):
            result.append(
                {
                    "station": idx,
                    "side": side,
                    "x_mm": float(x),
                    "side_sign": sign,
                    "angle_from_top_deg": float(s["hold_down_roller_angle_from_top_deg"]),
                }
            )
    return result


def raceway_contact_center(s: dict, theta_deg: float, roller_radius: float) -> tuple[float, float]:
    r = float(s["band_od_mm"]) / 2.0 + roller_radius
    return r * math.cos(math.radians(theta_deg)), r * math.sin(math.radians(theta_deg))


def drum_tilt_angle_deg(s: dict) -> float:
    return float(s.get("machine_nominal_incline_deg", 0.0))


def drum_tilt_pivot_x(s: dict) -> float:
    return float(s.get("drum_axis_height_reference_x_mm", float(s["drum_length_mm"]) / 2.0))


def coordinate_contract(s: dict) -> dict:
    return s["coordinate_contract"]


def height_profile(s: dict, profile_name: str) -> dict:
    return s["height_profiles"][profile_name]


def profile_z_above_ground_at_x(s: dict, profile_name: str, x: float) -> float:
    profile = height_profile(s, profile_name)
    x_feed = float(profile["feed_x_mm"])
    x_discharge = float(profile["discharge_x_mm"])
    z_feed = float(profile["feed_z_above_ground_mm"])
    z_discharge = float(profile["discharge_z_above_ground_mm"])
    t = (float(x) - x_feed) / (x_discharge - x_feed)
    return z_feed + t * (z_discharge - z_feed)


def frame_body_top_z_at_x(s: dict, x: float) -> float:
    return float(s["ground_z_mm"]) + profile_z_above_ground_at_x(s, "frame_body", x)


def drum_tilt_shape(s: dict, shape: cq.Workplane) -> cq.Workplane:
    angle = drum_tilt_angle_deg(s)
    if abs(angle) < 1e-9:
        return shape
    px = drum_tilt_pivot_x(s)
    return shape.rotate((px, 0.0, 0.0), (px, 1.0, 0.0), angle)


def drum_local_to_world(s: dict, x: float, y: float, z: float) -> tuple[float, float, float]:
    angle = math.radians(drum_tilt_angle_deg(s))
    px = drum_tilt_pivot_x(s)
    dx = float(x) - px
    c = math.cos(angle)
    sn = math.sin(angle)
    return (px + dx * c + float(z) * sn, float(y), -dx * sn + float(z) * c)


def drum_axis_z_at_x(s: dict, x: float) -> float:
    angle = math.radians(drum_tilt_angle_deg(s))
    px = drum_tilt_pivot_x(s)
    return -(float(x) - px) * math.tan(angle)


def drum_axis_z_above_ground_at_x(s: dict, x: float) -> float:
    return -float(s["ground_z_mm"]) + drum_axis_z_at_x(s, x)


def rail_center_z_at_x(s: dict, x: float) -> float:
    return float(s["ground_z_mm"]) + profile_z_above_ground_at_x(s, "longitudinal_rails", x)


def rail_top_z_at_x(s: dict, x: float) -> float:
    return rail_center_z_at_x(s, x) + float(s["frame_ptr_h_mm"]) / 2.0


def make_pillow_block() -> cq.Workplane:
    base = box_xyz(80.0, 95.0, 24.0).translate((0, 0, -32.0))
    upright = box_xyz(46.0, 82.0, 56.0)
    bore = cyl_x(50.0, 42.0)
    return base.union(upright).cut(bore)


def make_anchor_bolt_stack() -> cq.Workplane:
    bolt = cyl_z(34.0, 18.0).translate((0, 0, 17.0))
    washer = cyl_z(4.0, 42.0).translate((0, 0, 36.0))
    nut = cq.Workplane("XY").polygon(6, 34.0).extrude(16.0).translate((0, 0, 40.0))
    return bolt.union(washer).union(nut)


def make_jack_foot(s: dict, extension_extra: float = 0.0) -> cq.Workplane:
    foot_x, foot_y, foot_t = [float(v) for v in s["frame_foot_plate_mm"]]
    screw_h = float(s["frame_jack_screw_height_mm"]) + extension_extra
    plate = box_xyz(foot_x, foot_y, foot_t)
    screw = cyl_z(screw_h, float(s["frame_jack_screw_d_mm"])).translate((0, 0, foot_t / 2.0 + screw_h / 2.0))
    pad = cyl_z(12.0, 72.0).translate((0, 0, foot_t / 2.0 + screw_h + 6.0))
    locknut = cq.Workplane("XY").polygon(6, 70.0).extrude(18.0).translate((0, 0, foot_t / 2.0 + screw_h * 0.55))
    return plate.union(screw).union(pad).union(locknut)


def make_motor_mount_plate(s: dict) -> cq.Workplane:
    plate = box_xyz(520.0, 780.0, 16.0)
    return (
        plate.faces(">Z")
        .workplane()
        .pushPoints([(-180.0, -110.0), (-180.0, 110.0), (180.0, -110.0), (180.0, 110.0)])
        .hole(float(s["gearmotor_mount_clearance_d_mm"]))
    )


def make_visual_bolt_z(d: float = 12.0, h: float = 12.0) -> cq.Workplane:
    washer = cyl_z(3.0, d * 2.1).translate((0, 0, 1.5))
    head = cq.Workplane("XY").polygon(6, d * 1.7).extrude(h).translate((0, 0, 3.0))
    return washer.union(head)


def make_belt_span_x(width_x: float, length: float, thickness: float, angle_deg_from_y: float) -> cq.Workplane:
    return box_xyz(width_x, length, thickness).rotate((0, 0, 0), (1, 0, 0), angle_deg_from_y)


def add_frame(asm: cq.Assembly, s: dict) -> None:
    """Drum support frame.

    Current axis contract:

      - Material moves in +X: feed/inlet at X=0, discharge at X=4000.
      - frame_body profile: feed support high, discharge support low.
      - longitudinal_rails profile: feed support low, discharge support high.
      - rotating_drum_axis profile: feed high, discharge low.

    The profile split is intentional. Do not collapse the rails into the frame
    body or rotating drum axis; that was the visual bug Pablo caught by eye.
    """
    y_rail = float(s["frame_rail_y_mm"])
    ptr_w = float(s["frame_ptr_w_mm"])
    ptr_h = float(s["frame_ptr_h_mm"])
    ground_z = float(s["ground_z_mm"])
    leg_xs = s["frame_leg_x_mm"]
    leg_x_feed = min(leg_xs)       # lower X support before drum inlet
    leg_x_discharge = max(leg_xs)  # higher X support beyond drum discharge

    leg_h_feed = profile_z_above_ground_at_x(s, "frame_body", leg_x_feed)
    leg_h_discharge = profile_z_above_ground_at_x(s, "frame_body", leg_x_discharge)
    z_top_feed = ground_z + leg_h_feed
    z_top_discharge = ground_z + leg_h_discharge
    z_rail_feed = rail_center_z_at_x(s, leg_x_feed)
    z_rail_discharge = rail_center_z_at_x(s, leg_x_discharge)

    # Rail axis tilt (radians, then degrees for cadquery rotate API)
    dx = leg_x_discharge - leg_x_feed
    dz = z_rail_discharge - z_rail_feed
    rail_angle_deg = math.degrees(math.atan2(dz, dx))   # ≈ +3°
    # 2026-05-15 PM fix: extend rail to cover leg outer faces (ptr_w/2 past
    # each leg center) so the rail visually connects to the leg, not stopping
    # at the leg centerline. Adds ~40 mm total to rail length.
    rail_axis_len = math.hypot(dx, dz) + ptr_w           # ptr_w covers half on each side

    rail_mid_x = (leg_x_feed + leg_x_discharge) / 2.0
    rail_mid_z = (z_rail_feed + z_rail_discharge) / 2.0

    for y, side in ((-y_rail, "passive -Y"), (y_rail, "drive +Y")):
        # 2026-05-15 fix (component-position audit): box_xyz returns a CENTERED
        # box (cq.Workplane.box defaults to centered=True). The prior
        # `.translate((-rail_axis_len/2, -ptr_w/2, -ptr_h/2))` was a redundant
        # corner-shift that moved the box's corner (not center) to rail_mid_x
        # via the final translate, so rails ended up offset by
        # -rail_axis_len/2 ≈ -2153 mm — span -2300..+2004 instead of
        # -150..+4150. Caught when bbox-auditing every component vs spec.py.
        rail_box = box_xyz(rail_axis_len, ptr_w, ptr_h)
        rail_box = rail_box.rotate((0, 0, 0), (0, 1, 0), rail_angle_deg)
        rail_box = rail_box.translate((rail_mid_x, y, rail_mid_z))
        asm.add(
            rail_box,
            name=f"FRAME · FRM-90001 long rail {side} — PTR 80×80 painted/coated square steel, longitudinal_rails profile feed-low/discharge-high (welded weldment, anchored to floor at jack-screw feet)",
            color=cq.Color(0.12, 0.16, 0.18),
        )

    # Crossmembers stay tied to the leg-top weldment. Rails now follow the
    # annotated side-view contract independently of both leg tops and the
    # rotating drum axis, so crossmembers must not be derived from rail center
    # height.
    for idx, x in enumerate(s["frame_crossmember_x_mm"], start=1):
        z_top_at_x = frame_body_top_z_at_x(s, float(x))
        z_xm = z_top_at_x - ptr_h / 2.0   # crossmember center so its TOP is flush with leg top
        crossmember_span_y = 2.0 * y_rail - ptr_w
        add_box(
            asm,
            box_xyz(ptr_w, crossmember_span_y, ptr_h),
            (float(x), 0.0, z_xm),
            f"FRAME · FRM-90002 crossmember {idx}/{len(s['frame_crossmember_x_mm'])} — PTR 80×80 square, welded between inner leg faces (tracks leg-top tilt, independent of rail; no shared leg volume)",
            cq.Color(0.13, 0.17, 0.19),
        )

    # X-braces (2026-05-15 PM round 7 — feeder pattern): ONE X-brace per
    # long side, spanning the FULL frame face from US leg to DS leg
    # (no per-bay subdivision). Each X = 2 crossing diagonals, corner-to-
    # corner of the rail+foot-plate rectangle. Matches feeder spec:
    # "two vertical side X-braces, one per long side; each side uses 2
    # diagonal 30 × 5 mm flat bars". Was 3 bays × 2 = 6 diagonals per
    # side (= 12 total); now 1 × 2 = 2 per side (= 4 total). Larger
    # diagonals → steeper angle → "more vertical" structural look that
    # Pablo flagged was missing relative to feeder.
    brace_flat_w = 30.0
    brace_flat_t = 5.0
    foot_plate_thickness = float(s["frame_foot_plate_mm"][2])
    z_bottom = ground_z + foot_plate_thickness   # frame face bottom edge
    # Top edge = rail underside at each leg X
    z_top_at_feed = z_rail_feed - ptr_h / 2.0
    z_top_at_discharge = z_rail_discharge - ptr_h / 2.0
    # Frame face corners (per side) use coordinate-stable feed/discharge
    # labels. Human orientation must not drive geometry math.
    for side_y, label in ((-y_rail, "passive"), (y_rail, "drive")):
        for diag_label, (xa, za, xb, zb) in [
            ("A\\ feed-top to discharge-bottom", (leg_x_feed, z_top_at_feed, leg_x_discharge, z_bottom)),
            ("B/ discharge-top to feed-bottom", (leg_x_discharge, z_top_at_discharge, leg_x_feed, z_bottom)),
        ]:
            length = math.hypot(xb - xa, zb - za)
            angle = math.degrees(math.atan2(zb - za, xb - xa))
            brace = box_xyz(length, brace_flat_w, brace_flat_t)
            brace = brace.rotate((0, 0, 0), (0, 1, 0), angle)
            brace = brace.translate(((xa + xb) / 2.0, side_y, (za + zb) / 2.0))
            asm.add(
                brace,
                name=f"FRAME · FRM-90003 X-brace {label} side diagonal {diag_label} — solera 30×5 (welded corner-to-corner across full frame face, feeder pattern)",
                color=cq.Color(0.10, 0.13, 0.15),
            )

    # Legs — anchored at GROUND, leg height follows frame_body profile.
    for xi, x in enumerate(leg_xs, start=1):
        is_feed_end = float(x) == leg_x_feed
        leg_h = leg_h_feed if is_feed_end else leg_h_discharge
        side_lbl = "feed support, frame-body high end" if is_feed_end else "discharge support, frame-body low end"
        for yi, y in enumerate(s["frame_leg_y_mm"], start=1):
            extension = 90.0 if is_feed_end else 20.0
            # Center the leg at (ground + leg_h/2) so its bottom touches ground
            add_box(
                asm,
                box_xyz(ptr_w, ptr_h, leg_h),
                (float(x), float(y), ground_z + leg_h / 2.0),
                f"FRAME · FRM-90004 vertical post/leg x{xi} y{yi} {side_lbl} — PTR 80×80 square, height {leg_h:.0f} mm (welded to frame weldment, bears on jack screw)",
                cq.Color(0.12, 0.16, 0.18),
            )
            asm.add(
                make_jack_foot(s, extension).translate((float(x), float(y), ground_z)),
                name=(
                    f"FRAME · FRM-90005 adjustable jack screw/foot x{xi} y{yi} — one of 4 corner jacks, "
                    f"{'feed-end high jack' if is_feed_end else 'discharge-end low jack'}, 1.67–4.33° tilt range "
                    f"(bolted/anchored to floor via {s['frame_anchor_bolt']})"
                ),
                color=cq.Color(0.22, 0.22, 0.20),
            )
            asm.add(
                box_xyz(110.0, 6.0, 46.0).translate((float(x) + 95.0, float(y), ground_z + 190.0)),
                name=f"FRAME · FRM-90007 angle scale plate x{xi} y{yi} — 1.67–4.33° drum-frame tilt indicator (bolted to jack bracket)",
                color=cq.Color(0.85, 0.78, 0.28),
            )
            for bi, (ax, ay) in enumerate(((-65.0, -45.0), (-65.0, 45.0), (65.0, -45.0), (65.0, 45.0)), start=1):
                asm.add(
                    make_anchor_bolt_stack().translate((float(x) + ax, float(y) + ay, ground_z + 8.0)),
                    name=f"FRAME · FRM-90006 M16 anchor bolt foot{xi}{yi} pos{bi}/4 — Ø18 clearance (anchors jack foot to deck)",
                    color=cq.Color(0.05, 0.05, 0.055),
                )


def add_support_rollers(asm: cq.Assembly, s: dict) -> None:
    rr = float(s["support_roller_d_mm"]) / 2.0
    width = float(s["support_roller_face_width_mm"])
    shaft_d = float(s["support_roller_shaft_d_mm"])
    angle = float(s["support_roller_angle_from_bottom_deg"])

    for station_idx, x in enumerate(s["support_station_x_mm"], start=1):
        for theta, side in ((-90.0 - angle, "passive -Y"), (-90.0 + angle, "drive +Y")):
            y, z = raceway_contact_center(s, theta, rr)
            asm.add(
                drum_tilt_shape(s, cyl_x(width, float(s["support_roller_d_mm"])).translate((float(x), y, z))),
                name=(
                    f"ROLLER · ROL-70001 support roller station {station_idx} {side} — Ø{s['support_roller_d_mm']:.0f} × "
                    f"{width:.0f} hardened steel Rc45 (axis parallel to 3° inclined drum; shaft supported by pillow blocks bolted to FRAME rail)"
                ),
                color=cq.Color(0.46, 0.47, 0.49),
            )
            asm.add(
                drum_tilt_shape(s, cyl_x(width + 130.0, shaft_d).translate((float(x), y, z))),
                name=f"ROLLER · ROL-70002 support shaft station {station_idx} {side} — Ø{shaft_d:.0f} steel shaft (captured by two pillow blocks)",
                color=cq.Color(0.20, 0.21, 0.22),
            )
            for dx, end_name in ((-(width / 2.0 + 45.0), "inboard"), (width / 2.0 + 45.0, "outboard")):
                bx_local = float(x) + dx
                bz_local = z - 48.0
                bx, by, bz = drum_local_to_world(s, bx_local, y, bz_local)
                z_rail = rail_top_z_at_x(s, bx)
                pillow = drum_tilt_shape(s, make_pillow_block().translate((bx_local, y, bz_local)))
                pillow_bottom = pillow.val().BoundingBox().zmin
                pedestal_h = max(40.0, pillow_bottom - z_rail)
                asm.add(
                    box_xyz(95.0, 120.0, pedestal_h).translate((bx, by, z_rail + pedestal_h / 2.0)),
                    name=(
                        f"ROLLER · BRK-70004 heavy bearing pedestal {end_name}, station {station_idx} {side} — "
                        f"painted steel bracket (welded/bolted to inclined FRAME rail at x={bx:.0f})"
                    ),
                    color=cq.Color(0.10, 0.12, 0.13),
                )
                asm.add(
                    pillow,
                    name=(
                        f"ROLLER · BRG-70003 sealed pillow-block bearing {end_name}, station {station_idx} {side} — "
                        f"{s['support_roller_bearing']} (bolted to inclined rail pedestal via BRK-70004, 4 × M12)"
                    ),
                    color=cq.Color(0.08, 0.10, 0.11),
                )
            shield_y = y + (38.0 if y > 0 else -38.0)
            asm.add(
                drum_tilt_shape(s, box_xyz(width + 180.0, 16.0, 150.0).translate((float(x), shield_y, z + 5.0))),
                name=f"SAFETY GUARD · GRD-13001 support roller sand shield station {station_idx} {side} — transparent yellow shield (bolted to roller pedestal)",
                color=cq.Color(0.92, 0.72, 0.18, 0.45),
            )


def add_motor_cover(asm: cq.Assembly, s: dict, mx: float, my: float, mz: float, ml: float, mw: float, mh: float) -> None:
    c = float(s["motor_cover_clearance_mm"])
    t = 2.0
    ox = ml + 2.0 * c
    oy = mw + 2.0 * c
    oz = mh + c
    top_z = mz + mh / 2.0 + c
    bottom_open_z = mz - mh / 2.0

    panels = [
        ("top", box_xyz(ox, oy, t).translate((mx, my, top_z))),
        ("drive long side +Y", box_xyz(ox, t, oz).translate((mx, my + oy / 2.0, bottom_open_z + oz / 2.0))),
        ("inside long side -Y", box_xyz(ox, t, oz).translate((mx, my - oy / 2.0, bottom_open_z + oz / 2.0))),
        ("upstream short side", box_xyz(t, oy, oz).translate((mx - ox / 2.0, my, bottom_open_z + oz / 2.0))),
        ("downstream short side", box_xyz(t, oy, oz).translate((mx + ox / 2.0, my, bottom_open_z + oz / 2.0))),
    ]
    for label, panel in panels:
        asm.add(
            panel,
            name=f"SAFETY GUARD · GRD-13004 gearmotor 5-side cover {label} — 2 mm A36 bolt-on wrap, bottom open for ventilation (bolted to motor skid via M8 tabs)",
            color=cq.Color(0.62, 0.70, 0.78, 0.38),
        )

    for i in range(int(s["motor_cover_slot_vent_count"])):
        asm.add(
            box_xyz(90.0, 3.0, 14.0).translate((mx - 180.0 + i * 72.0, my + oy / 2.0 + 2.0, mz + 10.0)),
            name=f"SAFETY GUARD · GRD-13005 gearmotor cover slot vent {i + 1}/{s['motor_cover_slot_vent_count']} — black visual opening on +Y side",
            color=cq.Color(0.02, 0.02, 0.02),
        )


def add_drive_roller_and_transmission(asm: cq.Assembly, s: dict) -> None:
    drive_x = float(s["drive_roller_x_mm"])
    dr = float(s["drive_roller_d_mm"]) / 2.0
    drive_theta = -6.0
    drive_y, drive_z = raceway_contact_center(s, drive_theta, dr)
    width = float(s["drive_roller_face_width_mm"])

    asm.add(
        drum_tilt_shape(s, cyl_x(width, float(s["drive_roller_d_mm"])).translate((drive_x, drive_y, drive_z))),
        name=f"DRIVE · DRV-80010 lagged drive roller — Ø{s['drive_roller_d_mm']:.0f} × {width:.0f}, {s['drive_roller_lagging']} (axis parallel to 3° inclined drum; frame-mounted friction drive on downstream raceway)",
        color=cq.Color(0.02, 0.02, 0.025),
    )
    asm.add(
        drum_tilt_shape(s, cyl_x(width + 240.0, float(s["drive_roller_shaft_d_mm"])).translate((drive_x, drive_y, drive_z))),
        name=f"DRIVE · DRV-80011 drive roller shaft — Ø{s['drive_roller_shaft_d_mm']:.0f} steel (captured by frame-mounted pillow blocks)",
        color=cq.Color(0.16, 0.17, 0.18),
    )

    for dx, label in ((-(width / 2.0 + 75.0), "inboard"), (width / 2.0 + 75.0, "pulley side")):
        bx_local = drive_x + dx
        bz_local = drive_z - 58.0
        bx, by, bz = drum_local_to_world(s, bx_local, drive_y, bz_local)
        z_rail = rail_top_z_at_x(s, bx)
        pillow = drum_tilt_shape(s, make_pillow_block().translate((bx_local, drive_y, bz_local)))
        pillow_bottom = pillow.val().BoundingBox().zmin
        pedestal_h = max(120.0, pillow_bottom - z_rail)
        asm.add(
            box_xyz(110.0, 130.0, pedestal_h).translate((bx, by, z_rail + pedestal_h / 2.0)),
            name=f"DRIVE · BRK-80014 drive bearing pedestal {label} — heavy steel bracket (bolted/welded to inclined long rail, not to plate)",
            color=cq.Color(0.10, 0.12, 0.13),
        )
        asm.add(
            pillow,
            name=f"DRIVE · BRG-80012 drive roller pillow-block bearing {label} — sealed 2RS (bolted to inclined rail pedestal via BRK-80014, 4 × M12)",
            color=cq.Color(0.08, 0.10, 0.11),
        )

    screw = cyl_y(260.0, 28.0).translate((drive_x, drive_y + 105.0, drive_z - 20.0))
    spring = cyl_y(115.0, 60.0).translate((drive_x, drive_y + 48.0, drive_z - 20.0))
    bracket = box_xyz(220.0, 18.0, 190.0).translate((drive_x, drive_y + 210.0, drive_z - 20.0))
    asm.add(
        drum_tilt_shape(s, screw.union(spring).union(bracket)),
        name=f"DRIVE · DRV-80013 screw/spring preload assembly — target {s['drive_roller_nominal_preload_kn'][0]:.1f}–{s['drive_roller_nominal_preload_kn'][1]:.1f} kN (bolted to frame drive pedestal)",
        color=cq.Color(0.18, 0.18, 0.16),
    )

    nip_guard = box_xyz(width + 260.0, 18.0, 260.0).rotate((0, 0, 0), (1, 0, 0), -8.0)
    asm.add(
        drum_tilt_shape(s, nip_guard.translate((drive_x, drive_y - 68.0, drive_z + 80.0))),
        name="SAFETY GUARD · GRD-13002 drive roller nip guard — transparent yellow shield (bolted to drive pedestal)",
        color=cq.Color(0.95, 0.74, 0.16, 0.45),
    )

    mx, my, mz = [float(v) for v in s["gearmotor_location_mm"]]
    ml, mw, mh = [float(v) for v in s["gearmotor_envelope_mm"]]
    asm.add(
        box_xyz(ml, mw, mh).translate((mx, my, mz)),
        name=f"DRIVE · DRV-80020 gearmotor 3.0 kW — {s['gearmotor_material']}, envelope {ml:.0f} × {mw:.0f} × {mh:.0f} (bolted to skid via 4 × M12)",
        color=cq.Color(0.12, 0.28, 0.48),
    )
    asm.add(
        make_motor_mount_plate(s).translate((mx, my, mz - mh / 2.0 - 18.0)),
        name="DRIVE · DRV-80021 gearmotor skid/base plate — widened steel plate reaches drive-side frame rail (bolted to FRAME rail via M12 slotted tension bolts)",
        color=cq.Color(0.18, 0.20, 0.21),
    )
    for bx, by in ((-180.0, -110.0), (-180.0, 110.0), (180.0, -110.0), (180.0, 110.0)):
        asm.add(
            make_visual_bolt_z(12.0, 12.0).translate((mx + bx, my + by, mz - mh / 2.0 - 7.0)),
            name=f"DRIVE · DRV-80022 M12 gearmotor mount bolt visual ({bx:+.0f},{by:+.0f}) — slotted base locknut",
            color=cq.Color(0.04, 0.04, 0.045),
        )
    add_motor_cover(asm, s, mx, my, mz, ml, mw, mh)

    pulley_x = drive_x + width / 2.0 + 125.0
    pulley_d = float(s["pulley_d_mm"])
    pulley_w = float(s["pulley_face_width_mm"])
    driven_pulley_center = drum_local_to_world(s, pulley_x, drive_y, drive_z)
    motor_pulley_center = (driven_pulley_center[0], my, mz + 20.0)

    asm.add(
        drum_tilt_shape(s, cyl_x(pulley_w, pulley_d).translate((pulley_x, drive_y, drive_z))),
        name=f"DRIVE · DRV-80030 Ø{pulley_d:.0f} sheave/pulley on drive-roller shaft — steel sheave (keyed/bolted to shaft)",
        color=cq.Color(0.07, 0.07, 0.075),
    )
    asm.add(
        drum_tilt_shape(s, cyl_x(pulley_w + 24.0, 74.0).translate((pulley_x, drive_y, drive_z))),
        name=f"DRIVE · DRV-80031 pulley hub on drive-roller shaft — steel hub with M10 shaft-bolt callout",
        color=cq.Color(0.16, 0.16, 0.17),
    )
    for center, label in ((motor_pulley_center, "gearmotor shaft"),):
        px, py, pz = center
        asm.add(
            cyl_x(pulley_w, pulley_d).translate((px, py, pz)),
            name=f"DRIVE · DRV-80030 Ø{pulley_d:.0f} sheave/pulley on {label} — steel sheave (keyed/bolted to shaft)",
            color=cq.Color(0.07, 0.07, 0.075),
        )
        asm.add(
            cyl_x(pulley_w + 24.0, 74.0).translate((px, py, pz)),
            name=f"DRIVE · DRV-80031 pulley hub on {label} — steel hub with M10 shaft-bolt callout",
            color=cq.Color(0.16, 0.16, 0.17),
        )

    dy = motor_pulley_center[1] - driven_pulley_center[1]
    dz = motor_pulley_center[2] - driven_pulley_center[2]
    cdist = math.sqrt(dy * dy + dz * dz)
    angle = math.degrees(math.atan2(dz, dy))
    normal_y = -math.sin(math.radians(angle))
    normal_z = math.cos(math.radians(angle))
    belt_offset = pulley_d / 2.0 - 8.0
    for sign, label in ((1.0, "outer tight span"), (-1.0, "inner return span")):
        ymid = (motor_pulley_center[1] + driven_pulley_center[1]) / 2.0 + sign * normal_y * belt_offset
        zmid = (motor_pulley_center[2] + driven_pulley_center[2]) / 2.0 + sign * normal_z * belt_offset
        asm.add(
            make_belt_span_x(pulley_w + 10.0, cdist, 14.0, angle).translate((pulley_x, ymid, zmid)),
            name=f"DRIVE · DRV-80032 V-belt visual {label} — {s['belt_type']}",
            color=cq.Color(0.01, 0.01, 0.012),
        )

    asm.add(
        box_xyz(pulley_w + 70.0, abs(dy) + pulley_d + 160.0, abs(dz) + pulley_d + 140.0).translate(
            (pulley_x, (motor_pulley_center[1] + driven_pulley_center[1]) / 2.0, (motor_pulley_center[2] + driven_pulley_center[2]) / 2.0)
        ),
        name=f"SAFETY GUARD · GRD-13003 full belt/pulley guard — transparent yellow envelope, {s['belt_guard_material']} (bolted to gearmotor skid/frame)",
        color=cq.Color(0.95, 0.74, 0.16, 0.33),
    )


def add_axial_guides(asm: cq.Assembly, s: dict) -> None:
    band_w = float(s["band_axial_width_mm"])
    sw_t = float(s["side_wall_thickness_mm"])
    guide_d = float(s["guide_roller_d_mm"])
    guide_w = float(s["guide_roller_face_width_mm"])
    lip_r = float(s["side_wall_od_mm"]) / 2.0
    guide_zs = [120.0, -120.0]
    end_defs = (
        ("upstream", float(s["band_x_inset_mm"]) - band_w / 2.0 - sw_t, -1.0),
        ("downstream", float(s["drum_length_mm"]) - float(s["band_x_inset_mm"]) + band_w / 2.0 + sw_t, 1.0),
    )
    for end_label, x_face, axial_sign in end_defs:
        for i, z in enumerate(guide_zs, start=1):
            y_surface = math.sqrt(max(lip_r * lip_r - z * z, 0.0))
            guide_y = y_surface + guide_w / 2.0 + 2.0
            guide_x = x_face + axial_sign * (guide_d / 2.0 + 2.0)
            asm.add(
                drum_tilt_shape(s, cyl_y(guide_w, guide_d).translate((guide_x, guide_y, z))),
                name=f"ROLLER · ROL-70020 axial guide roller {end_label} {i}/2 — Ø{guide_d:.0f} × {guide_w:.0f}, acts on inclined PRT-80002 lip only (bolted to frame bracket)",
                color=cq.Color(0.42, 0.43, 0.45),
            )
            arm = box_xyz(120.0, 22.0, 32.0).translate((guide_x, guide_y + 54.0, z))
            bracket = box_xyz(80.0, 18.0, 140.0).translate((guide_x, guide_y + 118.0, z - 28.0))
            asm.add(
                drum_tilt_shape(s, arm.union(bracket)),
                name=f"ROLLER · BRG-70021 axial-guide adjustable bracket {end_label} {i}/2 — slotted painted steel (bolted to FRAME rail)",
                color=cq.Color(0.10, 0.12, 0.13),
            )


def add_vfd_panel(asm: cq.Assembly, s: dict) -> None:
    vx, vy, vz = [float(v) for v in s["vfd_location_mm"]]
    vw, vd, vh = [float(v) for v in s["vfd_enclosure_mm"]]
    asm.add(
        box_xyz(vw, vd, vh).translate((vx, vy, vz)),
        name=f"HMI · ELE-16001 NEMA 4X VFD/control enclosure — stainless/polymer washdown cabinet, {s['vfd_spec']} (bolted to frame/service post)",
        color=cq.Color(0.80, 0.82, 0.78),
    )
    asm.add(
        box_xyz(vw - 36.0, 8.0, vh - 60.0).translate((vx, vy - vd / 2.0 - 5.0, vz)),
        name="HMI · ELE-16002 control-panel door face — Spanish labels, display, hour meter, lockable disconnect (hinged to ELE-16001)",
        color=cq.Color(0.68, 0.70, 0.66),
    )
    asm.add(cyl_y(24.0, 56.0).translate((vx - 135.0, vy - vd / 2.0 - 18.0, vz + 140.0)), name="HMI · ELE-16003 lockable main disconnect handle — panel-mounted", color=cq.Color(0.10, 0.10, 0.10))
    asm.add(cyl_y(28.0, 70.0).translate((vx + 135.0, vy - vd / 2.0 - 20.0, vz + 130.0)), name="HMI · ELE-16004 emergency stop mushroom — panel-mounted", color=cq.Color(0.85, 0.02, 0.02))
    for idx, (cx, cz, col, label) in enumerate(
        [
            (-90.0, 30.0, cq.Color(0.02, 0.55, 0.12), "START green"),
            (0.0, 30.0, cq.Color(0.88, 0.80, 0.05), "RESET/JOG amber"),
            (90.0, 30.0, cq.Color(0.85, 0.02, 0.02), "STOP red"),
        ],
        start=1,
    ):
        asm.add(cyl_y(20.0, 34.0).translate((vx + cx, vy - vd / 2.0 - 18.0, vz + cz)), name=f"HMI · ELE-16005-{idx} {label} button — IP-rated panel operator", color=col)

    tower_x = vx + vw / 2.0 - 60.0
    tower_base_z = vz + vh / 2.0 + 35.0
    asm.add(cyl_z(70.0, 20.0).translate((tower_x, vy, tower_base_z)), name="HMI · ELE-16006 light-tower mast — bolted to control enclosure", color=cq.Color(0.08, 0.08, 0.08))
    for i, (col, label) in enumerate([(cq.Color(0.10, 0.75, 0.18), "RUN green"), (cq.Color(0.95, 0.75, 0.05), "WARN amber"), (cq.Color(0.88, 0.02, 0.02), "FAULT red")], start=1):
        asm.add(cyl_z(36.0, 54.0).translate((tower_x, vy, tower_base_z + 35.0 + (i - 1) * 38.0)), name=f"HMI · ELE-16007-{i} 3-stage light tower {label} lens — stacked on mast", color=col)

    # 2026-05-15 PM round 8: remote e-stop moved next to motor (downstream +Y)
    # per Pablo voice: "el emergency stop puede estar al lado del motor".
    # Motor is at x=3600. Keep the remote e-stop outboard of the widened +Y rail.
    estop_y = max(760.0, float(s["frame_rail_y_mm"]) + 180.0)
    asm.add(box_xyz(160.0, 70.0, 120.0).translate((3300.0, estop_y, -340.0)), name="HMI · ELE-16008 remote e-stop station enclosure — drive/motor side (bolted to frame, same side as motor, outboard of widened rail)", color=cq.Color(0.82, 0.82, 0.76))
    asm.add(cyl_y(28.0, 62.0).translate((3300.0, estop_y + 40.0, -330.0)), name="HMI · ELE-16009 emergency stop mushroom — red, motor-adjacent (panel-mounted)", color=cq.Color(0.85, 0.02, 0.02))


def bar_between_yz(x_center: float, p1_yz: tuple[float, float], p2_yz: tuple[float, float], x_width: float, z_thick: float) -> cq.Workplane:
    y1, z1 = p1_yz
    y2, z2 = p2_yz
    dy = y2 - y1
    dz = z2 - z1
    length = math.sqrt(dy * dy + dz * dz)
    angle = math.degrees(math.atan2(dz, dy))
    return box_xyz(x_width, length, z_thick).rotate((0, 0, 0), (1, 0, 0), angle).translate((x_center, (y1 + y2) / 2.0, (z1 + z2) / 2.0))


def cylinder_between_yz_xlocal(x_center: float, p1_yz: tuple[float, float], p2_yz: tuple[float, float], diameter: float) -> cq.Workplane:
    y1, z1 = p1_yz
    y2, z2 = p2_yz
    dy = y2 - y1
    dz = z2 - z1
    length = math.sqrt(dy * dy + dz * dz)
    angle = math.degrees(math.atan2(dz, dy))
    return cyl_y(length, diameter).rotate((0, 0, 0), (1, 0, 0), angle).translate((x_center, (y1 + y2) / 2.0, (z1 + z2) / 2.0))


def make_compression_spring_visual(x: float, start_yz: tuple[float, float], end_yz: tuple[float, float], spring_d: float) -> cq.Workplane:
    y1, z1 = start_yz
    y2, z2 = end_yz
    dy = y2 - y1
    dz = z2 - z1
    length = math.sqrt(dy * dy + dz * dz)
    angle = math.degrees(math.atan2(dz, dy))
    core = cyl_y(length, spring_d * 0.18).rotate((0, 0, 0), (1, 0, 0), angle).translate((x, (y1 + y2) / 2.0, (z1 + z2) / 2.0))
    spring = core
    coils = 8
    for i in range(coils):
        t = (i + 0.5) / coils
        cy = y1 + dy * t
        cz = z1 + dz * t
        ring = cyl_y(8.0, spring_d).rotate((0, 0, 0), (1, 0, 0), angle).translate((x, cy, cz))
        spring = spring.union(ring)
    return spring


def add_brushes(asm: cq.Assembly, s: dict) -> None:
    brush_len = float(s["brush_bristle_length_mm"])
    brush_d = float(s["brush_bristle_d_mm"])
    brush_r = brush_d / 2.0
    band_r = float(s["band_od_mm"]) / 2.0
    overlap = float(s.get("brush_bristle_overlap_mm", 5.0))
    contact_theta = -45.0

    # One and only one tangential cylindrical bristle bundle per band.
    # Bundle axis is parallel to drum X. Bristles touch the running-band OD at approx. 4 o'clock with ~5 mm overlap.
    bristle_center_r = band_r + brush_r - overlap
    bristle_y = bristle_center_r * math.cos(math.radians(contact_theta))
    bristle_z = bristle_center_r * math.sin(math.radians(contact_theta))

    # Frame-mounted pivot is radially outward from the bristle bundle, close to the drive-side rail/crossmember.
    pivot_r = float(s.get("brush_frame_pivot_radial_mm", 916.0))
    pivot_y = pivot_r * math.cos(math.radians(contact_theta))
    pivot_z = pivot_r * math.sin(math.radians(contact_theta))

    flat_bar_x, flat_bar_z = [float(v) for v in s.get("brush_pivot_arm_flat_bar_mm", [70.0, 18.0])]
    spring_d = float(s.get("brush_spring_d_mm", 50.0))
    tilt_cos = math.cos(math.radians(drum_tilt_angle_deg(s)))

    band_centers = [
        (float(s["band_x_inset_mm"]), "upstream"),
        (float(s["drum_length_mm"]) - float(s["band_x_inset_mm"]), "downstream"),
    ]

    for idx, (x, label) in enumerate(band_centers, start=1):
        bristle_yz = (bristle_y, bristle_z)
        pivot_yz = (pivot_y, pivot_z)
        arm_mid_yz = ((bristle_y + pivot_y) / 2.0, (bristle_z + pivot_z) / 2.0)

        # Fixed bracket/lug on the frame side for the compression spring; positioned so the spring is visibly loading the arm inward.
        spring_fixed_yz = (pivot_y - 24.0, pivot_z - 190.0)
        axis_z_at_x = drum_axis_z_at_x(s, x)
        rail_anchor_world_z = rail_top_z_at_x(s, x)
        rail_anchor_local_z = (rail_anchor_world_z - axis_z_at_x) / max(tilt_cos, 1e-6)

        asm.add(
            drum_tilt_shape(s, cyl_x(brush_len, brush_d).translate((x, bristle_y, bristle_z))),
            name=(
                f"AB-10001-{idx} anti-blinding bristle bundle, {label} running band — ONE Ø{brush_d:.0f} × {brush_len:.0f} "
                f"tangential cylinder, axis parallel to 3° inclined drum, bristles overlap PRT-80001 OD by {overlap:.0f} mm at 4-o'clock"
            ),
            color=cq.Color(0.06, 0.055, 0.035),
        )

        asm.add(
            drum_tilt_shape(s, cyl_y(150.0, float(s["brush_pivot_shaft_d_mm"])).translate((x, pivot_y, pivot_z))),
            name=(
                f"AB-10002-{idx} frame-mounted brush pivot pin, {label} band — Ø{s['brush_pivot_shaft_d_mm']:.0f} horizontal pin "
                f"perpendicular to drum axis, captured by welded frame bracket at drive-side rail/crossmember"
            ),
            color=cq.Color(0.18, 0.18, 0.17),
        )

        asm.add(
            drum_tilt_shape(s, bar_between_yz(x, bristle_yz, pivot_yz, flat_bar_x, flat_bar_z)),
            name=(
                f"AB-10003-{idx} single radial pivot arm, {label} band — flat bar from bristle-bundle midpoint to frame pivot; "
                f"no duplicate arms, no floating parts"
            ),
            color=cq.Color(0.14, 0.14, 0.13),
        )

        asm.add(
            drum_tilt_shape(s, make_compression_spring_visual(x, arm_mid_yz, spring_fixed_yz, spring_d)),
            name=(
                f"AB-10004-{idx} compression spring preload, {label} band — Ø{spring_d:.0f} spring between pivot-arm midpoint "
                f"and fixed frame bracket, loading bristles onto running band"
            ),
            color=cq.Color(0.72, 0.70, 0.62),
        )

        bracket_web = bar_between_yz(x, (float(s["frame_rail_y_mm"]), rail_anchor_local_z), pivot_yz, 80.0, 14.0)
        pivot_lug = box_xyz(120.0, 26.0, 90.0).translate((x, pivot_y, pivot_z))
        spring_lug = box_xyz(95.0, 22.0, 75.0).translate((x, spring_fixed_yz[0], spring_fixed_yz[1]))
        asm.add(
            drum_tilt_shape(s, bracket_web.union(pivot_lug).union(spring_lug)),
            name=(
                f"AB-10005-{idx} welded frame bracket for brush, {label} band — anchored to drive-side FRAME rail/crossmember; "
                f"carries pivot pin and spring fixed lug"
            ),
            color=cq.Color(0.10, 0.12, 0.13),
        )

    # 2026-05-15 PM round 10 (GPT review #1): SCREEN anti-blinding brush.
    # Bible §10.2 requires brush coverage over the first 40-60% of drum
    # length to clear blinded holes as they rotate past. Distinct from
    # the band brushes (above), which only clean the raceway band OD.
    # Positioned external, lower passive-side quadrant at approx 7-8
    # o'clock (~225° below horizontal, -Y bias) — opposite the band
    # brushes' drive-side 4 o'clock position.
    if s.get("screen_brush_included"):
        sb_segments = s.get("screen_brush_segments_mm") or [
            [float(s["screen_brush_x_start_mm"]), float(s["screen_brush_x_start_mm"]) + float(s["screen_brush_length_mm"])]
        ]
        sb_bristle_len = float(s["screen_brush_bristle_length_mm"])
        sb_bristle_d = float(s["screen_brush_bristle_d_mm"])
        drum_r = float(s["drum_od_mm"]) / 2.0
        # Bundle CENTER radius from drum axis: drum_r + rendered bundle radius - overlap.
        # The 90 mm bristle length is a procurement/adjustment dimension; the
        # rendered Ø60 bundle is what determines solid overlap in this CAD model.
        _ = sb_bristle_len
        sb_overlap = float(s.get("screen_brush_overlap_mm", 5.0))
        sb_bundle_r = drum_r + sb_bristle_d / 2.0 - sb_overlap
        # Position at ~225° (7:30 o'clock — bottom-passive-Y)
        sb_theta = math.radians(float(s.get("screen_brush_clock_deg", 225.0)))
        sb_y = sb_bundle_r * math.cos(sb_theta)
        sb_z = sb_bundle_r * math.sin(sb_theta)
        bracket_y = -float(s["frame_rail_y_mm"])    # passive rail
        total_active = sum(float(x1) - float(x0) for x0, x1 in sb_segments)
        for seg_idx, (x0_raw, x1_raw) in enumerate(sb_segments, start=1):
            sb_x_start = float(x0_raw)
            sb_x_end = float(x1_raw)
            sb_len = sb_x_end - sb_x_start
            sb_x_center = (sb_x_start + sb_x_end) / 2.0
            sb_cyl = cyl_x(sb_len, sb_bristle_d).translate((sb_x_center, sb_y, sb_z))
            asm.add(
                drum_tilt_shape(s, sb_cyl),
                name=(
                    f"AB-10010-{seg_idx} SCREEN anti-blinding brush segment — longitudinal bristle bundle "
                    f"Ø{sb_bristle_d:.0f} × {sb_len:.0f} mm spanning x={sb_x_start:.0f}..{sb_x_end:.0f} "
                    f"(total active {total_active/float(s['drum_length_mm'])*100:.0f}% of drum length), "
                    f"external lower-passive quadrant ~7:30 o'clock; segmented to clear rings/bands (Bible §10.2 + FAT F09)"
                ),
                color=cq.Color(0.55, 0.45, 0.30),
            )
            # Two end brackets per segment (US + DS) connecting the bundle to the long rail.
            for sb_end_label, sb_end_x in (("US", sb_x_start - 50.0), ("DS", sb_x_end + 50.0)):
                axis_z_at_end = drum_axis_z_at_x(s, sb_end_x)
                rail_anchor_world_z = rail_top_z_at_x(s, sb_end_x)
                bracket_z = (rail_anchor_world_z - axis_z_at_end) / max(tilt_cos, 1e-6)
                asm.add(
                    drum_tilt_shape(s, bar_between_yz(sb_end_x, (sb_y, sb_z), (bracket_y, bracket_z), 50.0, 12.0)),
                    name=f"AB-10011-{seg_idx}-{sb_end_label} screen-brush end bracket (anchors AB-10010 segment to passive long rail)",
                    color=cq.Color(0.12, 0.14, 0.15),
                )


def add_sand_deflector(asm: cq.Assembly, s: dict) -> None:
    length = float(s["sand_deflector_length_mm"])
    span = float(s["sand_deflector_span_mm"])
    thick = float(s["sand_deflector_thickness_mm"])
    tilt = float(s["sand_deflector_tilt_deg"])
    x_center = float(s["sand_deflector_x_center_mm"])
    y_center = float(s.get("sand_deflector_y_center_mm", 0.0))   # was hardcoded 0; respect spec
    z_center = float(s["sand_deflector_z_center_mm"])
    plate = box_xyz(length, span, thick).rotate((0, 0, 0), (1, 0, 0), tilt)
    plate = plate.translate((x_center, y_center, z_center))

    # 2026-05-15 PM round 8 fix (Pablo: "deflector crosses X-braces, add slots"):
    # Cut X-brace slots through the deflector plate. Reproduce the X-brace
    # diagonal geometry locally (same math as in add_frame), enlarge by a
    # clearance margin, and subtract from the plate.
    y_rail = float(s["frame_rail_y_mm"])
    ptr_h = float(s["frame_ptr_h_mm"])
    ground_z = float(s["ground_z_mm"])
    foot_plate_thickness = float(s["frame_foot_plate_mm"][2])
    leg_xs = s["frame_leg_x_mm"]
    leg_x_feed = min(leg_xs)
    leg_x_discharge = max(leg_xs)
    z_top_at_feed = rail_center_z_at_x(s, leg_x_feed) - ptr_h / 2.0
    z_top_at_discharge = rail_center_z_at_x(s, leg_x_discharge) - ptr_h / 2.0
    z_bottom = ground_z + foot_plate_thickness
    brace_flat_w = 30.0
    brace_flat_t = 5.0
    slot_clearance = 8.0   # mm — slot oversized vs brace cross-section for fit-up
    for side_y in (-y_rail, y_rail):
        for (xa, za, xb, zb) in [
            (leg_x_feed, z_top_at_feed, leg_x_discharge, z_bottom),
            (leg_x_discharge, z_top_at_discharge, leg_x_feed, z_bottom),
        ]:
            length_b = math.hypot(xb - xa, zb - za)
            angle_b = math.degrees(math.atan2(zb - za, xb - xa))
            cutter = box_xyz(
                length_b + 200.0,
                brace_flat_w + 2 * slot_clearance,
                brace_flat_t + 2 * slot_clearance,
            )
            cutter = cutter.rotate((0, 0, 0), (0, 1, 0), angle_b)
            cutter = cutter.translate(((xa + xb) / 2.0, side_y, (za + zb) / 2.0))
            try:
                plate = plate.cut(cutter)
            except Exception:
                pass

    # 2026-05-15 late Pablo correction: deflector length returns to the drum
    # X envelope (x=0..4000) so it does not bury the end legs. Keep the leg
    # slot cutters anyway; if a future tab/leg projection intersects the plate,
    # the cutout remains explicit instead of silently becoming a clash.
    # Each leg is PTR ptr_w × ptr_w in cross-section; slot = leg cross-section
    # + 30 mm clearance per side.
    leg_y_positions = s["frame_leg_y_mm"]   # [-700, +700]
    leg_slot_clearance = 30.0
    for leg_x in leg_xs:
        for leg_y in leg_y_positions:
            slot = box_xyz(
                float(s["frame_ptr_w_mm"]) + 2 * leg_slot_clearance,
                float(s["frame_ptr_w_mm"]) + 2 * leg_slot_clearance,
                2400.0,   # tall enough to fully pierce the tilted plate at any leg/brace elevation
            ).translate((float(leg_x), float(leg_y), float(s["ground_z_mm"]) + 900.0))
            try:
                plate = plate.cut(slot)
            except Exception:
                pass

    asm.add(
        plate,
        name=f"DEF-90040 single inclined sand-deflector plate — {s['sand_deflector_material']}, {length:.0f} × {span:.0f} × {thick:.0f}, tilted {tilt:.0f}° toward {s['sand_deflector_slope_side']} (bolted to frame tabs; X-brace and leg clearance slots retained 2026-05-15 late)",
        color=cq.Color(0.58, 0.62, 0.62, 0.72),
    )
    low_y = y_center - span * math.cos(math.radians(tilt)) / 2.0 - float(s.get("sand_deflector_low_lip_overhang_mm", 80.0))
    low_z = z_center - span * math.sin(math.radians(tilt)) / 2.0 - 15.0
    asm.add(
        box_xyz(length, 28.0, 42.0).translate((x_center, low_y, low_z)),
        name="DEF-90041 low-side drip edge / collection lip — 304 stainless angle (welded to DEF-90040, directs sand to side collection cart)",
        color=cq.Color(0.46, 0.50, 0.50),
    )


def add_misc_guards_and_reference(asm: cq.Assembly, s: dict) -> None:
    for y, side in ((-710.0, "passive -Y"), (710.0, "drive +Y")):
        asm.add(
            box_xyz(3850.0, 18.0, 480.0).translate((2000.0, y, -250.0)),
            name=f"SAFETY GUARD · GRD-13010 removable side guard {side} — transparent yellow roller/stringer access panel (bolted to frame, tool-only removal)",
            color=cq.Color(0.95, 0.74, 0.16, 0.22),
        )

    cx, cy, cz = [float(v) for v in s["cart_660l_location_mm"]]
    cl, cw, ch = [float(v) for v in s["cart_660l_envelope_mm"]]
    wall_t = float(s.get("cart_bin_wall_thickness_mm", 35.0))
    wheel_d = float(s.get("cart_bin_wheel_d_mm", 120.0))
    floor_t = min(45.0, wall_t + 10.0)
    body_bottom_z = cz - ch / 2.0 + wheel_d
    body_h = ch - wheel_d
    body_center_z = body_bottom_z + body_h / 2.0
    cart_color = cq.Color(0.10, 0.45, 0.18, 0.30)
    asm.add(
        box_xyz(cl, cw, floor_t).translate((cx, cy, body_bottom_z + floor_t / 2.0)),
        name=f"REF-90060 open-top 720 L catch bin bottom pan — {cl:.0f} × {cw:.0f} mm footprint, no top lid",
        color=cart_color,
    )
    for x_side, label in ((cx - cl / 2.0 + wall_t / 2.0, "upstream wall"), (cx + cl / 2.0 - wall_t / 2.0, "downstream wall")):
        asm.add(
            box_xyz(wall_t, cw, body_h).translate((x_side, cy, body_center_z)),
            name=f"REF-90060 open-top 720 L catch bin {label} — low-profile industrial bin wall, top open for discharge",
            color=cart_color,
        )
    for y_side, label in ((cy - cw / 2.0 + wall_t / 2.0, "passive -Y side wall"), (cy + cw / 2.0 - wall_t / 2.0, "drive +Y side wall")):
        asm.add(
            box_xyz(cl - 2.0 * wall_t, wall_t, body_h).translate((cx, y_side, body_center_z)),
            name=f"REF-90060 open-top 720 L catch bin {label} — low-profile industrial bin wall, top open for discharge",
            color=cart_color,
        )
    wheel_z = cz - ch / 2.0 + wheel_d / 2.0
    wheel_idx = 0
    for x_side in (cx - cl / 2.0 + 95.0, cx + cl / 2.0 - 95.0):
        for y_side in (cy - cw / 2.0 + 140.0, cy + cw / 2.0 - 140.0):
            wheel_idx += 1
            asm.add(
                cyl_y(70.0, wheel_d).translate((x_side, y_side, wheel_z)),
                name=f"REF-90060 open-top 720 L catch bin wheel {wheel_idx}/4 — swivel/caster visual",
                color=cq.Color(0.03, 0.035, 0.035),
            )


def build_assembly(s: dict = SPEC) -> cq.Assembly:
    asm = cq.Assembly(name=s["slug"])

    def add_rotating(shape: cq.Workplane, name: str, color: cq.Color) -> None:
        asm.add(drum_tilt_shape(s, shape), name=name, color=color)

    drum_len = float(s["drum_length_mm"])
    drum_od = float(s["drum_od_mm"])
    drum_id = float(s["drum_id_mm"])
    drum_inner_r = drum_id / 2.0

    add_rotating(
        hollow_cylinder_x(drum_len, drum_od, drum_id).translate((drum_len / 2.0, 0, 0)),
        name=f"PRT-80005 drum shell / screen carrier — 8 mm shell with {s['screen_panel_material']}, Ø{drum_od:.0f} OD × Ø{drum_id:.0f} ID × {drum_len:.0f}, Ø4 mm perforation callout (screen panels bolted/welded to shell stringers)",
        color=cq.Color(0.72, 0.76, 0.78, 0.55),
    )

    seam_len = drum_len - 2.0 * float(s["ring_x_inset_mm"])
    for i in range(4):
        theta = i * 90.0 + 45.0
        seam = oriented_internal_part(cq.Workplane("XY").box(seam_len, 6.0, 4.0), drum_len / 2.0, theta, drum_inner_r - 2.0)
        add_rotating(
            seam,
            name=f"PRT-80005 screen panel seam/backing strip {i + 1}/4 — 316L stainless backing strip (welded/bolted to screen shell)",
            color=cq.Color(0.55, 0.60, 0.62),
        )

    # Ring positions: upstream + midspan + downstream (3 rings total).
    # Midspan ring added 2026-04-30 per torsion/wobble analysis — drum length /
    # OD ratio = 4000/1100 = 3.6, industrial trommels at this ratio typically use
    # 3-4 rings, not 2. Mid-span ring shifts first natural mode away from operating
    # RPM and reduces drum body deflection under asymmetric sargassum load.
    ring_positions = (
        (float(s["ring_x_inset_mm"]),                      "upstream"),
        (drum_len / 2.0,                                    "midspan"),
        (drum_len - float(s["ring_x_inset_mm"]),            "downstream"),
    )
    for x_pos, label in ring_positions:
        add_rotating(
            make_structural_ring(s).translate((x_pos, 0, 0)),
            name=f"PRT-80003 {label} structural ring — {s['ring_material']}, 14 mm, OD {s['ring_od_mm']:.0f}/ID {s['ring_id_mm']:.0f}, 12 × M10 PCD {s['ring_bolt_pcd_mm']:.0f} (welded to drum shell)",
            color=cq.Color(0.33, 0.34, 0.36),
        )

    band_centers = ((float(s["band_x_inset_mm"]), "upstream"), (drum_len - float(s["band_x_inset_mm"]), "downstream"))
    for x_pos, label in band_centers:
        add_rotating(
            make_running_band(s).translate((x_pos, 0, 0)),
            name=f"PRT-80001 {label} running band / raceway — {s['band_material']}, OD {s['band_od_mm']:.0f}/ID {s['band_id_mm']:.0f} × {s['band_axial_width_mm']:.0f} wide (bolted through shell to PRT-80003 ring via same 12 × M10 PCD)",
            color=cq.Color(0.50, 0.50, 0.54),
        )

    sw_t = float(s["side_wall_thickness_mm"])
    band_w = float(s["band_axial_width_mm"])
    for band_x, band_label in band_centers:
        for side_name, offset in (("inner", -(band_w / 2.0 + sw_t / 2.0)), ("outer", band_w / 2.0 + sw_t / 2.0)):
            add_rotating(
                make_side_wall(s).translate((band_x + offset, 0, 0)),
                name=f"PRT-80002 {band_label} {side_name} side wall / axial flange — {s['side_wall_material']}, OD {s['side_wall_od_mm']:.0f}/ID {s['side_wall_id_mm']:.0f} × {s['side_wall_thickness_mm']:.0f} (bolted to PRT-80003 ring via 12 × M10 PCD 1120)",
                color=cq.Color(0.64, 0.66, 0.68),
            )

    if bool(s.get("include_inlet_cone", False)):
        add_rotating(
            make_inlet_cone(s),
            name=f"PRT-80004 upstream inlet cone — {s['cone_material']}, axial length {s['cone_length_mm']:.0f} (welded to upstream drum-shell mouth at x=0)",
            color=cq.Color(0.38, 0.42, 0.44),
        )

    n_str = int(s["stringer_count"])
    stringer_base = make_stringer(s)
    str_h = float(s["stringer_height_mm"])
    for i in range(n_str):
        theta = i * 360.0 / n_str
        add_rotating(
            oriented_internal_part(stringer_base, drum_len / 2.0, theta, drum_inner_r - str_h / 2.0),
            name=f"PRT-80006 longitudinal stringer / carrier rail {i + 1}/{n_str} — {s['stringer_material']}, 40 × 10 × {s['stringer_length_mm']:.0f}, M8 end holes (welded to upstream + downstream PRT-80003 rings)",
            color=cq.Color(0.29, 0.30, 0.31),
        )

    cols = int(s["lifter_angular_cols"])
    rows = int(s["lifter_axial_rows"])
    total = cols * rows
    x_start = float(s["lifter_x_start_mm"])
    x_end = float(s["lifter_x_end_mm"])
    x_step = (x_end - x_start) / max(1, rows - 1)
    theta_step = 360.0 / cols
    helix_step = float(s["lifter_helix_angle_deg_per_row"])
    lift_h = float(s["lifter_inward_height_mm"])
    wear_h = float(s["lifter_wear_bar_height_mm"])
    tab_lx, tab_ly, tab_lz = [float(v) for v in s["lifter_tab_mm"]]

    lifter_body_base = make_lifter_body(s)
    wear_bar_base = make_lifter_wear_bar(s)
    tab_base = make_lifter_tab(s)

    placed = 0
    for row in range(rows):
        x_pos = x_start + row * x_step
        helix_offset = row * helix_step
        for col in range(cols):
            theta = col * theta_step + helix_offset
            placed += 1
            add_rotating(
                oriented_internal_part(lifter_body_base, x_pos, theta, drum_inner_r - lift_h / 2.0),
                name=f"PRT-80007 lifter body {placed}/{total} — {s['lifter_body_material']}, row {row + 1}/{rows}, col {col + 1}/{cols}, θ={theta:.1f}° (welded to nearest stringer via tab)",
                color=cq.Color(0.55, 0.42, 0.25),
            )
            add_rotating(
                oriented_internal_part(tab_base, x_pos, theta, drum_inner_r - tab_lz / 2.0),
                name=f"PRT-80007 lifter connection tab {placed}/{total} — small visible tab with 2 × M8 Ø9 hole positions (welded to nearest stringer via tab)",
                color=cq.Color(0.62, 0.48, 0.28),
            )
            add_rotating(
                oriented_internal_part(wear_bar_base, x_pos, theta, drum_inner_r - lift_h + wear_h / 2.0),
                name=f"PRT-80007 Hardox wear bar {placed}/{total} — {s['lifter_wear_bar_material']} (bolted to lifter body via 2 × M10 countersunk clearance Ø11)",
                color=cq.Color(0.18, 0.18, 0.19),
            )

    add_frame(asm, s)
    add_support_rollers(asm, s)
    add_drive_roller_and_transmission(asm, s)
    add_axial_guides(asm, s)
    add_vfd_panel(asm, s)
    add_brushes(asm, s)
    add_sand_deflector(asm, s)
    add_misc_guards_and_reference(asm, s)
    add_hold_down_rollers_and_arch(asm, s)

    return asm


def add_hold_down_rollers_and_arch(asm: cq.Assembly, s: dict) -> None:
    """Top hold-down stabilizer rollers + portal-arch frame to mount them.

    2026-05-15 PM (Pablo voice note): V1 had top wheels on the drum to
    prevent uplift during DAF events. V2 only had bottom support rollers
    (30° V-cradle) which carry radial load but DON'T restrain radial
    uplift. This function adds the missing top stabilizer.

    Architecture:
      Portal arch at each support station X (mirroring bottom rollers):
        - 2 vertical posts per station, one each side at Y=±arch_post_y_mm
        - 1 horizontal beam at top spanning Y=-arch_post_y to +arch_post_y
        - 2 hold-down rollers per station, engaging band OD from above at
          ±30° from vertical-up (inverted V-cradle).

    Roller axis (in drum local frame, drum axis at z=0):
        r_axis = (R_band + R_roller) / 2 = (band_od + roller_d) / 4
        Wait — axis distance from drum axis = R_band + R_roller (radii sum, not diameter sum / 2).
        For band_od=1140, roller_d=140: axis distance = 570+70 = 640 mm.
        At 30° from top: y_offset = 640·sin(30°) = 320; z_offset = 640·cos(30°) = 554.
    """
    if not s.get("hold_down_station_count"):
        return

    drum_od = s["drum_od_mm"]
    band_od = s["band_od_mm"]
    roller_d = s["hold_down_roller_d_mm"]
    roller_face = s["hold_down_roller_face_width_mm"]
    ptr_w = float(s["frame_ptr_w_mm"])
    ptr_h = float(s["frame_ptr_h_mm"])
    hanger_ptr = float(s.get("hold_down_bracket_ptr_mm", 40.0))
    shaft_d = float(s.get("hold_down_roller_shaft_d_mm", s.get("support_roller_shaft_d_mm", 40.0)))
    bearing_offset = float(s.get("hold_down_bearing_offset_from_face_mm", 45.0))
    bearing_block_x, bearing_block_y, bearing_block_z = [
        float(v) for v in s.get("hold_down_bearing_block_mm", [50.0, 90.0, 60.0])
    ]

    crossbeam_rows = top_arch_crossbeam_part_specs(s)
    side_rows = top_arch_side_part_specs(s)
    roller_rows = hold_down_roller_part_specs(s)
    station_numbers = sorted({int(row["station"]) for row in crossbeam_rows})
    station_total = len(station_numbers)
    if not station_total:
        return

    arch_color = cq.Color(0.13, 0.17, 0.19)
    beam_bottom_by_key: dict[tuple[int, str], float] = {}
    beam_bottom_by_station: dict[int, float] = {}
    station_world_x_by_station: dict[int, float] = {}
    r_axis_dist = (band_od + roller_d) / 2.0   # = R_band + R_roller

    for beam_row in crossbeam_rows:
        station_idx = int(beam_row["station"])
        station_x = float(beam_row["x_mm"])
        side_label = str(beam_row.get("side", ""))
        beam_clearance = float(beam_row.get("beam_clearance_above_drum_top_mm", s["top_arch_beam_z_above_drum_top_mm"]))
        station_world_x, _, drum_top_world_z = drum_local_to_world(s, station_x, 0.0, drum_od / 2.0)
        beam_z = drum_top_world_z + beam_clearance + ptr_h / 2.0
        if side_label:
            sign = -1.0 if "passive" in side_label else 1.0
            outer_y = float(beam_row.get("outer_post_center_y_mm", sign * s["top_arch_post_y_mm"]))
            inner_y = float(beam_row.get("inner_end_y_mm", sign * r_axis_dist * math.sin(math.radians(s["hold_down_roller_angle_from_top_deg"]))))
            beam_span_y = abs(outer_y - inner_y) + ptr_h
            beam_center_y = (outer_y + inner_y) / 2.0
            beam_name = (
                f"ARCH · ARH-90020 side cap beam station {station_idx}/{station_total} {side_label} — "
                "PTR 80×80 from long-rail post to hold-down saddle; no full-width cross-drum tail"
            )
        else:
            beam_half_span_y = float(beam_row.get("beam_half_span_y_mm", s["top_arch_post_y_mm"]))
            beam_span_y = 2.0 * beam_half_span_y + ptr_w
            beam_center_y = 0.0
            beam_name = f"ARCH · ARH-90020 portal-arch crossbeam station {station_idx}/{station_total} — independent PTR 80×80 square above 3° inclined drum (mounts hold-down rollers)"
        beam = box_xyz(ptr_w, beam_span_y, ptr_h).translate((station_world_x, beam_center_y, beam_z))
        asm.add(
            beam,
            name=beam_name,
            color=arch_color,
        )
        beam_bottom_by_station.setdefault(station_idx, beam_z - ptr_h / 2.0)
        if side_label:
            beam_bottom_by_key[(station_idx, side_label)] = beam_z - ptr_h / 2.0
        else:
            beam_bottom_by_key[(station_idx, "passive -Y")] = beam_z - ptr_h / 2.0
            beam_bottom_by_key[(station_idx, "drive +Y")] = beam_z - ptr_h / 2.0
        station_world_x_by_station[station_idx] = station_world_x

    for side_row in side_rows:
        station_idx = int(side_row["station"])
        station_x = float(side_row["x_mm"])
        label = str(side_row["side"])
        station_world_x = station_world_x_by_station.get(station_idx)
        if station_world_x is None:
            station_world_x, _, _ = drum_local_to_world(s, station_x, 0.0, drum_od / 2.0)
        beam_bottom_z = beam_bottom_by_key.get((station_idx, label), beam_bottom_by_station[station_idx])
        foot_center_y = float(side_row["rail_foot_center_y_mm"])
        upper_post_center_y = float(side_row["upper_post_center_y_mm"])
        foot_h = float(side_row.get("rail_foot_min_height_mm", s.get("top_arch_rail_foot_height_mm", 120.0)))
        upper_post_target_h = float(side_row.get("upper_post_height_mm", s.get("top_arch_upper_post_height_mm", 0.0)))

        # The lower face is miter-cut to the sloped FRM-90001 rail top. This
        # part is now driven by its own row, so editing one adapter/post does
        # not drag the opposite side or opposite station with it.
        x0 = station_world_x - ptr_w / 2.0
        x1 = station_world_x + ptr_w / 2.0
        z0 = rail_top_z_at_x(s, x0)
        z1 = rail_top_z_at_x(s, x1)
        if upper_post_target_h > 0.0:
            foot_top_z = beam_bottom_z - upper_post_target_h
            min_foot_top_z = max(z0, z1) + 20.0
            if foot_top_z < min_foot_top_z:
                foot_top_z = min_foot_top_z
        else:
            foot_top_z = max(z0, z1) + foot_h
        foot = (
            cq.Workplane("XZ")
            .polyline([(x0, z0), (x1, z1), (x1, foot_top_z), (x0, foot_top_z)])
            .close()
            .extrude(ptr_h / 2.0, both=True)
            .translate((0.0, foot_center_y, 0.0))
        )
        asm.add(
            foot,
            name=f"ARCH · ARH-90021 portal-arch lower rail-bearing adapter station {station_idx}/{station_total} {label} — independent standoff from FRM-90001 long rail to upper stabilizer post",
            color=arch_color,
        )
        upper_post_h = beam_bottom_z - foot_top_z
        upper_post = box_xyz(ptr_w, ptr_h, upper_post_h).translate(
            (station_world_x, upper_post_center_y, foot_top_z + upper_post_h / 2.0)
        )
        asm.add(
            upper_post,
            name=f"ARCH · ARH-90023 portal-arch upper vertical post station {station_idx}/{station_total} {label} — independent PTR 80×80 square clear of rotating side-wall/raceway envelope",
            color=arch_color,
        )

    # Hold-down rollers: explicit row per station/side, with shaft, saddle,
    # bearing blocks, and two hangers added as separate assembly children.
    for roller_row in roller_rows:
        station_idx = int(roller_row["station"])
        station_x = float(roller_row["x_mm"])
        side_label = str(roller_row["side"])
        sign = float(roller_row.get("side_sign", -1.0 if "passive" in side_label else 1.0))
        angle_deg = float(roller_row.get("angle_from_top_deg", s["hold_down_roller_angle_from_top_deg"]))
        station_world_x = station_world_x_by_station.get(station_idx)
        if station_world_x is None:
            station_world_x, _, _ = drum_local_to_world(s, station_x, 0.0, drum_od / 2.0)
        beam_bottom_z = beam_bottom_by_key.get((station_idx, side_label), beam_bottom_by_station[station_idx])

        y_axis = sign * r_axis_dist * math.sin(math.radians(angle_deg))
        z_axis = r_axis_dist * math.cos(math.radians(angle_deg))   # above drum axis (positive z)
        roller = cyl_x(roller_face, roller_d).translate((station_x, y_axis, z_axis))
        roller_world_x, roller_world_y, roller_world_z = drum_local_to_world(s, station_x, y_axis, z_axis)
        asm.add(
            drum_tilt_shape(s, roller),
            name=f"HOLDDOWN · ROL-70030 hold-down roller station {station_idx}/{station_total} {side_label} — Ø{int(roller_d)} × {int(roller_face)} hardened steel Rc 45 (axis parallel to 3° inclined drum; engages band OD from above, restrains radial uplift)",
            color=cq.Color(0.18, 0.18, 0.18),
        )
        shaft_len = roller_face + 2.0 * bearing_offset + 2.0 * hanger_ptr
        asm.add(
            drum_tilt_shape(s, cyl_x(shaft_len, shaft_d).translate((station_x, y_axis, z_axis))),
            name=f"HOLDDOWN · SHF-70032 hold-down roller shaft station {station_idx}/{station_total} {side_label} — Ø{int(shaft_d)} steel shaft, captured by paired arch hangers",
            color=cq.Color(0.08, 0.09, 0.10),
        )

        saddle_len = roller_face + 2.0 * bearing_offset + hanger_ptr
        saddle_z = beam_bottom_z - hanger_ptr / 2.0
        saddle = box_xyz(saddle_len, hanger_ptr, hanger_ptr).translate((station_world_x, roller_world_y, saddle_z))
        asm.add(
            saddle,
            name=f"HOLDDOWN · BRK-70031 top saddle rail station {station_idx}/{station_total} {side_label} — independent PTR {int(hanger_ptr)}×{int(hanger_ptr)} bridging arch beam to paired shaft hangers",
            color=cq.Color(0.13, 0.17, 0.19),
        )

        hanger_top_z = saddle_z - hanger_ptr / 2.0
        for dx, end_label in (
            (-(roller_face / 2.0 + bearing_offset), "feed-side shaft end"),
            ((roller_face / 2.0 + bearing_offset), "discharge-side shaft end"),
        ):
            bx_local = station_x + dx
            bx, by, bz = drum_local_to_world(s, bx_local, y_axis, z_axis)
            bearing_block = box_xyz(bearing_block_x, bearing_block_y, bearing_block_z).translate((bx, by, bz))
            asm.add(
                bearing_block,
                name=f"HOLDDOWN · BRG-70033 hold-down bearing block {end_label}, station {station_idx}/{station_total} {side_label} — sealed pillow-block visual envelope bolted to paired PTR hanger",
                color=cq.Color(0.07, 0.09, 0.10),
            )
            bearing_top_z = bz + bearing_block_z / 2.0
            hanger_h = hanger_top_z - bearing_top_z
            if hanger_h > 0:
                hanger = box_xyz(hanger_ptr, hanger_ptr, hanger_h).translate((bx, by, bearing_top_z + hanger_h / 2.0))
                asm.add(
                    hanger,
                    name=f"HOLDDOWN · BRK-70034 hold-down shaft-end hanger {end_label}, station {station_idx}/{station_total} {side_label} — independent PTR {int(hanger_ptr)}×{int(hanger_ptr)} from saddle underside to bearing block",
                    color=cq.Color(0.13, 0.17, 0.19),
                )


def main() -> None:
    started_at = datetime.now(timezone.utc)
    asm = build_assembly(SPEC)
    out = SPEC["outputs"]

    asm.save(out["step"])
    asm.save(out["glb"])
    finished_at = datetime.now(timezone.utc)

    manifest = {
        "slug": "v2-drum-gpt5-5-v2",
        "started_at": started_at.isoformat(timespec="seconds"),
        "finished_at": finished_at.isoformat(timespec="seconds"),
        "elapsed_s": round((finished_at - started_at).total_seconds(), 1),
        "ok": True,
        "errors": [],
        "mode": "cadquery_only_local",
        "source": "local cadquery_part.py",
        "artifacts": [
            "projects/v2-drum-gpt5-5-v2/build/v2-drum-full-machine.step",
            "projects/v2-drum-gpt5-5-v2/build/v2-drum-full-machine.glb",
            "projects/v2-drum-gpt5-5-v2/build/cadquery.log",
        ],
    }
    manifest_path = Path("build") / "compile_manifest.json"
    manifest_path.parent.mkdir(parents=True, exist_ok=True)
    manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")

    print(
        f"{SPEC['title']}: exported {out['step']} and {out['glb']} with explicit drum/frame/rail height profiles. "
        "STL export intentionally skipped."
    )


if __name__ == "__main__":
    main()
from cadquery_part import build_assembly, main
from spec import SPEC

assembly = build_assembly(SPEC)

if __name__ == "__main__":
    main()
Working…
0s