From c953038e5fcb61272a58b1257df07987f5eea952 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 04:42:45 +0000 Subject: [PATCH] Harden generation pipeline, fix family placement, add CI and tests Bug fixes - Family placement: regenerate the document after activating the family symbol (a common cause of Family (.rfa) placement failures), guard against a missing/unresolvable symbol, and always clean up the temp .rfa file even when placement throws. Robustness - MeshyApiService: null-safe response parsing with clearer errors, DRY GET/POST helpers, a 5-minute download timeout, a minimum poll interval, and an API-key argument check. - Thread the cancellation token through task creation and model download so cancellation works across the whole pipeline, not just polling. - Omit empty texture_prompt from refine requests. UX - Add a working Cancel button (the cancellation plumbing existed but was unreachable from the UI) and guard the generate handlers against re-entrancy, wiring up the previously unused _isGenerating field. Project quality - Add a CI workflow that builds all three Revit configurations on Windows and runs unit tests on every push and pull request. - Add xUnit tests for the (Revit-free) OBJ parser; the test project links the parser sources so it stays cross-platform. - Add .editorconfig, ignore CI packaging/test artifacts. - README: fix the clone URL, document cancellation, add a security note for the plaintext API key, known limitations, and a testing section. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EidhhLZEGQ2JxyGTAkNGvo --- .editorconfig | 29 +++ .github/workflows/ci.yml | 48 +++++ .gitignore | 7 + MeshyRevit/Models/MeshyModels.cs | 2 +- MeshyRevit/Services/MeshyApiService.cs | 175 +++++++++------- MeshyRevit/Services/MeshyImportService.cs | 96 +++++---- MeshyRevit/UI/MeshyGeneratorWindow.xaml | 17 +- MeshyRevit/UI/MeshyGeneratorWindow.xaml.cs | 31 ++- README.md | 49 ++++- tests/MeshyRevit.Tests/MeshyObjParserTests.cs | 191 ++++++++++++++++++ .../MeshyRevit.Tests/MeshyRevit.Tests.csproj | 29 +++ 11 files changed, 549 insertions(+), 125 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 tests/MeshyRevit.Tests/MeshyObjParserTests.cs create mode 100644 tests/MeshyRevit.Tests/MeshyRevit.Tests.csproj diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8747ece --- /dev/null +++ b/.editorconfig @@ -0,0 +1,29 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.{cs,xaml}] +indent_size = 4 + +[*.{csproj,sln,props,targets}] +indent_size = 2 + +[*.{yml,yaml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.cs] +# Code style +dotnet_sort_system_directives_first = true +csharp_new_line_before_open_brace = all +csharp_prefer_braces = true:suggestion +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..717816f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: + - main + - 'claude/**' + pull_request: + branches: + - main + +jobs: + build: + name: Build ${{ matrix.config }} + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + config: + - Release-R2024 + - Release-R2025 + - Release-R2026 + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Build + run: dotnet build Meshy-Revit.sln --configuration ${{ matrix.config }} + + test: + name: Unit tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Run tests + run: dotnet test tests/MeshyRevit.Tests/MeshyRevit.Tests.csproj --configuration Release diff --git a/.gitignore b/.gitignore index c1d5876..a2b5bdb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,10 +14,17 @@ obj/ *.pdb *.exe +## Release packaging (produced by CI) +staging/ +*.zip + ## NuGet packages/ *.nupkg +## Test results +[Tt]est[Rr]esults/ + ## OS Thumbs.db Desktop.ini diff --git a/MeshyRevit/Models/MeshyModels.cs b/MeshyRevit/Models/MeshyModels.cs index fd1f1ee..8a82252 100644 --- a/MeshyRevit/Models/MeshyModels.cs +++ b/MeshyRevit/Models/MeshyModels.cs @@ -52,7 +52,7 @@ public class TextTo3DRefineRequest [JsonProperty("ai_model")] public string AiModel { get; set; } = "latest"; - [JsonProperty("texture_prompt")] + [JsonProperty("texture_prompt", NullValueHandling = NullValueHandling.Ignore)] public string TexturePrompt { get; set; } } diff --git a/MeshyRevit/Services/MeshyApiService.cs b/MeshyRevit/Services/MeshyApiService.cs index 1508f52..1bf179c 100644 --- a/MeshyRevit/Services/MeshyApiService.cs +++ b/MeshyRevit/Services/MeshyApiService.cs @@ -16,10 +16,18 @@ public class MeshyApiService : IDisposable { private const string BaseUrl = "https://api.meshy.ai"; private readonly HttpClient _httpClient; - private static readonly HttpClient _downloadClient = new HttpClient(); + + // Shared, long-lived client for downloading presigned model URLs (no auth header). + private static readonly HttpClient _downloadClient = new HttpClient + { + Timeout = TimeSpan.FromMinutes(5) + }; public MeshyApiService(string apiKey) { + if (string.IsNullOrWhiteSpace(apiKey)) + throw new ArgumentException("[Meshy Revit] API key is required.", nameof(apiKey)); + _httpClient = new HttpClient { BaseAddress = new Uri(BaseUrl) }; _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); @@ -29,91 +37,59 @@ public MeshyApiService(string apiKey) #region Text-to-3D - public async Task CreateTextTo3DPreviewAsync(TextTo3DPreviewRequest request) + public async Task CreateTextTo3DPreviewAsync( + TextTo3DPreviewRequest request, CancellationToken ct = default) { - string json = JsonConvert.SerializeObject(request); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync("/openapi/v2/text-to-3d", content); - await EnsureSuccessAsync(response); - - string body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - return result.Result; + var result = await PostJsonAsync( + "/openapi/v2/text-to-3d", request, ct); + return RequireTaskId(result); } - public async Task CreateTextTo3DRefineAsync(TextTo3DRefineRequest request) + public async Task CreateTextTo3DRefineAsync( + TextTo3DRefineRequest request, CancellationToken ct = default) { - string json = JsonConvert.SerializeObject(request); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync("/openapi/v2/text-to-3d", content); - await EnsureSuccessAsync(response); - - string body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - return result.Result; + var result = await PostJsonAsync( + "/openapi/v2/text-to-3d", request, ct); + return RequireTaskId(result); } - public async Task GetTextTo3DTaskAsync(string taskId) + public Task GetTextTo3DTaskAsync(string taskId, CancellationToken ct = default) { - var response = await _httpClient.GetAsync($"/openapi/v2/text-to-3d/{taskId}"); - await EnsureSuccessAsync(response); - - string body = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(body); + return GetJsonAsync($"/openapi/v2/text-to-3d/{taskId}", ct); } #endregion #region Image-to-3D - public async Task CreateImageTo3DAsync(ImageTo3DRequest request) + public async Task CreateImageTo3DAsync( + ImageTo3DRequest request, CancellationToken ct = default) { - string json = JsonConvert.SerializeObject(request); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync("/openapi/v1/image-to-3d", content); - await EnsureSuccessAsync(response); - - string body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - return result.Result; + var result = await PostJsonAsync( + "/openapi/v1/image-to-3d", request, ct); + return RequireTaskId(result); } - public async Task GetImageTo3DTaskAsync(string taskId) + public Task GetImageTo3DTaskAsync(string taskId, CancellationToken ct = default) { - var response = await _httpClient.GetAsync($"/openapi/v1/image-to-3d/{taskId}"); - await EnsureSuccessAsync(response); - - string body = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(body); + return GetJsonAsync($"/openapi/v1/image-to-3d/{taskId}", ct); } #endregion #region Multi-Image-to-3D - public async Task CreateMultiImageTo3DAsync(MultiImageTo3DRequest request) + public async Task CreateMultiImageTo3DAsync( + MultiImageTo3DRequest request, CancellationToken ct = default) { - string json = JsonConvert.SerializeObject(request); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync("/openapi/v1/multi-image-to-3d", content); - await EnsureSuccessAsync(response); - - string body = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(body); - return result.Result; + var result = await PostJsonAsync( + "/openapi/v1/multi-image-to-3d", request, ct); + return RequireTaskId(result); } - public async Task GetMultiImageTo3DTaskAsync(string taskId) + public Task GetMultiImageTo3DTaskAsync(string taskId, CancellationToken ct = default) { - var response = await _httpClient.GetAsync($"/openapi/v1/multi-image-to-3d/{taskId}"); - await EnsureSuccessAsync(response); - - string body = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(body); + return GetJsonAsync($"/openapi/v1/multi-image-to-3d/{taskId}", ct); } #endregion @@ -127,19 +103,25 @@ public async Task PollUntilCompleteAsync( CancellationToken ct = default, int pollIntervalMs = 3000) { - while (!ct.IsCancellationRequested) + // Guard against a misconfigured interval hammering the API. + if (pollIntervalMs < 1000) + pollIntervalMs = 1000; + + while (true) { + ct.ThrowIfCancellationRequested(); + MeshyTaskStatus status; switch (mode) { case GenerationMode.TextTo3D: - status = await GetTextTo3DTaskAsync(taskId); + status = await GetTextTo3DTaskAsync(taskId, ct); break; case GenerationMode.ImageTo3D: - status = await GetImageTo3DTaskAsync(taskId); + status = await GetImageTo3DTaskAsync(taskId, ct); break; case GenerationMode.MultiImageTo3D: - status = await GetMultiImageTo3DTaskAsync(taskId); + status = await GetMultiImageTo3DTaskAsync(taskId, ct); break; default: throw new ArgumentOutOfRangeException(nameof(mode)); @@ -158,36 +140,89 @@ public async Task PollUntilCompleteAsync( await Task.Delay(pollIntervalMs, ct); } - - throw new OperationCanceledException(); } - public async Task DownloadObjAsync(string objUrl) + public async Task DownloadObjAsync(string objUrl, CancellationToken ct = default) { - var response = await _downloadClient.GetAsync(objUrl); + var response = await _downloadClient.GetAsync(objUrl, ct); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); } - public async Task DownloadBytesAsync(string url) + public async Task DownloadBytesAsync(string url, CancellationToken ct = default) { - var response = await _downloadClient.GetAsync(url); + var response = await _downloadClient.GetAsync(url, ct); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsByteArrayAsync(); } #endregion + #region HTTP helpers + + private async Task PostJsonAsync( + string path, object body, CancellationToken ct) + { + string json = JsonConvert.SerializeObject(body); + using (var content = new StringContent(json, Encoding.UTF8, "application/json")) + { + var response = await _httpClient.PostAsync(path, content, ct); + await EnsureSuccessAsync(response); + string responseBody = await response.Content.ReadAsStringAsync(); + return Deserialize(responseBody); + } + } + + private async Task GetJsonAsync(string path, CancellationToken ct) + { + var response = await _httpClient.GetAsync(path, ct); + await EnsureSuccessAsync(response); + string responseBody = await response.Content.ReadAsStringAsync(); + return Deserialize(responseBody); + } + + private static TResponse Deserialize(string body) + { + try + { + var result = JsonConvert.DeserializeObject(body); + if (result == null) + throw new Exception($"[Meshy Revit] Empty or invalid API response: {Truncate(body)}"); + return result; + } + catch (JsonException ex) + { + throw new Exception( + $"[Meshy Revit] Could not parse API response: {ex.Message}. Body: {Truncate(body)}"); + } + } + + private static string RequireTaskId(MeshyTaskCreateResponse response) + { + if (response == null || string.IsNullOrWhiteSpace(response.Result)) + throw new Exception("[Meshy Revit] API response did not contain a task id."); + return response.Result; + } + private async Task EnsureSuccessAsync(HttpResponseMessage response) { if (!response.IsSuccessStatusCode) { string errorBody = await response.Content.ReadAsStringAsync(); throw new HttpRequestException( - $"[Meshy Revit] API error ({(int)response.StatusCode} {response.ReasonPhrase}): {errorBody}"); + $"[Meshy Revit] API error ({(int)response.StatusCode} {response.ReasonPhrase}): {Truncate(errorBody)}"); } } + private static string Truncate(string text, int max = 500) + { + if (string.IsNullOrEmpty(text)) + return "(empty)"; + return text.Length <= max ? text : text.Substring(0, max) + "..."; + } + + #endregion + public void Dispose() { _httpClient?.Dispose(); diff --git a/MeshyRevit/Services/MeshyImportService.cs b/MeshyRevit/Services/MeshyImportService.cs index 2314f45..a32515e 100644 --- a/MeshyRevit/Services/MeshyImportService.cs +++ b/MeshyRevit/Services/MeshyImportService.cs @@ -41,62 +41,78 @@ public static ElementId PlaceAsFamily(UIApplication uiApp, ParsedMesh mesh) string templatePath = GetFamilyTemplatePath(app); string tempRfaPath = Path.Combine(Path.GetTempPath(), $"Meshy_{Guid.NewGuid():N}.rfa"); - Document familyDoc = app.NewFamilyDocument(templatePath); - try { - using (var trans = new Transaction(familyDoc, "Meshy: Build Family Geometry")) + Document familyDoc = app.NewFamilyDocument(templatePath); + + try { - trans.Start(); + using (var trans = new Transaction(familyDoc, "Meshy: Build Family Geometry")) + { + trans.Start(); - var geomObjects = BuildTessellatedGeometry(mesh); - if (geomObjects == null || geomObjects.Count == 0) - throw new InvalidOperationException("[Meshy Revit] Failed to build tessellated geometry from mesh."); + var geomObjects = BuildTessellatedGeometry(mesh); + if (geomObjects == null || geomObjects.Count == 0) + throw new InvalidOperationException("[Meshy Revit] Failed to build tessellated geometry from mesh."); - var categoryId = new ElementId(BuiltInCategory.OST_GenericModel); - var ds = DirectShape.CreateElement(familyDoc, categoryId); - ds.SetShape(geomObjects); + var categoryId = new ElementId(BuiltInCategory.OST_GenericModel); + var ds = DirectShape.CreateElement(familyDoc, categoryId); + ds.SetShape(geomObjects); - if (familyDoc.FamilyManager.Types.Size == 0) - familyDoc.FamilyManager.NewType(mesh.Name ?? "Meshy_Type"); - else - familyDoc.FamilyManager.RenameCurrentType(mesh.Name ?? "Meshy_Type"); + if (familyDoc.FamilyManager.Types.Size == 0) + familyDoc.FamilyManager.NewType(mesh.Name ?? "Meshy_Type"); + else + familyDoc.FamilyManager.RenameCurrentType(mesh.Name ?? "Meshy_Type"); - trans.Commit(); - } + trans.Commit(); + } - familyDoc.SaveAs(tempRfaPath); - } - finally - { - familyDoc.Close(false); - } + familyDoc.SaveAs(tempRfaPath); + } + finally + { + familyDoc.Close(false); + } - using (var trans = new Transaction(doc, "Meshy: Load Family")) - { - trans.Start(); + using (var trans = new Transaction(doc, "Meshy: Load Family")) + { + trans.Start(); - if (!doc.LoadFamily(tempRfaPath, new MeshyFamilyLoadOptions(), out Family family)) - throw new InvalidOperationException("[Meshy Revit] Failed to load family into project."); + if (!doc.LoadFamily(tempRfaPath, new MeshyFamilyLoadOptions(), out Family family)) + throw new InvalidOperationException("[Meshy Revit] Failed to load family into project."); - var symbolId = family.GetFamilySymbolIds().FirstOrDefault(); - if (symbolId == null || symbolId == ElementId.InvalidElementId) - throw new InvalidOperationException("[Meshy Revit] Family has no symbol."); + var symbolId = family.GetFamilySymbolIds().FirstOrDefault(); + if (symbolId == null || symbolId == ElementId.InvalidElementId) + throw new InvalidOperationException("[Meshy Revit] Family has no symbol."); - var symbol = doc.GetElement(symbolId) as FamilySymbol; - if (!symbol.IsActive) - symbol.Activate(); + if (!(doc.GetElement(symbolId) is FamilySymbol symbol)) + throw new InvalidOperationException("[Meshy Revit] Family symbol could not be resolved."); - var instance = doc.Create.NewFamilyInstance( - XYZ.Zero, - symbol, - Autodesk.Revit.DB.Structure.StructuralType.NonStructural); + if (!symbol.IsActive) + { + symbol.Activate(); + // The symbol must be regenerated before it can be instantiated. + doc.Regenerate(); + } - trans.Commit(); + var instance = doc.Create.NewFamilyInstance( + XYZ.Zero, + symbol, + Autodesk.Revit.DB.Structure.StructuralType.NonStructural); - try { File.Delete(tempRfaPath); } catch { } + trans.Commit(); - return instance.Id; + return instance.Id; + } + } + finally + { + try + { + if (File.Exists(tempRfaPath)) + File.Delete(tempRfaPath); + } + catch { /* temp cleanup is best-effort */ } } } diff --git a/MeshyRevit/UI/MeshyGeneratorWindow.xaml b/MeshyRevit/UI/MeshyGeneratorWindow.xaml index 296448a..10d5f15 100644 --- a/MeshyRevit/UI/MeshyGeneratorWindow.xaml +++ b/MeshyRevit/UI/MeshyGeneratorWindow.xaml @@ -48,12 +48,17 @@ VerticalAlignment="Center"/> - - - - - + + +