/* * Proofly Publish — Unity Editor Plugin * Publish selected GameObjects as a draft listing on the Proofly 3D marketplace. * * Install (Unity 2021.3+): * Window → Package Manager → + → Add package from disk → select unity-plugin/package.json * OR copy the Editor/ folder into your project's Assets/ tree. * * Usage: Window → Proofly → Publish to Marketplace * API key: https://proofly.store/settings/api * * Optional: Install com.unity.formats.fbx for FBX export support. */ using System; using System.IO; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; using UnityEditor; using UnityEngine; namespace Proofly { public class ProoflyPublishWindow : EditorWindow { const string API_BASE = "https://api.proofly.store"; const string PREF_KEY = "Proofly_ApiKey"; // Form string _apiKey = ""; string _title = ""; string _description = ""; string _tags = ""; int _catIdx; int _licIdx; bool _isFree = true; float _priceUsd; int _fmtIdx; int _useIdx; // State bool _busy; string _status = ""; bool _statusErr; Vector2 _scroll; static readonly string[] k_Cats = { // Game Dev / Digital (Unity-primary) "game-ready-assets","low-poly","stylized","pbr-textured", "rigged-models","animated-models","game-environments","modular-kits", "base-meshes","weapon-packs","game-props","modular-buildings", "ar-vr", // Characters & Creatures "characters","realistic-characters","stylized-characters", "anime-manga","creatures-monsters","avatars-vtuber","vrchat-avatars", "robots-mechs","aliens","superheroes","base-bodies", // 3D Print "miniatures","fantasy","sci-fi","animals","vehicles", "architecture","jewelry","cosplay","art-sculpture","home-garden", "tools-parts","functional-prints","electronics","board-games", "tabletop-rpg","toys-games","education","mechanical-parts", "nature-plants", }; static readonly string[] k_CatLabels = { // Game Dev / Digital "Game-Ready Assets","Low-Poly","Stylized","PBR/Textured", "Rigged Models","Animated Models","Game Environments","Modular Kits", "Base Meshes","Weapon Packs (Game)","Props (Game)","Modular Buildings", "AR / VR Ready", // Characters & Creatures "Characters","Realistic Characters","Stylized Characters", "Anime/Manga","Creatures & Monsters","Avatars/VTuber","VRChat Avatars", "Robots & Mechs","Aliens","Superheroes","Base Bodies", // 3D Print "Miniatures","Fantasy","Sci-Fi","Animals","Vehicles", "Architecture","Jewelry","Cosplay","Art & Sculpture","Home & Garden", "Tools & Parts","Functional Prints","Electronics","Board Games", "Tabletop RPG","Toys & Games","Education","Mechanical Parts", "Nature & Plants", }; static readonly string[] k_Lics = { "personal", "commercial", "extended" }; static readonly string[] k_LicLabels = { "Personal (non-commercial)", "Commercial", "Extended Commercial", }; static readonly string[] k_Uses = { "", "game_engine", "3d_print", "vr_ar", "archviz", "animation", "vtuber" }; static readonly string[] k_UseLabels = { "— Not specified —", "Game Engine (Unity/Unreal/Godot)", "3D Printing", "VR / AR", "Architectural Viz", "Film / Animation", "VTuber / Streaming", }; // OBJ first — works with no extra packages. static readonly string[] k_Fmts = { "obj", "fbx" }; static readonly string[] k_FmtLabels = { "OBJ (built-in, no extra packages)", "FBX (requires com.unity.formats.fbx)" }; [MenuItem("Window/Proofly/Publish to Marketplace")] static void Open() => GetWindow("Proofly Publish").Show(); void OnEnable() => _apiKey = EditorPrefs.GetString(PREF_KEY, ""); // ─── GUI ───────────────────────────────────────────────────────────── void OnGUI() { _scroll = EditorGUILayout.BeginScrollView(_scroll); var heading = new GUIStyle(EditorStyles.boldLabel) { fontSize = 14, margin = new RectOffset(0,0,8,4) }; GUILayout.Label("Proofly — Publish to Marketplace", heading); EditorGUILayout.HelpBox( "Exports selected GameObjects and creates a private draft listing on Proofly. " + "Add cover images and publish from your dashboard.", MessageType.Info); EditorGUILayout.Space(6); // — API Key — GUILayout.Label("API Key", EditorStyles.boldLabel); EditorGUI.BeginChangeCheck(); _apiKey = EditorGUILayout.TextField(_apiKey); if (EditorGUI.EndChangeCheck()) EditorPrefs.SetString(PREF_KEY, _apiKey); if (!_apiKey.StartsWith("pfly_")) { if (GUILayout.Button("Get your key at proofly.store/settings/api →", EditorStyles.linkLabel)) Application.OpenURL("https://proofly.store/settings/api"); } EditorGUILayout.Space(6); // — Listing Details — GUILayout.Label("Listing Details", EditorStyles.boldLabel); GUILayout.Label("Title *"); _title = EditorGUILayout.TextField(_title); GUILayout.Label("Description"); _description = EditorGUILayout.TextArea(_description, GUILayout.Height(68)); GUILayout.Label("Tags (comma-separated)"); _tags = EditorGUILayout.TextField(_tags); GUILayout.Label("Category"); _catIdx = EditorGUILayout.Popup(_catIdx, k_CatLabels); GUILayout.Label("License"); _licIdx = EditorGUILayout.Popup(_licIdx, k_LicLabels); GUILayout.Label("Intended Use (+15 listing score)"); _useIdx = EditorGUILayout.Popup(_useIdx, k_UseLabels); EditorGUILayout.Space(4); GUILayout.Label("Pricing", EditorStyles.boldLabel); _isFree = EditorGUILayout.Toggle("Free", _isFree); if (!_isFree) { EditorGUILayout.BeginHorizontal(); GUILayout.Label("Price (USD)", GUILayout.Width(90)); _priceUsd = Mathf.Max(0f, EditorGUILayout.FloatField(_priceUsd)); EditorGUILayout.EndHorizontal(); } EditorGUILayout.Space(4); GUILayout.Label("Export Format", EditorStyles.boldLabel); _fmtIdx = EditorGUILayout.Popup(_fmtIdx, k_FmtLabels); EditorGUILayout.Space(8); // — Status message — if (!string.IsNullOrEmpty(_status)) EditorGUILayout.HelpBox(_status, _statusErr ? MessageType.Error : MessageType.Info); // — Selection hint — var sel = Selection.gameObjects; if (sel.Length == 0) EditorGUILayout.HelpBox("Select at least one GameObject in the Hierarchy to export.", MessageType.Warning); else EditorGUILayout.HelpBox($"{sel.Length} object(s) selected — will export as {k_Fmts[_fmtIdx].ToUpper()}.", MessageType.None); EditorGUILayout.Space(6); // — Publish button — GUI.enabled = !_busy && sel.Length > 0; if (GUILayout.Button(_busy ? "Uploading…" : "Publish Draft to Proofly", GUILayout.Height(34))) _ = RunPublish(sel); GUI.enabled = true; if (GUILayout.Button("Open dashboard →", EditorStyles.linkLabel)) Application.OpenURL("https://proofly.store/dashboard"); EditorGUILayout.EndScrollView(); } // ─── Publish flow ───────────────────────────────────────────────────── async Task RunPublish(GameObject[] objects) { _busy = true; SetStatus("Exporting…"); try { if (!_apiKey.StartsWith("pfly_")) { SetStatus("API key must start with 'pfly_'. Get one at proofly.store/settings/api.", true); return; } if (string.IsNullOrWhiteSpace(_title)) { SetStatus("Title is required.", true); return; } string fmt = k_Fmts[_fmtIdx]; string tmp = Path.Combine(Path.GetTempPath(), $"proofly_{Guid.NewGuid():N}.{fmt}"); string exportErr = ""; // Temporarily set selection to the chosen objects for FBX exporter var prevSel = Selection.objects; Selection.objects = objects; bool exported; try { exported = fmt == "fbx" ? TryExportFbx(tmp, ref exportErr) : ExportObj(tmp, objects, ref exportErr); } finally { Selection.objects = prevSel; } if (!exported) { SetStatus($"Export failed: {exportErr}", true); return; } SetStatus("Uploading…"); var r = await UploadAsync(tmp, fmt); try { File.Delete(tmp); } catch { /* ignore temp-file cleanup error */ } if (r.ok) { var msg = string.IsNullOrEmpty(r.markCode) ? "Draft created — opening dashboard…" : $"Draft created ✓ HiddenMark: {r.markCode} — opening dashboard…"; SetStatus(msg); Application.OpenURL(r.dashUrl ?? "https://proofly.store/dashboard"); } else { SetStatus($"Upload failed: {r.error}", true); } } catch (Exception ex) { SetStatus($"Unexpected error: {ex.Message}", true); } finally { _busy = false; Repaint(); } } // ─── OBJ exporter (no extra packages needed) ───────────────────────── static bool ExportObj(string path, GameObject[] objects, ref string err) { try { var sb = new StringBuilder(); sb.AppendLine("# Exported by Proofly Unity Plugin"); int vBase = 1; foreach (var go in objects) { var mf = go.GetComponentInChildren(); if (mf == null || mf.sharedMesh == null) continue; var mesh = mf.sharedMesh; var t = mf.transform; sb.AppendLine($"o {go.name}"); foreach (var v in mesh.vertices) { var w = t.TransformPoint(v); sb.AppendLine($"v {w.x:F6} {w.y:F6} {w.z:F6}"); } if (mesh.normals != null && mesh.normals.Length > 0) foreach (var n in mesh.normals) { var wn = t.TransformDirection(n); sb.AppendLine($"vn {wn.x:F6} {wn.y:F6} {wn.z:F6}"); } if (mesh.uv != null && mesh.uv.Length > 0) foreach (var uv in mesh.uv) sb.AppendLine($"vt {uv.x:F6} {uv.y:F6}"); bool hasNormals = mesh.normals != null && mesh.normals.Length == mesh.vertexCount; bool hasUv = mesh.uv != null && mesh.uv.Length == mesh.vertexCount; var tris = mesh.triangles; for (int i = 0; i < tris.Length; i += 3) { int a = tris[i] + vBase, b = tris[i+1] + vBase, c = tris[i+2] + vBase; if (hasNormals && hasUv) sb.AppendLine($"f {a}/{a}/{a} {b}/{b}/{b} {c}/{c}/{c}"); else if (hasNormals) sb.AppendLine($"f {a}//{a} {b}//{b} {c}//{c}"); else if (hasUv) sb.AppendLine($"f {a}/{a} {b}/{b} {c}/{c}"); else sb.AppendLine($"f {a} {b} {c}"); } vBase += mesh.vertexCount; } File.WriteAllText(path, sb.ToString(), Encoding.UTF8); return true; } catch (Exception ex) { err = ex.Message; return false; } } // ─── FBX exporter (optional com.unity.formats.fbx package) ────────── static bool TryExportFbx(string path, ref string err) { try { var exporterType = Type.GetType( "UnityEditor.Formats.Fbx.Exporter.ModelExporter, Unity.Formats.Fbx.Editor"); if (exporterType == null) { err = "FBX Exporter package not found. Install 'com.unity.formats.fbx' via Package Manager, then try again — or switch the export format to OBJ."; return false; } // ModelExporter.ExportObjects(string path, Object[] objects) var method = exporterType.GetMethod( "ExportObjects", new[] { typeof(string), typeof(UnityEngine.Object[]) }); if (method == null) { err = "FBX Exporter API mismatch. Update 'com.unity.formats.fbx' to the latest version."; return false; } method.Invoke(null, new object[] { path, Selection.objects }); return File.Exists(path); } catch (Exception ex) { err = ex.Message; return false; } } // ─── HTTP upload ────────────────────────────────────────────────────── struct UploadResult { public bool ok; public string error; public string dashUrl; public string markCode; } async Task UploadAsync(string filePath, string ext) { using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; client.DefaultRequestHeaders.Add("X-API-Key", _apiKey); using var form = new MultipartFormDataContent(); var bytes = await Task.Run(() => File.ReadAllBytes(filePath)); var fc = new ByteArrayContent(bytes); fc.Headers.ContentType = MediaTypeHeaderValue.Parse("application/octet-stream"); form.Add(fc, "file", $"model.{ext}"); form.Add(new StringContent(_title.Trim()), "title"); if (!string.IsNullOrWhiteSpace(_description)) form.Add(new StringContent(_description.Trim()), "description"); form.Add(new StringContent(k_Cats[_catIdx]), "category"); form.Add(new StringContent(k_Lics[_licIdx]), "license_code"); form.Add(new StringContent(_isFree ? "true" : "false"), "is_free"); if (!_isFree) form.Add(new StringContent(_priceUsd.ToString("F2")), "price_usd"); if (!string.IsNullOrWhiteSpace(_tags)) form.Add(new StringContent(_tags.Trim()), "tags"); var use = k_Uses[_useIdx]; if (!string.IsNullOrEmpty(use)) form.Add(new StringContent(use), "intended_use"); try { var resp = await client.PostAsync($"{API_BASE}/api/enterprise/publish", form); var body = await resp.Content.ReadAsStringAsync(); if (body.Contains("\"ok\":true") || body.Contains("\"ok\": true")) return new UploadResult { ok = true, dashUrl = JsonStr(body, "dashboard_url"), markCode = JsonStr(body, "mark_code"), }; var msg = JsonStr(body, "message") ?? JsonStr(body, "error") ?? $"HTTP {(int)resp.StatusCode}"; return new UploadResult { ok = false, error = msg }; } catch (TaskCanceledException) { return new UploadResult { ok = false, error = "Upload timed out after 120 s. Try a smaller file or check your connection." }; } catch (Exception ex) { return new UploadResult { ok = false, error = ex.Message }; } } // Minimal JSON string extractor — avoids a Newtonsoft dependency. static string JsonStr(string json, string key) { var tok = $"\"{key}\":"; int i = json.IndexOf(tok, StringComparison.Ordinal); if (i < 0) return null; i += tok.Length; while (i < json.Length && (json[i] == ' ' || json[i] == '\t')) i++; if (i >= json.Length || json[i] != '"') return null; i++; var sb = new StringBuilder(); while (i < json.Length && json[i] != '"') { if (json[i] == '\\' && i + 1 < json.Length) i++; sb.Append(json[i++]); } return sb.ToString(); } void SetStatus(string msg, bool err = false) { _status = msg; _statusErr = err; Repaint(); } } }