using System; using System.Text; using System.Threading; using System.Threading.Tasks; using MCPForUnity.Editor.Security; using MCPForUnity.Editor.Services.AssetGen.Http; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEngine; namespace MCPForUnity.Editor.Services.AssetGen.Providers { /// /// Meshy model provider. Text→3D posts a "preview" task to the v2 text-to-3d endpoint (geometry /// only); when textures are requested it then issues a "refine" task and surfaces the textured /// result. Image→3D posts to the v1 image-to-3d endpoint, which textures in a single call. Each /// task is polled at its OWN endpoint (text vs image). The bearer key is supplied per call and /// never logged; every error is run through . /// public sealed class MeshyAdapter : IModelProviderAdapter { private const string TextEndpoint = "https://api.meshy.ai/openapi/v2/text-to-3d"; private const string ImageEndpoint = "https://api.meshy.ai/openapi/v1/image-to-3d"; public string Id => "meshy"; // Stashed at submit so poll picks the right endpoint, model_urls entry, and texture flow. private string _format = "glb"; private bool _isImage; private bool _wantTexture = true; private string _refineTaskId; private bool _refineSubmitted; public async Task SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct) { if (req == null) throw new ArgumentNullException(nameof(req)); if (http == null) throw new ArgumentNullException(nameof(http)); _format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format.TrimStart('.').ToLowerInvariant(); _wantTexture = req.Texture; _isImage = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase) && (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath)); JObject body; string url; if (_isImage) { // image→3D textures in a single call (no separate refine task). image_url accepts a // hosted URL or an inline base64 data URI (for a local image_path). url = ImageEndpoint; string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath); body = new JObject { ["image_url"] = imageRef, ["ai_model"] = "meshy-6", ["should_texture"] = _wantTexture }; } else { // text→3D preview is geometry only; texturing happens via a follow-up refine task. url = TextEndpoint; body = new JObject { ["mode"] = "preview", ["prompt"] = req.Prompt ?? string.Empty, ["ai_model"] = "meshy-6" }; } return await PostTask(url, body, apiKey, http, ct, "submit"); } public async Task PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct) { if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId)); if (http == null) throw new ArgumentNullException(nameof(http)); bool refinePhase = _refineSubmitted; string pollId = refinePhase ? _refineTaskId : providerJobId; // image tasks live on the v1 image endpoint; preview/refine tasks on v2 text-to-3d. string statusBase = (_isImage && !refinePhase) ? ImageEndpoint : TextEndpoint; var spec = new HttpRequestSpec { Method = "GET", Url = statusBase + "/" + pollId }; spec.Headers["Authorization"] = "Bearer " + apiKey; HttpResult res = await http.SendAsync(spec, ct); JObject json = ParseOk(res, apiKey, "poll"); ProviderPollState state = MapState(json["status"]?.ToString()); var result = new ProviderPollResult { State = state }; // Two-phase (text + texture) splits progress across preview (0..0.5) and refine (0.5..1). bool twoPhase = !_isImage && _wantTexture; float raw = 0f; JToken prog = json["progress"]; if (prog != null && prog.Type != JTokenType.Null) raw = Mathf.Clamp01(prog.Value() / 100f); result.Progress = !twoPhase ? raw : (refinePhase ? 0.5f + raw * 0.5f : raw * 0.5f); if (state == ProviderPollState.Succeeded) { // Preview just finished and textures were requested: start the refine task and keep // polling it; never surface the untextured preview result. if (twoPhase && !refinePhase) { var refineBody = new JObject { ["mode"] = "refine", ["preview_task_id"] = providerJobId, ["ai_model"] = "meshy-6" }; _refineTaskId = await PostTask(TextEndpoint, refineBody, apiKey, http, ct, "refine"); _refineSubmitted = true; result.State = ProviderPollState.Running; result.Progress = 0.5f; return result; } result.Progress = 1f; result.DownloadUrl = ExtractModelUrl(json["model_urls"] as JObject); if (string.IsNullOrEmpty(result.DownloadUrl)) { result.State = ProviderPollState.Failed; result.Error = "Meshy reported success but no model URL was present in the response."; } } else if (state == ProviderPollState.Failed) { string err = json["task_error"]?["message"]?.ToString() ?? json["message"]?.ToString() ?? "Meshy task failed."; result.Error = SecretRedactor.Scrub(err, apiKey); } return result; } /// POST a task body and return its result task id (or null). private static async Task PostTask(string url, JObject body, string apiKey, IHttpTransport http, CancellationToken ct, string phase) { var spec = new HttpRequestSpec { Method = "POST", Url = url, ContentType = "application/json", Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None)) }; spec.Headers["Authorization"] = "Bearer " + apiKey; HttpResult res = await http.SendAsync(spec, ct); JObject json = ParseOk(res, apiKey, phase); string id = json["result"]?.ToString(); if (string.IsNullOrEmpty(id)) throw new Exception(SecretRedactor.Scrub( $"Meshy {phase} returned no task id: " + ProviderHttp.Truncate(ProviderHttp.BodyText(res)), apiKey)); return id; } private string ExtractModelUrl(JObject urls) { if (urls == null) return null; string byFormat = urls[_format]?.ToString(); if (!string.IsNullOrEmpty(byFormat)) return byFormat; string glb = urls["glb"]?.ToString(); if (!string.IsNullOrEmpty(glb)) return glb; string fbx = urls["fbx"]?.ToString(); return string.IsNullOrEmpty(fbx) ? null : fbx; } private static ProviderPollState MapState(string status) { switch ((status ?? string.Empty).ToUpperInvariant()) { case "SUCCEEDED": return ProviderPollState.Succeeded; case "FAILED": case "EXPIRED": case "CANCELED": case "CANCELLED": return ProviderPollState.Failed; case "IN_PROGRESS": return ProviderPollState.Running; case "PENDING": default: return ProviderPollState.Queued; } } private static JObject ParseOk(HttpResult res, string apiKey, string phase) { string text = ProviderHttp.BodyText(res); JObject json = null; if (!string.IsNullOrEmpty(text)) { try { json = JObject.Parse(text); } catch { /* non-JSON */ } } bool ok = res?.Ok == true; if (!ok) { string detail = json?["message"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text); throw new Exception(SecretRedactor.Scrub($"Meshy {phase} failed (status={res?.Status}): {detail}", apiKey)); } return json ?? new JObject(); } } }