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

  • Mini-Lathe: Cover Screw Knobs and Change Gear Protector

    About the third time I removed the mini-lathe’s change gear cover by deploying a 4 mm hex wrench on its pair of looong socket head cap screws, I realized that finger-friendly knobs were in order:

    LMS Mini-lathe cover screw knobs - installed
    LMS Mini-lathe cover screw knobs – installed

    A completely invisible length of 4 mm hex key (sliced off with the new miter saw) runs through the middle of the knob into the screw, with a dollop of clear epoxy holding everything together:

    LMS Mini-lathe cover screw knobs - epoxied
    LMS Mini-lathe cover screw knobs – epoxied

    The 2 mm cylindrical section matches the screw head, compensates for the 1.5 mm recess, and positions the knobs slightly away from the cover:

    LMS Mini-lathe cover screw knob - solid model
    LMS Mini-lathe cover screw knob – solid model

    They obviously descend from the Sherline tommy bar handles.

    I built three of ’em at a time to get a spare to show off and to let each one cool down before the next layer arrives on top:

    LMS Mini-lathe cover screw knobs - on platform
    LMS Mini-lathe cover screw knobs – on platform

    The top and bottom surfaces have Octagram Spiral infill that came out nicely, although it’s pretty much wasted in this application:

    LMS Mini-lathe cover screw knob - Slic3r first layer
    LMS Mini-lathe cover screw knob – Slic3r first layer

    I have no explanation for that single dent in the perimeter.

    The cover hangs from those two screws, which makes it awkward to line up, so I built a shim to support the cover in the proper position:

    LMS Mini-lathe cover support shim - Slic3r preview
    LMS Mini-lathe cover support shim – Slic3r preview

    Nope, it’s not quite rectangular, as the change gear plate isn’t mounted quite square on the headstock:

    LMS Mini-lathe - cover alignment block
    LMS Mini-lathe – cover alignment block

    I decided when if that plate eventually gets moved / adjusted / corrected, I’ll just build a new shim and move on. A length of double-sticky tape holds it onto the headstock.

    Mounting the cover now requires only two hands: plunk it atop the shim, press it to the right so the angled side settles in place, insert screws, and it’s done.

    A short article by Samuel Will (Home Shop Machinist 35.3 May 2016) pointed out that any chips entering the spindle bore will eventually fall out directly into the plastic change gears and destroy them. He epoxied a length of PVC pipe inside the cover to guide the swarf outside, but I figured a tidier solution would be in order:

    LMS Mini-lathe - change gear shield
    LMS Mini-lathe – change gear shield

    The solid model looks just like that:

    LMS Mini-lathe cover shaft shield - Slic3r preview
    LMS Mini-lathe cover shaft shield – Slic3r preview

    The backside of the shield has three M3 brass inserts pressed in place. I marked the holes on the cover by the simple expedient of bandsawing the base of the prototype shield (which I needed for a trial fit), lining it up with the spindle hole, and tracing the screw holes (which aren’t yet big enough for the inserts):

    LMS mini-lathe - cover hole template
    LMS mini-lathe – cover hole template

    Yeah, that’s burned PETG snot around 10 o’clock on the shield. You could print a separate template if you prefer.

    The various diameters and lengths come directly from my lathe and probably won’t be quite right for yours; there’s a millimeter or two of clearance in all directions that might not be sufficient.

    Don’t expect the cover hole to line up with the spindle bore:

    LMS mini-lathe - view through cover and spindle
    LMS mini-lathe – view through cover and spindle

    I should build an offset into the shield that jogs the holes in whatever direction makes the answer come out right, but that’s in the nature of fine tuning; those holes got filed slightly egg-shaped to ease the shield a bit to the right and it’s all good.

    Heck, having the spindle line up pretty closely with the tailstock seems like enough of a bonus for one day.

    The OpenSCAD source code as a GitHub Gist:

    // Tweakage for LMS Mini-Lathe cover
    // Ed Nisley – KE4ZNU – June 2016
    Layout = "Shaft"; // Knob Shim Shaft
    use <knurledFinishLib_v2.scad>
    //- Extrusion parameters must match reality!
    // Print with 2 shells and 3 solid layers
    ThreadThick = 0.20;
    ThreadWidth = 0.40;
    HoleWindage = 0.3; // extra clearance to improve hex socket fit
    Protrusion = 0.1; // make holes end cleanly
    inch = 25.4;
    //———————-
    // Dimensions
    //- Knobs for cover screws
    HeadDia = 8.5; // un-knurled section diameter
    HeadRecess = 2.0; // … length inside cover surface + some clearance
    SocketDia = 4.0; // hex key size
    SocketDepth = 10.0;
    KnurlLen = 15.0; // length of knurled section
    KnurlDia = 20.0; // … diameter at midline of knurl diamonds
    KnurlDPNom = 12; // Nominal diametral pitch = (# diamonds) / (OD inches)
    DiamondDepth = 1.5; // … depth of diamonds
    DiamondAspect = 4; // length to width ratio
    KnurlID = KnurlDia – DiamondDepth; // dia at bottom of knurl
    NumDiamonds = ceil(KnurlDPNom * KnurlID / inch);
    echo(str("Num diamonds: ",NumDiamonds));
    NumSides = 4*NumDiamonds; // 4 facets per diamond
    KnurlDP = NumDiamonds / (KnurlID / inch); // actual DP
    echo(str("DP Nom: ",KnurlDPNom," actual: ",KnurlDP));
    DiamondWidth = (KnurlID * PI) / NumDiamonds;
    DiamondLenNom = DiamondAspect * DiamondWidth; // nominal diamond length
    DiamondLength = KnurlLen / round(KnurlLen/DiamondLenNom); // … actual
    TaperLength = 0*DiamondLength;
    //- Shim to support cover
    CoverTopThick = 2.0;
    ShimThick = 10.0;
    ShimCornerRadius = 2.0;
    ShimPoints = [[0,0],[60,0],[60,(13.5 – CoverTopThick)],[0,(14.5 – CoverTopThick)]];
    //- Shaft extension to keep crap out of the change gear train
    ID = 0;
    OD = 1;
    LENGTH = 2;
    Shaft = [24.0,30.0,41.0]; // ID=through, OD=thread OD, Length = cover to nut seat
    ShaftThreadLength = 3.0;
    ShaftSides = 6*4;
    ShaftNut = [45,50,16]; // recess around shaft nut, OD = outside of cover
    Insert = [3.5,5.0,8.0]; // 3 mm threaded insert
    NumCoverHoles = 3;
    CoverHole = [Insert[OD],35.0,12.0]; // ID = insert, OD = BCD, LENGTH = screw hole depth
    ShaftPoints = [
    [Shaft[ID]/2,0],
    [ShaftNut[OD]/2,0],
    [ShaftNut[OD]/2,Shaft[LENGTH]],
    [ShaftNut[ID]/2,Shaft[LENGTH]],
    [ShaftNut[ID]/2,Shaft[LENGTH] – ShaftNut[LENGTH]],
    [Shaft[OD]/2, Shaft[LENGTH] – ShaftNut[LENGTH]],
    [Shaft[OD]/2, Shaft[LENGTH] – ShaftNut[LENGTH] – ShaftThreadLength],
    [Shaft[ID]/2, Shaft[LENGTH] – ShaftNut[LENGTH] – ShaftThreadLength],
    ];
    //———————-
    // 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 things
    if (Layout == "Knob")
    difference() {
    union() {
    render(convexity=10)
    translate([0,0,TaperLength]) // knurled cylinder
    knurl(k_cyl_hg=KnurlLen,
    k_cyl_od=KnurlDia,
    knurl_wd=DiamondWidth,
    knurl_hg=DiamondLength,
    knurl_dp=DiamondDepth,
    e_smooth=DiamondLength/2);
    color("Orange") // lower tapered cap
    cylinder(r1=HeadDia/2,
    r2=(KnurlDia – DiamondDepth)/2,
    h=(TaperLength + Protrusion),
    $fn=NumSides);
    color("Orange") // upper tapered cap
    translate([0,0,(TaperLength + KnurlLen – Protrusion)])
    cylinder(r2=HeadDia/2,
    r1=(KnurlDia – DiamondDepth)/2,
    h=(TaperLength + Protrusion),
    $fn=NumSides);
    color("Moccasin") // cylindrical extension
    translate([0,0,(2*TaperLength + KnurlLen – Protrusion)])
    cylinder(r=HeadDia/2,h=(HeadRecess + Protrusion),$fn=NumSides);
    }
    translate([0,0,(2*TaperLength + KnurlLen + HeadRecess – SocketDepth + Protrusion)])
    PolyCyl(SocketDia,(SocketDepth + Protrusion),6); // hex key socket
    }
    if (Layout == "Shim")
    linear_extrude(height=(ShimThick)) // overall flange around edges
    polygon(points=ShimPoints);
    if (Layout == "Shaft")
    difference() {
    rotate_extrude($fn=ShaftSides,convexity=5)
    polygon(points=ShaftPoints);
    for (i=[0:NumCoverHoles-1])
    rotate(i*360/NumCoverHoles)
    translate([CoverHole[OD]/2,0,-Protrusion])
    rotate(180/8)
    PolyCyl(Insert[OD],15,8);
    }

    The original doodle with more-or-less actual dimensions and clearances and suchlike:

    Cover to Shaft spacing doodles
    Cover to Shaft spacing doodles
  • Monthly Science: Chrysalid Engineer

    So then this happened:

    Karen - canonical tiger paw graduation picture
    Karen – canonical tiger paw graduation picture

    Yeah, tanker boots and all; not the weirdest thing we saw during RIT’s graduation ceremonies.

    This summer marks her fourth of four co-op semesters with Real Companies Doing Tech Stuff and her final classes end in December; RIT holds one ceremony in the spring and being offset by a semester apparently isn’t all that unusual. She (thinks she) has a job lined up after graduation and doesn’t need her doting father’s help.

    But, hey, should you know someone with a way-cool opportunity (*) for a bright, fresh techie who’s increasingly able to build electronic & mechanical gadgets and make them work, drop me a note and I’ll put the two of you in touch. [grin]

    (*) If that opportunity should involve 3D printed prosthetics with sensors and motors, she will crawl right out of your monitor…

  • Tempting TV Channel

    One of the motel’s TV channels offered this diversion:

    Fedora console on motel TV
    Fedora console on motel TV

    Alas, no combination of keys on the overly complex remote fed themselves to tty1. That didn’t surprise me, but ya gotta try, y’know.

    Contrary to what you might think, that’s a well-focused image. Apparently, someone, somewhere, aimed a crappy camera at a monitor and devoted one video input to the result.

    I wonder what critical infrastructure runs a Linux distro that end-of-lifed in December 2009.

    We’ll never know the rest of the story…

  • APRS/GPS + Voice Interface: Improved PTT Button Cap

    Long ago, Mary picked out a PTT switch with a raised, square post that provided a distinct shape and positive tactile feedback:

    PTT Button - bare post
    PTT Button – bare post

    Time passes, she dinged her thumb in the garden, and asked for a more rounded button. I have some switches with rounded caps, but replacing the existing switch looked a lot like work, sooooo:

    PTT Button Cap - Slic3r preview
    PTT Button Cap – Slic3r preview

    As with all small objects, building them four at a time gives the plastic in each one time to cool before slapping the next layer on top:

    PTT Button - on platform
    PTT Button – on platform

    The hole in the cap is 0.2 mm oversize, which results in a snug press fit on the small ridges barely visible around the post in the first image:

    PTT Button - rounded cap
    PTT Button – rounded cap

    Rather than compute the chord covering the surface, I just resized a sphere to twice the desired dome height (picked as 6 threads, just for convenience) and plunked it atop a cylinder. Remember to expand the sphere diameter by 1/cos(180/sides) to make it match the cylinder and force both to have the same number of sides.

    If it falls off, I have three backups.

    The OpenSCAD source code as a GitHub Gist:

    // PTT Button cap
    // Ed Nisley KE4ZNU – June 2016
    //- Extrusion parameters – must match reality!
    ThreadThick = 0.20;
    ThreadWidth = 0.40;
    Protrusion = 0.1;
    HoleWindage = 0.2;
    //——
    // Dimensions
    Post = [3.8,3.8,3.0];
    OD = 0;
    HEIGHT = 1;
    DOMEHEIGHT = 2;
    Button = [12,0+Post[2],6*ThreadThick];
    NumSides = 8*4;
    //———————-
    //- Build it
    difference() {
    union() {
    translate([0,0,Button[HEIGHT]])
    resize([0,0,2*Button[DOMEHEIGHT]])
    sphere(d=Button[OD]/cos(180/NumSides),$fn=NumSides);
    cylinder(d=Button[OD],h=Button[HEIGHT],$fn=NumSides);
    }
    translate([0,0,Post[2]/2 – Protrusion])
    cube(Post + [HoleWindage,HoleWindage,Protrusion],center=true);
    }
  • Raspberry Pi Streaming Radio Player: Mostly Viable Product

    The latest version of my simpleminded streaming radio player includes:

    • More durable parsing for track titles with embedded quotes and semicolons
    • Muting during empty / non-music Radionomy tracks
    • The Dutchess County E911 service

    Audionomy’s empty / non-music tracks include a remarkable number of mis-encoded MP3 sections triggering decoding problems; those problems don’t occur during music tracks. Some tracks come through as advertisements, which would be mostly OK apart from the garbled / high-volume gibberish, but on the whole they’re un-listenable:

    ICY Info: StreamTitle='';
    A:1271.0 (21:10.9) of 0.0 (00.0)  4.0% 44%
    [mp3float @ 0x7623e080]overread, skip -7 enddists: -5 -5
    [mp3float @ 0x7623e080]overread, skip -9 enddists: -6 -6
    A:1271.2 (21:11.2) of 0.0 (00.0)  4.0% 45%
    [mp3float @ 0x7623e080]overread, skip -7 enddists: -5 -5
    A:1309.1 (21:49.1) of 0.0 (00.0)  4.0% 42%
    ICY Info: StreamTitle='Targetspot - TargetSpot';
    A:1316.4 (21:56.4) of 0.0 (00.0)  4.0% 40%
    [mp3float @ 0x7623e080]overread, skip -5 enddists: -4 -4
    [mp3float @ 0x7623e080]overread, skip -5 enddists: -2 -2
    

    Muting happens in the mixer, because that seems easier than messing with mplayer in mid-flight. Rather than attempt to control the muted state with specific timeouts, I just un-mute after a new track title arrives; that has no effect if it’s already un-muted. The delays depend on the buffer fill level and avoid the worst of the gibberish.

    The player still falls over dead / jams solid on occasion, generally because the incoming data has stopped streaming or delivered severe encoding problems. Other than that, it runs pretty much all day, every day, on at least one of the Raspberry Pi streamers.

    Still no track display. Mostly, we still don’t miss it.

    The Python source code as a GitHub Gist:

    from evdev import InputDevice,ecodes,KeyEvent
    import subprocess32 as subprocess
    import select
    import re
    import sys
    import time
    Media = {'KEY_KP7' : ['Classical',['mplayer','-playlist','http://stream2137.init7.net/listen.pls'%5D%5D,
    'KEY_KP8' : ['Jazz',['mplayer','-playlist','http://stream2138.init7.net/listen.pls'%5D%5D,
    'KEY_KP9' : ['WMHT',['mplayer','http://live.str3am.com:2070/wmht1'%5D%5D,
    'KEY_KP4' : ['Classic 1000',['mplayer','-playlist','http://listen.radionomy.com/1000classicalhits.m3u'%5D%5D,
    'KEY_KP5' : ['DCNY 911',['mplayer','-playlist','http://www.broadcastify.com/scripts/playlists/1/12305/-5857889408.m3u'%5D%5D,
    'KEY_KP6' : ['WAMC',['mplayer','http://pubint.ic.llnwd.net/stream/pubint_wamc'%5D%5D,
    'KEY_KP1' : ['60s',['mplayer','-playlist','http://listen.radionomy.com/all60sallthetime-keepfreemusiccom.m3u'%5D%5D,
    'KEY_KP2' : ['50-70s',['mplayer','-playlist','http://listen.radionomy.com/golden-50-70s-hits.m3u'%5D%5D,
    'KEY_KP3' : ['Soft Rock',['mplayer','-playlist','http://listen.radionomy.com/softrockradio.m3u'%5D%5D,
    'KEY_KP0' : ['Zen',['mplayer','-playlist','http://listen.radionomy.com/zen-for-you.m3u'%5D%5D
    }
    CurrentKC = 'KEY_KP7'
    Controls = {'KEY_KPSLASH' : '//////',
    'KEY_KPASTERISK' : '******',
    'KEY_KPENTER' : ' ',
    'KEY_KPMINUS' : '<',
    'KEY_KPPLUS' : '>',
    'KEY_VOLUMEUP' : '*',
    'KEY_VOLUMEDOWN' : '/'
    }
    MuteDelay = 5.5 # delay before non-music track; varies with buffering
    UnMuteDelay = 7.3 # delay after non-music track
    MixerChannel = 'PCM' # which amixer thing to use
    # set up event inputs and polling objects
    # This requires some udev magic to create the symlinks
    k = InputDevice('/dev/input/keypad')
    k.grab()
    kp = select.poll()
    kp.register(k.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR)
    v = InputDevice('/dev/input/volume')
    v.grab()
    vp = select.poll()
    vp.register(v.fileno(),select.POLLIN + select.POLLPRI + select.POLLERR)
    # set up files for mplayer pipes
    lw = open('/tmp/mp.log','w') # mplayer piped output
    lr = open('/tmp/mp.log','r') # … reading that output
    # set the mixer output low enough that the initial stream won't wake the dead
    subprocess.call(['amixer','sset',MixerChannel,'10'])
    # Start the player with the default stream
    print 'Starting mplayer on',Media[CurrentKC][0],' -> ',Media[CurrentKC][-1][-1]
    p = subprocess.Popen(Media[CurrentKC][-1],
    stdin=subprocess.PIPE,stdout=lw,stderr=subprocess.STDOUT)
    print ' … running'
    #——————–
    #— Play the streams
    while True:
    # pluck next line from mplayer and decode it
    text = lr.readline()
    if 'ICY Info: ' in text: # these lines may contain track names
    trkinfo = text.split(';') # also splits at semicolon embedded in track name
    for ln in trkinfo:
    if 'StreamTitle' in ln: # this part contains a track name
    if 2 == ln.count("'"): # exactly two single quotes = probably none embedded in track name
    trkhit = re.search(r"StreamTitle='(.*)'",ln)
    if trkhit: # true for valid search results
    TrackName = trkhit.group(1) # the string between the two quotes
    print 'Track name: ', TrackName
    if ('' == TrackName or 'TargetSpot' in TrackName) and 'radionomy' in Media[CurrentKC][-1][-1]:
    print ' … muting empty Radionomy track'
    time.sleep(MuteDelay)
    subprocess.call(['amixer','-q','sset',MixerChannel,'mute'])
    else:
    print ' … unmuting'
    time.sleep(UnMuteDelay)
    subprocess.call(['amixer','-q','sset',MixerChannel,'unmute'])
    else:
    print ' … semicolon in track name: ', ln
    else:
    print ' … quotes in track name: ', ln
    elif 'Exiting…' in text:
    print 'Got EOF / stream cutoff'
    print ' … killing dead mplayer'
    p.kill()
    print ' … flushing pipes'
    lw.truncate(0)
    print ' … discarding keys'
    while [] != kp.poll(0):
    kev = k.read
    print ' … restarting mplayer: ',Media[CurrentKC][0]
    p = subprocess.Popen(Media[CurrentKC][-1],
    stdin=subprocess.PIPE,stdout=lw,stderr=subprocess.STDOUT)
    print ' … running'
    continue
    # accept pending events from volume control knob
    if [] != vp.poll(10):
    vev = v.read()
    for e in vev:
    if e.type == ecodes.EV_KEY:
    kc = KeyEvent(e).keycode
    # print 'Volume kc: ',kc
    if kc in Controls:
    print 'Vol Control: ',kc
    try:
    p.stdin.write(Controls[kc])
    except Exception as e:
    print "Can't send control: ",e
    print ' … restarting player: ',Media[CurrentKC][0]
    p = subprocess.Popen(Media[CurrentKC][-1],
    stdin=subprocess.PIPE,stdout=lw,stderr=subprocess.STDOUT)
    print ' … running'
    # accept pending events from keypad
    if [] != kp.poll(10):
    kev = k.read()
    for e in kev:
    if e.type == ecodes.EV_KEY:
    kc = KeyEvent(e).keycode
    if kc == 'KEY_NUMLOCK':
    continue
    # print 'Got: ',kc
    if (kc == 'KEY_BACKSPACE') and (KeyEvent(e).keystate == KeyEvent.key_hold):
    if True:
    print 'Backspace = shutdown!'
    p = subprocess.call(['sudo','shutdown','-HP','now'])
    else:
    print 'BS = bail from main, ssh to restart!'
    sys.exit(0)
    if KeyEvent(e).keystate != KeyEvent.key_down:
    continue
    if kc in Controls:
    print 'Control:', kc
    try:
    p.stdin.write(Controls[kc])
    except Exception as e:
    print "Can't send control: ",e
    print ' … restarting player: ',Media[CurrentKC][0]
    p = subprocess.Popen(Media[CurrentKC][-1],
    stdin=subprocess.PIPE,stdout=lw,stderr=subprocess.STDOUT)
    print ' … running'
    if kc in Media:
    print 'Switching stream to ',Media[kc][0],' -> ',Media[kc][-1][-1]
    CurrentKC = kc
    print ' … halting player'
    try:
    p.communicate(input='q')
    except Exception as e:
    print 'Perhaps mplayer died?',e
    print ' … killing it for sure'
    p.kill()
    print ' … flushing pipes'
    lw.truncate(0)
    print ' … restarting player: ',Media[CurrentKC][0]
    p = subprocess.Popen(Media[CurrentKC][-1],
    stdin=subprocess.PIPE,stdout=lw,stderr=subprocess.STDOUT)
    print ' … running'
    print 'Out of loop!'
    view raw Streamer.py hosted with ❤ by GitHub
  • Audio Direction Finding

    Given a point source of audio (or RF, for that matter) that’s far enough away to produce more-or-less plane wavefronts, the range difference between two microphones (or ears) is:

    ΔR = (mic separation) x sin Θ

    The angle lies between the perpendicular to the line from the midpoint between the mics counterclockwise to the source source: + for sounds to your left, – for sounds to your right. That’s the trig convention for angular measurement with 0° directly ahead, not the compass convention, but you can argue for either sign if you keep track of what’s going on.

    The time delay between the mics, given c = speed of sound:

    ΔT = ΔR / c

    For microphones 300 mm apart and c = 344 m/s:

    ΔT = 872 µs = 0.3 m / 344 m/s

    If you delay the sound from the mic closest to the source by that amount, then add the mic signals, you get a monaural result that emphasizes, at least a little bit, sounds from that source in relation to all other sounds.

    In principle, you could find the angle by listening for the loudest sound, but that’s a fool’s game.

    There’s an obvious symmetry for a source on the same side, at the same angle, toward the rear.

    A GNU Radio data flow diagram that lets you set the angle and listen to / watch the results:

    Audio Direction Finding.grc
    Audio Direction Finding.grc

    The original doodles show it takes me a while to work around to the answer:

    Audio direction finding doodles
    Audio direction finding doodles
  • Clover Seam Ripper Cap

    Mary wanted a rigid cap for a Clover seam ripper that came with a small plastic sheath, so I called one from the vasty digital deep:

    Clover Seam Ripper - new cap
    Clover Seam Ripper – new cap

    The solid model looks about like you’d expect, with a brim around the bottom to paste it on the platform:

    Clover Seam Ripper Cap - Slic3r preview
    Clover Seam Ripper Cap – Slic3r preview

    I added a slightly tapered entry to work around the usual tolerance problems:

    Clover Seam Ripper Cap - bottom view
    Clover Seam Ripper Cap – bottom view

    The taper comes from a hull wrapped around eight small spheres:

    Clover Seam Ripper Cap - Entry Pyramid
    Clover Seam Ripper Cap – Entry Pyramid

    That’s surprisingly easy to accomplish, at least after you get used to this sort of thing:

    hull() {																		// entry taper
    	for (i=[-1,1] , j=[-1,1])
    		translate([i*(HandleEntry[0]/2 - StemRadius),j*(HandleEntry[1]/2 - StemRadius),0])
    			sphere(r=StemRadius,$fn=4*4);
    	for (i=[-1,1] , j=[-1,1])
    		translate([i*(HandleStem[0]/2 - StemRadius),j*(HandleStem[1]/2 - StemRadius),HandleEntry[2] - StemRadius])
    			sphere(r=StemRadius,$fn=4*4);	
    }
    

    The side walls are two threads thick and, at least in PETG, entirely too rigid to slide on easily. I think a single-thread wall with a narrow ridge would provide more spring; if this one gets too annoying, I’ll try that.

    The OpenSCAD source code as a GitHub gist:

    // Clover seam ripper cap
    // Ed Nisley KE4ZNU – April 2016
    //- Extrusion parameters – must match reality!
    // Build with a 5 mm brim to keep it glued to the platform
    ThreadThick = 0.25;
    ThreadWidth = 0.40;
    Protrusion = 0.1;
    //——
    // Dimensions
    StemRadius = 0.50; // corner radius
    HandleStem = [6.1, 7.1, 9.0];
    HandleEntry = HandleStem + [1.0,1.0,-4.0]; // Z is -(straight part of stem)
    Cap = [8.5,11.0,45.0]; // XY exterior, Z interior
    //———————-
    //- Build it
    difference() {
    union() {
    translate([0,0,Cap[2]/2]) // main body column
    cube(Cap,center=true);
    translate([-Cap[0]/2,0,Cap[2]]) // rounded cap
    rotate([0,90,0])
    cylinder(d=Cap[1],h=Cap[0],$fn=8*4);
    translate([Cap[0]/2 – Protrusion,0,(Cap[2] + Cap[1]/2)/2]) // text
    rotate([0,90,0])
    linear_extrude(height=ThreadWidth,convexity=10)
    text("Mary Nisley",halign="center",valign="center",size=0.5*Cap[1],font="Arial");
    }
    hull() // stem + blade clearance
    for (i=[-1,1] , j=[-1,1])
    translate([i*(HandleStem[0]/2 – StemRadius),j*(HandleStem[1]/2 – StemRadius),-Protrusion])
    cylinder(r=StemRadius,h=Cap[2] + Protrusion,$fn=4*4);
    hull() { // entry taper
    for (i=[-1,1] , j=[-1,1])
    translate([i*(HandleEntry[0]/2 – StemRadius),j*(HandleEntry[1]/2 – StemRadius),0])
    sphere(r=StemRadius,$fn=4*4);
    for (i=[-1,1] , j=[-1,1])
    translate([i*(HandleStem[0]/2 – StemRadius),j*(HandleStem[1]/2 – StemRadius),HandleEntry[2] – StemRadius])
    sphere(r=StemRadius,$fn=4*4);
    }
    }