Wednesday, November 19, 2025

SVG contour map clipping

I've gone back to trying to figure out how to generate topographical data with contour maps.

I found a youtube that ended up pointing me to contours.axismaps.com, where I could generate a topographical map of an area, and download it as SVG.

On inspecting the SVG output, it was pretty simple.  It was constructed as an <svg> tag followed by a number of individual <path> tags.  Each <path> has a "d" attribute that contains all the Move and Line (M x,y L x,y L x,y L x,y L x,y L x,y...) definitions.  A "d" attribute in SVG can have all sorts of other things in it, like cubic Beziers, but this particular app doesn't generate them.

The "d" attribute actually also could contain multiple MLLLL values, so really, there were subpaths within the path.  The <path> tag has an "id" attribute that declares the elevation for that path (and all its subpaths).

The "d" attribute also is highly granular.  If you look at it, you'll find that it has tons of L movements that are only 1 numeric value apart.

If you generate a contour that, for example, has 100 foot elevation separations, and you're rendering a somewhat hilly region, you'll end up with a very large output file.  Mine was 57 MB.

As well, with that many segments, it makes Inkscape crawl.

Here's what it looks like when rendered.

 

What the picture above doesn't show is how slow it is to read and render.  It's really slow. 

I had a goal to clip the contours to a geometry, perhaps to use the lines for laser etching.  But due to the level of detail in the contour lines, it would still be slow when clipped.

Clipping

My initial attempt at clipping the lines with my own code was just to use a circle, and do a really crappy approach.  Since it seemed the segments were really small, I made the code include segments where both start and end points were within a given radius of a center point, and exclude others.  That isn't really "clipping".  But it's close enough for what I'm doing.

In addition, to address the contour line detail level, I chose to output only a subset of the segments.

The overall sequence was like this:

  • Read the file using  svgelements.SVG.parse.
  • Emit the <svg> element
  • For each <path> element, find its "d" attribute, and break the MLLLL values into subpaths.
  • For each subpath, clip the subpath according to the circle.  The result will result in zero, one, or multiple subsubpaths. 
  • For each subsubpath, emit a different set of MLLLL values.  In the end, I chose to keep the first 8 and last 8 vectors, and only every 8th segment in between.  This made it so that small islands would be preserved, but long ones would drop to about 1/8th their precision (but keep precise points at the ends). 
  • Close off the paths
  • Add an extra SVG element to show the clipping object. 

Here's the result of the crude, circular clip.

 

Later, I changed the "clip" to extend clipped segments to the circle edge but still in a cheap way, so it was not mathematically correct, but would yield segments that would truly join to the circle.

The next thing I did was to do rectangular clip.  Again, I did a cheap clipping mechanism, just checking to see if both endpoints of a segment were within the (x1,y1) to (x2,y2) definition of the rectangle.  Still, not mathematically clipping.

Here's the rectangle clip result:

 

Finally, I read that there is another system called shapely that provides a native mechanism for clipping.  It could do rectangle clipping, or arbitrary polygon clipping.  For rectangle clipping, it provides shapely.clip_by_rect, which does an efficient clip.  This probably uses the classic computer graphics algorithm for intersection detection, edge crossing, and real clip computation.

To use this, I had to switch the coordinate system to be shapely-friendly.  One of the really odd things about the svgelements library is that (x,y) values are represented as a single, complex number (e.g., (100,200) is 100.0+200.0j).  

shapely, on the other hand, depends on having LineString, MultiLineString, and Polygon geometries.  Each of those has coordinates, but the coordinates are in a more typical format of an array of (x,y) values. 

Here's the result.

 

The difference is negligible, both in computation time and result, but the shapely approach seems much more reliable.

 Later, I also tried the arbitrary polygon clipping mechanism, using a manually defined set of coordinates.  Here's the result.

 

I'm now trying to move to reading an existing SVG polygon to use for clipping, but handling holes as "interior" vs "exterior" polygon geometries is proving to be difficult.

 

 

Laser marking tile

 CO2 Laser marking on tile

Below are some results I've gotten when laser marking tile.

I'm using

  • 4"x4" glossy, white tiles
  • Full Spectrum Laser hobby laser (about 14" x 9.5" bed) 
  • Full Spectrum Retina Engrave (RE)

For engraving, the settings have varied.

  1. The native dithering algorithm provided by RE doesn't have a declared name, but because of that, and because it doesn't yield a high contrast image, I think we can guess it's Floyd-Steinberg.
  2. RE allows for 250dpi, 500dpi, and 1000dpi, but if you're not careful in your order of operations, you can get stuck with 250dpi when you want it higher.
  3. Each test is run with raster speed (defined in RE), raster power (defined in RE), and also the current (set using a multiturn pot on the main board).  Based on experiments, I'm guessing that "raster power" in RE actually controls things using PWM, but somehow the current setting does not.  I got some odd banding using 80% raster power, but when I bumped it to 100%, the banding went away. 

In early experiments, I tried the "Norton" method that involves creating a mixture of food-grade TiO2 and other substances (typically isopropyl alcohol, maybe some Elmer's glue), spraying that onto the tile, and letting it dry.  I found that approach to be difficult, mainly because it was clumping and would kill my spray bottles.

I switched to using a white spray paint that contains TiO2, or at least the Internet said it does.  

Spray paint: Rust-oleum Professional High Performance Enamel, 15 minute fast dry, Any-Angle Spray. 

For the image, I chose an old black and white photo, and brought it into Photoshop Elements to force it to 500 dpi.  Then, I pulled that directly into RE (that is, I did not read it in Inkscape and use the FSL print driver to get it to RE), and made sure to set the rastering DPI to 500. 

Here's the first set of results.

 

These images used the native RE dithering.  The left side used 80% power, whereas the right side had 100% power.  If you look closely, you'll see vertical banding.

In each case, the tile was sprayed, dried, and then "engraved".  Then, I would let it cool, wipe with paint thinner (using a paper towel), and then clean with soap and water.

I noticed that the 100% power tile wasn't all that great, so I tried using multiple spray levels.  Here's what I got:

In this example, the left side is the same as what I'd had in the prior photo.  But the right side had three layers of paint.  That came out much better.  However, it still didn't have the dark blacks I wanted.

I switched two things for the next experiment.  First, I'd read that the Atkinson Dithering method would be provide greater contrast.  I wrote a Python program that did the Atkinson dithering for me, based on a number of web examples.  The program I settled on used Numpy and numba for speed.  One trick to this is that you have to do extra steps to write out the file (from Python) with a DPI declaration, or else RE will assume a different resolution.

With the image at 500 DPI and dithered using Atkinson, I would then read the file into RE, but set its image rendering to "raw", so it just treats each black and white pixel as it is, and doesn't do any further dithering.

Then, I changed the paint to a primer that I'd heard about online.

Paint: Zinsser Bulls Eye 1-2-3 Primer for all projects, Bright White, Interior/Exterior.  (This still is a Rust-Oleum product.)

Here's the result:

 

The left side is the same photo from earlier, using RE dithering and the prior paint.  The right side is Atkinson with the Zinsser Bulls Eye 1-2-3.  The right-side tile has not been cleaned much at all.

Overally, I'm getting much blacker blacks with the Zinsser.

In one not-shown experiment, I painted with Zinsser, lased, and then wiped with 70% isopropyl alcohol instead of paint thinner.  It pretty much wiped off the marked image!  That's why I haven't (yet) wiped off the newly marked tile.

In the end, the good result appears to be:

  • 4" x 4" glossy white tile
  • 3 coats of Zinsser Bulls Eye 1-2-3
  • Atkinson dithering (local Python program); render as "raw" in RE
  • Engrave at 500 dpi 
  • Raster power 100%
  • Raster speed 70%
  • 7 turns of the current pot (which equates to 70% current) 

I've heard that a good result can be had by using two layers of Zinsser and one layer of High Performance, so I'll try that, too.