pi-revit 0.2.12 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,301 +1,301 @@
1
- using System.Text.Json;
2
- using Autodesk.Revit.DB;
3
-
4
- namespace RevitBridge.Tools
5
- {
6
- /// <summary>
7
- /// Resolves a user-facing category string (friendly alias, category display name,
8
- /// or BuiltInCategory enum name like OST_Walls) to a category ElementId usable
9
- /// with FilteredElementCollector.OfCategoryId. Shared by the query tools.
10
- /// </summary>
11
- internal static class CategoryResolver
12
- {
13
- private static readonly IReadOnlyDictionary<string, BuiltInCategory> Aliases = new Dictionary<string, BuiltInCategory>(StringComparer.OrdinalIgnoreCase)
14
- {
15
- ["wall"] = BuiltInCategory.OST_Walls,
16
- ["walls"] = BuiltInCategory.OST_Walls,
17
- ["door"] = BuiltInCategory.OST_Doors,
18
- ["doors"] = BuiltInCategory.OST_Doors,
19
- ["window"] = BuiltInCategory.OST_Windows,
20
- ["windows"] = BuiltInCategory.OST_Windows,
21
- ["floor"] = BuiltInCategory.OST_Floors,
22
- ["floors"] = BuiltInCategory.OST_Floors,
23
- ["room"] = BuiltInCategory.OST_Rooms,
24
- ["rooms"] = BuiltInCategory.OST_Rooms,
25
- ["area"] = BuiltInCategory.OST_Areas,
26
- ["areas"] = BuiltInCategory.OST_Areas,
27
- ["column"] = BuiltInCategory.OST_Columns,
28
- ["columns"] = BuiltInCategory.OST_Columns,
29
- ["roof"] = BuiltInCategory.OST_Roofs,
30
- ["roofs"] = BuiltInCategory.OST_Roofs,
31
- ["stair"] = BuiltInCategory.OST_Stairs,
32
- ["stairs"] = BuiltInCategory.OST_Stairs,
33
- ["ceiling"] = BuiltInCategory.OST_Ceilings,
34
- ["ceilings"] = BuiltInCategory.OST_Ceilings,
35
- ["furniture"] = BuiltInCategory.OST_Furniture,
36
- ["pipe"] = BuiltInCategory.OST_PipeCurves,
37
- ["pipes"] = BuiltInCategory.OST_PipeCurves,
38
- ["duct"] = BuiltInCategory.OST_DuctCurves,
39
- ["ducts"] = BuiltInCategory.OST_DuctCurves,
40
- ["beam"] = BuiltInCategory.OST_StructuralFraming,
41
- ["beams"] = BuiltInCategory.OST_StructuralFraming,
42
- ["structural framing"] = BuiltInCategory.OST_StructuralFraming,
43
- ["generic model"] = BuiltInCategory.OST_GenericModel,
44
- ["generic models"] = BuiltInCategory.OST_GenericModel,
45
- ["level"] = BuiltInCategory.OST_Levels,
46
- ["levels"] = BuiltInCategory.OST_Levels,
47
- ["grid"] = BuiltInCategory.OST_Grids,
48
- ["grids"] = BuiltInCategory.OST_Grids,
49
- ["sheet"] = BuiltInCategory.OST_Sheets,
50
- ["sheets"] = BuiltInCategory.OST_Sheets,
51
- ["view"] = BuiltInCategory.OST_Views,
52
- ["views"] = BuiltInCategory.OST_Views,
53
- };
54
-
55
- public static ElementId Resolve(Document doc, string input)
56
- {
57
- string trimmed = input.Trim();
58
- if (trimmed.Length == 0)
59
- throw new ArgumentException("category must be a non-empty string.");
60
-
61
- if (Aliases.TryGetValue(trimmed, out var alias))
62
- return new ElementId(alias);
63
- if (TryParseBuiltInCategory(trimmed, out var builtIn))
64
- return new ElementId(builtIn);
65
- if (TryParseBuiltInCategory("OST_" + trimmed.Replace(" ", string.Empty), out builtIn))
66
- return new ElementId(builtIn);
67
-
68
- foreach (Category category in doc.Settings.Categories)
69
- {
70
- if (string.Equals(category.Name, trimmed, StringComparison.OrdinalIgnoreCase))
71
- return category.Id;
72
- }
73
-
74
- throw new ArgumentException($"Unknown category: {input}. Use a BuiltInCategory enum name (e.g. OST_Walls) or a category display name as shown in Revit (e.g. Walls, Doors, Sheets, Views).");
75
- }
76
-
77
- private static bool TryParseBuiltInCategory(string text, out BuiltInCategory category)
78
- {
79
- category = BuiltInCategory.INVALID;
80
- return text.Length > 0
81
- && char.IsLetter(text[0])
82
- && Enum.TryParse(text, true, out category)
83
- && category != BuiltInCategory.INVALID;
84
- }
85
- }
86
-
87
- /// <summary>
88
- /// Resolves a Revit API class name ("Wall", "ViewSheet", "WallType", or a full
89
- /// name like "Autodesk.Revit.DB.Mechanical.Duct") to its Type for OfClass filters.
90
- /// </summary>
91
- internal static class ElementClassResolver
92
- {
93
- private static readonly Lazy<IReadOnlyDictionary<string, Type>> BySimpleName = new(BuildSimpleNameMap);
94
-
95
- public static Type Resolve(string input)
96
- {
97
- string trimmed = input.Trim();
98
- var assembly = typeof(Element).Assembly;
99
- Type? match = trimmed.Contains('.') ? assembly.GetType(trimmed, false, true) : null;
100
- match ??= assembly.GetType("Autodesk.Revit.DB." + trimmed, false, true);
101
- if (match is null)
102
- BySimpleName.Value.TryGetValue(trimmed, out match);
103
-
104
- if (match is null || !typeof(Element).IsAssignableFrom(match))
105
- throw new ArgumentException($"Unknown Revit element class: {input}. Use an Element subclass from the Revit API (e.g. Wall, FamilyInstance, ViewSheet, Level, WallType) or a full name like Autodesk.Revit.DB.Mechanical.Duct.");
106
- if (match == typeof(Element))
107
- throw new ArgumentException("of_class must be a concrete Element subclass, not Element itself.");
108
- return match;
109
- }
110
-
111
- private static IReadOnlyDictionary<string, Type> BuildSimpleNameMap()
112
- {
113
- Type[] types;
114
- try
115
- {
116
- types = typeof(Element).Assembly.GetTypes();
117
- }
118
- catch (System.Reflection.ReflectionTypeLoadException ex)
119
- {
120
- types = ex.Types.OfType<Type>().ToArray();
121
- }
122
-
123
- var map = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
124
- foreach (var type in types)
125
- {
126
- if (!type.IsPublic || !typeof(Element).IsAssignableFrom(type))
127
- continue;
128
- // Prefer the root Autodesk.Revit.DB namespace when simple names collide.
129
- if (!map.TryGetValue(type.Name, out var existing) || (type.Namespace == "Autodesk.Revit.DB" && existing.Namespace != "Autodesk.Revit.DB"))
130
- map[type.Name] = type;
131
- }
132
- return map;
133
- }
134
- }
135
-
136
- /// <summary>
137
- /// Resolves friendly forge unit names ("millimeters", "squareMeters") or full forge
138
- /// ids ("autodesk.unit.unit:millimeters-1.0.1") to UnitTypeId values, and back.
139
- /// </summary>
140
- internal static class UnitResolver
141
- {
142
- private static readonly Lazy<IReadOnlyDictionary<string, ForgeTypeId>> ByName = new(Build);
143
-
144
- public static ForgeTypeId Resolve(string unit)
145
- {
146
- string trimmed = unit.Trim();
147
- if (ByName.Value.TryGetValue(trimmed, out var match))
148
- return match;
149
- throw new ArgumentException($"Unknown unit: {unit}. Use a forge unit name like millimeters, meters, feet, inches, squareMeters, cubicFeet, degrees.");
150
- }
151
-
152
- /// <summary>"autodesk.unit.unit:millimeters-1.0.1" -> "millimeters".</summary>
153
- public static string ShortName(ForgeTypeId unit)
154
- {
155
- string id = unit.TypeId ?? string.Empty;
156
- int colon = id.IndexOf(':');
157
- string tail = colon >= 0 ? id[(colon + 1)..] : id;
158
- int dash = tail.IndexOf('-');
159
- return dash >= 0 ? tail[..dash] : tail;
160
- }
161
-
162
- private static IReadOnlyDictionary<string, ForgeTypeId> Build()
163
- {
164
- var map = new Dictionary<string, ForgeTypeId>(StringComparer.OrdinalIgnoreCase);
165
- foreach (ForgeTypeId unit in UnitUtils.GetAllUnits())
166
- {
167
- map[ShortName(unit)] = unit;
168
- map[unit.TypeId] = unit;
169
- }
170
- return map;
171
- }
172
- }
173
-
174
- /// <summary>Identity-only element row shared by the query tools.</summary>
175
- internal static class ElementIdentity
176
- {
177
- public static readonly IReadOnlyList<string> Fields = new[] { "id", "name", "category", "typeName", "levelId" };
178
-
179
- public static Dictionary<string, object?> Build(Document doc, Element element, IReadOnlyList<string> fields)
180
- {
181
- var row = new Dictionary<string, object?>(fields.Count);
182
- foreach (string field in fields)
183
- {
184
- row[field] = field switch
185
- {
186
- "id" => (object?)element.Id.Value,
187
- "name" => element.Name,
188
- "category" => element.Category?.Name,
189
- "typeName" => doc.GetElement(element.GetTypeId())?.Name,
190
- "levelId" => element.LevelId is { } levelId && levelId != ElementId.InvalidElementId ? levelId.Value : (long?)null,
191
- _ => null,
192
- };
193
- }
194
- return row;
195
- }
196
- }
197
-
198
- /// <summary>Small JsonElement argument readers shared by the tools.</summary>
199
- internal static class JsonArgs
200
- {
201
- public static string? GetString(JsonElement args, string name)
202
- => args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
203
- ? value.GetString()
204
- : null;
205
-
206
- public static bool GetBool(JsonElement args, string name, bool fallback)
207
- => args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False
208
- ? value.GetBoolean()
209
- : fallback;
210
-
211
- public static int GetInt(JsonElement args, string name, int fallback)
212
- => args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out int parsed)
213
- ? parsed
214
- : fallback;
215
-
216
- public static long? GetLong(JsonElement args, string name)
217
- {
218
- if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var value))
219
- return null;
220
- if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out long fromNumber))
221
- return fromNumber;
222
- if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out long fromString))
223
- return fromString;
224
- return null;
225
- }
226
-
227
- public static List<long> GetLongArray(JsonElement args, string name)
228
- {
229
- if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
230
- throw new ArgumentException($"Missing or invalid required field: {name} must be an array of element ids.");
231
-
232
- var result = new List<long>(value.GetArrayLength());
233
- foreach (var item in value.EnumerateArray())
234
- {
235
- if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out long fromNumber))
236
- result.Add(fromNumber);
237
- else if (item.ValueKind == JsonValueKind.String && long.TryParse(item.GetString(), out long fromString))
238
- result.Add(fromString);
239
- else
240
- throw new ArgumentException($"{name} must contain only integer element ids.");
241
- }
242
- return result;
243
- }
244
-
245
- public static List<string>? GetStringArray(JsonElement args, string name)
246
- {
247
- if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
248
- return null;
249
-
250
- var result = new List<string>(value.GetArrayLength());
251
- foreach (var item in value.EnumerateArray())
252
- {
253
- string? text = item.ValueKind == JsonValueKind.String ? item.GetString() : null;
254
- if (string.IsNullOrWhiteSpace(text))
255
- throw new ArgumentException($"{name} must contain only non-empty strings.");
256
- result.Add(text.Trim());
257
- }
258
- return result;
259
- }
260
- }
261
-
262
- /// <summary>
263
- /// Optional wrong-document protection for write tools. A write queued while the
264
- /// user switches models would otherwise land in whichever document is active when
265
- /// the queued call runs -- possibly silently, since low element ids resolve in
266
- /// most documents. Tools pass the caller's optional expected_document through
267
- /// here before touching the model.
268
- /// </summary>
269
- internal static class DocumentGuard
270
- {
271
- /// <summary>Throws when expected_document is provided and does not match the
272
- /// active document's title (case-insensitive; the .rvt extension and a
273
- /// detached suffix are tolerated). No-op when the argument is absent.</summary>
274
- public static void CheckExpectedDocument(JsonElement args, Document doc)
275
- {
276
- string? expected = JsonArgs.GetString(args, "expected_document");
277
- if (string.IsNullOrWhiteSpace(expected))
278
- return;
279
- string actual = doc.Title;
280
- if (Matches(expected, actual))
281
- return;
282
- throw new ArgumentException(
283
- $"Active document is '{actual}' but this call expected '{expected}'. Nothing was changed. "
284
- + "The user switched models (or several are open); re-read the target model (get_model_overview) and retry against the right one.");
285
- }
286
-
287
- private static bool Matches(string expected, string actual)
288
- {
289
- static string Normalize(string value)
290
- {
291
- string v = value.Trim();
292
- if (v.EndsWith(".rvt", StringComparison.OrdinalIgnoreCase))
293
- v = v[..^4];
294
- if (v.EndsWith("_detached", StringComparison.OrdinalIgnoreCase))
295
- v = v[..^"_detached".Length];
296
- return v;
297
- }
298
- return string.Equals(Normalize(expected), Normalize(actual), StringComparison.OrdinalIgnoreCase);
299
- }
300
- }
301
- }
1
+ using System.Text.Json;
2
+ using Autodesk.Revit.DB;
3
+
4
+ namespace RevitBridge.Tools
5
+ {
6
+ /// <summary>
7
+ /// Resolves a user-facing category string (friendly alias, category display name,
8
+ /// or BuiltInCategory enum name like OST_Walls) to a category ElementId usable
9
+ /// with FilteredElementCollector.OfCategoryId. Shared by the query tools.
10
+ /// </summary>
11
+ internal static class CategoryResolver
12
+ {
13
+ private static readonly IReadOnlyDictionary<string, BuiltInCategory> Aliases = new Dictionary<string, BuiltInCategory>(StringComparer.OrdinalIgnoreCase)
14
+ {
15
+ ["wall"] = BuiltInCategory.OST_Walls,
16
+ ["walls"] = BuiltInCategory.OST_Walls,
17
+ ["door"] = BuiltInCategory.OST_Doors,
18
+ ["doors"] = BuiltInCategory.OST_Doors,
19
+ ["window"] = BuiltInCategory.OST_Windows,
20
+ ["windows"] = BuiltInCategory.OST_Windows,
21
+ ["floor"] = BuiltInCategory.OST_Floors,
22
+ ["floors"] = BuiltInCategory.OST_Floors,
23
+ ["room"] = BuiltInCategory.OST_Rooms,
24
+ ["rooms"] = BuiltInCategory.OST_Rooms,
25
+ ["area"] = BuiltInCategory.OST_Areas,
26
+ ["areas"] = BuiltInCategory.OST_Areas,
27
+ ["column"] = BuiltInCategory.OST_Columns,
28
+ ["columns"] = BuiltInCategory.OST_Columns,
29
+ ["roof"] = BuiltInCategory.OST_Roofs,
30
+ ["roofs"] = BuiltInCategory.OST_Roofs,
31
+ ["stair"] = BuiltInCategory.OST_Stairs,
32
+ ["stairs"] = BuiltInCategory.OST_Stairs,
33
+ ["ceiling"] = BuiltInCategory.OST_Ceilings,
34
+ ["ceilings"] = BuiltInCategory.OST_Ceilings,
35
+ ["furniture"] = BuiltInCategory.OST_Furniture,
36
+ ["pipe"] = BuiltInCategory.OST_PipeCurves,
37
+ ["pipes"] = BuiltInCategory.OST_PipeCurves,
38
+ ["duct"] = BuiltInCategory.OST_DuctCurves,
39
+ ["ducts"] = BuiltInCategory.OST_DuctCurves,
40
+ ["beam"] = BuiltInCategory.OST_StructuralFraming,
41
+ ["beams"] = BuiltInCategory.OST_StructuralFraming,
42
+ ["structural framing"] = BuiltInCategory.OST_StructuralFraming,
43
+ ["generic model"] = BuiltInCategory.OST_GenericModel,
44
+ ["generic models"] = BuiltInCategory.OST_GenericModel,
45
+ ["level"] = BuiltInCategory.OST_Levels,
46
+ ["levels"] = BuiltInCategory.OST_Levels,
47
+ ["grid"] = BuiltInCategory.OST_Grids,
48
+ ["grids"] = BuiltInCategory.OST_Grids,
49
+ ["sheet"] = BuiltInCategory.OST_Sheets,
50
+ ["sheets"] = BuiltInCategory.OST_Sheets,
51
+ ["view"] = BuiltInCategory.OST_Views,
52
+ ["views"] = BuiltInCategory.OST_Views,
53
+ };
54
+
55
+ public static ElementId Resolve(Document doc, string input)
56
+ {
57
+ string trimmed = input.Trim();
58
+ if (trimmed.Length == 0)
59
+ throw new ArgumentException("category must be a non-empty string.");
60
+
61
+ if (Aliases.TryGetValue(trimmed, out var alias))
62
+ return new ElementId(alias);
63
+ if (TryParseBuiltInCategory(trimmed, out var builtIn))
64
+ return new ElementId(builtIn);
65
+ if (TryParseBuiltInCategory("OST_" + trimmed.Replace(" ", string.Empty), out builtIn))
66
+ return new ElementId(builtIn);
67
+
68
+ foreach (Category category in doc.Settings.Categories)
69
+ {
70
+ if (string.Equals(category.Name, trimmed, StringComparison.OrdinalIgnoreCase))
71
+ return category.Id;
72
+ }
73
+
74
+ throw new ArgumentException($"Unknown category: {input}. Use a BuiltInCategory enum name (e.g. OST_Walls) or a category display name as shown in Revit (e.g. Walls, Doors, Sheets, Views).");
75
+ }
76
+
77
+ private static bool TryParseBuiltInCategory(string text, out BuiltInCategory category)
78
+ {
79
+ category = BuiltInCategory.INVALID;
80
+ return text.Length > 0
81
+ && char.IsLetter(text[0])
82
+ && Enum.TryParse(text, true, out category)
83
+ && category != BuiltInCategory.INVALID;
84
+ }
85
+ }
86
+
87
+ /// <summary>
88
+ /// Resolves a Revit API class name ("Wall", "ViewSheet", "WallType", or a full
89
+ /// name like "Autodesk.Revit.DB.Mechanical.Duct") to its Type for OfClass filters.
90
+ /// </summary>
91
+ internal static class ElementClassResolver
92
+ {
93
+ private static readonly Lazy<IReadOnlyDictionary<string, Type>> BySimpleName = new(BuildSimpleNameMap);
94
+
95
+ public static Type Resolve(string input)
96
+ {
97
+ string trimmed = input.Trim();
98
+ var assembly = typeof(Element).Assembly;
99
+ Type? match = trimmed.Contains('.') ? assembly.GetType(trimmed, false, true) : null;
100
+ match ??= assembly.GetType("Autodesk.Revit.DB." + trimmed, false, true);
101
+ if (match is null)
102
+ BySimpleName.Value.TryGetValue(trimmed, out match);
103
+
104
+ if (match is null || !typeof(Element).IsAssignableFrom(match))
105
+ throw new ArgumentException($"Unknown Revit element class: {input}. Use an Element subclass from the Revit API (e.g. Wall, FamilyInstance, ViewSheet, Level, WallType) or a full name like Autodesk.Revit.DB.Mechanical.Duct.");
106
+ if (match == typeof(Element))
107
+ throw new ArgumentException("of_class must be a concrete Element subclass, not Element itself.");
108
+ return match;
109
+ }
110
+
111
+ private static IReadOnlyDictionary<string, Type> BuildSimpleNameMap()
112
+ {
113
+ Type[] types;
114
+ try
115
+ {
116
+ types = typeof(Element).Assembly.GetTypes();
117
+ }
118
+ catch (System.Reflection.ReflectionTypeLoadException ex)
119
+ {
120
+ types = ex.Types.OfType<Type>().ToArray();
121
+ }
122
+
123
+ var map = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
124
+ foreach (var type in types)
125
+ {
126
+ if (!type.IsPublic || !typeof(Element).IsAssignableFrom(type))
127
+ continue;
128
+ // Prefer the root Autodesk.Revit.DB namespace when simple names collide.
129
+ if (!map.TryGetValue(type.Name, out var existing) || (type.Namespace == "Autodesk.Revit.DB" && existing.Namespace != "Autodesk.Revit.DB"))
130
+ map[type.Name] = type;
131
+ }
132
+ return map;
133
+ }
134
+ }
135
+
136
+ /// <summary>
137
+ /// Resolves friendly forge unit names ("millimeters", "squareMeters") or full forge
138
+ /// ids ("autodesk.unit.unit:millimeters-1.0.1") to UnitTypeId values, and back.
139
+ /// </summary>
140
+ internal static class UnitResolver
141
+ {
142
+ private static readonly Lazy<IReadOnlyDictionary<string, ForgeTypeId>> ByName = new(Build);
143
+
144
+ public static ForgeTypeId Resolve(string unit)
145
+ {
146
+ string trimmed = unit.Trim();
147
+ if (ByName.Value.TryGetValue(trimmed, out var match))
148
+ return match;
149
+ throw new ArgumentException($"Unknown unit: {unit}. Use a forge unit name like millimeters, meters, feet, inches, squareMeters, cubicFeet, degrees.");
150
+ }
151
+
152
+ /// <summary>"autodesk.unit.unit:millimeters-1.0.1" -> "millimeters".</summary>
153
+ public static string ShortName(ForgeTypeId unit)
154
+ {
155
+ string id = unit.TypeId ?? string.Empty;
156
+ int colon = id.IndexOf(':');
157
+ string tail = colon >= 0 ? id[(colon + 1)..] : id;
158
+ int dash = tail.IndexOf('-');
159
+ return dash >= 0 ? tail[..dash] : tail;
160
+ }
161
+
162
+ private static IReadOnlyDictionary<string, ForgeTypeId> Build()
163
+ {
164
+ var map = new Dictionary<string, ForgeTypeId>(StringComparer.OrdinalIgnoreCase);
165
+ foreach (ForgeTypeId unit in UnitUtils.GetAllUnits())
166
+ {
167
+ map[ShortName(unit)] = unit;
168
+ map[unit.TypeId] = unit;
169
+ }
170
+ return map;
171
+ }
172
+ }
173
+
174
+ /// <summary>Identity-only element row shared by the query tools.</summary>
175
+ internal static class ElementIdentity
176
+ {
177
+ public static readonly IReadOnlyList<string> Fields = new[] { "id", "name", "category", "typeName", "levelId" };
178
+
179
+ public static Dictionary<string, object?> Build(Document doc, Element element, IReadOnlyList<string> fields)
180
+ {
181
+ var row = new Dictionary<string, object?>(fields.Count);
182
+ foreach (string field in fields)
183
+ {
184
+ row[field] = field switch
185
+ {
186
+ "id" => (object?)element.Id.Value,
187
+ "name" => element.Name,
188
+ "category" => element.Category?.Name,
189
+ "typeName" => doc.GetElement(element.GetTypeId())?.Name,
190
+ "levelId" => element.LevelId is { } levelId && levelId != ElementId.InvalidElementId ? levelId.Value : (long?)null,
191
+ _ => null,
192
+ };
193
+ }
194
+ return row;
195
+ }
196
+ }
197
+
198
+ /// <summary>Small JsonElement argument readers shared by the tools.</summary>
199
+ internal static class JsonArgs
200
+ {
201
+ public static string? GetString(JsonElement args, string name)
202
+ => args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
203
+ ? value.GetString()
204
+ : null;
205
+
206
+ public static bool GetBool(JsonElement args, string name, bool fallback)
207
+ => args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False
208
+ ? value.GetBoolean()
209
+ : fallback;
210
+
211
+ public static int GetInt(JsonElement args, string name, int fallback)
212
+ => args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out int parsed)
213
+ ? parsed
214
+ : fallback;
215
+
216
+ public static long? GetLong(JsonElement args, string name)
217
+ {
218
+ if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var value))
219
+ return null;
220
+ if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out long fromNumber))
221
+ return fromNumber;
222
+ if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out long fromString))
223
+ return fromString;
224
+ return null;
225
+ }
226
+
227
+ public static List<long> GetLongArray(JsonElement args, string name)
228
+ {
229
+ if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
230
+ throw new ArgumentException($"Missing or invalid required field: {name} must be an array of element ids.");
231
+
232
+ var result = new List<long>(value.GetArrayLength());
233
+ foreach (var item in value.EnumerateArray())
234
+ {
235
+ if (item.ValueKind == JsonValueKind.Number && item.TryGetInt64(out long fromNumber))
236
+ result.Add(fromNumber);
237
+ else if (item.ValueKind == JsonValueKind.String && long.TryParse(item.GetString(), out long fromString))
238
+ result.Add(fromString);
239
+ else
240
+ throw new ArgumentException($"{name} must contain only integer element ids.");
241
+ }
242
+ return result;
243
+ }
244
+
245
+ public static List<string>? GetStringArray(JsonElement args, string name)
246
+ {
247
+ if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Array)
248
+ return null;
249
+
250
+ var result = new List<string>(value.GetArrayLength());
251
+ foreach (var item in value.EnumerateArray())
252
+ {
253
+ string? text = item.ValueKind == JsonValueKind.String ? item.GetString() : null;
254
+ if (string.IsNullOrWhiteSpace(text))
255
+ throw new ArgumentException($"{name} must contain only non-empty strings.");
256
+ result.Add(text.Trim());
257
+ }
258
+ return result;
259
+ }
260
+ }
261
+
262
+ /// <summary>
263
+ /// Optional wrong-document protection for write tools. A write queued while the
264
+ /// user switches models would otherwise land in whichever document is active when
265
+ /// the queued call runs -- possibly silently, since low element ids resolve in
266
+ /// most documents. Tools pass the caller's optional expected_document through
267
+ /// here before touching the model.
268
+ /// </summary>
269
+ internal static class DocumentGuard
270
+ {
271
+ /// <summary>Throws when expected_document is provided and does not match the
272
+ /// active document's title (case-insensitive; the .rvt extension and a
273
+ /// detached suffix are tolerated). No-op when the argument is absent.</summary>
274
+ public static void CheckExpectedDocument(JsonElement args, Document doc)
275
+ {
276
+ string? expected = JsonArgs.GetString(args, "expected_document");
277
+ if (string.IsNullOrWhiteSpace(expected))
278
+ return;
279
+ string actual = doc.Title;
280
+ if (Matches(expected, actual))
281
+ return;
282
+ throw new ArgumentException(
283
+ $"Active document is '{actual}' but this call expected '{expected}'. Nothing was changed. "
284
+ + "The user switched models (or several are open); re-read the target model (get_model_overview) and retry against the right one.");
285
+ }
286
+
287
+ private static bool Matches(string expected, string actual)
288
+ {
289
+ static string Normalize(string value)
290
+ {
291
+ string v = value.Trim();
292
+ if (v.EndsWith(".rvt", StringComparison.OrdinalIgnoreCase))
293
+ v = v[..^4];
294
+ if (v.EndsWith("_detached", StringComparison.OrdinalIgnoreCase))
295
+ v = v[..^"_detached".Length];
296
+ return v;
297
+ }
298
+ return string.Equals(Normalize(expected), Normalize(actual), StringComparison.OrdinalIgnoreCase);
299
+ }
300
+ }
301
+ }
@@ -1,12 +1,12 @@
1
- @echo off
2
- rem pi-revit: open Pi in the pi-revit workspace from anywhere. All arguments are
3
- rem passed straight to pi (e.g. pi-revit -c continues the last session).
4
- rem Model output needs no foldering decision: the Revit tools file each export
5
- rem under Models\<model title>\ automatically, keyed by the document it came from.
6
- rem Installed next to the pi command by scripts\setup-workspace.ps1, which rewrites
7
- rem the WORKSPACE line below to the chosen -WorkspaceDir.
8
-
9
- set "WORKSPACE=%USERPROFILE%\Documents\pi-revit"
10
-
11
- cd /d "%WORKSPACE%"
12
- pi %*
1
+ @echo off
2
+ rem pi-revit: open Pi in the pi-revit workspace from anywhere. All arguments are
3
+ rem passed straight to pi (e.g. pi-revit -c continues the last session).
4
+ rem Model output needs no foldering decision: the Revit tools file each export
5
+ rem under Models\<model title>\ automatically, keyed by the document it came from.
6
+ rem Installed next to the pi command by scripts\setup-workspace.ps1, which rewrites
7
+ rem the WORKSPACE line below to the chosen -WorkspaceDir.
8
+
9
+ set "WORKSPACE=%USERPROFILE%\Documents\pi-revit"
10
+
11
+ cd /d "%WORKSPACE%"
12
+ pi %*