diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e428199..f68e151 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,10 @@ jobs: Write-Host "=== Building for Revit $rv ===" # Clean between versions: 2025/2026 (net8) and 2027 (net10) share bin/obj. Remove-Item ClaudeRevit\bin, ClaudeRevit\obj -Recurse -Force -ErrorAction SilentlyContinue - dotnet build ClaudeRevit\ClaudeRevit.csproj -c Release -p:RevitVersion=$rv -p:SkipDeploy=true + # Stamp the numeric version onto the assembly so the running add-in can report it and + # compare against the latest GitHub release (the update check). "v1.97" -> "1.97". + $numver = $ver.TrimStart('v') + dotnet build ClaudeRevit\ClaudeRevit.csproj -c Release -p:RevitVersion=$rv -p:SkipDeploy=true -p:Version=$numver if ($LASTEXITCODE -ne 0) { throw "Build failed for Revit $rv" } $src = "ClaudeRevit\bin\Release\release" if (-not (Test-Path $src)) { throw "Staging folder not found for Revit $rv at $src" } diff --git a/ClaudeRevit.Tests/ClaudeRevit.Tests.csproj b/ClaudeRevit.Tests/ClaudeRevit.Tests.csproj index aa0346f..42227df 100644 --- a/ClaudeRevit.Tests/ClaudeRevit.Tests.csproj +++ b/ClaudeRevit.Tests/ClaudeRevit.Tests.csproj @@ -24,6 +24,7 @@ Revit runtime. Keep this list to files with no Autodesk.Revit dependency. --> + diff --git a/ClaudeRevit.Tests/UnitsTests.cs b/ClaudeRevit.Tests/UnitsTests.cs new file mode 100644 index 0000000..995c6ca --- /dev/null +++ b/ClaudeRevit.Tests/UnitsTests.cs @@ -0,0 +1,50 @@ +using ClaudeRevit.Tools; +using Xunit; + +namespace ClaudeRevit.Tests; + +public class UnitsTests +{ + [Fact] + public void FootIsExactly304Point8Mm() + { + Assert.Equal(304.8, Units.FeetToMm(1.0), 9); + Assert.Equal(1.0, Units.MmToFeet(304.8), 9); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(3.2808398950131)] // ~1 m in feet + [InlineData(123.456)] + public void LengthRoundTrips(double feet) + { + Assert.Equal(feet, Units.MmToFeet(Units.FeetToMm(feet)), 9); + } + + [Fact] + public void AreaAndVolumeAreLengthSquaredAndCubed() + { + // 1 ft^2 = 0.3048^2 m^2, 1 ft^3 = 0.3048^3 m^3. + Assert.Equal(0.3048 * 0.3048, Units.SqFeetToSqM(1.0), 9); + Assert.Equal(0.3048 * 0.3048 * 0.3048, Units.CuFeetToCuM(1.0), 12); + } + + [Theory] + [InlineData(0)] + [InlineData(10.5)] + [InlineData(9999.9)] + public void AreaAndVolumeRoundTrip(double v) + { + Assert.Equal(v, Units.SqMToSqFeet(Units.SqFeetToSqM(v)), 9); + Assert.Equal(v, Units.CuMToCuFeet(Units.CuFeetToCuM(v)), 9); + } + + [Fact] + public void KnownMetreConversion() + { + // A 5 m wall is ~16.404 ft internally; converting back gives 5000 mm. + var feet = Units.MmToFeet(5000); + Assert.Equal(5000, Units.FeetToMm(feet), 6); + } +} diff --git a/ClaudeRevit/App.cs b/ClaudeRevit/App.cs index c7ae149..dac0ce9 100644 --- a/ClaudeRevit/App.cs +++ b/ClaudeRevit/App.cs @@ -21,6 +21,7 @@ public Result OnStartup(UIControlledApplication application) ToolRegistry.Instance.Register(new GetLevels()); ToolRegistry.Instance.Register(new GetSelection()); ToolRegistry.Instance.Register(new QueryElements()); + ToolRegistry.Instance.Register(new FilterElements()); ToolRegistry.Instance.Register(new AnalyzeWarnings()); ToolRegistry.Instance.Register(new CreateWall()); ToolRegistry.Instance.Register(new CreateWallType()); diff --git a/ClaudeRevit/Services/ChatService.cs b/ClaudeRevit/Services/ChatService.cs index 0d7d7a2..82861c6 100644 --- a/ClaudeRevit/Services/ChatService.cs +++ b/ClaudeRevit/Services/ChatService.cs @@ -337,7 +337,11 @@ private async Task SendViaClaudeCodeAsync( var idList = sel.Ids.Count > 30 ? string.Join(", ", sel.Ids.Take(30)) + $", … +{sel.Ids.Count - 30} more" : string.Join(", ", sel.Ids); - ctxHeader += $"\n\nCURRENT SELECTION: {sel.Description}. Element IDs: [{idList}]"; + // Inline the per-category breakdown so "what's selected?" is answerable with no tool call. + var cats = sel.CategoryCounts.Count > 0 + ? " — " + string.Join(", ", sel.CategoryCounts.Select(kv => $"{kv.Value}× {kv.Key}")) + : ""; + ctxHeader += $"\n\nCURRENT SELECTION: {sel.Description}{cats}. Element IDs: [{idList}]"; } contextedPrompt = ctxHeader + "\n\n---\n\nUSER REQUEST:\n" + prompt; } diff --git a/ClaudeRevit/Services/ClaudeCodeBackend.cs b/ClaudeRevit/Services/ClaudeCodeBackend.cs index d8b9892..f598d8a 100644 --- a/ClaudeRevit/Services/ClaudeCodeBackend.cs +++ b/ClaudeRevit/Services/ClaudeCodeBackend.cs @@ -339,7 +339,11 @@ private static Process Start(string file, IEnumerable args, string workD UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, - StandardErrorEncoding = Encoding.UTF8 + StandardErrorEncoding = Encoding.UTF8, + // CRITICAL: the prompt (with Cyrillic / any non-ASCII) is written to the CLI's stdin. Without + // this, .NET encodes it with the OS default (CP1251/OEM on a Russian Windows) and `claude` + // receives mojibake — the model literally can't read the request. UTF-8, no BOM. + StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false) }; foreach (var a in args) psi.ArgumentList.Add(a); return Process.Start(psi) ?? throw new InvalidOperationException("Process.Start returned null."); diff --git a/ClaudeRevit/Services/McpServer.cs b/ClaudeRevit/Services/McpServer.cs index 3757ab2..25b10b3 100644 --- a/ClaudeRevit/Services/McpServer.cs +++ b/ClaudeRevit/Services/McpServer.cs @@ -223,9 +223,56 @@ private static string BuildInstructions() var experience = ExperienceStore.Digest(); if (!string.IsNullOrWhiteSpace(experience)) sb.Append("\n\n").Append(experience!.Trim()); + // Full tool index in the handshake so the driving model knows every tool up front and can + // call the right one directly — no discovery round-trips even when the client defers the + // (180) tool schemas. Generated once, cached, and mirrored to a settings .md for the user. + sb.Append("\n\n").Append(ToolIndexMarkdown()); return sb.ToString(); } + private static string? _toolIndexCache; + private static string ToolCatalogPath => Path.Combine(AppDir, "tools-catalog.md"); + + private static string ToolIndexMarkdown() + { + if (_toolIndexCache != null) return _toolIndexCache; + + var allowCode = SettingsStore.AllowCodeExecution; + var byCat = new SortedDictionary>(StringComparer.Ordinal); + foreach (var t in ToolRegistry.Instance.All) + { + if (t.RequiresCodeExecutionOptIn && !allowCode) continue; + var cat = ClaudeRevit.Tools.ToolCatalog.CategoryOf(t); + if (!byCat.TryGetValue(cat, out var list)) byCat[cat] = list = new List(); + list.Add($"- `{t.Name}` — {FirstSentence(t.Description)}"); + } + + var sb = new StringBuilder(); + sb.Append("AVAILABLE TOOLS — the full set is listed here so you can call the right tool by its " + + "exact name without searching first. Schemas load on first use.\n"); + foreach (var kv in byCat) + { + sb.Append("\n**").Append(kv.Key).Append("**\n"); + kv.Value.Sort(StringComparer.Ordinal); + foreach (var line in kv.Value) sb.Append(line).Append('\n'); + } + _toolIndexCache = sb.ToString(); + + try { Directory.CreateDirectory(AppDir); File.WriteAllText(ToolCatalogPath, _toolIndexCache); } + catch { /* the md mirror is a convenience, not required */ } + return _toolIndexCache; + } + + // First sentence of a tool description, trimmed to keep the index compact. + private static string FirstSentence(string desc) + { + if (string.IsNullOrWhiteSpace(desc)) return ""; + var s = desc.Replace('\n', ' ').Trim(); + var dot = s.IndexOf(". ", StringComparison.Ordinal); + if (dot > 0) s = s.Substring(0, dot); + return s.Length > 140 ? s.Substring(0, 140).TrimEnd() + "…" : s; + } + private static async Task<(JsonNode? value, JsonObject? error)> Dispatch(string? method, JsonNode? prms, CancellationToken ct) { switch (method) diff --git a/ClaudeRevit/Services/ToolSearchLogic.cs b/ClaudeRevit/Services/ToolSearchLogic.cs index c580583..337820b 100644 --- a/ClaudeRevit/Services/ToolSearchLogic.cs +++ b/ClaudeRevit/Services/ToolSearchLogic.cs @@ -20,7 +20,7 @@ public sealed record SearchResult(List Categories, string Message); public static readonly HashSet CoreToolNames = new(StringComparer.Ordinal) { // Query / inspection - "get_selection", "query_elements", "get_element_parameters", "get_type_parameters", + "get_selection", "query_elements", "filter_elements", "get_element_parameters", "get_type_parameters", "get_element_locations", "get_element_bounding_box", "get_levels", "get_model_statistics", "get_project_catalog", "get_project_info", "get_active_view_info", "list_family_types", "list_loaded_families", "list_materials", "measure_distance", "analyze_warnings", diff --git a/ClaudeRevit/Services/UpdateChecker.cs b/ClaudeRevit/Services/UpdateChecker.cs new file mode 100644 index 0000000..49f3d15 --- /dev/null +++ b/ClaudeRevit/Services/UpdateChecker.cs @@ -0,0 +1,110 @@ +using System; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Threading.Tasks; + +namespace ClaudeRevit.Services; + +// Reports the running add-in version and checks GitHub for a newer release. Loaded DLLs can't +// self-replace while Revit is open, so this never installs anything — it surfaces "an update is +// available" plus the installer URL, and the user updates on their own terms. All network work is +// best-effort and non-fatal: a failed check just leaves LatestVersion null. +public static class UpdateChecker +{ + private const string Owner = "debug23win"; + private const string Repo = "clauderevit"; + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) }; + + // "v1.97" — from the assembly version stamped at release time; "dev" for a local build (0.0.0). + public static string CurrentVersion + { + get + { + try + { + var v = Assembly.GetExecutingAssembly().GetName().Version; + if (v == null || (v.Major == 0 && v.Minor == 0)) return "dev"; + return v.Build > 0 ? $"v{v.Major}.{v.Minor}.{v.Build}" : $"v{v.Major}.{v.Minor}"; + } + catch { return "dev"; } + } + } + + public sealed class Result + { + public string Current = ""; + public string? Latest; // "v1.98" + public bool UpdateAvailable; + public string? DownloadUrl; // the installer .exe asset, or the release page + public string? Error; + } + + // Query the latest release. Cached for the process lifetime after the first success so the pane + // can call it freely. + private static Result? _cached; + + public static async Task CheckAsync() + { + if (_cached is { UpdateAvailable: true }) return _cached; + + var result = new Result { Current = CurrentVersion }; + if (result.Current == "dev") { result.Error = "local build"; return result; } + + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, + $"https://api.github.com/repos/{Owner}/{Repo}/releases/latest"); + req.Headers.UserAgent.ParseAdd("ClaudeRevit-UpdateChecker"); + req.Headers.Accept.ParseAdd("application/vnd.github+json"); + + using var resp = await Http.SendAsync(req); + if (!resp.IsSuccessStatusCode) { result.Error = $"HTTP {(int)resp.StatusCode}"; return result; } + + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()); + var root = doc.RootElement; + var tag = root.TryGetProperty("tag_name", out var t) ? t.GetString() : null; + if (string.IsNullOrWhiteSpace(tag)) { result.Error = "no tag"; return result; } + + result.Latest = tag; + result.DownloadUrl = root.TryGetProperty("html_url", out var h) ? h.GetString() : null; + if (root.TryGetProperty("assets", out var assets) && assets.ValueKind == JsonValueKind.Array) + foreach (var a in assets.EnumerateArray()) + { + var name = a.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; + if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) && + a.TryGetProperty("browser_download_url", out var u)) + { result.DownloadUrl = u.GetString(); break; } + } + + result.UpdateAvailable = IsNewer(tag!, result.Current); + if (result.UpdateAvailable) _cached = result; + } + catch (Exception ex) { result.Error = ex.Message; } + return result; + } + + // Compare "v1.98" vs "v1.97" numerically, component by component. Unparseable → not newer. + private static bool IsNewer(string latest, string current) + { + var a = Parse(latest); + var b = Parse(current); + for (int i = 0; i < Math.Max(a.Length, b.Length); i++) + { + var x = i < a.Length ? a[i] : 0; + var y = i < b.Length ? b[i] : 0; + if (x != y) return x > y; + } + return false; + } + + private static int[] Parse(string v) + { + v = v.TrimStart('v', 'V'); + var parts = v.Split('.'); + var nums = new int[parts.Length]; + for (int i = 0; i < parts.Length; i++) + nums[i] = int.TryParse(parts[i], out var n) ? n : 0; + return nums; + } +} diff --git a/ClaudeRevit/Tools/FilterElements.cs b/ClaudeRevit/Tools/FilterElements.cs new file mode 100644 index 0000000..097a8a0 --- /dev/null +++ b/ClaudeRevit/Tools/FilterElements.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using Anthropic.Models.Beta.Messages; +using Autodesk.Revit.DB; +using Autodesk.Revit.UI; + +namespace ClaudeRevit.Tools; + +// Smart element filter — the answer to "find all walls taller than 3 m on Level 2, and total their +// length". A structured, UNIT-AWARE query so the model never has to know Revit's internal feet or the +// exact BuiltInParameter enum. Beats a vague natural-language filter: predicates combine with AND/OR, +// pseudo-parameters (length/area/volume/height/elevation) are computed from geometry regardless of how +// they're stored, scoping by level / active view is built in, and an optional aggregate rolls the +// matched set up (count/sum/avg/min/max) in the SAME call. +public class FilterElements : IRevitTool +{ + public string Name => "filter_elements"; + + public string Description => + "Filter elements of a category by parameter conditions and return the matches (id, name, type, " + + "level, plus each matched value). Optionally aggregate the matched set in one call.\n" + + "predicates: array of {parameter, op, value}, combined by `match` ('all'=AND default, 'any'=OR).\n" + + "ops: eq, ne, gt, lt, gte, lte (numeric), contains, starts_with (text), exists, not_exists.\n" + + "Pseudo-parameters (unit-aware, computed from geometry — USE THESE for numeric compares): " + + "'length' (mm), 'height' (mm, bbox Z-extent), 'area' (m2), 'volume' (m3), 'elevation' (mm). " + + "Also 'name', 'type'/'type_name', 'family', 'level', 'category', or ANY parameter by display " + + "name (fuzzy, case-insensitive) for text ops. Numeric gt/lt on a normal shared/instance " + + "parameter works only if it stores an integer; for others use text ops.\n" + + "Scope with `on_level` (level name) and/or `in_active_view`. `aggregate`: {op, parameter} where " + + "op is count|sum|avg|min|max (parameter omitted for count)."; + + public InputSchema InputSchema => new() + { + Properties = new Dictionary + { + ["category"] = JsonSerializer.SerializeToElement(new + { + type = "string", + description = "Revit category, e.g. 'Walls', 'Structural Columns', 'Doors', 'Rooms'." + }), + ["predicates"] = JsonSerializer.SerializeToElement(new + { + type = "array", + description = "Conditions, e.g. [{\"parameter\":\"length\",\"op\":\"gt\",\"value\":3000}].", + items = new + { + type = "object", + properties = new + { + parameter = new { type = "string" }, + op = new { type = "string", @enum = new[] { "eq", "ne", "gt", "lt", "gte", "lte", "contains", "starts_with", "exists", "not_exists" } }, + value = new { description = "String or number (omit for exists/not_exists)." } + }, + required = new[] { "parameter", "op" } + } + }), + ["match"] = JsonSerializer.SerializeToElement(new + { + type = "string", + @enum = new[] { "all", "any" }, + description = "Combine predicates with AND ('all', default) or OR ('any')." + }), + ["on_level"] = JsonSerializer.SerializeToElement(new + { + type = "string", + description = "Only elements hosted on this level (name)." + }), + ["in_active_view"] = JsonSerializer.SerializeToElement(new + { + type = "boolean", + description = "Only elements shown in the active view." + }), + ["aggregate"] = JsonSerializer.SerializeToElement(new + { + type = "object", + description = "Roll up the matched set, e.g. {\"op\":\"sum\",\"parameter\":\"length\"}.", + properties = new + { + op = new { type = "string", @enum = new[] { "count", "sum", "avg", "min", "max" } }, + parameter = new { type = "string" } + }, + required = new[] { "op" } + }), + ["limit"] = JsonSerializer.SerializeToElement(new + { + type = "integer", + description = "Max elements listed (default 50, max 500). Aggregates use the full set.", + minimum = 1, + maximum = 500 + }) + }, + Required = ["category"] + }; + + public bool RequiresTransaction => false; + + public string Execute(IReadOnlyDictionary input, UIApplication app) + { + var doc = app.ActiveUIDocument?.Document + ?? throw new InvalidOperationException("No document is open."); + + var category = input["category"].GetString() + ?? throw new InvalidOperationException("category is required."); + var bic = CategoryResolve.Parse(category); + + var matchAny = input.TryGetValue("match", out var mm) && + string.Equals(mm.GetString(), "any", StringComparison.OrdinalIgnoreCase); + var limit = input.TryGetValue("limit", out var l) ? l.GetInt32() : 50; + if (limit < 1 || limit > 500) limit = 50; + + var predicates = ParsePredicates(input); + + // Scope: active view (much cheaper) or the whole document, then the category. + FilteredElementCollector collector; + if (input.TryGetValue("in_active_view", out var iav) && iav.ValueKind == JsonValueKind.True && + doc.ActiveView != null) + collector = new FilteredElementCollector(doc, doc.ActiveView.Id); + else + collector = new FilteredElementCollector(doc); + + var candidates = collector.OfCategory(bic).WhereElementIsNotElementType(); + + ElementId? levelFilter = null; + if (input.TryGetValue("on_level", out var lv) && lv.ValueKind == JsonValueKind.String) + { + var name = lv.GetString(); + levelFilter = new FilteredElementCollector(doc).OfClass(typeof(Level)).Cast() + .FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase))?.Id; + if (levelFilter == null) + return JsonSerializer.Serialize(new { error = $"Level '{name}' not found." }); + } + + var matched = new List(); + foreach (var el in candidates) + { + try + { + if (levelFilter != null && el.LevelId != levelFilter) continue; + if (Matches(el, doc, predicates, matchAny)) matched.Add(el); + } + catch { /* skip elements that throw on a parameter read */ } + } + + object? aggregate = null; + if (input.TryGetValue("aggregate", out var agg) && agg.ValueKind == JsonValueKind.Object) + aggregate = Aggregate(matched, doc, agg); + + var listed = matched.Take(limit).Select(e => new + { + id = e.Id.Value, + name = e.Name, + type_name = doc.GetElement(e.GetTypeId())?.Name, + level = e.LevelId != ElementId.InvalidElementId ? doc.GetElement(e.LevelId)?.Name : null + }).ToList(); + + return JsonSerializer.Serialize(new + { + category, + match = matchAny ? "any" : "all", + total_matched = matched.Count, + listed = listed.Count, + truncated = matched.Count > listed.Count, + aggregate, + elements = listed + }); + } + + private sealed record Pred(string Param, string Op, JsonElement? Value); + + private static List ParsePredicates(IReadOnlyDictionary input) + { + var list = new List(); + if (!input.TryGetValue("predicates", out var arr) || arr.ValueKind != JsonValueKind.Array) + return list; + foreach (var p in arr.EnumerateArray()) + { + var param = p.TryGetProperty("parameter", out var pr) ? pr.GetString() : null; + var op = p.TryGetProperty("op", out var o) ? o.GetString() : null; + if (string.IsNullOrWhiteSpace(param) || string.IsNullOrWhiteSpace(op)) continue; + JsonElement? val = p.TryGetProperty("value", out var v) ? v : (JsonElement?)null; + list.Add(new Pred(param!.Trim(), op!.Trim().ToLowerInvariant(), val)); + } + return list; + } + + private static bool Matches(Element el, Document doc, List preds, bool any) + { + if (preds.Count == 0) return true; + foreach (var p in preds) + { + var ok = Test(el, doc, p); + if (any && ok) return true; + if (!any && !ok) return false; + } + return !any; + } + + private static bool Test(Element el, Document doc, Pred p) + { + var (num, text) = Resolve(el, doc, p.Param); + var present = num.HasValue || !string.IsNullOrEmpty(text); + switch (p.Op) + { + case "exists": return present; + case "not_exists": return !present; + } + if (p.Value is not { } val) return false; + + // Numeric comparison when both the element value and the predicate value are numbers. + if (num.HasValue && TryNumber(val, out var target)) + { + return p.Op switch + { + "eq" => Math.Abs(num.Value - target) < 1e-6, + "ne" => Math.Abs(num.Value - target) >= 1e-6, + "gt" => num.Value > target, + "lt" => num.Value < target, + "gte" => num.Value >= target, + "lte" => num.Value <= target, + _ => false + }; + } + + // Text comparison otherwise. + var left = text ?? (num?.ToString(CultureInfo.InvariantCulture) ?? ""); + var right = val.ValueKind == JsonValueKind.String + ? val.GetString() ?? "" + : val.ToString(); + return p.Op switch + { + "eq" => string.Equals(left, right, StringComparison.OrdinalIgnoreCase), + "ne" => !string.Equals(left, right, StringComparison.OrdinalIgnoreCase), + "contains" => left.IndexOf(right, StringComparison.OrdinalIgnoreCase) >= 0, + "starts_with" => left.StartsWith(right, StringComparison.OrdinalIgnoreCase), + _ => false + }; + } + + // Returns (numeric-in-friendly-unit, text) for a pseudo- or named parameter. Numeric is only set + // for well-defined units (mm/m2/m3) and integer parameters, so numeric compares are never + // ambiguous about feet vs metres. + private static (double? num, string? text) Resolve(Element el, Document doc, string param) + { + switch (param.ToLowerInvariant()) + { + case "length": + return (el.Location is LocationCurve lc ? lc.Curve.Length * Units.MmPerFoot : (double?)null, null); + case "height": + var bb = el.get_BoundingBox(null); + return (bb != null ? (bb.Max.Z - bb.Min.Z) * Units.MmPerFoot : (double?)null, null); + case "area": + return (Ft2(el, BuiltInParameter.HOST_AREA_COMPUTED, BuiltInParameter.ROOM_AREA), null); + case "volume": + return (Ft3(el, BuiltInParameter.HOST_VOLUME_COMPUTED, BuiltInParameter.ROOM_VOLUME), null); + case "elevation": + if (el is Level lvl) return (lvl.Elevation * Units.MmPerFoot, null); + var bbx = el.get_BoundingBox(null); + return (bbx != null ? bbx.Min.Z * Units.MmPerFoot : (double?)null, null); + case "name": + return (null, el.Name); + case "category": + return (null, el.Category?.Name); + case "level": + return (null, el.LevelId != ElementId.InvalidElementId ? doc.GetElement(el.LevelId)?.Name : null); + case "type": + case "type_name": + return (null, doc.GetElement(el.GetTypeId())?.Name); + case "family": + return (null, (doc.GetElement(el.GetTypeId()) as ElementType)?.FamilyName); + } + + // Any parameter by (fuzzy) display name — exact first, then case-insensitive contains. + var pm = el.LookupParameter(param) ?? FuzzyParam(el, param); + if (pm == null) return (null, null); + return pm.StorageType switch + { + StorageType.Integer => (pm.AsInteger(), pm.AsInteger().ToString(CultureInfo.InvariantCulture)), + StorageType.String => (null, pm.AsString()), + StorageType.Double => (null, pm.AsValueString()), + StorageType.ElementId => (null, pm.AsValueString() ?? pm.AsElementId().Value.ToString()), + _ => (null, pm.AsValueString()) + }; + } + + private static Parameter? FuzzyParam(Element el, string name) => + el.Parameters.Cast().FirstOrDefault(p => + (p.Definition?.Name ?? "").IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0); + + private static double? Ft2(Element el, params BuiltInParameter[] bips) + { + foreach (var bip in bips) + { + var p = el.get_Parameter(bip); + if (p != null && p.HasValue && p.StorageType == StorageType.Double) return p.AsDouble() * Units.SqMPerSqFoot; + } + return null; + } + + private static double? Ft3(Element el, params BuiltInParameter[] bips) + { + foreach (var bip in bips) + { + var p = el.get_Parameter(bip); + if (p != null && p.HasValue && p.StorageType == StorageType.Double) return p.AsDouble() * Units.CuMPerCuFoot; + } + return null; + } + + private static bool TryNumber(JsonElement v, out double d) + { + if (v.ValueKind == JsonValueKind.Number) { d = v.GetDouble(); return true; } + if (v.ValueKind == JsonValueKind.String && + double.TryParse(v.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out d)) return true; + d = 0; return false; + } + + private static object Aggregate(List matched, Document doc, JsonElement agg) + { + var op = (agg.TryGetProperty("op", out var o) ? o.GetString() : "count")?.ToLowerInvariant() ?? "count"; + if (op == "count") return new { op, value = matched.Count }; + + var param = agg.TryGetProperty("parameter", out var pr) ? pr.GetString() : null; + if (string.IsNullOrWhiteSpace(param)) + return new { op, error = "parameter is required for sum/avg/min/max." }; + + var vals = matched + .Select(e => { try { return Resolve(e, doc, param!).num; } catch { return null; } }) + .Where(x => x.HasValue).Select(x => x!.Value).ToList(); + if (vals.Count == 0) return new { op, parameter = param, value = (double?)null, note = "no numeric values" }; + + double result = op switch + { + "sum" => vals.Sum(), + "avg" => vals.Average(), + "min" => vals.Min(), + "max" => vals.Max(), + _ => double.NaN + }; + return new { op, parameter = param, value = Math.Round(result, 3), sampled = vals.Count }; + } +} diff --git a/ClaudeRevit/Tools/GetElementParameters.cs b/ClaudeRevit/Tools/GetElementParameters.cs index f475e8e..5150fb1 100644 --- a/ClaudeRevit/Tools/GetElementParameters.cs +++ b/ClaudeRevit/Tools/GetElementParameters.cs @@ -33,6 +33,11 @@ public class GetElementParameters : IRevitTool type = "array", description = "Optional whitelist of parameter names. Omit to return all parameters.", items = new { type = "string" } + }), + ["relevant_only"] = JsonSerializer.SerializeToElement(new + { + type = "boolean", + description = "When true, drop parameters with an empty/none value — a compact, meaningful set. Default false (all)." }) }, Required = ["element_ids"] @@ -52,6 +57,8 @@ public string Execute(IReadOnlyDictionary input, UIApplicat if (input.TryGetValue("parameter_names", out var pn) && pn.ValueKind == JsonValueKind.Array) whitelist = pn.EnumerateArray().Select(e => e.GetString() ?? "").ToHashSet(); + var relevantOnly = input.TryGetValue("relevant_only", out var ro) && ro.ValueKind == JsonValueKind.True; + var results = ids.Select(id => { var el = doc.GetElement(id); @@ -66,6 +73,12 @@ public string Execute(IReadOnlyDictionary input, UIApplicat storage = p.StorageType.ToString(), read_only = p.IsReadOnly }) + // Revit exposes the same display name more than once (built-in + shared) — collapse + // the noise to one entry per name. Optionally keep only parameters that actually + // carry a value. + .Where(p => !relevantOnly || (p.value.Length > 0 && p.value != "(none)")) + .GroupBy(p => p.name) + .Select(g => g.First()) .ToList(); return new diff --git a/ClaudeRevit/Tools/Units.cs b/ClaudeRevit/Tools/Units.cs new file mode 100644 index 0000000..03459db --- /dev/null +++ b/ClaudeRevit/Tools/Units.cs @@ -0,0 +1,21 @@ +namespace ClaudeRevit.Tools; + +// Single source of truth for the feet↔metric conversions that were copy-pasted (as raw magic +// numbers or per-file local helpers) across ~19 tools. Revit's internal unit is decimal feet; +// the add-in speaks mm / m² / m³ to the user. Pure math, no Revit types — unit-tested. +public static class Units +{ + // Exact by definition: 1 ft = 0.3048 m = 304.8 mm. + public const double MmPerFoot = 304.8; + public const double SqMPerSqFoot = 0.09290304; // 0.3048^2 + public const double CuMPerCuFoot = 0.028316846592; // 0.3048^3 + + public static double FeetToMm(double feet) => feet * MmPerFoot; + public static double MmToFeet(double mm) => mm / MmPerFoot; + + public static double SqFeetToSqM(double sqFeet) => sqFeet * SqMPerSqFoot; + public static double SqMToSqFeet(double sqM) => sqM / SqMPerSqFoot; + + public static double CuFeetToCuM(double cuFeet) => cuFeet * CuMPerCuFoot; + public static double CuMToCuFeet(double cuM) => cuM / CuMPerCuFoot; +} diff --git a/ClaudeRevit/UI/ChatPaneView.xaml b/ClaudeRevit/UI/ChatPaneView.xaml index cdb25b7..7624d72 100644 --- a/ClaudeRevit/UI/ChatPaneView.xaml +++ b/ClaudeRevit/UI/ChatPaneView.xaml @@ -196,8 +196,13 @@ - + + + + diff --git a/ClaudeRevit/UI/ChatPaneView.xaml.cs b/ClaudeRevit/UI/ChatPaneView.xaml.cs index 4a34f5d..0b10d24 100644 --- a/ClaudeRevit/UI/ChatPaneView.xaml.cs +++ b/ClaudeRevit/UI/ChatPaneView.xaml.cs @@ -51,6 +51,35 @@ public ChatPaneView() // code, mark it handled so the chat pane can never crash Revit. Exceptions from // Revit itself or other add-ins are logged but left to their normal handling. Dispatcher.UnhandledException += OnDispatcherUnhandledException; + + _ = CheckForUpdateAsync(); + } + + private string? _updateUrl; + + // Best-effort, non-blocking: if GitHub has a newer release, reveal the footer link. Loaded DLLs + // can't self-replace while Revit is open, so we only point the user at the installer. + private async Task CheckForUpdateAsync() + { + try + { + var r = await UpdateChecker.CheckAsync(); + if (!r.UpdateAvailable || r.DownloadUrl == null) return; + _updateUrl = r.DownloadUrl; + await Dispatcher.InvokeAsync(() => + { + UpdateNotice.Text = $"⬆ {r.Latest} available"; + UpdateNotice.Visibility = Visibility.Visible; + }); + } + catch { /* never let an update check disturb the pane */ } + } + + private void UpdateNotice_Click(object sender, MouseButtonEventArgs e) + { + if (string.IsNullOrEmpty(_updateUrl)) return; + try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(_updateUrl) { UseShellExecute = true }); } + catch (Exception ex) { Log.Error("Opening update URL failed", ex); } } private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) diff --git a/ClaudeRevit/UI/HelpWindow.xaml.cs b/ClaudeRevit/UI/HelpWindow.xaml.cs index 7a67b92..b91729e 100644 --- a/ClaudeRevit/UI/HelpWindow.xaml.cs +++ b/ClaudeRevit/UI/HelpWindow.xaml.cs @@ -1,4 +1,5 @@ using System.Windows; +using ClaudeRevit.Services; namespace ClaudeRevit.UI; @@ -7,5 +8,6 @@ public partial class HelpWindow : Window public HelpWindow() { InitializeComponent(); + Title = $"Claude Revit {UpdateChecker.CurrentVersion} — Help / Помощь"; } } diff --git a/ClaudeRevit/UI/SettingsWindow.xaml b/ClaudeRevit/UI/SettingsWindow.xaml index cd459ac..7732b25 100644 --- a/ClaudeRevit/UI/SettingsWindow.xaml +++ b/ClaudeRevit/UI/SettingsWindow.xaml @@ -30,170 +30,210 @@ Click="SaveButton_Click" IsDefault="True" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Sonnet 5 (balanced) - Haiku 4.5 (cheapest) - - - - Opus 4.8 (default) - Fable 5 (hardest tasks) - - - - - - - - - - - - - - - - - - - - - - - - - - — not used — - Google Gemini (free tier) - ChatGPT / OpenAI (paid) - xAI Grok - DeepSeek (cheap) - Qwen (DashScope) - OpenRouter (has free models) - Groq (free tier) - Ollama (local, free) - LM Studio (local, free) - Custom (any OpenAI-compatible) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sonnet 5 (balanced) + Haiku 4.5 (cheapest) + + + + Opus 4.8 (default) + Fable 5 (hardest tasks) + + + + + + + + + + + + + + + + + + + + + + + + + + — not used — + Google Gemini (free tier) + ChatGPT / OpenAI (paid) + xAI Grok + DeepSeek (cheap) + Qwen (DashScope) + OpenRouter (has free models) + Groq (free tier) + Ollama (local, free) + LM Studio (local, free) + Custom (any OpenAI-compatible) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +