@@ -2,7 +2,6 @@ package stl
22
33import (
44 "fmt"
5- "sync"
65
76 "github.com/github/gh-skyline/internal/errors"
87 "github.com/github/gh-skyline/internal/logger"
@@ -32,10 +31,25 @@ func GenerateSTLRange(contributions [][][]types.ContributionDay, outputPath, use
3231 return errors .Wrap (err , "failed to log debug message" )
3332 }
3433
34+ if len (contributions ) == 0 {
35+ return errors .New (errors .ValidationError , "contributions data cannot be empty" , nil )
36+ }
37+
3538 if err := validateInput (contributions [0 ], outputPath , username ); err != nil {
3639 return errors .Wrap (err , "input validation failed" )
3740 }
3841
42+ // Apply the same size bounds to every remaining year.
43+ // outputPath and username are shared across all years and have already been validated above.
44+ for i := 1 ; i < len (contributions ); i ++ {
45+ if len (contributions [i ]) == 0 {
46+ return errors .New (errors .ValidationError , fmt .Sprintf ("contributions data for year index %d cannot be empty" , i ), nil )
47+ }
48+ if len (contributions [i ]) > geometry .GridSize {
49+ return errors .New (errors .ValidationError , fmt .Sprintf ("contributions data for year index %d exceeds maximum grid size" , i ), nil )
50+ }
51+ }
52+
3953 dimensions , err := calculateDimensions (len (contributions ))
4054 if err != nil {
4155 return errors .Wrap (err , "failed to calculate dimensions" )
@@ -144,49 +158,51 @@ type geometryResult struct {
144158
145159// generateModelGeometry orchestrates the concurrent generation of all model components.
146160// It manages four parallel processes for generating the base, columns, text, and logo.
161+ // Channels are buffered so every goroutine can send and exit even if an error causes
162+ // an early return, preventing goroutine leaks.
147163func generateModelGeometry (contributionsPerYear [][][]types.ContributionDay , dims modelDimensions , maxContrib int , username string , startYear , endYear int ) ([]types.Triangle , error ) {
148164 if len (contributionsPerYear ) == 0 {
149165 return nil , errors .New (errors .ValidationError , "contributions data cannot be empty" , nil )
150166 }
151167
152- // Create channels for each geometry component
153- channels := map [string ]chan geometryResult {
154- "base" : make (chan geometryResult ),
155- "columns" : make (chan geometryResult ),
156- "text" : make (chan geometryResult ),
157- "image" : make (chan geometryResult ),
168+ // componentChannel pairs a name with its buffered result channel.
169+ // Using a slice (not a map) preserves a stable iteration order so that
170+ // triangles are always appended base → columns → text → image, giving
171+ // reproducible STL output across runs.
172+ type componentChannel struct {
173+ name string
174+ ch chan geometryResult
158175 }
159176
160- var wg sync.WaitGroup
161- wg .Add (len (channels ))
177+ // Buffered channels (size 1) allow each goroutine to send its result and exit
178+ // regardless of whether the main goroutine reads or returns early on error.
179+ components := []componentChannel {
180+ {"base" , make (chan geometryResult , 1 )},
181+ {"columns" , make (chan geometryResult , 1 )},
182+ {"text" , make (chan geometryResult , 1 )},
183+ {"image" , make (chan geometryResult , 1 )},
184+ }
162185
163186 // Launch goroutines for each component
164- go generateBase (dims , channels [ "base" ], & wg )
165- go generateColumnsForYearRange (contributionsPerYear , maxContrib , channels [ "columns" ], & wg )
166- go generateText (username , startYear , endYear , dims , channels [ "text" ], & wg )
167- go generateLogo (dims , channels [ "image" ], & wg )
187+ go generateBase (dims , components [ 0 ]. ch )
188+ go generateColumnsForYearRange (contributionsPerYear , maxContrib , components [ 1 ]. ch )
189+ go generateText (username , startYear , endYear , dims , components [ 2 ]. ch )
190+ go generateLogo (dims , components [ 3 ]. ch )
168191
169- // Collect results from all channels
192+ // Collect results in declaration order for a reproducible triangle sequence.
170193 modelTriangles := make ([]types.Triangle , 0 , estimateTriangleCount (contributionsPerYear [0 ])* len (contributionsPerYear ))
171- for componentName := range channels {
172- result := <- channels [ componentName ]
194+ for _ , component := range components {
195+ result := <- component . ch
173196 if result .err != nil {
174- return nil , errors .Wrap (result .err , fmt .Sprintf ("failed to generate %s geometry" , componentName ))
197+ return nil , errors .Wrap (result .err , fmt .Sprintf ("failed to generate %s geometry" , component . name ))
175198 }
176199 modelTriangles = append (modelTriangles , result .triangles ... )
177200 }
178201
179- // Clean up
180- wg .Wait ()
181- for _ , ch := range channels {
182- close (ch )
183- }
184-
185202 return modelTriangles , nil
186203}
187204
188- func generateBase (dims modelDimensions , ch chan <- geometryResult , wg * sync.WaitGroup ) {
189- defer wg .Done ()
205+ func generateBase (dims modelDimensions , ch chan <- geometryResult ) {
190206 baseTriangles , err := geometry .CreateCuboidBase (dims .innerWidth , dims .innerDepth )
191207
192208 if err != nil {
@@ -202,8 +218,7 @@ func generateBase(dims modelDimensions, ch chan<- geometryResult, wg *sync.WaitG
202218}
203219
204220// generateText creates 3D text geometry for the model
205- func generateText (username string , startYear int , endYear int , dims modelDimensions , ch chan <- geometryResult , wg * sync.WaitGroup ) {
206- defer wg .Done ()
221+ func generateText (username string , startYear int , endYear int , dims modelDimensions , ch chan <- geometryResult ) {
207222 embossedYear := fmt .Sprintf ("%d" , endYear )
208223
209224 // If start year and end year are the same, only show one year
@@ -225,8 +240,7 @@ func generateText(username string, startYear int, endYear int, dims modelDimensi
225240}
226241
227242// generateLogo handles the generation of the GitHub logo geometry
228- func generateLogo (dims modelDimensions , ch chan <- geometryResult , wg * sync.WaitGroup ) {
229- defer wg .Done ()
243+ func generateLogo (dims modelDimensions , ch chan <- geometryResult ) {
230244 logoTriangles , err := geometry .GenerateImageGeometry (dims .innerWidth , geometry .BaseHeight )
231245 if err != nil {
232246 // Log warning and continue without logo instead of failing
@@ -257,8 +271,7 @@ func estimateTriangleCount(contributions [][]types.ContributionDay) int {
257271}
258272
259273// generateColumnsForYearRange generates contribution columns for multiple years
260- func generateColumnsForYearRange (contributionsPerYear [][][]types.ContributionDay , maxContrib int , ch chan <- geometryResult , wg * sync.WaitGroup ) {
261- defer wg .Done ()
274+ func generateColumnsForYearRange (contributionsPerYear [][][]types.ContributionDay , maxContrib int , ch chan <- geometryResult ) {
262275 var yearTriangles []types.Triangle
263276
264277 // Process years in reverse order so most recent year is at the front
@@ -267,6 +280,8 @@ func generateColumnsForYearRange(contributionsPerYear [][][]types.ContributionDa
267280 triangles , err := geometry .CreateContributionGeometry (contributionsPerYear [i ], yearOffset , maxContrib )
268281 if err != nil {
269282 if logErr := logger .GetLogger ().Warning ("Failed to generate column geometry for year %d: %v. Skipping year." , i , err ); logErr != nil {
283+ // logErr is secondary; report the original geometry error to the caller.
284+ ch <- geometryResult {triangles : []types.Triangle {}, err : err }
270285 return
271286 }
272287 continue
@@ -276,34 +291,3 @@ func generateColumnsForYearRange(contributionsPerYear [][][]types.ContributionDa
276291
277292 ch <- geometryResult {triangles : yearTriangles }
278293}
279-
280- // CreateContributionGeometry generates geometry for a single year's worth of contributions
281- func CreateContributionGeometry (contributions [][]types.ContributionDay , yearIndex int , maxContrib int ) []types.Triangle {
282- var triangles []types.Triangle
283-
284- // Calculate the Y offset for this year's grid
285- // Each subsequent year is placed further back (larger Y value)
286- baseYOffset := float64 (yearIndex ) * (geometry .YearOffset + geometry .YearSpacing )
287-
288- // Generate contribution columns
289- for weekIdx , week := range contributions {
290- for dayIdx , day := range week {
291- if day .ContributionCount > 0 {
292- height := geometry .NormalizeContribution (day .ContributionCount , maxContrib )
293- x := float64 (weekIdx ) * geometry .CellSize
294- y := baseYOffset + float64 (dayIdx )* geometry .CellSize
295-
296- columnTriangles , err := geometry .CreateColumn (x , y , height , geometry .CellSize )
297- if err != nil {
298- if logErr := logger .GetLogger ().Warning ("Failed to generate column geometry: %v. Skipping column." , err ); logErr != nil {
299- return nil
300- }
301- continue
302- }
303- triangles = append (triangles , columnTriangles ... )
304- }
305- }
306- }
307-
308- return triangles
309- }
0 commit comments