0x77b35947…cc7dsent to0x2758442e…81ae·#25,448,162·view on Etherscan
r in 3D");for(var n=this.infos.length-1;0<=n;--n){var s=this.infos[n];if(s.index+e<o){r=(t=s).imageData;break}}if(!t){try{r=new ImageData(this.width,this.height)}catch(e){var i=document.getElementsByTagName("canvas")[0],a=!i,l=(i||((i=document.createElement("canvas")).style.display="none",document.body.appendChild(i)),i.getContext("2d"));l&&(r=l.createImageData(this.width,this.height)),a&&document.body.removeChild(i)}this.infos.push(t={index:0,imageData:r})}l=t.index;return t.index+=e,r._dirty=!0,{imageData:r,index:l}}}]),r}();function z(e,t,r,o,n){var s=e.imageData.data,e=4*e.index++;s[e++]=t,s[e++]=r,s[e++]=o,s[+e]=n}var H=Math.sqrt(3),M=function(){function t(e){G(this,t),this.font=e,this.strokeImageInfos=new l(64,64),this.colDimImageInfos=new l(64,64),this.rowDimImageInfos=new l(64,64),this.colCellImageInfos=new l(64,64),this.rowCellImageInfos=new l(64,64),this.glyphInfos={}}return V(t,[{key:"getGlyphInfo",value:function(e){var t=this.glyphInfos[e.index];if(!t){var r=e.getBoundingBox(),o=r.x1,n=r.y1,s=r.x2-o,i=r.y2-n,a=e.path.commands;if(0==s||0==i||!a.length)return this.glyphInfos[e.index]={};for(var u=[],c=[],d=[],l=8;0<=l;--l)d.push([]);for(l=8;0<=l;--l)c.push([]);var h,f,p,m,D=function(){function n(e,t,r,o){G(this,n),this.p0=e,this.c0=t,this.c1=r,this.p1=o}return V(n,[{key:"toQuadratic",value:function(){return{x:this.p0.x,y:this.p0.y,x1:this.p1.x,y1:this.p1.y,cx:(3*(this.c0.x+this.c1.x)-(this.p0.x+this.p1.x))/4,cy:(3*(this.c0.y+this.c1.y)-(this.p0.y+this.p1.y))/4}}},{key:"quadError",value:function(){return B.default.Vector.sub(B.default.Vector.sub(this.p1,this.p0),B.default.Vector.mult(B.default.Vector.sub(this.c1,this.c0),3)).mag()/2}},{key:"split",value:function(e){var t=B.default.Vector.lerp(this.p0,this.c0,e),r=B.default.Vector.lerp(this.c0,this.c1,e),o=B.default.Vector.lerp(t,r,e),r=(this.c1=B.default.Vector.lerp(this.c1,this.p1,e),this.c0=B.default.Vector.lerp(r,this.c1,e),B.default.Vector.lerp(o,this.c0,e)),e=new n(this.p0,t,o,r);return this.p0=r,e}},{key:"splitInflections",value:function(){var e=B.default.Vector.sub(this.c0,this.p0),t=B.default.Vector.sub(B.default.Vector.sub(this.c1,this.c0),e),r=B.default.Vector.sub(B.default.Vector.sub(B.default.Vector.sub(this.p1,this.c1),e),B.default.Vector.mult(t,2)),o=[],n=t.x*r.y-t.y*r.x;return 0!==n&&0<=(e=(r=e.x*r.y-e.y*r.x)*r-4*n*(e.x*t.y-e.y*t.x))&&(n<0&&(n=-n,r=-r,0),e=(-r+(t=Math.sqrt(e)))/(2*n),0<(r=(-r-t)/(2*n))&&r<1&&(o.push(this.split(r)),e=1-(1-e)/(1-r)),0<e&&e<1&&o.push(this.split(e))),o.push(this),o}}]),n}(),y=!0,g=!1,v=void 0;try{for(var b,_=a[Symbol.iterator]();!(y=(b=_.next()).done);y=!0){var j=b.value,x=(j.x-o)/s,w=(j.y-n)/i;if(!U(h,f,x,w)){switch(j.type){case"M":p=x,m=w;break;case"L":I(h,f,x,w);break;case"Q":var S=(j.x1-o)/s,T=(j.y1-n)/i;L([h,x,S],[f,w,T],{x:h,y:f,cx:S,cy:T});break;case"Z":U(h,f,p,m)?u.push({x:h,y:f}):(I(h,f,p,m),u.push({x:p,y:m}));break;case"C":for(var E=function(e,t,r,o,n,s,i,a){var e=new D(new B.default.Vector(e,t),new B.default.Vector(r,o),new B.default.Vector(n,s),new B.default.Vector(i,a)).splitInflections(),l=[],u=30/H,c=!0,t=!1,r=void 0;try{for(var d,h=e[Symbol.iterator]();!(c=(d=h.next()).done);c=!0){for(var f=d.value,p=[],m=void 0;!(.125<=(m=u/f.quadError()));){var y=Math.pow(m,1/3),g=f.split(y),v=f.split(1-y/(1-y));l.push(g),p.push(f),f=v}m<1&&l.push(f.split(.5)),l.push(f),Array.prototype.push.apply(l,p.reverse())}}catch(e){t=!0,r=e}finally{try{c||null==h.return||h.return()}finally{if(t)throw r}}return l}(h,f,(j.x1-o)/s,(j.y1-n)/i,(j.x2-o)/s,(j.y2-n)/i,x,w),M=0;M<E.length;M++){var k=E[M].toQuadratic();L([k.x,k.x1,k.cx],[k.y,k.y1,k.cy],k)}break;default:throw new Error("unknown command type: ".concat(j.type))}h=x,f=w}}}catch(e){g=!0,v=e}finally{try{y||null==_.return||_.return()}finally{if(g)throw v}}for(var O=u.length,C=this.strokeImageInfos.findImage(O),F=C.index,P=0;P<O;++P){var A=u[P];z(C,R(A.x),R(A.y),R(A.cx),R(A.cy))}(t=this.glyphInfos[e.index]={glyph:e,uGlyphRect:[r.x1,-r.y1,r.x2,-r.y2],strokeImageInfo:C,strokes:u,colInfo:N(d,this.colDimImageInfos,this.colCellImageInfos),rowInfo:N(c,this.rowDimImageInfos,this.rowCellImageInfos)}).uGridOffset=[t.colInfo.dimOffset,t.rowInfo.dimOffset]}return t;function L(e,t,r){var o=u.length;function n(e,t,r){for(var o=e.length;0<o--;){var n=e[o];n<t&&(t=n),r<n&&(r=n)}return{min:t,max:r}}u.push(r);for(var r=n(e,1,0),e=Math.max(Math.floor(9*r.min-.5),0),s=Math.min(Math.ceil(9*r.max+.5),9),i=e;i<s;++i)d[i].push(o);for(var r=n(t,1,0),e=Math.max(Math.floor(9*r.min-.5),0),a=Math.min(Math.ceil(9*r.max+.5),9),l=e;l<a;++l)c[l].push(o)}function R(e){return(e=(r=255)*e)<(t=0)?t:r<e?r:e;var t,r}function I(e,t,r,o){L([e,r],[t,o],{x:e,y:t,cx:(e+r)/2,cy:(t+o)/2})}function U(e,t,r,o){return Math.abs(r-e)<1e-5&&Math.abs(o-t)<1e-5}function N(e,t,r){for(var o=e.length,n=t.findImage(o),t=n.index,s=0,i=0;i<o;++i)s+=e[i].length;for(var a=r.findImage(s),l=0;l<o;++l){var u=e[l],c=u.length,d=a.index;z(n,d>>7,127&d,c>>7,127&c);for(var h=0;h<c;++h){var f=u[h]+F;z(a,f>>7,127&f,0,0)}}return{cellImageInfo:a,dimOffset:t,dimImageInfo:n}}}}]),t}();B.default.RendererGL.prototype._renderText=function(e,t,r,o,n){if(this._textFont&&"string"!=typeof this._textFont){if(!(n<=o)&&this._doFill){if(this._isOpenType()){e.push();var n=this._doStroke,s=this.drawMode,i=(this._doStroke=!1,this.drawMode=E.TEXTURE,this._textFont.font),a=(a=this._textFont._fontInfo)||(this._textFont._fontInfo=new M(i)),r=this._textFont._handleAlignment(this,t,r,o),o=this._textSize/i.unitsPerEm,l=(this.translate(r.x,r.y,0),this.scale(o,o,1),this.GL),r=!this._defaultFontShader,u=this._getFontShader(),c=(u.init(),u.bindShader(),r&&(u.setUniform("uGridImageSize",[64,64]),u.setUniform("uCellsImageSize",[64,64]),u.setUniform("uStrokeImageSize",[64,64]),u.setUniform("uGridSize",[9,9])),this._applyColorBlend(this.curFillColor),this.retainedMode.geometry.glyph),d=(c||((o=this._textGeom=new B.default.Geometry(1,1,function(){for(var e=0;e<=1;e++)for(var t=0;t<=1;t++)this.vertices.push(new B.default.Vector(t,e,0)),this.uvs.push(t,e)})).computeFaces().computeNormals(),c=this.createBuffers("glyph",o)),!0),r=!1,o=void 0;try{for(var h,f=this.retainedMode.buffers.text[Symbol.iterator]();!(d=(h=f.next()).done);d=!0)h.value._prepareBuffer(c,u)}catch(e){r=!0,o=e}finally{try{d||null==f.return||f.return()}finally{if(r)throw o}}this._bindBuffer(c.indexBuffer,l.ELEMENT_ARRAY_BUFFER),u.setUniform("uMaterialColor",this.curFillColor),l.pixelStorei(l.UNPACK_PREMULTIPLY_ALPHA_WEBGL,!1);try{var p=0,m=null,y=i.stringToGlyphs(t),g=!0,v=!1,b=void 0;try{for(var _,j=y[Symbol.iterator]();!(g=(_=j.next()).done);g=!0){var x,w,S=_.value,T=(m&&(p+=i.getKerningValue(m,S)),a.getGlyphInfo(S));T.uGlyphRect&&(x=T.rowInfo,w=T.colInfo,u.setUniform("uSamplerStrokes",T.strokeImageInfo.imageData),u.setUniform("uSamplerRowStrokes",x.cellImageInfo.imageData),u.setUniform("uSamplerRows",x.dimImageInfo.imageData),u.setUniform("uSamplerColStrokes",w.cellImageInfo.imageData),u.setUniform("uSamplerCols",w.dimImageInfo.imageData),u.setUniform("uGridOffset",T.uGridOffset),u.setUniform("uGlyphRect",T.uGlyphRect),u.setUniform("uGlyphOffset",p),u.bindTextures(),l.drawElements(l.TRIANGLES,6,this.GL.UNSIGNED_SHORT,0)),p+=S.advanceWidth,m=S}}catch(e){v=!0,b=e}finally{try{g||null==j.return||j.return()}finally{if(v)throw b}}}finally{u.unbindShader(),this._doStroke=n,this.drawMode=s,l.pixelStorei(l.UNPACK_PREMULTIPLY_ALPHA_WEBGL,!0),e.pop()}}else console.log("WEBGL: only Opentype (.otf) and Truetype (.ttf) fonts are supported. Make sure to set the font using textFont() before drawing text.");return e}}else console.log("WEBGL: you must load and set a font before drawing text. See `loadFont` and `textFont` for more details.")}},{"../core/constants":272,"../core/main":283,"./p5.RendererGL.Retained":340,"./p5.Shader":342,"core-js/modules/es.array.iterator":165,"core-js/modules/es.object.get-own-property-descriptor":186,"core-js/modules/es.object.to-string":190,"core-js/modules/es.regexp.exec":195,"core-js/modules/es.string.iterator":200,"core-js/modules/es.string.split":206,"core-js/modules/es.string.sub":208,"core-js/modules/es.symbol":212,"core-js/modules/es.symbol.description":210,"core-js/modules/es.symbol.iterator":211,"core-js/modules/es.weak-map":244,"core-js/modules/web.dom-collections.iterator":246}]},{},[267])(267)});</script>
<!-- 2. tl-gen-art: window.$art, seeded synchronously at load from URL mint params -->
<script>/**
* Generative art random helpers with p5-style configurable seeding.
*
* Reads mint params from the URL: tokenId, blockhash, txHash, minter
* (each falls back to a random value so the script runs locally).
*
* tokenId + blockhash are ALWAYS part of the seed (uniqueness +
* unpredictability). minter, txHash, gasPrice, and gasUsed are opt-in.
* Re-seeding resets the random stream deterministically, like p5's randomSeed().
*
* $art.seedFrom(); // tokenId + blockhash (default)
* $art.seedFrom("minter"); // + minter
* $art.seedFrom("gasPrice", "gasUsed"); // + gas fields
*
* A `seed` URL param is a curated, full override: when present it becomes the
* complete seed verbatim and seedFrom(...) defers to it (contract-injected for
* collector-curated drops). Absent locally, seeding composes from the fields.
*
* Snapshot + traits (for Cloudflare Browser Rendering):
* $art.setTraits({ Palette: "Sunset", Layers: 5 }); // hidden #art-traits JSON
* $art.snapshot(); // hidden #art-snapshot-ready marker
* $art.snapshot(() => noLoop()); // + freeze the frame under capture
*
* For animated pieces, pass snapshot() a freeze callback. It runs ONLY under the
* snapshot capture user agent (live collector views never freeze), letting the
* screenshot grab the frame that was live at call time. Making that frame
* reproducible is the artist's job: call snapshot() at a deterministic point.
*/
const $art = (function () {
const params = new URLSearchParams(window.location.search);
const get = (key, fb) => {
const v = params.get(key);
return v === null || v === "" ? fb : v;
};
const randHex = (len) =>
"0x" + Array.from({ length: len }, () => "0123456789abcdef"[(Math.random() * 16) | 0]).join("");
// Raw mint values (with local-dev fallbacks). "0" tokenId is preserved.
const mint = {
tokenId: get("tokenId", Math.floor(Math.random() * 10000).toString()),
blockhash: get("blockhash", randHex(64)),
txHash: get("txHash", randHex(64)),
minter: get("minter", randHex(40)),
gasPrice: get("gasPrice", Math.floor(Math.random() * 1e11).toString()), // ~wei
gasUsed: get("gasUsed", (21000 + Math.floor(Math.random() * 1e6)).toString()),
seed: get("seed", null), // curated full-seed override; null = compose from fields
};
// ---- seed -> PRNG ----
function xmur3(str) {
let h = 1779033703 ^ str.length;
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return function () {
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
return (h ^= h >>> 16) >>> 0;
};
}
function sfc32(a, b, c, d) {
return function () {
a |= 0; b |= 0; c |= 0; d |= 0;
const t = (((a + b) | 0) + d) | 0;
d = (d + 1) | 0;
a = b ^ (b >>> 9);
b = (c + (c << 3)) | 0;
c = (c << 21) | (c >>> 11);
c = (c + t) | 0;
return (t >>> 0) / 4294967296; // [0, 1)
};
}
// ---- seed composition ----
const FIELD_TAGS = {
tokenId: "tid", blockhash: "blk", txHash: "tx", minter: "min",
gasPrice: "gp", gasUsed: "gu",
};
const REQUIRED = ["tokenId", "blockhash"];
function composeSeed(extraFields) {
// required fields first, then opt-ins; de-duplicated, order preserved
const ordered = [...new Set([...REQUIRED, ...extraFields])];
return ordered
.map((f) => {
if (!(f in FIELD_TAGS)) throw new Error("$art: unknown seed field '" + f + "'");
return FIELD_TAGS[f] + ":" + String(mint[f]).toLowerCase();
})
.join("|");
}
// Mutable generator so re-seeding resets the stream (p5 semantics).
let rand;
let currentSeed;
function applySeed(seedString) {
currentSeed = seedString;
const next = xmur3(seedString);
rand = sfc32(next(), next(), next(), next());
for (let i = 0; i < 15; i++) rand(); // warm-up
}
// ---- p5-style seeding ----
// Curated full-seed override from the `seed` URL param (null when absent).
const seedOverride = mint.seed;
// Seed from chosen mint fields (tokenId + blockhash always included).
// If a `seed` URL param is present it is the complete seed verbatim and
// wins over the field composition (curated drops). Resets the random stream.
// Returns the seed string used.
function seedFrom(...extraFields) {
const composed = composeSeed(extraFields); // validates fields, may throw
applySeed(seedOverride !== null ? seedOverride : composed);
return currentSeed;
}
// Default seed on load: the two required fields.
seedFrom();
// ---- random helpers (draw from the current stream) ----
// random() -> [0,1) | random(max) -> [0,max) | random(min,max) -> [min,max)
function random(...args) {
let min = 0, max = 1;
if (args.length === 1) max = args[0];
else if (args.length >= 2) { min = args[0]; max = args[1]; }
return min + (max - min) * rand();
}
// Inclusive of both ends. randomInt(max) -> [0,max] | randomInt(min,max) -> [min,max]
function randomInt(...args) {
let min = 0, max = 1;
if (args.length === 1) max = args[0];
else if (args.length >= 2) { min = args[0]; max = args[1]; }
return Math.floor(random(min, max + 1));
}
function randomBool(p = 0.5) {
return random() < p;
}
function randomElement(array) {
if (!array || array.length === 0) return undefined;
return array[randomInt(0, array.length - 1)];
}
// ---- snapshot + traits (hidden DOM, survives in the HTML snapshot) ----
// Write a hidden, non-rendered JSON element (idempotent by id).
function writeHiddenJSON(id, data) {
let el = document.getElementById(id);
if (!el) {
el = document.createElement("script");
el.type = "application/json"; // not executed, not rendered, survives in HTML
el.id = id;
(document.body || document.documentElement).appendChild(el);
}
el.textContent = JSON.stringify(data);
return el;
}
let currentTraits = null;
// { Palette: "Sunset", Layers: 5 } -> OpenSea [{trait_type, value}, ...]
// Emitted into a hidden #art-traits element for the snapshot HTML.
function setTraits(traits) {
if (!traits || typeof traits !== "object" || Array.isArray(traits))
throw new Error("$art.setTraits: expects a plain object of { name: value }");
currentTraits = Object.keys(traits).map((k) => ({ trait_type: k, value: traits[k] }));
writeHiddenJSON("art-traits", currentTraits);
return currentTraits;
}
// Capture environment detection. Cloudflare Browser Rendering is configured to
// send this sentinel user agent; live collector views never match. Keep in sync
// with the snapshot infra's userAgent setting.
const CAPTURE_UA = "tl-gen-art";
const captureMode =
typeof navigator !== "undefined" &&
new RegExp(CAPTURE_UA).test(navigator.userAgent || "");
// Signal that the canvas is fully drawn: append a hidden marker so Cloudflare's
// Browser Rendering API can waitForSelector("#art-snapshot-ready"). Idempotent.
//
// For animated pieces, pass a freeze callback (e.g. () => noLoop(), or one that
// cancels your rAF loop). It runs ONLY under the capture user agent, so the
// screenshot captures the frame live at call time while collectors keep their
// animation. Reproducibility of that frame is the artist's responsibility.
function snapshot(onCapture) {
if (captureMode && typeof onCapture === "function") onCapture();
let el = document.getElementById("art-snapshot-ready");
if (!el) {
el = document.createElement("div");
el.id = "art-snapshot-ready";
el.style.display = "none";
(document.body || document.documentElement).appendChild(el);
}
return el;
}
return {
mint,
seedFrom,
getSeed: () => currentSeed,
random,
randomInt,
randomBool,
randomElement,
setTraits,
getTraits: () => currentTraits,
snapshot,
captureMode,
};
})();
window.$art = $art;
</script>
<!-- 3. Cinética engine (bundled ES modules, single scope) -->
<script>
const CinéticaEngine = (function(){
/* ===== engine/lib/prng.js ===== */
// Deterministic pseudo-random number generation.
//
// The whole collection is reproducible from a single seed: same seed -> same
// artwork, forever. We use the well-known, fast, public-domain sfc32 generator,
// seeded by hashing an arbitrary string with xmur3. Both are standard ~15-line
// snippets used widely in generative-art / Art Blocks scripts.
// xmur3: string -> a function that emits successive 32-bit hash values.
// Used to derive the four 32-bit seeds sfc32 needs from any input string.
function xmur3(str) {
let h = 1779033703 ^ str.length
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
h = (h << 13) | (h >>> 19)
}
return function () {
h = Math.imul(h ^ (h >>> 16), 2246822507)
h = Math.imul(h ^ (h >>> 13), 3266489909)
h ^= h >>> 16
return h >>> 0
}
}
// Derive four 32-bit unsigned integer seeds from a string.
function seedFromString(str) {
const next = xmur3(String(str))
return [next(), next(), next(), next()]
}
// sfc32 (Simple Fast Counter): four 32-bit seeds -> a () => float in [0, 1).
function sfc32(a, b, c, d) {
a >>>= 0
b >>>= 0
c >>>= 0
d >>>= 0
return function () {
a |= 0
b |= 0
c |= 0
d |= 0
const t = (((a + b) | 0) + d) | 0
d = (d + 1) | 0
a = b ^ (b >>> 9)
b = (c + (c << 3)) | 0
c = (c << 21) | (c >>> 11)
c = (c + t) | 0
return (t >>> 0) / 4294967296
}
}
/* ===== engine/lib/hash.js ===== */
// Hash normalization.
//
// Mint platforms inject a seed in different shapes: Art Blocks provides a
// `tokenData.hash` like "0x7c9f..."; other platforms may pass a plain string or
// a VRF-derived value. We normalize any of them into a single canonical seed
// string that feeds the PRNG. Normalization must be stable: the same logical
// input always yields the same canonical string, so the artwork never changes.
function normalizeHash(input) {
if (input === null || input === undefined) {
throw new Error('normalizeHash: seed is required')
}
const s = String(input).trim().toLowerCase()
if (s.length === 0) {
throw new Error('normalizeHash: seed must not be empty')
}
return s
}
/* ===== engine/lib/rng-stream.js ===== */
// The RNG stream — the single source of all randomness for one artwork.
//
// `makeRng(hash)` builds one sfc32 generator from the normalized hash and wraps
// it with convenience helpers. Every decision in the engine (traits, palette,
// composition, motion) draws from this one ordered stream, so the entire piece
// is reproducible from its hash. IMPORTANT for determinism: draws must always
// happen in the same order across runs — never branch the draw order on
// non-deterministic input.
function makeRng(hashInput) {
const seed = normalizeHash(hashInput)
const rand = sfc32(...seedFromString(seed))
const api = {
// Raw float in [0, 1).
next() {
return rand()
},
// Float in [a, b).
range(a, b) {
return a + rand() * (b - a)
},
// Integer in [a, b] inclusive.
int(a, b) {
return a + Math.floor(rand() * (b - a + 1))
},
// Uniform pick from an array.
pick(arr) {
return arr[Math.floor(rand() * arr.length)]
},
// Weighted pick. `table` is an array of [value, weight] pairs.
weighted(table) {
let total = 0
for (const [, w] of table) total += w
let r = rand() * total
for (const [value, w] of table) {
r -= w
if (r < 0) return value
}
return table[table.length - 1][0]
},
// True with probability p (default 0.5).
bool(p = 0.5) {
return rand() < p
},
// Alias for bool, reads naturally as a rarity gate: rng.chance(0.05).
chance(p) {
return rand() < p
},
}
return api
}
/* ===== engine/lib/color.js ===== */
// Small pure color helpers shared by the renderers. Kept dependency-free so the
// on-chain sketch stays self-contained.
function hexToRgb(hex) {
const h = hex.replace('#', '')
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
}
}
/* ===== engine/lib/palettes.js ===== */
// The Venezuelan Heritage Palette Family.
//
// Each palette is rooted in a real Venezuelan place, creature, tradition, or
// natural feature. The flag tricolor is ONE palette among many — the collection
// salutes the country without becoming a flag poster. Palette is a headline
// trait; `weight` controls how often each appears (iconic ones common, the
// more specialized studies rarer, so a palette becomes something to collect).
//
// `colors` are the blade/line hues. `bg` is the ground the kinetic structure
// sits on — it matters as much as the colors, because Cruz-Diez's optical
// effect comes from how narrow bands interact with each other and the field.
// ELEGANCE RULE (the palette family's reason): every palette is a chromatic
// homage — to a kinetic master or to Venezuela itself — and ALL obey one rule so
// the collection reads as a single refined body of work:
// • a DEEP, slightly-tinted ground (never flat black) for richness + depth
// • a restrained set of hues with a clear VALUE LADDER (lights / mids / darks)
// so every piece has dimension, not just flat poster color
// • saturated primaries pulled a touch toward painterly (silk, pigment, dusk)
// rather than screen-RGB — gallery-grade harmony, and it lets the holographic
// lenticular shift read as luminous rather than garish.
// Tricolor (the flag) is the hero anchor; the rest are a TIGHT curated family.
// Curated to 6 — cohesion (Tricolor dominant = unmistakably Venezuelan) plus the
// variety worth keeping (the master's own colors, a crisp B&W op study, the
// magenta bloom, a cool range, modernist energy). Every palette earns its place.
const PALETTES = [
{
key: 'tricolor',
name: 'Tricolor',
// THE HERO / ANCHOR — Venezuela's flag refined into silk. Weighted highest so
// the collection reads unmistakably Venezuelan. amber-gold / royal cobalt /
// carmine / cream, on midnight blue + near-black for depth.
weight: 30,
bg: '#070a16',
colors: ['#e8b73a', '#1442a6', '#c0303a', '#f0ead6', '#0e1b3c', '#121214'],
},
{
key: 'cruzdiez',
name: 'Cruz-Díez',
// The master's own vocabulary (red+green pair, yellow/red/blue triad, black +
// white as essential elements), pulled toward painterly pigment. The homage,
// made literal in color.
weight: 16,
bg: '#0a0a0b',
colors: ['#d23a2e', '#138a5a', '#1f5fae', '#e8b43c', '#efece2', '#16161a'],
},
{
key: 'tinta',
name: 'Tinta',
// Pure black & white op-art study (ink). The most austere, gallery-severe
// colorway — Soto's monochrome vibration / Cruz-Diez's black-white-as-essential.
// Crisp value ladder of greys for depth; reads as the "fine print" of the set.
weight: 14,
bg: '#0a0a0a',
colors: ['#f7f7f4', '#0d0d0d', '#cfcfca', '#3a3a3a', '#9a9a96', '#1c1c1c'],
},
{
key: 'caribe',
name: 'Caribe',
// Caribbean coast — refined teal/sea with a coral accent. The cool range.
weight: 14,
bg: '#04181f',
colors: ['#2aa7b3', '#0e6a7d', '#e87a63', '#ecdcc0', '#0a3a45', '#86d8cf'],
},
{
key: 'orquidea',
name: 'Orquídea',
// Flor de mayo — magenta/lilac/cream over deep plum. The bloom.
weight: 14,
bg: '#140a13',
colors: ['#c0457f', '#9069ac', '#e6c8d8', '#f1ebe2', '#5e3760', '#2a1426'],
},
{
key: 'canaima',
name: 'Canaima',
// Amazon / jungle / Salto Ángel — deep greens & blacks. Tannin-dark river,
// canopy emerald, light-through-leaves moss, waterfall mist/cream, a warm
// jasper-ochre of the tepui rock face. The deep-nature register of the set.
weight: 12,
bg: '#050b07',
colors: ['#0f5132', '#3f8f4e', '#cfe0c4', '#b07a2e', '#0a2a1a', '#020503'],
},
]
const BY_KEY = new Map(PALETTES.map((p) => [p.key, p]))
function getPalette(key) {
const p = BY_KEY.get(key)
if (!p) throw new Error(`getPalette: unknown palette "${key}"`)
return p
}
// Weighted deterministic pick from the family, driven by the artwork's rng.
function pickPalette(rng) {
const table = PALETTES.map((p) => [p.key, p.weight])
return getPalette(rng.weighted(table))
}
/* ===== engine/lib/noise.js ===== */
// Fractal noise (fBm) — the math behind the collection's signature warp.
//
// A small, dependency-free 1D value-noise + fractional Brownian motion. Pure and
// deterministic: noise1(x) and fbm1(x, ...) always return the same value for the
// same input, so the warped artwork is reproducible from its hash. Smooth
// (smoothstep-interpolated) so the warp flows without kinks.
// hash a number -> pseudo-random value in [0,1), stable across machines.
function hash1(n) {
const s = Math.sin(n * 127.1) * 43758.5453
return s - Math.floor(s)
}
// smooth 1D value noise in [0,1).
function noise1(x) {
const xi = Math.floor(x)
const xf = x - xi
const u = xf * xf * (3 - 2 * xf) // smoothstep
return hash1(xi) * (1 - u) + hash1(xi + 1) * u
}
// fractional Brownian motion: sum of `octaves` noise layers at doubling
// frequency and halving amplitude. Returns ~[0,1).
function fbm1(x, octaves = 5) {
let v = 0
let amp = 0.5
let freq = 1
let norm = 0
for (let o = 0; o < octaves; o++) {
v += amp * noise1(x * freq)
norm += amp
freq *= 2
amp *= 0.5
}
return v / norm
}
/* ===== engine/lib/traits.js ===== */
// Trait derivation — the hash becomes the artwork's DNA.
//
// deriveTraits(hash) builds ONE rng stream and draws the HEADLINE traits in a
// FIXED ORDER (order is part of the contract — append new traits at the end,
// never insert in the middle). These headline traits drive the composition and
// are what the platform reads for rarity (see features.js). Finer per-cell
// decisions (which master fills each cell, its sub-type, density, etc.) are
// derived deterministically inside the renderer from per-cell sub-seeds, so the
// whole piece is still a pure function of the hash.
//
// Identity: an homage to Venezuelan kinetic art (Cruz-Diez / Soto / Gego), with
// JN Silva's sacred-geometry composition as the signature. Hybrid-dominant.
// MODE = which master (or hybrid). Hybrid is the STAR (most common); pure
// single-master homages are the rarer, special tier.
const MODE_WEIGHTS = [
['hybrid', 58], // the star — masters in dialogue across the cells
['cruzdiez', 18],
['soto', 14],
['gego', 10],
]
// GEOMETRY ENGINE = the JN Silva compositional signature (quiet background
// variety — how the canvas is divided).
const ENGINE_WEIGHTS = [
['medium', 40], // golden cells (nested), depth 4
['deep', 26], // deeper golden nesting
['mandala', 18], // 4-quadrant mirror symmetry
['single', 16], // single field (no subdivision)
]
// FOCAL EVENT (per piece; many get none). Radial-fill events (singularity,
// interference, spiral) are GATED to nested compositions at render time so they
// never become a full-canvas sunburst — see compose.js.
const EVENT_WEIGHTS = [
['none', 26],
['singularity', 14],
['interference', 10],
['rings', 12],
['squares', 12],
['implosion', 12],
['spiral', 8],
['moire', 6],
]
// Events that read badly when they fill a canvas-dominating cell.
const RADIAL_FILL_EVENTS = ['singularity', 'interference', 'spiral']
// What a radial-fill event downgrades to when it can't nest safely.
const SAFE_EVENT_WEIGHTS = [
['none', 30],
['rings', 22],
['squares', 22],
['implosion', 16],
['moire', 10],
]
// Soto color treatment (only meaningful for Soto cells): authentic monochrome
// field (color reserved for floating element) vs full color.
const SOTO_COLOR_WEIGHTS = [
['color', 64],
['mono', 24],
['trait', 12], // mixed per cell
]
function deriveTraits(hash) {
const rng = makeRng(hash)
// --- FIXED DRAW ORDER (do not reorder) ---
const mode = rng.weighted(MODE_WEIGHTS)
const engine = rng.weighted(ENGINE_WEIGHTS)
const paletteObj = pickPalette(rng)
// headline event (gated later in the renderer once cell sizes are known)
const event = rng.weighted(EVENT_WEIGHTS)
// global continuous render params (continuous = key to uniqueness)
const iridescence = Math.round(rng.range(0.35, 0.7) * 1000) / 1000
const geo = Math.round(rng.range(0.55, 0.9) * 1000) / 1000 // geometry strength
const phase = rng.range(0, 1) // global color-sequence phase
const sweepSpeed = Math.round(rng.range(3.4, 5.6) * 100) / 100 // lenticular tempo
const sotoColor = rng.weighted(SOTO_COLOR_WEIGHTS)
return {
mode,
engine,
palette: paletteObj.key,
event,
iridescence,
geo,
phase,
sweepSpeed,
sotoColor,
}
}
// Re-pick a safe event when a radial-fill event would dominate the canvas. Pure
// + deterministic from the same hash (uses a distinct sub-stream).
function safeEvent(hash) {
return makeRng(hash + ':event2').weighted(SAFE_EVENT_WEIGHTS)
}
/* ===== engine/lib/features.js ===== */
// Platform features / rarity output.
//
// Mint platforms (Art Blocks, Transient Labs, fxhash) read a `features` object
// to compute and display trait rarity. Their contract: every value must be a
// STRING. This maps the headline traits into the human-readable labels collectors
// see ("Master: Hybrid", "Palette: Tricolor", "Composition: Mandala").
const MODE_LABEL = {
hybrid: 'Hybrid',
cruzdiez: 'Cruz-Díez',
soto: 'Soto',
gego: 'Gego',
}
const ENGINE_LABEL = {
medium: 'Golden Cells',
deep: 'Deep Nesting',
mandala: 'Mandala',
single: 'Single Field',
}
const EVENT_LABEL = {
none: 'None',
singularity: 'Singularity',
interference: 'Interference',
rings: 'Concentric Rings',
spiral: 'Spiral',
squares: 'Nested Squares',
implosion: 'Implosion',
moire: 'Moiré',
}
// Bucket the continuous lenticular tempo into a coarse, collectible label.
function tempoLabel(speed) {
if (speed < 4.0) return 'Calm'
if (speed < 4.9) return 'Living'
return 'Vivid'
}
function buildFeatures(traits) {
return {
Master: MODE_LABEL[traits.mode] || traits.mode,
Palette: getPalette(traits.palette).name,
Composition: ENGINE_LABEL[traits.engine] || traits.engine,
Event: EVENT_LABEL[traits.event] || traits.event,
Tempo: tempoLabel(traits.sweepSpeed),
Iridescence: traits.iridescence < 0.5 ? 'Subtle' : 'Luminous',
}
}
/* ===== engine/compose.js ===== */
// engine/compose.js — the FULL Cinetica composition system, framework-free.
//
// SINGLE SOURCE OF TRUTH ported from the approved Composition Studio
// (gallery/compose.html, tag studio-v1). Imported by engine/render.js.
// Given a p5 instance, the piece traits, its palette, the hash, and time t, it
// renders the complete artwork: geometry engine -> cells -> per-cell master
// (Cruz-Diez/Soto/Gego/hybrid) -> holographic lenticular + sheen -> gated event.
// Pure & deterministic: same hash + same t -> identical frame.
const PHI = 1.6180339887, INVPHI = 0.6180339887
const FIB = [1,1,2,3,5,8,13,21,34]
// The golden "power points": where the phi-lines of a cell cross. Picking the
// radial-event epicenter from these (per-hash) gives variety while keeping the
// placement sacred-geometry-justified (never an arbitrary spot). Includes the
// 4 corners-of-phi + the center.
function goldenNode(w, h, rng){
const xs=[w*INVPHI, w*(1-INVPHI), w*0.5]
const ys=[h*INVPHI, h*(1-INVPHI), h*0.5]
// anchor on a golden power point...
let x = rng.weighted([[xs[0],5],[xs[1],5],[xs[2],2]])
let y = rng.weighted([[ys[0],5],[ys[1],5],[ys[2],2]])
// ...then continuous jitter around it so the epicenter is effectively
// never identical between pieces (kills the "only ~9 positions" bug) while
// staying near the sacred-geometry anchor.
x += (rng.next()-0.5)*w*0.18
y += (rng.next()-0.5)*h*0.18
return {cx:x, cy:y}
}
// Pick the focal cell. For RADIAL events (singularity/interference/spiral/rings/
// squares) prefer a cell that does NOT dominate the canvas (<=45%), nearest the
// golden node among those — so the event has a real nested home and the gate
// rarely needs to downgrade it. Falls back to nearest-node if none qualify.
function pickFocal(cells, W, H, preferSmall){
const gx=W*INVPHI, gy=H*INVPHI
const near=(c)=>Math.hypot(c.x+c.w/2-gx, c.y+c.h/2-gy)
let pool=cells.map((c,idx)=>({c,idx}))
if(preferSmall){
const small=pool.filter(({c})=>c.w*c.h <= W*H*0.45)
if(small.length) pool=small
}
let best=1e9, focal=pool.length?pool[0].idx:0
pool.forEach(({c,idx})=>{ const d=near(c); if(d<best){best=d;focal=idx} })
return focal
}
function rgb2hsl({r,g,b}){r/=255;g/=255;b/=255;const mx=Math.max(r,g,b),mn=Math.min(r,g,b);let h,s,l=(mx+mn)/2;if(mx===mn){h=s=0;}else{const d=mx-mn;s=l>0.5?d/(2-mx-mn):d/(mx+mn);switch(mx){case r:h=(g-b)/d+(g<b?6:0);break;case g:h=(b-r)/d+2;break;default:h=(r-g)/d+4;}h/=6;}return{h:h*360,s,l};}
function hsl2rgb(h,s,l){ h=(((h%360)+360)%360)/360;s=Math.max(0,Math.min(1,s));l=Math.max(0,Math.min(1,l));
if(s===0){const v=Math.round(l*255);return[v,v,v];}
const q=l<0.5?l*(1+s):l+s-l*s,p=2*l-q,f=(t)=>{if(t<0)t+=1;if(t>1)t-=1;if(t<1/6)return p+(q-p)*6*t;if(t<1/2)return q;if(t<2/3)return p+(q-p)*(2/3-t)*6;return p;};
return[Math.round(f(h+1/3)*255),Math.round(f(h)*255),Math.round(f(h-1/3)*255)]; }
function goldenCells(x,y,w,h,depth,rng,geo){
if(depth<=0 || w<24 || h<24) return [{x,y,w,h,depth}]
// probability of splitting scales with geometry strength
if(rng.next() > 0.45 + geo*0.5) return [{x,y,w,h,depth}]
const horizontalCut = (w>=h) // cut the longer axis -> golden rectangles
const ratio = rng.bool() ? INVPHI : 1-INVPHI
const cells=[]
if(horizontalCut){
const wl=w*ratio
cells.push(...goldenCells(x,y,wl,h,depth-1,rng,geo))
cells.push(...goldenCells(x+wl,y,w-wl,h,depth-1,rng,geo))
} else {
const ht=h*ratio
cells.push(...goldenCells(x,y,w,ht,depth-1,rng,geo))
cells.push(...goldenCells(x,y+ht,w,h-ht,depth-1,rng,geo))
}
return cells
}
// MANDALA (mirror): build golden cells in one quadrant, then mirror to all
// four — radial bilateral symmetry, the most overtly 'sacred' layout.
function mandalaCells(W,H,rng,geo){
const hw=W/2, hh=H/2
const q=goldenCells(0,0,hw,hh,3,rng,geo)
const out=[]
q.forEach(c=>{
out.push({x:c.x, y:c.y, w:c.w, h:c.h}) // TL
out.push({x:W-c.x-c.w, y:c.y, w:c.w, h:c.h}) // TR (mirror x)
out.push({x:c.x, y:H-c.y-c.h, w:c.w, h:c.h}) // BL (mirror y)
out.push({x:W-c.x-c.w, y:H-c.y-c.h, w:c.w, h:c.h}) // BR (mirror xy)
})
return out
}
// Fibonacci-spaced band edges across length L: band widths follow a slice of
// the Fibonacci sequence (the rhythm itself is sacred-geometric), repeated.
function fibEdges(L, baseCount, rng, geo){
// geo=0 -> near-uniform; geo=1 -> strong fibonacci rhythm
const start = Math.floor(rng.next()*3)
const pattern = FIB.slice(start, start+4) // e.g. [2,3,5,8]
const patSum = pattern.reduce((a,c)=>a+c,0)
const uniformW = L/baseCount
const edges=[0]; let x=0, idx=0
const cycleW = uniformW * 4 // a full fib cycle ~ 4 uniform bands wide
while(x < L){
const fibW = (pattern[idx%pattern.length]/patSum) * cycleW
const w = uniformW*(1-geo) + fibW*geo
x += Math.max(2, w); edges.push(Math.min(L,x)); idx++
}
return edges
}
function renderCell(p, c, o){
const {horizontal,event}=o
p.push(); p.translate(c.x,c.y)
// CLIP to this cell: radial events overscan past the cell; clipping stops
// them bleeding into neighbors (which is what made neighbor grid-lines
// appear to cross the event). drawingContext is the raw 2D ctx; we MUST
// restore() it on the single exit path below so the clip never leaks.
const dc=p.drawingContext, clipped = event!=='none'
if(clipped){ dc.save(); dc.beginPath(); dc.rect(0,0,c.w,c.h); dc.clip() }
// FOCAL EVENTS are STRUCTURAL — they change how the cell's own blades are
// generated, never drawn over the top.
if(event==='implosion') drawImplosion(p, c, o)
else if(event==='singularity') drawSingularity(p, c, o, false)
else if(event==='interference') drawSingularity(p, c, o, true)
else if(event==='rings' || event==='squares'){
// EMBEDDED medallion: rings/squares never dominate. Draw the cell's own
// kinetic blade field first, then a small rings/squares medallion within
// it (sized to a fraction of the cell) so it reads as an accent that
// belongs to the piece, not a standalone target/box.
drawBands(p, c, o, {})
const med = Math.min(c.w,c.h) * (0.32 + makeRng(o.cellSeed+':med').next()*0.18) // 32-50%
const mo = {...o, medallion: med}
if(event==='rings') drawRings(p, c, mo); else drawSquares(p, c, mo)
}
else if(event==='spiral') drawSpiral(p, c, o)
else {
drawBands(p, c, o, {})
if(event==='moire'){
// SOTO homage: a second, slightly-offset blade field -> optical moiré.
drawBands(p, c, {...o, phase:o.phase+0.5, horizontal:!horizontal},
{alpha: 150, freqMul: 1.07})
}
}
if(clipped) dc.restore()
p.pop()
}
// Standard band fill for a cell (vertical or horizontal), holographic color.
function drawBands(p, c, o, opt={}){
const {baseHues,seqLen,phase,irid,t,sweepSpeed,horizontal}=o
const negative=opt.negative, alpha=opt.alpha??255, freqMul=opt.freqMul??1
const L = horizontal ? c.h : c.w
const baseCount = Math.max(6, Math.round(L/(6+ (o.cellDensity||10))) * freqMul)
const edges = fibEdges(L, baseCount, makeRng(o.cellSeed+':bands'), o.geo)
const cycle = baseHues.length
for(let b=0;b<edges.length-1;b++){
const a0=edges[b], a1=edges[b+1]
const slot=Math.floor(b + phase*seqLen)
let A=baseHues[((slot%cycle)+cycle)%cycle], B=baseHues[((slot+1)%cycle+cycle)%cycle]
if(negative){ // invert lightness + rotate hue 180 -> the "void" cell
A={h:A.h+180,s:A.s,l:1-A.l}; B={h:B.h+180,s:B.s,l:1-B.l}
}
const [ar,ag,ab]=hsl2rgb(A.h,A.s,A.l), [br,bg,bb]=hsl2rgb(B.h,B.s,B.l)
const along=(a0/Math.max(1,L))
const wave=Math.sin(t*sweepSpeed*0.16 + along*3.2 + phase*6.28)*0.5+0.5
const k=wave*wave*(3-2*wave); const m=Math.max(0,Math.min(1,0.5+(k-0.5)*(0.6+1.4*irid)))
p.fill(Math.round(ar*(1-m)+br*m),Math.round(ag*(1-m)+bg*m),Math.round(ab*(1-m)+bb*m), alpha)
if(horizontal){ p.rect(0,a0,c.w,a1-a0) } else { p.rect(a0,0,a1-a0,c.h) }
const r=Math.max(0.5,(a1-a0)*0.10)
p.fill(0,0,0,55*alpha/255); if(horizontal)p.rect(0,a0,c.w,r); else p.rect(a0,0,r,c.h)
p.fill(255,255,255,38*alpha/255); if(horizontal)p.rect(0,a0+r,c.w,r*0.6); else p.rect(a0+r,0,r*0.6,c.h)
}
}
// EVENT: recursive implosion — the cell subdivides (golden) into ever-smaller
// sub-cells toward a golden node, each filled with blades. A fractal vortex of
// escalating density, built entirely from cells+blades.
function drawImplosion(p, c, o){
;(function rec(x,y,w,h,d,idx){
if(d<=0 || w<6 || h<6){
const sub={x,y,w,h}; const so={...o, horizontal:(d%2===0), phase:o.phase+idx*0.12, cellSeed:o.cellSeed+':i'+idx, cellDensity:4+d}
p.push(); p.translate(x,y); drawBands(p,{x:0,y:0,w,h},so,{}); p.pop(); return
}
// keep one golden sub-rect, recurse into the other (the vortex)
const cutW = w>=h
const keep = INVPHI
if(cutW){ const wl=w*keep
const sub={x,y,w:wl,h}; const so={...o,horizontal:(d%2===0),phase:o.phase+d*0.15,cellSeed:o.cellSeed+':k'+d,cellDensity:3+d}
p.push(); p.translate(x,y); drawBands(p,{x:0,y:0,w:wl,h},so,{}); p.pop()
rec(x+wl,y,w-wl,h,d-1,idx+1)
} else { const ht=h*keep
const so={...o,horizontal:(d%2===0),phase:o.phase+d*0.15,cellSeed:o.cellSeed+':k'+d,cellDensity:3+d}
p.push(); p.translate(x,y); drawBands(p,{x:0,y:0,w,h:ht},so,{}); p.pop()
rec(x,y+ht,w,h-ht,d-1,idx+1)
}
})(0,0,c.w,c.h,7,0)
}
// EVENT: orientation singularity — blades fan radially from a golden node.
// Filled wedges (not stroked lines). interference=true overlays a second
// counter-rotating fan -> radial moiré buzz (a kinetic Soto-flavored burst).
function drawSingularity(p, c, o, interference){
const sr=makeRng(o.cellSeed+':sing') // per-piece structural entropy
const {cx,cy}=goldenNode(c.w,c.h,makeRng(o.cellSeed+':node'))
// Per-piece structural params (this is what makes every singularity unique,
// not one of ~9 fixed looks): ray count, color cadence, rotation dir/speed,
// color start, and wedge-width rhythm.
const rays = 48 + Math.floor(sr.next()*200) // 48..248
const cadence = 1 + Math.floor(sr.next()*4) // colors step every 1-4 rays
const colStart = Math.floor(sr.next()*4)
const rotDir = sr.bool()?1:-1
const rotSpd = 0.025 + sr.next()*0.06
const pulse = sr.next()<0.45 ? (1+Math.floor(sr.next()*4)) : 0 // 0 = even wedges, else fib-ish pulse
const fan=(rot,alpha)=>{
const {baseHues,seqLen,phase,irid,t,sweepSpeed}=o
const R=Math.hypot(Math.max(cx,c.w-cx),Math.max(cy,c.h-cy))*1.15
const cycle=baseHues.length
p.noStroke()
for(let i=0;i<rays;i++){
// optional uneven wedge widths (a slow sinusoidal pulse around the dial)
const wmul = pulse? (0.6 + 0.5*(0.5+0.5*Math.sin(i/rays*Math.PI*2*pulse))) : 1
const a0=(i/rays)*Math.PI*2+rot, a1=((i+wmul)/rays)*Math.PI*2+rot
const slot=Math.floor(i/cadence + colStart + phase*seqLen)
const A=baseHues[((slot%cycle)+cycle)%cycle]
const wave=Math.sin(t*sweepSpeed*0.16+i*0.3+phase*6.28)*0.5+0.5
const [cr,cg,cb]=hsl2rgb(A.h,A.s,Math.min(1,A.l+wave*0.18*irid))
p.fill(cr,cg,cb,alpha)
p.triangle(cx,cy,cx+Math.cos(a0)*R,cy+Math.sin(a0)*R,cx+Math.cos(a1)*R,cy+Math.sin(a1)*R)
p.fill(0,0,0,40*alpha/255)
p.triangle(cx,cy,cx+Math.cos(a0)*R,cy+Math.sin(a0)*R,cx+Math.cos(a0+0.004)*R,cy+Math.sin(a0+0.004)*R)
}
}
fan(o.t*rotSpd*rotDir, 255)
if(interference) fan(-o.t*rotSpd*rotDir + 0.013, 140) // counter-rotating -> radial moiré
}
// EVENT: concentric rings — color bands radiating from a golden node (target).
// MOTION: the color sequence flows continuously INWARD over time (each ring
// takes the next color), so the target appears to pulse toward the center.
// Drawn largest-first as filled circles; each smaller circle covers the
// previous, leaving a ring. Continuous flow = perfectly smooth.
function drawRings(p, c, o){
const {baseHues,seqLen,phase,irid,t}=o
const {cx,cy}=goldenNode(c.w,c.h,makeRng(o.cellSeed+':node'))
// EMBEDDED: when o.medallion is set, the target is a small disc of that
// radius centered on the golden node (drawn over the cell's blade field),
// not the whole cell. Otherwise fill the cell (legacy/full).
const sr=makeRng(o.cellSeed+':rings') // per-piece entropy
const R = o.medallion ? o.medallion/2 : Math.hypot(Math.max(cx,c.w-cx),Math.max(cy,c.h-cy))*1.2
const cycle=baseHues.length; p.noStroke()
const NR = 6 + Math.floor(sr.next()*16) // 6..22 rings (per-piece)
const cadence = 1 + Math.floor(sr.next()*3) // color steps every 1-3 rings
const colStart = Math.floor(sr.next()*4)
const dir = sr.bool()?1:-1 // flow in or out
const ringW=R/NR
const g = t*0.5*dir, gi=Math.floor(g), gf=g-gi
if(o.medallion){ // clip to the medallion disc so it has a crisp circular edge
const dc=p.drawingContext; dc.save(); dc.beginPath(); dc.arc(cx,cy,R,0,Math.PI*2); dc.clip()
}
for(let i=0;i<=NR+1;i++){
const rr = R - (i - gf)*ringW
if(rr<=0 || rr>R+ringW) continue
const band=i+gi; const slot=Math.floor(band/cadence + colStart + phase*seqLen)
const A=baseHues[((slot%cycle)+cycle)%cycle]
const lift=(0.5+0.5*Math.sin(band*0.7 + phase*6.28))*0.16*irid
const [cr,cg,cb]=hsl2rgb(A.h,A.s,Math.min(1,A.l+lift))
p.fill(cr,cg,cb); p.circle(cx,cy, rr*2)
p.fill(0,0,0,45); p.circle(cx,cy, (rr-Math.max(0.6,ringW*0.12))*2)
}
if(o.medallion){ p.drawingContext.restore()
// crisp rim so the medallion reads as an inset element
p.noFill(); p.stroke(0,0,0,120); p.strokeWeight(2); p.circle(cx,cy,R*2)
p.stroke(255,255,255,50); p.strokeWeight(1); p.circle(cx,cy,R*2-2); p.noStroke()
}
}
// EVENT: spiral blade-flow — wedges whose angular width + hue progress along a
// golden spiral (a kinetic swirl), built from filled wedges rotating outward.
function drawSpiral(p, c, o){
const {baseHues,seqLen,phase,irid,t,sweepSpeed}=o
const sr=makeRng(o.cellSeed+':spiral') // per-piece entropy
const {cx,cy}=goldenNode(c.w,c.h,makeRng(o.cellSeed+':node'))
const R=Math.hypot(Math.max(cx,c.w-cx),Math.max(cy,c.h-cy))*1.2
const cycle=baseHues.length; p.noStroke()
const arms=2+Math.floor(sr.next()*5) // 2..6 arms
const turns=2+sr.next()*3 // 2..5 turns
const growth=1.05+sr.next()*0.5 // spiral tightness
const steps=200
const dir=sr.bool()?1:-1
const rot=t*(0.04+sr.next()*0.05)*dir
for(let a=0;a<arms;a++){
for(let s=0;s<steps;s++){
const u=s/steps
const ang = u*Math.PI*2*turns + a*(Math.PI*2/arms) + rot
const rad = R*Math.pow(u,1/growth)
const slot=Math.floor(s*0.12 + a + phase*seqLen); const A=baseHues[((slot%cycle)+cycle)%cycle]
const wave=Math.sin(t*sweepSpeed*0.16 + s*0.1 + phase*6.28)*0.5+0.5
const [cr,cg,cb]=hsl2rgb(A.h,A.s,Math.min(1,A.l+wave*0.18*irid))
const w=Math.max(2, R*0.05*(1-u)+3)
p.fill(cr,cg,cb)
p.circle(cx+Math.cos(ang)*rad, cy+Math.sin(ang)*rad, w)
}
}
}
// EVENT: nested squares — concentric squares receding to the center (the
// light-box, Cruz-Diez ref 2). MOTION: colors flow inward over time so the
// box appears to recede/pulse. Even steps for more depth layers.
function drawSquares(p, c, o){
const {baseHues,seqLen,phase,irid,t}=o
const sr=makeRng(o.cellSeed+':squares') // per-piece entropy
const {cx,cy}=goldenNode(c.w,c.h,makeRng(o.cellSeed+':node'))
const cycle=baseHues.length; p.noStroke()
const NL=7+Math.floor(sr.next()*14) // 7..21 layers (per-piece)
const cadence=1+Math.floor(sr.next()*3)
const colStart=Math.floor(sr.next()*4)
const dir=sr.bool()?1:-1
// EMBEDDED: medallion -> a small nested-square box centered on the node,
// over the cell's blade field. Otherwise fill the cell.
const maxR = o.medallion ? o.medallion/2 : Math.max(cx,c.w-cx,cy,c.h-cy)*1.1
const step=maxR/NL
const ar = o.medallion?1:c.w/Math.max(c.w,c.h), arh = o.medallion?1:c.h/Math.max(c.w,c.h)
const g=t*0.5*dir, gi=Math.floor(g), gf=g-gi
if(o.medallion){ const dc=p.drawingContext; dc.save(); dc.beginPath()
dc.rect(cx-maxR,cy-maxR,maxR*2,maxR*2); dc.clip() }
for(let i=NL+1;i>=0;i--){
const r=(i - gf)*step; const w=r*2*ar, h=r*2*arh
if(w<=2||h<=2) continue
const band=i+gi; const slot=Math.floor(band/cadence + colStart + phase*seqLen)
const A=baseHues[((slot%cycle)+cycle)%cycle]
const lift=(0.5+0.5*Math.sin(band*0.7 + phase*6.28))*0.16*irid
const [cr,cg,cb]=hsl2rgb(A.h,A.s,Math.min(1,A.l+lift))
p.fill(cr,cg,cb); p.rect(cx-w/2, cy-h/2, w, h)
p.fill(0,0,0,40); p.rect(cx-w/2, cy-h/2, w, Math.max(1,step*0.12))
}
if(o.medallion){ p.drawingContext.restore()
p.noFill(); p.stroke(0,0,0,