diff --git a/stl/geometry/text.go b/stl/geometry/text.go index 65e83d0..bee5245 100644 --- a/stl/geometry/text.go +++ b/stl/geometry/text.go @@ -10,97 +10,49 @@ import ( "github.com/github/gh-skyline/types" ) -// Common configuration for rendered elements -type renderConfig struct { - startX float64 - startY float64 - startZ float64 - voxelScale float64 - depth float64 -} +const ( + baseWidthVoxelResolution = 2000 // Number of voxels across the skyline face + voxelDepth = 1.0 // Distance to come out of face -// TextConfig holds parameters for text rendering -type textRenderConfig struct { - renderConfig - text string - contextWidth int - contextHeight int - fontSize float64 -} + logoScale = 0.4 // Percent + logoTopOffset = 0.15 // Percent + logoLeftOffset = 0.03 // Percent -// ImageConfig holds parameters for image rendering -type imageRenderConfig struct { - renderConfig - imagePath string - height float64 -} + usernameFontSize = 120.0 + usernameJustification = "left" // "left", "center", "right" + usernameLeftOffset = 0.1 // Percent -const ( - imagePosition = 0.025 - usernameOffset = -0.01 - yearPosition = 0.77 - - defaultContextWidth = 800 - defaultContextHeight = 200 - textVoxelSize = 1.0 - textDepthOffset = 2.0 - frontEmbedDepth = 1.5 - - usernameContextWidth = 1000 - usernameContextHeight = 200 - usernameFontSize = 48.0 - usernameZOffset = 0.7 - - yearContextWidth = 800 - yearContextHeight = 200 - yearFontSize = 56.0 - yearZOffset = 0.4 - - defaultImageHeight = 9.0 - defaultImageScale = 0.8 - imageLeftMargin = 10.0 + yearFontSize = 100.0 + yearJustification = "right" // "left", "center", "right" + yearLeftOffset = 0.97 // Percent ) // Create3DText generates 3D text geometry for the username and year. -func Create3DText(username string, year string, innerWidth, baseHeight float64) ([]types.Triangle, error) { +func Create3DText(username string, year string, baseWidth float64, baseHeight float64) ([]types.Triangle, error) { if username == "" { username = "anonymous" } - usernameConfig := textRenderConfig{ - renderConfig: renderConfig{ - startX: innerWidth * usernameOffset, - startY: -textDepthOffset / 2, - startZ: baseHeight * usernameZOffset, - voxelScale: textVoxelSize, - depth: frontEmbedDepth, - }, - text: username, - contextWidth: usernameContextWidth, - contextHeight: usernameContextHeight, - fontSize: usernameFontSize, - } - - yearConfig := textRenderConfig{ - renderConfig: renderConfig{ - startX: innerWidth * yearPosition, - startY: -textDepthOffset / 2, - startZ: baseHeight * yearZOffset, - voxelScale: textVoxelSize * 0.75, - depth: frontEmbedDepth, - }, - text: year, - contextWidth: yearContextWidth, - contextHeight: yearContextHeight, - fontSize: yearFontSize, - } - - usernameTriangles, err := renderText(usernameConfig) + usernameTriangles, err := renderText( + username, + usernameJustification, + usernameLeftOffset, + usernameFontSize, + baseWidth, + baseHeight, + ) if err != nil { return nil, err } - yearTriangles, err := renderText(yearConfig) + yearTriangles, err := renderText( + year, + yearJustification, + yearLeftOffset, + yearFontSize, + baseWidth, + baseHeight, + ) if err != nil { return nil, err } @@ -108,11 +60,31 @@ func Create3DText(username string, year string, innerWidth, baseHeight float64) return append(usernameTriangles, yearTriangles...), nil } -// renderText generates 3D geometry for the given text configuration. -func renderText(config textRenderConfig) ([]types.Triangle, error) { - dc := gg.NewContext(config.contextWidth, config.contextHeight) +// renderText places text on the face of a skyline, offset from the left and vertically-aligned. +// The function takes the text to be displayed, offset from left, and font size. +// It returns an array of types.Triangle. +// +// Parameters: +// +// text (string): The text to be displayed on the skyline's front face. +// leftOffsetPercent (float64): The percentage distance from the left to start displaying the text. +// fontSize (float64): How large to make the text. Note: It scales with the baseWidthVoxelResolution. +// +// 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) { + // Create a rendering context for the face of the skyline + faceWidthRes := baseWidthVoxelResolution + faceHeightRes := int(float64(faceWidthRes) * baseHeight / baseWidth) + + // Create image representing the skyline face + dc := gg.NewContext(faceWidthRes, faceHeightRes) + dc.SetRGB(0, 0, 0) + dc.Clear() + dc.SetRGB(1, 1, 1) - // Get temporary font file + // Load font into context fontPath, cleanup, err := writeTempFont(PrimaryFont) if err != nil { // Try fallback font @@ -121,31 +93,42 @@ func renderText(config textRenderConfig) ([]types.Triangle, error) { return nil, errors.New(errors.IOError, "failed to load any fonts", err) } } - - if err := dc.LoadFontFace(fontPath, config.fontSize); err != nil { + if err := dc.LoadFontFace(fontPath, fontSize); err != nil { return nil, errors.New(errors.IOError, "failed to load font", err) } - dc.SetRGB(0, 0, 0) - dc.Clear() - dc.SetRGB(1, 1, 1) - dc.DrawStringAnchored(config.text, float64(config.contextWidth)/8, float64(config.contextHeight)/2, 0.0, 0.5) - + // Draw text on image at desired location var triangles []types.Triangle - for y := 0; y < config.contextHeight; y++ { - for x := 0; x < config.contextWidth; x++ { + // Convert justification to a number + var justificationPercent float64 + switch justification { + case "center": + justificationPercent = 0.5 + case "right": + justificationPercent = 1.0 + default: + justificationPercent = 0.0 + } + + dc.DrawStringAnchored( + text, + float64(faceWidthRes)*leftOffsetPercent, // Offset from right + float64(faceHeightRes)*0.5, // Offset from top + justificationPercent, // Justification (0.0=left, 0.5=center, 1.0=right) + 0.5, // Vertically aligned + ) + + // Convert context image pixels into voxels + for x := 0; x < faceWidthRes; x++ { + for y := 0; y < faceHeightRes; y++ { if isPixelActive(dc, x, y) { - xPos := config.startX + float64(x)*config.voxelScale/8 - zPos := config.startZ - float64(y)*config.voxelScale/8 - - voxel, err := CreateCube( - xPos, - config.startY, - zPos, - config.voxelScale/2, - config.depth, - config.voxelScale/2, + voxel, err := createVoxelOnFace( + float64(x), + float64(y), + voxelDepth, + baseWidth, + baseHeight, ) if err != nil { return nil, errors.New(errors.STLError, "failed to create cube", err) @@ -161,34 +144,78 @@ func renderText(config textRenderConfig) ([]types.Triangle, error) { return triangles, nil } +// createVoxelOnFace creates a voxel on the face of a skyline by generating a cube at the specified coordinates. +// The function takes in the x, y coordinates and height. +// It returns a slice of types.Triangle representing the cube and an error if the cube creation fails. +// +// Parameters: +// +// x (float64): The x-coordinate on the skyline face (left to right). +// y (float64): The y-coordinate on the skyline face (top to bottom). +// height (float64): Distance coming out of the face. +// +// 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) { + // Mapping resolution + xResolution := float64(baseWidthVoxelResolution) + yResolution := xResolution * baseHeight / baseWidth + + // Pixel size + voxelSize := 1.0 + + // Scale coordinate to face resolution + x = (x / xResolution) * baseWidth + y = (y / yResolution) * baseHeight + voxelSizeX := (voxelSize / xResolution) * baseWidth + voxelSizeY := (voxelSize / yResolution) * baseHeight + + 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. + -voxelSizeY-y, // z - Bottom to top + + // Size + voxelSizeX, // x length - left to right from specified point + height, // thickness - distance coming out of face + voxelSizeY, // y length - bottom to top from specified point + ) + + return cube, err +} + // GenerateImageGeometry creates 3D geometry from the embedded logo image. -func GenerateImageGeometry(innerWidth, baseHeight float64) ([]types.Triangle, error) { +func GenerateImageGeometry(baseWidth float64, baseHeight float64) ([]types.Triangle, error) { // Get temporary image file imgPath, cleanup, err := getEmbeddedImage() if err != nil { return nil, err } - config := imageRenderConfig{ - renderConfig: renderConfig{ - startX: innerWidth * imagePosition, - startY: -frontEmbedDepth / 2.0, - startZ: -0.85 * baseHeight, - voxelScale: defaultImageScale, - depth: frontEmbedDepth, - }, - imagePath: imgPath, - height: defaultImageHeight, - } - defer cleanup() - return renderImage(config) + return renderImage( + imgPath, + logoScale, + voxelDepth, + logoLeftOffset, + logoTopOffset, + baseWidth, + baseHeight, + ) } // renderImage generates 3D geometry for the given image configuration. -func renderImage(config imageRenderConfig) ([]types.Triangle, error) { - reader, err := os.Open(config.imagePath) +func renderImage(filePath string, scale float64, height float64, leftOffsetPercent float64, topOffsetPercent float64, baseWidth float64, baseHeight float64) ([]types.Triangle, error) { + + // Get voxel resolution of base face + faceWidthRes := baseWidthVoxelResolution + faceHeightRes := int(float64(faceWidthRes) * baseHeight / baseWidth) + + // Load image from file + reader, err := os.Open(filePath) if err != nil { return nil, errors.New(errors.IOError, "failed to open image", err) } @@ -199,34 +226,32 @@ func renderImage(config imageRenderConfig) ([]types.Triangle, error) { fmt.Println(closeErr) } }() - img, err := png.Decode(reader) if err != nil { return nil, errors.New(errors.IOError, "failed to decode PNG", err) } + // Get image size bounds := img.Bounds() - width := bounds.Max.X - height := bounds.Max.Y - - scale := config.height / float64(height) + logoWidth := bounds.Max.X + logoHeight := bounds.Max.Y + // Transfer image pixels onto face of skyline as voxels var triangles []types.Triangle - - for y := height - 1; y >= 0; y-- { - for x := 0; x < width; x++ { + for x := 0; x < logoWidth; x++ { + for y := logoHeight - 1; y >= 0; y-- { + // Get pixel color and alpha r, _, _, a := img.At(x, y).RGBA() + + // If pixel is active (white) and not fully transparent, create a voxel if a > 32768 && r > 32768 { - xPos := config.startX + float64(x)*config.voxelScale*scale - zPos := config.startZ + float64(height-1-y)*config.voxelScale*scale - - voxel, err := CreateCube( - xPos, - config.startY, - zPos, - config.voxelScale*scale, - config.depth, - config.voxelScale*scale, + + voxel, err := createVoxelOnFace( + (leftOffsetPercent*float64(faceWidthRes))+float64(x)*scale, + (topOffsetPercent*float64(faceHeightRes))+float64(y)*scale, + height, + baseWidth, + baseHeight, ) if err != nil { diff --git a/stl/geometry/text_test.go b/stl/geometry/text_test.go index c1500a7..229487a 100644 --- a/stl/geometry/text_test.go +++ b/stl/geometry/text_test.go @@ -13,10 +13,6 @@ import ( // TestCreate3DText verifies text geometry generation functionality. func TestCreate3DText(t *testing.T) { - // Skip tests if fonts are not available - if _, err := os.Stat(FallbackFont); err != nil { - t.Skip("Skipping text tests as font files are not available") - } t.Run("verify basic text mesh generation", func(t *testing.T) { triangles, err := Create3DText("test", "2023", 100.0, 5.0) @@ -63,47 +59,37 @@ func TestCreate3DText(t *testing.T) { // TestRenderText verifies internal text rendering functionality func TestRenderText(t *testing.T) { - // Skip if fonts not available - if _, err := os.Stat(FallbackFont); err != nil { - t.Skip("Skipping text tests as font files are not available") - } + t.Run("verify text renders", func(t *testing.T) { + triangles, err := renderText( + "Mona", // text + "left", // justification + 0.1, // leftOffsetPercent + 10.0, // fontSize + 200.0, // baseWidth + 10.0, // baseHeight + ) - t.Run("verify text config validation", func(t *testing.T) { - invalidConfig := textRenderConfig{ - renderConfig: renderConfig{ - startX: 0, - startY: 0, - startZ: 0, - voxelScale: 0, // Invalid scale - depth: 1, - }, - text: "test", - contextWidth: 100, - contextHeight: 100, - fontSize: 10, - } - _, err := renderText(invalidConfig) - if err == nil { - t.Error("Expected error for invalid text config") + if err != nil { + t.Fatalf("renderText failed: %v", err) + } + if len(triangles) == 0 { + t.Error("Expected non-zero triangles for rendered text") } }) } // TestRenderImage verifies internal image rendering functionality func TestRenderImage(t *testing.T) { - t.Run("verify invalid image path", func(t *testing.T) { - config := imageRenderConfig{ - renderConfig: renderConfig{ - startX: 0, - startY: 0, - startZ: 0, - voxelScale: 1, - depth: 1, - }, - imagePath: "nonexistent.png", - height: 10, - } - _, err := renderImage(config) + t.Run("verify invalid image", func(t *testing.T) { + _, err := renderImage( + "nonexistent.png", // filePath + 0.5, // scale + 100.0, // height + 0.1, // leftOffsetPercent + 0.1, // topOffsetPercent + 200.0, // baseWidth + 10.0, // baseHeight + ) if err == nil { t.Error("Expected error for invalid image path") }