Hand sketch in a notebook: a palm with its crown inside a red circle and its trunk inside a blue rectangle, a pinnate leaf with an arrow marked 'gradiente verde-café', a spathe with a red fruit cluster, and a coquito
From Claudio's sketchbook: the crown in a circle and the trunk in a rectangle (the guide figure comes from here), a frond with its green-to-brown gradient, the woody spathe with its cluster, and a coquito.Del cuaderno de Claudio: la copa en un círculo y el tronco en un rectángulo (de aquí sale la figura guía), una hoja con su degradado de verde a café, la espata leñosa con su racimo y un coquito.✦ IA

The idea in plain words ✦ IA

As with the araucaria, the generator draws nothing: it writes down what the palm is made of. A column in twelve pieces, about thirty fronds that all come out of one point at the top, and on each frond a curved rib with 115 leaflets per side. The renderers read that list and decide how much of it to show.

What is new is that every frond has an age of its own. A palm does not branch: it grows from a single bud at the tip, which puts out one frond after another. New fronds come out almost upright in the centre; with time they open, lie down and hang, and their colour turns from green to yellowish brown. It is like a clock face: where a frond points tells its age. The analogy has limits, since fronds do not turn at a fixed pace and the wind moves them.

Move Age. First the rosette widens with no trunk, and only then does the column rise, as in the field, where the stem takes 12–14 years to form. ✦ IA

Why it matters ✦ IA

The Chilean wine palm is endemic to central Chile, between 32° 45′ and 37° 30′ S, from sea level to 1,400 m, in sheltered ravines. About 121,000 remain, mostly in two stands: Ocoa (about 70,300) and Cocalán (35,500), after González and colleagues (2009), in the Ministry of the Environment's species sheet.

For a generator it is the test that the format works for something other than the araucaria: no whorls and no branches, and still the same two lists fit, parts and details. The only new things were a "rib" part and a "leaflet" detail. ✦ IA

TODO: Claudio's own note on the palm: a memory (Ocoa, La Campana, Viña?), the coquitos, palm honey and why it is Endangered (the sap is taken by felling it).

Is there a theorem? ✦ IA

No theorem governs the shape of a palm. The MMA sheet gives measurements: up to 30 m tall, a trunk 0.8–1.1 m across, fronds of 2–4 m with 110–120 leaflets per side and yellow fruit 4 cm long. The sources disagree on the trunk. The sheet calls it cylindrical and narrower at the top; Darwin saw it thicker in the middle, and bases of 1.5–1.8 m have been measured in Ocoa. That is why taper and bulge have sliders of their own. ✦ IA

The equations ✦ IA

Angles in degrees. a is the palm's age in [0, 1], f a frond's age in the crown (0 newest, 1 oldest), s(·) the smoothstep curve from 0 to 1.

  • Trunk height: h = H · s((a − 0.15) / 0.85)
  • Radius at relative height t: r(t) = r · (1 − e·t) · (1 + b · sin(π t)), with e the taper and b the bulge
  • A frond's starting angle: θ0(f) = 80° − (55° + 70° · s((a − 0.1) / 0.4)) · f
  • Elevation along the frond, u from 0 to 1: θ(u) = θ0 − arch · u²
  • Azimuth of frond i: φi = i · 137.5°

In words: each frond leaves the crown at an angle set by its age, from nearly upright to below the horizontal, and then bends down by the Arch, most at the tip (u²). Fronds go round the trunk at the golden angle, like the araucaria's scale leaves, so no two in a row cover each other. ✦ IA

What the picture does and does not say ✦ IA

The number of fronds (30) is set by eye: no source gives it, only "numerous". Also by eye: the arch, the V of the leaflets, three clusters of 70 fruit and the canoe shape of the spathe.

The palm is to scale (the grey figure is 1.7 m), but the fruit is drawn about twice its size. The sprites lie on purpose: they shorten the trunk, enlarge the crown and draw only a few fronds, because thirty fronds in 16 pixels are a blot. In pixel art the palm fills the sprite at any age, so a young and an old one are not to scale with each other. The wind only moves the line drawing. ✦ IA

How it works ✦ IA

This part walks through the generator and both renderers. It covers:

  • The parameters, with their sources.
  • The palm's age as curves.
  • One frond: a curved rib and its leaflets.
  • The guide figure from the sketch.
  • A knob that solves for the frond length.
  • The age gradient in the line drawing.
  • Which fronds survive in a 16 pixel sprite.

Plain JavaScript and Canvas 2D, no libraries. The generator never touches the DOM: it runs in Node for the bench and the golden test, and it can be ported to C# for Islands.

1. Parameters with a source

Each parameter states its unit and where the figure comes from; whatever has no source says "by eye".

export const DEFAULTS = {
  seed: 3,
  age: 0.8,          // life stage in [0, 1]: 0 a trunkless rosette, 1 an old palm. Not calibrated in years.
  height: 18,        // trunk height of an old palm, metres (MMA: up to 30 m; 34–35 m reported). 18 m chosen at the gate (Claudio, 2026-10-30): a common adult, closer to the photos
  heightSpread: 0,   // ± metres: each seed draws its own mature height in [height − spread, height + spread], for varied stands
  diameter: 1.1,     // trunk diameter, metres (MMA: 0.8–1.1 m; 1.5–1.8 m at the base in Ocoa)
  taper: 0.15,       // how much narrower the trunk is at the top (MMA: "más angosto hacia la parte superior"; amount by eye)
  bulge: 0,          // swelling of the middle (Darwin: "thicker in the middle"); off by default, as in Claudio's sketch
  fronds: 30,        // living fronds in the crown (no source gives a number: "numerosas"; by eye from photos)
  frondLength: 3.5,  // metres, mature (MMA: 2–4 m)
  arch: 65,          // how much a frond bends down from base to tip, degrees (by eye)
  leaflets: 115,     // leaflets per side of a frond (MMA: 110–120, alternate)
  skirt: 0,          // 0..1: dead fronds hanging against the trunk (wild palms keep a bare trunk, MMA: "desnudo")
  clusters: 3,       // fruit clusters with their woody spathe, on adult palms (count by eye)
};

const GOLDEN = Math.PI * (3 - Math.sqrt(5));   // 137.5°: each new frond turns this much from the last
const RAD = Math.PI / 180;
const SALT = { rise: 1, len: 2, az: 3, leaflet: 4, fruit: 5, tone: 6, skirt: 7, cluster: 8, height: 9 };

2. Age

Everything that changes with age is a smoothstep curve, so the slider never jumps. The palm widens first and rises later.

// What changes with age, as smoothstep curves so the slider never jumps. A palm first grows
// wide and only then tall: the rosette reaches its full trunk diameter before the stem rises
// (MMA: the critical period lasts until the stem forms, 12–14 years).
export function stage(p) {
  return {
    h: matureHeight(p) * smoothstep(0.15, 1, p.age),                            // no trunk while young
    r: (p.diameter / 2) * (0.35 + 0.65 * smoothstep(0, 0.5, p.age)),
    frond: p.frondLength * (0.4 + 0.6 * smoothstep(0, 0.45, p.age)),
    count: Math.max(5, Math.round(p.fronds * (0.35 + 0.65 * smoothstep(0, 0.4, p.age)))),
    fruit: p.age >= 0.55,
  };
}

// The mature height of this palm: `height`, moved by up to `heightSpread` either way by its seed.
export const matureHeight = (p) => Math.max(3, p.height + (p.heightSpread ?? 0) * (2 * u01(p.seed, 0, SALT.height) - 1));

// Trunk radius at a share t of its height (0 base, 1 top): a column that narrows upward, with an
// optional bulge in the middle and a slight flare at the foot.
export function trunkRadius(p, st, t) {
  const flare = 0.12 * Math.max(0, 1 - t / 0.04);
  return st.r * (1 - p.taper * t) * (1 + p.bulge * Math.sin(Math.PI * t) + flare);
}

// A frond's elevation at its base, from its age in the crown (fa: 0 newest, 1 oldest): the
// newest point almost straight up, the oldest hang below the horizontal. A young palm has no
// trunk to hang from, so its rosette opens less.
const riseOf = (p, fa) => (80 - (55 + 70 * smoothstep(0.1, 0.5, p.age)) * fa) * RAD;
// The rib's elevation at a share u of its length: it bends down by `arch` towards the tip.
const elevation = (p, rise, u) => rise - p.arch * RAD * u * u;
const RIB = 10;   // pieces per rib
const GROUND = 0.03;   // a rib that reaches the ground rests on it

3. One frond

A rib of ten pieces that arches over, and alternate leaflets, longest in the middle, in a V and hanging with their weight. This loop is nearly all the cost, so the arithmetic is written out instead of using vectors.

// A frond: a rib of RIB pieces that arches down, with leaflets on both sides. Alternate
// leaflets, longest in the middle of the frond, in a shallow V and hanging a little.
function frond(art, p, f) {
  const s = p.seed;
  const out = [Math.cos(f.az), 0, Math.sin(f.az)], side = [-Math.sin(f.az), 0, Math.cos(f.az)];
  const step = f.L / RIB;
  const ribs = [];
  let pos = f.from, parent = f.parent;
  const points = [pos];
  for (let k = 0; k < RIB; k++) {
    const u = (k + 0.5) / RIB;
    const el = elevation(p, f.rise, u);
    const dir = add3(mul3(out, Math.cos(el)), [0, Math.sin(el), 0]);
    const next = add3(pos, mul3(dir, step));
    next[1] = Math.max(GROUND, next[1]);
    parent = addPart(art, { kind: "rib", a: pos, b: next, r0: 0.07 * (1 - k / RIB) + 0.012, r1: 0.07 * (1 - (k + 1) / RIB) + 0.012,
      role: f.role, parent, group: f.i, tone: f.tone });
    ribs.push(parent);
    pos = next; points.push(pos);
  }

  // Leaflets from 8 % of the length (the short petiole) to the tip, alternating sides.
  const n = Math.round(p.leaflets * (f.leafletShare ?? 1));
  const width = 0.3 * (f.L / 3);                                 // longest leaflet: about half the frond's 50–60 cm width, along a slant
  for (let q = 0; q < 2 * n; q++) {
    const u = 0.08 + 0.92 * ((q + 0.5) / (2 * n));
    const seg = Math.min(RIB - 1, Math.floor(u * RIB)), w = u * RIB - seg;
    const a = points[seg], b = points[seg + 1];
    const at = [a[0] + (b[0] - a[0]) * w, a[1] + (b[1] - a[1]) * w, a[2] + (b[2] - a[2]) * w];
    const el = elevation(p, f.rise, u), ce = Math.cos(el), se = Math.sin(el);
    const sgn = q % 2 ? 1 : -1;
    const size = width * Math.pow(Math.sin(Math.PI * Math.min(1, (u - 0.04) * 1.05)), 0.6) * (0.85 + 0.3 * u01(s, f.i * 512 + q, SALT.leaflet));
    // Out to the side, forward along the rib (0.7), up in a V along the frond's normal (0.35),
    // and down with their own weight. Written out, since this loop is most of the cost.
    const h = 0.7 * ce - 0.35 * se;                                 // along `out`
    const dir = norm3([side[0] * sgn + out[0] * h, 0.7 * se + 0.35 * ce - 0.45 - 0.4 * u, side[2] * sgn + out[2] * h]);
    addDetail(art, { kind: "leaflet", shape: "tick", role: f.role, part: ribs[seg], p: at, dir, size,
      tone: Math.min(1, Math.max(0, f.tone + (u01(s, f.i * 512 + q, SALT.tone) - 0.5) * 0.12)) });
  }
  return { rise: f.rise, az: f.az, L: f.L, fa: f.fa, role: f.role, ribs, tip: pos };
}

4. The guide figure

Turn on Show the guide figure. The circle is the one in Claudio's sketch: where the tips would land if fronds were straight. The dots are where they really land, and that is what the generator follows. Raise the Arch and watch them part.

// The guide figure, for an overlay, in metres (x out from the axis, y up), in the side view.
// Claudio's sketch: a rectangle for the trunk and a circle for the crown. `trunk` is the
// trunk's real profile; `circle` is where the frond tips would be if fronds were straight
// (centre at the top, radius the frond length); `tips` is where an average frond of each age
// really ends once it arches, from the newest (top) to the oldest (bottom). The generator
// follows the tips, not the circle: the circle only shows how far the arch pulls them in.
export function guide(params = {}) {
  const p = { ...DEFAULTS, ...params };
  const st = stage(p);
  const trunk = [];
  for (let k = 0; k <= 24; k++) { const t = k / 24; trunk.push([trunkRadius(p, st, t), t * st.h]); }
  const tips = [];
  for (let k = 0; k <= 32; k++) {
    const fa = k / 32, rise = riseOf(p, fa);
    let x = 0, y = st.h;
    for (let j = 0; j < RIB; j++) {
      const el = elevation(p, rise, (j + 0.5) / RIB);
      x += Math.cos(el) * st.frond / RIB; y = Math.max(GROUND, y + Math.sin(el) * st.frond / RIB);
    }
    tips.push([x, y]);
  }
  return { trunk, circle: { x: 0, y: st.h, r: st.frond }, tips, height: st.h };
}

5. A knob

A designer thinks in crown width; the generator thinks in frond length. Since a frond's shape does not depend on its length, one division is enough.

// Custom parameters: what a designer asks for, and how it becomes the parameters above. The
// generator thinks in frond length; "crown width" solves for it. The frond's shape does not
// depend on its length, so the widest tip grows in proportion and one division is exact.
export const KNOBS = [
  { id: "crownWidth", label: { en: "crown width (m)", es: "ancho de copa (m)" }, min: 2, max: 12, step: 0.5, to: (v, p) => {
    const w = 2 * Math.max(...guide({ ...p, frondLength: 1, age: 1 }).tips.map(([x]) => Math.abs(x)));
    return { frondLength: Math.min(6, Math.max(1, v / Math.max(0.1, w))) };
  } },
];

6. The gradient

A frond's tone is its age. It is split into seven steps mixed in OKLab, so all strokes of one colour are drawn at once.

const leafColors = Array.from({ length: STEPS + 1 }, (_, k) => (k === STEPS ? C.dry : mixOklab(C.frondNew, C.frondOld, k / (STEPS - 1))));

7. A readable sprite

About seven fronds fit in 16 pixels. They are grouped by the direction of their tip, and in each group the one that reaches farthest stays.

// Fewer, readable fronds: at 16 px thirty fronds are one blot. Bin the fronds by the
// direction of their tip from the crown and keep, in each bin, the one that reaches farthest.
const bins = big ? 11 : 7, pick = new Map();
for (const f of fronds) {
  const t = f.pts[f.pts.length - 1], dx = t.x * k, dy = (mapY(t.y) - mapY(g.height)) * ky;
  const b = Math.min(bins - 1, Math.floor(((Math.atan2(dy, dx) + Math.PI) / (2 * Math.PI)) * bins));
  const d = Math.hypot(dx, dy);
  if (!f.dry && (!pick.has(b) || pick.get(b).d < d)) pick.set(b, { f, d });
}
// Dead fronds hang against the trunk: a few are enough to read as a skirt.
const dry = fronds.filter((f) => f.dry);
const shown = [...pick.values()].map((v) => v.f).concat(dry.filter((_, i) => i % Math.ceil(dry.length / (big ? 6 : 4)) === 0));

Going further

  • A palm grove in a scene, like the araucaria forest, with each seed's height drawn at random.
  • Wind of its own for pinnate fronds: leaflets that shiver.
  • The C# port for Islands, with the golden test's JSON as the reference.

La idea en palabras simples ✦ IA

Como con la araucaria, el generador no dibuja: anota de qué está hecha la palma. Una columna en doce tramos, unas treinta hojas que salen todas del mismo punto, en lo alto, y en cada hoja un raquis curvo con 115 folíolos por lado. Los dibujantes leen esa lista y deciden cuánto mostrar.

Lo nuevo es que cada hoja tiene su propia edad. Una palma no ramifica: crece desde una sola yema en la punta, que saca una hoja nueva tras otra. Las nuevas salen casi verticales en el centro; con el tiempo se abren, se tumban y cuelgan, y su color pasa del verde al amarillo pardo. Es como un reloj de agujas: la posición de cada hoja dice su edad. La analogía tiene límites, porque las hojas no giran a ritmo fijo y el viento las mueve.

Mueve la Edad. Primero la roseta se ensancha sin tronco, y solo después sube la columna, como en el campo, donde el estípite tarda 12–14 años en formarse. ✦ IA

Por qué importa ✦ IA

La palma chilena es endémica del centro de Chile, entre los 32° 45′ y los 37° 30′ S, y crece desde el nivel del mar hasta los 1.400 m, en quebradas protegidas. Quedan unas 121.000, sobre todo en dos palmares: Ocoa (unas 70.300) y Cocalán (35.500), según González y colaboradores (2009), en la ficha del Ministerio del Medio Ambiente.

Para un generador es la prueba de que el formato sirve para algo que no es la araucaria: no hay verticilos ni ramas, y aun así caben las mismas dos listas, partes y detalles. Lo único nuevo fueron el tipo de parte «raquis» y el detalle «folíolo». ✦ IA

TODO: Lo personal de Claudio sobre la palma: un recuerdo (¿Ocoa, La Campana, Viña?), los coquitos, la miel de palma y por qué está En Peligro (para sacar la savia hay que talarla).

¿Hay un teorema? ✦ IA

No hay un teorema sobre la forma de una palma. Lo que la ficha del MMA da son medidas: hasta 30 m de alto, un tronco de 0,8–1,1 m, hojas de 2–4 m con 110–120 folíolos por lado y frutos amarillos de 4 cm. Las fuentes no coinciden en el tronco. La ficha lo describe cilíndrico y más angosto arriba; Darwin lo vio más grueso en el medio, y en Ocoa se han medido bases de 1,5–1,8 m. Por eso el estrechamiento y el abombado tienen su propio deslizador. ✦ IA

Las ecuaciones ✦ IA

Ángulos en grados. a es la edad de la palma en [0, 1], f la edad de la hoja en la copa (0 la más nueva, 1 la más vieja), s(·) la curva smoothstep de 0 a 1.

  • Altura del tronco: h = H · s((a − 0,15) / 0,85)
  • Radio a la altura relativa t: r(t) = r · (1 − e·t) · (1 + b · sen(π t)), con e el estrechamiento y b el abombado
  • Ángulo de salida de una hoja: θ0(f) = 80° − (55° + 70° · s((a − 0,1) / 0,4)) · f
  • Elevación a lo largo de la hoja, u de 0 a 1: θ(u) = θ0 − arco · u²
  • Acimut de la hoja i: φi = i · 137,5°

En palabras: cada hoja sale con un ángulo que depende de su edad, de casi vertical a bajo la horizontal, y luego se curva hacia abajo con la Caída del arco, más en la punta (u²). Las hojas se reparten alrededor del tronco en el ángulo áureo, como las escamas de la araucaria, así que nunca se tapan dos seguidas. ✦ IA

Lo que el dibujo dice y lo que no ✦ IA

El número de hojas (30) está puesto a ojo: ninguna fuente lo da, solo «numerosas». También a ojo: la caída del arco, la V de los folíolos, tres racimos de 70 frutos y la forma de canoa de la espata.

La palma está a escala (la figura gris mide 1,7 m), pero los frutos se dibujan unas dos veces más grandes. Los sprites mienten a propósito: acortan el tronco, agrandan la copa y dibujan solo unas pocas hojas, porque treinta hojas en 16 píxeles son una mancha. En pixel art la palma llena el sprite a cualquier edad, así que una joven y una vieja no están a escala entre sí. El viento solo mueve el dibujo de líneas. ✦ IA

Cómo funciona ✦ IA

Esta parte recorre el generador y los dos dibujantes. Cubre:

  • Los parámetros, con su fuente.
  • La edad de la palma como curvas.
  • Una hoja: raquis curvo y folíolos.
  • La figura guía del boceto.
  • Una perilla que despeja el largo de hoja.
  • El degradado de edad en el dibujo de líneas.
  • Qué hojas sobreviven en un sprite de 16 píxeles.

JavaScript y Canvas 2D, sin librerías. El generador no toca el DOM: corre en Node para el banco y la prueba de oro, y se puede portar a C# para Islands. Los comentarios del código están en inglés.

1. Parámetros con fuente

Cada parámetro dice su unidad y de dónde sale la cifra; lo que no tiene fuente dice «a ojo».

export const DEFAULTS = {
  seed: 3,
  age: 0.8,          // life stage in [0, 1]: 0 a trunkless rosette, 1 an old palm. Not calibrated in years.
  height: 18,        // trunk height of an old palm, metres (MMA: up to 30 m; 34–35 m reported). 18 m chosen at the gate (Claudio, 2026-10-30): a common adult, closer to the photos
  heightSpread: 0,   // ± metres: each seed draws its own mature height in [height − spread, height + spread], for varied stands
  diameter: 1.1,     // trunk diameter, metres (MMA: 0.8–1.1 m; 1.5–1.8 m at the base in Ocoa)
  taper: 0.15,       // how much narrower the trunk is at the top (MMA: "más angosto hacia la parte superior"; amount by eye)
  bulge: 0,          // swelling of the middle (Darwin: "thicker in the middle"); off by default, as in Claudio's sketch
  fronds: 30,        // living fronds in the crown (no source gives a number: "numerosas"; by eye from photos)
  frondLength: 3.5,  // metres, mature (MMA: 2–4 m)
  arch: 65,          // how much a frond bends down from base to tip, degrees (by eye)
  leaflets: 115,     // leaflets per side of a frond (MMA: 110–120, alternate)
  skirt: 0,          // 0..1: dead fronds hanging against the trunk (wild palms keep a bare trunk, MMA: "desnudo")
  clusters: 3,       // fruit clusters with their woody spathe, on adult palms (count by eye)
};

const GOLDEN = Math.PI * (3 - Math.sqrt(5));   // 137.5°: each new frond turns this much from the last
const RAD = Math.PI / 180;
const SALT = { rise: 1, len: 2, az: 3, leaflet: 4, fruit: 5, tone: 6, skirt: 7, cluster: 8, height: 9 };

2. La edad

Todo lo que cambia con la edad es una curva smoothstep, así el deslizador nunca salta. La palma se ensancha primero y luego sube.

// What changes with age, as smoothstep curves so the slider never jumps. A palm first grows
// wide and only then tall: the rosette reaches its full trunk diameter before the stem rises
// (MMA: the critical period lasts until the stem forms, 12–14 years).
export function stage(p) {
  return {
    h: matureHeight(p) * smoothstep(0.15, 1, p.age),                            // no trunk while young
    r: (p.diameter / 2) * (0.35 + 0.65 * smoothstep(0, 0.5, p.age)),
    frond: p.frondLength * (0.4 + 0.6 * smoothstep(0, 0.45, p.age)),
    count: Math.max(5, Math.round(p.fronds * (0.35 + 0.65 * smoothstep(0, 0.4, p.age)))),
    fruit: p.age >= 0.55,
  };
}

// The mature height of this palm: `height`, moved by up to `heightSpread` either way by its seed.
export const matureHeight = (p) => Math.max(3, p.height + (p.heightSpread ?? 0) * (2 * u01(p.seed, 0, SALT.height) - 1));

// Trunk radius at a share t of its height (0 base, 1 top): a column that narrows upward, with an
// optional bulge in the middle and a slight flare at the foot.
export function trunkRadius(p, st, t) {
  const flare = 0.12 * Math.max(0, 1 - t / 0.04);
  return st.r * (1 - p.taper * t) * (1 + p.bulge * Math.sin(Math.PI * t) + flare);
}

// A frond's elevation at its base, from its age in the crown (fa: 0 newest, 1 oldest): the
// newest point almost straight up, the oldest hang below the horizontal. A young palm has no
// trunk to hang from, so its rosette opens less.
const riseOf = (p, fa) => (80 - (55 + 70 * smoothstep(0.1, 0.5, p.age)) * fa) * RAD;
// The rib's elevation at a share u of its length: it bends down by `arch` towards the tip.
const elevation = (p, rise, u) => rise - p.arch * RAD * u * u;
const RIB = 10;   // pieces per rib
const GROUND = 0.03;   // a rib that reaches the ground rests on it

3. Una hoja

Un raquis de diez tramos que se curva, y los folíolos alternos, más largos en el medio, en V y colgando por su peso. Este bucle es casi todo el coste, así que las cuentas van escritas a mano en vez de con vectores.

// A frond: a rib of RIB pieces that arches down, with leaflets on both sides. Alternate
// leaflets, longest in the middle of the frond, in a shallow V and hanging a little.
function frond(art, p, f) {
  const s = p.seed;
  const out = [Math.cos(f.az), 0, Math.sin(f.az)], side = [-Math.sin(f.az), 0, Math.cos(f.az)];
  const step = f.L / RIB;
  const ribs = [];
  let pos = f.from, parent = f.parent;
  const points = [pos];
  for (let k = 0; k < RIB; k++) {
    const u = (k + 0.5) / RIB;
    const el = elevation(p, f.rise, u);
    const dir = add3(mul3(out, Math.cos(el)), [0, Math.sin(el), 0]);
    const next = add3(pos, mul3(dir, step));
    next[1] = Math.max(GROUND, next[1]);
    parent = addPart(art, { kind: "rib", a: pos, b: next, r0: 0.07 * (1 - k / RIB) + 0.012, r1: 0.07 * (1 - (k + 1) / RIB) + 0.012,
      role: f.role, parent, group: f.i, tone: f.tone });
    ribs.push(parent);
    pos = next; points.push(pos);
  }

  // Leaflets from 8 % of the length (the short petiole) to the tip, alternating sides.
  const n = Math.round(p.leaflets * (f.leafletShare ?? 1));
  const width = 0.3 * (f.L / 3);                                 // longest leaflet: about half the frond's 50–60 cm width, along a slant
  for (let q = 0; q < 2 * n; q++) {
    const u = 0.08 + 0.92 * ((q + 0.5) / (2 * n));
    const seg = Math.min(RIB - 1, Math.floor(u * RIB)), w = u * RIB - seg;
    const a = points[seg], b = points[seg + 1];
    const at = [a[0] + (b[0] - a[0]) * w, a[1] + (b[1] - a[1]) * w, a[2] + (b[2] - a[2]) * w];
    const el = elevation(p, f.rise, u), ce = Math.cos(el), se = Math.sin(el);
    const sgn = q % 2 ? 1 : -1;
    const size = width * Math.pow(Math.sin(Math.PI * Math.min(1, (u - 0.04) * 1.05)), 0.6) * (0.85 + 0.3 * u01(s, f.i * 512 + q, SALT.leaflet));
    // Out to the side, forward along the rib (0.7), up in a V along the frond's normal (0.35),
    // and down with their own weight. Written out, since this loop is most of the cost.
    const h = 0.7 * ce - 0.35 * se;                                 // along `out`
    const dir = norm3([side[0] * sgn + out[0] * h, 0.7 * se + 0.35 * ce - 0.45 - 0.4 * u, side[2] * sgn + out[2] * h]);
    addDetail(art, { kind: "leaflet", shape: "tick", role: f.role, part: ribs[seg], p: at, dir, size,
      tone: Math.min(1, Math.max(0, f.tone + (u01(s, f.i * 512 + q, SALT.tone) - 0.5) * 0.12)) });
  }
  return { rise: f.rise, az: f.az, L: f.L, fa: f.fa, role: f.role, ribs, tip: pos };
}

4. La figura guía

Activa Mostrar la figura guía. El círculo es el de tu boceto: donde caerían las puntas si las hojas fueran rectas. Los puntos son donde caen de verdad, y eso es lo que sigue el generador. Sube la Caída del arco y mira cómo se separan.

// The guide figure, for an overlay, in metres (x out from the axis, y up), in the side view.
// Claudio's sketch: a rectangle for the trunk and a circle for the crown. `trunk` is the
// trunk's real profile; `circle` is where the frond tips would be if fronds were straight
// (centre at the top, radius the frond length); `tips` is where an average frond of each age
// really ends once it arches, from the newest (top) to the oldest (bottom). The generator
// follows the tips, not the circle: the circle only shows how far the arch pulls them in.
export function guide(params = {}) {
  const p = { ...DEFAULTS, ...params };
  const st = stage(p);
  const trunk = [];
  for (let k = 0; k <= 24; k++) { const t = k / 24; trunk.push([trunkRadius(p, st, t), t * st.h]); }
  const tips = [];
  for (let k = 0; k <= 32; k++) {
    const fa = k / 32, rise = riseOf(p, fa);
    let x = 0, y = st.h;
    for (let j = 0; j < RIB; j++) {
      const el = elevation(p, rise, (j + 0.5) / RIB);
      x += Math.cos(el) * st.frond / RIB; y = Math.max(GROUND, y + Math.sin(el) * st.frond / RIB);
    }
    tips.push([x, y]);
  }
  return { trunk, circle: { x: 0, y: st.h, r: st.frond }, tips, height: st.h };
}

5. Una perilla

El diseñador piensa en el ancho de la copa; el generador, en el largo de la hoja. Como la forma de la hoja no depende de su largo, una división basta.

// Custom parameters: what a designer asks for, and how it becomes the parameters above. The
// generator thinks in frond length; "crown width" solves for it. The frond's shape does not
// depend on its length, so the widest tip grows in proportion and one division is exact.
export const KNOBS = [
  { id: "crownWidth", label: { en: "crown width (m)", es: "ancho de copa (m)" }, min: 2, max: 12, step: 0.5, to: (v, p) => {
    const w = 2 * Math.max(...guide({ ...p, frondLength: 1, age: 1 }).tips.map(([x]) => Math.abs(x)));
    return { frondLength: Math.min(6, Math.max(1, v / Math.max(0.1, w))) };
  } },
];

6. El degradado

El tono de cada hoja es su edad. Se reparte en siete escalones mezclados en OKLab, para dibujar de una vez todos los trazos de un mismo color.

const leafColors = Array.from({ length: STEPS + 1 }, (_, k) => (k === STEPS ? C.dry : mixOklab(C.frondNew, C.frondOld, k / (STEPS - 1))));

7. Un sprite legible

A 16 píxeles caben unas siete hojas. Se agrupan por la dirección de su punta y se queda, en cada grupo, la que llega más lejos.

// Fewer, readable fronds: at 16 px thirty fronds are one blot. Bin the fronds by the
// direction of their tip from the crown and keep, in each bin, the one that reaches farthest.
const bins = big ? 11 : 7, pick = new Map();
for (const f of fronds) {
  const t = f.pts[f.pts.length - 1], dx = t.x * k, dy = (mapY(t.y) - mapY(g.height)) * ky;
  const b = Math.min(bins - 1, Math.floor(((Math.atan2(dy, dx) + Math.PI) / (2 * Math.PI)) * bins));
  const d = Math.hypot(dx, dy);
  if (!f.dry && (!pick.has(b) || pick.get(b).d < d)) pick.set(b, { f, d });
}
// Dead fronds hang against the trunk: a few are enough to read as a skirt.
const dry = fronds.filter((f) => f.dry);
const shown = [...pick.values()].map((v) => v.f).concat(dry.filter((_, i) => i % Math.ceil(dry.length / (big ? 6 : 4)) === 0));

Para seguir

  • Un palmar en una escena, como el bosque de araucarias, con la altura al azar de cada semilla.
  • Viento propio para las hojas pinnadas: que los folíolos tiemblen.
  • El port a C# para Islands, con el JSON de la prueba de oro como referencia.

SourcesFuentes