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

  • Crosman BB Bottle Cap

    Crosman BB Bottle Cap

    Mary made a frame weight to maintain tension on the fabric in the HQ Sixteen longarm:

    Longarm fabric frame weight
    Longarm fabric frame weight

    It’s a sturdy cloth tube filled with BBs, somewhat like a grossly overweight door snake (a.k.a. draft stopper).

    The bottle of 6000 copper-plated steel BBs arrived in an overwrap bag of the sort Amazon applies to all bottled products. This was a Good Thing, because the scrap of packing paper did nothing to cushion the bottle in an otherwise empty box. The bag contained most of the shattered cap and a few BBs, with escapees rattling around inside the box and surely a few left along the way.

    So I conjured a replacement cap from TPU:

    Crosman BB bottle cap - solid model - build view
    Crosman BB bottle cap – solid model – build view

    It fits around the bottle neck and snaps onto the spout just like the original:

    Crosman BB bottle cap
    Crosman BB bottle cap

    Except this one is unbreakable.

    The strapless TPU cap was a quick test to verify the fiddly shoulder snapping onto the bottle snout:

    Crosman BB bottle cap - solid model - section view
    Crosman BB bottle cap – solid model – section view

    As it turned out, we poured all 6000 BBs (minus those few lost-in-transit strays) into the cloth tube, but the bottle will come in handy for something someday.

    The OpenSCAD source code as a GitHub Gist:

    // Crosman BB bottle cap
    // Ed Nisley – KE4ZNU
    // 2026-02-22
    include <BOSL2/std.scad>
    Layout = "Show"; // [Show,Build,Section]
    /* [Hidden] */
    ID = 0;
    OD = 1;
    LENGTH = 2;
    HoleWindage = 0.2;
    Protrusion = 0.1;
    NumSides = 6*3*4;
    $fn=NumSides;
    WallThick = 1.0;
    Heights = [1.2,2.0,13.0,WallThick]; // for easy tweaking
    Ring = [34.5,39,WallThick];
    Strap = [70.0,5.0,Ring[LENGTH]];
    CapOAL = sum(Heights);
    //—–
    // Conjure it with magic numbers
    module Cap() {
    tube(Heights[0],id=16.8,wall=WallThick+0.6/2,anchor=BOTTOM) position(TOP)
    tube(Heights[1],id=17.4,wall=WallThick,anchor=BOTTOM) position(TOP)
    tube(Heights[2],id1=17.4,id2=14.0,wall=WallThick,anchor=BOTTOM) position(TOP)
    cyl(Heights[3],d=14.0+2*WallThick,rounding2=WallThick/2,anchor=BOTTOM) position(BOTTOM)
    cuboid(Strap,anchor=BOTTOM+LEFT) position(BOTTOM+RIGHT)
    left(1.0)
    tube(Ring[LENGTH],id=Ring[ID],od=Ring[OD],anchor=BOTTOM+LEFT);
    }
    //—–
    // Build things
    if (Layout == "Show") {
    Cap();
    }
    if (Layout == "Section") {
    difference() {
    Cap();
    down(Protrusion)
    cuboid(2*Strap.x,anchor=BOTTOM+LEFT+FRONT);
    }
    }
    if (Layout == "Build") {
    back(Strap.x/2)
    zrot(90)
    up(CapOAL)
    yrot(180)
    Cap();
    }
  • HQ Sixteen: Fabric Rod Bearings

    HQ Sixteen: Fabric Rod Bearings

    The rods (a.k.a. tubes or poles) holding & guiding the quilt top / batting / backing fabric on Mary’s HQ Sixteen longarm quilting machine span the eleven feet of the table:

    HQ Sixteen - table overview
    HQ Sixteen – table overview

    The two end plates are 1/4 inch steel plate with four punched holes for the rods / tubes, which look remarkably like EMT. The machine is two decades old and Mary is (at least) the third owner, so it’s no surprise the rods long ago wore through the white powder-coat paint on the plates and, during the course of a long quilting project, now deposit black dust on the table.

    Black dust not being tolerable near a quilt-in-progress, Mary asked for an improvement.

    The tube OD is 28.7 mm (so it’s probably 1 inch EMT) and the plate hole ID is 31.2 mm (likely a scant 1-¼ inch punch), leaving barely a millimeter of clearance all around. I wanted to make a bearing from suitably slippery Delrin / acetal, but figured 3D printed PETG would suffice for at least while.

    The proper term is “bushing“, because it has no moving parts:

    Rod Bearing Sleeve - solid model - show view
    Rod Bearing Sleeve – solid model – show view

    On the right side, the bushing rim must fit between the sprockets and the plate:

    HQ Sixteen rod - right front
    HQ Sixteen rod – right front

    The spring-loaded pin holding the tube in place (visible on the inside bottom) sets the maximum length:

    HQ Sixteen rod - right outer
    HQ Sixteen rod – right outer

    The left side has none of that, so I made the bushings a little longer:

    HQ Sixteen rod - left inner
    HQ Sixteen rod – left inner

    The left-side bushings will need a better design should normal back-and-forth sliding push them out of place.

    A touch of silicone grease around the plate holes makes those bushings / bearings turn sooo smooth.

    The OpenSCAD source code as a GitHub Gist:

    // Bearing sleeve for HQ Sixteen table rods
    // Ed Nisley – KE4ZNU
    // 2026-02-20
    include <BOSL2/std.scad>
    Layout = "Show"; // [Show,Build]
    /* [Hidden] */
    ID = 0;
    OD = 1;
    LENGTH = 2;
    HoleWindage = 0.2;
    Protrusion = 0.1;
    NumSides = 8*3*2*4;
    $fn=NumSides;
    Rod = [25.0,28.7,100.0]; // very short rod
    Sleeve = [Rod[OD] + 0.3,31.2 – 0.2,9.0]; // LENGTH = overall
    Rim = [Sleeve[ID],Sleeve[OD] + 6.0,0.6];
    IdlerLength = 15.0;
    NumSlots = 2*4;
    Kerf = 1.0;
    Gap = 5.0;
    module Bearing(oal) {
    difference() {
    union() {
    tube(oal,id=Sleeve[ID],od=Sleeve[OD],anchor=BOTTOM);
    tube(Rim[LENGTH],id=Rim[ID],od=Rim[OD],anchor=BOTTOM);
    }
    for (a=[0:NumSlots-1])
    zrot(a*360/NumSlots)
    up(oal/4 + Rim[LENGTH])
    right(Sleeve[ID]/2)
    cuboid([Sleeve[OD],Kerf,oal],anchor=BOTTOM);
    }
    }
    //—–
    // Build things
    if (Layout == "Show") {
    color("Gray",0.5)
    xcyl(Rod[LENGTH],d=Rod[OD]);
    right(Rod[LENGTH]/3)
    yrot(90)
    Bearing(Sleeve[LENGTH]);
    left(Rod[LENGTH]/3)
    yrot(90)
    Bearing(IdlerLength);
    }
    if (Layout == "Build") {
    right(Rim[OD]/2 + Gap/2)
    Bearing(Sleeve[LENGTH]);
    left(Rim[OD]/2 + Gap/2)
    Bearing(IdlerLength);
    }
  • Punched Cards: Almost Automated Punching

    Punched Cards: Almost Automated Punching

    With a printed card in a fixture and aligned to the punch pattern, all that’s left is to Fire The Laser:

    Punched cards - laser fixture - cut
    Punched cards – laser fixture – cut

    When the card drops free, then:

    1. Remove card from fixture
    2. Insert next card
    3. Import next SVG file
    4. Verify alignment
    5. Fire The Laser

    The gotcha lies in Step 3, which requires mousing & clicking through a tedious file selection dialog. For whatever reason, Windows / LightBurn does not remember your place in the file directory, so you must not only remember which card you just punched, but maneuver to the next card in the sequence.

    It turns out there exists a lightly documented SendUDP.exe command-line program to send a file to the running LightBurn instance, which will (in the case of an SVG file) import it and center the layout at the middle of the workspace.

    Which means a Windows batch file can feed SVG files, one at a time, in order, to LightBurn. Before importing the file, however, LightBurn verifies you want to blow away the previous layout:

    LightBurn - Confirm import
    LightBurn – Confirm import

    Tapping D lets the import proceed.

    The feed-lb.bat batch file:

    @echo off
    for %%f in (%1) do (
        echo Sending: %%f
        "c:\Program Files\LightBurn_Prerelease\sendudp" "%CD%\%%f"
        pause
    )
    echo Done!
    

    Because the SVG files have convenient sequential names, this does what’s needed:

    …snippage…>.\feed-lb.bat Cards\Tests\test-?-lb.svg
    Sending: Cards\Tests\test-1-lb.svg
    Press any key to continue . . .
    Sending: Cards\Tests\test-2-lb.svg
    Press any key to continue . . .
    

    Set up the process:

    1. Start LightBurn with the proper layer defaults
    2. Start a Command Prompt
    3. Get to the proper directory
    4. Run feed-lb.bat aimed at the SVG files
    5. Align the first card
    6. Click in LightBurn window
    7. Alt-S to start cutting

    When the cutting is done, the loop continues:

    1. Replace / align card
    2. Click the Command Prompt window
    3. Hit (almost) any key to send the next file
    4. Click the LightBurn window
    5. D to discard old layout / import next SVG
    6. Alt-S to start cutting
    7. Iterate

    Assuming you don’t spend too much time aligning a card, punching it can take up to four minutes. This process is definitely not competitive with an experienced operator on a real IBM 029 keypunch machine, but it’s as good as it gets in the Basement Shop.

    One wrinkle: The imported SVG file uses LightBurn layer colors, so the various shapes appear on those layers with their default speed / power cut settings. It’s your responsibility to make the cut setting defaults match the cardstock, because that’s the only way (short of per-card clicking) to make it happen.

    Another wrinkle: the Command Prompt window opens at your Windows home directory, thus requiring a little setdir.bat file in there to get you where you want to go:

    @echo off
    z:
    cd "\Project Files\Laser Cutter\Punched Cards\Programs\"
    dir
    

    Now it’s just a matter of punching and stacking cards:

    Punched cards - storage trays
    Punched cards – storage trays

    It’ll take a while before I’m ready for the next step …

  • Punched Cards: Print vs. Punch Alignment

    Punched Cards: Print vs. Punch Alignment

    The printed card layout has targets in all four corners:

    Test Card 3 - unpunched
    Test Card 3 – unpunched

    Which are at exactly the same positions as the targets in the punched card layout, because they come from the same source code:

    Punched Cards - laser SVG layout
    Punched Cards – laser SVG layout

    The alignment problem has several parts:

    • The 1/3 Letter sheets aren’t exactly 11/3 inch tall, because neither the paper cutter nor my cutting hand have any particular accuracy
    • The printer’s feed rollers don’t maintain accurate angular or positional alignment between the sheet and the printed design
    • A fractional-millimeter misalignment between the printed characters and the evaporated holes is obvious
    • Performing an intricate alignment dance on each card guarantees at least an occasional misstep

    I initially thought “Well, of course, I’ll just use LightBurn’s Print and Cut tool to match them up.” After some fumbling around, PnC is entirely too heavyweight for the problem at hand and a much simpler / faster / easier technique works better.

    It turns out LightBurn imports SVG files centered on the layout grid representing the laser platform:

    LightBurn - imported SVG layout
    LightBurn – imported SVG layout

    So putting the card fixture dead-center on the platform lines them up pretty closely:

    Punched cards - laser fixture overview
    Punched cards – laser fixture overview

    After importing an SVG, use Move Laser to Selection to put it in the middle of the upper right target, then create a Saved Position imaginatively called UR:

    LightBurn - Move window UR position
    LightBurn – Move window UR position

    Repeat for the lower left target to create the LL position.

    Because the targets are on 200×80 mm centers and the middle of the platform is at (350,250), the target positions will be nice round numbers:

    • UR = (250,210)
    • LL = (450,290)

    Yes, the coordinates run backwards, because that’s how Ruida controllers deal with a home position in the rear right corner of the platform.

    You define those positions once, because all the cards are the same size and end up in the same location on the platform.

    Although I expected to slide the cards under the fixture’s retaining lip from the front, it turns out an easier way is:

    • Gently buckle the card center upward
    • Align it against the rear edge
    • Slide the left edge under its lip
    • Lower the center while sliding the right edge under its lip
    • Tuck the card under the rear lip
    • Verify the front edge aligns with the marked lines, which means it’s properly in the fixture

    The magnets hold the fixture against the honeycomb:

    Punched cards - laser fixture alignment
    Punched cards – laser fixture alignment

    The fixture can still slide with firm finger pressure and the card can move a little bit within the fixture. Note that leaning on the honeycomb will press it (and the fixture) downward enough to put the dot at a slightly different position; if you align while leaning, recheck the dot’s position after you unlean.

    Move the laser to the UR position and skooch the fixture to align the upper right target to the red dot:

    Red dot vs printed target alignment
    Red dot vs printed target alignment

    The blue lines are nominally 0.2 mm wide and actually about 0.3 mm wide, so the red dot is 0.3 mm diameter. If your red dot is larger, better focus and a polarizing filter will help.

    I periodically fire a test pulse to verify the red dot matches the actual laser beam position:

    Red dot vs printed target vs laser spot alignment
    Red dot vs printed target vs laser spot alignment

    That slight mismatch adds to the overall positioning error.

    Repeat for the LL target, recheck UR to make sure it didn’t move, iterate as needed.

    The printed card is now aligned to the hole pattern.

    Although this sounds like a lot, it goes surprisingly quickly because all the cards are Pretty Close™ to identical and the adjustments are very small. Although it’s possible to park the laser head at the UR position, I prefer to have it out of the way while unloading & loading the cards, then move it directly to UR to check the new card.

    Fire The Laser:

    Test Card 3 - punched
    Test Card 3 – punched

    I love it when a plan comes together:

    Test Card 3 - punched - detail
    Test Card 3 – punched – detail

    A dash of automation helps when doing more than one card, which, believe it or not, involves a Windows batch file …

  • Punched Cards: Test Card Generator Script

    Punched Cards: Test Card Generator Script

    The Punched Card.py program can generate a set of test cards to simplify tweaking the rest of the process:

    • Test Card 1 - unpunched
    • Test Card 2 - unpunched
    • Test Card 3 - unpunched
    • Test Card 4 - unpunched
    • Test Card 5 - unpunched

    The Bash script calls the Python program twice to generate the two SVG files for printing and punching each card, which seemed simple enough.

    Unfortunately, sending the SVG file directly to the printer demonstrated my lack of understanding of that whole process, so the script now:

    • Converts the SVG to a PNG file
    • Composites the logo image into a first blank-card sized PNG image
    • Composites the logo underneath the card PNG to a second PNG
    • Composites that PNG onto a Letter page in the proper position to hit a 1/3 Letter blank card to a third PNG

    Which goes a little something like this:

    #/usr/bin/bash
    outdir="Cards/Tests/"
    prefix="test"
    for i in $(seq 5)
    do
        printf "Test pattern %s: print" ${i}
        python Punched\ Card.py --layout print --test $i --seq 0 > ${outdir}test-${i}-pr.svg
        printf ", punch"
        python Punched\ Card.py --lbsvg --layout laser --test $i --seq 0 > ${outdir}test-${i}-lb.svg
    
        tf1=$(mktemp --suffix=.png ${prefix}-XXXX)
        printf ", Inkscape → PNG"
        inkscape --actions="select-all; page-fit-to-selection; export-dpi:300" --export-filename=$tf1 ${outdir}${prefix}-${i}-pr.svg
    
        printf ", Imagemagick → logo"
        tf2=$(mktemp --suffix=.png ${prefix}-XXXX)
        magick composite ${outdir}"Card logo.png" -gravity center -geometry "x880+0+20" -size 2421x1004 canvas:white $tf2
    
        printf ", Imagemagick → page"
        tf3=$(mktemp --suffix=.png ${prefix}-XXXX)
        magick composite $tf1 $tf2 $tf3
        magick composite -density 300 -gravity east -geometry "97.0%x97.9%+100-50" $tf3 -size 3300x2550 canvas:white ${outdir}${prefix}-${i}-lt.png
    
        #printf ", PNG → printer"
        #lp -d EPSON_ET-3830_Series -o media=TLetter ${outdir}${prefix}-${i}-lt.png
    
        rm $tf1 $tf2 $tf3
        printf ", done\n"
    done
    
    

    Although the script could print the final PNG for each card as it’s generated, I prefer to print them after eyeballing the results to fix the inevitable bloopers:

    lp -d EPSON_ET-3830_Series -o media=TLetter Cards/Tests/test-?-lt.png
    

    Using ImageMagick to slam PNG images around was significantly less complex and more direct than trying to contort the SVG file to produce the same result. In particular:

    • Uniformly scaling the logo image to fit the card height actually worked, as opposed to specifying the logo file as an image in SVG file.
    • Scaling the card PNG while compositing it onto the final page PNG worked much consistently, which counts for a lot.
    • Hitting the middle of a 1/3 Letter blank fed from the printer’s paper tray required tedious trial-and-error.

    The Cards/Tests/ output directory lives in the Programs directory with the Bash and Python programs, which made sense at the time.

    The Card logo.png file also lives in Cards/Tests/ so I can have a different logo for each set of cards. A symlink to the appropriate logo file in Logos simplifies changing the artwork.

    None of the constants will match your setup, so have fun.

  • Punched Cards: SVG Scaling

    Punched Cards: SVG Scaling

    With a fixture aligning a printed card in the laser for cutting and trays for incoming and outgoing cards, it’s time to generate SVGs for printing and cutting. I use the svg.py library to translate card geometry into SVG elements.

    For example, this draws a rectangle around the card perimeter with a color that will automagically put it on a LightBurn tool layer used for alignment:

    if args.layout == "laser":
        ToolEls.append(
            svg.Rect(
                x=0,
                y=0,
                width=svg.mm(CardSize[X]),
                height=svg.mm(CardSize[Y]),
                stroke=Tooling,
                stroke_width=svg.mm(DefStroke),
                fill="none",
            )
        )
    

    Because I know the exact size of the card layout, all the coordinates / sizes / widths will be in hard millimeters. The constant INCH converts from the hard inch sizes of the OG IBM card layout.

    The args.layout variable corresponds to a command-line switch selecting output for either “print” or “laser”, because each requires different SVG elements and attributes. In this case, the card outline appears only in the laser file, because it should not be printed on the card face.

    Eventually, the SVG for the laser will look like this, with the blue rectangle boxing its red perimeter:

    Punched Cards - laser SVG layout
    Punched Cards – laser SVG layout

    The corresponding SVG to be printed, without the rectangle:

    Punched Cards - print SVG layout
    Punched Cards – print SVG layout

    Both SVGs have four alignment targets at exactly the same coordinates, although in different colors for their different purposes. The laser targets appear on a tool layer where they can be selected to position the laser head over the corresponding printed targets on the card in the fixture:

    Punched cards - laser fixture alignment
    Punched cards – laser fixture alignment

    Subsequent steps in the process composite the fancy logo under the SVG layout and scale the result before printing the card.

    This chunk of code produces those targets:

    for c in ((1,1),(-1,1),(-1,-1),(1,-1)):
        ctr = (CardSize[X]/2 + c[X]*TargetOC[X]/2,CardSize[Y]/2 + c[Y]*TargetOC[Y]/2)
        MarkEls.append(
            svg.Circle(
                cx=svg.mm(ctr[X]),
                cy=svg.mm(ctr[Y]),
                r=svg.mm(TargetOD/2),
                stroke=Tooling if args.layout == "laser" else CardMark,
                stroke_width=svg.mm(DefStroke),
                fill="none",
            )
        )
        MarkEls.append(
            svg.Path(
                d=[
                    svg.M(ctr[X] + TargetOD/2,ctr[Y] - TargetOD/2),
                    svg.l(-TargetOD,TargetOD),
                    svg.m(0,-TargetOD),
                    svg.l(TargetOD,TargetOD),
                ],
                transform="scale(" + repr(1.0 if args.lbsvg else SVGSCALE) + ")",
                stroke=Tooling if args.layout == "laser" else CardMark,
                stroke_width=svg.mm(DefStroke if args.lbsvg else DefStroke/SVGSCALE),
                fill="none",
            )
        )
    

    The stroke attribute sets the color of the result, with the Python ternary operator picking the appropriate RGB value based on args.layout.

    The args.lbsvg variable comes into play when the LightBurn interpretation of an SVG attribute or element differs from the Inkscape interpretation. I have absolutely no idea what’s going on in those situations, other than that the two programs sometimes regard coordinates / distances differently.

    For example, LightBurn regards the “user units” in a Path as millimeters and Inkscape regards them as “SVG pixels” requiring a scale factor I defined as SVGSCALE = 96.0/25.4 to get the right size. If there’s a less awful way to match them, I’m all eyes.

    Although setting args.layout to “laser” and args.lbsvg to True generally makes sense, I may want to print the “laser” layout for cross-checking. Hilarity generally ensues until I remember args.lbsvg. It’s worth noting the code has zero error checking, so hilarity generally continues until I fix whatever got missed.

    With that in mind, this Path traces the card perimeter:

    if args.layout == "laser":
        CardEls.append(
            svg.Path(
                d=[
                    svg.M(0.25*INCH,0),
                    svg.h(CardSize[X] - 2*0.25*INCH),
                    svg.a(0.250*INCH,0.25*INCH,0,0,1,0.25*INCH,0.25*INCH),
                    svg.v(CardSize[Y] - 2*0.25*INCH),
                    svg.a(0.25*INCH,0.25*INCH,0,0,1,-0.25*INCH,0.25*INCH),
                    svg.H(0.25*INCH),
                    svg.a(0.25*INCH,0.25*INCH,0,0,1,-0.25*INCH,-0.25*INCH),
                    svg.V(0.25*INCH/math.tan(math.radians(30))),
                    svg.Z(),
                ],
                transform="scale(" + repr(1.0 if args.lbsvg else SVGSCALE) + ")",
                stroke=CardCut if args.layout == "laser" else Tooling,
                stroke_width=svg.mm(DefStroke if args.lbsvg else DefStroke/SVGSCALE),
                fill="none",
            ),
        )
    
    

    Note that the stroke_width attribute also requires scaling, lest it take on a broad-brush aspect.

    More on generating the various characters and punching the holes to come …

    The Python source code as a GitHub Gist:

    # Generator for punched cards
    # Ed Nisley – KE4ZNU
    # 2026-01-20 cargo-culted from various sources
    import svg
    import math
    from argparse import ArgumentParser
    from pathlib import Path
    import curses.ascii
    import itertools
    INCH = 25.4
    X = 0
    Y = 1
    SVGSCALE = 96.0/25.4 # converts "millimeters as SVG points" to real millimeters
    parser = ArgumentParser(description="Create SVG files to print & laser-cut a punched card")
    parser.add_argument('–debug',action='store_true',
    help="Enable various test outputs, do not use XML file")
    parser.add_argument('–lower',action='store_true',
    help="Fake lowercase with italics")
    parser.add_argument('–test', type=int, choices=range(7), default=0,
    help="Various test patterns to verify card generation")
    parser.add_argument('–lbsvg', action='store_true',
    help="Work around LightBurn SVG issues")
    parser.add_argument('–layout', default="print", choices=["laser","print"],
    help="Laser-engrave hole & char text into card")
    parser.add_argument('–seq',type=int, default=0,
    help="If nonzero, use as squence number in col 72-80")
    parser.add_argument('–logofile', default="Card logo.png",
    help="Card logo filename")
    parser.add_argument('–prefix', default="",
    help="Card number prefix, no more than 5 characters")
    parser.add_argument('contents',nargs="*",default='Your text goes here',
    help="Line of text to be punched on card")
    args = parser.parse_args()
    PageSize = (round(8.5*INCH,3), round(11.0*INCH,3)) # sheet of paper
    CardSize = (7.375*INCH,3.25*INCH) # punch card bounding box
    NumCols = 80
    NumRows = 12
    HoleSize = (0.055*INCH,0.125*INCH) # punched hole
    HoleOC = (0.087*INCH,0.250*INCH)
    BaseHoleAt = (0.251*INCH,0.250*INCH) # center point
    TargetOC = (200,80) # alignment targets around card
    TargetOD = 5
    #— map ASCII / Unicode characters to rows
    # rows are names, not indexes: Row 12 is along the top of the card
    # Row 10 is the same as Row 0
    CharMap = {
    " ": (),
    "0": (0,),
    "1": (1,),
    "2": (2,),
    "3": (3,),
    "4": (4,),
    "5": (5,),
    "6": (6,),
    "7": (7,),
    "8": (8,),
    "9": (9,),
    "A": (12,1),
    "B": (12,2),
    "C": (12,3),
    "D": (12,4),
    "E": (12,5),
    "F": (12,6),
    "G": (12,7),
    "H": (12,8),
    "I": (12,9),
    "J": (11,1),
    "K": (11,2),
    "L": (11,3),
    "M": (11,4),
    "N": (11,5),
    "O": (11,6),
    "P": (11,7),
    "Q": (11,8),
    "R": (11,9),
    "S": (10,2),
    "T": (10,3),
    "U": (10,4),
    "V": (10,5),
    "W": (10,6),
    "X": (10,7),
    "Y": (10,8),
    "Z": (10,9),
    "a": (12,10,1),
    "b": (12,10,2),
    "c": (12,10,3),
    "d": (12,10,4),
    "e": (12,10,5),
    "f": (12,10,6),
    "g": (12,10,7),
    "h": (12,10,8),
    "i": (12,10,9),
    "j": (12,11,1),
    "k": (12,11,2),
    "l": (12,11,3),
    "m": (12,11,4),
    "n": (12,11,5),
    "o": (12,11,6),
    "p": (12,11,7),
    "q": (12,11,8),
    "r": (12,11,9),
    "s": (10,11,2),
    "t": (10,11,3),
    "u": (10,11,4),
    "v": (10,11,5),
    "w": (10,11,6),
    "x": (10,11,7),
    "y": (10,11,8),
    "z": (10,11,9),
    "¢": (12,2,8),
    ".": (12,3,8),
    "<": (12,4,8),
    "(": (12,5,8),
    "+": (12,6,8),
    "|": (12,7,8),
    "!": (11,2,8),
    "$": (11,3,8),
    "*": (11,4,8),
    ")": (11,5,8),
    ";": (11,6,8),
    "¬": (11,7,8),
    ",": (10,3,8),
    "%": (10,4,8),
    "_": (10,5,8),
    ">": (10,6,8),
    "?": (10,7,8),
    ":": (2,8),
    "#": (3,8),
    "@": (4,8),
    "'": (5,8),
    "=": (6,8),
    '"': (7,8),
    "&": (12,),
    "-": (11,),
    "/": (10,1),
    "█": (12,11,10,1,2,3,4,5,6,7,8,9), # used for lace card test pattern
    "▯": (12,10,2,4,6,8), # used for alignment tests with hack for row numbers
    }
    #— map row name to physical row offset from top
    RowMap = (2,3,4,5,6,7,8,9,10,11,2,1,0)
    RowGlyphs = "0123456789⁰¹²▯" # last four should never appear, hollow box is a hack
    #— pretty punch patterns
    TestStrings = ( " " * NumCols, # blank card for printing
    "█" * NumCols, # lace card for amusement
    "0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz",
    "¢.<(+|!$*);¬,%_>?:#@'=" + '"' + "&-/█",
    "▯" * NumCols, # hack for row number alignment
    )
    #— LightBurn layer colors
    HoleCut = "black" # C00 Black
    CardMark = "blue" # C01 Blue
    CardCut = "red" # C02 Red
    CardText = "green" # C03 Green
    CardGray = "rgb(125,135,185)" # C17 Dark Gray
    Tooling = "rgb(12,150,217)" # T2 Tool
    #— LightBurn uses only the stroke
    DefStroke = 0.20
    DefFill = "none"
    #———————
    # Set up card contents
    if args.test: # test patterns used without changes
    Contents = TestStrings[args.test 1].ljust(NumCols,' ')
    else: # real cards need cleaning
    Contents = ''.join(itertools.chain(*args.contents))
    if args.seq:
    nl = 8 len(args.prefix)
    Contents = Contents.ljust(NumCols 8,' ')[:(NumCols 8)]
    Contents = Contents + f"{args.prefix}{args.seq:0{nl}d}"
    else:
    Contents = Contents.ljust(NumCols,' ')[:NumCols]
    if not args.lower:
    Contents = Contents.upper()
    #— accumulate tooling layout
    ToolEls = []
    # mark center of card for drag-n-drop location
    if args.layout == "laser":
    ToolEls.append(
    svg.Circle(
    cx=svg.mm(CardSize[X]/2),
    cy=svg.mm(CardSize[Y]/2),
    r="2mm",
    stroke=Tooling,
    stroke_width=svg.mm(DefStroke),
    fill="none",
    )
    )
    # mark card perimeter for alignment check
    if args.layout == "laser":
    ToolEls.append(
    svg.Rect(
    x=0,
    y=0,
    width=svg.mm(CardSize[X]),
    height=svg.mm(CardSize[Y]),
    stroke=Tooling,
    stroke_width=svg.mm(DefStroke),
    fill="none",
    )
    )
    #— accumulate alignment targets
    MarkEls = []
    # alignment targets
    for c in ((1,1),(1,1),(1,1),(1,1)):
    ctr = (CardSize[X]/2 + c[X]*TargetOC[X]/2,CardSize[Y]/2 + c[Y]*TargetOC[Y]/2)
    MarkEls.append(
    svg.Circle(
    cx=svg.mm(ctr[X]),
    cy=svg.mm(ctr[Y]),
    r=svg.mm(TargetOD/2),
    stroke=Tooling if args.layout == "laser" else CardMark,
    stroke_width=svg.mm(DefStroke),
    fill="none",
    )
    )
    MarkEls.append(
    svg.Path(
    d=[
    svg.M(ctr[X] + TargetOD/2,ctr[Y] TargetOD/2),
    svg.l(TargetOD,TargetOD),
    svg.m(0,TargetOD),
    svg.l(TargetOD,TargetOD),
    ],
    transform="scale(" + repr(1.0 if args.lbsvg else SVGSCALE) + ")",
    stroke=Tooling if args.layout == "laser" else CardMark,
    stroke_width=svg.mm(DefStroke if args.lbsvg else DefStroke/SVGSCALE),
    fill="none",
    )
    )
    #— accumulate card cuts
    CardEls = []
    # card perimeter with magic numbers from card dimensions
    if args.layout == "laser":
    CardEls.append(
    svg.Path(
    d=[
    svg.M(0.25*INCH,0),
    svg.h(CardSize[X] 2*0.25*INCH),
    svg.a(0.250*INCH,0.25*INCH,0,0,1,0.25*INCH,0.25*INCH),
    svg.v(CardSize[Y] 2*0.25*INCH),
    svg.a(0.25*INCH,0.25*INCH,0,0,1,0.25*INCH,0.25*INCH),
    svg.H(0.25*INCH),
    svg.a(0.25*INCH,0.25*INCH,0,0,1,0.25*INCH,0.25*INCH),
    svg.V(0.25*INCH/math.tan(math.radians(30))),
    svg.Z(),
    ],
    transform="scale(" + repr(1.0 if args.lbsvg else SVGSCALE) + ")",
    stroke=CardCut if args.layout == "laser" else Tooling,
    stroke_width=svg.mm(DefStroke if args.lbsvg else DefStroke/SVGSCALE),
    fill="none",
    ),
    )
    # label hole positions in rows 0-9
    # special hack for outline boxes
    TextEls = []
    if args.layout == "print":
    xoffset = 0.3 # tiny offsets to align chars with cuts
    yoffset = 1.5
    for c in range(NumCols):
    glyph = Contents[c]
    rnx = CharMap[glyph] # will include row name 10 aliased as row name 0
    for rn in range(10):
    pch = RowGlyphs[rn] # default is digit for row
    if ((rn in rnx) or ((rn == 0) and (10 in rnx))): # suppress punched holes
    pch = "▯" if glyph == "▯" else " " # except for alignment tests
    r = RowMap[rn]
    TextEls.append(
    svg.Text(
    x=svg.mm(BaseHoleAt[X] + c*HoleOC[X] + xoffset),
    y=svg.mm(BaseHoleAt[Y] + r*HoleOC[Y] + yoffset),
    class_=["holes"],
    font_family="Arial", # required by LightBurn
    font_size="3.0mm", # required by LightBurn
    text_anchor="middle",
    text=pch
    )
    )
    # number the columns in tiny print
    if args.layout == "print":
    xoffset = 0.3
    yoffset = 0.5
    #xoffset = -0.25 if args.lbsvg else 0.0 # original hack
    for c in range(NumCols):
    for y in (22.7,80.0): # magic numbers between the rows
    TextEls.append(
    svg.Text(
    x=svg.mm(BaseHoleAt[X] + c*HoleOC[X] + xoffset),
    y=svg.mm(y + yoffset),
    class_=["cols"],
    font_family="Arial", # required by LightBurn
    font_size="1.5mm", # required by LightBurn
    text_anchor="middle",
    text=f"{c+1: 2d}",
    )
    )
    # add text attribution
    if args.layout == "print":
    TextEls.append(
    svg.Text(
    x=svg.mm(175.3),
    y=svg.mm(72.5 if args.lbsvg else 73.0),
    class_=["attrib"],
    font_family="Arial", # required by LightBurn
    font_size="2.0mm", # ignored by LightBurn
    text_anchor="middle",
    dominant_baseline="middle",
    text="softsolder.com",
    )
    )
    #— accumulate holes
    HoleEls = []
    # punch the holes
    if args.layout == "laser":
    for c in range(len(Contents)):
    glyph = Contents[c]
    if not (glyph in CharMap):
    glyph = ' '
    for rn in CharMap[glyph]:
    r = RowMap[rn]
    HoleEls.append(
    svg.Rect(
    x=svg.mm(BaseHoleAt[X] + c*HoleOC[X] HoleSize[X]/2),
    y=svg.mm(BaseHoleAt[Y] + r*HoleOC[Y] HoleSize[Y]/2),
    width=svg.mm(HoleSize[X]),
    height=svg.mm(HoleSize[Y]),
    stroke=HoleCut,
    stroke_width=svg.mm(DefStroke),
    fill="none",
    )
    )
    # print punched characters across the top edge
    # The KEYPUNCH029 font does not include lowercase characters, so
    # fake lowercase with italics, which LightBurn ignores
    if args.layout == "print":
    xoffset = 0.3
    for c in range(len(Contents)):
    glyph = Contents[c]
    if not (glyph in CharMap):
    glyph = ' '
    fc = "dottylc" if curses.ascii.islower(glyph) else "dotty"
    glyph = svg.escape(glyph) # escape the characters that wreck SVG syntax
    TextEls.append(
    svg.Text(
    x=svg.mm(BaseHoleAt[X] + c*HoleOC[X] + xoffset),
    y=svg.mm(5.0), # align just below card edge
    class_=[fc],
    font_family="KEYPUNCH029", # required by LightBurn
    font_size="4.0mm", # required by LightBurn
    text_anchor="middle",
    text=glyph
    )
    )
    #— assemble and blurt out the SVG file
    if not args.debug:
    canvas = svg.SVG(
    width=svg.mm(PageSize[X]),
    height=svg.mm(PageSize[Y]),
    elements=[
    svg.Style(
    text = f"\n.attrib{{ font: 2mm Arial; fill:{CardText}}}" +
    f"\n.holes{{ font: 3.0mm Arial; fill:{CardText}}}" +
    f"\n.cols{{ font: 1.5mm Arial; fill:{CardText}}}" +
    f"\n.dotty{{ font: 4.0mm KEYPUNCH029; fill:{CardGray}}}" +
    f"\n.dottylc{{ font: italic 4.0mm KEYPUNCH029; fill:{CardGray}}}"
    ),
    ToolEls,
    MarkEls,
    CardEls,
    TextEls,
    HoleEls,
    ],
    )
    print(canvas)
    view raw Punched Card.py hosted with ❤ by GitHub

    I derive a modest satisfaction from knowing Microsoft (which owns GitHub) uses my source code to train their Copilot LLM. Future generations of vibe coders will look at their programs and wonder WTF just happened.

  • Punched Cards: Storage Trays

    Punched Cards: Storage Trays

    Keeping stacks of punched cards under control is important for orderly production:

    Punched cards - storage trays
    Punched cards – storage trays

    The two on the left fit finished cards and the larger ones fit 1/3 Letter paper raw cards.

    The patterns come from boxes.py:

    I added a finger slot behind the front opening so I could pick up the whole stack at once:

    Punched Card Trays - LightBurn layout
    Punched Card Trays – LightBurn layout

    Admittedly, my “stacks” are nothing compared to the Bad Old Days, but …

    The program adds a sequence number in columns 73-80 as a last-ditch effort to ensure the punch pattern matches the print pattern: after a few dozen cards, the digits in the last few columns become recognizable.

    The LightBurn SVG layout as a GitHub Gist:

    Loading
    Sorry, something went wrong. Reload?
    Sorry, we cannot display this file.
    Sorry, this file is invalid so it cannot be displayed.