Server/client protocol and helpers for remote rigging via rented GPU instances. Builds on-start shell script and model-specific install/rig commands, embeds Python converter/driver scripts and a C# HTTP rig server source, parses remote JSON results and converts them into local RigResult with either remote weights or geodesic fallback.
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using AutoRig.Analyze;
using AutoRig.Dl;
using AutoRig.Rig;
using AutoRig.Solve.Organic;
using AutoRig.Voxel;
namespace AutoRig.Vast;
using Vector3 = System.Numerics.Vector3;
/// <summary>
/// The remote rigging contract. The rented instance boots with an onstart
/// script that stands up a tiny HTTP server (port 8188): POST /rig takes the
/// OBJ bytes, GET /status reports pending/done/error (+ log tail), GET /result
/// returns the rig JSON below. The editor side only ever speaks this contract,
/// so any model the server wraps behaves identically.
///
/// Result JSON: { "status": "done", "joints": [[x,y,z]...], "parents": [-1,0..],
/// "names": ["root",...]?, "weights": { "bone_indices": [[4]...],
/// "weights": [[4]...] }? } — weights are per-VERTEX and optional (geodesic
/// fallback fills in when the remote model is skeleton-only).
/// </summary>
public static class VastProtocol
{
/// <summary>User-adjustable via the editor settings dialog.</summary>
public static int ServerPort { get; set; } = 8188;
/// <summary>Docker image for the remote side: official PyTorch runtime
/// (the model pipelines need CUDA PyTorch; our wrapper server is C#).</summary>
public static string DockerImage { get; set; } = "pytorch/pytorch:2.4.0-cuda12.1-cudnn9-runtime";
/// <summary>Disk to request, GB — checkpoint + env headroom.</summary>
public static int DiskGb { get; set; } = 32;
/// <summary>The onstart script: installs the model pipeline, installs the
/// .NET 8 runtime, and starts the embedded PURE C# rig server (the only
/// python on the box is the model authors' own pipeline). UniRig first —
/// the heaviest local model, so the one users most need the cloud for.
/// The server is model-agnostic: RIG_COMMAND must write {out}/result.json
/// per the contract.</summary>
/// <summary>Whether a remote inference pipeline exists for this model id.
/// Every catalog model is wired; each pipeline clones the authors' repo,
/// pulls the checkpoints, runs their inference on the uploaded mesh, and
/// emits the result contract. These are best-effort (authored from each
/// repo, structurally validated) and skeleton-first — skin weights fill in
/// from our geodesic skinner, matching the local behavior of most models.</summary>
public static bool SupportsRemote( string modelId ) => Pipeline( modelId ) is not null;
public static string BuildOnStart( string modelId )
{
if ( Pipeline( modelId ) is null )
throw new FormatException(
$"Cloud rigging isn't wired up for '{modelId}'. Rig it locally instead." );
var sb = new StringBuilder();
sb.Append( "#!/bin/bash\nset -x\ncd /root\n" );
sb.Append( "pip install -q huggingface_hub trimesh numpy || true\n" );
sb.Append( "mkdir -p /root/pipelines /root/provision /root/rigcmd /root/provisioned /root/out\n" );
sb.Append( RigTxtConverter ); // shared RigNet-txt → result.json converter at /root
// Bake a provision script + a rig-command file for EVERY rentable model,
// so this one box can install any of them on demand (multi-model). Each
// provision script installs into /root/pipelines/{id} and marks itself
// done; the rig-command file is what the server runs for that model.
foreach ( var id in RentableModels() )
{
var tag = "PROV_" + id.ToUpperInvariant();
sb.Append( $"cat > /root/provision/{id}.sh <<'{tag}'\n" );
sb.Append( InstallFor( id ) );
sb.Append( $"touch /root/provisioned/{id}\n" );
sb.Append( $"{tag}\n" );
var rtag = "RC_" + id.ToUpperInvariant();
sb.Append( $"cat > /root/rigcmd/{id} <<'{rtag}'\n" );
sb.Append( RigCommandFor( id ) );
sb.Append( $"\n{rtag}\n" );
}
// Provision the model this box was rented for now, so a straight rent is
// ready without waiting for a /provision call. Others install on demand.
sb.Append( $"bash /root/provision/{modelId}.sh\n" );
// .NET runtime + the model-aware PURE C# server.
sb.Append( "curl -sSL https://dot.net/v1/dotnet-install.sh -o /root/dotnet-install.sh\n" );
sb.Append( "bash /root/dotnet-install.sh --channel 8.0 --install-dir /root/dotnet\n" );
sb.Append( "mkdir -p /root/server\n" );
sb.Append( "cat > /root/server/server.csproj <<'RIGPROJ'\n" + ServerProject + "RIGPROJ\n" );
sb.Append( "cat > /root/server/Program.cs <<'RIGSERVER'\n" + ServerSource + "RIGSERVER\n" );
sb.Append( $"nohup /root/dotnet/dotnet run --project /root/server -- {ServerPort} "
+ "> /root/rig_server.log 2>&1 &\n" );
return sb.ToString();
}
/// <summary>Every catalog model that has a remote pipeline.</summary>
static IEnumerable<string> RentableModels()
=> ModelCatalog.Entries.Where( e => SupportsRemote( e.Id ) ).Select( e => e.Id );
/// <summary>The per-model install root on the box (kept separate so several
/// models can coexist on one instance).</summary>
static string ModelRoot( string modelId ) => $"/root/pipelines/{modelId}";
/// <summary>The model's install commands with {root} resolved to its dir.</summary>
static string InstallFor( string modelId )
=> Pipeline( modelId )?.Install.Replace( "{root}", ModelRoot( modelId ) );
/// <summary>The model's rig command with {root} resolved; {mesh}/{out} stay
/// for the server to substitute per rig.</summary>
static string RigCommandFor( string modelId )
=> Pipeline( modelId )?.RigCommand.Replace( "{root}", ModelRoot( modelId ) );
/// <summary>A model's remote pipeline: its Install commands (run at provision
/// time into {root} = the model's own dir on the box) and the RigCommand the
/// server runs per rig ({mesh}/{out} substituted then). Null = not rentable.</summary>
sealed record ModelPipeline( string Install, string RigCommand );
static ModelPipeline Pipeline( string modelId ) => modelId switch
{
"unirig_v1" => new ModelPipeline(
"git clone --depth 1 https://github.com/VAST-AI-Research/UniRig {root}\n"
+ "pip install -q -r {root}/requirements.txt || true\n"
+ ConvertUniRigScript,
"cd {root} && bash launch/inference/generate_skeleton.sh "
+ "--input {mesh} --output_dir {out} && python /root/convert_unirig.py {out}" ),
"magicarticulate_v1" => new ModelPipeline(
"git clone --depth 1 https://github.com/Seed3D/MagicArticulate {root}\n"
+ "pip install -q -r {root}/requirements.txt || true\n"
+ Hf( "Seed3D/MagicArticulate", "skeleton_ckpt/checkpoint_trainonv2_hier.pth", "{root}" )
+ Hf( "Maikou/Michelangelo", "checkpoints/aligned_shape_latents/shapevae-256.ckpt",
"{root}/third_partys/Michelangelo" ),
"mkdir -p {root}/in && cp {mesh} {root}/in/ && cd {root} && "
+ "CUDA_VISIBLE_DEVICES=0 python demo.py --input_dir {root}/in --output_dir {out} "
+ "--pretrained_weights skeleton_ckpt/checkpoint_trainonv2_hier.pth --save_name res "
+ "--input_pc_num 8192 --apply_marching_cubes --hier_order && python /root/rig_txt2json.py {out}" ),
"puppeteer_v1" => new ModelPipeline(
"git clone --depth 1 https://github.com/Seed3D/Puppeteer {root}\n"
+ "cd {root} && git submodule update --init --depth 1 skeleton/third_partys/Michelangelo || true\n"
+ "pip install -q -r {root}/requirements.txt || true\n"
+ Hf( "Seed3D/Puppeteer", "skeleton_ckpts/puppeteer_skeleton_w_diverse_pose.pth", "{root}/skeleton" )
+ Hf( "Maikou/Michelangelo", "checkpoints/aligned_shape_latents/shapevae-256.ckpt",
"{root}/skeleton/third_partys/Michelangelo" ),
"mkdir -p {root}/in && cp {mesh} {root}/in/ && cd {root}/skeleton && "
+ "CUDA_VISIBLE_DEVICES=0 python demo.py --input_dir {root}/in --output_dir {out} "
+ "--pretrained_weights skeleton_ckpts/puppeteer_skeleton_w_diverse_pose.pth "
+ "--input_pc_num 8192 --joint_token --seq_shuffle && python /root/rig_txt2json.py {out}" ),
// Anymate ships no CLI (Inference.py hardcodes a dataset + save_dir), so
// we drive its own ui_utils/utils API on the uploaded mesh.
"anymate_v1" => new ModelPipeline(
"git clone --recurse-submodules --depth 1 https://github.com/yfde/Anymate {root}\n"
+ "cd {root} && pip install -q -r requirements.txt || true\n"
+ "pip install -q scikit-learn || true\n"
+ Hf( "yfdeng/Anymate", "checkpoints/joint/bert-transformer_latent-train-8gpu-finetune.pth.tar", "{root}" )
+ Hf( "yfdeng/Anymate", "checkpoints/conn/bert-attendjoints_con_combine-train-8gpu-finetune.pth.tar", "{root}" )
+ Hf( "yfdeng/Anymate", "checkpoints/skin/bert-attendjoints_combine-train-8gpu-finetune.pth.tar", "{root}" )
+ AnymateDriver,
"cd {root} && CUDA_VISIBLE_DEVICES=0 python /root/anymate_driver.py {mesh} {out}" ),
// RigAnything runs inference inside Blender's Python (bpy wheel). Writes an
// .npz per mesh under config.inference_out_dir (= "output").
"riganything_v1" => new ModelPipeline(
"git clone --depth 1 https://github.com/Isabella98Liu/RigAnything {root}\n"
+ "cd {root} && pip install -q -r requirements.txt || true\n"
+ "pip install -q bpy || true\n"
+ Hf( "Isabellaliu/RigAnything", "riganything_ckpt.pt", "{root}/ckpt" )
+ "cp {root}/ckpt/riganything_ckpt.pt {root}/ckpt/ckpt.pt || true\n"
+ NpzToJson,
"cd {root} && CUDA_VISIBLE_DEVICES=0 python Inference.py --config config.yaml "
+ "--mesh_path {mesh} && python /root/npz2json.py {root}/output {out}" ),
// SkinTokens' demo.py spawns a Blender server (bpy wheel). Output is a
// rigged .glb we parse for the skeleton.
"skintokens_v1" => new ModelPipeline(
"git clone --depth 1 https://github.com/VAST-AI-Research/SkinTokens {root}\n"
+ "cd {root} && pip install -q -r requirements.txt || true\n"
+ "pip install -q bpy pygltflib || true\n"
+ Hf( "VAST-AI/SkinTokens", "experiments/articulation_xl_quantization_256_token_4/grpo_1400.ckpt", "{root}" )
+ GlbToJson,
"cd {root} && mkdir -p {out} && CUDA_VISIBLE_DEVICES=0 python demo.py "
+ "--input {mesh} --output {out}/rigged.glb --use_skeleton && python /root/glb2json.py {out}/rigged.glb {out}" ),
// RigNet needs a binvox binary + Open3D + torch-geometric; its quick_start
// has no CLI, so a small driver runs predict_* and writes the rig .txt.
"rignet_v1" => new ModelPipeline(
"git clone --depth 1 https://github.com/zhan-xu/RigNet {root}\n"
+ "cd {root} && pip install -q open3d 'torch-scatter' 'torch-sparse' "
+ "'torch-geometric' 'torch-cluster' -f https://data.pyg.org/whl/torch-2.4.0+cu121.html || true\n"
+ "curl -sSL https://www.patrickmin.com/binvox/linux64/binvox -o {root}/binvox && "
+ "chmod +x {root}/binvox || true\n"
+ "python -c \"import gdown; gdown.download("
+ "'https://drive.google.com/uc?id=1gM2Lerk7a2R0g9DwlK3IvCfp8c2aFVXs', "
+ "'{root}/checkpoints.zip', quiet=True)\" && "
+ "cd {root} && unzip -o checkpoints.zip || true\n"
+ RigNetDriver,
"cd {root} && PATH={root}:$PATH CUDA_VISIBLE_DEVICES=0 "
+ "python /root/rignet_driver.py {mesh} {out} && python /root/rig_txt2json.py {out}" ),
_ => null,
};
/// <summary>A huggingface_hub download line for one checkpoint file.</summary>
static string Hf( string repo, string file, string localDir )
=> $"python -c \"from huggingface_hub import hf_hub_download; "
+ $"hf_hub_download('{repo}', '{file}', local_dir='{localDir}')\" || true\n";
/// <summary>Shared converter: the RigNet-style rig .txt (joints/root/hier —
/// what MagicArticulate, Puppeteer, RigNet and RigAnything all emit) into
/// the result contract. Skeleton only; our side skins geodesically.</summary>
const string RigTxtConverter =
"cat > /root/rig_txt2json.py <<'RIGTXT'\n"
+ "import json, sys, glob\n"
+ "out = sys.argv[1]\n"
+ "try:\n"
+ " text = None\n"
+ " for f in sorted(glob.glob(out + '/**/*.txt', recursive=True)):\n"
+ " t = open(f).read()\n"
+ " if 'joints ' in t:\n"
+ " text = t; break\n"
+ " assert text is not None, 'no rig .txt with joints found'\n"
+ " names, pos, idx, parent_name, root = [], [], {}, {}, None\n"
+ " for line in text.splitlines():\n"
+ " p = line.split()\n"
+ " if not p: continue\n"
+ " if p[0] == 'joints':\n"
+ " idx[p[1]] = len(names); names.append(p[1])\n"
+ " pos.append([float(p[2]), float(p[3]), float(p[4])])\n"
+ " elif p[0] == 'root': root = p[1]\n"
+ " elif p[0] == 'hier': parent_name[p[2]] = p[1]\n"
+ " parents = [-1] * len(names)\n"
+ " for n, i in idx.items():\n"
+ " pn = parent_name.get(n)\n"
+ " parents[i] = idx.get(pn, -1) if pn else -1\n"
+ " json.dump({'status': 'done', 'joints': pos, 'parents': parents, 'names': names},\n"
+ " open(out + '/result.json', 'w'))\n"
+ "except Exception as e:\n"
+ " json.dump({'status': 'error', 'log': repr(e)}, open(out + '/result.json', 'w'))\n"
+ "RIGTXT\n";
/// <summary>Anymate ships no single-mesh CLI, so we drive its own public
/// API (process_mesh_to_pc + get_result_joint/connectivity, exactly what
/// their Gradio UI calls) on the uploaded mesh and emit the contract.
/// conns[i] is joint i's parent index (self = root).</summary>
const string AnymateDriver =
"cat > /root/anymate_driver.py <<'ADRV'\n"
+ "import sys, os, json, trimesh, numpy as np\n"
+ "mesh_in, out = sys.argv[1], sys.argv[2]\n"
+ "os.makedirs(out, exist_ok=True)\n"
+ "try:\n"
+ " import torch\n"
+ " from Anymate.utils.ui_utils import process_mesh_to_pc, get_model, get_result_joint, get_result_connectivity\n"
+ " from Anymate.args import ui_args\n"
+ " work = os.path.join(out, 'object.obj')\n"
+ " trimesh.load(mesh_in, force='mesh').export(work)\n"
+ " pc = process_mesh_to_pc(work)\n"
+ " joints = get_result_joint(work, get_model(ui_args.checkpoint_joint), pc)\n"
+ " conns = get_result_connectivity(work, get_model(ui_args.checkpoint_conn), pc, joints)\n"
+ " j = np.asarray(joints.detach().cpu().numpy()).reshape(-1, 3).tolist()\n"
+ " c = np.asarray(conns.detach().cpu().numpy()).reshape(-1).astype(int).tolist()\n"
+ " parents = [-1 if c[i] == i else int(c[i]) for i in range(len(j))]\n"
+ " json.dump({'status': 'done', 'joints': j, 'parents': parents}, open(os.path.join(out, 'result.json'), 'w'))\n"
+ "except Exception:\n"
+ " import traceback\n"
+ " json.dump({'status': 'error', 'log': traceback.format_exc()}, open(os.path.join(out, 'result.json'), 'w'))\n"
+ "ADRV\n";
/// <summary>RigAnything writes one .npz per mesh (joints + connectivity)
/// under its inference_out_dir — read the first and emit the contract,
/// tolerating the several key spellings the repo has used.</summary>
const string NpzToJson =
"cat > /root/npz2json.py <<'NPZ'\n"
+ "import sys, os, json, glob, numpy as np\n"
+ "src, out = sys.argv[1], sys.argv[2]\n"
+ "os.makedirs(out, exist_ok=True)\n"
+ "try:\n"
+ " f = sorted(glob.glob(os.path.join(src, '**', '*.npz'), recursive=True))[0]\n"
+ " d = np.load(f, allow_pickle=True)\n"
+ " def pick(*names):\n"
+ " for n in names:\n"
+ " if n in d.files: return d[n]\n"
+ " return None\n"
+ " joints = pick('joints', 'pred_joints', 'joint_pos', 'bones_joints')\n"
+ " assert joints is not None, 'no joints in npz: %r' % (d.files,)\n"
+ " J = np.asarray(joints).reshape(-1, 3).tolist()\n"
+ " parents = pick('parents', 'pred_parents', 'parent')\n"
+ " if parents is not None:\n"
+ " P = np.asarray(parents).reshape(-1).astype(int).tolist()\n"
+ " else:\n"
+ " c = np.asarray(pick('conns', 'connectivity')).reshape(-1).astype(int).tolist()\n"
+ " P = [-1 if c[i] == i else int(c[i]) for i in range(len(J))]\n"
+ " json.dump({'status': 'done', 'joints': J, 'parents': P}, open(os.path.join(out, 'result.json'), 'w'))\n"
+ "except Exception:\n"
+ " import traceback\n"
+ " json.dump({'status': 'error', 'log': traceback.format_exc()}, open(os.path.join(out, 'result.json'), 'w'))\n"
+ "NPZ\n";
/// <summary>SkinTokens emits a rigged .glb; pull the armature out of it —
/// the skin's joint nodes, their hierarchy from node children, and each
/// bone's rest position accumulated down the parent chain.</summary>
const string GlbToJson =
"cat > /root/glb2json.py <<'GLBJ'\n"
+ "import sys, os, json\n"
+ "src, out = sys.argv[1], sys.argv[2]\n"
+ "os.makedirs(out, exist_ok=True)\n"
+ "try:\n"
+ " from pygltflib import GLTF2\n"
+ " g = GLTF2().load(src)\n"
+ " assert g.skins, 'no armature/skin in glb'\n"
+ " joint_nodes = g.skins[0].joints\n"
+ " idx = {n: i for i, n in enumerate(joint_nodes)}\n"
+ " parent_of = {}\n"
+ " for ni, node in enumerate(g.nodes):\n"
+ " for c in (node.children or []): parent_of[c] = ni\n"
+ " def world(n):\n"
+ " p, cur, seen = [0.0, 0.0, 0.0], n, set()\n"
+ " while cur is not None and cur not in seen:\n"
+ " seen.add(cur)\n"
+ " t = g.nodes[cur].translation or [0, 0, 0]\n"
+ " p = [p[0] + t[0], p[1] + t[1], p[2] + t[2]]\n"
+ " cur = parent_of.get(cur)\n"
+ " return p\n"
+ " joints, parents, names = [], [], []\n"
+ " for n in joint_nodes:\n"
+ " joints.append([float(v) for v in world(n)])\n"
+ " p = parent_of.get(n)\n"
+ " parents.append(idx[p] if p in idx else -1)\n"
+ " names.append(g.nodes[n].name or ('bone_%d' % n))\n"
+ " json.dump({'status': 'done', 'joints': joints, 'parents': parents, 'names': names}, open(os.path.join(out, 'result.json'), 'w'))\n"
+ "except Exception:\n"
+ " import traceback\n"
+ " json.dump({'status': 'error', 'log': traceback.format_exc()}, open(os.path.join(out, 'result.json'), 'w'))\n"
+ "GLBJ\n";
/// <summary>RigNet's quick_start is a hardcoded __main__, so this driver
/// imports its networks + predict_* helpers, runs them on the uploaded
/// mesh (binvox voxelization included), and writes the standard rig .txt
/// (joints/root/hier) our shared converter then parses.</summary>
const string RigNetDriver =
"cat > /root/rignet_driver.py <<'RNDRV'\n"
+ "import sys, os, json\n"
+ "mesh_in, out = sys.argv[1], sys.argv[2]\n"
+ "os.makedirs(out, exist_ok=True)\n"
+ "os.makedirs('quick_start', exist_ok=True)\n"
+ "try:\n"
+ " import torch, numpy as np, open3d as o3d, quick_start as qs\n"
+ " ori, remesh = 'quick_start/model_ori.obj', 'quick_start/model_remesh.obj'\n"
+ " norm = 'quick_start/model_normalized.obj'\n"
+ " m = o3d.io.read_triangle_mesh(mesh_in)\n"
+ " o3d.io.write_triangle_mesh(ori, m)\n"
+ " o3d.io.write_triangle_mesh(remesh, m.simplify_quadric_decimation(4000))\n"
+ " device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')\n"
+ " jointNet = qs.JOINTNET().to(device).eval()\n"
+ " jointNet.load_state_dict(torch.load('checkpoints/gcn_meanshift/model_best.pth.tar')['state_dict'])\n"
+ " rootNet = qs.ROOTNET().to(device).eval()\n"
+ " rootNet.load_state_dict(torch.load('checkpoints/rootnet/model_best.pth.tar')['state_dict'])\n"
+ " boneNet = qs.BONENET().to(device).eval()\n"
+ " boneNet.load_state_dict(torch.load('checkpoints/bonenet/model_best.pth.tar')['state_dict'])\n"
+ " skinNet = qs.SKINNET(nearest_bone=5, use_Dg=True, use_Lf=True)\n"
+ " skinNet.load_state_dict(torch.load('checkpoints/skinnet/model_best.pth.tar')['state_dict'])\n"
+ " skinNet.to(device).eval()\n"
+ " data, vox, sg, tn, sn = qs.create_single_data(remesh)\n"
+ " data.to(device)\n"
+ " data = qs.predict_joints(data, vox, jointNet, 0.75e-5, bandwidth=0.045, mesh_filename=norm)\n"
+ " data.to(device)\n"
+ " skel = qs.predict_skeleton(data, vox, rootNet, boneNet, mesh_filename=norm)\n"
+ " rig = qs.predict_skinning(data, skel, skinNet, sg, norm, subsampling=True)\n"
+ " rig.normalize(sn, -tn)\n"
+ " rig.save(os.path.join(out, 'model_rig.txt'))\n"
+ "except Exception:\n"
+ " import traceback\n"
+ " json.dump({'status': 'error', 'log': traceback.format_exc()}, open(os.path.join(out, 'result.json'), 'w'))\n"
+ " sys.exit(1)\n"
+ "RNDRV\n";
/// <summary>Converts UniRig's predicted skeleton (FBX exported by their
/// pipeline) to the contract's result.json. Best-effort: falls back to an
/// error status carrying the reason so the editor shows it verbatim.</summary>
const string ConvertUniRigScript =
"cat > /root/convert_unirig.py <<'CONV'\n"
+ "import json, sys, glob\n"
+ "out = sys.argv[1]\n"
+ "try:\n"
+ " import trimesh\n"
+ " fbx = (glob.glob(out + '/*.fbx') + glob.glob(out + '/**/*.fbx', recursive=True))[0]\n"
+ " scene = trimesh.load(fbx)\n"
+ " graph = scene.graph\n"
+ " names = [n for n in graph.nodes if n != graph.base_frame]\n"
+ " index = {n: i for i, n in enumerate(names)}\n"
+ " joints, parents = [], []\n"
+ " for n in names:\n"
+ " m = graph.get(n)[0]\n"
+ " joints.append([float(m[0, 3]), float(m[1, 3]), float(m[2, 3])])\n"
+ " p = graph.transforms.parents.get(n)\n"
+ " parents.append(index.get(p, -1) if p != graph.base_frame else -1)\n"
+ " json.dump({'status': 'done', 'joints': joints, 'parents': parents,\n"
+ " 'names': names}, open(out + '/result.json', 'w'))\n"
+ "except Exception as e:\n"
+ " json.dump({'status': 'error', 'log': repr(e)}, open(out + '/result.json', 'w'))\n"
+ "CONV\n";
/// <summary>csproj for the embedded server — bare net8, no packages.</summary>
internal const string ServerProject =
"<Project Sdk=\"Microsoft.NET.Sdk\">\n"
+ " <PropertyGroup>\n"
+ " <OutputType>Exe</OutputType>\n"
+ " <TargetFramework>net8.0</TargetFramework>\n"
+ " <ImplicitUsings>enable</ImplicitUsings>\n"
+ " <Nullable>disable</Nullable>\n"
+ " </PropertyGroup>\n"
+ "</Project>\n";
/// <summary>The embedded rig server — PURE C# (HttpListener + Process, no
/// python), now MODEL-AWARE: POST /provision?model=ID installs a model;
/// POST /rig?model=ID rigs with it (auto-provisioning first if missing) by
/// running /root/rigcmd/ID; GET /status,/result,/health,/models follow. A
/// box can hold several models. When no model is given it falls back to the
/// RIG_COMMAND env (kept for the local contract test). RIG_ROOT sets the
/// working root so the same code runs unchanged in tests.</summary>
internal const string ServerSource =
"using System.Diagnostics;\n"
+ "using System.Linq;\n"
+ "using System.Net;\n"
+ "using System.Text;\n"
+ "using System.Text.Json;\n"
+ "using System.Text.RegularExpressions;\n"
+ "\n"
+ "var root = Environment.GetEnvironmentVariable( \"RIG_ROOT\" ) ?? \"/root\";\n"
+ "var legacy = Environment.GetEnvironmentVariable( \"RIG_COMMAND\" );\n"
+ "var status = \"idle\";\n"
+ "var log = \"\";\n"
+ "var gate = new object();\n"
+ "\n"
+ "string San( string s ) => string.IsNullOrEmpty( s ) ? \"\" : Regex.Replace( s, \"[^a-z0-9_]\", \"\" );\n"
+ "\n"
+ "string Shell( string command )\n"
+ "{\n"
+ " var psi = OperatingSystem.IsWindows()\n"
+ " ? new ProcessStartInfo( \"cmd\", $\"/c {command}\" )\n"
+ " : new ProcessStartInfo( \"/bin/bash\", $\"-c \\\"{command.Replace( \"\\\"\", \"\\\\\\\"\" )}\\\"\" );\n"
+ " psi.RedirectStandardOutput = true;\n"
+ " psi.RedirectStandardError = true;\n"
+ " using var p = Process.Start( psi );\n"
+ " var so = p.StandardOutput.ReadToEnd();\n"
+ " var se = p.StandardError.ReadToEnd();\n"
+ " p.WaitForExit();\n"
+ " var t = so + se;\n"
+ " return t.Length > 4000 ? t[^4000..] : t;\n"
+ "}\n"
+ "\n"
+ "bool Provision( string model )\n"
+ "{\n"
+ " var script = $\"{root}/provision/{model}.sh\";\n"
+ " if ( !File.Exists( script ) ) { lock ( gate ) log = $\"no provision script for {model}\"; return false; }\n"
+ " if ( File.Exists( $\"{root}/provisioned/{model}\" ) ) return true;\n"
+ " lock ( gate ) status = $\"provisioning {model}\";\n"
+ " var output = Shell( $\"bash {script}\" );\n"
+ " lock ( gate ) log = output;\n"
+ " return File.Exists( $\"{root}/provisioned/{model}\" );\n"
+ "}\n"
+ "\n"
+ "void RunRig( string model )\n"
+ "{\n"
+ " Directory.CreateDirectory( $\"{root}/out\" );\n"
+ " try { File.Delete( $\"{root}/out/result.json\" ); } catch { }\n"
+ " string command;\n"
+ " if ( !string.IsNullOrEmpty( model ) )\n"
+ " {\n"
+ " if ( !Provision( model ) ) { lock ( gate ) status = \"error\"; return; }\n"
+ " var rc = $\"{root}/rigcmd/{model}\";\n"
+ " if ( !File.Exists( rc ) ) { lock ( gate ) { status = \"error\"; log = $\"no rigcmd for {model}\"; } return; }\n"
+ " command = File.ReadAllText( rc ).Trim();\n"
+ " }\n"
+ " else command = legacy ?? \"\";\n"
+ " command = command.Replace( \"{mesh}\", $\"{root}/input.obj\" ).Replace( \"{out}\", $\"{root}/out\" );\n"
+ " lock ( gate ) status = \"running\";\n"
+ " var result = Shell( command );\n"
+ " lock ( gate )\n"
+ " {\n"
+ " log = result;\n"
+ " status = File.Exists( $\"{root}/out/result.json\" ) ? \"done\" : \"error\";\n"
+ " }\n"
+ "}\n"
+ "\n"
+ "void RunProvision( string model )\n"
+ "{\n"
+ " var ok = Provision( model );\n"
+ " lock ( gate ) status = ok ? $\"provisioned {model}\" : \"error\";\n"
+ "}\n"
+ "\n"
+ "string StateJson()\n"
+ "{\n"
+ " lock ( gate )\n"
+ " return JsonSerializer.Serialize( new Dictionary<string, string>\n"
+ " {\n"
+ " [\"status\"] = status,\n"
+ " [\"log\"] = log,\n"
+ " } );\n"
+ "}\n"
+ "\n"
+ "var listener = new HttpListener();\n"
+ "listener.Prefixes.Add( $\"http://*:{args[0]}/\" );\n"
+ "try { listener.Start(); }\n"
+ "catch ( HttpListenerException )\n"
+ "{\n"
+ " listener = new HttpListener();\n"
+ " listener.Prefixes.Add( $\"http://127.0.0.1:{args[0]}/\" );\n"
+ " listener.Start();\n"
+ "}\n"
+ "Console.WriteLine( $\"rig server on :{args[0]}\" );\n"
+ "\n"
+ "while ( true )\n"
+ "{\n"
+ " var context = listener.GetContext();\n"
+ " var request = context.Request;\n"
+ " var path = request.Url.AbsolutePath;\n"
+ " var method = request.HttpMethod;\n"
+ " string body;\n"
+ " var code = 200;\n"
+ " if ( method == \"GET\" && path == \"/health\" )\n"
+ " body = \"ready\";\n"
+ " else if ( method == \"GET\" && path == \"/status\" )\n"
+ " body = StateJson();\n"
+ " else if ( method == \"GET\" && path == \"/models\" )\n"
+ " {\n"
+ " var dir = $\"{root}/provisioned\";\n"
+ " body = Directory.Exists( dir )\n"
+ " ? string.Join( \",\", Directory.GetFiles( dir ).Select( Path.GetFileName ) )\n"
+ " : \"\";\n"
+ " }\n"
+ " else if ( method == \"GET\" && path == \"/result\" )\n"
+ " body = File.Exists( $\"{root}/out/result.json\" )\n"
+ " ? File.ReadAllText( $\"{root}/out/result.json\" )\n"
+ " : StateJson();\n"
+ " else if ( method == \"POST\" && path == \"/provision\" )\n"
+ " {\n"
+ " var model = San( request.QueryString[\"model\"] );\n"
+ " if ( model.Length == 0 ) { code = 400; body = \"no model\"; }\n"
+ " else { new Thread( () => RunProvision( model ) ) { IsBackground = true }.Start(); body = \"accepted\"; }\n"
+ " }\n"
+ " else if ( method == \"POST\" && path == \"/rig\" )\n"
+ " {\n"
+ " using ( var file = File.Create( $\"{root}/input.obj\" ) )\n"
+ " request.InputStream.CopyTo( file );\n"
+ " var model = San( request.QueryString[\"model\"] );\n"
+ " new Thread( () => RunRig( model ) ) { IsBackground = true }.Start();\n"
+ " body = \"accepted\";\n"
+ " }\n"
+ " else\n"
+ " {\n"
+ " code = 404;\n"
+ " body = \"no\";\n"
+ " }\n"
+ " var bytes = Encoding.UTF8.GetBytes( body );\n"
+ " context.Response.StatusCode = code;\n"
+ " context.Response.ContentLength64 = bytes.Length;\n"
+ " context.Response.OutputStream.Write( bytes );\n"
+ " context.Response.OutputStream.Close();\n"
+ "}\n";
public sealed class RemoteRig
{
public required Vector3[] Joints;
public required int[] Parents;
public string[] Names;
public int[] BoneIndices; // 4 per vertex, null when absent
public float[] Weights;
}
/// <summary>Parses the /result payload. Throws on structural problems so
/// failures surface with the remote log, never as a silent bad rig.</summary>
public static RemoteRig ParseResult( string json )
{
ArgumentNullException.ThrowIfNull( json );
using var doc = JsonDocument.Parse( json );
var root = doc.RootElement;
var status = root.TryGetProperty( "status", out var s ) ? s.GetString() : null;
if ( status != "done" )
{
var log = root.TryGetProperty( "log", out var l ) ? l.GetString() : "";
throw new FormatException( $"remote rig status '{status ?? "missing"}': {log}" );
}
if ( !root.TryGetProperty( "joints", out var jointsElement )
|| jointsElement.ValueKind != JsonValueKind.Array )
throw new FormatException( "remote rig result has no 'joints'." );
if ( !root.TryGetProperty( "parents", out var parentsElement )
|| parentsElement.ValueKind != JsonValueKind.Array )
throw new FormatException( "remote rig result has no 'parents'." );
var joints = jointsElement.EnumerateArray()
.Select( j => new Vector3( j[0].GetSingle(), j[1].GetSingle(), j[2].GetSingle() ) )
.ToArray();
var parents = parentsElement.EnumerateArray().Select( p => p.GetInt32() ).ToArray();
if ( joints.Length != parents.Length || joints.Length < 2 )
throw new FormatException(
$"remote rig result malformed: {joints.Length} joints / {parents.Length} parents." );
string[] names = null;
if ( root.TryGetProperty( "names", out var namesElement )
&& namesElement.ValueKind == JsonValueKind.Array )
names = namesElement.EnumerateArray().Select( n => n.GetString() ).ToArray();
int[] boneIndices = null;
float[] weights = null;
if ( root.TryGetProperty( "weights", out var w )
&& w.ValueKind == JsonValueKind.Object
&& w.TryGetProperty( "bone_indices", out var bi )
&& w.TryGetProperty( "weights", out var wv ) )
{
boneIndices = bi.EnumerateArray()
.SelectMany( row => row.EnumerateArray().Select( x => x.GetInt32() ) ).ToArray();
weights = wv.EnumerateArray()
.SelectMany( row => row.EnumerateArray().Select( x => x.GetSingle() ) ).ToArray();
}
return new RemoteRig
{
Joints = joints,
Parents = parents,
Names = names,
BoneIndices = boneIndices,
Weights = weights,
};
}
/// <summary>Remote rig → RigResult: skeleton assembled parent-before-child;
/// remote per-vertex weights when provided, geodesic skinning otherwise.</summary>
public static RigResult ToRigResult( AnalysisResult analysis, RemoteRig remote, string modelTitle )
{
ArgumentNullException.ThrowIfNull( analysis );
ArgumentNullException.ThrowIfNull( remote );
var mesh = analysis.Mesh;
var order = new int[remote.Joints.Length];
var skeleton = new RigSkeleton();
var root = Array.IndexOf( remote.Parents, -1 );
if ( root < 0 )
throw new FormatException( "remote rig has no root joint." );
var queue = new Queue<int>();
queue.Enqueue( root );
var emitted = 0;
while ( queue.Count > 0 )
{
var j = queue.Dequeue();
order[j] = emitted++;
skeleton.Joints.Add( new RigJoint
{
Name = remote.Names is not null && j < remote.Names.Length && remote.Names[j] is not null
? remote.Names[j]
: j == root ? "root" : $"bone_{j}",
Parent = j == root ? -1 : order[remote.Parents[j]],
Position = remote.Joints[j],
} );
for ( var c = 0; c < remote.Parents.Length; c++ )
if ( c != root && remote.Parents[c] == j )
queue.Enqueue( c );
}
skeleton.Validate();
SkinWeights weights;
var hasRemoteWeights = remote.BoneIndices is not null
&& remote.BoneIndices.Length == mesh.Positions.Length * 4;
if ( hasRemoteWeights )
{
// Remote bone ids are in remote joint order — remap to BFS order.
var indices = new int[remote.BoneIndices.Length];
for ( var i = 0; i < indices.Length; i++ )
indices[i] = remote.Weights[i] > 0f ? order[remote.BoneIndices[i]] : 0;
weights = new SkinWeights { BoneIndices = indices, Weights = remote.Weights };
}
else
{
var resolution = mesh.TriangleCount < 50_000 ? 96 : 64;
var grid = VoxelGrid.Build( mesh, resolution );
weights = OrganicSkinner.Skin( mesh, grid, skeleton );
}
weights.Validate( mesh, skeleton );
return new RigResult
{
Skeleton = skeleton,
Weights = weights,
SolverName = "deep-learning/vast-cloud",
Degraded = false,
Explanation = $"{modelTitle} rigged remotely on a vast.ai GPU "
+ $"({skeleton.Joints.Count} joints"
+ (hasRemoteWeights ? ", remote skin weights)." : ", geodesic skinning)."),
};
}
}