pi-revit 0.3.0 → 0.3.1

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,5 +1,5 @@
1
- using System.Text.Json;
2
- using System.Text.Json.Nodes;
1
+ using System.Text.Json;
2
+ using System.Text.Json.Nodes;
3
3
  using Autodesk.Revit.DB;
4
4
  using Autodesk.Revit.UI;
5
5
  using RevitBridge.Tools;
@@ -15,9 +15,9 @@ namespace RevitBridge
15
15
  internal sealed record ToolContext(Document? Document, UIApplication? UIApplication);
16
16
 
17
17
  /// <summary>
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.
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.
21
21
  /// </summary>
22
22
  internal sealed record ToolOutput(object? Payload, string? CompactText = null);
23
23
 
@@ -100,36 +100,36 @@ namespace RevitBridge
100
100
  description = tool.Description,
101
101
  category = tool.Write ? "write" : "read",
102
102
  tier = tool.Tier,
103
- parameters = DescribeParameters(tool),
103
+ parameters = DescribeParameters(tool),
104
104
  executionMode = "sequential",
105
105
  write = tool.Write,
106
106
  requiresDocument = tool.RequiresDocument,
107
107
  promptSnippet = tool.PromptSnippet,
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
- }
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
+ }
134
134
  }
135
135
  }
@@ -1,64 +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
- }
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
+ }
@@ -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
- if (transaction.Start() != TransactionStatus.Started)
123
- throw new InvalidOperationException("Unable to start the execute_csharp transaction.");
124
- var failureGuard = FailureGuard.Attach(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,24 +147,24 @@ namespace RevitBridge.Tools
147
147
  }
148
148
  catch (Exception ex)
149
149
  {
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
- }
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
+ }
168
168
 
169
169
  stopwatch.Stop();
170
170
 
@@ -241,7 +241,7 @@ namespace RevitBridge.Tools
241
241
  string line = TryGetScriptLine(ex) is { } scriptLine ? $" at script line {scriptLine}" : string.Empty;
242
242
  string inner = ex.InnerException is { } innerEx ? $" Inner: {innerEx.GetType().Name}: {innerEx.Message}" : string.Empty;
243
243
  return $"C# script threw {ex.GetType().Name}{line}: {ex.Message}.{inner}"
244
- + DescribeDialogs(suppressedDialogs);
244
+ + DescribeDialogs(suppressedDialogs);
245
245
  }
246
246
 
247
247
  private static string DescribeDialogs(IReadOnlyList<string> suppressed)
@@ -1,6 +1,6 @@
1
- using System.IO;
2
- using System.Security.Cryptography;
3
- using System.Text;
1
+ using System.IO;
2
+ using System.Security.Cryptography;
3
+ using System.Text;
4
4
  using System.Text.Json;
5
5
  using Autodesk.Revit.DB;
6
6
 
@@ -88,37 +88,37 @@ namespace RevitBridge.Tools
88
88
  // recent (a shared output_dir, another tool writing alongside) does not.
89
89
  var before = SnapshotWriteTimes(outputDir);
90
90
 
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)
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)
122
122
  .Select(path => new Dictionary<string, object?>
123
123
  {
124
124
  ["path"] = path,
@@ -129,23 +129,23 @@ namespace RevitBridge.Tools
129
129
  throw new InvalidOperationException($"The {format} export finished but produced no files in {outputDir}.");
130
130
 
131
131
  string sample = string.Join(", ", files.Take(3).Select(file => Path.GetFileName((string)file["path"]!)));
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).";
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).";
135
135
  return new ToolOutput(new
136
136
  {
137
137
  format,
138
138
  outputDir,
139
139
  fileCount = files.Count,
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);
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);
149
149
 
150
150
  /// <summary>path -> last write time for the files already in the output directory.
151
151
  /// A file whose time cannot be read is left out, so the export reports it if it
@@ -227,29 +227,29 @@ namespace RevitBridge.Tools
227
227
  /// <summary>The IFC exporter writes IFC GUID parameters onto exported elements,
228
228
  /// so the Revit API requires a transaction around it — owned here, matching
229
229
  /// Revit's own behavior of committing those GUIDs on export.</summary>
230
- private static IReadOnlyList<string> 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)
231
231
  {
232
232
  var options = new IFCExportOptions();
233
233
  if (views.Count == 1)
234
234
  options.FilterViewId = views[0].Id;
235
235
 
236
236
  using var transaction = new Transaction(doc, "export_documents: ifc");
237
- if (transaction.Start() != TransactionStatus.Started)
238
- throw new InvalidOperationException("Unable to start the IFC export transaction.");
239
- var failureGuard = FailureGuard.Attach(transaction);
237
+ if (transaction.Start() != TransactionStatus.Started)
238
+ throw new InvalidOperationException("Unable to start the IFC export transaction.");
239
+ var failureGuard = FailureGuard.Attach(transaction);
240
240
  try
241
241
  {
242
242
  if (!Run(() => doc.Export(outputDir, baseName, options), "IFC"))
243
243
  throw new InvalidOperationException("Revit reported a failed IFC export.");
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);
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);
253
253
  }
254
254
  }
255
255
 
@@ -310,72 +310,72 @@ namespace RevitBridge.Tools
310
310
  }
311
311
 
312
312
  /// <summary>Per-model folder under the pi-revit workspace:
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)
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)
317
317
  {
318
318
  string workspace = Path.Combine(
319
319
  Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "pi-revit");
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)
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)
375
375
  {
376
376
  try
377
377
  {
378
- string line = $"{identity}\t{doc.PathName}";
378
+ string line = $"{identity}\t{doc.PathName}";
379
379
  string marker = Path.Combine(folder, "model.txt");
380
380
  if (!File.Exists(marker) || !File.ReadAllLines(marker).Contains(line))
381
381
  File.AppendAllLines(marker, new[] { line });
@@ -16,36 +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
- 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();
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();
24
24
  var options = transaction.GetFailureHandlingOptions();
25
25
  options.SetFailuresPreprocessor(guard);
26
26
  options.SetClearAfterRollback(true);
27
27
  transaction.SetFailureHandlingOptions(options);
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
+ 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
+ }
49
49
 
50
50
  public FailureProcessingResult PreprocessFailures(FailuresAccessor accessor)
51
51
  {