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 incross/3andclosest_on_segment/2, the residual stays many orders of magnitude below1.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-3–1.0e-4(e.g. gamut B and C's red differ at the third decimal place).1.0e-9is 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.
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
@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.
Functions
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.
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 everypoint, so this returnstrueunconditionally. - 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
pointlies 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 astrue, and any point off the line readsfalse.
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).
@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_typebut nocolor.gamut— the matchingstandard/1triangle is returned, unwrapped, exactly asstandard/1returns 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.
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.