diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c413d69..dc3266c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,8 @@ jobs: run: dotnet restore ClaudeRevit\ClaudeRevit.csproj - name: Build (Release, no deploy) run: dotnet build ClaudeRevit\ClaudeRevit.csproj -c Release -p:SkipDeploy=true --no-restore + - name: Compile native acceptance harness (does not run without Revit) + run: dotnet build ClaudeRevit.NativeTests\ClaudeRevit.NativeTests.csproj -c Release -p:SkipDeploy=true - name: Unit tests (pure logic, no Revit) run: dotnet test ClaudeRevit.Tests\ClaudeRevit.Tests.csproj -c Release - name: Pane checks (isolated WPF, no Revit or account) 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.NativeTests/ClaudeRevit.NativeTests.csproj b/ClaudeRevit.NativeTests/ClaudeRevit.NativeTests.csproj new file mode 100644 index 0000000..5702338 --- /dev/null +++ b/ClaudeRevit.NativeTests/ClaudeRevit.NativeTests.csproj @@ -0,0 +1,14 @@ + + + net10.0-windows + true + enable + enable + x64 + 2027 + true + + + + + diff --git a/ClaudeRevit.NativeTests/GeometryFixtures.cs b/ClaudeRevit.NativeTests/GeometryFixtures.cs new file mode 100644 index 0000000..b359b7f --- /dev/null +++ b/ClaudeRevit.NativeTests/GeometryFixtures.cs @@ -0,0 +1,59 @@ +using System.IO; +using System.Reflection; +using System.Text.Json; +using Autodesk.Revit.DB; +using Autodesk.Revit.DB.Structure; +using Autodesk.Revit.UI; +using ClaudeRevit.Tools; + +namespace ClaudeRevit.NativeTests; + +internal static class GeometryFixtures +{ + public static object Bore(UIApplication app,JsonElement input) + { + var family=app.Application.OpenDocumentFile(input.GetProperty("section_file").GetString()!); + try + { + var extrusion=new FilteredElementCollector(family).OfClass(typeof(Extrusion)).Cast().Single(); + var method=typeof(CreateParametricSection).Assembly.GetType("ClaudeRevit.Tools.ConnectionOpeningGeometry",true)!.GetMethod("Inspect")!; + JsonElement Inspect(XYZ origin,double diameter)=>JsonSerializer.SerializeToElement(method.Invoke(null,new object[]{extrusion,origin,XYZ.BasisZ,diameter,.1})); + var good=Inspect(XYZ.Zero,200);var offset=Inspect(new XYZ(5/304.8,0,0),200);var oversize=Inspect(XYZ.Zero,280); + if(!good.GetProperty("Verified").GetBoolean()||Math.Abs(good.GetProperty("DiameterMm").GetDouble()-276)>.001)throw new InvalidOperationException("Native through bore was not identified: "+good); + if(offset.GetProperty("Verified").ValueKind==JsonValueKind.True||oversize.GetProperty("Verified").ValueKind==JsonValueKind.True)throw new InvalidOperationException("Misaligned/oversized bolt was accepted."); + return new{passed=true,good,offset_rejected=offset,oversize_rejected=oversize,limitation="Actual bore geometry only; no capacity or thread engagement assertion."}; + } + finally{family.Close(false);} + } + public static object ReferencePlacement(UIApplication app,JsonElement input) + { + var project=app.Application.NewProjectDocument(UnitSystem.Metric); + try + { + FamilyInstance beam;FamilySymbol symbol; + using(var tx=new Transaction(project,"QA native reference framing placement")) + { + tx.Start();var level=Level.Create(project,0); + if(!project.LoadFamily(input.GetProperty("family_file").GetString()!,out var family))throw new InvalidOperationException("Cannot load reference framing."); + symbol=(FamilySymbol)project.GetElement(family.GetFamilySymbolIds().First());symbol.Activate();project.Regenerate(); + beam=project.Create.NewFamilyInstance(Line.CreateBound(new XYZ(0,0,1000/304.8),new XYZ(6000/304.8,0,1000/304.8)),symbol,level,StructuralType.Beam); + tx.Commit(); + } + if(beam.Category.Id.Value!=(long)BuiltInCategory.OST_StructuralFraming||beam.StructuralType!=StructuralType.Beam)throw new InvalidOperationException("Reference is not native structural framing."); + double Length()=>beam.get_Parameter(new Guid("b62d0a35-0f0f-432d-9d3d-e821093a7d02")).AsDouble()*304.8; + double Width() + { + var all=new FilteredElementCollector(project).OfClass(typeof(FamilyInstance)).Cast().Where(f=>f.Id==beam.Id||f.SuperComponent?.Id==beam.Id); + var points=all.SelectMany(SyntheticFixtures.Solids).SelectMany(s=>s.Faces.Cast()).SelectMany(f=>f.Triangulate().Vertices).ToArray(); + return (points.Max(p=>p.Y)-points.Min(p=>p.Y))*304.8; + } + var originalLength=Length();var originalWidth=Width(); + using(var tx=new Transaction(project,"QA native section edit")){tx.Start();symbol.LookupParameter("Ширина").Set(260/304.8);tx.Commit();} + var editedWidth=Width();if(Math.Abs(editedWidth-260)>.01||Math.Abs(editedWidth-originalWidth)<.01)throw new InvalidOperationException("Native framing width did not follow type dimension: "+editedWidth); + using(var tx=new Transaction(project,"QA native placement length edit")){tx.Start();((LocationCurve)beam.Location).Curve=Line.CreateBound(new XYZ(0,0,1000/304.8),new XYZ(8000/304.8,0,1000/304.8));tx.Commit();} + var editedLength=Length();if(Math.Abs(editedLength-originalLength-2000)>.01)throw new InvalidOperationException("Actual family placement length did not follow curve edit."); + return new{passed=true,category=beam.Category.Name,structural_type=beam.StructuralType.ToString(),original_length_mm=originalLength,edited_length_mm=editedLength,original_width_mm=originalWidth,edited_width_mm=editedWidth,limitation="Downloaded box beam acceptance; this does not certify every generated structural template."}; + } + finally{project.Close(false);} + } +} diff --git a/ClaudeRevit.NativeTests/NativeAcceptance.cs b/ClaudeRevit.NativeTests/NativeAcceptance.cs new file mode 100644 index 0000000..10f663b --- /dev/null +++ b/ClaudeRevit.NativeTests/NativeAcceptance.cs @@ -0,0 +1,114 @@ +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; +using Autodesk.Revit.DB; +using Autodesk.Revit.UI; +using ClaudeRevit.Tools; + +namespace ClaudeRevit.NativeTests; + +// Loaded in a separate AssemblyLoadContext from the installed add-in. Its +// ExternalEvent runs after the launcher's transaction ends. No OnStartup, MCP +// server, pane, or global updater is started by this acceptance harness. +public static class NativeAcceptance +{ + private static readonly Handler HandlerInstance = new(); + private static ExternalEvent? _event; + private static string? _pending; + private static bool _running; + public static string Queue(string requestPath) + { + if (_running || _pending != null) throw new InvalidOperationException("Acceptance job is already queued/running."); + _event ??= ExternalEvent.Create(HandlerInstance); + _pending = Path.GetFullPath(requestPath); + var result = _event.Raise(); + if (result != ExternalEventRequest.Accepted) { _pending = null; throw new InvalidOperationException("ExternalEvent: " + result); } + return "queued"; + } + private sealed class Handler : IExternalEventHandler + { + public string GetName() => "ClaudeRevit native acceptance (disposable documents only)"; + public void Execute(UIApplication app) + { + var requestPath = _pending; _pending = null; + if (requestPath == null) return; + _running = true; + var clock = Stopwatch.StartNew(); + var output = requestPath + ".result.json"; + var openBefore = app.Application.Documents.Cast().ToArray(); + var activeBefore = app.ActiveUIDocument?.Document; + var modifiedBefore = openBefore.ToDictionary(d => d, d => d.IsModified); + object? result = null; string? error = null; + try + { + File.WriteAllText(requestPath + ".started.json", JsonSerializer.Serialize(new { utc = DateTime.UtcNow, state = "running" })); + var failureType = typeof(InspectFamilyFiles).Assembly.GetType("ClaudeRevit.Tools.FamilyInspectionFailures", true)!; + using var backgroundFailures = (IDisposable)Activator.CreateInstance(failureType, app.Application)!; + using var request = JsonDocument.Parse(File.ReadAllText(requestPath)); + var root = request.RootElement; + var action = root.GetProperty("action").GetString(); + if (action == "tool") + { + var typeName = root.GetProperty("type").GetString()!; + var type = typeof(InspectFamilyFiles).Assembly.GetType("ClaudeRevit.Tools." + typeName, true)!; + if (Activator.CreateInstance(type) is not IRevitTool tool) throw new InvalidOperationException("Not a native tool."); + // This entry point inspects references only. Editing/model tests + // below must create their own disposable document explicitly. + if (tool is not (InspectFamilyFiles or AnalyzeFamilyStructure or FlexFamily)) throw new InvalidOperationException("Reference tool is not read-only or rollback-only."); + var input = root.GetProperty("arguments").EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()); + result = JsonSerializer.Deserialize(tool.Execute(input, app)); + } + else if (action == "sections") result = SyntheticFixtures.Sections(app, root); + else if (action == "checkpoint") result = SyntheticFixtures.Checkpoint(app, root); + else if (action == "nested") result = SyntheticFixtures.Nested(app, root); + else if (action == "live_schedule") result = SyntheticFixtures.LiveSchedule(app, root); + else if (action == "benchmark_probe") result = SyntheticFixtures.BenchmarkProbe(app); + else if (action == "dependent_node") result = SyntheticFixtures.DependentNode(app, root); + else if (action == "bore") result = GeometryFixtures.Bore(app,root); + else if (action == "reference_placement") result = GeometryFixtures.ReferencePlacement(app,root); + else if (action == "schedule_forms") result = SyntheticFixtures.ScheduleForms(app,root); + else if (action == "template_probe") + { + var rows = new List(); + foreach (var template in root.GetProperty("templates").EnumerateArray()) + { + Document? family = null; + try + { + family = app.Application.NewFamilyDocument(template.GetString()!); + rows.Add(new { template = template.GetString(), view = family.ActiveView?.Name, direction = family.ActiveView == null ? null : new[] { family.ActiveView.ViewDirection.X, family.ActiveView.ViewDirection.Y, family.ActiveView.ViewDirection.Z }, + views = new FilteredElementCollector(family).OfClass(typeof(View)).Cast().Where(v => !v.IsTemplate).Select(v => new { name = v.Name, kind = v.ViewType.ToString(), direction = new[] { v.ViewDirection.X, v.ViewDirection.Y, v.ViewDirection.Z } }).ToArray(), + parameters = family.FamilyManager.Parameters.Cast().Select(p => new { name = p.Definition.Name, instance = p.IsInstance, spec = p.Definition.GetDataType().TypeId }).ToArray() }); + } + finally { if (family is { IsValidObject: true }) family.Close(false); } + } + result = rows; + } + else throw new InvalidOperationException("Unknown acceptance action: " + action); + } + catch (Exception ex) { error = ex.ToString(); } + finally + { + _running = false; + var openAfter = app.Application.Documents.Cast().ToArray(); + var originalDocumentsPreserved = openBefore.All(d => d.IsValidObject && openAfter.Contains(d) && d.IsModified == modifiedBefore[d]); + var activeAfter = app.ActiveUIDocument?.Document; + // Revit may return a new managed wrapper for the same native document. + var activePreserved = activeBefore == null ? activeAfter == null : + activeAfter != null && activeBefore.IsValidObject && activeBefore.Equals(activeAfter); + File.WriteAllText(output, JsonSerializer.Serialize(new { utc = DateTime.UtcNow, revit = app.Application.VersionNumber, build = app.Application.VersionBuild, + assembly_sha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(typeof(InspectFamilyFiles).Assembly.Location))).ToLowerInvariant(), + elapsed_seconds = clock.Elapsed.TotalSeconds, original_documents_preserved = originalDocumentsPreserved, active_document_preserved = activePreserved, + background_document_leaks = openAfter.Except(openBefore).Select(d => d.Title).ToArray(), result, error }, new JsonSerializerOptions { WriteIndented = true })); + } + } + } + internal static void Target(Document? document) + { + var type = typeof(InspectFamilyFiles).Assembly.GetType("ClaudeRevit.Tools.ToolContext", true)!; + if (document == null) type.GetMethod("Clear")!.Invoke(null, null); + else type.GetMethod("Set")!.Invoke(null, new object?[] { CancellationToken.None, document, null }); + } +} diff --git a/ClaudeRevit.NativeTests/README.md b/ClaudeRevit.NativeTests/README.md new file mode 100644 index 0000000..fb56311 --- /dev/null +++ b/ClaudeRevit.NativeTests/README.md @@ -0,0 +1,72 @@ +# Native acceptance harness + +This is a developer-only Revit 2027 library. CI compiles it but cannot execute its +checks without a licensed running Revit. It is not included in the installer. + +Build with .NET 10 and `SkipDeploy=true`. Stage the resulting DLLs in a fresh, +immutable directory and scan them before loading. To test a development build +alongside an installed add-in, create a separate `AssemblyLoadContext`, eagerly +load its `ClaudeRevit.dll`, then load `ClaudeRevit.NativeTests.dll`. Share Revit API +and framework/dependency assemblies with the default context. Eager loading is +required: a late resolving callback alone can bind to the installed add-in. + +From a valid Revit API callback, invoke `NativeAcceptance.Queue(requestPath)`. +The harness creates an `ExternalEvent` and runs after the launcher's transaction +has ended. Never invoke document lifecycle tests inside `execute_csharp`'s own +transaction. The installed add-in's server and pane are left running; the test +assembly does not call its `OnStartup` or register its global updaters. + +Requests are UTF-8 JSON. A `.started.json` marker and `.result.json` report are +written next to the request. Reports record Revit build, exact development DLL +hash, elapsed time, active/original document preservation and leaked background +documents. Every test document is closed without saving user-owned originals. +Generated fixtures require unused output paths; existing files are not replaced. + +Supported actions: + +- `tool`: `type` is `InspectFamilyFiles`, `AnalyzeFamilyStructure` or `FlexFamily`; + `arguments` is that tool's schema. For downloaded references, supply absolute + `file_path` or `files[].path`; do not redistribute the library files. +- `template_probe`: `templates` contains absolute RFT paths. Reports actual + default view direction and parameters before choosing a sketch plane/driver. +- `sections`: `template`, `output_directory`, optional `shapes` list. Creates seven + constrained section seeds, checks analytical volume and physical mass outside + the tool's returned claims, then saves native RFA fixtures. Built-in section + flex additionally checks independent dimensions and return to nominal. +- `nested`: `template`, `child_file` (rectangle section seed), `output_directory`. + Creates two shared nesting levels, associates the independent width driver and + visibility variant, tests small/large/variant changes and verifies nominal + geometric volume without counting a shared child twice. +- `checkpoint`: `output_file`. Creates a new metric project, checks preview + rollback, bounded progress, saved/reopened persistence, duplicate retry and + rejection after an independent edit. The saved seed has one completed step. +- `benchmark_probe`: checks native level/unit and material evidence against the + objective evaluator, including a deliberately wrong elevation. It does not + invoke subscription models or establish performance timings. +- `live_schedule`: `section_file`, `output_file`. Compares independently expected + material mass after section-size edits, physical-density edits and deletion. + Optional `titleblock_template` and `image_directory` place native schedules on + an A3 sheet, export PNG pixels, and report native cell styles/rows and layout + audits. Numerical success does not certify wrapping or drawing compliance. +- `schedule_forms`: `section_file`, `output_file`, `titleblock_template`, + `image_directory`. Creates three live timber forms, checks native length-driven + amounts and physical material masses, then exports their A3 sheet. +- `bore`: `section_file` (tube seed). Checks actual inner cylindrical faces and + rejects an offset axis and a diameter larger than the through-bore. +- `reference_placement`: `family_file` (downloaded BIMStarter welded box beam). + Tests actual native beam placement length and width-driven solid bounds in a + disposable project. It does not certify a generated structural template. +- `dependent_node`: `section_file`, `output_file`. Checks relative placement and + parameter/actual-volume dependencies, preserves an independent managed-part + edit and tests a deleted source. The saved file is a negative regression case. + +For updater checks, the harness briefly pauses the corresponding older installed +ClaudeRevit updater during its single synchronous API callback. A test updater +is registered only for the disposable test document. Both registration and pause +are undone in `finally`; no user edit can interleave on Revit's API thread. + +Only executed reports can establish acceptance. Successful compilation, a +catalogue description or the presence of a fixture generator cannot do so. +See [executed October 2026 cases](../docs/native-acceptance-2026-10.md) for results +and the [BIMStarter findings](../docs/bimstarter-reference-cases.md) for expected +minimum-count behavior and remaining visibility checks. diff --git a/ClaudeRevit.NativeTests/SyntheticFixtures.cs b/ClaudeRevit.NativeTests/SyntheticFixtures.cs new file mode 100644 index 0000000..72f6a5c --- /dev/null +++ b/ClaudeRevit.NativeTests/SyntheticFixtures.cs @@ -0,0 +1,473 @@ +using System.IO; +using System.Security.Cryptography; +using System.Text.Json; +using Autodesk.Revit.DB; +using Autodesk.Revit.UI; +using ClaudeRevit.Tools; + +namespace ClaudeRevit.NativeTests; + +internal static class SyntheticFixtures +{ + // Analytical expectations are computed independently from the generated + // mesh and from the tool's returned success/flex claims. + public static object Sections(UIApplication app, JsonElement input) + { + var template = input.GetProperty("template").GetString()!; + var directory = Path.GetFullPath(input.GetProperty("output_directory").GetString()!); + Directory.CreateDirectory(directory); + var rows = new List(); + var requested = input.TryGetProperty("shapes", out var shapes) ? shapes.EnumerateArray().Select(s => s.GetString()!).ToArray() : new[] { "rectangle", "box", "i", "channel", "angle", "tube", "timber_pair" }; + foreach (var shape in requested) + { + Document? family = null; + try + { + family = app.Application.NewFamilyDocument(template); + NativeAcceptance.Target(family); + var n = family.ActiveView?.ViewDirection ?? XYZ.BasisZ; + if (Math.Abs(n.DotProduct(XYZ.BasisZ)) > .99) n = XYZ.BasisZ; + double width = 300, height = shape == "tube" ? 300 : 400, web = shape == "timber_pair" ? 40 : 12, flange = 20, length = 1000; + var args = JsonSerializer.SerializeToElement(new { shape, width_mm = width, height_mm = height, web_mm = web, flange_mm = flange, + gap_mm = web, length_mm = length, density_kg_m3 = 7850, preview = false, plane_normal = new[] { n.X, n.Y, n.Z } }) + .EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()); + var tool = JsonSerializer.Deserialize(new CreateParametricSection().Execute(args, app)); + var id = new ElementId(tool.GetProperty("result").GetProperty("form_id").GetInt64()); + var form = family.GetElement(id); + var solids = Solids(form).ToArray(); + double expectedArea = shape switch + { + "rectangle" => width * height, + "box" => width * height - (width - 2 * web) * (height - 2 * flange), + "i" => 2 * width * flange + web * (height - 2 * flange), + "channel" => height * web + 2 * (width - web) * flange, + "angle" => height * web + (width - web) * flange, + "timber_pair" => (width - web) * height, + "tube" => Math.PI * (width * width - (width - 2 * web) * (width - 2 * web)) / 4, + _ => throw new InvalidOperationException("Unknown fixture shape.") + }; + double actualVolumeMm3 = solids.Sum(s => s.Volume) * Math.Pow(304.8, 3), expectedVolumeMm3 = expectedArea * length; + var curvedTolerance=shape=="tube"?1e-4:1e-5; + if (Math.Abs(actualVolumeMm3 - expectedVolumeMm3) > Math.Max(1, expectedVolumeMm3 * curvedTolerance)) throw new InvalidOperationException("Independent fixture volume disagrees."); + if(shape=="tube") + { + var radii=solids.SelectMany(s=>s.Faces.Cast()).OfType().SelectMany(f=>new[]{f.get_Radius(0).GetLength(),f.get_Radius(1).GetLength()}).Select(r=>Math.Round(r*304.8,5)).Distinct().OrderBy(r=>r).ToArray(); + if(radii.Length!=2||Math.Abs(radii[0]-138)>.001||Math.Abs(radii[1]-150)>.001)throw new InvalidOperationException("Independent native cylindrical radii disagree."); + } + var fm = family.FamilyManager; + var mass = fm.Parameters.Cast().Single(p => p.Definition.Name == "Section_Mass"); + double actualMass = UnitUtils.ConvertFromInternalUnits(fm.CurrentType.AsDouble(mass)!.Value, UnitTypeId.Kilograms), expectedMass = expectedVolumeMm3 / 1e9 * 7850; + if (Math.Abs(actualMass - expectedMass) > Math.Max(.001, expectedMass * 1e-5)) throw new InvalidOperationException("Independent fixture mass disagrees."); + var file = Path.Combine(directory, "QA_Section_" + shape + ".rfa"); + if (File.Exists(file)) throw new InvalidOperationException("Refusing to overwrite an existing fixture: " + file); + family.SaveAs(file, new SaveAsOptions { OverwriteExistingFile = false, MaximumBackups = 1 }); + NativeAcceptance.Target(null); family.Close(false); family=null; + rows.Add(new { shape, passed = true, file, sha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file))).ToLowerInvariant(), + actual_volume_mm3 = actualVolumeMm3, expected_volume_mm3 = expectedVolumeMm3, actual_mass_kg = actualMass, expected_mass_kg = expectedMass, tool }); + } + catch (Autodesk.Revit.Exceptions.RegenerationFailedException) { throw; } + catch (OperationCanceledException) { throw; } + catch (Exception ex) { rows.Add(new { shape, passed = false, error = ex.ToString() }); } + finally { NativeAcceptance.Target(null); if (family is { IsValidObject: true }) family.Close(false); } + } + return rows; + } + internal static IEnumerable Solids(Element element) + { + using var options = new Options { DetailLevel = ViewDetailLevel.Fine, IncludeNonVisibleObjects = false }; + // Detached geometry remains usable after Options/GeometryElement wrappers + // are released; Revit can otherwise throw on later curved-face traversal. + return Collect(element.get_Geometry(options)).Select(SolidUtils.Clone).ToArray(); + static IEnumerable Collect(GeometryElement? geometry) + { + if (geometry == null) yield break; + foreach (var obj in geometry) + if (obj is Solid s && s.Volume > 1e-10) yield return s; + else if (obj is GeometryInstance instance) foreach (var child in Collect(instance.GetInstanceGeometry())) yield return child; + } + } + internal static IReadOnlyDictionary Args(object value) => JsonSerializer.SerializeToElement(value).EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()); + public static object Checkpoint(UIApplication app, JsonElement input) + { + var file = Path.GetFullPath(input.GetProperty("output_file").GetString()!); + if (File.Exists(file)) throw new InvalidOperationException("Refusing to overwrite fixture."); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + Document? project = null; + try + { + project = app.Application.NewProjectDocument(UnitSystem.Metric); + NativeAcceptance.Target(project); + ToolRegistry.Instance.Register(new CreateLevel()); + var tool = new RunCheckpointJob(); + var plan = new { steps = new[] { + new { key = "first", tool = "create_level", arguments = new { name = "QA_Checkpoint_A", elevation_ft = 3.5 / .3048 } }, + new { key = "second", tool = "create_level", arguments = new { name = "QA_Checkpoint_B", elevation_ft = 7.0 / .3048 } } } }; + int initial = new FilteredElementCollector(project).OfClass(typeof(Level)).GetElementCount(); + var preview = JsonSerializer.Deserialize(tool.Execute(Args(new { job_key = "QA_checkpoint", expected_revision = 0, plan, batch_size = 1, preview = true }), app)); + if (new FilteredElementCollector(project).OfClass(typeof(Level)).GetElementCount() != initial) throw new InvalidOperationException("Preview changed native level count."); + var first = JsonSerializer.Deserialize(tool.Execute(Args(new { job_key = "QA_checkpoint", expected_revision = 0, plan, batch_size = 1, preview = false }), app)); + var firstId = first.GetProperty("result").GetProperty("steps")[0].GetProperty("result").GetProperty("id").GetInt64(); + if (Math.Abs(((Level)project.GetElement(new ElementId(firstId))).Elevation * .3048 - 3.5) > 1e-7) throw new InvalidOperationException("Checkpoint units are wrong."); + project.SaveAs(file, new SaveAsOptions { OverwriteExistingFile = false, MaximumBackups = 1 }); + NativeAcceptance.Target(null); project.Close(false); project = app.Application.OpenDocumentFile(file); NativeAcceptance.Target(project); + var persisted = JsonSerializer.Deserialize(new GetCheckpointJob().Execute(Args(new { job_key = "QA_checkpoint" }), app)); + if (persisted.GetProperty("jobs")[0].GetProperty("completed").GetInt32() != 1) throw new InvalidOperationException("Saved checkpoint lost progress."); + var second = JsonSerializer.Deserialize(tool.Execute(Args(new { job_key = "QA_checkpoint", expected_revision = 1, batch_size = 1, preview = false }), app)); + if (!second.GetProperty("result").GetProperty("complete").GetBoolean()) throw new InvalidOperationException("Resume did not complete."); + var repeat = JsonSerializer.Deserialize(tool.Execute(Args(new { job_key = "QA_checkpoint", expected_revision = 2, preview = false }), app)); + if (!repeat.GetProperty("no_op").GetBoolean() || new FilteredElementCollector(project).OfClass(typeof(Level)).GetElementCount() != initial + 2) throw new InvalidOperationException("Retry duplicated native elements."); + using (var tx = new Transaction(project, "QA external checkpoint edit")) { tx.Start(); ((Level)project.GetElement(new ElementId(firstId))).Elevation += 1; tx.Commit(); } + bool rejected = false; + try { tool.Execute(Args(new { job_key = "QA_checkpoint", expected_revision = 2, preview = false }), app); } + catch (ToolInputException ex) when (ex.Message.Contains("externally")) { rejected = true; } + if (!rejected) throw new InvalidOperationException("External edit did not block resume."); + return new { passed = true, file, preview_rolled_back = true, reopened_progress = persisted, first, second, repeat, external_edit_rejected = true }; + } + finally { NativeAcceptance.Target(null); if (project is { IsValidObject: true }) project.Close(false); } + } + public static object Nested(UIApplication app, JsonElement input) + { + var template = input.GetProperty("template").GetString()!; + var childPath = Path.GetFullPath(input.GetProperty("child_file").GetString()!); + var output = Path.GetFullPath(input.GetProperty("output_directory").GetString()!); + Directory.CreateDirectory(output); + string middlePath = Path.Combine(output, "QA_Nested_Middle.rfa"), rootPath = Path.Combine(output, "QA_Nested_Root.rfa"); + if (File.Exists(middlePath) || File.Exists(rootPath)) throw new InvalidOperationException("Refusing to overwrite nested fixtures."); + Document? child = null, middle = null, root = null; + try + { + child = app.Application.OpenDocumentFile(childPath); + using (var tx = new Transaction(child, "QA shared leaf")) { tx.Start(); child.OwnerFamily.get_Parameter(BuiltInParameter.FAMILY_SHARED).Set(1); tx.Commit(); } + middle = app.Application.NewFamilyDocument(template); + var leaf = child.LoadFamily(middle, new FixtureLoadOptions()); + if (leaf == null) throw new InvalidOperationException("Cannot load shared fixture leaf."); + using (var tx = new Transaction(middle, "QA constrained nested pair")) + { + tx.Start(); var fm = middle.FamilyManager; fm.NewType("QA_Nominal"); + middle.OwnerFamily.get_Parameter(BuiltInParameter.FAMILY_SHARED).Set(1); + var width = fm.AddParameter("QA_Width", GroupTypeId.Geometry, SpecTypeId.Length, true); fm.Set(width, 300 / 304.8); + var visible = fm.AddParameter("QA_SecondVisible", GroupTypeId.Graphics, SpecTypeId.Boolean.YesNo, true); fm.Set(visible, 1); + var symbol = (FamilySymbol)middle.GetElement(leaf.GetFamilySymbolIds().First()); symbol.Activate(); middle.Regenerate(); + for (int i = 0; i < 2; i++) + { + var instance = middle.FamilyCreate.NewFamilyInstance(new XYZ(i * 600 / 304.8, 0, 0), symbol, Autodesk.Revit.DB.Structure.StructuralType.NonStructural); + fm.AssociateElementParameterToFamilyParameter(instance.LookupParameter("Section_Width"), width); + if (i == 1) fm.AssociateElementParameterToFamilyParameter(instance.get_Parameter(BuiltInParameter.IS_VISIBLE_PARAM), visible); + } + tx.Commit(); + } + middle.SaveAs(middlePath, new SaveAsOptions { OverwriteExistingFile = false, MaximumBackups = 1 }); + root = app.Application.NewFamilyDocument(template); + var nested = middle.LoadFamily(root, new FixtureLoadOptions()); + if (nested == null) throw new InvalidOperationException("Cannot load shared middle fixture."); + using (var tx = new Transaction(root, "QA second nesting level")) + { + tx.Start(); var fm = root.FamilyManager; fm.NewType("QA_Nominal"); + var width = fm.AddParameter("QA_Width", GroupTypeId.Geometry, SpecTypeId.Length, true); fm.Set(width, 300 / 304.8); + var visible = fm.AddParameter("QA_SecondVisible", GroupTypeId.Graphics, SpecTypeId.Boolean.YesNo, true); fm.Set(visible, 1); + var symbol = (FamilySymbol)root.GetElement(nested.GetFamilySymbolIds().First()); symbol.Activate(); root.Regenerate(); + var instance = root.FamilyCreate.NewFamilyInstance(XYZ.Zero, symbol, Autodesk.Revit.DB.Structure.StructuralType.NonStructural); + fm.AssociateElementParameterToFamilyParameter(instance.LookupParameter("QA_Width"), width); + fm.AssociateElementParameterToFamilyParameter(instance.LookupParameter("QA_SecondVisible"), visible); + tx.Commit(); + } + NativeAcceptance.Target(root); + var structure = JsonSerializer.Deserialize(new AnalyzeFamilyStructure().Execute(Args(new { max_depth = 4 }), app)); + if (structure.GetProperty("nodes").EnumerateArray().Max(n => n.GetProperty("depth").GetInt32()) < 2) throw new InvalidOperationException("Fixture does not contain two nesting levels."); + var flex = JsonSerializer.Deserialize(new FlexFamily().Execute(Args(new { require_geometry_change = true, scenarios = new object[] { + new { name = "width_240", values = new Dictionary { ["QA_Width"] = 240 }, require_solid = true }, + new { name = "width_360", values = new Dictionary { ["QA_Width"] = 360 }, require_solid = true }, + new { name = "variant_off", values = new Dictionary { ["QA_SecondVisible"] = false }, require_solid = true } + } }), app)); + if (flex.GetProperty("results").EnumerateArray().Any(r => !r.GetProperty("valid").GetBoolean())) throw new InvalidOperationException("Nested driver/visibility acceptance failed: " + flex); + // Shared leaves are separate native instances: Revit deliberately omits + // their solids from the middle instance. Probe only the leaf instances + // in this fixture, whose middle/root have no owned geometry. + double nominal = new FilteredElementCollector(root).OfClass(typeof(FamilyInstance)).Cast().Where(f => f.SuperComponent != null && f.GetSubComponentIds().Count==0).SelectMany(Solids).Sum(s => s.Volume) * Math.Pow(.3048, 3); + if (Math.Abs(nominal - .24) > 1e-6) throw new InvalidOperationException("Two nested 300x400x1000 blocks must occupy 0.24 m3; actual " + nominal + "; instances: " + JsonSerializer.Serialize(new FilteredElementCollector(root).OfClass(typeof(FamilyInstance)).Cast().Select(f=>new { id=f.Id.Value,parent=f.SuperComponent?.Id.Value,volume=Solids(f).Sum(s=>s.Volume)*Math.Pow(.3048,3) }))); + root.SaveAs(rootPath, new SaveAsOptions { OverwriteExistingFile = false, MaximumBackups = 1 }); + return new { passed = true, file = rootPath, middle_file = middlePath, independent_nominal_volume_m3 = nominal, expected_nominal_volume_m3 = .24, structure, flex }; + } + finally { NativeAcceptance.Target(null); foreach (var family in new[] { root, middle, child }) if (family is { IsValidObject: true }) family.Close(false); } + } + private sealed class FixtureLoadOptions : IFamilyLoadOptions + { + public bool OnFamilyFound(bool familyInUse, out bool overwriteParameterValues) { overwriteParameterValues = true; return true; } + public bool OnSharedFamilyFound(Family sharedFamily, bool familyInUse, out FamilySource source, out bool overwriteParameterValues) { source = FamilySource.Family; overwriteParameterValues = true; return true; } + } + public static object LiveSchedule(UIApplication app, JsonElement input) + { + using var oldUpdaters = new InstalledUpdaterPause("ClaudeRevit live SPDS quantities"); + var section = Path.GetFullPath(input.GetProperty("section_file").GetString()!); + var file = Path.GetFullPath(input.GetProperty("output_file").GetString()!); + if (File.Exists(file)) throw new InvalidOperationException("Refusing to overwrite fixture."); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + Document? project = null; IUpdater? updater = null; + try + { + project = app.Application.NewProjectDocument(UnitSystem.Metric); NativeAcceptance.Target(project); + var updaterType = typeof(CreateSpdsTable).Assembly.GetType("ClaudeRevit.Tools.LiveSpdsUpdater", true)!; + updater = new ScopedUpdater((IUpdater)Activator.CreateInstance(updaterType, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, null, + new object[] { app.ActiveAddInId }, null)!, app.ActiveAddInId, new Guid("d777dc4a-9e10-4699-a055-6b855127fe75")); + UpdaterRegistry.RegisterUpdater(updater, project, true); + var filter = new LogicalOrFilter(new ElementCategoryFilter(BuiltInCategory.OST_GenericModel), new ElementClassFilter(typeof(PropertySetElement))); + foreach (var change in new[] { Element.GetChangeTypeAny(), Element.GetChangeTypeElementAddition(), Element.GetChangeTypeElementDeletion() }) + UpdaterRegistry.AddTrigger(updater.GetUpdaterId(), project, filter, change); + FamilyInstance a, b; PropertySetElement assetElement; Material material; + using (var tx = new Transaction(project, "QA material and native fixture instances")) + { + tx.Start(); + if (!project.LoadFamily(section, new FixtureLoadOptions(), out var family)) throw new InvalidOperationException("Cannot load section fixture."); + var symbol = (FamilySymbol)project.GetElement(family.GetFamilySymbolIds().First()); symbol.Activate(); project.Regenerate(); + var asset = new StructuralAsset("QA actual steel", StructuralAssetClass.Metal) { Density = UnitUtils.ConvertToInternalUnits(7850, UnitTypeId.KilogramsPerCubicMeter) }; + assetElement = PropertySetElement.Create(project, asset); + material = (Material)project.GetElement(Material.Create(project, "QA actual steel")); material.SetMaterialAspectByPropertySet(MaterialAspect.Structural, assetElement.Id); + a = project.Create.NewFamilyInstance(XYZ.Zero, symbol, Autodesk.Revit.DB.Structure.StructuralType.NonStructural); + b = project.Create.NewFamilyInstance(new XYZ(2, 0, 0), symbol, Autodesk.Revit.DB.Structure.StructuralType.NonStructural); + a.LookupParameter("Section_Material").Set(material.Id); b.LookupParameter("Section_Material").Set(material.Id); + tx.Commit(); + } + var created = JsonSerializer.Deserialize(new CreateSpdsTable().Execute(Args(new { + profile = "steel_rollup", standard_edition = "2026", table_key = "QA_live_mass", category = "OST_GenericModel", nesting_policy = "explicit", scope = "entire_category", + field_mapping = new { profile = new { constant = "QA section" }, grade = new { constant = "S355" }, size = new { constant = "QA nominal" }, group = new { constant = "QA members" }, mass_kg = new { source = "material_volumes" } }, + construction_groups = new[] { "QA members" }, name = "QA live steel mass", font_name = "Arial", preview = false + }), app)); + var parameters = created.GetProperty("result").GetProperty("derived_parameters"); + var totalGuid = Guid.Parse(parameters.GetProperty("total").GetString()!); + double Total() => new[] { a, b }.Where(e => e.IsValidObject).Sum(e => UnitUtils.ConvertFromInternalUnits(e.get_Parameter(totalGuid).AsDouble(), UnitTypeId.Kilograms)); + void Expect(double expected, string stage) { var actual = Total(); if (Math.Abs(actual - expected) > .01) throw new InvalidOperationException(stage + ": expected " + expected + " kg; actual " + actual); } + Expect(1884, "initial actual material quantities"); + using (var tx = new Transaction(project, "QA width edit")) { tx.Start(); a.LookupParameter("Section_Width").Set(500 / 304.8); tx.Commit(); } + Expect(2512, "width updater"); + using (var tx = new Transaction(project, "QA density edit")) { tx.Start(); var asset = assetElement.GetStructuralAsset(); asset.Name="QA actual steel density 7000"; asset.Density = UnitUtils.ConvertToInternalUnits(7000, UnitTypeId.KilogramsPerCubicMeter); assetElement.SetStructuralAsset(asset); tx.Commit(); } + Expect(2240, "actual density updater"); + using (var tx = new Transaction(project, "QA source deletion")) { tx.Start(); project.Delete(b.Id); tx.Commit(); } + Expect(1400, "source deletion updater"); + var nativeSchedules=created.GetProperty("result").GetProperty("schedules").EnumerateArray().ToArray(); + if(nativeSchedules.Length!=5)throw new InvalidOperationException("Steel detail and four native summaries are required."); + foreach(var entry in nativeSchedules) + { + var schedule=(ViewSchedule)project.GetElement(new ElementId(entry.GetProperty("id").GetInt64())); + var body=schedule.GetTableData().GetSectionData(SectionType.Body); + var totalRows=Enumerable.Range(body.FirstRowNumber,body.NumberOfRows).Where(row=>double.TryParse(schedule.GetCellText(SectionType.Body,row,body.LastColumnNumber).Replace(',','.'),System.Globalization.NumberStyles.Float,System.Globalization.CultureInfo.InvariantCulture,out var value)&&Math.Abs(value-1.4)<1e-7).ToArray(); + if(totalRows.Length!=1)throw new InvalidOperationException("Native summary does not show exactly one 1.4 t data row: "+schedule.Name); + foreach(var row in totalRows)foreach(var col in Enumerable.Range(body.FirstColumnNumber,body.NumberOfColumns)) + { + var style=body.GetTableCellStyle(row,col); + if(new[]{style.BorderTopLineStyle,style.BorderBottomLineStyle,style.BorderLeftLineStyle,style.BorderRightLineStyle}.Any(id=>id.Value<=0))throw new InvalidOperationException("Native summary data has an invisible/unspecified border."); + if(Math.Abs(style.TextSize-2.5*96/25.4)>1e-6)throw new InvalidOperationException("Native summary text size disagrees with 2.5 mm."); + } + } + var audits = created.GetProperty("result").GetProperty("schedules").EnumerateArray().Select(s => JsonSerializer.Deserialize(new AuditSpdsSchedule().Execute(Args(new { schedule_id = s.GetProperty("id").GetInt64() }), app))).ToArray(); + object? visual=null; + if(input.TryGetProperty("titleblock_template",out var titleblock)) visual=ScheduleVisual(app,project,created.GetProperty("result").GetProperty("schedules").EnumerateArray().Select(s=>new ElementId(s.GetProperty("id").GetInt64())).ToArray(),titleblock.GetString()!,input.GetProperty("image_directory").GetString()!); + project.SaveAs(file, new SaveAsOptions { OverwriteExistingFile = false, MaximumBackups = 1 }); + return new { passed = true, file, expected_mass_stages_kg = new[] { 1884, 2512, 2240, 1400 }, created, audits, visual, layout_visual_review = "Export pixels still require independent inspection." }; + } + finally + { + NativeAcceptance.Target(null); + if (updater != null && project is { IsValidObject: true } && UpdaterRegistry.IsUpdaterRegistered(updater.GetUpdaterId(),project)) UpdaterRegistry.UnregisterUpdater(updater.GetUpdaterId(), project); + if (project is { IsValidObject: true }) project.Close(false); + } + } + private static object ScheduleVisual(UIApplication app,Document project,ElementId[] schedules,string template,string directory) + { + Document? block=null; + try + { + Directory.CreateDirectory(directory); + block=app.Application.NewFamilyDocument(template); + using(var tx=new Transaction(block,"QA A3 titleblock type")){tx.Start();if(block.FamilyManager.CurrentType==null)block.FamilyManager.NewType("QA A3");tx.Commit();} + var loaded=block.LoadFamily(project,new FixtureLoadOptions())??throw new InvalidOperationException("Cannot load QA titleblock."); + var symbol=loaded.GetFamilySymbolIds().First(); + block.Close(false);block=null; + ViewSheet sheet; + using(var tx=new Transaction(project,"QA live schedules on A3")) + { + tx.Start();sheet=ViewSheet.Create(project,symbol);sheet.Name="QA live specifications";sheet.SheetNumber="QA-01";project.Regenerate(); + var outline=sheet.Outline;var point=new XYZ(outline.Min.U+20/304.8,outline.Max.V-20/304.8,0); + foreach(var id in schedules) + { + var placed=ScheduleSheetInstance.Create(project,sheet.Id,id,point);project.Regenerate(); + var box=placed.get_BoundingBox(sheet)??throw new InvalidOperationException("No native schedule bounds."); + point=new XYZ(point.X,box.Min.Y-10/304.8,0); + } + tx.Commit(); + } + var audits=schedules.Select(id=>JsonSerializer.Deserialize(new AuditSpdsSchedule().Execute(Args(new{schedule_id=id.Value}),app))).ToArray(); + var options=new ImageExportOptions{ExportRange=ExportRange.SetOfViews,FilePath=Path.Combine(directory,"QA_A3"),HLRandWFViewsFileType=ImageFileType.PNG,ShadowViewsFileType=ImageFileType.PNG,ZoomType=ZoomFitType.FitToPage,PixelSize=2000,FitDirection=FitDirectionType.Horizontal}; + options.SetViewsAndSheets(new[]{sheet.Id});project.ExportImage(options); + var sections=schedules.Select(id=> + { + var schedule=(ViewSchedule)project.GetElement(id);var data=schedule.GetTableData();var rows=new List(); + foreach(var sectionType in new[]{SectionType.Header,SectionType.Body,SectionType.Summary,SectionType.Footer}) + { + var section=data.GetSectionData(sectionType);if(section==null)continue; + rows.Add(new{section=sectionType.ToString(),count=section.NumberOfRows,cells=Enumerable.Range(section.FirstRowNumber,section.NumberOfRows).Take(30).Select(row=>new{row,height_mm=section.GetRowHeight(row)*304.8,text=Enumerable.Range(section.FirstColumnNumber,section.NumberOfColumns).Select(col=>schedule.GetCellText(sectionType,row,col)).ToArray(),styles=Enumerable.Range(section.FirstColumnNumber,section.NumberOfColumns).Select(col=>{var style=section.GetTableCellStyle(row,col);return new{style.FontName,style.TextSize,top=style.BorderTopLineStyle.Value,bottom=style.BorderBottomLineStyle.Value,left=style.BorderLeftLineStyle.Value,right=style.BorderRightLineStyle.Value};}).ToArray()}).ToArray()}); + } + return new{id=id.Value,rows}; + }).ToArray(); + return new{sheet_id=sheet.Id.Value,audits,sections,images=Directory.GetFiles(directory,"*.png"),review="Native pixels must be inspected; this export alone does not certify SPDS compliance."}; + } + finally{if(block is {IsValidObject:true})block.Close(false);} + } + public static object ScheduleForms(UIApplication app,JsonElement input) + { + using var installed=new InstalledUpdaterPause("ClaudeRevit live SPDS quantities"); + var project=app.Application.NewProjectDocument(UnitSystem.Metric);NativeAcceptance.Target(project); + IUpdater? updater=null; + try + { + var type=typeof(CreateSpdsTable).Assembly.GetType("ClaudeRevit.Tools.LiveSpdsUpdater",true)!; + updater=new ScopedUpdater((IUpdater)Activator.CreateInstance(type,System.Reflection.BindingFlags.NonPublic|System.Reflection.BindingFlags.Instance,null,new object[]{app.ActiveAddInId},null)!,app.ActiveAddInId,Guid.NewGuid()); + UpdaterRegistry.RegisterUpdater(updater,project,true); + var filter=new ElementCategoryFilter(BuiltInCategory.OST_GenericModel); + foreach(var change in new[]{Element.GetChangeTypeAny(),Element.GetChangeTypeElementAddition(),Element.GetChangeTypeElementDeletion()})UpdaterRegistry.AddTrigger(updater.GetUpdaterId(),project,filter,change); + FamilyInstance a,b; + using(var tx=new Transaction(project,"QA physical timber sources")) + { + tx.Start();if(!project.LoadFamily(input.GetProperty("section_file").GetString()!,out var family))throw new InvalidOperationException("Cannot load timber QA fixture."); + var symbol=(FamilySymbol)project.GetElement(family.GetFamilySymbolIds().First());symbol.Activate();project.Regenerate(); + var asset=new StructuralAsset("QA actual timber density",StructuralAssetClass.Wood){Density=UnitUtils.ConvertToInternalUnits(500,UnitTypeId.KilogramsPerCubicMeter)}; + var physical=PropertySetElement.Create(project,asset);var material=(Material)project.GetElement(Material.Create(project,"QA timber"));material.SetMaterialAspectByPropertySet(MaterialAspect.Structural,physical.Id); + a=project.Create.NewFamilyInstance(XYZ.Zero,symbol,Autodesk.Revit.DB.Structure.StructuralType.NonStructural); + b=project.Create.NewFamilyInstance(new XYZ(2,0,0),symbol,Autodesk.Revit.DB.Structure.StructuralType.NonStructural); + a.LookupParameter("Section_Material").Set(material.Id);b.LookupParameter("Section_Material").Set(material.Id);tx.Commit(); + } + var created=new List(); + foreach(var profile in new[]{"timber_materials","timber_elements","scheme_specification"}) + created.Add(JsonSerializer.Deserialize(new CreateSpdsTable().Execute(Args(new{profile,standard_edition="2026",table_key="QA_"+profile,category="OST_GenericModel",scope="entire_category",nesting_policy="explicit",name="QA "+profile,font_name="Arial",preview=false, + field_mapping=new{name=new{constant="Контрольный деревянный элемент 300x400"},mark=new{constant="ДЭ-1"},designation=new{constant="QA 300x400"},unit=new{constant="м"},notes=new{constant="Плотность 500 кг/м³"},amount=new{parameter_name="Section_Length",unit="m"},mass_kg=new{source="material_volumes"}} + }),app))); + double Value(JsonElement table,string key)=>new[]{a,b}.Sum(e=>e.get_Parameter(Guid.Parse(table.GetProperty("result").GetProperty("derived_parameters").GetProperty(key).GetString()!)).AsDouble()); + if(Math.Abs(Value(created[0],"quantity")-2)>1e-7)throw new InvalidOperationException("Initial timber material amount is not 2 m."); + foreach(var table in created.Skip(1))if(Math.Abs(UnitUtils.ConvertFromInternalUnits(Value(table,"total"),UnitTypeId.Kilograms)-120)>.01)throw new InvalidOperationException("Initial physical timber total is not 120 kg."); + using(var tx=new Transaction(project,"QA timber length change")){tx.Start();a.LookupParameter("Section_Length").Set(2000/304.8);tx.Commit();} + if(Math.Abs(Value(created[0],"quantity")-3)>1e-7)throw new InvalidOperationException("Timber material amount did not follow native length."); + foreach(var table in created.Skip(1))if(Math.Abs(UnitUtils.ConvertFromInternalUnits(Value(table,"total"),UnitTypeId.Kilograms)-180)>.01)throw new InvalidOperationException("Physical timber total did not follow native length."); + var schedules=created.SelectMany(table=>table.GetProperty("result").GetProperty("schedules").EnumerateArray()).Select(s=>new ElementId(s.GetProperty("id").GetInt64())).ToArray(); + var visual=ScheduleVisual(app,project,schedules,input.GetProperty("titleblock_template").GetString()!,input.GetProperty("image_directory").GetString()!); + var file=input.GetProperty("output_file").GetString()!;if(File.Exists(file))throw new InvalidOperationException("Refusing to overwrite timber fixture.");Directory.CreateDirectory(Path.GetDirectoryName(file)!);project.SaveAs(file,new SaveAsOptions{MaximumBackups=1}); + return new{passed=true,file,initial_amount_m=2,edited_amount_m=3,initial_mass_kg=120,edited_mass_kg=180,created,visual}; + } + finally{NativeAcceptance.Target(null);if(updater!=null&&UpdaterRegistry.IsUpdaterRegistered(updater.GetUpdaterId(),project))UpdaterRegistry.UnregisterUpdater(updater.GetUpdaterId(),project);project.Close(false);} + } + private sealed class InstalledUpdaterPause : IDisposable + { + private readonly List _ids = new(); + public InstalledUpdaterPause(string name) + { + // A development copy shares storage schema IDs with the installed + // copy. Pause its older handler only during this one synchronous + // test callback; no user edits can interleave on the API thread. + var updaterGuid = name == "ClaudeRevit live SPDS quantities" ? "eac7c404-6d15-45b6-95cf-dc64ad75ce65" : "dc199cba-4d39-4c9d-9858-dab654bd7116"; + var id = new UpdaterId(new AddInId(new Guid("c8a3e9f4-7d2b-4e16-9a5c-3f8b6d4e2a1c")), new Guid(updaterGuid)); + if (UpdaterRegistry.IsUpdaterRegistered(id) && UpdaterRegistry.IsUpdaterEnabled(id)) { _ids.Add(id); UpdaterRegistry.DisableUpdater(id); } + } + public void Dispose() { foreach (var id in _ids) UpdaterRegistry.EnableUpdater(id); } + } + private sealed class ScopedUpdater(IUpdater implementation, AddInId addin, Guid id) : IUpdater + { + public UpdaterId GetUpdaterId()=>new(addin,id); + public string GetUpdaterName()=>"QA " + implementation.GetUpdaterName(); + public string GetAdditionalInformation()=>implementation.GetAdditionalInformation(); + public ChangePriority GetChangePriority()=>implementation.GetChangePriority(); + public void Execute(UpdaterData data)=>implementation.Execute(data); + } + public static object BenchmarkProbe(UIApplication app) + { + var project = app.Application.NewProjectDocument(UnitSystem.Metric); + NativeAcceptance.Target(project); + try + { + string Probe() + { + var values = new Dictionary { + ["levels"] = new FilteredElementCollector(project).OfClass(typeof(Level)).GetElementCount(), + ["materials"] = new FilteredElementCollector(project).OfClass(typeof(Material)).GetElementCount() + }; + var type = typeof(CreateLevel).Assembly.GetType("ClaudeRevit.Tools.BenchmarkModelProbe", true)!; + type.GetMethod("Append")!.Invoke(null, new object[] { project, values }); + return JsonSerializer.Serialize(values); + } + var before = Probe(); + long id; + using (var tx = new Transaction(project, "QA benchmark B0")) + { tx.Start(); var created = JsonSerializer.Deserialize(new CreateLevel().Execute(Args(new { name = "Bench B0", elevation_ft = 3.5 / .3048 }), app)); id = created.GetProperty("id").GetInt64(); tx.Commit(); } + var good = ClaudeRevit.Services.BenchmarkObjective.Evaluate("B0", before, Probe()); + if (!good.Passed) throw new InvalidOperationException("Correct native 3.5 m level was not accepted: " + JsonSerializer.Serialize(good)); + using (var tx = new Transaction(project, "QA wrong benchmark height")) { tx.Start(); ((Level)project.GetElement(new ElementId(id))).Elevation = 3.5; tx.Commit(); } + var bad = ClaudeRevit.Services.BenchmarkObjective.Evaluate("B0", before, Probe()); + if (bad.Passed || bad.Failed == 0) throw new InvalidOperationException("Native unit error was accepted."); + var materialBefore = Probe(); + using (var tx = new Transaction(project, "QA benchmark B5")) { tx.Start(); new CreateMaterial().Execute(Args(new { name = "Bench Concrete", r = 128, g = 128, b = 128 }), app); tx.Commit(); } + var material = ClaudeRevit.Services.BenchmarkObjective.Evaluate("B5", materialBefore, Probe()); + if (!material.Passed) throw new InvalidOperationException("Native material evidence failed: " + JsonSerializer.Serialize(material)); + return new { passed = true, correct_level = good, wrong_units_rejected = bad, material_probe = material, + limitation = "Native probe/objective smoke check only; subscription model execution and crash reproductions require separate runs." }; + } + finally { NativeAcceptance.Target(null); project.Close(false); } + } + public static object DependentNode(UIApplication app, JsonElement input) + { + using var oldUpdaters = new InstalledUpdaterPause("ClaudeRevit dependent connection nodes"); + var section = Path.GetFullPath(input.GetProperty("section_file").GetString()!); + var file = Path.GetFullPath(input.GetProperty("output_file").GetString()!); + if (File.Exists(file)) throw new InvalidOperationException("Refusing to overwrite fixture."); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + Document? project = null; IUpdater? updater = null; + try + { + project = app.Application.NewProjectDocument(UnitSystem.Metric); NativeAcceptance.Target(project); + var updaterType = typeof(UpsertConnectionNode).Assembly.GetType("ClaudeRevit.Tools.ConnectionNodeUpdater", true)!; + updater = new ScopedUpdater((IUpdater)Activator.CreateInstance(updaterType, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, null, + new object[] { app.ActiveAddInId }, null)!, app.ActiveAddInId, new Guid("dd181d42-0669-4d2c-873b-c92b36fd79ab")); + UpdaterRegistry.RegisterUpdater(updater, project, true); + var filter = new LogicalOrFilter(new ElementClassFilter(typeof(FamilyInstance)), new ElementClassFilter(typeof(FamilySymbol))); + foreach (var change in new[] { Element.GetChangeTypeAny(), Element.GetChangeTypeElementDeletion() }) UpdaterRegistry.AddTrigger(updater.GetUpdaterId(), project, filter, change); + FamilyInstance member; FamilySymbol symbol; + using (var tx = new Transaction(project, "QA connection source")) + { + tx.Start(); if (!project.LoadFamily(section, new FixtureLoadOptions(), out var family)) throw new InvalidOperationException("Cannot load section."); + symbol = (FamilySymbol)project.GetElement(family.GetFamilySymbolIds().First()); symbol.Activate(); project.Regenerate(); + member = project.Create.NewFamilyInstance(XYZ.Zero, symbol, Autodesk.Revit.DB.Structure.StructuralType.NonStructural); tx.Commit(); + } + var created = JsonSerializer.Deserialize(new UpsertConnectionNode().Execute(Args(new { node_key = "QA_dependent", preview = false, expected_revision = 0, + spec = new { parts = new object[] { + new { key = "member", role = "member", element_id = member.Id.Value }, + new { key = "plate", role = "plate", family_type_id = symbol.Id.Value, relative_to = "member", offset_mm = new[] { 100d, 0, 0 }, + parameters = new[] { new { name = "Section_Width", unit = "mm", value_from = new { part_key = "member", name = "Section_Width", scope = "instance", scale = .5, offset_mm = 20 } } } } + }, rules = Array.Empty() } }), app)); + var plate = (FamilyInstance)project.GetElement(new ElementId(created.GetProperty("result").GetProperty("bindings").GetProperty("plate").GetInt64())); + void Width(double expected) + { + var actual = plate.LookupParameter("Section_Width").AsDouble() * 304.8; + var volume = Solids(plate).Sum(s => s.Volume) * Math.Pow(304.8, 3); + if (Math.Abs(actual - expected) > .001 || Math.Abs(volume - expected * 400 * 1000) > 1) throw new InvalidOperationException("Dependent parameter/actual geometry disagree with width " + expected); + } + Width(170); + using (var tx = new Transaction(project, "QA source dimension and position edit")) + { tx.Start(); member.LookupParameter("Section_Width").Set(500 / 304.8); ElementTransformUtils.MoveElement(project, member.Id, new XYZ(1000 / 304.8, 0, 0)); tx.Commit(); } + Width(270); + if (Math.Abs(((LocationPoint)plate.Location).Point.X * 304.8 - 1100) > .001) throw new InvalidOperationException("Declared relative placement did not follow its source."); + using (var tx = new Transaction(project, "QA independent managed-part edit")) { tx.Start(); plate.LookupParameter("Section_Width").Set(280 / 304.8); tx.Commit(); } + Width(280); + var manual = JsonSerializer.Deserialize(new GetConnectionNode().Execute(Args(new { node_key = "QA_dependent" }), app)); + var record = manual; + if (!record.GetProperty("needs_refresh").GetBoolean()) throw new InvalidOperationException("Independent managed edit was not flagged."); + using (var tx = new Transaction(project, "QA deleted source")) { tx.Start(); project.Delete(member.Id); tx.Commit(); } + Width(280); + var deleted = JsonSerializer.Deserialize(new GetConnectionNode().Execute(Args(new { node_key = "QA_dependent" }), app)); + project.SaveAs(file, new SaveAsOptions { OverwriteExistingFile = false, MaximumBackups = 1 }); + return new { passed = true, file, created, manual_edit = manual, deleted_source = deleted, relative_x_mm = 1100, + limitation = "Point-family dependency test; native structural beam/column placement, bores and capacity are separate cases." }; + } + finally + { + NativeAcceptance.Target(null); + if (updater != null && project is { IsValidObject: true } && UpdaterRegistry.IsUpdaterRegistered(updater.GetUpdaterId(),project)) UpdaterRegistry.UnregisterUpdater(updater.GetUpdaterId(), project); + if (project is { IsValidObject: true }) project.Close(false); + } + } +} 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..8e146c1 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. @@ -345,6 +353,7 @@ public Result OnStartup(UIControlledApplication application) // transaction back as usual. No-op when a user is doing their own editing. private static void OnFailuresProcessing(object? sender, FailuresProcessingEventArgs e) { + if (FamilyInspectionFailures.Handle(e)) return; if (!ToolDispatcher.Suppressing) return; try { @@ -395,6 +404,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..06e2247 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.") @@ -81,12 +82,13 @@ void Analyze(Document doc, string path, int depth, HashSet ancestors) Analyze(child, childPath, depth + 1, new HashSet(ancestors) { family.Name }); } catch (OperationCanceledException) { throw; } + catch (Autodesk.Revit.Exceptions.RegenerationFailedException) { throw; } catch (Exception ex) { problems.Add(new { path = childPath, status = ex.Message }); } finally { if (child is { IsValidObject: true }) child.Close(false); } } } Analyze(scope.Document, scope.Document.OwnerFamily.Name, 0, new() { scope.Document.OwnerFamily.Name }); - return Services.Json.Serialize(new { inspected_families = count, nodes, edges, limitations = problems, + return Services.Json.Serialize(new { inspected_families = count, nodes, edges, limitations = problems, open_warnings = scope.OpenWarnings, note = "Element IDs are local to each family document. Analysis does not prove flex/constraint validity; run flex_family." }); } private static string? SafeLabel(Dimension d) { try { return d.FamilyLabel?.Definition.Name; } catch { return null; } } 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..73e1e1e 100644 --- a/ClaudeRevit/Tools/CreateParametricSection.cs +++ b/ClaudeRevit/Tools/CreateParametricSection.cs @@ -9,29 +9,42 @@ 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."), + ["view_id"]=NativeToolUtil.Field("integer","Optional native family plan/elevation view for sketch constraints. Defaults to the active view; background RFAs without an active view use a parallel plan/elevation."), + ["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 doc=NativeToolUtil.Doc(app);var fm=FamilyEditorUtil.Manager(doc); + var view=input.TryGetValue("view_id",out var viewId)?doc.GetElement(new ElementId(viewId.GetInt64())) as 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); var n=input.TryGetValue("plane_normal",out var norm)?NativeToolUtil.Point(norm,false).Normalize():XYZ.BasisZ; var u=Math.Abs(n.DotProduct(XYZ.BasisZ))>.99?XYZ.BasisX:XYZ.BasisZ.CrossProduct(n).Normalize();var v=n.CrossProduct(u).Normalize(); - if(view is View3D||Math.Abs(view.ViewDirection.Normalize().DotProduct(n))<.99999)throw new ToolInputException("Activate a family view parallel to the section sketch plane."); + if(view==null&&!input.ContainsKey("view_id"))view=new FilteredElementCollector(doc).OfClass(typeof(View)).Cast() + .Where(candidate=>!candidate.IsTemplate&&candidate.ViewType is ViewType.FloorPlan or ViewType.CeilingPlan or ViewType.Elevation or ViewType.Section) + .FirstOrDefault(candidate=>Math.Abs(candidate.ViewDirection.Normalize().DotProduct(n))>=.99999); + if(view==null||view.IsTemplate||view.ViewType is not (ViewType.FloorPlan or ViewType.CeilingPlan or ViewType.Elevation or ViewType.Section)||Math.Abs(view.ViewDirection.Normalize().DotProduct(n))<.99999)throw new ToolInputException("Choose a family plan/elevation view parallel to the section sketch plane."); var parameters=new Dictionary();Extrusion? form=null;var flex=new List(); var (result,warnings)=NativeToolUtil.Commit(doc,"Claude: parametric "+shape+" section",preview,()=> { @@ -43,18 +56,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) { @@ -62,36 +94,87 @@ void Drive(bool horizontal,Dictionary positions) foreach(var pair in positions.OrderBy(p=>p.Key)) { var p=axis*pair.Key/Units.MmPerFoot;var rp=doc.FamilyCreate.NewReferencePlane(p-tangent,p+tangent,n,view);rp.Name=prefix+(horizontal?"X":"Y")+pair.Key+" "+form.Id.Value; + doc.Regenerate(); if(pair.Key==0){rp.Pinned=true;origin=rp;} else { var refs=new ReferenceArray();refs.Append(origin!.GetReference());refs.Append(rp.GetReference()); var shift=-tangent*(100/Units.MmPerFoot);var dimension=doc.FamilyCreate.NewDimension(view,Line.CreateBound(shift,p+shift),refs);dimension.FamilyLabel=parameters[pair.Value!]; + doc.Regenerate(); } foreach(var edge in edges.Where(e=>e.GeometryCurve is Line && Math.Abs(e.GeometryCurve.GetEndPoint(0).DotProduct(axis)-pair.Key/Units.MmPerFoot)<1e-7&&Math.Abs(e.GeometryCurve.GetEndPoint(1).DotProduct(axis)-pair.Key/Units.MmPerFoot)<1e-7)) 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; + doc.Regenerate(); + 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(); + // Revit's native curved-solid volume integration has a small tolerance. + // Prove the actual cylindrical surfaces/radii as well as volume, so + // this tolerance cannot accept a polygonal or wrongly sized tube. + if(shape=="tube") + { + var radii=ConnectionNodes.Solids(doc.GetElement(form!.Id)).SelectMany(s=>s.Faces.Cast()).OfType() + .SelectMany(face=>new[]{face.get_Radius(0).GetLength(),face.get_Radius(1).GetLength()}).Select(radius=>Math.Round(radius*Units.MmPerFoot,5)).Distinct().OrderBy(r=>r).ToArray(); + var expectedRadii=new[]{dims["Width"]/2-dims["Web"],dims["Width"]/2}; + if(radii.Length!=2||radii.Zip(expectedRadii,(a,b)=>Math.Abs(a-b)>.001).Any(failed=>failed))throw new ToolInputException("Tube must retain its two native cylindrical radii; update rolled back."); + } + var volumeTolerance=shape=="tube"?1e-4:1e-5; + if(Math.Abs(actual-expected)>Math.Max(1,expected*volumeTolerance))throw new ToolInputException(FormattableString.Invariant($"Section flex '{scenario}' has wrong volume: measured {actual:F3} mm3, expected {expected:F3} mm3; 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)&&!(shape=="tube"&&k=="Height")&&!(placement!=null&&k=="Length")&&string.IsNullOrWhiteSpace(parameters[k].Formula)&&!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..56941a5 100644 --- a/ClaudeRevit/Tools/CreateSpdsTable.cs +++ b/ClaudeRevit/Tools/CreateSpdsTable.cs @@ -2,13 +2,14 @@ using Anthropic.Models.Beta.Messages; using Autodesk.Revit.DB; using Autodesk.Revit.UI; +using ClaudeRevit.Services; namespace ClaudeRevit.Tools; public sealed class CreateSpdsTable:IRevitTool { public string Name=>"create_spds_table"; - public string Description=>"Create LIVE native ViewSchedules from actual model parameter mappings. Steel form 2 has a construction-group mass matrix, profile/grade subtotals, overall total and a separate LIVE grade-total schedule. Timber material/element statements and scheme form 7 supported. Bound calculated model parameters update in the same Revit transaction after source instance/type edits, additions or deletions; masses are summed before display rounding. No drafting snapshots. Preview=true by default. Existing approved schedules should be reused first. Clarify category, source scope, nested-instance counting, units, form, edition and formatting. Registered add-in updater must remain available; Revit warns if missing."; + public string Description=>"Create LIVE native ViewSchedules from actual model parameter mappings. Steel consumption uses a construction-group mass matrix and four grouped LIVE summaries (profile/grade, profile, overall, grade); totals use framed native data rows because Revit footer borders cannot be overridden reliably. This set needs an agreed sheet arrangement; it is not an exact single-table form 2 facsimile. Timber material/element statements and scheme form 7 supported. Bound calculated model parameters update in the same Revit transaction after source instance/type edits, additions or deletions; masses are summed before display rounding. No drafting snapshots. Preview=true by default. Reuse approved schedules first. Clarify category, source scope, nesting, units, form, edition and formatting. Registered add-in updater must remain available; Revit warns if missing."; public bool RequiresTransaction=>false; public bool MutatesWithoutTransaction=>true; public bool RequiresNoTurnGroup=>true; @@ -16,42 +17,56 @@ 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,()=> { LiveSpds.Bind(doc,r);LiveSpds.Refresh(doc,r,true); var schedules=new List{LiveSpds.CreateSchedule(doc,r,name,font,size)}; - if(profile=="steel_rollup")schedules.Add(LiveSpds.CreateSchedule(doc,r,name+" — по маркам металла",font,size,true)); + if(profile=="steel_rollup") + { + schedules.Add(LiveSpds.CreateSchedule(doc,r,name+" — итоги по профилям и маркам",font,size,"profile_grade")); + schedules.Add(LiveSpds.CreateSchedule(doc,r,name+" — итоги по профилям",font,size,"profile")); + schedules.Add(LiveSpds.CreateSchedule(doc,r,name+" — всего масса металла",font,size,"overall")); + schedules.Add(LiveSpds.CreateSchedule(doc,r,name+" — по маркам металла",font,size,"grade")); + } r.ScheduleUniqueIds=schedules.Select(s=>s.UniqueId).ToArray();LiveSpds.Save(Autodesk.Revit.DB.ExtensibleStorage.DataStorage.Create(doc),r); foreach(var s in schedules)ModelProvenance.Write(s,new {generator="live_spds_table",profile,standard_edition=edition,live=true,scope,table_key=key,source_count=sources.Count}); - return new {profile,standard_edition=edition,table_key=key,live=true,source_count=sources.Count,schedules=schedules.Select(s=>new {id=preview?(long?)null:s.Id.Value,name=s.Name}).ToArray(),total_mass_kg=previewTable.TotalMassKg,columns=Services.SpdsTables.Columns(profile,r.Groups),derived_parameters=r.Parameters}; + return new {profile,standard_edition=edition,table_key=key,live=true,source_count=sources.Count,schedules=schedules.Select((s,index)=>new {id=preview?(long?)null:s.Id.Value,name=s.Name,aggregation=profile=="steel_rollup"?new[]{"detail","profile_grade","profile","overall","grade"}[index]:"detail"}).ToArray(),total_mass_kg=previewTable.TotalMassKg,columns=Services.SpdsTables.Columns(profile,r.Groups),derived_parameters=r.Parameters}; }); - return Services.Json.Serialize(new {preview,result,warnings,next_step="Place live schedules on sheets and inspect export_image for wrapping, borders and totals. Grade-total steel schedule is separate and must be placed below the main table. Derived fields require ClaudeRevit updater; source errors show an incomplete-data title/column instead of stale totals."}); + return Services.Json.Serialize(new {preview,result,warnings,next_step="Arrange all returned live schedules on sheets; steel summaries are separate native tables. Agree placement and inspect export_image for wrapping, borders and totals. Derived fields require ClaudeRevit updater; source errors show an incomplete-data title/column instead of stale totals."}); } } 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..dc99d92 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; @@ -8,8 +9,23 @@ internal sealed class FamilyDocumentScope : IDisposable { public Document Document { get; } private readonly bool _close; + private readonly FamilyInspectionFailures? _failures; + public IReadOnlyList OpenWarnings => _failures?.Warnings ?? (IReadOnlyList)Array.Empty(); 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."); + _failures = new FamilyInspectionFailures(app.Application); + try { Document = app.Application.OpenDocumentFile(path); _close = true; } + catch { _failures.Dispose(); throw; } + if (_failures.Errors.Count > 0) { var errors = string.Join("; ", _failures.Errors); Dispose(); throw new ToolInputException("Reference RFA upgrade failed: " + errors); } + if (!Document.IsFamilyDocument) { Dispose(); 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) @@ -20,7 +36,7 @@ public FamilyDocumentScope(UIApplication app, IReadOnlyDictionary _existing; + public List Warnings { get; } = new(); + public List Errors { get; } = new(); + public FamilyInspectionFailures(Application application) + { + _application = application; + _existing = application.Documents.Cast().ToHashSet(); + _previous = _current; _current = this; + application.FailuresProcessing += OnFailures; + } + private void OnFailures(object? sender, FailuresProcessingEventArgs e) => Handle(e); + public static bool Handle(FailuresProcessingEventArgs e) + { + var scope = _current; + if (scope == null) return false; + var accessor = e.GetFailuresAccessor(); + var document = accessor.GetDocument(); + if (scope._existing.Contains(document) || !document.IsFamilyDocument) return false; + var messages = accessor.GetFailureMessages(); + bool error = false; + foreach (var message in messages) + { + if (message.GetSeverity() == FailureSeverity.Warning) + { + scope.Warnings.Add(message.GetDescriptionText()); + accessor.DeleteWarning(message); + } + else { scope.Errors.Add(message.GetDescriptionText()); error = true; } + } + if (error) e.SetProcessingResult(FailureProcessingResult.ProceedWithRollBack); + else if (messages.Count > 0) e.SetProcessingResult(FailureProcessingResult.Continue); + return true; + } + public void Dispose() + { + _application.FailuresProcessing -= OnFailures; + _current = _previous; + } +} diff --git a/ClaudeRevit/Tools/FlexFamily.cs b/ClaudeRevit/Tools/FlexFamily.cs index 522b158..5b72114 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.") @@ -85,7 +86,7 @@ void Probe(GeometryElement? geometry, int depth) var changed=requireChange?Fingerprint(doc)!=baseline:(bool?)null; if(requireChange&&changed!=true)throw new InvalidOperationException("Parameters did not change actual solid geometry; a named parameter alone is not a working driver."); return new { geometry_changed=changed,type_name = fm.CurrentType?.Name, visible_solid_count = solids, summed_solid_volume_m3 = volume * Math.Pow(0.3048, 3), bounds, - values = fm.Parameters.Cast().Take(1000).Select(p => new { name = p.Definition.Name, value = FamilyEditorUtil.CurrentValue(fm, p).display }).ToArray() }; + values = fm.Parameters.Cast().Take(1000).Select(p => { var value=FamilyEditorUtil.CurrentValue(fm,p); return new { name=p.Definition.Name, value=value.display, internal_value=value.raw, value_mm=value.mm }; }).ToArray() }; }); results.Add(new { name, valid = true, geometry, warnings }); } @@ -94,7 +95,7 @@ void Probe(GeometryElement? geometry, int depth) catch (Exception ex) { results.Add(new { name, valid = false, error = ex.Message }); } if (fm.CurrentType?.Name != originalType) throw new InvalidOperationException("Flex rollback did not restore the original family type; stop and inspect the document."); } - return Services.Json.Serialize(new { family = doc.OwnerFamily.Name, restored = true, results, + return Services.Json.Serialize(new { family = doc.OwnerFamily.Name, restored = true, results, open_warnings = scope.OpenWarnings, coverage = "Only these scenarios were tested. Commit/regen and geometry probes cannot guarantee every size, visibility combination or nested type." }); } private static string Fingerprint(Document doc) 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..d48d1dc --- /dev/null +++ b/ClaudeRevit/Tools/InspectFamilyFiles.cs @@ -0,0 +1,47 @@ +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}}],require_geometry_change?:bool}]. 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, 0..6.") + }, "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; + if (item.TryGetProperty("require_geometry_change", out var change)) args["require_geometry_change"] = change; + 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..2228ec2 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}); @@ -149,16 +164,20 @@ public static (int Count,List Errors) Refresh(Document doc,LiveSpdsRecor } return(items.Count,errors); } - public static ViewSchedule CreateSchedule(Document doc,LiveSpdsRecord r,string name,string font,double size,bool gradeTotals=false) + public static ViewSchedule CreateSchedule(Document doc,LiveSpdsRecord r,string name,string font,double size,string aggregation="detail") { - 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;d.ShowGridLines=true; + schedule.RowHeightOverride=RowHeightOverrideOptions.All;schedule.RowHeight=Math.Max(8,size*2)/Units.MmPerFoot; + var border=doc.Settings.Categories.NewSubcategory(doc.Settings.Categories.get_Item(BuiltInCategory.OST_Lines),"SPDS table border "+Guid.NewGuid().ToString("N")[..8]);border.SetLineWeight(1,GraphicsStyleType.Projection); 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) { var id=SharedParameterElement.Lookup(doc,r.Parameters[key])?.Id??throw new InvalidOperationException("Missing shared parameter: "+key);var candidates=d.GetSchedulableFields().Where(f=>f.ParameterId==id&&f.FieldType==ScheduleFieldType.Instance).ToArray(); if(candidates.Length!=1)throw new ToolInputException("Derived field is not schedulable: "+key);var field=d.AddField(candidates[0]);field.ColumnHeading=heading??key;field.IsHidden=hidden;field.GridColumnWidth=field.SheetColumnWidth=width/Units.MmPerFoot;added[key]=field; - var style=field.GetStyle();style.FontName=font;style.TextSize=size*72/25.4;style.FontHorizontalAlignment=TextKeys.Contains(key)?HorizontalAlignmentStyle.Left:HorizontalAlignmentStyle.Center; - var flags=style.GetCellStyleOverrideOptions();flags.Font=true;flags.FontSize=true;flags.HorizontalAlignment=true;style.SetCellStyleOverrideOptions(flags);field.SetStyle(style); + // Table cell text uses display pixels (96 dpi), unlike TextNoteType's feet. + var style=field.GetStyle();style.FontName=font;style.TextSize=size*96/25.4;style.FontHorizontalAlignment=TextKeys.Contains(key)?HorizontalAlignmentStyle.Left:HorizontalAlignmentStyle.Center; + style.BorderTopLineStyle=style.BorderBottomLineStyle=style.BorderLeftLineStyle=style.BorderRightLineStyle=border.Id; + var flags=style.GetCellStyleOverrideOptions();flags.Font=true;flags.FontSize=true;flags.HorizontalAlignment=true;flags.BorderLineStyle=flags.BorderTopLineStyle=flags.BorderBottomLineStyle=flags.BorderLeftLineStyle=flags.BorderRightLineStyle=true;style.SetCellStyleOverrideOptions(flags);field.SetStyle(style); if(key is "mass" or "total"||key.StartsWith("group_")){using var format=new FormatOptions(r.Profile=="steel_rollup"?UnitTypeId.Tonnes:UnitTypeId.Kilograms){UseDefault=false,Accuracy=r.Profile=="steel_rollup"?.1:.001};field.SetFormatOptions(format);} bool total=key is "quantity" or "total"||key.StartsWith("group_");if(total){if(!field.CanTotal())throw new ToolInputException("Cannot total derived field.");field.DisplayType=ScheduleFieldDisplayType.Totals;} return field; @@ -166,19 +185,31 @@ ScheduleField Field(string key,bool hidden=false,string? heading=null,double wid for(int index=0;index=4&&index().FirstOrDefault()??throw new ToolInputException("No text style to duplicate.");var tt=(TextNoteType)seed.Duplicate("SPDS live "+Guid.NewGuid().ToString("N")[..8]);tt.get_Parameter(BuiltInParameter.TEXT_FONT).Set(font);tt.get_Parameter(BuiltInParameter.TEXT_SIZE).Set(size/Units.MmPerFoot);schedule.BodyTextTypeId=schedule.HeaderTextTypeId=schedule.TitleTextTypeId=tt.Id; - doc.Regenerate();if(r.Profile=="steel_rollup"){int start=gradeTotals?1:4;int end=start+r.Groups.Length-1;if(end>=start&&schedule.CanGroupHeaders(0,start,0,end))schedule.GroupHeaders(0,start,0,end,"Масса по группам конструкций, т");} + doc.Regenerate();if(r.Profile=="steel_rollup"){int start=aggregation switch{"detail"=>4,"profile_grade"=>2,"profile" or "grade"=>1,_=>0};int end=start+r.Groups.Length-1;if(end>start&&schedule.CanGroupHeaders(0,start,0,end))schedule.GroupHeaders(0,start,0,end,"Масса по группам конструкций, т");} return schedule; } } 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 @@ + + +