shapes.py
At 6a45318Copy the commit ID
Preview
Code · 6.4 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':
# Short bent stem, tapered shoulders, and a broad rounded lower belly.
return [(-.07,-.76),(-.10,-1.04),(.02,-1.20),(.14,-1.16),
(.04,-1.01),(.03,-.80),(.23,-.73),(.36,-.46),
(.44,-.16),(.73,.12),(.96,.47),(.90,.84),
(.56,1.09),(0,1.18),(-.56,1.09),(-.90,.84),
(-.96,.47),(-.73,.12),(-.44,-.16),(-.32,-.49),(-.22,-.74)]
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=.70*np.sin(t)*(1-.25*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':
# Plump jelly bean with a broad, soft inward sweep rather than a pinched dent.
return [(0,-1.02),(.53,-.98),(.84,-.65),(.80,-.32),
(.55,-.04),(.49,.28),(.62,.64),(.36,.98),
(-.16,1.08),(-.65,.82),(-.92,.30),(-.89,-.30),(-.57,-.83)]
if name == 'heart':
# Inflated, near-circular shoulders and a short rounded lower point.
return [(0,-.44),(.27,-1.04),(.85,-1.12),(1.25,-.78),
(1.30,-.20),(1.06,.39),(.59,.80),(0,1.06),
(-.59,.80),(-1.06,.39),(-1.30,-.20),(-1.25,-.78),
(-.85,-1.12),(-.27,-1.04)]
if name == 'crescent':
# A thick lunar body with a shallow, open inner arc and rounded horns.
return [(-.44,-1.06),(.30,-1.14),(.97,-.70),(1.14,0),
(.97,.70),(.30,1.14),(-.44,1.06),(-.08,.73),
(.12,.36),(.16,0),(.12,-.36),(-.08,-.73)]
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':
# Upright, mirror-symmetric union of circular lobes. No shear or stretch.
angles=np.linspace(0,2*math.pi,512,endpoint=False)
directions=np.column_stack((np.cos(angles),np.sin(angles)))
roots=[]
for cx,cy,radius in [(0,-.30,.66),(-.59,.10,.47),(.59,.10,.47),(0,.02,.58)]:
center=np.array([cx,cy]);projection=directions@center
discriminant=radius*radius-center@center+projection*projection
roots.append(np.where(discriminant>=0,projection+np.sqrt(np.maximum(discriminant,0)),0))
radii=np.max(roots,axis=0)
# A narrow symmetric blend rounds only the transitions between circles.
offsets=np.arange(-8,9);weights=np.exp(-.5*(offsets/2.0)**2);weights/=weights.sum()
radii=sum(w*np.roll(radii,int(o)) for w,o in zip(weights,offsets))
return directions*radii[:,None]
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)]