// Structural family: a field of standing pylons receding to the track's
// horizon. The columns converge on a vanishing point, giving real perspective
// depth, and each column carries the song's own object on top, so a hexagonal
// track grows hexagonal pylons. The beat walks a pulse of light down the rows
// rather than flashing the whole frame.
//
// Nothing here is the scene's own invention any more. The crown is the song's
// protagonist and the LEG is a stack of its chorus: a column of bodies holding
// the subject up, rather than the thin fixed-width strut this drew for years.
// That strut was the same object in every video — a scene that has migrated its
// subject and kept its structure is still telling you what IT is, in the half of
// the frame that is structure, and next to a marched crown it read as scaffolding
// left up around a finished building.
//
// The crowns are SOLID. This scene already had the one thing the 3D cast needs
// and most scenes lack — a real depth axis, with near rows large and far rows
// small — so a flat crown was the only part of it drawn without perspective: a
// sticker facing the viewer on top of a receding structure. Marching them puts
// the object in the same space as the field it stands in, and because each
// pylon turns its own crown at its own angle, one field shows the song's form
// from a dozen sides at once. See castSolid.
//
// WHAT THE NUMBERS SAY, since two of them disagree and both are right.
//
// Crop to the bottom third of the frame — the near rows, where the columns are
// tall and the crowns are not — and measure how far apart six identities are:
// 13.4/255 with the old strut, 22.2/255 with the chorus stacked. That is the
// complaint answered. The strut was a fixed width and a fixed shape, so the only
// thing separating one video's structure from another's was the palette.
//
// The whole-frame variety score barely moves for it: 0.1322 to 0.1348 over
// 8 songs x 8 seeds. That is honest rather than disappointing — the crowns
// already dominate a frame-level descriptor, and a measurement averaged over the
// whole image cannot see a change confined to the lower third. It is the reason
// the crop above exists.
//
// The frame score is also still under the flat version's 0.141, and the cause is
// known: a body always fills its own silhouette, where a stamped cast form paints
// anywhere between an outline and a disc depending on whether the identity is
// hollow, and that swing was worth three hundredths of LAYOUT variety. Two
// candidate fixes were tried and both measured flat — keeping the far rows
// stamped (0.132), and running the lit surface through inkPattern (0.132 here,
// and 0.0009 on Effigy). It is the solid itself, and the trade is deliberate:
// the score does not measure a crown that ignores the perspective of the field
// it stands in, or a leg that is the same object in every video.

export const pylonGrid = {
    name: 'Pylon Grid',
    family: 'structural',
    kind: 'fragment',
    // Personality: see look/Personality.js.
    // Takes the track's surface grain, but lightly — this is drawn, not filmed.
    texture: 0.4,
    consumes: ['form', 'ink', 'staging'],
    traits: ['shape', 'space', 'style'],

    params: {
        columns:  { type: 'float', range: [3, 16],  default: 8,   uniform: 'u_columns', bias: 'density' },
        rows:     { type: 'float', range: [2, 10],  default: 6,   uniform: 'u_rows', bias: 'density' },
        spread:   { type: 'float', range: [0.6, 1.6], default: 1.1, uniform: 'u_spread' },
        height:   { type: 'float', range: [0.3, 1.4], default: 0.85, uniform: 'u_height', bias: 'energy' },
        pulse:    { type: 'float', range: [0, 1.2], default: 0.5, uniform: 'u_pulse', bias: 'energy' },
        speed:    { type: 'float', range: [0.05, 0.8], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true },
        palette:  { type: 'palette', count: 4 },
    },

    reactive: {
        pulse: { feature: 'beat',    amount: 0.4, response: 'smooth' },
        rows:  { feature: 'bandLow', amount: 0.25 },
    },

    shader: `
vec4 scene(vec2 uv, vec2 p) {
    float t = u_time * u_speed + u_seed;
    float hy = sigHorizonY();
    vec3 col = pal(0) * 0.05;

    // Whether a crown already owns this pixel. Rows are walked near to far, so
    // first-wins is the correct occlusion here rather than merely the cheap one
    // — and it bounds a hundred and sixty instances to about one march.
    float painted = 0.0;

    for (int r = 0; r < 10; r++) {
        if (float(r) >= u_rows) break;
        float fr = float(r);

        // Perspective: the last row sits on the horizon, the first at the bottom
        // of the frame, everything between scaling by its depth fraction.
        float f = fr / max(u_rows - 1.0, 1.0);       // 0 near .. 1 far
        float y0 = mix(-1.08, hy + 0.06, f);          // row baseline
        float scale = mix(1.0, 0.12, f);              // perspective shrink
        float depthShade = mix(1.0, 0.35, f);         // far rows dim

        for (int c = 0; c < 16; c++) {
            if (float(c) >= u_columns) break;
            float fc = float(c);
            float cx = (fc / max(u_columns - 1.0, 1.0) - 0.5) * 2.0 * u_spread * scale;

            float h = u_height * scale;
            float yTop = y0 + h;

            // The beat pulse walks a glow down the depth axis, row by row, so the
            // light travels rather than strobing the whole frame.
            float walk = sat(1.0 - abs(fr - mod(u_beatPhase * 6.0 + t * 0.4, u_rows)));
            float inten = (0.35 + 0.65 * walk) * depthShade * (0.7 + 0.3 * u_beat);

            // The crown is resolved BEFORE the leg, because the two overlap
            // where the object sits on the column and the crown has to win it —
            // resolved the other way round, every subject is cut off at the
            // waist by the thing holding it up.
            //
            // The song's object, standing on the pylon.
            vec2 crown = vec2(cx, yTop);
            float size = (0.06 + 0.2 * scale) * (1.0 + u_beat * 0.4);
            vec2 local = (p - crown) / max(size, 1e-3);

            if (dot(local, local) < 1.6 && painted < 0.5) {
                // Each crown at its own angle, so the field is one form seen
                // from many sides rather than one silhouette repeated. The turn
                // is slow and per-pylon; the row term keeps a receding line of
                // them from presenting the same profile all the way back.
                mat3 turn = castTurn(t * 0.6 + fc * 0.9 + fr * 0.4, 0.2 + sin(t + fr) * 0.2);
                vec3 n;
                float hit = castSolid(local, turn, n);
                if (hit > 0.0) {
                    painted = 1.0;
                    col = mix(col, castLit(n, vec3(0.0, 0.0, 1.0)) * inten * (0.6 + u_pulse), 0.92);
                }
                // Its outline in the track's line weight, which is what carries
                // the far rows: at twelve percent scale a crown is a few pixels
                // and the lit faces are gone before the edge is.
                float d = castSDF3(turn * vec3(local, 0.0)) * size;
                col += pal(3) * inkStroke(d) * inten * (0.5 + u_pulse);
            }
            col += pal(int(mod(fr, 4.0))) * exp(-dot(p - crown, p - crown) * 60.0) * u_pulse * depthShade;

            // THE LEG: the chorus, stacked. How many members make up a column
            // is the song's element size deciding it — a track built of a few
            // huge forms stands on three of them, a track of many small ones on
            // a ladder of nine — and how WIDE the column is comes from the
            // chorus's own proportions, which is why no two songs get the same
            // pylon. See Identity.js: this is what the chorus solid is for.
            float segs = clamp(floor(2.0 + 3.6 / max(stageScale(), 0.3)), 2.0, 9.0);
            float member = max(h / (segs * 2.0), 1e-3);

            // The bounding test is the column's own box, so a pixel outside it
            // costs two comparisons rather than a march. With a hundred and
            // sixty pylons on screen that test is most of the scene's budget.
            if (abs(p.x - cx) < member * 1.3 && p.y > y0 - member * 0.3
                && p.y < yTop + member * 0.3 && painted < 0.5) {
                float rung = floor((p.y - y0) / (member * 2.0));
                float centreY = y0 + (rung + 0.5) * member * 2.0;
                vec2 legLocal = vec2(p.x - cx, p.y - centreY) / member;

                // Every member at its own angle, so a column is a stack of one
                // form seen many ways rather than one form printed nine times.
                mat3 legTurn = castTurn(t * 0.35 + rung * 1.1 + fc * 0.5, rung * 0.5);
                vec3 ln;
                float legHit = castChorusSolid(legLocal, legTurn, ln);
                if (legHit > 0.0) {
                    painted = 1.0;
                    col = mix(col, castLit(ln, vec3(0.0, 0.0, 1.0))
                                   * mix(inten, 1.0, 0.25) * pal(int(mod(fc, 4.0))) * 1.6, 0.9);
                }
                col += pal(int(mod(fc, 4.0)))
                     * inkStroke(castChorus3(legTurn * vec3(legLocal, 0.0)) * member) * inten * 0.8;
            }
        }
    }

    // Ground haze and the shared air between here and the horizon.
    col = sigAir(col, p, smoothstep(0.0, 1.6, abs(p.y - hy)));
    return vec4(inkValue(col), 1.0);
}
`,
};