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,725 @@
|
|
|
1
|
+
using System.Globalization;
|
|
2
|
+
using System.Text.Json;
|
|
3
|
+
using System.Text.RegularExpressions;
|
|
4
|
+
using Autodesk.Revit.DB;
|
|
5
|
+
|
|
6
|
+
namespace RevitBridge.Tools
|
|
7
|
+
{
|
|
8
|
+
/// <summary>
|
|
9
|
+
/// The generic FilteredElementCollector primitive: category/class/level/type/view
|
|
10
|
+
/// scoping plus parameter filter rules. Rules run inside Revit's collector via
|
|
11
|
+
/// ElementParameterFilter where the API supports it; regex rules (and rules the
|
|
12
|
+
/// collector cannot quick-filter) are evaluated as a post-collector scan over the
|
|
13
|
+
/// already-narrowed set.
|
|
14
|
+
/// </summary>
|
|
15
|
+
internal sealed class GetElements : ITool
|
|
16
|
+
{
|
|
17
|
+
private const int DefaultLimit = 200;
|
|
18
|
+
private const int MaxLimit = 1000;
|
|
19
|
+
private const int ProbeSize = 50;
|
|
20
|
+
private const double Epsilon = 1e-6;
|
|
21
|
+
|
|
22
|
+
public string Name => "get_elements";
|
|
23
|
+
public string Label => "Get Elements";
|
|
24
|
+
public string Description => "Query Revit elements: scope by category (display name like 'Walls' or enum name like 'OST_Walls'), element class, level, type id, or active view; filter by parameter rules; paginate with offset/limit or just count with count_only. Returns identity fields only (id, name, category, typeName, levelId) — read parameter values with get_element_details. Most filter rules evaluate inside Revit's collector; 'regex' rules (and rules on parameters that cannot be quick-filtered) run as a slower post-collector scan. Prefer combining display-name filter rules with a category or of_class scope: the same display name (e.g. 'Width') can resolve to different parameters per category, which forces the slower per-element scan in unscoped queries. Numeric rule values are interpreted in the document's display units for that parameter unless 'unit' is given.";
|
|
25
|
+
|
|
26
|
+
public object ParametersSchema => new
|
|
27
|
+
{
|
|
28
|
+
type = "object",
|
|
29
|
+
properties = new
|
|
30
|
+
{
|
|
31
|
+
category = new
|
|
32
|
+
{
|
|
33
|
+
type = "string",
|
|
34
|
+
description = "Category: BuiltInCategory enum name (e.g. OST_Walls) or display name (e.g. Walls, Doors, Rooms, Sheets, Views).",
|
|
35
|
+
},
|
|
36
|
+
of_class = new
|
|
37
|
+
{
|
|
38
|
+
type = "string",
|
|
39
|
+
description = "Revit API element class, e.g. Wall, FamilyInstance, ViewSheet, Level, or a full name like Autodesk.Revit.DB.Mechanical.Duct.",
|
|
40
|
+
},
|
|
41
|
+
filter = new
|
|
42
|
+
{
|
|
43
|
+
type = "object",
|
|
44
|
+
description = "Parameter filter. Numeric rule values are read in the document's display units for that parameter unless 'unit' is set.",
|
|
45
|
+
properties = new
|
|
46
|
+
{
|
|
47
|
+
match = new
|
|
48
|
+
{
|
|
49
|
+
type = "string",
|
|
50
|
+
@enum = new[] { "all", "any" },
|
|
51
|
+
description = "Combine rules with AND (all, default) or OR (any).",
|
|
52
|
+
},
|
|
53
|
+
rules = new
|
|
54
|
+
{
|
|
55
|
+
type = "array",
|
|
56
|
+
items = new
|
|
57
|
+
{
|
|
58
|
+
type = "object",
|
|
59
|
+
properties = new
|
|
60
|
+
{
|
|
61
|
+
param = new { type = "string", description = "Parameter display name, BuiltInParameter enum name (e.g. WALL_BASE_OFFSET), or guid:<GUID> for a shared parameter." },
|
|
62
|
+
op = new { type = "string", @enum = new[] { "equals", "not_equals", "greater", "greater_or_equal", "less", "less_or_equal", "contains", "is_empty", "is_not_empty", "regex" } },
|
|
63
|
+
value = new { description = "Comparison value (string, number, or bool). Regex pattern for op=regex. Unused for is_empty/is_not_empty." },
|
|
64
|
+
unit = new { type = "string", description = "Unit of a numeric value, e.g. millimeters, meters, feet, squareMeters. Default: the document's display units." },
|
|
65
|
+
},
|
|
66
|
+
required = new[] { "param", "op" },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
required = new[] { "rules" },
|
|
71
|
+
},
|
|
72
|
+
level = new { type = "string", description = "Only elements hosted on this level (level name or id)." },
|
|
73
|
+
type_id = new { type = "integer", description = "Only instances of this element type id (see get_element_types)." },
|
|
74
|
+
in_active_view = new { type = "boolean", description = "Only elements visible in the active view. Default false." },
|
|
75
|
+
count_only = new { type = "boolean", description = "Return only total_count, no element rows. Default false." },
|
|
76
|
+
fields = new
|
|
77
|
+
{
|
|
78
|
+
type = "array",
|
|
79
|
+
items = new { type = "string", @enum = new[] { "id", "name", "category", "typeName", "levelId" } },
|
|
80
|
+
description = "Identity fields per row; default all five. id is always included. Parameter values live in get_element_details.",
|
|
81
|
+
},
|
|
82
|
+
offset = new { type = "integer", description = "Pagination offset. Default 0." },
|
|
83
|
+
limit = new { type = "integer", description = "Max rows to return (1-1000). Default 200." },
|
|
84
|
+
},
|
|
85
|
+
required = Array.Empty<string>(),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
public string? PromptSnippet => "Query or count Revit elements of any category with parameter filters and pagination.";
|
|
89
|
+
public IReadOnlyList<string>? PromptGuidelines => new[]
|
|
90
|
+
{
|
|
91
|
+
"Use get_elements to list or count elements of any category or class (walls, doors, rooms, sheets, views, ...); use the returned ids with the other Revit tools.",
|
|
92
|
+
"get_elements returns identity fields only; read parameter values with get_element_details.",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
public object? Execute(JsonElement args, ToolContext context)
|
|
96
|
+
{
|
|
97
|
+
var doc = context.Document ?? throw new NoActiveDocumentException();
|
|
98
|
+
|
|
99
|
+
string? categoryInput = JsonArgs.GetString(args, "category");
|
|
100
|
+
string? classInput = JsonArgs.GetString(args, "of_class");
|
|
101
|
+
bool inActiveView = JsonArgs.GetBool(args, "in_active_view", false);
|
|
102
|
+
bool countOnly = JsonArgs.GetBool(args, "count_only", false)
|
|
103
|
+
|| string.Equals(JsonArgs.GetString(args, "mode"), "count", StringComparison.OrdinalIgnoreCase);
|
|
104
|
+
int offset = Math.Max(0, JsonArgs.GetInt(args, "offset", 0));
|
|
105
|
+
int limit = Math.Clamp(JsonArgs.GetInt(args, "limit", DefaultLimit), 1, MaxLimit);
|
|
106
|
+
var fields = ResolveFields(args);
|
|
107
|
+
|
|
108
|
+
ElementId? categoryId = string.IsNullOrWhiteSpace(categoryInput) ? null : CategoryResolver.Resolve(doc, categoryInput);
|
|
109
|
+
Type? elementClass = string.IsNullOrWhiteSpace(classInput) ? null : ElementClassResolver.Resolve(classInput);
|
|
110
|
+
ElementId? levelId = ResolveLevel(doc, args);
|
|
111
|
+
long? typeId = JsonArgs.GetLong(args, "type_id");
|
|
112
|
+
ElementId? activeViewId = null;
|
|
113
|
+
if (inActiveView)
|
|
114
|
+
{
|
|
115
|
+
activeViewId = doc.ActiveView?.Id
|
|
116
|
+
?? throw new ArgumentException("in_active_view requires an active graphical view in Revit.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
FilteredElementCollector CreateBaseCollector()
|
|
120
|
+
{
|
|
121
|
+
var collector = activeViewId != null
|
|
122
|
+
? new FilteredElementCollector(doc, activeViewId)
|
|
123
|
+
: new FilteredElementCollector(doc);
|
|
124
|
+
if (categoryId != null)
|
|
125
|
+
collector = collector.OfCategoryId(categoryId);
|
|
126
|
+
if (elementClass != null)
|
|
127
|
+
collector = collector.OfClass(elementClass);
|
|
128
|
+
collector = collector.WhereElementIsNotElementType();
|
|
129
|
+
if (levelId != null)
|
|
130
|
+
collector = collector.WherePasses(new ElementLevelFilter(levelId));
|
|
131
|
+
if (typeId is { } type)
|
|
132
|
+
{
|
|
133
|
+
collector = collector.WherePasses(new ElementParameterFilter(
|
|
134
|
+
ParameterFilterRuleFactory.CreateEqualsRule(new ElementId(BuiltInParameter.ELEM_TYPE_PARAM), new ElementId(type))));
|
|
135
|
+
}
|
|
136
|
+
return collector;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
var (quickFilter, postPredicate) = BuildParameterFilter(doc, args, CreateBaseCollector);
|
|
140
|
+
|
|
141
|
+
FilteredElementCollector CreateCollector()
|
|
142
|
+
{
|
|
143
|
+
var collector = CreateBaseCollector();
|
|
144
|
+
return quickFilter != null ? collector.WherePasses(quickFilter) : collector;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
string scope = DescribeScope(categoryInput, classInput, inActiveView);
|
|
148
|
+
|
|
149
|
+
if (countOnly && postPredicate is null)
|
|
150
|
+
return CountResult(scope, CreateCollector().GetElementCount());
|
|
151
|
+
|
|
152
|
+
var rows = countOnly ? null : new List<Dictionary<string, object?>>(Math.Min(limit, 256));
|
|
153
|
+
int total = 0;
|
|
154
|
+
foreach (Element element in CreateCollector())
|
|
155
|
+
{
|
|
156
|
+
if (postPredicate != null && !postPredicate(element))
|
|
157
|
+
continue;
|
|
158
|
+
if (rows != null && total >= offset && rows.Count < limit)
|
|
159
|
+
rows.Add(ElementIdentity.Build(doc, element, fields));
|
|
160
|
+
total++;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (rows is null)
|
|
164
|
+
return CountResult(scope, total);
|
|
165
|
+
|
|
166
|
+
bool hasMore = offset + rows.Count < total;
|
|
167
|
+
int? nextOffset = hasMore ? offset + rows.Count : null;
|
|
168
|
+
|
|
169
|
+
string sampleText = string.Empty;
|
|
170
|
+
if (rows.Count > 0 && fields.Contains("name"))
|
|
171
|
+
{
|
|
172
|
+
var sample = rows.Take(3).Select(row => $"'{row["name"]}' ({row["id"]})");
|
|
173
|
+
sampleText = $" Sample: {string.Join(", ", sample)}.";
|
|
174
|
+
}
|
|
175
|
+
string compact = hasMore
|
|
176
|
+
? $"{scope}: {total} total, returned {rows.Count} at offset {offset}, has_more (next_offset {nextOffset}).{sampleText}"
|
|
177
|
+
: $"{scope}: {total} total, returned {rows.Count} at offset {offset}.{sampleText}";
|
|
178
|
+
|
|
179
|
+
return new ToolOutput(new
|
|
180
|
+
{
|
|
181
|
+
total_count = total,
|
|
182
|
+
returned_count = rows.Count,
|
|
183
|
+
offset,
|
|
184
|
+
has_more = hasMore,
|
|
185
|
+
next_offset = nextOffset,
|
|
186
|
+
elements = rows,
|
|
187
|
+
}, compact);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private static ToolOutput CountResult(string scope, int count)
|
|
191
|
+
=> new(new { total_count = count, count_only = true }, $"{scope}: {count} elements match.");
|
|
192
|
+
|
|
193
|
+
private static string DescribeScope(string? category, string? className, bool inActiveView)
|
|
194
|
+
{
|
|
195
|
+
string scope = !string.IsNullOrWhiteSpace(category) ? category.Trim()
|
|
196
|
+
: !string.IsNullOrWhiteSpace(className) ? className.Trim()
|
|
197
|
+
: "elements";
|
|
198
|
+
return inActiveView ? scope + " in active view" : scope;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private static IReadOnlyList<string> ResolveFields(JsonElement args)
|
|
202
|
+
{
|
|
203
|
+
var requested = JsonArgs.GetStringArray(args, "fields");
|
|
204
|
+
if (requested is null || requested.Count == 0)
|
|
205
|
+
return ElementIdentity.Fields;
|
|
206
|
+
|
|
207
|
+
foreach (string field in requested)
|
|
208
|
+
{
|
|
209
|
+
if (!ElementIdentity.Fields.Any(known => string.Equals(known, field, StringComparison.OrdinalIgnoreCase)))
|
|
210
|
+
throw new ArgumentException($"Unknown field: {field}. Identity fields only: {string.Join(", ", ElementIdentity.Fields)}. Read parameter values with get_element_details.");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
var canonical = new List<string> { "id" };
|
|
214
|
+
foreach (string field in ElementIdentity.Fields)
|
|
215
|
+
{
|
|
216
|
+
if (field != "id" && requested.Any(r => string.Equals(r, field, StringComparison.OrdinalIgnoreCase)))
|
|
217
|
+
canonical.Add(field);
|
|
218
|
+
}
|
|
219
|
+
return canonical;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private static ElementId? ResolveLevel(Document doc, JsonElement args)
|
|
223
|
+
{
|
|
224
|
+
if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty("level", out var value) || value.ValueKind == JsonValueKind.Null)
|
|
225
|
+
return null;
|
|
226
|
+
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out long fromNumber))
|
|
227
|
+
return EnsureLevel(doc, new ElementId(fromNumber));
|
|
228
|
+
if (value.ValueKind == JsonValueKind.String)
|
|
229
|
+
{
|
|
230
|
+
string text = value.GetString()?.Trim() ?? string.Empty;
|
|
231
|
+
if (text.Length == 0)
|
|
232
|
+
return null;
|
|
233
|
+
if (long.TryParse(text, out long fromString))
|
|
234
|
+
return EnsureLevel(doc, new ElementId(fromString));
|
|
235
|
+
|
|
236
|
+
var levels = new FilteredElementCollector(doc).OfClass(typeof(Level)).Cast<Level>().ToList();
|
|
237
|
+
var match = levels.FirstOrDefault(level => string.Equals(level.Name, text, StringComparison.OrdinalIgnoreCase));
|
|
238
|
+
if (match != null)
|
|
239
|
+
return match.Id;
|
|
240
|
+
throw new ArgumentException($"Unknown level: {text}. Available levels: {string.Join(", ", levels.OrderBy(l => l.Elevation).Select(l => l.Name))}");
|
|
241
|
+
}
|
|
242
|
+
throw new ArgumentException("level must be a level name or a level id.");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private static ElementId EnsureLevel(Document doc, ElementId id)
|
|
246
|
+
=> doc.GetElement(id) is Level
|
|
247
|
+
? id
|
|
248
|
+
: throw new ArgumentException($"Element {id.Value} is not a Level.");
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------- filters
|
|
251
|
+
|
|
252
|
+
private enum RuleOp { Equals, NotEquals, Greater, GreaterOrEqual, Less, LessOrEqual, Contains, IsEmpty, IsNotEmpty, Regex }
|
|
253
|
+
|
|
254
|
+
private sealed class Rule
|
|
255
|
+
{
|
|
256
|
+
public required string ParamInput { get; init; }
|
|
257
|
+
public required RuleOp Op { get; init; }
|
|
258
|
+
public JsonElement Value { get; init; }
|
|
259
|
+
public string? Unit { get; init; }
|
|
260
|
+
public BuiltInParameter? BuiltIn { get; init; }
|
|
261
|
+
public Guid? SharedGuid { get; init; }
|
|
262
|
+
public Regex? CompiledRegex { get; init; }
|
|
263
|
+
public FilterRule? QuickRule { get; set; }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private static (ElementFilter? Quick, Func<Element, bool>? Post) BuildParameterFilter(
|
|
267
|
+
Document doc, JsonElement args, Func<FilteredElementCollector> createBaseCollector)
|
|
268
|
+
{
|
|
269
|
+
if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty("filter", out var filterElement) || filterElement.ValueKind != JsonValueKind.Object)
|
|
270
|
+
return (null, null);
|
|
271
|
+
|
|
272
|
+
bool matchAny = string.Equals(JsonArgs.GetString(filterElement, "match"), "any", StringComparison.OrdinalIgnoreCase);
|
|
273
|
+
if (!filterElement.TryGetProperty("rules", out var rulesElement) || rulesElement.ValueKind != JsonValueKind.Array)
|
|
274
|
+
throw new ArgumentException("filter.rules must be an array of { param, op, value?, unit? } rules.");
|
|
275
|
+
|
|
276
|
+
var rules = rulesElement.EnumerateArray().Select(ParseRule).ToList();
|
|
277
|
+
if (rules.Count == 0)
|
|
278
|
+
return (null, null);
|
|
279
|
+
|
|
280
|
+
// Probe a few in-scope elements so display-name parameters resolve to ids
|
|
281
|
+
// and value typing / unit conversion can use the parameter's storage + spec.
|
|
282
|
+
var probes = new List<Element>(ProbeSize);
|
|
283
|
+
foreach (Element element in createBaseCollector())
|
|
284
|
+
{
|
|
285
|
+
probes.Add(element);
|
|
286
|
+
if (probes.Count >= ProbeSize)
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
foreach (var rule in rules)
|
|
291
|
+
{
|
|
292
|
+
if (rule.Op is RuleOp.Regex or RuleOp.IsEmpty or RuleOp.IsNotEmpty)
|
|
293
|
+
continue; // always post-scan (missing-parameter semantics)
|
|
294
|
+
var found = probes.Select(probe => FindParameter(probe, rule)).Where(parameter => parameter != null).ToList();
|
|
295
|
+
if (found.Count == 0)
|
|
296
|
+
continue;
|
|
297
|
+
// BuiltInParameter/guid rules address one global parameter id. A plain
|
|
298
|
+
// display name can resolve to DIFFERENT ids per category or family
|
|
299
|
+
// (e.g. 'Width' -> DOOR_WIDTH vs WINDOW_WIDTH), and a quick rule pinned
|
|
300
|
+
// to one exemplar's id would silently drop every other category's
|
|
301
|
+
// matches. Promote only when all probed elements agree on the id;
|
|
302
|
+
// otherwise the rule stays on the (per-element, correct) post-scan path.
|
|
303
|
+
bool oneGlobalId = rule.BuiltIn != null || rule.SharedGuid != null
|
|
304
|
+
|| found.All(parameter => parameter!.Id == found[0]!.Id);
|
|
305
|
+
if (oneGlobalId)
|
|
306
|
+
rule.QuickRule = TryBuildQuickRule(doc, rule, found[0]!);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
var quickRules = rules.Where(rule => rule.QuickRule != null).ToList();
|
|
310
|
+
var postRules = rules.Where(rule => rule.QuickRule is null).ToList();
|
|
311
|
+
|
|
312
|
+
if (matchAny)
|
|
313
|
+
{
|
|
314
|
+
// OR with any post-scan rule means everything must be post-scanned: a
|
|
315
|
+
// collector-level OR filter would wrongly exclude post-rule-only matches.
|
|
316
|
+
if (postRules.Count > 0)
|
|
317
|
+
return (null, element => rules.Any(rule => EvaluatePost(doc, element, rule)));
|
|
318
|
+
|
|
319
|
+
var filters = quickRules.Select(rule => (ElementFilter)new ElementParameterFilter(rule.QuickRule!)).ToList();
|
|
320
|
+
return (filters.Count == 1 ? filters[0] : new LogicalOrFilter(filters), null);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
ElementFilter? quick = quickRules.Count > 0
|
|
324
|
+
? new ElementParameterFilter(quickRules.Select(rule => rule.QuickRule!).ToList())
|
|
325
|
+
: null;
|
|
326
|
+
Func<Element, bool>? post = postRules.Count > 0
|
|
327
|
+
? element => postRules.All(rule => EvaluatePost(doc, element, rule))
|
|
328
|
+
: null;
|
|
329
|
+
return (quick, post);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private static Rule ParseRule(JsonElement element)
|
|
333
|
+
{
|
|
334
|
+
if (element.ValueKind != JsonValueKind.Object)
|
|
335
|
+
throw new ArgumentException("Each filter rule must be an object with param and op.");
|
|
336
|
+
|
|
337
|
+
string param = JsonArgs.GetString(element, "param")?.Trim()
|
|
338
|
+
?? throw new ArgumentException("Each filter rule needs a param name.");
|
|
339
|
+
string opText = JsonArgs.GetString(element, "op")
|
|
340
|
+
?? throw new ArgumentException($"Filter rule on '{param}' needs an op.");
|
|
341
|
+
|
|
342
|
+
RuleOp op = opText.Trim().ToLowerInvariant() switch
|
|
343
|
+
{
|
|
344
|
+
"equals" or "eq" => RuleOp.Equals,
|
|
345
|
+
"not_equals" or "ne" or "neq" => RuleOp.NotEquals,
|
|
346
|
+
"greater" or "gt" => RuleOp.Greater,
|
|
347
|
+
"greater_or_equal" or "gte" or "ge" => RuleOp.GreaterOrEqual,
|
|
348
|
+
"less" or "lt" => RuleOp.Less,
|
|
349
|
+
"less_or_equal" or "lte" or "le" => RuleOp.LessOrEqual,
|
|
350
|
+
"contains" => RuleOp.Contains,
|
|
351
|
+
"is_empty" => RuleOp.IsEmpty,
|
|
352
|
+
"is_not_empty" => RuleOp.IsNotEmpty,
|
|
353
|
+
"regex" => RuleOp.Regex,
|
|
354
|
+
_ => throw new ArgumentException($"Unknown filter op: {opText}. Supported: equals, not_equals, greater, greater_or_equal, less, less_or_equal, contains, is_empty, is_not_empty, regex."),
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
bool hasValue = element.TryGetProperty("value", out var value) && value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined);
|
|
358
|
+
if (!hasValue && op is not (RuleOp.IsEmpty or RuleOp.IsNotEmpty))
|
|
359
|
+
throw new ArgumentException($"Filter rule on '{param}' with op {opText} needs a value.");
|
|
360
|
+
|
|
361
|
+
BuiltInParameter? builtIn = null;
|
|
362
|
+
Guid? sharedGuid = null;
|
|
363
|
+
if (param.StartsWith("guid:", StringComparison.OrdinalIgnoreCase))
|
|
364
|
+
{
|
|
365
|
+
sharedGuid = Guid.TryParse(param["guid:".Length..], out var guid)
|
|
366
|
+
? guid
|
|
367
|
+
: throw new ArgumentException($"Invalid shared parameter guid: {param}");
|
|
368
|
+
}
|
|
369
|
+
else
|
|
370
|
+
{
|
|
371
|
+
string enumName = param.StartsWith("BuiltInParameter.", StringComparison.OrdinalIgnoreCase)
|
|
372
|
+
? param["BuiltInParameter.".Length..]
|
|
373
|
+
: param;
|
|
374
|
+
// BuiltInParameter names are SHOUTY_SNAKE_CASE; require an underscore or
|
|
375
|
+
// all-caps so plain display names like "Comments" never collide.
|
|
376
|
+
bool looksLikeEnumName = enumName.Length > 0 && char.IsLetter(enumName[0]) && !enumName.Contains(' ')
|
|
377
|
+
&& (enumName.Contains('_') || enumName.All(c => !char.IsLetter(c) || char.IsUpper(c)));
|
|
378
|
+
if (looksLikeEnumName && Enum.TryParse<BuiltInParameter>(enumName, true, out var parsed) && parsed != BuiltInParameter.INVALID)
|
|
379
|
+
builtIn = parsed;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
Regex? regex = null;
|
|
383
|
+
if (op == RuleOp.Regex)
|
|
384
|
+
{
|
|
385
|
+
string pattern = ValueAsString(value);
|
|
386
|
+
try
|
|
387
|
+
{
|
|
388
|
+
regex = new Regex(pattern, RegexOptions.None, TimeSpan.FromSeconds(1));
|
|
389
|
+
}
|
|
390
|
+
catch (ArgumentException ex)
|
|
391
|
+
{
|
|
392
|
+
throw new ArgumentException($"Invalid regex for '{param}': {ex.Message}");
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return new Rule
|
|
397
|
+
{
|
|
398
|
+
ParamInput = param,
|
|
399
|
+
Op = op,
|
|
400
|
+
Value = hasValue ? value : default,
|
|
401
|
+
Unit = JsonArgs.GetString(element, "unit"),
|
|
402
|
+
BuiltIn = builtIn,
|
|
403
|
+
SharedGuid = sharedGuid,
|
|
404
|
+
CompiledRegex = regex,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private static Parameter? FindParameter(Element element, Rule rule)
|
|
409
|
+
{
|
|
410
|
+
if (rule.BuiltIn is { } builtIn)
|
|
411
|
+
return element.get_Parameter(builtIn);
|
|
412
|
+
if (rule.SharedGuid is { } guid)
|
|
413
|
+
return element.get_Parameter(guid);
|
|
414
|
+
|
|
415
|
+
var direct = element.LookupParameter(rule.ParamInput);
|
|
416
|
+
if (direct != null)
|
|
417
|
+
return direct;
|
|
418
|
+
foreach (Parameter parameter in element.Parameters)
|
|
419
|
+
{
|
|
420
|
+
if (string.Equals(parameter.Definition?.Name, rule.ParamInput, StringComparison.OrdinalIgnoreCase))
|
|
421
|
+
return parameter;
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/// <summary>Builds a collector-level FilterRule, or null when the op/storage pair must post-scan.</summary>
|
|
427
|
+
private static FilterRule? TryBuildQuickRule(Document doc, Rule rule, Parameter exemplar)
|
|
428
|
+
{
|
|
429
|
+
ElementId paramId = exemplar.Id;
|
|
430
|
+
switch (exemplar.StorageType)
|
|
431
|
+
{
|
|
432
|
+
case StorageType.String:
|
|
433
|
+
{
|
|
434
|
+
string target = ValueAsString(rule.Value);
|
|
435
|
+
return rule.Op switch
|
|
436
|
+
{
|
|
437
|
+
RuleOp.Equals => ParameterFilterRuleFactory.CreateEqualsRule(paramId, target),
|
|
438
|
+
RuleOp.NotEquals => ParameterFilterRuleFactory.CreateNotEqualsRule(paramId, target),
|
|
439
|
+
RuleOp.Contains => ParameterFilterRuleFactory.CreateContainsRule(paramId, target),
|
|
440
|
+
RuleOp.Greater => ParameterFilterRuleFactory.CreateGreaterRule(paramId, target),
|
|
441
|
+
RuleOp.GreaterOrEqual => ParameterFilterRuleFactory.CreateGreaterOrEqualRule(paramId, target),
|
|
442
|
+
RuleOp.Less => ParameterFilterRuleFactory.CreateLessRule(paramId, target),
|
|
443
|
+
RuleOp.LessOrEqual => ParameterFilterRuleFactory.CreateLessOrEqualRule(paramId, target),
|
|
444
|
+
_ => null,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
case StorageType.Double:
|
|
448
|
+
{
|
|
449
|
+
if (rule.Op == RuleOp.Contains)
|
|
450
|
+
return null; // post-scan over the display string
|
|
451
|
+
if (!TryValueAsDouble(rule.Value, out double raw))
|
|
452
|
+
throw new ArgumentException($"Filter rule on numeric parameter '{exemplar.Definition?.Name}' needs a numeric value (got {rule.Value.GetRawText()}).");
|
|
453
|
+
double target = ToInternalUnits(doc, rule, exemplar, raw);
|
|
454
|
+
return rule.Op switch
|
|
455
|
+
{
|
|
456
|
+
RuleOp.Equals => ParameterFilterRuleFactory.CreateEqualsRule(paramId, target, Epsilon),
|
|
457
|
+
RuleOp.NotEquals => ParameterFilterRuleFactory.CreateNotEqualsRule(paramId, target, Epsilon),
|
|
458
|
+
RuleOp.Greater => ParameterFilterRuleFactory.CreateGreaterRule(paramId, target, Epsilon),
|
|
459
|
+
RuleOp.GreaterOrEqual => ParameterFilterRuleFactory.CreateGreaterOrEqualRule(paramId, target, Epsilon),
|
|
460
|
+
RuleOp.Less => ParameterFilterRuleFactory.CreateLessRule(paramId, target, Epsilon),
|
|
461
|
+
RuleOp.LessOrEqual => ParameterFilterRuleFactory.CreateLessOrEqualRule(paramId, target, Epsilon),
|
|
462
|
+
_ => null,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
case StorageType.Integer:
|
|
466
|
+
{
|
|
467
|
+
if (rule.Op == RuleOp.Contains)
|
|
468
|
+
return null;
|
|
469
|
+
if (!TryValueAsInt(rule.Value, out int target))
|
|
470
|
+
throw new ArgumentException($"Filter rule on integer parameter '{exemplar.Definition?.Name}' needs an integer or boolean value.");
|
|
471
|
+
return rule.Op switch
|
|
472
|
+
{
|
|
473
|
+
RuleOp.Equals => ParameterFilterRuleFactory.CreateEqualsRule(paramId, target),
|
|
474
|
+
RuleOp.NotEquals => ParameterFilterRuleFactory.CreateNotEqualsRule(paramId, target),
|
|
475
|
+
RuleOp.Greater => ParameterFilterRuleFactory.CreateGreaterRule(paramId, target),
|
|
476
|
+
RuleOp.GreaterOrEqual => ParameterFilterRuleFactory.CreateGreaterOrEqualRule(paramId, target),
|
|
477
|
+
RuleOp.Less => ParameterFilterRuleFactory.CreateLessRule(paramId, target),
|
|
478
|
+
RuleOp.LessOrEqual => ParameterFilterRuleFactory.CreateLessOrEqualRule(paramId, target),
|
|
479
|
+
_ => null,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
case StorageType.ElementId:
|
|
483
|
+
{
|
|
484
|
+
if (rule.Op is RuleOp.Equals or RuleOp.NotEquals && TryValueAsLong(rule.Value, out long id))
|
|
485
|
+
{
|
|
486
|
+
return rule.Op == RuleOp.Equals
|
|
487
|
+
? ParameterFilterRuleFactory.CreateEqualsRule(paramId, new ElementId(id))
|
|
488
|
+
: ParameterFilterRuleFactory.CreateNotEqualsRule(paramId, new ElementId(id));
|
|
489
|
+
}
|
|
490
|
+
return null; // non-numeric values compare against the display string post-scan
|
|
491
|
+
}
|
|
492
|
+
default:
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private static bool EvaluatePost(Document doc, Element element, Rule rule)
|
|
498
|
+
{
|
|
499
|
+
var parameter = FindParameter(element, rule);
|
|
500
|
+
bool empty = parameter is null || !parameter.HasValue
|
|
501
|
+
|| (parameter.StorageType == StorageType.String && string.IsNullOrEmpty(parameter.AsString()));
|
|
502
|
+
if (rule.Op == RuleOp.IsEmpty)
|
|
503
|
+
return empty;
|
|
504
|
+
if (rule.Op == RuleOp.IsNotEmpty)
|
|
505
|
+
return !empty;
|
|
506
|
+
if (parameter is null)
|
|
507
|
+
return false;
|
|
508
|
+
|
|
509
|
+
if (rule.Op == RuleOp.Regex)
|
|
510
|
+
{
|
|
511
|
+
try
|
|
512
|
+
{
|
|
513
|
+
return rule.CompiledRegex!.IsMatch(ComparableText(doc, parameter));
|
|
514
|
+
}
|
|
515
|
+
catch (RegexMatchTimeoutException)
|
|
516
|
+
{
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
switch (parameter.StorageType)
|
|
522
|
+
{
|
|
523
|
+
case StorageType.String:
|
|
524
|
+
{
|
|
525
|
+
string actual = parameter.AsString() ?? string.Empty;
|
|
526
|
+
string target = ValueAsString(rule.Value);
|
|
527
|
+
return rule.Op switch
|
|
528
|
+
{
|
|
529
|
+
RuleOp.Equals => string.Equals(actual, target, StringComparison.Ordinal),
|
|
530
|
+
RuleOp.NotEquals => !string.Equals(actual, target, StringComparison.Ordinal),
|
|
531
|
+
RuleOp.Contains => actual.Contains(target, StringComparison.Ordinal),
|
|
532
|
+
_ => Compare(string.CompareOrdinal(actual, target), rule.Op),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
case StorageType.Double:
|
|
536
|
+
{
|
|
537
|
+
if (rule.Op == RuleOp.Contains)
|
|
538
|
+
return ComparableText(doc, parameter).Contains(ValueAsString(rule.Value), StringComparison.Ordinal);
|
|
539
|
+
if (!TryValueAsDouble(rule.Value, out double raw))
|
|
540
|
+
return false;
|
|
541
|
+
double target = ToInternalUnits(doc, rule, parameter, raw);
|
|
542
|
+
double actual = parameter.AsDouble();
|
|
543
|
+
return rule.Op switch
|
|
544
|
+
{
|
|
545
|
+
RuleOp.Equals => Math.Abs(actual - target) <= Epsilon,
|
|
546
|
+
RuleOp.NotEquals => Math.Abs(actual - target) > Epsilon,
|
|
547
|
+
_ => Compare(actual.CompareTo(target), rule.Op),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
case StorageType.Integer:
|
|
551
|
+
{
|
|
552
|
+
if (rule.Op == RuleOp.Contains)
|
|
553
|
+
return ComparableText(doc, parameter).Contains(ValueAsString(rule.Value), StringComparison.Ordinal);
|
|
554
|
+
if (!TryValueAsInt(rule.Value, out int target))
|
|
555
|
+
return false;
|
|
556
|
+
return Compare(parameter.AsInteger().CompareTo(target), rule.Op);
|
|
557
|
+
}
|
|
558
|
+
case StorageType.ElementId:
|
|
559
|
+
{
|
|
560
|
+
long actualId = parameter.AsElementId()?.Value ?? -1;
|
|
561
|
+
if (TryValueAsLong(rule.Value, out long targetId))
|
|
562
|
+
return Compare(actualId.CompareTo(targetId), rule.Op);
|
|
563
|
+
|
|
564
|
+
string actualText = ComparableText(doc, parameter);
|
|
565
|
+
string target = ValueAsString(rule.Value);
|
|
566
|
+
return rule.Op switch
|
|
567
|
+
{
|
|
568
|
+
RuleOp.Equals => string.Equals(actualText, target, StringComparison.Ordinal),
|
|
569
|
+
RuleOp.NotEquals => !string.Equals(actualText, target, StringComparison.Ordinal),
|
|
570
|
+
RuleOp.Contains => actualText.Contains(target, StringComparison.Ordinal),
|
|
571
|
+
_ => false,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
default:
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private static bool Compare(int comparison, RuleOp op) => op switch
|
|
580
|
+
{
|
|
581
|
+
RuleOp.Equals => comparison == 0,
|
|
582
|
+
RuleOp.NotEquals => comparison != 0,
|
|
583
|
+
RuleOp.Greater => comparison > 0,
|
|
584
|
+
RuleOp.GreaterOrEqual => comparison >= 0,
|
|
585
|
+
RuleOp.Less => comparison < 0,
|
|
586
|
+
RuleOp.LessOrEqual => comparison <= 0,
|
|
587
|
+
_ => false,
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
/// <summary>Converts a user-supplied numeric value to internal units using the
|
|
591
|
+
/// explicit unit, or the document's display units for the parameter's spec.</summary>
|
|
592
|
+
private static double ToInternalUnits(Document doc, Rule rule, Parameter parameter, double value)
|
|
593
|
+
{
|
|
594
|
+
ForgeTypeId? spec = null;
|
|
595
|
+
try
|
|
596
|
+
{
|
|
597
|
+
spec = parameter.Definition?.GetDataType();
|
|
598
|
+
}
|
|
599
|
+
catch
|
|
600
|
+
{
|
|
601
|
+
// Treat as unitless below.
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
bool measurable = false;
|
|
605
|
+
try
|
|
606
|
+
{
|
|
607
|
+
measurable = spec != null && UnitUtils.IsMeasurableSpec(spec);
|
|
608
|
+
}
|
|
609
|
+
catch
|
|
610
|
+
{
|
|
611
|
+
// Treat as unitless below.
|
|
612
|
+
}
|
|
613
|
+
if (!measurable || spec is null)
|
|
614
|
+
return value;
|
|
615
|
+
|
|
616
|
+
ForgeTypeId unit;
|
|
617
|
+
if (!string.IsNullOrWhiteSpace(rule.Unit))
|
|
618
|
+
{
|
|
619
|
+
unit = UnitResolver.Resolve(rule.Unit);
|
|
620
|
+
if (!UnitUtils.IsValidUnit(spec, unit))
|
|
621
|
+
throw new ArgumentException($"Unit '{rule.Unit}' does not fit parameter '{parameter.Definition?.Name}' (spec {UnitResolver.ShortName(spec)}); the document displays it in {UnitResolver.ShortName(doc.GetUnits().GetFormatOptions(spec).GetUnitTypeId())}.");
|
|
622
|
+
}
|
|
623
|
+
else
|
|
624
|
+
{
|
|
625
|
+
unit = doc.GetUnits().GetFormatOptions(spec).GetUnitTypeId();
|
|
626
|
+
}
|
|
627
|
+
return UnitUtils.ConvertToInternalUnits(value, unit);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/// <summary>String the user-facing ops compare/regex against: raw string for text
|
|
631
|
+
/// parameters, otherwise the formatted display value.</summary>
|
|
632
|
+
private static string ComparableText(Document doc, Parameter parameter)
|
|
633
|
+
{
|
|
634
|
+
if (parameter.StorageType == StorageType.String)
|
|
635
|
+
return parameter.AsString() ?? string.Empty;
|
|
636
|
+
|
|
637
|
+
string? display = null;
|
|
638
|
+
try
|
|
639
|
+
{
|
|
640
|
+
display = parameter.AsValueString();
|
|
641
|
+
}
|
|
642
|
+
catch
|
|
643
|
+
{
|
|
644
|
+
// Fall through to the raw value below.
|
|
645
|
+
}
|
|
646
|
+
if (!string.IsNullOrEmpty(display))
|
|
647
|
+
return display;
|
|
648
|
+
|
|
649
|
+
return parameter.StorageType switch
|
|
650
|
+
{
|
|
651
|
+
StorageType.Double => parameter.AsDouble().ToString(CultureInfo.InvariantCulture),
|
|
652
|
+
StorageType.Integer => parameter.AsInteger().ToString(CultureInfo.InvariantCulture),
|
|
653
|
+
StorageType.ElementId => doc.GetElement(parameter.AsElementId())?.Name
|
|
654
|
+
?? parameter.AsElementId()?.Value.ToString(CultureInfo.InvariantCulture)
|
|
655
|
+
?? string.Empty,
|
|
656
|
+
_ => string.Empty,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private static string ValueAsString(JsonElement value) => value.ValueKind switch
|
|
661
|
+
{
|
|
662
|
+
JsonValueKind.String => value.GetString() ?? string.Empty,
|
|
663
|
+
JsonValueKind.True => "true",
|
|
664
|
+
JsonValueKind.False => "false",
|
|
665
|
+
JsonValueKind.Undefined or JsonValueKind.Null => string.Empty,
|
|
666
|
+
_ => value.GetRawText(),
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
private static bool TryValueAsDouble(JsonElement value, out double result)
|
|
670
|
+
{
|
|
671
|
+
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out result))
|
|
672
|
+
return true;
|
|
673
|
+
if (value.ValueKind == JsonValueKind.String
|
|
674
|
+
&& double.TryParse(value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out result))
|
|
675
|
+
return true;
|
|
676
|
+
result = 0;
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private static bool TryValueAsInt(JsonElement value, out int result)
|
|
681
|
+
{
|
|
682
|
+
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out result))
|
|
683
|
+
return true;
|
|
684
|
+
if (value.ValueKind == JsonValueKind.True)
|
|
685
|
+
{
|
|
686
|
+
result = 1;
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
if (value.ValueKind == JsonValueKind.False)
|
|
690
|
+
{
|
|
691
|
+
result = 0;
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
if (value.ValueKind == JsonValueKind.String)
|
|
695
|
+
{
|
|
696
|
+
string text = value.GetString()?.Trim() ?? string.Empty;
|
|
697
|
+
if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out result))
|
|
698
|
+
return true;
|
|
699
|
+
if (string.Equals(text, "yes", StringComparison.OrdinalIgnoreCase) || string.Equals(text, "true", StringComparison.OrdinalIgnoreCase))
|
|
700
|
+
{
|
|
701
|
+
result = 1;
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
if (string.Equals(text, "no", StringComparison.OrdinalIgnoreCase) || string.Equals(text, "false", StringComparison.OrdinalIgnoreCase))
|
|
705
|
+
{
|
|
706
|
+
result = 0;
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
result = 0;
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private static bool TryValueAsLong(JsonElement value, out long result)
|
|
715
|
+
{
|
|
716
|
+
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out result))
|
|
717
|
+
return true;
|
|
718
|
+
if (value.ValueKind == JsonValueKind.String
|
|
719
|
+
&& long.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out result))
|
|
720
|
+
return true;
|
|
721
|
+
result = 0;
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|