From 2cfece657df440235c4efe750a8b5ee72973ab95 Mon Sep 17 00:00:00 2001 From: debug23win Date: Mon, 5 Oct 2026 23:44:11 +0300 Subject: [PATCH 1/4] Validate BIM results, calibrate benchmarks and checkpoint native workflows --- .gitignore | 4 + .../ClaudeRevit.PaneTests.csproj | 4 + ClaudeRevit.PaneTests/Stubs.cs | 5 + ClaudeRevit.Tests/BenchmarkRunnerStubs.cs | 4 +- ClaudeRevit.Tests/BenchmarkRunnerTests.cs | 3 +- .../CheckpointAndExperienceTests.cs | 64 +++++++ ClaudeRevit.Tests/ClaudeRevit.Tests.csproj | 7 + ClaudeRevit.Tests/ObjectiveEvidenceTests.cs | 37 ++++ ClaudeRevit/App.cs | 9 + ClaudeRevit/Services/BenchmarkCalibration.cs | 29 +++ ClaudeRevit/Services/BenchmarkObjective.cs | 170 ++++++++++++++++++ ClaudeRevit/Services/BenchmarkRunner.cs | 33 +++- ClaudeRevit/Services/BenchmarkStatistics.cs | 28 +++ ClaudeRevit/Services/ChatService.cs | 20 +-- ClaudeRevit/Services/CheckpointPlan.cs | 60 +++++++ ClaudeRevit/Services/ConnectionSpec.cs | 17 ++ ClaudeRevit/Services/CsvEvidence.cs | 34 ++++ ClaudeRevit/Services/ExperienceStore.cs | 10 +- ClaudeRevit/Services/McpServer.cs | 3 + ClaudeRevit/Services/NestingSelection.cs | 17 ++ ClaudeRevit/Services/ScriptJournal.cs | 13 +- ClaudeRevit/Services/SectionProfile.cs | 14 +- .../Services/VerifiedExperienceStore.cs | 39 ++++ ClaudeRevit/Tools/AnalyzeFamilyStructure.cs | 1 + ClaudeRevit/Tools/AuditSpdsSchedule.cs | 34 ++++ ClaudeRevit/Tools/BenchmarkModelProbe.cs | 5 + ClaudeRevit/Tools/CheckpointJob.cs | 121 +++++++++++++ ClaudeRevit/Tools/ConnectionNodeUpdater.cs | 58 ++++++ ClaudeRevit/Tools/ConnectionNodeValidation.cs | 15 +- ClaudeRevit/Tools/ConnectionNodes.cs | 34 +++- .../Tools/ConnectionOpeningGeometry.cs | 28 +++ ClaudeRevit/Tools/CreateParametricSection.cs | 91 ++++++++-- ClaudeRevit/Tools/CreateSpdsSchedule.cs | 8 +- ClaudeRevit/Tools/CreateSpdsTable.cs | 25 ++- ClaudeRevit/Tools/DynamicToolLoader.cs | 2 + ClaudeRevit/Tools/ExportScheduleCsv.cs | 3 + ClaudeRevit/Tools/FamilyDocumentScope.cs | 11 ++ ClaudeRevit/Tools/FlexFamily.cs | 1 + ClaudeRevit/Tools/GetScriptJournal.cs | 4 +- ClaudeRevit/Tools/InspectFamilyFiles.cs | 42 +++++ ClaudeRevit/Tools/LiveSpds.cs | 35 ++-- ClaudeRevit/Tools/ScheduleExportEvidence.cs | 38 ++++ ClaudeRevit/Tools/SpdsTables.cs | 16 +- ClaudeRevit/Tools/ToolCatalog.cs | 6 +- ClaudeRevit/Tools/ToolDispatcher.cs | 2 +- ClaudeRevit/Tools/VerifyModelResult.cs | 95 ++++++++++ ClaudeRevit/UI/BenchmarkWindow.xaml | 5 +- ClaudeRevit/UI/BenchmarkWindow.xaml.cs | 13 +- README.md | 16 +- README.ru.md | 14 +- docs/benchmark.md | 19 ++ docs/bimstarter-reference-cases.md | 40 +++++ docs/releases/v3.8.4.md | 29 +++ docs/structural-workflows.md | 79 ++++++-- 54 files changed, 1402 insertions(+), 112 deletions(-) create mode 100644 ClaudeRevit.Tests/CheckpointAndExperienceTests.cs create mode 100644 ClaudeRevit.Tests/ObjectiveEvidenceTests.cs create mode 100644 ClaudeRevit/Services/BenchmarkCalibration.cs create mode 100644 ClaudeRevit/Services/BenchmarkObjective.cs create mode 100644 ClaudeRevit/Services/BenchmarkStatistics.cs create mode 100644 ClaudeRevit/Services/CheckpointPlan.cs create mode 100644 ClaudeRevit/Services/CsvEvidence.cs create mode 100644 ClaudeRevit/Services/NestingSelection.cs create mode 100644 ClaudeRevit/Services/VerifiedExperienceStore.cs create mode 100644 ClaudeRevit/Tools/AuditSpdsSchedule.cs create mode 100644 ClaudeRevit/Tools/CheckpointJob.cs create mode 100644 ClaudeRevit/Tools/ConnectionNodeUpdater.cs create mode 100644 ClaudeRevit/Tools/ConnectionOpeningGeometry.cs create mode 100644 ClaudeRevit/Tools/InspectFamilyFiles.cs create mode 100644 ClaudeRevit/Tools/ScheduleExportEvidence.cs create mode 100644 ClaudeRevit/Tools/VerifyModelResult.cs create mode 100644 docs/bimstarter-reference-cases.md create mode 100644 docs/releases/v3.8.4.md diff --git a/.gitignore b/.gitignore index 7999f3e..33515a3 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ Thumbs.db *.env appsettings.Development.json secrets.json + +# Local pane verification artifacts and release scanner reports +pane-qa/ +release-security.json diff --git a/ClaudeRevit.PaneTests/ClaudeRevit.PaneTests.csproj b/ClaudeRevit.PaneTests/ClaudeRevit.PaneTests.csproj index 3117766..c8e0aae 100644 --- a/ClaudeRevit.PaneTests/ClaudeRevit.PaneTests.csproj +++ b/ClaudeRevit.PaneTests/ClaudeRevit.PaneTests.csproj @@ -19,6 +19,10 @@ + + + + diff --git a/ClaudeRevit.PaneTests/Stubs.cs b/ClaudeRevit.PaneTests/Stubs.cs index abc4519..ae58479 100644 --- a/ClaudeRevit.PaneTests/Stubs.cs +++ b/ClaudeRevit.PaneTests/Stubs.cs @@ -83,6 +83,10 @@ public sealed record Result(bool UpdateAvailable, string? DownloadUrl, string? L public static class TextUtil { public static string Truncate(string text, int limit) => text.Length > limit ? text[..limit] : text; } public sealed class BenchmarkResult { + public string SeedFingerprint {get;set;}=""; + public string EnvironmentKey {get;set;}=""; + public string ComparisonKey {get;set;}=""; + public ObjectiveReport? Objective {get;set;} public string RevitTime => "0.01s"; public string TaskId { get; set; } = ""; public string Model { get; set; } = ""; public string Title { get; set; } = ""; public string Verdict { get; set; } = "?"; public string Time { get; set; } = "0s"; public long Tokens { get; set; } @@ -90,6 +94,7 @@ public sealed class BenchmarkResult } public static class BenchmarkRunner { + public static void WriteSummary(IEnumerable rows) { } public static BenchmarkExecution? Execution, Judge; public static TaskCompletionSource Pending = new(); public static Action? Status; diff --git a/ClaudeRevit.Tests/BenchmarkRunnerStubs.cs b/ClaudeRevit.Tests/BenchmarkRunnerStubs.cs index 00d9863..87f6371 100644 --- a/ClaudeRevit.Tests/BenchmarkRunnerStubs.cs +++ b/ClaudeRevit.Tests/BenchmarkRunnerStubs.cs @@ -33,7 +33,7 @@ public sealed class ToolDispatcher { public static ToolDispatcher Instance { get; } = new(); public static bool ForceSuppress; - public bool Family, ScopeActive; + public bool Family, ScopeActive, LevelCreated; public int Starts, Ends; private string _seedKey = "", _fixtureKey = ""; public List Tools { get; } = new(); @@ -54,7 +54,7 @@ public Task BenchmarkScopeAsync(bool begin, string key, CancellationToke return Task.FromResult(ClaudeRevit.Services.DocumentSessions.CurrentDocumentKey); } public Task BenchmarkProbeAsync(CancellationToken ct, bool eligibilityOnly = false) - { ct.ThrowIfCancellationRequested(); return Task.FromResult(JsonSerializer.Serialize(new { is_family_document = Family, total = 0, nested_seed_types = Array.Empty() })); } + { ct.ThrowIfCancellationRequested(); return Task.FromResult(JsonSerializer.Serialize(new { is_family_document = Family, total = 0, levels = LevelCreated ? 1 : 0, level_elements = LevelCreated ? new[] { new { id = 42, name = "Bench B0", elevation_m = 3.5 } } : Array.Empty(), nested_seed_types = Array.Empty() })); } public Task ExecuteAsync(string name, IReadOnlyDictionary input, CancellationToken ct, string documentKey) { Tools.Add(name); diff --git a/ClaudeRevit.Tests/BenchmarkRunnerTests.cs b/ClaudeRevit.Tests/BenchmarkRunnerTests.cs index 4e4bd93..933d85c 100644 --- a/ClaudeRevit.Tests/BenchmarkRunnerTests.cs +++ b/ClaudeRevit.Tests/BenchmarkRunnerTests.cs @@ -13,6 +13,7 @@ public class BenchmarkRunnerTests : IDisposable public BenchmarkRunnerTests() { _dispatcher.Starts = _dispatcher.Ends = 0; _dispatcher.ScopeActive = _dispatcher.Family = false; + _dispatcher.LevelCreated = false; _dispatcher.Tools.Clear(); ToolDispatcher.ForceSuppress = false; DocumentSessions.CurrentDocumentKey = "test-document"; ChatService.Send = _ => Task.CompletedTask; ChatService.Grade = "{\"pass\":true,\"score\":90,\"reason\":\"objective test\"}"; } @@ -22,7 +23,7 @@ private Task Run(IReadOnlyList tasks, List rows, [Fact] public async Task SkipsWrongDocumentAndWritesQualitySpeedAndFormulaWithoutInventedPoints() { - ChatService.Send = _ => { Assert.Equal("test-document-scratch", DocumentSessions.CurrentDocumentKey); return Task.CompletedTask; }; + ChatService.Send = _ => { Assert.Equal("test-document-scratch", DocumentSessions.CurrentDocumentKey); _dispatcher.LevelCreated = true; return Task.CompletedTask; }; var rows = new List(); await Run(new[] { BenchmarkTasks.All[0], BenchmarkTasks.All.Single(t => t.Id == "F1") }, rows); Assert.Equal(1, _dispatcher.Starts); Assert.Equal(1, _dispatcher.Ends); Assert.False(_dispatcher.ScopeActive); diff --git a/ClaudeRevit.Tests/CheckpointAndExperienceTests.cs b/ClaudeRevit.Tests/CheckpointAndExperienceTests.cs new file mode 100644 index 0000000..acfc29c --- /dev/null +++ b/ClaudeRevit.Tests/CheckpointAndExperienceTests.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using ClaudeRevit.Services; +using Xunit; +namespace ClaudeRevit.Tests; + +public class CheckpointAndExperienceTests +{ + [Fact] public void ReferenceResolutionKeepsNativeOutputIdsAndRejectsForwardDependencies() + { + var plan=JsonSerializer.Deserialize("""{"steps":[{"key":"level","tool":"create_level","arguments":{"name":"L","elevation_mm":3500}},{"key":"wall","tool":"create_wall","arguments":{"level_id":{"from_step":"level","json_pointer":"/id"},"name":"A"}}]}""",CheckpointPlan.Options)!; + plan.Validate(); + var args=CheckpointPlan.Resolve(plan.Steps[1].Arguments,new Dictionary{{"level",JsonSerializer.SerializeToElement(new{id=42})}}); + Assert.Equal(42,args.GetProperty("level_id").GetInt32());Assert.Equal("A",args.GetProperty("name").GetString()); + plan.Steps.Reverse();Assert.Throws(plan.Validate); + } + [Fact] public void PlanChangesCannotReuseOldCompletionHash() + { + var plan=new CheckpointPlan{Steps=[new(){Key="a",Tool="create_level",Arguments=JsonSerializer.SerializeToElement(new{name="A"})}]};var hash=plan.Hash(); + plan.Steps[0].Arguments=JsonSerializer.SerializeToElement(new{name="B"});Assert.NotEqual(hash,plan.Hash()); + plan.Steps.Add(plan.Steps[0]);Assert.Throws(plan.Validate); + } + [Fact] public void JsonPointerEscapesAndArrayReferencesAreResolved() + { + var args=JsonSerializer.Deserialize("""{"id":{"from_step":"a","json_pointer":"/x~1y/1/~0value"}}"""); + var results=new Dictionary{{"a",JsonSerializer.Deserialize("""{"x/y":[{}, {"~value":88}]}""")}}; + Assert.Equal(88,CheckpointPlan.Resolve(args,results).GetProperty("id").GetInt32()); + } + [Fact] public void NestingPoliciesAvoidParentChildDoubleCountsAcrossThreeLevels() + { + (string,string?)[] pairs=[("assembly",null),("plate","assembly"),("bolt","plate"),("beam",null)]; + Assert.Equal(new[]{"assembly","beam"},NestingSelection.Select(pairs,"parents_only").Order()); + Assert.Equal(new[]{"beam","bolt"},NestingSelection.Select(pairs,"children_only").Order()); + Assert.Equal(4,NestingSelection.Select(pairs,"explicit").Count); + Assert.Throws(()=>NestingSelection.Select([("a","b"),("b","a")],"parents_only")); + } + [Fact] public void FailedAndWrongEnvironmentExperienceNeverBecomesVerifiedAdvice() + { + var dir=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var path=Path.Combine(dir,"records.json"); + try + { + VerifiedExperience Record(string id,string status,string version="2026")=>new(id,"hash","complete code","execute_csharp",version,"template","2026-10-05T00:00:00Z",1,[new("geometry",status,"")],new Dictionary()); + VerifiedExperienceStore.Save(Record("failed","failed"),path);VerifiedExperienceStore.Save(Record("unknown","incomplete"),path);VerifiedExperienceStore.Save(Record("old","passed","2025"),path);VerifiedExperienceStore.Save(Record("good","passed"),path); + Assert.Equal(4,VerifiedExperienceStore.Read(path).Count); + Assert.Equal("good",Assert.Single(VerifiedExperienceStore.Compatible("2026","template",path)).RunId); + Assert.Empty(VerifiedExperienceStore.Compatible("2026","another template",path)); + var digest=VerifiedExperienceStore.Digest(VerifiedExperienceStore.Read(path));Assert.Contains("Choose a better approach freely",digest);Assert.DoesNotContain("complete code",digest); + } + finally{if(Directory.Exists(dir))Directory.Delete(dir,true);} + } + [Fact] public void DependentNodeDimensionsOrderSourcesAndRejectCycles() + { + var dependent=new ConnectionPart{Key="plate",ElementId=1,Parameters=[new(){Name="Width",Unit="mm",ValueFrom=new(){PartKey="beam",Name="b",Scope="type",OffsetMm=20}}]}; + var root=new ConnectionPart{Key="beam",ElementId=2};var spec=new ConnectionSpec{Parts=[dependent,root]}; + Assert.Equal(new[]{"beam","plate"},spec.OrderedParts().Select(p=>p.Key)); + var roundtrip=JsonSerializer.Deserialize(JsonSerializer.Serialize(spec,ConnectionSpec.Options),ConnectionSpec.Options)!;Assert.Equal(20,roundtrip.Parts[0].Parameters[0].ValueFrom!.OffsetMm); + root.RelativeTo="plate";Assert.Throws(()=>spec.OrderedParts()); + } + [Theory] [InlineData("channel",200,300,10,20,10600)] [InlineData("angle",200,300,10,20,6800)] [InlineData("timber_pair",200,300,20,10,54000)] + public void NewSectionsPreserveAnalyticalAreaAndActualClosedPolygonArea(string shape,double w,double h,double t,double f,double expected) + { + double area=0;foreach(var loop in SectionProfile.Loops(shape,w,h,t,f))for(int i=0;i + + + + + + + diff --git a/ClaudeRevit.Tests/ObjectiveEvidenceTests.cs b/ClaudeRevit.Tests/ObjectiveEvidenceTests.cs new file mode 100644 index 0000000..0522e36 --- /dev/null +++ b/ClaudeRevit.Tests/ObjectiveEvidenceTests.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using ClaudeRevit.Services; +using Xunit; +namespace ClaudeRevit.Tests; + +public class ObjectiveEvidenceTests +{ + [Fact] public void ConfidentJudgeCannotPassWrongUnits() + { + var report = BenchmarkObjective.Evaluate("B0", "{\"levels\":0,\"level_elements\":[]}", "{\"levels\":1,\"level_elements\":[{\"id\":1,\"name\":\"Bench B0\",\"elevation_m\":3500}]}"); + var verdict = BenchmarkObjective.Apply(new(true, 100, "perfect", true), report); + Assert.False(verdict.Pass); Assert.True(verdict.Score < 100); Assert.Equal(1, report.Failed); + } + [Fact] public void MissingAndTruncatedEvidenceCannotPass() + { + foreach (var after in new[] { "{}", "{\"levels\":1,\"level_elements_truncated\":true,\"level_elements\":[{\"id\":1,\"name\":\"Bench B0\",\"elevation_m\":3.5}]}" }) + { var r = BenchmarkObjective.Evaluate("B0", "{\"levels\":0,\"level_elements\":[]}", after); Assert.False(r.Passed); Assert.True(r.Incomplete > 0); } + } + [Fact] public void CsvHandlesQuotesNewlinesAndDoesNotAcceptWrongRows() + { + var actual = CsvEvidence.Parse("name,value\r\n\"beam, A\",\"line1\nline2\"\r\n\"a\"\"b\",7\r\n"); + Assert.Equal(3, actual.Count); Assert.Equal("a\"b", actual[2][0]); + Assert.True(CsvEvidence.ContainsRows(actual, new[] { new[] { "beam, A", "line1\nline2" }, new[] { "a\"b", "7" } })); + Assert.False(CsvEvidence.ContainsRows(actual, new[] { new[] { "a\"b", "8" } })); + Assert.Throws(() => CsvEvidence.Parse("\"broken")); + } + [Fact] public void CalibrationKeepsSeedEnvironmentAndFailuresSeparate() + { + BenchmarkResult Row(double seconds, string verdict = "✓", string seed = "seed") => new() { TaskId = "B0", Model = "A", SeedFingerprint = seed, EnvironmentKey = "machine", ComparisonKey = "config", Seconds = seconds, Score = 95, Quality = 95, Verdict = verdict, Objective = new("objective-v1", new[] { new ObjectiveCheck("size", "passed", "") }) }; + var rows = new[] { Row(10), Row(20), Row(30), Row(40), Row(50) }; + var reference = Assert.Single(BenchmarkCalibration.Create(rows)); Assert.Equal(30, reference.MedianSeconds); Assert.Equal(48, reference.P95Seconds); + Assert.Empty(BenchmarkCalibration.Create(rows.Append(Row(60, "✗")))); + Assert.Empty(BenchmarkCalibration.Create(rows.Take(4).Append(Row(30, seed: "different")))); + var ungraded=new BenchmarkResult { TaskId="B0",Model="A",SeedFingerprint="seed",EnvironmentKey="machine",ComparisonKey="config",Seconds=60,Verdict="?" }; + Assert.Empty(BenchmarkCalibration.Create(rows.Append(ungraded))); + } +} diff --git a/ClaudeRevit/App.cs b/ClaudeRevit/App.cs index b0c6e48..0af6f35 100644 --- a/ClaudeRevit/App.cs +++ b/ClaudeRevit/App.cs @@ -252,7 +252,13 @@ public Result OnStartup(UIControlledApplication application) ToolRegistry.Instance.Register(new GetConnectionNode()); ToolRegistry.Instance.Register(new ValidateConnectionNode()); ToolRegistry.Instance.Register(new CreateParametricSection()); + ToolRegistry.Instance.Register(new InspectFamilyFiles()); + ToolRegistry.Instance.Register(new VerifyModelResult()); + ToolRegistry.Instance.Register(new RunCheckpointJob()); + ToolRegistry.Instance.Register(new GetCheckpointJob()); + ToolRegistry.Instance.Register(new GetVerifiedExperience()); ToolRegistry.Instance.Register(new GetSpdsTableProfiles()); + ToolRegistry.Instance.Register(new AuditSpdsSchedule()); ToolRegistry.Instance.Register(new CreateSpdsSchedule()); ToolRegistry.Instance.Register(new CreateSpdsTable()); ToolDispatcher.Initialize(ToolRegistry.Instance); @@ -281,6 +287,8 @@ public Result OnStartup(UIControlledApplication application) // Learning mode: capture the model delta of script tool calls (see ScriptJournal). application.ControlledApplication.DocumentChanged += ScriptJournal.OnDocumentChanged; LiveSpdsUpdater.Register(application); + LiveSpdsUpdater.RegisterMaterialTriggers(); + ConnectionNodeUpdater.Register(application); // Auto-dismiss Revit's modal warning/task dialogs WHILE ClaudeRevit is driving (a chat // turn or a benchmark) — otherwise an unattended run stalls on the first warning (e.g. @@ -395,6 +403,7 @@ private static void OnDialogBoxShowing(object? sender, DialogBoxShowingEventArgs public Result OnShutdown(UIControlledApplication application) { try { LiveSpdsUpdater.Unregister(); } catch { } + try { ConnectionNodeUpdater.Unregister(); } catch { } ChatWindowHost.Shutdown(); // Events registered in OnStartup must be unregistered here (Revit add-in contract). try { application.ControlledApplication.DocumentChanged -= ScriptJournal.OnDocumentChanged; } diff --git a/ClaudeRevit/Services/BenchmarkCalibration.cs b/ClaudeRevit/Services/BenchmarkCalibration.cs new file mode 100644 index 0000000..2919d1d --- /dev/null +++ b/ClaudeRevit/Services/BenchmarkCalibration.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.IO; +namespace ClaudeRevit.Services; + +public sealed record CalibratedReference(string Task, string Seed, string Environment, string Configuration, string Model, int Samples, double MedianSeconds, double P95Seconds, string ObjectiveVersion); +public static class BenchmarkCalibration +{ + public static string PathName => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ClaudeRevit", "benchmark_calibration.json"); + public static IReadOnlyList Create(IEnumerable rows) => rows + .GroupBy(r => $"{r.TaskId}|{r.Model}|{r.SeedFingerprint}|{r.EnvironmentKey}|{r.ComparisonKey}") + .Where(g => g.Count() >= 5 && g.All(r => r.Score.HasValue && r.Verdict == "✓" && r.Quality >= 90 && r.Objective?.Passed == true && r.Seconds > 0 && double.IsFinite(r.Seconds))) + .Select(g => { var r = g.First(); var d = BenchmarkStatistics.Summarize(g.Select(x => (x.Seconds, true))); return new CalibratedReference(r.TaskId, r.SeedFingerprint, r.EnvironmentKey, r.ComparisonKey, r.Model, d.Count, d.Median, d.P95, BenchmarkObjective.Version); }).ToArray(); + public static void Save(IEnumerable rows, string? path = null) + { + var references = Create(rows); if (references.Count == 0) throw new InvalidOperationException("Calibration needs at least five comparable passes with quality >=90 and complete objective checks. Failed runs are not silently discarded."); + path ??= PathName; Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + var tmp = path + ".tmp"; File.WriteAllText(tmp, JsonSerializer.Serialize(references)); File.Move(tmp, path, true); + } + public static (int Seconds, string Source) Resolve(string task, string seed, string environment, string config, int fallback, string? path = null) + { + try + { + var matches = JsonSerializer.Deserialize(File.ReadAllText(path ?? PathName))?.Where(r => r.Task == task && r.Seed == seed && r.Environment == environment && r.Configuration == config && r.ObjectiveVersion == BenchmarkObjective.Version && r.Samples >= 5 && double.IsFinite(r.MedianSeconds) && r.MedianSeconds > 0).ToArray(); + if (matches?.Length == 1) return (Math.Max(1, (int)Math.Ceiling(matches[0].MedianSeconds)), "calibrated median: " + matches[0].Model); + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) { } + return (fallback, "provisional task reference"); + } +} diff --git a/ClaudeRevit/Services/BenchmarkObjective.cs b/ClaudeRevit/Services/BenchmarkObjective.cs new file mode 100644 index 0000000..64f8185 --- /dev/null +++ b/ClaudeRevit/Services/BenchmarkObjective.cs @@ -0,0 +1,170 @@ +using System.Text.Json; + +namespace ClaudeRevit.Services; + +public sealed record ObjectiveCheck(string Name, string Status, string Detail, int Weight = 1); +public sealed record ObjectiveReport(string Version, IReadOnlyList Checks) +{ + public int Failed => Checks.Count(c => c.Status == "failed"); + public int Incomplete => Checks.Count(c => c.Status == "incomplete"); + public bool Passed => Checks.Count > 0 && Failed == 0 && Incomplete == 0; + public int Ceiling => Checks.Count == 0 ? 0 : (int)Math.Floor(100d * Checks.Where(c => c.Status == "passed").Sum(c => c.Weight) / Checks.Sum(c => c.Weight)); +} + +// These checks read the full independent probe BEFORE judge sampling. No model narration, +// English/localised display names or bounding boxes are accepted as proof of a rebar host. +public static class BenchmarkObjective +{ + public const string Version = "objective-v1"; + public static ObjectiveReport Evaluate(string task, string before, string after) + { + var checks = new List(); + void Add(string name, bool? passed, string detail, int weight = 1) => checks.Add(new(name, passed.HasValue ? passed.Value ? "passed" : "failed" : "incomplete", detail, weight)); + try + { + using var bd = JsonDocument.Parse(before); using var ad = JsonDocument.Parse(after); + var b = bd.RootElement; var a = ad.RootElement; + if (b.TryGetProperty("probe_error", out _) || a.TryGetProperty("probe_error", out _)) + { Add("independent probe", null, "Probe unavailable; no objective result can be established."); return new(Version, checks); } + double? N(JsonElement root, string name) => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var n) && double.IsFinite(n) ? n : null; + List? Rows(JsonElement root, string name) => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Array && + !(root.TryGetProperty(name + "_truncated", out var t) && t.ValueKind == JsonValueKind.True) ? v.EnumerateArray().ToList() : null; + List? New(string name) + { + var old = Rows(b, name); var current = Rows(a, name); if (old == null || current == null) return null; + var ids = old.Select(e => N(e, "id")).ToHashSet(); return current.Where(e => N(e, "id").HasValue && !ids.Contains(N(e, "id"))).ToList(); + } + bool Near(double? value, double expected, double tol = 1) => value.HasValue && Math.Abs(value.Value - expected) <= tol; + void Delta(string name, int count, int weight = 1) => Add(name + " delta", N(a, name) is { } x && N(b, name) is { } y ? Near(x - y, count, 0) : null, $"Expected {count} new native {name}.", weight); + void RowCheck(string name, string description, Func, bool> test, int weight = 1) + { var rows = New(name); Add(description, rows == null || rows.Any(e => e.TryGetProperty("evidence_error", out _)) ? null : test(rows), "Independent native snapshots: " + name, weight); } + double[]? Vector(JsonElement root, string name) => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Array && v.GetArrayLength() == 3 ? v.EnumerateArray().Select(n => n.GetDouble()).ToArray() : null; + double[]? Size(JsonElement row) => row.TryGetProperty("bounds", out var box) && Vector(box, "min_mm") is { } lo && Vector(box, "max_mm") is { } hi ? hi.Zip(lo, (x,y) => x-y).ToArray() : null; + void Rebar() + { + var rows = New("rebar_elements"); var hosts = Rows(a, "family_instances")?.Concat(Rows(a, "floor_elements") ?? []).Select(e => N(e, "id")).ToHashSet(); + Add("native hosted reinforcement", rows == null || hosts == null ? null : rows.Count > 0 && rows.All(e => hosts.Contains(N(e, "host_id")) && N(e, "quantity") > 0), "Rebar must reference an existing native family/floor host.", 3); + Add("centerlines inside host", rows == null ? null : rows.Count > 0 && rows.All(e => + { + if (!e.TryGetProperty("host_bounds", out var box) || Vector(box,"min_mm") is not { } lo || Vector(box,"max_mm") is not { } hi || !e.TryGetProperty("centerlines",out var lines)) return false; + var points = lines.EnumerateArray().SelectMany(s=>s.GetProperty("curves").EnumerateArray()).SelectMany(c=>c.GetProperty("points_mm").EnumerateArray()).ToArray(); + return points.Length > 0 && points.All(p=>p.GetArrayLength()==3 && p.EnumerateArray().Select(v=>v.GetDouble()).Select((x,i)=>x>=lo[i]-1 && x<=hi[i]+1).All(x=>x)); + }), "Sampled native centerlines must remain within host bounds; this does not prove cover or all unsampled positions.", 2); + } + switch (task) + { + case "B0": Delta("levels",1); RowCheck("level_elements","named level elevation",r=>r.Count==1 && r[0].GetProperty("name").GetString()=="Bench B0" && Near(N(r[0],"elevation_m"),3.5,.001)); break; + case "B1": Delta("walls",1); RowCheck("wall_elements","5000 mm straight wall",r=>r.Count==1 && r[0].GetProperty("curve").GetProperty("kind").GetString()=="Line" && Near(N(r[0].GetProperty("curve"),"length_mm"),5000,1)); break; + case "B2": Delta("floors",1); RowCheck("floor_elements","4 x 3 m floor",r=>r.Count==1 && Near(N(r[0],"area_m2"),12,.05) && Size(r[0]) is { } s && Near(s[0],4000,1)&&Near(s[1],3000,1)); break; + case "B3": Delta("grids",1); RowCheck("grid_elements","10000 mm straight grid",r=>r.Count==1 && r[0].GetProperty("curve").GetProperty("kind").GetString()=="Line" && Near(N(r[0].GetProperty("curve"),"length_mm"),10000,1)); break; + case "B4": Delta("structural_columns",1); RowCheck("family_instances","400 x 400 native column",r=>r.Any(e=>N(e,"category_id")==-2001330 && Size(e) is { } s && Near(s[0],400,1)&&Near(s[1],400,1))); break; + case "B5": Delta("materials",1); RowCheck("material_elements","named grey material",r=>r.Any(e=>e.GetProperty("name").GetString()=="Bench Concrete" && Vector(e,"rgb") is { } rgb && Near(rgb[0],rgb[1],1)&&Near(rgb[1],rgb[2],1))); break; + case "B6": Delta("direct_shape_count",1); RowCheck("direct_shape_geometry","2 m cube geometry",r=>r.Count==1 && Size(r[0]) is { } s && s.All(v=>Near(v,2000,1)) && N(r[0].GetProperty("geometry"),"triangle_count")>0); break; + case "L1": + Delta("walls",4); Delta("floors",1); Delta("roofs",1); Delta("doors",1); + RowCheck("floor_elements","40 m2 slab",r=>r.Count==1 && Near(N(r[0],"area_m2"),40,.1)); + RowCheck("wall_elements","closed rectangular wall loop",r=>ClosedLoop(r,8000,5000)); break; + case "L2": case "L5": + int floors=task=="L2"?5:10, columns=task=="L2"?20:120; Delta("floors",floors); Delta("structural_columns",columns,3); + RowCheck("floor_elements","storey elevations and floor areas",r=>r.Count==floors && r.All(e=>Near(N(e,"area_m2"),task=="L2"?144:180,.1)) && + r.Select(e=>e.GetProperty("bounds").GetProperty("max_mm")[2].GetDouble()).Order().Select((z,i)=>Near(z,i*3500,5)).All(x=>x)); + RowCheck("family_instances","column grid and sections",r=>ColumnGrid(r, task=="L2"?[0d,12000]:[0d,5000,10000,15000], task=="L2"?[0d,12000]:[0d,6000,12000],floors,task=="L5")); if(task=="L2")Delta("grids",4); break; + case "L3": Delta("walls",4); RowCheck("wall_elements","selective wall retyping",r=>r.Count==4 && r.Select(e=>e.GetProperty("curve").GetProperty("length_mm").GetDouble()).Order().Zip(new[]{4000d,5000,7000,8000},(x,y)=>Near(x,y,1)).All(v=>v) && r.All(e=> + e.GetProperty("curve").GetProperty("length_mm").GetDouble()>6000 ? Near(N(e,"type_width_mm"),300,1) : !Near(N(e,"type_width_mm"),300,1))); break; + case "L4": RowCheck("direct_shape_geometry","coarse half-cylinder surface",r=>r.Any(e=>Barrel(e)),3); break; + case "R1": case "R3": case "R4": case "R5": case "D6": + Rebar(); + if(task=="R3")RowCheck("rebar_elements","eight 20 mm longitudinal bars and stirrup zones",r=>r.Where(e=>Near(N(e,"diameter_mm"),20,.01)).Sum(e=>N(e,"quantity")??0)==8 && r.Count(e=>Near(N(e,"diameter_mm"),10,.01)&&Near(N(e,"spacing_mm"),100,1))>=2 && r.Any(e=>Near(N(e,"diameter_mm"),10,.01)&&Near(N(e,"spacing_mm"),200,1)),2); + if(task=="R4") { Delta("floors",1); RowCheck("openings","native slab opening",r=>r.Count>=1,2); RowCheck("rebar_elements","mesh, trimming and U-bar diameters",r=>r.Any(e=>Near(N(e,"diameter_mm"),12,.01))&&r.Any(e=>Near(N(e,"diameter_mm"),16,.01))); } + if(task=="R5")RowCheck("rebar_elements","native curved 16 mm centerlines",r=>r.Any(e=>Near(N(e,"diameter_mm"),16,.01)&&e.GetProperty("centerlines").EnumerateArray().Any(s=>s.GetProperty("curves").EnumerateArray().Any(c=>c.GetProperty("kind").GetString()=="Arc"))),2); + if(task=="D6")RowCheck("schedules","populated grouped rebar schedule",r=>r.Any(e=>e.GetProperty("name").GetString()=="Bench D6 Rebar" && N(e,"sort_group_count")>0 && e.GetProperty("fields").GetArrayLength()>=4 && N(e,"body_rows")>1)); break; + case "R2": + RowCheck("floor_elements","native 24 m2 slab",r=>r.Any(e=>Near(N(e,"area_m2"),24,.1))); + var floorIds=Rows(a,"floor_elements")?.Select(e=>N(e,"id")).ToHashSet(); + foreach(var name in new[]{"area_reinforcement_elements","path_reinforcement_elements"})RowCheck(name,name+" hosted in floor",r=>r.Count>0&&floorIds!=null&&r.All(e=>floorIds.Contains(N(e,"host_id"))),3); break; + case "S1": Delta("structural_columns",2,2); Delta("structural_framing",1,2); + var connections=New("connection_elements"); var shapes=New("direct_shape_geometry"); + Add("connected native or detailed joint", connections==null||shapes==null?null:connections.Any(e=>e.GetProperty("connected_element_ids").GetArrayLength()>=2)||shapes.Any(e=>N(e.GetProperty("geometry"),"solid_count")>=2&&HasMemberLinks(e)),"Native member links or multiple actual joint solids with explicit member IDs; dimensional completeness is checked by the independent judge.",2); break; + case "D1": + RowCheck("sheets","two populated sheets",r=>r.Count==2 && r.Select(e=>e.GetProperty("number").GetString()).Order().SequenceEqual(new[]{"BD-101","BD-102"})&&r.All(e=>e.GetProperty("placed_view_ids").GetArrayLength()>0),2); + RowCheck("viewports","distinct sheet viewports",r=>r.Count>=2&&r.Select(e=>N(e,"sheet_id")).Distinct().Count()==2&&r.Select(e=>N(e,"view_id")).Distinct().Count()>=2); break; + case "D2": + var schedules=New("schedules"); var exported=Rows(a,"schedule_exports"); + RowCheck("schedules","wall schedule has required fields",r=>r.Any(e=>e.GetProperty("name").GetString()=="Bench D2 Walls"&&e.GetProperty("fields").GetArrayLength()>=3),2); + Add("CSV export verified",exported==null?null:exported.Any(e=>e.TryGetProperty("verified",out var verified)&&verified.ValueKind==JsonValueKind.True&&schedules!=null&&schedules.Any(s=>N(s,"id")==N(e,"schedule_id"))),"Actual file hash and exported cells must match the current native schedule."); break; + case "D3": + var taskLevel=Rows(a,"level_elements")?.FirstOrDefault(e=>e.TryGetProperty("name",out var n)&&n.GetString()=="Bench D3"); + var levelId=taskLevel.HasValue?N(taskLevel.Value,"id"):null; + var plan=New("plans")?.FirstOrDefault(e=>levelId.HasValue&&N(e,"level_id")==levelId); var planId=plan.HasValue?N(plan.Value,"id"):null; + RowCheck("tags","three tags in requested plan",r=>planId.HasValue&&r.Count(e=>N(e,"owner_view_id")==planId)>=3,2); RowCheck("dimensions","dimension in requested plan",r=>planId.HasValue&&r.Any(e=>N(e,"owner_view_id")==planId)); break; + case "D4": Delta("walls",5); RowCheck("wall_elements","SHORT applied only below 4 m",r=>r.Count==5&&r.All(e=>(e.GetProperty("curve").GetProperty("length_mm").GetDouble()<4000-1e-6)==(e.GetProperty("comments").GetString()=="SHORT")),2); break; + case "D5": + foreach(var name in new[]{"wall_elements","floor_elements","family_instances","direct_shape_geometry"}) + {var old=Rows(b,name);var current=Rows(a,name);Add("preserved "+name,old==null||current==null?null:old.All(e=>current.Any(c=>N(c,"id")==N(e,"id"))),"Placed baseline geometry must not be deleted.");} break; + case "F1": case "F2": case "F3": case "F4": case "F5": + if(!a.TryGetProperty("independent_flex",out var flex)||!flex.TryGetProperty("results",out var scenarios)||scenarios.ValueKind!=JsonValueKind.Array)Add("independent flex",null,"Family flex evidence unavailable.",3); + else + { + var tests=scenarios.EnumerateArray().ToArray();Add("all independent flex scenarios valid",tests.Length>0&&tests.All(s=>s.TryGetProperty("valid",out var valid)&&valid.ValueKind==JsonValueKind.True),"No failed parameter regeneration or missing solids.",3); + if(task is "F1" or "F2" or "F4")Add("geometry varies across scenarios",tests.Length>1&&tests.Where(s=>s.TryGetProperty("geometry",out _)).Select(s=>s.GetProperty("geometry").GetProperty("bounds").GetRawText()).Distinct().Count()>1,"Actual geometry, not parameter metadata, must vary.",2); + if(task=="F1")Add("exact family size scenarios",tests.All(s=>FamilyBox(s)),"Width/depth/height and volume must match each requested scenario.",2); + } + if(task=="F2")RowCheck("family_instances","four native nested children",r=>r.Count==4,2); + break; + default: Add("task contract",null,"No deterministic contract is registered for this task.");break; + } + } + catch(Exception ex) when(ex is JsonException or InvalidOperationException or KeyNotFoundException or ArgumentException or IndexOutOfRangeException) + { Add("complete evidence",null,"Malformed or missing independent evidence: "+ex.Message,3); } + return new(Version,checks); + } + public static BenchmarkVerdict Apply(BenchmarkVerdict judge, ObjectiveReport report) + { + if(!judge.Graded)return judge; + if(report.Checks.All(c=>c.Status=="incomplete"))return new(false,0,"Objective evidence unavailable; no measured quality score. "+judge.Reason,false); + return judge with { Pass=judge.Pass&&report.Passed, Score=Math.Min(judge.Score,report.Ceiling), Reason=$"[objective {report.Checks.Count(c=>c.Status=="passed")}/{report.Checks.Count}; failed {report.Failed}; incomplete {report.Incomplete}] "+judge.Reason }; + } + private static bool ClosedLoop(List rows,double width,double depth) + { + if(rows.Count!=4)return false; + var points=rows.SelectMany(r=>new[]{r.GetProperty("curve").GetProperty("points_mm")[0],r.GetProperty("curve").GetProperty("points_mm")[4]}).Select(p=>p.EnumerateArray().Select(v=>v.GetDouble()).ToArray()).ToArray(); + bool Near(double x,double y)=>Math.Abs(x-y)<=1; + return points.All(p=>points.Count(q=>p.Zip(q,Near).All(v=>v))==2)&&Near(points.Max(p=>p[0])-points.Min(p=>p[0]),width)&&Near(points.Max(p=>p[1])-points.Min(p=>p[1]),depth)&&rows.All(r=>r.GetProperty("curve").GetProperty("kind").GetString()=="Line"); + } + private static bool HasMemberLinks(JsonElement element) + { + if(!element.TryGetProperty("provenance",out var p)||p.ValueKind!=JsonValueKind.Object)return false; + if(p.TryGetProperty("provenance",out var nested)&&nested.ValueKind==JsonValueKind.Object)p=nested; + return p.TryGetProperty("related_element_ids",out var ids)&&ids.ValueKind==JsonValueKind.Array&&ids.GetArrayLength()>=2; + } + private static bool ColumnGrid(List rows,double[] xs,double[] ys,int levels,bool retyped) + { + var columns=rows.Where(e=>e.GetProperty("category_id").ValueKind==JsonValueKind.Number && e.GetProperty("category_id").GetInt64()==-2001330).ToArray(); + if(columns.Length!=xs.Length*ys.Length*levels)return false; + var cells=new HashSet(); + foreach(var e in columns) + { + var p=e.GetProperty("location").EnumerateArray().Select(v=>v.GetDouble()).ToArray();var x=Array.FindIndex(xs,v=>Math.Abs(v-p[0])<=1);var y=Array.FindIndex(ys,v=>Math.Abs(v-p[1])<=1);int z=(int)Math.Round(p[2]/3500); + if(x<0||y<0||z<0||z>=levels||Math.Abs(p[2]-z*3500)>1||!cells.Add($"{x},{y},{z}"))return false; + var b=e.GetProperty("bounds");double side=retyped&&Math.Abs(p[0]-15000)<1?500:400; + if(Math.Abs(b.GetProperty("max_mm")[0].GetDouble()-b.GetProperty("min_mm")[0].GetDouble()-side)>1||Math.Abs(b.GetProperty("max_mm")[1].GetDouble()-b.GetProperty("min_mm")[1].GetDouble()-side)>1)return false; + } + return true; + } + private static bool Barrel(JsonElement e) + { + var b=e.GetProperty("bounds");var lo=b.GetProperty("min_mm").EnumerateArray().Select(v=>v.GetDouble()).ToArray();var hi=b.GetProperty("max_mm").EnumerateArray().Select(v=>v.GetDouble()).ToArray(); + var g=e.GetProperty("geometry");int triangles=g.GetProperty("triangle_count").GetInt32();if(triangles<=0||triangles>=2000)return false; + int axis=Math.Abs(hi[0]-lo[0]-20000)<100?0:1, cross=1-axis;if(Math.Abs(hi[axis]-lo[axis]-20000)>100||Math.Abs(hi[cross]-lo[cross]-10000)>100||Math.Abs(lo[2]-4000)>100||Math.Abs(hi[2]-lo[2]-5000)>100)return false; + var samples=g.GetProperty("surface_samples_mm").EnumerateArray().Select(v=>v.EnumerateArray().Select(n=>n.GetDouble()).ToArray()).ToArray();double center=(lo[cross]+hi[cross])/2; + return samples.Length>=12&&samples.All(p=>Math.Abs(Math.Sqrt(Math.Pow(p[cross]-center,2)+Math.Pow(p[2]-4000,2))-5000)<=100)&&samples.Any(p=>p[2]>6000&&p[2]<8500); + } + private static bool FamilyBox(JsonElement scenario) + { + var sizes=new Dictionary{["small"]=[300,200,240],["nominal"]=[1000,600,800],["large"]=[3000,1800,2400],["wide"]=[2500,200,2000],["deep"]=[300,2000,240],["repeat"]=[1000,600,800]}; + if(!sizes.TryGetValue(scenario.GetProperty("name").GetString()??"",out var expected)||!scenario.TryGetProperty("geometry",out var g))return false; + var bounds=g.GetProperty("bounds").EnumerateArray().ToArray();if(bounds.Length!=1)return false; + var actual=bounds[0].GetProperty("max_mm").EnumerateArray().Zip(bounds[0].GetProperty("min_mm").EnumerateArray(),(hi,lo)=>hi.GetDouble()-lo.GetDouble()).ToArray(); + return expected.Zip(actual,(x,y)=>Math.Abs(x-y)<=1).All(v=>v)&&Math.Abs(g.GetProperty("summed_solid_volume_m3").GetDouble()-expected.Aggregate(1d,(x,y)=>x*y)/1e9)<=1e-6; + } +} diff --git a/ClaudeRevit/Services/BenchmarkRunner.cs b/ClaudeRevit/Services/BenchmarkRunner.cs index 31e5722..213e215 100644 --- a/ClaudeRevit/Services/BenchmarkRunner.cs +++ b/ClaudeRevit/Services/BenchmarkRunner.cs @@ -25,11 +25,16 @@ public sealed class BenchmarkResult public double? Score { get; init; } public double Seconds { get; init; } public int ReferenceSeconds { get; init; } + public string ReferenceSource { get; init; } = "provisional task reference"; public int Rounds { get; init; } public long Tokens { get; init; } public string UsageScope { get; init; } = "unavailable"; public string Time { get; init; } = ""; // "12.3s" public string Reason { get; init; } = ""; + public ObjectiveReport? Objective { get; init; } + public string SeedFingerprint { get; init; } = ""; + public string EnvironmentKey { get; init; } = ""; + public string ComparisonKey { get; init; } = ""; public TurnTimings Timings { get; init; } = new(); public string RevitTime => Timings.ModelAndToolsSeconds > 0 ? Timings.RevitExecutionSeconds.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) + "s" : "—"; } @@ -39,6 +44,11 @@ public sealed class BenchmarkResult // chat history. Results are appended to benchmark_results.jsonl for later comparison across runs. public static class BenchmarkRunner { + public static void WriteSummary(IEnumerable rows) + { + var path=Path.Combine(Path.GetDirectoryName(ResultsPath)!,"benchmark_summary.json");Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path,JsonSerializer.Serialize(BenchmarkStatistics.Group(rows).Select(g=>new {key=g.Key,statistics=g.Distribution}))); + } private static int _running; private static string ResultsPath => Path.Combine( @@ -115,12 +125,14 @@ public static async Task RunAsync( if (DocumentSessions.CurrentDocumentKey != documentKey) throw new InvalidOperationException("Active document changed during the benchmark. Grading stopped; only the owned scratch copy is closed without saving."); onStatus($"{task.Id} · {task.Title} · capturing final evidence…"); var after = await StatsAsync(task, true, documentKey, ct); + var objective = BenchmarkObjective.Evaluate(task.Id, before, after); var evidence=JsonSerializer.Deserialize>(after)!; evidence["modelling_revit_execution_seconds"]=chat.Timings.RevitExecutionSeconds; after=JsonSerializer.Serialize(evidence); onStatus($"{task.Id} · {task.Title} · grading…"); var verdict = error != null ? new BenchmarkVerdict(false, 0, "Run error: " + Truncate(error, 200), true) : await JudgeAsync(judgeChat, judge, task, before, after, finalText, ct); + verdict = BenchmarkObjective.Apply(verdict, objective); if (budgetStopped) verdict = verdict with { Reason = "[task budget reached] " + verdict.Reason }; if (scopeStarted) { @@ -130,15 +142,20 @@ public static async Task RunAsync( scopeStarted = false; } var metrics = chat.LastTask; - var points = BenchmarkScoring.Calculate(verdict, seconds, task.ReferenceSeconds); + var seed=BenchmarkStatistics.SeedFingerprint(before); + var environment=BenchmarkStatistics.EnvironmentKey(InitialVersion(before)); + var comparison=$"{judge.Tag}|{judge.Effort}|{maxRoundsPerTask}|{maxSecondsPerTask}|{resetBetweenTasks}|{BenchmarkObjective.Version}"; + var reference=BenchmarkCalibration.Resolve(task.Id,seed,environment,comparison,task.ReferenceSeconds); + var points = BenchmarkScoring.Calculate(verdict, seconds, reference.Seconds); var row = new BenchmarkResult { TaskId = task.Id, Title = task.Title, Model = metrics?.Model ?? execution.Tag, Verdict = !verdict.Graded ? "?" : verdict.Pass ? "✓" : "✗", Quality = points?.Quality, Speed = points?.Speed, Score = points?.Total, - Seconds = seconds, ReferenceSeconds = task.ReferenceSeconds, + Seconds = seconds, ReferenceSeconds = reference.Seconds, ReferenceSource = reference.Source, UsageScope = metrics?.UsageScope ?? "unavailable", Rounds = metrics?.Rounds ?? 0, Tokens = (metrics?.InputTokens ?? 0) + (metrics?.OutputTokens ?? 0), - Time = seconds.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + "s", Reason = verdict.Reason, Timings = chat.Timings + Time = seconds.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + "s", Reason = verdict.Reason, Timings = chat.Timings, + Objective=objective, SeedFingerprint=seed, EnvironmentKey=environment, ComparisonKey=comparison }; Append(row, execution, judge, runStamp, verdict.Pass, metrics?.InputTokens ?? 0, metrics?.OutputTokens ?? 0, row.Rounds, seconds, resultsPath, maxRoundsPerTask, maxSecondsPerTask, resetBetweenTasks); onResult(row); @@ -276,10 +293,14 @@ private static void Append(BenchmarkResult r, BenchmarkExecution execution, Benc speed_score = r.Speed, scoring_version = BenchmarkScoring.Version, scoring_formula = "quality * (0.8 + 0.2 * min(1, reference_seconds / seconds))", - reference_seconds = r.ReferenceSeconds, + reference_seconds = r.ReferenceSeconds, reference_source = r.ReferenceSource, timing_scope = "modeller_and_tools_excluding_probe_judge_reset", execution_profile = execution.Backend == "api" ? "api_progressive_v1" : "compact_mcp_v1", - task_suite_version = "v3.8.0", + task_suite_version = "v3.8.4", + objective_checks = r.Objective, + seed_fingerprint = r.SeedFingerprint, + environment_key = r.EnvironmentKey, + comparison_key = r.ComparisonKey, phase_seconds = new { catalog = r.Timings.CatalogSeconds, context = r.Timings.ContextSeconds, model_and_tools = r.Timings.ModelAndToolsSeconds, mcp_tool_wait_sum = r.Timings.ToolWaitSeconds, revit_queue_sum = r.Timings.QueueSeconds, revit_execution_sum = r.Timings.RevitExecutionSeconds }, @@ -303,6 +324,8 @@ private static string TaskHints(BenchmarkTask task) => task.Id.StartsWith("R",St : task.Id == "S1" ? " Use list_structural_connection_types and create_structural_connection for native joints. A generic logical link alone is insufficient; a detailed equivalent joint needs actual solids, verified dimensions and related member IDs recorded with set_model_provenance." : ""; + private static string InitialVersion(string probe) + {using var d=JsonDocument.Parse(probe);return d.RootElement.TryGetProperty("revit_version",out var v)?v.GetString()??"unknown":"unknown";} private static string Truncate(string s, int max) => string.IsNullOrEmpty(s) || s.Length <= max ? s ?? "" : s.Substring(0, max) + "…"; } diff --git a/ClaudeRevit/Services/BenchmarkStatistics.cs b/ClaudeRevit/Services/BenchmarkStatistics.cs new file mode 100644 index 0000000..c4efa61 --- /dev/null +++ b/ClaudeRevit/Services/BenchmarkStatistics.cs @@ -0,0 +1,28 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +namespace ClaudeRevit.Services; + +public sealed record BenchmarkDistribution(int Count,double Median,double P95,double Minimum,double Maximum,double PassRate); +public static class BenchmarkStatistics +{ + public static BenchmarkDistribution Summarize(IEnumerable<(double Seconds,bool Passed)> samples) + { + var data=samples.Where(s=>double.IsFinite(s.Seconds)&&s.Seconds>0).ToArray(); + if(data.Length==0)return new(0,0,0,0,0,0); + var sorted=data.Select(s=>s.Seconds).Order().ToArray(); + double Quantile(double q){double index=(sorted.Length-1)*q;int lo=(int)Math.Floor(index),hi=(int)Math.Ceiling(index);return sorted[lo]+(sorted[hi]-sorted[lo])*(index-lo);} + return new(data.Length,Quantile(.5),Quantile(.95),sorted[0],sorted[^1],100d*data.Count(s=>s.Passed)/data.Length); + } + public static string SeedFingerprint(string probe) + { + using var parsed=JsonDocument.Parse(probe); + var values=parsed.RootElement.EnumerateObject().Where(p=>p.Name is not ("document_key" or "document_title" or "document_path" or "schedule_exports")) + .OrderBy(p=>p.Name,StringComparer.Ordinal).ToDictionary(p=>p.Name,p=>p.Value.Clone()); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(values)))); + } + public static string EnvironmentKey(string revitVersion)=>$"Revit-{revitVersion}|{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion.Version}|{Environment.Version}"; + public static IReadOnlyList<(string Key,BenchmarkDistribution Distribution)> Group(IEnumerable rows)=>rows.Where(r=>r.Score.HasValue) + .GroupBy(r=>$"{r.TaskId}|{r.Model}|{r.SeedFingerprint}|{r.EnvironmentKey}|{r.ComparisonKey}",StringComparer.Ordinal) + .Select(g=>(g.Key,Summarize(g.Select(r=>(r.Seconds,r.Verdict=="✓"))))).ToArray(); +} diff --git a/ClaudeRevit/Services/ChatService.cs b/ClaudeRevit/Services/ChatService.cs index db3e1ec..341bd78 100644 --- a/ClaudeRevit/Services/ChatService.cs +++ b/ClaudeRevit/Services/ChatService.cs @@ -105,16 +105,16 @@ public class ChatService "MEMORY: When the user states a lasting preference or project standard, or corrects you in a way worth " + "remembering, call save_memory with one concise fact. Apply what you already remember (below) without " + "being reminded.\n\n" + - "LEARNING: Scripts that worked before are journaled on disk and survive clearing the chat. Proven patterns " + - "may be listed below — reuse them. get_script_journal shows full past runs; generate_diagnostic_report " + - "summarizes recurring scripts as candidates for future dedicated tools (also written automatically when " + - "Revit closes). PROMOTE ON THE SECOND USE: the first time you solve something with execute_csharp, " + - "just run it. But when you are about to run essentially the SAME operation a second time — the same " + - "kind of script you already ran successfully this session, or one shown in the proven-scripts list — " + - "that is a reusable pattern: generalize it (turn the specific ids/sizes/names into input parameters) " + - "and call save_tool ONCE to compile it into a persistent named tool, then call that tool for this and " + - "every later use. A just-saved tool is available immediately, in the same turn. Do NOT promote a " + - "genuine one-off, and delete_tool removes a bad one.\n\n" + + "EXPERIENCE: get_script_journal is execution history, not proof of geometric correctness. Use verify_model_result " + + "with independently expected bounds/volume/parameters/host relationships to validate actual model results. " + + "Only reports whose declared checks passed enter the optional verified index. get_verified_experience filters " + + "by current Revit build and template/type context. These records prove only the checks listed, not optimality; " + + "choose a better method freely. Consider save_tool only when a validated reusable operation is useful, " + + "never solely because it ran twice. generate_diagnostic_report retains recurring failures for diagnosis.\n\n" + + "RESUMABLE WORK: for long sequences of supported native transactional tools, use run_checkpoint_job " + + "in bounded batches and get_checkpoint_job before continuing. Preview does not advance a job. Reuse its key " + + "and exact plan/revision; do not recreate completed steps. Save the RVT for crash durability. " + + "For live schedules agree nesting_policy and source categories; use audit_spds_schedule and actual sheet exports.\n\n" + "AGED RESULTS: To save tokens, tool results from earlier prompts are shown truncated with an " + "'[aged to save tokens …]' marker carrying an id. This is normal. If you genuinely need a full old " + "result AND it cannot have changed, call get_full_result with that id; if the model may have changed " + diff --git a/ClaudeRevit/Services/CheckpointPlan.cs b/ClaudeRevit/Services/CheckpointPlan.cs new file mode 100644 index 0000000..aa05669 --- /dev/null +++ b/ClaudeRevit/Services/CheckpointPlan.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Security.Cryptography; +using System.Text; +namespace ClaudeRevit.Services; + +public sealed class CheckpointStep +{ + public string Key {get;set;}=""; + public string Tool {get;set;}=""; + public JsonElement Arguments {get;set;} + public List WatchIds {get;set;}=[]; +} +public sealed class CheckpointPlan +{ + public List Steps {get;set;}=[]; + public static readonly JsonSerializerOptions Options=new(Json.Options){PropertyNamingPolicy=JsonNamingPolicy.SnakeCaseLower,UnmappedMemberHandling=System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow}; + public string Hash()=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(this,Options)))); + public void Validate() + { + if(Steps==null||Steps.Count is <1 or >500)throw new ArgumentException("Supply 1..500 steps.");var keys=new HashSet(StringComparer.Ordinal); + foreach(var step in Steps) + { + if(step==null||string.IsNullOrWhiteSpace(step.Key)||step.Key.Length>64||string.IsNullOrWhiteSpace(step.Tool)||step.Arguments.ValueKind!=JsonValueKind.Object||step.WatchIds==null||step.WatchIds.Count>1000||step.WatchIds.Any(i=>i<=0))throw new ArgumentException("Invalid step key/tool/arguments/watch_ids."); + ValidateRefs(step.Arguments,keys);if(!keys.Add(step.Key))throw new ArgumentException("Duplicate step key."); + } + if(JsonSerializer.Serialize(this,Options).Length>500_000)throw new ArgumentException("Plan exceeds 500000 characters."); + } + private static void ValidateRefs(JsonElement value,HashSet previous) + { + if(value.ValueKind==JsonValueKind.Object) + { + if(value.TryGetProperty("from_step",out var source)) + {if(value.EnumerateObject().Count()!=2||!value.TryGetProperty("json_pointer",out var pointer)||!previous.Contains(source.GetString()??"")||pointer.ValueKind!=JsonValueKind.String)throw new ArgumentException("References need a previous from_step and json_pointer, without extra members.");return;} + foreach(var p in value.EnumerateObject())ValidateRefs(p.Value,previous); + } + else if(value.ValueKind==JsonValueKind.Array)foreach(var item in value.EnumerateArray())ValidateRefs(item,previous); + } + public static JsonElement Resolve(JsonElement arguments,IReadOnlyDictionary results) + { + JsonNode? Visit(JsonElement value) + { + if(value.ValueKind==JsonValueKind.Object) + { + if(value.TryGetProperty("from_step",out var source)) + { + var selected=results.GetValueOrDefault(source.GetString()??"");if(selected.ValueKind==JsonValueKind.Undefined)throw new ArgumentException("Referenced result is unavailable."); + var pointer=value.GetProperty("json_pointer").GetString()??"";if(pointer!=""&&!pointer.StartsWith('/'))throw new ArgumentException("Use an RFC6901 JSON pointer."); + foreach(var segment in pointer.Split('/').Skip(1)) + {var key=segment.Replace("~1","/").Replace("~0","~");selected=selected.ValueKind==JsonValueKind.Array?selected[int.Parse(key,System.Globalization.CultureInfo.InvariantCulture)]:selected.GetProperty(key);} + return JsonNode.Parse(selected.GetRawText()); + } + var obj=new JsonObject();foreach(var p in value.EnumerateObject())obj[p.Name]=Visit(p.Value);return obj; + } + if(value.ValueKind==JsonValueKind.Array){var a=new JsonArray();foreach(var item in value.EnumerateArray())a.Add(Visit(item));return a;} + return JsonNode.Parse(value.GetRawText()); + } + return JsonSerializer.SerializeToElement(Visit(arguments)); + } +} diff --git a/ClaudeRevit/Services/ConnectionSpec.cs b/ClaudeRevit/Services/ConnectionSpec.cs index 06b33d8..8b6ec2f 100644 --- a/ClaudeRevit/Services/ConnectionSpec.cs +++ b/ClaudeRevit/Services/ConnectionSpec.cs @@ -30,7 +30,11 @@ public IReadOnlyList OrderedParts() if (new[] { p.PointMm != null, p.RelativeTo != null, p.FaceReference != null }.Count(x => x) > 1) throw new ArgumentException("Point, relative_to and face_reference are alternative placement modes."); if (p.Parameters==null || p.CutTargets==null || p.Parameters.Count > 100 || p.Parameters.Any(p=>p==null)) throw new ArgumentException("Supply nonnull parameter/cut arrays, at most 100 parameters per part."); foreach (var param in p.Parameters) + { if (param.Unit is not ("internal" or "mm") || string.IsNullOrWhiteSpace(param.Name) == string.IsNullOrWhiteSpace(param.Guid)) throw new ArgumentException("Each parameter needs exactly one name or guid and unit internal/mm."); + if ((param.ValueFrom != null) == (param.Value.ValueKind != JsonValueKind.Undefined)) throw new ArgumentException("Specify value OR value_from, not both/neither."); + if (param.ValueFrom is { } v && (string.IsNullOrWhiteSpace(v.PartKey) || string.IsNullOrWhiteSpace(v.Name) == string.IsNullOrWhiteSpace(v.Guid) || v.Scope is not ("instance" or "type") || !double.IsFinite(v.Scale) || !double.IsFinite(v.OffsetMm) || param.Unit != "mm")) throw new ArgumentException("value_from needs part_key, one parameter name/GUID, instance/type scope, finite scale/offset_mm and target unit mm."); + } } var ordered = new List(); var visiting = new HashSet(); var done = new HashSet(); void Visit(ConnectionPart p) @@ -38,6 +42,8 @@ void Visit(ConnectionPart p) if (done.Contains(p.Key)) return; if (!visiting.Add(p.Key)) throw new ArgumentException("relative_to contains a cycle."); if (p.RelativeTo != null) { if (!map.TryGetValue(p.RelativeTo, out var parent)) throw new ArgumentException($"Missing relative_to part {p.RelativeTo}."); Visit(parent); } + foreach (var dependency in p.Parameters.Where(v => v.ValueFrom != null).Select(v => v.ValueFrom!.PartKey)) + { if (!map.TryGetValue(dependency, out var source)) throw new ArgumentException("Missing value_from source: " + dependency); Visit(source); } visiting.Remove(p.Key); done.Add(p.Key); ordered.Add(p); } foreach (var p in Parts) Visit(p); @@ -78,9 +84,20 @@ public sealed class ConnectionParameter { public string? Name { get; set; } public string? Guid { get; set; } + [System.Text.Json.Serialization.JsonIgnore(Condition=System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingDefault)] public JsonElement Value { get; set; } + public ConnectionValueFrom? ValueFrom { get; set; } public string Unit { get; set; } = "internal"; } +public sealed class ConnectionValueFrom +{ + public string PartKey { get; set; } = ""; + public string? Name { get; set; } + public string? Guid { get; set; } + public string Scope { get; set; } = "instance"; + public double Scale { get; set; } = 1; + public double OffsetMm { get; set; } +} public sealed class ConnectionRule { public string Kind { get; set; } = ""; diff --git a/ClaudeRevit/Services/CsvEvidence.cs b/ClaudeRevit/Services/CsvEvidence.cs new file mode 100644 index 0000000..58023be --- /dev/null +++ b/ClaudeRevit/Services/CsvEvidence.cs @@ -0,0 +1,34 @@ +using System.Text; +namespace ClaudeRevit.Services; + +public static class CsvEvidence +{ + public static IReadOnlyList Parse(string text) + { + var rows = new List(); var cells = new List(); var cell = new StringBuilder(); bool quoted = false; + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (c == '"') { if (quoted && i + 1 < text.Length && text[i + 1] == '"') { cell.Append('"'); i++; } else quoted = !quoted; } + else if (!quoted && c == ',') { cells.Add(cell.ToString()); cell.Clear(); } + else if (!quoted && c is '\r' or '\n') + { if (c == '\r' && i + 1 < text.Length && text[i + 1] == '\n') i++; cells.Add(cell.ToString()); rows.Add(cells.ToArray()); cells.Clear(); cell.Clear(); } + else cell.Append(c); + } + if (quoted) throw new FormatException("Unterminated quoted CSV cell."); + if (cell.Length > 0 || cells.Count > 0) { cells.Add(cell.ToString()); rows.Add(cells.ToArray()); } + return rows; + } + public static bool ContainsRows(IReadOnlyList exported, IReadOnlyList expected) + { + int index = 0; + foreach (var row in expected.Where(r => r.Any(c => !string.IsNullOrWhiteSpace(c)))) + { + while (index < exported.Count && !row.Select(Normalize).SequenceEqual(exported[index].Select(Normalize))) index++; + if (index == exported.Count) return false; + index++; + } + return expected.Any(r => r.Any(c => !string.IsNullOrWhiteSpace(c))); + } + private static string Normalize(string value) => value.Trim().TrimStart('\uFEFF').Replace("\r\n", "\n"); +} diff --git a/ClaudeRevit/Services/ExperienceStore.cs b/ClaudeRevit/Services/ExperienceStore.cs index fce78ad..f45eea4 100644 --- a/ClaudeRevit/Services/ExperienceStore.cs +++ b/ClaudeRevit/Services/ExperienceStore.cs @@ -38,7 +38,7 @@ public static class ExperienceStore { if (_digestBuilt) return _digest; _digestBuilt = true; - try { _digest = BuildDigest(); } catch { _digest = null; } + try { _digest = VerifiedExperienceStore.Digest(VerifiedExperienceStore.Read()); } catch { _digest = null; } return _digest; } @@ -146,9 +146,9 @@ private static List ChangedCategories(JsonElement e) if (patterns.Count == 0) return null; var sb = new StringBuilder(); - sb.Append("PROVEN SCRIPTS IN THIS ENVIRONMENT (learned from earlier sessions and kept "); - sb.Append("even after the chat is cleared — the model delta each produced is real and "); - sb.Append("reproducible here). Reuse these patterns instead of re-deriving them; adapt "); + sb.Append("EXECUTED SCRIPTS (UNVERIFIED RESULTS) (learned from earlier sessions and kept "); + sb.Append("even after the chat is cleared — execution success and model deltas do not prove "); + sb.Append("correct geometry. Validate applicability before optional reuse; adapt "); sb.Append("the numbers to the request:\n"); int shown = 0; @@ -224,7 +224,7 @@ public static string BuildDiagnosticReport() if (!string.IsNullOrWhiteSpace(p.SampleCode)) { sb.AppendLine(); - sb.AppendLine("Representative proven code:"); + sb.AppendLine("Representative executed code (result correctness unverified):"); sb.AppendLine(); sb.AppendLine("```csharp"); sb.AppendLine(p.SampleCode!.Trim()); diff --git a/ClaudeRevit/Services/McpServer.cs b/ClaudeRevit/Services/McpServer.cs index d3a4a4a..9f44f0f 100644 --- a/ClaudeRevit/Services/McpServer.cs +++ b/ClaudeRevit/Services/McpServer.cs @@ -64,6 +64,9 @@ public static class McpServer "For repeated tower geometry use generate_floor_stack, generate_facade_grid and generate_spire; discover exact schemas. Generators default to preview:true; use preview:false after validation. " + "For code call validate_csharp before execute_csharp; prefer System.Text.Json. Report warnings as structured fields. Use export_image for actual native view pixels. " + "Record sources, confidence and assumed dimensions with set_model_provenance; distinguish native BIM elements from DirectShape geometry. " + + "For optional experience use verify_model_result and get_verified_experience: execution history alone is not verification, and a previous approach never restricts a better method. " + + "For long supported native-tool sequences use run_checkpoint_job in bounded batches, inspect get_checkpoint_job before resuming, and save the RVT for durable checkpoints. " + + "Agree live schedule source categories and nesting_policy; audit_spds_schedule checks fields/totals and sheet overlaps, then inspect exports for wrapping/formatting. " + "The user authorizes subagents for independent planning and checking on complex tasks. Use at most three, give each a bounded task and snapshot, and collect compact findings. " + "Keep all Revit mutations in the parent agent in dependency order; subagents must not edit Revit. Revit API calls are serialized on one UI thread. Do not delegate simple one-call operations. " + "Finish concisely with changed IDs, dimensions and any failed checks. The pane already knows the selected driving model; no model-report call is needed."; diff --git a/ClaudeRevit/Services/NestingSelection.cs b/ClaudeRevit/Services/NestingSelection.cs new file mode 100644 index 0000000..54b3cdc --- /dev/null +++ b/ClaudeRevit/Services/NestingSelection.cs @@ -0,0 +1,17 @@ +namespace ClaudeRevit.Services; +public static class NestingSelection +{ + public static HashSet Select(IEnumerable<(string Id,string? Parent)> instances,string policy) + { + if(policy is not ("explicit" or "parents_only" or "children_only"))throw new ArgumentException("Choose explicit, parents_only or children_only."); + var map=instances.ToDictionary(x=>x.Id,x=>x.Parent,StringComparer.Ordinal);var ancestors=new Dictionary>(); + foreach(var id in map.Keys) + { + var chain=new HashSet();var p=map[id]; + while(p!=null&&map.ContainsKey(p)){if(p==id||!chain.Add(p))throw new ArgumentException("Cyclic nested-instance references.");p=map[p];} + ancestors[id]=chain; + } + var parents=ancestors.Values.SelectMany(v=>v).ToHashSet(StringComparer.Ordinal); + return map.Keys.Where(id=>policy=="explicit"||policy=="parents_only"&&ancestors[id].Count==0||policy=="children_only"&&!parents.Contains(id)).ToHashSet(StringComparer.Ordinal); + } +} diff --git a/ClaudeRevit/Services/ScriptJournal.cs b/ClaudeRevit/Services/ScriptJournal.cs index 52cae20..83bb6e8 100644 --- a/ClaudeRevit/Services/ScriptJournal.cs +++ b/ClaudeRevit/Services/ScriptJournal.cs @@ -33,6 +33,8 @@ public static class ScriptJournal private static string? _code; private static string? _engine; private static string? _document; + private static string _runId="",_documentKey="",_revitVersion=""; + private static readonly List ModifiedIds=new(); private static DateTime _startedUtc; private static readonly Dictionary Added = new(); private static readonly Dictionary Modified = new(); @@ -58,14 +60,14 @@ public static void OnDocumentChanged(object? sender, DocumentChangedEventArgs e) foreach (var id in e.GetModifiedElementIds()) { var cat=doc.GetElement(id)?.Category?.Name ?? "(no category)"; - Modified[cat]=Modified.GetValueOrDefault(cat)+1; + Modified[cat]=Modified.GetValueOrDefault(cat)+1;ModifiedIds.Add(id.Value); } _deleted += e.GetDeletedElementIds().Count; } catch { /* journaling must never break a tool call */ } } - public static void Begin(string tool, string code, string? engine, string? document) + public static void Begin(string tool, string code, string? engine, string? document, Document? model = null) { // Single recording slot: correct because ToolDispatcher's ExternalEvent serializes // tool execution — at most one tool runs at a time. If journaling is ever extended @@ -74,6 +76,7 @@ public static void Begin(string tool, string code, string? engine, string? docum _code = code; _engine = engine; _document = document; + _runId=Guid.NewGuid().ToString("N");_documentKey=DocumentSessions.Key(model);_revitVersion=model==null?"unknown":model.Application.VersionNumber+"/"+model.Application.VersionBuild;ModifiedIds.Clear(); _startedUtc = DateTime.UtcNow; Added.Clear(); Modified.Clear(); @@ -97,7 +100,9 @@ public static void Complete(bool ok, string? resultOrError) { var entry = JsonSerializer.Serialize(new { - ts = _startedUtc.ToString("o"), + ts = _startedUtc.ToString("o"),run_id=_runId,document_key=_documentKey,revit_version=_revitVersion, + code_sha256=Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(_code??""))), + code_truncated=(_code?.Length??0)>4000, duration_ms = (int)(DateTime.UtcNow - _startedUtc).TotalMilliseconds, tool = _tool, engine = _engine, @@ -110,7 +115,7 @@ public static void Complete(bool ok, string? resultOrError) added_by_category = Added.Count > 0 ? new Dictionary(Added) : null, modified_by_category = Modified.Count > 0 ? new Dictionary(Modified) : null, deleted_count = _deleted, - added_ids = AddedIds.Count > 0 ? AddedIds.ToList() : null + added_ids = AddedIds.Count > 0 ? AddedIds.Distinct().ToList() : null, modified_ids=ModifiedIds.Distinct().ToArray() } }); diff --git a/ClaudeRevit/Services/SectionProfile.cs b/ClaudeRevit/Services/SectionProfile.cs index 243f8b3..7a33f33 100644 --- a/ClaudeRevit/Services/SectionProfile.cs +++ b/ClaudeRevit/Services/SectionProfile.cs @@ -4,13 +4,21 @@ public static class SectionProfile { public static double Area(string shape,double width,double height,double web,double flange) { - if(new[]{width,height,web,flange}.Any(x=>!double.IsFinite(x)||x<=0) || shape is not ("rectangle" or "box" or "i"))throw new ArgumentException("Unknown section shape or invalid dimensions."); - if(shape!="rectangle"&&(2*web>=width||2*flange>=height))throw new ArgumentException("Section thickness closes the internal opening."); - return shape switch {"rectangle"=>width*height,"box"=>width*height-(width-2*web)*(height-2*flange),_=>2*width*flange+(height-2*flange)*web}; + if(new[]{width,height,web,flange}.Any(x=>!double.IsFinite(x)||x<=0) || shape is not ("rectangle" or "box" or "i" or "channel" or "angle" or "tube" or "timber_pair"))throw new ArgumentException("Unknown section shape or invalid dimensions."); + if((shape is "box" or "i" or "channel") && (2*web>=width||2*flange>=height))throw new ArgumentException("Section thickness closes the internal opening."); + if(shape=="angle"&&(web>=width||flange>=height))throw new ArgumentException("Angle thickness closes the section."); + if(shape=="timber_pair"&&web>=width)throw new ArgumentException("Timber gap must be less than overall width."); + if(shape=="tube"&&(Math.Abs(height-width)>1e-6||2*web>=width))throw new ArgumentException("Tube needs equal overall width/height (diameter) and a positive internal bore."); + double area=shape switch {"rectangle"=>width*height,"box"=>width*height-(width-2*web)*(height-2*flange),"i"=>2*width*flange+(height-2*flange)*web,"channel"=>height*web+2*(width-web)*flange,"angle"=>height*web+(width-web)*flange,"timber_pair"=>(width-web)*height,_=>Math.PI*(width*width-Math.Pow(width-2*web,2))/4}; + if(!double.IsFinite(area)||area<=0)throw new ArgumentException("Section area overflows or is empty.");return area; } public static List Loops(string shape,double w,double h,double t,double f) { Area(shape,w,h,t,f); + if(shape=="tube")throw new ArgumentException("Circular tubes require native arcs, not a polygon approximation."); + if(shape=="channel")return [[ [0,0],[w,0],[w,f],[t,f],[t,h-f],[w,h-f],[w,h],[0,h] ]]; + if(shape=="angle")return [[ [0,0],[w,0],[w,f],[t,f],[t,h],[0,h] ]]; + if(shape=="timber_pair")return [[ [0,0],[(w-t)/2,0],[(w-t)/2,h],[0,h] ],[ [(w+t)/2,0],[w,0],[w,h],[(w+t)/2,h] ]]; if(shape=="i") {var l=(w-t)/2;var r=(w+t)/2;return [[ [0,0],[w,0],[w,f],[r,f],[r,h-f],[w,h-f],[w,h],[0,h],[0,h-f],[l,h-f],[l,f],[0,f] ]];} List loops=[[ [0,0],[w,0],[w,h],[0,h] ]]; diff --git a/ClaudeRevit/Services/VerifiedExperienceStore.cs b/ClaudeRevit/Services/VerifiedExperienceStore.cs new file mode 100644 index 0000000..9989635 --- /dev/null +++ b/ClaudeRevit/Services/VerifiedExperienceStore.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.IO; +namespace ClaudeRevit.Services; + +public sealed record VerifiedExperience(string RunId, string CodeHash, string? CompleteCode, string Tool, string RevitVersion, string ContextKey, string VerifiedUtc, double Seconds, IReadOnlyList Checks, IReadOnlyDictionary ElementSignatures) +{ + public bool Passed => Checks.Count > 0 && Checks.All(c => c.Status == "passed"); +} +public static class VerifiedExperienceStore +{ + public static string PathName => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ClaudeRevit", "verified_experience.json"); + private static readonly object Gate = new(); + public static IReadOnlyList Read(string? path = null) + { + lock (Gate) + { + try { return JsonSerializer.Deserialize(File.ReadAllText(path ?? PathName)) ?? []; } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) { return []; } + } + } + public static IReadOnlyList Compatible(string revit, string context, string? path = null) => Read(path) + .Where(r => r.Passed && r.RevitVersion == revit && r.ContextKey == context) + .OrderByDescending(r => r.VerifiedUtc, StringComparer.Ordinal).Take(20).ToArray(); + public static void Save(VerifiedExperience report, string? path = null) + { + lock (Gate) + { + path ??= PathName; + var rows = Read(path).Where(r => r.RunId != report.RunId).Append(report).OrderByDescending(r => r.VerifiedUtc, StringComparer.Ordinal).Take(300).ToArray(); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + var tmp = path + ".tmp"; File.WriteAllText(tmp, JsonSerializer.Serialize(rows)); File.Move(tmp, path, true); + } + } + public static string Digest(IEnumerable rows) + { + var passed=rows.Where(r=>r.Passed).Take(8).ToArray(); + return passed.Length==0 ? "" : "OPTIONAL VERIFIED EXPERIENCE INDEX: use get_verified_experience to check current Revit/template applicability and declared checks. Choose a better approach freely; these records do not prove optimality or correctness beyond the checked dimensions.\n" + string.Join("\n",passed.Select(r=>$"{r.Tool}: code SHA256 {r.CodeHash}, Revit {r.RevitVersion}, {r.Checks.Count} checks, {r.Seconds:0.00}s. Full evidence is available on demand.")); + } +} diff --git a/ClaudeRevit/Tools/AnalyzeFamilyStructure.cs b/ClaudeRevit/Tools/AnalyzeFamilyStructure.cs index ed47eba..b1a1289 100644 --- a/ClaudeRevit/Tools/AnalyzeFamilyStructure.cs +++ b/ClaudeRevit/Tools/AnalyzeFamilyStructure.cs @@ -14,6 +14,7 @@ public sealed class AnalyzeFamilyStructure : IRevitTool public bool RequiresNoTurnGroup => true; public InputSchema InputSchema => NativeToolUtil.Schema(new() { + ["file_path"] = NativeToolUtil.Field("string", "Optional absolute local RFA path: inspect in a background document, close unsaved; excludes family_id."), ["family_id"] = NativeToolUtil.Field("integer", "Optional loaded Family ID in active document; otherwise active RFA."), ["max_depth"] = NativeToolUtil.Field("integer", "Nested family depth 0..6, default 3."), ["max_families"] = NativeToolUtil.Field("integer", "Total family documents to inspect 1..50, default 20.") diff --git a/ClaudeRevit/Tools/AuditSpdsSchedule.cs b/ClaudeRevit/Tools/AuditSpdsSchedule.cs new file mode 100644 index 0000000..a259c4e --- /dev/null +++ b/ClaudeRevit/Tools/AuditSpdsSchedule.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using Anthropic.Models.Beta.Messages; +using Autodesk.Revit.DB; +using Autodesk.Revit.UI; +namespace ClaudeRevit.Tools; + +public sealed class AuditSpdsSchedule : IRevitTool +{ + public string Name=>"audit_spds_schedule"; + public string Description=>"Inspect live schedule fields/physical units/totals, source nested-parent counting, linked model status and actual sheet placements. Reports overlaps, printable-area overflow and column widths; does not certify text wrapping or every SPDS drawing requirement. Use export_image for visual review. No static rows are created."; + public bool RequiresTransaction=>false; + public InputSchema InputSchema=>NativeToolUtil.Schema(new(){["schedule_id"]=NativeToolUtil.Field("integer","Native live ViewSchedule."),["sheet_margin_mm"]=NativeToolUtil.Field("number","Agreed uniform sheet margin, default 5 mm; title-block-specific safe area still needs review.")},"schedule_id"); + public string Execute(IReadOnlyDictionary input,UIApplication app) + { + var doc=NativeToolUtil.Doc(app);var s=NativeToolUtil.Element(doc,input["schedule_id"].GetInt64()) as ViewSchedule??throw new ToolInputException("Not a native schedule."); + double margin=ToolInput.OptionalDouble(input,"sheet_margin_mm")??5;if(!double.IsFinite(margin)||margin<0||margin>100)throw new ToolInputException("Margin must be 0..100 mm."); + var placements=new FilteredElementCollector(doc).OfClass(typeof(ScheduleSheetInstance)).Cast().Where(p=>p.ScheduleId==s.Id&&!p.IsTitleblockRevisionSchedule).ToArray(); + var layout=placements.Select(p=> + { + var sheet=doc.GetElement(p.OwnerViewId) as ViewSheet;var bb=p.get_BoundingBox(sheet);var box=sheet?.Outline; + if(bb==null||box==null)return (object)new {id=p.Id.Value,status="incomplete",reason="No sheet or actual schedule bounds."}; + bool inside=bb.Min.X>=box.Min.U+margin/Units.MmPerFoot&&bb.Min.Y>=box.Min.V+margin/Units.MmPerFoot&&bb.Max.X<=box.Max.U-margin/Units.MmPerFoot&&bb.Max.Y<=box.Max.V-margin/Units.MmPerFoot; + var others=new FilteredElementCollector(doc,sheet!.Id).WhereElementIsNotElementType().Where(e=>e.Id!=p.Id&&e is ScheduleSheetInstance or Viewport or TextNote).Select(e=>new {e.Id,bounds=e.get_BoundingBox(sheet)}).Where(x=>x.bounds!=null&&x.bounds.Min.Xbb.Min.X&&x.bounds.Min.Ybb.Min.Y).Select(x=>x.Id.Value).Take(100).ToArray(); + return new {id=p.Id.Value,sheet_id=sheet.Id.Value,inside_uniform_margin=inside,overlapping_element_ids=others,status=inside&&others.Length==0?"passed":"failed",actual_width_mm=(bb.Max.X-bb.Min.X)*Units.MmPerFoot,actual_height_mm=(bb.Max.Y-bb.Min.Y)*Units.MmPerFoot}; + }).ToArray(); + var record=LiveSpds.All(doc).FirstOrDefault(p=>p.Record.ScheduleUniqueIds.Contains(s.UniqueId)).Record; + var sources=record==null?[]:LiveSpds.Sources(doc,record); + return Services.Json.Serialize(new {id=s.Id.Value,live=s.Definition.GetFieldCount()>0,include_linked_files=s.Definition.CanIncludeLinkedFiles()&&s.Definition.IncludeLinkedFiles, + fields=s.Definition.GetFieldOrder().Select(id=>s.Definition.GetField(id)).Select(f=>new {name=f.GetName(),heading=f.ColumnHeading,parameter_id=f.ParameterId.Value,spec=f.GetSpecTypeId().TypeId,total=f.DisplayType.ToString(),hidden=f.IsHidden,width_mm=f.SheetColumnWidth*Units.MmPerFoot}).ToArray(), + nesting_policy=record?.NestingPolicy,nested_sources=sources.OfType().Where(f=>f.SuperComponent!=null).Select(f=>new {id=f.Id.Value,parent_id=f.SuperComponent.Id.Value}).ToArray(),placements=layout, + linked_documents=new FilteredElementCollector(doc).OfClass(typeof(RevitLinkInstance)).Cast().Select(l=>new {id=l.Id.Value,loaded=l.GetLinkDocument()!=null,name=l.Name}).Take(100).ToArray(), + visual_review="Uniform margins and rectangular overlaps only. Check title-block zones, split schedule headers, border style and text wrapping in an exported view before issuing drawings."}); + } +} diff --git a/ClaudeRevit/Tools/BenchmarkModelProbe.cs b/ClaudeRevit/Tools/BenchmarkModelProbe.cs index 360d534..36eabee 100644 --- a/ClaudeRevit/Tools/BenchmarkModelProbe.cs +++ b/ClaudeRevit/Tools/BenchmarkModelProbe.cs @@ -14,6 +14,8 @@ internal static class BenchmarkModelProbe public static void Append(Document doc, Dictionary result) { result["is_family_document"] = doc.IsFamilyDocument; + result["revit_version"] = doc.Application.VersionNumber + "/" + doc.Application.VersionBuild; + result["schedule_exports"] = ScheduleExportEvidence.Probe(doc); var errors = new List(); void Collect(string name, Func snapshot) where T : Element { @@ -36,9 +38,11 @@ void Collect(string name, Func snapshot) where T : Element Collect("connection_elements",e=>new { id=e.Id.Value,connected_element_ids=e.GetConnectedElementIds().Select(i=>i.Value).ToArray(),geometry=Geometry(e),provenance=ModelProvenance.Read(e) }); Collect("area_reinforcement_elements",e=>new { id=e.Id.Value,host_id=e.GetHostId().Value,bounds=Bounds(e) }); Collect("path_reinforcement_elements",e=>new { id=e.Id.Value,host_id=e.GetHostId().Value,bounds=Bounds(e) }); + Collect("material_elements", m => new { id = m.Id.Value, name = m.Name, rgb = new[] { (int)m.Color.Red, (int)m.Color.Green, (int)m.Color.Blue } }); Collect("level_elements", l => new { id = l.Id.Value, name = l.Name, elevation_m = l.Elevation * 0.3048 }); Collect("grid_elements", g => new { id = g.Id.Value, name = g.Name, curve = Curve(g.Curve) }); Collect("wall_elements", w => new { id = w.Id.Value, type_id = w.GetTypeId().Value, level_id = w.LevelId.Value, + type_width_mm = w.WallType.Width * Units.MmPerFoot, curve = w.Location is LocationCurve lc ? Curve(lc.Curve) : null, comments = w.get_Parameter(BuiltInParameter.ALL_MODEL_INSTANCE_COMMENTS)?.AsString() }); Collect("floor_elements", f => new { id = f.Id.Value, type_id = f.GetTypeId().Value, level_id = f.LevelId.Value, bounds = Bounds(f), area_m2 = f.get_Parameter(BuiltInParameter.HOST_AREA_COMPUTED)?.AsDouble() * 0.3048 * 0.3048 }); @@ -84,6 +88,7 @@ void Collect(string name, Func snapshot) where T : Element } return new { id = s.Id.Value, name = s.Name, category_id = definition.CategoryId.Value, fields = definition.GetFieldOrder().Select(id => definition.GetField(id).GetName()).ToArray(), + field_specs = definition.GetFieldOrder().Select(id => new { parameter_id = definition.GetField(id).ParameterId.Value, spec = definition.GetField(id).GetSpecTypeId().TypeId }).ToArray(), sort_group_count = definition.GetSortGroupFieldCount(), rows, body_rows = body.NumberOfRows, rows_truncated = body.NumberOfRows > rows.Count }; }); diff --git a/ClaudeRevit/Tools/CheckpointJob.cs b/ClaudeRevit/Tools/CheckpointJob.cs new file mode 100644 index 0000000..f075248 --- /dev/null +++ b/ClaudeRevit/Tools/CheckpointJob.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using Anthropic.Models.Beta.Messages; +using Autodesk.Revit.DB; +using Autodesk.Revit.DB.ExtensibleStorage; +using Autodesk.Revit.UI; +using ClaudeRevit.Services; +namespace ClaudeRevit.Tools; + +internal sealed class JobRecord +{ + public string Key {get;set;}=""; + public int Revision {get;set;} + public string PlanHash {get;set;}=""; + public CheckpointPlan Plan {get;set;}=new(); + public Dictionary Results {get;set;}=new(); + public Dictionary Signatures {get;set;}=new(); +} +internal static class CheckpointJobs +{ + private static readonly Guid SchemaId=new("0e3c8c19-e074-45aa-999e-10a0d6431182"); + private static Schema Schema() + {var schema=Autodesk.Revit.DB.ExtensibleStorage.Schema.Lookup(SchemaId);if(schema!=null)return schema;var b=new SchemaBuilder(SchemaId);b.SetSchemaName("ClaudeRevitCheckpointJobV1");b.SetReadAccessLevel(AccessLevel.Public);b.SetWriteAccessLevel(AccessLevel.Public);b.AddSimpleField("Json",typeof(string));return b.Finish();} + public static IEnumerable<(DataStorage Storage,JobRecord Record)> All(Document doc) + { + var schema=Autodesk.Revit.DB.ExtensibleStorage.Schema.Lookup(SchemaId);if(schema==null)yield break; + foreach(var s in new FilteredElementCollector(doc).OfClass(typeof(DataStorage)).Cast()) + {var e=s.GetEntity(schema);if(e.IsValid()&&JsonSerializer.Deserialize(e.Get(schema.GetField("Json")),CheckpointPlan.Options) is { } r)yield return(s,r);} + } + public static void Save(DataStorage s,JobRecord r) + {var text=JsonSerializer.Serialize(r,CheckpointPlan.Options);if(text.Length>1_000_000)throw new ToolInputException("Checkpoint record exceeds 1 MB; split the plan.");var schema=Schema();var e=new Entity(schema);e.Set(schema.GetField("Json"),text);s.SetEntity(e);} + public static string[] Stale(Document doc,JobRecord r)=>r.Signatures.Where(p=>doc.GetElement(p.Key) is not { } e||ConnectionNodes.Signature(e)!=p.Value).Select(p=>p.Key).ToArray(); + public static IRevitTool Tool(string name) + { + var tool=ToolRegistry.Instance.Get(name)??throw new ToolInputException("Unknown tool: "+name); + if(!tool.RequiresTransaction||tool.IsScriptTool||tool is DynamicToolProxy||name is "run_batch" or "delete_elements"||tool.RequiresNoTurnGroup)throw new ToolInputException("Checkpoint steps need bounded built-in transactional tools; scripts, deletion, dynamic tools, batches and document lifecycle tools are excluded."); + if(tool.RequiresConfirmation&&SettingsStore.ConfirmOperations)throw new ToolInputException("Call confirmation-requiring tools directly."); + if(SettingsStore.DisabledToolGroups.Contains(ToolCatalog.CategoryOf(tool),StringComparer.OrdinalIgnoreCase))throw new ToolInputException("Tool group is disabled: "+name); + return tool; + } +} +public sealed class RunCheckpointJob : IRevitTool +{ + public string Name=>"run_checkpoint_job"; + public string Description=>"Run/resume a document-local plan in bounded atomic batches (default 5 steps, max 25). Completed step outputs and actual changed-element signatures persist in RVT DataStorage in the same transaction as edits, preventing duplicate retries. Repeating a completed job is a no-op. Rejects changed plan/revision or external edits/missing elements. One Undo per committed batch, no transaction held between MCP calls. Default preview=true does not advance progress. Save the RVT to make checkpoints durable across process crashes; unsaved Revit edits are not recovered automatically."; + public bool RequiresTransaction=>false; + public bool RequiresNoTurnGroup=>true; + public bool MutatesWithoutTransaction=>true; + public bool InvalidatesCatalog=>true; + public InputSchema InputSchema=>NativeToolUtil.Schema(new() + { + ["job_key"]=NativeToolUtil.Field("string","Stable 1..64 character document-local key."), + ["plan"]=NativeToolUtil.Any("New job: {steps:[{key,tool,arguments:{...},watch_ids?:[IDs]}]}. Use one ordinary native transactional tool per step. Argument values can reference earlier results as {from_step:key,json_pointer:'/id'}. Omit plan to resume. watch_ids are extra existing elements whose edits/deletion block continuation."), + ["batch_size"]=NativeToolUtil.Field("integer","Default 5; 1..25. Each call yields after one batch."),["expected_revision"]=NativeToolUtil.Field("integer","Optimistic lock: 0 for a new job."),["preview"]=NativeToolUtil.Field("boolean","Default true; false commits this batch.") + },"job_key","expected_revision"); + public string Execute(IReadOnlyDictionary input,UIApplication app) + { + var doc=NativeToolUtil.Doc(app);var key=ToolInput.RequiredString(input,"job_key");if(key.Length>64)throw new ToolInputException("job_key exceeds 64 chars."); + var matches=CheckpointJobs.All(doc).Where(p=>p.Record.Key==key).Take(2).ToArray();if(matches.Length>1)throw new ToolInputException("Duplicate copied job key; resolve its DataStorage first."); + var storage=matches.FirstOrDefault().Storage;var old=matches.FirstOrDefault().Record; + if(input["expected_revision"].GetInt32()!=(old?.Revision??0))throw new ToolInputException("Checkpoint revision differs; inspect get_checkpoint_job."); + var plan=input.TryGetValue("plan",out var p)?p.Deserialize(CheckpointPlan.Options)??throw new ToolInputException("Plan is empty."):old?.Plan??throw new ToolInputException("A new job requires a plan.");plan.Validate(); + if(old!=null&&old.PlanHash!=plan.Hash())throw new ToolInputException("Saved plan hash differs; start an explicit new job instead of reusing completed steps."); + if(old!=null&&CheckpointJobs.Stale(doc,old).Length>0)throw new ToolInputException("Completed elements were edited/deleted externally; inspect before continuation."); + var record=old==null?new JobRecord{Key=key,Plan=plan,PlanHash=plan.Hash()}:JsonSerializer.Deserialize(JsonSerializer.Serialize(old,CheckpointPlan.Options),CheckpointPlan.Options)!; + int start=record.Results.Count;if(start==plan.Steps.Count)return Services.Json.Serialize(new {job_key=key,revision=record.Revision,complete=true,no_op=true}); + var batch=ToolInput.OptionalInt(input,"batch_size")??5;if(batch is <1 or >25)throw new ToolInputException("batch_size must be 1..25."); + foreach(var step in plan.Steps.Skip(start).Take(batch))CheckpointJobs.Tool(step.Tool); + var preview=NativeToolUtil.Preview(input);var emitted=new List(); + var (result,warnings)=NativeToolUtil.Commit(doc,"Claude: checkpoint "+key,preview,()=> + { + var initial=BenchmarkModelProbe.AllElementIds(doc).ToHashSet();if(initial.Count>50000)throw new ToolInputException("Checkpoint evidence is bounded to 50000 elements; split/narrow the test model."); + var watched=plan.Steps.Skip(start).Take(batch).SelectMany(s=>s.WatchIds).ToHashSet(); + void Ids(JsonElement value,string name="") + { + if(value.ValueKind==JsonValueKind.Object){foreach(var prop in value.EnumerateObject())Ids(prop.Value,prop.Name);} + else if(value.ValueKind==JsonValueKind.Array){foreach(var item in value.EnumerateArray())Ids(item,name);} + else if((name.EndsWith("_id",StringComparison.Ordinal)||name.EndsWith("_ids",StringComparison.Ordinal))&&value.TryGetInt64(out var id)&&initial.Contains(id))watched.Add(id); + } + var signatures=record.Signatures.Keys.Select(doc.GetElement).Where(e=>e!=null).ToDictionary(e=>e!.UniqueId,e=>ConnectionNodes.Signature(e!)); + foreach(var step in plan.Steps.Skip(start).Take(batch)) + { + ToolContext.ReportProgress(record.Results.Count,plan.Steps.Count,"checkpoint steps"); + var args=CheckpointPlan.Resolve(step.Arguments,record.Results).EnumerateObject().ToDictionary(p=>p.Name,p=>p.Value.Clone()); + foreach(var argument in args)Ids(argument.Value,argument.Key); + foreach(var id in watched){var e=NativeToolUtil.Element(doc,id);signatures.TryAdd(e.UniqueId,ConnectionNodes.Signature(e));} + var inner=CheckpointJobs.Tool(step.Tool);inner.Preflight(args,app);var raw=inner.Execute(args,app); + if(ToolResult.ErrorMessage(raw) is { } error)throw new ToolInputException("Step "+step.Key+": "+error); + using var parsed=JsonDocument.Parse(raw);record.Results[step.Key]=parsed.RootElement.Clone();doc.Regenerate(); + foreach(var id in step.WatchIds){var e=NativeToolUtil.Element(doc,id);record.Signatures[e.UniqueId]=ConnectionNodes.Signature(e);} + emitted.Add(new {key=step.Key,result=parsed.RootElement.Clone()}); + } + var after=BenchmarkModelProbe.AllElementIds(doc);if(initial.Except(after).Any())throw new ToolInputException("This batch deleted existing elements; deletion plans require explicit manual handling."); + foreach(var id in after) + { + var e=doc.GetElement(new ElementId(id));if(e is null or DataStorage)continue; + if(initial.Contains(id)&&!signatures.ContainsKey(e.UniqueId)&&!record.Signatures.ContainsKey(e.UniqueId))continue;var signature=ConnectionNodes.Signature(e);if(!initial.Contains(id)||signatures.TryGetValue(e.UniqueId,out var before)&&before!=signature||record.Signatures.ContainsKey(e.UniqueId))record.Signatures[e.UniqueId]=signature; + } + record.Revision++;storage??=DataStorage.Create(doc);CheckpointJobs.Save(storage,record); + return new {job_key=key,revision=record.Revision,completed=record.Results.Count,total=plan.Steps.Count,complete=record.Results.Count==plan.Steps.Count,next_step=record.Results.Count + { + // Updaters and Revit joins may alter geometry during commit. Capture the + // committed state before yielding, in the same bounded transaction group. + using var tx=new Transaction(doc,"Claude: committed checkpoint evidence");tx.Start(); + foreach(var uid in record.Signatures.Keys.ToArray()){var e=doc.GetElement(uid)??throw new ToolInputException("A committed step element disappeared.");record.Signatures[uid]=ConnectionNodes.Signature(e);} + CheckpointJobs.Save(storage!,record);if(tx.Commit()!=TransactionStatus.Committed)throw new ToolInputException("Checkpoint evidence commit failed."); + }); + return Services.Json.Serialize(new {preview,result,warnings,save_required_for_crash_durability=!preview}); + } +} +public sealed class GetCheckpointJob : IRevitTool +{ + public string Name=>"get_checkpoint_job"; + public string Description=>"Inspect persisted job revision, completed outputs and stale/missing element signatures before resuming. Omit job_key to list document-local jobs. Survives reopening a saved RVT; unsaved changes follow ordinary Revit recovery."; + public bool RequiresTransaction=>false; + public InputSchema InputSchema=>NativeToolUtil.Schema(new(){["job_key"]=NativeToolUtil.Field("string","Optional saved job key.")}); + public string Execute(IReadOnlyDictionary input,UIApplication app) + { + var doc=NativeToolUtil.Doc(app);var key=NativeToolUtil.Text(input,"job_key");var jobs=CheckpointJobs.All(doc).Where(p=>key.Length==0||p.Record.Key==key).Take(100).Select(p=>new {key=p.Record.Key,revision=p.Record.Revision,plan_hash=p.Record.PlanHash,completed=p.Record.Results.Count,total=p.Record.Plan.Steps.Count,results=key.Length==0?null:p.Record.Results,stale_unique_ids=CheckpointJobs.Stale(doc,p.Record)}).ToArray();return Services.Json.Serialize(new {jobs}); + } +} diff --git a/ClaudeRevit/Tools/ConnectionNodeUpdater.cs b/ClaudeRevit/Tools/ConnectionNodeUpdater.cs new file mode 100644 index 0000000..f24d9dd --- /dev/null +++ b/ClaudeRevit/Tools/ConnectionNodeUpdater.cs @@ -0,0 +1,58 @@ +using Autodesk.Revit.DB; +using Autodesk.Revit.UI; +namespace ClaudeRevit.Tools; + +internal sealed class ConnectionNodeUpdater : IUpdater +{ + private static ConnectionNodeUpdater? _instance; + private readonly UpdaterId _id; + private ConnectionNodeUpdater(AddInId addin) => _id=new(addin,new Guid("dc199cba-4d39-4c9d-9858-dab654bd7116")); + public static void Register(UIControlledApplication app) + { + _instance=new(app.ActiveAddInId);UpdaterRegistry.RegisterUpdater(_instance,false); + var filter=new LogicalOrFilter(new ElementClassFilter(typeof(FamilyInstance)),new ElementClassFilter(typeof(FamilySymbol))); + foreach(var change in new[]{Element.GetChangeTypeAny(),Element.GetChangeTypeElementDeletion()})UpdaterRegistry.AddTrigger(_instance._id,filter,change); + } + public static void Unregister(){if(_instance!=null){UpdaterRegistry.UnregisterUpdater(_instance._id);_instance=null;}} + public string GetAdditionalInformation()=>"Follows declared connection-node member dimensions/placements. Missing or invalid inputs require explicit refresh."; + public ChangePriority GetChangePriority()=>ChangePriority.Structure; + public UpdaterId GetUpdaterId()=>_id; + public string GetUpdaterName()=>"ClaudeRevit dependent connection nodes"; + public void Execute(UpdaterData data) + { + var doc=data.GetDocument();if(doc.IsFamilyDocument)return; + foreach(var (storage,node) in ConnectionNodes.All(doc).Where(n=>n.Record.AutoUpdate).Take(100).ToArray()) + { + var elements=node.Bindings.ToDictionary(p=>p.Key,p=>doc.GetElement(p.Value.UniqueId)); + if(elements.All(p=>p.Value!=null&&ConnectionNodes.Signature(p.Value)==node.Bindings[p.Key].Signature))continue; + var sources=node.Spec.Parts.Where(p=>p.Role=="member").Select(p=>p.Key) + .Concat(node.Spec.Parts.Where(p=>p.RelativeTo!=null).Select(p=>p.RelativeTo!)) + .Concat(node.Spec.Parts.SelectMany(p=>p.Parameters).Where(p=>p.ValueFrom!=null).Select(p=>p.ValueFrom!.PartKey)).ToHashSet(); + if(elements.All(p=>p.Value!=null)&&sources.All(key=>ConnectionNodes.Signature(elements[key]!)==node.Bindings[key].Signature)) + {node.NeedsRefresh=true;node.LastUpdateError="A managed part was edited independently. Source members are unchanged; deliberate manual refresh is required.";ConnectionNodes.Save(storage,node);continue;} + using var sub=new SubTransaction(doc);sub.Start(); + try + { + if(elements.Values.Any(e=>e==null))throw new ToolInputException("A node part was deleted; supply an explicit replacement."); + var map=elements.ToDictionary(p=>p.Key,p=>p.Value!); + foreach(var p in node.Spec.OrderedParts()) + { + var e=map[p.Key]; + if(p.RelativeTo!=null) + {var o=p.OffsetMm??new double[3];var parent=ConnectionNodes.Frame(map[p.RelativeTo]);var point=parent.OfPoint(new XYZ(o[0],o[1],o[2])/Units.MmPerFoot);ConnectionNodes.Position(doc,e,point,p.AxisX==null?null:new XYZ(p.AxisX[0],p.AxisX[1],p.AxisX[2]).Normalize(),p.AxisY==null?null:new XYZ(p.AxisY[0],p.AxisY[1],p.AxisY[2]).Normalize());} + else if(p.FaceReference!=null) + {var face=ConnectionNodes.Face(doc,p.FaceReference);var o=p.OffsetMm??new double[3];ConnectionNodes.Position(doc,e,face.Origin+(face.XVector*o[0]+face.YVector*o[1]+face.FaceNormal*o[2])/Units.MmPerFoot,null,null);} + ConnectionNodes.Parameters(e,p.Parameters.Where(v=>v.ValueFrom!=null),map); + } + doc.Regenerate(); + foreach(var pair in map)node.Bindings[pair.Key].Signature=ConnectionNodes.Signature(pair.Value); + var check=ConnectionNodeValidation.Check(doc,node);if(check.Failed>0)throw new ToolInputException("Updated dependent node failed declared interface checks."); + node.NeedsRefresh=false;node.LastUpdateError=null;node.Revision++;ConnectionNodes.Save(storage,node);sub.Commit(); + } + catch(Autodesk.Revit.Exceptions.RegenerationFailedException){throw;} + catch(OperationCanceledException){throw;} + catch(Exception ex) + {if(sub.GetStatus()==TransactionStatus.Started)sub.RollBack();var failed=ConnectionNodes.Find(doc,node.Key).Record??node;failed.NeedsRefresh=true;failed.LastUpdateError=ex.Message;ConnectionNodes.Save(storage,failed);} + } + } +} diff --git a/ClaudeRevit/Tools/ConnectionNodeValidation.cs b/ClaudeRevit/Tools/ConnectionNodeValidation.cs index 4618827..719b0a1 100644 --- a/ClaudeRevit/Tools/ConnectionNodeValidation.cs +++ b/ClaudeRevit/Tools/ConnectionNodeValidation.cs @@ -30,7 +30,11 @@ void Result(string kind,string? a,string? b,string state,object detail) { if(!elements.ContainsKey(p.Key)||!elements.ContainsKey(target))continue; bool registered=elements[p.Key] is FamilyInstance cutter&&InstanceVoidCutUtils.InstanceVoidCutExists(elements[target],cutter); - Result("void_cut",p.Key,target,registered?"incomplete":"failed",new {relationship_registered=registered,geometry_verified=false,reason="Registered cut alone does not prove its position/penetration; inspect the actual opening or a section."}); + var rule=node.Spec.Rules.FirstOrDefault(r=>r.Kind=="bolt"&&r.B==p.Key&&r.StackParts.Contains(target)); + BoreEvidence? bore=null; + if(rule!=null&&Length(elements[p.Key],rule.HoleDiameterParameter) is { } diameter) + {var frame=ConnectionNodes.Frame(elements[p.Key]);var axis=frame.OfVector(new XYZ(rule.LocalAxisB[0],rule.LocalAxisB[1],rule.LocalAxisB[2])).Normalize();bore=ConnectionOpeningGeometry.Inspect(elements[target],frame.Origin,axis,diameter,rule.ToleranceMm);} + Result("void_cut",p.Key,target,!registered||bore?.Verified==false?"failed":bore?.Verified==true?"passed":"incomplete",new {relationship_registered=registered,geometry_verified=bore?.Verified,bore,reason="The relationship alone is insufficient; a declared bolt-axis/diameter check inspects an actual cylindrical through bore."}); } foreach(var r in node.Spec.Rules) { @@ -72,9 +76,12 @@ void Result(string kind,string? a,string? b,string state,object detail) {var e=elements[key];var positions=ConnectionNodes.Corners(e).Select(p=>ConnectionMath.Dot(ConnectionMath.Sub(p,origin),NativeToolUtil.Vector(axis))).ToArray();var min=positions.Min();var max=positions.Max();lo=Math.Min(lo,min);hi=Math.Max(hi,max);intervals.Add(new {part=key,start_mm=min,end_mm=max,method="projected bounding box, conservative envelope"});} double? stack=r.StackParts.Count>0?hi-lo:null; if(grip.HasValue&&stack.HasValue)ok&=grip.Value+r.ToleranceMm>=stack.Value; - bool full=diameter.HasValue&&hole.HasValue&&grip.HasValue&&r.StackParts.Count>0; + var bores=r.StackParts.Select(key=>new {part=key,evidence=ConnectionOpeningGeometry.Inspect(elements[key],fa.Origin,axis,diameter??double.PositiveInfinity,r.ToleranceMm)}).ToArray(); + if(bores.Any(bore=>bore.evidence.Verified==false))ok=false; + if(hole.HasValue)ok&=bores.All(bore=>bore.evidence.DiameterMm==null||Math.Abs(bore.evidence.DiameterMm.Value-hole.Value)<=r.ToleranceMm); + bool full=diameter.HasValue&&hole.HasValue&&grip.HasValue&&r.StackParts.Count>0&&bores.All(bore=>bore.evidence.Verified==true); Result("bolt",r.A,r.B,!ok?"failed":full?"passed":"incomplete",new {centerline_distance_mm=metrics.DistanceMm,axis_angle_deg=metrics.AngleDeg,bolt_diameter_mm=diameter,hole_diameter_mm=hole,grip_mm=grip,stack_envelope_mm=stack,intervals,in_forbidden_zone=forbidden, - opening_geometry_verified=false,coverage="Actual instance axes/length parameters, conservative stack envelope. Does not certify bore shape, connected plate count, capacity or thread engagement."});break; + opening_geometry_verified=bores.Length>0&&bores.All(bore=>bore.evidence.Verified==true),bores,coverage="Actual axes and inner cylindrical through-bore geometry for every declared stack part; conservative grip envelope. Capacity and thread engagement are not certified."});break; } } } @@ -95,7 +102,7 @@ void Result(string kind,string? a,string? b,string state,object detail) public sealed class ValidateConnectionNode:IRevitTool { public string Name=>"validate_connection_node"; - public string Description=>"Read-only check of a saved connection node: missing/stale parts, declared solid clashes, planar face contact, actual family bolt/hole axes, diameter/grip length parameters, forbidden splice zones and registered void cuts. Reports passed/failed/incomplete separately. Does not certify structural capacity or hole geometry; absent dimensions/checks stay incomplete."; + public string Description=>"Read-only check of a saved connection node: missing/stale parts, declared solid clashes, planar face contact, actual family bolt/hole axes, diameter/grip length parameters, forbidden splice zones and registered void cuts. Reports passed/failed/incomplete separately. Actual cylindrical through-bores are checked for declared stack parts. Does not certify structural capacity; absent dimensions/checks stay incomplete."; public bool RequiresTransaction=>false; public InputSchema InputSchema=>NativeToolUtil.Schema(new(){["node_key"]=NativeToolUtil.Field("string","Saved document-local node key.")},"node_key"); public string Execute(IReadOnlyDictionary input,UIApplication app) diff --git a/ClaudeRevit/Tools/ConnectionNodes.cs b/ClaudeRevit/Tools/ConnectionNodes.cs index f1caabf..8451246 100644 --- a/ClaudeRevit/Tools/ConnectionNodes.cs +++ b/ClaudeRevit/Tools/ConnectionNodes.cs @@ -18,6 +18,9 @@ internal sealed class NodeRecord { public string Key { get; set; } = ""; public int Revision { get; set; } + public bool AutoUpdate { get; set; } + public bool NeedsRefresh { get; set; } + public string? LastUpdateError { get; set; } public ConnectionSpec Spec { get; set; } = new(); public Dictionary Bindings { get; set; } = new(); } @@ -53,7 +56,8 @@ public static string Signature(Element e) var t=Frame(e);var bb=e.get_BoundingBox(null); return Services.Json.Serialize(new { type=e.GetTypeId().Value,origin=NativeToolUtil.Mm(t.Origin),x=NativeToolUtil.Vector(t.BasisX),y=NativeToolUtil.Vector(t.BasisY), bbox=bb==null?null:new { min=NativeToolUtil.Vector(bb.Min),max=NativeToolUtil.Vector(bb.Max) }, - parameters=e.Parameters.Cast().Where(p=>p.HasValue&&p.StorageType==StorageType.Double).OrderBy(p=>p.Id.Value).Select(p=>new {id=p.Id.Value,value=p.AsDouble()}).ToArray() }); + type_parameters=e.Document.GetElement(e.GetTypeId())?.Parameters.Cast().Where(p=>p.HasValue&&p.StorageType==StorageType.Double).OrderBy(p=>p.Id.Value).Select(p=>new {id=p.Id.Value,value=p.AsDouble()}).ToArray(), + parameters=e.Parameters.Cast().Where(p=>p.HasValue&&!p.Definition.Name.StartsWith("CR_SPDS_",StringComparison.Ordinal)).OrderBy(p=>p.Id.Value).Select(p=>new {id=p.Id.Value,value=p.StorageType switch {StorageType.Double=>p.AsDouble().ToString("R",System.Globalization.CultureInfo.InvariantCulture),StorageType.Integer=>p.AsInteger().ToString(System.Globalization.CultureInfo.InvariantCulture),StorageType.String=>p.AsString(),StorageType.ElementId=>p.AsElementId().Value.ToString(System.Globalization.CultureInfo.InvariantCulture),_=>null}}).ToArray() }); } public static PlanarFace Face(Document doc,string reference,Element? expected=null) {var r=Reference.ParseFromStableRepresentation(doc,reference);var host=NativeToolUtil.Element(doc,r.ElementId.Value);if(expected!=null&&host.Id!=expected.Id)throw new ToolInputException("face_reference belongs to a different host.");return host.GetGeometryObjectFromReference(r) as PlanarFace??throw new ToolInputException("A stable planar face reference is required.");} @@ -79,15 +83,27 @@ public static void Position(Document doc,Element e,XYZ point,XYZ? x,XYZ? y) frame=Frame(e);var twist=Math.Atan2(x.DotProduct(frame.BasisY.CrossProduct(y!)),frame.BasisY.DotProduct(y!));Rotate(x,twist); frame=Frame(e);if(frame.BasisX.AngleTo(x)>1e-6||frame.BasisY.AngleTo(y!)>1e-6)throw new ToolInputException("Revit constraints prevented the requested orientation."); } - public static void Parameters(Element e,IEnumerable values) + public static void Parameters(Element e,IEnumerable values,IReadOnlyDictionary? elements=null) { foreach(var v in values) { + var value=v.Value; + if(v.ValueFrom is { } from) + { + var source=elements?.GetValueOrDefault(from.PartKey)??throw new ToolInputException("Dependency source is unavailable."); + if(from.Scope=="type")source=source.Document.GetElement(source.GetTypeId())??throw new ToolInputException("Dependency has no type."); + var driver=NativeToolUtil.Parameter(source,from.Name??"",from.Guid??""); + if(driver?.StorageType!=StorageType.Double||!driver.HasValue||driver.Definition.GetDataType()!=SpecTypeId.Length)throw new ToolInputException("Dependent dimension source must be a real Length parameter."); + value=JsonSerializer.SerializeToElement(driver.AsDouble()*Units.MmPerFoot*from.Scale+from.OffsetMm); + } var p=NativeToolUtil.Parameter(e,v.Name??"",v.Guid??"")??throw new ToolInputException("Parameter missing from part."); if(p.IsReadOnly)throw new ToolInputException($"{p.Definition.Name} is read-only."); if(v.Unit=="mm"&&(p.StorageType!=StorageType.Double||p.Definition.GetDataType()!=SpecTypeId.Length))throw new ToolInputException("mm only applies to length parameters."); + double number=p.StorageType==StorageType.Double?value.GetDouble()/(v.Unit=="mm"?Units.MmPerFoot:1):0; + int integer=p.StorageType==StorageType.Integer?value.ValueKind==JsonValueKind.True?1:value.ValueKind==JsonValueKind.False?0:value.GetInt32():0; + if(p.StorageType==StorageType.Double&&!double.IsFinite(number))throw new ToolInputException("Nonfinite dependent dimension."); var ok=p.StorageType switch - {StorageType.Double=>p.Set(v.Value.GetDouble()/(v.Unit=="mm"?Units.MmPerFoot:1)),StorageType.Integer=>p.Set(v.Value.ValueKind==JsonValueKind.True?1:v.Value.ValueKind==JsonValueKind.False?0:v.Value.GetInt32()),StorageType.String=>p.Set(v.Value.GetString()??""),StorageType.ElementId=>p.Set(new ElementId(v.Value.GetInt64())),_=>false}; + {StorageType.Double=>p.HasValue&&Math.Abs(p.AsDouble()-number)<1e-10||p.Set(number),StorageType.Integer=>p.HasValue&&p.AsInteger()==integer||p.Set(integer),StorageType.String=>p.AsString()==value.GetString()||p.Set(value.GetString()??""),StorageType.ElementId=>p.AsElementId().Value==value.GetInt64()||p.Set(new ElementId(value.GetInt64())),_=>false}; if(!ok)throw new ToolInputException($"Revit rejected {p.Definition.Name}."); } } @@ -103,23 +119,25 @@ public sealed class UpsertConnectionNode:IRevitTool public InputSchema InputSchema=>NativeToolUtil.Schema(new() { ["node_key"]=NativeToolUtil.Field("string","Stable document-local node key, max 128 chars."), - ["spec"]=NativeToolUtil.Any("{parts:[{key,role:member|plate|splice|bolt|hole|cope|rib|other,element_id?,family_type_id?,level_id?,point_mm?,axis_x?,axis_y?,relative_to?,face_reference?,offset_mm?,parameters:[{name? OR guid?,value,unit:internal|mm}],cut_targets:[keys]}],rules:[{kind:clash|contact|bolt,a,b,tolerance_mm?:1,max_volume_mm3?:1,face_reference?:stable face on b for contact,expected_offset_mm?:0,local_axis_a?:[0,0,1],local_axis_b?:[0,0,1],bolt_diameter_parameter?,hole_diameter_parameter?,grip_parameter?,stack_parts?:[keys],forbidden_planes?:[{origin_mm,normal,half_width_mm}]}],calculation_evidence?,documentation_evidence?}. Use full spec on updates; omit to refresh. Diameter/grip names must be actual Length parameters."), + ["spec"]=NativeToolUtil.Any("{parts:[{key,role:member|plate|splice|bolt|hole|cope|rib|other,element_id?,family_type_id?,level_id?,point_mm?,axis_x?,axis_y?,relative_to?,face_reference?,offset_mm?,parameters:[{name? OR guid?,value? OR value_from:{part_key,name? OR guid?,scope:instance|type,scale?:1,offset_mm?:0},unit:internal|mm}],cut_targets:[keys]}],rules:[{kind:clash|contact|bolt,a,b,tolerance_mm?:1,max_volume_mm3?:1,face_reference?:stable face on b for contact,expected_offset_mm?:0,local_axis_a?:[0,0,1],local_axis_b?:[0,0,1],bolt_diameter_parameter?,hole_diameter_parameter?,grip_parameter?,stack_parts?:[keys],forbidden_planes?:[{origin_mm,normal,half_width_mm}]}],calculation_evidence?,documentation_evidence?}. Use full spec on updates; omit to refresh. Diameter/grip names must be actual Length parameters."), ["preview"]=NativeToolUtil.Field("boolean","Default true: roll back and return a change report; preview IDs are temporary."), ["expected_revision"]=NativeToolUtil.Field("integer","Optional optimistic revision check, 0 for a new node."), ["remove_missing_parts"]=NativeToolUtil.Field("boolean","Default false. Allow removal of omitted managed parts. Referenced members are only unbound."), ["require_valid"]=NativeToolUtil.Field("boolean","Default true: failed declared checks prevent apply. Incomplete checks are reported separately.") + ,["auto_update"]=NativeToolUtil.Field("boolean","Default true for new nodes; existing nodes preserve their setting. Follows saved relative placements and value_from Length dependencies after member/type edits. Failures mark needs_refresh and preserve dependent geometry.") },"node_key"); public string Execute(IReadOnlyDictionary input,UIApplication app) { var doc=NativeToolUtil.Doc(app);var key=ToolInput.RequiredString(input,"node_key");if(key.Length>128)throw new ToolInputException("node_key exceeds 128 chars."); var (storage,old)=ConnectionNodes.Find(doc,key); + if(old==null&&ConnectionNodes.All(doc).Take(100).Count()>=100)throw new ToolInputException("At most 100 managed nodes per document."); if(input.TryGetValue("expected_revision",out var rev)&&rev.GetInt32()!=(old?.Revision??0))throw new ToolInputException("Node revision changed; reload before updating."); var spec=input.TryGetValue("spec",out var raw)?raw.Deserialize(ConnectionSpec.Options)??throw new ToolInputException("Empty spec."):old?.Spec??throw new ToolInputException("New nodes need a spec."); spec=JsonSerializer.Deserialize(JsonSerializer.Serialize(spec,ConnectionSpec.Options),ConnectionSpec.Options)!; var ordered=spec.OrderedParts();var preview=NativeToolUtil.Preview(input);var remove=ToolInput.Flag(input,"remove_missing_parts"); var omitted=old?.Bindings.Keys.Except(spec.Parts.Select(p=>p.Key)).ToArray()??[]; if(omitted.Length>0&&!remove)throw new ToolInputException("Omitted parts require remove_missing_parts=true."); - var record=new NodeRecord {Key=key,Revision=(old?.Revision??0)+1,Spec=spec}; + var record=new NodeRecord {Key=key,Revision=(old?.Revision??0)+1,Spec=spec,AutoUpdate=input.TryGetValue("auto_update",out var auto)?auto.GetBoolean():old?.AutoUpdate??true}; var created=new List();var updated=new List();var deleted=new List();var unbound=new List();var parameterChanges=new List(); Dictionary elements=new(); object? validation=null; @@ -162,7 +180,7 @@ public string Execute(IReadOnlyDictionary input,UIApplicatio if(p.FaceReference!=null){var face=ConnectionNodes.Face(doc,p.FaceReference);var o=p.OffsetMm??new double[3];point=face.Origin+(face.XVector*o[0]+face.YVector*o[1]+face.FaceNormal*o[2])/Units.MmPerFoot;} if(point!=null)ConnectionNodes.Position(doc,e,point,p.AxisX==null?null:new XYZ(p.AxisX[0],p.AxisX[1],p.AxisX[2]).Normalize(),p.AxisY==null?null:new XYZ(p.AxisY[0],p.AxisY[1],p.AxisY[2]).Normalize()); else if(p.AxisX!=null)ConnectionNodes.Position(doc,e,ConnectionNodes.Frame(e).Origin,new XYZ(p.AxisX[0],p.AxisX[1],p.AxisX[2]).Normalize(),new XYZ(p.AxisY![0],p.AxisY[1],p.AxisY[2]).Normalize()); - ConnectionNodes.Parameters(e,p.Parameters);elements[p.Key]=e;record.Bindings[p.Key]=new(){UniqueId=e.UniqueId,Owned=owned}; + ConnectionNodes.Parameters(e,p.Parameters,elements);elements[p.Key]=e;record.Bindings[p.Key]=new(){UniqueId=e.UniqueId,Owned=owned}; if(!created.Contains(e.Id.Value))updated.Add(e.Id.Value); } doc.Regenerate(); @@ -183,7 +201,7 @@ public string Execute(IReadOnlyDictionary input,UIApplicatio var check=ConnectionNodeValidation.Check(doc,record);validation=check; if((!input.TryGetValue("require_valid",out var v)||v.GetBoolean())&&check.Failed>0)throw new ToolInputException("Node geometry checks failed; entire update rolled back. Use preview with require_valid=false to inspect the report."); }); - return Services.Json.Serialize(new {preview,preview_ids_are_temporary=preview,result,validation,warnings,refresh="Reapply this tool without spec after changing members; no background updater runs on manual edits."}); + return Services.Json.Serialize(new {preview,preview_ids_are_temporary=preview,result,validation,warnings,auto_update=record.AutoUpdate,refresh="Relative placements and declared value_from dimensions follow source edits through the required updater. Missing/cyclic/invalid dependencies are flagged; inspect get_connection_node before manual refresh."}); } } @@ -199,7 +217,7 @@ public string Execute(IReadOnlyDictionary input,UIApplicatio if(key.Length==0)return Services.Json.Serialize(new {nodes=ConnectionNodes.All(doc).Take(1000).Select(p=>new {key=p.Record.Key,revision=p.Record.Revision,part_count=p.Record.Bindings.Count}).ToArray(),limit=1000}); var (_,r)=ConnectionNodes.Find(doc,key);if(r==null)throw new ToolInputException("Node not found."); var parts=r.Bindings.Select(p=>{var e=doc.GetElement(p.Value.UniqueId);return new {key=p.Key,id=e?.Id.Value,owned=p.Value.Owned,missing=e==null,externally_changed=e!=null&&ConnectionNodes.Signature(e)!=p.Value.Signature};}).ToArray(); - return Services.Json.Serialize(new {node_key=key,revision=r.Revision,spec=JsonSerializer.SerializeToElement(r.Spec,ConnectionSpec.Options),parts,needs_refresh=parts.Any(p=>p.missing||p.externally_changed)}); + return Services.Json.Serialize(new {node_key=key,revision=r.Revision,auto_update=r.AutoUpdate,last_update_error=r.LastUpdateError,spec=JsonSerializer.SerializeToElement(r.Spec,ConnectionSpec.Options),parts,needs_refresh=r.NeedsRefresh||parts.Any(p=>p.missing||p.externally_changed)}); } } diff --git a/ClaudeRevit/Tools/ConnectionOpeningGeometry.cs b/ClaudeRevit/Tools/ConnectionOpeningGeometry.cs new file mode 100644 index 0000000..ec0ac30 --- /dev/null +++ b/ClaudeRevit/Tools/ConnectionOpeningGeometry.cs @@ -0,0 +1,28 @@ +using Autodesk.Revit.DB; +namespace ClaudeRevit.Tools; + +internal sealed record BoreEvidence(bool? Verified, double? DiameterMm, double? StartMm, double? EndMm, string Detail); +internal static class ConnectionOpeningGeometry +{ + public static BoreEvidence Inspect(Element plate, XYZ origin, XYZ axis, double requiredDiameterMm, double toleranceMm) + { + var solids=ConnectionNodes.Solids(plate);if(solids.Count==0)return new(null,null,null,null,"No native solid plate geometry."); + var vertices=solids.SelectMany(s=>s.Faces.Cast()).SelectMany(f=>f.Triangulate().Vertices).Take(50001).ToArray(); + if(vertices.Length is 0 or >50000)return new(null,null,null,null,"Actual solid vertices exceed inspection limits or are absent."); + double lo=vertices.Min(p=>(p-origin).DotProduct(axis))*Units.MmPerFoot,hi=vertices.Max(p=>(p-origin).DotProduct(axis))*Units.MmPerFoot; + foreach(var face in solids.SelectMany(s=>s.Faces.Cast()).OfType()) + { + ToolContext.ThrowIfCancelled(); + if(Math.Abs(face.Axis.Normalize().DotProduct(axis))toleranceMm)continue; + var uv=face.GetBoundingBox();var middle=(uv.Min+uv.Max)/2;var point=face.Evaluate(middle); + var radial=point-face.Origin;radial-=face.Axis.Normalize()*radial.DotProduct(face.Axis.Normalize()); + if(radial.GetLength()<1e-9||face.ComputeNormal(middle).DotProduct(radial.Normalize())>=-.5)continue; // inner bore, not an external bolt cylinder + double diameter=2*face.get_Radius(0).GetLength()*Units.MmPerFoot; + var mesh=face.Triangulate();double start=mesh.Vertices.Min(p=>(p-origin).DotProduct(axis))*Units.MmPerFoot,end=mesh.Vertices.Max(p=>(p-origin).DotProduct(axis))*Units.MmPerFoot; + if(diameter+1e-6=hi-toleranceMm)return new(true,diameter,start,end,"Actual inner cylindrical face penetrates the complete solid envelope along the bolt axis."); + } + return new(false,null,lo,hi,"No coaxial through bore of sufficient diameter exists in the actual plate solids; faceted/noncylindrical bores require another geometry check."); + } +} diff --git a/ClaudeRevit/Tools/CreateParametricSection.cs b/ClaudeRevit/Tools/CreateParametricSection.cs index 6e2ee56..a5a1c74 100644 --- a/ClaudeRevit/Tools/CreateParametricSection.cs +++ b/ClaudeRevit/Tools/CreateParametricSection.cs @@ -9,23 +9,31 @@ namespace ClaudeRevit.Tools; public sealed class CreateParametricSection:IRevitTool { public string Name=>"create_parametric_section"; - public string Description=>"In an active ordinary RFA, create a native rectangular/box/I extrusion with real labeled sketch constraints, formulas and length association. All section dimensions flex independently at 0.8x/1.2x and measured solid volume must match the analytical section area; any failure rolls back. Default preview=true. Reuses compatible named length parameters; never fabricates shared GUIDs. Preserves template category; use loaded native structural profiles first. Does not make a steel connection or automatically bind beam placement length."; + public string Description=>"Create a native constrained rectangle, box, I, channel, angle, circular tube or two-piece timber section in an ordinary RFA. Real labeled dimensions, material association, analytical area and optional explicit-density mass formula. Independent 0.8x/1.2x flex verifies actual dimensions AND volume; any failure rolls back. Circular tubes use arcs, not polygon meshes. Optionally bind Length to an inspected template placement-length parameter. Default preview=true. Preserves the template category; loaded native structural profiles are preferred. Verify structural placement in a project before production."; public bool RequiresTransaction=>false; public bool MutatesWithoutTransaction=>true; public bool RequiresNoTurnGroup=>true; public bool InvalidatesCatalog=>true; public InputSchema InputSchema=>NativeToolUtil.Schema(new() { - ["shape"]=NativeToolUtil.Field("string","rectangle, box or i."),["width_mm"]=NativeToolUtil.Field("number","Section width."),["height_mm"]=NativeToolUtil.Field("number","Section height."), + ["shape"]=NativeToolUtil.Field("string","rectangle, box, i, channel, angle, tube or timber_pair."),["width_mm"]=NativeToolUtil.Field("number","Overall section width; tube diameter."),["height_mm"]=NativeToolUtil.Field("number","Overall section height; tube must equal diameter."), ["web_mm"]=NativeToolUtil.Field("number","Box side wall/I web thickness; default 10."),["flange_mm"]=NativeToolUtil.Field("number","Box top/bottom wall/I flange thickness; default 10."), ["length_mm"]=NativeToolUtil.Field("number","Extrusion length; default 1000."),["parameter_prefix"]=NativeToolUtil.Field("string","Default Section_. Creates Width, Height, Web, Flange, Length and derived coordinate parameters."), ["is_instance"]=NativeToolUtil.Field("boolean","Default true. Driver parameter instance/type mode."),["plane_normal"]=NativeToolUtil.Any("Default Z; use X for a YZ section. Active family view must be parallel to sketch plane."), + ["gap_mm"]=NativeToolUtil.Field("number","timber_pair: gap between two rectangular timber pieces; overrides web_mm."), + ["placement_length_parameter"]=NativeToolUtil.Field("string","Optional EXISTING instance Length parameter from the beam template; Section_Length uses its formula. Discover it first; do not invent a name. Project placement still needs an acceptance test."), + ["material_id"]=NativeToolUtil.Field("integer","Optional Material in this family document. Always creates/associates a real Material family parameter."), + ["density_kg_m3"]=NativeToolUtil.Field("number","Optional explicitly agreed positive material density. Creates physical Density and Mass=Area*Length*Density parameters; density is not inferred from a material name."), ["preview"]=NativeToolUtil.Field("boolean","Default true; false commits after independent flex checks.") },"shape","width_mm","height_mm"); public string Execute(IReadOnlyDictionary input,UIApplication app) { var doc=NativeToolUtil.Doc(app);var fm=FamilyEditorUtil.Manager(doc);var view=doc.ActiveView; var shape=ToolInput.RequiredString(input,"shape");var w=input["width_mm"].GetDouble();var h=input["height_mm"].GetDouble();var t=ToolInput.OptionalDouble(input,"web_mm")??10;var f=ToolInput.OptionalDouble(input,"flange_mm")??10;var length=ToolInput.OptionalDouble(input,"length_mm")??1000; + if(shape=="timber_pair")t=ToolInput.OptionalDouble(input,"gap_mm")??t; + var placementName=NativeToolUtil.Text(input,"placement_length_parameter");var placement=placementName.Length==0?null:FamilyEditorUtil.Require(fm,placementName); + if(placement!=null){if(!FamilyEditorUtil.IsLength(placement)||!placement.IsInstance)throw new ToolInputException("Placement length must be an existing instance Length parameter.");if(fm.CurrentType==null)throw new ToolInputException("Set a nominal family type before binding template length.");length=fm.CurrentType.AsDouble(placement)!.Value*Units.MmPerFoot;} + var density=ToolInput.OptionalDouble(input,"density_kg_m3");if(density.HasValue&&(!double.IsFinite(density.Value)||density<=0))throw new ToolInputException("density_kg_m3 must be finite and positive."); SectionProfile.Area(shape,w,h,t,f);if(!double.IsFinite(length)||length<=0)throw new ToolInputException("length_mm must be positive."); var prefix=NativeToolUtil.Text(input,"parameter_prefix","Section_");Services.GeometryPreflight.Name(prefix+"Width"); bool instance=!input.TryGetValue("is_instance",out var ins)||ins.GetBoolean();bool preview=NativeToolUtil.Preview(input); @@ -43,18 +51,37 @@ FamilyParameter Add(string suffix,double value,string? formula=null) else if(!FamilyEditorUtil.IsLength(p)||p.IsInstance!=instance||p.IsDeterminedByFormula)throw new ToolInputException("Existing driver is incompatible: "+name); fm.Set(p,value/Units.MmPerFoot);if(formula!=null)fm.SetFormula(p,formula);parameters[suffix]=p;return p; } - Add("Width",w);Add("Height",h);Add("Length",length); - if(shape!="rectangle") {Add("Web",t);Add("Flange",f);} - double left=shape=="box"?t:(w-t)/2,right=shape=="box"?w-t:(w+t)/2; - if(shape!="rectangle") - {Add("InnerLeft",left,shape=="box"?prefix+"Web":"("+prefix+"Width - "+prefix+"Web) / 2");Add("InnerRight",right,shape=="box"?prefix+"Width - "+prefix+"Web":"("+prefix+"Width + "+prefix+"Web) / 2");Add("InnerTop",h-f,prefix+"Height - "+prefix+"Flange");} + Add("Width",w);Add("Height",h,shape=="tube"?prefix+"Width":null);Add("Length",length,placementName.Length>0?placementName:null); + string thickness=prefix+(shape=="timber_pair"?"Gap":"Web"); + if(shape!="rectangle"){var web=Add(shape=="timber_pair"?"Gap":"Web",t);parameters["Web"]=web;} + if(shape is "box" or "i" or "channel" or "angle")Add("Flange",f); + double left=shape is "box" or "channel" or "angle"?t:(w-t)/2,right=shape=="box"?w-t:(w+t)/2; + if(shape is "box" or "i" or "channel" or "angle" or "timber_pair") + { + Add("InnerLeft",left,shape is "box" or "channel" or "angle"?thickness:"("+prefix+"Width - "+thickness+") / 2"); + if(shape is "box" or "i" or "timber_pair")Add("InnerRight",right,shape=="box"?prefix+"Width - "+thickness:"("+prefix+"Width + "+thickness+") / 2"); + if(shape is "box" or "i" or "channel")Add("InnerTop",h-f,prefix+"Height - "+prefix+"Flange"); + } XYZ P(double x,double y)=>(u*x+v*y)/Units.MmPerFoot; using var plane=Plane.CreateByOriginAndBasis(XYZ.Zero,u,v);var sp=SketchPlane.Create(doc,plane); var loops=new CurveArrArray(); - foreach(var polygon in SectionProfile.Loops(shape,w,h,t,f)) + if(shape=="tube") + { + Add("Radius",w/2,prefix+"Width / 2");Add("InnerRadius",w/2-t,prefix+"Radius - "+thickness); + foreach(var radius in new[]{w/2,w/2-t}){var curves=new CurveArray();curves.Append(Arc.Create(XYZ.Zero,radius/Units.MmPerFoot,0,Math.PI,u,v));curves.Append(Arc.Create(XYZ.Zero,radius/Units.MmPerFoot,Math.PI,2*Math.PI,u,v));loops.Append(curves);} + } + else foreach(var polygon in SectionProfile.Loops(shape,w,h,t,f)) {var curves=new CurveArray();for(int i=0;i$"{W} * {H}","box"=>$"{W} * {H} - ({W} - 2 * {T}) * ({H} - 2 * {F})","i"=>$"2 * {W} * {F} + ({H} - 2 * {F}) * {T}","channel"=>$"{H} * {T} + 2 * ({W} - {T}) * {F}","angle"=>$"{H} * {T} + ({W} - {T}) * {F}","timber_pair"=>$"({W} - {T}) * {H}",_=>$"3.141592653589793 * ({W} * {W} - ({W} - 2 * {T}) * ({W} - 2 * {T})) / 4"}); + if(density.HasValue){var dp=fm.AddParameter(prefix+"Density",GroupTypeId.Materials,SpecTypeId.MassDensity,instance);fm.Set(dp,UnitUtils.ConvertToInternalUnits(density.Value,UnitTypeId.KilogramsPerCubicMeter));var mass=fm.AddParameter(prefix+"Mass",GroupTypeId.Data,SpecTypeId.Mass,instance);fm.SetFormula(mass,prefix+"Area * "+prefix+"Length * "+prefix+"Density");} var edges=form.Sketch.GetAllElements().Select(doc.GetElement).OfType().ToArray(); void Drive(bool horizontal,Dictionary positions) { @@ -72,26 +99,62 @@ void Drive(bool horizontal,Dictionary positions) doc.FamilyCreate.NewAlignment(view,rp.GetReference(),edge.GeometryCurve.Reference); } } - var xs=new Dictionary{{0,null},{w,"Width"}};var ys=new Dictionary{{0,null},{h,"Height"}}; - if(shape!="rectangle"){xs[left]="InnerLeft";xs[right]="InnerRight";ys[f]="Flange";ys[h-f]="InnerTop";} - Drive(true,xs);Drive(false,ys);doc.Regenerate(); + if(shape=="tube") + { + foreach(var edge in edges.Where(e=>e.GeometryCurve is Arc)) + {var arc=(Arc)edge.GeometryCurve;var label=Math.Abs(arc.Radius*Units.MmPerFoot-w/2)<1e-5?"Radius":"InnerRadius";doc.FamilyCreate.NewRadialDimension(view,arc.Reference,P(w,w)).FamilyLabel=parameters[label];} + // Nominal arc centres are fixed by orthogonal centre/end-point references. + var xplane=doc.FamilyCreate.NewReferencePlane(-v,v,n,view);var yplane=doc.FamilyCreate.NewReferencePlane(-u,u,n,view);xplane.Pinned=yplane.Pinned=true; + foreach(var edge in edges.Where(e=>e.GeometryCurve is Arc))foreach(var end in new[]{0,1})doc.FamilyCreate.NewAlignment(view,yplane.GetReference(),edge.GeometryCurve.GetEndPointReference(end)); + // Both diametric endpoints constrain the centre in the other direction. + // Revit may reject an incompatible template constraint; preview/flex rolls it back. + foreach(var edge in edges.Where(e=>e.GeometryCurve is Arc).GroupBy(e=>Math.Round(((Arc)e.GeometryCurve).Radius,8)).Select(g=>g.First()))foreach(var end in new[]{0,1}) + { + var arc=(Arc)edge.GeometryCurve;var refs=new ReferenceArray();refs.Append(xplane.GetReference());refs.Append(arc.GetEndPointReference(end)); + var point=arc.GetEndPoint(end);var shift=-v*(100/Units.MmPerFoot); + var dim=doc.FamilyCreate.NewDimension(view,Line.CreateBound(shift,point+shift),refs); + dim.FamilyLabel=parameters[Math.Abs(arc.Radius*Units.MmPerFoot-w/2)<1e-5?"Radius":"InnerRadius"]; + } + } + else + { + var xs=new Dictionary{{0,null},{w,"Width"}};var ys=new Dictionary{{0,null},{h,"Height"}}; + if(shape!="rectangle"){xs[left]="InnerLeft";if(shape is "box" or "i" or "timber_pair")xs[right]="InnerRight";if(shape is "box" or "i" or "channel" or "angle")ys[f]="Flange";if(shape is "box" or "i" or "channel")ys[h-f]="InnerTop";} + Drive(true,xs);Drive(false,ys); + } + doc.Regenerate(); return new {form_id=preview?(long?)null:form.Id.Value,shape,category=doc.OwnerFamily.FamilyCategory?.Name,drivers=parameters.Where(p=>p.Key is "Width" or "Height" or "Length" or "Web" or "Flange").Select(p=>new {name=p.Value.Definition.Name,is_instance=p.Value.IsInstance}).ToArray(),axis=NativeToolUtil.Vector(n)}; },()=> { double Volume() {var e=doc.GetElement(form!.Id)??throw new InvalidOperationException("Section disappeared after commit.");return ConnectionNodes.Solids(e).Sum(s=>s.Volume)*Math.Pow(Units.MmPerFoot,3);} var baseline=new Dictionary{{"Width",w},{"Height",h},{"Web",t},{"Flange",f},{"Length",length}}; void Verify(Dictionary dims,string scenario) - {var expected=SectionProfile.Area(shape,dims["Width"],dims["Height"],dims["Web"],dims["Flange"])*dims["Length"];var actual=Volume();if(Math.Abs(actual-expected)>Math.Max(1,expected*1e-5))throw new ToolInputException($"Section flex '{scenario}' did not change actual geometry as required; update rolled back.");flex.Add(new {scenario,measured_volume_mm3=actual,expected_volume_mm3=expected,passed=true});} + { + var expected=SectionProfile.Area(shape,dims["Width"],dims["Height"],dims["Web"],dims["Flange"])*dims["Length"];var actual=Volume();if(Math.Abs(actual-expected)>Math.Max(1,expected*1e-5))throw new ToolInputException($"Section flex '{scenario}' has wrong volume; update rolled back."); + var points=ConnectionNodes.Solids(doc.GetElement(form!.Id)).SelectMany(s=>s.Faces.Cast()).SelectMany(face=>face.Triangulate().Vertices).ToArray(); + var measured=new[]{u,v,n}.Select(axis=>(points.Max(p=>p.DotProduct(axis))-points.Min(p=>p.DotProduct(axis)))*Units.MmPerFoot).ToArray();var requested=new[]{dims["Width"],dims["Height"],dims["Length"]}; + if(measured.Zip(requested,(a,b)=>Math.Abs(a-b)>Math.Max(.01,b*1e-5)).Any(failed=>failed))throw new ToolInputException($"Section flex '{scenario}' has wrong width/height/length; update rolled back."); + var minimum=new[]{u,v,n}.Select(axis=>points.Min(p=>p.DotProduct(axis))*Units.MmPerFoot).ToArray(); + var expectedMinimum=shape=="tube"?new[]{-dims["Width"]/2,-dims["Height"]/2,0d}:new[]{0d,0,0}; + if(minimum.Zip(expectedMinimum,(a,b)=>Math.Abs(a-b)>.01).Any(failed=>failed))throw new ToolInputException($"Section flex '{scenario}' moved off its reference origin; update rolled back."); + flex.Add(new {scenario,measured_dimensions_mm=measured,expected_dimensions_mm=requested,measured_volume_mm3=actual,expected_volume_mm3=expected,passed=true}); + } Verify(baseline,"initial"); - foreach(var key in parameters.Keys.Where(k=>baseline.ContainsKey(k)).ToArray())foreach(var factor in new[]{.8,1.2}) + foreach(var key in parameters.Keys.Where(k=>baseline.ContainsKey(k)&&!parameters[k].IsDeterminedByFormula).ToArray())foreach(var factor in new[]{.8,1.2}) { ToolContext.ThrowIfCancelled();var dims=new Dictionary(baseline){[key]=baseline[key]*factor}; + if(shape=="tube"&&key=="Width")dims["Height"]=dims["Width"]; // Refuse shapes whose requested nominal proportions cannot sustain the test envelope. SectionProfile.Area(shape,dims["Width"],dims["Height"],dims["Web"],dims["Flange"]); NativeToolUtil.Commit(doc,"Claude: section flex "+key,true,()=>{fm.Set(parameters[key],dims[key]/Units.MmPerFoot);doc.Regenerate();return true;},()=>Verify(dims,key+" x "+factor)); } + if(placement!=null)foreach(var factor in new[]{.8,1.2}) + { + var dims=new Dictionary(baseline){["Length"]=length*factor}; + NativeToolUtil.Commit(doc,"Claude: template placement-length flex",true,()=>{fm.Set(placement,dims["Length"]/Units.MmPerFoot);doc.Regenerate();return true;},()=>Verify(dims,"placement length x "+factor)); + } Verify(baseline,"restored"); }); - return Services.Json.Serialize(new {preview,result,flex,warnings,limitation="Constrained profile geometry only. Existing beam template length/material/reference-plane behavior must be checked before loading as a structural beam."}); + return Services.Json.Serialize(new {preview,result,flex,warnings,placement_length_parameter=placementName,density_source=density.HasValue?"explicit_user_agreed_density":"not_set",limitation="Template placement length/material/reference-plane behavior still needs a native project acceptance test. Explicit density does not automatically follow a different material."}); } } diff --git a/ClaudeRevit/Tools/CreateSpdsSchedule.cs b/ClaudeRevit/Tools/CreateSpdsSchedule.cs index 52d25a1..7cf6602 100644 --- a/ClaudeRevit/Tools/CreateSpdsSchedule.cs +++ b/ClaudeRevit/Tools/CreateSpdsSchedule.cs @@ -17,6 +17,7 @@ public sealed class CreateSpdsSchedule:IRevitTool ["profile"]=NativeToolUtil.Field("string","scheme_specification, timber_materials, timber_elements."),["standard_edition"]=NativeToolUtil.Field("string","2020 or 2026."),["category"]=NativeToolUtil.Field("string","Source category."),["name"]=NativeToolUtil.Field("string","Unique schedule name."),["existing_schedule_id"]=NativeToolUtil.Field("integer","Optional approved schedule to duplicate. Its field mappings are replaced; filters retained unless filters is supplied."), ["fields"]=NativeToolUtil.Any("Array in profile column order: {parameter_id? OR name?,heading?,width_mm?,unit?:mm|m|m2|m3|kg|t,accuracy?,total?:bool,alignment?:left|center|right}. A schedulable Count field can be resolved by its inspected name/parameter ID."), ["sort_fields"]=NativeToolUtil.Array("integer","Optional field indices for ascending sort/group."),["filters"]=NativeToolUtil.Any("Optional [{field_index,value}] equality filters; values are raw internal numeric units or text. Use to select steel/timber and building section."),["scope"]=NativeToolUtil.Field("string","filtered or entire_category; explicit entire_category allows an unfiltered schedule."), + ["include_linked_files"]=NativeToolUtil.Field("boolean","Default false. Native live linked rows using fields already bound in the links; does not modify read-only linked documents."), ["is_itemized"]=NativeToolUtil.Field("boolean","Default false."),["font_name"]=NativeToolUtil.Field("string","Agreed installed font."),["text_height_mm"]=NativeToolUtil.Field("number","Default 2.5 mm."),["grand_total"]=NativeToolUtil.Field("boolean","Default false; set only when quantity/mass totals are meaningful."),["preview"]=NativeToolUtil.Field("boolean","Default true.") },"profile","standard_edition","category","name","fields","scope","font_name"); public string Execute(IReadOnlyDictionary input,UIApplication app) @@ -27,14 +28,15 @@ public string Execute(IReadOnlyDictionary input,UIApplicatio var fields=input["fields"].EnumerateArray().ToArray();if(fields.Length!=columns.Count)throw new ToolInputException("Each required profile column needs a field mapping."); var scope=ToolInput.RequiredString(input,"scope");if(scope is not ("filtered" or "entire_category"))throw new ToolInputException("scope must be filtered/entire_category."); var name=ToolInput.RequiredString(input,"name");Services.GeometryPreflight.Name(name);var size=ToolInput.OptionalDouble(input,"text_height_mm")??2.5;if(!double.IsFinite(size)||size<1.8||size>7)throw new ToolInputException("text_height_mm must be 1.8..7."); - var preview=NativeToolUtil.Preview(input); + var preview=NativeToolUtil.Preview(input);var categoryId=input["category"].GetString()=="multi_category"?ElementId.InvalidElementId:new ElementId(CategoryResolve.Parse(input["category"].GetString()??"")); var (result,warnings)=NativeToolUtil.Commit(doc,"Claude: SPDS schedule",preview,()=> { ViewSchedule s; if(input.TryGetValue("existing_schedule_id",out var existing)) - {var original=NativeToolUtil.Element(doc,existing.GetInt64()) as ViewSchedule??throw new ToolInputException("Not a ViewSchedule.");if(original.IsTemplate)throw new ToolInputException("Use an actual approved schedule, not a view template.");s=(ViewSchedule)doc.GetElement(original.Duplicate(ViewDuplicateOption.Duplicate));if(s.Definition.CategoryId.Value!=(long)CategoryResolve.Parse(input["category"].GetString()??""))throw new ToolInputException("Existing schedule category differs from requested category.");s.ViewTemplateId=ElementId.InvalidElementId;} - else s=ViewSchedule.CreateSchedule(doc,new ElementId(CategoryResolve.Parse(input["category"].GetString()??""))); + {var original=NativeToolUtil.Element(doc,existing.GetInt64()) as ViewSchedule??throw new ToolInputException("Not a ViewSchedule.");if(original.IsTemplate)throw new ToolInputException("Use an actual approved schedule, not a view template.");s=(ViewSchedule)doc.GetElement(original.Duplicate(ViewDuplicateOption.Duplicate));if(s.Definition.CategoryId!=categoryId)throw new ToolInputException("Existing schedule category differs from requested category.");s.ViewTemplateId=ElementId.InvalidElementId;} + else s=ViewSchedule.CreateSchedule(doc,categoryId); s.Name=name;var definition=s.Definition; + if(ToolInput.Flag(input,"include_linked_files")){if(!definition.CanIncludeLinkedFiles())throw new ToolInputException("This schedule category cannot include linked files.");definition.IncludeLinkedFiles=true;} // Filters/sort reference field IDs: snapshot approved ones only when reusing identical fields. var oldFilters=definition.GetFilters().ToArray();var available=definition.GetSchedulableFields();var added=new List(); var oldOrder=definition.GetFieldOrder();definition.ClearFilters();definition.ClearSortGroupFields(); diff --git a/ClaudeRevit/Tools/CreateSpdsTable.cs b/ClaudeRevit/Tools/CreateSpdsTable.cs index 92f8158..b8f6ad3 100644 --- a/ClaudeRevit/Tools/CreateSpdsTable.cs +++ b/ClaudeRevit/Tools/CreateSpdsTable.cs @@ -2,6 +2,7 @@ using Anthropic.Models.Beta.Messages; using Autodesk.Revit.DB; using Autodesk.Revit.UI; +using ClaudeRevit.Services; namespace ClaudeRevit.Tools; @@ -16,32 +17,40 @@ public sealed class CreateSpdsTable:IRevitTool { ["profile"]=NativeToolUtil.Field("string","steel_rollup, timber_materials, timber_elements, scheme_specification."),["standard_edition"]=NativeToolUtil.Field("string","2026 or agreed legacy 2020."), ["table_key"]=NativeToolUtil.Field("string","Unique persistent table key, 1..64 characters. Reusing a key is rejected; edit the existing schedule/mapping deliberately."), - ["category"]=NativeToolUtil.Field("string","OST_StructuralFraming, OST_StructuralColumns, OST_GenericModel, OST_StructuralFoundation, OST_Walls, OST_Floors or OST_Roofs. Single category per live table."), + ["category"]=NativeToolUtil.Field("string","OST_StructuralFraming, OST_StructuralColumns, OST_GenericModel, OST_StructuralFoundation, OST_Walls, OST_Floors or OST_Roofs. Use multi_category with categories[] for one matrix across member/plate/bolt categories."), + ["categories"]=NativeToolUtil.Array("string","For category=multi_category: distinct supported source categories."), + ["nesting_policy"]=NativeToolUtil.Field("string","Required for new tables: explicit (count every selected source), parents_only or children_only, among agreed sources. Avoid double-counting assembly parents and children."), + ["category_mappings"]=NativeToolUtil.Any("Optional object keyed by category name with field mappings; default field_mapping is used otherwise."), ["scope"]=NativeToolUtil.Field("string","selected: fixed explicitly approved instance IDs; entire_category: includes future category additions. Clarify nested parent/child counting and steel/timber classification first."), ["source_ids"]=NativeToolUtil.Array("integer","Required for selected scope: distinct source instances, same category, at most 5000. Values stay live; new instances are included only with entire_category scope."), - ["field_mapping"]=NativeToolUtil.Any("Map profile/grade/size/group or name/mark/designation/notes/unit and numeric mass_kg/amount/quantity to {parameter_name OR parameter_guid OR parameter_id,scope:instance|type,unit?}. Text may use explicit approved {constant:'text'}. Numeric mass needs kg/t unless physical Mass. Physical amounts require m/m2/m3. mass_kg/amount are per unit and multiplied by quantity, default count=1. Mappings may not reference CR_SPDS derived parameters."), + ["field_mapping"]=NativeToolUtil.Any("Map profile/grade/size/group or name/mark/designation/notes/unit and numeric mass_kg/amount/quantity to {parameter_name OR parameter_guid OR parameter_id,scope:instance|type,unit?}. Text may use explicit approved {constant:'text'}. Numeric mass needs kg/t unless physical Mass. mass_kg may use {source:material_volumes} for actual model volumes times material structural-asset density, no assumed steel density. Physical amounts require m/m2/m3. mass_kg/amount are per unit and multiplied by quantity, default count=1. Mappings may not reference CR_SPDS derived parameters."), ["construction_groups"]=NativeToolUtil.Array("string","Steel: approved 1..20 matrix column groups, in desired order; each source group must match one. Future unknown groups visibly mark totals incomplete."), ["name"]=NativeToolUtil.Field("string","Unique schedule name."),["font_name"]=NativeToolUtil.Field("string","Agreed installed font."),["text_height_mm"]=NativeToolUtil.Field("number","Default 2.5, 1.8..7 mm."),["preview"]=NativeToolUtil.Field("boolean","Default true: rollback model parameters and schedules after validating.") - },"profile","standard_edition","table_key","category","scope","field_mapping","name","font_name"); + },"profile","standard_edition","table_key","category","scope","field_mapping","name","font_name","nesting_policy"); public string Execute(IReadOnlyDictionary input,UIApplication app) { var doc=NativeToolUtil.Doc(app);if(doc.IsFamilyDocument)throw new ToolInputException("Live project schedules require an RVT; edit family parameters then load them into a project."); var profile=ToolInput.RequiredString(input,"profile");Services.SpdsTables.Columns(profile);var edition=ToolInput.RequiredString(input,"standard_edition");if(edition is not ("2020" or "2026"))throw new ToolInputException("Choose standard edition 2020/2026."); var key=ToolInput.RequiredString(input,"table_key");if(key.Length>64)throw new ToolInputException("table_key must contain 1..64 characters.");var existing=LiveSpds.All(doc).ToArray();if(existing.Any(p=>p.Record.Key==key))throw new ToolInputException("Table key already exists; use its live schedule instead of creating duplicates.");if(existing.Length>=16)throw new ToolInputException("At most 16 derived live tables per document."); - var category=CategoryResolve.Parse(ToolInput.RequiredString(input,"category"));if(!LiveSpdsUpdater.Categories.Contains(category))throw new ToolInputException("This category has no live derived updater; use create_spds_schedule for direct native fields."); + var categoryName=ToolInput.RequiredString(input,"category"); + var categories=categoryName=="multi_category"?input["categories"].EnumerateArray().Select(c=>CategoryResolve.Parse(c.GetString()??"")).Distinct().ToArray():[CategoryResolve.Parse(categoryName)]; + if(categories.Length is <1 or >7||categories.Any(c=>!LiveSpdsUpdater.Categories.Contains(c)))throw new ToolInputException("Use 1..7 supported categories; direct native fields use create_spds_schedule."); + var category=categories[0];var nesting=ToolInput.RequiredString(input,"nesting_policy");NestingSelection.Select([],nesting); var scope=ToolInput.RequiredString(input,"scope");if(scope is not ("selected" or "entire_category"))throw new ToolInputException("Choose explicit selected/entire_category scope."); - var r=new LiveSpdsRecord{Key=key,Profile=profile,CategoryId=(long)category,Scope=scope,Mapping=input["field_mapping"].Clone()}; + var r=new LiveSpdsRecord{Key=key,Profile=profile,CategoryId=(long)category,CategoryIds=categories.Select(c=>(long)c).ToArray(),NestingPolicy=nesting,Scope=scope,Mapping=input["field_mapping"].Clone()}; if(r.Mapping.ValueKind!=JsonValueKind.Object)throw new ToolInputException("field_mapping must be an object."); + if(input.TryGetValue("category_mappings",out var maps))foreach(var map in maps.EnumerateObject()) + {var id=(long)CategoryResolve.Parse(map.Name);if(!r.Categories.Contains(id))throw new ToolInputException("Mapping category is outside agreed scope.");if(map.Value.ValueKind!=JsonValueKind.Object)throw new ToolInputException("Category mapping must be an object.");r.CategoryMappings[id]=map.Value.Clone();} var allDerived=existing.SelectMany(p=>p.Record.Parameters.Values).ToHashSet(); - foreach(var mapping in r.Mapping.EnumerateObject()) + foreach(var mapping in r.CategoryMappings.Values.Prepend(r.Mapping).SelectMany(m=>m.EnumerateObject())) { var value=mapping.Value;if(value.ValueKind!=JsonValueKind.Object)throw new ToolInputException("Each field mapping must be an object."); if(value.TryGetProperty("parameter_name",out var pn)&&pn.GetString()?.StartsWith("CR_SPDS_",StringComparison.OrdinalIgnoreCase)==true||value.TryGetProperty("parameter_guid",out var pg)&&Guid.TryParse(pg.GetString(),out var guid)&&allDerived.Contains(guid)||value.TryGetProperty("parameter_id",out var pid)&&doc.GetElement(new ElementId(pid.GetInt64())) is SharedParameterElement shared&&allDerived.Contains(shared.GuidValue))throw new ToolInputException("Live source mappings cannot depend on derived SPDS parameters; this creates update cycles."); } - if(scope=="selected")r.SourceUniqueIds=NativeToolUtil.Ids(input.TryGetValue("source_ids",out var ids)?ids:throw new ToolInputException("Selected scope needs source_ids."),5000).Select(id=>{var e=NativeToolUtil.Element(doc,id.Value);if(e is ElementType||e.Category?.Id.Value!=(long)category)throw new ToolInputException("Source must be an instance in the requested category.");return e.UniqueId;}).ToArray(); + if(scope=="selected")r.SourceUniqueIds=NativeToolUtil.Ids(input.TryGetValue("source_ids",out var ids)?ids:throw new ToolInputException("Selected scope needs source_ids."),5000).Select(id=>{var e=NativeToolUtil.Element(doc,id.Value);if(e is ElementType||!r.Categories.Contains(e.Category?.Id.Value??0))throw new ToolInputException("Source must be an instance in the requested category.");return e.UniqueId;}).ToArray(); r.Groups=input.TryGetValue("construction_groups",out var groups)?groups.EnumerateArray().Select(g=>g.GetString()??"").ToArray():[]; if(profile=="steel_rollup"&&(r.Groups.Length is <1 or >20||r.Groups.Any(string.IsNullOrWhiteSpace)||r.Groups.Distinct().Count()!=r.Groups.Length))throw new ToolInputException("Steel needs 1..20 distinct approved construction_groups.");if(profile!="steel_rollup"&&r.Groups.Length>0)throw new ToolInputException("Construction group columns apply only to steel."); - var sources=LiveSpds.Sources(doc,r);if(sources.Count==0)throw new ToolInputException("No model sources in the agreed scope.");var rows=sources.Select(e=>SpdsSource.ReadElement(doc,e,r.Mapping)).ToList();var previewTable=Services.SpdsTables.Build(profile,rows);foreach(var i in rows)Services.SpdsTables.ModelValues(profile,i,r.Groups); + var sources=LiveSpds.Sources(doc,r);if(sources.Count==0)throw new ToolInputException("No model sources in the agreed scope.");var rows=sources.Select(e=>SpdsSource.ReadElement(doc,e,r.Map(e))).ToList();var previewTable=Services.SpdsTables.Build(profile,rows);foreach(var i in rows)Services.SpdsTables.ModelValues(profile,i,r.Groups); var name=ToolInput.RequiredString(input,"name");Services.GeometryPreflight.Name(name);var font=ToolInput.RequiredString(input,"font_name");var size=ToolInput.OptionalDouble(input,"text_height_mm")??2.5;if(!double.IsFinite(size)||size<1.8||size>7)throw new ToolInputException("Text height must be 1.8..7 mm.");bool preview=NativeToolUtil.Preview(input); var (result,warnings)=NativeToolUtil.Commit(doc,"Claude: live SPDS schedules",preview,()=> { diff --git a/ClaudeRevit/Tools/DynamicToolLoader.cs b/ClaudeRevit/Tools/DynamicToolLoader.cs index 7823f9a..22858a3 100644 --- a/ClaudeRevit/Tools/DynamicToolLoader.cs +++ b/ClaudeRevit/Tools/DynamicToolLoader.cs @@ -273,6 +273,8 @@ public static IReadOnlyList ListSavedFiles() var typeName = tool.GetType().Name; typeName = typeName switch { + "GetVerifiedExperience" => "VerifyModelResult", + "RunCheckpointJob" or "GetCheckpointJob" => "CheckpointJob", "GetBimStarterTools" or "RunBimStarterCommand" => "BimStarterPluginTools", "SetRebarConstraint" => "GetRebarConstraints", "UpsertConnectionNode" or "GetConnectionNode" or "PlanTrussLayout" => "ConnectionNodes", diff --git a/ClaudeRevit/Tools/ExportScheduleCsv.cs b/ClaudeRevit/Tools/ExportScheduleCsv.cs index 3845a01..55275da 100644 --- a/ClaudeRevit/Tools/ExportScheduleCsv.cs +++ b/ClaudeRevit/Tools/ExportScheduleCsv.cs @@ -61,9 +61,12 @@ public string Execute(IReadOnlyDictionary input, UIApplicat var options = new ViewScheduleExportOptions { FieldDelimiter = ",", + HeadersFootersBlanks = true, + ColumnHeaders = ExportColumnHeaders.MultipleRows, TextQualifier = ExportTextQualifier.DoubleQuote }; schedule.Export(dir, name, options); + ScheduleExportEvidence.Record(doc, schedule, outPath); return Services.Json.Serialize(new { diff --git a/ClaudeRevit/Tools/FamilyDocumentScope.cs b/ClaudeRevit/Tools/FamilyDocumentScope.cs index 7d8628e..80d8385 100644 --- a/ClaudeRevit/Tools/FamilyDocumentScope.cs +++ b/ClaudeRevit/Tools/FamilyDocumentScope.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.IO; using Autodesk.Revit.DB; using Autodesk.Revit.UI; @@ -10,6 +11,16 @@ internal sealed class FamilyDocumentScope : IDisposable private readonly bool _close; public FamilyDocumentScope(UIApplication app, IReadOnlyDictionary input) { + if (input.TryGetValue("file_path", out var file)) + { + if (input.ContainsKey("family_id")) throw new ToolInputException("Use file_path or family_id, not both."); + var path = Path.GetFullPath(file.GetString() ?? ""); + if (!Path.IsPathFullyQualified(file.GetString() ?? "") || !path.EndsWith(".rfa", StringComparison.OrdinalIgnoreCase) || !File.Exists(path)) throw new ToolInputException("Supply an existing absolute RFA path."); + if (app.Application.Documents.Cast().Any(d => string.Equals(d.PathName, path, StringComparison.OrdinalIgnoreCase))) throw new ToolInputException("RFA is already open; inspect its active document instead of closing a user-owned file."); + Document = app.Application.OpenDocumentFile(path); _close = true; + if (!Document.IsFamilyDocument) { Document.Close(false); throw new ToolInputException("The file is not an ordinary family document."); } + return; + } var source = NativeToolUtil.Doc(app); if (source.IsModifiable) throw new InvalidOperationException("Finish the current transaction before inspecting a family."); if (input.TryGetValue("family_id", out var id) && id.ValueKind == JsonValueKind.Number) diff --git a/ClaudeRevit/Tools/FlexFamily.cs b/ClaudeRevit/Tools/FlexFamily.cs index 522b158..f10cb39 100644 --- a/ClaudeRevit/Tools/FlexFamily.cs +++ b/ClaudeRevit/Tools/FlexFamily.cs @@ -13,6 +13,7 @@ public sealed class FlexFamily : IRevitTool public bool RequiresNoTurnGroup => true; public InputSchema InputSchema => NativeToolUtil.Schema(new() { + ["file_path"] = NativeToolUtil.Field("string", "Optional absolute local RFA path: inspect in a background document, close unsaved; excludes family_id."), ["family_id"] = NativeToolUtil.Field("integer", "Optional loaded Family; otherwise active RFA."), ["scenarios"] = NativeToolUtil.Any("1..100 objects {name, type_name(optional), values:{parameterNameOrGUID:value}, require_solid(optional bool)}. Default tests all existing types."), ["require_geometry_change"] = NativeToolUtil.Field("boolean", "Default false. True rejects scenarios whose values change but solid mesh geometry remains identical. Test each independent size driver separately.") diff --git a/ClaudeRevit/Tools/GetScriptJournal.cs b/ClaudeRevit/Tools/GetScriptJournal.cs index 130da68..ca74f22 100644 --- a/ClaudeRevit/Tools/GetScriptJournal.cs +++ b/ClaudeRevit/Tools/GetScriptJournal.cs @@ -14,8 +14,8 @@ public class GetScriptJournal : IRevitTool public string Description => "Returns the learning journal of past execute_csharp / run_dynamo_python calls in " + "this environment: the code, whether it succeeded, and the exact MODEL DELTA it " + - "produced (elements added/modified/deleted by category). Check it before writing a " + - "new script — reuse a snippet that is already proven to work here instead of " + + "produced (elements added/modified/deleted by category), stable run_id, code hash, Revit build and document key. Check it before writing a " + + "new script. Execution success does not prove correct geometry; use verify_model_result and get_verified_experience before optional reuse instead of " + "reinventing it, and learn which API calls actually behave in this Revit/Dynamo " + "version. Recurring patterns here are also candidates for new dedicated tools."; diff --git a/ClaudeRevit/Tools/InspectFamilyFiles.cs b/ClaudeRevit/Tools/InspectFamilyFiles.cs new file mode 100644 index 0000000..c23e695 --- /dev/null +++ b/ClaudeRevit/Tools/InspectFamilyFiles.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using System.IO; +using System.Security.Cryptography; +using Anthropic.Models.Beta.Messages; +using Autodesk.Revit.UI; +namespace ClaudeRevit.Tools; + +public sealed class InspectFamilyFiles : IRevitTool +{ + public string Name => "inspect_family_files"; + public string Description => "Inspect 1..10 local RFA reference files in temporary background documents, closing each without saving. Returns real parameter GUIDs/formulas/types, nested dependency tree and optional independent flex scenarios. Does not load files into the project or upgrade the originals. Rejects files already open by the user. Compare actual geometry after size/variant changes; a catalog card alone cannot prove constraints."; + public bool RequiresTransaction => false; + public bool RequiresNoTurnGroup => true; + public InputSchema InputSchema => NativeToolUtil.Schema(new() + { + ["files"] = NativeToolUtil.Any("[{path:absolute RFA path,scenarios?:[{name,type_name?,values:{actual parameter name or GUID:value}}]}]. Length in mm, area m2, volume m3, angle degrees. Inspect first to discover names before flexing. No scenarios means analysis only."), + ["max_depth"] = NativeToolUtil.Field("integer", "Default 4, 1..8.") + }, "files"); + public string Execute(IReadOnlyDictionary input, UIApplication app) + { + var files = input["files"].EnumerateArray().ToArray(); if (files.Length is <1 or >10) throw new ToolInputException("Supply 1..10 RFA files."); + var rows = new List(); + for (int i = 0; i < files.Length; i++) + { + ToolContext.ReportProgress(i, files.Length, "inspecting reference families"); + var item = files[i]; var path = item.GetProperty("path").GetString() ?? ""; + var args = new Dictionary { ["file_path"] = JsonSerializer.SerializeToElement(path), ["max_depth"] = input.TryGetValue("max_depth", out var d) ? d : JsonSerializer.SerializeToElement(4) }; + try + { + if(new FileInfo(path).Length>100_000_000)throw new ToolInputException("RFA exceeds the 100 MB reference inspection limit."); + var structure = JsonSerializer.Deserialize(new AnalyzeFamilyStructure().Execute(args, app)); + JsonElement? flex = null; + if (item.TryGetProperty("scenarios", out var scenarios)) { args["scenarios"] = scenarios; flex = JsonSerializer.Deserialize(new FlexFamily().Execute(args, app)); } + rows.Add(new { path, sha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))), structure, flex, original_saved = false }); + } + catch (OperationCanceledException) { throw; } + catch (Autodesk.Revit.Exceptions.RegenerationFailedException) { throw; } + catch (Exception ex) { rows.Add(new { path, error = ex.Message }); } + } + return Services.Json.Serialize(new { files = rows, scope = "Independent native inspection; passing supplied scenarios only certifies those dimensions/variants." }); + } +} diff --git a/ClaudeRevit/Tools/LiveSpds.cs b/ClaudeRevit/Tools/LiveSpds.cs index b91a17d..4347fc5 100644 --- a/ClaudeRevit/Tools/LiveSpds.cs +++ b/ClaudeRevit/Tools/LiveSpds.cs @@ -14,6 +14,11 @@ internal sealed class LiveSpdsRecord public string Key {get;set;}=""; public string Profile {get;set;}=""; public long CategoryId {get;set;} + public long[] CategoryIds {get;set;}=[]; + public string NestingPolicy {get;set;}="explicit"; + public Dictionary CategoryMappings {get;set;}=new(); + public IEnumerable Categories=>CategoryIds.Length==0?[CategoryId]:CategoryIds; + public JsonElement Map(Element e)=>CategoryMappings.GetValueOrDefault(e.Category?.Id.Value??0,Mapping); public string Scope {get;set;}=""; public string[] SourceUniqueIds {get;set;}=[]; public JsonElement Mapping {get;set;} @@ -43,6 +48,12 @@ public static void Register(UIControlledApplication app) foreach(var change in new[]{Element.GetChangeTypeAny(),Element.GetChangeTypeElementAddition(),Element.GetChangeTypeElementDeletion()})UpdaterRegistry.AddTrigger(_instance._id,filter,change); } } + public static void RegisterMaterialTriggers() + { + if(_instance==null)return; + var filter=new LogicalOrFilter(new ElementClassFilter(typeof(Material)),new ElementClassFilter(typeof(PropertySetElement))); + UpdaterRegistry.AddTrigger(_instance._id,filter,Element.GetChangeTypeAny()); + } public static void Unregister(){if(_instance!=null){UpdaterRegistry.UnregisterUpdater(_instance._id);_instance=null;}} public string GetAdditionalInformation()=>"Keeps SPDS schedule model parameters current; required for derived mass matrices."; public ChangePriority GetChangePriority()=>ChangePriority.Annotations; @@ -53,8 +64,9 @@ public void Execute(UpdaterData data) var doc=data.GetDocument();if(doc.IsFamilyDocument)return; var changed=data.GetModifiedElementIds().Concat(data.GetAddedElementIds()).Select(doc.GetElement).Where(e=>e?.Category!=null).Select(e=>e!.Category.Id.Value).ToHashSet(); bool deleted=data.GetDeletedElementIds().Count>0; + bool materialsChanged=data.GetModifiedElementIds().Select(doc.GetElement).Any(e=>e is Material or PropertySetElement); foreach(var (_,record) in LiveSpds.All(doc)) - if(deleted||changed.Contains(record.CategoryId))LiveSpds.Refresh(doc,record,false); + if(deleted||materialsChanged||record.Categories.Any(changed.Contains))LiveSpds.Refresh(doc,record,false); } } @@ -78,14 +90,17 @@ public static void Save(DataStorage storage,LiveSpdsRecord record) {var s=Schema();var e=new Entity(s);e.Set(s.GetField("Json"),JsonSerializer.Serialize(record));storage.SetEntity(e);} public static List Sources(Document doc,LiveSpdsRecord r) { - var all=new FilteredElementCollector(doc).OfCategoryId(new ElementId(r.CategoryId)).WhereElementIsNotElementType().ToElements(); + var all=Collect(doc,r); if(all.Count>10000)throw new ToolInputException("Live SPDS category exceeds 10000 instances; narrow the model/category before creating derived tables."); var selected=r.SourceUniqueIds.ToHashSet(StringComparer.Ordinal); - return all.Where(e=>r.Scope=="entire_category"||selected.Contains(e.UniqueId)).ToList(); + var scoped=all.Where(e=>r.Scope=="entire_category"||selected.Contains(e.UniqueId)).ToArray(); + var counted=NestingSelection.Select(scoped.Select(e=>(e.UniqueId,(e as FamilyInstance)?.SuperComponent?.UniqueId)),r.NestingPolicy); + return scoped.Where(e=>counted.Contains(e.UniqueId)).ToList(); } + private static IList Collect(Document doc,LiveSpdsRecord r)=>new FilteredElementCollector(doc).WherePasses(new ElementMulticategoryFilter(r.Categories.Select(id=>new ElementId(id)).ToList())).WhereElementIsNotElementType().ToElements(); public static void Bind(Document doc,LiveSpdsRecord r) { - var category=Category.GetCategory(doc,new ElementId(r.CategoryId));if(category==null||!category.AllowsBoundParameters)throw new ToolInputException("Category does not support project parameters."); + var categories=r.Categories.Select(id=>Category.GetCategory(doc,new ElementId(id))).ToArray();if(categories.Any(c=>c==null||!c.AllowsBoundParameters))throw new ToolInputException("A selected category does not support project parameters."); var app=doc.Application;var previous=app.SharedParametersFilename;var path=Path.Combine(Path.GetTempPath(),"ClaudeRevit-spds-"+Guid.NewGuid().ToString("N")+".txt"); try { @@ -99,7 +114,7 @@ public static void Bind(Document doc,LiveSpdsRecord r) var spec=TextKeys.Contains(key)?SpecTypeId.String.Text:key is "position" or "include"?SpecTypeId.Int.Integer:key is "mass" or "total"||key.StartsWith("group_")?SpecTypeId.Mass:SpecTypeId.Number; if(existing!=null){if(existing.GetDefinition().GetDataType()!=spec)throw new ToolInputException("Derived parameter identity has an incompatible data type.");continue;} var options=new ExternalDefinitionCreationOptions("CR_SPDS_"+Convert.ToHexString(bytes)[..8]+"_"+key,spec){GUID=guid,UserModifiable=false,Description="Calculated from model parameters for live SPDS table "+r.Key}; - var definition=group.Definitions.Create(options);var set=app.Create.NewCategorySet();set.Insert(category); + var definition=group.Definitions.Create(options);var set=app.Create.NewCategorySet();foreach(var category in categories)set.Insert(category); if(!doc.ParameterBindings.Insert(definition,app.Create.NewInstanceBinding(set),GroupTypeId.Data))throw new ToolInputException("Cannot bind derived SPDS model parameter: "+key); } doc.Regenerate(); @@ -115,13 +130,13 @@ private static void Set(Element e,Guid guid,object value,bool mass=false) } public static (int Count,List Errors) Refresh(Document doc,LiveSpdsRecord r,bool strict) { - var all=new FilteredElementCollector(doc).OfCategoryId(new ElementId(r.CategoryId)).WhereElementIsNotElementType().ToElements();if(all.Count>10000)throw new ToolInputException("Live SPDS category exceeds 10000 instances."); - var selected=r.SourceUniqueIds.ToHashSet(StringComparer.Ordinal);var items=new List<(Element Element,SpdsItem Item,Dictionary Values)>();var errors=new List(); + var all=Collect(doc,r);if(all.Count>10000)throw new ToolInputException("Live SPDS category exceeds 10000 instances."); + var selected=Sources(doc,r).Select(e=>e.UniqueId).ToHashSet(StringComparer.Ordinal);var items=new List<(Element Element,SpdsItem Item,Dictionary Values)>();var errors=new List(); foreach(var e in all) { if(strict)ToolContext.ThrowIfCancelled(); - bool include=r.Scope=="entire_category"||selected.Contains(e.UniqueId);Set(e,r.Parameters["include"],include?1:0);if(!include)continue; - try{var i=SpdsSource.ReadElement(doc,e,r.Mapping);var values=Services.SpdsTables.ModelValues(r.Profile,i,r.Groups);items.Add((e,i,values));Set(e,r.Parameters["error"],"");} + bool include=selected.Contains(e.UniqueId);Set(e,r.Parameters["include"],include?1:0);if(!include)continue; + try{var i=SpdsSource.ReadElement(doc,e,r.Map(e));var values=Services.SpdsTables.ModelValues(r.Profile,i,r.Groups);items.Add((e,i,values));Set(e,r.Parameters["error"],"");} catch(Exception ex) when(ex is ToolInputException or ArgumentException) { if(strict)throw new ToolInputException($"Source {e.Id.Value}: {ex.Message}");errors.Add(new {id=e.Id.Value,error=ex.Message}); @@ -151,7 +166,7 @@ public static (int Count,List Errors) Refresh(Document doc,LiveSpdsRecor } public static ViewSchedule CreateSchedule(Document doc,LiveSpdsRecord r,string name,string font,double size,bool gradeTotals=false) { - var schedule=ViewSchedule.CreateSchedule(doc,new ElementId(r.CategoryId));schedule.Name=name;var d=schedule.Definition;d.IsItemized=false;d.ShowHeaders=true;d.ShowTitle=true; + var schedule=ViewSchedule.CreateSchedule(doc,r.Categories.Count()>1?ElementId.InvalidElementId:new ElementId(r.CategoryId));schedule.Name=name;var d=schedule.Definition;d.IsItemized=false;d.ShowHeaders=true;d.ShowTitle=true; var columns=Services.SpdsTables.Columns(r.Profile,r.Groups);var added=new Dictionary(); ScheduleField Field(string key,bool hidden=false,string? heading=null,double width=15) { diff --git a/ClaudeRevit/Tools/ScheduleExportEvidence.cs b/ClaudeRevit/Tools/ScheduleExportEvidence.cs new file mode 100644 index 0000000..d7e24e5 --- /dev/null +++ b/ClaudeRevit/Tools/ScheduleExportEvidence.cs @@ -0,0 +1,38 @@ +using System.Runtime.CompilerServices; +using System.IO; +using System.Security.Cryptography; +using Autodesk.Revit.DB; +using ClaudeRevit.Services; +namespace ClaudeRevit.Tools; + +internal static class ScheduleExportEvidence +{ + private sealed record Export(string ScheduleUid, string Path, string Hash, int Rows); + private static readonly ConditionalWeakTable> Exports = new(); + public static void Record(Document doc, ViewSchedule schedule, string path) + { + var bytes = File.ReadAllBytes(path); + var records = Exports.GetOrCreateValue(doc); + if (records.Count >= 100) records.Remove(records.Keys.First()); + records[schedule.UniqueId] = new(schedule.UniqueId, Path.GetFullPath(path), Convert.ToHexString(SHA256.HashData(bytes)), CsvEvidence.Parse(File.ReadAllText(path)).Count); + } + public static object[] Probe(Document doc) => Exports.GetOrCreateValue(doc).Values.Select(e => + { + try + { + var schedule = doc.GetElement(e.ScheduleUid) as ViewSchedule; + if (schedule == null || !File.Exists(e.Path)) return (object)new { schedule_id = schedule?.Id.Value, verified = false, error = "Schedule or exported file is missing." }; + var bytes = File.ReadAllBytes(e.Path); if (bytes.Length > 10_000_000) throw new InvalidOperationException("Export exceeds evidence limit."); + var hash = Convert.ToHexString(SHA256.HashData(bytes)); + var exported = CsvEvidence.Parse(File.ReadAllText(e.Path)); + var body = schedule.GetTableData().GetSectionData(SectionType.Body); + if (body.NumberOfRows > 2000 || body.NumberOfColumns > 100) throw new InvalidOperationException("Schedule exceeds evidence limit."); + var expected = new List(); + for (int r = body.FirstRowNumber; r <= body.LastRowNumber; r++) + expected.Add(Enumerable.Range(body.FirstColumnNumber, body.NumberOfColumns).Select(c => schedule.GetCellText(SectionType.Body, r, c)).ToArray()); + bool matches = CsvEvidence.ContainsRows(exported, expected); + return new { schedule_id = schedule.Id.Value, path = e.Path, sha256 = hash, exported_rows = exported.Count, native_rows = body.NumberOfRows, unchanged_since_export = hash == e.Hash, model_rows_match = matches, verified = hash == e.Hash && matches }; + } + catch (Exception ex) { return (object)new { path = e.Path, verified = false, error = ex.Message }; } + }).ToArray(); +} diff --git a/ClaudeRevit/Tools/SpdsTables.cs b/ClaudeRevit/Tools/SpdsTables.cs index bb5abe0..10b285e 100644 --- a/ClaudeRevit/Tools/SpdsTables.cs +++ b/ClaudeRevit/Tools/SpdsTables.cs @@ -17,7 +17,7 @@ public string Execute(IReadOnlyDictionary input,UIApplicatio { var doc=NativeToolUtil.Doc(app);object? fields=null; if(input.TryGetValue("category",out var category)) - fields=NativeToolUtil.Commit(doc,"Claude: inspect schedule fields",true,()=>{var s=ViewSchedule.CreateSchedule(doc,new ElementId(CategoryResolve.Parse(category.GetString()??"")));return s.Definition.GetSchedulableFields().Select(f=>new {parameter_id=f.ParameterId.Value,name=f.GetName(doc),field_type=f.FieldType.ToString()}).ToArray();}).Value; + fields=NativeToolUtil.Commit(doc,"Claude: inspect schedule fields",true,()=>{var s=ViewSchedule.CreateSchedule(doc,category.GetString()=="multi_category"?ElementId.InvalidElementId:new ElementId(CategoryResolve.Parse(category.GetString()??"")));return s.Definition.GetSchedulableFields().Select(f=>new {parameter_id=f.ParameterId.Value,name=f.GetName(doc),field_type=f.FieldType.ToString()}).ToArray();}).Value; return Services.Json.Serialize(new {profiles=Services.SpdsTables.Profiles.Select(p=>new {profile=p,columns=Services.SpdsTables.Columns(p),reference=p=="steel_rollup"?"GOST 21.502-2016 annex L form 2":p=="timber_materials"?"GOST 21.504-2016 form 1":p=="timber_elements"?"GOST 21.504-2016 form 2":"GOST R 21.101 form 7; timber GOST 21.504-2016 clause 6.5",mode="native live ViewSchedule; steel includes live matrix and grade totals"}).ToArray(), editions=new[]{"2026","2020"},schedulable_fields=fields,existing_schedules=new FilteredElementCollector(doc).OfClass(typeof(ViewSchedule)).Cast().Take(200).Select(s=>new {id=s.Id.Value,name=s.Name,is_template=s.IsTemplate}).ToArray(), sources=new[]{"https://protect.gost.ru/gost/details/17bc12e8-6579-4145-b141-56855e772e7f","https://protect.gost.ru/gost/details/57d18a56-0d60-4071-8f93-053f789060ad","https://protect.gost.ru/gost/details/b4a24268-5643-4322-b59f-b60288da0806"}, @@ -39,7 +39,15 @@ string Text(Element e,string key) {if(!map.TryGetProperty(key,out var rule))return "";if(rule.TryGetProperty("constant",out var c))return c.GetString()??"";var p=Parameter(e,rule)??throw new ToolInputException($"Source {e.Id.Value} lacks {key}; clarify field mapping.");return p.AsString()??p.AsValueString()??"";} double? Number(Element e,string key) { - if(!map.TryGetProperty(key,out var rule))return null;var p=Parameter(e,rule)??throw new ToolInputException($"Source {e.Id.Value} lacks {key}."); + if(!map.TryGetProperty(key,out var rule))return null; + if(key=="mass_kg"&&rule.TryGetProperty("source",out var sourceRule)&&sourceRule.GetString()=="material_volumes") + { + double kg=0;var materialIds=e.GetMaterialIds(false);if(materialIds.Count==0)throw new ToolInputException("No actual material quantities for mass."); + foreach(var materialId in materialIds) + {var material=doc.GetElement(materialId) as Material;var asset=material==null?null:doc.GetElement(material.StructuralAssetId) as PropertySetElement;if(asset==null)throw new ToolInputException("Material lacks a physical density asset; agree an explicit source mapping.");double density=UnitUtils.ConvertFromInternalUnits(asset.GetStructuralAsset().Density,UnitTypeId.KilogramsPerCubicMeter);double volume=UnitUtils.ConvertFromInternalUnits(e.GetMaterialVolume(materialId),UnitTypeId.CubicMeters);if(!double.IsFinite(density)||density<=0||!double.IsFinite(volume)||volume<0)throw new ToolInputException("Invalid material density/volume.");kg+=density*volume;} + if(kg<=0)throw new ToolInputException("Actual material mass is empty.");return kg; + } + var p=Parameter(e,rule)??throw new ToolInputException($"Source {e.Id.Value} lacks {key}."); if(p.StorageType is not (StorageType.Double or StorageType.Integer))throw new ToolInputException($"{key} must be numeric; formatted text is not a measurement."); double n=p.StorageType==StorageType.Double?p.AsDouble():p.AsInteger();var spec=p.Definition.GetDataType();var unit=rule.TryGetProperty("unit",out var u)?u.GetString():null; if(key=="mass_kg") @@ -52,7 +60,9 @@ string Text(Element e,string key) return spec==SpecTypeId.Volume&&unit=="m3"?UnitUtils.ConvertFromInternalUnits(n,UnitTypeId.CubicMeters):spec==SpecTypeId.Area&&unit=="m2"?UnitUtils.ConvertFromInternalUnits(n,UnitTypeId.SquareMeters):spec==SpecTypeId.Length&&unit=="m"?UnitUtils.ConvertFromInternalUnits(n,UnitTypeId.Meters):spec==SpecTypeId.Number||spec==SpecTypeId.Int.Integer?n:throw new ToolInputException("amount needs compatible m/m2/m3 unit mapping."); if(spec!=SpecTypeId.Number&&spec!=SpecTypeId.Int.Integer)throw new ToolInputException("quantity must be a numeric count.");return n; } - var e=source;if(e is ElementType)throw new ToolInputException("Specify instance IDs for table sources."); + var e=source; + if(map.TryGetProperty("mass_kg",out var massRule)&&massRule.TryGetProperty("source",out var massSource)&&massSource.GetString()=="material_volumes"&&(Number(e,"quantity")??1)!=1)throw new ToolInputException("Actual material volume already covers this instance: material_volumes requires quantity=1; use an approved per-unit mass parameter for multiplied quantities."); + if(e is ElementType)throw new ToolInputException("Specify instance IDs for table sources."); return new SpdsItem {SourceId=e.Id.Value,Mark=Text(e,"mark"),Designation=Text(e,"designation"),Name=Text(e,"name"),Notes=Text(e,"notes"),Profile=Text(e,"profile"),Grade=Text(e,"grade"),Size=Text(e,"size"),Group=Text(e,"group"),Unit=Text(e,"unit"),Quantity=Number(e,"quantity")??1,MassKg=Number(e,"mass_kg"),Amount=Number(e,"amount")}; } public static List Read(Document doc,JsonElement ids,JsonElement map)=>NativeToolUtil.Ids(ids,5000).Select(id=>ReadElement(doc,NativeToolUtil.Element(doc,id.Value),map)).ToList(); diff --git a/ClaudeRevit/Tools/ToolCatalog.cs b/ClaudeRevit/Tools/ToolCatalog.cs index 75d9667..ec9a8d3 100644 --- a/ClaudeRevit/Tools/ToolCatalog.cs +++ b/ClaudeRevit/Tools/ToolCatalog.cs @@ -22,12 +22,12 @@ public static class ToolCatalog public static string CategoryOf(IRevitTool tool) { var n = tool.GetType().Name; - if(n is "UpsertConnectionNode" or "PlanTrussLayout") return "Modeling"; - if(n is "CreateParametricSection") return "Family editor"; + if(n is "UpsertConnectionNode" or "PlanTrussLayout" or "RunCheckpointJob") return "Modeling"; + if(n is "CreateParametricSection" or "InspectFamilyFiles") return "Family editor"; if(n.Contains("Spds",StringComparison.Ordinal))return "Schedules"; bool Has(params string[] keys) => keys.Any(k => n.IndexOf(k, StringComparison.OrdinalIgnoreCase) >= 0); if (n.StartsWith("Generate",StringComparison.Ordinal) || n is "SetModelProvenance") return "Modeling"; - if (n is "ValidateCSharp") return "Code & learning"; + if (n is "ValidateCSharp" or "GetVerifiedExperience" or "VerifyModelResult") return "Code & learning"; if (n is "BimStarterModelTools" or "RunBimStarterCommand") return "Modeling"; // Specific groups first so a broad keyword (e.g. "Delete", "Family") doesn't steal a diff --git a/ClaudeRevit/Tools/ToolDispatcher.cs b/ClaudeRevit/Tools/ToolDispatcher.cs index 17b3de0..6806b97 100644 --- a/ClaudeRevit/Tools/ToolDispatcher.cs +++ b/ClaudeRevit/Tools/ToolDispatcher.cs @@ -481,7 +481,7 @@ private void HandleTool(UIApplication app, ToolJob job) job.Input.TryGetValue("code", out var codeEl) ? codeEl.GetString() ?? "" : "", job.Input.TryGetValue("engine", out var engEl) && engEl.ValueKind == JsonValueKind.String ? engEl.GetString() : null, - boundDocument?.Title); + boundDocument?.Title, boundDocument); } string result; diff --git a/ClaudeRevit/Tools/VerifyModelResult.cs b/ClaudeRevit/Tools/VerifyModelResult.cs new file mode 100644 index 0000000..3c71960 --- /dev/null +++ b/ClaudeRevit/Tools/VerifyModelResult.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using System.Security.Cryptography; +using System.Text; +using Anthropic.Models.Beta.Messages; +using Autodesk.Revit.DB; +using Autodesk.Revit.DB.Structure; +using Autodesk.Revit.UI; +using ClaudeRevit.Services; +namespace ClaudeRevit.Tools; + +internal static class VerificationContext +{ + public static string Version(Document doc) => doc.Application.VersionNumber + "/" + doc.Application.VersionBuild; + public static string Key(Document doc) + { + var types=new FilteredElementCollector(doc).OfClass(typeof(FamilySymbol)).Cast().Select(s=>$"{s.Category?.Id.Value}|{s.FamilyName}|{s.Name}").Order(StringComparer.Ordinal).ToArray(); + var names=doc.IsFamilyDocument?doc.FamilyManager.Parameters.Cast().Select(p=>$"{p.Definition.Name}|{p.Definition.GetDataType().TypeId}|{p.IsInstance}|{p.Formula}").Order(StringComparer.Ordinal).ToArray():[]; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new {doc.IsFamilyDocument,category=doc.IsFamilyDocument?doc.OwnerFamily.FamilyCategory?.Id.Value:null,types,names})))); + } +} +public sealed class VerifyModelResult : IRevitTool +{ + public string Name => "verify_model_result"; + public string Description => "Independently compare current native element geometry/parameters with declared expected values. Each assertion is passed/failed/incomplete. Optionally associate checks with an exact journal run_id in this document and store verified experience; code success alone never qualifies. Bounds are actual solid tessellation, volume is actual solid volume. Missing geometry cannot pass. Records certify only the supplied checks, not engineering adequacy or an optimal workflow."; + public bool RequiresTransaction => false; + public InputSchema InputSchema => NativeToolUtil.Schema(new() + { + ["checks"] = NativeToolUtil.Any("1..100 {kind:bounds|volume|parameter|native_rebar_host,element_id,expected_mm?:[X,Y,Z],expected_m3?,parameter_name? OR parameter_guid?,expected_value?,unit?:internal|mm|kg,tolerance?:nonnegative}. bounds compares solid extents; volume in m3. Native host validates Rebar and RebarHostData relationship, not cover."), + ["journal_run_id"] = NativeToolUtil.Field("string", "Optional exact run_id returned by get_script_journal. All checked IDs must be actual added/modified IDs of that run, and at least one geometry assertion is required to record experience.") + }, "checks"); + public string Execute(IReadOnlyDictionary input, UIApplication app) + { + var doc = NativeToolUtil.Doc(app); var raw = input["checks"].EnumerateArray().ToArray(); if (raw.Length is <1 or >100) throw new ToolInputException("Supply 1..100 checks."); + var checks=new List(); var signatures=new Dictionary(); var ids=new HashSet(); bool geometryAssertion=false; + foreach(var c in raw) + { + ToolContext.ThrowIfCancelled(); var id=c.GetProperty("element_id").GetInt64();ids.Add(id);var kind=c.GetProperty("kind").GetString()??""; + double tolerance=c.TryGetProperty("tolerance",out var t)?t.GetDouble():kind=="volume"?1e-6:1; + if(!double.IsFinite(tolerance)||tolerance<0)throw new ToolInputException("Tolerance must be finite and nonnegative."); + var e=doc.GetElement(new ElementId(id));if(e==null){checks.Add(new(kind+":"+id,"failed","Element missing."));continue;} + signatures[e.UniqueId]=ConnectionNodes.Signature(e); + try + { + bool? valid=null;string detail=""; + switch(kind) + { + case "bounds": case "volume": + geometryAssertion=true;var solids=ConnectionNodes.Solids(e);if(solids.Count==0){detail="No native solid geometry.";break;} + if(kind=="volume") + {var value=solids.Sum(s=>s.Volume)*Math.Pow(.3048,3);var expected=c.GetProperty("expected_m3").GetDouble();if(!double.IsFinite(expected)||expected<=0)throw new ToolInputException("Expected volume must be positive and finite.");valid=Math.Abs(value-expected)<=tolerance;detail=$"Actual volume {value:R} m3; expected {expected:R}.";} + else + {var expected=c.GetProperty("expected_mm").EnumerateArray().Select(x=>x.GetDouble()).ToArray();if(expected.Length!=3||expected.Any(x=>!double.IsFinite(x)||x<=0))throw new ToolInputException("Expected bounds need three positive finite extents.");var points=solids.SelectMany(s=>s.Faces.Cast()).SelectMany(f=>f.Triangulate().Vertices).Take(50001).Select(NativeToolUtil.Mm).ToArray();if(points.Length>50000){detail="Geometry vertex evidence limit exceeded.";break;}if(points.Length==0){detail="No actual solid vertices.";break;}var actual=Enumerable.Range(0,3).Select(i=>points.Max(p=>p[i])-points.Min(p=>p[i])).ToArray();valid=actual.Zip(expected,(a,b)=>Math.Abs(a-b)<=tolerance).All(x=>x);detail="Actual extents mm: "+JsonSerializer.Serialize(actual);} + break; + case "parameter": + var p=NativeToolUtil.Parameter(e,c.TryGetProperty("parameter_name",out var n)?n.GetString()??"":"",c.TryGetProperty("parameter_guid",out var g)?g.GetString()??"":""); + if(p==null||!p.HasValue){valid=false;detail="Parameter missing/unset.";break;} + var v=c.GetProperty("expected_value");var unit=c.TryGetProperty("unit",out var u)?u.GetString():"internal"; + if(p.StorageType==StorageType.String){valid=p.AsString()==v.GetString();detail="Actual text: "+p.AsString();} + else if(p.StorageType is StorageType.Double or StorageType.Integer) + {double actual=p.StorageType==StorageType.Double?p.AsDouble():p.AsInteger();if(unit=="mm"&&p.Definition.GetDataType()==SpecTypeId.Length)actual*=Units.MmPerFoot;else if(unit=="kg"&&p.Definition.GetDataType()==SpecTypeId.Mass)actual=UnitUtils.ConvertFromInternalUnits(actual,UnitTypeId.Kilograms);else if(unit!="internal")throw new ToolInputException("Parameter dimension does not match unit.");var expected=v.ValueKind is JsonValueKind.True or JsonValueKind.False?v.GetBoolean()?1:0:v.GetDouble();if(!double.IsFinite(expected))throw new ToolInputException("Expected parameter must be finite.");valid=Math.Abs(actual-expected)<=tolerance;detail=$"Actual value {actual:R} {unit}.";} + else {detail="Unsupported parameter storage.";} + break; + case "native_rebar_host": + valid=e is Rebar r && doc.GetElement(r.GetHostId()) is { } host && RebarHostData.GetRebarHostData(host)?.IsValidHost()==true && r.Quantity>0; + detail="Native Rebar host validity/quantity only; cover and unsampled positions are not certified.";break; + default:throw new ToolInputException("Unknown check kind."); + } + checks.Add(new(kind+":"+id,valid==null?"incomplete":valid.Value?"passed":"failed",detail)); + } + catch(Autodesk.Revit.Exceptions.RegenerationFailedException){throw;} + catch(OperationCanceledException){throw;} + catch(Exception ex){checks.Add(new(kind+":"+id,"incomplete",ex.Message));} + } + VerifiedExperience? recorded=null; + if(input.TryGetValue("journal_run_id",out var run)) + { + var entry=ScriptJournal.ReadRecent(500).FirstOrDefault(e=>e.TryGetProperty("run_id",out var ri)&&ri.GetString()==run.GetString()); + if(entry.ValueKind!=JsonValueKind.Object||entry.GetProperty("document_key").GetString()!=DocumentSessions.Key(doc))throw new ToolInputException("Journal run not found in this document session."); + var changed=entry.GetProperty("changes");var affected=new[]{"added_ids","modified_ids"}.SelectMany(k=>changed.TryGetProperty(k,out var a)&&a.ValueKind==JsonValueKind.Array?a.EnumerateArray().Select(v=>v.GetInt64()):[]).ToHashSet(); + if(!geometryAssertion||ids.Any(id=>!affected.Contains(id))||!entry.GetProperty("ok").GetBoolean())throw new ToolInputException("Experience requires actual geometry assertions on the successful run's affected elements."); + recorded=new(run.GetString()!,entry.GetProperty("code_sha256").GetString()!,entry.GetProperty("code_truncated").GetBoolean()?null:entry.GetProperty("code").GetString(),entry.GetProperty("tool").GetString()!,VerificationContext.Version(doc),VerificationContext.Key(doc),DateTime.UtcNow.ToString("o"),entry.GetProperty("duration_ms").GetDouble()/1000,checks,signatures); + VerifiedExperienceStore.Save(recorded); + } + return Services.Json.Serialize(new {checks,passed=checks.All(c=>c.Status=="passed"),experience_recorded=recorded!=null,scope="Only declared checks; failed/incomplete records are retained but never returned as successful experience."}); + } +} +public sealed class GetVerifiedExperience : IRevitTool +{ + public string Name=>"get_verified_experience"; + public string Description=>"Read optional verified experience with matching actual Revit build and family/type catalog context. Includes exact checks, duration, code hash and complete code only when it was stored without truncation. No automatic replay or forced strategy; choose an improved approach freely. Failures are kept separately and returned only with include_failures=true."; + public bool RequiresTransaction=>false; + public InputSchema InputSchema=>NativeToolUtil.Schema(new(){["include_failures"]=NativeToolUtil.Field("boolean","Default false.")}); + public string Execute(IReadOnlyDictionary input,UIApplication app) + {var doc=NativeToolUtil.Doc(app);var version=VerificationContext.Version(doc);var context=VerificationContext.Key(doc);return Services.Json.Serialize(new {revit_version=version,context_key=context,optional=true,records=VerifiedExperienceStore.Read().Where(r=>r.RevitVersion==version&&r.ContextKey==context&&(r.Passed||ToolInput.Flag(input,"include_failures"))).Take(20).ToArray()});} +} diff --git a/ClaudeRevit/UI/BenchmarkWindow.xaml b/ClaudeRevit/UI/BenchmarkWindow.xaml index 3b6b1ae..3d50cb1 100644 --- a/ClaudeRevit/UI/BenchmarkWindow.xaml +++ b/ClaudeRevit/UI/BenchmarkWindow.xaml @@ -52,6 +52,9 @@ + + +