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: Software

General-purpose computers doing something specific

  • AA Alkaline Battery Holder

    AA Alkaline Battery Holder

    A battery holder for AA alkaline cells descends directly from the NP-BX1 version:

    Astable Multivibrator - Alkaline Batteries - solid model - Show layout
    Astable Multivibrator – Alkaline Batteries – solid model – Show layout

    The square recesses fit single contact pads on the left and a “positive-to-negative conversion” plate on the right, all secured with dabs of acrylic adhesive:

    Alkaline AA holder - contacts
    Alkaline AA holder – contacts

    Although the OpenSCAD code contains an array of battery dimensions, it only works for AA cells.

    The recess on the far left is where you solder the wires onto the contact tabs, with the wires leading outward through the holes in the lid. The case needs an indexing feature to hold the lid square while gluing it down.

    Alkaline cells cells do not have current-limiting circuitry, so a low-current PTC fuse seems like a Good Idea. I initially thought of hiding it in the recess, but the Brutalist nature of the astables suggests open air.

    The OpenSCAD source code as a GitHub Gist:

    // Astable Multivibrator
    // Holder for Alkaline cells
    // Ed Nisley KE4ZNU August 2020
    /* [Layout options] */
    CellName = "AA"; // [AA] — does not work with anything else
    NumCells = 2;
    Layout = "Case"; // [Build,Show,Lid]
    Struts = -1; // [0:None, -1:Dual, 1:Quad]
    // Extrusion parameters – must match reality! */
    /* [Hidden] */
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    HoleWindage = 0.2;
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    function IntegerLessMultiple(Size,Unit) = Unit * floor(Size / Unit);
    Protrusion = 0.1; // make holes end cleanly
    inch = 25.4;
    //- Basic dimensions
    WallThick = IntegerMultiple(3.0,ThreadWidth);
    CornerRadius = WallThick/2;
    FloorThick = IntegerMultiple(3.0,ThreadThick);
    TopThick = IntegerMultiple(2.0,ThreadThick);
    WireOD = 1.7; // wiring from pins to circuitry
    Gap = 5.0;
    // Cylindrical cell sizes
    // https://en.wikipedia.org/wiki/List_of_battery_sizes#Cylindrical_batteries
    CELL_NAME = 0;
    CELL_OD = 1;
    CELL_OAL = 2;
    CellData = [
    ["AAAA",8.3,42.5],
    ["AAA",10.5,44.5],
    ["AA",14.5,50.5],
    ["C",26.2,50],
    ["D",34.2,61.5],
    ["A23",10.3,28.5],
    ["CR123A",17.0,34.5],
    ["18650",18.8,65.2], // bare 18650 with button end
    ["18650Prot",19.0,70.0], // protected 18650 = 19670 plus a bit
    ];
    CellIndex = search([CellName],CellData,1,0)[0];
    echo(str("Cell index: ",CellIndex," = ",CellData[CellIndex][CELL_NAME]));
    //- Contact dimensions
    CONTACT_NAME = 0;
    CONTACT_WIDE = 1;
    CONTACT_HIGH = 2;
    CONTACT_THICK = 3; // plate thickness
    CONTACT_TIP = 4; // tip to rear face
    CONTACT_TAB = 5; // solder tab width
    ContactData = [
    ["AA+",12.2,12.2,0.3,1.7,3.5], // pos bump
    ["AA-",12.2,12.2,0.3,5.0,3.5], // half-compressed neg spring
    ["AA+-",28.2,12.2,0.3,5.0,0], // pos-neg bridge
    ["Li+",18.5,16.0,0.3,2.8,5.5],
    ["Li-",18.5,16.0,0.3,6.0,5.5],
    ];
    function ConDat(name,dim) = ContactData[search([name],ContactData,1,0)[0]][dim];
    ContactRecess = 2*ConDat(str(CellName,"+"),CONTACT_THICK);
    ContactOC = CellData[CellIndex][CELL_OD];
    WireBay = 6.0; // room for wiring to contacts
    //- Wire struts
    StrutDia = 1.6; // AWG 14 = 1.6 mm
    StrutSides = 3*4;
    ID = 0;
    OD = 1;
    LENGTH = 2;
    StrutBase = [StrutDia,StrutDia + 2*5*ThreadWidth, // ID = wire, OD = buildable
    FloorThick + CellData[CellIndex][CELL_OD]]; // base is flush with cell top
    //- Holder dimensions
    BatterySize = [CellData[CellIndex][CELL_OAL] + // cell
    ConDat(str(CellName,"+"),CONTACT_TIP) + // pos contact
    ConDat(str(CellName,"-"),CONTACT_TIP) – // neg contact
    2*ContactRecess, // sink into wall
    NumCells*CellData[CellIndex][CELL_OD],
    CellData[CellIndex][CELL_OD]
    ];
    echo(str("Battery space: ",BatterySize));
    CaseSize = [3*WallThick + // end walls + wiring partition
    BatterySize.x + // cell
    WireBay, // wiring bay
    2*WallThick + BatterySize.y,
    FloorThick + BatterySize.z
    ];
    BatteryOffset = (CaseSize.x – (2*WallThick +
    CellData[CellIndex][CELL_OAL] +
    ConDat(str(CellName,"-"),CONTACT_TIP))
    ) /2 ;
    ThumbRadius = 0.75 * CaseSize.z;
    StrutOC = [IntegerLessMultiple(CaseSize.x – 2*CornerRadius -2*StrutBase[OD],5.0),
    IntegerMultiple(CaseSize.y + StrutBase[OD],5.0)];
    StrutAngle = atan(StrutOC.y/StrutOC.x);
    echo(str("Strut OC: ",StrutOC));
    LidSize = [2*WallThick + WireBay + ConDat(str(CellName,"+"),CONTACT_THICK), CaseSize.y, FloorThick/2];
    //———————-
    // Useful routines
    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);
    }
    //– Overall case with origin at battery center
    module Case() {
    difference() {
    union() {
    hull()
    for (i=[-1,1], j=[-1,1])
    translate([i*(CaseSize.x/2 – CornerRadius),
    j*(CaseSize.y/2 – CornerRadius),
    0])
    cylinder(r=CornerRadius/cos(180/8),h=CaseSize.z,$fn=8); // cos() fixes undersize spheres!
    if (Struts)
    for (i = (Struts == 1) ? [-1,1] : -1) { // strut bases
    hull()
    for (j=[-1,1])
    translate([i*StrutOC.x/2,j*StrutOC.y/2,0])
    rotate(180/StrutSides)
    cylinder(d=StrutBase[OD],h=StrutBase[LENGTH],$fn=StrutSides);
    translate([i*StrutOC.x/2,0,StrutBase[LENGTH]/2])
    cube([2*StrutBase[OD],StrutOC.y,StrutBase[LENGTH]],center=true); // blocks for fairing
    for (j=[-1,1]) // hemisphere caps
    translate([i*StrutOC.x/2,
    j*StrutOC.y/2,
    StrutBase[LENGTH]])
    rotate(180/StrutSides)
    sphere(d=StrutBase[OD]/cos(180/StrutSides),$fn=StrutSides);
    }
    }
    translate([BatteryOffset,0,BatterySize.z/2 + FloorThick]) // cells
    cube(BatterySize + [0,0,Protrusion],center=true);
    translate([BatterySize.x/2 + BatteryOffset + ContactRecess/2 – Protrusion/2, // contacts
    0,
    BatterySize.z/2 + FloorThick])
    cube([ContactRecess + Protrusion,
    ConDat(str(CellName,"+-"),CONTACT_WIDE),
    ConDat(str(CellName,"+-"),CONTACT_HIGH)
    ],center=true);
    translate([-(BatterySize.x/2 – BatteryOffset + ContactRecess/2 – Protrusion/2),
    ContactOC/2,
    BatterySize.z/2 + FloorThick])
    cube([ContactRecess + Protrusion,
    ConDat(str(CellName,"+"),CONTACT_WIDE),
    ConDat(str(CellName,"+"),CONTACT_HIGH)
    ],center=true);
    translate([-(BatterySize.x/2 – BatteryOffset + ContactRecess/2 – Protrusion/2),
    -ContactOC/2,
    BatterySize.z/2 + FloorThick])
    cube([ContactRecess + Protrusion,
    ConDat(str(CellName,"-"),CONTACT_WIDE),
    ConDat(str(CellName,"-"),CONTACT_HIGH)
    ],center=true);
    translate([-CaseSize.x/2 + WireBay/2 + WallThick, // wire bay
    0,
    BatterySize.z/2 + FloorThick + Protrusion/2])
    cube([WireBay,
    BatterySize.y,
    BatterySize.z + Protrusion
    ],center=true);
    for (j=[-1,1])
    translate([-(BatterySize.x/2 – BatteryOffset + WallThick/2), // contact tabs
    j*ContactOC/2,
    BatterySize.z + FloorThick – Protrusion])
    cube([2*WallThick,
    ConDat(str(CellName,"+"),CONTACT_TAB),
    (BatterySize.z – ConDat(str(CellName,"+"),CONTACT_HIGH))
    ],center=true);
    if (false)
    translate([0,0,CaseSize.z]) // finger cutout
    rotate([90,00,0])
    cylinder(r=ThumbRadius,h=2*CaseSize.y,center=true,$fn=22);
    if (Struts)
    for (i2 = (Struts == 1) ? [-1,1] : -1) { // strut wire holes and fairing
    for (j=[-1,1])
    translate([i2*StrutOC.x/2,j*StrutOC.y/2,FloorThick])
    rotate(180/StrutSides)
    PolyCyl(StrutBase[ID],2*StrutBase[LENGTH],StrutSides);
    for (i=[-1,1], j=[-1,1]) // fairing cutaways
    translate([i*StrutBase[OD] + (i2*StrutOC.x/2),
    j*StrutOC.y/2,
    -Protrusion])
    rotate(180/StrutSides)
    PolyCyl(StrutBase[OD],StrutBase[LENGTH] + 2*Protrusion,StrutSides);
    }
    }
    }
    module Lid() {
    difference() {
    hull()
    for (i=[-1,1], j=[-1,1], k=[-1,1])
    translate([i*(LidSize.x/2 – CornerRadius),
    j*(LidSize.y/2 – CornerRadius),
    k*(LidSize.z – CornerRadius)]) // double thickness for flat bottom
    sphere(r=CornerRadius/cos(180/8),$fn=8);
    translate([0,0,-LidSize.z]) // remove bottom
    cube([(LidSize.x + 2*Protrusion),(LidSize.y + 2*Protrusion),2*LidSize.z],center=true);
    for (j=[-1,1]) // wire holes
    translate([0,j*LidSize.y/4,-Protrusion])
    PolyCyl(WireOD,2*LidSize.z,6);
    }
    }
    //——————-
    // Build it!
    if (Layout == "Case")
    Case();
    if (Layout == "Lid")
    Lid();
    if (Layout == "Build") {
    rotate(-90)
    translate([CaseSize.x/2 + Gap,0,0])
    Case();
    rotate(-90)
    translate([-LidSize.x/2 – Gap,0,0])
    Lid();
    }
    if (Layout == "Show") {
    Case();
    translate([-CaseSize.x/2 + LidSize.x/2,0,(CaseSize.z + Gap)])
    Lid();
    }

  • Shuttles Game: Tapered Pegs

    Shuttles Game: Tapered Pegs

    As is all too common with 3D printed replacement parts done remotely, the first Shuttles game pegs didn’t quite fit into the game board’s holes. Fortunately, living in the future means rapid prototyping and quick turnaround:

    Shuttles Game pegs - tapered - solid model
    Shuttles Game pegs – tapered – solid model

    They’re slightly smaller, tapered toward the bottom, and take slightly less time to print.

    The OpenSCAD code in the GitHub Gist now has has the tweaks.

  • Finding & Copying Only The New Music Files

    Given a collection of music files in various subdirectories, find all the mp3 files that aren’t in the target directory and copy them. The only catch: don’t use rsync, because the target directory is on a Google Pixel phone filesystem which doesn’t support various attributes required by rsync.

    The solution goes like this:

    cd /mnt/music/Netlabel Mixes
    sudo jmtpfs /mnt/pixel -o allow_other,fsname="Pixel"
    find . -name \*mp3 -execdir test ! -e  /mnt/pixel/Internal\ shared\ storage/Music/Netlabel/\{\} \; -execdir cp -v -t /mnt/pixel/Internal\ shared\ storage/Music/Netlabel/ \{\} \;
    sudo umount /mnt/pixel

    The trick is remembering the second execdir operation in find happens only if the first succeeds, so the cp runs when the target file doesn’t exist.

    All the backslash escaping gets tedious, but it’s the least awful way to get the job done when the directories contain blanks, which is true for the default directory structure inside the Pixel.

    Your choice in music will surely be different …

  • Seedling Shelter Frame

    Seedling Shelter Frame

    Plant seedlings started in pots require some hardening off time outdoors before being transplanted. Veggie seedlings also require protection from critters regarding them as a buffet, so Mary covers them with a sheet of floating row cover, which must be both suspended over the plants to give them growing room and tucked under the tray to keep the bugs out. She asked for a frame to simplify the process:

    Mesh Shelter Frame - assembled
    Mesh Shelter Frame – assembled

    The solid model shows the structure with no regard for proportion:

    Mesh Shelter Frame - show view
    Mesh Shelter Frame – show view

    The 5 mm fiberglass rods come from our decommissioned six-passenger umbrella, cut to length in the Tiny Lathe™ by applying a Swiss Pattern knife file around the perimeter, over the ShopVac’s snout to catch the glass dust. I started with a pull saw (also over the vacuum) during the weekly Squidwrench v-meeting, whereupon Amber recommended either a Dremel slitting wheel or a file, so I mashed everything together and it worked wonderfully well, without producing any errant glass-fiber shards to impale my fingers.

    The corners consist of three tubes stuck together at the origin:

    Mesh Shelter Frame - non-hulled corner model
    Mesh Shelter Frame – non-hulled corner model

    Shrink-wrapping them with a hull() adds plenty of strength where it’s needed:

    Mesh Shelter Frame - hulled corner model
    Mesh Shelter Frame – hulled corner model

    I decided putting the belly side (facing you in the picture) downward on the platform and the peak upward would distribute the distortion equally among the tubes and produce a nicely rounded outer surface for the mesh fabric:

    Mesh Shelter Frame - build layout
    Mesh Shelter Frame – build layout

    Which led to some Wikipedia trawling to disturb the silt over my long-buried analytic geometry, plus some calculator work to help recall the process; back in the day I would have used a slipstick, but I was unwilling to go there. Although I could special-case this particular layout, the general method uses Euler’s Rotation Theorem, simplified because I need only one rotation.

    Should you need concatenated rotations, you probably need quaternions, but, at this point, I don’t even remember forgetting quaternions.

    Anyhow, the Euler rotation axis is the cross product of the [1,1,1] vector aimed through the middle of the corner’s belly with the [0,0,-1] target vector pointing downward toward the platform. The rotation amount is the acos() of the dot product of those two vectors divided by the product of their norms. With vector and angle in hand, dropping them into OpenSCAD’s rotate() transformation does exactly what’s needed:

    rotate(acos((BaseVector*Nadir)/(norm(BaseVector)*norm(Nadir))),
           v=cross(BaseVector,Nadir))   // aim belly side downward
      Corner();

    Dang, I was so happy when that worked!

    Because the corner model rotates around the origin where all three tube centerlines meet, the result puts the belly below the platform, pointed downward. The next step applies a translation to haul the belly upward:

    translate([ArmOAL,0,    // raise base to just below platform level
               ArmOC/sqrt(3) + (ArmRadius/cos(180/SocketSides))*cos(atan(sqrt(3)/2)) + Finagle])

    This happens in a loop positioning the four corners for printing, so the first ArmOAL as the X axis parameter translates the shape far enough to let four of them coexist around the origin, as shown above.

    The mess in the Z axis parameter has three terms:

    • Raise the centerline of the ends of the tubes to Z=0
    • Raise the rim of the tube to Z=0
    • Add a wee bit to make the answer come out right

    The 0.18 mm Finagle constant fixes things having to do with the hull() applied to miscellaneous leftover angled-circles-as-polygons approximations and leaves just a skin below the platform to be sheared off by a huge cube below Z=0, matching the corner bellies with the bottoms of the feet.

    Because the corners have awful overhangs, the results look a bit raggedy:

    Mesh Shelter Frame - corner underside
    Mesh Shelter Frame – corner underside

    That’s after knocking off the high spots with a grubby sanding sponge and making a trial fit. They look somewhat less grotendous in person.

    If we need another iteration, I’ll think hard about eliminating the overhangs by splitting the corner parallel to the belly, flipping the belly upward, and joining the pieces with a screw. What we have seems serviceable, though.

    The OpenSCAD source code as a GitHub Gist:

    // Mesh Shelter Frame for outdoor sprouts
    // Ed Nisley KE4ZNU – July 2020
    /* [Layout Options] */
    Layout = "Show"; // [Build, Show, Corner, CornerSet, Base, BaseSet]
    //——-
    //- Extrusion parameters must match reality!
    // Print with 2 shells
    /* [Hidden] */
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    HoleFinagle = 0.2;
    HoleFudge = 1.00;
    function HoleAdjust(Diameter) = HoleFudge*Diameter + HoleFinagle;
    Protrusion = 0.1; // make holes end cleanly
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    inch = 25.4;
    //——-
    // Dimensions
    RodOD = 5.0;
    SocketDepth = 3*RodOD;
    WallThick = 3.0;
    ArmOD = RodOD + 2*WallThick;
    ArmRadius = ArmOD / 2;
    SocketSides = 3*4;
    ArmOC = SocketDepth + ArmOD; // rod entry to corner centerline
    ArmOAL = ArmOC + ArmRadius; // total arm length to outer edge
    echo(str("ArmOC: ",ArmOC));
    echo(str("ArmOAL: ",ArmOAL));
    Nadir = [0,0,-1]; // vector toward print platform
    RodLength = 100; // just for show
    //——-
    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=HoleAdjust(FixDia)/2,h=Height,$fn=Sides);
    }
    //——-
    BaseVector = [1,1,1]; // vector through middle of base surface
    module Corner() {
    difference() {
    hull() {
    scale([1/cos(180/SocketSides),1/cos(180/SocketSides),1])
    rotate(180/SocketSides)
    sphere(d=ArmOD,$fn=SocketSides);
    rotate(180/SocketSides)
    cylinder(d=ArmOD,h=ArmOC,$fn=SocketSides);
    rotate([-90,0,0]) rotate(180/SocketSides)
    cylinder(d=ArmOD,h=ArmOC,$fn=SocketSides);
    rotate([0,90,0]) rotate(180/SocketSides)
    cylinder(d=ArmOD,h=ArmOC,$fn=SocketSides);
    }
    rotate(180/SocketSides)
    translate([0,0,ArmOD])
    PolyCyl(RodOD,SocketDepth + Protrusion,SocketSides);
    rotate([-90,0,0]) rotate(180/SocketSides)
    translate([0,0,ArmOD])
    PolyCyl(RodOD,SocketDepth + Protrusion,SocketSides);
    rotate([0,90,0]) rotate(180/SocketSides)
    translate([0,0,ArmOD])
    PolyCyl(RodOD,SocketDepth + Protrusion,SocketSides);
    }
    }
    module CornerSet(s=RodLength) {
    translate([-s/2,-s/2,s])
    mirror([0,0,1])
    Corner();
    translate([s/2,-s/2,s])
    rotate([0,0,90]) mirror([0,0,1])
    Corner();
    translate([s/2,s/2,s])
    rotate([0,0,180]) mirror([0,0,1])
    Corner();
    translate([-s/2,s/2,s])
    rotate([0,0,-90]) mirror([0,0,1])
    Corner();
    }
    module Base() {
    difference() {
    union() {
    cylinder(d=ArmOD,h=ArmOAL/2,$fn=SocketSides);
    resize([0,0,ArmOC/2])
    sphere(d=ArmOC,$fn=2*SocketSides);
    }
    translate([0,0,3*ThreadThick])
    PolyCyl(RodOD,ArmOAL,SocketSides);
    translate([0,0,-SocketDepth]) // cut sphere below platform
    cube(2*SocketDepth,center=true);
    }
    }
    module BaseSet(s=RodLength) {
    for (i=[-1,1], j=[-1,1])
    translate([i*s/2,j*s/2,0])
    Base();
    }
    //——-
    // Build it!
    if (Layout == "Corner")
    Corner();
    if (Layout == "CornerSet")
    CornerSet();
    if (Layout == "Base")
    Base();
    if (Layout == "BaseSet")
    BaseSet();
    if (Layout == "Show") {
    CornerSet();
    for (i=[-1,1])
    translate([i*RodLength/2,RodLength/2,RodLength])
    rotate([90,0,0])
    color("Green",0.5)
    cylinder(d=RodOD,h=RodLength,$fn=SocketSides);
    for (j=[-1,1])
    translate([RodLength/2,j*RodLength/2,RodLength])
    rotate([0,-90,0])
    color("Green",0.5)
    cylinder(d=RodOD,h=RodLength,$fn=SocketSides);
    BaseSet();
    for (i=[-1,1], j=[-1,1])
    translate([i*RodLength/2,j*RodLength/2,0])
    color("Green",0.5)
    cylinder(d=RodOD,h=RodLength,$fn=SocketSides);
    }
    if (Layout == "Build") {
    Finagle = 0.18; // hack for hull's angled round-to-polygon approximations, I think
    difference() { // slice sliver from base to sit flat on platform
    union()
    for (a=[45:90:360])
    rotate(a) // distribute around origin
    translate([ArmOAL,0, // raise base to just below platform level
    ArmOC/sqrt(3) + (ArmRadius/cos(180/SocketSides))*cos(atan(sqrt(3)/2)) + Finagle])
    rotate(17) // arbitrary rotation for tidy arrangement
    rotate(acos((BaseVector*Nadir)/(norm(BaseVector)*norm(Nadir))),
    v=cross(BaseVector,Nadir)) // aim belly side downward
    Corner();
    translate([0,0,-ArmOD/2]) // slicing block below platform
    cube([6*ArmOAL,6*ArmOAL,ArmOD],center=true);
    }
    rotate(45)
    for (i=[-1,1], j=[-1,1])
    translate([i*1.5*ArmOC,j*1.5*ArmOC,0])
    Base();
    }

  • Reinforced QD Propane Adapter Tool

    Reinforced QD Propane Adapter Tool

    Having just emptied a propane tank while making bacon, I couldn’t find any of the wrench adapters I made to remove the QD adapter from the tank’s POL fitting. With memory of the broken garden valve wrench still fresh, I tweaked the solid model to include a trio of 1 mm music wire reinforcements:

    Propane QD Adapter Tool - reinforced - Slic3r
    Propane QD Adapter Tool – reinforced – Slic3r

    Holes that small require clearing with a 1 mm drill, after which ramming the wires in place poses no problem:

    Reinforced QD Adapter Tool - inserting wire
    Reinforced QD Adapter Tool – inserting wire

    Except for the one that got away:

    Reinforced QD Adapter Tool - errant wire
    Reinforced QD Adapter Tool – errant wire

    The music wire came from a coil and each snippet required gentle straightening; perhaps that one wasn’t sufficiently bar-straight.

    Anyhow, I printed two tools for that very reason:

    Reinforced QD Adapter Tool - side view
    Reinforced QD Adapter Tool – side view

    They’re now where I can’t miss ’em the next time I need them, although that’s not where the previous ones reside.

    The OpenSCAD source code as a GitHub Gist:

    // Propane tank QD connector adapter tool
    // Ed Nisley KE4ZNU November 2012
    // 2018-04-08 toss MCAD includes overboard
    // 2020-07-27 add reinforcing rods
    //- Extrusion parameters must match reality!
    // Print with about half a dozen perimeter threads and 50% infill
    ThreadThick = 0.25;
    ThreadWidth = 2.0 * ThreadThick;
    HoleWindage = 0.2;
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    Protrusion = 0.1; // make holes end cleanly
    inch = 25.4;
    //———————-
    // Dimensions
    WrenchSize = (5/8) * inch; // across the flats
    WrenchThick = 10;
    NoseDia = 8.6;
    NoseLength = 9.0;
    LockDia = 12.5;
    LockRingLength = 1.0;
    LockTaperLength = 1.5;
    TriDia = 15.1;
    TriWide = 12.2; // from OD across center to triangle side
    TriOffset = TriWide – TriDia/2; // from center to triangle side
    TriLength = 9.8;
    NeckDia = TriDia;
    NeckLength = 4.0;
    RebarOD = 1.0; // music wire pin 1 mm = 39 mil
    RebarLength = WrenchThick + NeckLength + TriLength;
    //———————-
    // Useful routines
    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);
    }
    //——————-
    // Build it…
    $fn = 4*6;
    union() {
    translate([0,0,(WrenchThick + NeckLength + TriLength – LockTaperLength – LockRingLength + Protrusion)])
    cylinder(r1=NoseDia/2,r2=LockDia/2,h=LockTaperLength);
    translate([0,0,(WrenchThick + NeckLength + TriLength – LockRingLength)])
    cylinder(r=LockDia/2,h=LockRingLength);
    difference() {
    union() {
    translate([0,0,WrenchThick/2])
    cube([WrenchSize,WrenchSize,WrenchThick],center=true);
    cylinder(r=TriDia/2,h=(WrenchThick + NeckLength +TriLength));
    cylinder(r=NoseDia/2,h=(WrenchThick + NeckLength + TriLength + NoseLength));
    }
    for (a=[-1:1]) {
    rotate(a*120)
    translate([(TriOffset + WrenchSize/2),0,(WrenchThick + NeckLength + TriLength/2 + Protrusion/2)])
    cube([WrenchSize,WrenchSize,(TriLength + Protrusion)],center=true);
    }
    for (a=[-1:1]) {
    rotate(a*120 + 60)
    translate([NoseDia/2,0,-Protrusion])
    PolyCyl(RebarOD,RebarLength,6);
    }
    }
    }

  • Shuttles Board Game: Replacement Pegs

    Shuttles Board Game: Replacement Pegs

    For reasons not relevant here, I made replacement pegs for the Shuttles board game:

    Shuttles Game - solid model - Slic3r
    Shuttles Game – solid model – Slic3r

    Not the most challenging solid model I’ve ever conjured from the vasty digital deep, but 3D printing is really good for stuff like this.

    The OEM pegs have a hollow center, most likely to simplify stripping them from the injection mold, which I dutifully duplicated:

    Shuttles Game pegs - hollow - solid model
    Shuttles Game pegs – hollow – solid model

    It turns out the additional perimeter length inside the pegs requires 50% more printing time, far offsetting the reduced 10% infill. Given that each solid set takes just under an hour, I decided to lose half an hour of verisimilitude.

    I plunked a nice round cap atop the OEM peg’s flat end, but stopped short of printing & installing a round plug for the butt end.

    While the 3D printer’s hot, ya may as well make a bunch:

    Shuttles game pegs
    Shuttles game pegs

    Game on …

    The OpenSCAD source code as a GitHub Gist:

    Update: They’re a bit too large, so the Gist now produces tapered pegs.

    // Shuttles game pegs
    // Ed Nisley KE4ZNU – July 2020
    /* [Layout Options] */
    Layout = "Peg"; // [Build, Peg]
    Hollow = false;
    //——-
    //- Extrusion parameters must match reality!
    /* [Hidden] */
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    HoleWindage = 0.2;
    Protrusion = 0.1; // make holes end cleanly
    ID = 0;
    OD = 1;
    LENGTH = 2;
    //——-
    // Dimensions
    /* [Dimensions] */
    Peg = [4.0,7.5,26.0]; // overall length, including the rounded Cap
    Taper = 1.0;
    CapRadius = Peg[OD]/2;
    PegBaseLength = Peg[LENGTH] – CapRadius;
    NumPegs = [1,6]; // lay out in array
    ArrayCenter = [NumPegs[0] – 1,NumPegs[1] – 1] / 2;
    NumSides = 6*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/2 + HoleWindage,h=Height,$fn=Sides);
    }
    //——-
    // One peg
    module Peg() {
    union() {
    translate([0,0,PegBaseLength])
    difference() {
    sphere(d=Peg[OD],$fn=NumSides);
    translate([0,0,-Peg[OD]/2])
    cube([2*Peg[OD],2*Peg[OD],Peg[OD]],center=true);
    }
    difference() {
    cylinder(d1=Peg[OD] – Taper,d2=Peg[OD],h=PegBaseLength,$fn=NumSides);
    if (Hollow)
    translate([0,0,-Protrusion])
    PolyCyl(Peg[ID],PegBaseLength+Protrusion,NumSides);
    }
    }
    }
    //——-
    // Build it!
    if (Layout == "Peg")
    Peg();
    if (Layout == "Build")
    for (i=[0:NumPegs[0] – 1], j=[0:NumPegs[1] – 1])
    translate([(i – ArrayCenter.x)*1.5*Peg[OD],(j – ArrayCenter.y)*1.5*Peg[OD],0])
    Peg();

  • Quilting Hexagon Template Generator

    Quilting Hexagon Template Generator

    Mary took on the task of finishing a hexagonal quilt from pieced strips, only to discover she’ll need several more strips and the myriad triangles required to turn hexagons into strips. The as-built strips do not match any of the standard pattern sizes, which meant ordinary templates were unavailing. I offered to build a template matching the (average) as-built hexagons, plus a triangle template based on those dimensions.

    Wikipedia has useful summaries of hexagon and equilateral triangle geometry and equations.

    Quilters measure hexes based on their finished side length, so a “1 inch hex” has sides measuring 1 inch, with the seam allowance extending ¼ inch beyond the sides. It’s difficult to measure finished sides with sufficient accuracy, so we averaged the side-to-side distance across several hexes.

    Some thrashing around produced a quick-and-dirty check piece that matched (most of) the stack of un-sewn hexes:

    Quilting Hexagon Cutting Template
    Quilting Hexagon Cutting Template

    That one came from a knockoff of the circle template, after some cleanup & tweakage, but failed user testing for not withstanding the side force from the rotary cutter blade. The inside and outside dimensions were correct, however, so I could proceed with some confidence I understood the geometry.

    Both the pattern width (the side-to-side distance across the inside of the hex) and the seam allowance appearing in the Customizer appear in inches, because that’s how things get measured outside the Basement Laboratory & Fabrication Facility:

    FinishedWidthInch = 2.75;
    FinishedWidth = FinishedWidthInch * inch;
    
    SeamAllowanceInch = 0.25;
    SeamAllowance = SeamAllowanceInch * inch;

    You feed in one side-to-side measurement and all other hex dimensions get calculated from that number; quilters default to a ¼ inch seam allowance. Remember, standard quilt hexes are measured by their side length, so just buy some standard templates.

    This is one of the few times I’ve needed triangle graph paper:

    Hex Quilting Template - geometry doodles
    Hex Quilting Template – geometry doodles

    After I gave up trying to get it right on square-grid paper, of course.

    Solidifying those relations:

    Quilting Hex Template - build layout
    Quilting Hex Template – build layout

    Then math got real:

    Hex Quilting Templates - on strips
    Hex Quilting Templates – on strips

    Both templates have non-skid strips to keep the fabric in place while cutting:

    Hex Quilting Template - grip strips
    Hex Quilting Template – grip strips

    I should have embossed the size on each template, but this feels like a one-off project and YAGNI. Of course, that’s how I felt about the circle templates, so maybe next time I’ll get it right.

    As it turned out, Mary realized she needed a template for the two half-triangles at the end of each row:

    Quilting Hex Template - half-triangle
    Quilting Hex Template – half-triangle

    It’s half of the finished size of the equilateral triangle on the right, with seam allowance added all around. The test scrap of fabric on the left shows the stitching along the hypotenuse of the half-triangle, where it joins to the end-of-row hexagon. Ideally, you need two half-triangle templates, but Mary says it’s easier to cut the fabric from the back side than to keep track of two templates.

    The family portrait now has three members:

    Quilting Hex Template - family
    Quilting Hex Template – family

    The OpenSCAD source code as a GitHub Gist:

    // Quilting – Hexagon Templates
    // Ed Nisley KE4ZNU – July 2020
    // Reverse-engineered to repair a not-quite-standard hexagon quilt
    // Useful geometry:
    // https://en.wikipedia.org/wiki/Hexagon
    /* [Layout Options] */
    Layout = "Build"; // [Build, HexBuild, HexPlate, TriBuild, TriPlate, EndBuild, EndPlate]
    //——-
    //- Extrusion parameters must match reality!
    // Print with 2 shells
    /* [Hidden] */
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    HoleFinagle = 0.2;
    HoleFudge = 1.00;
    function HoleAdjust(Diameter) = HoleFudge*Diameter + HoleFinagle;
    Protrusion = 0.1; // make holes end cleanly
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    inch = 25.4;
    //——-
    // Dimensions
    /* [Layout Options] */
    FinishedWidthInch = 2.75;
    FinishedWidth = FinishedWidthInch * inch;
    SeamAllowanceInch = 0.25;
    SeamAllowance = SeamAllowanceInch * inch;
    TemplateThick = 3.0;
    TriKnob = true;
    EndKnob = false;
    /* [Hidden] */
    FinishedSideInch = FinishedWidthInch/sqrt(3);
    FinishedSide = FinishedSideInch * inch;
    echo(str("Finished side: ",FinishedSideInch," inch"));
    CutWidth = FinishedWidth + 2*SeamAllowance;
    CutSide = CutWidth/sqrt(3);
    echo(str("Cut side: ",CutSide / inch," inch"));
    // Make polygon-circles circumscribe the target widths
    TemplateID = FinishedWidth / cos(180/6);
    TemplateOD = CutWidth / cos(180/6);
    /* [Hidden] */
    TriRadius = FinishedSide/sqrt(3);
    TriPoints = [[TriRadius,0],
    [TriRadius*cos(120),TriRadius*sin(120)],
    [TriRadius*cos(240),TriRadius*sin(240)]
    ];
    echo(str("TriPoints: ",TriPoints));
    EndPoints = [[TriRadius,0],
    [TriRadius*cos(120),TriRadius*sin(120)],
    [TriRadius*cos(120),0]
    ];
    echo(str("EndPoints: ",EndPoints));
    TipCutRadius = 2*(TriRadius + SeamAllowance); // circumscribing radius of tip cutter
    TipPoints = [[TipCutRadius,0],
    [TipCutRadius*cos(120),TipCutRadius*sin(120)],
    [TipCutRadius*cos(240),TipCutRadius*sin(240)]
    ];
    HandleHeight = 1 * inch;
    HandleLength = (TemplateID + TemplateOD)/2;
    HandleThick = IntegerMultiple(3.0,ThreadWidth);
    HandleSides = 12*4;
    StringDia = 4.0;
    StringHeight = 0.6*HandleHeight;
    DentDepth = HandleThick/4;
    DentDia = 15 * DentDepth;
    DentSphereRadius = (pow(DentDepth,2) + pow(DentDia,2)/4)/(2*DentDepth);
    KnobOD = 15.0; // Triangle handle
    KnobHeight = 20.0;
    //——-
    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=HoleAdjust(FixDia)/2,h=Height,$fn=Sides);
    }
    //——-
    // Hex template
    module HexPlate() {
    difference() {
    cylinder(r=TemplateOD/2,h=TemplateThick,$fn=6);
    translate([0,0,-Protrusion])
    cylinder(r=TemplateID/2,h=(TemplateThick + 2*Protrusion),$fn=6);
    }
    for (i=[1:6/2])
    rotate(i*60)
    translate([0,0,TemplateThick/2])
    cube([HandleLength,HandleThick,TemplateThick],center=true);
    }
    module HexHandle() {
    difference() {
    rotate([90,0,0])
    scale([1,HandleHeight/(TemplateOD/2),1])
    rotate(180/HandleSides)
    cylinder(d=HandleLength,h=HandleThick,center=true,$fn=HandleSides);
    translate([0,0,-HandleHeight])
    cube([2*TemplateOD,2*TemplateOD,2*HandleHeight],center=true);
    translate([0,HandleThick,StringHeight])
    rotate([90,090,0])
    rotate(180/8)
    PolyCyl(StringDia,2*HandleThick,8);
    for (j=[-1,1]) {
    translate([0,j*(DentSphereRadius + HandleThick/2 – DentDepth),StringHeight])
    rotate(180/48)
    sphere(r=DentSphereRadius,$fn=48);
    }
    }
    }
    module HexTemplate() {
    HexPlate();
    HexHandle();
    }
    //——-
    // Triangle template
    module TriPlate() {
    linear_extrude(height=TemplateThick)
    intersection() {
    offset(delta=SeamAllowance) // basic cutting outline
    polygon(points=TriPoints);
    rotate(180)
    polygon(points=TipPoints);
    }
    }
    module TriTemplate() {
    union() {
    if (TriKnob)
    cylinder(d=KnobOD,h=KnobHeight,$fn=HandleSides);
    TriPlate();
    }
    }
    //——-
    // End piece template
    module EndPlate() {
    linear_extrude(height=TemplateThick)
    intersection() {
    offset(delta=SeamAllowance) // basic cutting outline
    polygon(points=EndPoints);
    rotate(180)
    polygon(points=TipPoints);
    }
    }
    module EndTemplate() {
    union() {
    if (EndKnob)
    translate([0,(TriRadius/2)*sin(30),0])
    cylinder(d=KnobOD,h=KnobHeight,$fn=HandleSides);
    EndPlate();
    }
    }
    //——-
    // Build it!
    if (Layout == "HexPlate")
    HexPlate();
    if (Layout == "HexBuild")
    HexTemplate();
    if (Layout == "TriPlate")
    TriPlate();
    if (Layout == "TriBuild")
    TriTemplate();
    if (Layout == "EndPlate")
    EndPlate();
    if (Layout == "EndBuild")
    EndTemplate();
    if (Layout == "Build") {
    translate([1.5*TriRadius,-TriRadius,0])
    rotate(180/6)
    TriTemplate();
    translate([-1.5*TriRadius,-TriRadius,0])
    rotate(180/6)
    EndTemplate();
    translate([0,TemplateOD/2,0])
    HexTemplate();
    }