Skip to content

Instantly share code, notes, and snippets.

@migurski
Created January 5, 2025 02:26
Show Gist options
  • Save migurski/53212c0f4505cb41987c319f334f3995 to your computer and use it in GitHub Desktop.
Save migurski/53212c0f4505cb41987c319f334f3995 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
import itertools
import typing
import math
import cairo
import shapely.affinity
import shapely.geometry
DISTANCE_CUTOFF = 2 # max jump length to bridge with a curve
OUTLINE_DISTANCE = .5 # ideally half the line width
INSET_DISTANCE = .9 # distance from outside edge to begin hatching
HATCH_OVERLAP = .3 # amount to overlap hatch lines to account for slop; 0 = no overlap
CURVE_JOIN_DISTANCE = .25
BACK_AND_FORTH = True
class Segment (typing.NamedTuple):
'''
'''
row: int
x1: float
y1: float
x2: float
y2: float
def distance(self, other):
''' Distance from self end to other beginning
'''
return math.hypot(self.x2 - other.x1, self.y2 - other.y1)
class Fragment (typing.NamedTuple):
'''
'''
p1: int
p2: int
p: int
class MultiPath (typing.NamedTuple):
'''
'''
points: shapely.geometry.MultiPoint
fragments: typing.List[typing.List[Fragment]]
def get_hatch_segment_xs(xmin, xmax):
'''
'''
if not BACK_AND_FORTH:
# All parallel lines point in the same direction and ends do not connect
return itertools.cycle([(xmax, xmin)])
return itertools.cycle([(xmin, xmax), (xmax, xmin)])
def fill_hatch_segments(polygon):
'''
'''
assert polygon.type == 'Polygon'
xmin, y, xmax, ymax = polygon.bounds
rows, xs = itertools.count(), get_hatch_segment_xs(xmin, xmax)
segments = []
for (row, (x1, x2)) in zip(rows, xs):
line = shapely.geometry.LineString([(x1, y), (x2, y)])
if y > ymax:
break
elif not line.intersects(polygon):
continue
else:
y += 1
scan = line.intersection(polygon)
segments.extend([
Segment(
row,
part.coords[0][0],
part.coords[0][1],
part.coords[-1][0],
part.coords[-1][1],
)
for part in getattr(scan, 'geoms', [scan])
if part.type == 'LineString'
])
return segments
def fill_hatch_sequences(polygon):
'''
'''
assert polygon.type == 'Polygon'
distance_cutoff = DISTANCE_CUTOFF if BACK_AND_FORTH else 0
segments = fill_hatch_segments(polygon)
sequences = []
while segments:
segment = segments.pop(0)
sequence = [(segment.x1, segment.y1), (segment.x2, segment.y2)]
while True:
others = [
(i, other)
for (i, other) in enumerate(segments)
if abs(other.row - segment.row) == 1
and segment.distance(other) < distance_cutoff
]
if not others:
sequences.append(sequence)
break
others.sort(key=lambda o: segment.distance(o[1]))
segment = segments.pop(others[0][0])
prior_length = abs(sequence[-1][-2] - sequence[-2][-2])
follow_length = abs(segment.x2 - segment.x1)
prior_diff = min(CURVE_JOIN_DISTANCE, prior_length)
follow_diff = min(CURVE_JOIN_DISTANCE, follow_length)
if segment.row % 2 == 0:
# pointing right
prior_diff, follow_diff = -prior_diff, -follow_diff
sequence[-1] = tuple(
list(sequence[-1][:-2]) + [sequence[-1][-2] - prior_diff, sequence[-1][-1]]
)
x3 = segment.x1 - follow_diff
y3 = segment.y1
x1, y1 = sequence[-1][-2] + 2*prior_diff, sequence[-1][-1]
x2, y2 = x3 + 2*follow_diff, y3
sequence.extend([
(x1, y1, x2, y2, x3, y3),
(segment.x2, segment.y2),
])
return sequences
def inscribed_hatch_multipath(polygon, scale, angle=None):
'''
'''
assert polygon.type == 'Polygon'
# Add a bit of overlap to the hatched lines when the Axidraw is sloppy
_scale = scale / (1.0 + HATCH_OVERLAP)
# Track the best option of the angle deltas we try
_count, multipath = math.inf, None
# Try several candidate angle deltas?
angles = [angle] if angle is not None else (0, 26, 52, 77, 103, 129, 154)
for _angle in angles:
transformed_polygon = shapely.affinity.rotate(
shapely.affinity.scale(polygon, 1/_scale, 1/_scale, origin=(0, 0)),
_angle,
origin=(0, 0),
)
transformed_multipath = native_hatch_multipath(transformed_polygon)
untransformed_points = shapely.affinity.scale(
shapely.affinity.rotate(transformed_multipath.points, -_angle, origin=(0, 0)),
_scale,
_scale,
origin=(0, 0),
)
if len(transformed_multipath.fragments) < _count:
# Choose a new, better multipath candidate
_count = len(transformed_multipath.fragments)
multipath = MultiPath(untransformed_points, transformed_multipath.fragments)
if _count <= 3:
# Eh, good enough
break
return multipath
def native_hatch_multipath(polygon):
'''
'''
assert polygon.type == 'Polygon'
points, fragments = [], []
outline = polygon.buffer(-OUTLINE_DISTANCE).boundary
for line in getattr(outline, 'geoms', [outline]):
if line.type == 'LineString' and True:
fragments.append([
Fragment(None, None, i)
for i in range(len(points), len(points) + len(line.coords))
])
points.extend(line.coords)
inset_multipolygon = polygon.buffer(-INSET_DISTANCE)
if inset_multipolygon.type not in ('Polygon', 'MultiPolygon') or inset_multipolygon.area == 0:
return MultiPath(shapely.geometry.MultiPoint(points), fragments)
for inset_polygon in getattr(inset_multipolygon, 'geoms', [inset_multipolygon]):
for sequence in fill_hatch_sequences(inset_polygon):
head, tail = sequence[0], sequence[1:]
assert len(head) == 2
fragments.append([Fragment(None, None, len(points))])
points.append(head[-2:])
for part in tail:
assert len(part) in (2, 6)
if len(part) == 2:
fragments[-1].append(Fragment(None, None, len(points)))
points.append(part)
else:
fragments[-1].append(Fragment(len(points), len(points)+1, len(points)+2))
points.extend([part[:2], part[2:4], part[4:6]])
return MultiPath(shapely.geometry.MultiPoint(points), fragments)
def draw_shapely_line_geom(ctx, geom):
if getattr(geom, 'geoms', False):
parts = [part for part in geom.geoms]
else:
parts = [geom]
for part in parts:
try:
coords = part.coords
except NotImplementedError:
continue
if not coords:
continue
ctx.move_to(*coords[0])
for coord in coords[1:]:
ctx.line_to(*coord)
def draw_multipath_strokes(ctx, multipath):
'''
'''
for fragments in multipath.fragments:
head, tail = fragments[0], fragments[1:]
p = multipath.points.geoms[head.p]
ctx.move_to(p.x, p.y)
for fragment in tail:
if fragment.p1 is None and fragment.p2 is None:
p = multipath.points.geoms[fragment.p]
ctx.line_to(p.x, p.y)
else:
p1 = multipath.points.geoms[fragment.p1]
p2 = multipath.points.geoms[fragment.p2]
p = multipath.points.geoms[fragment.p]
ctx.curve_to(p1.x, p1.y, p2.x, p2.y, p.x, p.y)
ctx.stroke()
def calculate_hatch_angle(shape):
'''
'''
rectangle = shape.minimum_rotated_rectangle.exterior
(x1, y1), (x2, y2), (x3, y3) = rectangle.coords[:3]
if math.hypot(x3 - x2, y3 - y2) < math.hypot(x2 - x1, y2 - y1):
return 180 * math.atan2(y2 - y3, x3 - x2) / math.pi
else:
return 180 * math.atan2(y1 - y2, x2 - x1) / math.pi
if __name__ == '__main__':
font_svg = __import__('font-svg')
color_cube = __import__('color-cube')
FONT = font_svg.load_font('fonts/EMSReadability.svg')
with cairo.SVGSurface('out.svg', 8.5*72, 5.5*72) as surface:
context = cairo.Context(surface)
hotdog1 = shapely.geometry.LineString([(72, 72), (144, 144)]).buffer(36, 5)
draw_multipath_strokes(
context,
inscribed_hatch_multipath(hotdog1, 9, None),
)
hotdog2 = shapely.geometry.LineString([(216, 72), (252, 144)]).buffer(36, 5)
draw_multipath_strokes(
context,
inscribed_hatch_multipath(hotdog2, 6, None),
)
hotdog3 = shapely.geometry.LineString([(288, 72), (360, 108)]).buffer(36, 5)
draw_multipath_strokes(
context,
inscribed_hatch_multipath(hotdog3, 3, None),
)
context.stroke()
Display the source blob
Display the rendered blob
Raw
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="612pt" height="396pt" viewBox="0 0 612 396" version="1.1">
<g id="surface1">
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:10;" d="M 120.800781 166.816406 L 129.246094 172.953125 L 138.917969 176.09375 L 149.082031 176.09375 L 158.753906 172.953125 L 166.976562 166.976562 L 172.953125 158.753906 L 176.09375 149.082031 L 176.09375 138.917969 L 172.953125 129.246094 L 166.816406 120.800781 L 95.199219 49.183594 L 86.753906 43.046875 L 77.082031 39.90625 L 66.917969 39.90625 L 57.246094 43.046875 L 49.023438 49.023438 L 43.046875 57.246094 L 39.90625 66.917969 L 39.90625 77.082031 L 43.046875 86.753906 L 49.183594 95.199219 L 120.800781 166.816406 "/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:10;" d="M 91.058594 49.597656 L 54.671875 49.597656 C 51.210938 49.597656 45.265625 56.519531 48.730469 56.519531 L 96.890625 56.519531 C 100.351562 56.519531 107.273438 63.441406 103.8125 63.441406 L 45.675781 63.441406 C 42.214844 63.441406 40.941406 70.367188 44.40625 70.367188 L 110.734375 70.367188 C 114.195312 70.367188 121.121094 77.289062 117.660156 77.289062 L 44.613281 77.289062 C 41.152344 77.289062 43.402344 84.210938 46.863281 84.210938 L 124.582031 84.210938 C 128.042969 84.210938 134.964844 91.136719 131.503906 91.136719 L 51.382812 91.136719 C 47.921875 91.136719 54.226562 98.058594 57.6875 98.058594 L 138.429688 98.058594 C 141.890625 98.058594 148.8125 104.980469 145.351562 104.980469 L 64.613281 104.980469 C 61.152344 104.980469 68.074219 111.90625 71.535156 111.90625 L 152.273438 111.90625 C 155.734375 111.90625 162.660156 118.828125 159.195312 118.828125 L 78.457031 118.828125 C 74.996094 118.828125 81.921875 125.75 85.382812 125.75 L 165.257812 125.75 C 168.722656 125.75 172.886719 132.675781 169.425781 132.675781 L 92.304688 132.675781 C 88.84375 132.675781 95.765625 139.597656 99.226562 139.597656 L 171.59375 139.597656 C 175.058594 139.597656 175.058594 146.519531 171.59375 146.519531 L 106.152344 146.519531 C 102.6875 146.519531 109.613281 153.441406 113.074219 153.441406 L 170.035156 153.441406 C 173.496094 153.441406 170.089844 160.367188 166.628906 160.367188 L 119.996094 160.367188 C 116.535156 160.367188 124.433594 167.289062 127.894531 167.289062 L 161.835938 167.289062 "/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:10;" d="M 221.785156 158.90625 L 228.015625 167.621094 L 236.488281 173.878906 L 246.480469 177.207031 L 257.011719 177.289062 L 267.054688 174.109375 L 275.621094 167.984375 L 281.878906 159.511719 L 285.207031 149.519531 L 285.289062 138.988281 L 282.054688 128.769531 L 246.214844 57.09375 L 239.984375 48.378906 L 231.511719 42.121094 L 221.519531 38.792969 L 210.988281 38.710938 L 200.945312 41.890625 L 192.378906 48.015625 L 186.121094 56.488281 L 182.792969 66.480469 L 182.710938 77.011719 L 185.945312 87.230469 L 221.785156 158.90625 "/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:10;" d="M 232.539062 45.175781 L 200.679688 45.175781 C 198.371094 45.175781 192.207031 49.789062 194.515625 49.789062 L 237.570312 49.789062 C 239.878906 49.789062 243.179688 54.40625 240.871094 54.40625 L 191.109375 54.40625 C 188.800781 54.40625 186.070312 59.023438 188.378906 59.023438 L 243.960938 59.023438 C 246.269531 59.023438 248.578125 63.636719 246.269531 63.636719 L 186.839844 63.636719 C 184.53125 63.636719 183.472656 68.253906 185.777344 68.253906 L 248.578125 68.253906 C 250.886719 68.253906 253.191406 72.867188 250.886719 72.867188 L 185.742188 72.867188 C 183.4375 72.867188 183.644531 77.484375 185.949219 77.484375 L 253.191406 77.484375 C 255.5 77.484375 257.808594 82.097656 255.5 82.097656 L 187.410156 82.097656 C 185.105469 82.097656 186.597656 86.714844 188.90625 86.714844 L 257.808594 86.714844 C 260.117188 86.714844 262.421875 91.328125 260.117188 91.328125 L 191.214844 91.328125 C 188.90625 91.328125 191.214844 95.945312 193.519531 95.945312 L 262.421875 95.945312 C 264.730469 95.945312 267.039062 100.558594 264.730469 100.558594 L 195.828125 100.558594 C 193.519531 100.558594 195.828125 105.175781 198.136719 105.175781 L 267.039062 105.175781 C 269.347656 105.175781 271.65625 109.789062 269.347656 109.789062 L 200.445312 109.789062 C 198.136719 109.789062 200.445312 114.40625 202.75 114.40625 L 271.65625 114.40625 C 273.960938 114.40625 276.269531 119.023438 273.960938 119.023438 L 205.058594 119.023438 C 202.75 119.023438 205.058594 123.636719 207.367188 123.636719 L 276.269531 123.636719 C 278.578125 123.636719 280.886719 128.253906 278.578125 128.253906 L 209.675781 128.253906 C 207.367188 128.253906 209.675781 132.867188 211.984375 132.867188 L 280.261719 132.867188 C 282.570312 132.867188 284.03125 137.484375 281.722656 137.484375 L 214.289062 137.484375 C 211.984375 137.484375 214.289062 142.097656 216.597656 142.097656 L 282.265625 142.097656 C 284.570312 142.097656 284.535156 146.714844 282.230469 146.714844 L 218.90625 146.714844 C 216.597656 146.714844 218.90625 151.328125 221.214844 151.328125 L 281.503906 151.328125 C 283.8125 151.328125 282.273438 155.945312 279.964844 155.945312 L 223.519531 155.945312 C 221.214844 155.945312 224.082031 160.558594 226.390625 160.558594 L 277.652344 160.558594 C 279.960938 160.558594 276.554688 165.175781 274.246094 165.175781 L 229.691406 165.175781 C 227.382812 165.175781 232.910156 169.789062 235.214844 169.789062 L 269.921875 169.789062 "/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:10;" d="M 260 174.40625 L 243.914062 174.40625 "/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:10;" d="M 344.335938 139.125 L 354.8125 142.441406 L 365.710938 142.359375 L 376.046875 138.914062 L 384.816406 132.441406 L 391.15625 123.578125 L 394.441406 113.1875 L 394.359375 102.289062 L 390.914062 91.953125 L 384.441406 83.183594 L 375.503906 76.792969 L 303.664062 40.875 L 293.1875 37.558594 L 282.289062 37.640625 L 271.953125 41.085938 L 263.183594 47.558594 L 256.84375 56.421875 L 253.558594 66.8125 L 253.640625 77.710938 L 257.085938 88.046875 L 263.558594 96.816406 L 272.496094 103.207031 L 344.335938 139.125 "/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:10;" d="M 270.769531 43.105469 L 256.078125 61.910156 "/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:10;" d="M 254.488281 67.695312 L 275.0625 41.359375 C 275.773438 40.449219 279.734375 39.128906 279.023438 40.039062 L 254.871094 70.953125 C 254.160156 71.863281 254.1875 75.574219 254.898438 74.664062 L 282.753906 39.011719 C 283.464844 38.101562 286.410156 38.078125 285.699219 38.988281 L 255.21875 78.003906 C 254.507812 78.914062 255.382812 81.539062 256.09375 80.632812 L 288.644531 38.964844 C 289.355469 38.058594 292.300781 38.035156 291.589844 38.945312 L 256.96875 83.257812 C 256.257812 84.167969 257.132812 86.796875 257.84375 85.886719 L 294.164062 39.402344 C 294.875 38.492188 297.222656 39.234375 296.511719 40.144531 L 259 88.152344 C 258.292969 89.0625 259.714844 90.992188 260.425781 90.082031 L 298.859375 40.886719 C 299.570312 39.976562 301.917969 40.722656 301.207031 41.628906 L 261.847656 92.007812 C 261.136719 92.917969 262.558594 94.84375 263.269531 93.9375 L 303.492188 42.453125 C 304.203125 41.542969 306.308594 42.59375 305.597656 43.503906 L 264.730469 95.8125 C 264.023438 96.722656 265.902344 98.066406 266.609375 97.15625 L 307.707031 44.558594 C 308.414062 43.648438 310.523438 44.703125 309.8125 45.609375 L 268.488281 98.5 C 267.78125 99.410156 269.660156 100.75 270.367188 99.84375 L 311.917969 46.664062 C 312.628906 45.753906 314.734375 46.808594 314.023438 47.714844 L 272.246094 101.1875 C 271.539062 102.09375 273.511719 103.316406 274.222656 102.40625 L 316.128906 48.769531 C 316.839844 47.859375 318.945312 48.914062 318.234375 49.824219 L 276.328125 103.460938 C 275.617188 104.367188 277.722656 105.421875 278.433594 104.511719 L 320.339844 50.875 C 321.050781 49.964844 323.15625 51.019531 322.445312 51.929688 L 280.539062 105.566406 C 279.832031 106.472656 281.9375 107.527344 282.644531 106.617188 L 324.550781 52.980469 C 325.261719 52.070312 327.367188 53.125 326.65625 54.035156 L 284.753906 107.671875 C 284.042969 108.582031 286.148438 109.632812 286.859375 108.722656 L 328.765625 55.085938 C 329.472656 54.179688 331.582031 55.230469 330.871094 56.140625 L 288.964844 109.777344 C 288.253906 110.6875 290.359375 111.738281 291.070312 110.828125 L 332.976562 57.191406 C 333.6875 56.285156 335.792969 57.335938 335.082031 58.246094 L 293.175781 111.882812 C 292.464844 112.792969 294.570312 113.84375 295.28125 112.9375 L 337.1875 59.296875 C 337.898438 58.390625 340.003906 59.441406 339.292969 60.351562 L 297.386719 113.988281 C 296.675781 114.898438 298.78125 115.949219 299.492188 115.042969 L 341.398438 61.40625 C 342.109375 60.496094 344.214844 61.546875 343.503906 62.457031 L 301.597656 116.09375 C 300.890625 117.003906 302.996094 118.058594 303.703125 117.148438 L 345.609375 63.511719 C 346.320312 62.601562 348.425781 63.652344 347.714844 64.5625 L 305.8125 118.199219 C 305.101562 119.109375 307.207031 120.164062 307.917969 119.253906 L 349.824219 65.617188 C 350.53125 64.707031 352.640625 65.761719 351.929688 66.667969 L 310.023438 120.304688 C 309.3125 121.214844 311.417969 122.269531 312.128906 121.359375 L 354.035156 67.722656 C 354.746094 66.8125 356.851562 67.867188 356.140625 68.773438 L 314.234375 122.410156 C 313.523438 123.320312 315.628906 124.375 316.339844 123.464844 L 358.246094 69.828125 C 358.957031 68.917969 361.0625 69.972656 360.351562 70.882812 L 318.445312 124.519531 C 317.734375 125.425781 319.839844 126.480469 320.550781 125.570312 L 362.457031 71.933594 C 363.167969 71.023438 365.273438 72.078125 364.5625 72.988281 L 322.65625 126.625 C 321.949219 127.53125 324.054688 128.585938 324.761719 127.675781 L 366.667969 74.039062 C 367.378906 73.128906 369.484375 74.183594 368.773438 75.09375 L 326.871094 128.730469 C 326.160156 129.640625 328.265625 130.691406 328.976562 129.78125 L 370.882812 76.144531 C 371.589844 75.238281 373.699219 76.289062 372.988281 77.199219 L 331.082031 130.835938 C 330.371094 131.746094 332.476562 132.796875 333.1875 131.886719 L 375.046875 78.308594 C 375.757812 77.402344 377.636719 78.742188 376.925781 79.652344 L 335.292969 132.941406 C 334.582031 133.851562 336.6875 134.902344 337.398438 133.996094 L 378.804688 80.996094 C 379.515625 80.085938 381.394531 81.429688 380.683594 82.339844 L 339.503906 135.046875 C 338.792969 135.957031 340.898438 137.007812 341.609375 136.101562 L 382.5625 83.683594 C 383.273438 82.773438 384.90625 84.433594 384.195312 85.339844 L 343.714844 137.152344 C 343.007812 138.0625 345.203125 139 345.914062 138.089844 L 385.617188 87.269531 C 386.328125 86.359375 387.753906 88.285156 387.042969 89.195312 L 348.261719 138.832031 C 347.550781 139.742188 349.898438 140.488281 350.609375 139.578125 L 388.464844 91.121094 C 389.175781 90.214844 390.539062 92.21875 389.828125 93.128906 L 352.957031 140.320312 C 352.246094 141.230469 354.59375 141.972656 355.304688 141.0625 L 390.703125 95.753906 C 391.414062 94.847656 392.289062 97.472656 391.578125 98.382812 L 358.25 141.042969 C 357.539062 141.953125 360.484375 141.929688 361.195312 141.019531 L 392.453125 101.011719 C 393.164062 100.101562 393.800781 103.035156 393.089844 103.941406 L 364.140625 140.996094 C 363.429688 141.90625 366.78125 141.367188 367.492188 140.457031 L 393.121094 107.65625 C 393.828125 106.746094 393.859375 110.457031 393.148438 111.367188 L 371.449219 139.136719 C 370.742188 140.046875 374.699219 138.726562 375.410156 137.816406 L 392.671875 115.722656 "/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:10;" d="M 390.675781 122.027344 L 381.554688 133.703125 "/>
</g>
</svg>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment