shapes.py
At d794128Copy the commit ID
Preview
Code · 8.4 KiB"""Closed cubic Bézier silhouettes for the iridescent shape generator.
Every outline uses the same point format as ``geometry.curve_points``: one
start point followed by groups of two control points and one end point.
Coordinates below are local to a unit circle; ``shape`` applies size, turn,
and placement only after constructing the outline.
"""
import math
import numpy as np
from geometry import flower
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 _smooth(points, tension=1.0):
"""Interpolate a closed contour with gentle, continuous cubic tangents."""
p = np.asarray(points, dtype=float)
n = len(p)
tension = max(tension, 1.12)
path = [p[0]]
for i in range(n):
a, b = p[i], p[(i + 1) % n]
tangent_a = (p[(i + 1) % n] - p[(i - 1) % n]) * tension / 6
tangent_b = (p[(i + 2) % n] - p[i]) * tension / 6
path.extend((a + tangent_a, b - tangent_b, b))
return path
def _rounded_polygon(points, rounding):
"""Round selected corners while keeping each connecting side straight."""
p = np.asarray(points, dtype=float)
n = len(p)
if np.isscalar(rounding):
rounding = [rounding] * n
before = [p[i] + (p[(i - 1) % n] - p[i]) * rounding[i] for i in range(n)]
after = [p[i] + (p[(i + 1) % n] - p[i]) * rounding[i] for i in range(n)]
path = [before[0]]
for i in range(n):
# A short cubic rounds the vertex, then an exact cubic line joins it
# to the next vertex. Even the deepest notches remain separate.
path.extend((p[i], p[i], after[i]))
end = before[(i + 1) % n]
path.extend((after[i] + (end - after[i]) / 3,
after[i] + 2 * (end - after[i]) / 3, end))
return path
def _radial_points(count, inner, outer, phase=-math.pi / 2):
return [(r * math.cos(phase + i * math.pi / count),
r * math.sin(phase + i * math.pi / count))
for i in range(count * 2)
for r in [outer if i % 2 == 0 else inner]]
def _star():
return _rounded_polygon(_radial_points(5, .48, 1), .48)
def _sparkle():
# Four long points and four generously scooped intervals.
return _rounded_polygon(_radial_points(4, .24, 1, -math.pi / 2),
[.15, .28] * 4)
def _sun():
# A continuous wavy rim gives twelve inflated, round-ended rays.
angles = np.linspace(0, 2 * math.pi, 144, endpoint=False)
radii = .85 + .13 * np.cos(12 * angles)
return _smooth(np.stack([radii * np.cos(angles), radii * np.sin(angles)], 1))
def _coral():
# A flat-bottomed stem grows into unequal, blunt branches.
return _smooth([
(-.22, .94), (-.26, .48), (-.46, .34), (-.76, .38),
(-.86, .23), (-.76, .10), (-.47, .12), (-.43, -.15),
(-.70, -.39), (-.67, -.58), (-.51, -.63), (-.26, -.40),
(-.18, -.80), (-.04, -.98), (.12, -.92), (.18, -.49),
(.34, -.36), (.60, -.53), (.79, -.45), (.81, -.27),
(.62, -.17), (.39, -.14), (.38, .13), (.69, .17),
(.82, .33), (.72, .49), (.43, .43), (.22, .34),
(.20, .94),
], .92)
def _seaweed():
# A slender upright ribbon with broad opposing lobes.
return _smooth([
(-.21, .96), (-.27, .65), (-.55, .65), (-.79, .53),
(-.78, .37), (-.61, .32), (-.30, .40), (-.32, .08),
(-.60, -.02), (-.72, -.21), (-.62, -.35), (-.43, -.31),
(-.27, -.18), (-.16, -.91), (.01, -.98), (.19, -.89),
(.23, -.49), (.54, -.64), (.76, -.58), (.83, -.40),
(.68, -.29), (.27, -.27), (.29, .04), (.54, .00),
(.75, .10), (.75, .25), (.60, .33), (.30, .29),
(.24, .92), (.06, 1.00),
], .92)
def _leaf():
return _smooth([
(-.87, .64), (-.70, .28), (-.55, -.10), (-.27, -.52),
(.10, -.81), (.59, -.94), (.94, -.77), (.75, -.37),
(.43, .02), (.04, .37), (-.41, .59),
], .72)
def _bean():
return _smooth([
(-.91, -.03), (-.76, -.48), (-.38, -.79), (.13, -.83),
(.58, -.57), (.82, -.17), (.64, .08), (.31, .04),
(.10, .29), (.18, .64), (-.13, .84), (-.57, .65),
])
def _pebble():
return _smooth([
(-.91, .15), (-.73, -.48), (-.23, -.83), (.40, -.78),
(.90, -.35), (.98, .18), (.62, .72), (.10, .90),
(-.49, .69),
], .85)
def _bubbles():
# Three unequal overlapping circular cells. A short circular smoothing
# kernel rounds their union saddles into a continuous inflated contour.
angles = np.linspace(0, 2 * math.pi, 180, endpoint=False)
directions = np.stack([np.cos(angles), np.sin(angles)], 1)
intersections = []
for center, radius in [((-.28, .30), .66), ((.04, -.44), .50), ((.50, .05), .36)]:
center = np.asarray(center)
projection = directions @ center
discriminant = radius ** 2 - center @ center + projection ** 2
distance = projection + np.sqrt(np.maximum(discriminant, 0))
intersections.append(np.where(discriminant >= 0, distance, 0))
radii = np.max(intersections, axis=0)
offsets = np.arange(-9, 10)
weights = np.exp(-.5 * (offsets / 3.2) ** 2)
weights /= weights.sum()
radii = sum(weight * np.roll(radii, offset) for weight, offset in zip(weights, offsets))
return _smooth(directions * radii[:, None])
def _cloud():
return _smooth([
(-.90, .49), (-.99, .20), (-.87, -.08), (-.56, -.17),
(-.51, -.49), (-.24, -.66), (.06, -.57), (.28, -.80),
(.63, -.73), (.80, -.45), (.74, -.18), (.96, -.04),
(1.00, .27), (.83, .49), (.57, .49), (.42, .72),
(.12, .83), (-.16, .68), (-.40, .83), (-.69, .73),
], .82)
def _pear():
return _smooth([
(0, -.97), (.28, -.89), (.41, -.65), (.42, -.32),
(.54, -.05), (.79, .22), (.82, .58), (.59, .90),
(.17, 1.00), (-.28, .96), (-.70, .75), (-.83, .41),
(-.69, .08), (-.45, -.20), (-.40, -.60), (-.23, -.87),
], .87)
def _lemon():
return _smooth([
(-1.00, .00), (-.82, -.12), (-.65, -.48), (-.27, -.70),
(.23, -.72), (.66, -.48), (.85, -.12), (1.00, .00),
(.84, .13), (.63, .48), (.22, .70), (-.27, .72),
(-.67, .46), (-.85, .12),
], .72)
def _apple():
return _smooth([
(0, -.73), (.27, -.87), (.52, -.81), (.78, -.51),
(.90, -.03), (.73, .56), (.40, .88), (.08, .94),
(0, .82), (-.09, .94), (-.42, .87), (-.74, .55),
(-.90, -.04), (-.77, -.52), (-.50, -.82), (-.25, -.87),
], .80)
def _heart():
return _smooth([
(0, -.55), (.28, -.87), (.66, -.85), (.94, -.59),
(.98, -.21), (.81, .13), (.50, .43), (0, .96),
(-.50, .43), (-.81, .13), (-.98, -.21),
(-.94, -.59), (-.66, -.85), (-.28, -.87),
], .72)
def _crescent():
# Both sides of the crescent share the same tips; the inner arc is
# deliberately offset so the silhouette stays a single simple contour.
return [
(-.15, -.99),
(.40, -.99), (.87, -.55), (.91, .02),
(.95, .61), (.42, .99), (-.15, .99),
(.29, .78), (.53, .43), (.53, .02),
(.53, -.44), (.28, -.78), (-.15, -.99),
]
_BUILDERS = {
"star": _star, "sparkle": _sparkle, "sun": _sun,
"coral": _coral, "seaweed": _seaweed, "leaf": _leaf,
"bean": _bean, "pebble": _pebble, "bubbles": _bubbles,
"cloud": _cloud, "pear": _pear, "lemon": _lemon,
"apple": _apple, "heart": _heart, "crescent": _crescent,
}
def shape(name, center, radius, rotation=0):
"""Return a closed cubic outline in image coordinates.
``rotation`` is in radians, matching ``geometry.flower``. Unknown names
raise ``ValueError`` so generator options fail clearly at the boundary.
"""
if name not in SHAPES:
raise ValueError(f"Unknown shape {name!r}; choose from {', '.join(SHAPES)}")
if radius <= 0:
raise ValueError("radius must be positive")
if name == "flower":
return flower(center, radius, rotation=rotation)
points = np.asarray(_BUILDERS[name](), dtype=float)
c, s = math.cos(rotation), math.sin(rotation)
turn = np.array([[c, -s], [s, c]])
transformed = points @ turn.T * radius + np.asarray(center, dtype=float)
return [tuple(p) for p in transformed]