Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ var (
web bool
artOnly bool
output string // new output path flag
baseType string // base type flag
)

// rootCmd is the root command for the GitHub Skyline CLI tool.
Expand Down Expand Up @@ -74,6 +75,7 @@ func initFlags() {
flags.BoolVarP(&web, "web", "w", false, "Open GitHub profile (authenticated or specified user).")
flags.BoolVarP(&artOnly, "art-only", "a", false, "Generate only ASCII preview")
flags.StringVarP(&output, "output", "o", "", "Output file path (optional)")
flags.StringVarP(&baseType, "base-type", "b", "flat", "Type of base to generate (flat or slanted)")
}

// executeRootCmd is the main execution function for the root command.
Expand Down Expand Up @@ -105,7 +107,7 @@ func handleSkylineCommand(_ *cobra.Command, _ []string) error {
return fmt.Errorf("invalid year range: %v", err)
}

return skyline.GenerateSkyline(startYear, endYear, user, full, output, artOnly)
return skyline.GenerateSkyline(startYear, endYear, user, full, output, artOnly, baseType)
}

// Browser interface matches browser.Browser functionality.
Expand Down
6 changes: 3 additions & 3 deletions cmd/skyline/skyline.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type GitHubClientInterface interface {
}

// GenerateSkyline creates a 3D model with ASCII art preview of GitHub contributions for the specified year range, or "full lifetime" of the user
func GenerateSkyline(startYear, endYear int, targetUser string, full bool, output string, artOnly bool) error {
func GenerateSkyline(startYear, endYear int, targetUser string, full bool, output string, artOnly bool, baseType string) error {
log := logger.GetLogger()

client, err := github.InitializeGitHubClient()
Expand Down Expand Up @@ -96,9 +96,9 @@ func GenerateSkyline(startYear, endYear int, targetUser string, full bool, outpu

// Generate the STL file
if len(allContributions) == 1 {
return stl.GenerateSTL(allContributions[0], outputPath, targetUser, startYear)
return stl.GenerateSTL(allContributions[0], outputPath, targetUser, startYear, baseType)
}
return stl.GenerateSTLRange(allContributions, outputPath, targetUser, startYear, endYear)
return stl.GenerateSTLRange(allContributions, outputPath, targetUser, startYear, endYear, baseType)
}

return nil
Expand Down
2 changes: 1 addition & 1 deletion cmd/skyline/skyline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func TestGenerateSkyline(t *testing.T) {
return github.NewClient(tt.mockClient), nil
}

err := GenerateSkyline(tt.startYear, tt.endYear, tt.targetUser, tt.full, "", false)
err := GenerateSkyline(tt.startYear, tt.endYear, tt.targetUser, tt.full, "", false, "flat")
if (err != nil) != tt.wantErr {
t.Errorf("GenerateSkyline() error = %v, wantErr %v", err, tt.wantErr)
}
Expand Down
37 changes: 23 additions & 14 deletions internal/stl/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import (

// GenerateSTL creates a 3D model from GitHub contribution data and writes it to an STL file.
// It's a convenience wrapper around GenerateSTLRange for single year processing.
func GenerateSTL(contributions [][]types.ContributionDay, outputPath, username string, year int) error {
func GenerateSTL(contributions [][]types.ContributionDay, outputPath, username string, year int, baseType string) error {
// Wrap single year data in the format expected by GenerateSTLRange
contributionsRange := [][][]types.ContributionDay{contributions}
return GenerateSTLRange(contributionsRange, outputPath, username, year, year)
return GenerateSTLRange(contributionsRange, outputPath, username, year, year, baseType)
}

// GenerateSTLRange creates a 3D model from multiple years of GitHub contribution data.
Expand All @@ -26,7 +26,8 @@ func GenerateSTL(contributions [][]types.ContributionDay, outputPath, username s
// - username: GitHub username for the contribution data
// - startYear: first year in the range
// - endYear: last year in the range
func GenerateSTLRange(contributions [][][]types.ContributionDay, outputPath, username string, startYear, endYear int) error {
// - baseType: type of the model base (flat or slanted)
func GenerateSTLRange(contributions [][][]types.ContributionDay, outputPath, username string, startYear, endYear int, baseType string) error {
log := logger.GetLogger()
if err := log.Debug("Starting STL generation for user %s, years %d-%d", username, startYear, endYear); err != nil {
return errors.Wrap(err, "failed to log debug message")
Expand All @@ -44,7 +45,7 @@ func GenerateSTLRange(contributions [][][]types.ContributionDay, outputPath, use
// Find global max contribution across all years
maxContribution := findMaxContributionsAcrossYears(contributions)

modelTriangles, err := generateModelGeometry(contributions, dimensions, maxContribution, username, startYear, endYear)
modelTriangles, err := generateModelGeometry(contributions, dimensions, maxContribution, username, startYear, endYear, baseType)
if err != nil {
return errors.Wrap(err, "failed to generate geometry")
}
Expand Down Expand Up @@ -144,7 +145,7 @@ type geometryResult struct {

// generateModelGeometry orchestrates the concurrent generation of all model components.
// It manages four parallel processes for generating the base, columns, text, and logo.
func generateModelGeometry(contributionsPerYear [][][]types.ContributionDay, dims modelDimensions, maxContrib int, username string, startYear, endYear int) ([]types.Triangle, error) {
func generateModelGeometry(contributionsPerYear [][][]types.ContributionDay, dims modelDimensions, maxContrib int, username string, startYear, endYear int, baseType string) ([]types.Triangle, error) {
if len(contributionsPerYear) == 0 {
return nil, errors.New(errors.ValidationError, "contributions data cannot be empty", nil)
}
Expand All @@ -161,10 +162,10 @@ func generateModelGeometry(contributionsPerYear [][][]types.ContributionDay, dim
wg.Add(len(channels))

// Launch goroutines for each component
go generateBase(dims, channels["base"], &wg)
go generateBase(dims, baseType, channels["base"], &wg)
go generateColumnsForYearRange(contributionsPerYear, maxContrib, channels["columns"], &wg)
go generateText(username, startYear, endYear, dims, channels["text"], &wg)
go generateLogo(dims, channels["image"], &wg)
go generateText(username, startYear, endYear, dims, baseType, channels["text"], &wg)
go generateLogo(dims, baseType, channels["image"], &wg)

// Collect results from all channels
modelTriangles := make([]types.Triangle, 0, estimateTriangleCount(contributionsPerYear[0])*len(contributionsPerYear))
Expand All @@ -185,9 +186,17 @@ func generateModelGeometry(contributionsPerYear [][][]types.ContributionDay, dim
return modelTriangles, nil
}

func generateBase(dims modelDimensions, ch chan<- geometryResult, wg *sync.WaitGroup) {
func generateBase(dims modelDimensions, baseType string, ch chan<- geometryResult, wg *sync.WaitGroup) {
defer wg.Done()
baseTriangles, err := geometry.CreateCuboidBase(dims.innerWidth, dims.innerDepth)

var baseTriangles []types.Triangle
var err error

if baseType == "slanted" {
baseTriangles, err = geometry.CreateSlantedBase(dims.innerWidth, dims.innerDepth)
} else {
baseTriangles, err = geometry.CreateCuboidBase(dims.innerWidth, dims.innerDepth)
}

if err != nil {
if logErr := logger.GetLogger().Warning("Failed to generate base geometry: %v. Continuing without base.", err); logErr != nil {
Expand All @@ -202,7 +211,7 @@ func generateBase(dims modelDimensions, ch chan<- geometryResult, wg *sync.WaitG
}

// generateText creates 3D text geometry for the model
func generateText(username string, startYear int, endYear int, dims modelDimensions, ch chan<- geometryResult, wg *sync.WaitGroup) {
func generateText(username string, startYear int, endYear int, dims modelDimensions, baseType string, ch chan<- geometryResult, wg *sync.WaitGroup) {
defer wg.Done()
embossedYear := fmt.Sprintf("%d", endYear)

Expand All @@ -212,7 +221,7 @@ func generateText(username string, startYear int, endYear int, dims modelDimensi
embossedYear = fmt.Sprintf("%04d-%02d", startYear, endYear%100)
}

textTriangles, err := geometry.Create3DText(username, embossedYear, dims.innerWidth, geometry.BaseHeight)
textTriangles, err := geometry.Create3DText(username, embossedYear, dims.innerWidth, geometry.BaseHeight, baseType)
if err != nil {
if logErr := logger.GetLogger().Warning("Failed to generate text geometry: %v. Continuing without text.", err); logErr != nil {
ch <- geometryResult{triangles: []types.Triangle{}, err: logErr}
Expand All @@ -225,9 +234,9 @@ func generateText(username string, startYear int, endYear int, dims modelDimensi
}

// generateLogo handles the generation of the GitHub logo geometry
func generateLogo(dims modelDimensions, ch chan<- geometryResult, wg *sync.WaitGroup) {
func generateLogo(dims modelDimensions, baseType string, ch chan<- geometryResult, wg *sync.WaitGroup) {
defer wg.Done()
logoTriangles, err := geometry.GenerateImageGeometry(dims.innerWidth, geometry.BaseHeight)
logoTriangles, err := geometry.GenerateImageGeometry(dims.innerWidth, geometry.BaseHeight, baseType)
if err != nil {
// Log warning and continue without logo instead of failing
if logErr := logger.GetLogger().Warning("Failed to generate logo geometry: %v. Continuing without logo.", err); logErr != nil {
Expand Down
26 changes: 13 additions & 13 deletions internal/stl/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestGenerateSTL(t *testing.T) {
tempDir := t.TempDir()
outputPath := filepath.Join(tempDir, "test.stl")

err := GenerateSTL(contributions, outputPath, "testuser", 2023)
err := GenerateSTL(contributions, outputPath, "testuser", 2023, "flat")
if err != nil {
// Check if error is due to missing resources
if strings.Contains(err.Error(), "failed to open image") ||
Expand Down Expand Up @@ -58,7 +58,7 @@ func TestGenerateSTL(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := GenerateSTL(tt.contributions, tt.outputPath, tt.username, tt.year)
err := GenerateSTL(tt.contributions, tt.outputPath, tt.username, tt.year, "flat")
if (err != nil) != tt.wantErr {
t.Errorf("GenerateSTL() error = %v, wantErr %v", err, tt.wantErr)
}
Expand Down Expand Up @@ -168,7 +168,7 @@ func TestGenerateSTLRange(t *testing.T) {
}
}()

err := GenerateSTLRange(tt.contributions, tt.outputPath, tt.username, tt.startYear, tt.endYear)
err := GenerateSTLRange(tt.contributions, tt.outputPath, tt.username, tt.startYear, tt.endYear, "flat")
if (err != nil) != tt.wantErr {
// Only fail if the error is not related to missing resources
if !strings.Contains(err.Error(), "failed to open image") {
Expand Down Expand Up @@ -327,7 +327,7 @@ func TestGenerateBase(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)

go generateBase(dims, ch, &wg)
go generateBase(dims, "flat", ch, &wg)

result := <-ch
if result.err != nil {
Expand All @@ -347,7 +347,7 @@ func TestGenerateText(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)

go generateText("testuser", 2023, 2023, dims, ch, &wg)
go generateText("testuser", 2023, 2023, dims, "flat", ch, &wg)

result := <-ch
if result.err != nil {
Expand Down Expand Up @@ -429,7 +429,7 @@ func TestGenerateModelGeometry(t *testing.T) {
startYear := 2022
endYear := 2023

triangles, err := generateModelGeometry(contributionsPerYear, dims, maxContrib, username, startYear, endYear)
triangles, err := generateModelGeometry(contributionsPerYear, dims, maxContrib, username, startYear, endYear, "flat")
if err != nil {
t.Errorf("generateModelGeometry() error = %v", err)
}
Expand All @@ -438,13 +438,13 @@ func TestGenerateModelGeometry(t *testing.T) {
}

// Test error case with nil contributions
_, err = generateModelGeometry(nil, dims, maxContrib, username, startYear, endYear)
_, err = generateModelGeometry(nil, dims, maxContrib, username, startYear, endYear, "flat")
if err == nil {
t.Error("generateModelGeometry() should return error for nil contributions")
}

// Test with empty username
_, err = generateModelGeometry(contributionsPerYear, dims, maxContrib, "", startYear, endYear)
_, err = generateModelGeometry(contributionsPerYear, dims, maxContrib, "", startYear, endYear, "flat")
if err != nil {
t.Error("generateModelGeometry() should handle empty username")
}
Expand All @@ -459,7 +459,7 @@ func TestGenerateLogo(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)

go generateLogo(dims, ch, &wg)
go generateLogo(dims, "flat", ch, &wg)

result := <-ch
// Even if image file is not found, result should not be nil
Expand Down Expand Up @@ -521,7 +521,7 @@ func TestGenerateText_WithYearRange(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)

go generateText(tt.username, tt.startYear, tt.endYear, dims, ch, &wg)
go generateText(tt.username, tt.startYear, tt.endYear, dims, "flat", ch, &wg)

result := <-ch
// Even if font generation fails, result should not be nil
Expand Down Expand Up @@ -584,7 +584,7 @@ func TestResourceHandling(t *testing.T) {
wg.Add(1)

// This should log a warning but continue
go generateText("testuser", 2023, 2023, dims, ch, &wg)
go generateText("testuser", 2023, 2023, dims, "flat", ch, &wg)

result := <-ch
// Even with missing fonts, we should get a valid (possibly empty) result
Expand All @@ -605,7 +605,7 @@ func TestResourceHandling(t *testing.T) {
wg.Add(1)

// This should log a warning but continue
go generateLogo(dims, ch, &wg)
go generateLogo(dims, "flat", ch, &wg)

result := <-ch
// Even with missing image, we should get a valid (possibly empty) result
Expand All @@ -629,7 +629,7 @@ func TestResourceHandling(t *testing.T) {
maxContrib := findMaxContributionsAcrossYears(contributionsPerYear)

// This should complete successfully even with missing resources
triangles, err := generateModelGeometry(contributionsPerYear, dims, maxContrib, "testuser", 2022, 2023)
triangles, err := generateModelGeometry(contributionsPerYear, dims, maxContrib, "testuser", 2022, 2023, "flat")
if err != nil {
t.Errorf("generateModelGeometry() failed with missing resources: %v", err)
}
Expand Down
1 change: 1 addition & 0 deletions internal/stl/geometry/geometry.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const (
GridSize int = 53 // Number of weeks in a year
BaseThickness float64 = 10.0 // Total thickness of the base
MinHeight float64 = CellSize // Minimum height for any contribution column
BaseSlant float64 = 3.64 // Slant offset for the base (BaseHeight * tan(20°))
)

// Text rendering constants control the appearance and positioning of text.
Expand Down
61 changes: 61 additions & 0 deletions internal/stl/geometry/shapes.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ func CreateCuboidBase(width, depth float64) ([]types.Triangle, error) {
return createBox(0, 0, -BaseHeight, width, depth, BaseHeight)
}

// CreateSlantedBase generates triangles for a rectangular base with a slanted bottom.
func CreateSlantedBase(width, depth float64) ([]types.Triangle, error) {
// The base starts at Z = -BaseHeight and extends to Z = 0
// We offset the bottom vertices outward by a slant amount to create an angled base.
// Based on original implementations, a 22.5-degree slant angle is standard.
return createSlantedBox(0, 0, -BaseHeight, width, depth, BaseHeight, BaseSlant)
}

// CreateColumn generates triangles for a vertical column at the specified position.
// The column extends from the base height to the specified height.
func CreateColumn(x, y, height, size float64) ([]types.Triangle, error) {
Expand Down Expand Up @@ -107,3 +115,56 @@ func createBox(x, y, z, width, height, depth float64) ([]types.Triangle, error)

return triangles, nil
}

// createSlantedBox generates triangles for a box shape where the bottom vertices are expanded outwards by the 'slant' offset.
// This creates a base that is wider at the bottom than at the top.
func createSlantedBox(x, y, z, width, height, depth, slant float64) ([]types.Triangle, error) {
if width < 0 || height < 0 || depth < 0 {
return nil, errors.New(errors.ValidationError, "negative dimensions not allowed", nil)
}

const facesCount = 6
const trianglesPerFace = 2
triangles := make([]types.Triangle, 0, facesCount*trianglesPerFace)

vertices := make([]types.Point3D, 8)
quads := [6][4]int{
{0, 3, 2, 1}, // bottom
{5, 6, 7, 4}, // top
{4, 7, 3, 0}, // left
{1, 2, 6, 5}, // right
{3, 7, 6, 2}, // back
{4, 0, 1, 5}, // front
}

// Wait, is 'height' Y, and 'depth' Z like createBox? Yes.
// vertices[0..3] are at Z=z. We expand them by slant.
vertices[0] = types.Point3D{X: x - slant, Y: y - slant, Z: z}
vertices[1] = types.Point3D{X: x + width + slant, Y: y - slant, Z: z}
vertices[2] = types.Point3D{X: x + width + slant, Y: y + height + slant, Z: z}
vertices[3] = types.Point3D{X: x - slant, Y: y + height + slant, Z: z}

// vertices[4..7] are at Z=z+depth. Keep them regular size.
vertices[4] = types.Point3D{X: x, Y: y, Z: z + depth}
vertices[5] = types.Point3D{X: x + width, Y: y, Z: z + depth}
vertices[6] = types.Point3D{X: x + width, Y: y + height, Z: z + depth}
vertices[7] = types.Point3D{X: x, Y: y + height, Z: z + depth}

for _, quad := range quads {
quadTriangles, err := CreateQuad(
vertices[quad[0]],
vertices[quad[1]],
vertices[quad[2]],
vertices[quad[3]],
)

if err != nil {
return nil, errors.New(errors.STLError, "failed to create quad", err)
}

triangles = append(triangles, quadTriangles...)
}

return triangles, nil
}

Loading