harmony function

List<Hct> harmony(
  1. Hct x,
  2. int n, {
  3. bool balanced = false,
  4. HarmonyTonePolicy tonePolicy = HarmonyTonePolicy.preserveTone,
})

n colors whose chromatic directions are evenly spaced around the white point, starting at x. Because the directions cancel, an additive mixture of the set (at some ratio) is neutral gray: this is the checkable property behind complement (n=2), triad (n=3), and quad (n=4).

By default each color is as vivid as the gamut allows in its direction. With balanced, all are capped at the weakest direction's strength for an even ensemble (equal HctUvCoordinates.uvChroma, not equal HCT chroma; the two differ by hue).

tonePolicy controls L*: preserve tone for theming, reflect tone for the dramatic painterly complement, move tone only as far as necessary to preserve u'v' chroma, or move tone to preserve HCT chroma.

Achromatic inputs return n copies of themselves: no direction to spread.

Implementation

List<Hct> harmony(
  Hct x,
  int n, {
  bool balanced = false,
  HarmonyTonePolicy tonePolicy = HarmonyTonePolicy.preserveTone,
}) {
  assert(n >= 2, 'harmony needs at least 2 colors');
  final p = uvOfArgb(x.toInt());
  if (p == null || x.chroma < 1e-4) return List.filled(n, x);
  final (wu, wv) = whiteUvPoint;
  final offset = (p.$1 - wu, p.$2 - wv);
  final angles = [for (var i = 0; i < n; i++) i * 360.0 / n];
  var shared = 1.0;
  if (balanced) {
    for (final a in angles) {
      final rotated = _rotateOffset(offset, a);
      final targetU = wu + rotated.$1, targetV = wv + rotated.$2;
      final tone = _targetTone(x, a, tonePolicy, targetU, targetV);
      final exit = SliceCache.of(tone).rayExit(rotated.$1, rotated.$2);
      if (exit != null) shared = math.min(shared, exit.$1 * 0.995);
    }
  }
  return [
    for (final a in angles)
      if (a == 0.0 && !balanced)
        x // the seed itself, untouched
      else
        _rotated(x, offset, a, shared, tonePolicy),
  ];
}