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) + } +}