From 57d9543ca8f6f3ad9b9cfbd48369aec591a19968 Mon Sep 17 00:00:00 2001 From: ChunKoo Park Date: Mon, 30 Mar 2026 18:14:04 +0900 Subject: [PATCH 1/2] feat: implement slanted base option and 3D text alignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added a new `--base-type` (or `-b`) flag to the CLI, allowing users to generate 3D models with a slanted base (20.0-degree angle). This matches the aesthetic and stackability of the original GitHub Skyline tool. Key changes: - CLI: Introduced `--base-type` flag in `rootCmd`, defaulting to "flat". - Geometry: - Added `CreateSlantedBase` and `createSlantedBox` to support angled geometry. - Defined `BaseSlant` constant (3.64mm for a 20.0° slant) in `geometry.go`. - Rendering: - Updated 3D text and logo generation to accurately project voxels onto the slanted trapezoidal front face of the base. - Implemented proportional X-scaling and Y-offsetting within `createVoxelOnFace` to ensure text remains readable and centered on slanted surfaces. - Plumbing: Updated function signatures in `internal/stl` and `cmd/skyline` to propagate `baseType` throughout the generation pipeline. - Tests: Updated unit test suite to support the new `baseType` parameter. --- cmd/root.go | 4 +- cmd/skyline/skyline.go | 6 +-- cmd/skyline/skyline_test.go | 2 +- internal/stl/generator.go | 37 +++++++++++------- internal/stl/generator_test.go | 26 ++++++------- internal/stl/geometry/geometry.go | 1 + internal/stl/geometry/shapes.go | 61 ++++++++++++++++++++++++++++++ internal/stl/geometry/text.go | 42 +++++++++++++++----- internal/stl/geometry/text_test.go | 12 +++--- 9 files changed, 144 insertions(+), 47 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 38d2b97..bc9ac47 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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. @@ -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. @@ -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. diff --git a/cmd/skyline/skyline.go b/cmd/skyline/skyline.go index de62e5e..83ce713 100644 --- a/cmd/skyline/skyline.go +++ b/cmd/skyline/skyline.go @@ -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() @@ -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 diff --git a/cmd/skyline/skyline_test.go b/cmd/skyline/skyline_test.go index 05970a5..757e393 100644 --- a/cmd/skyline/skyline_test.go +++ b/cmd/skyline/skyline_test.go @@ -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) } diff --git a/internal/stl/generator.go b/internal/stl/generator.go index 8dfb5bb..f343676 100644 --- a/internal/stl/generator.go +++ b/internal/stl/generator.go @@ -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. @@ -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") @@ -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") } @@ -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) } @@ -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)) @@ -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 { @@ -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) @@ -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} @@ -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 { diff --git a/internal/stl/generator_test.go b/internal/stl/generator_test.go index eea9421..efc086a 100644 --- a/internal/stl/generator_test.go +++ b/internal/stl/generator_test.go @@ -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") || @@ -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) } @@ -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") { @@ -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 { @@ -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 { @@ -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) } @@ -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") } @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) } diff --git a/internal/stl/geometry/geometry.go b/internal/stl/geometry/geometry.go index 89f3583..feb32ea 100644 --- a/internal/stl/geometry/geometry.go +++ b/internal/stl/geometry/geometry.go @@ -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. diff --git a/internal/stl/geometry/shapes.go b/internal/stl/geometry/shapes.go index 5f731db..8c68886 100644 --- a/internal/stl/geometry/shapes.go +++ b/internal/stl/geometry/shapes.go @@ -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) { @@ -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 +} + diff --git a/internal/stl/geometry/text.go b/internal/stl/geometry/text.go index e29127a..bf43225 100644 --- a/internal/stl/geometry/text.go +++ b/internal/stl/geometry/text.go @@ -28,7 +28,7 @@ const ( ) // Create3DText generates 3D text geometry for the username and year. -func Create3DText(username string, year string, baseWidth float64, baseHeight float64) ([]types.Triangle, error) { +func Create3DText(username string, year string, baseWidth float64, baseHeight float64, baseType string) ([]types.Triangle, error) { if username == "" { username = "anonymous" } @@ -40,6 +40,7 @@ func Create3DText(username string, year string, baseWidth float64, baseHeight fl usernameFontSize, baseWidth, baseHeight, + baseType, ) if err != nil { return nil, err @@ -52,6 +53,7 @@ func Create3DText(username string, year string, baseWidth float64, baseHeight fl yearFontSize, baseWidth, baseHeight, + baseType, ) if err != nil { return nil, err @@ -73,7 +75,7 @@ func Create3DText(username string, year string, baseWidth float64, baseHeight fl // Returns: // // ([]types.Triangle, error): A slice of triangles representing text. -func renderText(text string, justification string, leftOffsetPercent float64, fontSize float64, baseWidth float64, baseHeight float64) ([]types.Triangle, error) { +func renderText(text string, justification string, leftOffsetPercent float64, fontSize float64, baseWidth float64, baseHeight float64, baseType string) ([]types.Triangle, error) { // Create a rendering context for the face of the skyline faceWidthRes := baseWidthVoxelResolution faceHeightRes := int(float64(faceWidthRes) * baseHeight / baseWidth) @@ -129,6 +131,7 @@ func renderText(text string, justification string, leftOffsetPercent float64, fo voxelDepth, baseWidth, baseHeight, + baseType, ) if err != nil { return nil, errors.New(errors.STLError, "failed to create cube", err) @@ -157,7 +160,7 @@ func renderText(text string, justification string, leftOffsetPercent float64, fo // Returns: // // ([]types.Triangle, error): A slice of triangles representing the cube and an error if any. -func createVoxelOnFace(x float64, y float64, height float64, baseWidth float64, baseHeight float64) ([]types.Triangle, error) { +func createVoxelOnFace(px float64, py float64, height float64, baseWidth float64, baseHeight float64, baseType string) ([]types.Triangle, error) { // Mapping resolution xResolution := float64(baseWidthVoxelResolution) yResolution := xResolution * baseHeight / baseWidth @@ -166,19 +169,36 @@ func createVoxelOnFace(x float64, y float64, height float64, baseWidth float64, voxelSize := 1.0 // Scale coordinate to face resolution - x = (x / xResolution) * baseWidth - y = (y / yResolution) * baseHeight + x := (px / xResolution) * baseWidth + y := (py / yResolution) * baseHeight voxelSizeX := (voxelSize / xResolution) * baseWidth voxelSizeY := (voxelSize / yResolution) * baseHeight + // Slant adjustment + finalX := x + finalY := -height + finalWidth := voxelSizeX + + if baseType == "slanted" { + slantFactor := y / baseHeight + // The horizontal width increases from baseWidth at top (y=0) to baseWidth + 2*BaseSlant at bottom (y=baseHeight) + faceWidthAtZ := baseWidth + 2*BaseSlant*slantFactor + // Position X proportionally within the wider face + finalX = (x/baseWidth)*faceWidthAtZ - BaseSlant*slantFactor + // Shift Y outwards to match the slanted base face + finalY = -height - BaseSlant*slantFactor + // Scale voxel width slightly + finalWidth = (voxelSizeX / baseWidth) * faceWidthAtZ + } + cube, err := CreateCube( // Location (from top left corner of skyline face) - x, // x - Left to right - -height, // y - Negative comes out of face. Positive goes into face. + finalX, // x - Left to right + finalY, // y - Depth -voxelSizeY-y, // z - Bottom to top // Size - voxelSizeX, // x length - left to right from specified point + finalWidth, // x length - left to right from specified point height, // thickness - distance coming out of face voxelSizeY, // y length - bottom to top from specified point ) @@ -187,7 +207,7 @@ func createVoxelOnFace(x float64, y float64, height float64, baseWidth float64, } // GenerateImageGeometry creates 3D geometry from the embedded logo image. -func GenerateImageGeometry(baseWidth float64, baseHeight float64) ([]types.Triangle, error) { +func GenerateImageGeometry(baseWidth float64, baseHeight float64, baseType string) ([]types.Triangle, error) { // Get temporary image file imgPath, cleanup, err := getEmbeddedImage() if err != nil { @@ -204,11 +224,12 @@ func GenerateImageGeometry(baseWidth float64, baseHeight float64) ([]types.Trian logoTopOffset, baseWidth, baseHeight, + baseType, ) } // renderImage generates 3D geometry for the given image configuration. -func renderImage(filePath string, scale float64, height float64, leftOffsetPercent float64, topOffsetPercent float64, baseWidth float64, baseHeight float64) ([]types.Triangle, error) { +func renderImage(filePath string, scale float64, height float64, leftOffsetPercent float64, topOffsetPercent float64, baseWidth float64, baseHeight float64, baseType string) ([]types.Triangle, error) { // Get voxel resolution of base face faceWidthRes := baseWidthVoxelResolution @@ -252,6 +273,7 @@ func renderImage(filePath string, scale float64, height float64, leftOffsetPerce height, baseWidth, baseHeight, + baseType, ) if err != nil { diff --git a/internal/stl/geometry/text_test.go b/internal/stl/geometry/text_test.go index 229487a..fc8e86c 100644 --- a/internal/stl/geometry/text_test.go +++ b/internal/stl/geometry/text_test.go @@ -15,7 +15,7 @@ import ( func TestCreate3DText(t *testing.T) { t.Run("verify basic text mesh generation", func(t *testing.T) { - triangles, err := Create3DText("test", "2023", 100.0, 5.0) + triangles, err := Create3DText("test", "2023", 100.0, 5.0, "flat") if err != nil { t.Fatalf("Create3DText failed: %v", err) } @@ -25,7 +25,7 @@ func TestCreate3DText(t *testing.T) { }) t.Run("verify text generation with empty username", func(t *testing.T) { - triangles, err := Create3DText("", "2023", 100.0, 5.0) + triangles, err := Create3DText("", "2023", 100.0, 5.0, "flat") if err != nil { t.Fatalf("Create3DText failed with empty username: %v", err) } @@ -35,7 +35,7 @@ func TestCreate3DText(t *testing.T) { }) t.Run("verify normal vectors of text geometry", func(t *testing.T) { - triangles, err := Create3DText("test", "2023", 100.0, 5.0) + triangles, err := Create3DText("test", "2023", 100.0, 5.0, "flat") if err != nil { t.Fatalf("Create3DText failed: %v", err) } @@ -67,6 +67,7 @@ func TestRenderText(t *testing.T) { 10.0, // fontSize 200.0, // baseWidth 10.0, // baseHeight + "flat", // baseType ) if err != nil { @@ -89,6 +90,7 @@ func TestRenderImage(t *testing.T) { 0.1, // topOffsetPercent 200.0, // baseWidth 10.0, // baseHeight + "flat", // baseType ) if err == nil { t.Error("Expected error for invalid image path") @@ -153,7 +155,7 @@ func TestGenerateImageGeometry(t *testing.T) { }() t.Run("verify valid image geometry generation", func(t *testing.T) { - triangles, err := GenerateImageGeometry(100.0, 5.0) + triangles, err := GenerateImageGeometry(100.0, 5.0, "flat") if err != nil { t.Fatalf("GenerateImageGeometry failed: %v", err) } @@ -163,7 +165,7 @@ func TestGenerateImageGeometry(t *testing.T) { }) t.Run("verify geometry normal vectors", func(t *testing.T) { - triangles, err := GenerateImageGeometry(100.0, 5.0) + triangles, err := GenerateImageGeometry(100.0, 5.0, "flat") if err != nil { t.Fatalf("GenerateImageGeometry failed: %v", err) } From cb57783b25791cee274ff0470379e50db1df2b44 Mon Sep 17 00:00:00 2001 From: ChunKoo Park Date: Mon, 30 Mar 2026 18:29:47 +0900 Subject: [PATCH 2/2] no X scaling for text. --- internal/stl/geometry/text.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/internal/stl/geometry/text.go b/internal/stl/geometry/text.go index bf43225..8d109c2 100644 --- a/internal/stl/geometry/text.go +++ b/internal/stl/geometry/text.go @@ -181,14 +181,9 @@ func createVoxelOnFace(px float64, py float64, height float64, baseWidth float64 if baseType == "slanted" { slantFactor := y / baseHeight - // The horizontal width increases from baseWidth at top (y=0) to baseWidth + 2*BaseSlant at bottom (y=baseHeight) - faceWidthAtZ := baseWidth + 2*BaseSlant*slantFactor - // Position X proportionally within the wider face - finalX = (x/baseWidth)*faceWidthAtZ - BaseSlant*slantFactor - // Shift Y outwards to match the slanted base face + // Shift Y outwards to match the slanted base face. + // We avoid scaling X or shifting finalX to keep the font vertically consistent. finalY = -height - BaseSlant*slantFactor - // Scale voxel width slightly - finalWidth = (voxelSizeX / baseWidth) * faceWidthAtZ } cube, err := CreateCube(