pi-revit 0.2.18 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
- using System.Text.Json;
1
+ using System.Text.Json;
2
+ using System.Text.Json.Nodes;
2
3
  using Autodesk.Revit.DB;
3
4
  using Autodesk.Revit.UI;
4
5
  using RevitBridge.Tools;
@@ -14,9 +15,9 @@ namespace RevitBridge
14
15
  internal sealed record ToolContext(Document? Document, UIApplication? UIApplication);
15
16
 
16
17
  /// <summary>
17
- /// Optional tool return shape: compact text for model context plus the full payload for
18
- /// details. Tools may instead return any plain JSON-serializable object, which the bridge
19
- /// serializes into both channels (capped in content).
18
+ /// Optional tool return shape: complete structured payload plus a display summary.
19
+ /// The bridge/extension expose complete bounded data or explicit result retrieval to
20
+ /// the model; structured details remain available for rendering and diagnostics.
20
21
  /// </summary>
21
22
  internal sealed record ToolOutput(object? Payload, string? CompactText = null);
22
23
 
@@ -99,12 +100,36 @@ namespace RevitBridge
99
100
  description = tool.Description,
100
101
  category = tool.Write ? "write" : "read",
101
102
  tier = tool.Tier,
102
- parameters = tool.ParametersSchema,
103
+ parameters = DescribeParameters(tool),
103
104
  executionMode = "sequential",
104
105
  write = tool.Write,
105
106
  requiresDocument = tool.RequiresDocument,
106
107
  promptSnippet = tool.PromptSnippet,
107
- promptGuidelines = tool.PromptGuidelines,
108
- };
108
+ promptGuidelines = tool.RequiresDocument
109
+ ? (tool.PromptGuidelines ?? Array.Empty<string>()).Concat(new[]
110
+ {
111
+ $"{tool.Name}: use project.documentId from get_model_overview as expected_document_id to bind the call to that exact open document. It is required for model writes, open_view, and selection changes; legacy expected_document titles alone are insufficient. Refresh after closing/reopening or restarting Revit."
112
+ }).ToArray()
113
+ : tool.PromptGuidelines,
114
+ };
115
+
116
+ private static object DescribeParameters(ITool tool)
117
+ {
118
+ if (!tool.RequiresDocument) return tool.ParametersSchema;
119
+ var schema = JsonSerializer.SerializeToNode(tool.ParametersSchema)!.AsObject();
120
+ var properties = schema["properties"]!.AsObject();
121
+ properties["expected_document_id"] = new JsonObject
122
+ {
123
+ ["type"] = "string",
124
+ ["description"] = "Exact opaque project.documentId from get_model_overview. Required for document writes and UI mutations; optional for reads. Invalid after close/reopen or bridge restart."
125
+ };
126
+ if (DocumentGuard.AlwaysRequiresIdentity(tool.Name))
127
+ {
128
+ var required = schema["required"] as JsonArray ?? new JsonArray();
129
+ if (!required.Any(x => x?.GetValue<string>() == "expected_document_id")) required.Add("expected_document_id");
130
+ schema["required"] = required;
131
+ }
132
+ return schema;
133
+ }
109
134
  }
110
135
  }
@@ -0,0 +1,64 @@
1
+ using System.Text.Json;
2
+ using Autodesk.Revit.DB;
3
+
4
+ namespace RevitBridge.Tools;
5
+
6
+ /// <summary>Opaque identity of one open native document in this loaded bridge session.</summary>
7
+ internal static class DocumentGuard
8
+ {
9
+ private sealed record Identity(Document Document, string Value);
10
+ private static readonly string Generation = Guid.NewGuid().ToString("N");
11
+ private static readonly List<Identity> Identities = new();
12
+
13
+ public static string GetIdentity(Document document)
14
+ {
15
+ if (!document.IsValidObject)
16
+ throw new ArgumentException("The target document is closed or invalid. Read get_model_overview again.");
17
+ // Revit can supply multiple managed wrappers for the same native document.
18
+ // Its documented Equals semantics identify that native document; reference identity cannot.
19
+ // All accesses happen on the Revit API thread. Pruning avoids retaining closed documents.
20
+ Identities.RemoveAll(entry => !entry.Document.IsValidObject);
21
+ var existing = Identities.FirstOrDefault(entry => entry.Document.Equals(document));
22
+ if (existing != null) return existing.Value;
23
+ var created = new Identity(document, Generation + ":" + Guid.NewGuid().ToString("N"));
24
+ Identities.Add(created);
25
+ return created.Value;
26
+ }
27
+
28
+ public static bool AlwaysRequiresIdentity(string toolName)
29
+ => toolName is "set_parameters" or "execute_csharp" or "export_documents" or "open_view";
30
+
31
+ /// <summary>Must run on the Revit API thread immediately before the tool action.</summary>
32
+ public static void CheckForTool(JsonElement args, Document document, string toolName)
33
+ {
34
+ bool required = AlwaysRequiresIdentity(toolName)
35
+ || (toolName == "manage_selection"
36
+ && (!string.Equals((JsonArgs.GetString(args, "action") ?? "get").Trim(), "get", StringComparison.OrdinalIgnoreCase)
37
+ || JsonArgs.GetBool(args, "isolate_in_view", false)));
38
+ if (required && !args.TryGetProperty("expected_document_id", out _))
39
+ throw new ArgumentException("expected_document_id is required for this operation. Read project.documentId from get_model_overview for the intended open document and pass it unchanged. A title alone cannot identify a document. No action was performed.");
40
+ CheckExpectedDocument(args, document);
41
+ }
42
+
43
+ public static void CheckExpectedDocument(JsonElement args, Document document)
44
+ {
45
+ if (args.TryGetProperty("expected_document_id", out var id))
46
+ {
47
+ if (id.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(id.GetString()))
48
+ throw new ArgumentException("expected_document_id must be a non-empty identity from get_model_overview. No action was performed.");
49
+ if (!string.Equals(id.GetString(), GetIdentity(document), StringComparison.Ordinal))
50
+ throw new ArgumentException($"The active document '{document.Title}' is not the exact open document this call expected, or that identity is stale after reopening/restarting. No action was performed. Activate the intended document and read get_model_overview again.");
51
+ }
52
+
53
+ // Retained as an additional human-readable check, never as a substitute for identity.
54
+ string? expected = JsonArgs.GetString(args, "expected_document");
55
+ if (string.IsNullOrWhiteSpace(expected)) return;
56
+ static string Normalize(string title)
57
+ {
58
+ string value = title.Trim();
59
+ return value.EndsWith(".rvt", StringComparison.OrdinalIgnoreCase) ? value[..^4] : value;
60
+ }
61
+ if (!string.Equals(Normalize(expected), Normalize(document.Title), StringComparison.OrdinalIgnoreCase))
62
+ throw new ArgumentException($"Active document is '{document.Title}' but this call expected title '{expected}'. No action was performed. Read get_model_overview for the intended document.");
63
+ }
64
+ }
@@ -59,7 +59,7 @@ namespace RevitBridge.Tools
59
59
 
60
60
  public string Name => "execute_csharp";
61
61
  public string Label => "Execute C#";
62
- public string Description => "Compile and run a C# script on the Revit API thread against the open model — the escape hatch for anything without a dedicated tool: element creation, deletion, geometry edits (move/copy/rotate), views, sheets, schedules, tagging, families, links, worksets. Globals: doc (Document), uidoc (UIDocument), uiapp (UIApplication), and Dump(value) to record intermediate values into the result's dumps[]. Default imports: System, System.Linq, System.Collections.Generic, Autodesk.Revit.DB, Autodesk.Revit.UI — add using directives at the top for sub-namespaces (e.g. using Autodesk.Revit.DB.Architecture;). The entire run is wrapped in ONE transaction named 'execute_csharp': committed on success, rolled back on any exception, so a failed script never changes the model (do not open your own Transaction; sub-transactions are fine). The script's final expression or return statement becomes returnValue; return primitives, strings, or anonymous objects/lists — Revit API values are projected to safe shapes (Element -> {id,name,category,typeName,levelId}, ElementId -> number, XYZ -> {x,y,z}, Parameter -> {name,value,displayValue}; other API objects become strings) with depth and item caps, so never rely on raw API objects round-tripping. Lengths are in internal units (decimal feet) — convert with UnitUtils. Prefer collector-level filtering (FilteredElementCollector .OfCategory/.OfClass/.WhereElementIsNotElementType) and bounded loops: the call budget is 120s and Revit cannot be interrupted mid-script. Scripts must be fully synchronous — await/async is rejected at compile time, and blocking on tasks (Task.Result/.Wait()) can freeze Revit. Modal dialogs raised while running are auto-dismissed and reported in suppressedDialogs — unrecognized dialogs are answered dismissively (Cancel/Close/No) rather than confirmed, so an operation that raises a confirmation prompt may be cancelled; check suppressedDialogs when a result looks incomplete. Verify unfamiliar signatures with search_api_docs first.";
62
+ public string Description => "Compile and run a C# script on the Revit API thread against the open model — the escape hatch for anything without a dedicated tool: element creation, deletion, geometry edits (move/copy/rotate), views, sheets, schedules, tagging, families, links, worksets. Globals: doc (Document), uidoc (UIDocument), uiapp (UIApplication), and Dump(value) to record intermediate values into the result's dumps[]. Default imports: System, System.Linq, System.Collections.Generic, Autodesk.Revit.DB, Autodesk.Revit.UI — add using directives at the top for sub-namespaces (e.g. using Autodesk.Revit.DB.Architecture;). The entire run is wrapped in ONE transaction named 'execute_csharp': committed on success; on failure rollback is attempted and its confirmed status is reported (do not open your own Transaction; sub-transactions are fine). The script's final expression or return statement becomes returnValue; return primitives, strings, or anonymous objects/lists — Revit API values are projected to safe shapes (Element -> {id,name,category,typeName,levelId}, ElementId -> number, XYZ -> {x,y,z}, Parameter -> {name,value,displayValue}; other API objects become strings) with depth and item caps, so never rely on raw API objects round-tripping. Lengths are in internal units (decimal feet) — convert with UnitUtils. Prefer collector-level filtering (FilteredElementCollector .OfCategory/.OfClass/.WhereElementIsNotElementType) and bounded loops: the call budget is 120s and Revit cannot be interrupted mid-script. Scripts must be fully synchronous — await/async is rejected at compile time, and blocking on tasks (Task.Result/.Wait()) can freeze Revit. Modal dialogs raised while running are auto-dismissed and reported in suppressedDialogs — unrecognized dialogs are answered dismissively (Cancel/Close/No) rather than confirmed, so an operation that raises a confirmation prompt may be cancelled; check suppressedDialogs when a result looks incomplete. Verify unfamiliar signatures with search_api_docs first.";
63
63
  public bool Write => true;
64
64
 
65
65
  public object ParametersSchema => new
@@ -119,9 +119,9 @@ namespace RevitBridge.Tools
119
119
 
120
120
  using var dialogGuard = new DialogGuard(uiapp);
121
121
  using var transaction = new Transaction(doc, "execute_csharp");
122
- var failureGuard = FailureGuard.Attach(transaction);
123
- if (transaction.Start() != TransactionStatus.Started)
124
- throw new InvalidOperationException("Unable to start the execute_csharp transaction.");
122
+ if (transaction.Start() != TransactionStatus.Started)
123
+ throw new InvalidOperationException("Unable to start the execute_csharp transaction.");
124
+ var failureGuard = FailureGuard.Attach(transaction);
125
125
 
126
126
  object? returnValue;
127
127
  string? projectionError = null;
@@ -147,23 +147,24 @@ namespace RevitBridge.Tools
147
147
  }
148
148
  catch (Exception ex)
149
149
  {
150
- try
151
- {
152
- if (transaction.GetStatus() == TransactionStatus.Started)
153
- transaction.RollBack();
154
- }
155
- catch
156
- {
157
- // Reporting the script failure outranks a rollback hiccup.
158
- }
159
- throw new InvalidOperationException(FormatRuntimeError(ex, dialogGuard.Suppressed));
160
- }
161
-
162
- if (transaction.Commit() != TransactionStatus.Committed)
163
- throw new InvalidOperationException(
164
- "Revit rolled back the execute_csharp transaction during commit (failure processing rejected the changes); no model changes were saved."
165
- + failureGuard.DescribeErrors()
166
- + DescribeDialogs(dialogGuard.Suppressed));
150
+ throw new InvalidOperationException(
151
+ FormatRuntimeError(ex, dialogGuard.Suppressed) + " " + FailureGuard.RollBackAndDescribe(transaction), ex);
152
+ }
153
+
154
+ try
155
+ {
156
+ var status = transaction.Commit();
157
+ var finalStatus = transaction.GetStatus();
158
+ if (status != TransactionStatus.Committed || finalStatus != TransactionStatus.Committed)
159
+ throw new InvalidOperationException($"The execute_csharp commit returned {status}; current transaction status is {finalStatus}.");
160
+ }
161
+ catch (Exception ex)
162
+ {
163
+ throw new InvalidOperationException(
164
+ $"{ex.Message} {FailureGuard.RollBackAndDescribe(transaction)}"
165
+ + failureGuard.DescribeErrors()
166
+ + DescribeDialogs(dialogGuard.Suppressed), ex);
167
+ }
167
168
 
168
169
  stopwatch.Stop();
169
170
 
@@ -240,8 +241,7 @@ namespace RevitBridge.Tools
240
241
  string line = TryGetScriptLine(ex) is { } scriptLine ? $" at script line {scriptLine}" : string.Empty;
241
242
  string inner = ex.InnerException is { } innerEx ? $" Inner: {innerEx.GetType().Name}: {innerEx.Message}" : string.Empty;
242
243
  return $"C# script threw {ex.GetType().Name}{line}: {ex.Message}.{inner}"
243
- + " The execute_csharp transaction was rolled back; no model changes were saved."
244
- + DescribeDialogs(suppressedDialogs);
244
+ + DescribeDialogs(suppressedDialogs);
245
245
  }
246
246
 
247
247
  private static string DescribeDialogs(IReadOnlyList<string> suppressed)
@@ -1,4 +1,6 @@
1
- using System.IO;
1
+ using System.IO;
2
+ using System.Security.Cryptography;
3
+ using System.Text;
2
4
  using System.Text.Json;
3
5
  using Autodesk.Revit.DB;
4
6
 
@@ -19,7 +21,7 @@ namespace RevitBridge.Tools
19
21
 
20
22
  public string Name => "export_documents";
21
23
  public string Label => "Export Documents";
22
- public string Description => "Export documents from the open Revit model. format 'pdf'/'dwg'/'png' export the given sheet/view ids to files: pdf combines everything into one file by default (combine=false writes one PDF per sheet/view, named by Revit's naming rule); png renders 2048 px wide; ifc exports the whole model, or just what one given view shows. Files sort themselves per model: with output_dir omitted they land in Documents\\pi-revit\\Models\\<model title>\\exports, derived from the document being exported (pass output_dir only for a different explicit target); file_name_prefix sets the base file name (default: the document title; Revit appends view/sheet suffixes for multi-file exports). Returns the produced file paths with sizes. Find sheet/view ids with get_elements (category 'Sheets' or 'Views') first.";
24
+ public string Description => "Export documents from the open Revit model. format 'pdf'/'dwg'/'png' export the given sheet/view ids to files: pdf combines everything into one file by default (combine=false writes one PDF per sheet/view, named by Revit's naming rule); png renders 2048 px wide; ifc exports the whole model, or just what one given view shows. Files sort themselves per model: with output_dir omitted they land in Documents\\pi-revit\\Models\\<model title>--<identity hash>\\exports, derived from the document being exported (pass output_dir only for a different explicit target); file_name_prefix sets the base file name (default: the document title; Revit appends view/sheet suffixes for multi-file exports). Returns the produced file paths with sizes. Find sheet/view ids with get_elements (category 'Sheets' or 'Views') first.";
23
25
  public bool Write => true;
24
26
  public string Tier => "advanced";
25
27
 
@@ -43,7 +45,7 @@ namespace RevitBridge.Tools
43
45
  output_dir = new
44
46
  {
45
47
  type = "string",
46
- description = "Output directory, created if missing. Default: Documents\\pi-revit\\Models\\<model title>\\exports — files sort under the exported model automatically; omit unless the user names a different target.",
48
+ description = "Output directory, created if missing. Default: Documents\\pi-revit\\Models\\<model title>--<identity hash>\\exports — files sort under the exported model automatically; omit unless the user names a different target.",
47
49
  },
48
50
  file_name_prefix = new
49
51
  {
@@ -86,17 +88,37 @@ namespace RevitBridge.Tools
86
88
  // recent (a shared output_dir, another tool writing alongside) does not.
87
89
  var before = SnapshotWriteTimes(outputDir);
88
90
 
89
- switch (format)
90
- {
91
- case "pdf": ExportPdf(doc, views, outputDir, baseName, combine); break;
92
- case "dwg": ExportDwg(doc, views, outputDir, baseName); break;
93
- case "png": ExportPng(doc, views, outputDir, baseName); break;
94
- default: ExportIfc(doc, views, outputDir, baseName); break;
95
- }
96
-
97
- var files = Directory.GetFiles(outputDir)
98
- .Where(path => !before.TryGetValue(path, out DateTime writtenBefore) || SafeWriteTime(path) != writtenBefore)
99
- .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
91
+ IReadOnlyList<string> commitWarnings = Array.Empty<string>();
92
+ try
93
+ {
94
+ switch (format)
95
+ {
96
+ case "pdf": ExportPdf(doc, views, outputDir, baseName, combine); break;
97
+ case "dwg": ExportDwg(doc, views, outputDir, baseName); break;
98
+ case "png": ExportPng(doc, views, outputDir, baseName); break;
99
+ default: commitWarnings = ExportIfc(doc, views, outputDir, baseName); break;
100
+ }
101
+ }
102
+ catch (Exception ex)
103
+ {
104
+ // A failed export may already have written some files. A Revit
105
+ // transaction rollback cannot undo those filesystem changes.
106
+ string observed;
107
+ try
108
+ {
109
+ var changed = FindChangedFiles(outputDir, before).ToList();
110
+ observed = changed.Count == 0
111
+ ? "No changed files were observed."
112
+ : $"Observed {changed.Count} new or changed file(s): {string.Join(", ", changed.Take(20))}{(changed.Count > 20 ? " (additional files omitted)" : string.Empty)}.";
113
+ }
114
+ catch (Exception scanError)
115
+ {
116
+ observed = $"Could not inspect remaining files: {scanError.Message}.";
117
+ }
118
+ throw new InvalidOperationException($"{ex.Message} The export failed; files in '{outputDir}' may be incomplete and were not removed. {observed}", ex);
119
+ }
120
+
121
+ var files = FindChangedFiles(outputDir, before)
100
122
  .Select(path => new Dictionary<string, object?>
101
123
  {
102
124
  ["path"] = path,
@@ -107,15 +129,23 @@ namespace RevitBridge.Tools
107
129
  throw new InvalidOperationException($"The {format} export finished but produced no files in {outputDir}.");
108
130
 
109
131
  string sample = string.Join(", ", files.Take(3).Select(file => Path.GetFileName((string)file["path"]!)));
110
- string compact = $"Exported {files.Count} {format.ToUpperInvariant()} file(s) to {outputDir}: {sample}{(files.Count > 3 ? $" (+{files.Count - 3} more)" : string.Empty)}.";
132
+ string compact = $"Exported {files.Count} {format.ToUpperInvariant()} file(s) to {outputDir}: {sample}{(files.Count > 3 ? $" (+{files.Count - 3} more)" : string.Empty)}.";
133
+ if (commitWarnings.Count > 0)
134
+ compact += $" {commitWarnings.Count} Revit warning(s) auto-dismissed (see commitWarnings).";
111
135
  return new ToolOutput(new
112
136
  {
113
137
  format,
114
138
  outputDir,
115
139
  fileCount = files.Count,
116
- files,
117
- }, compact);
118
- }
140
+ files,
141
+ commitWarnings,
142
+ }, compact);
143
+ }
144
+
145
+ private static IEnumerable<string> FindChangedFiles(string directory, IReadOnlyDictionary<string, DateTime> before)
146
+ => Directory.GetFiles(directory)
147
+ .Where(path => !before.TryGetValue(path, out DateTime writtenBefore) || SafeWriteTime(path) != writtenBefore)
148
+ .OrderBy(path => path, StringComparer.OrdinalIgnoreCase);
119
149
 
120
150
  /// <summary>path -> last write time for the files already in the output directory.
121
151
  /// A file whose time cannot be read is left out, so the export reports it if it
@@ -197,27 +227,29 @@ namespace RevitBridge.Tools
197
227
  /// <summary>The IFC exporter writes IFC GUID parameters onto exported elements,
198
228
  /// so the Revit API requires a transaction around it — owned here, matching
199
229
  /// Revit's own behavior of committing those GUIDs on export.</summary>
200
- private static void ExportIfc(Document doc, List<View> views, string outputDir, string baseName)
230
+ private static IReadOnlyList<string> ExportIfc(Document doc, List<View> views, string outputDir, string baseName)
201
231
  {
202
232
  var options = new IFCExportOptions();
203
233
  if (views.Count == 1)
204
234
  options.FilterViewId = views[0].Id;
205
235
 
206
236
  using var transaction = new Transaction(doc, "export_documents: ifc");
207
- if (transaction.Start() != TransactionStatus.Started)
208
- throw new InvalidOperationException("Unable to start the IFC export transaction.");
237
+ if (transaction.Start() != TransactionStatus.Started)
238
+ throw new InvalidOperationException("Unable to start the IFC export transaction.");
239
+ var failureGuard = FailureGuard.Attach(transaction);
209
240
  try
210
241
  {
211
242
  if (!Run(() => doc.Export(outputDir, baseName, options), "IFC"))
212
243
  throw new InvalidOperationException("Revit reported a failed IFC export.");
213
- if (transaction.Commit() != TransactionStatus.Committed)
214
- throw new InvalidOperationException("The IFC export transaction failed to commit.");
215
- }
216
- catch
217
- {
218
- if (transaction.GetStatus() == TransactionStatus.Started)
219
- transaction.RollBack();
220
- throw;
244
+ var status = transaction.Commit();
245
+ var finalStatus = transaction.GetStatus();
246
+ if (status != TransactionStatus.Committed || finalStatus != TransactionStatus.Committed)
247
+ throw new InvalidOperationException($"The IFC export commit returned {status}; current transaction status is {finalStatus}." + failureGuard.DescribeErrors());
248
+ return failureGuard.Warnings;
249
+ }
250
+ catch (Exception ex)
251
+ {
252
+ throw new InvalidOperationException($"{ex.Message} {FailureGuard.RollBackAndDescribe(transaction)}", ex);
221
253
  }
222
254
  }
223
255
 
@@ -278,27 +310,72 @@ namespace RevitBridge.Tools
278
310
  }
279
311
 
280
312
  /// <summary>Per-model folder under the pi-revit workspace:
281
- /// Documents\pi-revit\Models\&lt;model title&gt;. The exporting tool is the one
282
- /// component that knows with certainty which model a file belongs to, so the
283
- /// sorting is automatic — never an agent or user decision. model.txt records
284
- /// the model's identity so same-titled models stay distinguishable.</summary>
285
- private static string ModelFolder(Document doc)
313
+ /// Documents\pi-revit\Models\&lt;model title&gt;--&lt;identity hash&gt;.
314
+ /// Existing title-only folders are left untouched; identity markers are
315
+ /// diagnostic records, not the mechanism that separates exports.</summary>
316
+ private static string ModelFolder(Document doc)
286
317
  {
287
318
  string workspace = Path.Combine(
288
319
  Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "pi-revit");
289
- string title = SanitizeFileName(string.IsNullOrWhiteSpace(doc.Title) ? "untitled" : doc.Title);
290
- string folder = Path.Combine(workspace, "Models", title);
291
- Directory.CreateDirectory(folder);
292
- TryRecordModelIdentity(folder, doc);
293
- return folder;
294
- }
295
-
296
- private static void TryRecordModelIdentity(string folder, Document doc)
320
+ string identity = GetModelIdentity(doc);
321
+ string folder = Path.Combine(workspace, "Models", GetModelFolderName(doc.Title, identity));
322
+ Directory.CreateDirectory(folder);
323
+ TryRecordModelIdentity(folder, doc, identity);
324
+ return folder;
325
+ }
326
+
327
+ internal static string GetModelFolderName(string title, string identity)
328
+ {
329
+ string cleanTitle = SanitizeFileName(string.IsNullOrWhiteSpace(title) ? "untitled" : title);
330
+ // Bound the default path component, leaving room for the identity and exports.
331
+ if (cleanTitle.Length > 80)
332
+ cleanTitle = cleanTitle[..80];
333
+ string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant()[..24];
334
+ return $"{cleanTitle}--{hash}";
335
+ }
336
+
337
+ // Revit can return different managed wrappers for one open native document.
338
+ // Document.Equals/GetHashCode identify that open document, unlike reference
339
+ // equality in ConditionalWeakTable. Closed document entries are pruned below.
340
+ // Tool execution and this dictionary are confined to the Revit API thread.
341
+ private static readonly Dictionary<Document, string> OpenDocumentIdentities = new();
342
+
343
+ internal static string GetModelIdentity(Document doc)
344
+ {
345
+ try
346
+ {
347
+ if (doc.IsModelInCloud)
348
+ {
349
+ var path = doc.GetCloudModelPath();
350
+ return $"cloud:{path.Region.ToUpperInvariant()}:{path.GetProjectGUID():D}:{path.GetModelGUID():D}";
351
+ }
352
+ string pathName = doc.PathName;
353
+ if (!string.IsNullOrWhiteSpace(pathName))
354
+ {
355
+ // Revit file paths are Windows paths; canonicalize separators,
356
+ // relative segments and case. Revit Server identities use RSN URLs.
357
+ if (pathName.StartsWith("RSN://", StringComparison.OrdinalIgnoreCase))
358
+ return "server:" + pathName.Replace('\\', '/').TrimEnd('/').ToUpperInvariant();
359
+ return "file:" + Path.GetFullPath(pathName).Replace('/', '\\').ToUpperInvariant();
360
+ }
361
+ }
362
+ catch
363
+ {
364
+ // An unavailable identity must not collapse unrelated documents into
365
+ // a shared title-only directory. Use the same safe fallback as unsaved docs.
366
+ }
367
+ foreach (var closed in OpenDocumentIdentities.Keys.Where(key => !key.IsValidObject).ToList())
368
+ OpenDocumentIdentities.Remove(closed);
369
+ if (!OpenDocumentIdentities.TryGetValue(doc, out string? identity))
370
+ OpenDocumentIdentities[doc] = identity = $"session:{Guid.NewGuid():N}";
371
+ return identity;
372
+ }
373
+
374
+ private static void TryRecordModelIdentity(string folder, Document doc, string identity)
297
375
  {
298
376
  try
299
377
  {
300
- string guid = doc.ProjectInformation?.UniqueId ?? string.Empty;
301
- string line = $"{guid}\t{doc.PathName}";
378
+ string line = $"{identity}\t{doc.PathName}";
302
379
  string marker = Path.Combine(folder, "model.txt");
303
380
  if (!File.Exists(marker) || !File.ReadAllLines(marker).Contains(line))
304
381
  File.AppendAllLines(marker, new[] { line });
@@ -16,15 +16,36 @@ namespace RevitBridge.Tools
16
16
  public List<string> Warnings { get; } = new();
17
17
  public List<string> Errors { get; } = new();
18
18
 
19
- public static FailureGuard Attach(Transaction transaction)
20
- {
21
- var guard = new FailureGuard();
19
+ public static FailureGuard Attach(Transaction transaction)
20
+ {
21
+ if (transaction.GetStatus() != TransactionStatus.Started)
22
+ throw new InvalidOperationException("FailureGuard must be attached after the transaction starts; Start resets failure handling options.");
23
+ var guard = new FailureGuard();
22
24
  var options = transaction.GetFailureHandlingOptions();
23
25
  options.SetFailuresPreprocessor(guard);
24
26
  options.SetClearAfterRollback(true);
25
27
  transaction.SetFailureHandlingOptions(options);
26
- return guard;
27
- }
28
+ return guard;
29
+ }
30
+
31
+ /// <summary>Best-effort cleanup that preserves the original failure and never
32
+ /// claims a rollback unless Revit confirms its final transaction status.</summary>
33
+ public static string RollBackAndDescribe(Transaction transaction)
34
+ {
35
+ try
36
+ {
37
+ if (transaction.GetStatus() == TransactionStatus.Started)
38
+ transaction.RollBack();
39
+ var status = transaction.GetStatus();
40
+ return status == TransactionStatus.RolledBack
41
+ ? "The transaction was rolled back; no changes from this transaction were saved."
42
+ : $"Transaction status is {status}; rollback is not confirmed. Inspect Revit before retrying.";
43
+ }
44
+ catch (Exception ex)
45
+ {
46
+ return $"Rollback could not be confirmed ({ex.GetType().Name}: {ex.Message}). Inspect Revit before retrying.";
47
+ }
48
+ }
28
49
 
29
50
  public FailureProcessingResult PreprocessFailures(FailuresAccessor accessor)
30
51
  {
@@ -36,7 +36,7 @@ namespace RevitBridge.Tools
36
36
  properties = new
37
37
  {
38
38
  parameters = new { type = "boolean", description = "Instance parameter values. Default true." },
39
- type_parameters = new { type = "boolean", description = "Also include the element type's parameters, marked isType=true. Default false." },
39
+ type_parameters = new { type = "boolean", description = "Include the element type's parameters, marked isType=true, independently of the instance parameters flag. Default false." },
40
40
  location = new { type = "boolean", description = "Location point or curve (coordinates in internal feet). Default false." },
41
41
  bounding_box = new { type = "boolean", description = "Model bounding box min/max (internal feet). Default false." },
42
42
  materials = new { type = "boolean", description = "Material ids/names with area/volume (internal units). Default false." },
@@ -70,8 +70,9 @@ namespace RevitBridge.Tools
70
70
  JsonElement include = args.TryGetProperty("include", out var includeElement) && includeElement.ValueKind == JsonValueKind.Object
71
71
  ? includeElement
72
72
  : default;
73
- bool withParameters = JsonArgs.GetBool(include, "parameters", true);
74
- bool withTypeParameters = JsonArgs.GetBool(include, "type_parameters", false);
73
+ bool withParameters = JsonArgs.GetBool(include, "parameters", true);
74
+ bool withTypeParameters = JsonArgs.GetBool(include, "type_parameters", false);
75
+ bool withAnyParameters = withParameters || withTypeParameters;
75
76
  bool withLocation = JsonArgs.GetBool(include, "location", false);
76
77
  bool withBoundingBox = JsonArgs.GetBool(include, "bounding_box", false);
77
78
  bool withMaterials = JsonArgs.GetBool(include, "materials", false);
@@ -102,10 +103,11 @@ namespace RevitBridge.Tools
102
103
 
103
104
  int parameterCount = 0;
104
105
  int parameterTotal = 0;
105
- if (withParameters)
106
- {
107
- var parameters = new List<Dictionary<string, object?>>();
108
- parameterTotal += AppendParameters(doc, element, nameFilter, isType: false, parameters);
106
+ if (withAnyParameters)
107
+ {
108
+ var parameters = new List<Dictionary<string, object?>>();
109
+ if (withParameters)
110
+ parameterTotal += AppendParameters(doc, element, nameFilter, isType: false, parameters);
109
111
  if (withTypeParameters && elementType != null)
110
112
  parameterTotal += AppendParameters(doc, elementType, nameFilter, isType: true, parameters);
111
113
  dto["parameters"] = parameters;
@@ -122,7 +124,7 @@ namespace RevitBridge.Tools
122
124
  elements.Add(dto);
123
125
  // With a name filter active, "0 params" is ambiguous (none matched vs none
124
126
  // exist): report matched-of-total so a localization miss is visible.
125
- string paramSummary = !withParameters ? string.Empty
127
+ string paramSummary = !withAnyParameters ? string.Empty
126
128
  : nameFilter != null ? $", {parameterCount} of {parameterTotal} params matched parameter_names"
127
129
  : $", {parameterCount} params";
128
130
  compactParts.Add($"'{element.Name}' (id {id}, {element.Category?.Name ?? "no category"}{paramSummary})");
@@ -21,7 +21,7 @@ namespace RevitBridge.Tools
21
21
 
22
22
  public string Name => "get_elements";
23
23
  public string Label => "Get Elements";
24
- public string Description => "Query Revit elements: scope by category (display name like 'Walls' or enum name like 'OST_Walls'), element class, level, type id, or active view; filter by parameter rules; paginate with offset/limit or just count with count_only. Returns identity fields only (id, name, category, typeName, levelId) — read parameter values with get_element_details. Most filter rules evaluate inside Revit's collector; 'regex' rules (and rules on parameters that cannot be quick-filtered) run as a slower post-collector scan. Prefer combining display-name filter rules with a category or of_class scope: the same display name (e.g. 'Width') can resolve to different parameters per category, which forces the slower per-element scan in unscoped queries. Numeric rule values are interpreted in the document's display units for that parameter unless 'unit' is given.";
24
+ public string Description => "Query Revit elements: scope by category (display name like 'Walls' or enum name like 'OST_Walls'), element class, level, type id, or active view; filter by parameter rules; paginate with offset/limit or just count with count_only. Returns identity fields only (id, name, category, typeName, levelId) — read parameter values with get_element_details. Rules with an explicit BuiltInParameter or shared GUID can evaluate inside Revit's collector; display-name rules, regex rules, and unsupported quick filters run as a per-element scan. A display name can identify different parameters even within one category or class. Scope queries to reduce the scan, or use an explicit parameter identity when appropriate. Numeric rule values are interpreted in the document's display units for that parameter unless 'unit' is given.";
25
25
 
26
26
  public object ParametersSchema => new
27
27
  {
@@ -292,8 +292,9 @@ namespace RevitBridge.Tools
292
292
  if (rules.Count == 0)
293
293
  return (null, null, Array.Empty<string>());
294
294
 
295
- // Probe a few in-scope elements so display-name parameters resolve to ids
296
- // and value typing / unit conversion can use the parameter's storage + spec.
295
+ // Probe a few in-scope elements for missing-name diagnostics and to find
296
+ // storage/spec exemplars for explicit parameter identities. A sample must
297
+ // never turn a display name into an assumed globally uniform identity.
297
298
  var probes = new List<Element>(ProbeSize);
298
299
  foreach (Element element in createBaseCollector())
299
300
  {
@@ -325,30 +326,19 @@ namespace RevitBridge.Tools
325
326
  }
326
327
  }
327
328
 
328
- // Display-name promotion is only trustworthy inside one category/class: the
329
- // probe sees just the first ProbeSize elements in collector order, so in an
330
- // unscoped query a unanimous sample can still hide other categories further
331
- // on whose same-named parameter has a different id — and a pinned quick rule
332
- // would silently drop their matches.
333
- bool scoped = !string.IsNullOrWhiteSpace(JsonArgs.GetString(args, "category"))
334
- || !string.IsNullOrWhiteSpace(JsonArgs.GetString(args, "of_class"));
335
-
336
- foreach (var rule in rules)
337
- {
338
- if (rule.Op is RuleOp.Regex or RuleOp.IsEmpty or RuleOp.IsNotEmpty)
339
- continue; // always post-scan (missing-parameter semantics)
340
- var found = probes.Select(probe => FindParameter(probe, rule)).Where(parameter => parameter != null).ToList();
341
- if (found.Count == 0)
342
- continue;
343
- // BuiltInParameter/guid rules address one global parameter id. A plain
344
- // display name can resolve to DIFFERENT ids per category or family
345
- // (e.g. 'Width' -> DOOR_WIDTH vs WINDOW_WIDTH). Promote a display-name
346
- // rule only when the query is scoped AND all probed elements agree on
347
- // the id; otherwise it stays on the (per-element, correct) post-scan path.
348
- bool oneGlobalId = rule.BuiltIn != null || rule.SharedGuid != null
349
- || (scoped && found.All(parameter => parameter!.Id == found[0]!.Id));
350
- if (oneGlobalId)
351
- rule.QuickRule = TryBuildQuickRule(doc, rule, found[0]!);
329
+ foreach (var rule in rules)
330
+ {
331
+ if (rule.Op is RuleOp.Regex or RuleOp.IsEmpty or RuleOp.IsNotEmpty)
332
+ continue; // always post-scan (missing-parameter semantics)
333
+ // Category/class scope and unanimous samples do not establish a
334
+ // uniform parameter identity across later families. Resolve display
335
+ // names per element; only explicit identities may use a quick rule.
336
+ if (rule.BuiltIn is null && rule.SharedGuid is null)
337
+ continue;
338
+ var found = probes.Select(probe => FindParameter(probe, rule)).Where(parameter => parameter != null).ToList();
339
+ if (found.Count == 0)
340
+ continue;
341
+ rule.QuickRule = TryBuildQuickRule(doc, rule, found[0]!);
352
342
  }
353
343
 
354
344
  var quickRules = rules.Where(rule => rule.QuickRule != null).ToList();
@@ -45,7 +45,8 @@ namespace RevitBridge.Tools
45
45
  string? lengthUnit = DisplayUnitName(units, SpecTypeId.Length);
46
46
  var project = new Dictionary<string, object?>
47
47
  {
48
- ["title"] = doc.Title,
48
+ ["title"] = doc.Title,
49
+ ["documentId"] = DocumentGuard.GetIdentity(doc),
49
50
  ["name"] = info?.Name,
50
51
  ["number"] = info?.Number,
51
52
  ["clientName"] = info?.ClientName,