shapes.py

At 1ae6528

1ae6528History

Preview

Code · 6.8 KiB
"""Smooth, closed cubic silhouettes.

Spline contours have continuous position, tangent and second derivative.
The heart uses smooth Bezier lobes with intentional corners at its notch and tip. 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',
    'butterfly':'Butterfly', 'bow':'Rounded bow', 'pebble':'Pebble', 'bubbles':'Bubble cluster',
    'cloud':'Cloud', 'strawberry':'Strawberry', '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 == 'strawberry':
        # Broad berry shoulders, tapered base, and a three-leaf crown.
        return [(0,-1.18),(.19,-1.03),(.14,-.73),(.50,-.98),
                (.66,-.89),(.47,-.61),(.83,-.65),(1.00,-.35),
                (.96,.04),(.73,.52),(.34,.97),(0,1.13),
                (-.34,.97),(-.73,.52),(-.96,.04),(-1.00,-.35),
                (-.83,-.65),(-.47,-.61),(-.66,-.89),(-.50,-.98),
                (-.14,-.73),(-.19,-1.03)]
    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 == 'butterfly':
        return [(0,-.39),(.27,-.96),(.80,-1.11),(1.18,-.78),
                (1.14,-.27),(.63,.08),(.99,.43),(.95,.89),
                (.54,1.03),(.17,.62),(0,.30),(-.17,.62),
                (-.54,1.03),(-.95,.89),(-.99,.43),(-.63,.08),
                (-1.14,-.27),(-1.18,-.78),(-.80,-1.11),(-.27,-.96)]
    if name == 'bow':
        return [(0,-.24),(.45,-.58),(.97,-.87),(1.12,-.55),
                (1.12,.55),(.97,.87),(.45,.58),(0,.24),
                (-.45,.58),(-.97,.87),(-1.12,.55),(-1.12,-.55),
                (-.97,-.87),(-.45,-.58)]
    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 _heart():
    """Broad reference-shaped heart: smooth lobes with deliberate notch and tip.

    The six spans meet tangentially at the shoulders and outer sides. The top
    notch and bottom point are intentional corners, not spline smoothing errors.
    """
    right=np.array([(0,-.54),(.18,-.76),(.34,-.83),(.53,-.83),
                    (.82,-.83),(1.,-.65),(1.,-.29),
                    (1.,.04),(.42,.55),(0,.82)])
    left=right[::-1].copy();left[:,0]*=-1
    return np.concatenate([right,left[1:]])


def shape(name, center, radius, rotation=0):
    """Return one closed cubic path; the heart retains its designed notch and tip."""
    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=_heart() if name=='heart' else _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)]

Files