buildClaymorphismCandidate function

ThemeSpec buildClaymorphismCandidate(
  1. int seed,
  2. FamilyDefinition def,
  3. int engineVersion, {
  4. int attempt = 0,
  5. bool accessible = false,
  6. Steering? steering,
})

Builds one unvalidated Claymorphism candidate (the generator repairs and validates it, retries with palette/retry/n and finally falls back to a Claymorphism hero).

Implementation

ThemeSpec buildClaymorphismCandidate(
  int seed,
  FamilyDefinition def,
  int engineVersion, {
  int attempt = 0,
  bool accessible = false,
  Steering? steering,
}) {
  final lim = accessible
      ? GuardrailLimits.accessible
      : GuardrailLimits.standard;
  final policy = lim.labelPolicy;
  final bile = def.bile;

  // Style first: the label solve needs the shade depth and the highlight.
  final st = Rng.stream(seed, RngTag.style);
  final styleKind = pickWeightedValue(st, def.styles);
  final blurCdp = clayShadowBlurCdp.sampleStep(st, 200);
  final shadePermille = clayInnerShadePermille.sampleStep(st, 10);
  final highlightAlpha = clayHighlightAlpha.sampleStep(st, 10);
  final highlight = withAlpha(0xFFFFFFFF, highlightAlpha);

  // Palette.
  final paletteTag = attempt == 0
      ? RngTag.palette
      : RngTag.sub(RngTag.palette, 'retry', attempt);
  final p = Rng.stream(seed, paletteTag);
  final drawnDark = p.pickWeighted(def.brightnessWeights) == 0;
  final steeredBrightness = steering?.brightnessFor(def);
  final dark = steeredBrightness == null
      ? drawnDark
      : steeredBrightness == ThemeBrightness.dark;
  final brightness = dark ? ThemeBrightness.dark : ThemeBrightness.light;
  final ranges = def.ranges(brightness);
  final variant = dark
      ? ClayVariant.night
      : ClayVariant.values[p.pickWeighted(clayLightVariantWeights)];
  final tonal = variant == ClayVariant.tonal;
  final anchors = dark ? clayNightBodyHues : clayLightBodyHues;
  final drawnAnchor = p.pickWeighted([for (final a in anchors) a.weight]);
  final drawnBodyHue = _wrap(anchors[drawnAnchor].value.sample(p));
  // Steering (engine v4): the body stays on one of the family's anchors —
  // the one nearest the intent's base hue, inside their overlap. A base
  // hue far from every anchor colours the operators instead. Steered hues
  // come from their own sub-stream.
  final ir = steering == null ? null : Rng.stream(seed, intentTag(paletteTag));
  final baseDraw = ir?.nextU32();
  var anchor = drawnAnchor;
  var bodyHue = drawnBodyHue;
  int? baseAsAccent;
  final target = steering?.baseRange;
  if (steering != null && target != null) {
    final m = clayAnchorFor(anchors, target.lo, target.span);
    anchor = m.index;
    final a = anchors[anchor].value;
    if (m.overlapSpan != null) {
      bodyHue = _wrap(steering.pickIn(m.overlapLo!, m.overlapSpan!, baseDraw!));
    } else {
      bodyHue = _wrap(steering.pickIn(a.lo, a.hi - a.lo, baseDraw!));
      if (m.distance > clayAccentFallbackTenths) {
        baseAsAccent = steering.pickIn(target.lo, target.span, baseDraw);
      }
    }
  }
  final steeredOpHue =
      (ir == null ? null : steering!.accentHue(ir, 0)) ?? baseAsAccent;
  final steeredEqHue = ir == null ? null : steering!.accentHue(ir, 1);
  final butter = !dark && anchor == clayButterAnchor;
  final warmBody = !dark && (anchor == 1 || butter);
  var baseLMilli = ranges.background.lMilli.sample(p);
  if (butter) {
    final r = ranges.background.lMilli;
    baseLMilli = _spread(clayButterLMilli, baseLMilli - r.lo, r.hi - r.lo);
  }
  if (dark && accessible) {
    final lo = ranges.background.lMilli.lo;
    final span = ranges.background.lMilli.hi - lo;
    const target = clayAccessibleNightBodyLMilli;
    baseLMilli =
        target.lo + (baseLMilli - lo) * (target.hi - target.lo) ~/ span;
  }
  final baseRel = p.nextIntIn(
    ranges.background.chroma.lo,
    ranges.background.chroma.hi,
  );
  final scheme = pickWeightedValue(
    p,
    tonal ? clayTonalSchemes : clayCandySchemes,
  );
  final jOp = tonal ? clayTonalJitter : clayOpJitter;
  final jFn = tonal ? clayTonalJitter : clayFnJitter;
  final jEq = tonal ? clayTonalJitter : clayEqJitter;
  final drawnOpHue = _wrap(bodyHue + scheme.$1 + p.nextJitterTenths(jOp));
  final rawFnHue = _wrap(bodyHue + scheme.$2 + p.nextJitterTenths(jFn));
  final drawnEqHue = _wrap(bodyHue + scheme.$3 + p.nextJitterTenths(jEq));
  final rawOpHue = steeredOpHue ?? drawnOpHue;
  final rawEqHue = steeredEqHue ?? drawnEqHue;
  final opSign = scheme.$1 >= 0 ? 1 : -1;
  final srcA = tonal ? _wrap(bodyHue + 300 * opSign) : rawOpHue;
  final srcB = tonal ? _wrap(bodyHue - 300 * opSign) : rawFnHue;
  final rawBlobAHue = _wrap(srcA + p.nextJitterTenths(claySphereJitter));
  final rawBlobBHue = _wrap(srcB + p.nextJitterTenths(claySphereJitter));
  int blobL() => dark
      ? clayNightBlobLMilli.sample(p)
      : baseLMilli + clayLightBlobLMilli.sample(p);
  int blobC() => dark
      ? clayNightBlobChromaRel.sample(p)
      : clayLightBlobChromaMilli.sample(p);
  final blobALMilli = blobL();
  final blobAC = blobC();
  final blobBLMilli = blobL();
  final blobBC = blobC();
  final cream = dark ? p.nextChance(1, 2) : p.nextChance(2, 3);
  final digitHue = cream
      ? _wrap(
          clayCreamHueTenths.sample(p) +
              (warmBody ? clayCoolMilkShiftTenths : 0),
        )
      : _wrap(bodyHue + p.nextJitterTenths(clayDigitHueJitter));
  var digitLMilli = ranges.digit.lMilli.sample(p);
  final digitCMilli = p.nextIntIn(
    ranges.digit.chroma.lo,
    ranges.digit.chroma.hi,
  );
  var fnLMilli = ranges.function.lMilli.sample(p);
  final fnRel = p.nextIntIn(
    ranges.function.chroma.lo,
    ranges.function.chroma.hi,
  );
  var opLMilli = ranges.operator.lMilli.sample(p);
  final opRel = p.nextIntIn(
    ranges.operator.chroma.lo,
    ranges.operator.chroma.hi,
  );
  var eqLMilli = ranges.equals.lMilli.sample(p);
  final eqRel = p.nextIntIn(ranges.equals.chroma.lo, ranges.equals.chroma.hi);
  // Retired draws (the tray pick and the old pillow chroma): kept so the
  // label chroma below keeps its place in the stream.
  p
    ..pickWeighted(clayPanelWeights)
    ..nextIntIn(ranges.surfaceChroma.lo, ranges.surfaceChroma.hi);
  final labelCMilli = p.nextIntIn(25, ranges.labelMaxChromaMilli);

  // ---- derived (no draws) ----
  // Candy and night hues leave the band on their own nearer side; tonal
  // ones on the side of the body their scheme put them (the operator and
  // sphere A on one side, function keys, `=` and sphere B on the other),
  // so the moved operator and function hues never meet.
  final pivotA = tonal ? srcA : null;
  final pivotB = tonal ? srcB : null;
  int candy(int h, int? pivot) => clayCandyHue(
    h,
    clayCandyKeyBand,
    clayCandyKeyTargets,
    pivot ?? h,
    maxPush: clayCandyKeyPush,
  );
  int candyOp(int h) {
    final c = candy(h, pivotA);
    return dark ? clayNightOperatorHue(c) : c;
  }

  int candyFn(int h) => candy(h, pivotB);
  final opHue = candyOp(rawOpHue);
  var fnHue = candyFn(rawFnHue);

  // Body.
  final baseL = baseLMilli / 1000;
  final int bodyHueFinal;
  final double baseC;
  if (dark) {
    final c = _min(
      baseRel / 1000 * maxChroma(baseL, bodyHue / 10),
      ranges.background.chroma.capMilli / 1000,
    );
    baseC = _max(c, clayNightBodyChromaFloorMilli / 1000);
    bodyHueFinal = bile.apply(bodyHue, baseL, baseC);
  } else if (butter) {
    final r = ranges.background.chroma;
    baseC = _min(
      _spread(clayButterChromaMilli, baseRel - r.lo, r.hi - r.lo) / 1000,
      maxChroma(baseL, bodyHue / 10),
    );
    bodyHueFinal = bodyHue;
  } else {
    final c = _min(
      baseRel / 1000 * maxChroma(baseL, bodyHue / 10),
      ranges.background.chroma.capMilli / 1000,
    );
    baseC = _max(c, clayBodyChromaFloorMilli / 1000);
    bodyHueFinal = bodyHue;
  }
  final base = oklchToArgb(baseL, baseC, bodyHueFinal / 10);

  // `=`: the candy band, the dull band, grape beside a green operator.
  int candyEq(int h) => clayEqualsHue(
    h,
    opHue,
    pivot: pivotB,
    nightBodyHue: dark ? bodyHueFinal : null,
  );

  var eqHue = candyEq(rawEqHue);
  final topWash = dark
      ? oklchToArgb(
          baseL + clayTopWashNightMilli / 1000,
          baseC,
          bodyHueFinal / 10,
        )
      : oklchToArgb(
          baseL + clayTopWashLightMilli / 1000,
          baseC * 0.8,
          bodyHueFinal / 10,
        );

  // Spheres: (lit, body, shade, hue). Night spheres leave the warm and
  // green-teal bands (sphere B also keeps clear of sphere A).
  (int, int, int, int) sphere(
    int rawHue,
    int? pivot,
    int lMilli,
    int cDraw, {
    int? other,
  }) {
    var hue = candy(rawHue, pivot);
    if (dark) hue = clayNightSphereHue(hue, bodyHueFinal, other: other);
    final l = lMilli / 1000;
    double c;
    if (dark) {
      double night(int h) {
        final max = maxChroma(l, h / 10);
        return _max(
          _min(cDraw / 1000 * max, clayNightBlobChromaCapMilli / 1000),
          _min(clayNightBlobChromaFloorMilli / 1000, 0.9 * max),
        );
      }

      c = night(hue);
      final rotated = bile.apply(hue, l, c);
      if (rotated != hue) {
        hue = rotated;
        c = _min(c, maxChroma(l, hue / 10));
      }
    } else {
      final max = maxChroma(l, hue / 10);
      c = _min(cDraw / 1000, _min(0.12, max));
      if (clayLightSphereWarmBand.contains(hue)) {
        c = _max(c, _min(clayLightSphereWarmChromaMilli / 1000, 0.9 * max));
      }
    }
    final h = hue / 10;
    final body = oklchToArgb(l, c, h);
    final int lit;
    final int shade;
    if (dark) {
      lit = oklchToArgb(l + 0.070, c * 0.9, h);
      shade = oklchToArgb(_max(0.12, l - 0.070), c * 0.9, h);
    } else {
      lit = oklchToArgb(_min(0.975, l + 0.050), c * 0.75, h);
      shade = oklchToArgb(l - 0.075, _min(c * 1.10, 0.12), h);
    }
    return (lit, body, shade, hue);
  }

  final (aLit, aBody, aShade, aHue) = sphere(
    rawBlobAHue,
    pivotA,
    blobALMilli,
    blobAC,
  );
  final (bLit, bBody, bShade, _) = sphere(
    rawBlobBHue,
    pivotB,
    blobBLMilli,
    blobBC,
    other: aHue,
  );
  final shadowCore = dark
      ? oklchToArgb(_max(0.10, baseL - 0.080), baseC * 0.8, bodyHueFinal / 10)
      : oklchToArgb(baseL - 0.085, _min(baseC + 0.02, 0.08), bodyHueFinal / 10);
  final colors = List<int>.unmodifiable([
    base,
    topWash,
    aLit,
    aBody,
    aShade,
    bLit,
    bBody,
    bShade,
    shadowCore,
  ]);
  final b = Rng.stream(seed, RngTag.background);
  final background = BackgroundSpec(
    kind: pickWeightedValue(b, def.backgrounds),
    colors: colors,
    angleTenths: 150 * b.nextInt(6),
  );

  // Surface: the pillow card, 0.06 L above the body (a pale tint of it
  // in light themes, the body's own chroma at night).
  final surface = dark
      ? oklchToArgb(baseL + 0.060, baseC, bodyHueFinal / 10)
      : oklchToArgb(
          _min(clayPillowMaxLMilli / 1000, baseL + 0.060),
          _clamp(
            clayPillowChromaRel / 1000 * baseC,
            clayPillowChromaMilli.lo / 1000,
            clayPillowChromaMilli.hi / 1000,
          ),
          bodyHueFinal / 10,
        );

  // Faces.
  if (!dark) {
    final floor = baseLMilli + clayDigitAboveBodyMilli;
    if (digitLMilli < floor) digitLMilli = floor;
    if (digitLMilli > clayDigitMaxLMilli) digitLMilli = clayDigitMaxLMilli;
    final cap = baseLMilli - clayFunctionBelowBodyMilli;
    if (fnLMilli > cap) fnLMilli = cap;
  }
  if (accessible && opLMilli < clayAccessibleOperatorMinLMilli) {
    opLMilli = clayAccessibleOperatorMinLMilli;
  }
  if (accessible) {
    final r = ranges.equals.lMilli;
    const t = clayAccessibleEqualsLMilli;
    eqLMilli = t.lo + (eqLMilli - r.lo) * (t.hi - t.lo) ~/ (r.hi - r.lo);
  }

  int digitFace() =>
      oklchToArgb(digitLMilli / 1000, digitCMilli / 1000, digitHue / 10);
  int accentFace(
    int hue,
    int lMilli,
    int rel,
    ChromaRange range, {
    bool warmFloor = false,
    bool equalsFloor = false,
  }) {
    final l = lMilli / 1000;
    final cap = range.capMilli / 1000;
    double chroma(int h) {
      final max = maxChroma(l, h / 10);
      var c = _min(rel / 1000 * max, cap);
      if (equalsFloor) {
        c = _max(
          c,
          _min(clayEqualsChromaMilli / 1000, clayEqualsChromaRel / 1000 * max),
        );
      }
      if (!warmFloor || !clayWarmFunctionBand.contains(h)) return c;
      return _max(c, _min(clayWarmFunctionChromaMilli / 1000, 0.95 * max));
    }

    var c = chroma(hue);
    final h = bile.apply(hue, l, c);
    if (h != hue) c = chroma(h);
    return oklchToArgb(l, c, h / 10);
  }

  int fnFace() => accentFace(
    fnHue,
    fnLMilli,
    fnRel,
    ranges.function.chroma,
    warmFloor: true,
  );
  int opFace() => accentFace(opHue, opLMilli, opRel, ranges.operator.chroma);
  int eqFace() => accentFace(
    eqHue,
    eqLMilli,
    eqRel,
    ranges.equals.chroma,
    equalsFloor: true,
  );

  var digit = digitFace();
  var function = fnFace();
  var op = opFace();
  var eq = eqFace();

  // 1. Operators away from digits.
  bool opOk() =>
      contrastRatio(op, digit) >= 1.60 && minCvdDeltaE(op, digit) >= 0.10;
  final opFloor = accessible ? clayAccessibleOperatorMinLMilli : 640;
  while (!opOk() && opLMilli - 20 >= opFloor) {
    opLMilli -= 20;
    op = opFace();
  }
  for (
    var i = 0;
    i < 4 && !opOk() && digitLMilli + 15 <= clayDigitMaxLMilli;
    i++
  ) {
    digitLMilli += 15;
    digit = digitFace();
  }
  // 2. `=` away from the operators (a quarter turn either way, else a
  // half turn: the first that clears, or the best), and (normal-mode
  // night) off the body.
  if (minCvdDeltaE(eq, op) < 0.065) {
    final from = eqHue;
    var bestHue = eqHue;
    var best = eq;
    var bestDe = minCvdDeltaE(eq, op);
    for (final turn in const [900, -900, 1800]) {
      eqHue = candyEq(_wrap(from + turn));
      eq = eqFace();
      final de = minCvdDeltaE(eq, op);
      if (de > bestDe) {
        bestHue = eqHue;
        best = eq;
        bestDe = de;
      }
      if (de >= 0.065) break;
    }
    eqHue = bestHue;
    eq = best;
  }
  if (dark && !accessible) {
    while (contrastRatio(eq, base) < clayNightEqualsMinContrast &&
        eqLMilli + 10 <= clayNightEqualsMaxLMilli) {
      eqLMilli += 10;
      eq = eqFace();
    }
  }
  // 3. Light function keys below the body, for every eye (generation
  // backstop): darker 0.015 L at a time while the inner shade still
  // holds a dark label at the floor, then (still too close for a
  // colour-blind eye) a turn of the hue.
  if (!dark) {
    final bodyL = _lightness(base);
    bool fnOk(int f) =>
        deltaEOKArgb(f, base) >= 0.045 &&
        bodyL - _lightness(f) >= 0.03 &&
        minCvdDeltaE(f, base) >= clayFunctionMinCvdDeltaE;
    bool ringOk(int f) =>
        contrastRatio(0xFF000000, clayInnerShade(f, shadePermille)) >=
        lim.labelRatio + 0.2;
    for (var i = 0; i < 6 && !fnOk(function); i++) {
      fnLMilli -= 15;
      final next = fnFace();
      if (!ringOk(next)) {
        fnLMilli += 15;
        break;
      }
      function = next;
    }
    if (!fnOk(function)) {
      final from = fnHue;
      for (final turn in const [300, -300, 600, -600]) {
        fnHue = candyFn(_wrap(from + turn));
        final next = fnFace();
        if (fnOk(next) && ringOk(next)) {
          function = next;
          break;
        }
        fnHue = from;
      }
    }
  }

  // 4. Labels (the function label also on its inner shade), then the
  // candy + bile re-check on the final faces.
  final labelC = labelCMilli / 1000;
  Lch darkStart(int face) =>
      (l: ranges.labelStartLMilli / 1000, c: labelC, h: argbToOklch(face).h);
  const creamStart = (l: 0.985, c: 0.012, h: 85.0);
  KeyColors keyFor(int face, Lch start, {bool ring = false}) {
    final f = argbToOklch(face);
    final pair = repairPair(f, start, policy: policy);
    var label = pair.label;
    if (ring) {
      label =
          solveOnAll(label, [
            pair.face,
            clayInnerShade(pair.face, shadePermille),
          ], lim.labelRatio) ??
          label;
    }
    return KeyColors(face: pair.face, label: label, edge: 0);
  }

  // The final faces' hues are re-checked by the bile rule only: a hue the
  // builder placed on a band edge may drift a tenth into the band when
  // quantised, and must not be folded again for that.
  KeyColors recheck(
    KeyColors k,
    Lch start,
    int Function(int) rule, {
    bool ring = false,
  }) {
    final f = argbToOklch(k.face);
    final hue = _hueTenths(f);
    if (bile.apply(hue, f.l, f.c) == hue) return k;
    final rotated = bile.apply(rule(bile.apply(hue, f.l, f.c)), f.l, f.c);
    return keyFor(oklchToArgb(f.l, f.c, rotated / 10), start, ring: ring);
  }

  final digitKey = keyFor(digit, darkStart(digit));
  var fnKey = keyFor(function, darkStart(function), ring: true);
  var opKey = keyFor(op, darkStart(op));
  var eqKey = keyFor(eq, creamStart);
  if (bile.recheckFinal) {
    fnKey = recheck(fnKey, darkStart(function), candyFn, ring: true);
    opKey = recheck(opKey, darkStart(op), candyOp);
    eqKey = recheck(eqKey, creamStart, candyEq);
  }

  // 5. Texts on the panel; the focus ring on the scene.
  final bgs = claymorphismSceneSamples(background)!;
  final backdrops = [surface];
  int onAll(int start, List<int> on, double target, int fallback) =>
      solveOnAll(start, on, target) ?? fallback;
  final h = bodyHueFinal / 10;
  final opH = opHue / 10;
  final previewTarget = lim.previewRatio < ratioText
      ? ratioText
      : lim.previewRatio;
  final displayText = onAll(
    dark ? oklchToArgb(0.97, 0.015, h) : oklchToArgb(0.27, 0.045, h),
    backdrops,
    lim.displayRatio,
    blackOrWhite(surface),
  );
  final previewText = onAll(
    dark ? oklchToArgb(0.80, 0.030, h) : oklchToArgb(0.44, 0.035, h),
    backdrops,
    previewTarget,
    blackOrWhite(surface),
  );
  final error = onAll(
    dark ? oklchToArgb(0.76, 0.14, 22) : oklchToArgb(0.50, 0.17, 25),
    backdrops,
    lim.displayRatio,
    blackOrWhite(surface),
  );
  final caretL = dark ? 0.80 : 0.52;
  final caret = onAll(
    oklchToArgb(
      caretL,
      _min(0.8 * maxChroma(caretL, opH), dark ? 0.14 : 0.17),
      opH,
    ),
    backdrops,
    lim.caretRatio,
    displayText,
  );
  final edged = _withEdges(
    Palette(
      background: base,
      surface: surface,
      displayText: displayText,
      previewText: previewText,
      error: error,
      caret: caret,
      focusRing: 0,
      accent: caret,
      digit: digitKey,
      operator: opKey,
      equals: eqKey,
      function: fnKey,
      shadow: dark
          ? withAlpha(oklchToArgb(0.08, 0.03, h), 0x80)
          : withAlpha(oklchToArgb(0.32, 0.06, h), 0x40),
      highlight: highlight,
      seedColor: opKey.face,
    ),
    base,
    dark: dark,
  );
  // The ring on the scene and where it crosses a key's own shadow.
  final focusL = dark ? 0.88 : 0.42;
  final focus = onAll(
    oklchToArgb(
      focusL,
      dark ? 0.08 : _min(0.8 * maxChroma(focusL, opH), 0.12),
      opH,
    ),
    [...bgs, ...clayFocusShadowSamples(edged, colors)],
    lim.focusRingRatio,
    blackOrWhite(base),
  );
  final palette = edged.copyWith(focusRing: focus);

  // Motion, shape, style, type, name.
  final motion = def.motionRanges.vary(
    def.motion,
    Rng.stream(seed, RngTag.motion),
  );
  final s = Rng.stream(seed, RngTag.shape);
  final keyShape = KeyShapeSpec(
    kind: pickWeightedValue(s, def.shapes),
    cornerPermille: def.cornerPermille.sampleStep(s, 10),
  );
  final keyStyle = def.keyStyle.copyWith(
    kind: styleKind,
    shadowOffsetCdp: motion.depthCdp,
    shadowBlurCdp: blurCdp,
    edgeShadePermille: shadePermille,
  );
  final type = _type(Rng.stream(seed, RngTag.type), def);
  String generatedName() => generateThemeName(
    rng: Rng.stream(seed, RngTag.name),
    accentArgb: palette.operator.face,
    family: ThemeFamily.claymorphism,
    dark: dark,
  );
  final name = steering?.nameOr(generatedName) ?? generatedName();

  return ThemeSpec(
    seed: seed,
    family: ThemeFamily.claymorphism,
    engineVersion: engineVersion,
    variant: steering == null ? ThemeVariant.generated : ThemeVariant.steered,
    intent: steering?.intent.forSpec,
    intentCentred: steering?.centred ?? false,
    brightness: brightness,
    accessible: accessible,
    palette: palette,
    keyShape: keyShape,
    keyStyle: keyStyle,
    type: type,
    background: background,
    motion: motion,
    sound: def.sound,
    name: name,
    nonColourOperatorCue: def.nonColourOperatorCue,
  );
}