"""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()