t templated, so nudge the base
// x by a seed-stable, bell-weighted amount (mostly small, occasional larger) — the whole plant +
// its companions shift together; the full-width ground line stays, so it just sits left/right of
// centre. Bounded so wide canopies don't clip. Species can opt out / override via spec.basePlace.
const baseDX = (spec.basePlace===false ? 0 : gauss(rng('placeX',st.seed)) * (spec.basePlace||45));
const env={ageF,m,density,structure:structureOf(spec,st),rho,rewards,reproOK,v,dead,sen,dormancy,
season:seasonOf(st.now),browny:clamp(Math.max(1-v,sen),0,1),now:st.now,W,H,baseX:BASEX+baseDX,baseY:BASEY};
env.pollinators=pollinatorsOf(spec,st,env);
return env;
}
function renderPlant(spec, st){
const env=computeEnv(spec,st);
const C=makeCanvas();
spec.draw(st,C,env);
let soil='';
if(!spec.noSoil){ // species can opt out of the drawn ground line
const soilC=mix((spec.PAL&&spec.PAL.soil)||'#6f5128','#9a9180',0.25);
for(let i=0;i<30;i++){ const x=40+i*((W-80)/29), yy=BASEY+16+Math.sin(i*1.7)*3;
soil+=glyph('.',x,yy,GLY,soilC,1.1); } }
const sc=schemeFor(st.seed);
const plant=C.resolve(sc, rng('struct',st.artSeed), spec.LW||DEFLW, spec.schemeExempt||['flowers'], spec.LS||DEFLS);
// ecosystem visitors: drawn ABOVE the plant in negative space; never the plant body (§6.3)
let poll='';
if(env.pollinators.length && !spec.placesPollinators){ // species may place its own instead
placePollinators(C, env.pollinators, st); // anchor to this species' actual blooms
for(const p of env.pollinators) poll+=drawPollinator(p,env);
}
const inner=soil+plant+(poll?`<g id="lyr-pollinators">${poll}</g>`:'');
return framed(BACKDROPS[backdropFor(st.seed)], inner);
}
// ---------- common stage / traits ----------
function defaultStage(spec, st, env){
if(st.diedAt>0) return 'Sealed';
const m=env.m, age=env.ageF;
if(age>0.85) return 'Senescent';
if(m<0.12) return 'Sprout'; if(m<0.4) return 'Young'; if(m<0.62) return 'Developing';
if(m<0.82) return 'Mature'; return 'Full';
}
function commonTraits(spec, st, env){
const stage = spec.stageName ? spec.stageName(st,env) : defaultStage(spec,st,env);
const rows = [
['Backdrop', BACKDROP_NAMES[backdropFor(st.seed)]],
['Scheme', SchemeName(schemeFor(st.seed))],
['Stage', stage],
['Vitality', Math.round(env.v*100)+'%'],
['Devotion', st.entryCount>=20?'High':st.entryCount>=8?'Medium':'Low'],
['Never wilted', st.everWilted?'No':'Yes'],
];
const ps=env.pollinators||[]; // earned via public love (>10 notes, in season)
// exact tokenURI attribute names — match ART_SPEC §8 / CONTRACT_SPEC §8.5 so the contract emits these
const cap=s=>s.charAt(0).toUpperCase()+s.slice(1);
rows.push(['Pollinator', ps.length ? [...new Set(ps.map(p=>p.type))].map(cap).join('/') : 'None']);
rows.push(['Pollinator Count', String(ps.length)]);
const morph=ps.map(p=>p.morph).find(Boolean); // rare collectible variant, if any
rows.push(['Pollinator Morph', morph?cap(morph):'None']);
return rows;
}
// ============================================================
// mount(spec): inject chrome + wire the controller
// ============================================================
function mount(spec){
document.title='Seedling · '+spec.title;
const accent=(spec.PAL&&spec.PAL.accent)||'#7FA66B';
document.body.innerHTML=`
<style>
:root{ --bg:#11140f; --panel:#1b1f17; --line:#2c3322; --ink:#e9e6d8; --muted:#9aa088; --accent:${accent}; }
*{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);
font:14px/1.5 ui-sans-serif,system-ui,sans-serif;display:flex;min-height:100vh}
.stage{flex:1;display:flex;align-items:center;justify-content:center;padding:22px}
.stage svg{height:min(90vh,880px);width:auto;box-shadow:0 20px 60px rgba(0,0,0,.45)}
.panel{width:340px;background:var(--panel);border-left:1px solid var(--line);padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:16px}
h1{font-size:15px;margin:0;letter-spacing:.05em;text-transform:uppercase}
h1 small{display:block;color:var(--muted);font-size:11px;letter-spacing:.12em;margin-top:4px}
.group{border:1px solid var(--line);border-radius:11px;padding:13px}
.group h2{font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--muted);margin:0 0 9px}
textarea{width:100%;min-height:52px;resize:vertical;background:#0e120b;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px;font:inherit}
.btns{display:flex;flex-wrap:wrap;gap:7px;margin-top:9px}
button{background:#23291b;color:var(--ink);border:1px solid var(--line);padding:8px 11px;border-radius:8px;cursor:pointer;font:inherit;transition:.15s}
button:hover{border-color:var(--accent);background:#2b3322}
button.primary{background:var(--accent);color:#10140c;border-color:var(--accent);font-weight:600}
button:disabled{opacity:.4;cursor:not-allowed}
.stat{display:flex;justify-content:space-between;align-items:center;margin:6px 0;font-size:13px}
.stat b{font-variant-numeric:tabular-nums}
.bar{height:7px;background:#0e120b;border-radius:5px;overflow:hidden;margin-top:3px}
.bar>i{display:block;height:100%;border-radius:5px;transition:width .35s}
.meta{display:grid;grid-template-columns:1fr 1fr;gap:5px 12px;font-size:12.5px}
.pill{display:inline-block;padding:2px 9px;border-radius:20px;font-size:11px;background:#0e120b;border:1px solid var(--line);color:var(--muted)}
.pill.alive{color:#9fd68a;border-color:#3c5a30}.pill.dead{color:#d98a8a;border-color:#5a3030}
.traits{display:flex;flex-wrap:wrap;gap:6px}.traits .t{font-size:11px;padding:3px 8px;border-radius:6px;background:#0e120b;border:1px solid var(--line)}
.note{color:var(--muted);font-size:11px;line-height:1.5}
a.dl{color:var(--accent);font-size:12px;text-decoration:none}
</style>
<div class="stage"><div id="art"></div></div>
<div class="panel">
<h1>Seedling <small>${spec.title} · ${spec.sub||'v1'}</small></h1>
<div class="group"><h2>Write an entry (waters the plant)</h2>
<textarea id="entry" placeholder="Write today's thought… what you write shapes the plant."></textarea>
<div class="btns"><button class="primary" id="write">✎ Write entry</button>
<button id="w7">+1 week</button><button id="w30">+1 month</button></div>
${spec.note?`<div class="note" style="margin-top:8px">${spec.note}</div>`:''}
</div>
<div class="group"><h2>The three inputs</h2>
<div class="stat"><span>Maturity</span><b id="mVal">0%</b></div><div class="bar"><i id="mBar" style="width:0%;background:var(--accent)"></i></div>
<div class="stat"><span>Vitality</span><b id="vVal">100%</b></div><div class="bar"><i id="vBar" style="width:100%;background:#E8A200"></i></div>
<div class="stat"><span>Age</span><b id="aVal">0%</b></div><div class="bar"><i id="aBar" style="width:0%;background:#c47a4a"></i></div>
</div>
<div class="group"><h2>State</h2>
<div class="meta"><span>Stage</span><b id="stage">—</b><span>Entries</span><b id="entries">0</b>
<span>Age</span><b id="age">0 d</b><span>Season</span><b id="season">—</b>
<span>Status</span><span id="status" class="pill alive">alive</span></div>
</div>
<div class="group"><h2>Live traits</h2><div class="traits" id="traits"></div></div>
<div class="btns"><button id="play" disabled>▶ Memorial</button><button id="reset">↺ New seed</button></div>
<a class="dl" id="download" href="#">⤓ Download SVG</a>
</div>`;
const LIFESPAN=spec.LIFESPAN, DECAY=spec.DECAY||1/21;
let st, history;
const $=id=>document.getElementById(id);
function fresh(seed){ st={seed,artSeed:seed,entryCount:0,now:0,lastWatered:0,vitality:1,
wateredEpochs:0,lastWaterWeek:-1,diedAt:0,everWilted:false}; history=[snap()]; }
function snap(){ return {now:st.now,entryCount:st.entryCount,vitality:st.vitality,
wateredEpochs:st.wateredEpochs,artSeed:st.artSeed,diedAt:st.diedAt}; }
function paint(s){ $('art').innerHTML=renderPlant(spec, s||st); }
function refresh(){ paint();
const env=computeEnv(spec,st), age=clamp(st.now/LIFESPAN,0,1);
$('mVal').textContent=Math.round(env.m*100)+'%'; $('mBar').style.width=(env.m*100)+'%';
$('vVal').textContent=Math.round(st.vitality*100)+'%'; $('vBar').style.width=(st.vitality*100)+'%';
$('aVal').textContent=Math.round(age*100)+'%'; $('aBar').style.width=(age*100)+'%';
$('stage').textContent=spec.stageName?spec.stageName(st,env):defaultStage(spec,st,env);
$('entries').textContent=st.entryCount; $('age').textContent=Math.round(st.now)+' d';
$('season').textContent=seasonOf(st.now);
const dead=st.diedAt>0, status=$('status');
status.textContent=dead?('sealed · '+st.deathCause):'alive'; status.className='pill '+(dead?'dead':'alive');
$('play').disabled=!dead; ['write','w7','w30'].forEach(id=>$(id).disabled=dead);
const tw=$('traits'); tw.innerHTML='';
const tr=commonTraits(spec,st,env).concat(spec.traits?spec.traits(st,env):[]);
tr.forEach(([k,val])=>{const e=document.createElement('span');e.className='t';e.innerHTML=`<b>${k}</b> ${val}`;tw.appendChild(e);});
const blob=new Blob([renderPlant(spec,st)],{type:'image/svg+xml'});
$('download').href=URL.createObjectURL(blob); $('download').download='seedling-'+spec.title.toLowerCase().replace(/\W+/g,'-')+'.svg';
}
function checkDeath(){ if(st.diedAt>0)return;
// §3.2: deathTime monotonic in watered weeks — never moves earlier, so no retroactive death.
const dt=deathTimeOf(spec,st);
if(st.now>=dt){ st.diedAt=st.now;
st.deathCause = spec.MONOCARPIC ? 'MonocarpicBloom'
: (consistency(st)<0.6 ? 'CareShortened' : 'FullTerm'); } }
function writeEntry(){ if(st.diedAt>0)return;
const txt=$('entry').value.trim()||('e'+(st.entryCount+1)+'-'+Math.random());
st.artSeed=hashStr(st.artSeed+'|'+txt+'|'+st.entryCount).toString(16).padStart(8,'0');
st.entryCount++; st.lastWatered=st.now; st.vitality=1;
const wk=Math.floor(st.now/EPOCH); // distinct watered weeks (anti-gaming)
if(wk!==st.lastWaterWeek){ st.wateredEpochs++; st.lastWaterWeek=wk; }
$('entry').value=''; history.push(snap()); refresh(); }
function advance(d){ if(st.diedAt>0)return;
st.now+=d; st.vitality=vitalityOf(spec,st); // §3.1 exponential recency decay
if(st.vitality<0.25)st.everWilted=true;
checkDeath(); history.push(snap()); refresh(); }
let raf=null;
function memorial(){ if(history.length<2)return; cancelAnimationFrame(raf);
const F=history,n=F.length,DUR=5200,t0=performance.now();
(function step(t){ const p=clamp((t-t0)/DUR,0,1), fp=p*(n-1), i=Math.floor(fp), f=fp-i;
const A=F[i],B=F[Math.min(i+1,n-1)];
paint({seed:st.seed,artSeed:(f<0.5?A.artSeed:B.artSeed),entryCount:lerp(A.entryCount,B.entryCount,f),
now:lerp(A.now,B.now,f),vitality:lerp(A.vitality,B.vitality,f),
wateredEpochs:lerp(A.wateredEpochs,B.wateredEpochs,f),lastWatered:lerp(A.now,B.now,f),
diedAt:(p>=1?st.diedAt:0)});
if(p<1)raf=requestAnimationFrame(step); else refresh(); })(t0); }
function newSeed(){ fresh('0x'+Math.floor(Math.random()*0xffffffff).toString(16).padStart(8,'0')); refresh(); }
$('write').onclick=writeEntry; $('w7').onclick=()=>advance(7); $('w30').onclick=()=>advance(30);
$('reset').onclick=newSeed; $('play').onclick=memorial;
$('entry').addEventListener('keydown',e=>{if(e.key==='Enter'&&(e.metaKey||e.ctrlKey))writeEntry();});
// expose for headless/preview harnesses
window.__seedling={spec,renderPlant,maturity,computeEnv,get st(){return st;},set st(x){st=x;},refresh,fresh,schemeFor,settingName:null};
newSeed();
// URL params for gallery/preview: ?seed=..&e=entries&d=days&v=vitality&bare(hide panel)
const q=new URLSearchParams(location.search);
if(q.has('e')||q.has('seed')){
fresh(q.get('seed')||st.seed);
st.entryCount=q.has('e')?+q.get('e'):20; st.now=q.has('d')?+q.get('d'):220;
st.lastWatered=st.now; st.vitality=q.has('v')?+q.get('v'):0.92;
const weeks=Math.max(1,Math.floor(st.now/EPOCH)); // assume ≈1 watering/week up to entryCount
st.wateredEpochs=q.has('w')?+q.get('w'):clamp(st.entryCount,0,weeks);
st.lastWaterWeek=Math.floor(st.now/EPOCH);
st.notes=q.has('notes')?+q.get('notes'):0; // public notes → pollinators (§6.4)
if(st.now>=deathTimeOf(spec,st)){st.diedAt=st.now;st.deathCause='FullTerm';}
refresh();
}
if(q.has('bare')){ document.querySelector('.panel').style.display='none';
const stg=document.querySelector('.stage'); stg.style.padding='0';
document.querySelector('.stage svg').style.height='100vh'; }
// Studio/export support: when embedded in an iframe, PUSH the rendered SVG to the parent via
// postMessage. Origin-independent — works on file:// and in Safari, where the parent reading this
// iframe's contentDocument cross-origin throws SecurityError. token echoes ?_= so the parent can
// match this exact frame; harmless when no parent is listening.
try{ if(window.parent && window.parent!==window){
const tok=q.get('_')||'';
const emit=()=>{ const sv=document.querySelector('#art svg');
if(sv) window.parent.postMessage({seedlingSVG:1, token:tok, svg:sv.outerHTML}, '*'); };
emit(); requestAnimationFrame(emit);
} }catch(e){}
}
// ---------- exports ----------
const __api={ EPOCH,K_DEV,clamp,lerp,smooth,rng,hashStr,seasonOf,
consistency,maturity,structureOf,densityOf,densityCap,deathTimeOf,
vitalityOf,dormancyOf,pollinatorsOf,drawPollinator,computeEnv };
// headless (Node simulation harness):
if(typeof module!=='undefined'&&module.exports) module.exports=__api;
// browser namespace (so self-contained species can converge onto the corrected math
// without colliding with their own local helpers — see e.g. sunflower.html):
if(typeof window!=='undefined') window.SeedlingCore=__api;
;
/* boot.js */
/* ============================================================
Seedling — on-chain engine BOOT entrypoint.
Concatenated LAST into the engine bundle (after species-params.js,
seedling-core.js, and the per-species registry files). Defines the
single symbol the on-chain renderer calls:
SeedlingRenderer.boot(state)
`state` is the JSON object SeedlingRenderer.sol injects, mirroring the
clone's DiaryState (all times in SECONDS, seed/artSeed as 0x-hex):
{ tokenId, species, seed, artSeed, plantedAt, lastWatered,
diedAt, entryCount, wateredEpochs, deathTime }
boot() maps that to the engine's `st` (which counts in DAYS, EPOCH=7),
then renders + loops the life-cycle animation into the page.
============================================================ */
(function () {
var DAY = 86400;
var EPOCH_DAYS = 7;
// species index -> spec-extension ({ title, PAL, LW, LS, draw, stageName, … }),
// populated by the bundled bundle/species/*.js files (extracted from the prototypes).
var REGISTRY = {};
function registerSpecies(idx, ext) { REGISTRY[idx] = ext; }
// contract seconds -> engine st (DAYS). Mirrors the known-good prototype harness st.
function toEngineState(state, nowSec) {
var planted = Number(state.plantedAt) || 0;
var ageDays = Math.max(0, (nowSec - planted) / DAY);
var deathDays = planted > 0 ? Math.max(1, (Number(state.deathTime) - planted) / DAY) : 365;
var lastWaterDays = Number(state.lastWatered) > planted ? (Number(state.lastWatered) - planted) / DAY : ageDays;
var st = {
seed: state.seed,
artSeed: state.artSeed || state.seed,
entryCount: Number(state.entryCount) || 0,
wateredEpochs: Number(state.wateredEpochs) || 0,
now: ageDays,
lastWatered: lastWaterDays,
lastWaterWeek: Math.floor(lastWaterDays / EPOCH_DAYS),
vitality: 1,
notes: 0,
diedAt: 0,
deathCause: null,
};
if (Number(state.diedAt) > 0) {
st.diedAt = Math.max(0, (Number(state.diedAt) - planted) / DAY);
st.deathCause = "FullTerm";
}
return { st: st, deathDays: deathDays };
}
// Each species file registers its FULLY-RESOLVED spec (the prototype already merges
// SEEDLING_toSpec(SP) before registering), so boot just hands it to the engine as-is.
function specFor(species) {
return REGISTRY[species] || null;
}
function mountTarget() {
var el = document.getElementById("art");
if (!el) {
el = document.createElement("div");
el.id = "art";
document.body.appendChild(el);
}
// Fill the frame; the injected SVG is styled (below) to letterbox into ANY aspect.
// OpenSea renders animation_url in a SQUARE, but the art is portrait (720×980) — so the
// whole plant must fit via preserveAspectRatio=meet instead of overflowing → no scroll/crop.
el.style.cssText = "width:100vw;height:100vh;overflow:hidden";
if (!document.getElementById("seedling-fit")) {
var s = document.createElement("style");
s.id = "seedling-fit";
// Contain the whole portrait card into any frame; leftover space is TRANSPARENT (takes the
// host background — dark on OpenSea) so there are never cream bars. Portrait frames get no bars.
s.textContent =
"html,body{margin:0;background:transparent}#art{background:transparent}#art svg{display:block;width:100%;height:100%}";
document.head.appendChild(s);
}
return el;
}
// Honest placeholder until a species' draw() is registered (extraction is the remaining
// art work). Keeps the bundle runnable + the on-chain pipeline demonstrable today.
function placeholder(target, species, st) {
target.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600" style="width:100%;height:100%">' +
'<rect width="600" height="600" fill="#f3efe6"/>' +
'<text x="300" y="290" text-anchor="middle" font-family="Georgia,serif" font-size="26" fill="#2c3a2c">Seedling engine</text>' +
'<text x="300" y="330" text-anchor="middle" font-family="monospace" font-size="16" fill="#5a6a5a">species ' +
species + " · entries " + st.entryCount + "</text></svg>";
}
function boot(state) {
var target = mountTarget();
var spec = specFor(Number(state.species));
var planted = Number(state.plantedAt) || 0;
var nowSec = planted > 0 ? Math.floor(Date.now() / 1000) : 0;
// Static frame (used when the species engine isn't bundled yet, or for a still).
var mapped = toEngineState(state, nowSec || planted);
if (!spec || typeof renderPlant !== "function") {
placeholder(target, Number(state.species), mapped.st);
return;
}
// One-way growth to NOW, then HOLD (art §7.5, revised). We sweep sprout → the plant's
// CURRENT age only — never fast-forward past it — so the art shows real growth-so-far and
// then freezes on the present frame. That held "now" frame is the still marketplaces poster
// (the "last image"); a living plant never renders senescence/death. For the pre-reveal
// teaser the contract feeds a lush mid-life state, so this grows a full green canopy + holds.
var DURATION = 4200; // ms sprout→now sweep (short so the held frame settles fast)
var targetDays = Math.max(0, mapped.st.now); // grow only up to the current age (NOW), never beyond
var start = null;
function frame(ts) {
if (start === null) start = ts;
var p = Math.min(1, (ts - start) / DURATION);
var eased = p * p * (3 - 2 * p); // smoothstep — gentle sprout, easy settle
var anim = toEngineState(state, planted + eased * targetDays * DAY).st;
target.innerHTML = renderPlant(spec, anim);
if (p < 1) requestAnimationFrame(frame); // stop at NOW → holds the last image (no loop, no death)
}
requestAnimationFrame(frame);
}
// public entrypoint + registry hook for the bundled species files
if (typeof window !== "undefined") {
window.SeedlingRenderer = { boot: boot, registerSpecies: registerSpecies, _registry: REGISTRY };
}
})();
;
/* species/aloe.js */
/* aloe — species index 2. GENERATED from prototypes/aloe-vera.html by extract.js.
Do not edit here — edit the prototype and re-run `npm run extract`. The prototype's
mount() call is redirected to SeedlingRenderer.registerSpecies via the local shim. */
(function () {
const mount = (spec) => SeedlingRenderer.registerSpecies(2, spec);
/* Aloe Vera — a stemless rosette of thick succulent toothed leaves: it starts as a 2-leaf V, builds
a narrow distichous fan, then opens into a spiral vase-rosette (newest leaves central & upright,
oldest outer, long & recurved). A mature rosette throws up several thin green RACEMES, each topped
by a cone of small coral florets that colour from the base up (green bud tip). Clonal PUPS crowd the
base; clumps via Setting. Hardy; wind barely leans it. Ported to the shared core. */
const SP = window.SEEDLING_SPECIES.find(x=>x.key==='aloe');
mount(Object.assign(window.SEEDLING_toSpec(SP), {
title:'Aloe Vera', sub:'aloe v9',
senFrom:0.82,
LW:{season:1.0, stem:1.2, leaves:1.25, flowers:1.45},
LS:{season:1.0, stem:1.0, leaves:0.9, flowers:0.7},
note:'Succulent rosette (2-leaf V → fan → spiral vase) · toothed flecked blades · fanned racemes of coral florets (colour rises from the base) · clonal pups · clumps.',
PAL:{ soil:'#6f5128', dry:'#7a5a30',
leaf:'#79A074', leafDk:'#496B47', leafLt:'#A7C58D', tooth:'#3A5638', fleck:'#E2EFD2',
bud:'#8FA653', budDk:'#5E7338', stalk:'#7E9B54',
flower:'#E8602E', flowerDk:'#C5331A', flowerTip:'#F4C24A',
capsule:'#90A158', capsuleDry:'#9C7E40' },
stageName(st,env){ if(st.diedAt>0)return'Sealed'; const m=env.m;
if(env.ageF>0.85)return'Senescent'; if(m<0.10)return'Sprout'; if(m<0.34)return'Fan';
if(m<0.58)return'Rosette'; if(m<0.78)return'Mature'; return'Flowering'; },
draw(st, C, env){
const {m,v,sen,season,browny,density,rewards,baseX,baseY}=env, P=this.PAL;
const bgC = BACKDROPS[backdropFor(st.seed)], GA=Math.PI*(3-Math.sqrt(5));
const seasonBloom = (season==='Winter'||season==='Spring')?1 : season==='Autumn'?0.7 : 0.58;
function aloeDNA(seed){ const dd=rng('dna',seed), g=()=>(dd()+dd()+dd()-1.5)/1.5;
return { leafN:0.8+0.55*dd(), fat:0.65+0.6*dd(), recurve:0.45+0.85*dd(), twist:0.5+0.9*dd(), teeth:0.45+0.7*dd(),
flecks:dd(), stature:0.85+0.30*dd(), flowerHue:g()*0.5, nRaceme:dd()<0.62?1:2, pups:0.25+0.75*dd(),
windDir:dd()<0.5?1:-1, windStr:Math.pow(dd(),2.4),
// — posture DNA — the rosette spiral used to be globally one-handed (sin(i*GA) is seed-independent),
// so EVERY plant's crown drifted the same way → "always leans right". These break that symmetry:
chir:dd()<0.5?1:-1, // spiral handedness — flips which way the phyllotaxis winds
spiralPh:dd()*TAU, // per-seed phyllotaxis phase → a different wobble signature each seed
lean:g(), // bell-weighted BIDIRECTIONAL posture (most upright; some tilt L or R)
leanCurve:0.6+0.8*dd(), // how strongly the lean reads through blade curvature vs a rigid tilt
fullSide:dd()<0.5?1:-1, // which flank carries fuller / longer blades (no rosette is a mirror)
fullAmt:Math.pow(dd(),1.3) }; // strength of that lopsidedness (skewed subtle, occasionally bold)
}
function paintAloe(I){
const {seed, artSeed, cx0, by, S, fade, m, density, v, sen, browny, rewards} = I;
const FC = c => fade>0 ? mix(c, bgC, fade) : c;
const ra=rng('struct',artSeed