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.

Category: Recumbent Bicycling

Cruisin’ the streets

  • Copying Action Camera Video Files: Now With Arrays

    Copying Action Camera Video Files: Now With Arrays

    Using Bash arrays is an exercise in masochism, but I got to recycle most of the oddities from the previous script, so it wasn’t a dead loss.

    The cameras use individually unique / screwy / different filesystem layouts, so the script must have individual code to both copy the file and decapitalize the file extensions. This prevents using a single tidy function, although laying out the code in case statements keyed by the camera name helps identify what’s going on.

    My previous approach identified the MicroSD cards by their UUIDs, which worked perfectly right up until the camera reformats the card while recovering from a filesystem crash and installs a randomly generated UUID. Because there’s no practical way to modify an existing UUID on a VFAT drive, I’m switching to the volume label as needed:

    #-- action cameras and USB video storage
    UUID=B40C6DD40C6D9262	/mnt/video	ntfs	user,noauto,uid=ed	0	0
    UUID=B257-AE02		/mnt/Fly6	vfat	user,noauto,uid=ed	0	0
    #UUID=0000-0001		/mnt/M20	vfat	user,noauto,uid=ed	0	0
    UUID=3339-3338		/mnt/M20	vfat	user,noauto,uid=ed	0	0
    LABEL=AS30V		/mnt/AS30V	exfat	user,noauto,uid=ed	0	0
    LABEL=C100-0001		/mnt/C100_1	vfat	user,noauto,uid=ed	0	0
    LABEL=C100-0002		/mnt/C100_2	vfat	user,noauto,uid=ed	0	0
    UUID=0050-0001		/mnt/M50	vfat	user,noauto,uid=ed	0	0
    

    In particular, note the two UUIDs for the M20 camera: there’s a crash and reformat in between those two lines. The two C100 cameras started out with labels because the M20 taught me the error of my ways.

    The script simply iterates through a list array of the cameras and tries to mount the corresponding MicroSD card for each one: the mount points are cleverly chosen to match the camera names in the array. Should the mount succeeds, an asynchronous rsync then slurps the files onto the bulk video drive.

    With all the rsync operations running, the script waits for all of them to complete before continuing. I don’t see much point in trying to identify which rsync just finished and fix up its files while the others continue to run, so the script simply stalls in a loop until everything is finished.

    All in all, the script scratches my itch and, if naught else, can serve as a Bad Example™ of how to get the job done.

    A picture to keep WordPress from reminding me that readers respond positively to illustrated posts:

    A pleasant day for a ride - 2023-06-01
    A pleasant day for a ride – 2023-06-01

    Ride on!

    The Bash script as a GitHub Gist:

    #!/bin/bash
    # This uses too many bashisms for dash
    source /etc/os-release
    echo 'Running on' $PRETTY_NAME
    if [[ "$PRETTY_NAME" == *Manjaro* ]] ; then
    ren='perl-rename'
    dm='sudo dmesg'
    elif [[ "$PRETTY_NAME" == *Ubuntu* ]] ; then
    ren='rename'
    dm='dmesg'
    else
    echo 'New distro to me:' $PRETTY_NAME
    echo ' … which rename command is valid?'
    exit
    fi
    echo Check for good SD card spin-up
    $dm | tail -50
    echo … Ctrl-C to bail out and fix / Enter to proceed
    read junk
    thisdate=$(date –rfc-3339=date)
    echo Date: $thisdate
    # MicroSD / readers / USB drive defined in fstab
    # … with UUID or PARTID as appropriate
    echo Mounting bulk video drive
    sudo mount /mnt/video
    if [ $? -ne 0 ]; then
    echo '** Cannot mount video storage drive'
    exit
    fi
    # Show starting space available
    df -h /mnt/video
    # list the cameras
    declare -a cams=( AS30V Fly6 M20 M50 C100_1 C100_2 )
    declare -A targets=( \
    [AS30V]=/mnt/video/AS30V/$thisdate \
    [Fly6]=/mnt/video/Fly6/DCIM \
    [M20]=/mnt/video/M20/$thisdate \
    [M50]=/mnt/video/M50/$thisdate \
    [C100_1]=/mnt/video/C100_1/$thisdate \
    [C100_2]=/mnt/video/C100_2/$thisdate \
    )
    declare -A PIDs
    declare -A Copied
    echo Iterating through cameras: ${cams[*]}
    Running=0
    for cam in ${cams[*]} ; do
    printf "\nProcessing: $cam\n"
    mpt="/mnt/$cam"
    target=${targets[$cam]}
    sudo mount $mpt
    if [ $? -eq 0 ]; then
    echo " Start $cam transfer from $mpt"
    echo " Make target directory: $target"
    mkdir $target
    case $cam in
    ( AS30V )
    rsync -ahu –progress –exclude "*THM" $mpt/MP_ROOT/100ANV01/ $target &
    ;;
    ( Fly6 )
    rsync -ahu –progress $mpt /mnt/video &
    ;;
    ( M20 )
    n=$( ls $mpt/DCIM/Photo/* 2> /dev/null | wc -l )
    if [ $n -gt 0 ] ; then
    echo " copy M20 photos first"
    rsync -ahu –progress $mpt/DCIM/Photo/ $target
    fi
    echo " cmd: rsync -ahu –progress $mpt/DCIM/Movie/ $target"
    rsync -ahu –progress $mpt/DCIM/Movie/ $target &
    ;;
    ( M50 )
    n=$( ls $mpt/DCIM/PHOTO/* 2> /dev/null | wc -l )
    if [ $n -gt 0 ] ; then
    echo " copy M50 photos first"
    rsync -ahu –progress $mpt/DCIM/PHOTO/ $target
    fi
    rsync -ahu –progress $mpt/DCIM/MOVIE/ $target &
    ;;
    ( C100_1 | C100_2 )
    n=$( ls $mpt/DCIM/Photo/* 2> /dev/null | wc -l )
    if [ $n -gt 0 ] ; then
    echo " copy $cam photos first"
    rsync -ahu –progress $mpt/DCIM/Photo/ $target
    fi
    rsync -ahu –progress $mpt/DCIM/Movie/ $target &
    ;;
    ( * )
    printf "\n**** Did not find $cam in list!\n"
    ;;
    esac
    PIDs[$cam]=$!
    echo " PID for $cam: " "${PIDs[$cam]}"
    Copied[$cam]=1
    (( Running++ ))
    else
    echo " skipping $cam"
    Copied[$cam]=0
    fi
    done
    printf "\n—– Waiting for all rsync terminations\n"
    echo PIDs: "${PIDs[*]}"
    if [ $Running -eq 0 ] ; then
    echo No rsyncs started, force error
    rcsum=9999
    else
    rcsum=0
    while [ $Running -gt 0 ] ; do
    echo " waiting: $Running"
    wait -n -p PID
    rc=$?
    rcsum=$(( rcsum+$rc ))
    echo RC for $PID: $rc
    (( Running– ))
    done
    echo All rsyncs finished
    fi
    if [ $rcsum -eq 0 ] ; then
    echo '—– Final cleanups'
    for cam in ${cams[*]} ; do
    if [ "${Copied[$cam]}" -eq 1 ] ; then
    echo Cleanup for: $cam
    mpt=/mnt/$cam
    target=${targets[$cam]}
    echo Target dir: $target
    case $cam in
    ( Fly6 )
    find $target -name \*AVI -print0 | xargs -0 $ren -v -f 's/AVI/avi/'
    rm -rf $mpt/DCIM/*
    ;;
    ( AS30V )
    find $target -name \*MP4 -print0 | xargs -0 $ren -v -f 's/MP4/mp4/'
    rm $mpt/MP_ROOT/100ANV01/*
    ;;
    ( M50 )
    find $target -name \*MP4 -print0 | xargs -0 $ren -v -f 's/MP4/mp4/'
    rm $mpt/DCIM/MOVIE/*
    n=$( ls $mpt/DCIM/PHOTO/* 2> /dev/null | wc -l )
    if [ $n -gt 0 ] ; then
    echo placeholder $cam
    rm $mpt/DCIM/PHOTO/*
    fi
    ;;
    ( * )
    find $target -name \*MP4 -print0 | xargs -0 $ren -v -f 's/MP4/mp4/'
    find $target -name \*JPG -print0 | xargs -0 $ren -v -f 's/JPG/jpg/'
    rm $mpt/DCIM/Movie/*
    n=$( ls $mpt/DCIM/Photo/* 2> /dev/null | wc -l )
    if [ $n -gt 0 ] ; then
    echo placeholder $cam
    rm $mpt/DCIM/Photo/*
    fi
    ;;
    esac
    sudo umount $mpt
    else
    echo No cleanup for: $cam
    fi
    done
    echo '—– Space remaining on video drive'
    df -h /mnt/video
    sudo umount /mnt/video
    date
    echo Done!
    else
    echo Whoopsie! Total RC: $rcsum
    fi
  • Tour Easy: Another SJCAM C100+ Mount

    Tour Easy: Another SJCAM C100+ Mount

    Eight years of progress in the action camera world gets you from a rather expensive Cycliq Fly6:

    Tour Easy - Fly6 image
    Tour Easy – Fly6 image

    To an SJCAM C100+ camera costing the better part of fifty bucks on closeout:

    Tour Easy - C100 image
    Tour Easy – C100 image

    The camera is mounted on the side of the seat frame on Mary’s Tour Easy:

    Tour Easy C100 mount - side rail
    Tour Easy C100 mount – side rail

    The slightly tilted picture comes from the frame rail’s incline. My C100+ camera mounts on the horizontal part of the rail:

    Tour Easy C100 mount - rear rail
    Tour Easy C100 mount – rear rail

    As expected, the internal battery does not last for our usual hour-long rides, so the cameras now operate in “car mode”: recording starts when we plug in the USB battery pack and stops shortly after unplugging.

    I started with the waterproof case on my bike:

    Tour Easy - SJCAM C100 mount - installed
    Tour Easy – SJCAM C100 mount – installed

    Which (obviously) does not allow for an external battery, so they’re now in the “frame” mount. The hatch covering the MicroSD card and USB Micro-B connector (and a Reset button!) is on the bottom of the camera, but (fortunately) the whole affair mounts up-side-down and the settings include an image flip mode.

    Putting the camera on the side required changing the mount angle from -20° to +35°:

    SJCAM C100 Mount - 35 degree solid model
    SJCAM C100 Mount – 35 degree solid model

    The ergonomics / user interface of this whole setup is terrible:

    • The camera’s flexible hatch is recessed inside the frame far enough that it cannot be opened without using a small & sharp screwdriver
    • The USB jack is slightly off-center, so lining the plug up with the camera body doesn’t align it with the jack
    • The MicroSD card is in a push-to-release socket, but its raised ridge faces the hatch flap and cannot be reached by a fingernail. I added a small tab that helps, but it’s difficult to grasp.

    Extracting the video files from the camera through the app is an exercise in frustration. Having already figured out how to do this for the other cameras in the fleet, it’s easier to fumble with the MicroSD card.

    I devoutly hope we never really need any of the videos.

  • New Jeep, Reckless Driver

    New Jeep, Reckless Driver

    Some drivers give us absurd amounts of clearance, which is what we thought the driver of the white Jeep was doing:

    New Jeep Reckless Driving - Jeep passing
    New Jeep Reckless Driving – Jeep passing

    Some drivers give us very little clearance, either deliberately or though negligence, which is what I thought the driver of the silver Subaru was doing:

    New Jeep Reckless Driving - Subaru close pass
    New Jeep Reckless Driving – Subaru close pass

    Reviewing the videos revealed a different story that could have ended very badly for everyone involved.

    Moving back in time, we crossed the bridge over the Wappingers creek, which has two southbound lanes. The left lane is dedicated to left turns onto Red Oaks Mill Road and the right lane is for through traffic southbound on Rt 376:

    New Jeep Reckless Driving - Red Oaks Mill bridge
    New Jeep Reckless Driving – Red Oaks Mill bridge

    I had noticed oncoming drivers in the northbound lane were moving far over to the fog line, but (unseen by me) they were definitely swerving off the road:

    New Jeep Reckless Driving - swerve 1
    New Jeep Reckless Driving – swerve 1

    It seems the Jeep driver crossed the bridge in the left lane and continued straight through, passing the solid line of vehicles in the right lane behind us. You can see the top of the Jeep’s windshield peeking out behind the Subaru, with minimal clearance to the black car swerving out of the way:

    New Jeep Reckless Driving - swerve 1 clearance
    New Jeep Reckless Driving – swerve 1 clearance

    There’s not much shoulder on that side of the road, but the driver of the white Honda is using it all:

    New Jeep Reckless Driving - swerve 2
    New Jeep Reckless Driving – swerve 2

    With all the oncoming traffic out of the way, the Jeep driver now accelerates in the wrong lane:

    New Jeep Reckless Driving - passing 1
    New Jeep Reckless Driving – passing 1

    And passes the Subaru just behind us:

    New Jeep Reckless Driving - passing 2
    New Jeep Reckless Driving – passing 2

    The license plate looks like JAE-7751, early in the “J” plate series, so that’s a shiny new Jeep.

    Being passed at close range in an obviously no-passing zone caused the Subaru driver to flinch in our direction:

    New Jeep Reckless Driving - Subaru clearance
    New Jeep Reckless Driving – Subaru clearance

    Unsurprisingly, the Jeep driver ran the red light at the top of the hill, presumably to avoid being stopped directly in front of us.

    Never a dull moment out there on the road …

  • Bafang vs. Tour Easy: Chain Guide

    Bafang vs. Tour Easy: Chain Guide

    After adding the Bafang motor to my Tour Easy, the chain has fallen off the chainring a few times, prompting the gap filler between the motor and the chainring spider. That this has never happened to Mary’s essentially identical Tour Easy suggests I have a different shift technique, but adding a chain catcher seemed easier than re-learning shifting:

    Chain Catcher - top view
    Chain Catcher – top view

    It’s more properly called a “chain guide” and is basically a shifter cage minus the mechanism:

    Chain Catcher - side view
    Chain Catcher – side view

    Because the Tour Easy frame has a 25 mm tube where the guide’s clamp expects a minimum 31.8 mm tube, a 3D printed adapter fills the gap:

    Chain Catcher adapter ring - solid model
    Chain Catcher adapter ring – solid model

    The hole is off-center because it seemed like a good idea, although it’s not strictly necessary. The flange helps align the pieces while tightening the clamp screw.

    The guide cage clears the chain on all sides while up on the work stand, but there’s nothing like getting out on the road to find out why something doesn’t work as you expect.

    The OpenSCAD source code as a GitHub Gist:

    // Chain catcher adapter ring
    // Ed Nisley – KE4ZNU – 2023-05
    /* [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);
    ID = 0;
    OD = 1;
    LENGTH = 2;
    inch = 25.4;
    //———————-
    // Dimensions
    TubeOD = 26.0; // frame tube with silicone tape
    Clamp = [35.0,39.0,12.0]; // Chain catcher clamp ring
    Flange = [Clamp[ID],Clamp[OD],3*ThreadThick];
    Kerf = 1.0;
    Offset = (Clamp[ID] – TubeOD)/2 – 3*ThreadWidth;
    NumSides = 2*3*4;
    //———————–
    $fn=NumSides;
    difference() {
    union() {
    cylinder(d=Flange[OD],h=Flange[LENGTH]);
    cylinder(d=Clamp[ID],h=Clamp[LENGTH]+Flange[LENGTH]);
    }
    cube([2*Flange[OD],Kerf,3*Clamp[LENGTH]],center=true);
    translate([0,Offset,0])
    cylinder(d=TubeOD,h=3*Clamp[LENGTH],center=true);
    }
  • Bafang Motor: Chain Gap Filler

    Bafang Motor: Chain Gap Filler

    When the chain falls off the top of the chainring toward the motor, the part remaining engaged with the chainring will inevitably drag the rest into the gap between the motor and the chainring spider, whereupon it will jam firmly in place and be almost impossible to extract. Preventing this means filling the gap, which required several iterations:

    Bafang motor gap filler - prototypes
    Bafang motor gap filler – prototypes

    The Bafang motor has a cover held in place by seven M3 flat-head screws, shown here below a test filler using pan head screws:

    Bafang motor gap filler - installed
    Bafang motor gap filler – installed

    Contrary to what you might think, the five screws that obviously sit on five points of a hexagon do not in fact sit 60° apart. How you find this out is by making the obvious layout, including the two screws bracketing the pinion gear in the lower right, then applying windage:

    Bafang motor housing gap filler - hole adjustments
    Bafang motor housing gap filler – hole adjustments

    That’s one of the paper templates seen above, with laser-cut holes 60° apart and ugly holes punched at the actual screw locations. Then you scan and overlay that image with the LightBurn layout and twiddle the hole locations to make the answer come out right:

    Bafang motor housing gap filler - hole adjustments - LB overlay
    Bafang motor housing gap filler – hole adjustments – LB overlay

    With that in hand, I cut a 1 mm acrylic shape to measure the clearance between the motor + filler and the chainring spider, with pan-head screws replacing the original flat-head screws:

    Bafang motor gap filler - top view
    Bafang motor gap filler – top view

    That’s a single piece of 2.5 mm acrylic I used after discovering a pair of the 1 mm acrylic shapes fit with space to spare: hooray for rapid prototyping.

    A test chain drop suggested it might suffice:

    Bafang motor gap filler - test
    Bafang motor gap filler – test

    If I were so inclined, 3 mm acrylic with countersunk holes and slightly longer flat-head screws would probably work, but I’ll use this until it fails to prevent a chain snag.

    The careful observer will have noted the stress crack extending radially inward from the upper-right screw, which I am carefully avoiding doing anything about, pending the aforementioned failure.

    The LightBurn layout as a GitHub Gist:

    Loading
    Sorry, something went wrong. Reload?
    Sorry, we cannot display this file.
    Sorry, this file is invalid so it cannot be displayed.
  • Tour Easy Running Light: Heatsink Machining

    Tour Easy Running Light: Heatsink Machining

    Having acquired some thick-wall (1 inch OD, ¾ inch ID) aluminum tube, making the LED heatsink and lens holder for a running light generates a lot less scrap. A new doodle gives the dimensions in a rather Picasso-ish layout:

    Running Light - dimension doodles
    Running Light – dimension doodles

    The back end of the tube gets turned down to 23 mm OD and cleaned up to 19 mm ID, then scored to give the epoxy something to grip:

    Front Running Light - Heatsink shell scoring
    Front Running Light – Heatsink shell scoring

    The front end gets bored to 22.5 mm for the lens holder and has its OD cleaned up to 25 mm:

    Front Running Light - finished shell
    Front Running Light – finished shell

    Clean up the end of a ¾ inch rod to 19 mm OD, knurl it a little to increase the OD ever so slightly and improve its griptivity, slice off a bit more than 10 mm, butter it up with JB Weld epoxy, and shove it into the shell with its front end aligned and its back end sticking out:

    Front Running Light - epoxied plug in shell - rear
    Front Running Light – epoxied plug in shell – rear

    Face off the back end and the front end looks fine as assembled:

    Front Running Light - epoxied plug in shell - front
    Front Running Light – epoxied plug in shell – front

    Grab it in the Sherline mill’s three jaw chuck to:

    • Drill & tap the M3 central hole for the stud holding the circuit plate to the back end
    • Drill 1.6 mm blind holes for the circuit plate pins
    • Drill 2 mm through holes for the LED wires, 60° apart

    Which looks like this from the front:

    Front Running Light - drilled heatsink - front
    Front Running Light – drilled heatsink – front

    And like this with the circuit plate screwed & glued to the rear:

    Front Running Light - circuit plate mounted
    Front Running Light – circuit plate mounted

    Clean up the OD of some ¾ inch PVC pipe to 25 mm, bore it out to 23 mm.

    While the Sherline is set up, drill a pair of 2 mm holes in the lens holder for the wires, aligned so they’ll match the heatsink holes.

    Because we live in the future, laser-cut the rear cap from some edge-lit acrylic with a black inner disk:

    Front Running Light - PVC tube - end cap
    Front Running Light – PVC tube – end cap

    Cutting that cap with the notch included is now trivially easy, compared to the previous machining.

    Now for some circuitry …

  • Tour Easy: SJCAM C100+ Mount

    Tour Easy: SJCAM C100+ Mount

    The batteries (which are no longer available) and the control buttons have worn out on the SJCAM M20 camera on the back of my Tour Easy, so a replacement is in order:

    Tour Easy - SJCAM C100 mount - installed
    Tour Easy – SJCAM C100 mount – installed

    It’s an SJCAM C100+ in its waterproof housing, screwed to a block descended from the M20 mount:

    SJCAM C100 Mount - solid model
    SJCAM C100 Mount – solid model

    The C100+ has a non-replaceable lithium pouch battery that may not last for the hour or so we generally ride, but at least this is a starting point for seeing how the thing works.

    The PrusaSlicer preview shows the support structure inside the seat rail arches:

    SJCAM C100 Mount - slicer
    SJCAM C100 Mount – slicer

    That appears under the four central facets of each arch, where I “painted” the support enforcers, because the automagic supports fill the entire arch and are basically impossible to pry off.

    The hole between the ears on the top holds an aluminum tab intended to diffuse the wobble from that tall camera. A laser-cut chipboard template simplified drilling & cutting the tab from an aluminum sheet:

    Tour Easy - SJCAM C100 mount - test fit
    Tour Easy – SJCAM C100 mount – test fit

    The tab and the brass inserts are held in place with JB Weld Plastic Bonder, my new go-to adhesive for such things.

    The camera includes WiFi and the inevitable app lets you download images without opening the case. Because I’ll be charging the camera after each ride, I may as well just haul the whole thing inside, plug it into a USB port, and proceed as before.

    For future reference, the manual details the operating modes:

    SJCAM C100 Manual - Modes
    SJCAM C100 Manual – Modes

    Because the camera powers up with WiFi enabled and I have no plans to communicate with it while riding, the startup sequence will be:

    • Long-press to power on
    • Double-click to disable WiFi
    • Single-click to start recording

    The OpenSCAD source code as a GitHub Gist:

    // SJCAM C100+ Camera Mount for Tour Easy seat back rail
    // Ed Nisley – KE4ZNU
    // 2023-04
    /* [Layout Options] */
    LookAngle = -20; // camera angle, looking backwards = 0°
    Layout = "Show"; // [Show,Build]
    /* [Hidden] */
    ThreadWidth = 0.40;
    ThreadThick = 0.25;
    HoleWindage = 0.2;
    Protrusion = 0.1;
    ID = 0;
    OD = 1;
    LENGTH = 2;
    //—–
    // Dimensions
    ClampScrew = [5.0,10.0,40.0]; // ID=thread OD=washer LENGTH=total
    ClampInsert = [5.0,7.5,10.5]; // brass insert
    MountScrew = [5.0,10.0,23.0]; // ID=thread OD=washer LENGTH=under nut
    MountInsert = [5.0,7.5,10.5]; // ID=screw OD, OD=knurl dia
    EmbossDepth = 2*ThreadThick + Protrusion; // recess depth + Protrusion beyond surface
    DebossHeight = EmbossDepth; // text height + Protrusion into part
    RailOD = 20.0; // slightly elliptical in bent section
    RailSides = 2*3*4;
    ClampOA = [60.0,40.0,ClampScrew[LENGTH]]; // set clamp size to avoid weird screw spacing
    echo(ClampOA = ClampOA);
    ClampScrewOC = IntegerMultiple(ClampOA.x – ClampScrew[OD] – 10*ThreadWidth,1.0);
    echo(ClampScrewOC = ClampScrewOC);
    ClampOffset = 5.0; // in case we need more room on top
    ClampRadius = 3.0;
    ClampSides = 8;
    Kerf = 1.0; // slice through the middle
    // center mount blade, Z = depth into block
    MountBlade = [15.0 + 2*HoleWindage,
    3.0 + 2*HoleWindage,
    (ClampOA.z – RailOD + ClampOffset)/2 – 4*ThreadThick + Protrusion];
    echo(MountBlade = MountBlade);
    MountRadius = MountBlade.x / 2;
    MountGap = 9.5; // camera mount gap around center blade
    MountOffset = [0,0,7.0]; // mount hole offset from block surface
    FadeColor = "Green";
    FadeAlpha = 0.25;
    //—–
    // Useful routines
    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(r=(FixDia + HoleWindage)/2,
    h=Height,
    $fn=Sides);
    }
    //—–
    // Clamp
    // Grips seat frame rail
    // Origin at middle of seat rail, X rearward, Y parallel to seat frame rail
    // Block offset raises whole thing
    module Clamp() {
    difference() {
    translate([0,0,ClampOffset]) {
    difference() {
    union() {
    hull() // the main block
    for (i=[-1,1], j=[-1,1], k=[-1,1])
    translate([i*(ClampOA.x – 2*ClampRadius)/2,
    j*(ClampOA.y – 2*ClampRadius)/2,
    k*(ClampOA.z – 2*ClampRadius)/2])
    sphere(r=ClampRadius/cos(180/ClampSides),$fn=ClampSides);
    hull() // camera mount boss
    for (k=[0,1])
    translate([0,0,k*(MountOffset.z) + ClampOA.z/2])
    rotate([0,90,LookAngle + 90]) rotate(180/12)
    cylinder(r=MountRadius,h=MountScrew[LENGTH],center=true,$fn=12);
    }
    for (i=[-1,1]) // clamp inserts
    translate([i*ClampScrewOC/2,0,-(ClampOA.z/2 + Protrusion)])
    rotate(180/6)
    PolyCyl(ClampInsert[OD],ClampInsert[LENGTH],6);
    for (i=[-1,1]) // clamp screw holes
    translate([i*ClampScrewOC/2,0,-ClampOA.z])
    rotate(180/6)
    PolyCyl(ClampScrew[ID],2*ClampOA.z,6);
    translate([0,0,ClampOA.z/2 – (MountBlade.z/2 – Protrusion/2)]) // camera center blade
    rotate(LookAngle)
    cube(MountBlade,center=true);
    rotate(LookAngle + 90) // camera mount boss slot
    translate([0,0,ClampOA.z/2 + 2*MountRadius])
    cube([MountGap,4*MountRadius,4*MountRadius],center=true);
    translate([0,0,ClampOA.z/2 + MountOffset.z]) // camera mount boss hole
    rotate([90,0,LookAngle])
    cylinder(d=MountScrew[ID],h=4*MountGap,center=true,$fn=6);
    translate([0.3*ClampOA.x, // recess for LookAngle legend
    -(ClampOA.y/2 – (EmbossDepth – Protrusion)/2),
    ClampOA.z/4])
    cube([15,EmbossDepth,8],center=true);
    translate([0,0,-ClampOA.z/2 + (EmbossDepth – Protrusion)/2]) // recess for ID legend
    cube([35,10,EmbossDepth],center=true);
    }
    translate([0.3*ClampOA.x, // LookAngle legend
    -ClampOA.y/2 + DebossHeight + Protrusion/2,
    ClampOA.z/4])
    rotate([90,0,00])
    linear_extrude(height=DebossHeight,convexity=20)
    text(text=str(LookAngle),size=6,spacing=1.20,
    font="Arial:style:Bold",halign="center",valign="center");
    translate([0,0,-ClampOA.z/2]) // ID legend
    linear_extrude(height=DebossHeight,convexity=20)
    mirror([0,1,0])
    text(text="KE4ZNU",size=5,spacing=1.20,
    font="Arial:style:Bold",halign="center",valign="center");
    }
    cube([2*ClampOA.x,2*ClampOA.y,Kerf],center=true); // split across rail
    rotate([90,0,0]) // seat rail
    cylinder(d=RailOD,h=2*ClampOA.y,$fn=RailSides,center=true);
    }
    }
    //—–
    // Build things
    // Layouts for design & tweaking
    if (Layout == "Show") {
    Clamp();
    color(FadeColor,FadeAlpha)
    rotate([90,0,0])
    cylinder(d=RailOD,h=2*ClampOA.y,$fn=RailSides,center=true);
    }
    // Build layout
    if (Layout == "Build") {
    translate([0,0.7*ClampOA.y,0])
    difference() {
    translate([0,0,-Kerf/2])
    Clamp();
    translate([0,0,-ClampOA.z])
    cube(2*ClampOA,center=true);
    }
    translate([0,-0.7*ClampOA.y,-0])
    difference() {
    translate([0,0,-Kerf/2])
    rotate([0,180,0])
    Clamp();
    translate([0,0,-ClampOA.z])
    cube(2*ClampOA,center=true);
    }
    }