using System.Globalization; using System.Text.Json; using System.Text.RegularExpressions; using Autodesk.Revit.DB; namespace RevitBridge.Tools { /// /// The generic FilteredElementCollector primitive: category/class/level/type/view /// scoping plus parameter filter rules. Rules run inside Revit's collector via /// ElementParameterFilter where the API supports it; regex rules (and rules the /// collector cannot quick-filter) are evaluated as a post-collector scan over the /// already-narrowed set. /// internal sealed class GetElements : ITool { private const int DefaultLimit = 200; private const int MaxLimit = 1000; private const int ProbeSize = 50; private const double Epsilon = 1e-6; public string Name => "get_elements"; public string Label => "Get Elements"; 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."; public object ParametersSchema => new { type = "object", properties = new { category = new { type = "string", description = "Category: BuiltInCategory enum name (e.g. OST_Walls) or display name (e.g. Walls, Doors, Rooms, Sheets, Views).", }, of_class = new { type = "string", description = "Revit API element class, e.g. Wall, FamilyInstance, ViewSheet, Level, or a full name like Autodesk.Revit.DB.Mechanical.Duct.", }, filter = new { type = "object", description = "Parameter filter. Numeric rule values are read in the document's display units for that parameter unless 'unit' is set.", properties = new { match = new { type = "string", @enum = new[] { "all", "any" }, description = "Combine rules with AND (all, default) or OR (any).", }, rules = new { type = "array", items = new { type = "object", properties = new { param = new { type = "string", description = "Parameter display name, BuiltInParameter enum name (e.g. WALL_BASE_OFFSET), or guid: for a shared parameter." }, op = new { type = "string", @enum = new[] { "equals", "not_equals", "greater", "greater_or_equal", "less", "less_or_equal", "contains", "is_empty", "is_not_empty", "regex" } }, value = new { description = "Comparison value (string, number, or bool). Regex pattern for op=regex. Unused for is_empty/is_not_empty." }, unit = new { type = "string", description = "Unit of a numeric value, e.g. millimeters, meters, feet, squareMeters. Default: the document's display units." }, }, required = new[] { "param", "op" }, }, }, }, required = new[] { "rules" }, }, level = new { type = "string", description = "Only elements hosted on this level (level name or id)." }, type_id = new { type = "integer", description = "Only instances of this element type id (see get_element_types)." }, in_active_view = new { type = "boolean", description = "Only elements visible in the active view. Default false." }, count_only = new { type = "boolean", description = "Return only total_count, no element rows. Default false." }, fields = new { type = "array", items = new { type = "string", @enum = new[] { "id", "name", "category", "typeName", "levelId" } }, description = "Identity fields per row; default all five. id is always included. Parameter values live in get_element_details.", }, offset = new { type = "integer", description = "Pagination offset. Default 0." }, limit = new { type = "integer", description = "Max rows to return (1-1000). Default 200." }, }, required = Array.Empty(), }; public string? PromptSnippet => "Query or count Revit elements of any category with parameter filters and pagination."; public IReadOnlyList? PromptGuidelines => new[] { "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.", "get_elements returns identity fields only; read parameter values with get_element_details.", }; public object? Execute(JsonElement args, ToolContext context) { var doc = context.Document ?? throw new NoActiveDocumentException(); string? categoryInput = JsonArgs.GetString(args, "category"); string? classInput = JsonArgs.GetString(args, "of_class"); bool inActiveView = JsonArgs.GetBool(args, "in_active_view", false); bool countOnly = JsonArgs.GetBool(args, "count_only", false) || string.Equals(JsonArgs.GetString(args, "mode"), "count", StringComparison.OrdinalIgnoreCase); int offset = Math.Max(0, JsonArgs.GetInt(args, "offset", 0)); int limit = Math.Clamp(JsonArgs.GetInt(args, "limit", DefaultLimit), 1, MaxLimit); var fields = ResolveFields(args); ElementId? categoryId = string.IsNullOrWhiteSpace(categoryInput) ? null : CategoryResolver.Resolve(doc, categoryInput); Type? elementClass = string.IsNullOrWhiteSpace(classInput) ? null : ElementClassResolver.Resolve(classInput); ElementId? levelId = ResolveLevel(doc, args); long? typeId = JsonArgs.GetLong(args, "type_id"); ElementId? activeViewId = null; if (inActiveView) { activeViewId = doc.ActiveView?.Id ?? throw new ArgumentException("in_active_view requires an active graphical view in Revit."); } FilteredElementCollector CreateBaseCollector() { var collector = activeViewId != null ? new FilteredElementCollector(doc, activeViewId) : new FilteredElementCollector(doc); if (categoryId != null) collector = collector.OfCategoryId(categoryId); if (elementClass != null) collector = collector.OfClass(elementClass); collector = collector.WhereElementIsNotElementType(); if (levelId != null) collector = collector.WherePasses(new ElementLevelFilter(levelId)); if (typeId is { } type) { collector = collector.WherePasses(new ElementParameterFilter( ParameterFilterRuleFactory.CreateEqualsRule(new ElementId(BuiltInParameter.ELEM_TYPE_PARAM), new ElementId(type)))); } return collector; } var (quickFilter, postPredicate, filterWarnings) = BuildParameterFilter(doc, args, CreateBaseCollector); FilteredElementCollector CreateCollector() { var collector = CreateBaseCollector(); return quickFilter != null ? collector.WherePasses(quickFilter) : collector; } string scope = DescribeScope(categoryInput, classInput, inActiveView); if (countOnly && postPredicate is null) { int quickCount = CreateCollector().GetElementCount(); return CountResult(scope, quickCount, quickCount > 0 ? Array.Empty() : filterWarnings); } var rows = countOnly ? null : new List>(Math.Min(limit, 256)); int total = 0; foreach (Element element in CreateCollector()) { if (postPredicate != null && !postPredicate(element)) continue; if (rows != null && total >= offset && rows.Count < limit) rows.Add(ElementIdentity.Build(doc, element, fields)); total++; } // A found match proves the query worked: the not-found-on-probes warning is // load-bearing only next to a zero, where it distinguishes 'unknown parameter // name' from 'no matching elements'. Beside real matches it is just noise. if (total > 0) filterWarnings = Array.Empty(); if (rows is null) return CountResult(scope, total, filterWarnings); bool hasMore = offset + rows.Count < total; int? nextOffset = hasMore ? offset + rows.Count : null; string sampleText = string.Empty; if (rows.Count > 0 && fields.Contains("name")) { var sample = rows.Take(3).Select(row => $"'{row["name"]}' ({row["id"]})"); sampleText = $" Sample: {string.Join(", ", sample)}."; } string compact = (hasMore ? $"{scope}: {total} total, returned {rows.Count} at offset {offset}, has_more (next_offset {nextOffset}).{sampleText}" : $"{scope}: {total} total, returned {rows.Count} at offset {offset}.{sampleText}") + WarningSuffix(filterWarnings); return new ToolOutput(new { total_count = total, returned_count = rows.Count, offset, has_more = hasMore, next_offset = nextOffset, elements = rows, warnings = filterWarnings.Count > 0 ? filterWarnings : null, }, compact); } private static ToolOutput CountResult(string scope, int count, IReadOnlyList warnings) => new(new { total_count = count, count_only = true, warnings = warnings.Count > 0 ? warnings : null }, $"{scope}: {count} elements match." + WarningSuffix(warnings)); private static string WarningSuffix(IReadOnlyList warnings) => warnings.Count > 0 ? " WARNING: " + string.Join(" ", warnings) : string.Empty; private static string DescribeScope(string? category, string? className, bool inActiveView) { string scope = !string.IsNullOrWhiteSpace(category) ? category.Trim() : !string.IsNullOrWhiteSpace(className) ? className.Trim() : "elements"; return inActiveView ? scope + " in active view" : scope; } private static IReadOnlyList ResolveFields(JsonElement args) { var requested = JsonArgs.GetStringArray(args, "fields"); if (requested is null || requested.Count == 0) return ElementIdentity.Fields; foreach (string field in requested) { if (!ElementIdentity.Fields.Any(known => string.Equals(known, field, StringComparison.OrdinalIgnoreCase))) throw new ArgumentException($"Unknown field: {field}. Identity fields only: {string.Join(", ", ElementIdentity.Fields)}. Read parameter values with get_element_details."); } var canonical = new List { "id" }; foreach (string field in ElementIdentity.Fields) { if (field != "id" && requested.Any(r => string.Equals(r, field, StringComparison.OrdinalIgnoreCase))) canonical.Add(field); } return canonical; } private static ElementId? ResolveLevel(Document doc, JsonElement args) { if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty("level", out var value) || value.ValueKind == JsonValueKind.Null) return null; if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out long fromNumber)) return EnsureLevel(doc, new ElementId(fromNumber)); if (value.ValueKind == JsonValueKind.String) { string text = value.GetString()?.Trim() ?? string.Empty; if (text.Length == 0) return null; if (long.TryParse(text, out long fromString)) return EnsureLevel(doc, new ElementId(fromString)); var levels = new FilteredElementCollector(doc).OfClass(typeof(Level)).Cast().ToList(); var match = levels.FirstOrDefault(level => string.Equals(level.Name, text, StringComparison.OrdinalIgnoreCase)); if (match != null) return match.Id; throw new ArgumentException($"Unknown level: {text}. Available levels: {string.Join(", ", levels.OrderBy(l => l.Elevation).Select(l => l.Name))}"); } throw new ArgumentException("level must be a level name or a level id."); } private static ElementId EnsureLevel(Document doc, ElementId id) => doc.GetElement(id) is Level ? id : throw new ArgumentException($"Element {id.Value} is not a Level."); // ---------------------------------------------------------------- filters private enum RuleOp { Equals, NotEquals, Greater, GreaterOrEqual, Less, LessOrEqual, Contains, IsEmpty, IsNotEmpty, Regex } private sealed class Rule { public required string ParamInput { get; init; } public required RuleOp Op { get; init; } public JsonElement Value { get; init; } public string? Unit { get; init; } public BuiltInParameter? BuiltIn { get; init; } public Guid? SharedGuid { get; init; } public Regex? CompiledRegex { get; init; } public FilterRule? QuickRule { get; set; } } private static (ElementFilter? Quick, Func? Post, IReadOnlyList Warnings) BuildParameterFilter( Document doc, JsonElement args, Func createBaseCollector) { if (args.ValueKind != JsonValueKind.Object || !args.TryGetProperty("filter", out var filterElement) || filterElement.ValueKind != JsonValueKind.Object) return (null, null, Array.Empty()); bool matchAny = string.Equals(JsonArgs.GetString(filterElement, "match"), "any", StringComparison.OrdinalIgnoreCase); if (!filterElement.TryGetProperty("rules", out var rulesElement) || rulesElement.ValueKind != JsonValueKind.Array) throw new ArgumentException("filter.rules must be an array of { param, op, value?, unit? } rules."); var rules = rulesElement.EnumerateArray().Select(ParseRule).ToList(); if (rules.Count == 0) return (null, null, Array.Empty()); // Probe a few in-scope elements so display-name parameters resolve to ids // and value typing / unit conversion can use the parameter's storage + spec. var probes = new List(ProbeSize); foreach (Element element in createBaseCollector()) { probes.Add(element); if (probes.Count >= ProbeSize) break; } // A display-name rule that no probed element carries is the localized-UI trap: // the post-scan then matches nothing and the result reads "0 total" -- which the // caller cannot tell apart from a genuine no-match. Typical case: an English // display name queried against a non-English UI, where the same parameter // carries a translated name. // A warning (not an error: the parameter may exist past the probe window, // and is_empty legitimately matches missing parameters) makes the zero honest. var warnings = new List(); if (probes.Count > 0) { foreach (var rule in rules) { if (rule.BuiltIn != null || rule.SharedGuid != null) continue; if (probes.All(probe => FindParameter(probe, rule) is null)) warnings.Add( $"Filter parameter '{rule.ParamInput}' was not found on any of the {probes.Count} probed element(s) in scope, " + "so a 0-match result may mean 'unknown parameter name', not 'no matching elements'. Display names are " + "localized in non-English Revit UIs -- prefer the language-independent BuiltInParameter enum name " + "(get_element_details reports it as builtInParameter per parameter)."); } } // Display-name promotion is only trustworthy inside one category/class: the // probe sees just the first ProbeSize elements in collector order, so in an // unscoped query a unanimous sample can still hide other categories further // on whose same-named parameter has a different id — and a pinned quick rule // would silently drop their matches. bool scoped = !string.IsNullOrWhiteSpace(JsonArgs.GetString(args, "category")) || !string.IsNullOrWhiteSpace(JsonArgs.GetString(args, "of_class")); foreach (var rule in rules) { if (rule.Op is RuleOp.Regex or RuleOp.IsEmpty or RuleOp.IsNotEmpty) continue; // always post-scan (missing-parameter semantics) var found = probes.Select(probe => FindParameter(probe, rule)).Where(parameter => parameter != null).ToList(); if (found.Count == 0) continue; // BuiltInParameter/guid rules address one global parameter id. A plain // display name can resolve to DIFFERENT ids per category or family // (e.g. 'Width' -> DOOR_WIDTH vs WINDOW_WIDTH). Promote a display-name // rule only when the query is scoped AND all probed elements agree on // the id; otherwise it stays on the (per-element, correct) post-scan path. bool oneGlobalId = rule.BuiltIn != null || rule.SharedGuid != null || (scoped && found.All(parameter => parameter!.Id == found[0]!.Id)); if (oneGlobalId) rule.QuickRule = TryBuildQuickRule(doc, rule, found[0]!); } var quickRules = rules.Where(rule => rule.QuickRule != null).ToList(); var postRules = rules.Where(rule => rule.QuickRule is null).ToList(); if (matchAny) { // OR with any post-scan rule means everything must be post-scanned: a // collector-level OR filter would wrongly exclude post-rule-only matches. if (postRules.Count > 0) return (null, element => rules.Any(rule => EvaluatePost(doc, element, rule)), warnings); var filters = quickRules.Select(rule => (ElementFilter)new ElementParameterFilter(rule.QuickRule!)).ToList(); return (filters.Count == 1 ? filters[0] : new LogicalOrFilter(filters), null, warnings); } ElementFilter? quick = quickRules.Count > 0 ? new ElementParameterFilter(quickRules.Select(rule => rule.QuickRule!).ToList()) : null; Func? post = postRules.Count > 0 ? element => postRules.All(rule => EvaluatePost(doc, element, rule)) : null; return (quick, post, warnings); } private static Rule ParseRule(JsonElement element) { if (element.ValueKind != JsonValueKind.Object) throw new ArgumentException("Each filter rule must be an object with param and op."); string param = JsonArgs.GetString(element, "param")?.Trim() ?? throw new ArgumentException("Each filter rule needs a param name."); string opText = JsonArgs.GetString(element, "op") ?? throw new ArgumentException($"Filter rule on '{param}' needs an op."); RuleOp op = opText.Trim().ToLowerInvariant() switch { "equals" or "eq" => RuleOp.Equals, "not_equals" or "ne" or "neq" => RuleOp.NotEquals, "greater" or "gt" => RuleOp.Greater, "greater_or_equal" or "gte" or "ge" => RuleOp.GreaterOrEqual, "less" or "lt" => RuleOp.Less, "less_or_equal" or "lte" or "le" => RuleOp.LessOrEqual, "contains" => RuleOp.Contains, "is_empty" => RuleOp.IsEmpty, "is_not_empty" => RuleOp.IsNotEmpty, "regex" => RuleOp.Regex, _ => 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."), }; bool hasValue = element.TryGetProperty("value", out var value) && value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined); if (!hasValue && op is not (RuleOp.IsEmpty or RuleOp.IsNotEmpty)) throw new ArgumentException($"Filter rule on '{param}' with op {opText} needs a value."); BuiltInParameter? builtIn = null; Guid? sharedGuid = null; if (param.StartsWith("guid:", StringComparison.OrdinalIgnoreCase)) { sharedGuid = Guid.TryParse(param["guid:".Length..], out var guid) ? guid : throw new ArgumentException($"Invalid shared parameter guid: {param}"); } else { string enumName = param.StartsWith("BuiltInParameter.", StringComparison.OrdinalIgnoreCase) ? param["BuiltInParameter.".Length..] : param; // BuiltInParameter names are SHOUTY_SNAKE_CASE; require an underscore or // all-caps so plain display names like "Comments" never collide. bool looksLikeEnumName = enumName.Length > 0 && char.IsLetter(enumName[0]) && !enumName.Contains(' ') && (enumName.Contains('_') || enumName.All(c => !char.IsLetter(c) || char.IsUpper(c))); if (looksLikeEnumName && Enum.TryParse(enumName, true, out var parsed) && parsed != BuiltInParameter.INVALID) builtIn = parsed; } Regex? regex = null; if (op == RuleOp.Regex) { string pattern = ValueAsString(value); try { regex = new Regex(pattern, RegexOptions.None, TimeSpan.FromSeconds(1)); } catch (ArgumentException ex) { throw new ArgumentException($"Invalid regex for '{param}': {ex.Message}"); } } return new Rule { ParamInput = param, Op = op, Value = hasValue ? value : default, Unit = JsonArgs.GetString(element, "unit"), BuiltIn = builtIn, SharedGuid = sharedGuid, CompiledRegex = regex, }; } private static Parameter? FindParameter(Element element, Rule rule) { if (rule.BuiltIn is { } builtIn) return element.get_Parameter(builtIn); if (rule.SharedGuid is { } guid) return element.get_Parameter(guid); var direct = element.LookupParameter(rule.ParamInput); if (direct != null) return direct; foreach (Parameter parameter in element.Parameters) { if (string.Equals(parameter.Definition?.Name, rule.ParamInput, StringComparison.OrdinalIgnoreCase)) return parameter; } return null; } /// Builds a collector-level FilterRule, or null when the op/storage pair must post-scan. private static FilterRule? TryBuildQuickRule(Document doc, Rule rule, Parameter exemplar) { ElementId paramId = exemplar.Id; switch (exemplar.StorageType) { case StorageType.String: { string target = ValueAsString(rule.Value); return rule.Op switch { RuleOp.Equals => ParameterFilterRuleFactory.CreateEqualsRule(paramId, target), RuleOp.NotEquals => ParameterFilterRuleFactory.CreateNotEqualsRule(paramId, target), RuleOp.Contains => ParameterFilterRuleFactory.CreateContainsRule(paramId, target), RuleOp.Greater => ParameterFilterRuleFactory.CreateGreaterRule(paramId, target), RuleOp.GreaterOrEqual => ParameterFilterRuleFactory.CreateGreaterOrEqualRule(paramId, target), RuleOp.Less => ParameterFilterRuleFactory.CreateLessRule(paramId, target), RuleOp.LessOrEqual => ParameterFilterRuleFactory.CreateLessOrEqualRule(paramId, target), _ => null, }; } case StorageType.Double: { if (rule.Op == RuleOp.Contains) return null; // post-scan over the display string if (!TryValueAsDouble(rule.Value, out double raw)) throw new ArgumentException($"Filter rule on numeric parameter '{exemplar.Definition?.Name}' needs a numeric value (got {rule.Value.GetRawText()})."); double target = ToInternalUnits(doc, rule, exemplar, raw); return rule.Op switch { RuleOp.Equals => ParameterFilterRuleFactory.CreateEqualsRule(paramId, target, Epsilon), RuleOp.NotEquals => ParameterFilterRuleFactory.CreateNotEqualsRule(paramId, target, Epsilon), RuleOp.Greater => ParameterFilterRuleFactory.CreateGreaterRule(paramId, target, Epsilon), RuleOp.GreaterOrEqual => ParameterFilterRuleFactory.CreateGreaterOrEqualRule(paramId, target, Epsilon), RuleOp.Less => ParameterFilterRuleFactory.CreateLessRule(paramId, target, Epsilon), RuleOp.LessOrEqual => ParameterFilterRuleFactory.CreateLessOrEqualRule(paramId, target, Epsilon), _ => null, }; } case StorageType.Integer: { if (rule.Op == RuleOp.Contains) return null; if (!TryValueAsInt(rule.Value, out int target)) throw new ArgumentException($"Filter rule on integer parameter '{exemplar.Definition?.Name}' needs an integer or boolean value."); return rule.Op switch { RuleOp.Equals => ParameterFilterRuleFactory.CreateEqualsRule(paramId, target), RuleOp.NotEquals => ParameterFilterRuleFactory.CreateNotEqualsRule(paramId, target), RuleOp.Greater => ParameterFilterRuleFactory.CreateGreaterRule(paramId, target), RuleOp.GreaterOrEqual => ParameterFilterRuleFactory.CreateGreaterOrEqualRule(paramId, target), RuleOp.Less => ParameterFilterRuleFactory.CreateLessRule(paramId, target), RuleOp.LessOrEqual => ParameterFilterRuleFactory.CreateLessOrEqualRule(paramId, target), _ => null, }; } case StorageType.ElementId: { if (rule.Op is RuleOp.Equals or RuleOp.NotEquals && TryValueAsLong(rule.Value, out long id)) { return rule.Op == RuleOp.Equals ? ParameterFilterRuleFactory.CreateEqualsRule(paramId, new ElementId(id)) : ParameterFilterRuleFactory.CreateNotEqualsRule(paramId, new ElementId(id)); } return null; // non-numeric values compare against the display string post-scan } default: return null; } } /// Per-element evaluation for rules the collector cannot quick-filter. /// A value that does not fit the parameter's storage type is a caller mistake, and /// must fail the same way it does on the quick path (TryBuildQuickRule) — otherwise /// the identical query reports a clear error when scoped and a silent zero matches /// when unscoped. private static bool EvaluatePost(Document doc, Element element, Rule rule) { var parameter = FindParameter(element, rule); bool empty = parameter is null || !parameter.HasValue || (parameter.StorageType == StorageType.String && string.IsNullOrEmpty(parameter.AsString())); if (rule.Op == RuleOp.IsEmpty) return empty; if (rule.Op == RuleOp.IsNotEmpty) return !empty; if (parameter is null) return false; if (rule.Op == RuleOp.Regex) { try { return rule.CompiledRegex!.IsMatch(ComparableText(doc, parameter)); } catch (RegexMatchTimeoutException) { return false; } } switch (parameter.StorageType) { case StorageType.String: { // Case-insensitive to match Revit's own collector: ParameterFilterRuleFactory // string rules ignore case (verified on Revit 2025 -- a promoted equals rule // matched 'CaseProbe' for the query 'caseprobe'). The same rule must not turn // case-sensitive merely because it fell back to the post-scan path. string actual = parameter.AsString() ?? string.Empty; string target = ValueAsString(rule.Value); return rule.Op switch { RuleOp.Equals => string.Equals(actual, target, StringComparison.OrdinalIgnoreCase), RuleOp.NotEquals => !string.Equals(actual, target, StringComparison.OrdinalIgnoreCase), RuleOp.Contains => actual.Contains(target, StringComparison.OrdinalIgnoreCase), _ => Compare(string.Compare(actual, target, StringComparison.OrdinalIgnoreCase), rule.Op), }; } case StorageType.Double: { if (rule.Op == RuleOp.Contains) return ComparableText(doc, parameter).Contains(ValueAsString(rule.Value), StringComparison.Ordinal); if (!TryValueAsDouble(rule.Value, out double raw)) throw new ArgumentException($"Filter rule on numeric parameter '{parameter.Definition?.Name}' needs a numeric value (got {rule.Value.GetRawText()})."); double target = ToInternalUnits(doc, rule, parameter, raw); double actual = parameter.AsDouble(); return rule.Op switch { RuleOp.Equals => Math.Abs(actual - target) <= Epsilon, RuleOp.NotEquals => Math.Abs(actual - target) > Epsilon, _ => Compare(actual.CompareTo(target), rule.Op), }; } case StorageType.Integer: { if (rule.Op == RuleOp.Contains) return ComparableText(doc, parameter).Contains(ValueAsString(rule.Value), StringComparison.Ordinal); if (!TryValueAsInt(rule.Value, out int target)) throw new ArgumentException($"Filter rule on integer parameter '{parameter.Definition?.Name}' needs an integer or boolean value."); return Compare(parameter.AsInteger().CompareTo(target), rule.Op); } case StorageType.ElementId: { long actualId = parameter.AsElementId()?.Value ?? -1; if (TryValueAsLong(rule.Value, out long targetId)) return Compare(actualId.CompareTo(targetId), rule.Op); string actualText = ComparableText(doc, parameter); string target = ValueAsString(rule.Value); return rule.Op switch { RuleOp.Equals => string.Equals(actualText, target, StringComparison.Ordinal), RuleOp.NotEquals => !string.Equals(actualText, target, StringComparison.Ordinal), RuleOp.Contains => actualText.Contains(target, StringComparison.Ordinal), _ => false, }; } default: return false; } } private static bool Compare(int comparison, RuleOp op) => op switch { RuleOp.Equals => comparison == 0, RuleOp.NotEquals => comparison != 0, RuleOp.Greater => comparison > 0, RuleOp.GreaterOrEqual => comparison >= 0, RuleOp.Less => comparison < 0, RuleOp.LessOrEqual => comparison <= 0, _ => false, }; /// Converts a user-supplied numeric value to internal units using the /// explicit unit, or the document's display units for the parameter's spec. private static double ToInternalUnits(Document doc, Rule rule, Parameter parameter, double value) { ForgeTypeId? spec = null; try { spec = parameter.Definition?.GetDataType(); } catch { // Treat as unitless below. } bool measurable = false; try { measurable = spec != null && UnitUtils.IsMeasurableSpec(spec); } catch { // Treat as unitless below. } if (!measurable || spec is null) return value; ForgeTypeId unit; if (!string.IsNullOrWhiteSpace(rule.Unit)) { unit = UnitResolver.Resolve(rule.Unit); if (!UnitUtils.IsValidUnit(spec, unit)) 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())}."); } else { unit = doc.GetUnits().GetFormatOptions(spec).GetUnitTypeId(); } return UnitUtils.ConvertToInternalUnits(value, unit); } /// String the user-facing ops compare/regex against: raw string for text /// parameters, otherwise the formatted display value. private static string ComparableText(Document doc, Parameter parameter) { if (parameter.StorageType == StorageType.String) return parameter.AsString() ?? string.Empty; string? display = null; try { display = parameter.AsValueString(); } catch { // Fall through to the raw value below. } if (!string.IsNullOrEmpty(display)) return display; return parameter.StorageType switch { StorageType.Double => parameter.AsDouble().ToString(CultureInfo.InvariantCulture), StorageType.Integer => parameter.AsInteger().ToString(CultureInfo.InvariantCulture), StorageType.ElementId => doc.GetElement(parameter.AsElementId())?.Name ?? parameter.AsElementId()?.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty, _ => string.Empty, }; } private static string ValueAsString(JsonElement value) => value.ValueKind switch { JsonValueKind.String => value.GetString() ?? string.Empty, JsonValueKind.True => "true", JsonValueKind.False => "false", JsonValueKind.Undefined or JsonValueKind.Null => string.Empty, _ => value.GetRawText(), }; private static bool TryValueAsDouble(JsonElement value, out double result) { if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out result)) return true; if (value.ValueKind == JsonValueKind.String && double.TryParse(value.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out result)) return true; result = 0; return false; } private static bool TryValueAsInt(JsonElement value, out int result) { if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out result)) return true; if (value.ValueKind == JsonValueKind.True) { result = 1; return true; } if (value.ValueKind == JsonValueKind.False) { result = 0; return true; } if (value.ValueKind == JsonValueKind.String) { string text = value.GetString()?.Trim() ?? string.Empty; if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)) return true; if (string.Equals(text, "yes", StringComparison.OrdinalIgnoreCase) || string.Equals(text, "true", StringComparison.OrdinalIgnoreCase)) { result = 1; return true; } if (string.Equals(text, "no", StringComparison.OrdinalIgnoreCase) || string.Equals(text, "false", StringComparison.OrdinalIgnoreCase)) { result = 0; return true; } } result = 0; return false; } private static bool TryValueAsLong(JsonElement value, out long result) { if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out result)) return true; if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out result)) return true; result = 0; return false; } } }