Hue.Color.Gamut (Hue v0.2.0)

Copy Markdown View Source

The triangle of colours a particular light can actually produce.

Hue reports gamut per light as three CIE xy primaries, and they differ by model — a single bridge commonly hosts several (the reference bridge behind Hue.Fixtures has all three published types: A, B, and C). A colour outside a light's triangle is not merely approximate, it is unrepresentable, so it is projected onto the nearest point the light can actually reach.

This is the part of colour handling the color dependency cannot do: its Color.Gamut maps between named working spaces (:SRGB, :P3, …), never three arbitrary xy primaries, and these triangles are per-device.

The published fallback triangles

@standard/1's constants are the three triangles Philips publishes for gamut types A, B, and C. They are also, byte for byte (modulo trailing zeros), what the reference bridge behind Hue.Fixtures reports for real lights of each type — see the standard/1 doctest-adjacent test "matches the gamut reported by real lights of each type on the reference bridge" in Hue.Color.GamutTest, which pins that comparison as a regression.

Floating point at the boundary

clamp/2 projects an exterior point onto the nearest triangle edge. That projection is exact in real-number arithmetic, but IEEE 754 rounding in the multiply/divide chain (closest_on_segment/2) can land the result a few ULPs to either side of the edge it was projected onto. contains?/2 would then read that hair-outside point as outside the very triangle it was just clamped into.

@boundary_epsilon absorbs that: a cross-product magnitude smaller than it is treated as zero — "on the boundary" — rather than as a definite sign. Its value, 1.0e-9, is chosen from the two things that bound it, not picked to make a test pass:

  • Above the noise floor. Every cross product here is a handful of additions/subtractions/multiplications of doubles whose magnitude stays within a small constant factor of 1 (CIE xy coordinates and their differences are all in [-1, 1]). Double rounding error per operation is bounded by the machine epsilon, ~2.22e-16; accumulated over the few operations in cross/3 and closest_on_segment/2, the residual stays many orders of magnitude below 1.0e-9.
  • Below any real colour difference. The tightest gap between two distinct primaries recorded on the reference bridge is on the order of 1.0e-31.0e-4 (e.g. gamut B and C's red differ at the third decimal place). 1.0e-9 is nine orders of magnitude below that, so it can never mask a genuine outside point.

Summary

Types

What from_light/1 hands back for a light that has a "color" key but data this module cannot turn into a triangle: a "gamut" object missing a primary or carrying a non-numeric coordinate, or a "gamut_type" this module does not recognise (Hue defines A, B, and C; a future bridge reporting something else is not a caller bug — see standard/1). Distinct from nil, which means the light has no colour capability at all.

t()

Functions

point, or the nearest point on gamut's boundary if point is outside.

Whether point lies inside (or on the boundary of) gamut.

The gamut a light reports for itself.

The published triangle for one of Hue's gamut types, "A", "B", or "C".

Types

from_light_error()

@type from_light_error() :: {:error, :invalid_gamut}

What from_light/1 hands back for a light that has a "color" key but data this module cannot turn into a triangle: a "gamut" object missing a primary or carrying a non-numeric coordinate, or a "gamut_type" this module does not recognise (Hue defines A, B, and C; a future bridge reporting something else is not a caller bug — see standard/1). Distinct from nil, which means the light has no colour capability at all.

point()

@type point() :: {float(), float()}

t()

@type t() :: %{red: point(), green: point(), blue: point()}

Functions

clamp(gamut, point)

@spec clamp(t(), point()) :: point()

point, or the nearest point on gamut's boundary if point is outside.

An interior (or boundary) point is returned unchanged. An exterior point is projected onto each of the three edges in turn and the closest of the three projections is kept.

contains?(map, point)

@spec contains?(t(), point()) :: boolean()

Whether point lies inside (or on the boundary of) gamut.

Uses the standard same-side cross-product test against all three edges, with @boundary_epsilon slack so a point that floating-point rounding put a hair outside an edge it is mathematically on still reads as inside — see the moduledoc.

A degenerate, zero-area gamut behaves in two different ways depending on which primaries coincide, and neither is "the triangle's interior" — there is no interior to test:

  • If all three primaries are the same point, all three cross products collapse to (0, 0) terms and are exactly zero for every point, so this returns true unconditionally.
  • If only two coincide (or all three are merely collinear, no two equal), the three primaries still describe a single infinite line — two of the three "edges" run along it and the third is the zero-length one between the coincident pair (or none, if none coincide). The test then answers whether point lies on that infinite line, not whether it lies between the primaries: a point on the line but far outside the segment they span still reads as true, and any point off the line reads false.

Both are an honest consequence of the algorithm, not a special case handled here: a sign test has nothing to test against on a shape with no area. No real Hue bridge reports a degenerate gamut (every gamut on the reference bridge is a proper triangle).

from_light(arg1)

@spec from_light(map()) :: t() | nil | from_light_error()

The gamut a light reports for itself.

Three outcomes:

  • The light has an explicit color.gamut — its own three primaries are returned, provided all six coordinates are present and numeric.
  • The light has color.gamut_type but no color.gamut — the matching standard/1 triangle is returned, unwrapped, exactly as standard/1 returns it.
  • The light has no "color" key at all — nil. It has no colour capability; this is expected and common (two lights on the reference bridge are dimmable-only).

Anything else under "color" — a malformed gamut object, a gamut_type this module does not recognise, or a "color" value that is not even a map — is {:error, :invalid_gamut}, never a crash and never conflated with the "no colour support" nil case. See from_light_error/0.

standard(type)

@spec standard(String.t()) :: t()

The published triangle for one of Hue's gamut types, "A", "B", or "C".

Raises for anything else. That is a caller bug, not a bridge-data problem: the three gamut types are a closed, documented set, and every call site in this library reaches this function only after checking the type is one of them (see from_light/1). A bridge reporting a type outside that set is a different situation — handled there, not here, as {:error, :invalid_gamut} rather than a crash.