feat: initial commit

This commit is contained in:
devilreef 2026-01-22 16:26:39 +06:00
commit 1b81917307
17 changed files with 1565 additions and 0 deletions

138
internal/api/handlers.go Normal file
View file

@ -0,0 +1,138 @@
package api
import (
"encoding/json"
"io"
"net/http"
"blockyserver/internal/render"
"blockyserver/internal/service"
)
// Handlers contains HTTP handlers for the API
type Handlers struct {
svc *service.MergeService
}
// NewHandlers creates a new Handlers instance
func NewHandlers(svc *service.MergeService) *Handlers {
return &Handlers{svc: svc}
}
// HandleGLB handles POST /render/glb
func (h *Handlers) HandleGLB(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read request body")
return
}
defer r.Body.Close()
result, err := h.svc.MergeFromJSON(body)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "model/gltf-binary")
w.Header().Set("Content-Disposition", "attachment; filename=character.glb")
w.Write(result.GLBBytes)
}
// HandlePNG handles POST /render/png
func (h *Handlers) HandlePNG(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read request body")
return
}
defer r.Body.Close()
var req PNGRequest
if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
req.ApplyDefaults()
if req.Character == nil {
writeError(w, http.StatusBadRequest, "character field is required")
return
}
result, err := h.svc.MergeFromJSON(req.Character)
if err != nil {
writeError(w, http.StatusInternalServerError, "merge failed: "+err.Error())
return
}
pngBytes, err := render.RenderPNG(result.GLBBytes, result.Atlas, req.Rotation, req.Background, req.Width, req.Height)
if err != nil {
writeError(w, http.StatusInternalServerError, "render failed: "+err.Error())
return
}
w.Header().Set("Content-Type", "image/png")
w.Write(pngBytes)
}
// HandleGIF handles POST /render/gif
func (h *Handlers) HandleGIF(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read request body")
return
}
defer r.Body.Close()
var req GIFRequest
if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
req.ApplyDefaults()
if req.Character == nil {
writeError(w, http.StatusBadRequest, "character field is required")
return
}
result, err := h.svc.MergeFromJSON(req.Character)
if err != nil {
writeError(w, http.StatusInternalServerError, "merge failed: "+err.Error())
return
}
gifBytes, err := render.RenderGIF(result.GLBBytes, result.Atlas, req.Background, req.Frames, req.Width, req.Height, req.Delay)
if err != nil {
writeError(w, http.StatusInternalServerError, "render failed: "+err.Error())
return
}
w.Header().Set("Content-Type", "image/gif")
w.Write(gifBytes)
}
// HandleHealth handles GET /health
func (h *Handlers) HandleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
}
// HandleOpenAPISpec handles GET /openapi.json
func (h *Handlers) HandleOpenAPISpec(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(OpenAPISpec))
}
// HandleSwaggerUI handles GET /docs
func (h *Handlers) HandleSwaggerUI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(SwaggerUIHTML))
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(ErrorResponse{Error: message})
}

257
internal/api/openapi.go Normal file
View file

@ -0,0 +1,257 @@
package api
// OpenAPISpec is the OpenAPI 3.0 specification for the BlockyServer API
const OpenAPISpec = `{
"openapi": "3.0.3",
"info": {
"title": "BlockyModel Merger API",
"description": "HTTP API for rendering Hytale character models as GLB, PNG, or animated GIF.\n\n**BlockyServer** by [devilreef](https://github.com/devilr33f)\n\n**BlockyModel Merger** by [JackGamesFTW](https://github.com/JackGamesFTW)",
"version": "1.0.0"
},
"servers": [
{
"url": "/",
"description": "Current server"
}
],
"paths": {
"/health": {
"get": {
"summary": "Health check",
"operationId": "getHealth",
"tags": ["System"],
"responses": {
"200": {
"description": "Server is healthy",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
}
}
}
}
}
}
}
}
},
"/render/glb": {
"post": {
"summary": "Render character as GLB",
"description": "Renders a character and returns GLB binary file.",
"operationId": "renderGLB",
"tags": ["Render"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CharacterConfig"
}
}
}
},
"responses": {
"200": {
"description": "GLB binary file",
"content": {
"model/gltf-binary": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/render/png": {
"post": {
"summary": "Render character as PNG",
"description": "Renders a character as a PNG image with configurable rotation and background.",
"operationId": "renderPNG",
"tags": ["Render"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PNGRequest"
}
}
}
},
"responses": {
"200": {
"description": "PNG image",
"content": {
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/render/gif": {
"post": {
"summary": "Render character as animated GIF",
"description": "Renders a character as an animated rotating GIF.",
"operationId": "renderGIF",
"tags": ["Render"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GIFRequest"
}
}
}
},
"responses": {
"200": {
"description": "Animated GIF",
"content": {
"image/gif": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
}
},
"components": {
"schemas": {
"CharacterConfig": {
"type": "object",
"description": "Character appearance configuration. All fields are optional. Format: \"AccessoryId.Color.Variant\"",
"properties": {
"bodyCharacteristic": {"type": "string", "example": "Default.02", "description": "Body type and skin tone"},
"underwear": {"type": "string", "example": "Underwear_Male", "description": "Base underwear"},
"face": {"type": "string", "example": "Face_A", "description": "Face shape"},
"ears": {"type": "string", "example": "Ears_A", "description": "Ear type"},
"mouth": {"type": "string", "example": "Mouth_A", "description": "Mouth shape"},
"haircut": {"type": "string", "example": "Scavenger_Hair.PitchBlack", "description": "Hair style and color"},
"facialHair": {"type": "string", "example": "Beard_A.Brown", "description": "Beard/mustache"},
"eyebrows": {"type": "string", "example": "Eyebrows_A.Black", "description": "Eyebrow style and color"},
"eyes": {"type": "string", "example": "Large_Eyes.Pink", "description": "Eye style and color"},
"pants": {"type": "string", "example": "Pants_A.Blue", "description": "Lower body clothing"},
"overpants": {"type": "string", "description": "Pants overlay (belt, etc.)"},
"undertop": {"type": "string", "example": "Shirt_A.White", "description": "Shirt/undershirt"},
"overtop": {"type": "string", "example": "Jacket_A.Red", "description": "Jacket/coat"},
"shoes": {"type": "string", "example": "Boots_A.Brown", "description": "Footwear"},
"headAccessory": {"type": "string", "description": "Hat/helmet"},
"faceAccessory": {"type": "string", "description": "Glasses/mask"},
"earAccessory": {"type": "string", "description": "Earrings"},
"skinFeature": {"type": "string", "description": "Tattoos/markings"},
"gloves": {"type": "string", "description": "Hand accessories"},
"cape": {"type": "string", "description": "Back cape"}
}
},
"PNGRequest": {
"type": "object",
"required": ["character"],
"properties": {
"character": {"$ref": "#/components/schemas/CharacterConfig"},
"rotation": {"type": "number", "default": 0, "description": "Rotation in degrees"},
"background": {"type": "string", "default": "transparent", "description": "\"transparent\" or hex color \"#RRGGBB\""},
"width": {"type": "integer", "default": 512, "description": "Image width in pixels"},
"height": {"type": "integer", "default": 512, "description": "Image height in pixels"}
}
},
"GIFRequest": {
"type": "object",
"required": ["character"],
"properties": {
"character": {"$ref": "#/components/schemas/CharacterConfig"},
"background": {"type": "string", "default": "#FFFFFF", "description": "Hex color (no transparency for GIF)"},
"frames": {"type": "integer", "default": 36, "description": "Number of frames (36 = 10° per frame)"},
"width": {"type": "integer", "default": 512, "description": "Image width in pixels"},
"height": {"type": "integer", "default": 512, "description": "Image height in pixels"},
"delay": {"type": "integer", "default": 5, "description": "Centiseconds between frames"}
}
},
"ErrorResponse": {
"type": "object",
"properties": {
"error": {"type": "string", "description": "Error message"}
}
}
},
"responses": {
"BadRequest": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
}
}
},
"InternalError": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/ErrorResponse"}
}
}
}
}
}
}`
// SwaggerUIHTML returns the Swagger UI HTML page
const SwaggerUIHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BlockyServer API</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = () => {
SwaggerUIBundle({
url: '/openapi.json',
dom_id: '#swagger-ui',
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
layout: "BaseLayout"
});
};
</script>
</body>
</html>`

34
internal/api/server.go Normal file
View file

@ -0,0 +1,34 @@
package api
import (
"net/http"
"time"
"blockyserver/internal/service"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// NewServer creates a new HTTP server with all routes configured
func NewServer(svc *service.MergeService) http.Handler {
r := chi.NewRouter()
// Middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
// Create handlers
h := NewHandlers(svc)
// Routes
r.Get("/health", h.HandleHealth)
r.Get("/openapi.json", h.HandleOpenAPISpec)
r.Get("/docs", h.HandleSwaggerUI)
r.Post("/render/glb", h.HandleGLB)
r.Post("/render/png", h.HandlePNG)
r.Post("/render/gif", h.HandleGIF)
return r
}

59
internal/api/types.go Normal file
View file

@ -0,0 +1,59 @@
package api
import "encoding/json"
// PNGRequest represents a request to render a character as PNG
type PNGRequest struct {
Character json.RawMessage `json:"character"`
Rotation float64 `json:"rotation"` // degrees, default 0
Background string `json:"background"` // "transparent" or hex "#RRGGBB"
Width int `json:"width"` // default 512
Height int `json:"height"` // default 512
}
// GIFRequest represents a request to render a character as animated GIF
type GIFRequest struct {
Character json.RawMessage `json:"character"`
Background string `json:"background"` // hex color "#RRGGBB"
Frames int `json:"frames"` // default 36 (10° per frame)
Width int `json:"width"` // default 512
Height int `json:"height"` // default 512
Delay int `json:"delay"` // centiseconds between frames, default 5
}
// ErrorResponse represents an error returned by the API
type ErrorResponse struct {
Error string `json:"error"`
}
// ApplyDefaults fills in default values for PNGRequest
func (r *PNGRequest) ApplyDefaults() {
if r.Width == 0 {
r.Width = 512
}
if r.Height == 0 {
r.Height = 512
}
if r.Background == "" {
r.Background = "transparent"
}
}
// ApplyDefaults fills in default values for GIFRequest
func (r *GIFRequest) ApplyDefaults() {
if r.Width == 0 {
r.Width = 512
}
if r.Height == 0 {
r.Height = 512
}
if r.Frames == 0 {
r.Frames = 36
}
if r.Delay == 0 {
r.Delay = 5
}
if r.Background == "" {
r.Background = "#FFFFFF"
}
}

63
internal/render/gif.go Normal file
View file

@ -0,0 +1,63 @@
package render
import (
"bytes"
"fmt"
"image"
"image/color/palette"
"image/draw"
"image/gif"
"github.com/hytale-tools/blockymodel-merger/pkg/texture"
)
// RenderGIF renders a GLB model to an animated GIF rotating 360 degrees
func RenderGIF(glbBytes []byte, atlas *texture.Atlas, background string, frames, width, height, delay int) ([]byte, error) {
// Parse background color
bgColor, err := ParseHexColor(background)
if err != nil {
return nil, fmt.Errorf("invalid background color: %w", err)
}
// Get atlas image
var atlasImage = atlas.Image
// Convert GLB to mesh
mesh, err := GLBToMesh(glbBytes, atlasImage)
if err != nil {
return nil, fmt.Errorf("converting GLB to mesh: %w", err)
}
// Calculate rotation per frame
rotationPerFrame := 360.0 / float64(frames)
// Create GIF structure
g := &gif.GIF{
Image: make([]*image.Paletted, frames),
Delay: make([]int, frames),
LoopCount: 0, // 0 = infinite loop
}
// Render each frame
for i := 0; i < frames; i++ {
rotation := float64(i) * rotationPerFrame
// Render frame
img := RenderScene(mesh, atlasImage, rotation, width, height, bgColor)
// Quantize to palette
paletted := image.NewPaletted(img.Bounds(), palette.Plan9)
draw.FloydSteinberg.Draw(paletted, img.Bounds(), img, image.Point{})
g.Image[i] = paletted
g.Delay[i] = delay
}
// Encode GIF
var buf bytes.Buffer
if err := gif.EncodeAll(&buf, g); err != nil {
return nil, fmt.Errorf("encoding GIF: %w", err)
}
return buf.Bytes(), nil
}

38
internal/render/png.go Normal file
View file

@ -0,0 +1,38 @@
package render
import (
"bytes"
"fmt"
"image/png"
"github.com/hytale-tools/blockymodel-merger/pkg/texture"
)
// RenderPNG renders a GLB model to PNG with the given parameters
func RenderPNG(glbBytes []byte, atlas *texture.Atlas, rotation float64, background string, width, height int) ([]byte, error) {
// Parse background color
bgColor, err := ParseHexColor(background)
if err != nil {
return nil, fmt.Errorf("invalid background color: %w", err)
}
// Get atlas image
var atlasImage = atlas.Image
// Convert GLB to mesh
mesh, err := GLBToMesh(glbBytes, atlasImage)
if err != nil {
return nil, fmt.Errorf("converting GLB to mesh: %w", err)
}
// Render the scene
img := RenderScene(mesh, atlasImage, rotation, width, height, bgColor)
// Encode to PNG
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("encoding PNG: %w", err)
}
return buf.Bytes(), nil
}

386
internal/render/software.go Normal file
View file

@ -0,0 +1,386 @@
package render
import (
"bytes"
"fmt"
"image"
"image/color"
"math"
"github.com/fogleman/fauxgl"
"github.com/qmuntal/gltf"
)
// GLBToMesh converts GLB bytes to a fauxgl mesh with texture
func GLBToMesh(glbBytes []byte, atlasImage image.Image) (*fauxgl.Mesh, error) {
doc := new(gltf.Document)
if err := gltf.NewDecoder(bytes.NewReader(glbBytes)).Decode(doc); err != nil {
return nil, fmt.Errorf("parsing GLB: %w", err)
}
mesh := fauxgl.NewEmptyMesh()
// Process all nodes in the scene
if len(doc.Scenes) == 0 || len(doc.Scenes[0].Nodes) == 0 {
return nil, fmt.Errorf("GLB has no scene nodes")
}
// Build node transforms
for _, nodeIdx := range doc.Scenes[0].Nodes {
if err := processNode(doc, int(nodeIdx), fauxgl.Identity(), mesh, atlasImage); err != nil {
return nil, err
}
}
return mesh, nil
}
func processNode(doc *gltf.Document, nodeIdx int, parentTransform fauxgl.Matrix, mesh *fauxgl.Mesh, atlasImage image.Image) error {
node := doc.Nodes[nodeIdx]
// Build local transform in TRS order: Translation * Rotation * Scale
localTransform := fauxgl.Identity()
// Apply scale first (rightmost in matrix multiplication)
if node.Scale != [3]float64{1, 1, 1} && node.Scale != [3]float64{0, 0, 0} {
localTransform = fauxgl.Scale(fauxgl.V(node.Scale[0], node.Scale[1], node.Scale[2])).Mul(localTransform)
}
// Apply rotation
if node.Rotation != [4]float64{0, 0, 0, 1} {
qx, qy, qz, qw := node.Rotation[0], node.Rotation[1], node.Rotation[2], node.Rotation[3]
R := quaternionToMatrix(qx, qy, qz, qw)
localTransform = R.Mul(localTransform)
}
// Apply translation last (leftmost)
if node.Translation != [3]float64{0, 0, 0} {
localTransform = fauxgl.Translate(fauxgl.V(node.Translation[0], node.Translation[1], node.Translation[2])).Mul(localTransform)
}
// World transform = parent * local
worldTransform := parentTransform.Mul(localTransform)
// Process mesh if present
if node.Mesh != nil {
gltfMesh := doc.Meshes[*node.Mesh]
for _, prim := range gltfMesh.Primitives {
if err := processPrimitive(doc, prim, worldTransform, mesh, atlasImage); err != nil {
return err
}
}
}
// Process children
for _, childIdx := range node.Children {
if err := processNode(doc, int(childIdx), worldTransform, mesh, atlasImage); err != nil {
return err
}
}
return nil
}
func processPrimitive(doc *gltf.Document, prim *gltf.Primitive, transform fauxgl.Matrix, mesh *fauxgl.Mesh, atlasImage image.Image) error {
// Get position accessor
posAccessorIdx, ok := prim.Attributes[gltf.POSITION]
if !ok {
return nil
}
posAccessor := doc.Accessors[posAccessorIdx]
positions, err := readVec3Accessor(doc, posAccessor)
if err != nil {
return fmt.Errorf("reading positions: %w", err)
}
// Get UV accessor (optional)
var uvs [][2]float32
if uvAccessorIdx, ok := prim.Attributes[gltf.TEXCOORD_0]; ok {
uvAccessor := doc.Accessors[uvAccessorIdx]
uvs, err = readVec2Accessor(doc, uvAccessor)
if err != nil {
return fmt.Errorf("reading UVs: %w", err)
}
}
// Get indices
var indices []uint32
if prim.Indices != nil {
indicesAccessor := doc.Accessors[*prim.Indices]
indices, err = readIndicesAccessor(doc, indicesAccessor)
if err != nil {
return fmt.Errorf("reading indices: %w", err)
}
} else {
for i := 0; i < len(positions); i++ {
indices = append(indices, uint32(i))
}
}
// Build triangles
for i := 0; i < len(indices); i += 3 {
i0, i1, i2 := indices[i], indices[i+1], indices[i+2]
p0 := positions[i0]
p1 := positions[i1]
p2 := positions[i2]
v0 := fauxgl.V(float64(p0[0]), float64(p0[1]), float64(p0[2]))
v1 := fauxgl.V(float64(p1[0]), float64(p1[1]), float64(p1[2]))
v2 := fauxgl.V(float64(p2[0]), float64(p2[1]), float64(p2[2]))
// Transform positions
v0 = transform.MulPosition(v0)
v1 = transform.MulPosition(v1)
v2 = transform.MulPosition(v2)
tri := fauxgl.Triangle{
V1: fauxgl.Vertex{Position: v0, Color: fauxgl.White},
V2: fauxgl.Vertex{Position: v1, Color: fauxgl.White},
V3: fauxgl.Vertex{Position: v2, Color: fauxgl.White},
}
// Add UVs if available
if len(uvs) > 0 {
uv0 := uvs[i0]
uv1 := uvs[i1]
uv2 := uvs[i2]
tri.V1.Texture = fauxgl.V(float64(uv0[0]), 1.0-float64(uv0[1]), 0)
tri.V2.Texture = fauxgl.V(float64(uv1[0]), 1.0-float64(uv1[1]), 0)
tri.V3.Texture = fauxgl.V(float64(uv2[0]), 1.0-float64(uv2[1]), 0)
}
// Compute normal from transformed vertices
edge1 := v1.Sub(v0)
edge2 := v2.Sub(v0)
normal := edge1.Cross(edge2).Normalize()
tri.V1.Normal = normal
tri.V2.Normal = normal
tri.V3.Normal = normal
mesh.Triangles = append(mesh.Triangles, &tri)
}
return nil
}
func readVec3Accessor(doc *gltf.Document, accessor *gltf.Accessor) ([][3]float32, error) {
if accessor.BufferView == nil {
return nil, fmt.Errorf("accessor has no buffer view")
}
bv := doc.BufferViews[*accessor.BufferView]
buf := doc.Buffers[bv.Buffer]
data := buf.Data[bv.ByteOffset+accessor.ByteOffset:]
stride := bv.ByteStride
if stride == 0 {
stride = 12
}
count := int(accessor.Count)
result := make([][3]float32, count)
for i := 0; i < count; i++ {
offset := i * int(stride)
result[i][0] = readFloat32LE(data[offset:])
result[i][1] = readFloat32LE(data[offset+4:])
result[i][2] = readFloat32LE(data[offset+8:])
}
return result, nil
}
func readVec2Accessor(doc *gltf.Document, accessor *gltf.Accessor) ([][2]float32, error) {
if accessor.BufferView == nil {
return nil, fmt.Errorf("accessor has no buffer view")
}
bv := doc.BufferViews[*accessor.BufferView]
buf := doc.Buffers[bv.Buffer]
data := buf.Data[bv.ByteOffset+accessor.ByteOffset:]
stride := bv.ByteStride
if stride == 0 {
stride = 8
}
count := int(accessor.Count)
result := make([][2]float32, count)
for i := 0; i < count; i++ {
offset := i * int(stride)
result[i][0] = readFloat32LE(data[offset:])
result[i][1] = readFloat32LE(data[offset+4:])
}
return result, nil
}
func readIndicesAccessor(doc *gltf.Document, accessor *gltf.Accessor) ([]uint32, error) {
if accessor.BufferView == nil {
return nil, fmt.Errorf("accessor has no buffer view")
}
bv := doc.BufferViews[*accessor.BufferView]
buf := doc.Buffers[bv.Buffer]
data := buf.Data[bv.ByteOffset+accessor.ByteOffset:]
count := int(accessor.Count)
result := make([]uint32, count)
switch accessor.ComponentType {
case gltf.ComponentUbyte:
for i := 0; i < count; i++ {
result[i] = uint32(data[i])
}
case gltf.ComponentUshort:
for i := 0; i < count; i++ {
result[i] = uint32(readUint16LE(data[i*2:]))
}
case gltf.ComponentUint:
for i := 0; i < count; i++ {
result[i] = readUint32LE(data[i*4:])
}
default:
return nil, fmt.Errorf("unsupported index component type: %v", accessor.ComponentType)
}
return result, nil
}
func readFloat32LE(data []byte) float32 {
bits := uint32(data[0]) | uint32(data[1])<<8 | uint32(data[2])<<16 | uint32(data[3])<<24
return math.Float32frombits(bits)
}
func readUint16LE(data []byte) uint16 {
return uint16(data[0]) | uint16(data[1])<<8
}
func readUint32LE(data []byte) uint32 {
return uint32(data[0]) | uint32(data[1])<<8 | uint32(data[2])<<16 | uint32(data[3])<<24
}
// AlphaTestShader is a texture shader with alpha cutoff
type AlphaTestShader struct {
Matrix fauxgl.Matrix
Texture fauxgl.Texture
AlphaCutoff float64
}
func NewAlphaTestShader(matrix fauxgl.Matrix, texture fauxgl.Texture, alphaCutoff float64) *AlphaTestShader {
return &AlphaTestShader{matrix, texture, alphaCutoff}
}
func (s *AlphaTestShader) Vertex(v fauxgl.Vertex) fauxgl.Vertex {
v.Output = s.Matrix.MulPositionW(v.Position)
return v
}
func (s *AlphaTestShader) Fragment(v fauxgl.Vertex) fauxgl.Color {
color := s.Texture.Sample(v.Texture.X, v.Texture.Y)
if color.A < s.AlphaCutoff {
return fauxgl.Discard
}
return color
}
func quaternionToMatrix(x, y, z, w float64) fauxgl.Matrix {
n := math.Sqrt(x*x + y*y + z*z + w*w)
if n > 0 {
x /= n
y /= n
z /= n
w /= n
}
xx := x * x
yy := y * y
zz := z * z
xy := x * y
xz := x * z
yz := y * z
wx := w * x
wy := w * y
wz := w * z
return fauxgl.Matrix{
1 - 2*(yy+zz), 2 * (xy - wz), 2 * (xz + wy), 0,
2 * (xy + wz), 1 - 2*(xx+zz), 2 * (yz - wx), 0,
2 * (xz - wy), 2 * (yz + wx), 1 - 2*(xx+yy), 0,
0, 0, 0, 1,
}
}
// RenderScene renders a mesh with the given parameters
func RenderScene(mesh *fauxgl.Mesh, atlasImage image.Image, rotationY float64, width, height int, bgColor color.Color) image.Image {
context := fauxgl.NewContext(width, height)
context.Cull = fauxgl.CullNone
context.AlphaBlend = false
r, g, b, a := bgColor.RGBA()
if a == 0 {
context.ClearColor = fauxgl.Transparent
} else {
context.ClearColor = fauxgl.Color{
R: float64(r) / 65535.0,
G: float64(g) / 65535.0,
B: float64(b) / 65535.0,
A: float64(a) / 65535.0,
}
}
context.ClearColorBuffer()
context.ClearDepthBuffer()
box := mesh.BoundingBox()
modelCenter := box.Center()
modelSize := box.Size()
aspect := float64(width) / float64(height)
fovy := 30.0
near := 0.1
far := 100.0
maxDim := math.Max(modelSize.X, math.Max(modelSize.Y, modelSize.Z))
cameraDistance := maxDim / (2 * math.Tan(fauxgl.Radians(fovy/2))) * 1.5
eye := fauxgl.V(modelCenter.X, modelCenter.Y, modelCenter.Z+cameraDistance)
center := modelCenter
up := fauxgl.V(0, 1, 0)
modelMatrix := fauxgl.Rotate(fauxgl.V(0, 1, 0), fauxgl.Radians(rotationY))
viewMatrix := fauxgl.LookAt(eye, center, up)
projMatrix := fauxgl.Perspective(fovy, aspect, near, far)
matrix := projMatrix.Mul(viewMatrix).Mul(modelMatrix)
var shader fauxgl.Shader
if atlasImage != nil {
shader = NewAlphaTestShader(matrix, fauxgl.NewImageTexture(atlasImage), 0.05)
} else {
shader = fauxgl.NewSolidColorShader(matrix, fauxgl.HexColor("#CCCCCC"))
}
context.Shader = shader
context.DrawMesh(mesh)
return context.Image()
}
// ParseHexColor parses a hex color string like "#RRGGBB" or "transparent"
func ParseHexColor(hex string) (color.Color, error) {
if hex == "transparent" || hex == "" {
return color.RGBA{0, 0, 0, 0}, nil
}
if len(hex) != 7 || hex[0] != '#' {
return nil, fmt.Errorf("invalid hex color: %s (expected #RRGGBB)", hex)
}
var r, g, b uint8
_, err := fmt.Sscanf(hex, "#%02x%02x%02x", &r, &g, &b)
if err != nil {
return nil, fmt.Errorf("parsing hex color %s: %w", hex, err)
}
return color.RGBA{r, g, b, 255}, nil
}

230
internal/service/merger.go Normal file
View file

@ -0,0 +1,230 @@
package service
import (
"encoding/json"
"fmt"
"github.com/hytale-tools/blockymodel-merger/pkg/blockymodel"
"github.com/hytale-tools/blockymodel-merger/pkg/character"
"github.com/hytale-tools/blockymodel-merger/pkg/export"
"github.com/hytale-tools/blockymodel-merger/pkg/merger"
"github.com/hytale-tools/blockymodel-merger/pkg/registry"
"github.com/hytale-tools/blockymodel-merger/pkg/texture"
)
const (
basePath = "assets/Characters/Player.blockymodel"
baseTexturePath = "Characters/Player_Textures/Player_Greyscale.png"
)
// MergeService handles character merging operations
type MergeService struct {
registry *registry.Registry
gradientSets *texture.GradientSets
baseModel *blockymodel.BlockyModel
}
// MergeResult contains the results of a merge operation
type MergeResult struct {
Model *blockymodel.BlockyModel
Atlas *texture.Atlas
GLBBytes []byte
}
// NewMergeService creates a new merge service with all required data loaded
func NewMergeService() (*MergeService, error) {
// Load gradient sets for tinting
gradientSets, err := texture.LoadGradientSets()
if err != nil {
return nil, fmt.Errorf("loading gradient sets: %w", err)
}
// Load accessory registry
reg, err := registry.Load()
if err != nil {
return nil, fmt.Errorf("loading registry: %w", err)
}
// Load base player model
baseModel, err := blockymodel.Load(basePath)
if err != nil {
return nil, fmt.Errorf("loading base model: %w", err)
}
return &MergeService{
registry: reg,
gradientSets: gradientSets,
baseModel: baseModel,
}, nil
}
// MergeFromJSON merges a character from JSON data and returns the result
func (s *MergeService) MergeFromJSON(charJSON []byte) (*MergeResult, error) {
// Parse character data
var charData character.CharacterData
if err := json.Unmarshal(charJSON, &charData); err != nil {
return nil, fmt.Errorf("parsing character JSON: %w", err)
}
// Resolve accessories
result, err := charData.ResolveAccessories(s.registry)
if err != nil {
return nil, fmt.Errorf("resolving accessories: %w", err)
}
// Create merger from base model
m, err := merger.New(s.baseModel)
if err != nil {
return nil, fmt.Errorf("creating merger: %w", err)
}
// Merge each accessory
for _, acc := range result.Accessories {
accessory, err := blockymodel.Load(acc.Path)
if err != nil {
return nil, fmt.Errorf("loading accessory %s: %w", acc.Path, err)
}
if err := m.Merge(accessory, acc.Spec.ID); err != nil {
return nil, fmt.Errorf("merging accessory %s: %w", acc.Path, err)
}
}
// Get merged model
mergedModel := m.Result()
// Process textures
var tintedTextures []*texture.TintedTexture
skinTone := charData.GetSkinTone()
// Load and tint base player texture
if skinTone != "" {
baseTinted, err := texture.ProcessAccessoryTexture(
"_base",
baseTexturePath,
"Skin",
skinTone,
s.gradientSets,
)
if err == nil {
tintedTextures = append(tintedTextures, baseTinted)
}
} else {
baseImg, err := texture.LoadImage(baseTexturePath)
if err == nil {
baseTex := &texture.TintedTexture{
Name: "_base",
Image: baseImg,
OriginalPath: baseTexturePath,
}
tintedTextures = append(tintedTextures, baseTex)
}
}
// Process accessory textures
for _, acc := range result.Accessories {
if acc.ResolvedTexture == nil {
continue
}
var tinted *texture.TintedTexture
if acc.ResolvedTexture.DirectTexture != "" {
img, err := texture.LoadImage(acc.ResolvedTexture.DirectTexture)
if err != nil {
continue
}
tinted = &texture.TintedTexture{
Name: acc.Spec.ID,
Image: img,
OriginalPath: acc.ResolvedTexture.DirectTexture,
}
} else if acc.ResolvedTexture.GreyscaleTexture != "" {
var err error
tinted, err = texture.ProcessAccessoryTexture(
acc.Spec.ID,
acc.ResolvedTexture.GreyscaleTexture,
acc.ResolvedTexture.GradientSet,
acc.Spec.Color,
s.gradientSets,
)
if err != nil {
continue
}
} else {
continue
}
tintedTextures = append(tintedTextures, tinted)
}
// Pack textures into atlas
var atlas *texture.Atlas
if len(tintedTextures) > 0 {
var err error
atlas, err = texture.PackAtlasSimple(tintedTextures, 1)
if err != nil {
return nil, fmt.Errorf("packing atlas: %w", err)
}
// Update texture offsets in the merged model
for _, tex := range tintedTextures {
if tex.Name == "_base" {
continue
}
x, y, _, _, ok := atlas.GetPixelCoords(tex.Name)
if !ok {
continue
}
// Find all node IDs that came from this accessory
nodeIDs := make(map[string]bool)
for nodeID, accessoryID := range m.NodeSources {
if accessoryID == tex.Name {
nodeIDs[nodeID] = true
}
}
if len(nodeIDs) > 0 {
offset := blockymodel.AtlasOffset{X: float64(x), Y: float64(y)}
blockymodel.UpdateTextureOffsets(mergedModel.Nodes, nodeIDs, offset)
}
}
}
// Export to GLB
exporter := export.NewGLBExporter()
var materialIdx uint32
if atlas != nil {
w, h := atlas.Image.Bounds().Dx(), atlas.Image.Bounds().Dy()
exporter.SetAtlasSize(float64(w), float64(h))
atlasBytes, err := texture.EncodePNG(atlas.Image)
if err != nil {
return nil, fmt.Errorf("encoding atlas: %w", err)
}
texIdx := exporter.AddTexture(atlasBytes)
materialIdx = exporter.AddMaterial("textured", texIdx)
} else {
exporter.SetAtlasSize(64, 64)
materialIdx = 0
}
if err := exporter.ExportModel(mergedModel, materialIdx); err != nil {
return nil, fmt.Errorf("exporting model: %w", err)
}
glbBytes, err := exporter.Bytes()
if err != nil {
return nil, fmt.Errorf("getting GLB bytes: %w", err)
}
return &MergeResult{
Model: mergedModel,
Atlas: atlas,
GLBBytes: glbBytes,
}, nil
}