Files
llmgw/internal/router/objglb.go
Ilker ManapandClaude Opus 5.5 88781cfcfe A job says how far along it is; a ComfyUI model brings its custom nodes
G2: the gate keeps a websocket open to its ComfyUI under a client id of its
own, puts that id on every workflow it lets through — ComfyUI tells only
the client that queued a workflow how it goes — and keeps the last word on
each: the node running, its step of how many. It answers at
/progress/{prompt_id}. While a job's request waits, the router reads the
queue (queued, n ahead) and the gate (running, node, step, percent) and
writes it into the job, which a client reads back. Only into a job of the
same key: a tag naming another's job writes nothing.

G3, the rest of the plumbing: comfyui.nodes names custom nodes by https
repository and full commit, with the PyPI packages they need. The gate puts
each in the replica's own custom_nodes at that commit and installs the
packages beside the replica, constrained to the versions ComfyUI's
environment already has, which it never changes. The catalog refuses a
branch for a commit and anything but package specs for requirements. An
OBJ a node saves leaves the gateway as a GLB, colours kept, so every mesh
can carry its licence.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:46:44 +02:00

147 lines
4.6 KiB
Go

package router
import (
"bufio"
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
)
// objToGLB turns a Wavefront OBJ — what a 3D node saves when it saves no
// GLB — into a glTF binary: one mesh of its vertices, their colours where
// the file gives them (TripoSR's do, after each position), and its faces
// as triangles. A GLB is the one file a game engine, a browser and the
// licence notice all take, so every mesh leaves the gateway as one.
func objToGLB(obj []byte) ([]byte, error) {
var pos, col []float32
var idx []uint32
colours := true
sc := bufio.NewScanner(bytes.NewReader(obj))
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
for sc.Scan() {
f := strings.Fields(sc.Text())
if len(f) == 0 {
continue
}
switch f[0] {
case "v":
if len(f) < 4 {
return nil, errors.New("a vertex with fewer than three coordinates")
}
for _, s := range f[1:4] {
v, err := strconv.ParseFloat(s, 32)
if err != nil {
return nil, fmt.Errorf("a vertex coordinate %q: %w", s, err)
}
pos = append(pos, float32(v))
}
if len(f) >= 7 {
for _, s := range f[4:7] {
v, err := strconv.ParseFloat(s, 32)
if err != nil {
return nil, fmt.Errorf("a vertex colour %q: %w", s, err)
}
col = append(col, float32(v))
}
} else {
colours = false
}
case "f":
n := uint32(len(pos) / 3) //nolint:gosec // bounded by the file read
var face []uint32
for _, s := range f[1:] {
ref, _, _ := strings.Cut(s, "/")
i, err := strconv.Atoi(ref)
if err != nil || i == 0 {
return nil, fmt.Errorf("a face index %q", s)
}
if i < 0 {
i = int(n) + i + 1
}
if i < 1 || uint32(i) > n { //nolint:gosec // checked positive
return nil, fmt.Errorf("a face index %d past the %d vertices", i, n)
}
face = append(face, uint32(i-1)) //nolint:gosec // checked positive
}
// A polygon as a fan of triangles.
for k := 1; k+1 < len(face); k++ {
idx = append(idx, face[0], face[k], face[k+1])
}
}
}
if err := sc.Err(); err != nil {
return nil, err
}
if len(pos) == 0 || len(idx) == 0 {
return nil, errors.New("the OBJ has no faces")
}
if !colours || len(col) != len(pos) {
col = nil
}
var bin bytes.Buffer
view := func(data any) (offset, length int) {
offset = bin.Len()
_ = binary.Write(&bin, binary.LittleEndian, data)
length = bin.Len() - offset
for bin.Len()%4 != 0 {
bin.WriteByte(0)
}
return offset, length
}
lo := []float32{math.MaxFloat32, math.MaxFloat32, math.MaxFloat32}
hi := []float32{-math.MaxFloat32, -math.MaxFloat32, -math.MaxFloat32}
for i, v := range pos {
lo[i%3], hi[i%3] = min(lo[i%3], v), max(hi[i%3], v)
}
po, pl := view(pos)
io, il := view(idx)
views := []map[string]any{
{"buffer": 0, "byteOffset": po, "byteLength": pl, "target": 34962},
{"buffer": 0, "byteOffset": io, "byteLength": il, "target": 34963},
}
accessors := []map[string]any{
{"bufferView": 0, "componentType": 5126, "count": len(pos) / 3, "type": "VEC3", "min": lo, "max": hi},
{"bufferView": 1, "componentType": 5125, "count": len(idx), "type": "SCALAR"},
}
attrs := map[string]any{"POSITION": 0}
if col != nil {
co, cl := view(col)
views = append(views, map[string]any{"buffer": 0, "byteOffset": co, "byteLength": cl, "target": 34962})
accessors = append(accessors, map[string]any{"bufferView": 2, "componentType": 5126, "count": len(col) / 3, "type": "VEC3"})
attrs["COLOR_0"] = 2
}
doc := map[string]any{
"asset": map[string]any{"version": "2.0", "generator": "llmgw"},
"scene": 0,
"scenes": []any{map[string]any{"nodes": []int{0}}},
"nodes": []any{map[string]any{"mesh": 0}},
"meshes": []any{map[string]any{"primitives": []any{map[string]any{"attributes": attrs, "indices": 1}}}},
"accessors": accessors,
"bufferViews": views,
"buffers": []any{map[string]any{"byteLength": bin.Len()}},
}
js, err := json.Marshal(doc)
if err != nil {
return nil, err
}
for len(js)%4 != 0 {
js = append(js, ' ')
}
out := make([]byte, 0, 28+len(js)+bin.Len())
out = append(out, "glTF"...)
out = binary.LittleEndian.AppendUint32(out, 2)
out = binary.LittleEndian.AppendUint32(out, uint32(28+len(js)+bin.Len())) //nolint:gosec // bounded by the file read
out = binary.LittleEndian.AppendUint32(out, uint32(len(js))) //nolint:gosec // bounded by the file read
out = append(out, "JSON"...)
out = append(out, js...)
out = binary.LittleEndian.AppendUint32(out, uint32(bin.Len())) //nolint:gosec // bounded by the file read
out = append(out, "BIN\x00"...)
return append(out, bin.Bytes()...), nil
}