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

  • Raspberry Pi: Adding a PIXEL Desktop Launcher

    The Raspberry Pi’s Raspbian PIXEL Desktop UI (not to be confused with the Google Pixel phone) descends from LXDE, with all the advantages & disadvantages that entails. One nuisance seems to be the inability to create a launcher for a non-standard program.

    The stock task bar (or whatever it’s called) has a few useful launchers and you can add a launcher for a program installed through the usual Add/Remove Software function, as shown by the VLC icon:

    LXDE launcher icons
    LXDE launcher icons

    Adding a bCNC launcher requires a bit of legerdemain, because it’s not found in the RPi repositories. Instead, install bCNC according to its directions:

    … install various pre-requisites as needed …
    pip2 install --upgrade git+https://github.com/vlachoudis/bCNC 

    Which is also how you upgrade to the latest & greatest version, as needed.

    You then launch bCNC from inside a terminal:

    python2 -m bCNC

    The installation includes all the bits & pieces required to create a launcher; they’re just not in the right places.

    So put them there:

    sudo cp ./.local/lib/python2.7/site-packages/bCNC/bCNC.png /usr/share/icons/
    sudo cp .local/lib/python2.7/site-packages/bCNC/bCNC.desktop /usr/share/applications/bCNC.desktop

    The bCNC.desktop file looks like this:

    [Desktop Entry]
    Version=1.0
    Type=Application
    Name=bCNC
    Comment=bCNC Controller
    Exec=bCNC
    Icon=bCNC.png
    Path=
    Terminal=true
    StartupNotify=false
    Name[en_US]=bCNC

    Set Terminal=false if you don’t want a separate terminal window and don’t care about any of the messages bCNC writes to the console during its execution. However, those messages may provide the only hint about happened as bCNC falls off the rails.

    With all that in place, it turns out LXDE creates a user-specific panel configuration file only when you change the default system panel configuration. Add a VLC launcher to create the local ~/.config/lxpanel/LXDE-pi/panels/panel file.

    With that ball rolled, then add the bCNC launcher:

    nano .config/lxpanel/LXDE-pi/panels/panel
    … add this stanza …
    Plugin {
      type=launchbar
      Config {
        Button {
          id=bCNC.desktop
        }
      }
    }

    Log out, log back in again, and the bCNC icon should appear:

    LXDE launcher icons - additions
    LXDE launcher icons – additions

    Click it and away you go:

    bCNC - Running from LXDE Launcher
    bCNC – Running from LXDE Launcher

    At least you (and I) will start closer to the goal when something else changes …

  • Drag Knife Cuttery: Entry & Exit Moves

    The first pass at cutting laminated decks for the Homage Tektronix Circuit Computer left little uncut snippets at the starting point of the cut. The point of the drag knife blade trundles along behind the cutting edge and, when the ending point equals the starting point, leaves an un-cut sliver as it’s retracted vertically:

    Drag Knife - LM12UU - knife blade detail
    Drag Knife – LM12UU – knife blade detail

    The knife blade isn’t aligned in any particular direction, so it can leave a nick on either side as it enters the deck vertically at the start of the cut.

    Gradually entering the deck along the cut line gives the blade enough time to swivel around to the proper alignment before it gets down to serious cutting. Continuing the final cut past the starting point then allows the blade to recut anything remaining from the entry move.

    The middle and top decks have windows exposing the scales:

    Tek CC - radial text example
    Tek CC – radial text example

    The paths are basically two arcs connected by semicircular cuts, but with ramps on each end recutting the entry and exit paths:

    Top Deck - Window Cut Path
    Top Deck – Window Cut Path

    The entry path in the upper left slants downward from the TravelZ level of 1.5 (-ish) mm to Z=0, with the nose of the blade holder flush against the surface and the blade sunk to its full length. The vertical path to Z=-2 (-ish) increases the cutting pressure from roughly the preload value to preload + 2*(spring rate), so the blade won’t ride up under the cutting forces.

    The path then goes completely around the window at Z=-2, then ramps up to the TravelZ level again.

    All of which produces a neat cutout that sticks to the Cricut mat when I peel the rest of the deck off:

    Tek CC - MPCNC drag knife
    Tek CC – MPCNC drag knife

    That’s a middle deck before I started laminating them, but you get the general idea.

    The GCMC code (extracted from the complete lump) looks like this:

      local WindowArc = 54deg;
    
      local ac = -17 * ScaleArc + ScaleRT/2;   // center of window arc
      local r0 = DeckRad - ScaleHeight;        // outer
      local r1 = DeckRad - 2 * ScaleHeight;    // inner
    
      local aw = WindowArc - to_deg(atan(ScaleHeight,(r0 + r1)/2));    // window arc minus endcaps
    
      p0 = r0 * [cos(ac + aw/2),sin(ac + aw/2),-];
      p1 = r0 * [cos(ac - aw/2),sin(ac - aw/2),-];
      local p2 = r1 * [cos(ac - aw/2),sin(ac - aw/2),-];
      local p3 = r1 * [cos(ac + aw/2),sin(ac + aw/2),-];
    
      goto(p3);
      arc_cw(p0 +| [-,-,0],ScaleHeight/2);    // blade enters surface
      move([-,-,KnifeZ]);                     // apply pressure
    
      arc_cw(p1,r0);                          // smallest arc
      arc_cw(p2,ScaleHeight/2);               // half a circle
      arc_ccw(p3,r1);
      arc_cw(p0,ScaleHeight/2);
    
      arc_cw(p1 +| [-,-,TravelZ],r0);         // exit from cut
    
      goto([0,0,-]);
      goto([-,-,SafeZ]);

    Having measured the angular position of the window and its size on the original Tek CC, I compute the coordinates of the four points where the semicircular “end caps” meet the longer arcs, then connect the dots with arc_xx() functions to generate the G-Code commands. As always, using the proper radius signs requires trial & error.

    While I was at it, I added entry & exit moves for the deck’s central pivot hole and outer perimeter.

    I’m pretty sure the right CAM package would take care of that, but GCMC operates well below the CAM level.

  • Google Pixel 3a Microscope Adapter

    Hand-holding my Google Pixel 3a phone over the microscope eyepiece worked well enough to justify building Yet Another Camera Adapter:

    Pixel 3a Microscope Adapter - in action
    Pixel 3a Microscope Adapter – in action

    The solid model looks about like you’d expect:

    Google Pixel 3a Zoom Microscope Mount - solid model - top
    Google Pixel 3a Zoom Microscope Mount – solid model – top

    The “camera” actually has the outside dimensions of a Spigen case, rather than the bare phone, because dropping a bare phone is never a good idea.

    The base plate pretty much fills the M2’s platform:

    Pixel 3a Microscope Adapter - M2 platform
    Pixel 3a Microscope Adapter – M2 platform

    I originally arranged the four corners around the plate to print everything in one go, but an estimated six hours of print time suggested doing the corners separately would maximize local happiness. Which it did, whew, even if the plate ran for a bit over 4-1/2 hours.

    The snout is a loose fit around the 5× widefield microscope eyepiece, with the difference made up in a wrap of black tape; it’s much easier to adjust the fit upward than to bore out the snout. An overwrap of tape secures the snout to the eyepiece, which I’ve dedicated to the cause; the scope normally rocks 10× widefield glass.

    The tapered hole exposes the phone’s fingerprint reader to simplify unlocking, should it shut down while I’m fiddling with something else.

    The microscope doesn’t fully illuminate the camera’s entrance pupil at minimum zoom, with 4.5× filling the screen and (mostly) eliminating the vignette. The corner blocks have oversize holes to allow aligning the camera lens axis over the microscope optical axis. The solid model incorporates Lessons Learned from the version you see here, because you (well, I) can’t measure the camera axis with respect to the outside dimensions accurately enough:

    Pixel 3a Microscope Adapter - installed - front
    Pixel 3a Microscope Adapter – installed – front

    Although it’s less unsteady than it looks, microscopy requires a gentle touch at the best of times. The adapter doesn’t add much wobble to the outcome:

    Pixel 3a Microscope Adapter - installed - side
    Pixel 3a Microscope Adapter – installed – side

    The field is about 14×19 mm with the camera at 4.5× and the microscope at minimum zoom:

    Pixel 3a Microscope Adapter - test image - min mag
    Pixel 3a Microscope Adapter – test image – min mag

    You can see a little darkening on the upper and lower right corners, so the phone’s still minutely leftward.

    The field is about 1.5×2 mm at full throttle:

    Pixel 3a Microscope Adapter - test image - max mag
    Pixel 3a Microscope Adapter – test image – max mag

    Color balance with the cold white LED ring isn’t the best, but it’s survivable. Mad props to OpenCamera for exposing All. The. Controls. you might possibly need.

    The OpenSCAD source code as a GitHub Gist:

    // Google Pixel 3a mount for stereo zoom microscope
    // Ed Nisley – KE4ZNU – 2019-12
    Layout = "Show"; // [Show,BuildAll,BuildBumpers,BuildPlate,DrillGuide,Phone,Plate,Bumper]
    /* [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
    Phone = [74.5,156.0,12.0]; // inside Spigen case
    PhoneRadii = [10.0,10.0,3.0]; // corner rounding, likewise
    LensOffset = [-17.0,-18.5,0]; // looking at phone screen, (-) sign = from right/top edge
    PrintReader = [0,Phone.y/2 – 44.0,0]; // fingerprint reader from center
    PrintReaderDia = [20.0,30.0,0]; // … hole for access
    Eyepiece = [11.5,28.0 + 0.50,27.0]; // ID = lens, OD includes clearance
    Insert = [3.0,4.5,4.0]; // M3 threaded brass insert
    Screw = [3.0,7.0,3.5]; // OD = washer, LENGTH = washer + head height
    WallThick = 3.0; // minimum wall thickness
    Bumper = [2*Screw[OD],20.0,Phone.z]; // bumper edge piece
    BumperOAL = Bumper.y + Bumper.x; // outside length for corner piece
    BumperRadius = 2.0;
    MinMargin = 1.2*Bumper.x; // at least this much extra plate for bumpers
    echo(str("MinMargin: ",MinMargin));
    Plate = [IntegerMultiple(Phone.x + 2*MinMargin,5.0),
    IntegerMultiple(Phone.y + 2*MinMargin,5.0),
    false ? 3*ThreadThick : max(Insert[LENGTH] + 2*ThreadThick,WallThick)];
    PlateRadius = 5.0;
    echo(str("Plate: ",Plate," radius: ",PlateRadius));
    EmbossDepth = 2*ThreadThick + Protrusion;
    DebossHeight = EmbossDepth;
    ScrewOffset = Bumper.x/2;
    ScrewAdjust = 1.5*Screw[ID];
    NumSides = 2*3*4;
    Gap = 2.0; // between build layout parts
    //———————-
    // 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);
    }
    // Basic shapes
    // Overall phone outline
    module Phone() {
    hull()
    for (i=[-1,1], j=[-1,1], k=[-1,1])
    translate([i*(Phone.x/2 – PhoneRadii.x),j*(Phone.y/2 – PhoneRadii.y),k*(Phone.z/2 – PhoneRadii.z)])
    resize(2*PhoneRadii)
    sphere(r=1,$fn=NumSides);
    }
    module Plate() {
    union() {
    difference() {
    union() {
    hull()
    for (i=[-1,1], j=[-1,1])
    translate([i*(Plate.x/2 – PlateRadius),j*(Plate.y/2 – PlateRadius),0])
    cylinder(r=PlateRadius,h=Plate.z,center=true,$fn=NumSides);
    translate([Phone.x/2,Phone.y/2,-Eyepiece[LENGTH]/3 + Plate.z/2] + LensOffset)
    cylinder(d=Eyepiece[OD] + 2*WallThick,h=Eyepiece[LENGTH]/3,
    center=false,$fn=NumSides);
    translate([Phone.x/2,Phone.y/2,-2*Eyepiece[LENGTH]/3 + Plate.z/2 + Protrusion] + LensOffset)
    cylinder(d1=Eyepiece[OD] + 10*ThreadThick,
    d2=Eyepiece[OD] + 2*WallThick,
    h=Eyepiece[LENGTH]/3,
    center=false,$fn=NumSides);
    }
    translate([Phone.x/2,Phone.y/2,-2*Eyepiece[LENGTH] + Plate.z/2 + Protrusion] + LensOffset)
    PolyCyl(Eyepiece[OD],2*Eyepiece[LENGTH],NumSides);
    translate(PrintReader + [0,0,-Plate.z/2 – Protrusion])
    cylinder(d1=PrintReaderDia[OD],d2=PrintReaderDia[ID],h=Plate.z + 2*Protrusion,$fn=NumSides);
    for (i=[-1,1], j=[-1,1])
    translate([i*(Phone.x/2 + Bumper.x/2),j*(Phone.y/2 – Bumper.y/2),-Plate.z])
    PolyCyl(Insert[OD],2*Plate.z,8);
    for (i=[-1,1], j=[-1,1])
    translate([i*(Phone.x/2 – Bumper.y/2),j*(Phone.y/2 + Bumper.x/2),-Plate.z])
    PolyCyl(Insert[OD],2*Plate.z,8);
    translate([0,-12,Plate.z/2]) // recess for legend
    cube([55,40,EmbossDepth],center=true);
    }
    translate([0,0,Plate.z/2 – EmbossDepth])
    linear_extrude(height=DebossHeight,convexity=20)
    text(text="Pixel 3a",size=6,spacing=1.20,
    font="Arial:style:Bold",halign="center",valign="center");
    translate([0,-15,Plate.z/2 – EmbossDepth])
    linear_extrude(height=DebossHeight,convexity=20)
    text(text="Ed Nisley",size=6,spacing=1.20,
    font="Arial:style:Bold",halign="center",valign="center");
    translate([0,-25,Plate.z/2 – EmbossDepth])
    linear_extrude(height=DebossHeight,convexity=20)
    text(text="softsolder.com",size=4,spacing=1.20,
    font="Arial:style:Bold",halign="center",valign="center");
    }
    }
    module BumperPiece() {
    difference() {
    translate([0,-BumperOAL/2 + Bumper.x,0])
    hull()
    for (i=[-1,1], j=[-1,1])
    translate([i*(Bumper.x/2 – BumperRadius),j*(BumperOAL/2 – BumperRadius),0])
    cylinder(r=BumperRadius,h=Bumper.z,center=true,$fn=NumSides);
    translate([0,-Bumper.y/2,-Bumper.z])
    PolyCyl(ScrewAdjust,2*Bumper.z,8);
    }
    }
    // Side bumpers, XY origin at inner corner
    module BumperCorner() {
    union() {
    translate([Bumper.x/2,0,0])
    BumperPiece();
    translate([0,Bumper.x/2,0])
    rotate(-90)
    BumperPiece();
    }
    }
    //- Build things
    if (Layout == "Phone")
    Phone();
    if (Layout == "Plate")
    Plate();
    if (Layout == "Bumper")
    BumperCorner();
    if (Layout == "Show") {
    color("LightBlue") Plate();
    for (i=[-1,1], j=[-1,1]) {
    a =
    i > 0 && j > 0 ? 0 :
    i < 0 && j > 0 ? 90 :
    i > 0 && j < 0 ? -90 :
    180
    ;
    translate([i*Phone.x/2,j*Phone.y/2,Plate.z/2 + Bumper.z/2])
    rotate(a)
    color("LightGreen") BumperCorner();
    translate([0,0,Phone.z/2 + Plate.z/2 + Protrusion])
    color("DarkGray",0.5) Phone();
    }
    }
    if (Layout == "BuildAll") {
    translate([0,0,Plate.z/2])
    rotate([0,180,0])
    Plate();
    for (i=[-1,1], j=[-1,1]) {
    a =
    i > 0 && j > 0 ? 0 :
    i < 0 && j > 0 ? 90 :
    i > 0 && j < 0 ? -90 :
    180
    ;
    translate([i*(Plate.x/2 + Gap),j*(Plate.y/2 + Gap),Bumper.z/2])
    rotate(a)
    BumperCorner();
    }
    }
    if (Layout == "BuildPlate") {
    translate([0,0,Plate.z/2])
    rotate([0,180,0])
    Plate();
    }
    if (Layout == "BuildBumpers") {
    for (i=[-1,1], j=[-1,1]) {
    a =
    i > 0 && j > 0 ? 180 :
    i < 0 && j > 0 ? -90 :
    i > 0 && j < 0 ? 90 :
    0
    ;
    translate([i*(Bumper.x + Gap),j*(Bumper.x + Gap),Bumper.z/2])
    rotate(a)
    BumperCorner();
    }
    }
    if (Layout == "DrillGuide") {
    projection(cut=true)
    Plate();
    }

  • Homage Tektronix Circuit Computer: Pen Plotter Version

    A reproduction circular slide rule from the mid-1960s may not be the cutting edge of consumer demand, but the pen version of a Tektronix Circuit Computer came out pretty well:

    Homage Tektronix Circuit Computer - green on white laminated
    Homage Tektronix Circuit Computer – green on white laminated

    A Bash script compiles the GCMC code with eight different parameter combinations to produce pairs of G-Code files to draw (“engrave” being aspirational) and cut (“mill”, likewise) the three decks and the cursor.

    The CNC 3018XL with a Pilot V5RT pen draws the deck scales on white paper:

    Pilot V5RT holder - installed
    Pilot V5RT holder – installed

    Better paper definitely produces better results, so I must rummage through the Big Box o’ Paper to see what lies within. Laminating the decks improves their durability and matches the original Tek surface finish.

    The MPCNC with a drag knife blade cuts through a laminated deck like butter:

    Tek CC - MPCNC drag knife
    Tek CC – MPCNC drag knife

    Setting the XY origin to dead center on each deck requires carefully calibrating the USB video camera, with the end result accurate to maybe ±0.1 mm around the entire perimeter. Both machines move equal linear distances along both axes, which was definitely comforting.

    Having made half a dozen cursors from various bits of acrylic, none of which look particularly good, demonstrates my engraving hand is too weak for a complete slide rule:

    Tek Circuit Computer - cursor hairline
    Tek Circuit Computer – cursor hairline

    With logarithmic scales in hand, however, adapting the GCMC source code to produce general-purpose circular slide rules with only two decks and smaller diameters may be the way to improve my engraving-fu, as a full-scale Tektronix Circuit Computer would chew up three square-foot plastic sheets.

    A general-purpose slide rule would need multi-color (well, at least bi-color) labels and digits for red “inverse” scales to remind you (well, me) they read backwards. Some slipsticks use left-slanting italics, left-pointing markers (“<2”), or other weirdness, but they’re all different.

    An early small-scale version engraved on ABS came out OK, modulo poor ink fill:

    Tek CC bottom - ABS 160g 2400mm-min
    Tek CC bottom – ABS 160g 2400mm-min

    Engraving the decks on hard drive platters doesn’t count:

    Tek CC - bottom deck - scaled to HD platter
    Tek CC – bottom deck – scaled to HD platter

    All in all, it’s been an interesting exercise and, as you may have guessed, will become a Digital Machinist column.

    The GCMC and Bash source code as a GitHub Gist:

    // Tektronix Circuit Computer Reproduction
    // Ed Nisley KE4ZNU – 2019-11
    //—–
    // Library routines
    include("tracepath.inc.gcmc");
    include("engrave.inc.gcmc");
    TekOD = to_mm(7.75in); // orginal Tek Circuit Computer diameter
    FALSE = 0;
    TRUE = 1;
    //—–
    // Command line parameters
    // -D various useful tidbits
    // add unit to speeds and depths: 2000mm / -3.00mm / etc
    if (!isdefined("BaseOD")) {
    BaseOD = TekOD;
    }
    comment("Base OD: ",BaseOD);
    SizeRatio = BaseOD / TekOD; // overall scaling for different base diameters
    comment(" scale factor: ",SizeRatio);
    if (!isdefined("SelectPart")) {
    SelectPart = "Bottom";
    }
    comment("Part: ",SelectPart);
    if (!isdefined("Operation")) {
    Operation = "Engrave";
    }
    comment("Operation: ",Operation);
    if (!isdefined("ScaleSpeed")) {
    ScaleSpeed = 2400mm;
    }
    if (!isdefined("TextSpeed")) {
    TextSpeed = 2400mm;
    }
    // Engraving & drag knife force is proportional to depth, but you must know the coefficent!
    if (!isdefined("EngraveZ")) {
    EngraveZ = -1.0mm;
    }
    if (!isdefined("KnifeZ")) {
    KnifeZ = -2.0mm;
    }
    if (!isdefined("KnifeSpeed")) {
    KnifeSpeed = 1000mm;
    }
    //—–
    // Define useful constants
    SafeZ = 10.00mm; // above all obstructions
    TravelZ = 1.00mm; // within engraving area
    //—–
    // Overall values
    ScaleHeight = to_inch(3.0/8.0) * SizeRatio; // scale-to-scale distance
    WindowHeight = ScaleHeight; // cutout window opening
    DeckBottomOD = BaseOD; // deck sizes depend on scale height
    DeckMiddleOD = DeckBottomOD – 2*ScaleHeight;
    DeckTopOD = DeckMiddleOD – 2*(ScaleHeight + WindowHeight);
    ScaleArc = 18deg; // angular length of one decade: +CCW
    ScaleExdent = 0.20; // log spacing at end of scales to identifiers
    Scale2Pi = log10(2*pi()) * ScaleArc; // angular offset for scales using 2*pi
    ScaleRT = log10(2.197225) * ScaleArc; // angular offset for risetime
    TauAngle = 150deg; // arbitrary offset to 1.0 on tau scales
    TitleAngle = -50deg; // … to Tek title, then +180deg to logo
    INWARD = -1; // text and tick alignment (used as integers)
    OUTWARD = 1;
    TEXT_LEFT = -1; // text justification
    TEXT_CENTERED = 0;
    TEXT_RIGHT = 1;
    TextFont = FONT_HSANS_1_RS;
    TitleTextSize = 3.1 * SizeRatio * [1.0mm,1.0mm];
    LegendTextSize = 1.8 * SizeRatio * [1.0mm,1.0mm];
    ScaleTextSize = 1.4 * SizeRatio * [1.0mm,1.0mm];
    //—-
    // Define tick layout for scales
    // Numeric values = scale position, tick length
    // These are not algorithmic!
    TickMajor = 3.2mm * SizeRatio; // length of tick marks
    TickMid = 1.9mm * SizeRatio;
    TickMinor = 1.2mm * SizeRatio;
    TickScaleNarrow = {
    [1.0,TickMajor],
    [1.1,TickMinor],[1.2,TickMinor],[1.3,TickMinor],[1.4,TickMinor],
    [1.5,TickMid],
    [1.6,TickMinor],[1.7,TickMinor],[1.8,TickMinor],[1.9,TickMinor],
    [2.0,TickMajor],
    [2.2,TickMinor],[2.4,TickMinor],[2.6,TickMinor],[2.8,TickMinor],
    [3.0,TickMajor],
    [3.2,TickMinor],[3.4,TickMinor],[3.6,TickMinor],[3.8,TickMinor],
    [4.0,TickMajor],
    [4.5,TickMinor],
    [5.0,TickMajor],
    [5.5,TickMinor],
    [6.0,TickMajor],
    [6.5,TickMinor],
    [7.0,TickMajor],
    [7.5,TickMinor],
    [8.0,TickMajor],
    [8.5,TickMinor],
    [9.0,TickMajor],
    [9.5,TickMinor]
    };
    TickScaleWide = {
    [1.0,TickMajor],
    [1.1,TickMinor],[1.2,TickMinor],[1.3,TickMinor],[1.4,TickMinor],
    [1.5,TickMid],
    [1.6,TickMinor],[1.7,TickMinor],[1.8,TickMinor],[1.9,TickMinor],
    [2.0,TickMajor],
    [2.1,TickMinor],[2.2,TickMinor],[2.3,TickMinor],[2.4,TickMinor],
    [2.5,TickMid],
    [2.6,TickMinor],[2.7,TickMinor],[2.8,TickMinor],[2.9,TickMinor],
    [3.0,TickMajor],
    [3.2,TickMinor],[3.4,TickMinor],[3.6,TickMinor],[3.8,TickMinor],
    [4.0,TickMajor],
    [4.2,TickMinor],[4.4,TickMinor],[4.6,TickMinor],[4.8,TickMinor],
    [5.0,TickMajor],
    [5.5,TickMinor],
    [6.0,TickMajor],
    [6.5,TickMinor],
    [7.0,TickMajor],
    [7.5,TickMinor],
    [8.0,TickMajor],
    [8.5,TickMinor],
    [9.0,TickMajor],
    [9.5,TickMinor]
    };
    TickLabels = [1,2,5]; // labels only these ticks, must be integers
    TickGap = 0.50 * ScaleTextSize.y; // gap between text and ticks
    PivotOD = 5.0mm; // center bolt OD
    Legend1 = "Ed Nisley – KE4ZNU";
    Legend2 = "softsolder.com";
    //—————————————————————————–
    // Text & Scale Engraving
    //—–
    // Write text on a radial line
    function RadialText(TextPath,CenterPt,Radius,Angle,Justify,Orient) {
    local pl = TextPath[-1].x; // path length
    local ji = (Justify == TEXT_LEFT) ? 0mm : // justification, assume OUTWARD
    (Justify == TEXT_CENTERED) ? -pl/2 :
    (Justify == TEXT_RIGHT) ? -pl :
    0mm;
    if (Orient == INWARD) {
    TextPath = rotate_xy(TextPath,180deg);
    ji = -ji;
    }
    TextPath += [Radius + ji,0mm];
    return rotate_xy(TextPath,Angle) + CenterPt;
    }
    //—–
    // Draw a radial legend
    // Offset in units of char height: 0 = baseline on radius, +/- = above/below
    function RadialLegend(Text,Center,Radius,Angle,Justify,Orient,Offset) {
    local tp = scale(typeset(Text,TextFont),LegendTextSize) + [0mm,Offset * LegendTextSize.y];
    local tpr = RadialText(tp,Center,Radius,Angle,Justify,Orient);
    feedrate(TextSpeed);
    engrave(tpr,TravelZ,EngraveZ);
    }
    //—–
    // Bend text around an arc
    function ArcText(TextPath,CenterPt,Radius,BaseAngle,Justify,Orient) {
    local pl = TextPath[-1].x; // path length
    local c = 2*pi()*Radius;
    local ta = to_deg(360 * pl / c); // subtended angle
    local ja = (Justify == TEXT_LEFT ? 0deg : // assume OUTWARD
    (Justify == TEXT_CENTERED) ? -ta / 2 :
    (Justify == TEXT_RIGHT) ? -ta :
    0deg);
    ja = BaseAngle + Orient*ja;
    local ArcPath = {};
    local pt,r,a;
    foreach(TextPath; pt) {
    if (!isundef(pt.x) && !isundef(pt.y) && isundef(pt.z)) { // XY motion, no Z
    r = (Orient == OUTWARD) ? Radius – pt.y : Radius + pt.y;
    a = Orient * 360deg * (pt.x / c) + ja;
    ArcPath += {[r*cos(a) + CenterPt.x, r*sin(a) + CenterPt.y,-]};
    }
    elif (isundef(pt.x) && isundef(pt.y) && !isundef(pt.z)) { // no XY, Z up/down
    ArcPath += {pt};
    }
    else {
    error("ArcText – Point is not pure XY or pure Z: " + to_string(pt));
    }
    }
    return ArcPath;
    }
    //—–
    // Draw scale legend
    function ArcLegend(Text,Radius,Angle,Orient) {
    local tp = scale(typeset(Text,TextFont),LegendTextSize);
    local tpa = ArcText(tp,[0mm,0mm],Radius,Angle,TEXT_CENTERED,Orient);
    feedrate(TextSpeed);
    engrave(tpa,TravelZ,EngraveZ);
    }
    //—–
    // Draw a decade of ticks & labels
    // ArcLength > 0 = CCW, < 0 = CW
    // UnitOnly forces just the unit tick, so as to allow creating the last tick of the scale
    function DrawTicks(Radius,TickMarks,TickOrient,UnitAngle,ArcLength,Decade,LabelOrient,UnitOnly) {
    feedrate(ScaleSpeed);
    local a,r0,r1,p0,p1;
    if (Decade == 1 || UnitOnly) { // draw unit marker
    a = UnitAngle;
    r0 = Radius + TickOrient * (TickMajor + 2*TickGap + ScaleTextSize.y);
    p0 = r0 * [cos(a),sin(a)];
    r1 = Radius + TickOrient * (ScaleHeight – 2*TickGap);
    p1 = r1 * [cos(a),sin(a)];
    goto(p0);
    move([-,-,EngraveZ]);
    move(p1);
    goto([-,-,TravelZ]);
    }
    local ticklist = UnitOnly ? {TickMarks[0]} : TickMarks;
    local tick;
    foreach(ticklist; tick) {
    a = UnitAngle + ArcLength * log10(tick[0]);
    p0 = Radius * [cos(a), sin(a)];
    p1 = (Radius + TickOrient*tick[1]) * [cos(a), sin(a)];
    goto(p0);
    move([-,-,EngraveZ]);
    move(p1);
    goto([-,-,TravelZ]);
    }
    feedrate(TextSpeed); // draw scale values
    local lrad = Radius + TickOrient * (TickMajor + TickGap);
    if (TickOrient == INWARD) {
    if (LabelOrient == INWARD) {
    lrad -= ScaleTextSize.y; // inward ticks + inward labels = offset inward
    }
    }
    else {
    if (LabelOrient == OUTWARD) {
    lrad += ScaleTextSize.y; // outward ticks + outward labels = offset outward
    }
    }
    ticklist = UnitOnly ? [TickLabels[0]] : TickLabels;
    local ltext,lpath,tpa;
    foreach(ticklist; tick) {
    ltext = to_string(Decade * to_int(tick));
    lpath = scale(typeset(ltext,TextFont),ScaleTextSize);
    a = UnitAngle + ArcLength * log10(tick);
    tpa = ArcText(lpath,[0mm,0mm],lrad,a,TEXT_CENTERED,LabelOrient);
    engrave(tpa,TravelZ,EngraveZ);
    }
    }
    //—–
    // Mark key locations
    function MarkPivot() {
    comment("Mark center point");
    feedrate(ScaleSpeed);
    if (TRUE) {
    goto([-,-,SafeZ]);
    goto([PivotOD/2,0,-]);
    move([-,-,EngraveZ]);
    circle_cw([0,0]); // outline pivot
    move([-PivotOD/2,0,-]); // draw X line
    goto([-,-,TravelZ]);
    goto([0,PivotOD/2,-]);
    move([-,-,EngraveZ]);
    move ([0,-PivotOD/2,-]); // draw Y line
    goto([-,-,TravelZ]);
    }
    }
    //—–
    // Draw attribution
    function DrawAttribution(AttribRad) {
    comment("Attribution at: ",AttribRad);
    feedrate(TextSpeed);
    local tp,tpa;
    if (Legend1) {
    tp = scale(typeset(Legend1,TextFont),TitleTextSize);
    tpa = ArcText(tp,[0mm,0mm],AttribRad,0deg,TEXT_CENTERED,OUTWARD);
    feedrate(TextSpeed);
    engrave(tpa,TravelZ,EngraveZ);
    }
    if (Legend2) {
    tp = scale(typeset(Legend2,TextFont),TitleTextSize);
    tpa = ArcText(tp,[0mm,0mm],AttribRad,180deg,TEXT_CENTERED,OUTWARD);
    feedrate(TextSpeed);
    engrave(tpa,TravelZ,EngraveZ);
    }
    if (FALSE) { // test code to verify ArcText
    comment("ArcText test");
    ctr = [0mm,0mm];
    tp = scale(typeset("Right Inward",TextFont),ScaleTextSize);
    tpa = ArcText(tp,ctr,30mm,45deg,TEXT_RIGHT,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    tp = scale(typeset("Right Outward",TextFont),ScaleTextSize);
    tpa = ArcText(tp,ctr,30mm,45deg,TEXT_RIGHT,OUTWARD);
    engrave(tpa,TravelZ,EngraveZ);
    tp = scale(typeset("Center Inward",TextFont),ScaleTextSize);
    tpa = ArcText(tp,ctr,20mm,45deg,TEXT_CENTERED,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    tp = scale(typeset("Center Outward",TextFont),ScaleTextSize);
    tpa = ArcText(tp,ctr,20mm,45deg,TEXT_CENTERED,OUTWARD);
    engrave(tpa,TravelZ,EngraveZ);
    tp = scale(typeset("Left Inward",TextFont),ScaleTextSize);
    tpa = ArcText(tp,ctr,10mm,45deg,TEXT_LEFT,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    tp = scale(typeset("Left Outward",TextFont),ScaleTextSize);
    tpa = ArcText(tp,ctr,10mm,45deg,TEXT_LEFT,OUTWARD);
    engrave(tpa,TravelZ,EngraveZ);
    goto([0mm,0mm,-]);
    move([40mm,40mm,-]);
    }
    if (FALSE) { // test code to verify RadialText
    comment("RadialText test");
    ctr = [0mm,0mm];
    r = 20mm;
    a = 0deg;
    tp = scale(typeset("Left Inward",TextFont),LegendTextSize);
    tpr = RadialText(tp,ctr,r,a,TEXT_LEFT,INWARD);
    feedrate(TextSpeed);
    engrave(tpr,TravelZ,EngraveZ);
    tp = scale(typeset("Left Outward",TextFont),LegendTextSize);
    tpr = RadialText(tp,ctr,r,a,TEXT_LEFT,OUTWARD);
    feedrate(TextSpeed);
    engrave(tpr,TravelZ,EngraveZ);
    a = 90deg;
    tp = scale(typeset("Right Inward",TextFont),LegendTextSize);
    tpr = RadialText(tp,ctr,r,a,TEXT_RIGHT,INWARD);
    feedrate(TextSpeed);
    engrave(tpr,TravelZ,EngraveZ);
    tp = scale(typeset("Right Outward",TextFont),LegendTextSize);
    tpr = RadialText(tp,ctr,r,a,TEXT_RIGHT,OUTWARD);
    feedrate(TextSpeed);
    engrave(tpr,TravelZ,EngraveZ);
    a = 180deg;
    tp = scale(typeset("Center Inward",TextFont),LegendTextSize);
    tpr = RadialText(tp,ctr,r,a,TEXT_CENTERED,INWARD);
    feedrate(TextSpeed);
    engrave(tpr,TravelZ,EngraveZ);
    tp = scale(typeset("Center Outward",TextFont),LegendTextSize);
    tpr = RadialText(tp,ctr,r,a,TEXT_CENTERED,OUTWARD);
    feedrate(TextSpeed);
    engrave(tpr,TravelZ,EngraveZ);
    a = 270deg;
    RadialLegend("Offset to radius",ctr,r,a,TEXT_CENTERED,INWARD,-0.5);
    goto(ctr);
    move([0,-2*r,EngraveZ]);
    goto([r,0mm,-]);
    circle_cw(ctr);
    }
    }
    //—————————————————————————–
    // Deck Engraving
    //———-
    // Engrave bottom deck
    function EngraveBottom() {
    // Mark center pivot
    MarkPivot();
    comment("Inductance scale");
    Radius = DeckRad – ScaleHeight;
    MinLog = -9;
    MaxLog = 6;
    Arc = -ScaleArc;
    dec = 1;
    offset = 0deg;
    for (logval = MinLog; logval < MaxLog; logval++) {
    a = offset + logval * Arc;
    DrawTicks(Radius,TickScaleNarrow,OUTWARD,a,Arc,dec,INWARD,FALSE);
    dec = (dec == 100) ? 1 : 10 * dec;
    }
    a = offset + MaxLog * Arc;
    DrawTicks(Radius,TickScaleNarrow,OUTWARD,a,Arc,1000,INWARD,TRUE);
    r = Radius + TickMajor + 2*TickGap + LegendTextSize.y;
    logval = MinLog + 1.5;
    a = offset + logval * Arc;
    ArcLegend("nH – nanohenry x10^-9",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("μH – microhenry x10^-6",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("mH – millihenry x10^-3",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("H – henry",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("kH – kilohenry x10^3",r,a,INWARD);
    r = Radius + TickMajor + TickGap;
    logval = MinLog – ScaleExdent; // scale identifiers
    a = offset + logval * Arc;
    tp = scale(typeset("L Scale →",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_RIGHT,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    logval = MaxLog + ScaleExdent;
    a = offset + logval * Arc;
    tp = scale(typeset("← L Scale",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_LEFT,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    comment("Inductive frequency scale");
    Radius = DeckRad – 2*ScaleHeight;
    MinLog = 0;
    MaxLog = 9;
    Arc = 2*ScaleArc; // double-length scale for square roots
    dec = 1;
    offset = -(18 * ScaleArc – Scale2Pi); // using 18 degree arc length
    for (logval = MinLog; logval < MaxLog; logval++) {
    a = offset + logval * Arc;
    DrawTicks(Radius,TickScaleWide,OUTWARD,a,Arc,dec,OUTWARD,FALSE);
    dec = (dec == 100) ? 1 : 10 * dec;
    }
    a = offset + MaxLog * Arc;
    DrawTicks(Radius,TickScaleWide,OUTWARD,a,Arc,1000,OUTWARD,TRUE);
    feedrate(TextSpeed); // draw prefix legends
    r = Radius + TickMajor + 2*TickGap + 2*LegendTextSize.y;
    logval = MinLog + 0.5;
    for (i = 0; i < 3; i++) {
    a = offset + (i + logval) * Arc;
    ArcLegend("Hz – hertz",r,a,OUTWARD);
    }
    for (i = 3; i < 6; i++) {
    a = offset + (i + logval) * Arc;
    ArcLegend("kHz – kilohertz x10^3",r,a,OUTWARD);
    }
    for (i = 6; i < 9; i++) {
    a = offset + (i + logval) * Arc;
    ArcLegend("MHz – megahertz x10^6",r,a,OUTWARD);
    }
    r = Radius + TickMajor + TickGap + LegendTextSize.y;
    logval = MinLog – 0.5; // scale identifier
    a = offset + logval * Arc;
    ArcLegend("←——- FL Scale ——-→",r,a,OUTWARD);
    comment("Inductive TC / Risetime scale");
    Radius = DeckRad – 3*ScaleHeight;
    MinLog = -12;
    MaxLog = 3;
    Arc = -ScaleArc;
    dec = 1;
    offset = -TauAngle;
    for (logval = MinLog; logval < MaxLog; logval++) {
    a = offset + logval * Arc;
    DrawTicks(Radius,TickScaleNarrow,OUTWARD,a,Arc,dec,INWARD,FALSE);
    dec = (dec == 100) ? 1 : 10 * dec;
    }
    a = offset + MaxLog * Arc;
    DrawTicks(Radius,TickScaleNarrow,OUTWARD,a,Arc,1000,INWARD,TRUE);
    feedrate(TextSpeed); // prefix legends
    r = Radius + TickMajor + 2*TickGap + LegendTextSize.y;
    logval = MinLog + 1.5;
    a = offset + logval * Arc;
    ArcLegend("ps – picosecond x10^-12",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("ns – nanosecond x10^-9",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("μs – microsecond x10^-6",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("ms – millisecond x10^-3",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("s – second",r,a,INWARD);
    r = Radius + TickMajor + TickGap;
    logval = MinLog – ScaleExdent; // scale identifiers
    a = offset + logval * Arc;
    tp = scale(typeset("τL Scale →",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_RIGHT,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    logval = MaxLog + ScaleExdent;
    a = offset + logval * Arc;
    tp = scale(typeset("← τL Scale",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_LEFT,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    //—–
    // Add construction notes
    comment("Attribution begins");
    r = DeckTopOD/2 – 2*ScaleHeight – WindowHeight;
    DrawAttribution(r);
    if (FALSE) {
    t = "Disk OD: " + to_string(DeckBottomOD) + " " +
    to_string(DeckMiddleOD) + " " +
    to_string(DeckTopOD) + " mm";
    tp = scale(typeset(t,TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,90deg,TEXT_CENTERED,OUTWARD);
    engrave(tpa,TravelZ,EngraveZ);
    }
    goto([-,-,SafeZ]); // done, so get out of the way
    goto([0,0,-]);
    comment("Bottom deck ends");
    }
    //———-
    // Engrave middle deck
    function EngraveMiddle() {
    // Mark center pivot
    MarkPivot();
    comment("Capacitance scale");
    Radius = DeckRad;
    MinLog = -15;
    MaxLog = 0;
    Arc = ScaleArc;
    dec = 1;
    offset = 0deg;
    for (logval = MinLog; logval < MaxLog; logval++) {
    a = offset + logval * Arc;
    DrawTicks(Radius,TickScaleNarrow,INWARD,a,Arc,dec,INWARD,FALSE);
    dec = (dec == 100) ? 1 : 10 * dec;
    }
    a = offset + MaxLog * Arc;
    DrawTicks(Radius,TickScaleNarrow,INWARD,a,Arc,1000,INWARD,TRUE);
    r = Radius – ScaleHeight + TickGap;
    logval = MinLog + 1.5;
    a = offset + logval * Arc;
    ArcLegend("fF – femtofarad x10^-15",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("pF – picofarad x10^-12",r,a,INWARD);
    logval += 2.5; // offset for L/R window;
    a = offset + logval * Arc;
    ArcLegend("nF – nanofarad x10^-9",r,a,INWARD);
    logval += 4; // … likewise
    a = offset + logval * Arc;
    ArcLegend("μF – microfarad x10^-6",r,a,INWARD);
    logval += 2.5; // … restore normal spacing
    a = offset + logval * Arc;
    ArcLegend("mF – millifarad x10^-3",r,a,INWARD);
    r = Radius – ScaleHeight – TickGap – LegendTextSize.y; // into blank space
    logval = MinLog; // scale identifiers
    a = offset + logval * Arc;
    tp = scale(typeset("←— C Scale",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_RIGHT,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    logval = MinLog + 6;
    a = offset + logval * Arc;
    tp = scale(typeset("←— C Scale —→",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_CENTERED,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    logval = MaxLog;
    a = offset + logval * Arc;
    tp = scale(typeset("C Scale —→",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_LEFT,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    comment("Capacitive TC / risetime scale");
    Radius = DeckRad – 4*ScaleHeight;
    MinLog = -12;
    MaxLog = 3;
    Arc = ScaleArc;
    dec = 1;
    offset = 3 * ScaleArc;
    for (logval = MinLog; logval < MaxLog; logval++) {
    a = offset + logval * Arc;
    DrawTicks(Radius,TickScaleNarrow,OUTWARD,a,Arc,dec,INWARD,FALSE);
    dec = (dec == 100) ? 1 : 10 * dec;
    }
    a = offset + MaxLog * Arc;
    DrawTicks(Radius,TickScaleNarrow,OUTWARD,a,Arc,1000,INWARD,TRUE);
    r = Radius + TickMajor + 2*TickGap + LegendTextSize.y;
    logval = MinLog + 1.5;
    a = offset + logval * Arc;
    ArcLegend("ps – picosecond x10^-12",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("ns – nanosecond x10^-9",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("μs – microsecond x10^-6",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("ms – millisecond x10^-3",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("s – second",r,a,INWARD);
    r = Radius + TickMajor + TickGap;
    logval = MinLog – ScaleExdent; // scale identifiers
    a = offset + logval * Arc;
    tp = scale(typeset("← τC Scale",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_LEFT,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    logval = MaxLog + ScaleExdent;
    a = offset + logval * Arc;
    tp = scale(typeset("τC Scale →",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_RIGHT,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    logval = MinLog – 2.5;
    a = offset + logval * Arc;
    tp = scale(typeset("←— τC Scale —→",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_CENTERED,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    comment("Inductive frequency scale legend");
    r = DeckRad – ScaleHeight – ScaleTextSize.y;
    a = 58deg; // arbitrary text placement
    ArcLegend("FL Scale",r,a,OUTWARD);
    comment("Index for resonance calculations");
    Index = -(18*ScaleArc + Scale2Pi); // negative to read reciprocal of product
    r = DeckRad – 1.5*ScaleHeight + 0.5*LegendTextSize.y;
    ArcLegend("Frequency",r,Index,OUTWARD);
    r = DeckRad – ScaleHeight – LegendTextSize.y;
    ArcLegend("⇑",(r – TickGap),Index,INWARD);
    r = DeckRad – 2*ScaleHeight + LegendTextSize.y;
    ArcLegend("⇑",(r + TickGap),Index,OUTWARD);
    r0 = DeckRad – ScaleHeight;
    r1 = r0 – TickMajor;
    goto(r0 * [cos(Index),sin(Index)]);
    move([-,-,EngraveZ]);
    move(r1 * [cos(Index),sin(Index)]);
    goto([-,-,TravelZ]);
    r0 = DeckRad – 2*ScaleHeight;
    r1 = r0 + TickMajor;
    goto(r0 * [cos(Index),sin(Index)]);
    move([-,-,EngraveZ]);
    move(r1 * [cos(Index),sin(Index)]);
    goto([-,-,TravelZ]);
    //—–
    // Draw the attribution
    comment("Attribution begins");
    r = DeckTopOD/2 – 2*ScaleHeight – WindowHeight;
    DrawAttribution(r);
    goto([-,-,SafeZ]); // done, so get out of the way
    goto([0,0,-]);
    comment("Middle deck ends");
    }
    //———-
    // Engrave top deck
    function EngraveTop() {
    // Mark center pivot
    MarkPivot();
    comment("Resistance scale");
    Radius = DeckRad;
    MinLog = -1;
    MaxLog = 8;
    Arc = -ScaleArc;
    dec = 100;
    offset = 0deg;
    for (logval = MinLog; logval < MaxLog; logval++) {
    a = offset + logval * Arc;
    DrawTicks(Radius,TickScaleNarrow,INWARD,a,Arc,dec,INWARD,FALSE);
    dec = (dec == 100) ? 1 : 10 * dec;
    }
    a = offset + MaxLog * Arc;
    DrawTicks(Radius,TickScaleNarrow,INWARD,a,Arc,100,INWARD,TRUE);
    r = Radius – ScaleHeight + TickGap;
    logval = MinLog + 0.5;
    a = offset + logval * Arc;
    ArcLegend("mΩ – milliohm",r,a,INWARD);
    logval = MinLog + 2.5;
    a = offset + logval * Arc;
    ArcLegend("Ω – ohm",r,a,INWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("kΩ – kilohm x10^3",r,a,INWARD);
    logval = MaxLog – 1;
    a = offset + logval * Arc;
    ArcLegend("MΩ – megohm x10^6",r,a,INWARD);
    r = Radius – ScaleHeight – TickGap – LegendTextSize.y;
    logval = MinLog + 4;
    a = offset + logval * Arc;
    ArcLegend("←— R XC XL Scale —→",r,a,INWARD);
    comment("Capacitive frequency scale");
    Radius = DeckRad;
    MinLog = 0;
    MaxLog = 9;
    Arc = ScaleArc;
    dec = 1;
    offset = 18 * -ScaleArc;
    for (logval = MinLog; logval < MaxLog; logval++) {
    a = offset + logval * Arc;
    DrawTicks(Radius,TickScaleNarrow,INWARD,a,Arc,dec,OUTWARD,FALSE);
    dec = (dec == 100) ? 1 : 10 * dec;
    }
    a = offset + MaxLog * Arc;
    DrawTicks(Radius,TickScaleNarrow,INWARD,a,Arc,1000,OUTWARD,TRUE);
    r = Radius – (TickMajor + 2*TickGap + LegendTextSize.y);
    logval = MinLog + 1.5;
    a = offset + logval * Arc;
    ArcLegend("Hz – hertz",r,a,OUTWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("kHz – kilohertz x10^3",r,a,OUTWARD);
    logval += 3;
    a = offset + logval * Arc;
    ArcLegend("MHz – megahertz x10^6",r,a,OUTWARD);
    r = Radius – ScaleHeight – TickGap – LegendTextSize.y;
    logval = MaxLog – 3;
    a = offset + logval * Arc;
    ArcLegend("←— FC Scale —→",r,a,OUTWARD);
    comment("RC Circuit Pointers");
    local ctr = [0mm,0mm];
    r0 = DeckRad – 2*ScaleHeight;
    r1 = r0 – ScaleHeight;
    a = -(17 * ScaleArc);
    goto(r0 * [cos(a),sin(a)]);
    move([-,-,EngraveZ]);
    move(r1 * [cos(a),sin(a)]);
    goto([-,-,TravelZ]);
    ArcLegend("⇓",(r0 – TickGap),a,OUTWARD);
    RadialLegend(" Time Constant",ctr,r1,a,TEXT_LEFT,INWARD,-0.5);
    a += ScaleRT;
    goto(r0 * [cos(a),sin(a)]);
    move([-,-,EngraveZ]);
    move(r1 * [cos(a),sin(a)]);
    goto([-,-,TravelZ]);
    ArcLegend("⇓",(r0 – TickGap),a,OUTWARD);
    RadialLegend(" Risetime",ctr,r1,a,TEXT_LEFT,INWARD,-0.5);
    a -= ScaleRT/2;
    RadialLegend(" RC",ctr,r0 – 2*ScaleTextSize.y,a,TEXT_LEFT,INWARD,-0.5);
    comment("L/R Circuit Pointers");
    r0 = DeckRad;
    r1 = r0 – ScaleHeight;
    a = -TauAngle;
    goto(r0 * [cos(a),sin(a)]);
    move([-,-,EngraveZ]);
    move(r1 * [cos(a),sin(a)]);
    goto([-,-,TravelZ]);
    ArcLegend("⇓",(r0 – TickGap),a,OUTWARD);
    RadialLegend("Time Constant ",ctr,r1,a,TEXT_RIGHT,OUTWARD,-0.5);
    a -= ScaleRT;
    goto(r0 * [cos(a),sin(a)]);
    move([-,-,EngraveZ]);
    move(r1 * [cos(a),sin(a)]);
    goto([-,-,TravelZ]);
    ArcLegend("⇓",(r0 – TickGap),a,OUTWARD);
    RadialLegend("Risetime ",ctr,r1,a,TEXT_RIGHT,OUTWARD,-0.5);
    a += ScaleRT/2;
    RadialLegend("L/R ",ctr,r0 – 2*ScaleTextSize.y,a,TEXT_RIGHT,OUTWARD,-0.5);
    comment("Title and logo");
    feedrate(TextSpeed);
    r = 0.65*DeckRad;
    tp = scale(typeset("Homage",TextFont),TitleTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,TitleAngle,TEXT_CENTERED,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    r -= 1.5*TitleTextSize.y;
    tp = scale(typeset("Tektronix",TextFont),TitleTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,TitleAngle,TEXT_CENTERED,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    r -= 1.5*TitleTextSize.y;
    tp = scale(typeset("Circuit Computer",TextFont),TitleTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,TitleAngle,TEXT_CENTERED,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    r -= 1.5*TitleTextSize.y;
    if (TRUE) {
    tp = scale(typeset("TEK 003-023",TextFont),LegendTextSize);
    }
    else {
    tp = scale(typeset("https://vintagetek.org/tektronix-circuit-computer/&quot;,TextFont),LegendTextSize);
    }
    tpa = ArcText(tp,[0mm,0mm],r,TitleAngle,TEXT_CENTERED,INWARD);
    engrave(tpa,TravelZ,EngraveZ);
    r = 0.3*DeckRad;
    a = TitleAngle + 180deg;
    tp = scale(typeset("Ed Nisley",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_CENTERED,OUTWARD);
    engrave(tpa,TravelZ,EngraveZ);
    r += 1.5*TitleTextSize.y;
    tp = scale(typeset("KE4ZNU",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_CENTERED,OUTWARD);
    engrave(tpa,TravelZ,EngraveZ);
    r += 1.5*TitleTextSize.y;
    tp = scale(typeset("softsolder.com",TextFont),LegendTextSize);
    tpa = ArcText(tp,[0mm,0mm],r,a,TEXT_CENTERED,OUTWARD);
    engrave(tpa,TravelZ,EngraveZ);
    goto([-,-,SafeZ]); // done, so get out of the way
    goto([0,0,-]);
    comment("Top deck ends");
    }
    //———-
    // Engrave cursor hairline
    function EngraveCursor() {
    // Mark center pivot
    MarkPivot();
    comment("Cursor hairline");
    feedrate(ScaleSpeed);
    goto([-,-,TravelZ]);
    repeat(2) {
    goto([DeckTopOD/2 – 2.25*ScaleHeight,0,-]); // slight overlap on arrows
    move([-,-,EngraveZ]);
    move([DeckBottomOD/2 + ScaleHeight,0,-]);
    goto([-,-,TravelZ]);
    }
    goto([-,-,SafeZ]); // done, so get out of the way
    goto([0,0,-]);
    }
    //—————————————————————————–
    // Deck milling
    // Assumes adhesive clamping to avoid protrusions above work area
    //—–
    // Bottom deck
    function MillBottom() {
    comment("Mill Bottom");
    feedrate(KnifeSpeed);
    goto([-,-,TravelZ]);
    local r = PivotOD/2;
    goto([0,r,-]); // entry move to align knife
    arc_cw([r,0,0],r); // blade enters surface
    move([-,-,KnifeZ]); // apply cutting force
    circle_cw([0,0]);
    arc_cw([0,-r],r); // cut past entry point
    goto([-,-,TravelZ]);
    r = DeckRad;
    local a = 5deg;
    local p0 = r * [cos(a),sin(a),-]; // entry point
    local p1 = r * [cos(-a),sin(-a),-]; // exit point
    goto(p0);
    arc_cw([r,0,0],r); // blade enters surface
    move([-,-,KnifeZ]); // apply cutting force
    circle_cw([0,0]); // cut circle
    arc_cw(p1,r); // cut past entry point
    goto([-,-,TravelZ]);
    goto([0,0,-]);
    goto([-,-,SafeZ]);
    }
    //—–
    // Middle deck
    function MillMiddle() {
    FLNotchArc = 85deg; // width exposing FL scale
    FLRampArc = 7deg; // … width of entry & exit ramps
    FLNotchOffset = 2deg; // … start angle from 0°
    comment("Mill Middle");
    feedrate(KnifeSpeed);
    goto([-,-,TravelZ]);
    local r = PivotOD/2;
    goto([0,r,-]); // entry move to align knife
    arc_cw([r,0,0],r); // blade enters surface
    move([-,-,KnifeZ]); // apply cutting force
    circle_cw([0,0]);
    arc_cw([0,-r],r); // cut past entry point
    goto([-,-,TravelZ]);
    // FL scale notch
    local r0 = DeckRad;
    local a0 = FLNotchOffset; // end of notch ramp
    local p0 = r0 * [cos(a0),sin(a0),-];
    local a1 = a0 + FLNotchArc; // start of notch ramp
    local p1 = r0 * [cos(a1),sin(a1),-];
    goto(p0);
    arc_cw([r0,0,0],r0); // blade enters surface
    move([-,-,KnifeZ]); // apply cutting force
    arc_cw(p1,-r0); // largest arc to start of notch
    local r1 = r0 – ScaleHeight;
    local a3 = a1 – FLRampArc; // start of notch base
    local p3 = r1 * [cos(a3),sin(a3),-];
    local a4 = a0 + FLRampArc; // end of notch base
    local p4 = r1 * [cos(a4),sin(a4),-];
    move(p3);
    arc_cw(p4,r1); // smallest arc on notch base
    move(p0); // end of notch ramp
    arc_cw([r0,0,-],r0); // round off corner
    local p5 = r0 * [cos(-a0),sin(-a0),-]; // small overtravel past entry point
    arc_cw(p5,r0);
    goto([-,-,TravelZ]);
    // L/R τ and RT Scale window
    local WindowArc = 39deg;
    ac = -6 * ScaleArc; // center of window arc
    r0 = DeckRad – ScaleHeight; // outer
    r1 = DeckRad – 2 * ScaleHeight; // inner
    aw = WindowArc – to_deg(atan(ScaleHeight,(r0 + r1)/2)); // window arc minus endcaps
    p0 = r0 * [cos(ac + aw/2),sin(ac + aw/2),-]; // endcap entry & exit
    p1 = r0 * [cos(ac – aw/2),sin(ac – aw/2),-];
    p2 = r1 * [cos(ac – aw/2),sin(ac – aw/2),-];
    p3 = r1 * [cos(ac + aw/2),sin(ac + aw/2),-];
    goto(p3); // cut entry point
    arc_cw(p0 +| [-,-,0],ScaleHeight/2); // blade enters surface
    move([-,-,KnifeZ]); // apply pressure
    arc_cw(p1,r0); // smallest arc
    arc_cw(p2,ScaleHeight/2); // half a circle
    arc_ccw(p3,r1);
    arc_cw(p0,ScaleHeight/2);
    arc_cw(p1 +| [-,-,TravelZ],r0); // exit from cut
    goto([0,0,-]);
    goto([-,-,SafeZ]);
    }
    //—–
    // Top deck
    function MillTop() {
    comment("Mill Top");
    feedrate(KnifeSpeed);
    goto([-,-,TravelZ]);
    local r = PivotOD/2;
    goto([0,r,-]); // entry move to align knife
    arc_cw([r,0,0],r); // blade enters surface
    move([-,-,KnifeZ]); // apply cutting force
    circle_cw([0,0]);
    arc_cw([0,-r],r); // cut past entry point
    goto([-,-,TravelZ]);
    r = DeckRad;
    local a = 5deg;
    local p0 = r * [cos(a),sin(a),-]; // entry point
    local p1 = r * [cos(-a),sin(-a),-]; // exit point
    goto(p0);
    arc_cw([r,0,0],r); // blade enters surface
    move([-,-,KnifeZ]); // apply cutting force
    circle_cw([0,0]); // cut circle
    arc_cw(p1,r); // cut past entry point
    goto([-,-,TravelZ]);
    // RC τ and RT Scale window
    local WindowArc = 54deg;
    local ac = -17 * ScaleArc + ScaleRT/2; // center of window arc
    local r0 = DeckRad – ScaleHeight; // outer
    local r1 = DeckRad – 2 * ScaleHeight; // inner
    local aw = WindowArc – to_deg(atan(ScaleHeight,(r0 + r1)/2)); // window arc minus endcaps
    p0 = r0 * [cos(ac + aw/2),sin(ac + aw/2),-];
    p1 = r0 * [cos(ac – aw/2),sin(ac – aw/2),-];
    local p2 = r1 * [cos(ac – aw/2),sin(ac – aw/2),-];
    local p3 = r1 * [cos(ac + aw/2),sin(ac + aw/2),-];
    goto(p3);
    arc_cw(p0 +| [-,-,0],ScaleHeight/2); // blade enters surface
    move([-,-,KnifeZ]); // apply pressure
    arc_cw(p1,r0); // smallest arc
    arc_cw(p2,ScaleHeight/2); // half a circle
    arc_ccw(p3,r1);
    arc_cw(p0,ScaleHeight/2);
    arc_cw(p1 +| [-,-,TravelZ],r0); // exit from cut
    goto([0,0,-]);
    goto([-,-,SafeZ]);
    }
    //———-
    // Cut cursor outline
    CursorHubOD = 1.0in;
    CursorTipWidth = to_inch(9.0/16.0);
    CursorTipRadius = to_inch(1.0/16.0);
    function MillCursor() {
    // Mark center pivot
    MarkPivot();
    comment("Cursor outline");
    local dr = DeckBottomOD/2;
    local hr = CursorHubOD/2;
    local a = atan(hr – CursorTipWidth/2,dr); // rough & ready approximation
    local p0 = hr * [sin(a),cos(a),-]; // upper tangent point on hub
    local c1 = [dr – CursorTipRadius,CursorTipWidth/2 – CursorTipRadius*cos(a),-];
    local p1 = c1 + [CursorTipRadius*sin(a),CursorTipRadius*cos(a),-];
    local p2 = c1 + [CursorTipRadius,0,-]; // around tip radius
    feedrate(KnifeSpeed);
    goto([-,-,TravelZ]);
    goto([-hr,0,-]);
    move([-,-,EngraveZ]);
    repeat(3) {
    arc_cw(p0,hr);
    move(p1);
    arc_cw(p2,CursorTipRadius);
    move([p2.x,-p2.y,-]);
    arc_cw([p1.x,-p1.y,-],CursorTipRadius);
    move([p0.x,-p0.y,-]);
    arc_cw([-hr,0,-],hr);
    }
    goto([-,-,SafeZ]); // done, so get out of the way
    goto([0,0,-]);
    }
    //—————————————————————————–
    // The actual machining sequences!
    //—–
    // Bottom Deck
    if (SelectPart == "Bottom") {
    DeckOD = DeckBottomOD;
    DeckRad = DeckOD / 2;
    comment(" OD: ",DeckOD);
    if (Operation == "Engrave") {
    EngraveBottom();
    }
    elif (Operation == "Mill") {
    MillBottom();
    }
    else {
    error("Invalid operation: ",Operation);
    }
    }
    //——
    // Middle Deck
    if (SelectPart == "Middle") {
    DeckOD = DeckMiddleOD;
    DeckRad = DeckOD / 2;
    comment(" OD: ",DeckOD);
    if (Operation == "Engrave") {
    EngraveMiddle();
    }
    elif (Operation == "Mill") {
    MillMiddle();
    }
    else {
    error("Invalid operation: ",Operation);
    }
    }
    //—–
    // Top Deck
    if (SelectPart == "Top") {
    DeckOD = DeckTopOD;
    DeckRad = DeckOD / 2;
    comment(" OD: ",DeckOD);
    if (Operation == "Engrave") {
    EngraveTop();
    }
    elif (Operation == "Mill") {
    MillTop();
    }
    else {
    error("Invalid operation: ",Operation);
    }
    }
    //—–
    // Cursor
    if (SelectPart == "Cursor") {
    DeckOD = DeckBottomOD;
    DeckRad = DeckOD / 2;
    comment(" OD: ",DeckOD);
    if (Operation == "Engrave") {
    EngraveCursor();
    }
    elif (Operation == "Mill") {
    MillCursor();
    }
    else {
    error("Invalid operation: ",Operation);
    }
    }
    #!/bin/bash
    # Tek Circuit Computer Engraving
    # Ed Nisley KE4ZNU – 2019-11
    #OD='BaseOD=118mm' # CD = 120
    #OD='BaseOD=93mm' # hard drive = 95mm
    #EZ='EngraveZ=-5mm' # Engraving Z
    Flags='-P 3 –pedantic' # avoid leading hyphen gotcha
    # Set these to match your file layout
    ProjPath='/mnt/bulkdata/Project Files/Tektronix Circuit Computer/Firmware'
    LibPath='/opt/gcmc/library'
    Prolog='prolog.gcmc'
    Epilog='epilog.gcmc'
    ScriptPath=$ProjPath
    Script='Tek Circuit Computer.gcmc'
    #—–
    # params: deck operation
    function Runit {
    fn=TekCC-${1}-${2}.ngc
    echo "(File: "$fn")" > $fn
    sel='SelectPart="'$1'"'
    op='Operation="'$2'"'
    echo Output: $fn
    echo " "$sel
    echo " "$op
    if [ -e $fn ]
    then rm -f $fn
    fi
    gcmc -D "$OD" -D "$EZ" \
    -D "$sel" -D "$op" $Flags \
    –include "$LibPath" –prologue "$Prolog" –epilogue "$Epilog" \
    "$ScriptPath"/"$Script" >> "$fn"
    }
    #—–
    Runit Bottom Engrave
    Runit Bottom Mill
    Runit Middle Engrave
    Runit Middle Mill
    Runit Top Engrave
    Runit Top Mill
    Runit Cursor Engrave
    Runit Cursor Mill
    view raw TekCC.sh hosted with ❤ by GitHub

  • CNC 3018XL: Pilot V5RT Pen Holder

    It turns out my all-time favorite Pilot Precise V5 Extra Fine stick pen also comes in a clicky-top retractable version:

    Pilot V5 and V5RT pens
    Pilot V5 and V5RT pens

    The cartridge is a nice 6 mm cylinder, eminently transformable into a plotter pen:

    Pilot V5RT holder - installed
    Pilot V5RT holder – installed

    A few minutes with a caliper provides key measurements for a snout surrounding the business end:

    Pilot V5RT Pen Holder - snout dimension doodle
    Pilot V5RT Pen Holder – snout dimension doodle

    The green letters & numbers give the nearest drill sizes. The “T” values along the bottom are the tailstock turns (at 1.5 mm/turn) required to poke the drills to the indicated depths, eyeballed when the body just enters the hole.

    Having recently decomissioned the Thing-O-Matic and harvested its organs parts, I have a vast collection of 3/8 inch = 9.52 mm shafts and matching bronze bushings:

    9.52 mm shaft and bushings
    9.52 mm shaft and bushings

    Bronze bushings have low stiction, at least when they’re co-axial, and are much shorter than linear ball bearings.

    I chopped off a 70 mm length of shaft and faced the raw end:

    Pilot V5RT holder - facing shaft
    Pilot V5RT holder – facing shaft

    The other end had a maker’s logo, but I don’t recognize it:

    Pilot V5RT holder - center drill
    Pilot V5RT holder – center drill

    I really wanted an 8 mm bore around the snout, but it just didn’t work out. The ring around the 7.5 mm counterbore shows where the larger drill just … stopped:

    Pilot V5RT holder - drilled shaft
    Pilot V5RT holder – drilled shaft

    A trial fit with the pen cartridge:

    Pilot V5RT holder - pen in shaft
    Pilot V5RT holder – pen in shaft

    The top of the shaft gets a somewhat longer knurled ring for the 3 mm SHCS holding the cartridge in place:

    Pilot V5RT holder - knurling pen clamp
    Pilot V5RT holder – knurling pen clamp

    The screw bears on a split collar turned and drilled from a Delrin rod:

    Pilot V5RT holder - drilling Delrin clamp
    Pilot V5RT holder – drilling Delrin clamp

    The “split” came from a simple saw cut across one side and I milled a flat spot in the knurling to seat the screw. As usual, the knurled ring got epoxied to the shaft.

    The snout started as a 3/8 inch aluminum rod, drilled as shown in the sketch, with a (scant) 7.5 mm section to fit the shaft. The carbide insert left a nicely rounded shoulder that required trimming to fit snugly into the shaft:

    Pilot V5RT holder - shaping snout seat
    Pilot V5RT holder – shaping snout seat

    The compound can handle the shallow angle required to shape the snout:

    Pilot V5RT holder - tapering snout
    Pilot V5RT holder – tapering snout

    A trial fit showed the snout was a bit too long for comfort:

    Pilot V5RT holder - snout test fit
    Pilot V5RT holder – snout test fit

    Making something shorter doesn’t pose much of a challenge:

    Pilot V5RT holder - trimming snout
    Pilot V5RT holder – trimming snout

    Another trial fit shows it’s spot on:

    Pilot V5RT holder - shaft snout pen test fit
    Pilot V5RT holder – shaft snout pen test fit

    The critical part is having the snout support the plastic around the pen tip to prevent wobbulation.

    Epoxy the whole thing together, add a suitable spring, tighten the screws & nuts for the reaction plate, and it’s all good. I write with about 50 g of force for these pens, so a light preload seemed in order:

    Pilot V5RT Pen Holder - initial downforce measurement
    Pilot V5RT Pen Holder – initial downforce measurement

    If I’d weighed the full-up shaft + snout + collar + cartridge, I’d know if the Y intercept matches that weight. It seems a little lighter, but I’m not taking the thing apart to find out.

    The first version of the 3D printed holder (shown above) is a straightforward modification of the LM12UU diamond drag bit holder, but, after building enough of these things, I realized the circular reaction plate should be triangular to get more clearance in front of the Z-axis stepper motor when installing & removing the holder:

    Pilot V5RT Pen Holder - solid model - show view
    Pilot V5RT Pen Holder – solid model – show view

    It also has a recess for the serrated top of the bearing, to prevent the knurled collar from clicking annoyingly as the Z-axis rises at the end of each stroke.

    Now, to see how well it draws!

    The OpenSCAD source code as a GitHub Gist:

    // Diamond Scribe in linear bearings for CNC3018
    // Ed Nisley KE4ZNU – 2019-08-9
    Layout = "Build"; // [Build, Show, Base, Mount, Plate]
    /* [Hidden] */
    ThreadThick = 0.25; // [0.20, 0.25]
    ThreadWidth = 0.40; // [0.40, 0.40]
    /* [Hidden] */
    Protrusion = 0.1; // [0.01, 0.1]
    HoleWindage = 0.2;
    inch = 25.4;
    function IntegerMultiple(Size,Unit) = Unit * ceil(Size / Unit);
    ID = 0;
    OD = 1;
    LENGTH = 2;
    //- Adjust hole diameter to make the size come out right
    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);
    }
    //- Dimensions
    PenOD = 6.1; // pen refill shaft, max OD
    Bearing = [(3.0/8.0)*inch,16.0,10.6]; // linear bearing body, ID = shaft diameter
    BearingFlange = [Bearing[OD],17.2,1.0]; // flange around end of bearing
    Spring = [8.5,9.5,15.5]; // compression spring around shaft, LENGTH = uncompressed
    SpringRecess = 4*ThreadThick;
    WallThick = 4.0; // minimum thickness / width
    Screw = [3.0,6.75,25.0]; // holding it all together, OD = washer
    Insert = [3.0,4.2,7.9]; // brass insert
    //Insert = [3.0,5.0,8.0];
    //Insert = [4.0,6.0,10.0];
    Clamp = [43.2,44.0,34.0]; // tool clamp ring, OD = clearance around top
    LipHeight = IntegerMultiple(2.0,ThreadThick); // above clamp for retaining
    BottomExtension = 15.0; // below clamp to reach workpiece
    MountOAL = LipHeight + Clamp[LENGTH] + BottomExtension; // total mount length
    echo(str("Mount OAL: ",MountOAL));
    Plate = [PenOD + 4*ThreadWidth,Clamp[ID] – 0*2*WallThick,WallThick]; // spring reaction plate
    echo(str("Screw length: ",Spring[LENGTH] + Plate[LENGTH] + Insert[LENGTH]));
    NumScrews = 3;
    ScrewBCD = Bearing[OD] + Insert[OD] + 2*WallThick;
    echo(str("Retainer max OD: ",ScrewBCD – Screw[OD]));
    NumSides = 9*4; // cylinder facets (multiple of 3 for lathe trimming)
    // Basic mount shape
    module CNC3018Base() {
    translate([0,0,MountOAL – LipHeight])
    cylinder(d=Clamp[OD],h=LipHeight,$fn=NumSides);
    translate([0,0,MountOAL – LipHeight – Clamp[LENGTH] – Protrusion])
    cylinder(d=Clamp[ID],h=(Clamp[LENGTH] + 2*Protrusion),$fn=NumSides);
    cylinder(d1=Bearing[OD] + 2*WallThick,d2=Clamp[ID],h=BottomExtension + Protrusion,$fn=NumSides);
    }
    // Mount with holes & c
    module Mount() {
    difference() {
    CNC3018Base();
    translate([0,0,-Protrusion]) // bearing
    PolyCyl(Bearing[OD],2*MountOAL,NumSides);
    translate([0,0,-Protrusion]) // bearing flanges
    PolyCyl(BearingFlange[OD],BearingFlange[LENGTH] + Protrusion,NumSides);
    translate([0,0,MountOAL – 1.5*BearingFlange[LENGTH]]) // sink into surface
    PolyCyl(BearingFlange[OD],2*BearingFlange[LENGTH],NumSides);
    for (i=[0:NumScrews – 1]) // clamp screws
    rotate(i*360/NumScrews)
    translate([ScrewBCD/2,0,MountOAL – Clamp[LENGTH]])
    rotate(180/8)
    PolyCyl(Insert[OD],Clamp[LENGTH] + Protrusion,8);
    }
    }
    module SpringPlate() {
    difference() {
    hull()
    for (i=[0:NumScrews – 1])
    rotate(i*360/NumScrews)
    translate([ScrewBCD/2,0,0])
    cylinder(d=Screw[OD] + 4*ThreadWidth,h=Plate[LENGTH],$fn=24);
    translate([0,0,-Protrusion])
    PolyCyl(Plate[ID],2*MountOAL,NumSides);
    translate([0,0,Plate[LENGTH] – SpringRecess]) // spring retainer
    PolyCyl(Spring[OD] + 4*ThreadWidth,SpringRecess + Protrusion,NumSides);
    for (i=[0:NumScrews – 1]) // clamp screws
    rotate(i*360/NumScrews)
    translate([ScrewBCD/2,0,-Protrusion])
    rotate(180/8)
    PolyCyl(Screw[ID],2*MountOAL,8);
    }
    }
    //—–
    // Build it
    if (Layout == "Base")
    CNC3018Base();
    if (Layout == "Mount")
    Mount();
    if (Layout == "Plate")
    SpringPlate();
    if (Layout == "Show") {
    Mount();
    translate([0,0,MountOAL + Plate[LENGTH] + Spring[LENGTH]])
    rotate([180,0,0])
    SpringPlate();
    }
    if (Layout == "Build") {
    translate([0,-0.75*Clamp[OD],MountOAL])
    rotate([180,0,0])
    Mount();
    translate([0,0.75*Plate[OD],0])
    SpringPlate();
    }

  • Huion H610Pro (V2) Tablet vs. Ubuntu 18.04

    Given the hassle involved with getting my ancient Wacom Graphire3 tablet working with various Linux versions, I was unsurprised to find a Huion H610Pro (V2) tablet (*) didn’t quite work out of the box.

    Good old lsusb showed the tablet’s USB info:

    lsusb
    Bus 002 Device 002: ID 8087:0024 Intel Corp. Integrated Rate Matching Hub
    Bus 002 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
    Bus 001 Device 004: ID 248a:ff0f  
    Bus 001 Device 007: ID 058f:9410 Alcor Micro Corp. Keyboard
    Bus 001 Device 006: ID 047d:1020 Kensington Expert Mouse Trackball
    Bus 001 Device 005: ID 046d:c508 Logitech, Inc. Cordless Trackball
    Bus 001 Device 003: ID 0451:2046 Texas Instruments, Inc. TUSB2046 Hub
    Bus 001 Device 002: ID 8087:0024 Intel Corp. Integrated Rate Matching Hub
    Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
    Bus 004 Device 006: ID 05e3:0748 Genesys Logic, Inc. 
    Bus 004 Device 005: ID 0480:a202 Toshiba America Inc Canvio Basics HDD
    Bus 004 Device 004: ID 0bda:0411 Realtek Semiconductor Corp. 
    Bus 004 Device 003: ID 0451:8041 Texas Instruments, Inc. 
    Bus 004 Device 002: ID 0bda:0411 Realtek Semiconductor Corp. 
    Bus 004 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
    Bus 003 Device 006: ID 256c:006d  
    Bus 003 Device 004: ID 0bda:5411 Realtek Semiconductor Corp. 
    Bus 003 Device 003: ID 0451:8043 Texas Instruments, Inc. 
    Bus 003 Device 002: ID 0bda:5411 Realtek Semiconductor Corp. 
    Bus 003 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub

    Yes, the space normally occupied by the product description is blank. The first blank description comes from a generic wireless keypad’s USB receiver; the 0x248a Vendor ID claims be Maxxter, a step down from the usual Logitech ID rip, and its 0xff0f Device ID looks bogus to me, too.

    The 0x256c Vendor ID isn’t in the online databases yet, but some grepping found it in /lib/udev/rules.d/65-libwacom.rules:

    # Huion H610 Pro
    ENV{ID_BUS}=="usb", ENV{ID_VENDOR_ID}=="256c", ENV{ID_MODEL_ID}=="006e",  ENV{ID_INPUT}="1", ENV{ID_INPUT_JOYSTICK}="", ENV{ID_INPUT_TABLET}="1"
    ATTRS{name}=="* Pad", ENV{ID_BUS}=="usb", ENV{ID_VENDOR_ID}=="256c", ENV{ID_MODEL_ID}=="006e",  ENV{ID_INPUT_TABLET_PAD}="1"
    # Huion H610 Pro
    ENV{ID_BUS}=="usb", ENV{ID_VENDOR_ID}=="256c", ENV{ID_MODEL_ID}=="006e",  ENV{ID_INPUT}="1", ENV{ID_INPUT_JOYSTICK}="", ENV{ID_INPUT_TABLET}="1"
    ATTRS{name}=="* Pad", ENV{ID_BUS}=="usb", ENV{ID_VENDOR_ID}=="256c", ENV{ID_MODEL_ID}=="006e",  ENV{ID_INPUT_TABLET_PAD}="1"
    

    Note, however, that the Device ID is 0x006e, where the upgraded V2 tablet is 0x006d; I have no idea why the number goes down as the version goes up. Change all instances of the former to the latter.

    Even though the Wacom driver can apparently handle the older H610Pro, the V2 tablet’s buttons were missing in action.

    The solution seems come from the Digimend project, although it also expects the 0x006e Device ID, and as is usually the case, installing the latest & greatest version, hot from GitHub, did the trick.

    For this first pass, I didn’t use DKMS, which this post will remind me to do after the next kernel upgrade.

    A reboot settled all the drivers into place, after which:

    xsetwacom --list
    HUION Huion Tablet Pen stylus   	id: 10	type: STYLUS    
    HUION Huion Tablet Pad pad      	id: 11	type: PAD 

    Yes, it’s “Tablet Pad pad” and capitalization is important.

    For my simple needs, confining the stylus cursor to the landscape monitor makes sense. Adding this to ~/.config/startup.sh did the same thing as similar invocations for the Wacom:

    xsetwacom --verbose set "HUION Huion Tablet Pen stylus" MapToOutput "DP-1"

    The various buttons still need configuration, although that’s in the nature of fine tuning. The top three buttons are 1, 2, 3, with the rest tagging along at 8 through 12. They take trendy gray-on-black labeling to an absurd limit:

    Huion H610Pro V2 - embedded gray-on-black buttons
    Huion H610Pro V2 – embedded gray-on-black buttons

    That’s with intense overhead lighting shining into the buttons and lighting up the lower-surface iconography. In normal light, they’re shiny black disks with invisible legends and, no, they’re not backlit.

    The overall button-tweaking syntax:

    xsetwacom set "HUION Huion Tablet Pad pad" button 12 key whatever

    Where whatever comes from the list in /usr/include/X11/keysymdef.h, per the doc in man xsetwacom and a list of possibilities from:

    xsetwacom --list modifiers  "HUION Huion Tablet Pad pad"

    For example, this causes the bottom button to spit out a question mark:

    xsetwacom set "HUION Huion Tablet Pad pad" button 12 key shift /

    It’s not obvious changing the buttons from their default button numbers to anything else makes any sense; just tweaking individual programs to map those numbers into useful actions should work better.

    (*) It has a “battery-free” stylus which, to my way of thinking, is a major selling point.

  • Tek Circuit Computer: Drag Knife Deck Cutting

    Creating a paper version of the Tektronix Circuit Computer requires nothing more than a drag knife to cut the deck outlines:

    Tek Circuit Computer - cursor hairline
    Tek Circuit Computer – cursor hairline

    The middle deck is a disk with a notch exposing the FL scale, a cutout window exposing the inductive time constant / risetime scale, and a wee circle for the Chicago screw in the middle:

    Tek CC - middle deck outline
    Tek CC – middle deck outline

    Three angles define the notch:

      FLNotchArc = 85deg;                   // width exposing FL scale
      FLRampArc = 7deg;                     // … width of entry & exit ramps
      FLNotchOffset = 2deg;                 // … start angle from 0°

    Given those, along with the deck radius and notch height (equals the underlying scale height), calculate four points defining the start and end of the ramps and connect the dots:

      local a0 = FLNotchOffset;
      local p0 = DeckRad * [cos(a0),sin(a0),-];
    
      local a1 = a0 + FLNotchArc;
      local p1 = DeckRad * [cos(a1),sin(a1),-];
    
      goto(p0);
      move([-,-,KnifeZ]);
      arc_cw(p1,-DeckRad);          // largest arc
    
      local r = DeckRad - ScaleHeight;
      local a3 = a1 - FLRampArc;
      local p3 = r * [cos(a3),sin(a3),-];
    
      local a4 = a0 + FLRampArc;
      local p4 = r * [cos(a4),sin(a4),-];
    
      move(p3);
      arc_cw(p4,r);                 // smallest arc
    
      move(p0);                     // end of notch
    
      arc_cw([DeckRad,0,-],DeckRad);      // round off corner

    The arc_cw() functions draw arcs, as you’d expect, with a positive radius tracing the shortest arc and a negative radius for the longest arc. Although I know how that works, I must still preview the result to verify the G-Code does what I want, not what I said.

    The unhappy result of a wrong sign:

    Tek CC - middle deck outline - wrong arc sign
    Tek CC – middle deck outline – wrong arc sign

    GCMC uses the (signed) radius to generate the XY coordinates and IJ offsets for G2 commands in the preferred center format:

    G0 X88.846 Y3.103
    G1 Z-2.000
    G2 X4.653 Y88.778 I-88.846 J-3.103

    Cutting the window starts from its angular width and offset, which are hardcoded magic numbers from the Tek artifact, and proceeds similarly:

      local WindowArc = 39deg;
    
      local ac = -6 * ScaleArc;                  // center of window arc
      local r0 = DeckRad - ScaleHeight;         // outer
      local r1 = DeckRad - 2 * ScaleHeight;     // inner
    
      local aw = WindowArc - to_deg(atan(ScaleHeight,(r0 + r1)/2));    // window arc minus endcaps
    
      local p0 = r0 * [cos(ac + aw/2),sin(ac + aw/2),-];
      local p1 = r0 * [cos(ac - aw/2),sin(ac - aw/2),-];
      local p2 = r1 * [cos(ac - aw/2),sin(ac - aw/2),-];
      local p3 = r1 * [cos(ac + aw/2),sin(ac + aw/2),-];
    
      goto(p0);
      move([-,-,KnifeZ]);
    
      arc_cw(p1,r0);                          // smallest arc
      arc_cw(p2,ScaleHeight/2);               // half a circle
      arc_ccw(p3,r1);
      arc_cw(p0,ScaleHeight/2);

    Trust me on this: incorrect radius signs generate unrecognizable outlines. Which, of course, is why you preview the G-Code before actually cutting anything:

    Tek CC - MPCNC drag knife
    Tek CC – MPCNC drag knife

    A similar hunk of code cuts the top deck; the bottom deck is a simple circle.

    The workflow, such as it is:

    • Tape a sheet of paper (Index stock, Basis 110 = 10 mil = 0.25 mm) at the center of the 3018-ProXL platform
    • Plot (“engrave”) the scales with a pen
    • Affix paper to a Cricut sticky mat taped to the MPCNC platform
    • Touch off the origin at the middle
    • Drag-cut (“mill”) the outlines

    Less complex than it may appear, but the GCMC file now spits out two G-Code files per deck: one to engrave / draw the scales on the 3018 and another to mill / cut the outlines on the MPCNC.