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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/extensions/pi-revit/index.ts +239 -0
- package/package.json +28 -0
- package/scripts/build.ps1 +37 -0
- package/scripts/deploy.ps1 +99 -0
- package/scripts/revit.ps1 +112 -0
- package/scripts/setup-workspace.ps1 +53 -0
- package/scripts/uninstall.ps1 +103 -0
- package/skills/pi-revit/SKILL.md +50 -0
- package/src/Revit/Application.cs +94 -0
- package/src/Revit/BridgeServer.cs +419 -0
- package/src/Revit/CommandQueue.cs +93 -0
- package/src/Revit/RevitBridge.addin +11 -0
- package/src/Revit/RevitBridge.csproj +48 -0
- package/src/Revit/ToolRegistry.cs +109 -0
- package/src/Revit/Tools/CaptureView.cs +168 -0
- package/src/Revit/Tools/ExecuteCsharp.cs +418 -0
- package/src/Revit/Tools/ExportDocuments.cs +256 -0
- package/src/Revit/Tools/GetElementDetails.cs +318 -0
- package/src/Revit/Tools/GetElementTypes.cs +151 -0
- package/src/Revit/Tools/GetElements.cs +725 -0
- package/src/Revit/Tools/GetModelHealth.cs +191 -0
- package/src/Revit/Tools/GetModelOverview.cs +159 -0
- package/src/Revit/Tools/ManageSelection.cs +220 -0
- package/src/Revit/Tools/SearchApiDocs.cs +610 -0
- package/src/Revit/Tools/SetParameters.cs +382 -0
- package/src/Revit/Tools/ToolSupport.cs +261 -0
- package/workspace/AGENTS.md +23 -0
- package/workspace/pi-revit-global.cmd +31 -0
- package/workspace/pi-revit-here.cmd +5 -0
- package/workspace/project-AGENTS.md +8 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
using System.IO;
|
|
2
|
+
using System.Net;
|
|
3
|
+
using System.Net.Sockets;
|
|
4
|
+
using System.Text;
|
|
5
|
+
using System.Text.Json;
|
|
6
|
+
|
|
7
|
+
namespace RevitBridge
|
|
8
|
+
{
|
|
9
|
+
/// <summary>
|
|
10
|
+
/// Minimal localhost HTTP bridge. Routes:
|
|
11
|
+
///
|
|
12
|
+
/// GET /ping -> bridge availability
|
|
13
|
+
/// GET /tools -> tool metadata list
|
|
14
|
+
/// GET /tools/{name} -> one tool's metadata
|
|
15
|
+
/// POST /tools/{name}/execute -> execute a tool on the Revit API thread
|
|
16
|
+
///
|
|
17
|
+
/// Every request must carry ?token=... matching the per-start token written
|
|
18
|
+
/// to %APPDATA%\RevitBridge\bridge.json. The HTTP handling is deliberately
|
|
19
|
+
/// minimal (loopback only) with byte-accurate UTF-8 body reads.
|
|
20
|
+
/// </summary>
|
|
21
|
+
internal sealed class BridgeServer : IDisposable
|
|
22
|
+
{
|
|
23
|
+
private const int DefaultPort = 47777;
|
|
24
|
+
private const int MaxPort = 47797;
|
|
25
|
+
private const int MaxRequestBytes = 4_000_000;
|
|
26
|
+
private const int MaxContentChars = 12_000;
|
|
27
|
+
|
|
28
|
+
private readonly CommandQueue _queue;
|
|
29
|
+
private readonly ToolRegistry _registry;
|
|
30
|
+
private readonly string _revitVersion;
|
|
31
|
+
private readonly Func<bool> _hasOpenDocument;
|
|
32
|
+
|
|
33
|
+
private TcpListener? _listener;
|
|
34
|
+
private CancellationTokenSource? _cts;
|
|
35
|
+
private string _token = string.Empty;
|
|
36
|
+
private int _port;
|
|
37
|
+
|
|
38
|
+
public BridgeServer(CommandQueue queue, ToolRegistry registry, string revitVersion, Func<bool> hasOpenDocument)
|
|
39
|
+
{
|
|
40
|
+
_queue = queue;
|
|
41
|
+
_registry = registry;
|
|
42
|
+
_revitVersion = revitVersion;
|
|
43
|
+
_hasOpenDocument = hasOpenDocument;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public void Start()
|
|
47
|
+
{
|
|
48
|
+
if (_listener != null) return;
|
|
49
|
+
|
|
50
|
+
_token = Guid.NewGuid().ToString("N");
|
|
51
|
+
_cts = new CancellationTokenSource();
|
|
52
|
+
|
|
53
|
+
for (int port = ResolveStartPort(); port <= MaxPort; port++)
|
|
54
|
+
{
|
|
55
|
+
try
|
|
56
|
+
{
|
|
57
|
+
_listener = new TcpListener(IPAddress.Loopback, port);
|
|
58
|
+
_listener.Start();
|
|
59
|
+
_port = port;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
catch
|
|
63
|
+
{
|
|
64
|
+
_listener = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (_listener is null)
|
|
69
|
+
throw new InvalidOperationException($"Unable to start the Revit bridge on localhost ports {DefaultPort}-{MaxPort}.");
|
|
70
|
+
|
|
71
|
+
WriteBridgeInfo();
|
|
72
|
+
_ = Task.Run(() => AcceptLoopAsync(_cts.Token));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public void Stop()
|
|
76
|
+
{
|
|
77
|
+
try { _cts?.Cancel(); } catch { }
|
|
78
|
+
try { _listener?.Stop(); } catch { }
|
|
79
|
+
_listener = null;
|
|
80
|
+
_cts?.Dispose();
|
|
81
|
+
_cts = null;
|
|
82
|
+
DeleteBridgeInfoIfOwned();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public void Dispose() => Stop();
|
|
86
|
+
|
|
87
|
+
private static int ResolveStartPort()
|
|
88
|
+
{
|
|
89
|
+
var raw = Environment.GetEnvironmentVariable("REVIT_BRIDGE_PORT");
|
|
90
|
+
return int.TryParse(raw, out int port) && port > 0 ? port : DefaultPort;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
internal static string BridgeInfoDirectory() => Path.Combine(
|
|
94
|
+
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
95
|
+
"RevitBridge");
|
|
96
|
+
|
|
97
|
+
public static string BridgeInfoPath() => Path.Combine(BridgeInfoDirectory(), "bridge.json");
|
|
98
|
+
|
|
99
|
+
private void WriteBridgeInfo()
|
|
100
|
+
{
|
|
101
|
+
Directory.CreateDirectory(BridgeInfoDirectory());
|
|
102
|
+
File.WriteAllText(BridgeInfoPath(), JsonSerializer.Serialize(new
|
|
103
|
+
{
|
|
104
|
+
baseUrl = $"http://127.0.0.1:{_port}",
|
|
105
|
+
token = _token,
|
|
106
|
+
pid = Environment.ProcessId,
|
|
107
|
+
revitVersion = _revitVersion,
|
|
108
|
+
startedAtUtc = DateTime.UtcNow.ToString("o"),
|
|
109
|
+
}, new JsonSerializerOptions { WriteIndented = true }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private static void DeleteBridgeInfoIfOwned()
|
|
113
|
+
{
|
|
114
|
+
try
|
|
115
|
+
{
|
|
116
|
+
string path = BridgeInfoPath();
|
|
117
|
+
if (!File.Exists(path)) return;
|
|
118
|
+
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
|
119
|
+
if (doc.RootElement.TryGetProperty("pid", out var pid) && pid.TryGetInt32(out int value) && value == Environment.ProcessId)
|
|
120
|
+
File.Delete(path);
|
|
121
|
+
}
|
|
122
|
+
catch
|
|
123
|
+
{
|
|
124
|
+
// Never let cleanup break Revit shutdown.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async Task AcceptLoopAsync(CancellationToken token)
|
|
129
|
+
{
|
|
130
|
+
while (!token.IsCancellationRequested && _listener != null)
|
|
131
|
+
{
|
|
132
|
+
TcpClient? client = null;
|
|
133
|
+
try
|
|
134
|
+
{
|
|
135
|
+
client = await _listener.AcceptTcpClientAsync(token);
|
|
136
|
+
_ = Task.Run(() => HandleClientAsync(client, token), token);
|
|
137
|
+
}
|
|
138
|
+
catch (OperationCanceledException)
|
|
139
|
+
{
|
|
140
|
+
client?.Dispose();
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
catch
|
|
144
|
+
{
|
|
145
|
+
client?.Dispose();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async Task HandleClientAsync(TcpClient client, CancellationToken token)
|
|
151
|
+
{
|
|
152
|
+
using var _ = client;
|
|
153
|
+
NetworkStream? stream = null;
|
|
154
|
+
try
|
|
155
|
+
{
|
|
156
|
+
stream = client.GetStream();
|
|
157
|
+
var request = await ReadRequestAsync(stream, token);
|
|
158
|
+
if (request is null) return;
|
|
159
|
+
|
|
160
|
+
var (method, target, body) = request.Value;
|
|
161
|
+
var uri = new Uri("http://127.0.0.1" + target);
|
|
162
|
+
var query = ParseQuery(uri.Query);
|
|
163
|
+
|
|
164
|
+
if (!query.TryGetValue("token", out var tokenValue) || !string.Equals(tokenValue, _token, StringComparison.Ordinal))
|
|
165
|
+
{
|
|
166
|
+
await WriteJsonAsync(stream, 403, new { error = true, message = "Invalid Revit bridge token." }, token);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
var (status, response) = await RouteAsync(method, uri.AbsolutePath, query, body, token);
|
|
171
|
+
await WriteJsonAsync(stream, status, response, token);
|
|
172
|
+
}
|
|
173
|
+
catch (Exception ex)
|
|
174
|
+
{
|
|
175
|
+
if (stream != null)
|
|
176
|
+
{
|
|
177
|
+
try { await WriteJsonAsync(stream, 500, new { error = true, message = ex.Message }, token); }
|
|
178
|
+
catch { }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// <summary>Reads one HTTP request with a byte-accurate Content-Length body.</summary>
|
|
184
|
+
private static async Task<(string Method, string Target, string Body)?> ReadRequestAsync(NetworkStream stream, CancellationToken token)
|
|
185
|
+
{
|
|
186
|
+
var buffer = new byte[8192];
|
|
187
|
+
using var data = new MemoryStream();
|
|
188
|
+
int headerEnd = -1;
|
|
189
|
+
|
|
190
|
+
while (headerEnd < 0)
|
|
191
|
+
{
|
|
192
|
+
int n = await stream.ReadAsync(buffer, token);
|
|
193
|
+
if (n <= 0) return null;
|
|
194
|
+
data.Write(buffer, 0, n);
|
|
195
|
+
if (data.Length > MaxRequestBytes)
|
|
196
|
+
throw new InvalidOperationException("Request too large.");
|
|
197
|
+
headerEnd = FindHeaderEnd(data.GetBuffer(), (int)data.Length);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
string headerText = Encoding.ASCII.GetString(data.GetBuffer(), 0, headerEnd);
|
|
201
|
+
string[] lines = headerText.Split("\r\n");
|
|
202
|
+
string[] requestParts = lines[0].Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
|
|
203
|
+
if (requestParts.Length < 2)
|
|
204
|
+
throw new InvalidOperationException("Invalid HTTP request line.");
|
|
205
|
+
|
|
206
|
+
string method = requestParts[0].ToUpperInvariant();
|
|
207
|
+
if (method != "GET" && method != "POST")
|
|
208
|
+
throw new InvalidOperationException("Only GET and POST are supported.");
|
|
209
|
+
|
|
210
|
+
int contentLength = 0;
|
|
211
|
+
foreach (string line in lines.Skip(1))
|
|
212
|
+
{
|
|
213
|
+
int separator = line.IndexOf(':');
|
|
214
|
+
if (separator <= 0) continue;
|
|
215
|
+
if (line.AsSpan(0, separator).Trim().Equals("Content-Length", StringComparison.OrdinalIgnoreCase))
|
|
216
|
+
int.TryParse(line.AsSpan(separator + 1).Trim(), out contentLength);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (contentLength is < 0 or > MaxRequestBytes)
|
|
220
|
+
throw new InvalidOperationException("Invalid Content-Length.");
|
|
221
|
+
|
|
222
|
+
int bodyStart = headerEnd + 4;
|
|
223
|
+
var bodyBytes = new byte[contentLength];
|
|
224
|
+
int alreadyRead = Math.Min((int)data.Length - bodyStart, contentLength);
|
|
225
|
+
Array.Copy(data.GetBuffer(), bodyStart, bodyBytes, 0, alreadyRead);
|
|
226
|
+
|
|
227
|
+
int read = alreadyRead;
|
|
228
|
+
while (read < contentLength)
|
|
229
|
+
{
|
|
230
|
+
int n = await stream.ReadAsync(bodyBytes.AsMemory(read, contentLength - read), token);
|
|
231
|
+
if (n <= 0) break;
|
|
232
|
+
read += n;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return (method, requestParts[1], Encoding.UTF8.GetString(bodyBytes, 0, read));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private static int FindHeaderEnd(byte[] data, int length)
|
|
239
|
+
{
|
|
240
|
+
for (int i = 3; i < length; i++)
|
|
241
|
+
{
|
|
242
|
+
if (data[i - 3] == '\r' && data[i - 2] == '\n' && data[i - 1] == '\r' && data[i] == '\n')
|
|
243
|
+
return i - 3;
|
|
244
|
+
}
|
|
245
|
+
return -1;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private static Dictionary<string, string> ParseQuery(string query)
|
|
249
|
+
{
|
|
250
|
+
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
251
|
+
foreach (string pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
|
|
252
|
+
{
|
|
253
|
+
int separator = pair.IndexOf('=');
|
|
254
|
+
if (separator <= 0) continue;
|
|
255
|
+
result[WebUtility.UrlDecode(pair[..separator])] = WebUtility.UrlDecode(pair[(separator + 1)..]);
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async Task<(int Status, object Response)> RouteAsync(string method, string path, Dictionary<string, string> query, string body, CancellationToken token)
|
|
261
|
+
{
|
|
262
|
+
if (method == "GET" && path == "/ping")
|
|
263
|
+
return (200, new { ok = true, service = "revit-bridge", revitVersion = _revitVersion, pid = Environment.ProcessId });
|
|
264
|
+
|
|
265
|
+
if (method == "GET" && path == "/tools")
|
|
266
|
+
{
|
|
267
|
+
var tools = _registry.DescribeAll();
|
|
268
|
+
return (200, new { protocolVersion = 1, tools, count = tools.Count });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const string toolPrefix = "/tools/";
|
|
272
|
+
if (path.StartsWith(toolPrefix, StringComparison.OrdinalIgnoreCase))
|
|
273
|
+
{
|
|
274
|
+
string tail = path[toolPrefix.Length..].Trim('/');
|
|
275
|
+
|
|
276
|
+
if (tail.EndsWith("/execute", StringComparison.OrdinalIgnoreCase))
|
|
277
|
+
{
|
|
278
|
+
if (method != "POST")
|
|
279
|
+
return (405, new { error = true, message = "Tool execution requires POST." });
|
|
280
|
+
|
|
281
|
+
string name = WebUtility.UrlDecode(tail[..^"/execute".Length].Trim('/'));
|
|
282
|
+
return await ExecuteToolAsync(name, body, query, token);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (method != "GET")
|
|
286
|
+
return (405, new { error = true, message = "Tool description requires GET." });
|
|
287
|
+
|
|
288
|
+
string describeName = WebUtility.UrlDecode(tail);
|
|
289
|
+
var tool = _registry.Get(describeName);
|
|
290
|
+
return tool is null
|
|
291
|
+
? (404, new { error = true, message = $"Unknown tool: {describeName}" })
|
|
292
|
+
: (200, _registry.Describe(tool));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return (404, new { error = true, message = $"Unknown Revit bridge endpoint: {path}" });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private async Task<(int Status, object Response)> ExecuteToolAsync(string name, string body, Dictionary<string, string> query, CancellationToken token)
|
|
299
|
+
{
|
|
300
|
+
var tool = _registry.Get(name);
|
|
301
|
+
if (tool is null)
|
|
302
|
+
return (404, new { error = true, message = $"Unknown tool: {name}" });
|
|
303
|
+
|
|
304
|
+
// Pre-check before enqueueing: with zero documents open Revit does not
|
|
305
|
+
// pump ExternalEvents, so a queued call would hang instead of failing.
|
|
306
|
+
// The in-queue NoActiveDocumentException below remains as the backstop
|
|
307
|
+
// for a document closing between this check and execution. Tools that
|
|
308
|
+
// never touch the Revit API (RequiresDocument = false) skip the gate.
|
|
309
|
+
if (tool.RequiresDocument && !_hasOpenDocument())
|
|
310
|
+
return (409, new { error = true, hasActiveDocument = false, message = "No active Revit document is open." });
|
|
311
|
+
|
|
312
|
+
JsonElement args;
|
|
313
|
+
try
|
|
314
|
+
{
|
|
315
|
+
using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
|
|
316
|
+
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
|
317
|
+
return (400, new { error = true, message = "Tool arguments must be a JSON object." });
|
|
318
|
+
args = doc.RootElement.Clone();
|
|
319
|
+
}
|
|
320
|
+
catch (JsonException ex)
|
|
321
|
+
{
|
|
322
|
+
return (400, new { error = true, message = $"Invalid JSON tool arguments: {ex.Message}" });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
int timeoutMs = ResolveTimeoutMs(query);
|
|
326
|
+
|
|
327
|
+
try
|
|
328
|
+
{
|
|
329
|
+
object? output = tool.RequiresDocument
|
|
330
|
+
? await _queue.RunAsync(uiApp =>
|
|
331
|
+
{
|
|
332
|
+
var document = uiApp.ActiveUIDocument?.Document ?? throw new NoActiveDocumentException();
|
|
333
|
+
return tool.Execute(args, new ToolContext(document, uiApp));
|
|
334
|
+
}, TimeSpan.FromMilliseconds(timeoutMs))
|
|
335
|
+
// RequiresDocument = false tools never touch the Revit API, so they
|
|
336
|
+
// run right here on the server task instead of the CommandQueue.
|
|
337
|
+
: tool.Execute(args, new ToolContext(null, null));
|
|
338
|
+
|
|
339
|
+
return (200, BuildToolResponse(name, output));
|
|
340
|
+
}
|
|
341
|
+
catch (NoActiveDocumentException ex)
|
|
342
|
+
{
|
|
343
|
+
return (409, new { error = true, hasActiveDocument = false, message = ex.Message });
|
|
344
|
+
}
|
|
345
|
+
catch (ArgumentException ex)
|
|
346
|
+
{
|
|
347
|
+
return (400, new { error = true, toolName = name, message = ex.Message });
|
|
348
|
+
}
|
|
349
|
+
catch (Exception ex)
|
|
350
|
+
{
|
|
351
|
+
return (500, new { error = true, toolName = name, message = ex.Message });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/// <summary>Optional timeout_ms query param, clamped to 1s..10min; default 30s.</summary>
|
|
356
|
+
private static int ResolveTimeoutMs(Dictionary<string, string> query)
|
|
357
|
+
=> query.TryGetValue("timeout_ms", out var raw) && int.TryParse(raw, out int value)
|
|
358
|
+
? Math.Clamp(value, 1_000, 600_000)
|
|
359
|
+
: 30_000;
|
|
360
|
+
|
|
361
|
+
/// <summary>Compact text for model context; full payload in details. Tools may
|
|
362
|
+
/// return a ToolOutput (CompactText -> content, Payload -> details.payload) or
|
|
363
|
+
/// any plain object (serialized to both).</summary>
|
|
364
|
+
private static object BuildToolResponse(string toolName, object? output)
|
|
365
|
+
{
|
|
366
|
+
object? payload = output;
|
|
367
|
+
string? compact = null;
|
|
368
|
+
if (output is ToolOutput toolOutput)
|
|
369
|
+
{
|
|
370
|
+
payload = toolOutput.Payload;
|
|
371
|
+
compact = toolOutput.CompactText;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
string text = compact ?? JsonSerializer.Serialize(payload ?? new { });
|
|
375
|
+
bool truncated = text.Length > MaxContentChars;
|
|
376
|
+
if (truncated)
|
|
377
|
+
{
|
|
378
|
+
string suffix = $"... [truncated at {MaxContentChars} chars; full payload is in details]";
|
|
379
|
+
text = text[..Math.Max(0, MaxContentChars - suffix.Length)] + suffix;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return new
|
|
383
|
+
{
|
|
384
|
+
success = true,
|
|
385
|
+
toolName,
|
|
386
|
+
content = new[] { new { type = "text", text } },
|
|
387
|
+
details = new { payload, contentTruncated = truncated },
|
|
388
|
+
isError = false,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private static async Task WriteJsonAsync(NetworkStream stream, int status, object value, CancellationToken token)
|
|
393
|
+
{
|
|
394
|
+
string reason = status switch
|
|
395
|
+
{
|
|
396
|
+
200 => "OK",
|
|
397
|
+
400 => "Bad Request",
|
|
398
|
+
403 => "Forbidden",
|
|
399
|
+
404 => "Not Found",
|
|
400
|
+
405 => "Method Not Allowed",
|
|
401
|
+
409 => "Conflict",
|
|
402
|
+
500 => "Internal Server Error",
|
|
403
|
+
_ => "OK",
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
byte[] body = JsonSerializer.SerializeToUtf8Bytes(value);
|
|
407
|
+
string header =
|
|
408
|
+
$"HTTP/1.1 {status} {reason}\r\n" +
|
|
409
|
+
"Content-Type: application/json; charset=utf-8\r\n" +
|
|
410
|
+
$"Content-Length: {body.Length}\r\n" +
|
|
411
|
+
"Connection: close\r\n" +
|
|
412
|
+
"\r\n";
|
|
413
|
+
|
|
414
|
+
await stream.WriteAsync(Encoding.ASCII.GetBytes(header), token);
|
|
415
|
+
await stream.WriteAsync(body, token);
|
|
416
|
+
await stream.FlushAsync(token);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
using System.Collections.Concurrent;
|
|
2
|
+
using System.Diagnostics;
|
|
3
|
+
using Autodesk.Revit.UI;
|
|
4
|
+
|
|
5
|
+
namespace RevitBridge
|
|
6
|
+
{
|
|
7
|
+
/// <summary>
|
|
8
|
+
/// Thrown by queued work when no Revit document is open.
|
|
9
|
+
/// The bridge maps this to HTTP 409 with hasActiveDocument = false.
|
|
10
|
+
/// </summary>
|
|
11
|
+
internal sealed class NoActiveDocumentException : InvalidOperationException
|
|
12
|
+
{
|
|
13
|
+
public NoActiveDocumentException()
|
|
14
|
+
: base("No active Revit document is open.")
|
|
15
|
+
{
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/// <summary>
|
|
20
|
+
/// Schedules work onto the Revit API thread via an ExternalEvent and returns
|
|
21
|
+
/// a Task that completes with the result (standard ExternalEvent +
|
|
22
|
+
/// TaskCompletionSource dispatch).
|
|
23
|
+
/// </summary>
|
|
24
|
+
internal sealed class CommandQueue : IExternalEventHandler
|
|
25
|
+
{
|
|
26
|
+
private sealed class WorkItem
|
|
27
|
+
{
|
|
28
|
+
public required Func<UIApplication, object?> Action { get; init; }
|
|
29
|
+
public required TaskCompletionSource<object?> Completion { get; init; }
|
|
30
|
+
|
|
31
|
+
/// <summary>Stopwatch timestamp after which the item must not start; 0 = no deadline.</summary>
|
|
32
|
+
public long DeadlineTimestamp { get; init; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private readonly ConcurrentQueue<WorkItem> _queue = new();
|
|
36
|
+
|
|
37
|
+
/// <summary>Set once from Application.OnStartup (a valid Revit API context).</summary>
|
|
38
|
+
public ExternalEvent? ExternalEvent { get; set; }
|
|
39
|
+
|
|
40
|
+
public Task<T> RunAsync<T>(Func<UIApplication, T> action, TimeSpan? timeout = null)
|
|
41
|
+
{
|
|
42
|
+
var completion = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
43
|
+
_queue.Enqueue(new WorkItem
|
|
44
|
+
{
|
|
45
|
+
Action = app => action(app),
|
|
46
|
+
Completion = completion,
|
|
47
|
+
DeadlineTimestamp = timeout is { } budget
|
|
48
|
+
? Stopwatch.GetTimestamp() + (long)(budget.TotalSeconds * Stopwatch.Frequency)
|
|
49
|
+
: 0,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
var externalEvent = ExternalEvent;
|
|
53
|
+
if (externalEvent is null)
|
|
54
|
+
{
|
|
55
|
+
completion.TrySetException(new InvalidOperationException("Revit bridge command queue is not initialized."));
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
{
|
|
59
|
+
externalEvent.Raise();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return Await<T>(completion.Task);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private static async Task<T> Await<T>(Task<object?> task) => (T)(await task.ConfigureAwait(false))!;
|
|
66
|
+
|
|
67
|
+
public void Execute(UIApplication app)
|
|
68
|
+
{
|
|
69
|
+
while (_queue.TryDequeue(out var item))
|
|
70
|
+
{
|
|
71
|
+
// The HTTP caller abandons the request at the same deadline, so running
|
|
72
|
+
// the item late would mutate the model behind the agent's back.
|
|
73
|
+
if (item.DeadlineTimestamp != 0 && Stopwatch.GetTimestamp() > item.DeadlineTimestamp)
|
|
74
|
+
{
|
|
75
|
+
item.Completion.TrySetException(new TimeoutException(
|
|
76
|
+
"Tool call expired before execution; no work was performed. Revit stayed busy past the timeout_ms deadline; retry, optionally with a larger timeout_ms."));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try
|
|
81
|
+
{
|
|
82
|
+
item.Completion.TrySetResult(item.Action(app));
|
|
83
|
+
}
|
|
84
|
+
catch (Exception ex)
|
|
85
|
+
{
|
|
86
|
+
item.Completion.TrySetException(ex);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public string GetName() => "Revit Bridge Command Queue";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<RevitAddIns>
|
|
3
|
+
<AddIn Type="Application">
|
|
4
|
+
<Name>Revit Bridge</Name>
|
|
5
|
+
<Assembly>RevitBridge\RevitBridge.dll</Assembly>
|
|
6
|
+
<FullClassName>RevitBridge.Application</FullClassName>
|
|
7
|
+
<AddInId>5E4AA11A-BBAF-4B2C-8649-EC6C541FABA1</AddInId>
|
|
8
|
+
<VendorId>pi-revit</VendorId>
|
|
9
|
+
<VendorDescription>Headless local bridge that exposes Revit API tools to a Pi coding-agent session.</VendorDescription>
|
|
10
|
+
</AddIn>
|
|
11
|
+
</RevitAddIns>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk">
|
|
2
|
+
|
|
3
|
+
<PropertyGroup>
|
|
4
|
+
<!-- net8.0-windows -> Revit 2025/2026; net10.0-windows -> Revit 2027. The build picks
|
|
5
|
+
per-TFM defaults below; deploy.ps1 builds the single TFM that matches its -RevitVersion. -->
|
|
6
|
+
<TargetFrameworks>net8.0-windows;net10.0-windows</TargetFrameworks>
|
|
7
|
+
<LangVersion>latest</LangVersion>
|
|
8
|
+
<Nullable>enable</Nullable>
|
|
9
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
10
|
+
<AssemblyName>RevitBridge</AssemblyName>
|
|
11
|
+
<RootNamespace>RevitBridge</RootNamespace>
|
|
12
|
+
<PlatformTarget>x64</PlatformTarget>
|
|
13
|
+
<!-- MSB3277: harmless version-conflict noise from RevitAPIUI transitive deps; nothing is copied local. -->
|
|
14
|
+
<MSBuildWarningsAsMessages>MSB3277</MSBuildWarningsAsMessages>
|
|
15
|
+
<!-- execute_csharp: copy the Roslyn package closure into the build output so
|
|
16
|
+
scripts/deploy.ps1 can ship it next to RevitBridge.dll. -->
|
|
17
|
+
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
|
18
|
+
<!-- Compiler messages stay English-only; skips ~13 localized Roslyn resource subfolders. -->
|
|
19
|
+
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
|
20
|
+
</PropertyGroup>
|
|
21
|
+
|
|
22
|
+
<!-- RevitAPI.dll location, defaulted per target framework. Override either target with:
|
|
23
|
+
dotnet build -p:TargetFramework=net10.0-windows -p:RevitApiPath="D:\Autodesk\Revit 2027" -->
|
|
24
|
+
<PropertyGroup Condition="'$(RevitApiPath)' == '' and '$(TargetFramework)' == 'net10.0-windows'">
|
|
25
|
+
<RevitApiPath>C:\Program Files\Autodesk\Revit 2027</RevitApiPath>
|
|
26
|
+
</PropertyGroup>
|
|
27
|
+
<PropertyGroup Condition="'$(RevitApiPath)' == ''">
|
|
28
|
+
<RevitApiPath>C:\Program Files\Autodesk\Revit 2025</RevitApiPath>
|
|
29
|
+
</PropertyGroup>
|
|
30
|
+
|
|
31
|
+
<!-- Revit API assemblies are provided by Revit at runtime; never copy them local. -->
|
|
32
|
+
<ItemGroup>
|
|
33
|
+
<Reference Include="RevitAPI">
|
|
34
|
+
<HintPath>$(RevitApiPath)\RevitAPI.dll</HintPath>
|
|
35
|
+
<Private>false</Private>
|
|
36
|
+
</Reference>
|
|
37
|
+
<Reference Include="RevitAPIUI">
|
|
38
|
+
<HintPath>$(RevitApiPath)\RevitAPIUI.dll</HintPath>
|
|
39
|
+
<Private>false</Private>
|
|
40
|
+
</Reference>
|
|
41
|
+
</ItemGroup>
|
|
42
|
+
|
|
43
|
+
<!-- Roslyn scripting engine for the execute_csharp tool. -->
|
|
44
|
+
<ItemGroup>
|
|
45
|
+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.8.0" />
|
|
46
|
+
</ItemGroup>
|
|
47
|
+
|
|
48
|
+
</Project>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
using System.Text.Json;
|
|
2
|
+
using Autodesk.Revit.DB;
|
|
3
|
+
using Autodesk.Revit.UI;
|
|
4
|
+
using RevitBridge.Tools;
|
|
5
|
+
|
|
6
|
+
namespace RevitBridge
|
|
7
|
+
{
|
|
8
|
+
/// <summary>
|
|
9
|
+
/// Execution context handed to tools. Document and UIApplication are non-null when the
|
|
10
|
+
/// tool runs on the Revit API thread (RequiresDocument = true, the default). Tools with
|
|
11
|
+
/// RequiresDocument = false run on the bridge's server task with both set to null and
|
|
12
|
+
/// must not touch the Revit API.
|
|
13
|
+
/// </summary>
|
|
14
|
+
internal sealed record ToolContext(Document? Document, UIApplication? UIApplication);
|
|
15
|
+
|
|
16
|
+
/// <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).
|
|
20
|
+
/// </summary>
|
|
21
|
+
internal sealed record ToolOutput(object? Payload, string? CompactText = null);
|
|
22
|
+
|
|
23
|
+
/// <summary>A single Revit tool exposed through the bridge.</summary>
|
|
24
|
+
internal interface ITool
|
|
25
|
+
{
|
|
26
|
+
string Name { get; }
|
|
27
|
+
string Label { get; }
|
|
28
|
+
string Description { get; }
|
|
29
|
+
|
|
30
|
+
/// <summary>JSON schema (anonymous object) describing the tool arguments.</summary>
|
|
31
|
+
object ParametersSchema { get; }
|
|
32
|
+
|
|
33
|
+
/// <summary>True only for tools that mutate the model. Write tools own their transaction.</summary>
|
|
34
|
+
bool Write => false;
|
|
35
|
+
|
|
36
|
+
/// <summary>
|
|
37
|
+
/// False only for tools that never touch the Revit API. They skip the bridge's
|
|
38
|
+
/// no-document 409 gate and run directly on the server task instead of the Revit
|
|
39
|
+
/// API thread, so ToolContext.Document/UIApplication are null for them.
|
|
40
|
+
/// </summary>
|
|
41
|
+
bool RequiresDocument => true;
|
|
42
|
+
|
|
43
|
+
/// <summary>Activation tier metadata: "core" (default) or "advanced".</summary>
|
|
44
|
+
string Tier => "core";
|
|
45
|
+
|
|
46
|
+
/// <summary>Optional one-line "Available tools" system prompt entry.</summary>
|
|
47
|
+
string? PromptSnippet => null;
|
|
48
|
+
|
|
49
|
+
/// <summary>Optional system prompt guideline bullets. Each bullet must name the tool.</summary>
|
|
50
|
+
IReadOnlyList<string>? PromptGuidelines => null;
|
|
51
|
+
|
|
52
|
+
/// <summary>
|
|
53
|
+
/// Runs on the Revit API thread (or the server task when RequiresDocument is false).
|
|
54
|
+
/// Returns a ToolOutput or any plain JSON-serializable payload.
|
|
55
|
+
/// </summary>
|
|
56
|
+
object? Execute(JsonElement args, ToolContext context);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// <summary>Name -> tool map plus the metadata projection served by GET /tools.</summary>
|
|
60
|
+
internal sealed class ToolRegistry
|
|
61
|
+
{
|
|
62
|
+
private readonly SortedDictionary<string, ITool> _tools = new(StringComparer.Ordinal);
|
|
63
|
+
|
|
64
|
+
public static ToolRegistry CreateDefault()
|
|
65
|
+
{
|
|
66
|
+
var registry = new ToolRegistry();
|
|
67
|
+
registry.Add(new SearchApiDocs());
|
|
68
|
+
registry.Add(new ExecuteCsharp());
|
|
69
|
+
registry.Add(new GetModelOverview());
|
|
70
|
+
registry.Add(new GetElements());
|
|
71
|
+
registry.Add(new GetElementTypes());
|
|
72
|
+
registry.Add(new GetElementDetails());
|
|
73
|
+
registry.Add(new ManageSelection());
|
|
74
|
+
registry.Add(new SetParameters());
|
|
75
|
+
registry.Add(new CaptureView());
|
|
76
|
+
registry.Add(new ExportDocuments());
|
|
77
|
+
registry.Add(new GetModelHealth());
|
|
78
|
+
return registry;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public void Add(ITool tool)
|
|
82
|
+
{
|
|
83
|
+
if (_tools.ContainsKey(tool.Name))
|
|
84
|
+
throw new InvalidOperationException($"Duplicate tool name: {tool.Name}");
|
|
85
|
+
_tools[tool.Name] = tool;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public ITool? Get(string name)
|
|
89
|
+
=> _tools.TryGetValue(name, out var tool) ? tool : null;
|
|
90
|
+
|
|
91
|
+
public IReadOnlyList<object> DescribeAll()
|
|
92
|
+
=> _tools.Values.Select(Describe).ToArray();
|
|
93
|
+
|
|
94
|
+
public object Describe(ITool tool) => new
|
|
95
|
+
{
|
|
96
|
+
name = tool.Name,
|
|
97
|
+
label = tool.Label,
|
|
98
|
+
description = tool.Description,
|
|
99
|
+
category = tool.Write ? "write" : "read",
|
|
100
|
+
tier = tool.Tier,
|
|
101
|
+
parameters = tool.ParametersSchema,
|
|
102
|
+
executionMode = "sequential",
|
|
103
|
+
write = tool.Write,
|
|
104
|
+
requiresDocument = tool.RequiresDocument,
|
|
105
|
+
promptSnippet = tool.PromptSnippet,
|
|
106
|
+
promptGuidelines = tool.PromptGuidelines,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|