#!/usr/bin/env python3
"""
Comitl
Concentrically arranges randomly sized arcs into a pretty disc shape.
Copyright © 2020 Christian Rosentreter
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
$Id: comitl.py 122 2020-05-30 03:40:01Z tokai $
"""
import math
import random
import argparse
import sys
import os
import xml.etree.ElementTree as xtree
__author__ = 'Christian Rosentreter'
__version__ = '1.7'
__all__ = ['SVGArcPathSegment']
class SVGArcPathSegment():
"""An 'arc' SVG path segment."""
def __init__(self, offset=0.0, angle=90.0, radius=1.0, x=0.0, y=0.0):
self.offset = offset
self.angle = angle
self.radius = radius
self.x = x
self.y = y
def __str__(self):
if self.angle == 0:
return ''
if abs(self.angle) < 360:
path_format = (
'M {sx} {sy} '
'A {rd} {rd} 0 {fl} 1 {dx} {dy}'
)
ts = (self.offset - 180.0) * math.pi / -180.0
td = (self.offset + self.angle - 180.0) * math.pi / -180.0
else:
path_format = (
'M {sx} {sy} '
'A {rd} {rd} 0 0 1 {dx} {dy} ' # essentially a circle formed by…
'A {rd} {rd} 0 1 1 {sx} {sy} ' # … two 180° arcs
'Z'
)
ts = 0
td = math.pi
return path_format.format(
sx=round(self.x + self.radius * math.sin(ts), 9),
sy=round(self.y + self.radius * math.cos(ts), 9),
rd=round(self.radius, 9),
fl=int(abs(ts - td) > math.pi),
dx=round(self.x + self.radius * math.sin(td), 9),
dy=round(self.y + self.radius * math.cos(td), 9)
)
def main():
"""First, build fire. Second, start coffee."""
ap = argparse.ArgumentParser(
description=('Concentrically arranges randomly sized arcs into a pretty disc shape. Output is '
'generated as a set of vector shapes in Scalable Vector Graphics (SVG) format and printed '
'on the standard output stream.'),
epilog='Report bugs, request features, or provide suggestions via https://github.com/the-real-tokai/macuahuitl/issues',
add_help=False,
)
g = ap.add_argument_group('Startup')
g.add_argument('-V', '--version', action='version', help="show version number and exit", version='%(prog)s {}'.format(__version__), )
g.add_argument('-h', '--help', action='help', help='show this help message and exit')
g = ap.add_argument_group('Algorithm')
g.add_argument('--circles', metavar='INT', type=int, help='number of concentric arc elements to generate inside the disc [:21]', default=21)
g.add_argument('--stroke-width', metavar='FLOAT', type=float, help='width of the generated strokes [:6]', default=6.0)
g.add_argument('--gap', metavar='FLOAT', type=float, help='distance between the generated strokes')
g.add_argument('--inner-radius', metavar='FLOAT', type=float, help='setup inner disc radius to create an annular shape')
g.add_argument('--hoffset', metavar='FLOAT', type=float, help='shift the whole disc horizontally [:0.0]', default=0.0)
g.add_argument('--voffset', metavar='FLOAT', type=float, help='shift the whole disc vertically [:0.0]', default=0.0)
g.add_argument('--color', metavar='COLOR', type=str, help='SVG compliant color specification or identifier [:black]', default='black')
g.add_argument('--random-seed', metavar='INT', type=int, help='fixed initialization of the random number generator for predictable results')
g.add_argument('--randomize', action='store_true', help='generate truly random disc layouts; other algorithm values provided via command line parameters are utilized as limits')
g = ap.add_argument_group('Miscellaneous')
g.add_argument('--separate-paths', action='store_true', help='generate separate elements for each arc; automatically implied when animation support is enabled')
g.add_argument('--outline-mode', help='generate bounding outline circles [:both]', choices=['both', 'outside', 'inside', 'none'], default='both')
g.add_argument('--background-color', metavar='COLOR', type=str, help='SVG compliant color specification or identifier; adds a background to the SVG output')
g.add_argument('--disc-color', metavar='COLOR', type=str, help='SVG compliant color specification or identifier; fills the background of the generated disc by adding an extra element')
g.add_argument('--animation-mode', help='enables SVG support', choices=['random', 'bidirectional', 'cascade-in', 'cascade-out'])
g.add_argument('--animation-duration', metavar='FLOAT', type=float, help='defines base duration of one full 360° arc rotation (in seconds); negative inputs switch to counter-clockwise base direction [:6.0]', default=6.0)
g.add_argument('--animation-offset', metavar='FLOAT', type=float, help='offset the animation (in seconds) to support rendering to frame sequences for frame based animation formats. [:0]', default=0.0)
g = ap.add_argument_group('Output')
g.add_argument('-o', '--output', metavar='FILENAME', type=str, help='optionally rasterize the generated vector paths and write the result into a PNG file (requires the `svgcairo\' Python module)')
g.add_argument('--output-size', metavar='INT', type=int, help='force pixel width and height of the raster image; if omitted the generated SVG viewbox dimensions are used')
user_input = ap.parse_args()
# Initialize…
#
chaos = random.Random(user_input.random_seed)
circles = user_input.circles
stroke = abs(user_input.stroke_width) if user_input.stroke_width else 1.0
gap = user_input.gap if (user_input.gap is not None) else stroke
radius = abs(user_input.inner_radius) if (user_input.inner_radius is not None) else stroke
x = user_input.hoffset
y = user_input.voffset
color = user_input.color
if user_input.randomize:
circles = chaos.randrange(0, circles) if circles else 0
stroke = chaos.uniform(0, stroke)
stroke = 1.0 if stroke == 0 else stroke
gap = chaos.uniform(0, gap)
radius = chaos.uniform(0, radius)
x = chaos.uniform(-x, x) if x else 0.0
y = chaos.uniform(-y, y) if y else 0.0
color = '#{:02x}{:02x}{:02x}'.format(chaos.randrange(0, 255), chaos.randrange(0, 255), chaos.randrange(0, 255))
# TODO: randomize background and disc color too when the respective parameters are used
# (needs to respect color harmonies)
if radius < stroke:
radius = stroke
# Generate data…
#
outlines = []
arcs = []
if user_input.outline_mode in ('both', 'inside'):
outlines.append({'x':x, 'y':y, 'r':radius})
radius += (gap + stroke)
for _ in range(circles):
# Calculate angular space requirement for the "round" stroke caps to avoid some overlapping
sqrd2 = 2.0 * math.pow(radius, 2.0)
theta = ((2.0 * math.acos((sqrd2 - math.pow((stroke / 2.0), 2.0)) / sqrd2)) * (180.0 / math.pi))
arcs.append(SVGArcPathSegment(offset=chaos.uniform(0, 359.0), angle=chaos.uniform(0, 359.0 - theta), radius=radius, x=x, y=y))
radius += (gap + stroke)
if user_input.outline_mode in ('both', 'outside'):
outlines.append({'x':x, 'y':y, 'r':radius})
else:
radius -= (gap + stroke)
# Generate SVG/XML…
#
def _f(v, max_digits=9):
if isinstance(v, float):
v = round(v, max_digits)
return v if isinstance(v, str) else str(v)
vb_dim = (radius + (stroke * 0.5)) * (256.0 / (256.0 - 37.35)) # 37px border for 256x256; a golden ratio in there… somewhere…
vb_off = _f(vb_dim * -1.0, 2)
vb_dim = _f(vb_dim * 2.0, 2)
config = {'stroke':color, 'stroke-width':_f(stroke), 'fill':'none'}
svg = xtree.Element('svg', {'width':'100%', 'height':'100%', 'xmlns':'http://www.w3.org/2000/svg', 'viewBox':'{o} {o} {s} {s}'.format(o=vb_off, s=vb_dim)})
title = xtree.SubElement(svg, 'title')
title.text = 'A Comitl Artwork'
if user_input.background_color:
xtree.SubElement(svg, 'rect', {'id':'background', 'x':vb_off, 'y':vb_off, 'width':vb_dim, 'height':vb_dim, 'fill':user_input.background_color})
svg_m = xtree.SubElement(svg, 'g', {'id':'comitl-disc'})
if user_input.disc_color:
xtree.SubElement(svg_m, 'circle', {'id':'disc-background', 'cx':_f(x), 'cy':_f(y), 'r':_f(radius), 'fill':user_input.disc_color})
if arcs:
if user_input.separate_paths or user_input.animation_mode:
svg_ga = xtree.SubElement(svg_m, 'g', {'id':'arcs'})
for aid, a in enumerate(arcs):
svg_arc = xtree.SubElement(svg_ga, 'path', {'id':'arc-{}'.format(aid+1), 'stroke-linecap':'round', **config})
shift = 0.0
if user_input.animation_mode:
if user_input.animation_mode == 'cascade-out':
d = user_input.animation_duration * ((aid+1) * 0.25) # TODO: 1/4 decay value could be configurable
elif user_input.animation_mode == 'cascade-in':
d = user_input.animation_duration * ((len(arcs)-aid+1) * 0.25)
else:
# limits duration range into a 50% variation window to avoid super fast arcs with values closer to 0
d = chaos.uniform(abs(user_input.animation_duration) * 0.5, abs(user_input.animation_duration)) # TODO: variation could be configurable
if user_input.animation_duration < 0:
d *= -1 # restore user direction
if (user_input.animation_mode == 'bidirectional') and (chaos.random() < 0.5):
d *= -1 # switch direction randomly
shift = (360.0 / d) * user_input.animation_offset
xtree.SubElement(svg_arc, 'animateTransform', {
'attributeName': 'transform',
'type': 'rotate',
'from': '{} {} {}'.format(360 if d < 0 else 0, x, y),
'to': '{} {} {}'.format( 0 if d < 0 else 360, x, y),
'dur': '{}s'.format(abs(d)),
'repeatCount': 'indefinite'
})
a.offset += shift
svg_arc.set('d', str(a))
else:
xtree.SubElement(svg_m, 'path', {'id':'arcs', 'd':''.join(map(str, arcs)), 'stroke-linecap':'round', **config})
if outlines:
svg_go = xtree.SubElement(svg_m, 'g', {'id':'outlines'})
for oid, o in enumerate(outlines):
xtree.SubElement(svg_go, 'circle', {'id':'outline-{}'.format(oid+1), 'cx':_f(o['x']), 'cy':_f(o['y']), 'r':_f(o['r']), **config})
svg.append(xtree.Comment(' Generator: comitl.py {} (https://github.com/the-real-tokai/macuahuitl) '.format(__version__)))
rawxml = xtree.tostring(svg, encoding='unicode')
# Send happy little arcs out into the world…
#
if not user_input.output:
print(rawxml)
else:
try:
from cairosvg import svg2png
svg2png(bytestring=rawxml,
write_to=os.path.realpath(os.path.expanduser(user_input.output)),
output_width=user_input.output_size,
output_height=user_input.output_size
)
except ImportError as e:
print('Couldn\'t rasterize nor write a PNG file. Required Python module \'cairosvg\' is not available: {}'.format(str(e)), file=sys.stderr)
if __name__ == "__main__":
main()