Skip to content

Commit 3192443

Browse files
committed
Merge branch 'master' of github.com:tdewolff/canvas
2 parents 73208ac + adbce01 commit 3192443

3 files changed

Lines changed: 306 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ This is a non-exhaustive list of library users I've come across. PRs are welcome
124124

125125
- https://github.com/aldernero/gaul (generative art utility library)
126126
- https://github.com/aldernero/sketchy (generative art framework)
127+
- https://github.com/aldernero/spider (spider/radar chart generator)
127128
- https://github.com/BrowserJam/jam001/tree/master/jamierocks (a browser!)
128129
- https://github.com/carbocation/genomisc (genomics tools)
129130
- https://github.com/Dadido3/noita-mapcap (Noita map renderer)

colors.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func NewGradient() Grad {
9696

9797
// Add adds a new color stop to a gradient.
9898
func (g *Grad) Add(t float64, color color.RGBA) {
99-
stop := Stop{math.Min(math.Max(t, 0.0), 1.0), color}
99+
stop := Stop{math.Min(math.Max(t, 0.0), 1.0), rgbaColor(color)}
100100
// insert or replace stop and keep sort order
101101
for i := range *g {
102102
if Equal((*g)[i].Offset, stop.Offset) {
@@ -250,12 +250,27 @@ func (g *RadialGradient) At(x, y float64) color.RGBA {
250250
b := pd.Dot(g.cd) + g.R0*g.dr
251251
c := pd.Dot(pd) - g.R0*g.R0
252252
t0, t1 := solveQuadraticFormula(g.a, -2.0*b, c)
253-
if !math.IsNaN(t1) {
253+
254+
valid := func(t float64) bool {
255+
return !math.IsNaN(t) && t >= 0 && t <= 1 && g.R0+g.dr*t >= 0
256+
}
257+
hasPositive := func(t float64) bool {
258+
return !math.IsNaN(t) && t > 0 && g.R0+g.dr*t >= 0
259+
}
260+
261+
// Pick the largest valid t (t1 >= t0 from solveQuadraticFormula).
262+
if valid(t1) {
254263
return g.Grad.At(t1)
255-
} else if !math.IsNaN(t0) {
264+
}
265+
if valid(t0) {
256266
return g.Grad.At(t0)
257267
}
258-
return Transparent
268+
269+
// No valid t in [0,1]. Extend boundary colors.
270+
if hasPositive(t0) || hasPositive(t1) {
271+
return g.Grad.At(1)
272+
}
273+
return g.Grad.At(0)
259274
}
260275

261276
// ColorSpace defines the color space within the RGB color model. All colors passed to this library are assumed to be in the sRGB color space, which is a ubiquitous assumption in most software. This works great for most applications, but fails when blending semi-transparent layers. See an elaborate explanation at https://blog.johnnovak.net/2016/09/21/what-every-coder-should-know-about-gamma/, which goes into depth of the problems of using sRGB for blending and the need for gamma correction. In short, we need to transform the colors, which are in the sRGB color space, to the linear color space, perform blending, and then transform them back to the sRGB color space.

colors_test.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
package canvas
2+
3+
import (
4+
"fmt"
5+
"image/color"
6+
"testing"
7+
)
8+
9+
func TestGradAdd(t *testing.T) {
10+
red := color.RGBA{255, 0, 0, 255}
11+
green := color.RGBA{0, 255, 0, 255}
12+
blue := color.RGBA{0, 0, 255, 255}
13+
white := color.RGBA{255, 255, 255, 255}
14+
15+
tests := []struct {
16+
name string
17+
initial Grad
18+
addT float64
19+
addColor color.RGBA
20+
want Grad
21+
}{
22+
{
23+
name: "add to empty gradient",
24+
initial: Grad{},
25+
addT: 0.5,
26+
addColor: red,
27+
want: Grad{{0.5, red}},
28+
},
29+
{
30+
name: "add at end",
31+
initial: Grad{{0.0, red}},
32+
addT: 1.0,
33+
addColor: blue,
34+
want: Grad{{0.0, red}, {1.0, blue}},
35+
},
36+
{
37+
name: "add at beginning",
38+
initial: Grad{{0.5, red}},
39+
addT: 0.0,
40+
addColor: blue,
41+
want: Grad{{0.0, blue}, {0.5, red}},
42+
},
43+
{
44+
name: "insert in middle maintains sort order",
45+
initial: Grad{{0.0, red}, {1.0, blue}},
46+
addT: 0.5,
47+
addColor: green,
48+
want: Grad{{0.0, red}, {0.5, green}, {1.0, blue}},
49+
},
50+
{
51+
name: "replace existing offset",
52+
initial: Grad{{0.0, red}, {0.5, green}, {1.0, blue}},
53+
addT: 0.5,
54+
addColor: white,
55+
want: Grad{{0.0, red}, {0.5, white}, {1.0, blue}},
56+
},
57+
{
58+
name: "clamp t below 0",
59+
initial: Grad{},
60+
addT: -0.5,
61+
addColor: red,
62+
want: Grad{{0.0, red}},
63+
},
64+
{
65+
name: "clamp t above 1",
66+
initial: Grad{},
67+
addT: 1.5,
68+
addColor: red,
69+
want: Grad{{1.0, red}},
70+
},
71+
{
72+
name: "add multiple maintains order",
73+
initial: Grad{{0.2, red}, {0.8, blue}},
74+
addT: 0.4,
75+
addColor: green,
76+
want: Grad{{0.2, red}, {0.4, green}, {0.8, blue}},
77+
},
78+
{
79+
name: "add semi-transparent color clips to premultiplied",
80+
initial: Grad{},
81+
addT: 0.5,
82+
addColor: color.RGBA{255, 0, 0, 128},
83+
want: Grad{{0.5, color.RGBA{128, 0, 0, 128}}},
84+
},
85+
{
86+
name: "replace opaque with semi-transparent clips to premultiplied",
87+
initial: Grad{{0.0, red}, {0.5, green}, {1.0, blue}},
88+
addT: 0.5,
89+
addColor: color.RGBA{0, 255, 0, 64},
90+
want: Grad{{0.0, red}, {0.5, color.RGBA{0, 64, 0, 64}}, {1.0, blue}},
91+
},
92+
{
93+
name: "fully transparent color",
94+
initial: Grad{{0.0, red}},
95+
addT: 1.0,
96+
addColor: color.RGBA{0, 0, 0, 0},
97+
want: Grad{{0.0, red}, {1.0, color.RGBA{0, 0, 0, 0}}},
98+
},
99+
{
100+
name: "mixed transparent stops maintain order",
101+
initial: Grad{{0.0, color.RGBA{255, 0, 0, 200}}, {1.0, color.RGBA{0, 0, 255, 100}}},
102+
addT: 0.5,
103+
addColor: color.RGBA{0, 255, 0, 50},
104+
want: Grad{{0.0, color.RGBA{255, 0, 0, 200}}, {0.5, color.RGBA{0, 50, 0, 50}}, {1.0, color.RGBA{0, 0, 255, 100}}},
105+
},
106+
}
107+
108+
for _, tt := range tests {
109+
t.Run(tt.name, func(t *testing.T) {
110+
g := make(Grad, len(tt.initial))
111+
copy(g, tt.initial)
112+
g.Add(tt.addT, tt.addColor)
113+
114+
if len(g) != len(tt.want) {
115+
t.Fatalf("got %d stops, want %d", len(g), len(tt.want))
116+
}
117+
for i := range tt.want {
118+
if !Equal(g[i].Offset, tt.want[i].Offset) {
119+
t.Errorf("stop[%d].Offset = %v, want %v", i, g[i].Offset, tt.want[i].Offset)
120+
}
121+
if g[i].Color != tt.want[i].Color {
122+
t.Errorf("stop[%d].Color = %v, want %v", i, g[i].Color, tt.want[i].Color)
123+
}
124+
}
125+
})
126+
}
127+
}
128+
129+
func TestRadialGradientAt(t *testing.T) {
130+
red := color.RGBA{255, 0, 0, 255}
131+
blue := color.RGBA{0, 0, 255, 255}
132+
133+
// Helper to build a radial gradient with red at t=0 and blue at t=1.
134+
makeGrad := func(c0 Point, r0 float64, c1 Point, r1 float64) *RadialGradient {
135+
g := NewRadialGradient(c0, r0, c1, r1)
136+
g.Add(0.0, red)
137+
g.Add(1.0, blue)
138+
return g
139+
}
140+
141+
// colorApproxEqual allows small rounding differences from interpolation.
142+
colorApproxEqual := func(a, b color.RGBA) bool {
143+
diff := func(x, y uint8) int {
144+
d := int(x) - int(y)
145+
if d < 0 {
146+
return -d
147+
}
148+
return d
149+
}
150+
return diff(a.R, b.R) <= 1 && diff(a.G, b.G) <= 1 && diff(a.B, b.B) <= 1 && diff(a.A, b.A) <= 1
151+
}
152+
153+
tests := []struct {
154+
name string
155+
grad *RadialGradient
156+
x, y float64
157+
want color.RGBA
158+
}{
159+
{
160+
name: "empty gradient returns transparent",
161+
grad: NewRadialGradient(Point{0, 0}, 0, Point{0, 0}, 10),
162+
x: 5,
163+
y: 0,
164+
want: Transparent,
165+
},
166+
{
167+
name: "concentric at center returns first stop",
168+
grad: makeGrad(Point{0, 0}, 0, Point{0, 0}, 10),
169+
x: 0,
170+
y: 0,
171+
want: red,
172+
},
173+
{
174+
name: "concentric at outer edge returns last stop",
175+
grad: makeGrad(Point{0, 0}, 0, Point{0, 0}, 10),
176+
x: 10,
177+
y: 0,
178+
want: blue,
179+
},
180+
{
181+
name: "concentric beyond outer edge clamps to last stop",
182+
grad: makeGrad(Point{0, 0}, 0, Point{0, 0}, 10),
183+
x: 20,
184+
y: 0,
185+
want: blue,
186+
},
187+
{
188+
name: "concentric at midpoint interpolates",
189+
grad: makeGrad(Point{0, 0}, 0, Point{0, 0}, 10),
190+
x: 5,
191+
y: 0,
192+
want: color.RGBA{128, 0, 127, 255},
193+
},
194+
{
195+
name: "concentric along y axis",
196+
grad: makeGrad(Point{0, 0}, 0, Point{0, 0}, 10),
197+
x: 0,
198+
y: 10,
199+
want: blue,
200+
},
201+
{
202+
name: "concentric with offset center",
203+
grad: makeGrad(Point{5, 5}, 0, Point{5, 5}, 10),
204+
x: 5,
205+
y: 5,
206+
want: red,
207+
},
208+
{
209+
name: "concentric with offset center at edge",
210+
grad: makeGrad(Point{5, 5}, 0, Point{5, 5}, 10),
211+
x: 15,
212+
y: 5,
213+
want: blue,
214+
},
215+
{
216+
name: "non-concentric gradient at inner center",
217+
grad: makeGrad(Point{0, 0}, 0, Point{10, 0}, 10),
218+
x: 0,
219+
y: 0,
220+
want: red,
221+
},
222+
{
223+
name: "single stop gradient returns that stop everywhere",
224+
grad: func() *RadialGradient {
225+
g := NewRadialGradient(Point{0, 0}, 0, Point{0, 0}, 10)
226+
g.Add(0.0, red)
227+
return g
228+
}(),
229+
x: 5,
230+
y: 0,
231+
want: red,
232+
},
233+
{
234+
name: "offset centers uses valid root not largest root",
235+
grad: func() *RadialGradient {
236+
g := NewRadialGradient(Point{30, 30}, 5, Point{170, 170}, 60)
237+
g.Add(0.0, color.RGBA{255, 192, 203, 255})
238+
g.Add(0.5, color.RGBA{255, 255, 255, 255})
239+
g.Add(1.0, color.RGBA{0, 128, 0, 255})
240+
return g
241+
}(),
242+
x: 160,
243+
y: 111,
244+
want: color.RGBA{180, 218, 180, 255},
245+
},
246+
{
247+
name: "offset centers near outer boundary not clamped to last stop",
248+
grad: func() *RadialGradient {
249+
g := NewRadialGradient(Point{30, 30}, 5, Point{170, 170}, 60)
250+
g.Add(0.0, color.RGBA{255, 192, 203, 255})
251+
g.Add(0.5, color.RGBA{255, 255, 255, 255})
252+
g.Add(1.0, color.RGBA{0, 128, 0, 255})
253+
return g
254+
}(),
255+
x: 165,
256+
y: 111,
257+
want: color.RGBA{164, 210, 164, 255},
258+
},
259+
{
260+
name: "concentric with semi-transparent stops",
261+
grad: func() *RadialGradient {
262+
g := NewRadialGradient(Point{0, 0}, 0, Point{0, 0}, 10)
263+
g.Add(0.0, color.RGBA{128, 0, 0, 128})
264+
g.Add(1.0, color.RGBA{0, 0, 128, 128})
265+
return g
266+
}(),
267+
x: 0,
268+
y: 0,
269+
want: color.RGBA{128, 0, 0, 128},
270+
},
271+
}
272+
273+
for _, tt := range tests {
274+
t.Run(tt.name, func(t *testing.T) {
275+
got := tt.grad.At(tt.x, tt.y)
276+
if !colorApproxEqual(got, tt.want) {
277+
t.Errorf("At(%v, %v) = %v, want %v (within ±1 per channel)",
278+
tt.x, tt.y, formatRGBA(got), formatRGBA(tt.want))
279+
}
280+
})
281+
}
282+
}
283+
284+
func formatRGBA(c color.RGBA) string {
285+
return fmt.Sprintf("RGBA{%d, %d, %d, %d}", c.R, c.G, c.B, c.A)
286+
}

0 commit comments

Comments
 (0)