pi-revit 0.2.8 → 0.2.9

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/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ Format follows [Keep a Changelog](https://keepachangelog.com/); version headers
7
7
  Every published version gets an entry with **Added** / **Changed** / **Fixed** sections
8
8
  describing what the user will notice — not internal refactors.
9
9
 
10
+ ## [0.2.9] - 2026-07-21
11
+
12
+ ### Added
13
+ - Self-healing tool discovery: when pi starts before Revit, the extension now keeps
14
+ retrying tool discovery in the background (every 15s) and also re-discovers on a
15
+ successful `ping` — no more sessions stuck with only `ping` registered until a fresh
16
+ pi start. When tools arrive mid-session, `ping`'s result says so.
17
+ - `set_parameters` and `execute_csharp` accept an optional `expected_document` (the model
18
+ title): if the active document differs — e.g. the user switched models mid-session —
19
+ the write fails cleanly instead of landing in the wrong model.
20
+ - `get_element_details.parameter_names` now also matches language-independent
21
+ BuiltInParameter enum names (e.g. `ALL_MODEL_MARK`), so filtering works in non-English
22
+ Revit UIs where display names are localized.
23
+
24
+ ### Fixed
25
+ - `get_element_details` no longer reports a misleading "0 params" when a
26
+ `parameter_names` filter simply matched nothing — it now reports "N of M params
27
+ matched parameter_names" so localization misses are visible. (This explains the
28
+ earlier "0 params vs 38 params" reports: different filter arguments, not flaky reads.)
29
+ - `get_elements`: a display-name filter rule in an **unscoped** query (no category /
30
+ of_class) is no longer promoted to a pinned collector filter based on a 50-element
31
+ probe — it stays on the per-element post-scan path, so categories beyond the probe
32
+ window can't be silently dropped when the same parameter name maps to different ids.
33
+
34
+ ### Changed
35
+ - SKILL.md: guidance on localized parameter names (prefer BuiltInParameter enum names)
36
+ and on using `expected_document` for long sessions / multiple open models.
37
+ - README: new "Safety model" section stating explicitly what the add-in enforces and
38
+ that write-confirmation UX is a client-side decision.
39
+
40
+ Requires redeploying the Revit add-in (`scripts\deploy.ps1` with Revit closed, then
41
+ restart Revit).
42
+
10
43
  ## [0.2.8] - 2026-07-21
11
44
 
12
45
  ### Fixed
package/README.md CHANGED
@@ -29,10 +29,32 @@ headless Revit add-in ← no ribbon, no panels; just a bridge
29
29
  Revit API ← reads run directly; writes run in one named transaction
30
30
  ```
31
31
 
32
- The extension discovers its tools from the bridge at startup (`/reload` re-discovers), so the
33
- tool list always matches what the add-in serves. Everything between Pi and Revit is
34
- local-machine only; note that Pi sends conversation context and tool results to your selected
35
- LLM provider, like any Pi session.
32
+ The extension discovers its tools from the bridge at startup (retrying in the background until
33
+ Revit is up), so the tool list always matches what the add-in serves. Everything between Pi and
34
+ Revit is local-machine only; note that Pi sends conversation context and tool results to your
35
+ selected LLM provider, like any Pi session.
36
+
37
+ ## Safety model
38
+
39
+ Be deliberate about pointing an LLM at a real project model. The add-in enforces what it can
40
+ enforce mechanically, and is honest about what it cannot:
41
+
42
+ - Every write tool is flagged `write: true` — that flag is the machine-readable signal a client
43
+ can gate on. Whether a write needs human confirmation is a **client-side decision**: the
44
+ add-in cannot know your policy, so confirmation UX belongs in the Pi client/agent layer, not
45
+ here.
46
+ - All writes run in one named transaction: committed on success, rolled back on failure, always
47
+ visible in Revit's undo history. Commit-time warnings are reported back (`commitWarnings`);
48
+ error-severity failures roll back with Revit's failure text.
49
+ - `execute_csharp` is an unrestricted escape hatch by design — scripts have full CLR access.
50
+ Treat it like giving the agent a macro editor, on a model you have saved or can restore.
51
+ - Blocking popups are auto-answered so Revit can never hang behind a dialog; unrecognized
52
+ dialogs get the dismissive answer (Cancel/Close/No), never a blind OK.
53
+ - Writes accept an optional `expected_document` check so a queued write cannot silently land in
54
+ a different model than intended.
55
+
56
+ Practical advice: work on saved models, keep worksharing backups/central protection as usual,
57
+ and review the agent's summary of what changed after any write session.
36
58
 
37
59
  ## Requirements
38
60
 
@@ -275,7 +275,7 @@ async function announceUpdateOnce(notify: (message: string, level: "info") => vo
275
275
  }
276
276
  }
277
277
 
278
- function registerPing(pi: ExtensionAPI) {
278
+ function registerPing(pi: ExtensionAPI, onBridgeAlive?: () => Promise<"ready" | "registered" | "failed">) {
279
279
  pi.registerTool({
280
280
  name: "ping",
281
281
  label: "Ping Revit Bridge",
@@ -287,18 +287,71 @@ function registerPing(pi: ExtensionAPI) {
287
287
  async execute(_toolCallId, _params, signal) {
288
288
  const payload = await bridgeRequest("/ping", { method: "GET" }, signal, 10_000);
289
289
  const warning = versionMismatch((payload as { addinVersion?: string }).addinVersion);
290
+ // The bridge is alive: if this session started before Revit and only has
291
+ // ping, register the bridge tools now and tell the model they arrived.
292
+ let registrationNote = "";
293
+ if (onBridgeAlive) {
294
+ const state = await onBridgeAlive();
295
+ if (state === "registered")
296
+ registrationNote = "\nNOTE: The Revit bridge tools (get_elements, set_parameters, execute_csharp, ...) were just registered in this session and are available from now on.";
297
+ else if (state === "failed")
298
+ registrationNote = "\nNOTE: Bridge tool discovery failed even though ping succeeded; retry ping or restart pi.";
299
+ }
290
300
  return {
291
- content: [{ type: "text", text: JSON.stringify(payload) + (warning ? `\nWARNING: ${warning}` : "") }],
301
+ content: [{ type: "text", text: JSON.stringify(payload) + (warning ? `\nWARNING: ${warning}` : "") + registrationNote }],
292
302
  details: payload,
293
303
  };
294
304
  },
295
305
  });
296
306
  }
297
307
 
308
+ const REDISCOVERY_INTERVAL_MS = 15_000;
309
+
298
310
  export default async function revitConnector(pi: ExtensionAPI) {
311
+ // Self-healing discovery: when pi starts before Revit is ready, the initial
312
+ // GET /tools fails and only ping is registered. Rather than requiring a
313
+ // fresh pi start (/reload does not reliably re-run async registration), a
314
+ // background retry keeps probing until the bridge appears, and a successful
315
+ // ping also triggers an immediate attempt.
316
+ let bridgeToolsRegistered = false;
317
+ let discoveryInFlight: Promise<boolean> | null = null;
318
+
319
+ async function discoverAndRegister(): Promise<boolean> {
320
+ if (bridgeToolsRegistered) return true;
321
+ if (discoveryInFlight) return discoveryInFlight;
322
+ discoveryInFlight = (async () => {
323
+ try {
324
+ const payload = (await bridgeRequest("/tools", { method: "GET" }, undefined, DISCOVERY_TIMEOUT_MS)) as {
325
+ tools?: BridgeToolDescriptor[];
326
+ };
327
+ const descriptors = Array.isArray(payload?.tools) ? payload.tools : [];
328
+ if (descriptors.length === 0) return false;
329
+ for (const descriptor of descriptors) {
330
+ if (!descriptor || typeof descriptor.name !== "string" || !descriptor.name) continue;
331
+ if (descriptor.name === "ping") continue;
332
+ registerBridgeTool(pi, descriptor);
333
+ }
334
+ bridgeToolsRegistered = true;
335
+ return true;
336
+ } catch {
337
+ // Bridge down (Revit closed, still starting, stale bridge.json):
338
+ // stay on ping only and try again later.
339
+ return false;
340
+ } finally {
341
+ discoveryInFlight = null;
342
+ }
343
+ })();
344
+ return discoveryInFlight;
345
+ }
346
+
299
347
  // ping is hard-coded: it must work (and report clearly) even when the
300
- // bridge is down, so it is never part of /tools discovery.
301
- registerPing(pi);
348
+ // bridge is down, so it is never part of /tools discovery. A successful
349
+ // ping doubles as a re-discovery trigger — the natural first call in a
350
+ // session that finds itself without bridge tools.
351
+ registerPing(pi, async () => {
352
+ if (bridgeToolsRegistered) return "ready";
353
+ return (await discoverAndRegister()) ? "registered" : "failed";
354
+ });
302
355
 
303
356
  // Surface an incomplete update (see versionMismatch) once per session, right
304
357
  // where the user lands after running `pi update --extensions`. Bridge down at
@@ -314,21 +367,12 @@ export default async function revitConnector(pi: ExtensionAPI) {
314
367
  }
315
368
  });
316
369
 
317
- let descriptors: BridgeToolDescriptor[];
318
- try {
319
- const payload = (await bridgeRequest("/tools", { method: "GET" }, undefined, DISCOVERY_TIMEOUT_MS)) as {
320
- tools?: BridgeToolDescriptor[];
321
- };
322
- descriptors = Array.isArray(payload?.tools) ? payload.tools : [];
323
- } catch {
324
- // Bridge down at startup (Revit closed, stale bridge.json, ...): keep
325
- // only ping registered and never block pi startup. /reload re-discovers.
326
- return;
327
- }
370
+ if (await discoverAndRegister()) return;
328
371
 
329
- for (const descriptor of descriptors) {
330
- if (!descriptor || typeof descriptor.name !== "string" || !descriptor.name) continue;
331
- if (descriptor.name === "ping") continue;
332
- registerBridgeTool(pi, descriptor);
333
- }
372
+ // Never block pi startup on Revit: keep retrying quietly in the background
373
+ // and stop the moment discovery succeeds.
374
+ const timer = setInterval(async () => {
375
+ if (await discoverAndRegister()) clearInterval(timer);
376
+ }, REDISCOVERY_INTERVAL_MS);
377
+ timer.unref?.();
334
378
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-revit",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Native Pi connector for Autodesk Revit. Run npx.cmd -y pi-revit for the full Windows install.",
5
5
  "author": "Ahmad Altahlawi",
6
6
  "license": "MIT",
@@ -29,7 +29,8 @@ Workflow guidance:
29
29
  - Call `get_model_overview` first when starting work on an unfamiliar model — one call returns project metadata, units, levels, grids, and category counts.
30
30
  - `get_elements` is the listing/counting primitive (`count_only: true` for bare counts). It returns identity fields only (id, name, category, typeName, levelId); read parameter values with `get_element_details`. Prefer a `category` or `of_class` scope when filtering by a parameter's display name.
31
31
  - The selection pipeline is `get_elements` -> ids -> `manage_selection` (action `set`); there is no inline filter on selection.
32
- - `set_parameters` is the home for bulk parameter writes AND renames (the `Name` parameter covers levels, views, sheets, types). One transaction per batch; per-element failures are reported.
32
+ - `set_parameters` is the home for bulk parameter writes AND renames (the `Name` parameter covers levels, views, sheets, types). One transaction per batch; per-element failures are reported. Pass `expected_document` (the model title) when several models are open or the session is long — it makes the write fail cleanly instead of landing in a different active document.
33
+ - Parameter display names are LOCALIZED: in a non-English Revit UI, `Mark` is `Kennzeichen` (German), `マーク` (Japanese), etc. When a display-name lookup or `parameter_names` filter finds nothing, or the document may be non-English, use the language-independent `BuiltInParameter` enum name instead (e.g. `ALL_MODEL_MARK` for Mark, `ALL_MODEL_INSTANCE_COMMENTS` for Comments) — `set_parameters`, `get_element_details.parameter_names`, and `get_elements` filter rules all accept them, and `get_element_details` reports each parameter's `builtInParameter` name for discovery.
33
34
  - Before writing `execute_csharp` code, verify unfamiliar classes/members with `search_api_docs` (works with no document open; first query builds the index and takes a few seconds). The top match carries its remarks, parameter docs, and returns inline, and every public API enum value is searchable — trust the result over guessing or web search; narrow the query to promote a different match into the top slot.
34
35
  - `export_documents` files its output under `Documents\pi-revit\Models\<model title>\exports` automatically when `output_dir` is omitted — keyed to the exported document, so it lands right even across many models. Pass `output_dir` only when the user names a different target.
35
36
 
@@ -72,6 +72,11 @@ namespace RevitBridge.Tools
72
72
  type = "string",
73
73
  description = "C# script body (top-level statements; using directives allowed at the top). Globals doc/uidoc/uiapp and Dump(value) are in scope. The final expression or a return statement is the result.",
74
74
  },
75
+ expected_document = new
76
+ {
77
+ type = "string",
78
+ description = "Optional safety check: title of the document this script is meant for (as reported by get_model_overview). If the active document differs (user switched models), the call fails before running anything.",
79
+ },
75
80
  },
76
81
  required = new[] { "code" },
77
82
  };
@@ -88,6 +93,7 @@ namespace RevitBridge.Tools
88
93
  var doc = context.Document ?? throw new NoActiveDocumentException();
89
94
  var uiapp = context.UIApplication ?? throw new NoActiveDocumentException();
90
95
  var uidoc = uiapp.ActiveUIDocument ?? throw new NoActiveDocumentException();
96
+ DocumentGuard.CheckExpectedDocument(args, doc);
91
97
 
92
98
  string code = JsonArgs.GetString(args, "code") ?? string.Empty;
93
99
  if (string.IsNullOrWhiteSpace(code))
@@ -27,7 +27,7 @@ namespace RevitBridge.Tools
27
27
  {
28
28
  type = "array",
29
29
  items = new { type = "string" },
30
- description = "Only return these parameters (case-insensitive exact names). Default: all.",
30
+ description = "Only return these parameters. Matches the display name OR the BuiltInParameter enum name (case-insensitive exact). Display names are localized (e.g. 'Mark' is 'Kennzeichen' in a German UI) — prefer enum names like ALL_MODEL_MARK for language-independent filtering. Default: all.",
31
31
  },
32
32
  include = new
33
33
  {
@@ -101,12 +101,13 @@ namespace RevitBridge.Tools
101
101
  };
102
102
 
103
103
  int parameterCount = 0;
104
+ int parameterTotal = 0;
104
105
  if (withParameters)
105
106
  {
106
107
  var parameters = new List<Dictionary<string, object?>>();
107
- AppendParameters(doc, element, nameFilter, isType: false, parameters);
108
+ parameterTotal += AppendParameters(doc, element, nameFilter, isType: false, parameters);
108
109
  if (withTypeParameters && elementType != null)
109
- AppendParameters(doc, elementType, nameFilter, isType: true, parameters);
110
+ parameterTotal += AppendParameters(doc, elementType, nameFilter, isType: true, parameters);
110
111
  dto["parameters"] = parameters;
111
112
  parameterCount = parameters.Count;
112
113
  }
@@ -119,7 +120,12 @@ namespace RevitBridge.Tools
119
120
  dto["materials"] = DescribeMaterials(doc, element);
120
121
 
121
122
  elements.Add(dto);
122
- compactParts.Add($"'{element.Name}' (id {id}, {element.Category?.Name ?? "no category"}{(withParameters ? $", {parameterCount} params" : string.Empty)})");
123
+ // With a name filter active, "0 params" is ambiguous (none matched vs none
124
+ // exist): report matched-of-total so a localization miss is visible.
125
+ string paramSummary = !withParameters ? string.Empty
126
+ : nameFilter != null ? $", {parameterCount} of {parameterTotal} params matched parameter_names"
127
+ : $", {parameterCount} params";
128
+ compactParts.Add($"'{element.Name}' (id {id}, {element.Category?.Name ?? "no category"}{paramSummary})");
123
129
  }
124
130
 
125
131
  string compact = $"{elements.Count} element(s)"
@@ -131,18 +137,32 @@ namespace RevitBridge.Tools
131
137
  return new ToolOutput(new { count = elements.Count, elements, not_found = notFound }, compact);
132
138
  }
133
139
 
134
- private static void AppendParameters(Document doc, Element element, HashSet<string>? nameFilter, bool isType, List<Dictionary<string, object?>> target)
140
+ /// <summary>Appends matching parameter rows and returns how many parameters the
141
+ /// element exposes in total (before the name filter).</summary>
142
+ private static int AppendParameters(Document doc, Element element, HashSet<string>? nameFilter, bool isType, List<Dictionary<string, object?>> target)
135
143
  {
136
144
  var rows = new List<Dictionary<string, object?>>();
145
+ int total = 0;
137
146
  foreach (Parameter parameter in element.Parameters)
138
147
  {
148
+ total++;
139
149
  string name = parameter.Definition?.Name ?? string.Empty;
140
150
  if (nameFilter != null && !nameFilter.Contains(name))
141
- continue;
151
+ {
152
+ // Display names are localized; accept the language-independent
153
+ // BuiltInParameter enum name (e.g. ALL_MODEL_MARK) as an alias.
154
+ string? builtIn = parameter.Definition is InternalDefinition internalDefinition
155
+ && internalDefinition.BuiltInParameter != BuiltInParameter.INVALID
156
+ ? internalDefinition.BuiltInParameter.ToString()
157
+ : null;
158
+ if (builtIn is null || !nameFilter.Contains(builtIn))
159
+ continue;
160
+ }
142
161
  rows.Add(DescribeParameter(doc, parameter, name, isType));
143
162
  }
144
163
  rows.Sort((a, b) => string.Compare(a["name"] as string, b["name"] as string, StringComparison.OrdinalIgnoreCase));
145
164
  target.AddRange(rows);
165
+ return total;
146
166
  }
147
167
 
148
168
  private static Dictionary<string, object?> DescribeParameter(Document doc, Parameter parameter, string name, bool isType)
@@ -287,6 +287,14 @@ namespace RevitBridge.Tools
287
287
  break;
288
288
  }
289
289
 
290
+ // Display-name promotion is only trustworthy inside one category/class: the
291
+ // probe sees just the first ProbeSize elements in collector order, so in an
292
+ // unscoped query a unanimous sample can still hide other categories further
293
+ // on whose same-named parameter has a different id — and a pinned quick rule
294
+ // would silently drop their matches.
295
+ bool scoped = !string.IsNullOrWhiteSpace(JsonArgs.GetString(args, "category"))
296
+ || !string.IsNullOrWhiteSpace(JsonArgs.GetString(args, "of_class"));
297
+
290
298
  foreach (var rule in rules)
291
299
  {
292
300
  if (rule.Op is RuleOp.Regex or RuleOp.IsEmpty or RuleOp.IsNotEmpty)
@@ -296,12 +304,11 @@ namespace RevitBridge.Tools
296
304
  continue;
297
305
  // BuiltInParameter/guid rules address one global parameter id. A plain
298
306
  // 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.
307
+ // (e.g. 'Width' -> DOOR_WIDTH vs WINDOW_WIDTH). Promote a display-name
308
+ // rule only when the query is scoped AND all probed elements agree on
309
+ // the id; otherwise it stays on the (per-element, correct) post-scan path.
303
310
  bool oneGlobalId = rule.BuiltIn != null || rule.SharedGuid != null
304
- || found.All(parameter => parameter!.Id == found[0]!.Id);
311
+ || (scoped && found.All(parameter => parameter!.Id == found[0]!.Id));
305
312
  if (oneGlobalId)
306
313
  rule.QuickRule = TryBuildQuickRule(doc, rule, found[0]!);
307
314
  }
@@ -222,11 +222,12 @@ namespace RevitBridge.Tools
222
222
  return markdown.ToString();
223
223
  }
224
224
 
225
- /// <summary>The model sees only this markdown — details.payload never reaches it —
226
- /// so the top match carries its remarks, parameter, return, and exception docs
227
- /// inline (capped for display; the payload keeps full text). Lines that would blow
228
- /// the markdown budget are dropped individually. When the top match is one of
229
- /// several same-named overloads, a note says how to target another one.</summary>
225
+ /// <summary>The model can read details.payload (the cap note above even points it
226
+ /// there), but this markdown is what lands in the strongest-attention position of
227
+ /// the tool result — so the top match carries its remarks, parameter, return, and
228
+ /// exception docs inline (capped for display; the payload keeps full text). Lines
229
+ /// that would blow the markdown budget are dropped individually. When the top match
230
+ /// is one of several same-named overloads, a note says how to target another one.</summary>
230
231
  private static void AppendTopMatchDocs(StringBuilder markdown, ApiMember member, int overloadCount)
231
232
  {
232
233
  var lines = new List<string>(5);
@@ -44,6 +44,11 @@ namespace RevitBridge.Tools
44
44
  required = new[] { "element_id", "parameter", "value" },
45
45
  },
46
46
  },
47
+ expected_document = new
48
+ {
49
+ type = "string",
50
+ description = "Optional safety check: title of the document these writes are meant for (as reported by get_model_overview). If the active document differs (user switched models), the call fails without changing anything.",
51
+ },
47
52
  },
48
53
  required = new[] { "updates" },
49
54
  };
@@ -58,6 +63,7 @@ namespace RevitBridge.Tools
58
63
  public object? Execute(JsonElement args, ToolContext context)
59
64
  {
60
65
  var doc = context.Document ?? throw new NoActiveDocumentException();
66
+ DocumentGuard.CheckExpectedDocument(args, doc);
61
67
  var updates = ParseUpdates(args);
62
68
 
63
69
  var succeeded = new List<Dictionary<string, object?>>();
@@ -1,261 +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
- }
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
+ }