WIP
This commit is contained in:
commit
48a9fcf678
2 changed files with 496 additions and 0 deletions
169
bruder.py
Normal file
169
bruder.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import tempfile
|
||||
import re
|
||||
import copy
|
||||
import subprocess
|
||||
import shlex
|
||||
import os
|
||||
import sys
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from svg_util import setup_svg, Tag, parse_path_d, Transform
|
||||
|
||||
|
||||
def run_cargo_command(binary, *args, **kwargs):
|
||||
cmd_args = []
|
||||
for key, value in kwargs.items():
|
||||
if value is not None:
|
||||
if value is False:
|
||||
continue
|
||||
|
||||
cmd_args.append(f'--{key.replace("_", "-")}')
|
||||
|
||||
if value is not True:
|
||||
cmd_args.append(value)
|
||||
cmd_args.extend(map(str, args))
|
||||
|
||||
# By default, try a number of options:
|
||||
candidates = [
|
||||
# somewhere in $PATH
|
||||
binary,
|
||||
# wasi-wrapper in $PATH
|
||||
f'wasi-{binary}',
|
||||
# in user-local cargo installation
|
||||
Path.home() / '.cargo' / 'bin' / binary,
|
||||
# wasi-wrapper in user-local pip installation
|
||||
Path.home() / '.local' / 'bin' / f'wasi-{binary}',
|
||||
# next to our current python interpreter (e.g. in virtualenv)
|
||||
str(Path(sys.executable).parent / f'wasi-{binary}')
|
||||
]
|
||||
|
||||
# if envvar is set, try that first.
|
||||
if (env_var := os.environ.get(binary.upper())):
|
||||
candidates = [env_var, *candidates]
|
||||
|
||||
for cand in candidates:
|
||||
try:
|
||||
res = subprocess.run([cand, *cmd_args], check=True)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
else:
|
||||
raise SystemError(f'{binary} executable not found')
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
@cli.command()
|
||||
@click.option('--num-rows', type=int, default=5, help='Number of tapes')
|
||||
@click.option('--tape-width', type=float, default=24, help='Width of tape')
|
||||
@click.option('--tape-border', type=float, default=3, help='Width of empty border at the edges of the tape in mm')
|
||||
@click.option('--tape-spacing', type=float, default=2, help='Space between tapes')
|
||||
@click.option('--tape-length', type=float, default=250, help='Length of tape segments')
|
||||
@click.option('--magic-color', type=str, default='#cc0000', help='SVG color of tape')
|
||||
@click.argument('output_svg', type=click.File(mode='w'), default='-')
|
||||
def template(num_rows, tape_width, tape_border, tape_spacing, tape_length, magic_color, output_svg):
|
||||
pitch = tape_width + tape_spacing
|
||||
tags = [Tag('g', inkscape__layer='Layer 1', inkscape__groupmode='layer', id='layer1', children=[
|
||||
Tag('g', id='g1', children=[
|
||||
Tag('g', id=f'tape{i}', children=[
|
||||
Tag('path', id=f'tape{i}_outline', fill='none', stroke='black', opacity='0.3', stroke_width=f'{tape_width}px',
|
||||
d=f'M 0 {tape_width/2 + i*pitch} {tape_length} {tape_width/2 + i*pitch}'),
|
||||
Tag('path', id=f'tape{i}_printable_area', fill='none', stroke=magic_color, stroke_width=f'{tape_width-2*tape_border}px',
|
||||
d=f'M 0 {tape_width/2 + i*pitch} {tape_length} {tape_width/2 + i*pitch}'),
|
||||
])
|
||||
for i in range(num_rows)
|
||||
])
|
||||
])]
|
||||
|
||||
bounds = (0, 0), (tape_length, num_rows*tape_width + (num_rows-1)*tape_spacing)
|
||||
svg = setup_svg(tags, bounds, margin=tape_width, inkscape=True)
|
||||
output_svg.write(str(svg))
|
||||
|
||||
@cli.command()
|
||||
@click.option('--magic-color', type=str, default='#cc0000', help='SVG color of tape')
|
||||
@click.argument('input_svg', type=click.File(mode='r'), default='-')
|
||||
def dither(input_svg, magic_color):
|
||||
tmpdir = Path('/tmp/foo') # FIXME debug
|
||||
with tempfile.NamedTemporaryFile('w', suffix='.svg') as tmp_in_svg,\
|
||||
tempfile.NamedTemporaryFile('r', suffix='.svg') as tmp_out_svg:
|
||||
tmp_in_svg.write(input_svg.read())
|
||||
tmp_in_svg.flush()
|
||||
|
||||
try:
|
||||
run_cargo_command('usvg', *shlex.split(os.environ.get('USVG_OPTIONS', '')), tmp_in_svg.name, tmp_out_svg.name)
|
||||
except SystemError:
|
||||
raise ClickException('Cannot find usvg. Please install usvg using cargo, or pass the full path to the usvg binary in the USVG environment variable.')
|
||||
|
||||
soup = BeautifulSoup(tmp_out_svg.read(), 'xml')
|
||||
|
||||
print(soup.prettify())
|
||||
for i, path in enumerate(list(soup.find_all('path'))):
|
||||
if path.get('stroke').lower() != magic_color:
|
||||
continue
|
||||
path_id = path.get('id', '<no id?>')
|
||||
|
||||
commands = list(parse_path_d(path.get('d', '')))
|
||||
if len(commands) != 2:
|
||||
print('Path', path_id, 'has magic color, but has more than two nodes. Ignoring.', file=sys.stderr)
|
||||
continue
|
||||
if commands[1][0] != 'L':
|
||||
print('Path', path_id, 'has magic color, but has a curve. Ignoring.', file=sys.stderr)
|
||||
continue
|
||||
if commands[0][0] != 'M':
|
||||
print('Path', path_id, 'has magic color, but is malformed (does not start with M command). Ignoring.', file=sys.stderr)
|
||||
continue
|
||||
(_c1, (x1, y1)), (_c2, (x2, y2)) = commands
|
||||
|
||||
mat = Transform.parse_svg(path.get('transform', ''))
|
||||
for parent in path.parents:
|
||||
xf = Transform.parse_svg(parent.get('transform', ''))
|
||||
mat = xf * mat # make sure we apply the parent transform from the left, i.e. after the child transform
|
||||
x1, y1 = mat.transform_point(x1, y1)
|
||||
x2, y2 = mat.transform_point(x2, y2)
|
||||
path_len = math.dist((x1, y1), (x2, y2))
|
||||
|
||||
if math.isclose(path_len, 0, abs_tol=1e-3):
|
||||
print('Path', path_id, 'has magic color, but has (almost) zero length. Ignoring.', file=sys.stderr)
|
||||
continue
|
||||
|
||||
if not (stroke_w := path.get('stroke-width')):
|
||||
print('Path', path_id, 'has magic color, but has no defined stroke width. Ignoring.', file=sys.stderr)
|
||||
continue
|
||||
stroke_w = float(re.match('[-0-9.]+', stroke_w).group(0))
|
||||
|
||||
path_angle = math.atan2((y2-y1), (x2-x1))
|
||||
dx, dy = (x2-x1)/path_len, (y2-y1)/path_len
|
||||
|
||||
sx1, sy1 = mat.transform_point(x1-dy*stroke_w/2, y1+dx*stroke_w/2)
|
||||
sx2, sy2 = mat.transform_point(x1+dy*stroke_w/2, y1-dx*stroke_w/2)
|
||||
stroke_w = round(math.dist((sx1, sy1), (sx2, sy2)), 3)
|
||||
|
||||
out_soup = copy.copy(soup)
|
||||
|
||||
print(f'found path {path_id} of length {path_len:2f} and angle {math.degrees(path_angle):.1f} deg with physical stroke width {stroke_w:.2f} from ({x1:.2f}, {y1:.2f}) to ({x2:.2f}, {y2:.2f})', file=sys.stderr)
|
||||
out_soup.find('svg').append(out_soup.new_tag('path', fill='none', stroke='blue', stroke_width=f'24px',
|
||||
d=f'M {x1} {y1} L {x2} {y2}'))
|
||||
xf = Transform.translate(0, stroke_w/2) * Transform.rotate(-path_angle) * Transform.translate(-x1, -y1)
|
||||
g = out_soup.new_tag('g', id='transform-group', transform=xf.as_svg())
|
||||
k = list(out_soup.find('svg').contents)
|
||||
for c in k:
|
||||
g.append(c.extract())
|
||||
out_soup.find('svg').append(g)
|
||||
out_soup.find('svg')['viewBox'] = f'0 0 {path_len} {stroke_w}'
|
||||
out_soup.find('svg')['width'] = f'{path_len}mm'
|
||||
out_soup.find('svg')['height'] = f'{stroke_w}mm'
|
||||
|
||||
with open(f'/tmp/baz-{i}.svg', 'w') as f:
|
||||
f.write(out_soup.prettify())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
||||
327
svg_util.py
Normal file
327
svg_util.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import math
|
||||
import re
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LengthUnit:
|
||||
""" Convenience length unit class. Used in :py:class:`.GraphicObject` and :py:class:`.Aperture` to store lenght
|
||||
information. Provides a number of useful unit conversion functions.
|
||||
|
||||
Singleton, use only global instances ``utils.MM`` and ``utils.Inch``.
|
||||
"""
|
||||
|
||||
name: str
|
||||
shorthand: str
|
||||
this_in_mm: float
|
||||
|
||||
def convert_from(self, unit, value):
|
||||
""" Convert ``value`` from ``unit`` into this unit.
|
||||
|
||||
:param unit: ``MM``, ``Inch`` or one of the strings ``"mm"`` or ``"inch"``
|
||||
:param float value:
|
||||
:rtype: float
|
||||
"""
|
||||
|
||||
if isinstance(unit, str):
|
||||
unit = units[unit]
|
||||
|
||||
if unit == self or unit is None or value is None:
|
||||
return value
|
||||
|
||||
return value * unit.this_in_mm / self.this_in_mm
|
||||
|
||||
def convert_to(self, unit, value):
|
||||
""" :py:meth:`.LengthUnit.convert_from` but in reverse. """
|
||||
|
||||
if isinstance(unit, str):
|
||||
unit = to_unit(unit)
|
||||
|
||||
if unit is None:
|
||||
return value
|
||||
|
||||
return unit.convert_from(self, value)
|
||||
|
||||
def convert_bounds_from(self, unit, value):
|
||||
""" :py:meth:`.LengthUnit.convert_from` but for ((min_x, min_y), (max_x, max_y)) bounding box tuples. """
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
(min_x, min_y), (max_x, max_y) = value
|
||||
min_x = self.convert_from(unit, min_x)
|
||||
min_y = self.convert_from(unit, min_y)
|
||||
max_x = self.convert_from(unit, max_x)
|
||||
max_y = self.convert_from(unit, max_y)
|
||||
return (min_x, min_y), (max_x, max_y)
|
||||
|
||||
def convert_bounds_to(self, unit, value):
|
||||
""" :py:meth:`.LengthUnit.convert_to` but for ((min_x, min_y), (max_x, max_y)) bounding box tuples. """
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
(min_x, min_y), (max_x, max_y) = value
|
||||
min_x = self.convert_to(unit, min_x)
|
||||
min_y = self.convert_to(unit, min_y)
|
||||
max_x = self.convert_to(unit, max_x)
|
||||
max_y = self.convert_to(unit, max_y)
|
||||
return (min_x, min_y), (max_x, max_y)
|
||||
|
||||
def format(self, value):
|
||||
""" Return a human-readdable string representing value in this unit.
|
||||
|
||||
:param float value:
|
||||
:returns: something like "3mm"
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
return f'{value:.3f}{self.shorthand}' if value is not None else ''
|
||||
|
||||
def __call__(self, value, unit):
|
||||
""" Convenience alias for :py:meth:`.LengthUnit.convert_from` """
|
||||
return self.convert_from(unit, value)
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, str):
|
||||
return other.lower() in (self.name, self.shorthand)
|
||||
else:
|
||||
return id(self) == id(other)
|
||||
|
||||
# This class is a singleton, we don't want copies around
|
||||
def __copy__(self):
|
||||
return self
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
return self
|
||||
|
||||
def __str__(self):
|
||||
return self.shorthand
|
||||
|
||||
def __repr__(self):
|
||||
return f'<LengthUnit {self.name}>'
|
||||
|
||||
|
||||
MILLIMETERS_PER_INCH = 25.4
|
||||
Inch = LengthUnit('inch', 'in', MILLIMETERS_PER_INCH)
|
||||
MM = LengthUnit('millimeter', 'mm', 1)
|
||||
units = {'inch': Inch, 'mm': MM, None: None}
|
||||
|
||||
|
||||
class Tag:
|
||||
""" Helper class to ease creation of SVG. All API functions that create SVG allow you to substitute this with your
|
||||
own implementation by passing a ``tag`` parameter. """
|
||||
|
||||
def __init__(self, name, children=None, root=False, **attrs):
|
||||
if (fill := attrs.get('fill')) and isinstance(fill, tuple):
|
||||
attrs['fill'], attrs['fill-opacity'] = fill
|
||||
if (stroke := attrs.get('stroke')) and isinstance(stroke, tuple):
|
||||
attrs['stroke'], attrs['stroke-opacity'] = stroke
|
||||
self.name, self.attrs = name, attrs
|
||||
self.children = children or []
|
||||
self.root = root
|
||||
|
||||
def __str__(self):
|
||||
prefix = '<?xml version="1.0" encoding="utf-8"?>\n' if self.root else ''
|
||||
opening = ' '.join([self.name] + [f'{key.replace("__", ":").replace("_", "-")}="{value}"' for key, value in self.attrs.items()])
|
||||
if self.children:
|
||||
children = '\n'.join(textwrap.indent(str(c), ' ') for c in self.children)
|
||||
return f'{prefix}<{opening}>\n{children}\n</{self.name}>'
|
||||
else:
|
||||
return f'{prefix}<{opening}/>'
|
||||
|
||||
|
||||
def svg_rotation(angle_rad, cx=0, cy=0):
|
||||
if math.isclose(angle_rad, 0.0, abs_tol=1e-3):
|
||||
return {}
|
||||
else:
|
||||
return {'transform': f'rotate({float(math.degrees(angle_rad)):.4} {float(cx):.6} {float(cy):.6})'}
|
||||
|
||||
def setup_svg(tags, bounds, margin=0, arg_unit=MM, svg_unit=MM, pagecolor='white', tag=Tag, inkscape=False):
|
||||
(min_x, min_y), (max_x, max_y) = bounds
|
||||
|
||||
if margin:
|
||||
margin = svg_unit(margin, arg_unit)
|
||||
min_x -= margin
|
||||
min_y -= margin
|
||||
max_x += margin
|
||||
max_y += margin
|
||||
|
||||
w, h = max_x - min_x, max_y - min_y
|
||||
w = 1.0 if math.isclose(w, 0.0) else w
|
||||
h = 1.0 if math.isclose(h, 0.0) else h
|
||||
|
||||
if inkscape:
|
||||
tags.insert(0, tag('sodipodi:namedview', [], id='namedview1', pagecolor=pagecolor,
|
||||
inkscape__document_units=svg_unit.shorthand))
|
||||
namespaces = dict(
|
||||
xmlns="http://www.w3.org/2000/svg",
|
||||
xmlns__xlink="http://www.w3.org/1999/xlink",
|
||||
xmlns__sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
|
||||
xmlns__inkscape='http://www.inkscape.org/namespaces/inkscape')
|
||||
|
||||
else:
|
||||
namespaces = dict(
|
||||
xmlns="http://www.w3.org/2000/svg",
|
||||
xmlns__xlink="http://www.w3.org/1999/xlink")
|
||||
|
||||
svg_unit = 'in' if svg_unit == 'inch' else 'mm'
|
||||
# TODO export apertures as <uses> where reasonable.
|
||||
return tag('svg', tags,
|
||||
width=f'{w}{svg_unit}', height=f'{h}{svg_unit}',
|
||||
viewBox=f'{min_x} {min_y} {w} {h}',
|
||||
style=f'background-color:{pagecolor}',
|
||||
**namespaces,
|
||||
root=True)
|
||||
|
||||
|
||||
class Transform:
|
||||
xform_re = r'((matrix|translate|scale|rotate|skewX|skewY)\(([-0-9. ]+)\))|(.+)'
|
||||
|
||||
def __init__(self, a=1, b=0, c=0, d=1, e=0, f=0):
|
||||
# Reference: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform
|
||||
self.mat = (a, b, c, d, e, f)
|
||||
|
||||
def __mul__(self, other):
|
||||
a1, b1, c1, d1, e1, f1 = self.mat
|
||||
a2, b2, c2, d2, e2, f2 = other.mat
|
||||
|
||||
a = a1*a2 + c1*b2
|
||||
b = d1*b2 + b1*a2
|
||||
c = c1*d2 + a1*c2
|
||||
d = d1*d2 + b1*c2
|
||||
e = e1 + c1*f2 + a1*e2
|
||||
f = f1 + d1*f2 + b1*e2
|
||||
|
||||
return Transform(a, b, c, d, e, f)
|
||||
|
||||
def __str__(self):
|
||||
a, b, c, d, e, f = self.mat
|
||||
return f'Transform({a=:.3f} {b=:.3f} {c=:.3f} {d=:.3f} {e=:.3f} {f=:.3f})'
|
||||
|
||||
def transform_point(self, x, y):
|
||||
a, b, c, d, e, f = self.mat
|
||||
x_new = a*x + c*y + e
|
||||
y_new = b*x + d*y + f
|
||||
return x_new, y_new
|
||||
|
||||
@classmethod
|
||||
def translate(kls, x, y):
|
||||
return kls(1, 0, 0, 1, x, y)
|
||||
|
||||
@classmethod
|
||||
def scale(kls, x, y):
|
||||
return kls(x, 0, 0, y, 0, 0)
|
||||
|
||||
@classmethod
|
||||
def rotate(kls, a, x=0, y=0):
|
||||
s, c = math.sin(a), math.cos(a)
|
||||
mat = kls(c, s, -s, c, 0, 0)
|
||||
if not math.isclose(x, 0) or not math.isclose(y, 0):
|
||||
mat = kls.translate(x, y) * (mat * kls.translate(-x, -y))
|
||||
return mat
|
||||
|
||||
@classmethod
|
||||
def skew_x(kls, a):
|
||||
return kls(1, 0, math.tan(a), 1, 0, 0)
|
||||
|
||||
@classmethod
|
||||
def skew_y(kls, a):
|
||||
return kls(1, math.tan(a), 0, 1, 0, 0)
|
||||
|
||||
@classmethod
|
||||
def _parse_single_svg(kls, xform_string):
|
||||
_transform, name, nums, _garbage = re.match(kls.xform_re, xform_string).groups()
|
||||
nums = [float(x) for x in nums.strip().split()]
|
||||
match (name, *nums):
|
||||
case ('matrix', a, b, c, d, e, f):
|
||||
return kls(a, b, c, d, e, f)
|
||||
case ('translate', x):
|
||||
return kls.translate(x, 0)
|
||||
case ('translate', x, y):
|
||||
return kls.translate(x, y)
|
||||
case ('scale', s):
|
||||
return kls.scale(s, s)
|
||||
case ('scale', x, y):
|
||||
return kls.scale(x, y)
|
||||
case ('rotate', a):
|
||||
return kls.rotate(math.radians(a))
|
||||
case ('rotate', a, x, y):
|
||||
return kls.rotate(math.radians(a), x, y)
|
||||
case ('skewX', a):
|
||||
return kls.skew_x(math.radians(a))
|
||||
case ('skewY', a):
|
||||
return kls.skew_y(math.radians(a))
|
||||
|
||||
@classmethod
|
||||
def parse_svg(kls, xform_string):
|
||||
mat = kls()
|
||||
for xf in re.finditer(kls.xform_re, xform_string):
|
||||
component, command, params, garbage = xf.groups()
|
||||
if garbage:
|
||||
raise ValueError(f'Unknown SVG transform {garbage!r}')
|
||||
mat *= kls._parse_single_svg(xf.group(0))
|
||||
return mat
|
||||
|
||||
def as_svg(self):
|
||||
a, b, c, d, e, f = self.mat
|
||||
return f'matrix({a} {b} {c} {d} {e} {f})'
|
||||
|
||||
|
||||
def parse_path_d(d):
|
||||
# Reference: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#path_commands
|
||||
cur_x, cur_y = None, None
|
||||
start_x, start_y = None, None
|
||||
for m in re.finditer(r'([MmLlHhVvCcSsQqTtAaZz])\s*((-?[0-9.]+)(\s*[\s,]\s*-?[0-9.]+)*)', d):
|
||||
command = m.group(1)
|
||||
is_relative, command = command.islower(), command.upper()
|
||||
params = [float(x or 0) for x in re.split(r'\s*[\s,]\s*', m.group(2).strip())]
|
||||
|
||||
def r(x, y, reset=True):
|
||||
if is_relative:
|
||||
x, y = x+cur_x, y+cur_y
|
||||
if reset:
|
||||
cur_x, cur_y = x, y
|
||||
return x, y
|
||||
|
||||
if command == 'Z':
|
||||
if params:
|
||||
raise ValueError('Z (close path) command followed by numeric parameters')
|
||||
if not math.isclose(cur_x, start_x) or not math.isclose(cur_y, start_y):
|
||||
yield 'L', (start_x, start_y)
|
||||
|
||||
else:
|
||||
while params:
|
||||
match (command, *params):
|
||||
case ('M', x, y, *_extra):
|
||||
yield 'M', r(x, y)
|
||||
start_x, start_y = cur_x, cur_y
|
||||
command = 'L'
|
||||
params = params[2:]
|
||||
case ('L', x, y, *_extra):
|
||||
yield 'L', r(x, y)
|
||||
params = params[2:]
|
||||
case ('H', x, *_extra):
|
||||
yield 'L', r(x, 0 if is_relative else cur_y)
|
||||
params = params[1:]
|
||||
case ('V', y, *_extra):
|
||||
yield 'L', r(0 if is_relative else cur_x, y)
|
||||
params = params[1:]
|
||||
case ('C', x1, y1, x2, y2, x, y, *_extra):
|
||||
yield 'C', r(x1, y1, False), r(x2, y2, False), r(x, y)
|
||||
params = params[6:]
|
||||
case ('S', dx2, dy2, x, y, *_extra):
|
||||
yield 'S', r(dx2, dy2, False), r(x, y)
|
||||
params = params[4:]
|
||||
case ('Q', x1, y1, x, y, *_extra):
|
||||
yield 'Q', r(x1, y1, False), r(x, y)
|
||||
params = params[4:]
|
||||
case ('T', x, y, *_extra):
|
||||
yield 'T', r(x, y)
|
||||
params = params[2:]
|
||||
case ('A', rx, ry, a, l, s, x, y, *_extra):
|
||||
yield 'A', (rx, ry), a, l, s, r(x, y)
|
||||
params = params[7:]
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue