shapes.py
At 204b365Copy the commit ID
Preview
Code · 5.3 KiB"""Smooth, closed cubic B-spline silhouettes.
Every contour has continuous position, tangent and second derivative, including
its closing seam. Analytic profiles define simple forms; sparse control polygons
define branching cutouts. Controls are approximated, never interpolated through
an uneven series of hand-positioned landmarks.
"""
import math
import numpy as np
SHAPES = {
'flower':'Flower', 'star':'Rounded star', 'sparkle':'Sparkle',
'sun':'Sunburst', 'coral':'Coral cutout', 'seaweed':'Seaweed cutout',
'leaf':'Leaf', 'bean':'Bean', 'pebble':'Pebble', 'bubbles':'Bubble cluster',
'cloud':'Cloud', 'pear':'Pear', 'lemon':'Lemon', 'apple':'Apple',
'heart':'Heart', 'crescent':'Crescent',
}
def _spline(controls):
"""Exact periodic uniform cubic B-spline converted to cubic Bezier spans."""
p = np.asarray(controls, dtype=float)
start = (p[-1] + 4*p[0] + p[1])/6
path = [start]
for i in range(len(p)):
a,b,c = p[i],p[(i+1)%len(p)],p[(i+2)%len(p)]
path.extend(((4*a+2*b)/6, (2*a+4*b)/6, (a+4*b+c)/6))
return np.asarray(path)
def _polar(lobes, base, depth, count=192, phase=-math.pi/2):
t = np.linspace(0,2*math.pi,count,endpoint=False)
r = base+depth*np.cos(lobes*t)
return np.column_stack((r*np.cos(t+phase),r*np.sin(t+phase)))
def _controls(name):
t = np.linspace(0,2*math.pi,128,endpoint=False)
if name == 'flower':
return _polar(5,.69,.29)
if name in ('star','sparkle'):
n = 5 if name=='star' else 4
# Alternating controls round the tips and valleys in one continuous curve.
angles = np.arange(2*n)*math.pi/n-math.pi/2
radii = np.resize([1.32,.34 if name=='star' else .10],2*n)
return np.column_stack((radii*np.cos(angles),radii*np.sin(angles)))
if name == 'sun':
return _polar(12,.86,.12,288)
if name == 'pebble':
return np.column_stack((.95*np.cos(t),.86*np.sin(t)))
if name == 'pear':
return [(0,-1.22),(.36,-1.02),(.43,-.68),(.36,-.31),
(.64,.03),(.97,.43),(.84,.89),(.40,1.12),
(-.40,1.12),(-.84,.89),(-.97,.43),(-.64,.03),
(-.36,-.31),(-.43,-.68),(-.36,-1.02)]
if name == 'lemon':
return np.column_stack((np.cos(t),.69*np.sin(t)*(1-.24*np.cos(t)**2)))
if name == 'apple':
return [(0,-.70),(.38,-1.02),(.91,-.80),(1.12,-.15),
(.85,.65),(.38,1.05),(0,.83),(-.38,1.05),
(-.85,.65),(-1.12,-.15),(-.91,-.80),(-.38,-1.02)]
if name == 'leaf':
# A tapered lens, then a fixed turn; no sampled hand-drawn perimeter.
x=np.cos(t); y=.40*np.sin(t)*(1-.32*np.cos(t)**2)
a=-.65
return np.column_stack((x*np.cos(a)-y*np.sin(a),x*np.sin(a)+y*np.cos(a)))
if name == 'bean':
# A smooth limacon has one shallow kidney-shaped indentation.
r=.75+.62*np.cos(t)
return np.column_stack((-(r*np.cos(t)-.43),1.12*r*np.sin(t)))
if name == 'heart':
return [(0,-.43),(.32,-1.04),(.93,-.94),(1.12,-.36),
(.81,.25),(.37,.68),(0,1.16),(-.37,.68),
(-.81,.25),(-1.12,-.36),(-.93,-.94),(-.32,-1.04)]
if name == 'crescent':
# Two clean arcs joined through small rounded tips.
return [(-.28,-1.03),(.32,-.97),(.88,-.51),(1.00,.05),
(.85,.60),(.30,1.02),(-.28,1.03),(.13,.70),
(.42,.34),(.45,-.05),(.34,-.44),(.10,-.77)]
if name == 'coral':
# Rounded asymmetric lobes, with long clean transitions between branches.
return [(-.20,1.10),(-.37,.98),(-.29,.40),(-.56,.25),
(-1.02,.40),(-1.07,.09),(-.64,-.01),(-.45,-.11),
(-.88,-.51),(-.78,-.83),(-.47,-.66),(-.23,-.32),
(-.23,-.98),(.02,-1.18),(.25,-.90),(.16,-.30),
(.58,-.69),(.97,-.58),(.91,-.29),(.39,-.16),
(.37,.10),(.97,.15),(1.03,.47),(.77,.64),
(.27,.30),(.25,.79),(.22,1.09)]
if name == 'seaweed':
return [(-.14,1.14),(-.37,.99),(-.18,.52),(-.62,.72),
(-1.02,.54),(-.90,.24),(-.53,.27),(-.22,.34),
(-.36,-.06),(-.78,-.11),(-.85,-.44),(-.56,-.59),
(-.21,-.17),(-.15,-.71),(-.06,-1.17),(.24,-1.13),
(.34,-.82),(.16,-.42),(.57,-.76),(.98,-.63),
(1.03,-.30),(.61,-.23),(.24,-.24),(.29,.12),
(.70,-.02),(.97,.20),(.84,.48),(.44,.35),
(.19,.40),(.29,.93),(.13,1.14)]
if name == 'bubbles':
# Smooth low-frequency radial profile: three unequal fused round cells.
r=.76+.17*np.cos(3*t+.6)+.085*np.cos(t-2.3)
return np.column_stack((r*np.cos(t),r*np.sin(t)))
if name == 'cloud':
# Five broad lobes with exact, smooth transitions and a flatter base.
r=.83+.105*np.cos(5*t)+.045*np.sin(2*t)
return np.column_stack((1.07*r*np.cos(t),.82*r*np.sin(t)))
raise ValueError(f'Unknown shape {name!r}')
def shape(name, center, radius, rotation=0):
"""Return one closed, C2-continuous cubic path in image coordinates."""
if name not in SHAPES:
raise ValueError(f"Unknown shape {name!r}; choose from {', '.join(SHAPES)}")
if not np.isfinite(radius) or radius<=0:
raise ValueError('radius must be finite and positive')
points=_spline(_controls(name))
c,s=math.cos(rotation),math.sin(rotation)
return [tuple(p) for p in points@np.array([[c,s],[-s,c]])*radius+np.asarray(center)]