pi-revit 0.2.11 → 0.2.13

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.
@@ -15,6 +15,10 @@ namespace RevitBridge.Tools
15
15
  {
16
16
  private const int MaxLongEdgePx = 1568;
17
17
 
18
+ /// <summary>How long a captured PNG stays on disk before a later capture sweeps it.
19
+ /// Long enough that a capture is never pulled out from under a session still reading it.</summary>
20
+ private static readonly TimeSpan CaptureRetention = TimeSpan.FromHours(24);
21
+
18
22
  public string Name => "capture_view";
19
23
  public string Label => "Capture View";
20
24
  public string Description => "Export a PNG snapshot of a Revit view to a temporary file and return its path — the response contains NO image data; open the returned filePath with the read tool to actually see the image. Defaults to the active view; pass view_id for any other graphical view or sheet (find ids with get_elements, category 'Views' or 'Sheets'). The long image edge is capped at 1568 px. Schedules and view templates cannot be captured.";
@@ -45,7 +49,8 @@ namespace RevitBridge.Tools
45
49
  var doc = context.Document ?? throw new NoActiveDocumentException();
46
50
  var view = ResolveView(doc, args);
47
51
 
48
- string prefix = Path.Combine(Path.GetTempPath(), "revit_view_" + Guid.NewGuid().ToString("N"));
52
+ PruneOldCaptures();
53
+ string prefix = Path.Combine(CaptureDirectory(), "revit_view_" + Guid.NewGuid().ToString("N"));
49
54
  ExportPng(doc, view, prefix, GuessLandscape(view));
50
55
  string filePath = FindExportedFile(prefix);
51
56
  var (width, height) = ReadPngSize(filePath);
@@ -74,6 +79,60 @@ namespace RevitBridge.Tools
74
79
  }, compact);
75
80
  }
76
81
 
82
+ /// <summary>Captures live in one folder this add-in owns, NOT in Path.GetTempPath().
83
+ /// Revit can hand out a fresh per-session temp folder (observed: %LOCALAPPDATA%\Temp\
84
+ /// &lt;guid&gt;\), so a sweep of GetTempPath() only ever sees the current session's own
85
+ /// files while every earlier session's captures accumulate unreachably in sibling
86
+ /// folders. A fixed directory makes the sweep below correct and cheap. Falls back to
87
+ /// the temp path if the folder cannot be created — a capture must never fail over
88
+ /// where it is filed.</summary>
89
+ private static string CaptureDirectory()
90
+ {
91
+ try
92
+ {
93
+ string directory = Path.Combine(
94
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
95
+ "pi-revit",
96
+ "captures");
97
+ Directory.CreateDirectory(directory);
98
+ return directory;
99
+ }
100
+ catch
101
+ {
102
+ return Path.GetTempPath();
103
+ }
104
+ }
105
+
106
+ /// <summary>The PNG must outlive the call — the agent opens it with the read tool
107
+ /// after the result returns — so this run's file is never deleted here. Instead each
108
+ /// capture sweeps the ones left by earlier sessions, which otherwise accumulate
109
+ /// forever. Best-effort: a locked or vanished file is skipped, and a failing sweep
110
+ /// never costs the caller their capture. The wildcard also covers the older
111
+ /// 'revit_view_snapshot_*' naming so pre-existing captures are collected too.</summary>
112
+ private static void PruneOldCaptures()
113
+ {
114
+ try
115
+ {
116
+ DateTime cutoff = DateTime.UtcNow - CaptureRetention;
117
+ foreach (string path in Directory.EnumerateFiles(CaptureDirectory(), "revit_view_*.png"))
118
+ {
119
+ try
120
+ {
121
+ if (File.GetLastWriteTimeUtc(path) < cutoff)
122
+ File.Delete(path);
123
+ }
124
+ catch
125
+ {
126
+ // In use by a reader, or already gone: leave it for the next sweep.
127
+ }
128
+ }
129
+ }
130
+ catch
131
+ {
132
+ // Capture folder unreadable: cleanup is never worth failing a capture over.
133
+ }
134
+ }
135
+
77
136
  private static View ResolveView(Document doc, JsonElement args)
78
137
  {
79
138
  View view;
@@ -124,13 +124,26 @@ namespace RevitBridge.Tools
124
124
  throw new InvalidOperationException("Unable to start the execute_csharp transaction.");
125
125
 
126
126
  object? returnValue;
127
+ string? projectionError = null;
127
128
  try
128
129
  {
129
130
  // Runs synchronously on this (the Revit API) thread; await/async was
130
131
  // rejected at compile time, so the task completes without suspending.
131
132
  var state = script.RunAsync(globals).GetAwaiter().GetResult();
132
- // Project inside the transaction so values reflect the script-end model state.
133
- returnValue = Project(state.ReturnValue, 0);
133
+ // Project inside the transaction so values reflect the script-end model
134
+ // state — but never let projection failure masquerade as script failure:
135
+ // a lazy sequence that faults while being walked (a deleted element, a
136
+ // throwing property) would otherwise roll back a script that fully
137
+ // succeeded. The script's changes stand; only the return value is lost.
138
+ try
139
+ {
140
+ returnValue = Project(state.ReturnValue, 0);
141
+ }
142
+ catch (Exception ex)
143
+ {
144
+ projectionError = $"{ex.GetType().Name}: {ex.Message}";
145
+ returnValue = $"<the script succeeded, but its return value could not be projected: {projectionError}. Return primitives, strings, or anonymous objects/lists, and use Dump(...) for intermediates.>";
146
+ }
134
147
  }
135
148
  catch (Exception ex)
136
149
  {
@@ -160,6 +173,8 @@ namespace RevitBridge.Tools
160
173
  ["dumps"] = dumps,
161
174
  ["durationMs"] = stopwatch.ElapsedMilliseconds,
162
175
  };
176
+ if (projectionError != null)
177
+ payload["returnValueError"] = projectionError;
163
178
  if (dialogGuard.Suppressed.Count > 0)
164
179
  payload["suppressedDialogs"] = dialogGuard.Suppressed;
165
180
  if (failureGuard.Warnings.Count > 0)
@@ -79,10 +79,12 @@ namespace RevitBridge.Tools
79
79
  string baseName = SanitizeFileName(string.IsNullOrWhiteSpace(prefixInput) ? doc.Title : prefixInput.Trim());
80
80
  bool combine = JsonArgs.GetBool(args, "combine", true);
81
81
 
82
- // Produced files are found by diffing the directory; the timestamp check
83
- // (with clock-skew slack) also catches overwrites of pre-existing names.
84
- DateTime startedUtc = DateTime.UtcNow.AddSeconds(-2);
85
- var before = new HashSet<string>(Directory.GetFiles(outputDir), StringComparer.OrdinalIgnoreCase);
82
+ // Produced files are found by diffing the directory, because Revit appends its
83
+ // own view/sheet suffixes to multi-file export names. Each file is compared
84
+ // against ITS OWN pre-export write time, so an overwrite of a pre-existing name
85
+ // still counts as produced while an untouched file that merely happens to be
86
+ // recent (a shared output_dir, another tool writing alongside) does not.
87
+ var before = SnapshotWriteTimes(outputDir);
86
88
 
87
89
  switch (format)
88
90
  {
@@ -93,7 +95,7 @@ namespace RevitBridge.Tools
93
95
  }
94
96
 
95
97
  var files = Directory.GetFiles(outputDir)
96
- .Where(path => !before.Contains(path) || File.GetLastWriteTimeUtc(path) >= startedUtc)
98
+ .Where(path => !before.TryGetValue(path, out DateTime writtenBefore) || SafeWriteTime(path) != writtenBefore)
97
99
  .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
98
100
  .Select(path => new Dictionary<string, object?>
99
101
  {
@@ -115,6 +117,35 @@ namespace RevitBridge.Tools
115
117
  }, compact);
116
118
  }
117
119
 
120
+ /// <summary>path -> last write time for the files already in the output directory.
121
+ /// A file whose time cannot be read is left out, so the export reports it if it
122
+ /// shows up afterwards rather than silently swallowing a produced file.</summary>
123
+ private static Dictionary<string, DateTime> SnapshotWriteTimes(string directory)
124
+ {
125
+ var snapshot = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
126
+ foreach (string path in Directory.GetFiles(directory))
127
+ {
128
+ DateTime writtenAt = SafeWriteTime(path);
129
+ if (writtenAt != DateTime.MinValue)
130
+ snapshot[path] = writtenAt;
131
+ }
132
+ return snapshot;
133
+ }
134
+
135
+ /// <summary>DateTime.MinValue when the timestamp is unreadable (file locked or gone),
136
+ /// which compares as "changed" — an unknown file is reported, never hidden.</summary>
137
+ private static DateTime SafeWriteTime(string path)
138
+ {
139
+ try
140
+ {
141
+ return File.GetLastWriteTimeUtc(path);
142
+ }
143
+ catch
144
+ {
145
+ return DateTime.MinValue;
146
+ }
147
+ }
148
+
118
149
  // ------------------------------------------------------------- format runs
119
150
 
120
151
  private static void ExportPdf(Document doc, List<View> views, string outputDir, string baseName, bool combine)
@@ -1,62 +1,62 @@
1
- using Autodesk.Revit.DB;
2
-
3
- namespace RevitBridge.Tools
4
- {
5
- /// <summary>
6
- /// Deterministic commit-time failure handling for tool-owned transactions. Without a
7
- /// preprocessor, Revit resolves commit failures interactively: warnings pop the
8
- /// transient toast (and spam the journal), and error-severity failures block the
9
- /// Revit API thread behind the modal resolution dialog until a human clicks — past
10
- /// the bridge's call budget. Attach() deletes warnings (recorded for the tool
11
- /// result) and turns errors into a rollback, so a headless caller always gets an
12
- /// answer instead of a hang.
13
- /// </summary>
14
- internal sealed class FailureGuard : IFailuresPreprocessor
15
- {
16
- public List<string> Warnings { get; } = new();
17
- public List<string> Errors { get; } = new();
18
-
19
- public static FailureGuard Attach(Transaction transaction)
20
- {
21
- var guard = new FailureGuard();
22
- var options = transaction.GetFailureHandlingOptions();
23
- options.SetFailuresPreprocessor(guard);
24
- options.SetClearAfterRollback(true);
25
- transaction.SetFailureHandlingOptions(options);
26
- return guard;
27
- }
28
-
29
- public FailureProcessingResult PreprocessFailures(FailuresAccessor accessor)
30
- {
31
- bool hasError = false;
32
- foreach (FailureMessageAccessor failure in accessor.GetFailureMessages())
33
- {
34
- string description;
35
- try
36
- {
37
- description = failure.GetDescriptionText();
38
- }
39
- catch
40
- {
41
- description = "(failure without description)";
42
- }
43
-
44
- if (failure.GetSeverity() == FailureSeverity.Warning)
45
- {
46
- Warnings.Add(description);
47
- accessor.DeleteWarning(failure);
48
- }
49
- else
50
- {
51
- hasError = true;
52
- Errors.Add(description);
53
- }
54
- }
55
- return hasError ? FailureProcessingResult.ProceedWithRollBack : FailureProcessingResult.Continue;
56
- }
57
-
58
- /// <summary>Formats the recorded errors for a commit-failure message.</summary>
59
- public string DescribeErrors()
60
- => Errors.Count > 0 ? $" Revit failure(s): {string.Join("; ", Errors)}." : string.Empty;
61
- }
62
- }
1
+ using Autodesk.Revit.DB;
2
+
3
+ namespace RevitBridge.Tools
4
+ {
5
+ /// <summary>
6
+ /// Deterministic commit-time failure handling for tool-owned transactions. Without a
7
+ /// preprocessor, Revit resolves commit failures interactively: warnings pop the
8
+ /// transient toast (and spam the journal), and error-severity failures block the
9
+ /// Revit API thread behind the modal resolution dialog until a human clicks — past
10
+ /// the bridge's call budget. Attach() deletes warnings (recorded for the tool
11
+ /// result) and turns errors into a rollback, so a headless caller always gets an
12
+ /// answer instead of a hang.
13
+ /// </summary>
14
+ internal sealed class FailureGuard : IFailuresPreprocessor
15
+ {
16
+ public List<string> Warnings { get; } = new();
17
+ public List<string> Errors { get; } = new();
18
+
19
+ public static FailureGuard Attach(Transaction transaction)
20
+ {
21
+ var guard = new FailureGuard();
22
+ var options = transaction.GetFailureHandlingOptions();
23
+ options.SetFailuresPreprocessor(guard);
24
+ options.SetClearAfterRollback(true);
25
+ transaction.SetFailureHandlingOptions(options);
26
+ return guard;
27
+ }
28
+
29
+ public FailureProcessingResult PreprocessFailures(FailuresAccessor accessor)
30
+ {
31
+ bool hasError = false;
32
+ foreach (FailureMessageAccessor failure in accessor.GetFailureMessages())
33
+ {
34
+ string description;
35
+ try
36
+ {
37
+ description = failure.GetDescriptionText();
38
+ }
39
+ catch
40
+ {
41
+ description = "(failure without description)";
42
+ }
43
+
44
+ if (failure.GetSeverity() == FailureSeverity.Warning)
45
+ {
46
+ Warnings.Add(description);
47
+ accessor.DeleteWarning(failure);
48
+ }
49
+ else
50
+ {
51
+ hasError = true;
52
+ Errors.Add(description);
53
+ }
54
+ }
55
+ return hasError ? FailureProcessingResult.ProceedWithRollBack : FailureProcessingResult.Continue;
56
+ }
57
+
58
+ /// <summary>Formats the recorded errors for a commit-failure message.</summary>
59
+ public string DescribeErrors()
60
+ => Errors.Count > 0 ? $" Revit failure(s): {string.Join("; ", Errors)}." : string.Empty;
61
+ }
62
+ }
@@ -136,7 +136,7 @@ namespace RevitBridge.Tools
136
136
  return collector;
137
137
  }
138
138
 
139
- var (quickFilter, postPredicate) = BuildParameterFilter(doc, args, CreateBaseCollector);
139
+ var (quickFilter, postPredicate, filterWarnings) = BuildParameterFilter(doc, args, CreateBaseCollector);
140
140
 
141
141
  FilteredElementCollector CreateCollector()
142
142
  {
@@ -147,7 +147,7 @@ namespace RevitBridge.Tools
147
147
  string scope = DescribeScope(categoryInput, classInput, inActiveView);
148
148
 
149
149
  if (countOnly && postPredicate is null)
150
- return CountResult(scope, CreateCollector().GetElementCount());
150
+ return CountResult(scope, CreateCollector().GetElementCount(), filterWarnings);
151
151
 
152
152
  var rows = countOnly ? null : new List<Dictionary<string, object?>>(Math.Min(limit, 256));
153
153
  int total = 0;
@@ -161,7 +161,7 @@ namespace RevitBridge.Tools
161
161
  }
162
162
 
163
163
  if (rows is null)
164
- return CountResult(scope, total);
164
+ return CountResult(scope, total, filterWarnings);
165
165
 
166
166
  bool hasMore = offset + rows.Count < total;
167
167
  int? nextOffset = hasMore ? offset + rows.Count : null;
@@ -172,9 +172,10 @@ namespace RevitBridge.Tools
172
172
  var sample = rows.Take(3).Select(row => $"'{row["name"]}' ({row["id"]})");
173
173
  sampleText = $" Sample: {string.Join(", ", sample)}.";
174
174
  }
175
- string compact = hasMore
175
+ string compact = (hasMore
176
176
  ? $"{scope}: {total} total, returned {rows.Count} at offset {offset}, has_more (next_offset {nextOffset}).{sampleText}"
177
- : $"{scope}: {total} total, returned {rows.Count} at offset {offset}.{sampleText}";
177
+ : $"{scope}: {total} total, returned {rows.Count} at offset {offset}.{sampleText}")
178
+ + WarningSuffix(filterWarnings);
178
179
 
179
180
  return new ToolOutput(new
180
181
  {
@@ -184,11 +185,16 @@ namespace RevitBridge.Tools
184
185
  has_more = hasMore,
185
186
  next_offset = nextOffset,
186
187
  elements = rows,
188
+ warnings = filterWarnings.Count > 0 ? filterWarnings : null,
187
189
  }, compact);
188
190
  }
189
191
 
190
- private static ToolOutput CountResult(string scope, int count)
191
- => new(new { total_count = count, count_only = true }, $"{scope}: {count} elements match.");
192
+ private static ToolOutput CountResult(string scope, int count, IReadOnlyList<string> warnings)
193
+ => new(new { total_count = count, count_only = true, warnings = warnings.Count > 0 ? warnings : null },
194
+ $"{scope}: {count} elements match." + WarningSuffix(warnings));
195
+
196
+ private static string WarningSuffix(IReadOnlyList<string> warnings)
197
+ => warnings.Count > 0 ? " WARNING: " + string.Join(" ", warnings) : string.Empty;
192
198
 
193
199
  private static string DescribeScope(string? category, string? className, bool inActiveView)
194
200
  {
@@ -263,11 +269,11 @@ namespace RevitBridge.Tools
263
269
  public FilterRule? QuickRule { get; set; }
264
270
  }
265
271
 
266
- private static (ElementFilter? Quick, Func<Element, bool>? Post) BuildParameterFilter(
272
+ private static (ElementFilter? Quick, Func<Element, bool>? Post, IReadOnlyList<string> Warnings) BuildParameterFilter(
267
273
  Document doc, JsonElement args, Func<FilteredElementCollector> createBaseCollector)
268
274
  {
269
275
  if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty("filter", out var filterElement) || filterElement.ValueKind != JsonValueKind.Object)
270
- return (null, null);
276
+ return (null, null, Array.Empty<string>());
271
277
 
272
278
  bool matchAny = string.Equals(JsonArgs.GetString(filterElement, "match"), "any", StringComparison.OrdinalIgnoreCase);
273
279
  if (!filterElement.TryGetProperty("rules", out var rulesElement) || rulesElement.ValueKind != JsonValueKind.Array)
@@ -275,7 +281,7 @@ namespace RevitBridge.Tools
275
281
 
276
282
  var rules = rulesElement.EnumerateArray().Select(ParseRule).ToList();
277
283
  if (rules.Count == 0)
278
- return (null, null);
284
+ return (null, null, Array.Empty<string>());
279
285
 
280
286
  // Probe a few in-scope elements so display-name parameters resolve to ids
281
287
  // and value typing / unit conversion can use the parameter's storage + spec.
@@ -287,6 +293,29 @@ namespace RevitBridge.Tools
287
293
  break;
288
294
  }
289
295
 
296
+ // A display-name rule that no probed element carries is the localized-UI trap:
297
+ // the post-scan then matches nothing and the result reads "0 total" -- which the
298
+ // caller cannot tell apart from a genuine no-match. Typical case: an English
299
+ // display name queried against a non-English UI, where the same parameter
300
+ // carries a translated name.
301
+ // A warning (not an error: the parameter may exist past the probe window,
302
+ // and is_empty legitimately matches missing parameters) makes the zero honest.
303
+ var warnings = new List<string>();
304
+ if (probes.Count > 0)
305
+ {
306
+ foreach (var rule in rules)
307
+ {
308
+ if (rule.BuiltIn != null || rule.SharedGuid != null)
309
+ continue;
310
+ if (probes.All(probe => FindParameter(probe, rule) is null))
311
+ warnings.Add(
312
+ $"Filter parameter '{rule.ParamInput}' was not found on any of the {probes.Count} probed element(s) in scope, "
313
+ + "so a 0-match result may mean 'unknown parameter name', not 'no matching elements'. Display names are "
314
+ + "localized in non-English Revit UIs -- prefer the language-independent BuiltInParameter enum name "
315
+ + "(get_element_details reports it as builtInParameter per parameter).");
316
+ }
317
+ }
318
+
290
319
  // Display-name promotion is only trustworthy inside one category/class: the
291
320
  // probe sees just the first ProbeSize elements in collector order, so in an
292
321
  // unscoped query a unanimous sample can still hide other categories further
@@ -321,10 +350,10 @@ namespace RevitBridge.Tools
321
350
  // OR with any post-scan rule means everything must be post-scanned: a
322
351
  // collector-level OR filter would wrongly exclude post-rule-only matches.
323
352
  if (postRules.Count > 0)
324
- return (null, element => rules.Any(rule => EvaluatePost(doc, element, rule)));
353
+ return (null, element => rules.Any(rule => EvaluatePost(doc, element, rule)), warnings);
325
354
 
326
355
  var filters = quickRules.Select(rule => (ElementFilter)new ElementParameterFilter(rule.QuickRule!)).ToList();
327
- return (filters.Count == 1 ? filters[0] : new LogicalOrFilter(filters), null);
356
+ return (filters.Count == 1 ? filters[0] : new LogicalOrFilter(filters), null, warnings);
328
357
  }
329
358
 
330
359
  ElementFilter? quick = quickRules.Count > 0
@@ -333,7 +362,7 @@ namespace RevitBridge.Tools
333
362
  Func<Element, bool>? post = postRules.Count > 0
334
363
  ? element => postRules.All(rule => EvaluatePost(doc, element, rule))
335
364
  : null;
336
- return (quick, post);
365
+ return (quick, post, warnings);
337
366
  }
338
367
 
339
368
  private static Rule ParseRule(JsonElement element)
@@ -501,6 +530,11 @@ namespace RevitBridge.Tools
501
530
  }
502
531
  }
503
532
 
533
+ /// <summary>Per-element evaluation for rules the collector cannot quick-filter.
534
+ /// A value that does not fit the parameter's storage type is a caller mistake, and
535
+ /// must fail the same way it does on the quick path (TryBuildQuickRule) — otherwise
536
+ /// the identical query reports a clear error when scoped and a silent zero matches
537
+ /// when unscoped.</summary>
504
538
  private static bool EvaluatePost(Document doc, Element element, Rule rule)
505
539
  {
506
540
  var parameter = FindParameter(element, rule);
@@ -544,7 +578,7 @@ namespace RevitBridge.Tools
544
578
  if (rule.Op == RuleOp.Contains)
545
579
  return ComparableText(doc, parameter).Contains(ValueAsString(rule.Value), StringComparison.Ordinal);
546
580
  if (!TryValueAsDouble(rule.Value, out double raw))
547
- return false;
581
+ throw new ArgumentException($"Filter rule on numeric parameter '{parameter.Definition?.Name}' needs a numeric value (got {rule.Value.GetRawText()}).");
548
582
  double target = ToInternalUnits(doc, rule, parameter, raw);
549
583
  double actual = parameter.AsDouble();
550
584
  return rule.Op switch
@@ -559,7 +593,7 @@ namespace RevitBridge.Tools
559
593
  if (rule.Op == RuleOp.Contains)
560
594
  return ComparableText(doc, parameter).Contains(ValueAsString(rule.Value), StringComparison.Ordinal);
561
595
  if (!TryValueAsInt(rule.Value, out int target))
562
- return false;
596
+ throw new ArgumentException($"Filter rule on integer parameter '{parameter.Definition?.Name}' needs an integer or boolean value.");
563
597
  return Compare(parameter.AsInteger().CompareTo(target), rule.Op);
564
598
  }
565
599
  case StorageType.ElementId:
@@ -0,0 +1,103 @@
1
+ using System.Text.Json;
2
+ using Autodesk.Revit.DB;
3
+
4
+ namespace RevitBridge.Tools
5
+ {
6
+ /// <summary>
7
+ /// Activates a view or sheet in the Revit UI — the API equivalent of double-clicking
8
+ /// it in the Project Browser. Uses UIDocument.RequestViewChange, which is explicitly
9
+ /// permitted from an ExternalEvent callback (where all bridge tools run) as long as no
10
+ /// transaction is open; the tool opens none. The activation is asynchronous by design:
11
+ /// Revit performs it the moment control returns from the bridge call.
12
+ /// </summary>
13
+ internal sealed class OpenView : ITool
14
+ {
15
+ public string Name => "open_view";
16
+ public string Label => "Open View";
17
+ public string Description => "Activate a view or sheet in the Revit UI, like double-clicking it in the Project Browser. Identify the target by view_id (an id from get_elements category Views/Sheets or from view/sheet creation) or by name (exact view name, sheet number like 'A-101', or sheet number - name; case-insensitive). Activation is queued and completes the instant this call returns control to Revit — a capture_view immediately after may still show the previous active view. View templates and internal views cannot be opened.";
18
+ public bool Write => false;
19
+
20
+ public object ParametersSchema => new
21
+ {
22
+ type = "object",
23
+ properties = new
24
+ {
25
+ view_id = new { type = "integer", description = "Id of the view or sheet to activate." },
26
+ name = new { type = "string", description = "Exact view name, sheet number, or sheet 'number - name' (case-insensitive). Used when view_id is absent." },
27
+ },
28
+ };
29
+
30
+ public string? PromptSnippet => "Activate a Revit view or sheet in the UI by id or name (like double-clicking it in the Project Browser).";
31
+ public IReadOnlyList<string>? PromptGuidelines => new[]
32
+ {
33
+ "Use open_view to put a view or sheet on the user's screen (e.g. after creating a sheet); activation completes right after the call, so capture_view in the SAME call batch may still show the old view.",
34
+ };
35
+
36
+ public object? Execute(JsonElement args, ToolContext context)
37
+ {
38
+ var doc = context.Document ?? throw new NoActiveDocumentException();
39
+ var uiapp = context.UIApplication ?? throw new NoActiveDocumentException();
40
+ var uidoc = uiapp.ActiveUIDocument ?? throw new NoActiveDocumentException();
41
+
42
+ long? viewId = JsonArgs.GetLong(args, "view_id");
43
+ string? name = JsonArgs.GetString(args, "name");
44
+ if (viewId is null && string.IsNullOrWhiteSpace(name))
45
+ throw new ArgumentException("Pass view_id or name to identify the view or sheet to open.");
46
+
47
+ View view = viewId is { } id ? ResolveById(doc, id) : ResolveByName(doc, name!.Trim());
48
+
49
+ try
50
+ {
51
+ uidoc.RequestViewChange(view);
52
+ }
53
+ catch (Autodesk.Revit.Exceptions.ArgumentException ex)
54
+ {
55
+ throw new ArgumentException($"View '{view.Name}' (id {view.Id.Value}) cannot be activated: {ex.Message}");
56
+ }
57
+ catch (Autodesk.Revit.Exceptions.InvalidOperationException ex)
58
+ {
59
+ throw new InvalidOperationException(
60
+ $"Revit refused to queue the view change to '{view.Name}': {ex.Message} "
61
+ + "The document may be mid-edit; retry after the current operation finishes.");
62
+ }
63
+
64
+ string kind = view is ViewSheet sheet ? $"sheet {sheet.SheetNumber}" : view.ViewType.ToString();
65
+ return new ToolOutput(new
66
+ {
67
+ requestedViewId = view.Id.Value,
68
+ viewName = view.Name,
69
+ viewType = view.ViewType.ToString(),
70
+ }, $"Queued activation of {kind} '{view.Name}' (id {view.Id.Value}); it opens as soon as Revit regains control.");
71
+ }
72
+
73
+ private static View ResolveById(Document doc, long id)
74
+ {
75
+ var view = doc.GetElement(new ElementId(id)) as View
76
+ ?? throw new ArgumentException($"Element {id} is not a view or sheet (or does not exist). Find view ids with get_elements, category 'Views' or 'Sheets'.");
77
+ if (view.IsTemplate)
78
+ throw new ArgumentException($"View '{view.Name}' (id {id}) is a view template; templates cannot be opened.");
79
+ return view;
80
+ }
81
+
82
+ private static View ResolveByName(Document doc, string name)
83
+ {
84
+ var candidates = new FilteredElementCollector(doc)
85
+ .OfClass(typeof(View))
86
+ .Cast<View>()
87
+ .Where(view => !view.IsTemplate)
88
+ .Where(view =>
89
+ string.Equals(view.Name, name, StringComparison.OrdinalIgnoreCase)
90
+ || (view is ViewSheet sheet
91
+ && (string.Equals(sheet.SheetNumber, name, StringComparison.OrdinalIgnoreCase)
92
+ || string.Equals($"{sheet.SheetNumber} - {sheet.Name}", name, StringComparison.OrdinalIgnoreCase))))
93
+ .ToList();
94
+
95
+ if (candidates.Count == 1)
96
+ return candidates[0];
97
+ if (candidates.Count == 0)
98
+ throw new ArgumentException($"No view or sheet named '{name}'. Find the exact name or sheet number with get_elements, category 'Views' or 'Sheets'.");
99
+ string list = string.Join("; ", candidates.Take(8).Select(view => $"'{view.Name}' (id {view.Id.Value}, {view.ViewType})"));
100
+ throw new ArgumentException($"'{name}' matches {candidates.Count} views: {list}. Pass view_id to disambiguate.");
101
+ }
102
+ }
103
+ }