ihsm-secondary-mesh/gear_mesh_planning.py
2024-08-14 16:15:33 +02:00

499 lines
21 KiB
Python

#!/usr/bin/env python3
import re
import colorsys
import textwrap
import math
from pathlib import Path
import webbrowser
import click
tau = 2*math.pi
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.lstrip("_").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}/>'
@classmethod
def setup_svg(kls, tags, bounds, margin=0, unit='mm', pagecolor='white', inkscape=False):
(min_x, min_y), (max_x, max_y) = bounds
if margin:
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, kls('sodipodi:namedview', [], id='namedview1', pagecolor=pagecolor,
inkscape__document_units=unit))
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")
return kls('svg', tags,
width=f'{w}{unit}', height=f'{h}{unit}',
viewBox=f'{min_x} {min_y} {w} {h}',
style=f'background-color:{pagecolor}',
**namespaces,
root=True)
def dimension(p1, p2, h=5, line_offset=1, arrow_offset=.5, inner_arrow_min=5, arrow_head_size=1, arrow_length=3,
connecting_line_offset=1, color='black', line_width=0.2, text_offset=3, text_offset_max=15,
text_color=None, text_format='{:.2f} mm', font_size=2, offset_1=0, offset_2 = 0):
if text_color is None:
text_color = color
x1, y1 = p1
x2, y2 = p2
dx, dy = x2-x1, y2-y1
l = math.hypot(dx, dy)
nx, ny = -dy/l, dx/l
ax, ay = dx/l, dy/l
if l > inner_arrow_min:
xa1, ya1 = x1 + ax*arrow_offset, y1 + ay*arrow_offset
xa2, ya2 = x2 - ax*arrow_offset, y2 - ay*arrow_offset
cl_off = 2*arrow_offset + arrow_length
else:
xa2, ya2 = x1 - ax*arrow_offset, y1 - ay*arrow_offset
xa1, ya1 = x2 + ax*arrow_offset, y2 + ay*arrow_offset
cl_off = 2*arrow_offset
angle = math.degrees(math.atan2(dy, dx))
c_off = h - connecting_line_offset
cl_off = min(l/2, cl_off)
yield Tag('path', fill='none', stroke=color, stroke_width=line_width,
d=f'''
M {x1-nx*line_width/2},{y1-ny*line_width/2} l {nx*line_width},{ny*line_width}
M {x1+nx*(line_offset + offset_1)},{y1+ny*(line_offset+offset_1)} l {nx*(h-offset_1)},{ny*(h-offset_1)}
M {x2-nx*line_width/2},{y2-ny*line_width/2} l {nx*line_width},{ny*line_width}
M {x2+nx*(line_offset+offset_2)},{y2+ny*(line_offset+offset_2)} l {nx*(h-offset_2)},{ny*(h-offset_2)}
M {x1+nx*c_off + ax*cl_off},{y1+ny*c_off+ay*cl_off}
l {ax*(l-2*cl_off)},{ay*(l-2*cl_off)}
''')
xa1, ya1 = xa1 + nx*c_off, ya1 + ny*c_off
xa2, ya2 = xa2 + nx*c_off, ya2 + ny*c_off
aw, al = arrow_head_size, arrow_head_size*2
ab = aw*0.2
for xa, ya, al, arrow_length, abl in [(xa1, ya1, al, arrow_length, ab), (xa2, ya2, -al, -arrow_length, -ab)]:
yield Tag('path', fill=color, stroke=color, stroke_width=line_width,
d=f'''
M {xa},{ya}
l {nx*aw/2 + ax*al},{ny*aw/2 + ay*al}
l {-nx*aw/2 - ax*abl},{-ny*aw/2 - ay*abl}
l {ax*(arrow_length-al+abl)},{ay*(arrow_length-al+abl)}
l {-ax*(arrow_length-al+abl)},{-ay*(arrow_length-al+abl)}
l {-nx*aw/2 + ax*abl},{-ny*aw/2 + ay*abl}
Z
''')
if not (-90 <= angle < 90):
angle = (angle - 180) % 360
baseline = 'auto'
else:
baseline = 'hanging'
if l > text_offset_max:
text_offset = 0
baseline = 'middle'
tx = x1 + nx*c_off + ax*l/2 + nx*text_offset
ty = y1 + ny*c_off + ay*l/2 + ny*text_offset
for text_color, stroke in [('white', 'white'), (text_color, 'none')]:
yield Tag('text', children=[text_format.format(l)],
x=tx, y=ty,
font_family='sans-serif', font_size=f'{font_size:.3f}px',
text_anchor='middle', dominant_baseline=baseline,
transform=f'rotate({angle},{tx},{ty})',
stroke=stroke, stroke_width=1,
stroke_linejoin='round',
fill=text_color)
def angular_dimension(p, a1, a2, large_arc=None, r=3, line_offset=1, arrow_offset=.5, inner_arrow_min=5, arrow_head_size=1, arrow_length=3,
connecting_line_offset=1, color='black', line_width=0.2, text_offset=2, text_offset_max=15,
text_color=None, text_format='{:.1f} °', font_size=2, offset_1=0, offset_2 = 0):
text_color= text_color if text_color else color
da = a2 - a1
da = (da + math.pi) % (2*math.pi) - math.pi
arrow_offset = math.asin(arrow_offset/(2*r))
if da < 0 == large_arc:
arrow_offset = -arrow_offset
if abs(da) > 2*arrow_offset:
yield Tag('path', fill='none', stroke_width=line_width, stroke=color, transform=f'translate({p[0]} {p[1]})',
d=f'''M {math.cos(a1 + arrow_offset)*r} {math.sin(a1 + arrow_offset)*r}
A {r} {r} 0 {int(bool(large_arc))} {int(bool(large_arc))} {math.cos(a2 - arrow_offset)*r} {math.sin(a2 - arrow_offset)*r}
''')
tx = p[0] + math.cos(a1 + da/2) * (r + text_offset)
ty = p[1] + math.sin(a1 + da/2) * (r + text_offset)
ta = (math.degrees(a1+da/2) + 90 + 180) % 360 - 180
if not (-90 <= ta < 90):
ta = (ta - 180) % 360
for text_color, stroke in [('white', 'white'), (text_color, 'none')]:
yield Tag('text', children=[text_format.format(abs(math.degrees(da)))],
x=tx, y=ty,
font_family='sans-serif', font_size=f'{font_size:.3f}px',
text_anchor='middle', dominant_baseline='middle',
transform=f'rotate({ta},{tx},{ty})',
stroke=stroke, stroke_width=1,
stroke_linejoin='round',
fill=text_color)
crosshairs = lambda pt, s=2, color='black', xf='': Tag('path', fill='none', stroke_width=0.2, stroke=color, transform=xf,
d=f'M {pt[0]-s},{pt[1]} h {2*s} m {-s},{s} v {-2*s}')
circle = lambda pt, r, c, w=0.2, op=1.0: Tag('circle', stroke=c, fill='none', stroke_width=w, cx=pt[0], cy=pt[1], r=r, stroke_opacity=op)
hls_svg = lambda h, s, l: '#'+''.join(f'{round(c*255):02x}' for c in colorsys.hls_to_rgb(h/360, l/100, s/100))
red = lambda s, l: hls_svg(0, l, s)
blue = lambda s, l: hls_svg(240, l, s)
@click.command()
@click.option('-r', '--radius', default='50/50')
@click.option('-n', '--num-meshes', default='5/3')
@click.option('-w', '--mesh-width', default='10/10')
@click.option('-t', '--mesh-thickness', default='1.6/1.6')
@click.option('-o', '--offset', type=float, default=20)
@click.option('-p', '--phase', type=float, default=0)
@click.option('--counterrotation/--corotation')
@click.argument('out_svg_1', type=click.Path(dir_okay=False, path_type=Path))
@click.argument('out_svg_2', type=click.Path(dir_okay=False, path_type=Path))
@click.argument('out_svg_3', type=click.Path(dir_okay=False, path_type=Path))
def cli(out_svg_1, out_svg_2, out_svg_3, radius, num_meshes, mesh_width, mesh_thickness, offset, phase, counterrotation):
tags = []
dim_tags = []
plots = []
def parse_split(s, parse=float, extra_args=((), ())):
if not isinstance(s, str):
return s, s
l, _, r = s.partition('/')
l = parse(l, *extra_args[0])
r = parse(r, *extra_args[1]) if r else l
return l, r
r1, r2 = parse_split(radius)
assert r1 > 0 and r2 > 0
assert offset > 0
assert offset-r2 < r1 < offset+r2
th1, th2 = parse_split(mesh_thickness)
assert th1 > 0 and th2 > 0
n1, n2 = parse_split(num_meshes, int)
assert n1 > 0 and n2 > 0
def parse_mesh_width(s, r, th):
num, unit = re.fullmatch(r'([0-9.]+)\s*(|deg|degree|mm|cm)', s).groups()
num = float(num)
if unit in ('', 'deg', 'degree'):
return num
r -= th/2
out = math.degrees(math.atan(num/2 / r))
print(f'Calculated mesh angle for mesh width {num:.1f}{unit} and thickness {th:.1f}mm at radius {r:.1f}mm: {out:.1f}°')
return out
mesh_w1, mesh_w2 = parse_split(mesh_width, parse=parse_mesh_width, extra_args=((r1, th1), (r2, th2)))
assert mesh_w1 > 0 and mesh_w2 > 0
mesh_w1, mesh_w2 = math.radians(mesh_w1), math.radians(mesh_w2)
assert n1 * mesh_w1 < 2*math.pi
assert n2 * mesh_w2 < 2*math.pi
# https://mathworld.wolfram.com/Circle-CircleIntersection.html
chord = 1/offset * math.sqrt(4 * offset**2 * r1**2 - (offset**2 - r2**2 + r1**2)**2)
x = (offset**2 - r2**2 + r1**2) / (2*offset)
a1 = math.asin(chord/(2*r1))
a2 = math.asin(chord/(2*r2))
if offset > x:
a2 = math.pi - a2
c1, c2 = red(40, 70), blue(40, 70)
center_1, center_2 = (0, 0), (offset, 0)
tags.append(circle(center_1, r1, c1))
tags.append(circle(center_2, r2, c2))
dim_tags += dimension((x, chord/2), (x, -chord/2), h=offset/2+5)
tags += angular_dimension(center_1, a1, -a1, large_arc=False)
tags += angular_dimension(center_2, -a2 if offset > x else a2, a2 if offset > x else -a2, large_arc=False)
tags.append(Tag('path', fill='none', stroke_width=0.2, stroke=c1, d=f'M {x} {chord/2} L 0 0 L {x} {-chord/2}'))
tags.append(Tag('path', fill='none', stroke_width=0.2, stroke=c2, d=f'M {x} {chord/2} L {offset} 0 L {x} {-chord/2}'))
tags.append(Tag('path', fill='none', stroke_width=0.2, stroke='black', d=f'M {x} {chord/2} l 0 {-chord}'))
out_svg_1.write_text(str(Tag.setup_svg(tags + dim_tags, bounds=((-r1, -max(r1, r2)), (offset+r2, max(r1, r2))), margin=3)))
tags = []
dim_tags = []
# https://stackoverflow.com/questions/13069446/simple-fill-pattern-in-svg-diagonal-hatching
defs = []
defs.append(Tag('pattern', [Tag('path', stroke=c1, stroke_width=1,
d='M -1,1 l 2,-2 M 0,4 l 4,-4 M 3,5 l2,-2')],
id='hatch_1', patternUnits='userSpaceOnUse', width='4', height='4', patternTransform='rotate(-45) scale(0.15)'))
defs.append(Tag('pattern', [Tag('path', stroke=c2, stroke_width=1,
d='M -1,1 l 2,-2 M 0,4 l 4,-4 M 3,5 l2,-2')],
id='hatch_2', patternUnits='userSpaceOnUse', width='4', height='4', patternTransform='scale(0.15)'))
defs.append(Tag('pattern', [Tag('path', stroke='black', stroke_width=1,
d='M -1,1 l 2,-2 M 0,4 l 4,-4 M 3,5 l2,-2')],
id='hatch_b', patternUnits='userSpaceOnUse', width='4', height='4', patternTransform='scale(0.15)'))
tags.append(Tag('defs', defs))
def plot_angles(angles, color='black', fill='lightgray', title=None, h=10, w=100, minor_ivl=5, major_ivl=45,
minor_len=.8, major_len=1.2, tick_label_sp=1, tick_format='{:.0f}°', font_size=2, title_offset=-2,
axes=True, axes_stroke=0.15, tick_stroke=0.15, box_stroke=0.15):
if axes:
yield Tag('path', fill='none', stroke_width=axes_stroke, stroke='black', d=f'''
M 0 0 v {h} h {w} v {-h} Z
''')
assert math.isclose(major_ivl % minor_ivl, 0, abs_tol=1e-6)
num_minor = round(major_ivl/minor_ivl)
ticks = [(i*minor_ivl, i%num_minor == 0)
for i in range(int(360.001/minor_ivl) + 1)]
yield Tag('path', fill='none', stroke_width=tick_stroke, stroke='black', d=' '.join(
f'M {w*a/360} {h} v {major_len if major else minor_len}' for a, major in ticks
))
for a, major in ticks:
if not major:
continue
tx, ty = w*a/360, h + major_len + tick_label_sp
for text_color, stroke in [('white', 'white'), ('black', 'none')]:
yield Tag('text', children=[tick_format.format(a)],
x=tx, y=ty,
font_family='sans-serif', font_size=f'{font_size:.3f}px',
text_anchor='middle', dominant_baseline='hanging',
stroke=stroke, stroke_width=1,
stroke_linejoin='round',
fill=text_color)
if title is not None:
for text_color, stroke in [('white', 'white'), ('black', 'none')]:
yield Tag('text', children=[title],
x=w/2, y=title_offset,
font_family='sans-serif', font_size=f'{font_size:.3f}px',
text_anchor='middle', dominant_baseline='auto',
stroke=stroke, stroke_width=1,
stroke_linejoin='round',
fill=text_color)
for i in range(len(angles)):
a1, a2 = angles[i]
a1, a2 = a1%(2*math.pi), a2%(2*math.pi)
if a1 > a2:
angles[i] = a1, 2*math.pi
angles.append((0, a2))
for a1, a2 in angles:
yield Tag('rect', x=w*math.degrees(a1)/360, y=0, width=w*math.degrees(a2-a1)/360, height=h,
stroke_width=box_stroke, stroke=color, fill=fill, fill_opacity=0.4)
def make_angles(num, w, phase):
space = (2*math.pi - num*w) / num
for i in range(num):
yield (i*(space+w) + phase)%(2*math.pi), (i*(space+w) + w + phase)%(2*math.pi)
shift_angles = lambda angles, shift: [((a1+shift)%tau, (a2+shift)%tau) for a1, a2 in angles]
angles_1, angles_2 = list(make_angles(n1, mesh_w1, 0)), list(make_angles(n2, mesh_w2, math.radians(phase)))
plots.append(list(plot_angles(angles_1, c1, 'url(#hatch_1)', title='Mesh 1')))
plots.append(list(plot_angles(angles_2, c2, 'url(#hatch_2)', title='Mesh 2')))
def collide_schedules(sch_a, sch_b):
boundaries = []
for a1, a2 in sch_a:
for b1, b2 in sch_b:
boundaries.append(((a1-b2 - (b2 - b1))%tau, 1))
boundaries.append(((a2-b1)%tau, -1))
#for a, b in zip(boundaries[0::2], boundaries[1::2]):
# yield a[0], b[0]
#return
# Prevent floating point rounding errors during sorting affecting the output
boundaries = [(round(a, 3), d) for a, d in boundaries]
prev = None
rd = sum(1 for a, b in zip(boundaries[0::2], boundaries[1::2]) if b < a)
boundaries = sorted(boundaries)
for a, d in boundaries:
if d == 1 and rd == 0 and prev is not None:
yield prev, a
rd += d
prev = a
if rd == 0:
yield prev, boundaries[0][0]
def intersect_schedules(sch_a, sch_b):
boundaries = []
for a1, a2 in sch_a + sch_b:
boundaries.append((a1%tau, 1))
boundaries.append((a2%tau, -1))
# Prevent floating point rounding errors during sorting affecting the output
boundaries = [(round(a, 3), d) for a, d in boundaries]
prev = None
rd = sum(1 for a, b in zip(boundaries[0::2], boundaries[1::2]) if b < a)
boundaries = sorted(boundaries)
for a, d in boundaries:
if d == -1 and rd == 2 and prev is not None:
yield prev, a
rd += d
prev = a
if rd == 2:
yield prev, boundaries[0][0]
print(f'Shifting by {math.degrees(a1):.2f}° and {math.degrees(a2):.2f}°')
angles_1_intersection_1 = shift_angles(angles_1, -a1)
angles_1_intersection_2 = shift_angles(angles_1, a1)
angles_2_intersection_1 = shift_angles(angles_2, -a2)
angles_2_intersection_2 = shift_angles(angles_2, a2)
collided_1 = list(collide_schedules(angles_1_intersection_1, angles_2_intersection_1))
collided_2 = list(collide_schedules(angles_1_intersection_2, angles_2_intersection_2))
best_solution = None
def plot_centers(collided):
nonlocal best_solution
widths = sorted((((end-start)%tau, start, end) for start, end in collided), reverse=True)
if collided:
print('Best phases:')
w, start, end = widths[0]
best_solution = (start + (end-start)/2) % tau
else:
print('No solution.')
for w, start, end in widths:
if math.isclose(start, end, abs_tol=1e-6):
print(f'{math.degrees(start):>3.0f}° (exact)')
else:
print(f'{math.degrees(start):>5.1f}° - {math.degrees(end):>5.1f}°: phase tolerance {math.degrees(w/2):.2f}°')
yield Tag('path', stroke=c1, stroke_width=0.2, fill='none',
d=f'M {100* (((start + w/2) / tau) % 1.0)} 0 v 10')
print(' === One-sided schedule === ')
plots.append([
*plot_angles(collided_1, 'black', 'url(#hatch_b)', title='Valid shifts for first intersection'),
*plot_centers(collided_1)
])
print()
print(' === Two-sided schedule === ')
intersected = list(intersect_schedules(collided_1, collided_2))
plots.append([
*plot_angles(intersected, 'black', 'url(#hatch_b)', title='Valid shifts for both intersections'),
*plot_centers(intersected)
])
tags2 = tags
tags = []
tags.append(Tag('defs', defs))
dim_tags = []
for cx, cy, r, th, sched, stroke, fill in [
( 0, 0, r1, th1, angles_1, c1, 'url(#hatch_1)'),
(offset, 0, r2, th2, shift_angles(angles_2, best_solution), c2, 'url(#hatch_2)')]:
max_a = max((a2 - a1) % tau for a1, a2 in sched)
max_w = math.tan(max_a) * (r-th/2)
max_rd = math.hypot(r + th/2, max_w)
tags.append(Tag('circle', cx=cx, cy=cy, fill='none', stroke=fill, stroke_opacity=0.4,
r=((r-th/2) + max_rd)/2, stroke_width=max_rd - (r-th/2)))
tags.append(Tag('circle', cx=cx, cy=cy, fill='none', stroke=stroke,
r=(r-th/2), stroke_width=0.15))
tags.append(Tag('circle', cx=cx, cy=cy, fill='none', stroke=stroke,
r=max_rd, stroke_width=0.15))
group = []
for a1, a2 in sched:
da = (a2-a1)%tau
aw = math.tan(da)*(r-th/2)
rot = math.degrees(a2 - a1 + a1)
group.append(Tag('rect', x=cx + r-th/2, y=cy-aw, width=th, height=2*aw,
fill=stroke, transform=f'rotate({rot} {cx} {cy})'))
group.append(Tag('animateTransform', attributeName='transform', attributeType='XML', type='rotate',
_from=f'360 {cx} {cy}', to=f'0 {cx} {cy}', dur='10s', repeatCount='indefinite'))
tags.append(Tag('g', group))
print(f'Phase of best solution: {math.degrees(best_solution):.2f}°')
plots.append([
*plot_angles(angles_1_intersection_1, c1, 'url(#hatch_1)', title='First intersection'),
*plot_angles(shift_angles(angles_2_intersection_1, best_solution), c2, 'url(#hatch_2)', axes=False),
])
plots.append([
*plot_angles(angles_1_intersection_2, c1, 'url(#hatch_1)', title='Second intersection'),
Tag('g', [*plot_angles(shift_angles(angles_2_intersection_2, best_solution), c2, 'url(#hatch_2)', axes=False)],
style='mix-blend-mode:screen'),
])
pitch = 20
for i, children in enumerate(plots):
tags2.append(Tag('g', children, transform=f'translate(0 {(i + .2)*pitch})'))
out_svg_2.write_text(str(Tag.setup_svg(tags2 + dim_tags, bounds=((0, 0), (100, (i+.8)*pitch)), margin=3)))
out_svg_3.write_text(str(Tag.setup_svg(tags + dim_tags, bounds=(
(min(-r1, -r2+offset), min(-r1, -r2)),
(max(r1, r2+offset), max(r1, r2))), margin=3)))
if __name__ == '__main__':
cli()