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)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ClaudeRevit/UI/SettingsWindow.xaml.cs b/ClaudeRevit/UI/SettingsWindow.xaml.cs
index ba93f55..d61e1c0 100644
--- a/ClaudeRevit/UI/SettingsWindow.xaml.cs
+++ b/ClaudeRevit/UI/SettingsWindow.xaml.cs
@@ -219,11 +219,44 @@ private void ApplyLanguage()
"Uncheck groups you don't need — each request gets smaller in tokens. Helps free / rate-limited providers (e.g. Gemini's free tier, where one request can hit the limit). All on by default.",
"Снимите галочки с ненужных групп — каждый запрос станет меньше по токенам. Полезно для бесплатных / лимитированных провайдеров (напр. бесплатный тир Gemini, где одного запроса хватает до лимита). По умолчанию включены все.");
+ TabGeneral.Header = L("General", "Основное");
+ TabModels.Header = L("Models", "Модели");
+ TabMcp.Header = L("Subscription (MCP)", "Подписка (MCP)");
+ TabTools.Header = L("Tools", "Инструменты");
+ TabAbout.Header = L("About", "О программе");
+ AboutHeader.Text = L("Version & updates", "Версия и обновления");
+ VersionText.Text = L("Current version: ", "Текущая версия: ") + Services.UpdateChecker.CurrentVersion;
+ CheckUpdateButton.Content = L("Check for updates", "Проверить обновления");
+ AboutNote.Text = L(
+ "Updates are notify-only: a loaded add-in can't replace its own DLL while Revit is open, so when a newer release exists the check offers the installer to download — close Revit, run it, reopen. The chat pane footer also shows an “update available” link when one is found.",
+ "Обновления только уведомляют: загруженный аддин не может заменить свою DLL при открытом Revit, поэтому при наличии новой версии предлагается скачать установщик — закройте Revit, запустите его, откройте снова. В подвале панели чата тоже появляется ссылка «доступно обновление».");
+
HelpButton.Content = L("Help / Помощь", "Помощь / Help");
CancelButton.Content = L("Cancel", "Отмена");
SaveButton.Content = L("Save", "Сохранить");
}
+ private async void CheckUpdateButton_Click(object sender, RoutedEventArgs e)
+ {
+ UpdateResultText.Text = L("Checking…", "Проверяю…");
+ try
+ {
+ var r = await Services.UpdateChecker.CheckAsync();
+ if (r.UpdateAvailable && r.DownloadUrl != null)
+ {
+ UpdateResultText.Text = L($"Update {r.Latest} available — opening download…",
+ $"Доступно обновление {r.Latest} — открываю загрузку…");
+ try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(r.DownloadUrl) { UseShellExecute = true }); }
+ catch (Exception ex) { Log.Error("Opening update URL failed", ex); }
+ }
+ else if (r.Error != null)
+ UpdateResultText.Text = L("Couldn't check: ", "Не удалось проверить: ") + r.Error;
+ else
+ UpdateResultText.Text = L("You're on the latest version.", "У вас последняя версия.");
+ }
+ catch (Exception ex) { UpdateResultText.Text = ex.Message; }
+ }
+
// One checkbox per tool group (with its tool count). Unchecking a group drops those tools
// from every request — the token-budget lever for rate-limited / free providers.
private void PopulateToolGroups()
diff --git a/README.md b/README.md
index 0db0041..8e57bab 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,10 @@ Run it on the pay-per-token **Anthropic API**, on your **Claude Pro/Max subscrip
- **Auto (cost-optimized) mode** — the default: a cheap model (Sonnet 5) runs every turn and consults a stronger advisor (Opus 4.8, or Fable 5) mid-turn *only when it needs a plan*, via Anthropic's advisor tool. The cheap model's prompt cache stays warm all session; the advisor is billed only for the short consult. A legacy whole-turn model-switch is available in Settings.
- **Subscription mode (MCP / Claude Code)** — drive Revit on your **Claude Pro/Max subscription** instead of the pay-per-token API. The plugin runs a local **MCP server** (127.0.0.1, token-protected) exposing the Revit tools; the **Claude Code CLI** runs headless in the background and drives them. Tick **Subscription** in the model dropdown (or connect the MCP server to the Claude Desktop app with the ready-made config in Settings). Zero API cost.
- **Model benchmark (📊)** — run a graded task set (basics, composite, rebar, steel) on any model and compare pass rate, rounds, tokens and time. An independent judge grades strictly from an objective before/after probe (never the model's own claims); both the tested model and the judge can run on the subscription for a zero-API-cost benchmark.
-- **Lazy-loaded toolset** — only a core set of tools rides in each request; specialised groups (rebar, MEP, schedules, sheets, annotation, sections, family editing, export) are revealed on demand via `find_tools`, cutting the per-request tool-schema cost by ~⅔ (a big saving on non-caching alt models). `run_batch` repeats one tool over many items in a single transaction.
+- **Lazy-loaded toolset** — only a core set of tools rides in each request; specialised groups (rebar, MEP, schedules, sheets, annotation, sections, family editing, export) are revealed on demand via `find_tools`, cutting the per-request tool-schema cost by ~⅔ (a big saving on non-caching alt models). `run_batch` repeats one tool over many items in a single transaction. In subscription (MCP) mode the handshake also hands the model a full tool index so it can call the right tool without a discovery round-trip.
+- **Smart element filter** — `filter_elements` answers "all walls taller than 3 m on Level 2, and total their length" in one call: unit-aware predicates (mm/m²/m³ pseudo-parameters computed from geometry), AND/OR logic, level/active-view scoping, and an optional count/sum/avg/min/max aggregate.
+- **Version & update check** — the pane shows a clickable "update available" link (and the Settings → About tab a "Check for updates" button) when a newer GitHub release exists; notify-only, since a loaded add-in can't replace its own DLL while Revit runs.
+- **Tabbed settings** — General · Models · Subscription (MCP) · Tools · About.
- **Single-undo per prompt** — Ctrl+Z reverts everything Claude did in one turn
- **Selection awareness** — green pill shows what's selected; Claude knows what "this" means
- **Markdown rendering** + **selectable text** in messages