Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion MeshyRevit/Models/MeshyModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}

Expand Down
175 changes: 105 additions & 70 deletions MeshyRevit/Services/MeshyApiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -29,91 +37,59 @@ public MeshyApiService(string apiKey)

#region Text-to-3D

public async Task<string> CreateTextTo3DPreviewAsync(TextTo3DPreviewRequest request)
public async Task<string> 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<MeshyTaskCreateResponse>(body);
return result.Result;
var result = await PostJsonAsync<MeshyTaskCreateResponse>(
"/openapi/v2/text-to-3d", request, ct);
return RequireTaskId(result);
}

public async Task<string> CreateTextTo3DRefineAsync(TextTo3DRefineRequest request)
public async Task<string> 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<MeshyTaskCreateResponse>(body);
return result.Result;
var result = await PostJsonAsync<MeshyTaskCreateResponse>(
"/openapi/v2/text-to-3d", request, ct);
return RequireTaskId(result);
}

public async Task<MeshyTaskStatus> GetTextTo3DTaskAsync(string taskId)
public Task<MeshyTaskStatus> 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<MeshyTaskStatus>(body);
return GetJsonAsync<MeshyTaskStatus>($"/openapi/v2/text-to-3d/{taskId}", ct);
}

#endregion

#region Image-to-3D

public async Task<string> CreateImageTo3DAsync(ImageTo3DRequest request)
public async Task<string> 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<MeshyTaskCreateResponse>(body);
return result.Result;
var result = await PostJsonAsync<MeshyTaskCreateResponse>(
"/openapi/v1/image-to-3d", request, ct);
return RequireTaskId(result);
}

public async Task<MeshyTaskStatus> GetImageTo3DTaskAsync(string taskId)
public Task<MeshyTaskStatus> 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<MeshyTaskStatus>(body);
return GetJsonAsync<MeshyTaskStatus>($"/openapi/v1/image-to-3d/{taskId}", ct);
}

#endregion

#region Multi-Image-to-3D

public async Task<string> CreateMultiImageTo3DAsync(MultiImageTo3DRequest request)
public async Task<string> 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<MeshyTaskCreateResponse>(body);
return result.Result;
var result = await PostJsonAsync<MeshyTaskCreateResponse>(
"/openapi/v1/multi-image-to-3d", request, ct);
return RequireTaskId(result);
}

public async Task<MeshyTaskStatus> GetMultiImageTo3DTaskAsync(string taskId)
public Task<MeshyTaskStatus> 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<MeshyTaskStatus>(body);
return GetJsonAsync<MeshyTaskStatus>($"/openapi/v1/multi-image-to-3d/{taskId}", ct);
}

#endregion
Expand All @@ -127,19 +103,25 @@ public async Task<MeshyTaskStatus> 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));
Expand All @@ -158,36 +140,89 @@ public async Task<MeshyTaskStatus> PollUntilCompleteAsync(

await Task.Delay(pollIntervalMs, ct);
}

throw new OperationCanceledException();
}

public async Task<string> DownloadObjAsync(string objUrl)
public async Task<string> 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<byte[]> DownloadBytesAsync(string url)
public async Task<byte[]> 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<TResponse> PostJsonAsync<TResponse>(
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<TResponse>(responseBody);
}
}

private async Task<TResponse> GetJsonAsync<TResponse>(string path, CancellationToken ct)
{
var response = await _httpClient.GetAsync(path, ct);
await EnsureSuccessAsync(response);
string responseBody = await response.Content.ReadAsStringAsync();
return Deserialize<TResponse>(responseBody);
}

private static TResponse Deserialize<TResponse>(string body)
{
try
{
var result = JsonConvert.DeserializeObject<TResponse>(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();
Expand Down
Loading
Loading