The Smell of Molten Projects in the Morning

Ed Nisley's Blog: Shop notes, electronics, firmware, machinery, 3D printing, laser cuttery, and curiosities. Contents: 100% human thinking, 0% AI slop.

Tag: Improvements

Making the world a better place, one piece at a time

  • CNC 3018-Pro: Tape-Down Platter Fixture

    Diamond drag engraving doesn’t put much sideways force on the platters, so taping the CD in place suffices to hold it:

    CNC 3018-Pro - CD taped to platform
    CNC 3018-Pro – CD taped to platform

    Wrapping a flange around the screw-down platter fixture provides plenty of surface area for tape:

    Platter Fixtures - CD on 3018 - tape flange
    Platter Fixtures – CD on 3018 – tape flange

    Which looks exactly as you think it would in real life:

    CNC 3018-Pro - CD fixture - taped
    CNC 3018-Pro – CD fixture – taped

    Admittedly, masking tape doesn’t look professional, but it’s low-profile, cheap and works perfectly. Blue painter’s tape for the “permanent” hold-down strips on the platform would be a colorful upgrade.

    It’s centered on the platform at the XY=0 origin in the middle of the XY travel limits, with edges aligned parallel to the axes. Homing the 3018 and moving to XY=0 puts the tool point directly over the center of the CD without any fussy alignment.

    The blue-and-red rings around the center hole assist probe camera alignment, whenever that’s necessary.

    The OpenSCAD source code as a GitHub Gist:

    // Machining fixtures for CD and hard drive platters
    // Ed Nisley KE4ZNU February … September 2016
    // 2019-08 split from tube base models
    PlatterName = "CD"; // [3.5inch,CD]
    CNCName = "3018"; // [3018,Sherline]
    TapeFlange = true; // Generate tape attachment
    PlateThick = 5.0; // [3.0,5.0,10.0,15.0]
    RecessDepth = 4.0; // [0.0,2.0,4.0]
    //- Extrusion parameters must match reality!
    /* [Hidden] */
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    HoleWindage = 0.2;
    Protrusion = 0.1; // make holes end cleanly
    inch = 25.4;
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    module PolyCyl(Dia,Height,ForceSides=0) { // based on nophead's polyholes
    Sides = (ForceSides != 0) ? ForceSides : (ceil(Dia) + 2);
    FixDia = Dia / cos(180/Sides);
    cylinder(d=(FixDia + HoleWindage),h=Height,$fn=Sides);
    }
    ID = 0;
    OD = 1;
    LENGTH = 2;
    //———————-
    // Dimensions
    P_NAME = 0; // platter name
    P_ID = 1; // … inner diameter
    P_OD = 2; // … outer diameter
    P_THICK = 3; // … thickness
    PlatterData = [
    ["3.5inch", 25.0, 95.0, 1.75],
    ["CD", 15.0, 120.0, 1.20],
    ];
    PlatterSides = 3*4*5; // polygon approximation
    B_NAME = 0; // machine name
    B_OC = 1; // … platform screw OC, use small integer for slot
    B_STUD = 2; // … screw OD clearance
    BaseData = [
    ["3018", [5.0, 45.0], 6.0], // slots along X axis
    ["Sherline", [1.16*inch,1.16*inch], 5.0], // tooling plate
    ];
    PlateRound = 10.0; // corner radius
    FlangeSize = [5.0,5.0,3*ThreadThick]; // all-around tape flange
    //– calculate values based on input parameters
    PI = search([PlatterName],PlatterData,1,0)[P_NAME]; // get platter index
    echo(str("Platter: ",PlatterName));
    Platter = [PlatterData[PI][P_ID],
    PlatterData[PI][P_OD],
    PlatterData[PI][P_THICK]];
    BI = search([CNCName],BaseData,1,0)[B_NAME]; // get base index
    echo(str("Machine: ",CNCName));
    AlignOC = IntegerMultiple(Platter[OD],10);
    echo(str("Alignment pip offset: ±",AlignOC/2));
    AlignSlot = [3*ThreadWidth,10.0,3*ThreadThick];
    StudClear = BaseData[BI][B_STUD]; // … clearance
    StudOC = [IntegerMultiple(AlignOC + 2*StudClear,BaseData[BI][B_OC].x), // … screw spacing
    BaseData[BI][B_OC].y];
    echo(str("Stud spacing: ",StudOC));
    NumStuds = [2,1 + 2*floor(Platter[OD] / StudOC.y)]; // holes only along ±X edges
    echo(str("Stud holes: ",NumStuds));
    BasePlate = [(20 + StudOC.x*ceil(Platter[OD] / StudOC.x)),
    (10 + AlignOC),
    PlateThick];
    echo(str("Plate: ",BasePlate));
    Flange = [BasePlate.x + 2*FlangeSize.x,BasePlate.y + 2*FlangeSize.y,FlangeSize.z];
    echo(str("Flange: ",Flange));
    //———————-
    // Drilling fixture for disk platters
    module PlatterFixture() {
    difference() {
    union() {
    hull() // basic plate shape
    for (i=[-1,1], j=[-1,1])
    translate([i*(BasePlate.x/2 – PlateRound),j*(BasePlate.y/2 – PlateRound),0])
    cylinder(r=PlateRound,h=BasePlate.z,$fn=4*4);
    if (TapeFlange)
    hull()
    for (i=[-1,1], j=[-1,1])
    translate([i*(Flange.x/2 – PlateRound),
    j*(Flange.y/2 – PlateRound),
    0])
    cylinder(r=PlateRound,h=FlangeSize.z,$fn=4*4);
    }
    for (i=[-1,0,1], j=[-1,0,1]) // origin pips
    translate([i*AlignOC/2,j*AlignOC/2,BasePlate.z – 2*ThreadThick])
    cylinder(d=4*ThreadWidth,h=1,$fn=6);
    for (i=[-1,1], j=[-1,1]) { // alignment slots
    translate([i*(AlignOC + AlignSlot.x)/2,
    j*Platter[OD]/4,
    (BasePlate.z – AlignSlot.z/2 + Protrusion/2)])
    cube(AlignSlot + [0,0,Protrusion],center=true);
    translate([i*Platter[OD]/4,
    j*(AlignOC + AlignSlot.x)/2,
    (BasePlate.z – AlignSlot.z/2 + Protrusion/2)])
    rotate(90)
    cube(AlignSlot + [0,0,Protrusion],center=true);
    }
    for (i=[-1,1], j=[-floor(NumStuds.y/2):floor(NumStuds.y/2)]) // mounting stud holes
    translate([i*StudOC.x/2,j*StudOC.y/2,-Protrusion])
    rotate(180/6)
    PolyCyl(StudClear,BasePlate.z + 2*Protrusion,6);
    translate([0,0,-Protrusion]) // center clamp hole
    rotate(180/6)
    PolyCyl(StudClear,BasePlate.z + 2*Protrusion,6);
    translate([0,0,BasePlate.z – Platter[LENGTH]]) // disk locating recess
    rotate(180/PlatterSides)
    linear_extrude(height=(Platter[LENGTH] + Protrusion),convexity=2)
    difference() {
    circle(d=(Platter[OD] + 2*HoleWindage),$fn=PlatterSides);
    circle(d=Platter[ID] – HoleWindage,$fn=PlatterSides);
    }
    translate([0,0,BasePlate.z – RecessDepth]) // drilling recess
    rotate(180/PlatterSides)
    linear_extrude(height=(RecessDepth + Protrusion),convexity=2)
    difference() {
    circle(d=(Platter[OD] – 10),$fn=PlatterSides);
    circle(d=(Platter[ID] + 10),$fn=PlatterSides);
    }
    }
    }
    //———————-
    // Build it
    PlatterFixture();

  • DRV8825 Stepper Driver: Adding a Home Output

    The DRV8825 stepper driver chip has a -Home output going active during the (micro)step corresponding to 45°, where both winding currents equal 71% of the peak value:

    DRV8825 pinout
    DRV8825 pinout

    Unfortunately, pin 27 is another unconnected pin on the DRV8825 PCB, without even a hint of a pad for E-Z soldering.

    It’s also an open-drain output in need of a pullup, so I globbed on a 1/8 W 10 kΩ resistor in addition to the tiny wire from the IC pad to the left header pin:

    DRV8825 PCB - Home signal output
    DRV8825 PCB – Home signal output

    Read it from the right: brown black black red gold. Even in person, the colors don’t look like that, not even a little bit: always measure before installation!

    The right header pin is firmly soldered to the PCB ground pin I also used for the 1:8 microstep hack. The whole affair received a generous layer of hot melt glue in the hope of some mechanical stabilization, although hanging a scope probe off those pins can’t possibly end well.

    The general idea is to provide a scope sync output independent of the motor speed, so I can look at the current waveforms:

    3018 X - Fast - 12V - 140mm-min 1A-div
    3018 X – Fast – 12V – 140mm-min 1A-div

    The alert reader will note the pulse occurs on the down-going side of the waveforms, which means I have the current probes clipped on backwards or, equivalently, on the wrong wire. The point is to get a stable sync, so it’s all good no matter which way the current goes.

  • Tour Easy: PTT Switch Replacement

    The PTT switch on Mary’s Tour Easy became intermittent:

    Tour Easy - failed PTT switch
    Tour Easy – failed PTT switch

    It’s been sitting there for least five years, as witnessed by the sun-yellowed hot melt glue blob, which is pretty good service from a switch intended for indoor use. The 3D printed button never fell off and, in fact, was difficult to remove, so that worked well.

    I took it apart and cleaned the contacts, but to no avail, so her bike now sports a new switch with a similar rounded dome:

    Tour Easy - new PTT switch
    Tour Easy – new PTT switch

    I clipped the wires a bit beyond the terminals and soldered the new switch in place, so it’s the same cable as before.

    Now, to see how long this one lasts …

  • Raspberry Pi “Moster” Heatsink Retaping

    A pair of colorful laser-cut stacked acrylic Raspberry Pi cases with “Moster” (*) heatsinks arrived, with the intent of dressing up the HP 7475A plotters for their next Show-n-Tell:

    Moster RPi Heatsink - assembled case
    Moster RPi Heatsink – assembled case

    Unfortunately, the thermal tape on one of the CPU heatsinks was sufficiently wrinkled to prevent good contact with the CPU:

    RPi taped heatsinks - as received
    RPi taped heatsinks – as received

    The seller sent a replacement copper slug with tape on one side. Presumably, they glue it to the heatsink with thermal silicone:

    Moster RPi Heatsink - silicone adhesive
    Moster RPi Heatsink – silicone adhesive

    Of which, I have none on hand.

    So I did what I should have done originally, which was to drop a few bucks on a lifetime supply of thermally conductive heatsink tape, apply it to the bare side of the slug and stick the slug to the heatsink with their tape:

    Moster RPi Heatsink - replacement adhesive tape
    Moster RPi Heatsink – replacement adhesive tape

    The blue stuff is the separation film, with the tape being white. It doesn’t match the black tape on the other side, but seems gooey enough to work.

    Done!

    Despite the heatsink hype, ball grid array chips dissipate most of their heat through their pads (and perhaps a central thermal pad) into the PCB, so sticking a heatsink atop the package is largely decorative, along the lines of hotrod ornamentation.

    The epoxy packages used in previous Raspberry Pi iterations had better thermal conductivity to their top surface:

    RPi 3 B - epoxy CPU
    RPi 3 B – epoxy CPU

    Than the more recent metal-top packages, which surely have inert-gas fill under the lid:

    RPi 3 B - metal CPU
    RPi 3 B – metal CPU

    Pix cropped after being pilfered from the Official Raspberry Pi site.

    Yes, the heatsink does conduct some heat into the air, even if not nearly as much as you might want.

    (*) I’m pretty sure “Moster” was a typo in the original eBay listing which took on a life of its own to become something of an unofficial trademark. All of the search results ship from Duluth, Georgia (USA), regardless of the nominal seller; feel free to draw your own conclusions.

  • Ripping Vinyl Records on a Raspberry Pi

    Having already set up a Raspberry Pi streaming radio player for the living room, it seemed reasonable to also use it for recording audio from the long-disused turntable. A pair of RCA cables from the amplifier’s TAPE 2 REC outputs to the Behringer UCA202 USB audio interface got the audio where it needed to be:

    RPi streamer - Behringer UCA202 USB audio box
    RPi streamer – Behringer UCA202 USB audio box

    Fiddle with the amp to select the phonograph, poke the TAPE 2 button to send audio to/from the UCA202, fire up an SSH session, kill the streaming audio program, and figure out what audio hardware is out there:

    $ arecord -l
    **** List of CAPTURE Hardware Devices ****
    card 1: CODEC [USB Audio CODEC], device 0: USB Audio [USB Audio]
      Subdevices: 1/1
      Subdevice #0: subdevice #0

    Not much else to choose from on a Raspberry Pi, so see what the UCA202 can do:

    $ arecord --dump-hw-params 
    Recording WAVE 'stdin' : Unsigned 8 bit, Rate 8000 Hz, Mono
    HW Params of device "default":
    --------------------
    ACCESS:  MMAP_INTERLEAVED RW_INTERLEAVED
    FORMAT:  S8 S16_LE
    SUBFORMAT:  STD
    SAMPLE_BITS: [8 16]
    FRAME_BITS: [8 32]
    CHANNELS: [1 2]
    RATE: [8000 48000]
    PERIOD_TIME: [1000 65536000]
    PERIOD_SIZE: [16 524288]
    PERIOD_BYTES: [64 524288]
    PERIODS: [2 1024]
    BUFFER_TIME: (666 131072000]
    BUFFER_SIZE: [32 1048576]
    BUFFER_BYTES: [64 1048576]
    TICK_TIME: ALL
    --------------------
    arecord: set_params:1233: Sample format non available
    Available formats:
    - S8
    - S16_LE

    Given those limited choices, 16 bit little-endian at 48 kHz seems like a no-brainer.

    Find out all the ALSA device names:

    $ arecord -L
    null
        Discard all samples (playback) or generate zero samples (capture)
    sysdefault:CARD=CODEC
        USB Audio CODEC, USB Audio
        Default Audio Device
    front:CARD=CODEC,DEV=0
        USB Audio CODEC, USB Audio
        Front speakers
    surround21:CARD=CODEC,DEV=0
        USB Audio CODEC, USB Audio
        2.1 Surround output to Front and Subwoofer speakers
    surround40:CARD=CODEC,DEV=0
        USB Audio CODEC, USB Audio
        4.0 Surround output to Front and Rear speakers
    surround41:CARD=CODEC,DEV=0
        USB Audio CODEC, USB Audio
        4.1 Surround output to Front, Rear and Subwoofer speakers
    surround50:CARD=CODEC,DEV=0
        USB Audio CODEC, USB Audio
        5.0 Surround output to Front, Center and Rear speakers
    surround51:CARD=CODEC,DEV=0
        USB Audio CODEC, USB Audio
        5.1 Surround output to Front, Center, Rear and Subwoofer speakers
    surround71:CARD=CODEC,DEV=0
        USB Audio CODEC, USB Audio
        7.1 Surround output to Front, Center, Side, Rear and Woofer speakers
    iec958:CARD=CODEC,DEV=0
        USB Audio CODEC, USB Audio
        IEC958 (S/PDIF) Digital Audio Output
    dmix:CARD=CODEC,DEV=0
        USB Audio CODEC, USB Audio
        Direct sample mixing device
    dsnoop:CARD=CODEC,DEV=0
        USB Audio CODEC, USB Audio
        Direct sample snooping device
    hw:CARD=CODEC,DEV=0
        USB Audio CODEC, USB Audio
        Direct hardware device without any conversions
    plughw:CARD=CODEC,DEV=0
        USB Audio CODEC, USB Audio
        Hardware device with all software conversions

    They all point to the same hardware, so AFAICT the default device will work fine.

    Try recording something directly to the RPi’s /tmp directory, using the --format=dat shortcut for “stereo 16 bit 48 kHz” and --mmap to (maybe) avoid useless I/O:

    $ arecord --format=dat --mmap --vumeter=stereo --duration=$(( 30 * 60 ))  /tmp/Side\ 1.wav
    Recording WAVE '/tmp/Side 1.wav' : Signed 16 bit Little Endian, Rate 48000 Hz, Stereo
                                      +02%|01%+                                   overrun!!! (at least 1.840 ms long)
                                      +02%|02%+                                   overrun!!! (at least 247.720 ms long)
                                    +# 07%|06%##+                                 overrun!!! (at least 449.849 ms long)
                                     + 03%|02%+                                   overrun!!! (at least 116.850 ms long)

    Huh. Looks like “writing to disk” sometimes takes far too long, which seems to be the default for MicroSD cards.

    The same thing happened over NFS to the file server in the basement:

    $ arecord --format=dat --mmap --vumeter=stereo --duration=$(( 30 * 60 ))  /mnt/part/Transfers/Side\ 1.wav
    
    Recording WAVE '/mnt/part/Transfers/Side 1.wav' : Signed 16 bit Little Endian, Rate 48000 Hz, Stereo
    
                                   +   09%|07%  +                                 overrun!!! (at least 660.372 ms long)
    
                                    +# 08%|06%# +                                 overrun!!! (at least 687.906 ms long)

    So maybe it’s an I/O thing on the RPi’s multiplexed / overloaded USB + Ethernet hardware?

    Trying a USB memory jammed into the RPi, under the assumption it might be better at recording than the MicroSD Card:

    $ arecord --format=dat --mmap --vumeter=stereo --duration=$(( 30 * 60 ))  /mnt/part/Side\ 1.wav
    Recording WAVE '/mnt/part/Side 1.wav' : Signed 16 bit Little Endian, Rate 48000 Hz, Stereo
                                      +01%|01%+                                   overrun!!! (at least 236.983 ms long)

    Well, if it’s overrunning the default buffer, obviously it needs Moah Buffah:

    $ arecord --format=dat --mmap --vumeter=stereo --buffer-time=1000000 --duration=$(( 30 * 60 ))  /mnt/part/Side\ 1.wav
    Recording WAVE '/mnt/part/Side 1.wav' : Signed 16 bit Little Endian, Rate 48000 Hz, Stereo
                                   +## 10%|06%# +                                 overrun!!! (at least 359.288 ms long)

    When brute force doesn’t work, you’re just not using enough of it:

    $ arecord --format=dat --mmap --vumeter=stereo --buffer-time=2000000 --duration=$(( 30 * 60 ))  /mnt/part/Side\ 1.wav
    Recording WAVE '/mnt/part/Side 1.wav' : Signed 16 bit Little Endian, Rate 48000 Hz, Stereo
                                      +00%|00%+                                   

    Sampling four bytes at 48 kHz fills 192 kB/s, so a 2 s buffer blots up 384 kB, which seems survivable even on a Raspberry Pi.

    The audio arrives at 11.5 MB/min, so an LP side with 20 min of audio will require about 250 MB of disk space. The USB memory was an ancient 2 GB card, so all four sides filled it halfway:

    $ ll /mnt/part
    total 1.1G
    drwxr-xr-x  2 ed   root 4.0K Dec 31  1969  ./
    drwxr-xr-x 17 root root 4.0K Jun  7 19:15  ../
    -rwxr-xr-x  1 ed   root 281M Sep  1 14:38 'Side 1.wav'*
    -rwxr-xr-x  1 ed   root 242M Sep  1 15:01 'Side 2.wav'*
    -rwxr-xr-x  1 ed   root 265M Sep  1 15:27 'Side 3.wav'*
    -rwxr-xr-x  1 ed   root 330M Sep  1 15:58 'Side 4.wav'*

    Side 4 is a bit longer than the rest, because I was folding laundry and the recording stopped at the 30 minute timeout after 10 minutes of silence.

    Now, to load ’em into Audacity, chop ’em into tracks, and save the lot as MP3 files …

  • CNC 3018-Pro: Table Riser

    With the 3018-Pro used for drag engraving on CDs and hard drive platters, there’s no need for all the clearance below the Z-axis carriage required for the OEM motor and ER11 collet chuck. A chunk of laminate countertop and a hunk of Celotex foam insulation produce a nicely flat surface 47 mm above the platform:

    CNC 3018 Table Riser
    CNC 3018 Table Riser

    It’s surprisingly flat:

    Table Flatness Measurement - 2019-08-30
    Table Flatness Measurement – 2019-08-30

    Those are millimeters of clearance between the gray plastic clamp around the diamond drag tool holder (about which, more later) and my trusty bench block, measured at 50 mm intervals across the platform. The lower figures appeared after tightening the upper-left screw by a little over 1/6 turn = 0.2 mm, making the entire platform flat & aligned within ±0.1 mm.

    Yeah, not bad for a scrap countertop!

    The four M6 socket head cap screws pass through the stack into T-nuts in the platform:

    CNC 3018 Table Riser - screw clearance
    CNC 3018 Table Riser – screw clearance

    The countertop was thick enough to allow countersinking the screws slightly below the surface:

    CNC 3018 Table Riser - screw countersink
    CNC 3018 Table Riser – screw countersink

    I transfer-punched the screw clearance hole locations into the Celotex and drilled it with an ordinary twist drill. It wasn’t pretty, but nobody will ever notice.

    Two sheets, maybe 1 mm thick, of closed-cell foam below the Celotext provide enough squish to align the top surface without straining anything. The screws are firmly tight, so they shouldn’t work their way loose under minimal engraving loads.

    Taping the CDs to the surface works well for now, although a simpler version of the fixture may be in order.

  • CNC 3018-Pro: Probe Camera Case for Anonymous USB Camera

    The anonymous USB camera I used with the stereo zoom microscope not only works with VLC, but also with bCNC, and it has a round PCB with ears:

    CNC 3018-Pro - Probe Camera - PCB
    CNC 3018-Pro – Probe Camera – PCB

    Which suggested putting it in a ball mount for E-Z aiming:

    CNC 3018-Pro - Probe Camera - ball mount
    CNC 3018-Pro – Probe Camera – ball mount

    Black filament snippets serve as alignment pins to hold the ball halves together while they’re getting clamped. They’re epoxied into the upper half of the ball, because who knows when I’ll need to harvest the camera.

    The clamp mount descends from the Tour Easy Daytime Running Lights, with more screws and less fancy shaping:

    USB Camera - Round PCB Mount - solid model - build
    USB Camera – Round PCB Mount – solid model – build

    The clamp pieces fit around the ball with four M3 screws providing the clamping force:

    USB Camera - Round PCB Mount - solid model sectioned
    USB Camera – Round PCB Mount – solid model sectioned

    The whole affair sticks onto the Z axis carrier with double-sided foam tape:

    CNC 3018-Pro - Probe Camera - alignment
    CNC 3018-Pro – Probe Camera – alignment

    It barely clears the strut on the -X side of the carriage, although it does stick out over the edge of the chassis.

    After the fact, I tucked a closed-cell foam ring between the lens threads and the ball housing to stabilize the lens; the original camera glued the thing in place, but some fiddly alignment & focusing lies ahead:

    Alignment mirror - collimation
    Alignment mirror – collimation

    It’s worth noting that the optical axis of these cheap cameras rarely coincides with the physical central axis of the lens. This one requires a jaunty tilt, although it’s not noticeable in any of the pictures I tried to take.

    All in all, this one works just like the probe camera on the MPCNC.

    The OpenSCAD source code as a GitHub Gist:

    // CNC 3018-Pro Probe Camera mount for anonymous USB camera
    // Ed Nisley KE4ZNU – August 2019
    Layout = "Show"; // [Show, Build, Ball, Clamp, Bracket, Mount]
    //——-
    //- Extrusion parameters must match reality!
    // Print with 2 shells
    /* [Hidden] */
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    HoleWindage = 0.2;
    Protrusion = 0.1; // make holes end cleanly
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    inch = 25.4;
    ID = 0;
    OD = 1;
    LENGTH = 2;
    //——-
    // Dimensions
    //– Camera
    PCBThick = 1.2;
    PCBDia = 25.0;
    KeySize = [28.0,8.5,IntegerMultiple(PCBThick,ThreadThick)];
    KeyOffset = [0.0,2.0,0.0];
    KeyRadius = IntegerMultiple(sqrt(pow(KeySize.y – KeyOffset.y,2) + pow(KeySize.x/2,2)),0.01);
    echo(str("Key radius: ",KeyRadius));
    Lens = [14.0,18.0,25.0];
    BallID = PCBDia;
    BallOD = IntegerMultiple(2*KeyRadius,5.0);
    echo(str("Ball OD: ",BallOD));
    WallThick = 3.0;
    CableOD = 3.75;
    NumPins = 3;
    Pin = [1.75,1.8,5.0];
    Screw = [
    3.0,6.8,25.0 // M3 ID=thread, OD=washer, LENGTH=below head
    ];
    RoundRadius = IntegerMultiple(Screw[OD]/2,1.0); // corner rounding
    ClampSize = [BallOD + 2*WallThick,BallOD + 2*WallThick,20.0];
    echo(str("Clamp: ",ClampSize));
    MountSize = [5.0,BallOD,25.0];
    MountClearance = 1.0; // distance between clamp and mount
    Kerf = 2*ThreadThick;
    ScrewOC = [ClampSize.x – 2*RoundRadius,ClampSize.y – 2*RoundRadius];
    echo(str("Screw OC: ",ScrewOC));
    Insert = [ // brass insert: body, knurl,length
    3.9,4.9,8.0
    ];
    UseInsert = false;
    NumSides = 12*4;
    //——-
    module PolyCyl(Dia,Height,ForceSides=0) { // based on nophead's polyholes
    Sides = (ForceSides != 0) ? ForceSides : (ceil(Dia) + 2);
    FixDia = Dia / cos(180/Sides);
    cylinder(r=(FixDia + HoleWindage)/2,
    h=Height,
    $fn=Sides);
    }
    //——-
    // Components
    module CamBall(Section="Both") {
    Offset = (Section == "Both") ? 0 :
    (Section == "Upper") ? BallOD/2 :
    (Section == "Lower") ? -BallOD/2 :
    0;
    render(convexity=4)
    intersection(convexity = 3) {
    difference() {
    sphere(d=BallOD,$fn=NumSides);
    sphere(d=BallID,$fn=NumSides); // interior
    PolyCyl(CableOD,2*BallOD,8); // cable & lens holes
    translate([0,0,-Lens[LENGTH]])
    PolyCyl(Lens[OD],Lens[LENGTH],NumSides);
    translate([0,0,-PCBThick])
    PolyCyl(PCBDia,PCBThick,NumSides);
    translate(KeyOffset + [0,-KeySize.y/2,-PCBThick/2]) // PCB key
    cube(KeySize,center=true);
    for (i=[0:NumPins – 1])
    rotate(i*360/NumPins)
    translate([0,-(BallID + BallOD)/4,-Pin[LENGTH]/2])
    PolyCyl(Pin[OD],Pin[LENGTH],6);
    }
    translate([0,0,Offset])
    cube([BallOD,BallOD,BallOD] + 2*[Protrusion,Protrusion,0],center=true);
    }
    }
    module Clamp(Section="Both") {
    Offset = (Section == "Both") ? 0 :
    (Section == "Upper") ? ClampSize.z/2 :
    (Section == "Lower") ? -ClampSize.z/2 :
    0;
    render(convexity=4)
    intersection() {
    difference() {
    hull()
    for (i=[-1,1], j=[-1,1])
    translate([i*ScrewOC.x/2,j*ScrewOC.y/2,0])
    cylinder(r=RoundRadius,h=ClampSize.z,$fn=NumSides,center=true);
    sphere(d=BallOD + 2*HoleWindage,$fn=NumSides); // space around camera ball
    for (i=[-1,1], j=[-1,1]) // screws
    translate([i*ScrewOC.x/2,j*ScrewOC.y/2,-ClampSize.z])
    PolyCyl(Screw[ID],2*ClampSize.z,6);
    if (UseInsert)
    for (i=[-1,1], j=[-1,1]) // inserts
    translate([i*ScrewOC.x/2,j*ScrewOC.y/2,-(ClampSize.z/2 + Protrusion)])
    PolyCyl(Insert[OD],Insert[LENGTH] + Protrusion,8);
    cube([2*ClampSize.x,2*ClampSize.y,Kerf],center=true); // clamping gap
    }
    translate([0,0,Offset])
    cube([ClampSize.x,ClampSize.y,ClampSize.z] + 2*[Protrusion,Protrusion,0],center=true);
    }
    }
    module Bracket() {
    translate([ClampSize.x/2 + MountSize.x/2 + MountClearance,0,MountSize.z/2 – ClampSize.z/2])
    cube(MountSize,center=true);
    translate([ClampSize.x/2 + MountClearance/2,0,-(ClampSize.z + Kerf)/4])
    cube([MountClearance + 2*Protrusion,MountSize.y,(ClampSize.z – Kerf)/2],center=true);
    }
    module Mount() {
    union() {
    Clamp("Lower");
    Bracket();
    }
    }
    //——-
    // Build it!
    if (Layout == "Ball")
    CamBall();
    if (Layout == "Clamp")
    Clamp();
    if (Layout == "Bracket")
    Bracket();
    if (Layout == "Mount")
    Mount();
    if (Layout == "Show") {
    difference() {
    union() {
    color("Purple")
    Clamp("Upper");
    Mount();
    color("LimeGreen")
    CamBall();
    }
    rotate([0,0,45])
    translate([-ClampSize.x,0,0])
    cube(2*ClampSize,center=true);
    }
    }
    if (Layout == "Build") {
    Gap = 0.6;
    translate([-Gap*BallOD,Gap*BallOD,0])
    CamBall("Upper");
    translate([-Gap*BallOD,-Gap*BallOD,0])
    rotate([0,180,0])
    CamBall("Lower");
    translate([Gap*ClampSize.x,-Gap*ClampSize.y,ClampSize.z/2])
    rotate([0,180,0])
    Clamp("Upper");
    translate([Gap*ClampSize.x,Gap*ClampSize.y,ClampSize.z/2]) {
    rotate(180)
    Mount();
    }
    }