pi-revit 0.3.1 → 0.4.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +388 -351
  2. package/README.md +426 -22
  3. package/extensions/pi-revit/index.ts +246 -187
  4. package/extensions/pi-revit/instance-router.ts +86 -0
  5. package/extensions/pi-revit/script-library.ts +146 -0
  6. package/extensions/pi-revit/tool-catalog.ts +67 -0
  7. package/package.json +59 -59
  8. package/skills/pi-revit/SKILL.md +196 -32
  9. package/skills/pi-revit/references/model-audit-export.md +29 -0
  10. package/skills/pi-revit/references/room-documentation.md +28 -0
  11. package/src/Revit/BridgeServer.cs +87 -37
  12. package/src/Revit/OperationStore.cs +178 -0
  13. package/src/Revit/ToolRegistry.cs +57 -35
  14. package/src/Revit/Tools/CaptureView.cs +2 -1
  15. package/src/Revit/Tools/ChangeElementTypes.cs +60 -0
  16. package/src/Revit/Tools/CreateTags.cs +95 -0
  17. package/src/Revit/Tools/DeleteElements.cs +44 -0
  18. package/src/Revit/Tools/DocumentGuard.cs +64 -64
  19. package/src/Revit/Tools/ElementQueryScope.cs +27 -0
  20. package/src/Revit/Tools/ExecuteCsharp.cs +45 -35
  21. package/src/Revit/Tools/ExportDocuments.cs +121 -120
  22. package/src/Revit/Tools/FailureGuard.cs +26 -26
  23. package/src/Revit/Tools/GetElementDetails.cs +42 -12
  24. package/src/Revit/Tools/GetElementRelationships.cs +76 -0
  25. package/src/Revit/Tools/GetElements.cs +37 -23
  26. package/src/Revit/Tools/GetLinkedElements.cs +82 -0
  27. package/src/Revit/Tools/GetLinkedModels.cs +66 -0
  28. package/src/Revit/Tools/GetModelCoordinates.cs +49 -0
  29. package/src/Revit/Tools/GetModelOverview.cs +2 -2
  30. package/src/Revit/Tools/GetScheduleFields.cs +37 -0
  31. package/src/Revit/Tools/GetSchedules.cs +89 -0
  32. package/src/Revit/Tools/ManageElementSets.cs +106 -0
  33. package/src/Revit/Tools/ManageSchedules.cs +164 -0
  34. package/src/Revit/Tools/ManageSelection.cs +37 -36
  35. package/src/Revit/Tools/ManageSheetPlacements.cs +97 -0
  36. package/src/Revit/Tools/ManageSheets.cs +63 -0
  37. package/src/Revit/Tools/ManageViews.cs +100 -0
  38. package/src/Revit/Tools/MeasureGeometry.cs +54 -0
  39. package/src/Revit/Tools/ModelEditBatch.cs +102 -0
  40. package/src/Revit/Tools/ModelEditInputs.cs +49 -0
  41. package/src/Revit/Tools/OpenView.cs +2 -1
  42. package/src/Revit/Tools/QuerySpatialElements.cs +63 -0
  43. package/src/Revit/Tools/SetParameters.cs +43 -93
  44. package/src/Revit/Tools/SpatialBounds.cs +30 -0
  45. package/src/Revit/Tools/SummarizeElements.cs +87 -0
  46. package/src/Revit/Tools/ToolSupport.cs +1 -1
  47. package/src/Revit/Tools/TransformElements.cs +58 -0
  48. package/workspace/AGENTS.md +45 -45
@@ -0,0 +1,178 @@
1
+ using System.Diagnostics;
2
+ using System.Security.Cryptography;
3
+ using System.Text.Json;
4
+
5
+ namespace RevitBridge;
6
+
7
+ /// <summary>Session-local operation receipts. Expiring a result never permits the same ID to execute again.</summary>
8
+ internal sealed class OperationStore
9
+ {
10
+ internal sealed record Reply(int Status, object Response, string? Outcome = null);
11
+ internal sealed class Entry
12
+ {
13
+ internal required string Id;
14
+ internal required string Tool;
15
+ internal required string Fingerprint;
16
+ internal required long Deadline;
17
+ internal DateTimeOffset Created = DateTimeOffset.UtcNow;
18
+ internal DateTimeOffset? Started;
19
+ internal DateTimeOffset? Finished;
20
+ internal string State = "queued";
21
+ internal TaskCompletionSource<Reply>? Completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
22
+ internal Reply? SavedReply;
23
+ internal int SavedBytes;
24
+ }
25
+
26
+ private readonly object _gate = new();
27
+ private readonly Dictionary<string, Entry> _entries = new(StringComparer.Ordinal);
28
+ private readonly Queue<Entry> _results = new();
29
+ private readonly int _maxRecords;
30
+ private readonly int _maxResults;
31
+ private readonly int _maxBytes;
32
+ private int _bytes;
33
+ public string Generation { get; } = Guid.NewGuid().ToString("N");
34
+
35
+ internal OperationStore(int maxRecords = 10000, int maxResults = 128, int maxBytes = 32 * 1024 * 1024)
36
+ {
37
+ _maxRecords = maxRecords; _maxResults = maxResults; _maxBytes = maxBytes;
38
+ }
39
+
40
+ public (Entry Entry, bool IsNew) Reserve(string id, string tool, JsonElement args, TimeSpan timeout)
41
+ {
42
+ id = ValidateId(id);
43
+ string fingerprint = Fingerprint(tool, args);
44
+ lock (_gate)
45
+ {
46
+ if (_entries.TryGetValue(id, out var existing))
47
+ {
48
+ if (existing.Fingerprint != fingerprint) throw new ArgumentException("operation_id was already used with different tool arguments. No new operation was started.");
49
+ return (existing, false);
50
+ }
51
+ if (_entries.Count >= _maxRecords) throw new InvalidOperationException("Operation receipt capacity reached. Restart the bridge before starting new tracked operations; existing receipts remain queryable.");
52
+ var entry = new Entry { Id = id, Tool = tool, Fingerprint = fingerprint,
53
+ Deadline = Stopwatch.GetTimestamp() + (long)(timeout.TotalSeconds * Stopwatch.Frequency) };
54
+ _entries.Add(id, entry);
55
+ return (entry, true);
56
+ }
57
+ }
58
+
59
+ public Task<Reply> Wait(Entry entry)
60
+ {
61
+ lock (_gate)
62
+ {
63
+ ExpireQueued(entry);
64
+ return entry.Completion?.Task ?? Task.FromResult(entry.SavedReply ?? new Reply(409, new
65
+ {
66
+ error = true, operation_id = entry.Id, state = entry.State,
67
+ message = "The operation already finished, but its full result has expired. It was not repeated. Inspect current model state before deciding on a new operation.",
68
+ }));
69
+ }
70
+ }
71
+
72
+ public bool TryStart(Entry entry)
73
+ {
74
+ lock (_gate)
75
+ {
76
+ ExpireQueued(entry);
77
+ if (entry.State != "queued") return false;
78
+ entry.State = "running"; entry.Started = DateTimeOffset.UtcNow;
79
+ return true;
80
+ }
81
+ }
82
+
83
+ public void Complete(Entry entry, Reply reply)
84
+ {
85
+ lock (_gate)
86
+ {
87
+ ExpireQueued(entry);
88
+ if (entry.Completion == null) return;
89
+ Finish(entry, reply.Outcome ?? (reply.Status < 400 ? "succeeded" : "failed"), reply);
90
+ }
91
+ }
92
+
93
+ public object Status(string id)
94
+ {
95
+ var parts = id.Split(':');
96
+ if (parts.Length == 2 && Guid.TryParse(parts[1], out var uuid)) id = parts[0] + ":" + uuid.ToString("N");
97
+ lock (_gate)
98
+ {
99
+ if (!_entries.TryGetValue(id, out var entry)) return new
100
+ {
101
+ operation_id = id, bridge_id = Generation, state = "unknown",
102
+ message = "No receipt exists in this bridge session. The outcome is unknown; this does not prove that the operation never executed. Check the original bridge and model before retrying.",
103
+ };
104
+ ExpireQueued(entry);
105
+ return new
106
+ {
107
+ operation_id = id, bridge_id = Generation, tool = entry.Tool, state = entry.State,
108
+ created_at = entry.Created, started_at = entry.Started, finished_at = entry.Finished,
109
+ result_available = entry.SavedReply != null, result_http_status = entry.SavedReply?.Status,
110
+ result = entry.SavedReply?.Response,
111
+ };
112
+ }
113
+ }
114
+
115
+ private void ExpireQueued(Entry entry)
116
+ {
117
+ if (entry.State == "queued" && Stopwatch.GetTimestamp() > entry.Deadline)
118
+ Finish(entry, "expired_before_start", new Reply(408, new { error = true, operation_id = entry.Id,
119
+ message = "Operation expired before it started; no tool action was performed." }));
120
+ }
121
+
122
+ private void Finish(Entry entry, string state, Reply reply)
123
+ {
124
+ var completion = entry.Completion!;
125
+ // Keep a detached JSON value, never API objects or callbacks, in the receipt cache.
126
+ byte[] bytes;
127
+ try { bytes = JsonSerializer.SerializeToUtf8Bytes(reply.Response); }
128
+ catch (Exception error)
129
+ {
130
+ state = "result_unavailable";
131
+ reply = new Reply(500, new { error = true, message = "The tool finished but its receipt could not be serialized. Model or other effects may already have occurred. Inspect before retrying. " + error.Message });
132
+ bytes = JsonSerializer.SerializeToUtf8Bytes(reply.Response);
133
+ }
134
+ entry.State = state; entry.Finished = DateTimeOffset.UtcNow;
135
+ entry.SavedReply = new Reply(reply.Status, JsonSerializer.Deserialize<JsonElement>(bytes));
136
+ entry.SavedBytes = bytes.Length; _bytes += bytes.Length;
137
+ _results.Enqueue(entry);
138
+ entry.Completion = null;
139
+ while (_results.Count > _maxResults || _bytes > _maxBytes)
140
+ {
141
+ var old = _results.Dequeue();
142
+ _bytes -= old.SavedBytes; old.SavedBytes = 0; old.SavedReply = null;
143
+ }
144
+ completion.TrySetResult(reply);
145
+ }
146
+
147
+ private string ValidateId(string id)
148
+ {
149
+ string[] parts = id.Split(':');
150
+ if (parts.Length != 2 || parts[0] != Generation || !Guid.TryParse(parts[1], out _))
151
+ throw new ArgumentException("operation_id must contain this bridge session's bridgeId and a UUID. An ID from another bridge/restart cannot be replayed; its outcome must be checked separately.");
152
+ return parts[0] + ":" + Guid.Parse(parts[1]).ToString("N");
153
+ }
154
+
155
+ private static string Fingerprint(string tool, JsonElement args)
156
+ {
157
+ using var stream = new MemoryStream();
158
+ using (var writer = new Utf8JsonWriter(stream))
159
+ {
160
+ void Write(JsonElement element)
161
+ {
162
+ if (element.ValueKind == JsonValueKind.Object)
163
+ {
164
+ writer.WriteStartObject();
165
+ foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal)) { writer.WritePropertyName(property.Name); Write(property.Value); }
166
+ writer.WriteEndObject();
167
+ }
168
+ else if (element.ValueKind == JsonValueKind.Array)
169
+ {
170
+ writer.WriteStartArray(); foreach (var item in element.EnumerateArray()) Write(item); writer.WriteEndArray();
171
+ }
172
+ else element.WriteTo(writer);
173
+ }
174
+ writer.WriteStartArray(); writer.WriteStringValue(tool); Write(args); writer.WriteEndArray();
175
+ }
176
+ return Convert.ToHexString(SHA256.HashData(stream.ToArray()));
177
+ }
178
+ }
@@ -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
 
@@ -32,7 +32,10 @@ namespace RevitBridge
32
32
  object ParametersSchema { get; }
33
33
 
34
34
  /// <summary>True only for tools that mutate the model. Write tools own their transaction.</summary>
35
- bool Write => false;
35
+ bool Write => false;
36
+
37
+ /// <summary>Potential effects, independent of whether this particular call changes anything.</summary>
38
+ IReadOnlyList<string> Effects => Write ? new[] { "model" } : Array.Empty<string>();
36
39
 
37
40
  /// <summary>
38
41
  /// False only for tools that never touch the Revit API. They skip the bridge's
@@ -76,7 +79,25 @@ namespace RevitBridge
76
79
  registry.Add(new SetParameters());
77
80
  registry.Add(new CaptureView());
78
81
  registry.Add(new ExportDocuments());
79
- registry.Add(new GetModelHealth());
82
+ registry.Add(new GetModelHealth());
83
+ registry.Add(new GetLinkedModels());
84
+ registry.Add(new GetLinkedElements());
85
+ registry.Add(new GetSchedules());
86
+ registry.Add(new GetElementRelationships());
87
+ registry.Add(new SummarizeElements());
88
+ registry.Add(new ManageElementSets());
89
+ registry.Add(new TransformElements());
90
+ registry.Add(new Tools.DeleteElements());
91
+ registry.Add(new ChangeElementTypes());
92
+ registry.Add(new ManageViews());
93
+ registry.Add(new ManageSheets());
94
+ registry.Add(new ManageSheetPlacements());
95
+ registry.Add(new GetScheduleFields());
96
+ registry.Add(new ManageSchedules());
97
+ registry.Add(new CreateTags());
98
+ registry.Add(new QuerySpatialElements());
99
+ registry.Add(new MeasureGeometry());
100
+ registry.Add(new GetModelCoordinates());
80
101
  return registry;
81
102
  }
82
103
 
@@ -100,36 +121,37 @@ namespace RevitBridge
100
121
  description = tool.Description,
101
122
  category = tool.Write ? "write" : "read",
102
123
  tier = tool.Tier,
103
- parameters = DescribeParameters(tool),
124
+ parameters = DescribeParameters(tool),
104
125
  executionMode = "sequential",
105
- write = tool.Write,
126
+ write = tool.Write,
127
+ effects = tool.Effects,
106
128
  requiresDocument = tool.RequiresDocument,
107
129
  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
- }
130
+ promptGuidelines = tool.RequiresDocument
131
+ ? (tool.PromptGuidelines ?? Array.Empty<string>()).Concat(new[]
132
+ {
133
+ $"{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."
134
+ }).ToArray()
135
+ : tool.PromptGuidelines,
136
+ };
137
+
138
+ private static object DescribeParameters(ITool tool)
139
+ {
140
+ if (!tool.RequiresDocument) return tool.ParametersSchema;
141
+ var schema = JsonSerializer.SerializeToNode(tool.ParametersSchema)!.AsObject();
142
+ var properties = schema["properties"]!.AsObject();
143
+ properties["expected_document_id"] = new JsonObject
144
+ {
145
+ ["type"] = "string",
146
+ ["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."
147
+ };
148
+ if (tool.Write || DocumentGuard.AlwaysRequiresIdentity(tool.Name))
149
+ {
150
+ var required = schema["required"] as JsonArray ?? new JsonArray();
151
+ if (!required.Any(x => x?.GetValue<string>() == "expected_document_id")) required.Add("expected_document_id");
152
+ schema["required"] = required;
153
+ }
154
+ return schema;
155
+ }
134
156
  }
135
157
  }
@@ -19,7 +19,8 @@ namespace RevitBridge.Tools
19
19
  /// Long enough that a capture is never pulled out from under a session still reading it.</summary>
20
20
  private static readonly TimeSpan CaptureRetention = TimeSpan.FromHours(24);
21
21
 
22
- public string Name => "capture_view";
22
+ public string Name => "capture_view";
23
+ public IReadOnlyList<string> Effects => new[] { "files" };
23
24
  public string Label => "Capture View";
24
25
  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.";
25
26
  public string Tier => "advanced";
@@ -0,0 +1,60 @@
1
+ using System.Text.Json;
2
+ using Autodesk.Revit.DB;
3
+
4
+ namespace RevitBridge.Tools;
5
+
6
+ internal sealed class ChangeElementTypes : ITool
7
+ {
8
+ public string Name => "change_element_types";
9
+ public string Label => "Change Element Types";
10
+ public string Tier => "advanced";
11
+ public bool Write => true;
12
+ public string Description => "Change the type of 1–200 elements using explicit element_id/type_id pairs. Revit validates each type against the target; invalid or pinned targets are reported per update. Some type changes replace the original element: always use resulting_id/unique_id afterward. Default partial success; atomic=true rolls back all if any update fails. preview=true commit-validates then rolls back, and replacement IDs from previews are temporary. Revit constraints can affect connected/hosted elements; results describe requested targets, not every dependent effect.";
13
+ public object ParametersSchema => new
14
+ {
15
+ type = "object", properties = new
16
+ {
17
+ updates = new { type = "array", minItems = 1, maxItems = 200, items = new
18
+ {
19
+ type = "object", properties = new { element_id = new { type = "integer", minimum = 1 }, type_id = new { type = "integer", minimum = 1 } },
20
+ required = new[] { "element_id", "type_id" },
21
+ } },
22
+ preview = ModelEditInputs.PreviewSchema,
23
+ atomic = new { type = "boolean", description = "Roll back the complete batch if any update fails. Default false." },
24
+ }, required = new[] { "updates" },
25
+ };
26
+ public object Execute(JsonElement args, ToolContext context)
27
+ {
28
+ var doc = context.Document ?? throw new NoActiveDocumentException();
29
+ if (!args.TryGetProperty("updates", out var updates) || updates.ValueKind != JsonValueKind.Array || updates.GetArrayLength() is < 1 or > 200)
30
+ throw new ArgumentException("updates must contain 1–200 element_id/type_id pairs.");
31
+ var seen = new HashSet<long>();
32
+ var steps = new List<ModelEditBatch.Step>();
33
+ foreach (var update in updates.EnumerateArray())
34
+ {
35
+ long elementId = JsonArgs.GetLong(update, "element_id") ?? 0;
36
+ long typeId = JsonArgs.GetLong(update, "type_id") ?? 0;
37
+ if (elementId <= 0 || typeId <= 0 || !seen.Add(elementId)) throw new ArgumentException("Each update needs positive IDs, and element_id must be unique within the batch.");
38
+ steps.Add(new(new() { ["element_id"] = elementId, ["type_id"] = typeId }, () =>
39
+ {
40
+ var element = doc.GetElement(new ElementId(elementId)) ?? throw new ArgumentException($"Element {elementId} not found.");
41
+ if (element.Pinned) throw new ArgumentException("Target is pinned; it was not unpinned.");
42
+ var type = doc.GetElement(new ElementId(typeId)) as ElementType ?? throw new ArgumentException($"Type {typeId} not found.");
43
+ if (!element.IsValidType(type.Id)) throw new ArgumentException($"Type {typeId} is not valid for element {elementId}.");
44
+ var before = ModelEditInputs.Snapshot(element);
45
+ var replacement = element.ChangeTypeId(type.Id);
46
+ doc.Regenerate();
47
+ var resultId = replacement == ElementId.InvalidElementId ? new ElementId(elementId) : replacement;
48
+ var result = doc.GetElement(resultId) ?? throw new InvalidOperationException("Changed element could not be resolved.");
49
+ return new() { ["before"] = before, ["after"] = ModelEditInputs.Snapshot(result),
50
+ ["resulting_id"] = result.Id.Value, ["unique_id"] = result.UniqueId,
51
+ ["replaced"] = result.Id.Value != elementId,
52
+ ["resulting_id_is_temporary"] = result.Id.Value != elementId && JsonArgs.GetBool(args, "preview", false) };
53
+ }));
54
+ }
55
+ var batch = ModelEditBatch.Run(doc, Name, args, steps);
56
+ foreach (var row in batch.Proposed)
57
+ if (row["replaced"] is true) row["resulting_id_is_temporary"] = true;
58
+ return batch.Payload;
59
+ }
60
+ }
@@ -0,0 +1,95 @@
1
+ using System.Text.Json;
2
+ using Autodesk.Revit.DB;
3
+ using Autodesk.Revit.DB.Architecture;
4
+ using Autodesk.Revit.DB.Mechanical;
5
+
6
+ namespace RevitBridge.Tools;
7
+
8
+ internal sealed class CreateTags : ITool
9
+ {
10
+ public string Name => "create_tags";
11
+ public string Label => "Create Tags";
12
+ public string Tier => "advanced";
13
+ public bool Write => true;
14
+ public string Description => "Create up to 100 tags for host-document elements in one explicit view using a loaded tag FamilySymbol. kind=element uses IndependentTag; room, space and area use their corresponding spatial tag APIs. Targets contain element_id and head_position [x,y,z] in document internal coordinates with explicit length unit. head_position always means the tag head, including with leader=true. Spatial tags require a compatible plan view and positions at that spatial element's level; orientation applies only to element tags. Templates, perspective views and unlocked 3D views cannot host independent tags. Default partial success; atomic=true rolls back all on any failure. preview=true commit-validates then rolls back; all proposed tag IDs are temporary. Linked targets and face/subelement references are outside this tool.";
15
+ public object ParametersSchema => new
16
+ {
17
+ type = "object", properties = new
18
+ {
19
+ kind = new { type = "string", @enum = new[] { "element", "room", "space", "area" } },
20
+ view_id = new { type = "integer", minimum = 1 }, tag_type_id = new { type = "integer", minimum = 1 }, unit = ModelEditInputs.LengthUnitSchema,
21
+ targets = new { type = "array", minItems = 1, maxItems = 100, items = new { type = "object", properties = new { element_id = new { type = "integer", minimum = 1 }, head_position = ModelEditInputs.VectorSchema("Tag head in document internal coordinates, in unit.") }, required = new[] { "element_id", "head_position" } } },
22
+ leader = new { type = "boolean" }, orientation = new { type = "string", @enum = new[] { "horizontal", "vertical" } },
23
+ preview = ModelEditInputs.PreviewSchema, atomic = new { type = "boolean" },
24
+ }, required = new[] { "kind", "view_id", "tag_type_id", "unit", "targets" },
25
+ };
26
+ public object Execute(JsonElement args, ToolContext context)
27
+ {
28
+ var doc = context.Document ?? throw new NoActiveDocumentException();
29
+ string kind = JsonArgs.GetString(args, "kind") ?? "";
30
+ if (kind is not ("element" or "room" or "space" or "area")) throw new ArgumentException("Unknown tag kind.");
31
+ var view = doc.GetElement(new ElementId(JsonArgs.GetLong(args, "view_id") ?? 0)) as View ?? throw new ArgumentException("view_id is not a view.");
32
+ if (view.IsTemplate || (view is View3D three && (three.IsPerspective || !three.IsLocked))) throw new ArgumentException("Tags require a non-template view; 3D views must be orthographic and locked.");
33
+ if (kind != "element" && view is not ViewPlan) throw new ArgumentException("Spatial tags require a plan view.");
34
+ var type = doc.GetElement(new ElementId(JsonArgs.GetLong(args, "tag_type_id") ?? 0)) as FamilySymbol ?? throw new ArgumentException("tag_type_id must identify a loaded tag FamilySymbol.");
35
+ bool leader = false;
36
+ if (args.TryGetProperty("leader", out var leaderValue))
37
+ {
38
+ if (leaderValue.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) throw new ArgumentException("leader must be boolean.");
39
+ leader = leaderValue.GetBoolean();
40
+ }
41
+ if (kind != "element" && args.TryGetProperty("orientation", out _)) throw new ArgumentException("orientation applies only to element tags.");
42
+ var orientation = (JsonArgs.GetString(args, "orientation") ?? "horizontal") switch
43
+ {
44
+ "horizontal" => TagOrientation.Horizontal, "vertical" => TagOrientation.Vertical, _ => throw new ArgumentException("Invalid orientation."),
45
+ };
46
+ if (!args.TryGetProperty("targets", out var targets) || targets.ValueKind != JsonValueKind.Array || targets.GetArrayLength() is < 1 or > 100) throw new ArgumentException("targets must contain 1–100 tag requests.");
47
+ double scale = ModelEditInputs.LengthScale(args);
48
+ var steps = new List<ModelEditBatch.Step>();
49
+ foreach (var input in targets.EnumerateArray())
50
+ {
51
+ if (input.ValueKind != JsonValueKind.Object) throw new ArgumentException("Each target must be an object.");
52
+ long id = JsonArgs.GetLong(input, "element_id") ?? 0;
53
+ if (id <= 0) throw new ArgumentException("Target element_id must be positive.");
54
+ var point = ModelEditInputs.Vector(input, "head_position").Multiply(scale);
55
+ steps.Add(new(new() { ["element_id"] = id }, () =>
56
+ {
57
+ var element = doc.GetElement(new ElementId(id)) ?? throw new ArgumentException($"Element {id} not found.");
58
+ if (!type.IsActive) { type.Activate(); doc.Regenerate(); }
59
+ Element tag;
60
+ if (kind == "element")
61
+ {
62
+ var independent = IndependentTag.Create(doc, type.Id, view.Id, new Reference(element), false, orientation, point);
63
+ independent.HasLeader = leader;
64
+ independent.TagHeadPosition = point;
65
+ tag = independent;
66
+ }
67
+ else
68
+ {
69
+ if (element.Location is not LocationPoint location || view.GenLevel == null || element.LevelId != view.GenLevel.Id)
70
+ throw new ArgumentException("A spatial tag target must be placed on the plan view's level.");
71
+ var anchor = new UV(location.Point.X, location.Point.Y);
72
+ SpatialElementTag spatial = kind switch
73
+ {
74
+ "room" when element is Room => doc.Create.NewRoomTag(new LinkElementId(element.Id), anchor, view.Id),
75
+ "space" when element is Space space => doc.Create.NewSpaceTag(space, anchor, view),
76
+ "area" when element is Area area => doc.Create.NewAreaTag((ViewPlan)view, area, anchor),
77
+ _ => throw new ArgumentException($"Element {id} does not match kind {kind}."),
78
+ };
79
+ if (!spatial.IsValidType(type.Id)) throw new ArgumentException("Tag type does not match the spatial tag category.");
80
+ var replacement = spatial.ChangeTypeId(type.Id);
81
+ if (replacement != ElementId.InvalidElementId) spatial = (SpatialElementTag)doc.GetElement(replacement);
82
+ spatial.HasLeader = leader; spatial.TagHeadPosition = point;
83
+ tag = spatial;
84
+ }
85
+ doc.Regenerate();
86
+ var actual = tag is IndependentTag independentResult ? independentResult.TagHeadPosition : ((SpatialElementTag)tag).TagHeadPosition;
87
+ return new() { ["tag_id"] = tag.Id.Value, ["unique_id"] = tag.UniqueId, ["tag_type_id"] = tag.GetTypeId().Value, ["view_id"] = tag.OwnerViewId.Value,
88
+ ["kind"] = kind, ["head_position"] = new[] { actual.X, actual.Y, actual.Z }, ["unit"] = "feet", ["leader"] = leader, ["id_is_temporary"] = false };
89
+ }));
90
+ }
91
+ var batch = ModelEditBatch.Run(doc, Name, args, steps);
92
+ foreach (var row in batch.Proposed) row["id_is_temporary"] = true;
93
+ return batch.Payload;
94
+ }
95
+ }
@@ -0,0 +1,44 @@
1
+ using System.Text.Json;
2
+ using Autodesk.Revit.DB;
3
+
4
+ namespace RevitBridge.Tools;
5
+
6
+ internal sealed class DeleteElements : ITool
7
+ {
8
+ public string Name => "delete_elements";
9
+ public string Label => "Delete Elements";
10
+ public string Tier => "advanced";
11
+ public bool Write => true;
12
+ public string Description => "Delete 1–200 explicitly selected elements in one atomic step, returning the full set of IDs returned by Revit's deletion API, including dependents. Use preview=true to inspect that set with commit validation and confirmed rollback. Optional expected_deleted_ids rejects a changed deletion set before commit; pass the full preview set to bind a later deletion to that set. Pinned requested elements are rejected. At most 10,000 deleted IDs are permitted; a larger cascade rolls back. Returned IDs include dependencies removed by Document.Delete, not a complete audit of surviving elements modified by Revit.";
13
+ public object ParametersSchema => new
14
+ {
15
+ type = "object", properties = new
16
+ {
17
+ element_ids = ModelEditInputs.IdsSchema,
18
+ preview = ModelEditInputs.PreviewSchema,
19
+ expected_deleted_ids = new { type = "array", minItems = 1, maxItems = 10000, uniqueItems = true, items = new { type = "integer", minimum = 1 }, description = "Exact full deleted_ids set from a previous preview; mismatches roll back." },
20
+ }, required = new[] { "element_ids" },
21
+ };
22
+ public object Execute(JsonElement args, ToolContext context)
23
+ {
24
+ var doc = context.Document ?? throw new NoActiveDocumentException();
25
+ var ids = ModelEditInputs.Ids(args);
26
+ List<long>? expected = args.TryGetProperty("expected_deleted_ids", out _) ? JsonArgs.GetLongArray(args, "expected_deleted_ids") : null;
27
+ if (expected != null && (expected.Count is < 1 or > 10000 || expected.Any(x => x <= 0) || expected.Distinct().Count() != expected.Count))
28
+ throw new ArgumentException("expected_deleted_ids must contain 1–10,000 distinct positive IDs.");
29
+ return ModelEditBatch.Run(doc, Name, args, new[] { new ModelEditBatch.Step(
30
+ new() { ["element_ids"] = ids.Select(x => x.Value).ToArray() }, () =>
31
+ {
32
+ foreach (var id in ids)
33
+ {
34
+ var element = doc.GetElement(id) ?? throw new ArgumentException($"Element {id.Value} not found.");
35
+ if (element.Pinned) throw new ArgumentException($"Element {id.Value} is pinned; no elements were unpinned.");
36
+ }
37
+ var deleted = doc.Delete(ids).Select(id => id.Value).OrderBy(id => id).ToArray();
38
+ if (deleted.Length > 10000) throw new ArgumentException("Deletion exceeds the 10,000-ID cascade limit.");
39
+ if (expected != null && !deleted.ToHashSet().SetEquals(expected)) throw new ArgumentException("The deletion set changed since the preview; deletion was rolled back. Run a fresh preview.");
40
+ return new() { ["deleted_ids"] = deleted, ["deleted_count"] = deleted.Length,
41
+ ["dependent_ids"] = deleted.Except(ids.Select(x => x.Value)).ToArray() };
42
+ }) }).Payload;
43
+ }
44
+ }
@@ -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, bool writes = false)
33
+ {
34
+ bool required = writes || 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
+ }