pi-revit 0.1.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.
@@ -0,0 +1,168 @@
1
+ using System.IO;
2
+ using System.Text.Json;
3
+ using Autodesk.Revit.DB;
4
+
5
+ namespace RevitBridge.Tools
6
+ {
7
+ /// <summary>
8
+ /// Exports a PNG of a view to a temp file for the agent to open with the read
9
+ /// tool: the response carries only the file path and image metadata,
10
+ /// never base64 image data. The long edge is clamped to 1568 px; the fit
11
+ /// direction comes from the view outline and is verified against the produced
12
+ /// PNG header, re-exporting once along the long edge if the guess was wrong.
13
+ /// </summary>
14
+ internal sealed class CaptureView : ITool
15
+ {
16
+ private const int MaxLongEdgePx = 1568;
17
+
18
+ public string Name => "capture_view";
19
+ public string Label => "Capture View";
20
+ 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.";
21
+ public string Tier => "advanced";
22
+
23
+ public object ParametersSchema => new
24
+ {
25
+ type = "object",
26
+ properties = new
27
+ {
28
+ view_id = new
29
+ {
30
+ type = "integer",
31
+ description = "Element id of the view or sheet to capture. Default: the active view.",
32
+ },
33
+ },
34
+ required = Array.Empty<string>(),
35
+ };
36
+
37
+ public string? PromptSnippet => "Export a PNG of a Revit view to a temp file (open it with the read tool).";
38
+ public IReadOnlyList<string>? PromptGuidelines => new[]
39
+ {
40
+ "capture_view returns a filePath and never image data — open that file with the read tool to actually see the view.",
41
+ };
42
+
43
+ public object? Execute(JsonElement args, ToolContext context)
44
+ {
45
+ var doc = context.Document ?? throw new NoActiveDocumentException();
46
+ var view = ResolveView(doc, args);
47
+
48
+ string prefix = Path.Combine(Path.GetTempPath(), "revit_view_" + Guid.NewGuid().ToString("N"));
49
+ ExportPng(doc, view, prefix, GuessLandscape(view));
50
+ string filePath = FindExportedFile(prefix);
51
+ var (width, height) = ReadPngSize(filePath);
52
+
53
+ // The outline-based aspect guess can be wrong (crop boxes, title blocks);
54
+ // the PNG header is authoritative. One re-export with the fit direction
55
+ // on the long edge guarantees the <= 1568 px clamp.
56
+ if (width > 0 && Math.Max(width, height) > MaxLongEdgePx)
57
+ {
58
+ File.Delete(filePath);
59
+ ExportPng(doc, view, prefix, horizontal: width >= height);
60
+ filePath = FindExportedFile(prefix);
61
+ (width, height) = ReadPngSize(filePath);
62
+ }
63
+
64
+ long fileSizeBytes = new FileInfo(filePath).Length;
65
+ string compact = $"Captured view '{view.Name}' (id {view.Id.Value}) to {filePath} ({width}x{height} px, {Math.Max(1, fileSizeBytes / 1024)} KB). Open filePath with the read tool to see it.";
66
+ return new ToolOutput(new
67
+ {
68
+ filePath,
69
+ viewId = view.Id.Value,
70
+ viewName = view.Name,
71
+ width,
72
+ height,
73
+ fileSizeBytes,
74
+ }, compact);
75
+ }
76
+
77
+ private static View ResolveView(Document doc, JsonElement args)
78
+ {
79
+ View view;
80
+ if (JsonArgs.GetLong(args, "view_id") is { } viewId)
81
+ {
82
+ view = doc.GetElement(new ElementId(viewId)) as View
83
+ ?? throw new ArgumentException($"view_id {viewId} is not a view. Find views with get_elements (category 'Views' or 'Sheets').");
84
+ if (view.IsTemplate)
85
+ throw new ArgumentException($"View '{view.Name}' ({viewId}) is a view template and cannot be captured.");
86
+ }
87
+ else
88
+ {
89
+ view = doc.ActiveView
90
+ ?? throw new ArgumentException("No active view to capture; pass view_id.");
91
+ }
92
+
93
+ if (view is ViewSchedule)
94
+ throw new ArgumentException($"View '{view.Name}' is a schedule; schedules cannot be exported as images. Capture a graphical view or sheet, or query the data with get_elements instead.");
95
+ return view;
96
+ }
97
+
98
+ private static void ExportPng(Document doc, View view, string prefix, bool horizontal)
99
+ {
100
+ var options = new ImageExportOptions
101
+ {
102
+ ExportRange = ExportRange.SetOfViews,
103
+ FilePath = prefix,
104
+ HLRandWFViewsFileType = ImageFileType.PNG,
105
+ ShadowViewsFileType = ImageFileType.PNG,
106
+ ImageResolution = ImageResolution.DPI_150,
107
+ ZoomType = ZoomFitType.FitToPage,
108
+ PixelSize = MaxLongEdgePx,
109
+ FitDirection = horizontal ? FitDirectionType.Horizontal : FitDirectionType.Vertical,
110
+ };
111
+ options.SetViewsAndSheets(new List<ElementId> { view.Id });
112
+
113
+ try
114
+ {
115
+ doc.ExportImage(options);
116
+ }
117
+ catch (Autodesk.Revit.Exceptions.ApplicationException ex)
118
+ {
119
+ throw new InvalidOperationException($"Revit could not export view '{view.Name}' as an image: {ex.Message}");
120
+ }
121
+ }
122
+
123
+ /// <summary>Initial fit direction from the view outline; verified against the produced PNG.</summary>
124
+ private static bool GuessLandscape(View view)
125
+ {
126
+ try
127
+ {
128
+ var outline = view.Outline;
129
+ return outline.Max.U - outline.Min.U >= outline.Max.V - outline.Min.V;
130
+ }
131
+ catch
132
+ {
133
+ return true;
134
+ }
135
+ }
136
+
137
+ /// <summary>ExportRange.SetOfViews appends " - [view type] - [view name]" to the
138
+ /// file path prefix, so the produced name is discovered by globbing the prefix.</summary>
139
+ private static string FindExportedFile(string prefix)
140
+ {
141
+ string directory = Path.GetDirectoryName(prefix)!;
142
+ var matches = Directory.GetFiles(directory, Path.GetFileName(prefix) + "*.png");
143
+ return matches.Length > 0
144
+ ? matches[0]
145
+ : throw new InvalidOperationException("Revit reported a successful export but no PNG file was produced.");
146
+ }
147
+
148
+ /// <summary>Reads width/height from the PNG IHDR chunk (offsets 16/20, big-endian).</summary>
149
+ private static (int Width, int Height) ReadPngSize(string filePath)
150
+ {
151
+ try
152
+ {
153
+ using var stream = File.OpenRead(filePath);
154
+ Span<byte> header = stackalloc byte[24];
155
+ stream.ReadExactly(header);
156
+ if (header[12] != (byte)'I' || header[13] != (byte)'H' || header[14] != (byte)'D' || header[15] != (byte)'R')
157
+ return (0, 0);
158
+ int width = (header[16] << 24) | (header[17] << 16) | (header[18] << 8) | header[19];
159
+ int height = (header[20] << 24) | (header[21] << 16) | (header[22] << 8) | header[23];
160
+ return (width, height);
161
+ }
162
+ catch
163
+ {
164
+ return (0, 0);
165
+ }
166
+ }
167
+ }
168
+ }
@@ -0,0 +1,418 @@
1
+ using System.Collections;
2
+ using System.Diagnostics;
3
+ using System.Reflection;
4
+ using System.Text.Json;
5
+ using System.Text.RegularExpressions;
6
+ using Autodesk.Revit.DB;
7
+ using Autodesk.Revit.UI;
8
+ using Autodesk.Revit.UI.Events;
9
+ using Microsoft.CodeAnalysis;
10
+ using Microsoft.CodeAnalysis.CSharp;
11
+ using Microsoft.CodeAnalysis.CSharp.Scripting;
12
+ using Microsoft.CodeAnalysis.Scripting;
13
+
14
+ namespace RevitBridge.Tools
15
+ {
16
+ /// <summary>
17
+ /// Globals visible to execute_csharp scripts: doc, uidoc, uiapp, and the Dump helper.
18
+ /// Public because the dynamically compiled script assembly must bind to it; the
19
+ /// lowercase member names are the script-facing contract.
20
+ /// </summary>
21
+ public sealed class ScriptGlobals
22
+ {
23
+ internal ScriptGlobals(Document document, UIDocument uiDocument, UIApplication uiApplication, Action<object?> dump)
24
+ {
25
+ doc = document;
26
+ uidoc = uiDocument;
27
+ uiapp = uiApplication;
28
+ Dump = dump;
29
+ }
30
+
31
+ /// <summary>The active document.</summary>
32
+ public Document doc { get; }
33
+
34
+ /// <summary>The active UI document (selection, active view).</summary>
35
+ public UIDocument uidoc { get; }
36
+
37
+ /// <summary>The Revit UI application.</summary>
38
+ public UIApplication uiapp { get; }
39
+
40
+ /// <summary>Records a value into the result's dumps[] (safely projected at call time).</summary>
41
+ public Action<object?> Dump { get; }
42
+ }
43
+
44
+ /// <summary>
45
+ /// Unrestricted C# escape hatch: compiles the script with Roslyn, runs it on the
46
+ /// Revit API thread inside ONE backend-owned Transaction("execute_csharp") — commit on
47
+ /// success, rollback on any exception. Results are projected through a depth-limited
48
+ /// serializer that never emits raw Revit API objects. A DialogBoxShowing guard
49
+ /// auto-dismisses modal dialogs so a script cannot hang the Revit thread behind a popup.
50
+ /// </summary>
51
+ internal sealed class ExecuteCsharp : ITool
52
+ {
53
+ private const int MaxDumps = 200;
54
+ private const int MaxDepth = 6;
55
+ private const int MaxItems = 100;
56
+ private const int MaxProperties = 40;
57
+ private const int MaxStringChars = 4_000;
58
+ private const int MaxReportedErrors = 20;
59
+
60
+ public string Name => "execute_csharp";
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. Verify unfamiliar signatures with search_api_docs first.";
63
+ public bool Write => true;
64
+
65
+ public object ParametersSchema => new
66
+ {
67
+ type = "object",
68
+ properties = new
69
+ {
70
+ code = new
71
+ {
72
+ type = "string",
73
+ description = "C# script body (top-level statements; using directives allowed at the top). Globals doc/uidoc/uiapp and Dump(value) are in scope. The final expression or a return statement is the result.",
74
+ },
75
+ },
76
+ required = new[] { "code" },
77
+ };
78
+
79
+ public string? PromptSnippet => "Run a C# script against the Revit API inside one transaction (globals doc/uidoc/uiapp, Dump helper) — the escape hatch for operations without a dedicated tool.";
80
+ public IReadOnlyList<string>? PromptGuidelines => new[]
81
+ {
82
+ "Use execute_csharp for Revit operations no dedicated tool covers (creation, deletion, geometry edits, views/sheets, tagging, ...); check exact signatures with search_api_docs before writing the script.",
83
+ "execute_csharp scripts must be fully synchronous (await/async is rejected; never block on Task.Result/.Wait()) and should return primitives or anonymous objects/lists, using Dump(...) for intermediates — raw Revit objects are projected to compact summaries; keep loops bounded and filter at the collector level.",
84
+ };
85
+
86
+ public object? Execute(JsonElement args, ToolContext context)
87
+ {
88
+ var doc = context.Document ?? throw new NoActiveDocumentException();
89
+ var uiapp = context.UIApplication ?? throw new NoActiveDocumentException();
90
+ var uidoc = uiapp.ActiveUIDocument ?? throw new NoActiveDocumentException();
91
+
92
+ string code = JsonArgs.GetString(args, "code") ?? string.Empty;
93
+ if (string.IsNullOrWhiteSpace(code))
94
+ throw new ArgumentException("code must be a non-empty C# script.");
95
+
96
+ var stopwatch = Stopwatch.StartNew();
97
+
98
+ // Compile before opening the transaction: compile errors must not touch the model.
99
+ var script = CSharpScript.Create<object>(code, Options.Value, typeof(ScriptGlobals));
100
+ var errors = script.Compile().Where(d => d.Severity == DiagnosticSeverity.Error).ToList();
101
+ if (errors.Count > 0)
102
+ throw new ArgumentException(FormatCompileErrors(errors));
103
+ RejectAsyncCode(script.GetCompilation());
104
+
105
+ var dumps = new List<object?>();
106
+ var globals = new ScriptGlobals(doc, uidoc, uiapp, value =>
107
+ {
108
+ if (dumps.Count < MaxDumps)
109
+ dumps.Add(Project(value, 0));
110
+ else if (dumps.Count == MaxDumps)
111
+ dumps.Add($"… Dump cap reached ({MaxDumps}); further values were ignored.");
112
+ });
113
+
114
+ using var dialogGuard = new DialogGuard(uiapp);
115
+ using var transaction = new Transaction(doc, "execute_csharp");
116
+ if (transaction.Start() != TransactionStatus.Started)
117
+ throw new InvalidOperationException("Unable to start the execute_csharp transaction.");
118
+
119
+ object? returnValue;
120
+ try
121
+ {
122
+ // Runs synchronously on this (the Revit API) thread; await/async was
123
+ // rejected at compile time, so the task completes without suspending.
124
+ var state = script.RunAsync(globals).GetAwaiter().GetResult();
125
+ // Project inside the transaction so values reflect the script-end model state.
126
+ returnValue = Project(state.ReturnValue, 0);
127
+ }
128
+ catch (Exception ex)
129
+ {
130
+ try
131
+ {
132
+ if (transaction.GetStatus() == TransactionStatus.Started)
133
+ transaction.RollBack();
134
+ }
135
+ catch
136
+ {
137
+ // Reporting the script failure outranks a rollback hiccup.
138
+ }
139
+ throw new InvalidOperationException(FormatRuntimeError(ex, dialogGuard.Suppressed));
140
+ }
141
+
142
+ if (transaction.Commit() != TransactionStatus.Committed)
143
+ throw new InvalidOperationException(
144
+ "Revit rolled back the execute_csharp transaction during commit (failure processing rejected the changes); no model changes were saved."
145
+ + DescribeDialogs(dialogGuard.Suppressed));
146
+
147
+ stopwatch.Stop();
148
+
149
+ var payload = new Dictionary<string, object?>
150
+ {
151
+ ["returnValue"] = returnValue,
152
+ ["dumps"] = dumps,
153
+ ["durationMs"] = stopwatch.ElapsedMilliseconds,
154
+ };
155
+ if (dialogGuard.Suppressed.Count > 0)
156
+ payload["suppressedDialogs"] = dialogGuard.Suppressed;
157
+ return payload;
158
+ }
159
+
160
+ // -------------------------------------------------------------- scripting
161
+
162
+ /// <summary>Compile options: core BCL + LINQ + Revit API references; the five
163
+ /// default imports; debug info so runtime stack traces carry script line numbers.</summary>
164
+ private static readonly Lazy<ScriptOptions> Options = new(() => ScriptOptions.Default
165
+ .WithReferences(
166
+ typeof(object).Assembly, // System.Private.CoreLib
167
+ Assembly.Load("System.Runtime"),
168
+ Assembly.Load("netstandard"),
169
+ typeof(Enumerable).Assembly, // System.Linq
170
+ typeof(Regex).Assembly, // System.Text.RegularExpressions
171
+ typeof(Console).Assembly, // System.Console
172
+ typeof(Document).Assembly, // RevitAPI
173
+ typeof(UIApplication).Assembly) // RevitAPIUI
174
+ .WithImports("System", "System.Linq", "System.Collections.Generic", "Autodesk.Revit.DB", "Autodesk.Revit.UI")
175
+ .WithEmitDebugInformation(true));
176
+
177
+ private static string FormatCompileErrors(IReadOnlyList<Diagnostic> errors)
178
+ {
179
+ var lines = errors.Take(MaxReportedErrors).Select(diagnostic =>
180
+ {
181
+ var position = diagnostic.Location.GetLineSpan().StartLinePosition;
182
+ return $" line {position.Line + 1}, col {position.Character + 1}: {diagnostic.Id} {diagnostic.GetMessage()}";
183
+ });
184
+ string more = errors.Count > MaxReportedErrors ? $"\n … +{errors.Count - MaxReportedErrors} more error(s)" : string.Empty;
185
+ return $"C# compilation failed with {errors.Count} error(s):\n{string.Join("\n", lines)}{more}\n"
186
+ + "Globals: doc, uidoc, uiapp, Dump(value). Default imports: System, System.Linq, System.Collections.Generic, "
187
+ + "Autodesk.Revit.DB, Autodesk.Revit.UI — add using directives for other namespaces.";
188
+ }
189
+
190
+ /// <summary>Rejects await/async at compile time. The script runs synchronously on the
191
+ /// Revit API thread (RunAsync + GetResult): an await would either deadlock Revit
192
+ /// forever (the dispatcher is blocked by GetResult, so a captured-context continuation
193
+ /// can never run) or resume the rest of the script on a thread-pool thread inside the
194
+ /// open transaction. Deterministic syntax-tree guard at the execution boundary, not
195
+ /// source-text sniffing.</summary>
196
+ private static void RejectAsyncCode(Compilation compilation)
197
+ {
198
+ foreach (var tree in compilation.SyntaxTrees)
199
+ {
200
+ foreach (var token in tree.GetRoot().DescendantTokens())
201
+ {
202
+ if (!token.IsKind(SyntaxKind.AwaitKeyword) && !token.IsKind(SyntaxKind.AsyncKeyword))
203
+ continue;
204
+ var position = token.GetLocation().GetLineSpan().StartLinePosition;
205
+ throw new ArgumentException(
206
+ $"'{token.ValueText}' at line {position.Line + 1}, col {position.Character + 1}: "
207
+ + "scripts run synchronously on the Revit API thread — remove await/async "
208
+ + "(and never block on Task.Result/.Wait()); write fully synchronous code.");
209
+ }
210
+ }
211
+ }
212
+
213
+ private static string FormatRuntimeError(Exception ex, IReadOnlyList<string> suppressedDialogs)
214
+ {
215
+ string line = TryGetScriptLine(ex) is { } scriptLine ? $" at script line {scriptLine}" : string.Empty;
216
+ string inner = ex.InnerException is { } innerEx ? $" Inner: {innerEx.GetType().Name}: {innerEx.Message}" : string.Empty;
217
+ return $"C# script threw {ex.GetType().Name}{line}: {ex.Message}.{inner}"
218
+ + " The execute_csharp transaction was rolled back; no model changes were saved."
219
+ + DescribeDialogs(suppressedDialogs);
220
+ }
221
+
222
+ private static string DescribeDialogs(IReadOnlyList<string> suppressed)
223
+ => suppressed.Count > 0 ? $" Auto-dismissed dialog(s) during the run: {string.Join(", ", suppressed)}." : string.Empty;
224
+
225
+ /// <summary>Script frames carry sequence points (EmitDebugInformation): pull the
226
+ /// first ":line N" out of the stack trace.</summary>
227
+ private static int? TryGetScriptLine(Exception ex)
228
+ {
229
+ string? stackTrace = ex.StackTrace;
230
+ if (stackTrace is null)
231
+ return null;
232
+ var match = Regex.Match(stackTrace, @":line (\d+)");
233
+ return match.Success && int.TryParse(match.Groups[1].Value, out int line) ? line : null;
234
+ }
235
+
236
+ /// <summary>Auto-dismisses any modal Revit dialog raised while the script runs, so a
237
+ /// popup cannot hang the Revit thread; dismissed dialog ids are reported.</summary>
238
+ private sealed class DialogGuard : IDisposable
239
+ {
240
+ private readonly UIApplication _uiapp;
241
+ public List<string> Suppressed { get; } = new();
242
+
243
+ public DialogGuard(UIApplication uiapp)
244
+ {
245
+ _uiapp = uiapp;
246
+ _uiapp.DialogBoxShowing += OnDialogBoxShowing;
247
+ }
248
+
249
+ private void OnDialogBoxShowing(object? sender, DialogBoxShowingEventArgs e)
250
+ {
251
+ Suppressed.Add(string.IsNullOrEmpty(e.DialogId) ? e.GetType().Name : e.DialogId);
252
+ try
253
+ {
254
+ e.OverrideResult(1); // IDOK
255
+ }
256
+ catch
257
+ {
258
+ // Some dialogs reject overrides; never let the guard itself throw.
259
+ }
260
+ }
261
+
262
+ public void Dispose()
263
+ {
264
+ try
265
+ {
266
+ _uiapp.DialogBoxShowing -= OnDialogBoxShowing;
267
+ }
268
+ catch
269
+ {
270
+ // Unsubscribe must never mask the tool result.
271
+ }
272
+ }
273
+ }
274
+
275
+ // ------------------------------------------------------------- projection
276
+
277
+ /// <summary>Safe serializer: depth-limited, item-capped, and never emits raw Revit
278
+ /// API objects — Element/ElementId/XYZ/Parameter/BoundingBoxXYZ/Category get compact
279
+ /// shapes, any other Autodesk type falls back to ToString().</summary>
280
+ private static object? Project(object? value, int depth)
281
+ {
282
+ if (value is null)
283
+ return null;
284
+
285
+ switch (value)
286
+ {
287
+ case string text:
288
+ return text.Length <= MaxStringChars ? text : text[..MaxStringChars] + "… [truncated]";
289
+ case bool or int or long or short or byte or sbyte or uint or ulong or ushort or decimal:
290
+ return value;
291
+ case double number:
292
+ return double.IsFinite(number) ? number : number.ToString();
293
+ case float number:
294
+ return float.IsFinite(number) ? (object)number : number.ToString();
295
+ case char or Guid or DateTime or DateTimeOffset or TimeSpan or Uri:
296
+ return value.ToString();
297
+ case Enum enumValue:
298
+ return enumValue.ToString();
299
+ case JsonElement json:
300
+ return json.Clone();
301
+ case ElementId id:
302
+ return id.Value;
303
+ case XYZ point:
304
+ return new Dictionary<string, object?> { ["x"] = point.X, ["y"] = point.Y, ["z"] = point.Z };
305
+ case UV uv:
306
+ return new Dictionary<string, object?> { ["u"] = uv.U, ["v"] = uv.V };
307
+ case BoundingBoxXYZ box:
308
+ return new Dictionary<string, object?>
309
+ {
310
+ ["min"] = Safe(() => Project(box.Min, depth + 1)),
311
+ ["max"] = Safe(() => Project(box.Max, depth + 1)),
312
+ };
313
+ case Element element:
314
+ return ProjectElement(element);
315
+ case Parameter parameter:
316
+ return ProjectParameter(parameter);
317
+ case Category category:
318
+ return new Dictionary<string, object?>
319
+ {
320
+ ["id"] = Safe(() => category.Id.Value),
321
+ ["name"] = Safe(() => category.Name),
322
+ };
323
+ }
324
+
325
+ if (depth >= MaxDepth)
326
+ return Safe(value.ToString);
327
+
328
+ if (value is IDictionary dictionary)
329
+ {
330
+ var result = new Dictionary<string, object?>();
331
+ foreach (DictionaryEntry entry in dictionary)
332
+ {
333
+ if (result.Count >= MaxItems)
334
+ {
335
+ result["…"] = $"truncated at {MaxItems} entries";
336
+ break;
337
+ }
338
+ result[entry.Key?.ToString() ?? "null"] = Project(entry.Value, depth + 1);
339
+ }
340
+ return result;
341
+ }
342
+
343
+ if (value is IEnumerable enumerable)
344
+ {
345
+ var items = new List<object?>();
346
+ foreach (object? item in enumerable)
347
+ {
348
+ if (items.Count >= MaxItems)
349
+ {
350
+ items.Add($"… truncated at {MaxItems} items");
351
+ break;
352
+ }
353
+ items.Add(Project(item, depth + 1));
354
+ }
355
+ return items;
356
+ }
357
+
358
+ var type = value.GetType();
359
+ // Any other Revit/Autodesk API object (Curve, Material asset, ...) has no safe
360
+ // JSON shape: string fallback instead of reflecting into native handles.
361
+ if (type.Namespace?.StartsWith("Autodesk.", StringComparison.Ordinal) == true)
362
+ return Safe(value.ToString);
363
+ if (value is Task)
364
+ return "<Task — return awaited values, not tasks>";
365
+
366
+ // Plain CLR object (anonymous types, records, user classes): public properties.
367
+ var projected = new Dictionary<string, object?>();
368
+ foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
369
+ {
370
+ if (projected.Count >= MaxProperties)
371
+ {
372
+ projected["…"] = $"truncated at {MaxProperties} properties";
373
+ break;
374
+ }
375
+ if (property.GetIndexParameters().Length > 0)
376
+ continue;
377
+ projected[property.Name] = Safe(() => Project(property.GetValue(value), depth + 1));
378
+ }
379
+ return projected.Count > 0 ? projected : Safe(value.ToString);
380
+ }
381
+
382
+ private static object ProjectElement(Element element) => new Dictionary<string, object?>
383
+ {
384
+ ["id"] = Safe(() => element.Id.Value),
385
+ ["name"] = Safe(() => element.Name),
386
+ ["category"] = Safe(() => element.Category?.Name),
387
+ ["typeName"] = Safe(() => element.Document.GetElement(element.GetTypeId())?.Name),
388
+ ["levelId"] = Safe(() => element.LevelId != ElementId.InvalidElementId ? element.LevelId.Value : (object?)null),
389
+ };
390
+
391
+ private static object ProjectParameter(Parameter parameter) => new Dictionary<string, object?>
392
+ {
393
+ ["name"] = Safe(() => parameter.Definition?.Name),
394
+ ["storageType"] = Safe(() => parameter.StorageType.ToString()),
395
+ ["value"] = Safe(() => parameter.StorageType switch
396
+ {
397
+ StorageType.String => parameter.AsString(),
398
+ StorageType.Double => parameter.AsDouble(),
399
+ StorageType.Integer => parameter.AsInteger(),
400
+ StorageType.ElementId => (object?)parameter.AsElementId().Value,
401
+ _ => null,
402
+ }),
403
+ ["displayValue"] = Safe(() => parameter.AsValueString()),
404
+ };
405
+
406
+ private static object? Safe(Func<object?> producer)
407
+ {
408
+ try
409
+ {
410
+ return producer();
411
+ }
412
+ catch (Exception ex)
413
+ {
414
+ return $"<error: {ex.Message}>";
415
+ }
416
+ }
417
+ }
418
+ }