Code/AutoRig/Vast/VastProtocol.cs

Cloud rigging protocol and server/script builder. Defines the remote contract, builds onstart bash to provision Docker instances, embeds many per-model install and driver scripts, generates a small C# HTTP server project source to run on rented instances, and parses/results mapping from remote JSON into local RigResult.

Process ExecutionNetworkingExternal DownloadFile AccessHttp Calls
🌐 https://dot.net/v1/dotnet-install.sh, https://github.com/VAST-AI-Research/UniRig, https://github.com/Seed3D/MagicArticulate, https://github.com/Seed3D/Puppeteer, https://github.com/yfde/Anymate, https://github.com/Isabella98Liu/RigAnything, https://github.com/VAST-AI-Research/SkinTokens, https://github.com/zhan-xu/RigNet, https://data.pyg.org/whl/torch-2.4.0+cu121.html, https://www.patrickmin.com/binvox/linux64/binvox, https://drive.google.com/uc?id=1gM2Lerk7a2R0g9DwlK3IvCfp8c2aFVXs, https://dot.net/v1/dotnet-install.sh
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)."),
        };
    }
}