← canonical-projection-continued-fractions

Substitution across the octagonal family

Flexible substitution rules for a family of canonical projection tilings

Source

octagonalSubstitution/index.js

/**
 * octagonalSubstitution -- the two generators of the octagonal continued
 * fraction as ONE figure, over the whole band of tile shapes.
 *
 * The family: axis edges a at 0deg and 90deg, diagonal edges b at 45deg and
 * 135deg, and the single modulus rho = b : a. Relabelling which cross is the
 * axis one is a symmetry of the family and inverts rho, so every shape appears
 * exactly once with the DIAGONAL SHORTER, rho in (0, 1]: the slider is that
 * rho, running from the collapsed diagonal to the classical Ammann-Beenker
 * tiling at rho = 1.
 *
 * Two lattice maps generate the continued fraction on this modulus, and each
 * owns half of the band:
 *
 *   rho in [1/sqrt2, 1]   the SILVER INFLATION, multiplication by 1 + sqrt2.
 *     The inflated edges are A = a + sqrt2 b and C likewise, B = sqrt2 a + b
 *     and D likewise, so the ratio goes to (sqrt2 + rho)/(1 + sqrt2 rho) -- the
 *     Moebius map with the classical tiling as its fixed point. This is the
 *     flexible Ammann-Beenker substitution.
 *
 *   rho in (0, 1/sqrt2)   the PARABOLIC SHEAR, the unipotent matrix
 *     M = ((1,0,0,0),(1,1,-1,0),(0,0,1,0),(1,0,1,1)), which stretches the axis
 *     cross in place (a to (1 + sqrt2 rho) a, c likewise) and fixes the
 *     diagonal cross, sending rho to rho/(1 + sqrt2 rho). Inflating nothing, its
 *     rule is a recomposition: the supertile cuts through the base tiles around
 *     it and regroups them.
 *
 * The split at 1/sqrt2 is forced, not chosen. The shear's image window has axis
 * generator 1 - sqrt2 rho, which vanishes at rho = 1/sqrt2; past that wall the
 * image acceptance selects a folded surface and no single-window figure can
 * show it. So the shear rule is drawn exactly where it exists, and the silver
 * inflation -- which is defined across the whole family -- takes the band above
 * the wall. Dragging the slider through 1/sqrt2 swaps the rule.
 *
 * WHAT STAYS PUT ACROSS THE WALL. Both rules are drawn in the same frame, so
 * only the rule itself changes at 1/sqrt2:
 *   - the LEFT column of every panel is the base tile at ratio rho, marked --
 *     the same twelve marked oriented tiles for both generators (the axis
 *     square in its four mark orientations, the diagonal square in its four,
 *     the four rhombs), each in a fixed slot keyed by its mark direction or,
 *     for the rhombs, by its pair of edge directions;
 *   - the RIGHT column is that tile's image under the generator, decomposed
 *     into base tiles. Both images have axis edge 1 + sqrt2 rho, so the common
 *     normalisation k = 1/(1 + sqrt2 rho) holds the supertiles at near constant
 *     size while the base tiles sweep the family;
 *   - the OUTER window on the right is the window of the base tiling at ratio
 *     rho, the same octagon on both sides of the wall: the internal star of
 *     each generator's figure is the Galois conjugate rho -> -rho of the same
 *     physical star, and the two agree as centred zonogons. What swaps is the
 *     SUBWINDOW nested at its centre -- the window of the image tiling,
 *     partitioned into one cell per rule and drawn in blues. Each cell,
 *     translated by the anchor offset of each of its children, is a copy out in
 *     the window, and together the copies tile the window exactly: the action
 *     of the rule as a piecewise translation. A copy carries the colour of the
 *     tile it substitutes FROM.
 * Colour is read off the geometry alone and so is shared by the two regimes:
 * green for the axis square {0,90}, brown for the diagonal square {45,135},
 * cream and tan for the two mirror classes of rhomb.
 *
 * The rule data lives in two sibling data modules, one per generator. The
 * silver side takes the lattice rule tables, prototile panels and window
 * octagons of `./flexibleAB.js` and evaluates them in JS at the current ratio;
 * the parabolic side calls `./shearSubstitution.js`'s per-ratio `scene()`
 * directly. Both are reduced to one class structure -- prototile,
 * image outline, children, window cell, copy offsets -- pushed to CindyJS as
 * CLS whenever the slider moves. The interaction state is two scalars living in
 * Cindy, HLC (hovered class) and HLK (hovered child within it); the pointer is
 * hit-tested against HITL through evalcs, repainting only when the state
 * changes. No geometry flows back to JS. This is a site-only visualiser (a
 * `_visualisers/<name>/` folder with a meta.ts) mounted by VisualiserHost via
 * the mount() contract.
 */

import { _test as AB } from './flexibleAB.js';
import { _test as SH } from './shearSubstitution.js';

const CINDY_SRC = '/vendor/cindyjs/Cindy.js';

// Idempotent <script> loader: resolves once window.CindyJS exists. Shared
// across every mount on the page (the global is a singleton).
let cindyPromise = null;
function loadCindy() {
  if (typeof window !== 'undefined' && window.CindyJS) return Promise.resolve();
  if (cindyPromise) return cindyPromise;
  cindyPromise = new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = CINDY_SRC;
    s.async = true;
    s.onload = () => resolve();
    s.onerror = () => reject(new Error(`CindyJS failed to load from ${CINDY_SRC}`));
    document.head.appendChild(s);
  });
  return cindyPromise;
}

// ── The band ─────────────────────────────────────────────────────────────────
const SQ2 = Math.SQRT2;
const WALL = Math.SQRT1_2;      // where the shear's image window degenerates
const RHO_MIN = 0.001, RHO_MAX = 1, RHO_DEF = 0.85;

// ── Palette ──────────────────────────────────────────────────────────────────
// Indices 1..4 are the tile classes read off the edge directions, so both
// regimes colour the same shape the same way.
const colA = [0.502, 0.753, 0.502]; // 1 axis square {0,90}: green
const colB = [0.376, 0.188, 0.063]; // 2 diagonal square {45,135}: brown
const colR = [0.941, 0.941, 0.816]; // 3 rhombs {45,90} and {0,135}: cream
const colT = [0.871, 0.816, 0.639]; // 4 rhombs {0,45} and {90,135}: tan
const TYPECOL = [colA, colB, colR, colT];
// The window figure is drawn in blues, one shade per tile class.
const TYPEBLUE = [[0.45, 0.62, 0.85], [0.25, 0.38, 0.66],
  [0.72, 0.82, 0.93], [0.58, 0.71, 0.89]];
const ink = [0.125, 0.125, 0.314];
const selCol = [50 / 255, 74 / 255, 33 / 255];
const pageBg = [1, 1, 1];
const dotFill = [1, 1, 1];

// ── Small geometry ───────────────────────────────────────────────────────────
const cadd = (a, b) => [a[0] + b[0], a[1] + b[1]];
const csc = (a, s) => [a[0] * s, a[1] * s];
const centroid = (P) => {
  let x = 0, y = 0;
  for (const p of P) { x += p[0]; y += p[1]; }
  return [x / P.length, y / P.length];
};
const bboxOf = (P) => {
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  for (const p of P) {
    if (p[0] < x0) x0 = p[0];
    if (p[1] < y0) y0 = p[1];
    if (p[0] > x1) x1 = p[0];
    if (p[1] > y1) y1 = p[1];
  }
  return { x0, y0, x1, y1, w: x1 - x0, h: y1 - y0, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2 };
};
// Clip a convex polygon by a convex CCW polygon (Sutherland-Hodgman).
function convexClip(poly, O) {
  let out = poly;
  for (let i = 0; i < O.length && out.length; i++) {
    const A = O[i], B = O[(i + 1) % O.length];
    const side = (p) => (B[0] - A[0]) * (p[1] - A[1]) - (B[1] - A[1]) * (p[0] - A[0]);
    const nxt = [];
    for (let j = 0; j < out.length; j++) {
      const P = out[j], Q = out[(j + 1) % out.length], sp = side(P), sq = side(Q);
      if (sp >= -1e-12) nxt.push(P);
      if ((sp > 1e-12 && sq < -1e-12) || (sp < -1e-12 && sq > 1e-12)) {
        const t = sp / (sp - sq);
        nxt.push([P[0] + t * (Q[0] - P[0]), P[1] + t * (Q[1] - P[1])]);
      }
    }
    out = nxt;
  }
  return out;
}
const dedupe = (P) => {
  const out = [];
  for (const p of P) {
    const prev = out.length ? out[out.length - 1] : P[P.length - 1];
    if (Math.hypot(p[0] - prev[0], p[1] - prev[1]) > 1e-7) out.push(p);
  }
  return out;
};

// ── Reading a tile off its geometry ──────────────────────────────────────────
// Every edge in the family points at an exact multiple of 45deg in both
// regimes (the shear stretches the axis cross, the inflation is a real scalar),
// so a tile's identity -- its class, its colour, its slot -- is read from
// direction indices rather than from either module's own star labelling, which
// differ (flexibleAB puts b at 45deg, shearSubstitution at -45deg).
const dirIdx = (v) => ((Math.round(Math.atan2(v[1], v[0]) / (Math.PI / 4)) % 4) + 4) % 4;
const markIdx = (v) => ((Math.round(Math.atan2(v[1], v[0]) / (Math.PI / 4)) % 8) + 8) % 8;
// A tile is a parallelogram [p, p+u, p+u+v, p+v]: its pair of edge directions.
const pairKey = (poly) => [dirIdx([poly[1][0] - poly[0][0], poly[1][1] - poly[0][1]]),
  dirIdx([poly[3][0] - poly[0][0], poly[3][1] - poly[0][1]])].sort().join(',');
const PAIRCOL = { '0,2': 1, '1,3': 2, '1,2': 3, '0,3': 3, '0,1': 4, '2,3': 4 };
const colourOf = (poly) => {
  const c = PAIRCOL[pairKey(poly)];
  if (!c) throw new Error(`octagonalSubstitution: tile with edge pair ${pairKey(poly)}`);
  return c;
};

// The twelve fixed slots, three columns of four: the axis square in its four
// mark orientations, the diagonal square in its four, then the four rhombs.
// A marked square's slot is its mark direction in reading order (top-left,
// top-right, bottom-left, bottom-right for the axis square, whose marks sit on
// the diagonals; top, left, right, bottom for the diagonal square, whose marks
// sit on the axes); a rhomb's is its pair of edge directions. Both generators
// carry all twelve, which is what lets the swap at the wall keep every tile in
// place.
const AXIS_MARKS = [3, 1, 5, 7];
const DIAG_MARKS = [2, 4, 0, 6];
const RHOMB_SLOTS = ['0,1', '1,2', '2,3', '0,3'];
function slotOf(protoPoly, markPt) {
  const key = pairKey(protoPoly);
  if (key === '0,2' || key === '1,3') {
    if (!markPt) throw new Error('octagonalSubstitution: unmarked square');
    const c = centroid(protoPoly);
    const m = markIdx([markPt[0] - c[0], markPt[1] - c[1]]);
    const i = (key === '0,2' ? AXIS_MARKS : DIAG_MARKS).indexOf(m);
    if (i < 0) throw new Error(`octagonalSubstitution: square mark at index ${m}`);
    return (key === '0,2' ? 0 : 4) + i;
  }
  const i = RHOMB_SLOTS.indexOf(key);
  if (i < 0) throw new Error(`octagonalSubstitution: rhomb with edge pair ${key}`);
  return 8 + i;
}

// The orientation dot: 30% in from the marked corner toward the centre, radius
// from the tile's mean corner distance (both source figures' convention).
function dotAt(poly, corner) {
  const c = centroid(poly);
  const hd = poly.reduce((s, p) => s + Math.hypot(p[0] - c[0], p[1] - c[1]), 0) / poly.length;
  return [[corner[0] + 0.3 * (c[0] - corner[0]), corner[1] + 0.3 * (c[1] - corner[1])], 0.17 * hd];
}
// shearSubstitution reports a mark as a sector direction in the window; the dot
// goes on the corner lying that way. flexibleAB gives the corner itself.
function cornerInDir(poly, dir) {
  const c = centroid(poly);
  let best = poly[0], bs = -Infinity;
  for (const p of poly) {
    const v = [p[0] - c[0], p[1] - c[1]], n = Math.hypot(v[0], v[1]) || 1;
    const s = (v[0] * dir[0] + v[1] * dir[1]) / n;
    if (s > bs) { bs = s; best = p; }
  }
  return best;
}

// ── The silver inflation regime, rho >= 1/sqrt2 ──────────────────────────────
// flexibleAB's PANELS carry the rule as lattice data: for each of the twelve
// marked oriented tiles, the prototile, its inflated outline, the owned
// children with their marks, the anchor offsets that place the window copies,
// and the lattice offsets that cut its window region. All of it is
// ratio-independent; only the projection carries rho.
const planeMap = (r) => (n) => [n[0] + (r / SQ2) * (n[1] - n[3]), n[2] + (r / SQ2) * (n[1] + n[3])];
// The internal (window) star is the Galois conjugate rho -> -rho, in uv
// coordinates u = (n0, n2), v = (n1 - n3, n1 + n3). Taken in the BASE tiling's
// own frame, so the outer octagon is the base window -- the same object the
// shear figure draws, which is what carries it continuously across the wall.
// (flexibleAB works in the inflated tiling's coarse frame instead, where the
// internal ratio is 1/sigma; the region combinatorics are the same either way,
// since they are cut by lattice offsets under one common projection.)
const winMap = (SW) => (n) => {
  const w = AB.uv4(n);
  return [w[0] - SW * w[2], w[1] - SW * w[3]];
};

function sceneSilver(rho) {
  const k = 1 / (1 + SQ2 * rho);
  const L0 = planeMap(rho);
  const L = (n) => csc(L0(n), k);
  const wp = winMap(rho / SQ2);
  const OFP = AB.OCT_F.map(wp);   // window of the base tiling
  const OBP = AB.OCT_B.map(wp);   // window of the inflated tiling: the subwindow
  const classes = new Array(12).fill(null);
  for (const P of AB.PANELS) {
    const proto = P.proto.poly.map(L);
    const protoMark = P.proto.mark ? L(P.proto.mark) : null;
    const drawn = P.pieces.filter((pc) => pc.owned);
    // The window region of this class: the part of the subwindow whose points
    // admit this supertile (its own corners, against subwindow translates) with
    // exactly these children (their corners, against window translates).
    let R = OBP;
    for (const r of P.relInf) {
      const t = wp(r);
      R = convexClip(R, OBP.map((p) => [p[0] - t[0], p[1] - t[1]]));
    }
    for (const o of P.clipOffs) {
      const t = wp(o);
      R = convexClip(R, OFP.map((p) => [p[0] - t[0], p[1] - t[1]]));
    }
    classes[slotOf(proto, protoMark)] = {
      proto,
      protoDot: protoMark ? dotAt(proto, protoMark) : null,
      colour: colourOf(proto),
      outline: P.outline.map(L),
      kids: drawn.map((pc) => {
        const poly = pc.poly.map(L);
        return {
          poly,
          colour: colourOf(poly),
          dot: pc.mark ? dotAt(poly, L(pc.mark)) : null,
          copyOff: wp(pc.off),
        };
      }),
      cell: dedupe(R),
      cellOff: [0, 0],
    };
  }
  return { k, classes, outer: OFP, inner: OBP };
}

// ── The parabolic shear regime, rho < 1/sqrt2 ────────────────────────────────
// shearSubstitution enumerates its rule afresh at every ratio and hands back
// the whole scene; only the framing changes here. Its panels lead with the
// SUPERTILE, this figure with the base tile of the same type at the current
// ratio (the tile whose image the rule describes), so that the left column is
// the same object on both sides of the wall.
function sceneShear(rho) {
  const SC = SH.scene(Math.min(rho, SH.RHO_MAX));
  const z = SC.S.z;
  const classes = new Array(12).fill(null);
  for (const cl of SC.classes) {
    if (!cl) continue;
    const [i, j] = SH.PAIRS[cl.pi];
    const proto = [[0, 0], z[i], cadd(z[i], z[j]), z[j]].map((p) => csc(p, SC.k));
    const protoMark = cl.mark ? cornerInDir(proto, cl.mark) : null;
    classes[slotOf(proto, protoMark)] = {
      proto,
      protoDot: protoMark ? dotAt(proto, protoMark) : null,
      colour: colourOf(proto),
      outline: SC.quads[cl.pi],
      kids: cl.kids.map((e, ki) => {
        const poly = SC.childQuad(e);
        const mk = cl.kidMark[ki];
        return {
          poly,
          colour: colourOf(poly),
          dot: mk ? dotAt(poly, cornerInDir(poly, mk)) : null,
          copyOff: SC.av(e),
        };
      }),
      // shearSubstitution also carries the base tiles the supertile cuts
      // through but does not own; only owned children are drawn here, as in the
      // inflation panels, so the two regimes read alike.
      cell: cl.cell,
      cellOff: cl.off2,
    };
  }
  return { k: SC.k, classes, outer: SC.W, inner: SC.W2 };
}

// The wall is degenerate for BOTH rules, and for the same reason: the internal
// axis generator of either image lattice is 1 - sqrt2 rho, which vanishes at
// rho = 1/sqrt2. There each subwindow has collapsed to the same bare diagonal
// square of area 1/2 -- the shear's rule breaks down past it, and on the silver
// side the four rhomb classes' cells have shrunk to nothing. Both regimes are
// held a slider half-step clear of it (shearSubstitution clamps itself from
// below; the same guard applies here from above), which keeps the clipping away
// from the collapse without any visible loss of range.
const WALL_GUARD = 1e-3;
const scene = (rho) => (rho >= WALL
  ? sceneSilver(Math.max(rho, WALL + WALL_GUARD))
  : sceneShear(rho));

// ── Fixed layout (figure coordinates, y up) ──────────────────────────────────
// One slot per class, sized on the maximum extents over the WHOLE band, both
// regimes together, so nothing jumps as the ratio moves or as the rule swaps.
const COLS = [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]];
const LAYOUT = (() => {
  // The shear side costs a lattice sweep per sample, so it is sampled coarsely;
  // extents vary smoothly and are dominated by the band ends.
  const rhos = [RHO_MIN, 0.2, 0.35, 0.5, 0.62, 0.7,
    WALL + 1e-3, 0.75, 0.8, 0.85, 0.9, 0.95, RHO_MAX];
  const ext = Array.from({ length: 12 }, () => ({ pw: 0, ph: 0, dw: 0, dh: 0 }));
  let winR = 0;
  for (const r of rhos) {
    const SC = scene(r);
    for (const p of SC.outer) winR = Math.max(winR, Math.hypot(p[0], p[1]));
    SC.classes.forEach((cl, gi) => {
      if (!cl) return;
      const pb = bboxOf(cl.proto);
      ext[gi].pw = Math.max(ext[gi].pw, pb.w);
      ext[gi].ph = Math.max(ext[gi].ph, pb.h);
      // The image outline stays centred in its slot and the children poke out
      // around it, so the slot is sized symmetrically about that centre.
      const ob = bboxOf(cl.outline);
      const db = bboxOf(cl.outline.concat(...cl.kids.map((kd) => kd.poly)));
      ext[gi].dw = Math.max(ext[gi].dw, 2 * Math.max(ob.cx - db.x0, db.x1 - ob.cx));
      ext[gi].dh = Math.max(ext[gi].dh, 2 * Math.max(ob.cy - db.y0, db.y1 - ob.cy));
    });
  }
  const gap = 0.28, arrowLen = 0.5, rowGap = 0.42, colGap = 0.9;
  const protoSlot = COLS.map((c) => Math.max(...c.map((gi) => ext[gi].pw)));
  const decompSlot = COLS.map((c) => Math.max(...c.map((gi) => ext[gi].dw)));
  const rowH = [0, 1, 2, 3].map((r) =>
    Math.max(...COLS.map((c) => Math.max(ext[c[r]].ph, ext[c[r]].dh))));
  const slots = new Array(12);
  let colX = 0;
  COLS.forEach((col, kk) => {
    const ax1 = colX + protoSlot[kk] + gap;
    const ax2 = ax1 + arrowLen;
    let rowY = 0;
    col.forEach((gi, r) => {
      const cy = rowY - rowH[r] / 2;
      slots[gi] = {
        protoC: [colX + protoSlot[kk] / 2, cy],
        decompC: [ax2 + gap + decompSlot[kk] / 2, cy],
        ax1, ax2, ay: cy,
        halfH: Math.max(ext[gi].ph, ext[gi].dh) / 2 + 0.45 * rowGap,
        x0: colX - 0.2, x1: ax2 + gap + decompSlot[kk] + 0.2,
      };
      rowY -= rowH[r] + rowGap;
    });
    colX += protoSlot[kk] + gap + arrowLen + gap + decompSlot[kk] + colGap;
  });
  const gridW = colX - colGap;
  const gridH = rowH.reduce((a, b) => a + b, 0) + 3 * rowGap;
  // One window on the right: the base window with the image window nested
  // centrally, both centred zonogons about 0 in one internal plane at one
  // fixed scale, so the octagon really does grow with the ratio.
  const winSlotR = 0.4 * gridH;
  const WSC = winSlotR / winR;
  const winX = gridW + 0.7 + winSlotR;
  const wY = -0.5 * gridH;
  const bounds = { x0: 0, y0: -gridH - 0.02 * gridH, x1: winX + winSlotR, y1: 0 };
  return { slots, gridW, gridH, WSC, winX, wY, winSlotR, bounds };
})();

// ── Resident CindyJS scene serialization ─────────────────────────────────────
const round6 = (x) => Math.round(x * 1e6) / 1e6;
const ser = (v) => JSON.stringify(v, (_k, val) => (typeof val === 'number' ? round6(val) : val));

const UCPTS = Array.from({ length: 20 }, (_, k) =>
  [Math.cos((k * Math.PI) / 10), Math.sin((k * Math.PI) / 10)]);

// Static arrows between the panel columns.
const ARROWS = LAYOUT.slots.map(({ ax1, ax2, ay }) => {
  const hl = 0.2, hw = 0.11, th = 0.038;
  return [
    [[ax1, ay - th], [ax2 - hl, ay - th], [ax2 - hl, ay + th], [ax1, ay + th]],
    [[ax2, ay], [ax2 - hl, ay + hw], [ax2 - hl, ay - hw]],
  ];
});

// Per-ratio push: panel polygons in figure coordinates, window data in internal
// coordinates.
// CLS entry (one per class, [] when a class is absent):
//   1 protoPolyFig      2 imageOutlineFig
//   3 kids([quadFig, colourIdx, copyOffInt, dot([centreFig, rFig] or [])])
//   4 hitboxFig         5 cellInt        6 cellOffInt
//   7 protoDot([centreFig, rFig] or [])  8 ownColourIdx
// Dots are drawn on the prototile and on marked children, never on the image
// outline, so a dot always belongs to the tile it sits in.
// WIN: [subwindowInt, windowInt]
function pushString(SC) {
  const cls = SC.classes.map((cl, gi) => {
    if (!cl) return [];
    const slot = LAYOUT.slots[gi];
    const pb = bboxOf(cl.proto), ob = bboxOf(cl.outline);
    const atP = (p) => [slot.protoC[0] + p[0] - pb.cx, slot.protoC[1] + p[1] - pb.cy];
    const atD = (p) => [slot.decompC[0] + p[0] - ob.cx, slot.decompC[1] + p[1] - ob.cy];
    const mapDot = (d, at) => (d ? [at(d[0]), d[1]] : []);
    const hit = [[slot.x0, slot.ay - slot.halfH], [slot.x1, slot.ay - slot.halfH],
      [slot.x1, slot.ay + slot.halfH], [slot.x0, slot.ay + slot.halfH]];
    return [
      cl.proto.map(atP),
      cl.outline.map(atD),
      cl.kids.map((kd) => [kd.poly.map(atD), kd.colour, kd.copyOff, mapDot(kd.dot, atD)]),
      hit, cl.cell, cl.cellOff, mapDot(cl.protoDot, atP), cl.colour,
    ];
  });
  return 'CLS=' + ser(cls) + ';WIN=' + ser([SC.inner, SC.outer]) + ';';
}

// Helper functions living in Cindy. dc dims toward the page background; tcol
// applies hover dimming per class; mp maps figure to view space; wm maps
// internal coordinates into the window slot (window and subwindow share the
// plane, so they share the map); ptin tests a point against a convex polygon of
// either orientation; mrk draws an orientation dot; hitat scans HITL, the
// [kind, class, key, poly] hit targets recorded by the last draw pass, in
// priority order.
const FN_DEFS =
  'dc(c):=c*0.25+PBG*0.75;' +
  'tcol(c,g):=if((HLC>0)&(g!=HLC),dc(c),c);' +
  'mp(p):=[MPX+MSC*p_1,MPY+MSC*p_2];' +
  'wm(p):=mp([WX+WSC2*p_1,WY+WSC2*p_2]);' +
  'ptin(pl,q):=(regional(sg,ok,aa,bb,crr,s2);sg=0;ok=true;' +
  'forall(1..length(pl),ii,(aa=pl_ii;bb=pl_(mod(ii,length(pl))+1);' +
  'crr=(bb_1-aa_1)*(q_2-aa_2)-(bb_2-aa_2)*(q_1-aa_1);' +
  'if(abs(crr)>0.000000000001,(s2=if(crr>0,1,-1);' +
  'if(sg==0,sg=s2,if(s2!=sg,ok=false))))));ok);' +
  'mrk(dd,g):=(regional(cv,rr);cv=dd_1;rr=dd_2;' +
  'fillpoly(apply(UC,u,mp(cv+rr*u)),color->tcol(DOT,g),alpha->1);' +
  'drawpoly(apply(UC,u,mp(cv+rr*u)),color->tcol(INK,g),size->LW*0.5));' +
  'hitat(hx,hy):=(regional(res);res=[-1,-1,-1];' +
  'forall(HITL,h,if(res_1<0,if(ptin(h_4,[hx,hy]),res=[h_1,h_2,h_3])));res);';

// The draw pass. Everything comes from CLS/WIN (pushed when the slider moves)
// and two interaction scalars: HLC, the hovered class (1..12, -1 for none,
// everything else dimming), and HLK, the hovered child within it (-1 for none,
// when the whole class's copies light up instead). Alongside drawing it
// rebuilds HITL, the hit targets, in priority order: panel child tiles,
// subwindow cells, window copies, panel boxes.
const DRAW =
  'HA=[];HB=[];HC=[];HD=[];' +
  // panels: prototile, arrow, owned children, image outline, dots
  'forall(1..NC,g,(cl=CLS_g;if(length(cl)>0,(' +
  'ppv=apply(cl_1,p,mp(p));' +
  'fillpoly(ppv,color->tcol(TC_(cl_8),g),alpha->1);' +
  'drawpoly(ppv,color->tcol(INK,g),size->LW*1.1);' +
  'if(length(cl_7)>0,mrk(cl_7,g));' +
  'forall(ARR_g,ap,fillpoly(apply(ap,p,mp(p)),color->tcol(INK,g),alpha->1));' +
  'forall(1..length(cl_3),ki,(kd=(cl_3)_ki;wp=apply(kd_1,p,mp(p));' +
  'HA=HA++[[1,g,ki,wp]];' +
  'fillpoly(wp,color->if((g==HLC)&(ki==HLK),SEL,tcol(TC_(kd_2),g)),alpha->1);' +
  'drawpoly(wp,color->tcol(INK,g),size->LW*0.9)));' +
  'drawpoly(apply(cl_2,p,mp(p)),color->if(g==HLC,SEL,tcol(INK,g)),size->LW*1.6);' +
  'forall(cl_3,kd,if(length(kd_4)>0,mrk(kd_4,g)));' +
  'HD=HD++[[0,g,-1,apply(cl_4,p,mp(p))]]' +
  '))));' +
  // the window: each class cell translated by each of its children's anchor
  // offsets. These copies tile the whole window: the action of the rule. Each
  // is filled with the colour of the tile it substitutes FROM, so the window
  // reads as the rule's action rather than as an inventory of the base tiles.
  'forall(1..NC,g,(cl=CLS_g;if(length(cl)>0,(' +
  'forall(1..length(cl_3),ki,(kd=(cl_3)_ki;' +
  'cpv=apply(cl_5,p,wm(p+kd_3));' +
  'HC=HC++[[2,g,ki,cpv]];' +
  'fillpoly(cpv,color->if((g==HLC)&((HLK<0)%(ki==HLK)),SEL,tcol(TC_(cl_8),g)),alpha->1);' +
  'drawpoly(cpv,color->tcol(INK,g),alpha->0.6,size->LWW*0.5)))' +
  '))));' +
  // the subwindow: the image window nested centrally, one blue cell per class
  'forall(1..NC,g,(cl=CLS_g;if(length(cl)>0,(' +
  'cpv=apply(cl_5,p,wm(p+cl_6));' +
  'HB=HB++[[3,g,-1,cpv]];' +
  'fillpoly(cpv,color->if(g==HLC,SEL*0.5+TB_(cl_8)*0.5,tcol(TB_(cl_8),g)),alpha->1);' +
  'drawpoly(cpv,color->INK,alpha->0.8,size->LWW*0.6)' +
  '))));' +
  'drawpoly(apply(WIN_1,p,wm(p)),color->INK,size->LWW*1.2);' +
  'drawpoly(apply(WIN_2,p,wm(p)),color->INK,size->LWW*1.8);' +
  'HITL=HA++HB++HC++HD;';

// ── mount contract ───────────────────────────────────────────────────────────
const FIT_M = 2;
const ASPECT = (() => {
  const B = LAYOUT.bounds;
  return 100 / ((100 - 2 * FIT_M) * (B.y1 - B.y0) / (B.x1 - B.x0) + 2 * FIT_M);
})();
let uid = 0;

export async function mount(el, opts = {}) {
  await loadCindy();

  el.style.height = 'auto';
  el.style.aspectRatio = String(ASPECT);
  el.style.position = 'relative';
  el.style.background = '#fff';

  let rho = RHO_DEF;
  let cdy = null;
  let view = { VW: 100, VH: 75 };
  const id = 'octSub_' + (++uid);

  const clampRho = (r) => Math.min(RHO_MAX, Math.max(RHO_MIN, +r));

  function pushScene() {
    if (!cdy) return;
    try { cdy.evokeCS(pushString(scene(rho))); } catch (e) { console.error('octagonalSubstitution push:', e); }
  }

  function makeMount() {
    const div = document.createElement('div');
    div.id = id;
    div.style.width = '100%';
    div.style.height = '100%';
    el.appendChild(div);
    if (opts.poster) return;
    // Pointer flow: view coordinates through evalcs (no repaint unless the
    // interaction state changed). Hovering a panel or a subwindow cell selects
    // the class; hovering one child tile or one window copy selects the pairing
    // inside it. The returned kind styles the cursor.
    div.addEventListener('pointermove', (ev) => {
      if (!cdy) return;
      const r = div.getBoundingClientRect();
      if (!r.width || !r.height) return;
      const vx = ((ev.clientX - r.left) / r.width) * view.VW;
      const vy = (1 - (ev.clientY - r.top) / r.height) * view.VH;
      try {
        const res = cdy.evalcs(
          'hh=hitat(' + vx + ',' + vy + ');ch=0;' +
          'if(hh_1>=0,(' +
          'if(HLC!=hh_2,(HLC=hh_2;ch=1));' +
          'if(HLK!=hh_3,(HLK=hh_3;ch=1))' +
          '),(' +
          'if(HLC>0,(HLC=-1;ch=1));' +
          'if(HLK>=0,(HLK=-1;ch=1))' +
          '));' +
          'if(ch>0,repaint());hh_1');
        const kind = res && res.value && typeof res.value.real === 'number' ? res.value.real : -1;
        div.style.cursor = kind >= 0 ? 'pointer' : '';
      } catch (e) { console.error('octagonalSubstitution hover:', e); }
    });
    div.addEventListener('pointerleave', () => {
      if (!cdy) return;
      div.style.cursor = '';
      try {
        cdy.evalcs('if((HLC>0)%(HLK>=0),(HLC=-1;HLK=-1;repaint()));');
      } catch (e) { console.error('octagonalSubstitution hover:', e); }
    });
  }

  function sizeNow() {
    const r = el.getBoundingClientRect();
    return [Math.max(1, Math.round(r.width)), Math.max(1, Math.round(r.height))];
  }

  function create() {
    const [W, H] = sizeNow();
    makeMount();
    view = { VW: 100, VH: (100 * H) / W };
    const B = LAYOUT.bounds;
    const bw = B.x1 - B.x0, bh = B.y1 - B.y0, m = FIT_M;
    const sc = Math.min((view.VW - 2 * m) / bw, (view.VH - 2 * m) / bh);
    const exX = (view.VW - 2 * m) - bw * sc, exY = (view.VH - 2 * m) - bh * sc;
    const pxPerView = W / view.VW; // stroke widths are in px
    const DATA =
      'PBG=' + ser(pageBg) + ';SEL=' + ser(selCol) + ';INK=' + ser(ink) + ';' +
      'DOT=' + ser(dotFill) + ';' +
      'TC=' + ser(TYPECOL) + ';TB=' + ser(TYPEBLUE) + ';UC=' + ser(UCPTS) + ';' +
      'ARR=' + ser(ARROWS) + ';NC=12;' +
      'WX=' + round6(LAYOUT.winX) + ';WY=' + round6(LAYOUT.wY) +
      ';WSC2=' + round6(LAYOUT.WSC) + ';' +
      'MPX=' + (m + exX / 2 - B.x0 * sc) + ';MPY=' + (m + exY / 2 - B.y0 * sc) +
      ';MSC=' + sc + ';' +
      'LW=' + round6(Math.max(0.9, 0.02 * sc * pxPerView)) + ';' +
      'LWW=' + round6(Math.max(0.8, 0.016 * sc * pxPerView)) + ';' +
      'HLC=-1;HLK=-1;HITL=[];' +
      FN_DEFS +
      pushString(scene(rho));
    cdy = window.CindyJS({
      scripts: { init: DATA, draw: DRAW },
      ports: [{
        id,
        width: W,
        height: H,
        transform: [{ visibleRect: [0, 0, view.VW, view.VH] }],
      }],
    });
    // CindyJS defers its setup to window.onload; we mount lazily, so kick
    // startup ourselves (guarded: startup before load would double-init).
    if (cdy && typeof cdy.startup === 'function' && document.readyState !== 'loading') {
      try { cdy.startup(); } catch (e) { console.error('octagonalSubstitution startup:', e); }
    }
  }

  create();

  // Recreate on resize (CindyJS ports are fixed-size); debounced, skip no-ops.
  let [lastW, lastH] = sizeNow();
  let rt = null;
  const ro = new ResizeObserver(() => {
    clearTimeout(rt);
    rt = setTimeout(() => {
      const [W, H] = sizeNow();
      if (W === lastW && H === lastH) return;
      lastW = W; lastH = H;
      try { cdy && cdy.shutdown && cdy.shutdown(); } catch (e) { /* ignore */ }
      el.innerHTML = '';
      create();
    }, 180);
  });
  ro.observe(el);

  return {
    setUniform(name, value) {
      if (name === 'uRho') { rho = clampRho(value); pushScene(); }
    },
    getUniform(name) { return name === 'uRho' ? rho : undefined; },
    pause() { /* static image: nothing to pause */ },
    resume() { },
    reset() {
      rho = RHO_DEF;
      if (cdy) {
        try { cdy.evokeCS('HLC=-1;HLK=-1;'); } catch (e) { /* ignore */ }
      }
      pushScene();
    },
    get isPaused() { return false; },
    destroy() {
      try { ro.disconnect(); } catch (e) { /* ignore */ }
      clearTimeout(rt);
      try { cdy && cdy.shutdown && cdy.shutdown(); } catch (e) { /* ignore */ }
      el.innerHTML = '';
    },
  };
}

// Pure internals exposed for the regression tests; the mount contract above is
// the only consumer-facing export.
export const _test = { scene, sceneSilver, sceneShear, pushString, slotOf, colourOf,
  LAYOUT, WALL, RHO_MIN, RHO_MAX, RHO_DEF, DRAW, FN_DEFS };