Skip to content

Commit 2c40085

Browse files
committed
Add Color.Material and :material_pbr sort strategy for mixed finishes
1 parent 587a000 commit 2c40085

4 files changed

Lines changed: 668 additions & 21 deletions

File tree

lib/color/material.ex

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
defmodule Color.Material do
2+
@moduledoc """
3+
A physically-based-rendering (PBR) material wrapper around a
4+
base colour.
5+
6+
`%Color.SRGB{}` describes a colour stimulus. `%Color.Material{}`
7+
describes a *surface* — a colour plus the appearance parameters
8+
(metallic, roughness, clearcoat) that determine whether the
9+
surface reads as plastic, painted metal, glossy varnish, or
10+
matte ceramic. Two materials can share the same base colour yet
11+
look entirely different because of how light interacts with
12+
them.
13+
14+
The parameter set deliberately matches the Disney Principled
15+
BSDF / glTF 2.0 `pbrMetallicRoughness` convention so values
16+
from those sources can be imported directly.
17+
18+
## Why this exists
19+
20+
When you sort a palette that mixes plastic swatches with
21+
polished-metal swatches, any single-axis colour sort puts
22+
identical base colours next to each other regardless of
23+
finish — a red plastic and a red-anodized aluminium end up
24+
visually adjacent even though users file them into different
25+
mental categories. `Color.Palette.Sort`'s `:material_pbr`
26+
strategy consumes this struct and produces an ordering that
27+
respects the metallic-vs-dielectric cliff first, then colour,
28+
then gloss.
29+
30+
## Parameters
31+
32+
* `:base_color` — a `%Color.SRGB{}` struct. The "albedo" for
33+
dielectrics or the "specular colour" for metals.
34+
35+
* `:metallic` — `0.0`–`1.0`. `0.0` is a pure dielectric
36+
(plastic, paint, wood, skin); `1.0` is a pure conductor
37+
(gold, copper, aluminium). Values in between model partial
38+
metallic coatings such as metallic automotive paint.
39+
40+
* `:roughness` — `0.0`–`1.0`. `0.0` is a perfect mirror;
41+
`1.0` is a fully diffuse (Lambertian) surface.
42+
43+
* `:clearcoat` — `0.0`–`1.0`. Strength of an optional
44+
dielectric varnish layer on top. Defaults to `0.0`
45+
(no clearcoat).
46+
47+
* `:clearcoat_roughness` — `0.0`–`1.0`. Roughness of the
48+
clearcoat layer. Ignored when `:clearcoat` is `0.0`.
49+
Defaults to `0.03` (near-mirror, matching automotive paint).
50+
51+
* `:name` — optional string label stored with the material.
52+
53+
## Example
54+
55+
iex> {:ok, red} = Color.new("#ff0000")
56+
iex> mat = Color.Material.new(red, metallic: 0.0, roughness: 0.85, name: "Matte Red PC")
57+
iex> mat.name
58+
"Matte Red PC"
59+
iex> mat.metallic
60+
0.0
61+
62+
"""
63+
64+
@default_metallic 0.0
65+
@default_roughness 0.5
66+
@default_clearcoat 0.0
67+
@default_clearcoat_roughness 0.03
68+
69+
@valid_keys [:metallic, :roughness, :clearcoat, :clearcoat_roughness, :name]
70+
71+
defstruct [
72+
:base_color,
73+
:name,
74+
metallic: @default_metallic,
75+
roughness: @default_roughness,
76+
clearcoat: @default_clearcoat,
77+
clearcoat_roughness: @default_clearcoat_roughness
78+
]
79+
80+
@type t :: %__MODULE__{
81+
base_color: Color.SRGB.t(),
82+
metallic: float(),
83+
roughness: float(),
84+
clearcoat: float(),
85+
clearcoat_roughness: float(),
86+
name: binary() | nil
87+
}
88+
89+
@doc """
90+
Builds a `%Color.Material{}` struct.
91+
92+
### Arguments
93+
94+
* `color_input` is anything accepted by `Color.new/1` — a hex
95+
string, a CSS named colour, an `%Color.SRGB{}` struct, an
96+
Oklch struct, etc. The input is normalised to `%Color.SRGB{}`
97+
and stored in `:base_color`.
98+
99+
### Options
100+
101+
* `:metallic` is the metallic parameter in `[0.0, 1.0]`.
102+
Default `0.0` (dielectric).
103+
104+
* `:roughness` is the roughness parameter in `[0.0, 1.0]`.
105+
Default `0.5`.
106+
107+
* `:clearcoat` is the clearcoat strength in `[0.0, 1.0]`.
108+
Default `0.0`.
109+
110+
* `:clearcoat_roughness` is the clearcoat roughness in
111+
`[0.0, 1.0]`. Default `0.03`.
112+
113+
* `:name` is an optional string label.
114+
115+
### Returns
116+
117+
* A `%Color.Material{}` struct.
118+
119+
### Examples
120+
121+
iex> mat = Color.Material.new("#c0c0c0", metallic: 1.0, roughness: 0.2)
122+
iex> mat.metallic
123+
1.0
124+
iex> mat.roughness
125+
0.2
126+
127+
iex> mat = Color.Material.new("red")
128+
iex> Color.to_hex(mat.base_color)
129+
"#ff0000"
130+
iex> mat.metallic
131+
0.0
132+
133+
"""
134+
@spec new(Color.input(), keyword()) :: t()
135+
def new(color_input, options \\ []) do
136+
options = validate_options!(options)
137+
138+
{:ok, srgb} = Color.new(color_input)
139+
140+
%__MODULE__{
141+
base_color: srgb,
142+
metallic: Keyword.fetch!(options, :metallic),
143+
roughness: Keyword.fetch!(options, :roughness),
144+
clearcoat: Keyword.fetch!(options, :clearcoat),
145+
clearcoat_roughness: Keyword.fetch!(options, :clearcoat_roughness),
146+
name: Keyword.get(options, :name)
147+
}
148+
end
149+
150+
@doc """
151+
Returns the underlying base colour.
152+
153+
### Arguments
154+
155+
* `material` is a `%Color.Material{}` struct.
156+
157+
### Returns
158+
159+
* A `%Color.SRGB{}` struct.
160+
161+
### Examples
162+
163+
iex> mat = Color.Material.new("#ff0000")
164+
iex> Color.to_hex(Color.Material.base_color(mat))
165+
"#ff0000"
166+
167+
"""
168+
@spec base_color(t()) :: Color.SRGB.t()
169+
def base_color(%__MODULE__{base_color: color}), do: color
170+
171+
@doc """
172+
Returns a tuple suitable for tuple-based sorting.
173+
174+
The tuple is `{metallic_bucket, hue, lightness, roughness}`
175+
where `metallic_bucket` is `0` for dielectrics and `1` for
176+
metals, computed from `metallic >= threshold`.
177+
178+
Used by `Color.Palette.Sort`'s `:material_pbr` strategy and
179+
exposed so third parties can compose their own sort keys.
180+
181+
### Arguments
182+
183+
* `material` is a `%Color.Material{}` struct.
184+
185+
### Options
186+
187+
* `:metallic_threshold` is the cutoff between dielectric and
188+
metallic buckets, in `(0.0, 1.0]`. Default `0.5`.
189+
190+
### Returns
191+
192+
* A `{integer, float, float, float}` tuple.
193+
194+
### Examples
195+
196+
iex> mat = Color.Material.new("#c0c0c0", metallic: 1.0, roughness: 0.2)
197+
iex> {bucket, _h, _l, rough} = Color.Material.to_pbr_tuple(mat)
198+
iex> bucket
199+
1
200+
iex> rough
201+
0.2
202+
203+
"""
204+
@spec to_pbr_tuple(t(), keyword()) ::
205+
{non_neg_integer(), float(), float(), float()}
206+
def to_pbr_tuple(%__MODULE__{} = material, options \\ []) do
207+
threshold = Keyword.get(options, :metallic_threshold, 0.5)
208+
209+
{:ok, oklch} = Color.convert(material.base_color, Color.Oklch)
210+
211+
bucket = if material.metallic >= threshold, do: 1, else: 0
212+
h = oklch.h || 0.0
213+
l = oklch.l || 0.0
214+
215+
{bucket, h, l, material.roughness}
216+
end
217+
218+
# ---- options validation -------------------------------------------------
219+
220+
defp validate_options!(options) do
221+
Enum.each(Keyword.keys(options), fn key ->
222+
unless key in @valid_keys do
223+
raise Color.PaletteError,
224+
reason: :unknown_option,
225+
detail: "#{inspect(key)} (valid options: #{inspect(@valid_keys)})"
226+
end
227+
end)
228+
229+
options =
230+
options
231+
|> Keyword.put_new(:metallic, @default_metallic)
232+
|> Keyword.put_new(:roughness, @default_roughness)
233+
|> Keyword.put_new(:clearcoat, @default_clearcoat)
234+
|> Keyword.put_new(:clearcoat_roughness, @default_clearcoat_roughness)
235+
236+
check_unit_float!(options, :metallic)
237+
check_unit_float!(options, :roughness)
238+
check_unit_float!(options, :clearcoat)
239+
check_unit_float!(options, :clearcoat_roughness)
240+
241+
options
242+
end
243+
244+
defp check_unit_float!(options, key) do
245+
value = Keyword.fetch!(options, key)
246+
247+
unless is_number(value) and value >= 0.0 and value <= 1.0 do
248+
raise Color.PaletteError,
249+
reason: :"invalid_#{key}",
250+
detail: "#{inspect(key)} must be a number in [0.0, 1.0]"
251+
end
252+
end
253+
end

lib/color/palette.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,12 @@ defmodule Color.Palette do
3131
3232
* `sort/2` — orders an arbitrary list of colours into a
3333
perceptually-sensible sequence (rainbow, stepped-hue grid,
34-
or lightness ramp). Useful when you have a heterogeneous
35-
bag of swatches and need a human-readable layout.
34+
lightness ramp, or material-aware PBR order). Useful when
35+
you have a heterogeneous bag of swatches and need a
36+
human-readable layout. When the input mixes
37+
`%Color.Material{}` structs (plastic, metal, ceramic), the
38+
`:material_pbr` strategy splits dielectrics from metals
39+
before colour-sorting each bucket.
3640
3741
## Working space
3842

0 commit comments

Comments
 (0)