From 1b819173073fee453d356347900326556bdbd946 Mon Sep 17 00:00:00 2001
From: devilreef <86633411+devilr33f@users.noreply.github.com>
Date: Thu, 22 Jan 2026 16:26:39 +0600
Subject: [PATCH] feat: initial commit
---
.github/workflows/docker.yml | 55 +++++
.gitignore | 19 ++
CLAUDE.md | 67 ++++++
Dockerfile | 20 ++
README.md | 126 ++++++++++++
docker-compose.yml | 9 +
go.mod | 14 ++
go.sum | 12 ++
internal/api/handlers.go | 138 +++++++++++++
internal/api/openapi.go | 257 +++++++++++++++++++++++
internal/api/server.go | 34 +++
internal/api/types.go | 59 ++++++
internal/render/gif.go | 63 ++++++
internal/render/png.go | 38 ++++
internal/render/software.go | 386 +++++++++++++++++++++++++++++++++++
internal/service/merger.go | 230 +++++++++++++++++++++
main.go | 38 ++++
17 files changed, 1565 insertions(+)
create mode 100644 .github/workflows/docker.yml
create mode 100644 .gitignore
create mode 100644 CLAUDE.md
create mode 100644 Dockerfile
create mode 100644 README.md
create mode 100644 docker-compose.yml
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 internal/api/handlers.go
create mode 100644 internal/api/openapi.go
create mode 100644 internal/api/server.go
create mode 100644 internal/api/types.go
create mode 100644 internal/render/gif.go
create mode 100644 internal/render/png.go
create mode 100644 internal/render/software.go
create mode 100644 internal/service/merger.go
create mode 100644 main.go
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
new file mode 100644
index 0000000..9a8daeb
--- /dev/null
+++ b/.github/workflows/docker.yml
@@ -0,0 +1,55 @@
+name: Docker
+
+on:
+ push:
+ branches: [main, master]
+ tags: ['v*']
+ pull_request:
+ branches: [main, master]
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ if: github.event_name != 'pull_request'
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha,prefix=
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..10db92e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,19 @@
+assets/
+data/
+old/
+
+# Go binaries
+blockymerge
+extract-assets
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# AI slop
+.claude
+.sisyphus
+
+# Hytale assets archive
+Assets.zip
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..4b39071
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,67 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Build & Run Commands
+
+```bash
+# Build
+go build -o blockyserver.exe .
+
+# Run (default port 8080)
+./blockyserver.exe
+./blockyserver.exe -port 3000
+
+# Dependencies
+go mod tidy
+```
+
+## Architecture
+
+BlockyServer is an HTTP API for rendering Hytale character models. It wraps the `github.com/hytale-tools/blockymodel-merger` library to provide web endpoints.
+
+### Request Flow
+
+```
+HTTP Request → api.Handlers → service.MergeService → blockymodel-merger pkg
+ ↓
+ render.RenderPNG/GIF (for image endpoints)
+ ↓
+ HTTP Response (GLB/PNG/GIF)
+```
+
+### Package Structure
+
+- **main.go** - Entry point, flag parsing, server startup
+- **internal/api/** - HTTP layer (chi router, handlers, OpenAPI spec)
+- **internal/service/** - Business logic wrapping blockymodel-merger
+- **internal/render/** - Software 3D rendering using fauxgl (GLB→PNG/GIF)
+
+### Key Dependencies
+
+- `github.com/hytale-tools/blockymodel-merger` - Core model merging, texture atlas, GLB export
+- `github.com/fogleman/fauxgl` - Software 3D renderer for PNG/GIF output
+- `github.com/go-chi/chi/v5` - HTTP router
+
+### Runtime Data
+
+Server requires these directories at runtime (relative to working directory):
+- `assets/` - Character models (.blockymodel), textures
+- `data/` - JSON registry files (accessories, colors, gradients)
+
+### API Endpoints
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/render/glb` | POST | Character JSON → GLB binary |
+| `/render/png` | POST | Character JSON + options → PNG image |
+| `/render/gif` | POST | Character JSON + options → Animated GIF |
+| `/docs` | GET | Swagger UI |
+| `/openapi.json` | GET | OpenAPI spec |
+| `/health` | GET | Health check |
+
+### Character JSON Format
+
+Accessory format: `"AccessoryId.Color.Variant"` (e.g., `"Scavenger_Hair.PitchBlack"`)
+
+Key fields: `bodyCharacteristic`, `haircut`, `eyes`, `pants`, `undertop`, `overtop`, `shoes`
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..3b46cf7
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,20 @@
+FROM golang:1.25-alpine AS builder
+
+WORKDIR /app
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+RUN CGO_ENABLED=0 GOOS=linux go build -o blockyserver .
+
+FROM alpine:latest
+
+WORKDIR /app
+
+COPY --from=builder /app/blockyserver .
+
+EXPOSE 8080
+
+ENTRYPOINT ["./blockyserver"]
+CMD ["-port", "8080"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c01780e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,126 @@
+# BlockyServer
+
+> [!WARNING]
+> Not affiliated with Hypixel Studios.
+> All trademarks and assets are property of their respective owners.
+
+HTTP API for rendering Hytale character models as GLB, PNG, or animated GIF.
+
+Built on top of [blockymodel-merger](https://github.com/hytale-tools/blockymodel-merger) by [JackGamesFTW](https://github.com/JackGamesFTW), special thanks to him!
+
+## Features
+
+- Merge character accessories into a single model
+- Export as GLB (glTF binary)
+- Render to PNG with configurable rotation and background
+- Render to animated rotating GIF
+- Swagger UI documentation
+
+## Requirements
+
+- Go 1.21+
+- `assets/` directory with character models and textures
+- `data/` directory with JSON registry files
+
+## Obtaining Assets
+
+> [!NOTE]
+> You must purchase [Hytale](https://store.hytale.com) to obtain assets. Use code `jack` in the Hytale Store to support Jack's projects.
+
+Download `assets.zip` from the [Hytale Server Manual](https://support.hytale.com/hc/en-us/articles/45326769420827-Hytale-Server-Manual#server-setup).
+
+**Important:** Use the server version, not the client version. The server package includes the `data/` directory with registry JSON files.
+
+### Using extract-assets tool
+
+Clone and build the extraction tool from blockymodel-merger:
+
+```bash
+git clone https://github.com/hytale-tools/blockymodel-merger.git
+cd blockymodel-merger
+go build -o extract-assets ./cmd/extract-assets
+./extract-assets /path/to/assets.zip
+```
+
+This extracts files into the required structure:
+- `Common/Characters` → `assets/Characters/`
+- `Common/Cosmetics` → `assets/Cosmetics/`
+- `Common/TintGradients` → `assets/TintGradients/`
+- `Cosmetics/CharacterCreator` → `data/`
+
+Copy the resulting `assets/` and `data/` directories to your blockyserver folder.
+
+## Installation
+
+```bash
+git clone https://github.com/yourusername/blockyserver.git
+cd blockyserver
+go mod tidy
+go build -o blockyserver.exe .
+```
+
+## Usage
+
+```bash
+# Start server on default port 8080
+./blockyserver.exe
+
+# Start on custom port
+./blockyserver.exe -port 3000
+```
+
+## Docker
+
+### Using Docker Compose (recommended)
+
+```bash
+docker compose up -d
+```
+
+This mounts `assets/` and `data/` directories as read-only volumes.
+
+### Using Docker directly
+
+```bash
+# Build image
+docker build -t blockyserver .
+
+# Run container
+docker run -d -p 8080:8080 \
+ -v $(pwd)/assets:/app/assets:ro \
+ -v $(pwd)/data:/app/data:ro \
+ blockyserver
+```
+
+## API Endpoints
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/render/glb` | POST | Returns GLB binary |
+| `/render/png` | POST | Returns PNG image |
+| `/render/gif` | POST | Returns animated GIF |
+| `/docs` | GET | Swagger UI |
+| `/openapi.json` | GET | OpenAPI specification |
+| `/health` | GET | Health check |
+
+## Example Request
+
+### Render PNG
+
+```bash
+curl -X POST http://localhost:8080/render/png \
+ -H "Content-Type: application/json" \
+ -d '{
+ "character": {
+ "bodyCharacteristic": "Default.02",
+ "haircut": "Scavenger_Hair.PitchBlack",
+ "eyes": "Large_Eyes.Pink",
+ "pants": "Pants_A.Blue",
+ "undertop": "Shirt_A.White"
+ },
+ "rotation": 45,
+ "background": "transparent",
+ "width": 512,
+ "height": 512
+ }' --output character.png
+```
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..0b53232
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,9 @@
+services:
+ blockyserver:
+ build: .
+ ports:
+ - "8080:8080"
+ volumes:
+ - ./assets:/app/assets:ro
+ - ./data:/app/data:ro
+ restart: unless-stopped
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..b6c0037
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,14 @@
+module blockyserver
+
+go 1.21
+
+require (
+ github.com/fogleman/fauxgl v0.0.0-20200818143847-27cddc103802
+ github.com/go-chi/chi/v5 v5.0.12
+ github.com/qmuntal/gltf v0.28.0
+)
+
+require (
+ github.com/fogleman/simplify v0.0.0-20170216171241-d32f302d5046 // indirect
+ github.com/hytale-tools/blockymodel-merger v0.3.0
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..67255a9
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,12 @@
+github.com/fogleman/fauxgl v0.0.0-20200818143847-27cddc103802 h1:5vdq0jOnV15v1NdZbAcU+dIJ22rFgwaieiFewPvnKCA=
+github.com/fogleman/fauxgl v0.0.0-20200818143847-27cddc103802/go.mod h1:7f7F8EvO8MWvDx9sIoloOfZBCKzlWuZV/h3TjpXOO3k=
+github.com/fogleman/simplify v0.0.0-20170216171241-d32f302d5046 h1:n3RPbpwXSFT0G8FYslzMUBDO09Ix8/dlqzvUkcJm4Jk=
+github.com/fogleman/simplify v0.0.0-20170216171241-d32f302d5046/go.mod h1:KDwyDqFmVUxUmo7tmqXtyaaJMdGon06y8BD2jmh84CQ=
+github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
+github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
+github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
+github.com/hytale-tools/blockymodel-merger v0.3.0 h1:3nsPl8zilJHwckD3S2X8AiMTmPAZc1Kjy/gHAjAmHU4=
+github.com/hytale-tools/blockymodel-merger v0.3.0/go.mod h1:T4jzkIZbtC9uSA9kpRx2Lclyc50/kWw2WQLtwRyn4m4=
+github.com/qmuntal/gltf v0.28.0 h1:C4A1temWMPtcI2+qNfpfRq8FEJxoBGUN3ZZM8BCc+xU=
+github.com/qmuntal/gltf v0.28.0/go.mod h1:YoXZOt0Nc0kIfSKOLZIRoV4FycdC+GzE+3JgiAGYoMs=
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
new file mode 100644
index 0000000..b98d511
--- /dev/null
+++ b/internal/api/handlers.go
@@ -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})
+}
diff --git a/internal/api/openapi.go b/internal/api/openapi.go
new file mode 100644
index 0000000..e7573dc
--- /dev/null
+++ b/internal/api/openapi.go
@@ -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 = `
+
+
+
+
+ BlockyServer API
+
+
+
+
+
+
+
+`
diff --git a/internal/api/server.go b/internal/api/server.go
new file mode 100644
index 0000000..68b9ef7
--- /dev/null
+++ b/internal/api/server.go
@@ -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
+}
diff --git a/internal/api/types.go b/internal/api/types.go
new file mode 100644
index 0000000..8379712
--- /dev/null
+++ b/internal/api/types.go
@@ -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"
+ }
+}
diff --git a/internal/render/gif.go b/internal/render/gif.go
new file mode 100644
index 0000000..6762d52
--- /dev/null
+++ b/internal/render/gif.go
@@ -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
+}
diff --git a/internal/render/png.go b/internal/render/png.go
new file mode 100644
index 0000000..0fde33a
--- /dev/null
+++ b/internal/render/png.go
@@ -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
+}
diff --git a/internal/render/software.go b/internal/render/software.go
new file mode 100644
index 0000000..e04782e
--- /dev/null
+++ b/internal/render/software.go
@@ -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
+}
diff --git a/internal/service/merger.go b/internal/service/merger.go
new file mode 100644
index 0000000..c3d8f85
--- /dev/null
+++ b/internal/service/merger.go
@@ -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
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..90f65ea
--- /dev/null
+++ b/main.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+
+ "blockyserver/internal/api"
+ "blockyserver/internal/service"
+)
+
+func main() {
+ port := flag.Int("port", 8080, "Port to listen on")
+ flag.Parse()
+
+ log.Println("Loading merge service...")
+ svc, err := service.NewMergeService()
+ if err != nil {
+ log.Fatalf("Failed to create merge service: %v", err)
+ }
+
+ srv := api.NewServer(svc)
+
+ addr := fmt.Sprintf(":%d", *port)
+ log.Printf("Starting server on %s", addr)
+ log.Printf("Endpoints:")
+ log.Printf(" GET /docs - Swagger UI")
+ log.Printf(" GET /openapi.json - OpenAPI spec")
+ log.Printf(" POST /render/glb - Returns GLB binary")
+ log.Printf(" POST /render/png - Returns PNG image")
+ log.Printf(" POST /render/gif - Returns animated GIF")
+ log.Printf(" GET /health - Health check")
+
+ if err := http.ListenAndServe(addr, srv); err != nil {
+ log.Fatalf("Server error: %v", err)
+ }
+}