File size: 5,860 Bytes
51a9974 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | """
Path-following scroll unroll: the spiral lays down ALONG its arm, led by a 3D curl.
The shell's `growth` parameter truncates the spiral along its own arm (not
radially), so a sequence of growth stages IS a scroll unrolling along the spiral
path: an outer ring stays hidden until the arm reaches it, even if an inner point
at the same clock-angle is already laid down.
We render N growth stages as a flipbook stacked in one SVG. Each stage is shown
in sequence via SMIL (set/animate on opacity), so the laid parchment grows along
the arm. A 3D curl object (a small cylinder with a gold highlight and a cast
shadow) rides the leading tip via animateMotion along the centerline path,
shrinking as it spends its paper, then vanishes at the rim.
This is heavier than a single-SVG animation (~1.6MB for 14 stages) but it is the
only way to get a genuine path-following reveal of a filled spiral.
"""
from __future__ import annotations
import math
import re
N_STAGES = 14
UNROLL_DUR = 3.6 # total seconds
EYE_X, EYE_Y = 320.0, 335.0
def _centerline_d(svg: str) -> str | None:
m = re.search(r'<path class="shell-centerline" d="([^"]*)"', svg)
return m.group(1) if m else None
def _curl_object(stage_frac: float) -> str:
"""A 3D paper-curl cylinder, sized for how much paper remains (shrinks as the
scroll unrolls). Returned as a <g id="curl"> to be motion-animated."""
# remaining paper ~ (1 - stage_frac); curl radius shrinks from ~16 to ~5
r = 5 + 11 * (1 - stage_frac)
return (
f'<g id="paper-curl">'
# cast shadow on the parchment just behind the curl
f'<ellipse cx="2" cy="{r*0.6:.1f}" rx="{r*1.1:.1f}" ry="{r*0.45:.1f}" '
f'fill="#1a1410" opacity="0.28"/>'
# the cylinder body (the rolled paper, seen end-on)
f'<ellipse cx="0" cy="0" rx="{r:.1f}" ry="{r*0.78:.1f}" '
f'fill="#d8c79a" stroke="#8a7340" stroke-width="1"/>'
# inner roll rings (paper thickness)
f'<ellipse cx="0" cy="0" rx="{r*0.6:.1f}" ry="{r*0.46:.1f}" '
f'fill="none" stroke="#a8915c" stroke-width="0.8" opacity="0.7"/>'
f'<ellipse cx="0" cy="0" rx="{r*0.28:.1f}" ry="{r*0.22:.1f}" '
f'fill="#bda874" stroke="#8a7340" stroke-width="0.6"/>'
# gold highlight on the rounded leading edge
f'<ellipse cx="{-r*0.4:.1f}" cy="{-r*0.3:.1f}" rx="{r*0.34:.1f}" '
f'ry="{r*0.2:.1f}" fill="#efe0b0" opacity="0.85"/>'
f'</g>'
)
def build_unroll_doc(stage_svgs: list[str]) -> str:
"""Stack growth-stage SVGs as a flipbook + a curl riding the tip. Returns one
SVG string with SMIL timing. stage_svgs[i] is the shell at growth (i+1)/N.
Each stage is wrapped in a <g> that becomes visible at its time slot and
hides when the next appears, so the laid parchment advances along the arm.
The final stage stays visible (freeze).
"""
n = len(stage_svgs)
slot = UNROLL_DUR / n
# strip the outer <svg ...> wrapper from each stage, keep inner content
def inner(s):
a = s.find(">", s.find("<svg")) + 1
b = s.rfind("</svg>")
return s[a:b]
# the background (sky, particles) is identical across stages; take it from
# the LAST stage once, render it always-on underneath, and only flip the
# SHELL portion. But simplest robust approach: each stage is a full frame;
# show one at a time. The sky is identical so no flicker on the backdrop.
layers = []
for i, s in enumerate(stage_svgs):
content = inner(s)
# Each stage is visible only during its own [begin, begin+slot) window.
# We drive this with a SINGLE <animate> using values/keyTimes over the
# whole duration, so it resets cleanly on replay (setCurrentTime(0)).
on_t = i / n
off_t = (i + 1) / n
if i == n - 1:
# final stage: turn on at its start and STAY on (freeze full shell)
if on_t <= 0:
vals, keys = "1;1", "0;1"
else:
vals = "0;0;1;1"
keys = f"0;{on_t:.4f};{on_t:.4f};1"
elif i == 0:
# first stage: on from 0, off at off_t
vals = "1;1;0;0"
keys = f"0;{off_t:.4f};{off_t:.4f};1"
else:
# middle stage: off, on at on_t, off at off_t, off after
vals = "0;0;1;1;0;0"
keys = f"0;{on_t:.4f};{on_t:.4f};{off_t:.4f};{off_t:.4f};1"
anim = (
f'<animate attributeName="opacity" values="{vals}" keyTimes="{keys}" '
f'dur="{UNROLL_DUR}s" begin="0s" fill="freeze" calcMode="discrete"/>'
)
start_op = "1" if i == 0 else "0"
layers.append(f'<g opacity="{start_op}">{anim}{content}</g>')
# the 3D curl rides the centerline path of the FINAL stage (full spiral),
# via animateMotion, shrinking over the unroll, vanishing at the end.
cl = _centerline_d(stage_svgs[-1])
curl_layer = ""
if cl:
curl = _curl_object(0.0)
curl_layer = (
f'<g>{curl}'
f'<animateMotion dur="{UNROLL_DUR}s" begin="0s" fill="freeze" '
f'rotate="auto" keyPoints="0;1" keyTimes="0;1" calcMode="linear" '
f'path="{cl}"/>'
# shrink the curl as it spends paper
f'<animateTransform attributeName="transform" type="scale" '
f'additive="sum" from="1.6" to="0.5" dur="{UNROLL_DUR}s" begin="0s" '
f'fill="freeze"/>'
# vanish at the very end
f'<animate attributeName="opacity" values="1;1;0" keyTimes="0;0.92;1" '
f'dur="{UNROLL_DUR}s" begin="0s" fill="freeze"/>'
f'</g>'
)
W = H = 640
body = "".join(layers) + curl_layer
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" '
f'width="{W}" height="{H}">{body}</svg>'
)
|