feat: initial commit
This commit is contained in:
commit
1b81917307
17 changed files with 1565 additions and 0 deletions
55
.github/workflows/docker.yml
vendored
Normal file
55
.github/workflows/docker.yml
vendored
Normal file
|
|
@ -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
|
||||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
67
CLAUDE.md
Normal file
67
CLAUDE.md
Normal file
|
|
@ -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`
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
126
README.md
Normal file
126
README.md
Normal file
|
|
@ -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
|
||||||
|
```
|
||||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
services:
|
||||||
|
blockyserver:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./assets:/app/assets:ro
|
||||||
|
- ./data:/app/data:ro
|
||||||
|
restart: unless-stopped
|
||||||
14
go.mod
Normal file
14
go.mod
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
12
go.sum
Normal file
12
go.sum
Normal file
|
|
@ -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=
|
||||||
138
internal/api/handlers.go
Normal file
138
internal/api/handlers.go
Normal 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
257
internal/api/openapi.go
Normal 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
34
internal/api/server.go
Normal 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
59
internal/api/types.go
Normal 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
63
internal/render/gif.go
Normal 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
38
internal/render/png.go
Normal 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
386
internal/render/software.go
Normal 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
230
internal/service/merger.go
Normal 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
|
||||||
|
}
|
||||||
38
main.go
Normal file
38
main.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue