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

  • Layered Paper: Inkscape Random Block Setup

    Layered Paper: Inkscape Random Block Setup

    The random block layered paper design starts as an Inkscape layout, although the amount of manual intervention required to make it happen suggests it’s not really worthwhile. With that in mind, this is how to make it happen …

    Draw a 9×9 mm square with these properties:

    • Undefined fill: each block will become different
    • Flat color stroke set to black with 100% alpha
    • 0.2 mm stroke width: so LightBurn will see it

    Because the squares will be on 10 mm centers, draw a 159 mm square:

    • No fill (this is different from Undefined fill)
    • Flat color stroke set to LightBurn T2 color
    • 0.2 mm stroke width

    Align the big square on the grid, which should have 10 mm spacing because that’s convenient. This will become the way you align the array of squares in the LightBurn layout, so you really want the array to fit neatly and symmetrically inside the 159 mm square.

    Iterate 16 times, all in T2 layer color:

    • Create a layer with a name like H000 through H337
    • Create a corresponding text string
    • Align fussily
    • Duplicate the 159 mm square
    • Put the block and the text string on the new layer
    • Lock the square and text so they can’t move

    Which will look like this:

    Random Blocks - 16x16 159mm - Inkscape layer labels
    Random Blocks – 16×16 159mm – Inkscape layer labels

    Unlike LightBurn, the color is not linked directly to the layer, so each of those text strings is on the corresponding named layer and there are 16 duplicates of the large box at exactly the same coordinates. Plus the original 159 mm square, which remains unlabeled and unlocked.

    Select the black 9 mm block and create a 16×16 clone army array:

    Random Blocks - 16x16 159mm - Inkscape clone setup
    Random Blocks – 16×16 159mm – Inkscape clone setup

    The Initial Color is critical:

    Random Blocks - 16x16 159mm - Inkscape clone color
    Random Blocks – 16×16 159mm – Inkscape clone color

    The layer names come from the 6.25% hue steps, starting with H=0, which will look like this:

    Random Blocks - 16x16 159mm - Inkscape hue steps
    Random Blocks – 16×16 159mm – Inkscape hue steps

    Note that LightBurn absolutely does not care about the colors. All it will get is the outlines corresponding to the strokes, with the colors collecting them into separate groups for the paper layers.

    Go to the Layers window, select the original block (which is likely on Layer 1 or some such), cut it, and paste it somewhere outside the 159 mm square where it won’t cause any trouble.

    Iterate 16 times in the Layers window:

    • Select one of the 256 clone squares, which will have an automagic name like use1272
    • Right-click, hit Select Same → Fill Color
    • Right-click, hit Move to Layer …
    • Pick the layer name matching the hue

    Select all the squares and Distribute randomly:

    Random Blocks - 16x16 159mm - Inkscape rearrange
    Random Blocks – 16×16 159mm – Inkscape rearrange

    Then Align them in a grid:

    Random Blocks - 16x16 159mm - Inkscape grid distribute
    Random Blocks – 16×16 159mm – Inkscape grid distribute

    The 0.8 mm Spacing is the distance between 9 mm blocks with 0.2 mm strokes.

    Shift-click on the 159 mm square to add it to the selection, then hit the two center-align buttons to center the 16×16 array in the square:

    Random Blocks - 16x16 159mm - Inkscape center align
    Random Blocks – 16×16 159mm – Inkscape center align

    Save that sucker as an Inkscape SVG and it’s ready to import into lightBurn.

    With all that done, you can generate different random layouts by:

    • Select the existing 16×16 array (but not the outer 159 mm square; Undo is your friend)
    • Randomize the array
    • Align it
    • Center it

    The colored blocks remain in their corresponding layers, so you need not go through all that overhead ever again.

    Whether that’s worthwhile is up for grabs, but now I have a faint chance of getting it right the next time.

  • Layered Paper: Random Blocks

    Layered Paper: Random Blocks

    I wanted to see / feel what 18 paper layers would look & feel like:

    Random Blocks - framed
    Random Blocks – framed

    That’s a black mask layer atop 16 cut layers of cheerful colored paper in rainbow order and a solid purple sheet at the bottom:

    Random Blocks - framed detail
    Random Blocks – framed detail

    The layer runs at 100 mm/s with 20% of a 60 W laser. The relatively low speed, combined with right-angle corners, produces very crisp results unlike the rounded-corner Subpixel holes.

    The holes form a 16×16 grid and cutting the first few layers with 250-ish holes takes a bit under three minutes apiece:

    Random Blocks - cutting red layer
    Random Blocks – cutting red layer

    The sheets sit in the Letter sheet fixture and get four round holes in the corners for the assembly fixture, plus a binary sheet ID helping me with the stacking order:

    Random Blocks - assembly process
    Random Blocks – assembly process

    The hole patterns come from Inkscape through LightBurn, in a grindingly intricate manual process crying out for automation. This is a feasibility study to see if the result is worthwhile and, yeah, it looks promising. More about all that later.

    If someone had asked Young Me what I’d be doing in half a century, dabbing colored paper with a glue stick would not have been one of my choices and not just because glue sticks hadn’t been invented back then.

    Another couple of years and I’ll be ready for the Activity Room at the Olde Folkes Home.

  • CNC-3018XL: Reversing the Axes

    CNC-3018XL: Reversing the Axes

    The CNC-3018XL fit into its new home with the Run/Hold buttons toward the front:

    3018CNC - new orientation
    3018CNC – new orientation

    Which is rotated 180° from its previous orientation, putting Quadrant I and the most-positive coordinates in the left-front corner. Rather than stand on my head while trying to use the jog keypad upside-down, I reversed the axis directions by changing the GRBL Direction port invert mask value from its previous 4:

    $3=7

    Because the home switch positions haven’t changed, reverse the Homing dir invert mask from 0:

    $23=3

    The XY origin remains in the center of the platform, so the G54 XY offset didn’t change. The Z offset puts the Pilot pen tip 10 mm above the platform, which will change as you (well, I) touch it off on the paper:

    G10 L2 P1 X-169.0 Y-149.5 Z-44.0

    Jog to the left rear corner (with Z at the home position) and set the G28 park position:

    G28.1

    Jog to the right front corner (also Z homed) where (manual) tool changes take place:

    G30.1

    Configure bCNC for manual tool changes without probing at the G30 position:

    bCNC probe config
    bCNC probe config

    The machine will move to the tool change position at each Tn M6, the operator (that would be me) inserts tool pen n as needed, pokes the Run button, and watches it draw pretty pictures in a resolutely techie manner:

    3018CNC - Spirograph test pattern
    3018CNC – Spirograph test pattern

    For completeness, the current GRBL settings:

    $$
    $0=10
    $1=100
    $2=0
    $3=7
    $4=0
    $5=0
    $6=0
    $10=1
    $11=0.010
    $12=0.020
    $13=0
    $20=1
    $21=0
    $22=1
    $23=3
    $24=100.000
    $25=2000.000
    $26=25
    $27=1.250
    $30=1000
    $31=0
    $32=0
    $100=401.284
    $101=400.000
    $102=400.000
    $110=3000.000
    $111=3000.000
    $112=3000.000
    $120=1000.000
    $121=1000.000
    $122=1000.000
    $130=338.000
    $131=299.000
    $132=44.000
    $#
    [G54:-169.000,-149.500,-34.450]
    [G55:0.000,0.000,0.000]
    [G56:0.000,0.000,0.000]
    [G57:0.000,0.000,0.000]
    [G58:0.000,0.000,0.000]
    [G59:0.000,0.000,0.000]
    [G28:-335.000,-3.310,-3.450]
    [G30:-1.000,-297.000,-1.000]
    [G92:0.000,0.000,0.000]
    [TLO:0.000]
    [PRB:0.000,0.000,0.000:0]
    

    The weird $100 X axis step/mm value is correct, because QC escapes are a thing.

  • CNC-3018XL Setup: Table Riser Blocks

    CNC-3018XL Setup: Table Riser Blocks

    After fixing the X axis drive, the CNC-3018XL table moved properly again, so I measured its overall alignment:

    3018CNC - table height measurement
    3018CNC – table height measurement

    The +Y side (on the left in the photo, keeping in mind I’ve rotated the axes) turned out to be 0.7 mm too low, so I made a set of riser blocks to level the tabletop:

    Table Riser - solid model
    Table Riser – solid model

    The 10 mm height would ram the tip of a Pilot pen about 10 mm below the tabletop surface, were it not for the spring-loaded pen holder:

    Pilot V5RT holder - installed
    Pilot V5RT holder – installed

    The 0.7 mm difference in height levels the tabletop:

    CNC3018XL - table riser positions
    CNC3018XL – table riser positions

    The OpenSCAD code produces an SVG outline I intended to use for a foam pad, but then I found a quartet of springs that worked even better:

    CNC3018XL - table spring mount
    CNC3018XL – table spring mount

    So it’s now aligned within ±0.3-ish mm across the surface, with the unflatness of a slab cut from a 1955-era Formica kitchen countertop accounting for most of the difference in a swale from Quadrant III across the origin to Quadrant I.

    Which a check plot using an old file shows will be Flat Enough for my simple needs:

    CNC3018XL - test plot
    CNC3018XL – test plot

    Having the camera alignment remain exactly spot on came as a pleasant surprise:

    Camera Alignment check
    Camera Alignment check

    The faded cross to the left came from the table’s previous position; there’s no positive index between the countertop slab and the underlying T-slots.

    Part of the motivation for these blocks was to verify PrusaSlicer automagically handles filament / color changes between two objects, as long as OpenSCAD hasn’t unioned them as part of a common transformation. Not having to cut out the socket around the text simplifies the code from what I’d been doing with previous objects.

    The OpenSCAD source code as a GitHub Gist:

    // CNC 3018 table riser blocks
    // Ed Nisley – KE4ZNU
    // 2025-06-29
    include <BOSL2/std.scad>
    Layout = "Show"; // [Show,Build,Outlines]
    /* [Hidden] */
    HoleWindage = 0.2;
    Protrusion = 0.1;
    BlockOA = [40.0,30.0,10.0]; // riser block size
    SlotBlock = [8.0,BlockOA.y,3.0]; // alignment in slot
    BoltOD = 6.0 + HoleWindage; // central bolt
    LogoFont = "Fira Sans Condensed:style=SemiBold";
    LogoSize = 7.5;
    LogoColor = "Red";
    LogoThick = 0.4;
    //———-
    // Define Shapes
    module Riser(thick=1,matl="Block") {
    LogoText = format_fixed(thick,1);
    if (matl == "Text" || matl == "All")
    right(BlockOA.x/4) zrot(90)
    color(LogoColor)
    up(thick + SlotBlock.z + ((matl == "All") ? 0.01 : 0))
    text3d(LogoText,LogoThick + ((matl == "All") ? 0.01 : 0),LogoSize,LogoFont,
    anchor=TOP,atype="ycenter");
    if (matl == "Block" || matl == "All")
    difference() {
    cuboid(SlotBlock,$fn=8*3,anchor=BOTTOM,rounding=2.0,except=[BOTTOM,TOP]) position(TOP)
    cuboid(BlockOA,$fn=8*3,anchor=BOTTOM,rounding=2.0,except=[BOTTOM,TOP]);
    down(Protrusion)
    zrot(180/6)
    cyl(2*BlockOA.z,d=BoltOD,$fn=6,anchor=BOTTOM,circum=true);
    }
    }
    //———-
    // Build things
    if (Layout == "Show")
    down(SlotBlock.z)
    Riser(BlockOA.z,matl="All");
    if (Layout == "Outlines") {
    projection(cut=false)
    Riser(BlockOA.z,matl="Block");
    }
    if (Layout == "Build") {
    up(BlockOA.z + SlotBlock.z) xrot(180)
    Riser(BlockOA.z,matl="Block");
    up(BlockOA.z + SlotBlock.z) xrot(180)
    Riser(BlockOA.z,matl="Text");
    }

  • GIMP 3.0 vs. XSane vs. gimp-xsanecli

    GIMP 3.0 vs. XSane vs. gimp-xsanecli

    For reasons I do not profess to understand, GIMP 3.0 does not work with plugins written for GIMP 2.0, including the XSane plugin that handles scanning. This seems like an obvious oversight, but after three months it also seems to be one of those things that’s like that and that’s the way it is.

    Protracted searching turned up gimp-xsanecli, a GIMP 3.0 plugin invoking XSane through its command-line interface to scan an image into a temporary file, then stuff the file into GIMP. Unfortunately, it didn’t work over the network with the Epson ET-3830 printer / scanner in the basement.

    It turns out gimp-xsanecli tells XSane to output the filename it’s using, then expects to find the identifying XSANE_IMAGE_FILENAME string followed by the filename on the first line of whatever it gets back:

    if result != 'XSANE_IMAGE_FILENAME: ' + png_out:
      Gimp.message('Unexpected XSane result: ' + result)
      return Gimp.ValueArray.new_from_values([GObject.Value(Gimp.PDBStatusType, Gimp.PDBStatusType.EXECUTION_ERROR)])
    
    

    The font ligature that may or may not mash != into is not under my control.

    Protracted poking showed the scanner fires a glob of HTML through proc/stdout into gimp-xsanecli before XSane produces its output, but after the scan completes:

    <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN "
    "http://www.w3.org/TR/html4/strict.dtd">
    <html>
    <head>
    … snippage …
    </head>
    <body><noscript>Enable your browser's JavaScript setting.</noscript></body></HTML>XSANE_IMAGE_FILENAME: /tmp/out.png
    

    Complicating the process:

    • The HTML glob only appears on the first scan, after which XSane produces exactly what gimp-xsanecli expects
    • There is no newline separating the glob from the expected output on the last line

    So …

    Insert a while loop into the main loop to strip off the HTML glob line by line by line:

            while True:
                    # Wait until XSane prints the name of the scanned file, indicating scanning is finished
                    # This blocks Python but that is ok because GIMP UI is not affected
    
                    # discard HTML header added by scanner to first scan
                    while True :
    
                            result = proc.stdout.readline().strip()
    
                            if r'</body>' in result :
                                    result = result.partition(r'</HTML>')[-1]
                            #        Gimp.message('Found end of HTML: ' + result)
                                    break
    
                            elif 'XSANE_IMAGE_FILENAME:' in result :
                            #        Gimp.message('Found filename: ' + result)
                                    break
    
                            else :
                            #        Gimp.message('Discarding: ' + result)
                                    continue
    
                    if result == '':
                            # XSane was closed
                            break
    
                    if result != 'XSANE_IMAGE_FILENAME: ' + png_out:
                            Gimp.message('Unexpected XSane result: ' + result)
                            return Gimp.ValueArray.new_from_values([GObject.Value(Gimp.PDBStatusType, Gimp.PDBStatusType.EXECUTION_ERROR)])
    
                    # Open image
                    image = Gimp.file_load(Gimp.RunMode.NONINTERACTIVE, Gio.File.new_for_path(png_out))
                    Gimp.Display.new(image)
    
                    # Remove temporary files
                    os.unlink(png_out)
    
                    if not SCAN_MULTIPLE:
                            proc.terminate()
                            break
    
            os.rmdir(tempdir)
    
            return Gimp.ValueArray.new_from_values([GObject.Value(Gimp.PDBStatusType, Gimp.PDBStatusType.SUCCESS), GObject.Value(Gimp.Image.__gtype__, image)])
    
    

    While it’s tempting to absorb the whole thing in one gulp with proc.stdout.read().strip(), that doesn’t work because nothing arrives until the XSane subprocess terminates, which is not what you want.

    A scan to show It Just Works™ :

    I expect it doesn’t work under a variety of common conditions, but … so far so good.

  • HQ Sixteen: Nose Ring Lights Power Supply

    HQ Sixteen: Nose Ring Lights Power Supply

    With the quilt off the HQ Sixteen, I could install the 24 V power supply for the Nose Ring Lights:

    HQ Sixteen Nose Ring Lights - power supply installed
    HQ Sixteen Nose Ring Lights – power supply installed

    IMO, black nylon screws look spiffier than brass.

    The solid model shows the covers have a 2 mm overlap with the power supply case to keep them lined up:

    HQ Sixteen Nose Ring Lights - power supply cover - solid model
    HQ Sixteen Nose Ring Lights – power supply cover – solid model

    I managed to reuse three of the five holes from the previous 12 V power supply and drill only three more:

    HQ Sixteen Nose Ring Lights - power supply detail
    HQ Sixteen Nose Ring Lights – power supply detail

    The tops of the power supply ears aren’t quite flat, giving the standoffs a slight tilt that the covers mostly drag back into alignment.

    The M4 brass standoffs screw into holes tapped in the thick plastic, thus eliminating nuts inside the power pod:

     HQ Sixteen Nose Ring Lights - power supply wiring
    HQ Sixteen Nose Ring Lights – power supply wiring

    The yellow silicone tape wraps two pairs of Wago connectors that dramatically simplify electrical connections in anything with enough space for their chonky bodies.

    In the unlikely event you need such things, the original post links the OpenSCAD source code.

    With the power supply in place, I think I can put some LED strips under the arm of the machine to light up more of the quilt than the nose lights can reach. More pondering is in order.

  • WS-5000 Anemometer Bird Spike Ring

    WS-5000 Anemometer Bird Spike Ring

    A critter made off with our battered plastic rain gauge, so I set up an Ambient Weather WS-5000 station to tell Mary how much rain her garden was getting. I added the Official Bird Spike Ring around the rain gauge to keep birds off, but robins began perching atop the anemometer while surveying the yard and crapping on the insolation photocell.

    After a few false starts, the anemometer now has its own spikes:

    Weather station with additional spikes
    Weather station with additional spikes

    It’s a snugly fitting TPU ring:

    Weather Station Spikes - build test piece
    Weather Station Spikes – build test piece

    The spikes are Chromel A themocouple wire, because a spool of the stuff didn’t scamper out of the way when I opened the Big Box o’ Specialty Wire. As you can tell from the picture, it’s very stiff (which is good for spikes) and hard to straighten (which is bad for looking cool).

    The shape in the middle is a hole diameter test piece. Next time around, I’ll use thicker 14 AWG copper wire:

    Weather station spikes - test piece
    Weather station spikes – test piece

    The test piece showed I lack good control over the TPU extrusion parameters on the Makergear M2, as holes smaller than about 2 mm vanish, even though the block’s outside dimensions are spot on. This application wasn’t too critical, so I sharpened the wire ends and stabbed them into the middle of the perimeter threads encircling the hole.

    Now we’ll discover how TPU survives weather.

    The OpenSCAD source code as a GitHub Gist:

    // Ambient Weather – Ambient Weather WS-5000 anemometer bird spike ring
    // Ed Nisley – KE4ZNU
    // 2025-06-09
    include <BOSL2/std.scad>
    Layout = "Show"; // [Show,Build,Slice]
    /* [Hidden] */
    HoleWindage = 0.2;
    Protrusion = 0.1;
    ID = 0;
    OD = 1;
    LENGTH = 2;
    SpikeOC = 30.0; // straight-line distance between spikes, OEM = 35
    WallThick = 4.0;
    BandID = 3.5*INCH – 0.5; // = OD of weather station
    BandOD = BandID + 2*WallThick;
    BandHeight = 8.0;
    SpikeOD = 1.7 + HoleWindage; // wire diameter
    SpikeWall = 2.0; // around wires
    SpikeBCD = BandOD;
    MountOD = SpikeOD + 2*SpikeWall;
    NumSpikes = ceil(PI*BandOD/SpikeOC); // need integral number of spikes
    SpikeAngle = 360/NumSpikes;
    NumSides = 3*NumSpikes;
    echo(SpikeAngle=SpikeAngle);
    echo(NumSpikes=NumSpikes);
    //———-
    // Define Shapes
    module Slice() {
    difference() {
    hull() {
    pie_slice(h=BandHeight,d=BandOD,$fn=NumSides,ang=SpikeAngle,spin=-SpikeAngle/2,anchor=BOTTOM);
    right(SpikeBCD/2 – MountOD/2)
    cyl(h=BandHeight,d=MountOD,realign=true,anchor=LEFT+BOTTOM,$fn=2*6);
    }
    down(Protrusion) {
    cyl(h=BandHeight + 2*Protrusion,d=BandID,$fn=NumSides,circum=true,realign=true,anchor=BOTTOM);
    right(SpikeBCD/2)
    cyl(h=BandHeight + 2*Protrusion,d=SpikeOD,$fn=6,circum=true,realign=true,anchor=BOTTOM);
    }
    }
    }
    module SpikeRing() {
    for (i=[0:NumSpikes-1])
    zrot(i*SpikeAngle)
    Slice();
    }
    //———-
    // Build things
    if (Layout == "Slice") {
    Slice();
    }
    if (Layout == "Show") {
    left(SpikeBCD/2)
    Slice();
    SpikeRing();
    }
    if (Layout == "Build") {
    SpikeRing();
    }